diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a788f5f..19098d6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: @@ -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: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 10825f8..fccb97a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 }}) diff --git a/scripts/ci/test_release_verifiers.sh b/scripts/ci/test_release_verifiers.sh new file mode 100755 index 0000000..de87e7d --- /dev/null +++ b/scripts/ci/test_release_verifiers.sh @@ -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 ''" \ + 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' diff --git a/scripts/ci/verify_prepare_run.sh b/scripts/ci/verify_prepare_run.sh new file mode 100755 index 0000000..5ac4108 --- /dev/null +++ b/scripts/ci/verify_prepare_run.sh @@ -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 ' + +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 "" else tostring end) + | .[]' "$run_json" +) + +test "$index" -eq "${#fields[@]}" || die "incomplete workflow-run metadata: $run_json" + +printf 'prepare run: ok\n' diff --git a/scripts/ci/verify_release_artifacts.sh b/scripts/ci/verify_release_artifacts.sh new file mode 100755 index 0000000..89a5e68 --- /dev/null +++ b/scripts/ci/verify_release_artifacts.sh @@ -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 ' + +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'