From 8f44c1e73b288ef09d2a3e48a5771d34fba22b39 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 10 Sep 2026 12:35:20 -0700 Subject: [PATCH 1/5] Refresh the Nix vendorHash on Dependabot Go bumps A Dependabot Go bump changes go.sum and leaves the flake's vendorHash in nix/package.nix stale, so the caller's "Nix flake builds" check fails with a fixed-output hash mismatch: the PR wedges where the check is required (hey-cli #427) and merges red where it is not, breaking main's next flake build (basecamp-cli #697, repaired by #701). This reusable workflow refreshes the hash on the Dependabot PR itself. A compute job with no secrets builds the flake at the PR's merge commit and takes the hash only from a go-modules fixed-output mismatch; a separate push job mints a one-hour App token scoped to the calling repository, rewrites exactly the vendorHash line, proves the diff is that one line, and commits through createCommitOnBranch with the captured head as expectedHeadOid. The App push re-triggers the PR's pull_request CI, whose Nix check verifies the new hash; the re-triggered run is actored by the App bot, so the Dependabot actor guard ends the loop. Every other path fails closed with nothing pushed. The header records why pushing into Dependabot PRs is sound for Go bumps even though it was abandoned for actions bumps (#11): the workflow code that re-runs is the default branch's, and the callers' pull_request jobs reference no secret a Go bump can reach. --- .../dependabot-sync-nix-vendor-hash.yml | 348 ++++++++++++++++++ 1 file changed, 348 insertions(+) create mode 100644 .github/workflows/dependabot-sync-nix-vendor-hash.yml diff --git a/.github/workflows/dependabot-sync-nix-vendor-hash.yml b/.github/workflows/dependabot-sync-nix-vendor-hash.yml new file mode 100644 index 0000000..bb7b4e9 --- /dev/null +++ b/.github/workflows/dependabot-sync-nix-vendor-hash.yml @@ -0,0 +1,348 @@ +name: Refresh the Nix vendorHash on Dependabot PRs + +# A Dependabot Go bump changes go.sum; the flake's `vendorHash` in +# nix/package.nix (buildGoModule's fixed-output hash over `go mod vendor`) +# goes stale, and the caller's "Nix flake builds" check fails with a hash +# mismatch. Where that check is required the PR wedges until a human runs +# `make update-nix-hash` (Docker); where it is not, auto-merge lands the PR red +# and main's next flake build is broken until someone notices (basecamp-cli +# #697 -> #701). This workflow refreshes the hash on the Dependabot PR itself, +# so the PR's own Nix check goes green and main is never broken. +# +# Two jobs, deliberately on separate runners: +# +# compute contents: read, no secrets. Builds the flake at the PR's merge +# commit — the same thing the Nix check builds — and, only when Nix +# reports a fixed-output mismatch for the `*-go-modules.drv` +# derivation, takes the SRI hash from that diagnostic's `got:` line. +# A build that fails for any other reason fails this job with Nix's +# own output and nothing is written anywhere. (The classifier is a +# copy of hey-cli's scripts/extract-nix-vendor-hash.sh, whose +# header explains each guard; both guards are load-bearing.) +# +# push Runs only when compute produced a hash. Mints a one-hour GitHub +# App installation token scoped to the calling repository with +# contents: write, rewrites exactly the `vendorHash = "..."` line of +# the package file, proves the diff is that one line, and commits +# through GraphQL createCommitOnBranch with expectedHeadOid set to +# the head the hash was computed for — a compare-and-swap, so a +# Dependabot rebase mid-run makes the commit fail cleanly and the +# rebase's own run recomputes. No git credential ever touches a +# runner; the token exists only in this job, which runs no build. +# +# Why an App token: a GITHUB_TOKEN push does not re-trigger pull_request +# workflows, so the required checks would never report on the new head and the +# PR would wedge on a different thing. The App push is a normal push: the +# caller's full pull_request CI, including the Nix check, runs on the new head +# and is the verification that the refreshed hash builds. +# +# Actor analysis, for the reviewer who remembers why pushing into Dependabot +# PRs was abandoned for *actions* bumps (basecamp/.github #11): after any +# non-Dependabot push, the re-triggered runs are actored by the pusher, not +# dependabot[bot], so they see the caller's Actions secrets instead of the +# Dependabot sandbox. For an actions bump that means the PR's freshly bumped, +# unreviewed workflow code runs with those secrets — unacceptable. For a Go +# bump the workflow code that runs is the default branch's (Dependabot does not +# touch workflows, and the commit this workflow pushes is proven to touch one +# line of the package file); the unreviewed content is Go dependency code +# running inside reviewed jobs, which the Test workflow already executes on the +# same head. The lift therefore exposes exactly the secrets those reviewed +# pull_request jobs reference. Callers must keep that set empty for jobs that +# execute dependency code; hey-cli and basecamp-cli were audited when this +# landed (hey-cli: none; basecamp-cli: ANTHROPIC_API_KEY, in a job path-gated +# to skills/ changes a Go bump never makes). The App token itself grants the +# power every write-access member already has: the default branch is ruleset +# protected and the App is not a bypass actor. No pull_request_target anywhere; +# the head is Dependabot's own same-repo branch, and Dependabot-triggered runs +# read the App key from the Dependabot secret store. +# +# Loop prevention: the pushed commit's pull_request run is actored by the App +# bot, not dependabot[bot], so the guard below skips it. Nothing else is +# pushed. `workflow_dispatch` with a PR number re-runs the refresh by hand +# (and is how the caller was verified before it merged); the PR still has to +# be an open, same-repo, Dependabot-authored one. +# +# Caller contract (thin caller in each repo): +# on: +# pull_request: +# paths: [go.mod, go.sum] +# workflow_dispatch: +# inputs: {pr: {description: Dependabot pull request number, required: true, type: string}} +# permissions: {} +# jobs: +# refresh: +# if: >- +# github.event_name == 'workflow_dispatch' || +# (github.actor == 'dependabot[bot]' && github.event.pull_request.user.login == 'dependabot[bot]') +# uses: basecamp/.github/.github/workflows/dependabot-sync-nix-vendor-hash.yml@ +# with: {pr: "${{ inputs.pr }}", app-client-id: "${{ vars.RELEASE_CLIENT_ID }}"} +# permissions: {contents: read, pull-requests: read, actions: read} +# secrets: {app-private-key: "${{ secrets.RELEASE_APP_PRIVATE_KEY }}"} +# plus the App installed on the repository with contents: write, and its +# private key stored as a *Dependabot* secret (Dependabot-triggered runs do +# not see Actions secrets) as well as the Actions secret the dispatch path +# reads. + +on: + workflow_call: + inputs: + pr: + description: >- + Pull request number (workflow_dispatch only; pull_request events + carry their own). + required: false + default: "" + type: string + app-client-id: + description: Client ID of the GitHub App whose installation token pushes the commit + required: true + type: string + package-file: + description: The Nix file carrying the `vendorHash = "..."` line + required: false + default: nix/package.nix + type: string + secrets: + app-private-key: + description: Private key of the GitHub App named by app-client-id + required: true + +permissions: {} + +jobs: + compute: + name: Compute the vendorHash + runs-on: ubuntu-latest + # Belt and braces with the caller's own guard: this workflow does not + # trust a caller to have one. Dependabot must be both the actor of this + # run (so the App bot's own push does not loop back here) and the PR's + # author, and the head must live in this repository. Dispatch is for + # maintainers; the PR gate below still applies to it. + if: >- # zizmor: ignore[bot-conditions] -- dual check: actor validates the current trigger, user.login validates PR origin; on:pull_request (not pull_request_target), so GitHub sets the actor from who pushed + github.event_name == 'workflow_dispatch' || + ( + github.event_name == 'pull_request' && + github.actor == 'dependabot[bot]' && + github.event.pull_request.user.login == 'dependabot[bot]' && + github.event.pull_request.head.repo.full_name == github.repository + ) + # One run per PR; a newer head cancels the computation for the old one. + concurrency: + group: dependabot-sync-nix-vendor-hash-${{ github.event.pull_request.number || inputs.pr }} + cancel-in-progress: true + permissions: + contents: read + pull-requests: read + outputs: + number: ${{ steps.pr.outputs.number }} + head_sha: ${{ steps.pr.outputs.head_sha }} + head_ref: ${{ steps.pr.outputs.head_ref }} + hash: ${{ steps.build.outputs.hash }} + steps: + # Exactly one open, same-repo PR authored by the Dependabot app (gh + # reports it as app/dependabot — distinct from the dependabot[bot] actor + # checked above). Its head SHA is captured once here and is both what + # the build must correspond to and the push's compare-and-swap lease. + - name: Resolve the pull request + id: pr + env: + GH_TOKEN: ${{ github.token }} + EVENT_PR: ${{ github.event.pull_request.number }} + INPUT_PR: ${{ inputs.pr }} + run: | + number="${EVENT_PR:-$INPUT_PR}" + if ! printf '%s' "$number" | grep -qE '^[0-9]+$'; then + echo "::error::no pull request number (event: '${EVENT_PR}', input: '${INPUT_PR}')" + exit 1 + fi + pr="$(gh pr view "$number" --repo "$GITHUB_REPOSITORY" \ + --json state,author,isCrossRepository,headRefOid,headRefName)" + state="$(jq -r '.state' <<<"$pr")" + author="$(jq -r '.author.login' <<<"$pr")" + cross="$(jq -r '.isCrossRepository' <<<"$pr")" + head_sha="$(jq -r '.headRefOid' <<<"$pr")" + head_ref="$(jq -r '.headRefName' <<<"$pr")" + if [ "$state" != "OPEN" ] || [ "$author" != "app/dependabot" ] || [ "$cross" != "false" ]; then + echo "::error::#${number} is not an open, same-repo, Dependabot-authored PR (state=${state}, author=${author}, cross=${cross})" + exit 1 + fi + if ! printf '%s' "$head_sha" | grep -qE '^[0-9a-f]{40}$'; then + echo "::error::head SHA of #${number} is not a 40-hex commit id" + exit 1 + fi + { echo "number=$number"; echo "head_sha=$head_sha"; echo "head_ref=$head_ref"; } >> "$GITHUB_OUTPUT" + + # The merge commit is what the caller's Nix check builds, so the hash + # computed here is the one that check will accept. + - name: Check out the merge commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: refs/pull/${{ steps.pr.outputs.number }}/merge + persist-credentials: false + + # GitHub refreshes refs/pull/N/merge lazily; a head that moved since + # the PR was resolved above would be built against the wrong go.sum. + - name: Confirm the merge commit is for the captured head + env: + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + run: | + parents="$(git cat-file -p HEAD | awk '/^parent /{print $2}')" + if ! grep -qx "$HEAD_SHA" <<<"$parents"; then + echo "::error::merge commit $(git rev-parse HEAD) is not a merge of ${HEAD_SHA}; the head moved, a later run will pick it up" + exit 1 + fi + + - name: Install Nix + uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 + + # Exit paths, all explicit: a build that succeeds means the hash is + # current and there is nothing to do; a go-modules fixed-output mismatch + # yields the hash; anything else fails here with Nix's own diagnostics + # and nothing downstream runs. + - name: Build the flake and classify the failure + id: build + run: | + log="$(mktemp)" + status=0 + nix build --no-link 2>"$log" || status=$? + cat "$log" >&2 + if [ "$status" -eq 0 ]; then + echo "::notice::the flake builds; vendorHash is current" + exit 0 + fi + # The mismatch names its derivation on one line and `got:` on a + # following one. Take the first `got:` after a go-modules mismatch + # and stop, so another derivation's mismatch cannot supply the value. + hash="$(awk ' + /hash mismatch in fixed-output derivation .*-go-modules\.drv/ { in_block = 1; next } + in_block && /got:/ { + if (match($0, /sha256-[A-Za-z0-9+\/]+=*/)) { print substr($0, RSTART, RLENGTH) } + exit + } + ' "$log")" + if ! printf '%s' "$hash" | grep -qE '^sha256-[A-Za-z0-9+/]{43}=$'; then + echo "::error::nix build failed, and not because of the vendorHash; nothing to refresh" + exit "$status" + fi + echo "::notice::stale vendorHash; nix computed ${hash}" + echo "hash=$hash" >> "$GITHUB_OUTPUT" + + push: + name: Commit the refreshed vendorHash + runs-on: ubuntu-latest + needs: compute + if: needs.compute.outputs.hash != '' + permissions: + contents: read # read the package file at the captured head; the write goes through the App token + actions: read # confirm pull_request CI started on the pushed commit + steps: + - name: Mint a push token for this repository + id: token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ inputs.app-client-id }} + private-key: ${{ secrets.app-private-key }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-contents: write + + # The file is taken from the captured head, not a checkout, and the + # commit is created server-side against that exact head: the runner + # holds no clone and no credential beyond the token in this step's env. + - name: Rewrite the vendorHash line and commit it behind a lease + id: commit + env: + GH_TOKEN: ${{ github.token }} + APP_TOKEN: ${{ steps.token.outputs.token }} + PR: ${{ needs.compute.outputs.number }} + HEAD_SHA: ${{ needs.compute.outputs.head_sha }} + HEAD_REF: ${{ needs.compute.outputs.head_ref }} + NEW_HASH: ${{ needs.compute.outputs.hash }} + PACKAGE_FILE: ${{ inputs.package-file }} + run: | + old="$RUNNER_TEMP/package.nix.old" + new="$RUNNER_TEMP/package.nix.new" + gh api "repos/${GITHUB_REPOSITORY}/contents/${PACKAGE_FILE}?ref=${HEAD_SHA}" --jq '.content' | base64 -d > "$old" + + line_re='^[[:space:]]*vendorHash = "sha256-[A-Za-z0-9+/]{43}=";[[:space:]]*$' + if [ "$(grep -cE "$line_re" "$old")" != "1" ]; then + echo "::error::${PACKAGE_FILE} does not carry exactly one vendorHash line" + exit 1 + fi + old_hash="$(sed -nE 's/^[[:space:]]*vendorHash = "([^"]*)";[[:space:]]*$/\1/p' "$old")" + if [ "$old_hash" = "$NEW_HASH" ]; then + echo "::notice::${PACKAGE_FILE} already carries ${NEW_HASH}; nothing to commit" + exit 0 + fi + + # The grep above already proved the line's shape; here only the + # quoted value is swapped (no interval regexes — Ubuntu's awk is mawk). + awk -v new="$NEW_HASH" ' + !done && /^[[:space:]]*vendorHash = "[^"]*";[[:space:]]*$/ { + sub(/"[^"]*"/, "\"" new "\"") + done = 1 + } + { print } + ' "$old" > "$new" + + # Prove the change is that one line: the diff has exactly one + # removed and one added line, and both are vendorHash lines. + changed="$(diff "$old" "$new" | grep -E '^[<>]' || true)" + if [ "$(printf '%s\n' "$changed" | wc -l | tr -d ' ')" != "2" ] || \ + printf '%s\n' "$changed" | sed -E 's/^[<>] //' | grep -qvE "$line_re"; then + echo "::error::rewrite changed something other than the vendorHash line; refusing to commit" + printf '%s\n' "$changed" + exit 1 + fi + + # createCommitOnBranch with expectedHeadOid is the lease: the commit + # lands only if the branch still points at the head the hash was + # computed for. GitHub signs commits created this way and attributes + # them to the App. + jq -n \ + --arg repo "$GITHUB_REPOSITORY" \ + --arg branch "$HEAD_REF" \ + --arg head "$HEAD_SHA" \ + --arg path "$PACKAGE_FILE" \ + --arg contents "$(base64 -w0 "$new")" \ + --arg headline "Refresh the Nix vendorHash for the Go dependency bump" \ + --arg body "go.sum changed in #${PR}, so buildGoModule's fixed-output hash over the vendored modules moved from ${old_hash} to ${NEW_HASH}. Computed by nix build at the merge of ${HEAD_SHA}; this PR's own Nix flake check verifies it." \ + '{ + query: "mutation($input: CreateCommitOnBranchInput!) { createCommitOnBranch(input: $input) { commit { oid url } } }", + variables: { input: { + branch: { repositoryNameWithOwner: $repo, branchName: $branch }, + expectedHeadOid: $head, + message: { headline: $headline, body: $body }, + fileChanges: { additions: [ { path: $path, contents: $contents } ] } + } } + }' > "$RUNNER_TEMP/commit.json" + result="$(GH_TOKEN="$APP_TOKEN" gh api graphql --input "$RUNNER_TEMP/commit.json")" + oid="$(jq -r '.data.createCommitOnBranch.commit.oid' <<<"$result")" + if ! printf '%s' "$oid" | grep -qE '^[0-9a-f]{40}$'; then + echo "::error::commit was not created: ${result}" + exit 1 + fi + echo "::notice::pushed $(jq -r '.data.createCommitOnBranch.commit.url' <<<"$result") onto ${HEAD_REF}" + echo "oid=$oid" >> "$GITHUB_OUTPUT" + + # An App push fires pull_request synchronize on its own; confirm it did + # so the run log carries the proof. There is deliberately no dispatch + # fallback: dispatching the branch's workflows would run them outside + # the pull_request context with whatever permissions they declare. + - name: Confirm pull_request CI started on the pushed commit + if: steps.commit.outputs.oid != '' + env: + GH_TOKEN: ${{ github.token }} + NEW_HEAD: ${{ steps.commit.outputs.oid }} + run: | + for attempt in 1 2 3 4 5 6; do + count="$(gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/runs" \ + -f event=pull_request -f head_sha="$NEW_HEAD" -f per_page=100 \ + --jq '.workflow_runs | length')" + if [ "${count:-0}" -gt 0 ]; then + echo "::notice::${count} pull_request workflow run(s) started for ${NEW_HEAD}" + exit 0 + fi + [ "$attempt" = "6" ] || sleep 10 + done + echo "::warning::no pull_request run appeared for ${NEW_HEAD} within a minute; check the PR's checks tab" From 12f8950fc925285f3dd5bce2e5cc9ea8b7f72da6 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 10 Sep 2026 12:41:13 -0700 Subject: [PATCH 2/5] Caller contract: the App client id is a literal, the key a Dependabot secret --- .github/workflows/dependabot-sync-nix-vendor-hash.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/dependabot-sync-nix-vendor-hash.yml b/.github/workflows/dependabot-sync-nix-vendor-hash.yml index bb7b4e9..1b6f510 100644 --- a/.github/workflows/dependabot-sync-nix-vendor-hash.yml +++ b/.github/workflows/dependabot-sync-nix-vendor-hash.yml @@ -75,13 +75,15 @@ name: Refresh the Nix vendorHash on Dependabot PRs # github.event_name == 'workflow_dispatch' || # (github.actor == 'dependabot[bot]' && github.event.pull_request.user.login == 'dependabot[bot]') # uses: basecamp/.github/.github/workflows/dependabot-sync-nix-vendor-hash.yml@ -# with: {pr: "${{ inputs.pr }}", app-client-id: "${{ vars.RELEASE_CLIENT_ID }}"} +# with: {pr: "${{ inputs.pr }}", app-client-id: } # permissions: {contents: read, pull-requests: read, actions: read} # secrets: {app-private-key: "${{ secrets.RELEASE_APP_PRIVATE_KEY }}"} # plus the App installed on the repository with contents: write, and its -# private key stored as a *Dependabot* secret (Dependabot-triggered runs do -# not see Actions secrets) as well as the Actions secret the dispatch path -# reads. +# private key stored as a repository *Dependabot* secret (Dependabot- +# triggered runs see only Dependabot secrets) and, for the dispatch path, +# as a repository Actions secret. Environment-scoped copies (the CLIs' +# `release` environment) are out of reach here, by design; `vars` are not +# used because the CLIs keep the client id in that environment too. on: workflow_call: From 8dc61b563cc72855da15c619aeb263172b9810ef Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 10 Sep 2026 14:39:12 -0700 Subject: [PATCH 3/5] Enforce the Go-bump boundary on the diff and test the classifier The caller's path filter is an inclusion filter and workflow_dispatch bypasses it, so the reusable workflow now decides "Go bump" from the PR's changed files: anything outside go.mod, go.sum and the package file is refused before Nix is installed, and a PR touching neither go.mod nor go.sum is a no-op. That lets a caller run it on every Dependabot PR and sequence auto-merge after it, which is the second of the two ways a caller keeps a stale head from merging first; the header now states both. The classifier moves out of the run step into scripts/, embedded by heredoc like the sync-actions-comments updater, with fixtures for the go-modules mismatch, another fixed-output derivation before and after it, a bare `got:`, and malformed SRI values. Writing the fixtures found that a go-modules block without a `got:` line would take the next derivation's, so a mismatch for any other derivation now closes the block. check-embedded-sync.sh covers both embeddings. --- .github/workflows/ci.yml | 2 +- .../dependabot-sync-nix-vendor-hash.yml | 159 +++++++++++++++--- scripts/check-embedded-sync.sh | 37 ++-- scripts/extract-nix-vendor-hash.sh | 57 +++++++ scripts/extract-nix-vendor-hash.test.mjs | 110 ++++++++++++ 5 files changed, 328 insertions(+), 37 deletions(-) create mode 100755 scripts/extract-nix-vendor-hash.sh create mode 100644 scripts/extract-nix-vendor-hash.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3677be5..13c6e0f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,5 +41,5 @@ jobs: # versions with built-in --test glob support. run: node --test scripts/*.test.mjs - - name: Check embedded updater script matches source + - name: Check embedded scripts match their sources run: bash scripts/check-embedded-sync.sh diff --git a/.github/workflows/dependabot-sync-nix-vendor-hash.yml b/.github/workflows/dependabot-sync-nix-vendor-hash.yml index 1b6f510..db622ec 100644 --- a/.github/workflows/dependabot-sync-nix-vendor-hash.yml +++ b/.github/workflows/dependabot-sync-nix-vendor-hash.yml @@ -11,14 +11,20 @@ name: Refresh the Nix vendorHash on Dependabot PRs # # Two jobs, deliberately on separate runners: # -# compute contents: read, no secrets. Builds the flake at the PR's merge -# commit — the same thing the Nix check builds — and, only when Nix -# reports a fixed-output mismatch for the `*-go-modules.drv` -# derivation, takes the SRI hash from that diagnostic's `got:` line. -# A build that fails for any other reason fails this job with Nix's -# own output and nothing is written anywhere. (The classifier is a -# copy of hey-cli's scripts/extract-nix-vendor-hash.sh, whose -# header explains each guard; both guards are load-bearing.) +# compute contents: read, no secrets. Refuses any PR whose changed files +# are not a subset of go.mod, go.sum and the package file — the +# "Go bumps only" boundary is enforced here, on the PR's actual +# diff, not by the caller's path filter or by who dispatched the +# run — and does nothing for a PR that touches neither go.mod nor +# go.sum. Otherwise builds the flake at the PR's merge commit — the +# same thing the Nix check builds — and, only when Nix reports a +# fixed-output mismatch for the `*-go-modules.drv` derivation, +# takes the SRI hash from that diagnostic's `got:` line. A build +# that fails for any other reason fails this job with Nix's own +# output and nothing is written anywhere. (The classifier is +# scripts/extract-nix-vendor-hash.sh, embedded below so the +# caller's SHA pin covers it; CI keeps the copy identical to the +# tested source, whose header explains each guard.) # # push Runs only when compute produced a hash. Mints a one-hour GitHub # App installation token scoped to the calling repository with @@ -29,6 +35,11 @@ name: Refresh the Nix vendorHash on Dependabot PRs # Dependabot rebase mid-run makes the commit fail cleanly and the # rebase's own run recomputes. No git credential ever touches a # runner; the token exists only in this job, which runs no build. +# The lease covers the head only: a base branch whose go.sum moves +# after the build leaves this PR's checks exactly where any PR's +# are after its base moves — run at the merge commit of their +# moment — and the caller's required check, not this push, is +# what holds that. # # Why an App token: a GITHUB_TOKEN push does not re-trigger pull_request # workflows, so the required checks would never report on the new head and the @@ -84,6 +95,25 @@ name: Refresh the Nix vendorHash on Dependabot PRs # as a repository Actions secret. Environment-scoped copies (the CLIs' # `release` environment) are out of reach here, by design; `vars` are not # used because the CLIs keep the client id in that environment too. +# +# The refresh has to land before auto-merge can complete, and this workflow +# cannot make that so on its own: GitHub auto-merge waits for required checks +# only, and this build takes minutes. A caller does one of two things — +# +# - keeps "Nix flake builds" a required status check (hey-cli): the stale +# head stays red until the push, and the App push, being a write-access +# actor's, leaves auto-merge enabled for the new head; or +# - sequences auto-merge after this workflow in one workflow file +# (basecamp-cli): the refresh job calls this workflow on every Dependabot +# PR (the changed-files boundary above makes non-Go bumps a no-op) and +# the auto-merge job `needs:` it, so `gh pr merge --auto` runs only once +# the hash is current or the push has landed, and never when the refresh +# failed. The caller then needs no `paths:` filter. +# +# A caller with neither merges a stale head whenever its required checks +# finish first, as basecamp-cli #697 did; the push then lands on a closed +# PR and main stays broken. The required check is also the only thing that +# holds the hash against a base branch whose go.sum moves after the push. on: workflow_call: @@ -143,7 +173,8 @@ jobs: steps: # Exactly one open, same-repo PR authored by the Dependabot app (gh # reports it as app/dependabot — distinct from the dependabot[bot] actor - # checked above). Its head SHA is captured once here and is both what + # checked above) whose changed files are a subset of go.mod, go.sum and + # the package file. Its head SHA is captured once here and is both what # the build must correspond to and the push's compare-and-swap lease. - name: Resolve the pull request id: pr @@ -151,6 +182,7 @@ jobs: GH_TOKEN: ${{ github.token }} EVENT_PR: ${{ github.event.pull_request.number }} INPUT_PR: ${{ inputs.pr }} + PACKAGE_FILE: ${{ inputs.package-file }} run: | number="${EVENT_PR:-$INPUT_PR}" if ! printf '%s' "$number" | grep -qE '^[0-9]+$'; then @@ -172,11 +204,35 @@ jobs: echo "::error::head SHA of #${number} is not a 40-hex commit id" exit 1 fi - { echo "number=$number"; echo "head_sha=$head_sha"; echo "head_ref=$head_ref"; } >> "$GITHUB_OUTPUT" + + # Go bumps only, decided by the diff itself. A PR that changes + # anything but go.mod, go.sum and the package file (the latter is + # this workflow's own earlier push, on a re-run) is refused whoever + # dispatched it: an actions bump's workflow code must never re-run + # under the App actor. One that changes neither go.mod nor go.sum + # has nothing to refresh and the build is skipped. + files="$(gh api --paginate "repos/${GITHUB_REPOSITORY}/pulls/${number}/files" --jq '.[].filename')" + if [ -z "$files" ]; then + echo "::error::#${number} reports no changed files" + exit 1 + fi + if printf '%s\n' "$files" | grep -qvxF -e go.mod -e go.sum -e "$PACKAGE_FILE"; then + echo "::error::#${number} changes files other than go.mod, go.sum and ${PACKAGE_FILE}; not a Go-only bump, refusing to refresh" + printf '%s\n' "$files" + exit 1 + fi + go_bump=false + if printf '%s\n' "$files" | grep -qxE 'go\.(mod|sum)'; then + go_bump=true + else + echo "::notice::#${number} changes neither go.mod nor go.sum; nothing to refresh" + fi + { echo "number=$number"; echo "head_sha=$head_sha"; echo "head_ref=$head_ref"; echo "go_bump=$go_bump"; } >> "$GITHUB_OUTPUT" # The merge commit is what the caller's Nix check builds, so the hash # computed here is the one that check will accept. - name: Check out the merge commit + if: steps.pr.outputs.go_bump == 'true' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: refs/pull/${{ steps.pr.outputs.number }}/merge @@ -185,6 +241,7 @@ jobs: # GitHub refreshes refs/pull/N/merge lazily; a head that moved since # the PR was resolved above would be built against the wrong go.sum. - name: Confirm the merge commit is for the captured head + if: steps.pr.outputs.go_bump == 'true' env: HEAD_SHA: ${{ steps.pr.outputs.head_sha }} run: | @@ -195,13 +252,83 @@ jobs: fi - name: Install Nix + if: steps.pr.outputs.go_bump == 'true' uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 + # The classifier is embedded here so the caller's SHA pin on this + # reusable workflow covers it byte for byte — no runtime fetch, no + # mutable ref. scripts/extract-nix-vendor-hash.sh in basecamp/.github is + # the tested source of truth; CI fails if this embedded copy drifts from + # it (scripts/check-embedded-sync.sh). + - name: Materialize the trusted classifier + if: steps.pr.outputs.go_bump == 'true' + run: | + cat > "$RUNNER_TEMP/extract-nix-vendor-hash.sh" <<'EXTRACT_SH' + #!/usr/bin/env bash + # Extracts the corrected vendorHash from a nix build log. + # + # Usage: scripts/extract-nix-vendor-hash.sh [LOGFILE] (reads stdin when omitted) + # + # The classifier for "did this build fail because of the vendorHash?" behind + # dependabot-sync-nix-vendor-hash.yml, which embeds it so the caller's SHA + # pin covers it byte for byte; CI fails if the embedded copy drifts from this + # file (scripts/check-embedded-sync.sh), and extract-nix-vendor-hash.test.mjs + # holds the fixtures that are its contract. Derived from hey-cli's script of + # the same name. + # + # Three conditions must hold before a hash comes back. Each guard is + # load-bearing, because the caller writes the result into a tracked file: + # + # - Nix must have reported a fixed-output hash mismatch. Matching a bare + # `got:` is far too loose: any failing build whose log happens to contain + # one — a Go test assertion printing `got: 42`, say — would yield "42" and + # misreport an unrelated failure as a hash problem. + # - That mismatch must belong to buildGoModule's vendor derivation + # (`*-go-modules.drv`). A flake can carry other fixed-output derivations — + # hey-cli's once fetched a Go source tarball while nixpkgs lagged go.mod — + # and their hashes must never land in vendorHash. The `got:` is taken from + # that one diagnostic, so a mismatch for another derivation cannot supply + # the value however the two are ordered. + # - The value must be a complete SRI sha256: 43 base64 characters and one + # `=`. A truncated or otherwise malformed value is not a hash to write. + # + # Exit codes: + # 0 — a go-modules fixed-output hash mismatch was reported; the SRI hash is + # on stdout + # 1 — the log reports no such mismatch, or its value is malformed; nothing + # on stdout + + set -euo pipefail + + LOG=$(cat -- "${1:--}") + + # The mismatch diagnostic names the derivation on its own line; `specified:` + # and `got:` follow on the next lines. Take the first `got:` inside the + # go-modules block and stop there; a mismatch for any other derivation ends + # the block, so its `got:` can never be read as ours. (No interval regexes: + # Ubuntu's awk is mawk. The length check is grep's, below.) + HASH=$(awk ' + /hash mismatch in fixed-output derivation .*-go-modules\.drv/ { in_block = 1; next } + /hash mismatch in fixed-output derivation/ { in_block = 0; next } + in_block && /got:/ { + if (match($0, /sha256-[A-Za-z0-9+\/]+=*/)) { print substr($0, RSTART, RLENGTH) } + exit + } + ' <<<"$LOG") + + if ! printf '%s' "$HASH" | grep -qE '^sha256-[A-Za-z0-9+/]{43}=$'; then + exit 1 + fi + + printf '%s\n' "$HASH" + EXTRACT_SH + # Exit paths, all explicit: a build that succeeds means the hash is # current and there is nothing to do; a go-modules fixed-output mismatch # yields the hash; anything else fails here with Nix's own diagnostics # and nothing downstream runs. - name: Build the flake and classify the failure + if: steps.pr.outputs.go_bump == 'true' id: build run: | log="$(mktemp)" @@ -212,17 +339,7 @@ jobs: echo "::notice::the flake builds; vendorHash is current" exit 0 fi - # The mismatch names its derivation on one line and `got:` on a - # following one. Take the first `got:` after a go-modules mismatch - # and stop, so another derivation's mismatch cannot supply the value. - hash="$(awk ' - /hash mismatch in fixed-output derivation .*-go-modules\.drv/ { in_block = 1; next } - in_block && /got:/ { - if (match($0, /sha256-[A-Za-z0-9+\/]+=*/)) { print substr($0, RSTART, RLENGTH) } - exit - } - ' "$log")" - if ! printf '%s' "$hash" | grep -qE '^sha256-[A-Za-z0-9+/]{43}=$'; then + if ! hash="$(bash "$RUNNER_TEMP/extract-nix-vendor-hash.sh" "$log")"; then echo "::error::nix build failed, and not because of the vendorHash; nothing to refresh" exit "$status" fi diff --git a/scripts/check-embedded-sync.sh b/scripts/check-embedded-sync.sh index 9f72830..245f602 100755 --- a/scripts/check-embedded-sync.sh +++ b/scripts/check-embedded-sync.sh @@ -1,20 +1,27 @@ #!/usr/bin/env bash -# The reusable dependabot-sync-actions-comments workflow embeds the updater -# script in a heredoc so the caller's SHA pin covers it byte for byte. -# scripts/sync-action-pin-comments.mjs is the tested source of truth; this -# check fails CI if the embedded copy drifts from it. +# The reusable workflows embed their scripts in heredocs so the caller's SHA +# pin covers them byte for byte. scripts/ holds each one's tested source of +# truth; this check fails CI if an embedded copy drifts from its source. set -euo pipefail -wf=.github/workflows/dependabot-sync-actions-comments.yml -src=scripts/sync-action-pin-comments.mjs - -# The heredoc body is the workflow-file text between the SYNC_MJS markers, +# The heredoc body is the workflow-file text between the marker lines, # de-indented by the run block's 10 spaces (blank lines carry no indent). -embedded="$(sed -n "/<<'SYNC_MJS'\$/,/^ *SYNC_MJS\$/p" "$wf" | sed -e '1d' -e '$d' -e 's/^ //')" +check() { + local wf="$1" marker="$2" src="$3" + local embedded + embedded="$(sed -n "/<<'${marker}'\$/,/^ *${marker}\$/p" "$wf" | sed -e '1d' -e '$d' -e 's/^ //')" + + if [ -z "$embedded" ]; then + echo "error: no ${marker} heredoc found in $wf" >&2 + exit 1 + fi + if ! diff -u "$src" <(printf '%s\n' "$embedded"); then + echo "error: embedded script in $wf drifted from $src" >&2 + echo "regenerate the heredoc from the script file (or vice versa) so they match" >&2 + exit 1 + fi + echo "embedded script in $wf matches $src" +} -if ! diff -u "$src" <(printf '%s\n' "$embedded"); then - echo "error: embedded updater script in $wf drifted from $src" >&2 - echo "regenerate the heredoc from the script file (or vice versa) so they match" >&2 - exit 1 -fi -echo "embedded updater script matches $src" +check .github/workflows/dependabot-sync-actions-comments.yml SYNC_MJS scripts/sync-action-pin-comments.mjs +check .github/workflows/dependabot-sync-nix-vendor-hash.yml EXTRACT_SH scripts/extract-nix-vendor-hash.sh diff --git a/scripts/extract-nix-vendor-hash.sh b/scripts/extract-nix-vendor-hash.sh new file mode 100755 index 0000000..b7ad658 --- /dev/null +++ b/scripts/extract-nix-vendor-hash.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Extracts the corrected vendorHash from a nix build log. +# +# Usage: scripts/extract-nix-vendor-hash.sh [LOGFILE] (reads stdin when omitted) +# +# The classifier for "did this build fail because of the vendorHash?" behind +# dependabot-sync-nix-vendor-hash.yml, which embeds it so the caller's SHA +# pin covers it byte for byte; CI fails if the embedded copy drifts from this +# file (scripts/check-embedded-sync.sh), and extract-nix-vendor-hash.test.mjs +# holds the fixtures that are its contract. Derived from hey-cli's script of +# the same name. +# +# Three conditions must hold before a hash comes back. Each guard is +# load-bearing, because the caller writes the result into a tracked file: +# +# - Nix must have reported a fixed-output hash mismatch. Matching a bare +# `got:` is far too loose: any failing build whose log happens to contain +# one — a Go test assertion printing `got: 42`, say — would yield "42" and +# misreport an unrelated failure as a hash problem. +# - That mismatch must belong to buildGoModule's vendor derivation +# (`*-go-modules.drv`). A flake can carry other fixed-output derivations — +# hey-cli's once fetched a Go source tarball while nixpkgs lagged go.mod — +# and their hashes must never land in vendorHash. The `got:` is taken from +# that one diagnostic, so a mismatch for another derivation cannot supply +# the value however the two are ordered. +# - The value must be a complete SRI sha256: 43 base64 characters and one +# `=`. A truncated or otherwise malformed value is not a hash to write. +# +# Exit codes: +# 0 — a go-modules fixed-output hash mismatch was reported; the SRI hash is +# on stdout +# 1 — the log reports no such mismatch, or its value is malformed; nothing +# on stdout + +set -euo pipefail + +LOG=$(cat -- "${1:--}") + +# The mismatch diagnostic names the derivation on its own line; `specified:` +# and `got:` follow on the next lines. Take the first `got:` inside the +# go-modules block and stop there; a mismatch for any other derivation ends +# the block, so its `got:` can never be read as ours. (No interval regexes: +# Ubuntu's awk is mawk. The length check is grep's, below.) +HASH=$(awk ' + /hash mismatch in fixed-output derivation .*-go-modules\.drv/ { in_block = 1; next } + /hash mismatch in fixed-output derivation/ { in_block = 0; next } + in_block && /got:/ { + if (match($0, /sha256-[A-Za-z0-9+\/]+=*/)) { print substr($0, RSTART, RLENGTH) } + exit + } +' <<<"$LOG") + +if ! printf '%s' "$HASH" | grep -qE '^sha256-[A-Za-z0-9+/]{43}=$'; then + exit 1 +fi + +printf '%s\n' "$HASH" diff --git a/scripts/extract-nix-vendor-hash.test.mjs b/scripts/extract-nix-vendor-hash.test.mjs new file mode 100644 index 0000000..9e574f3 --- /dev/null +++ b/scripts/extract-nix-vendor-hash.test.mjs @@ -0,0 +1,110 @@ +// node --test fixtures for extract-nix-vendor-hash.sh: every guard in the +// script's header has a log here that only that guard rejects. + +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it } from "node:test"; + +const SCRIPT = new URL("./extract-nix-vendor-hash.sh", import.meta.url).pathname; + +const VENDOR_HASH = "sha256-epvdZN17f1Ui3khPEUz1L6sQncs1FWlcg/IZlVbTtaI="; +const OTHER_HASH = "sha256-wa0/kiCrQ/7SbRDx4YEzIoGpWmMiUOOQYDynoL3wWvE="; + +// The go-modules mismatch as Nix prints it (basecamp-cli #700's job log). +const GO_MODULES_MISMATCH = [ + "building '/nix/store/kghm9bfkv37amlpxrw549zprrwapl62i-basecamp-0.11.0-go-modules.drv'...", + "error: hash mismatch in fixed-output derivation '/nix/store/kghm9bfkv37amlpxrw549zprrwapl62i-basecamp-0.11.0-go-modules.drv':", + " specified: sha256-MdVsRrJ3SEEtFFU6X2gP5s414kEhulSkxLqoD9GxiIg=", + ` got: ${VENDOR_HASH}`, + "error: Cannot build '/nix/store/szd0fwdn05x89csg3dh26irb4ciky9rk-basecamp-0.11.0.drv'.", + " Reason: 1 dependency failed.", +].join("\n"); + +// A second fixed-output derivation in the same flake: the Go source tarball +// hey-cli fetched while nixpkgs lagged go.mod. +const OTHER_MISMATCH = [ + "error: hash mismatch in fixed-output derivation '/nix/store/0p5b3w9c5fhz6z6x1r3q3r0c6l8x9v1m-go1.26.7.src.tar.gz.drv':", + " specified: sha256-4gpQp6AEEO64sxSC1gJa7EgYjcJEShqLQx1ktnU7aQY=", + ` got: ${OTHER_HASH}`, +].join("\n"); + +function extract(input, args = []) { + const result = spawnSync("bash", [SCRIPT, ...args], { input, encoding: "utf8" }); + return { status: result.status, stdout: result.stdout, stderr: result.stderr }; +} + +describe("extract-nix-vendor-hash.sh", () => { + it("returns the hash from a go-modules fixed-output mismatch", () => { + const { status, stdout } = extract(GO_MODULES_MISMATCH); + assert.equal(status, 0); + assert.equal(stdout, `${VENDOR_HASH}\n`); + }); + + it("reads a log file argument as well as stdin", () => { + const log = join(mkdtempSync(join(tmpdir(), "extract-nix-")), "build.log"); + writeFileSync(log, GO_MODULES_MISMATCH); + const { status, stdout } = extract("", [log]); + assert.equal(status, 0); + assert.equal(stdout, `${VENDOR_HASH}\n`); + }); + + it("reports nothing for a build that succeeded", () => { + const { status, stdout } = extract("building '/nix/store/abc-basecamp-0.11.0.drv'...\n"); + assert.equal(status, 1); + assert.equal(stdout, ""); + }); + + it("reports nothing for a failure whose log merely contains `got:`", () => { + const log = [ + "--- FAIL: TestVendorHashParse (0.00s)", + " parse_test.go:12: want: 41, got: 42", + "error: builder for '/nix/store/abc-basecamp-0.11.0.drv' failed with exit code 1", + ].join("\n"); + const { status, stdout } = extract(log); + assert.equal(status, 1); + assert.equal(stdout, ""); + }); + + it("ignores a fixed-output mismatch for a derivation other than go-modules", () => { + const { status, stdout } = extract(OTHER_MISMATCH); + assert.equal(status, 1); + assert.equal(stdout, ""); + }); + + it("takes the go-modules hash when another derivation's mismatch precedes it", () => { + const { status, stdout } = extract(`${OTHER_MISMATCH}\n${GO_MODULES_MISMATCH}`); + assert.equal(status, 0); + assert.equal(stdout, `${VENDOR_HASH}\n`); + }); + + it("takes the go-modules hash when another derivation's mismatch follows it", () => { + const { status, stdout } = extract(`${GO_MODULES_MISMATCH}\n${OTHER_MISMATCH}`); + assert.equal(status, 0); + assert.equal(stdout, `${VENDOR_HASH}\n`); + }); + + it("does not let a later mismatch supply a go-modules block that has no `got:`", () => { + const truncated = GO_MODULES_MISMATCH.split("\n").filter((line) => !line.includes("got:")).join("\n"); + const { status, stdout } = extract(`${truncated}\n${OTHER_MISMATCH}`); + assert.equal(status, 1); + assert.equal(stdout, ""); + }); + + for (const [name, value] of [ + ["truncated", VENDOR_HASH.slice(0, -2) + "="], + ["unpadded", VENDOR_HASH.slice(0, -1)], + ["overlong", VENDOR_HASH.slice(0, -1) + "AA="], + ["not base64", "sha256-not_a_hash="], + ["another algorithm", "sha512-" + VENDOR_HASH.slice(7)], + ]) { + it(`rejects a malformed SRI value (${name})`, () => { + const log = GO_MODULES_MISMATCH.replace(VENDOR_HASH, value); + const { status, stdout } = extract(log); + assert.equal(status, 1, `${value} must not be reported as a hash`); + assert.equal(stdout, ""); + }); + } +}); From 37d260ae0bb28f0710e6281b87a3422ae5687494 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 10 Sep 2026 14:39:52 -0700 Subject: [PATCH 4/5] Name the files outside the Go-bump boundary instead of relying on grep -qv --- .github/workflows/dependabot-sync-nix-vendor-hash.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dependabot-sync-nix-vendor-hash.yml b/.github/workflows/dependabot-sync-nix-vendor-hash.yml index db622ec..12af4a1 100644 --- a/.github/workflows/dependabot-sync-nix-vendor-hash.yml +++ b/.github/workflows/dependabot-sync-nix-vendor-hash.yml @@ -216,9 +216,10 @@ jobs: echo "::error::#${number} reports no changed files" exit 1 fi - if printf '%s\n' "$files" | grep -qvxF -e go.mod -e go.sum -e "$PACKAGE_FILE"; then + outside="$(printf '%s\n' "$files" | grep -vxF -e go.mod -e go.sum -e "$PACKAGE_FILE" || true)" + if [ -n "$outside" ]; then echo "::error::#${number} changes files other than go.mod, go.sum and ${PACKAGE_FILE}; not a Go-only bump, refusing to refresh" - printf '%s\n' "$files" + printf '%s\n' "$outside" exit 1 fi go_bump=false From 49eaa2156461cee5ac15c5236d3ff3f5fe900d3f Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 10 Sep 2026 14:48:04 -0700 Subject: [PATCH 5/5] Make non-Go Dependabot PRs a no-op before applying the Go-bump boundary At 37d260a the changed-files allowlist ran before the "is this a Go bump" decision, so an actions- or docker-only Dependabot PR was refused with an error rather than being the no-op the header promised. A caller that sequences auto-merge after this workflow with `needs:` would then lose auto-merge for docker bumps and show a failing job on every non-Go PR. The no-op decision now comes first; the allowlist applies to Go bumps, where it still refuses a mixed PR whoever dispatched it. The lease note stops claiming a required check holds the hash against a base whose go.sum moves after the build: required checks are not re-run when the base moves, so two Go bumps merging on the same day leave the second with a stale hash whatever the ruleset. That is the exposure every PR has to a moving base; the structural closure is a post-merge repair on main. --- .../dependabot-sync-nix-vendor-hash.yml | 59 +++++++++++-------- 1 file changed, 34 insertions(+), 25 deletions(-) diff --git a/.github/workflows/dependabot-sync-nix-vendor-hash.yml b/.github/workflows/dependabot-sync-nix-vendor-hash.yml index 12af4a1..331afba 100644 --- a/.github/workflows/dependabot-sync-nix-vendor-hash.yml +++ b/.github/workflows/dependabot-sync-nix-vendor-hash.yml @@ -11,12 +11,13 @@ name: Refresh the Nix vendorHash on Dependabot PRs # # Two jobs, deliberately on separate runners: # -# compute contents: read, no secrets. Refuses any PR whose changed files -# are not a subset of go.mod, go.sum and the package file — the -# "Go bumps only" boundary is enforced here, on the PR's actual -# diff, not by the caller's path filter or by who dispatched the -# run — and does nothing for a PR that touches neither go.mod nor -# go.sum. Otherwise builds the flake at the PR's merge commit — the +# compute contents: read, no secrets. Does nothing for a PR that touches +# neither go.mod nor go.sum (an actions or docker bump), and +# refuses a Go bump whose changed files are not a subset of go.mod, +# go.sum and the package file — the "Go bumps only" boundary is +# enforced here, on the PR's actual diff, not by the caller's path +# filter or by who dispatched the run. Otherwise builds the flake +# at the PR's merge commit — the # same thing the Nix check builds — and, only when Nix reports a # fixed-output mismatch for the `*-go-modules.drv` derivation, # takes the SRI hash from that diagnostic's `got:` line. A build @@ -35,11 +36,17 @@ name: Refresh the Nix vendorHash on Dependabot PRs # Dependabot rebase mid-run makes the commit fail cleanly and the # rebase's own run recomputes. No git credential ever touches a # runner; the token exists only in this job, which runs no build. -# The lease covers the head only: a base branch whose go.sum moves -# after the build leaves this PR's checks exactly where any PR's -# are after its base moves — run at the merge commit of their -# moment — and the caller's required check, not this push, is -# what holds that. +# The lease covers the head only. A base whose go.sum moves after +# the build — during it or, far more often, after this PR's checks +# have all run, as when two Go bumps merge on the same day — yields +# a hash for the old merge. That is the exposure every PR's checks +# have to a moving base, and no pull_request-time control holds +# it: required checks are not re-run when the base moves (neither +# caller requires up-to-date branches), and recomputing on this +# workflow's own push would cover only the minutes of the build. +# The caller's Nix job on the default branch reports it after the +# merge; closing it is a post-merge repair on main, the +# sync-actions-comments pattern, which is follow-up work. # # Why an App token: a GITHUB_TOKEN push does not re-trigger pull_request # workflows, so the required checks would never report on the new head and the @@ -112,8 +119,8 @@ name: Refresh the Nix vendorHash on Dependabot PRs # # A caller with neither merges a stale head whenever its required checks # finish first, as basecamp-cli #697 did; the push then lands on a closed -# PR and main stays broken. The required check is also the only thing that -# holds the hash against a base branch whose go.sum moves after the push. +# PR and main stays broken. Neither form holds the hash against a base whose +# go.sum moves after the build; see the lease note above. on: workflow_call: @@ -206,27 +213,29 @@ jobs: fi # Go bumps only, decided by the diff itself. A PR that changes - # anything but go.mod, go.sum and the package file (the latter is - # this workflow's own earlier push, on a re-run) is refused whoever - # dispatched it: an actions bump's workflow code must never re-run - # under the App actor. One that changes neither go.mod nor go.sum - # has nothing to refresh and the build is skipped. + # neither go.mod nor go.sum — an actions or docker bump — is not + # this workflow's business and is a no-op, so a caller can sequence + # auto-merge after it on every Dependabot PR. A Go bump that also + # changes anything but go.mod, go.sum and the package file (the + # latter is this workflow's own earlier push, on a re-run) is + # refused whoever dispatched it: a mixed PR's workflow code must + # never re-run under the App actor. files="$(gh api --paginate "repos/${GITHUB_REPOSITORY}/pulls/${number}/files" --jq '.[].filename')" if [ -z "$files" ]; then echo "::error::#${number} reports no changed files" exit 1 fi - outside="$(printf '%s\n' "$files" | grep -vxF -e go.mod -e go.sum -e "$PACKAGE_FILE" || true)" - if [ -n "$outside" ]; then - echo "::error::#${number} changes files other than go.mod, go.sum and ${PACKAGE_FILE}; not a Go-only bump, refusing to refresh" - printf '%s\n' "$outside" - exit 1 - fi go_bump=false if printf '%s\n' "$files" | grep -qxE 'go\.(mod|sum)'; then go_bump=true + outside="$(printf '%s\n' "$files" | grep -vxF -e go.mod -e go.sum -e "$PACKAGE_FILE" || true)" + if [ -n "$outside" ]; then + echo "::error::#${number} changes files other than go.mod, go.sum and ${PACKAGE_FILE}; not a Go-only bump, refusing to refresh" + printf '%s\n' "$outside" + exit 1 + fi else - echo "::notice::#${number} changes neither go.mod nor go.sum; nothing to refresh" + echo "::notice::#${number} changes neither go.mod nor go.sum; not a Go bump, nothing to refresh" fi { echo "number=$number"; echo "head_sha=$head_sha"; echo "head_ref=$head_ref"; echo "go_bump=$go_bump"; } >> "$GITHUB_OUTPUT"