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)); + } + } } } }