From 5cc34acb54b4da8b6bd7f1d4f7b2e21987109e0b Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:44:05 -0400 Subject: [PATCH] fix(ci): the dependency audit fails loudly when it cannot open its pull request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The job remediated correctly and force-pushed its branch, then died on `gh pr create` with "GitHub Actions is not permitted to create or approve pull requests". Because that call aborted the step, the `gh workflow run` line after it never ran, so the pushed head got no CI either, and no pull request existed to make the remediation visible. Six consecutive daily runs failed that way while three critical advisories sat remediated on the branch; the pull request had to be opened by hand (#1228). Adopt the DEPS_COMBINE_TOKEN that combine-deps.yml already documents for this exact constraint, falling back to GITHUB_TOKEN so the remediation half keeps working without it. A create that cannot succeed now records its status instead of aborting, prints what a maintainer must do, and lets the CI dispatch run before the step exits non-zero. The failure is deliberate, and the obvious alternative is wrong: by then the working tree holds the remediated lockfiles, so a passing step would let the residual audit gate report a clean audit and take the whole run green with an advisory on a branch nobody is looking at. The release-gate contract test pins the dispatch to run after a new or updated pull request and the dispatch's status to propagate; both still hold. It gains the token dimension it never had — the one this failure lived in — asserting that a refused create still pushes, still dispatches, still fails, and names DEPS_COMBINE_TOKEN as the remedy. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/dep-audit.yml | 52 +++++++++++++++++++++++++++++-- scripts/ci/release-gates.test.mjs | 50 ++++++++++++++++++++--------- 2 files changed, 84 insertions(+), 18 deletions(-) diff --git a/.github/workflows/dep-audit.yml b/.github/workflows/dep-audit.yml index 2ca14f380..fce3a6a94 100644 --- a/.github/workflows/dep-audit.yml +++ b/.github/workflows/dep-audit.yml @@ -6,6 +6,22 @@ name: Dependency audit auto-remediation # runs the same `npm audit` policy the release gate enforces, remediates what # it safely can into a rolling pull request, and fails loudly when an advisory # needs manual attention — so tags cut from an already-remediated tree. +# +# It needs a token that can open a pull request. The remediation half works with +# GITHUB_TOKEN, but `gh pr create` does not: this organisation has "Allow GitHub +# Actions to create and approve pull requests" turned off, so the call fails with +# `GitHub Actions is not permitted to create or approve pull requests`. That is +# the same constraint combine-deps.yml documents, and the same DEPS_COMBINE_TOKEN +# answers it — see that file's header for why the setting is not simply turned on. +# +# Until this was fixed the failure was silent in the worst way. The job remediated +# correctly and force-pushed the branch, then died on `gh pr create` — which sits +# BEFORE the `gh workflow run` line, so the pushed head never even got CI, and no +# pull request existed to make the fix visible. Six consecutive daily runs failed +# that way with three critical advisories already remediated on the branch; the +# pull request had to be opened by hand (#1228). So: push and trigger CI first, +# treat a create refusal as loud but non-fatal, and let the residual audit gate +# below decide the run's verdict. on: schedule: @@ -29,9 +45,15 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # DEPS_COMBINE_TOKEN is a PAT (or GitHub App token) with `repo` scope, shared + # with combine-deps.yml. An empty secret is falsy, so this degrades to + # GITHUB_TOKEN: everything except opening the pull request still works. + GH_TOKEN: ${{ secrets.DEPS_COMBINE_TOKEN || secrets.GITHUB_TOKEN }} + HAVE_PR_TOKEN: ${{ secrets.DEPS_COMBINE_TOKEN != '' }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + token: ${{ secrets.DEPS_COMBINE_TOKEN || secrets.GITHUB_TOKEN }} - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 @@ -62,16 +84,40 @@ jobs: git switch -C "$branch" git commit -am "chore(deps): remediate transitive security advisories" git push --force origin "$branch" - if [[ -z "$(gh pr list --head "$branch" --state open --json number --jq '.[].number')" ]]; then + + # A create refusal must not abort the step: the dispatch below is what + # gives the pushed head its CI, and the contract test pins the dispatch + # to run after a new or updated pull request. So record the outcome and + # carry on. + create_status=0 + if [[ -n "$(gh pr list --head "$branch" --state open --json number --jq '.[].number')" ]]; then + echo "Remediation pull request is already open; branch updated in place." + elif [[ "$HAVE_PR_TOKEN" == "true" ]]; then gh pr create \ --base main \ --head "$branch" \ --title "chore(deps): auto-remediate transitive security advisories (#406)" \ - --body "Automated \`npm audit fix --package-lock-only --omit=dev\` remediation of newly-published advisories against the pinned dependency tree. Review the lockfile diff and let the release gate confirm the audit is clean before merging." + --body "Automated \`npm audit fix --package-lock-only --omit=dev\` remediation of newly-published advisories against the pinned dependency tree. Review the lockfile diff and let the release gate confirm the audit is clean before merging." || create_status=$? + else + echo "::error::Remediated advisories are pushed to '$branch' but no pull request could be opened." + echo "::error::GITHUB_TOKEN cannot create one: Actions is not permitted to create pull requests in this organisation." + echo "::error::Add DEPS_COMBINE_TOKEN as an organisation secret (a PAT with 'repo' scope); combine-deps.yml needs the same one." + echo "::error::Until then, open the pull request by hand: gh pr create --base main --head $branch" + create_status=1 fi + # GITHUB_TOKEN suppresses push/pull_request workflow triggers, but # workflow_dispatch runs CI on the pushed head for new and updated PRs. + # Unconditional: with a PAT the push raises its own event too, so that + # head may get two runs — preferred over a head with none. gh workflow run ci.yml --ref "$branch" + + # Reached only when the dispatch succeeded; -e has already propagated a + # dispatch failure. A pull request that could not be opened is a failure + # of this job: by now the working tree holds the REMEDIATED lockfiles, so + # the residual gate below would report a clean audit and the run would go + # green with an advisory on a branch nobody is looking at. + exit "$create_status" - name: Fail if high-severity advisories remain after auto-fix shell: bash run: | diff --git a/scripts/ci/release-gates.test.mjs b/scripts/ci/release-gates.test.mjs index 57c09b64f..87403ddfb 100644 --- a/scripts/ci/release-gates.test.mjs +++ b/scripts/ci/release-gates.test.mjs @@ -60,21 +60,41 @@ test("dependency remediation dispatches CI after new and updated PRs, and propag fi } `; - for (const existing of ["", "123"]) { - for (const changed of [false, true]) { - for (const dispatchStatus of [0, 17]) { - const result = spawnSync("bash", ["-e", "-o", "pipefail", "-c", stubs + script], { - encoding: "utf8", - env: { ...process.env, DIFF_STATUS: changed ? "1" : "0", EXISTING_PR: existing, DISPATCH_STATUS: String(dispatchStatus) }, - }); - assert.equal(result.status, changed ? dispatchStatus : 0, result.stderr); - const lines = result.stdout.trim().split("\n"); - const dispatch = "gh workflow run ci.yml --ref chore/dep-audit-remediation"; - assert.equal(lines.filter(line => line === dispatch).length, changed ? 1 : 0); - assert.equal(lines.some(line => line.startsWith("gh pr create ")), changed && !existing); - if (changed) { - assert.ok(lines.indexOf("git push --force origin chore/dep-audit-remediation") < lines.indexOf(dispatch)); - if (!existing) assert.ok(lines.findIndex(line => line.startsWith("gh pr create ")) < lines.indexOf(dispatch)); + // The token dimension is the one that cost six days of silent failure: with no + // DEPS_COMBINE_TOKEN, `gh pr create` is refused outright by the organisation, and + // the job must still push, still dispatch CI, and still fail — because by then the + // working tree holds the remediated lockfiles, so a passing step would let the + // residual audit gate report a clean audit and take the whole run green with an + // advisory sitting on a branch nobody is looking at. + for (const havePrToken of ["true", ""]) { + for (const existing of ["", "123"]) { + for (const changed of [false, true]) { + for (const dispatchStatus of [0, 17]) { + const result = spawnSync("bash", ["-e", "-o", "pipefail", "-c", stubs + script], { + encoding: "utf8", + env: { + ...process.env, + DIFF_STATUS: changed ? "1" : "0", + EXISTING_PR: existing, + DISPATCH_STATUS: String(dispatchStatus), + HAVE_PR_TOKEN: havePrToken, + }, + }); + const refused = changed && !existing && havePrToken !== "true"; + const expected = !changed ? 0 : dispatchStatus || (refused ? 1 : 0); + assert.equal(result.status, expected, result.stderr); + const lines = result.stdout.trim().split("\n"); + const dispatch = "gh workflow run ci.yml --ref chore/dep-audit-remediation"; + assert.equal(lines.filter(line => line === dispatch).length, changed ? 1 : 0); + assert.equal(lines.some(line => line.startsWith("gh pr create ")), changed && !existing && havePrToken === "true"); + // A refusal has to name its remedy, not just redden the badge. + assert.equal(lines.some(line => line.includes("::error::") && line.includes("DEPS_COMBINE_TOKEN")), refused); + if (changed) { + assert.ok(lines.indexOf("git push --force origin chore/dep-audit-remediation") < lines.indexOf(dispatch)); + if (!existing && havePrToken === "true") { + assert.ok(lines.findIndex(line => line.startsWith("gh pr create ")) < lines.indexOf(dispatch)); + } + } } } }