Skip to content

Commit 09b4275

Browse files
authored
ci: publish nix hashes through an auto-merged pull request (#59464)
1 parent 19d87a7 commit 09b4275

4 files changed

Lines changed: 240 additions & 43 deletions

File tree

‎.github/nix-hashes/publish.sh‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
#!/usr/bin/env bash
2+
# publish.sh <hashes.json>: run from a checkout of dev. Puts <hashes.json> on bot/nix-hashes as one commit on top of dev,
3+
# opens or updates the pull request into dev, dispatches its required checks and enables auto-merge. Closes the pull
4+
# request instead when dev already has these hashes. Uses git, gh and jq only; never runs repository code.
5+
set -euo pipefail
6+
7+
here=$(cd "$(dirname "$0")" && pwd)
8+
new=$(realpath "$1")
9+
branch=bot/nix-hashes
10+
file=nix/hashes.json
11+
title="chore: update nix node_modules hashes"
12+
: "${GH_REPO:?}" "${GITHUB_SHA:?}"
13+
git=(git -c core.hooksPath=/dev/null -c user.name='github-actions[bot]' -c user.email='41898282+github-actions[bot]@users.noreply.github.com')
14+
15+
bash "$here/validate.sh" "$new"
16+
17+
"${git[@]}" fetch --no-tags --depth=1 origin "+refs/heads/dev:refs/remotes/origin/dev" "$GITHUB_SHA"
18+
19+
# A newer push that changed the inputs has its own run, which publishes hashes for it. Mirrors on.push.paths.
20+
if ! git diff --quiet "$GITHUB_SHA" origin/dev -- bun.lock package.json ':(glob)packages/*/package.json' \
21+
':(glob)services/*/package.json' flake.lock nix/node_modules.nix nix/scripts patches; then
22+
echo "::notice::dev changed the hash inputs since $GITHUB_SHA; leaving it to the newer run"
23+
exit 0
24+
fi
25+
26+
# Only a same-repository pull request is the bot's; a fork can use the same branch name.
27+
find_pr() {
28+
gh pr list --repo "$GH_REPO" --base dev --head "$branch" --state open --json number,isCrossRepository \
29+
--jq 'map(select(.isCrossRepository | not)) | .[0].number // empty'
30+
}
31+
pr=$(find_pr)
32+
33+
"${git[@]}" checkout -B "$branch" origin/dev
34+
if cmp -s "$new" "$file"; then
35+
echo "dev already has these hashes"
36+
if [ -n "$pr" ]; then
37+
gh pr close "$pr" --repo "$GH_REPO" --delete-branch --comment "dev already has these hashes."
38+
fi
39+
exit 0
40+
fi
41+
42+
cp "$new" "$file"
43+
bash "$here/validate.sh" "$file"
44+
"${git[@]}" add -- "$file"
45+
"${git[@]}" commit --no-verify -m "$title"
46+
[ "$(git diff --name-only origin/dev HEAD)" = "$file" ] || {
47+
echo "::error::the commit changes more than $file" >&2
48+
exit 1
49+
}
50+
"${git[@]}" push --no-verify --force origin "HEAD:refs/heads/$branch"
51+
52+
if [ -z "$pr" ]; then
53+
gh pr create --repo "$GH_REPO" --base dev --head "$branch" --title "$title" \
54+
--body "Updates \`$file\` to the node_modules hashes computed for $GITHUB_SHA by bot-nix-hashes. check-bot-scope keeps this branch to that file."
55+
pr=$(find_pr)
56+
fi
57+
58+
# Pushes and pull requests made with GITHUB_TOKEN start no workflows; dispatches do. These produce the checks dev
59+
# requires on the new head.
60+
for workflow in check.yml check-test.yml check-bot-scope.yml; do
61+
gh workflow run "$workflow" --repo "$GH_REPO" --ref "$branch"
62+
done
63+
64+
gh pr merge "$pr" --repo "$GH_REPO" --auto --squash
65+
echo "### Nix hashes" >> "${GITHUB_STEP_SUMMARY:-/dev/null}"
66+
echo "Updated pull request #$pr" >> "${GITHUB_STEP_SUMMARY:-/dev/null}"

‎.github/nix-hashes/validate.sh‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
#!/usr/bin/env bash
2+
# validate.sh <file>: fails unless <file> has exactly the shape of nix/hashes.json, so the hashes bot can only ever publish
3+
# three SRI hashes. Used by bot-nix-hashes.yml before it commits and by check-bot-scope.yml on the bot's pull request.
4+
# Adding a system to the flake means adding it to SYSTEMS here.
5+
set -euo pipefail
6+
7+
file=${1:?usage: validate.sh <file>}
8+
SYSTEMS='["aarch64-darwin","aarch64-linux","x86_64-linux"]'
9+
10+
fail() {
11+
echo "::error::$file: $*" >&2
12+
exit 1
13+
}
14+
15+
if [ ! -f "$file" ] || [ -L "$file" ]; then fail "not a regular file"; fi
16+
[ "$(wc -c < "$file")" -lt 4096 ] || fail "4096 bytes or larger"
17+
jq -se 'length == 1' "$file" > /dev/null 2>&1 || fail "not a single JSON value"
18+
jq -e --argjson systems "$SYSTEMS" '
19+
type == "object"
20+
and keys == ["nodeModules"]
21+
and (.nodeModules | type == "object")
22+
and (.nodeModules | keys == $systems)
23+
and (.nodeModules | all(.[]; type == "string" and test("\\Asha256-[A-Za-z0-9+/]{43}=\\z")))
24+
' "$file" > /dev/null || fail "expected {\"nodeModules\": {<$SYSTEMS>: \"sha256-<44 base64 chars>\"}}"
25+
# The canonical form rules out duplicate keys and anything else jq would drop on parse.
26+
jq . "$file" | cmp -s - "$file" || fail "not formatted as jq . prints it"
27+
echo "$file: valid"

‎.github/workflows/bot-nix-hashes.yml‎

Lines changed: 84 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,22 @@
11
name: bot-nix-hashes
22

3+
# Keeps nix/hashes.json in step with the lockfile through a pull request, using only GITHUB_TOKEN.
4+
# - hash and compute run repository code (nix build) with a read-only token and no secrets. compute uploads only the
5+
# new nix/hashes.json.
6+
# - publish holds the write token. It runs only .github/nix-hashes/ from this commit, treats the artifact as data,
7+
# validates its exact shape and commits only that file to bot/nix-hashes. It never runs repository code.
8+
# - check-bot-scope.yml, a required check, fails the bot's pull request unless it changes only a valid nix/hashes.json,
9+
# so a tampered branch cannot auto-merge.
10+
# This file, check-bot-scope.yml and .github/nix-hashes/ are the security boundary.
11+
312
permissions:
413
contents: read
514

615
on:
716
workflow_dispatch:
817
push:
918
branches: [dev]
19+
# Mirrored in .github/nix-hashes/publish.sh.
1020
paths:
1121
- "bun.lock"
1222
- "package.json"
@@ -25,7 +35,7 @@ concurrency:
2535
jobs:
2636
# Native runners required: bun install cross-compilation flags (--os/--cpu)
2737
# do not produce byte-identical node_modules as native installs.
28-
compute-hash:
38+
hash:
2939
strategy:
3040
fail-fast: false
3141
matrix:
@@ -41,6 +51,8 @@ jobs:
4151
steps:
4252
- name: Checkout repository
4353
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
54+
with:
55+
persist-credentials: false
4456

4557
- name: Setup Nix
4658
uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34
@@ -87,37 +99,29 @@ jobs:
8799
path: hash.txt
88100
retention-days: 1
89101

90-
update-hashes:
91-
needs: compute-hash
92-
if: github.event_name != 'pull_request'
93-
runs-on: blacksmith-4vcpu-ubuntu-2404
94-
environment: release
102+
# Assembles nix/hashes.json from the per-system hashes and uploads it as the only input publish accepts.
103+
compute:
104+
needs: hash
105+
runs-on: ubuntu-24.04
106+
outputs:
107+
changed: ${{ steps.update.outputs.changed }}
95108

96109
steps:
97110
- name: Checkout repository
98111
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
99112
with:
100113
persist-credentials: false
101-
fetch-depth: 0
102-
ref: ${{ github.ref_name }}
103-
104-
- name: Setup git committer
105-
uses: ./.github/actions/setup-git-committer
106-
with:
107-
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
108-
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
109-
110-
- name: Pull latest changes
111-
run: |
112-
git pull --rebase --autostash origin "$GITHUB_REF_NAME"
113114

114115
- name: Download hash artifacts
115116
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
116117
with:
117-
path: hashes
118+
path: ${{ runner.temp }}/hashes
118119
pattern: hash-*
119120

120121
- name: Update hashes.json
122+
id: update
123+
env:
124+
HASHES: ${{ runner.temp }}/hashes
121125
run: |
122126
set -euo pipefail
123127
@@ -126,37 +130,74 @@ jobs:
126130
[ -f "$HASH_FILE" ] || echo '{"nodeModules":{}}' > "$HASH_FILE"
127131
128132
for SYSTEM in x86_64-linux aarch64-linux aarch64-darwin; do
129-
FILE="hashes/hash-${SYSTEM}/hash.txt"
130-
if [ -f "$FILE" ]; then
131-
HASH="$(tr -d '[:space:]' < "$FILE")"
132-
echo "${SYSTEM}: ${HASH}"
133-
jq --arg sys "$SYSTEM" --arg h "$HASH" '.nodeModules[$sys] = $h' "$HASH_FILE" > tmp.json
134-
mv tmp.json "$HASH_FILE"
135-
else
136-
echo "::warning::Missing hash for ${SYSTEM}"
137-
fi
133+
HASH="$(tr -d '[:space:]' < "$HASHES/hash-${SYSTEM}/hash.txt")"
134+
echo "${SYSTEM}: ${HASH}"
135+
jq --arg sys "$SYSTEM" --arg h "$HASH" '.nodeModules[$sys] = $h' "$HASH_FILE" > tmp.json
136+
mv tmp.json "$HASH_FILE"
138137
done
139138
140139
cat "$HASH_FILE"
140+
bash .github/nix-hashes/validate.sh "$HASH_FILE"
141141
142-
- name: Commit changes
143-
run: |
144-
set -euo pipefail
145-
146-
HASH_FILE="nix/hashes.json"
147-
148-
if [ -z "$(git status --short -- "$HASH_FILE")" ]; then
149-
echo "No changes to commit"
142+
if git diff --quiet -- "$HASH_FILE"; then
143+
echo "changed=false" >> "$GITHUB_OUTPUT"
150144
echo "### Nix hashes" >> "$GITHUB_STEP_SUMMARY"
151145
echo "Status: no changes" >> "$GITHUB_STEP_SUMMARY"
152-
exit 0
146+
else
147+
echo "changed=true" >> "$GITHUB_OUTPUT"
148+
echo "### Nix hashes" >> "$GITHUB_STEP_SUMMARY"
149+
echo "Status: changed" >> "$GITHUB_STEP_SUMMARY"
153150
fi
154151
155-
git add "$HASH_FILE"
156-
git commit -m "chore: update nix node_modules hashes"
152+
- name: Upload hashes.json
153+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
154+
with:
155+
name: nix-hashes
156+
path: nix/hashes.json
157+
if-no-files-found: error
158+
retention-days: 1
159+
160+
# Runs even when compute found no change, so an open bot pull request with stale hashes is closed, not merged.
161+
publish:
162+
needs: compute
163+
if: github.repository == 'anomalyco/opencode' && github.ref == 'refs/heads/dev'
164+
runs-on: ubuntu-24.04
165+
concurrency:
166+
group: bot-nix-hashes-publish
167+
cancel-in-progress: false
168+
permissions:
169+
contents: write
170+
pull-requests: write
171+
actions: write
157172

158-
git pull --rebase --autostash origin "$GITHUB_REF_NAME"
159-
git push origin HEAD:"$GITHUB_REF_NAME"
173+
steps:
174+
- name: Check out publish scripts
175+
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
176+
with:
177+
ref: ${{ github.sha }}
178+
path: trusted
179+
sparse-checkout: /.github/nix-hashes/
180+
sparse-checkout-cone-mode: false
181+
persist-credentials: false
160182

161-
echo "### Nix hashes" >> "$GITHUB_STEP_SUMMARY"
162-
echo "Status: committed $(git rev-parse --short HEAD)" >> "$GITHUB_STEP_SUMMARY"
183+
# Holds the push credential; only nix/hashes.json is checked out and nothing in it runs.
184+
- name: Check out dev
185+
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
186+
with:
187+
ref: dev
188+
path: repo
189+
sparse-checkout: /nix/hashes.json
190+
sparse-checkout-cone-mode: false
191+
192+
- name: Download hashes.json
193+
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
194+
with:
195+
name: nix-hashes
196+
path: ${{ runner.temp }}/nix-hashes
197+
198+
- name: Publish
199+
working-directory: repo
200+
run: bash ../trusted/.github/nix-hashes/publish.sh "$RUNNER_TEMP/nix-hashes/hashes.json"
201+
env:
202+
GH_TOKEN: ${{ github.token }}
203+
GH_REPO: ${{ github.repository }}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
name: check-bot-scope
2+
3+
# Required on dev. bot-nix-hashes.yml pushes bot/nix-hashes and auto-merges its pull request without review, so this
4+
# fails that branch unless it changes nothing but a valid nix/hashes.json. Every other branch passes. The bot's own
5+
# pushes start no workflows, so bot-nix-hashes.yml dispatches this; pull_request covers anyone else pushing the branch.
6+
7+
on:
8+
pull_request:
9+
types: [opened, synchronize, reopened, ready_for_review]
10+
workflow_dispatch:
11+
12+
concurrency:
13+
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
14+
cancel-in-progress: true
15+
16+
permissions:
17+
contents: read
18+
19+
jobs:
20+
bot-scope:
21+
name: bot-scope
22+
runs-on: ubuntu-24.04
23+
timeout-minutes: 10
24+
steps:
25+
- name: Check branch
26+
id: branch
27+
env:
28+
HEAD_REF: ${{ github.event_name == 'pull_request' && github.head_ref || github.ref_name }}
29+
run: |
30+
if [ "$HEAD_REF" = bot/nix-hashes ]; then
31+
echo "bot=true" >> "$GITHUB_OUTPUT"
32+
else
33+
echo "$HEAD_REF is not a bot branch; nothing to check"
34+
fi
35+
36+
- name: Checkout repository
37+
if: steps.branch.outputs.bot == 'true'
38+
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
39+
with:
40+
ref: ${{ github.event.pull_request.head.sha || github.sha }}
41+
fetch-depth: 0
42+
filter: blob:none
43+
persist-credentials: false
44+
45+
- name: Check changed files
46+
if: steps.branch.outputs.bot == 'true'
47+
env:
48+
BASE: ${{ github.base_ref || 'dev' }}
49+
run: |
50+
set -euo pipefail
51+
git fetch --no-tags --filter=blob:none origin "+refs/heads/$BASE:refs/remotes/origin/$BASE"
52+
changed=$(git diff --name-only "origin/$BASE...HEAD")
53+
echo "Changed files:"
54+
echo "$changed"
55+
if [ "$changed" != nix/hashes.json ]; then
56+
echo "::error::bot/nix-hashes may change only nix/hashes.json"
57+
exit 1
58+
fi
59+
60+
# Runs dev's validator: the step above proved this branch leaves .github/ unchanged.
61+
- name: Validate nix/hashes.json
62+
if: steps.branch.outputs.bot == 'true'
63+
run: bash .github/nix-hashes/validate.sh nix/hashes.json

0 commit comments

Comments
 (0)