Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,27 @@ jobs:
ref: ${{ inputs.tag }}
fetch-depth: 0

# Runs before the download so a wrong, unfinished, or foreign run is
# rejected without its archives ever reaching the runner.
- name: Verify prepare run
env:
GH_TOKEN: ${{ github.token }}
PREPARE_RUN_ID: ${{ inputs.prepare_run_id }}
run: |
set -euo pipefail
[[ "$PREPARE_RUN_ID" =~ ^[0-9]+$ ]] || {
echo "::error::prepare_run_id must be numeric"
exit 1
}
gh api \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"repos/${GITHUB_REPOSITORY}/actions/runs/${PREPARE_RUN_ID}" \
>"$RUNNER_TEMP/prepare-run.json"
bash scripts/ci/verify_prepare_run.sh \
"$RUNNER_TEMP/prepare-run.json" \
"$(git rev-parse HEAD)"

- name: Download fsck artifacts
uses: actions/download-artifact@v8
with:
Expand All @@ -135,6 +156,13 @@ jobs:
path: ./artifacts
merge-multiple: true

- name: Verify release artifacts
env:
VERSION: ${{ needs.validate-version.outputs.version }}
run: |
set -euo pipefail
bash scripts/ci/verify_release_artifacts.sh ./artifacts "$VERSION"

- name: Create GitHub Release
uses: softprops/action-gh-release@v3
with:
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@ jobs:
exit 1
fi

- name: Shellcheck release scripts
run: shellcheck scripts/ci/*.sh

- name: Release artifact verification
run: bash scripts/ci/test_release_verifiers.sh

# ─────────────────────────────────────────────────────────────────────────
test:
name: Test (${{ matrix.os }})
Expand Down
178 changes: 178 additions & 0 deletions scripts/ci/test_release_verifiers.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
#!/usr/bin/env bash
# Regression tests for the release verifiers.
#
# Generated data only — no network, no tracked fixtures, no assertions about
# workflow YAML layout. Every negative case asserts the reason for the
# rejection, so a verifier that breaks in an unrelated way (bad arity, missing
# jq, a typo in a guard) cannot pass by merely exiting nonzero.

set -euo pipefail

repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
verify_run="$repo_root/scripts/ci/verify_prepare_run.sh"
verify_artifacts="$repo_root/scripts/ci/verify_release_artifacts.sh"

work_dir="$(mktemp -d)"
trap 'rm -rf "$work_dir"' EXIT

version="0.2.0-beta.1"
commit="0123456789abcdef0123456789abcdef01234567"
other_commit="ffffffffffffffffffffffffffffffffffffffff"
artifacts="$work_dir/artifacts"
log="$work_dir/verifier.log"
failures=0

report_log() {
sed 's/^/ /' "$log" >&2
}

expect_ok() {
local label="$1"
shift
if "$@" >"$log" 2>&1; then
printf 'ok %s\n' "$label"
else
printf 'FAIL %s: expected success\n' "$label" >&2
report_log
failures=$((failures + 1))
fi
}

expect_failure() {
local label="$1" want="$2"
shift 2
if "$@" >"$log" 2>&1; then
printf 'FAIL %s: expected failure, got success\n' "$label" >&2
failures=$((failures + 1))
elif grep -qF -- "$want" "$log"; then
printf 'ok %s\n' "$label"
else
printf 'FAIL %s: expected message containing: %s\n' "$label" "$want" >&2
report_log
failures=$((failures + 1))
fi
}

# A successful prepare run for $commit, optionally mutated by a jq filter.
write_run() {
local out="$1" filter="${2:-.}"
jq -n --arg head_sha "$commit" \
'{head_sha: $head_sha,
path: ".github/workflows/release-prepare.yml",
event: "push",
status: "completed",
conclusion: "success"}' |
jq "$filter" >"$out"
}

seed_artifacts() {
rm -rf "$artifacts"
mkdir -p "$artifacts"
local label
for label in linux-x64 linux-arm64 macos-arm64 macos-x64; do
: >"$artifacts/pagedb-fsck-${version}-${label}.tar.gz"
done
: >"$artifacts/pagedb-fsck-${version}-windows-x64.zip"
}

# ── prepare-run provenance ──────────────────────────────────────────────────

run_json="$work_dir/run.json"
write_run "$run_json"

expect_ok "prepare run: successful tag build" \
bash "$verify_run" "$run_json" "$commit"

expect_failure "prepare run: different tag commit" "run head_sha is" \
bash "$verify_run" "$run_json" "$other_commit"

write_run "$work_dir/failed.json" '.conclusion = "failure"'
expect_failure "prepare run: unsuccessful conclusion" "run conclusion is 'failure'" \
bash "$verify_run" "$work_dir/failed.json" "$commit"

write_run "$work_dir/in-progress.json" '.status = "in_progress" | .conclusion = null'
expect_failure "prepare run: still running" "run status is 'in_progress'" \
bash "$verify_run" "$work_dir/in-progress.json" "$commit"

write_run "$work_dir/other-workflow.json" '.path = ".github/workflows/test.yml"'
expect_failure "prepare run: different workflow" "run path is" \
bash "$verify_run" "$work_dir/other-workflow.json" "$commit"

write_run "$work_dir/dispatched.json" '.event = "workflow_dispatch"'
expect_failure "prepare run: not a tag push" "run event is 'workflow_dispatch'" \
bash "$verify_run" "$work_dir/dispatched.json" "$commit"

write_run "$work_dir/truncated.json" 'del(.conclusion)'
expect_failure "prepare run: absent field" "run conclusion is '<absent>'" \
bash "$verify_run" "$work_dir/truncated.json" "$commit"

printf 'not json' >"$work_dir/garbage.json"
expect_failure "prepare run: malformed metadata" "not valid JSON" \
bash "$verify_run" "$work_dir/garbage.json" "$commit"

expect_failure "prepare run: absent metadata file" "missing workflow-run metadata" \
bash "$verify_run" "$work_dir/nonexistent.json" "$commit"

expect_failure "prepare run: malformed commit" "invalid release commit" \
bash "$verify_run" "$run_json" "HEAD"

# ── release attachment set ──────────────────────────────────────────────────

seed_artifacts
expect_ok "artifacts: exact release set" \
bash "$verify_artifacts" "$artifacts" "$version"

seed_artifacts
: >"$artifacts/unexpected.txt"
expect_failure "artifacts: extra attachment" "unexpected release attachment: unexpected.txt" \
bash "$verify_artifacts" "$artifacts" "$version"

seed_artifacts
: >"$artifacts/.hidden"
expect_failure "artifacts: hidden attachment" "unexpected release attachment: .hidden" \
bash "$verify_artifacts" "$artifacts" "$version"

# Renamed rather than deleted: the attachment count stays at five, so this
# reaches the by-name check instead of tripping a coarser cardinality guard.
seed_artifacts
mv "$artifacts/pagedb-fsck-${version}-windows-x64.zip" "$artifacts/pagedb-fsck-${version}-windows-x86.zip"
expect_failure "artifacts: missing archive at full count" \
"missing release archive: pagedb-fsck-${version}-windows-x64.zip" \
bash "$verify_artifacts" "$artifacts" "$version"

seed_artifacts
rm "$artifacts/pagedb-fsck-${version}-macos-x64.tar.gz"
expect_failure "artifacts: absent archive" \
"missing release archive: pagedb-fsck-${version}-macos-x64.tar.gz" \
bash "$verify_artifacts" "$artifacts" "$version"

seed_artifacts
rm "$artifacts/pagedb-fsck-${version}-linux-x64.tar.gz"
ln -s /etc/hostname "$artifacts/pagedb-fsck-${version}-linux-x64.tar.gz"
expect_failure "artifacts: symlinked archive" "is not a regular file" \
bash "$verify_artifacts" "$artifacts" "$version"

seed_artifacts
rm "$artifacts/pagedb-fsck-${version}-linux-arm64.tar.gz"
mkdir "$artifacts/pagedb-fsck-${version}-linux-arm64.tar.gz"
expect_failure "artifacts: directory in place of archive" "is not a regular file" \
bash "$verify_artifacts" "$artifacts" "$version"

seed_artifacts
expect_failure "artifacts: version mismatch" "missing release archive" \
bash "$verify_artifacts" "$artifacts" "9.9.9"

expect_failure "artifacts: malformed version" "invalid release version" \
bash "$verify_artifacts" "$artifacts" "not-a-version"

expect_failure "artifacts: absent directory" "missing artifact directory" \
bash "$verify_artifacts" "$work_dir/nonexistent" "$version"

# ────────────────────────────────────────────────────────────────────────────

if test "$failures" -ne 0; then
printf 'release artifact verification: %d test(s) failed\n' "$failures" >&2
exit 1
fi

printf 'release artifact verification: tests passed\n'
55 changes: 55 additions & 0 deletions scripts/ci/verify_prepare_run.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# Gate the maintainer-supplied Release Prepare run before any of its artifacts
# are downloaded. The run must be a successful, tag-pushed execution of the
# release prepare workflow for the exact commit the release tag resolves to.
#
# Provenance only. Nothing here inspects archives; the published attachment set
# is checked separately by verify_release_artifacts.sh once it is on disk.
# Archive contents are trusted on the strength of this gate — a run pinned to
# the tag commit, the prepare workflow file, and a successful conclusion — so
# there is deliberately no digest comparison anywhere in the release path.

set -euo pipefail

usage='usage: verify_prepare_run.sh <run.json> <commit>'

die() {
printf 'prepare run: %s\n' "$*" >&2
exit 1
}

run_json="${1:?$usage}"
commit="${2:?$usage}"

test -f "$run_json" || die "missing workflow-run metadata: $run_json"
[[ "$commit" =~ ^[0-9a-f]{40}$ ]] || die "invalid release commit: $commit"
jq -e . "$run_json" >/dev/null 2>&1 ||
die "workflow-run metadata is not valid JSON: $run_json"

# Checked field-by-field rather than as one boolean so a rejection names the
# field that disagreed — the maintainer needs to know whether they picked the
# wrong run, the wrong tag, or a run that has not finished.
fields=(head_sha path event status conclusion)
expected=(
"$commit"
".github/workflows/release-prepare.yml"
push
completed
success
)

index=0
while IFS= read -r actual; do
test "$index" -lt "${#fields[@]}" || die "unexpected workflow-run metadata shape"
test "$actual" = "${expected[$index]}" ||
die "run ${fields[$index]} is '$actual', expected '${expected[$index]}'"
index=$((index + 1))
done < <(
jq -r '[.head_sha, .path, .event, .status, .conclusion]
| map(if . == null then "<absent>" else tostring end)
| .[]' "$run_json"
)

test "$index" -eq "${#fields[@]}" || die "incomplete workflow-run metadata: $run_json"

printf 'prepare run: ok\n'
65 changes: 65 additions & 0 deletions scripts/ci/verify_release_artifacts.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Verify the downloaded release attachments are exactly the archive set the
# Release Prepare workflow publishes for this version — every expected archive
# present, nothing else alongside them.
#
# Provenance of the producing run is established beforehand by
# verify_prepare_run.sh; this script only decides what gets attached.

set -euo pipefail

usage='usage: verify_release_artifacts.sh <artifact-dir> <version>'

die() {
printf 'release artifacts: %s\n' "$*" >&2
exit 1
}

artifact_dir="${1:?$usage}"
version="${2:?$usage}"

test -d "$artifact_dir" || die "missing artifact directory: $artifact_dir"
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z][A-Za-z0-9-]*\.[0-9]+)?$ ]] ||
die "invalid release version: $version"

# Mirrors the build matrix in .github/workflows/release-prepare.yml. Adding a
# release target means changing both lists in the same commit.
expected=(
"pagedb-fsck-${version}-linux-x64.tar.gz"
"pagedb-fsck-${version}-linux-arm64.tar.gz"
"pagedb-fsck-${version}-macos-arm64.tar.gz"
"pagedb-fsck-${version}-macos-x64.tar.gz"
"pagedb-fsck-${version}-windows-x64.zip"
)

# Both directions are checked by name. A count comparison would be shorter but
# would let one absence and one intruder cancel out, and it reports "wrong
# number of files" where the useful message is which file.
for archive in "${expected[@]}"; do
path="$artifact_dir/$archive"
# Symlinks are rejected ahead of the existence test so a dangling link is
# reported as what it is rather than as an absent archive.
if test -L "$path" || { test -e "$path" && test ! -f "$path"; }; then
die "release archive is not a regular file: $archive"
elif test ! -e "$path"; then
die "missing release archive: $archive"
fi
done

shopt -s nullglob dotglob
entries=("$artifact_dir"/*)
shopt -u nullglob dotglob

for entry in "${entries[@]}"; do
name="${entry##*/}"
known=""
for archive in "${expected[@]}"; do
if test "$name" = "$archive"; then
known=1
break
fi
done
test -n "$known" || die "unexpected release attachment: $name"
done

printf 'release artifacts: ok\n'