diff --git a/.github/scripts/dependabot-automerge.test.mjs b/.github/scripts/dependabot-automerge.test.mjs new file mode 100644 index 0000000..6a90136 --- /dev/null +++ b/.github/scripts/dependabot-automerge.test.mjs @@ -0,0 +1,583 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; + +const workflow = readFileSync( + new URL("../workflows/dependabot-automerge.yml", import.meta.url), + "utf8" +); +const [, source] = workflow.split(" script: |\n"); +if (!source) { + throw new Error("The tests must execute the script shipped in the workflow."); +} +const script = source + .split("\n") + .map((line) => line.slice(12)) + .join("\n"); +const AsyncFunction = Object.getPrototypeOf(async () => { + await Promise.resolve(); +}).constructor; +const execute = new AsyncFunction( + "github", + "context", + "core", + "process", + script +); +const HEAD_MISMATCH = /Head SHA mismatch/; +const APPROVAL_DENIED = /Approval denied/; +const MERGE_BLOCKED = /Merge requirements are not satisfied/; +const INVALID_PR = /pull request number/; +const INVALID_SHA = /full commit SHA/; +const MISSING_CREDENTIAL = /DEPENDABOT_AUTOMERGE_TOKEN/; + +const sha = "a".repeat(40); +const otherSha = "b".repeat(40); + +function fixture() { + return { + pr: { + number: 7, + state: "open", + draft: false, + user: { login: "dependabot[bot]" }, + head: { sha, repo: { full_name: "DefangLabs/example" } }, + }, + runs: [ + { + id: 100, + workflow_id: 1, + event: "pull_request", + check_suite_id: 10, + path: ".github/workflows/ci.yml", + status: "completed", + conclusion: "success", + }, + ], + checks: [ + { + name: "test", + check_suite: { id: 10 }, + status: "completed", + conclusion: "success", + }, + ], + statuses: [], + reviews: [], + }; +} + +async function run(data = fixture(), options = {}) { + const writes = []; + let reads = 0; + let merged = false; + const get = (args) => { + if (data.prs) { + return { + data: structuredClone( + data.prs.find((candidate) => candidate.number === args.pull_number) + ), + }; + } + const snapshots = data.snapshots || [data.pr]; + const pr = snapshots[Math.min(reads, snapshots.length - 1)]; + reads += 1; + return { data: structuredClone(pr) }; + }; + const github = { + rest: { + repos: { + listPullRequestsAssociatedWithCommit: "candidates", + listCommitStatusesForRef: "statuses", + }, + actions: { listWorkflowRunsForRepo: "runs" }, + checks: { listForRef: "checks" }, + pulls: { + get, + list: "candidates", + listReviews: "reviews", + createReview: (args) => { + if (data.approvalError) { + throw new Error("Approval denied"); + } + writes.push({ type: "approve", ...args }); + }, + merge: (args) => { + writes.push({ type: "merge", ...args }); + if (data.liveSha && data.liveSha !== args.sha) { + throw new Error("Head SHA mismatch"); + } + merged = !( + data.mergeBlocked || data.blockedPRs?.includes(args.pull_number) + ); + return { + data: { merged, message: "Merge requirements are not satisfied" }, + }; + }, + }, + }, + paginate: (route, args) => { + if (args.per_page !== 100) { + throw new Error("Pagination must request full pages."); + } + if (route === "candidates") { + return data.noCandidates ? [] : data.prs || [data.pr]; + } + if (!["runs", "checks", "statuses", "reviews"].includes(route)) { + throw new Error("Unexpected API route."); + } + return structuredClone(data[route]); + }, + }; + const context = { + repo: { owner: "DefangLabs", repo: "example" }, + eventName: + options.event || + (options.dispatch ? "workflow_dispatch" : "workflow_run"), + payload: options.dispatch + ? { + inputs: { + "pull-request": options.number || "7", + "dry-run": options.dryRun || "false", + }, + } + : { workflow_run: { head_sha: options.sha || sha } }, + }; + if (options.event === "check_run") { + context.payload = { check_run: { head_sha: sha } }; + } + if (options.event === "status") { + context.payload = { sha }; + } + if (options.event === "schedule") { + context.payload = {}; + } + const messages = []; + let error; + try { + await execute( + github, + context, + { + info: (message) => messages.push(message), + error: (message) => messages.push(message), + }, + { + env: { + MERGE_CREDENTIAL_AVAILABLE: + options.credential === false ? "false" : "true", + }, + } + ); + } catch (caught) { + if (!options.captureError) { + throw caught; + } + error = caught; + } + return { writes, merged, messages, error }; +} + +async function remainsOpen(data, options) { + const result = await run(data, options); + if (result.writes.length || result.merged) { + throw new Error( + `Unexpected merge or approval: ${JSON.stringify(result.writes)}` + ); + } +} + +test("a green Dependabot PR is approved and squash merged at its exact SHA, without a version restriction", async () => { + const result = await run(); + assert.equal(result.merged, true); + assert.deepEqual( + result.writes.map((write) => write.type), + ["approve", "merge"] + ); + assert.equal(result.writes[0].commit_id, sha); + assert.equal(result.writes[1].sha, sha); + assert.equal(result.writes[1].merge_method, "squash"); +}); + +for (const [name, mutate] of [ + [ + "human author", + (data) => { + data.pr.user.login = "maintainer"; + }, + ], + [ + "draft", + (data) => { + data.pr.draft = true; + }, + ], + [ + "closed", + (data) => { + data.pr.state = "closed"; + }, + ], + [ + "fork", + (data) => { + data.pr.head.repo.full_name = "elsewhere/example"; + }, + ], + [ + "missing head repository", + (data) => { + data.pr.head.repo = null; + }, + ], + [ + "outdated CI commit", + (data) => { + data.pr.head.sha = otherSha; + }, + ], +]) { + test(`${name} never approves or merges`, async () => { + const data = fixture(); + mutate(data); + await remainsOpen(data); + }); +} + +for (const conclusion of [ + "failure", + "cancelled", + "timed_out", + "action_required", + "stale", + null, +]) { + test(`${conclusion} check leaves the PR open`, async () => { + const data = fixture(); + data.checks[0].conclusion = conclusion; + await remainsOpen(data); + }); +} + +for (const status of ["queued", "in_progress", "waiting", "pending"]) { + test(`${status} check leaves the PR open`, async () => { + const data = fixture(); + data.checks[0].status = status; + await remainsOpen(data); + }); +} + +for (const state of ["pending", "failure", "error"]) { + test(`${state} legacy commit status blocks otherwise-green checks`, async () => { + const data = fixture(); + data.statuses = [{ id: 1, context: "external-ci", state }]; + await remainsOpen(data); + }); +} + +test("a queued workflow blocks merging even before its checks are created", async () => { + const data = fixture(); + data.runs.push({ + id: 200, + workflow_id: 2, + event: "pull_request", + status: "queued", + conclusion: null, + }); + await remainsOpen(data); +}); + +test("a failed workflow blocks merging even if its visible job is green", async () => { + const data = fixture(); + data.runs[0].conclusion = "failure"; + await remainsOpen(data); +}); + +test("no check results is not green", async () => { + const data = fixture(); + data.checks = []; + await remainsOpen(data); +}); + +test("an entirely skipped or neutral check suite is not green", async () => { + const data = fixture(); + data.checks[0].conclusion = "neutral"; + await remainsOpen(data); +}); + +test("neutral and skipped checks can accompany successful CI", async () => { + const data = fixture(); + data.checks.push( + { status: "completed", conclusion: "neutral" }, + { status: "completed", conclusion: "skipped" } + ); + assert.equal((await run(data)).merged, true); +}); + +test("a green legacy status can supply the success signal", async () => { + const data = fixture(); + data.checks = []; + data.statuses = [{ id: 1, context: "external-ci", state: "success" }]; + assert.equal((await run(data)).merged, true); +}); + +for (const state of ["success", "failure"]) { + test(`the newest legacy status (${state}) wins independently of array order`, async () => { + const data = fixture(); + data.statuses = [ + { id: 1, context: "external-ci", state: "failure" }, + { id: 2, context: "external-ci", state }, + ]; + assert.equal((await run(data)).merged, state === "success"); + }); +} + +test("a successful workflow rerun replaces its older failed suite", async () => { + const data = fixture(); + data.runs.push({ + ...data.runs[0], + id: 90, + check_suite_id: 9, + conclusion: "failure", + }); + data.checks.push({ + check_suite: { id: 9 }, + status: "completed", + conclusion: "failure", + }); + assert.equal((await run(data)).merged, true); +}); + +test("a pending replacement workflow prevents using its previous green suite", async () => { + const data = fixture(); + data.runs.push({ + ...data.runs[0], + id: 101, + check_suite_id: 11, + status: "queued", + conclusion: null, + }); + await remainsOpen(data); +}); + +test("different CI events for one workflow are each enforced", async () => { + const data = fixture(); + data.runs.push({ + ...data.runs[0], + id: 101, + event: "push", + conclusion: "failure", + }); + await remainsOpen(data); +}); + +test("obsolete auto-merge checks cannot deadlock the replacement policy", async () => { + const data = fixture(); + data.runs.push({ + id: 200, + workflow_id: 2, + event: "pull_request", + check_suite_id: 20, + path: ".github/workflows/dependabot-automerge.yml", + status: "in_progress", + conclusion: null, + }); + data.checks.push({ + check_suite: { id: 20 }, + status: "in_progress", + conclusion: null, + }); + assert.equal((await run(data)).merged, true); +}); + +test("third-party failures are enforced independently of Actions workflows", async () => { + const data = fixture(); + data.checks.push({ status: "completed", conclusion: "failure" }); + await remainsOpen(data); +}); + +test("a failing check after the first 100 results still blocks merging", async () => { + const data = fixture(); + data.checks = Array.from({ length: 100 }, () => ({ + status: "completed", + conclusion: "success", + })); + data.checks.push({ status: "completed", conclusion: "failure" }); + await remainsOpen(data); +}); + +test("a new head discovered immediately before approval is left alone", async () => { + const data = fixture(); + const fresh = structuredClone(data.pr); + fresh.head.sha = otherSha; + data.snapshots = [data.pr, fresh]; + await remainsOpen(data); +}); + +test("a newly drafted PR is left alone", async () => { + const data = fixture(); + const fresh = structuredClone(data.pr); + fresh.draft = true; + data.snapshots = [data.pr, fresh]; + await remainsOpen(data); +}); + +test("the merge SHA guard rejects a push occurring after the last PR read", async () => { + const data = fixture(); + data.liveSha = otherSha; + await assert.rejects(run(data), HEAD_MISMATCH); +}); + +test("an existing bot approval at this SHA is reused", async () => { + const data = fixture(); + data.reviews = [ + { + user: { login: "maintenance-app[bot]" }, + commit_id: sha, + state: "APPROVED", + body: "Approved automatically after CI passed for this Dependabot update.", + }, + ]; + const result = await run(data); + assert.deepEqual( + result.writes.map((write) => write.type), + ["merge"] + ); +}); + +test("an approval from an older SHA does not approve the current change", async () => { + const data = fixture(); + data.reviews = [ + { + user: { login: "maintenance-app[bot]" }, + commit_id: otherSha, + state: "APPROVED", + body: "Approved automatically after CI passed for this Dependabot update.", + }, + ]; + assert.equal((await run(data)).writes[0].type, "approve"); +}); + +test("an approval permission failure never reaches merge", async () => { + const data = fixture(); + data.approvalError = true; + await assert.rejects(run(data), APPROVAL_DENIED); +}); + +test("GitHub merge protections remain authoritative", async () => { + const data = fixture(); + data.mergeBlocked = true; + await assert.rejects(run(data), MERGE_BLOCKED); +}); + +test("manual dispatch uses the current Dependabot head", async () => { + assert.equal((await run(fixture(), { dispatch: true })).merged, true); +}); + +test("manual dispatch cannot authorize a human PR", async () => { + const data = fixture(); + data.pr.user.login = "maintainer"; + await remainsOpen(data, { dispatch: true }); +}); + +test("malformed manual input is rejected", async () => { + await assert.rejects( + run(fixture(), { dispatch: true, number: "7; echo unsafe" }), + INVALID_PR + ); +}); + +test("an invalid commit SHA is rejected", async () => { + await assert.rejects(run(fixture(), { sha: "invalid" }), INVALID_SHA); +}); + +test("unrelated workflow completion cannot merge a PR", async () => { + const data = fixture(); + data.noCandidates = true; + await remainsOpen(data); +}); + +for (const event of ["check_run", "status", "schedule"]) { + test(`${event} rechecks a PR after external checks complete`, async () => { + assert.equal((await run(fixture(), { event })).merged, true); + }); +} + +test("an hourly retry uses the current head, not an old event SHA", async () => { + const data = fixture(); + data.pr.head.sha = otherSha; + const result = await run(data, { event: "schedule" }); + assert.equal(result.writes[1].sha, otherSha); +}); + +test("a dry run never writes even when every check is green", async () => { + const result = await run(fixture(), { + dispatch: true, + dryRun: "true", + credential: false, + }); + assert.deepEqual(result.writes, []); + assert.equal(result.merged, false); + assert.ok( + result.messages.some((message) => message.startsWith("Would merge")) + ); +}); + +test("a missing maintenance credential fails closed instead of merging with GITHUB_TOKEN", async () => { + await assert.rejects( + run(fixture(), { credential: false }), + MISSING_CREDENTIAL + ); +}); + +test("a blocked PR does not starve the rest of the hourly sweep", async () => { + const data = fixture(); + const second = structuredClone(data.pr); + second.number = 8; + second.head.sha = otherSha; + data.prs = [data.pr, second]; + data.blockedPRs = [7]; + const result = await run(data, { event: "schedule", captureError: true }); + assert.ok(result.error); + assert.ok( + result.writes.some( + (write) => write.type === "merge" && write.pull_number === 8 + ) + ); + assert.ok(result.messages.some((message) => message.startsWith("PR #7:"))); +}); + +test("a dismissed maintenance approval is not reused", async () => { + const data = fixture(); + data.reviews = [ + { + id: 1, + user: { login: "maintenance-app[bot]" }, + commit_id: sha, + state: "DISMISSED", + body: "Approved automatically after CI passed for this Dependabot update.", + }, + ]; + assert.equal((await run(data)).writes[0].type, "approve"); +}); + +test("the latest verdict replaces an older approval by the same maintenance identity", async () => { + const data = fixture(); + data.reviews = [ + { + id: 1, + user: { login: "maintenance-app[bot]" }, + commit_id: sha, + state: "APPROVED", + body: "Approved automatically after CI passed for this Dependabot update.", + }, + { + id: 2, + user: { login: "maintenance-app[bot]" }, + commit_id: sha, + state: "CHANGES_REQUESTED", + body: "Recheck", + }, + ]; + assert.equal((await run(data)).writes[0].type, "approve"); +}); diff --git a/.github/workflows/dependabot-automerge-test.yml b/.github/workflows/dependabot-automerge-test.yml new file mode 100644 index 0000000..de74b79 --- /dev/null +++ b/.github/workflows/dependabot-automerge-test.yml @@ -0,0 +1,19 @@ +name: Dependabot auto-merge tests + +on: + pull_request: + paths: + - .github/workflows/dependabot-automerge.yml + - .github/workflows/dependabot-automerge-test.yml + - .github/scripts/dependabot-automerge.test.mjs + +permissions: + contents: read + +jobs: + policy-tests: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - run: node --test .github/scripts/dependabot-automerge.test.mjs diff --git a/.github/workflows/dependabot-automerge.yml b/.github/workflows/dependabot-automerge.yml index ebc625e..73afd0f 100644 --- a/.github/workflows/dependabot-automerge.yml +++ b/.github/workflows/dependabot-automerge.yml @@ -1,36 +1,241 @@ name: Dependabot auto-merge -on: pull_request +on: + workflow_run: + workflows: ['*', '!Dependabot auto-merge'] + types: [completed] + check_run: + types: [completed] + status: + # Retry external checks that finish last; GitHub may suppress their check_run trigger. + schedule: + - cron: '17 * * * *' + workflow_dispatch: + inputs: + pull-request: + description: Existing Dependabot pull request to check + required: true + type: string + dry-run: + description: Check eligibility without approving or merging + type: boolean + default: true -permissions: - contents: write - pull-requests: write +permissions: {} + +concurrency: + group: dependabot-automerge-${{ github.event.workflow_run.head_sha || github.event.check_run.head_sha || github.event.sha || inputs.pull-request || github.event_name }} + cancel-in-progress: false jobs: automerge: + if: >- + github.event_name != 'workflow_run' || + github.event.workflow_run.head_branch != github.event.repository.default_branch runs-on: ubuntu-latest - if: github.event.pull_request.user.login == 'dependabot[bot]' + timeout-minutes: 10 + permissions: + actions: read + checks: read + statuses: read + contents: read + pull-requests: read steps: - - name: Fetch dependabot metadata - id: metadata - uses: dependabot/fetch-metadata@21025c705c08248db411dc16f3619e6b5f9ea21a # v2 - with: - github-token: "${{ secrets.GITHUB_TOKEN }}" - - - name: Approve patch and minor updates - if: | - steps.metadata.outputs.update-type == 'version-update:semver-patch' || - steps.metadata.outputs.update-type == 'version-update:semver-minor' - run: gh pr review --approve "$PR_URL" + # Trusted default-branch code only; never check out or execute the PR. + # A separate maintenance credential preserves push-triggered CI/deployment. + - name: Merge Dependabot updates after all checks pass + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - PR_URL: ${{ github.event.pull_request.html_url }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + MERGE_CREDENTIAL_AVAILABLE: ${{ secrets.DEPENDABOT_AUTOMERGE_TOKEN != '' }} + with: + github-token: ${{ secrets.DEPENDABOT_AUTOMERGE_TOKEN || github.token }} + script: | + const repo = context.repo; + const safeConclusions = new Set(["success", "neutral", "skipped"]); + const validSha = (value) => /^[0-9a-f]{40}$/.test(value); + const dryRun = + context.eventName === "workflow_dispatch" && + context.payload.inputs["dry-run"] !== "false"; + let eventSha; + let candidates; + if (context.eventName === "workflow_dispatch") { + const input = context.payload.inputs["pull-request"]; + if (!/^[1-9][0-9]*$/.test(input)) + throw new Error("A pull request number is required."); + const { data: pr } = await github.rest.pulls.get({ + ...repo, + pull_number: Number(input), + }); + candidates = [pr]; + } else if (context.eventName === "schedule") { + candidates = await github.paginate(github.rest.pulls.list, { + ...repo, + state: "open", + per_page: 100, + }); + } else { + eventSha = + context.payload.workflow_run?.head_sha || + context.payload.check_run?.head_sha || + context.payload.sha; + if (!validSha(eventSha)) throw new Error("A full commit SHA is required."); + candidates = await github.paginate( + github.rest.repos.listPullRequestsAssociatedWithCommit, + { + ...repo, + commit_sha: eventSha, + per_page: 100, + } + ); + } + const failures = []; + for (const candidate of candidates) { + try { + if (candidate.user?.login !== "dependabot[bot]") continue; + const pull_number = candidate.number; + const { data: pr } = await github.rest.pulls.get({ ...repo, pull_number }); + const sha = eventSha || pr.head.sha; + if (!validSha(sha)) throw new Error("A full commit SHA is required."); + const eligible = (value) => + value.state === "open" && + !value.draft && + value.user.login === "dependabot[bot]" && + value.head.sha === sha && + value.head.repo?.full_name === `${repo.owner}/${repo.repo}`; + if (!eligible(pr)) continue; + const runs = await github.paginate( + github.rest.actions.listWorkflowRunsForRepo, + { + ...repo, + head_sha: sha, + per_page: 100, + } + ); + const latestRuns = new Map(); + const ignoredSuites = new Set(); + for (const run of runs) { + if ( + run.path?.split("@")[0] === ".github/workflows/dependabot-automerge.yml" + ) { + if (run.check_suite_id) ignoredSuites.add(run.check_suite_id); + continue; + } + const key = `${run.workflow_id}:${run.event}`; + const previous = latestRuns.get(key); + if (!previous || run.id > previous.id) latestRuns.set(key, run); + } + for (const run of runs) { + const latest = latestRuns.get(`${run.workflow_id}:${run.event}`); + if (latest && latest.id !== run.id && run.check_suite_id) + ignoredSuites.add(run.check_suite_id); + } + const checks = ( + await github.paginate(github.rest.checks.listForRef, { + ...repo, + ref: sha, + filter: "latest", + per_page: 100, + }) + ).filter((check) => !ignoredSuites.has(check.check_suite?.id)); + const statuses = await github.paginate( + github.rest.repos.listCommitStatusesForRef, + { + ...repo, + ref: sha, + per_page: 100, + } + ); + const latestStatuses = new Map(); + for (const status of statuses) { + const previous = latestStatuses.get(status.context); + if (!previous || status.id > previous.id) + latestStatuses.set(status.context, status); + } + const green = + [...latestRuns.values()].every( + (run) => + run.status === "completed" && safeConclusions.has(run.conclusion) + ) && + checks.every( + (check) => + check.status === "completed" && safeConclusions.has(check.conclusion) + ) && + [...latestStatuses.values()].every( + (status) => status.state === "success" + ); + const hasSuccess = + checks.some((check) => check.conclusion === "success") || + [...latestStatuses.values()].some((status) => status.state === "success"); + if (!green || !hasSuccess) { + core.info( + `PR #${pull_number}: checks are absent, pending, or unsuccessful; leaving it open.` + ); + continue; + } + const { data: fresh } = await github.rest.pulls.get({ + ...repo, + pull_number, + }); + if (!eligible(fresh)) continue; + if (dryRun) { + core.info( + `Would merge Dependabot PR #${pull_number} at ${sha}; all checks passed.` + ); + continue; + } + if (process.env.MERGE_CREDENTIAL_AVAILABLE !== "true") { + throw new Error( + "Set DEPENDABOT_AUTOMERGE_TOKEN to a maintenance App/PAT credential so post-merge workflows still run." + ); + } + const approvalBody = + "Approved automatically after CI passed for this Dependabot update."; + const reviews = await github.paginate(github.rest.pulls.listReviews, { + ...repo, + pull_number, + per_page: 100, + }); + const latestReviews = new Map(); + for (const review of reviews) { + const login = review.user?.login; + if (!login) continue; + const previous = latestReviews.get(login); + if (!previous || review.id > previous.id) + latestReviews.set(login, review); + } + if ( + ![...latestReviews.values()].some( + (review) => + review.commit_id === sha && + review.state === "APPROVED" && + review.body === approvalBody + ) + ) { + await github.rest.pulls.createReview({ + ...repo, + pull_number, + commit_id: sha, + event: "APPROVE", + body: approvalBody, + }); + } + const { data: result } = await github.rest.pulls.merge({ + ...repo, + pull_number, + sha, + merge_method: "squash", + }); + if (!result.merged) + throw new Error(`PR #${pull_number}: ${result.message}`); + core.info( + `Merged Dependabot PR #${pull_number} at ${sha} after all checks passed.` + ); + } catch (error) { + if (context.eventName !== "schedule") throw error; + const message = `PR #${candidate.number}: ${error.message}`; + core.error(message); + failures.push(message); + } + } - - name: Enable auto-merge for patch and minor updates - if: | - steps.metadata.outputs.update-type == 'version-update:semver-patch' || - steps.metadata.outputs.update-type == 'version-update:semver-minor' - run: gh pr merge --auto --squash "$PR_URL" - env: - PR_URL: ${{ github.event.pull_request.html_url }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + if (failures.length) throw new Error(failures.join("\n"));