From b689be5dcdc9bfb2a5ae02117a74dce461b73559 Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Sun, 16 Aug 2026 12:09:58 +0200 Subject: [PATCH] Add contributor agreement enforcement This change adds a GitHub Action that requires contributors to confirm they agree to the current CONTRIBUTING.md before a PR can merge, and records the accepted hash in CONTRIBUTORS.md after merge. It also updates the PR template and contribution docs to make the agreement explicit and persistent. --- .github/pull_request_template.md | 3 +- .github/workflows/contributing-agreement.yml | 193 +++++++++++++++++++ CONTRIBUTING.md | 2 + CONTRIBUTORS.md | 6 + 4 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/contributing-agreement.yml create mode 100644 CONTRIBUTORS.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index d9eb6dbe10..f1fe453106 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -16,7 +16,8 @@ Closes #(issue_number) ### General -- [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) + +- [ ] I have read and agree to the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code diff --git a/.github/workflows/contributing-agreement.yml b/.github/workflows/contributing-agreement.yml new file mode 100644 index 0000000000..75e1fe1e88 --- /dev/null +++ b/.github/workflows/contributing-agreement.yml @@ -0,0 +1,193 @@ +name: Contributing agreement + +on: + pull_request_target: + types: [opened, edited, synchronize, reopened, ready_for_review, closed] + paths-ignore: + - "CONTRIBUTORS.md" + +concurrency: + group: contributing-agreement-${{ github.event.action == 'closed' && 'merged' || github.event.pull_request.number || github.ref }} + cancel-in-progress: false + +jobs: + check-agreement: + permissions: + contents: write + issues: write + pull-requests: write + name: Check CONTRIBUTING.md agreement + if: github.event.action != 'closed' || github.event.pull_request.merged == true + runs-on: ubuntu-latest + steps: + - name: Validate agreement + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + AGREEMENT_MARKER: "" + CONTRIBUTORS_FILE: "CONTRIBUTORS.md" + AGREEMENT_LABEL: "contributing-agreement-required" + with: + github-token: ${{ github.token }} + script: | + const crypto = require('crypto'); + const owner = context.repo.owner; + const repo = context.repo.repo; + const pullRequest = context.payload.pull_request; + const branch = context.payload.repository.default_branch; + const login = pullRequest.user.login; + + async function ensureAgreementLabel() { + try { + await github.rest.issues.getLabel({ + owner, + repo, + name: process.env.AGREEMENT_LABEL, + }); + } catch (error) { + if (error.status !== 404) { + throw error; + } + await github.rest.issues.createLabel({ + owner, + repo, + name: process.env.AGREEMENT_LABEL, + color: 'B60205', + description: 'Pull request author must agree to CONTRIBUTING.md', + }); + } + } + + async function updateAgreementComment(body) { + const marker = ''; + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: pullRequest.number, + per_page: 100, + }); + const existingComment = comments.find((comment) => comment.user?.type === 'Bot' && comment.body?.includes(marker)); + const commentBody = `${marker}\n${body}`; + + if (existingComment) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existingComment.id, + body: commentBody, + }); + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pullRequest.number, + body: commentBody, + }); + } + } + + async function getFile(path, ref = branch) { + const response = await github.rest.repos.getContent({ owner, repo, path, ref }); + if (Array.isArray(response.data) || response.data.encoding !== 'base64') { + throw new Error(`${path} is not a regular base64-encoded file.`); + } + return { + content: Buffer.from(response.data.content, 'base64').toString('utf8'), + sha: response.data.sha, + }; + } + + const contributingRef = context.payload.action === 'closed' + ? pullRequest.merge_commit_sha + : branch; + const contributing = await getFile('CONTRIBUTING.md', contributingRef); + const contributingHash = crypto.createHash('sha256') + .update(Buffer.from(contributing.content, 'utf8')) + .digest('hex'); + const contributors = await getFile(process.env.CONTRIBUTORS_FILE); + const existingEntry = contributors.content + .split(/\r?\n/) + .find((line) => line.startsWith(`| @${login} |`)); + const alreadyAgreed = existingEntry?.includes(contributingHash) === true; + const agreementPattern = new RegExp( + `${process.env.AGREEMENT_MARKER}\\s*\\r?\\n\\s*- \\[xX\\]\\s*I have read and agree to the \\[Contribution Guidelines\\]`, + 'i', + ); + // Only the PR's opening description is authoritative for the agreement. + // Regular issue/PR comments, including the bot's reminder below, are ignored. + const openingDescription = pullRequest.body || ''; + const hasAgreement = agreementPattern.test(openingDescription); + + if (!alreadyAgreed && !hasAgreement) { + await ensureAgreementLabel(); + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: pullRequest.number, + labels: [process.env.AGREEMENT_LABEL], + }); + await updateAgreementComment( + `A current agreement to [CONTRIBUTING.md](https://github.com/${owner}/${repo}/blob/${branch}/CONTRIBUTING.md) is required before this Pull Request can be merged. Please check **"I have read and agree to the Contribution Guidelines"** in the PR description.\n\nThe required CONTRIBUTING.md SHA-256 is `${contributingHash}`.`, + ); + core.setFailed( + `A current agreement is required. Check the Contribution Guidelines checkbox in the PR description (CONTRIBUTING.md SHA-256: ${contributingHash}).`, + ); + return; + } + + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: pullRequest.number, + name: process.env.AGREEMENT_LABEL, + }).catch((error) => { + if (error.status !== 404) { + throw error; + } + }); + core.info(`Agreement accepted for @${login} (CONTRIBUTING.md SHA-256: ${contributingHash}).`); + + if (context.payload.action !== 'closed' || pullRequest.merged !== true) { + return; + } + + if (alreadyAgreed) { + core.info(`@${login} is already listed for this CONTRIBUTING.md version.`); + return; + } + + const line = `| @${login} | [#${pullRequest.number}](${pullRequest.html_url}) | ${contributingHash} |`; + for (let attempt = 1; attempt <= 3; attempt += 1) { + const latest = await getFile(process.env.CONTRIBUTORS_FILE); + if (latest.content.split(/\r?\n/).some((entry) => entry.startsWith(`| @${login} |`) && entry.includes(contributingHash))) { + core.info(`@${login} was recorded by a concurrent merge.`); + return; + } + + try { + const updatedContent = latest.content.trimEnd() + `\n${line}\n`; + await github.rest.repos.createOrUpdateFileContents({ + owner, + repo, + path: process.env.CONTRIBUTORS_FILE, + message: `docs: record contributor agreement for #${pullRequest.number}`, + content: Buffer.from(updatedContent, 'utf8').toString('base64'), + sha: latest.sha, + branch, + committer: { + name: 'github-actions[bot]', + email: '41898282+github-actions[bot]@users.noreply.github.com', + }, + author: { + name: 'github-actions[bot]', + email: '41898282+github-actions[bot]@users.noreply.github.com', + }, + }); + core.info(`Added @${login} to ${process.env.CONTRIBUTORS_FILE}.`); + return; + } catch (error) { + if (error.status !== 409 || attempt === 3) { + throw error; + } + core.warning(`Concurrent update detected; retrying (${attempt}/3).`); + } + } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9092d5cd72..a6fea62ff1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,6 +31,8 @@ Run `task --list` to see all available commands. Please make sure your Pull Request adheres to the following guidelines: - Use the PR template provided. +- The `I have read and agree to the Contribution Guidelines` checkbox is mandatory for your first merged Pull Request. If `CONTRIBUTING.md` changes, you must confirm the updated version again. +- After a Pull Request is merged, contributors who have agreed to the current guidelines are listed in [CONTRIBUTORS.md](CONTRIBUTORS.md). - Keep your Pull Request title succinct, detailed, and to the point. - Keep commits atomic. One commit should contain one change. If you want to make multiple changes, submit multiple Pull Requests. - Commits should be clear, concise, and easy to understand. diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md new file mode 100644 index 0000000000..be68982d97 --- /dev/null +++ b/CONTRIBUTORS.md @@ -0,0 +1,6 @@ +# Contributors + +This list is maintained automatically after a Pull Request is merged. An entry means that the contributor agreed to the `CONTRIBUTING.md` version identified by the SHA-256 hash below. + +| GitHub user | Merged Pull Request | `CONTRIBUTING.md` SHA-256 | +| --- | --- | --- |