Skip to content

Commit c9aa941

Browse files
committed
ci(release): gate artifact download on prepare-run provenance
Split release verification into two focused checks: verify_prepare_run.sh confirms the selected Release Prepare run is a successful, tag-pushed build of the exact release commit before its artifacts are ever downloaded, and verify_release_artifacts.sh now only checks the downloaded attachment set against the expected archive list. Add regression tests for both verifiers and run them with shellcheck in CI.
1 parent a89f0f4 commit c9aa941

6 files changed

Lines changed: 284 additions & 88 deletions

File tree

.github/workflows/release.yml

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -126,22 +126,26 @@ jobs:
126126
ref: ${{ inputs.tag }}
127127
fetch-depth: 0
128128

129-
- name: Read prepare run metadata
129+
# Runs before the download so a wrong, unfinished, or foreign run is
130+
# rejected without its archives ever reaching the runner.
131+
- name: Verify prepare run
130132
env:
131-
GITHUB_TOKEN: ${{ github.token }}
133+
GH_TOKEN: ${{ github.token }}
132134
PREPARE_RUN_ID: ${{ inputs.prepare_run_id }}
133135
run: |
134136
set -euo pipefail
135137
[[ "$PREPARE_RUN_ID" =~ ^[0-9]+$ ]] || {
136138
echo "::error::prepare_run_id must be numeric"
137139
exit 1
138140
}
139-
curl --fail --location --silent --show-error \
141+
gh api \
140142
-H "Accept: application/vnd.github+json" \
141-
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
142143
-H "X-GitHub-Api-Version: 2022-11-28" \
143-
"https://api.github.com/repos/${GITHUB_REPOSITORY}/actions/runs/${PREPARE_RUN_ID}" \
144-
--output "$RUNNER_TEMP/prepare-run.json"
144+
"repos/${GITHUB_REPOSITORY}/actions/runs/${PREPARE_RUN_ID}" \
145+
>"$RUNNER_TEMP/prepare-run.json"
146+
bash scripts/ci/verify_prepare_run.sh \
147+
"$RUNNER_TEMP/prepare-run.json" \
148+
"$(git rev-parse HEAD)"
145149
146150
- name: Download fsck artifacts
147151
uses: actions/download-artifact@v8
@@ -152,16 +156,12 @@ jobs:
152156
path: ./artifacts
153157
merge-multiple: true
154158

155-
- name: Verify prepared release
159+
- name: Verify release artifacts
156160
env:
157161
VERSION: ${{ needs.validate-version.outputs.version }}
158162
run: |
159163
set -euo pipefail
160-
bash scripts/ci/verify_release_artifacts.sh \
161-
"$RUNNER_TEMP/prepare-run.json" \
162-
./artifacts \
163-
"$VERSION" \
164-
"$(git rev-parse HEAD)"
164+
bash scripts/ci/verify_release_artifacts.sh ./artifacts "$VERSION"
165165
166166
- name: Create GitHub Release
167167
uses: softprops/action-gh-release@v3

.github/workflows/test.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,11 @@ jobs:
102102
exit 1
103103
fi
104104
105+
- name: Shellcheck release scripts
106+
run: shellcheck scripts/ci/*.sh
107+
105108
- name: Release artifact verification
106-
run: bash scripts/test-release-artifacts.sh
109+
run: bash scripts/ci/test_release_verifiers.sh
107110

108111
# ─────────────────────────────────────────────────────────────────────────
109112
test:
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
#!/usr/bin/env bash
2+
# Regression tests for the release verifiers.
3+
#
4+
# Generated data only — no network, no tracked fixtures, no assertions about
5+
# workflow YAML layout. Every negative case asserts the reason for the
6+
# rejection, so a verifier that breaks in an unrelated way (bad arity, missing
7+
# jq, a typo in a guard) cannot pass by merely exiting nonzero.
8+
9+
set -euo pipefail
10+
11+
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
12+
verify_run="$repo_root/scripts/ci/verify_prepare_run.sh"
13+
verify_artifacts="$repo_root/scripts/ci/verify_release_artifacts.sh"
14+
15+
work_dir="$(mktemp -d)"
16+
trap 'rm -rf "$work_dir"' EXIT
17+
18+
version="0.2.0-beta.1"
19+
commit="0123456789abcdef0123456789abcdef01234567"
20+
other_commit="ffffffffffffffffffffffffffffffffffffffff"
21+
artifacts="$work_dir/artifacts"
22+
log="$work_dir/verifier.log"
23+
failures=0
24+
25+
report_log() {
26+
sed 's/^/ /' "$log" >&2
27+
}
28+
29+
expect_ok() {
30+
local label="$1"
31+
shift
32+
if "$@" >"$log" 2>&1; then
33+
printf 'ok %s\n' "$label"
34+
else
35+
printf 'FAIL %s: expected success\n' "$label" >&2
36+
report_log
37+
failures=$((failures + 1))
38+
fi
39+
}
40+
41+
expect_failure() {
42+
local label="$1" want="$2"
43+
shift 2
44+
if "$@" >"$log" 2>&1; then
45+
printf 'FAIL %s: expected failure, got success\n' "$label" >&2
46+
failures=$((failures + 1))
47+
elif grep -qF -- "$want" "$log"; then
48+
printf 'ok %s\n' "$label"
49+
else
50+
printf 'FAIL %s: expected message containing: %s\n' "$label" "$want" >&2
51+
report_log
52+
failures=$((failures + 1))
53+
fi
54+
}
55+
56+
# A successful prepare run for $commit, optionally mutated by a jq filter.
57+
write_run() {
58+
local out="$1" filter="${2:-.}"
59+
jq -n --arg head_sha "$commit" \
60+
'{head_sha: $head_sha,
61+
path: ".github/workflows/release-prepare.yml",
62+
event: "push",
63+
status: "completed",
64+
conclusion: "success"}' |
65+
jq "$filter" >"$out"
66+
}
67+
68+
seed_artifacts() {
69+
rm -rf "$artifacts"
70+
mkdir -p "$artifacts"
71+
local label
72+
for label in linux-x64 linux-arm64 macos-arm64 macos-x64; do
73+
: >"$artifacts/pagedb-fsck-${version}-${label}.tar.gz"
74+
done
75+
: >"$artifacts/pagedb-fsck-${version}-windows-x64.zip"
76+
}
77+
78+
# ── prepare-run provenance ──────────────────────────────────────────────────
79+
80+
run_json="$work_dir/run.json"
81+
write_run "$run_json"
82+
83+
expect_ok "prepare run: successful tag build" \
84+
bash "$verify_run" "$run_json" "$commit"
85+
86+
expect_failure "prepare run: different tag commit" "run head_sha is" \
87+
bash "$verify_run" "$run_json" "$other_commit"
88+
89+
write_run "$work_dir/failed.json" '.conclusion = "failure"'
90+
expect_failure "prepare run: unsuccessful conclusion" "run conclusion is 'failure'" \
91+
bash "$verify_run" "$work_dir/failed.json" "$commit"
92+
93+
write_run "$work_dir/in-progress.json" '.status = "in_progress" | .conclusion = null'
94+
expect_failure "prepare run: still running" "run status is 'in_progress'" \
95+
bash "$verify_run" "$work_dir/in-progress.json" "$commit"
96+
97+
write_run "$work_dir/other-workflow.json" '.path = ".github/workflows/test.yml"'
98+
expect_failure "prepare run: different workflow" "run path is" \
99+
bash "$verify_run" "$work_dir/other-workflow.json" "$commit"
100+
101+
write_run "$work_dir/dispatched.json" '.event = "workflow_dispatch"'
102+
expect_failure "prepare run: not a tag push" "run event is 'workflow_dispatch'" \
103+
bash "$verify_run" "$work_dir/dispatched.json" "$commit"
104+
105+
write_run "$work_dir/truncated.json" 'del(.conclusion)'
106+
expect_failure "prepare run: absent field" "run conclusion is '<absent>'" \
107+
bash "$verify_run" "$work_dir/truncated.json" "$commit"
108+
109+
printf 'not json' >"$work_dir/garbage.json"
110+
expect_failure "prepare run: malformed metadata" "not valid JSON" \
111+
bash "$verify_run" "$work_dir/garbage.json" "$commit"
112+
113+
expect_failure "prepare run: absent metadata file" "missing workflow-run metadata" \
114+
bash "$verify_run" "$work_dir/nonexistent.json" "$commit"
115+
116+
expect_failure "prepare run: malformed commit" "invalid release commit" \
117+
bash "$verify_run" "$run_json" "HEAD"
118+
119+
# ── release attachment set ──────────────────────────────────────────────────
120+
121+
seed_artifacts
122+
expect_ok "artifacts: exact release set" \
123+
bash "$verify_artifacts" "$artifacts" "$version"
124+
125+
seed_artifacts
126+
: >"$artifacts/unexpected.txt"
127+
expect_failure "artifacts: extra attachment" "unexpected release attachment: unexpected.txt" \
128+
bash "$verify_artifacts" "$artifacts" "$version"
129+
130+
seed_artifacts
131+
: >"$artifacts/.hidden"
132+
expect_failure "artifacts: hidden attachment" "unexpected release attachment: .hidden" \
133+
bash "$verify_artifacts" "$artifacts" "$version"
134+
135+
# Renamed rather than deleted: the attachment count stays at five, so this
136+
# reaches the by-name check instead of tripping a coarser cardinality guard.
137+
seed_artifacts
138+
mv "$artifacts/pagedb-fsck-${version}-windows-x64.zip" "$artifacts/pagedb-fsck-${version}-windows-x86.zip"
139+
expect_failure "artifacts: missing archive at full count" \
140+
"missing release archive: pagedb-fsck-${version}-windows-x64.zip" \
141+
bash "$verify_artifacts" "$artifacts" "$version"
142+
143+
seed_artifacts
144+
rm "$artifacts/pagedb-fsck-${version}-macos-x64.tar.gz"
145+
expect_failure "artifacts: absent archive" \
146+
"missing release archive: pagedb-fsck-${version}-macos-x64.tar.gz" \
147+
bash "$verify_artifacts" "$artifacts" "$version"
148+
149+
seed_artifacts
150+
rm "$artifacts/pagedb-fsck-${version}-linux-x64.tar.gz"
151+
ln -s /etc/hostname "$artifacts/pagedb-fsck-${version}-linux-x64.tar.gz"
152+
expect_failure "artifacts: symlinked archive" "is not a regular file" \
153+
bash "$verify_artifacts" "$artifacts" "$version"
154+
155+
seed_artifacts
156+
rm "$artifacts/pagedb-fsck-${version}-linux-arm64.tar.gz"
157+
mkdir "$artifacts/pagedb-fsck-${version}-linux-arm64.tar.gz"
158+
expect_failure "artifacts: directory in place of archive" "is not a regular file" \
159+
bash "$verify_artifacts" "$artifacts" "$version"
160+
161+
seed_artifacts
162+
expect_failure "artifacts: version mismatch" "missing release archive" \
163+
bash "$verify_artifacts" "$artifacts" "9.9.9"
164+
165+
expect_failure "artifacts: malformed version" "invalid release version" \
166+
bash "$verify_artifacts" "$artifacts" "not-a-version"
167+
168+
expect_failure "artifacts: absent directory" "missing artifact directory" \
169+
bash "$verify_artifacts" "$work_dir/nonexistent" "$version"
170+
171+
# ────────────────────────────────────────────────────────────────────────────
172+
173+
if test "$failures" -ne 0; then
174+
printf 'release artifact verification: %d test(s) failed\n' "$failures" >&2
175+
exit 1
176+
fi
177+
178+
printf 'release artifact verification: tests passed\n'

scripts/ci/verify_prepare_run.sh

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
#!/usr/bin/env bash
2+
# Gate the maintainer-supplied Release Prepare run before any of its artifacts
3+
# are downloaded. The run must be a successful, tag-pushed execution of the
4+
# release prepare workflow for the exact commit the release tag resolves to.
5+
#
6+
# Provenance only. Nothing here inspects archives; the published attachment set
7+
# is checked separately by verify_release_artifacts.sh once it is on disk.
8+
# Archive contents are trusted on the strength of this gate — a run pinned to
9+
# the tag commit, the prepare workflow file, and a successful conclusion — so
10+
# there is deliberately no digest comparison anywhere in the release path.
11+
12+
set -euo pipefail
13+
14+
usage='usage: verify_prepare_run.sh <run.json> <commit>'
15+
16+
die() {
17+
printf 'prepare run: %s\n' "$*" >&2
18+
exit 1
19+
}
20+
21+
run_json="${1:?$usage}"
22+
commit="${2:?$usage}"
23+
24+
test -f "$run_json" || die "missing workflow-run metadata: $run_json"
25+
[[ "$commit" =~ ^[0-9a-f]{40}$ ]] || die "invalid release commit: $commit"
26+
jq -e . "$run_json" >/dev/null 2>&1 ||
27+
die "workflow-run metadata is not valid JSON: $run_json"
28+
29+
# Checked field-by-field rather than as one boolean so a rejection names the
30+
# field that disagreed — the maintainer needs to know whether they picked the
31+
# wrong run, the wrong tag, or a run that has not finished.
32+
fields=(head_sha path event status conclusion)
33+
expected=(
34+
"$commit"
35+
".github/workflows/release-prepare.yml"
36+
push
37+
completed
38+
success
39+
)
40+
41+
index=0
42+
while IFS= read -r actual; do
43+
test "$index" -lt "${#fields[@]}" || die "unexpected workflow-run metadata shape"
44+
test "$actual" = "${expected[$index]}" ||
45+
die "run ${fields[$index]} is '$actual', expected '${expected[$index]}'"
46+
index=$((index + 1))
47+
done < <(
48+
jq -r '[.head_sha, .path, .event, .status, .conclusion]
49+
| map(if . == null then "<absent>" else tostring end)
50+
| .[]' "$run_json"
51+
)
52+
53+
test "$index" -eq "${#fields[@]}" || die "incomplete workflow-run metadata: $run_json"
54+
55+
printf 'prepare run: ok\n'
Lines changed: 35 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,29 @@
11
#!/usr/bin/env bash
2-
# Verify that a successful Release Prepare run produced the exact release set.
2+
# Verify the downloaded release attachments are exactly the archive set the
3+
# Release Prepare workflow publishes for this version — every expected archive
4+
# present, nothing else alongside them.
5+
#
6+
# Provenance of the producing run is established beforehand by
7+
# verify_prepare_run.sh; this script only decides what gets attached.
38

49
set -euo pipefail
510

11+
usage='usage: verify_release_artifacts.sh <artifact-dir> <version>'
12+
613
die() {
714
printf 'release artifacts: %s\n' "$*" >&2
815
exit 1
916
}
1017

11-
run_json="${1:?usage: verify_release_artifacts.sh <run.json> <artifact-dir> <version> <commit>}"
12-
artifact_dir="${2:?usage: verify_release_artifacts.sh <run.json> <artifact-dir> <version> <commit>}"
13-
version="${3:?usage: verify_release_artifacts.sh <run.json> <artifact-dir> <version> <commit>}"
14-
commit="${4:?usage: verify_release_artifacts.sh <run.json> <artifact-dir> <version> <commit>}"
18+
artifact_dir="${1:?$usage}"
19+
version="${2:?$usage}"
1520

16-
test -f "$run_json" || die "missing workflow-run metadata: $run_json"
1721
test -d "$artifact_dir" || die "missing artifact directory: $artifact_dir"
1822
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z][A-Za-z0-9-]*\.[0-9]+)?$ ]] ||
1923
die "invalid release version: $version"
20-
[[ "$commit" =~ ^[0-9a-f]{40}$ ]] || die "invalid release commit: $commit"
21-
22-
jq -e \
23-
--arg commit "$commit" \
24-
'.head_sha == $commit
25-
and .path == ".github/workflows/release-prepare.yml"
26-
and .event == "push"
27-
and .status == "completed"
28-
and .conclusion == "success"' \
29-
"$run_json" >/dev/null ||
30-
die "selected run is not a successful Release Prepare run for the tag commit"
3124

25+
# Mirrors the build matrix in .github/workflows/release-prepare.yml. Adding a
26+
# release target means changing both lists in the same commit.
3227
expected=(
3328
"pagedb-fsck-${version}-linux-x64.tar.gz"
3429
"pagedb-fsck-${version}-linux-arm64.tar.gz"
@@ -37,19 +32,34 @@ expected=(
3732
"pagedb-fsck-${version}-windows-x64.zip"
3833
)
3934

35+
# Both directions are checked by name. A count comparison would be shorter but
36+
# would let one absence and one intruder cancel out, and it reports "wrong
37+
# number of files" where the useful message is which file.
38+
for archive in "${expected[@]}"; do
39+
path="$artifact_dir/$archive"
40+
# Symlinks are rejected ahead of the existence test so a dangling link is
41+
# reported as what it is rather than as an absent archive.
42+
if test -L "$path" || { test -e "$path" && test ! -f "$path"; }; then
43+
die "release archive is not a regular file: $archive"
44+
elif test ! -e "$path"; then
45+
die "missing release archive: $archive"
46+
fi
47+
done
48+
4049
shopt -s nullglob dotglob
4150
entries=("$artifact_dir"/*)
4251
shopt -u nullglob dotglob
43-
test "${#entries[@]}" -eq "${#expected[@]}" ||
44-
die "expected exactly ${#expected[@]} release archives"
4552

4653
for entry in "${entries[@]}"; do
47-
test -f "$entry" && test ! -L "$entry" ||
48-
die "unexpected release artifact: $entry"
49-
done
50-
for archive in "${expected[@]}"; do
51-
test -f "$artifact_dir/$archive" ||
52-
die "missing release archive: $archive"
54+
name="${entry##*/}"
55+
known=""
56+
for archive in "${expected[@]}"; do
57+
if test "$name" = "$archive"; then
58+
known=1
59+
break
60+
fi
61+
done
62+
test -n "$known" || die "unexpected release attachment: $name"
5363
done
5464

5565
printf 'release artifacts: ok\n'

0 commit comments

Comments
 (0)