diff --git a/.github/scripts/dependabot-auto-merge.mjs b/.github/scripts/dependabot-auto-merge.mjs new file mode 100644 index 0000000..09dcdd5 --- /dev/null +++ b/.github/scripts/dependabot-auto-merge.mjs @@ -0,0 +1,204 @@ +/** Validate that a dependency pull request remains safe to auto-merge. */ +import { existsSync, readFileSync, statSync } from "node:fs"; +import { isAbsolute, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +const DEPENDABOT = "dependabot[bot]"; +const WEB_FLOW = "web-flow"; + +function refuse(message) { + throw new Error(`Refusing auto-merge; ${message}`); +} + +function dependencyEcosystem(headRef) { + if (headRef.startsWith("dependabot/uv/")) return "uv"; + if (headRef.startsWith("dependabot/npm_and_yarn/")) return "npm"; + if (headRef.startsWith("dependabot/github_actions/")) return "github-actions"; + refuse(`unsupported Dependabot branch: ${headRef}`); +} + +function isTrustedBaseFile(trustedBaseDirectory, path) { + const base = resolve(trustedBaseDirectory); + const candidate = resolve(base, path); + const pathFromBase = relative(base, candidate); + if ( + pathFromBase === "" || + isAbsolute(pathFromBase) || + pathFromBase === ".." || + pathFromBase.startsWith(`..${sep}`) + ) + return false; + return existsSync(candidate) && statSync(candidate).isFile(); +} + +function assertAllowedFiles(ecosystem, changedFiles, trustedBaseDirectory) { + if (changedFiles.length === 0) + refuse("the pull request has no changed files."); + if (ecosystem === "uv") { + if (!isTrustedBaseFile(trustedBaseDirectory, "uv.lock")) + refuse("the trusted base does not use uv."); + if (changedFiles.length !== 1 || changedFiles[0] !== "uv.lock") + refuse("uv updates must change only uv.lock."); + return; + } + if (ecosystem === "npm") { + if ( + !isTrustedBaseFile(trustedBaseDirectory, "package.json") || + !isTrustedBaseFile(trustedBaseDirectory, "package-lock.json") + ) + refuse("the trusted base does not use npm."); + const isPackageFile = (path) => + path === "package.json" || path === "package-lock.json"; + const isLockfileOnly = + changedFiles.length === 1 && changedFiles[0] === "package-lock.json"; + const isManifestAndLockfile = + changedFiles.length === 2 && + changedFiles.includes("package.json") && + changedFiles.includes("package-lock.json") && + changedFiles.every(isPackageFile); + if (!isLockfileOnly && !isManifestAndLockfile) + refuse( + "npm updates must change only package.json and package-lock.json.", + ); + return; + } + + const isExistingAllowedFile = (path) => + (/^\.github\/workflows\/[^/]+\.ya?ml$/.test(path) || + /(^|\/)action\.ya?ml$/.test(path)) && + isTrustedBaseFile(trustedBaseDirectory, path); + if (!changedFiles.every(isExistingAllowedFile)) + refuse("the update changes a file outside the trusted dependency scope."); +} + +function isVerifiedDependabotCommit(commit) { + return ( + commit?.author?.login === DEPENDABOT && + commit?.committer?.login === WEB_FLOW && + commit?.commit?.verification?.verified === true + ); +} + +function assertDirectDependabotHistory(event, commits) { + const [commit] = commits; + if ( + commits.length !== 1 || + !isVerifiedDependabotCommit(commit) || + commit?.sha !== event.pull_request.head.sha + ) + refuse("the pull request does not have a verified Dependabot head commit."); +} + +function assertMergeParentAncestry(event, commits, ancestryProofs) { + const mergeCommits = commits.slice(1); + const currentBase = event.pull_request.base.sha; + if ( + !Array.isArray(ancestryProofs) || + ancestryProofs.length !== mergeCommits.length + ) + refuse("the merge-parent ancestry evidence is incomplete."); + + for (const [index, commit] of mergeCommits.entries()) { + const secondParent = commit?.parents?.[1]?.sha; + const proof = ancestryProofs[index]; + if ( + typeof secondParent !== "string" || + proof?.parent_sha !== secondParent || + proof?.base_sha !== currentBase || + proof?.base_commit !== secondParent || + proof?.head_commit !== currentBase || + proof?.merge_base_commit !== secondParent || + !["ahead", "identical"].includes(proof?.status) || + !Number.isInteger(proof?.ahead_by) || + proof.ahead_by < 0 || + proof?.behind_by !== 0 + ) + refuse("a merge second parent is not proven to be on the current base."); + } +} + +function assertUpdateBranchHistory(event, commits, ancestryProofs) { + if (commits.length < 2) + refuse("the pull request is not a GitHub Update branch merge."); + if (!isVerifiedDependabotCommit(commits[0])) + refuse("the pull request history does not begin with Dependabot."); + + for (let index = 1; index < commits.length; index += 1) { + const commit = commits[index]; + const previous = commits[index - 1]; + if ( + commit?.committer?.login !== WEB_FLOW || + commit?.commit?.verification?.verified !== true || + commit?.parents?.length !== 2 || + commit.parents[0]?.sha !== previous?.sha + ) + refuse("the pull request contains a non-Dependabot edit."); + } + + assertMergeParentAncestry(event, commits, ancestryProofs); + const latest = commits.at(-1); + if ( + latest?.sha !== event.pull_request.head.sha || + latest.parents[1]?.sha !== event.pull_request.base.sha + ) + refuse("the latest commit is not an update from the current base branch."); +} + +/** + * Authorize a Dependabot update or a chain containing only GitHub Update branch merges. + * + * @param {object} input Validation inputs from the pull-request event and API. + */ +export function authorizeDependabotUpdate({ + ancestryProofs = [], + changedFiles, + commits, + event, + trustedBaseDirectory = process.cwd(), +}) { + const pullRequest = event.pull_request; + if ( + event.repository?.fork !== false || + pullRequest?.user?.login !== DEPENDABOT || + pullRequest?.head?.repo?.full_name !== event.repository?.full_name || + pullRequest?.base?.ref !== event.repository?.default_branch + ) + refuse( + "the pull request does not have the required Dependabot provenance.", + ); + + const ecosystem = dependencyEcosystem(pullRequest.head.ref); + if (commits.length === 1) assertDirectDependabotHistory(event, commits); + else assertUpdateBranchHistory(event, commits, ancestryProofs); + assertAllowedFiles(ecosystem, changedFiles, trustedBaseDirectory); + return ecosystem; +} + +function main() { + const [, , eventPath, changedFilesPath, commitsPath, ancestryProofsPath] = + process.argv; + if (!eventPath || !changedFilesPath || !commitsPath || !ancestryProofsPath) + throw new Error( + "Usage: dependabot-auto-merge.mjs EVENT CHANGED_FILES COMMITS ANCESTRY_PROOFS", + ); + const event = JSON.parse(readFileSync(eventPath, "utf8")); + const changedFiles = readFileSync(changedFilesPath, "utf8") + .split("\n") + .filter(Boolean); + const commitPages = JSON.parse(readFileSync(commitsPath, "utf8")); + const commits = commitPages.flat(); + const ancestryProofs = JSON.parse(readFileSync(ancestryProofsPath, "utf8")); + authorizeDependabotUpdate({ + ancestryProofs, + changedFiles, + commits, + event, + }); + console.log("Authorized dependency update files and commit history."); +} + +if ( + process.argv[1] && + fileURLToPath(import.meta.url) === resolve(process.argv[1]) +) + main(); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d6c81c9..3f761fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,7 @@ on: permissions: contents: read + pull-requests: read concurrency: group: node-ci-${{ github.workflow }}-${{ github.ref }} @@ -18,7 +19,51 @@ jobs: test: name: Node CI runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read steps: + - name: Checkout trusted dependency authorization helper + if: >- + github.event_name == 'pull_request' && + github.event.pull_request.user.login == 'dependabot[bot]' + uses: actions/checkout@v7 + with: + persist-credentials: false + ref: ${{ github.event.pull_request.base.sha }} + + - name: Authorize Dependabot update before checking out its head + if: >- + github.event_name == 'pull_request' && + github.event.pull_request.user.login == 'dependabot[bot]' + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + changed_files="${RUNNER_TEMP}/dependabot-changed-files" + commits="${RUNNER_TEMP}/dependabot-commits.json" + ancestry_proofs="${RUNNER_TEMP}/dependabot-ancestry-proofs.json" + ancestry_proof_items="${RUNNER_TEMP}/dependabot-ancestry-proof-items.jsonl" + gh api --paginate \ + "repos/${REPOSITORY}/pulls/${PR_NUMBER}/files?per_page=100" \ + --jq '.[].filename' > "${changed_files}" + gh api --paginate --slurp \ + "repos/${REPOSITORY}/pulls/${PR_NUMBER}/commits?per_page=100" \ + > "${commits}" + : > "${ancestry_proof_items}" + while IFS= read -r second_parent; do + gh api "repos/${REPOSITORY}/compare/${second_parent}...${BASE_SHA}" | + jq -c --arg parent_sha "${second_parent}" --arg base_sha "${BASE_SHA}" \ + '{parent_sha, base_sha, base_commit: .base_commit.sha, head_commit: .head_commit.sha, merge_base_commit: .merge_base_commit.sha, status, ahead_by, behind_by}' \ + >> "${ancestry_proof_items}" + done < <(jq -r '.[].[] | select(.parents | length == 2) | .parents[1].sha' "${commits}") + jq -s . "${ancestry_proof_items}" > "${ancestry_proofs}" + node .github/scripts/dependabot-auto-merge.mjs \ + "${GITHUB_EVENT_PATH}" "${changed_files}" "${commits}" "${ancestry_proofs}" + - name: Check out repository uses: actions/checkout@v7 with: diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index 800f23c..ba750b5 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -1,4 +1,4 @@ -name: Dependabot dependency auto-merge +name: Dependabot auto-merge on: pull_request: @@ -9,63 +9,51 @@ concurrency: cancel-in-progress: true jobs: - verify-dependabot-metadata: + authorize-dependency-update: if: >- - github.event.repository.fork == false && - github.event.pull_request.user.login == 'dependabot[bot]' && - github.event.pull_request.head.repo.full_name == github.repository && - github.event.pull_request.base.ref == - github.event.repository.default_branch + github.event.pull_request.user.login == 'dependabot[bot]' runs-on: ubuntu-latest permissions: contents: read pull-requests: read steps: - - name: Fetch Dependabot metadata - uses: dependabot/fetch-metadata@v3 + - name: Checkout trusted authorization helper + uses: actions/checkout@v7 + with: + persist-credentials: false + ref: ${{ github.event.pull_request.base.sha }} - verify-changed-files: - if: >- - github.event.repository.fork == false && - github.event.pull_request.user.login == 'dependabot[bot]' && - github.event.pull_request.head.repo.full_name == github.repository && - github.event.pull_request.base.ref == - github.event.repository.default_branch - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - steps: - - name: Verify supported dependency update + - name: Authorize dependency update files env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUMBER: ${{ github.event.pull_request.number }} REPOSITORY: ${{ github.repository }} run: | - changed_files="$(gh api --paginate \ + set -euo pipefail + changed_files="${RUNNER_TEMP}/dependabot-changed-files" + commits="${RUNNER_TEMP}/dependabot-commits.json" + ancestry_proofs="${RUNNER_TEMP}/dependabot-ancestry-proofs.json" + ancestry_proof_items="${RUNNER_TEMP}/dependabot-ancestry-proof-items.jsonl" + gh api --paginate \ "repos/${REPOSITORY}/pulls/${PR_NUMBER}/files?per_page=100" \ - --jq '.[].filename')" - [[ -n "${changed_files}" ]] || { - echo "Refusing auto-merge; no changed files were reported" - exit 1 - } - npm_invalid_files="$(printf '%s\n' "${changed_files}" | grep -Ev '^package(-lock)?\.json$' || true)" - if [[ -z "${npm_invalid_files}" ]] && \ - printf '%s\n' "${changed_files}" | grep -Fxq 'package-lock.json'; then - exit 0 - fi - - actions_invalid_files="$(printf '%s\n' "${changed_files}" | grep -Ev '^(\.github/workflows/[^/]+\.ya?ml|(review/|fix/)?action\.ya?ml)$' || true)" - if [[ -z "${actions_invalid_files}" ]]; then - exit 0 - fi - - echo "Refusing auto-merge; changed files are neither a supported npm update nor a GitHub Actions-only update:" - echo "${changed_files}" - exit 1 + --jq '.[].filename' > "${changed_files}" + gh api --paginate --slurp \ + "repos/${REPOSITORY}/pulls/${PR_NUMBER}/commits?per_page=100" \ + > "${commits}" + : > "${ancestry_proof_items}" + while IFS= read -r second_parent; do + gh api "repos/${REPOSITORY}/compare/${second_parent}...${BASE_SHA}" | + jq -c --arg parent_sha "${second_parent}" --arg base_sha "${BASE_SHA}" \ + '{parent_sha, base_sha, base_commit: .base_commit.sha, head_commit: .head_commit.sha, merge_base_commit: .merge_base_commit.sha, status, ahead_by, behind_by}' \ + >> "${ancestry_proof_items}" + done < <(jq -r '.[].[] | select(.parents | length == 2) | .parents[1].sha' "${commits}") + jq -s . "${ancestry_proof_items}" > "${ancestry_proofs}" + node .github/scripts/dependabot-auto-merge.mjs \ + "${GITHUB_EVENT_PATH}" "${changed_files}" "${commits}" "${ancestry_proofs}" enable-auto-merge: - needs: [verify-dependabot-metadata, verify-changed-files] + needs: authorize-dependency-update runs-on: ubuntu-latest permissions: contents: write @@ -76,20 +64,16 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} PR_URL: ${{ github.event.pull_request.html_url }} - run: - gh pr merge --auto --squash --match-head-commit "${HEAD_SHA}" - "${PR_URL}" + run: gh pr merge --auto --squash --match-head-commit "${HEAD_SHA}" "${PR_URL}" disable-auto-merge: if: >- - always() && (needs.verify-dependabot-metadata.result != 'success' || - needs.verify-changed-files.result != 'success') && + failure() && !cancelled() && github.event.repository.fork == false && github.event.pull_request.user.login == 'dependabot[bot]' && github.event.pull_request.head.repo.full_name == github.repository && - github.event.pull_request.base.ref == - github.event.repository.default_branch - needs: [verify-dependabot-metadata, verify-changed-files] + github.event.pull_request.base.ref == github.event.repository.default_branch + needs: authorize-dependency-update runs-on: ubuntu-latest permissions: contents: write diff --git a/ui/dependabot_auto_merge_test.js b/ui/dependabot_auto_merge_test.js new file mode 100644 index 0000000..f246043 --- /dev/null +++ b/ui/dependabot_auto_merge_test.js @@ -0,0 +1,255 @@ +// Copyright (C) 2026 Snuffy2 +// SPDX-License-Identifier: AGPL-3.0-only + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +import { afterEach, describe, expect, test } from "vitest"; + +import { authorizeDependabotUpdate } from "../.github/scripts/dependabot-auto-merge.mjs"; + +const [dependabotSha, firstBaseSha, firstUpdateSha, currentBaseSha, headSha] = [ + "1", + "2", + "3", + "4", + "5", +].map((character) => character.repeat(40)); +const temporaryDirectories = []; + +function eventFor(headRef, action = "reopened") { + return { + action, + repository: { + default_branch: "main", + fork: false, + full_name: "Snuffy2/shellport", + }, + pull_request: { + base: { ref: "main", sha: currentBaseSha }, + head: { + ref: headRef, + repo: { full_name: "Snuffy2/shellport" }, + sha: headSha, + }, + user: { login: "dependabot[bot]" }, + }, + }; +} + +function dependabotCommit( + sha = headSha, + verified = true, + committer = "web-flow", +) { + return { + author: { login: "dependabot[bot]" }, + commit: { verification: { verified } }, + committer: { login: committer }, + parents: [], + sha, + }; +} + +function updateCommit(sha, previous, base) { + return { + author: { login: "maintainer" }, + commit: { verification: { verified: true } }, + committer: { login: "web-flow" }, + parents: [{ sha: previous }, { sha: base }], + sha, + }; +} + +function ancestryProof(parentSha, status = "ahead") { + return { + ahead_by: status === "identical" ? 0 : 1, + base_commit: parentSha, + base_sha: currentBaseSha, + behind_by: 0, + head_commit: currentBaseSha, + merge_base_commit: parentSha, + parent_sha: parentSha, + status, + }; +} + +function updateChain() { + return [ + dependabotCommit(dependabotSha), + updateCommit(firstUpdateSha, dependabotSha, firstBaseSha), + updateCommit(headSha, firstUpdateSha, currentBaseSha), + ]; +} + +function updateChainProofs() { + return [ + ancestryProof(firstBaseSha), + ancestryProof(currentBaseSha, "identical"), + ]; +} + +function trustedBase(...paths) { + const directory = mkdtempSync(join(tmpdir(), "dependabot-authorizer-")); + temporaryDirectories.push(directory); + for (const path of paths) { + const file = join(directory, path); + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, "trusted\n"); + } + return directory; +} + +function authorize({ + actor = "dependabot[bot]", + ancestryProofs = [], + changedFiles = ["package-lock.json"], + commits = [dependabotCommit()], + event = eventFor("dependabot/npm_and_yarn/example-1.0.0"), + trustedBaseDirectory = trustedBase("package.json", "package-lock.json"), +} = {}) { + return authorizeDependabotUpdate({ + actor, + ancestryProofs, + changedFiles, + commits, + event, + trustedBaseDirectory, + }); +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) + rmSync(directory, { force: true, recursive: true }); +}); + +describe("Dependabot auto-merge authorization", () => { + test("authorizes reopened direct updates from verified exact history", () => { + expect(authorize()).toBe("npm"); + expect( + authorize({ changedFiles: ["package.json", "package-lock.json"] }), + ).toBe("npm"); + }); + + test("authorizes reopened GitHub Update branch chains", () => { + expect( + authorize({ + ancestryProofs: updateChainProofs(), + commits: updateChain(), + }), + ).toBe("npm"); + }); + + test("rejects direct updates and chain roots without GitHub web-flow identity", () => { + for (const committer of [undefined, "maintainer"]) + expect(() => { + const direct = dependabotCommit(headSha, true, committer); + if (committer === undefined) delete direct.committer; + authorize({ commits: [direct] }); + }).toThrow(); + + for (const committer of [undefined, "maintainer"]) + expect(() => { + const chain = updateChain(); + if (committer === undefined) delete chain[0].committer; + else chain[0].committer.login = committer; + authorize({ ancestryProofs: updateChainProofs(), commits: chain }); + }).toThrow(); + }); + + test("rejects an Update branch merge not committed by GitHub web flow", () => { + const chain = updateChain(); + chain[1].committer.login = "maintainer"; + expect(() => + authorize({ ancestryProofs: updateChainProofs(), commits: chain }), + ).toThrow(); + }); + + test("does not use the triggering actor or action as authorization inputs", () => { + for (const [actor, action] of [ + ["dependabot[bot]", "opened"], + ["maintainer", "synchronize"], + ["any-user", "reopened"], + ]) + expect( + authorize({ + actor, + event: eventFor("dependabot/npm_and_yarn/example-1.0.0", action), + }), + ).toBe("npm"); + }); + + test("accepts an older base ancestor for an intermediate GitHub merge", () => { + expect( + authorize({ + ancestryProofs: updateChainProofs(), + commits: updateChain(), + }), + ).toBe("npm"); + }); + + test("rejects absent, arbitrary, diverged, and mismatched ancestry evidence", () => { + const invalidProofSets = [ + [], + [{}, ancestryProof(currentBaseSha, "identical")], + [ancestryProof(firstBaseSha), ancestryProof("9".repeat(40))], + [ + ancestryProof(firstBaseSha, "diverged"), + ancestryProof(currentBaseSha, "identical"), + ], + [ + { ...ancestryProof(firstBaseSha), head_commit: "8".repeat(40) }, + ancestryProof(currentBaseSha, "identical"), + ], + ]; + for (const ancestryProofs of invalidProofSets) + expect(() => + authorize({ ancestryProofs, commits: updateChain() }), + ).toThrow(); + }); + + test("requires the latest merge parent and latest commit to match event state", () => { + const staleParentChain = updateChain(); + staleParentChain[2] = updateCommit(headSha, firstUpdateSha, firstBaseSha); + for (const commits of [staleParentChain, [dependabotCommit(dependabotSha)]]) + expect(() => + authorize({ ancestryProofs: updateChainProofs(), commits }), + ).toThrow(); + }); + + test("rejects unverified history, invalid provenance, and scope changes", () => { + const untrustedEvent = eventFor("dependabot/npm_and_yarn/example-1.0.0"); + untrustedEvent.pull_request.head.repo.full_name = "fork/repository"; + for (const input of [ + { commits: [dependabotCommit(headSha, false)] }, + { event: untrustedEvent }, + { changedFiles: ["README.md"] }, + ]) + expect(() => authorize(input)).toThrow(); + }); + + test("keeps each ecosystem scope rooted in trusted base contents", () => { + expect( + authorize({ + changedFiles: ["uv.lock"], + event: eventFor("dependabot/uv/example-1.0.0"), + trustedBaseDirectory: trustedBase("uv.lock"), + }), + ).toBe("uv"); + expect( + authorize({ + changedFiles: [".github/workflows/ci.yml"], + event: eventFor("dependabot/github_actions/actions/checkout-7"), + trustedBaseDirectory: trustedBase(".github/workflows/ci.yml"), + }), + ).toBe("github-actions"); + expect(() => + authorize({ + changedFiles: ["action.yml"], + event: eventFor("dependabot/github_actions/example/action"), + trustedBaseDirectory: trustedBase(".github/workflows/ci.yml"), + }), + ).toThrow(); + }); +}); diff --git a/ui/dependabot_workflow_contract_test.js b/ui/dependabot_workflow_contract_test.js new file mode 100644 index 0000000..7ab30a8 --- /dev/null +++ b/ui/dependabot_workflow_contract_test.js @@ -0,0 +1,196 @@ +// Copyright (C) 2026 Snuffy2 +// SPDX-License-Identifier: AGPL-3.0-only + +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import * as prettier from "prettier"; +import { describe, expect, test } from "vitest"; + +function yamlValue(node) { + if ( + [ + "plain", + "quoteDouble", + "quoteSingle", + "blockFolded", + "blockLiteral", + ].includes(node.type) + ) + return node.value ?? ""; + if (node.type === "mapping") + return Object.fromEntries( + (node.children ?? []).map((item) => { + const [key, value] = item.children ?? []; + return [yamlValue(key).toString(), yamlValue(value)]; + }), + ); + if (node.type === "sequence") + return (node.children ?? []).map((item) => yamlValue(item)); + for (const child of node.children ?? []) { + const value = yamlValue(child); + if (value !== null) return value; + } + return null; +} + +async function workflow(path) { + const source = readFileSync(resolve(path), "utf8"); + const parsed = await prettier.__debug.parse(source, { parser: "yaml" }); + return yamlValue(parsed.ast); +} + +function steps(job) { + return job.steps; +} + +function authorizationStep(job) { + const step = steps(job).find((candidate) => + candidate.run?.includes("dependabot-auto-merge.mjs"), + ); + expect(step).toBeDefined(); + return step; +} + +function trustedCheckoutBefore(job) { + const jobSteps = steps(job); + const authorizationIndex = jobSteps.indexOf(authorizationStep(job)); + const checkout = jobSteps + .slice(0, authorizationIndex) + .find( + (candidate) => + candidate.uses?.startsWith("actions/checkout@") && + candidate.with?.ref === "${{ github.event.pull_request.base.sha }}", + ); + expect(checkout).toBeDefined(); + expect(checkout.with["persist-credentials"]).toBe("false"); +} + +function requiresEligibleDependabot(condition) { + const value = String(condition); + for (const term of [ + "repository.fork == false", + "pull_request.user.login == 'dependabot[bot]'", + "pull_request.head.repo.full_name == github.repository", + "pull_request.base.ref == github.event.repository.default_branch", + ]) + expect(value).toContain(term); +} + +function requiresDependabotPullRequest(condition) { + const value = String(condition); + expect(value).toContain("github.event_name == 'pull_request'"); + requiresDependabotAuthor(condition); +} + +function requiresDependabotAuthor(condition) { + const value = String(condition); + expect(value).toContain("pull_request.user.login == 'dependabot[bot]'"); + for (const excludedRestriction of [ + "repository.fork == false", + "pull_request.head.repo.full_name == github.repository", + "pull_request.base.ref == github.event.repository.default_branch", + ]) + expect(value).not.toContain(excludedRestriction); +} + +function assertsAuthoritativeDataflow(job) { + const step = authorizationStep(job); + const run = step.run; + expect(run).toMatch(/pulls.*files/); + expect(run).toMatch(/pulls.*commits/); + expect(run).toContain("compare/"); + expect(run).toContain("dependabot-auto-merge.mjs"); + expect(step.env?.BASE_SHA).toBe("${{ github.event.pull_request.base.sha }}"); + expect(run).toContain("BASE_SHA"); + expect(run).not.toContain("github.event.pull_request.base.sha"); +} + +function authorizationJob(workflowDefinition) { + const job = Object.values(workflowDefinition.jobs).find((candidate) => + steps(candidate).some((step) => + step.run?.includes("dependabot-auto-merge.mjs"), + ), + ); + expect(job).toBeDefined(); + return job; +} + +describe("Dependabot workflow trust contracts", () => { + test("uses trusted read-only authorization with PR data and ancestry evidence", async () => { + const autoMerge = await workflow( + ".github/workflows/dependabot-auto-merge.yml", + ); + const ci = await workflow(".github/workflows/ci.yml"); + const authorization = authorizationJob(autoMerge); + const ciAuthorization = authorizationJob(ci); + expect(authorization.permissions).toMatchObject({ + contents: "read", + "pull-requests": "read", + }); + expect(ciAuthorization.permissions).toMatchObject({ + contents: "read", + "pull-requests": "read", + }); + requiresDependabotAuthor(authorization.if); + requiresDependabotPullRequest(steps(ciAuthorization)[0].if); + requiresDependabotPullRequest(authorizationStep(ciAuthorization).if); + trustedCheckoutBefore(authorization); + trustedCheckoutBefore(ciAuthorization); + assertsAuthoritativeDataflow(authorization); + assertsAuthoritativeDataflow(ciAuthorization); + }); + + test("keeps write jobs dependent on authorization and checkout-free", async () => { + const autoMerge = await workflow( + ".github/workflows/dependabot-auto-merge.yml", + ); + const authorization = authorizationJob(autoMerge); + const authorizationName = Object.entries(autoMerge.jobs).find( + ([, candidate]) => Object.is(candidate, authorization), + )[0]; + const writeJobs = Object.values(autoMerge.jobs).filter((job) => + [job.permissions?.contents, job.permissions?.["pull-requests"]].includes( + "write", + ), + ); + expect(writeJobs).not.toHaveLength(0); + for (const job of writeJobs) { + expect(String(job.needs)).toContain(authorizationName); + expect( + steps(job).some((step) => step.uses?.startsWith("actions/checkout@")), + ).toBe(false); + } + const enable = writeJobs.find( + (job) => !String(job.if).includes("failure()"), + ); + expect(enable).toBeDefined(); + expect(enable.if).toBeUndefined(); + }); + + test("uses cancellation-safe cleanup under the eligibility guard", async () => { + const autoMerge = await workflow( + ".github/workflows/dependabot-auto-merge.yml", + ); + const cleanup = Object.values(autoMerge.jobs).find((job) => + String(job.if).includes("failure()"), + ); + expect(cleanup).toBeDefined(); + expect(String(cleanup.if)).toContain("!cancelled()"); + requiresEligibleDependabot(cleanup.if); + }); + + test("authorizes eligible Dependabot PRs before normal CI checks out their head", async () => { + const ci = await workflow(".github/workflows/ci.yml"); + const ciAuthorization = authorizationJob(ci); + trustedCheckoutBefore(ciAuthorization); + const jobSteps = steps(ciAuthorization); + const authorizationIndex = jobSteps.indexOf( + authorizationStep(ciAuthorization), + ); + const headCheckoutIndex = jobSteps.findIndex( + (step) => step.uses?.startsWith("actions/checkout@") && !step.with?.ref, + ); + expect(headCheckoutIndex).toBeGreaterThan(authorizationIndex); + }); +});