diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d64282..cae4cb8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,12 @@ jobs: # v0.3.6 shipped a broken action; the docs kept pointing at it for two # weeks because nothing checked the pins a reader copy-pastes. run: bash scripts/check-doc-pins.sh + - name: Documented action pins regression + # The stable release window: docs may name vX.Y.Z before its tag + # exists (only that tag, only in the stable channel, only when the + # remote positively lacks it), and SHA-pinned templates may stay on + # the release the consumer canary's stable pin names until the tag. + run: bash scripts/check-doc-pins_test.sh - name: Action OCI-artifact detection regression # Guards the fix that lets the action accept remote OCI references # (ghcr.io/...) as `artifact`. Extracts is_remote_artifact() from diff --git a/docs/production-release-process.md b/docs/production-release-process.md index f15d6bd..5f4fa37 100644 --- a/docs/production-release-process.md +++ b/docs/production-release-process.md @@ -228,17 +228,53 @@ test; production evidence must verify the candidate attestation online. After every operational gate passes, set both `stable_version` and `release_version` to `X.Y.Z`, set `release_channel` to `stable`, update stable -documentation, and tag the checked merge commit. The operator checks the -graduation report and draft assets, records -`[bpfcompat-solo-promotion:v1]`, and dispatches the exact promotion inputs. -Only this path may update the `X.Y` and `latest` image aliases or GitHub's -latest release. +documentation, and tag the checked merge commit. The operator's +`[bpfcompat-solo-promotion:v1]` record is an input of the final readiness +report, so it is written before that report is generated; the operator then +checks the graduation report and draft assets and dispatches the exact +promotion inputs as a separate decision. Only this path may update the `X.Y` +and `latest` image aliases or GitHub's latest release. + +The order is: the operator's record, the final readiness report, the stable +pull request, the tag, then promotion. Before tagging, also confirm these +stable-only gates, which are easy to miss; the stable tag build refuses +otherwise, and a `v*` tag cannot be reused: + +- `scripts/check-distribution-policy.sh --require-decided` passes, that is, + every decision listed in `blocks_stable` in `distribution/policy.json` is + recorded. +- The stable commit's validator rebuilds to the graduated release candidate's + `bpfcompat-validator-static-linux-amd64` sha256 + (`scripts/check-validator-matches-candidate.sh`). + +Publish no other stable release between the stable pull request's CI and the +`vX.Y.Z` tag build: the stable pin may only name the newest published stable +release below `X.Y.Z`, so one published in that window (a maintenance release, +for example) fails the tag build. Re-run `make check-release-consistency` on +the exact commit immediately before tagging, and tag the merge commit as soon +as the pull request merges: from then until promotion, `main`'s docs name a +release that is not published yet. In the stable pull request, the tag-form action +references in `README.md` and `docs/` name `vX.Y.Z`, which +`scripts/check-doc-pins.sh` accepts while that tag positively does not exist. +The SHA-pinned integration templates in `docs/integrations/` cannot name it +yet; during the stable pull request and the tag build they stay, pinned by +SHA, on the release the consumer canary's `# bpfcompat-pin: stable` step names +(the most recent published stable release before `X.Y.Z`). + +The stable release pull request and the `vX.Y.Z` tag build cannot pin a tag +that does not exist yet or that names their own commit, so they keep the +`# bpfcompat-pin: stable` step on the most recent published (non-draft, +non-prerelease) stable release before `X.Y.Z`. `scripts/check-canary-pins.sh` +accepts that pin only while `vX.Y.Z` is absent or resolves to the commit under +check; a tag or GitHub release lookup it cannot complete fails the check. Once the new stable tag exists, repin the `# bpfcompat-pin: stable` step in -`.github/workflows/consumer-canary.yml` to its commit SHA and update the -`# vX.Y.Z` comment beside it. `scripts/check-canary-pins.sh` runs inside the -release-consistency gate and fails the build until that is done, because a -canary left on the previous release stops testing the path the documentation -now sends every reader down. The `# bpfcompat-pin: candidate` step is not -enforced while `release_channel` is `stable`; repin it with the next -`X.Y.Z-rc.1`. +`.github/workflows/consumer-canary.yml` and the SHA-pinned templates in +`docs/integrations/` to its commit SHA, and update the `# vX.Y.Z` comments +beside them. `scripts/check-canary-pins.sh` (inside the release-consistency +gate) and `scripts/check-doc-pins.sh` fail every later commit until that is +done, +because a canary left on the previous release stops testing the path the +documentation now sends every reader down. The `# bpfcompat-pin: candidate` +step is not enforced while `release_channel` is `stable`; repin it with the +next `X.Y.Z-rc.1`. diff --git a/scripts/check-canary-pins.sh b/scripts/check-canary-pins.sh index 09c2602..4f6bd7c 100755 --- a/scripts/check-canary-pins.sh +++ b/scripts/check-canary-pins.sh @@ -23,7 +23,7 @@ # documented consumer path has no canary at all, which is the gap this guard # exists to close, so it is a failure and not a skip. # -# Preparing the next release candidate is the one state the strict rule +# Preparing the next release candidate is one state the strict rule # cannot be met in: the commit that sets release_version to a new RC (and the # tag build of that very commit) cannot pin the canary to a tag that does not # exist yet, or to itself. While v positively does not exist @@ -32,7 +32,18 @@ # (vX.Y.Z-rc.M, M < N) that is a published, non-draft GitHub prerelease and # whose tag resolves to the pinned full SHA. Any lookup or API failure fails # closed. Every later commit is strict again, so the canary must be repinned -# to the new RC before anything else lands. The stable pin rule is unchanged. +# to the new RC before anything else lands. +# +# The stable release is the same state for the stable pin: the PR that sets +# stable_version to X.Y.Z (release_channel `stable`) and the vX.Y.Z tag build +# cannot pin the canary to vX.Y.Z either. With release_channel `stable`, while +# vX.Y.Z positively does not exist or resolves to the commit under check, the +# stable pin may still name the most recent published stable release before +# X.Y.Z -- the newest vA.B.C, A.B.C < X.Y.Z, that is a non-draft, +# non-prerelease GitHub release -- whose tag resolves to the pinned full SHA. +# Any lookup or API failure fails closed, and every later commit is strict +# again: repin the stable step once the new stable tag exists. The prerelease +# channel's stable pin is always strict. # # `candidate` is enforced only while release_channel is `prerelease`. In the # stable channel there is no active prerelease to graduate, release_version @@ -201,8 +212,6 @@ check_pin() { echo "[canary-pins] ${channel} ${expected} ${sha}" } -check_pin stable "v${stable_version}" - # A release published as a non-draft GitHub prerelease. Any failure to ask # (no gh, no token, API error) is a failure, never a pass. canary_repo="Kernel-Guard/bpfcompat" @@ -212,6 +221,111 @@ published_prerelease() { [[ "$state" == "false true" ]] } +# Every GitHub release, one " " line each. Any +# failure to ask (no gh, no token, API error, a page that fails part-way) or +# a line that is not in that shape is a failure, never a pass. +release_states() { + local out line + out="$(gh api --paginate "repos/${canary_repo}/releases?per_page=100" \ + --jq '.[] | "\(.tag_name) \(.draft) \(.prerelease)"' 2>/dev/null)" || return 1 + while IFS= read -r line; do + [[ -z "$line" || "$line" =~ ^[^[:space:]]+\ (true|false)\ (true|false)$ ]] || return 1 + done <<<"$out" + printf '%s\n' "$out" +} + +# version_lt A B: X.Y.Z version A sorts strictly before X.Y.Z version B. +version_lt() { + local -a a b + local i + IFS=. read -r -a a <<<"$1" + IFS=. read -r -a b <<<"$2" + for i in 0 1 2; do + if (( 10#${a[i]} != 10#${b[i]} )); then + (( 10#${a[i]} < 10#${b[i]} )) + return + fi + done + return 1 +} + +# check_bootstrap_stable: while v is being prepared or built, +# the canary may still pin the most recent published stable release before +# it, provided that release's tag resolves to the pinned commit. +check_bootstrap_stable() { + local sha="${pin_sha[stable]:-}" line="${pin_line[stable]:-}" version="${pin_version[stable]:-}" + if [[ -z "$sha" ]]; then + note_fail "${workflow} declares no '# bpfcompat-pin: stable' pin, so the stable consumer path has no canary" + return + fi + if [[ ! "$sha" =~ ^[0-9a-f]{40}$ ]]; then + note_fail "${workflow}:${line} stable pin '${sha}' is not a full 40-character commit SHA" + return + fi + if [[ ! "$version" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + note_fail "${workflow}:${line} stable pin '${version:-}' must be an earlier vX.Y.Z stable release while v${stable_version} is being prepared" + return + fi + if ! version_lt "${BASH_REMATCH[1]}" "$stable_version"; then + note_fail "${workflow}:${line} stable pin ${version} is not earlier than v${stable_version}" + return + fi + tag_lookup "$version" + if [[ "$LOOKUP_STATE" != found ]]; then + note_fail "${workflow}:${line} could not resolve ${version} (${LOOKUP_STATE}); failing closed" + return + fi + if [[ "$LOOKUP_SHA" != "$sha" ]]; then + note_fail "${workflow}:${line} stable pin is ${sha} but ${version} is ${LOOKUP_SHA}" + return + fi + local states tag draft prerelease newest="" + if ! states="$(release_states)"; then + note_fail "${workflow}:${line} could not list the GitHub releases to verify ${version} (no gh, no token, API error, or a malformed answer); failing closed" + return + fi + if ! grep -Fxq -- "${version} false false" <<<"$states"; then + note_fail "${workflow}:${line} ${version} is not a verified published stable release (draft, prerelease, or no GitHub release)" + return + fi + while read -r tag draft prerelease; do + [[ "$draft $prerelease" == "false false" && "$tag" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)$ ]] || continue + version_lt "${BASH_REMATCH[1]}" "$stable_version" || continue + if [[ -z "$newest" ]] || version_lt "${newest#v}" "${tag#v}"; then + newest="$tag" + fi + done <<<"$states" + if [[ "$newest" != "$version" ]]; then + note_fail "${workflow}:${line} stable pin ${version} is not the most recent published stable release before v${stable_version} (${newest} is)" + return + fi + echo "[canary-pins] stable bootstrap: v${stable_version} is being prepared; canary still pins published stable ${version} ${sha} (repin after the tag)" +} + +if [[ "$release_channel" == stable && "$stable_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + tag_lookup "v${stable_version}" + case "$LOOKUP_STATE" in + absent) + # The new stable release is being prepared: its tag positively does not exist. + check_bootstrap_stable + ;; + found) + head_commit="$(git rev-parse -q --verify HEAD 2>/dev/null || true)" + if [[ "$head_commit" =~ ^[0-9a-f]{40}$ && "$LOOKUP_SHA" == "$head_commit" ]]; then + # The tag build of the new stable release itself. + check_bootstrap_stable + else + check_pin stable "v${stable_version}" + fi + ;; + *) + note_fail "cannot determine whether v${stable_version} exists (remote unreachable or malformed answer); failing closed" + ;; + esac +else + check_pin stable "v${stable_version}" +fi + # check_bootstrap_candidate : while v-rc. is # being prepared or built, the canary may still pin an EARLIER rc of the SAME # release that is a published prerelease and resolves to the pinned commit. diff --git a/scripts/check-canary-pins_test.sh b/scripts/check-canary-pins_test.sh index 3e6a4b6..b24da25 100755 --- a/scripts/check-canary-pins_test.sh +++ b/scripts/check-canary-pins_test.sh @@ -234,11 +234,23 @@ fx="$tmp/bootstrap" mkdir -p "$fx/bin" cat >"$fx/bin/gh" <<'STUB' #!/usr/bin/env bash +# $GH_RELEASES holds one " " line per GitHub release. # gh api repos/Kernel-Guard/bpfcompat/releases/tags/ --jq ... -[[ "$1" == api && "$2" == repos/Kernel-Guard/bpfcompat/releases/tags/* ]] || exit 99 -tag="${2##*/}" -line="$(grep -m1 "^${tag} " "$GH_RELEASES" 2>/dev/null)" || exit 1 -echo "${line#* }" +if [[ "$1" == api && "$2" == repos/Kernel-Guard/bpfcompat/releases/tags/* ]]; then + tag="${2##*/}" + line="$(grep -m1 "^${tag} " "$GH_RELEASES" 2>/dev/null)" || exit 1 + echo "${line#* }" + exit 0 +fi +# gh api --paginate repos/Kernel-Guard/bpfcompat/releases?per_page=100 --jq ... +# (the stable bootstrap; only the exact projection it asks for is answered). +if [[ "$1" == api && "$2" == --paginate && + "$3" == "repos/Kernel-Guard/bpfcompat/releases?per_page=100" && + "$4" == --jq && "$5" == '.[] | "\(.tag_name) \(.draft) \(.prerelease)"' ]]; then + cat -- "$GH_RELEASES" 2>/dev/null || exit 1 + exit 0 +fi +exit 99 STUB chmod +x "$fx/bin/gh" @@ -346,16 +358,21 @@ ffail "commit after the rc tag with a stale candidate pin" "candidate pin is com # ...and repinned to the new RC it passes strictly. { pin stable "$S" v0.3.7; pin candidate "$(fsha v0.4.0-rc.4)" v0.4.0-rc.4; } | fcanary fpass "repinned to the new rc after the tag" -# 27. Stable channel is unchanged: wrong stable pin fails, correct passes, -# and no bootstrap applies to the stable pin. +# 27. Stable channel, strict path: whenever v exists at a +# commit other than the one under check, a wrong stable pin fails, the +# correct one passes, and a pin left on the previous stable release fails +# even though that release is published -- the stable bootstrap (29-38) +# never outlives the new stable tag. fmeta 0.3.7 0.3.7 stable { pin stable "$R3" v0.3.7; } | fcanary ffail "stable channel: stale stable pin" "stable pin is ${R3} but v0.3.7 is ${S}" { pin stable "$S" v0.3.7; } | fcanary fpass "stable channel: correct stable pin" +printf 'v0.3.7 false false\n' >>"$fx/releases" +( cd "$fx/work" && gitq tag -a v0.3.8 -m v0.3.8 HEAD^ && gitq push -q origin --tags ) fmeta 0.3.8 0.3.8 stable { pin stable "$S" v0.3.7; } | fcanary -ffail "stable channel: stable pin never bootstraps to an older release" "stable pin is commented 'v0.3.7' but release.yaml says v0.3.8" +ffail "stable channel: previous stable pin once v0.3.8 is tagged at another commit" "stable pin is commented 'v0.3.7' but release.yaml says v0.3.8" # 28. Every workflow step that reaches the canary-pin check -- directly or via # `make production-tech-check` -- must provide GH_TOKEN, or the RC @@ -388,4 +405,156 @@ if [[ -n "$missing" ]]; then fi echo "[canary-pins-test] every workflow path into the canary-pin check provides GH_TOKEN" +# 29-38. The stable bootstrap (DEF-1). The stable release PR sets +# stable_version = release_version = X.Y.Z with release_channel stable +# before vX.Y.Z exists, and the vX.Y.Z tag build checks the very commit +# that tag names; neither can pin the canary to vX.Y.Z. While vX.Y.Z +# positively does not exist, or resolves to the commit under check, the +# stable pin may still name the most recent published stable release +# before X.Y.Z (non-draft, non-prerelease GitHub release); every later +# commit is strict again. Same harness as 16-27. The fixture mirrors the +# real cycle: v0.3.6 and v0.3.7 published, v0.3.8 only a draft, +# v0.4.0-rc.3 a prerelease, and research-v1 a published release whose +# tag is not a version; the API lists them in no particular order. +new_stable_fixture() { + rm -rf "$fx/work" "$fx/origin.git" + git init -q --bare "$fx/origin.git" + git init -q -b main "$fx/work" + mkdir -p "$fx/work/scripts" "$fx/work/.github/workflows" + cp "$ROOT_DIR/$script" "$fx/work/scripts/check-canary-pins.sh" + ( + cd "$fx/work" + gitq remote add origin "$fx/origin.git" + for t in v0.3.6 v0.3.7 v0.3.8 v0.4.0-rc.3; do + gitq commit --allow-empty -m "$t" + gitq tag -a "$t" -m "$t" + done + gitq commit --allow-empty -m "release: v0.4.0 stable metadata" + gitq push -q origin main --tags + ) + printf 'v0.3.6 false false\nv0.4.0-rc.3 false true\nresearch-v1 false false\nv0.3.8 true false\nv0.3.7 false false\n' >"$fx/releases" +} +new_stable_fixture +T6="$(fsha v0.3.6)"; T7="$(fsha v0.3.7)"; T8="$(fsha v0.3.8)"; RC3="$(fsha v0.4.0-rc.3)" + +# 29. The stable PR: v0.4.0 positively absent, the pin stays on v0.3.7. +fmeta 0.4.0 0.4.0 stable +pin stable "$T7" v0.3.7 | fcanary +fpass "stable PR before its tag: most recent published stable accepted" +grep -Fq "[canary-pins] stable bootstrap: v0.4.0 is being prepared; canary still pins published stable v0.3.7 ${T7}" "$fx/out.log" || + { echo "[canary-pins-test] stable bootstrap must be announced" >&2; cat "$fx/out.log" >&2; exit 1; } +{ pin stable "$T7" v0.3.7; pin candidate "$RC3" v0.4.0-rc.3; } | fcanary +fpass "stable PR before its tag, last cycle's candidate pin left in place" +grep -Fq "candidate pin not enforced" "$fx/out.log" || + { echo "[canary-pins-test] candidate pin must stay unenforced in the stable channel" >&2; exit 1; } + +# 30. Naming the new (or a later) release before its tag exists still fails. +pin stable "$T7" v0.4.0 | fcanary +ffail "stable PR pins v0.4.0 before the tag exists" "stable pin v0.4.0 is not earlier than v0.4.0" +pin stable "$T7" v0.4.1 | fcanary +ffail "stable PR pins a later release" "stable pin v0.4.1 is not earlier than v0.4.0" + +# 31. A published stable release that is not the most recent one before v0.4.0. +pin stable "$T6" v0.3.6 | fcanary +ffail "bootstrap pin on an older-than-latest stable" "stable pin v0.3.6 is not the most recent published stable release before v0.4.0 (v0.3.7 is)" + +# 32. Not a published stable release: an rc, a draft, a release marked +# prerelease, a tag with no release at all. +pin stable "$RC3" v0.4.0-rc.3 | fcanary +ffail "bootstrap pin on a release candidate" "stable pin 'v0.4.0-rc.3' must be an earlier vX.Y.Z stable release while v0.4.0 is being prepared" +pin stable "$T7" "" | fcanary +ffail "bootstrap pin with no version comment" "stable pin '' must be an earlier vX.Y.Z stable release" +pin stable "$T8" v0.3.8 | fcanary +ffail "bootstrap pin on a draft release" "v0.3.8 is not a verified published stable release" +pin stable "$T7" v0.3.7 | fcanary +sed -i 's/^v0.3.7 false false$/v0.3.7 false true/' "$fx/releases" +ffail "bootstrap pin on a release marked prerelease" "v0.3.7 is not a verified published stable release" +sed -i '/^v0.3.7 /d' "$fx/releases" +ffail "bootstrap pin on a tag with no GitHub release" "v0.3.7 is not a verified published stable release" +printf 'v0.3.7 false false\n' >>"$fx/releases" +fpass "bootstrap restored once v0.3.7 is published again" + +# 33. The pinned commit must be the one the named tag resolves to, and the +# named tag must resolve at all. +pin stable "$T6" v0.3.7 | fcanary +ffail "bootstrap pin whose tag resolves elsewhere" "stable pin is ${T6} but v0.3.7 is ${T7}" +pin stable "$T7" v0.3.5 | fcanary +ffail "bootstrap pin names a tag that does not exist" "could not resolve v0.3.5 (absent); failing closed" + +# 34. A mutable or short ref is not a pin, and a missing pin is not a skip. +pin stable v0.3.7 v0.3.7 | fcanary +ffail "bootstrap pin is a tag ref" "stable pin 'v0.3.7' is not a full 40-character commit SHA" +pin stable "${T7:0:12}" v0.3.7 | fcanary +ffail "bootstrap pin is a short SHA" "stable pin '${T7:0:12}' is not a full 40-character commit SHA" +pin candidate "$RC3" v0.4.0-rc.3 | fcanary +ffail "bootstrap without a stable pin" "declares no '# bpfcompat-pin: stable' pin" + +# 35. Every lookup failure fails closed: an unanswerable API, an API that +# fails part-way through pagination, a malformed API answer, and a remote +# that cannot say whether v0.4.0 exists. +pin stable "$T7" v0.3.7 | fcanary +mv "$fx/releases" "$fx/releases.off" +ffail "GitHub API unanswerable" "could not list the GitHub releases to verify v0.3.7" +mv "$fx/releases.off" "$fx/releases" +mkdir -p "$fx/partialgh" +cat >"$fx/partialgh/gh" <<'STUB' +#!/usr/bin/env bash +# A first page that looks fine, then a failed request for the next one. +printf 'v0.3.7 false false\n' +exit 1 +STUB +chmod +x "$fx/partialgh/gh" +ffail "GitHub API fails part-way through" "could not list the GitHub releases to verify v0.3.7" "$fx/partialgh" +cp "$fx/releases" "$fx/releases.good" +printf 'v0.3.9 false\n' >>"$fx/releases" +ffail "malformed GitHub API answer" "could not list the GitHub releases to verify v0.3.7" +cp "$fx/releases.good" "$fx/releases" +git -C "$fx/work" remote set-url origin "$fx/no-such-origin.git" +ffail "unreachable remote" "cannot determine whether v0.4.0 exists" +git -C "$fx/work" remote remove origin +ffail "no remote configured" "cannot determine whether v0.4.0 exists" +git -C "$fx/work" remote add origin "$fx/origin.git" +cat >"$fx/badgit/git" <>"$fx/releases" +fpass "tag build with the v0.4.0 draft staged" +sed -i 's/^v0.4.0 true false$/v0.4.0 false false/' "$fx/releases" +fpass "tag build re-checked after v0.4.0 is published" +pin stable "$(git -C "$fx/work" rev-parse HEAD^)" v0.4.0 | fcanary +ffail "tag build pins its parent commit as v0.4.0" "stable pin v0.4.0 is not earlier than v0.4.0" +pin stable "$T6" v0.3.6 | fcanary +ffail "tag build pins an older-than-latest stable" "stable pin v0.3.6 is not the most recent published stable release before v0.4.0 (v0.3.7 is)" + +# 37. Any later commit is strict again: the previous stable pin fails, and the +# repin to the new stable passes on the strict path, not the bootstrap. +( cd "$fx/work" && gitq commit --allow-empty -m "next change on main" ) +pin stable "$T7" v0.3.7 | fcanary +ffail "commit after the stable tag with the previous stable pin" "stable pin is commented 'v0.3.7' but release.yaml says v0.4.0" +pin stable "$(fsha v0.4.0)" v0.4.0 | fcanary +fpass "repinned to the new stable after the tag" +if ! grep -Fq "[canary-pins] stable v0.4.0 $(fsha v0.4.0)" "$fx/out.log" || grep -q "stable bootstrap" "$fx/out.log"; then + echo "[canary-pins-test] the repinned stable must pass strictly" >&2; cat "$fx/out.log" >&2; exit 1 +fi + +# 38. release_channel: prerelease is untouched: its stable pin is always +# strict, even while v does not exist and the pin names +# the most recent published stable before it. +fmeta 0.4.1 0.5.0-rc.1 prerelease +pin stable "$(fsha v0.4.0)" v0.4.0 | fcanary +ffail "prerelease channel: no stable bootstrap" "stable pin is commented 'v0.4.0' but release.yaml says v0.4.1" +fmeta 0.4.1 0.4.1 stable +fpass "the same pin is the stable bootstrap once the channel is stable" + echo "[canary-pins-test] PASS" diff --git a/scripts/check-doc-pins.sh b/scripts/check-doc-pins.sh index 977c6ce..8c8eebe 100755 --- a/scripts/check-doc-pins.sh +++ b/scripts/check-doc-pins.sh @@ -18,6 +18,22 @@ # (docs/external-ci-proof.md) is history, not an instruction, and is left alone. # `@main` and `@latest` are deliberate and exempt, as is any line carrying an # explicit `doc-pins: allow` marker. +# +# The stable release pull request is the one state rule 2 cannot be met in: +# the release-consistency gate requires its docs to name vX.Y.Z, and that tag +# is created only after the PR merges. So while release.yaml says +# release_channel `stable`, a tag-form pin on exactly v is +# accepted when the remote positively has no such tag; an unreachable remote +# still fails. Commit-SHA pins never get this exemption. +# +# The integration templates are pinned by commit SHA (docs/integrations), so +# they cannot name vX.Y.Z until its tag exists. During the same stable window +# -- the stable PR while vX.Y.Z is absent, and the vX.Y.Z tag build itself -- +# rule 3 allows exactly one other version beside v, and only +# as commit-SHA pins: the earlier release the consumer canary's stable pin +# names (scripts/check-canary-pins.sh verifies that pin is the newest published +# stable release before X.Y.Z). Every later commit is strict again, so the +# templates are repinned with the canary once the tag exists. set -euo pipefail cd "$(dirname "${BASH_SOURCE[0]}")/.." @@ -45,6 +61,52 @@ tag_commit() { printf '%s' "$sha" } +# The release the stable PR is preparing, or nothing outside the stable channel. +release_value() { awk -F': *' -v k="$1" '$1 == k { gsub(/["\r]/, "", $2); print $2 }' release.yaml; } +preparing="" +[[ "$(release_value release_channel)" == stable ]] && preparing="v$(release_value stable_version)" +prepared="" +declare -A tag_form=() + +# version_lt A B: X.Y.Z version A sorts strictly before X.Y.Z version B. +version_lt() { + local -a a b + local i + IFS=. read -r -a a <<<"$1" + IFS=. read -r -a b <<<"$2" + for i in 0 1 2; do + if (( 10#${a[i]} != 10#${b[i]} )); then + (( 10#${a[i]} < 10#${b[i]} )) + return + fi + done + return 1 +} + +# canary_stable_version: the `# vA.B.C` comment on the action pin that follows +# the `# bpfcompat-pin: stable` marker in the consumer canary, or nothing. +canary_stable_version() { + awk ' + /#[[:space:]]*bpfcompat-pin:[[:space:]]*stable[[:space:]]*$/ { want = 1; next } + want && /uses:[[:space:]]*Kernel-Guard\/bpfcompat@/ { + # The first "# v..." comment, exactly as scripts/check-canary-pins.sh + # reads it, so the two checks always see the same version. + if (match($0, /#[[:space:]]*v[0-9][0-9A-Za-z._-]*/)) { + v = substr($0, RSTART, RLENGTH); sub(/^#[[:space:]]*/, "", v); print v + } + exit + } + ' .github/workflows/consumer-canary.yml 2>/dev/null || true +} + +# tag_absent : the remote answered and has no such tag (git ls-remote +# --exit-code returns 2); a remote that cannot be queried is not "absent". +tag_absent() { + local rc=0 + git ls-remote --exit-code --tags origin "refs/tags/$1" >/dev/null 2>&1 || rc=$? + [[ $rc -eq 2 ]] +} + while IFS= read -r hit; do file="${hit%%:*}" rest="${hit#*:}" @@ -77,9 +139,14 @@ while IFS= read -r hit; do fi elif [[ "$ref" =~ ^v[0-9]+\.[0-9]+\.[0-9]+ ]]; then version="$ref" + tag_form["$version"]=1 if [[ -z "$(tag_commit "$version")" ]]; then - note_fail "$file:$line pins $version, which is not a released tag" - continue + if [[ -n "$preparing" && "$version" == "$preparing" ]] && tag_absent "$version"; then + prepared="$version" + else + note_fail "$file:$line pins $version, which is not a released tag" + continue + fi fi else note_fail "$file:$line pins '$ref', which is neither a release tag nor a commit SHA" @@ -95,7 +162,22 @@ if (( ${#versions[@]} == 0 )); then note_fail "found no 'uses: Kernel-Guard/bpfcompat@...' pins in README.md or docs -- the docs moved, or this check no longer matches them" fi -if (( ${#versions[@]} > 1 )); then +# The stable window: v is being prepared (positively absent) +# or this is its tag build (it resolves to HEAD). +window_previous="" +if [[ -n "$preparing" && ${#versions[@]} -eq 2 && -n "${versions[$preparing]:-}" ]]; then + head_commit="$(git rev-parse -q --verify 'HEAD^{commit}' 2>/dev/null || true)" + if [[ -n "$prepared" ]] || [[ -n "$head_commit" && "$(tag_commit "$preparing")" == "$head_commit" ]]; then + candidate_previous="$(canary_stable_version)" + if [[ "$candidate_previous" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)$ ]] && + version_lt "${BASH_REMATCH[1]}" "${preparing#v}" && [[ -z "${tag_form[$candidate_previous]:-}" ]]; then + window_previous="$candidate_previous" + fi + fi +fi +if (( ${#versions[@]} == 2 )) && [[ -n "$window_previous" && -n "${versions[$window_previous]:-}" ]]; then + echo "[doc-pins] stable window for ${preparing}: SHA-pinned templates may stay on ${window_previous} until the tag exists (${versions[$window_previous]% })" +elif (( ${#versions[@]} > 1 )); then note_fail "docs pin more than one bpfcompat version:" for v in "${!versions[@]}"; do echo " $v -> ${versions[$v]}" >&2 @@ -107,4 +189,10 @@ if (( fail )); then exit 1 fi -echo "[doc-pins] ok: every documented action pin resolves to ${!versions[*]}" +if [[ -n "$window_previous" ]]; then + echo "[doc-pins] ok: documented pins name ${preparing}, the stable release being prepared or built; SHA-pinned templates stay on ${window_previous} until its tag exists" +elif [[ -n "$prepared" ]]; then + echo "[doc-pins] ok: every documented action pin names ${prepared}, the stable release being prepared (its tag does not exist yet)" +else + echo "[doc-pins] ok: every documented action pin resolves to ${!versions[*]}" +fi diff --git a/scripts/check-doc-pins_test.sh b/scripts/check-doc-pins_test.sh new file mode 100755 index 0000000..f64f6fb --- /dev/null +++ b/scripts/check-doc-pins_test.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +# Regression cover for scripts/check-doc-pins.sh. Each case builds a throwaway +# repository whose `origin` is a local bare repository holding the tags, with +# a release.yaml and a README that pins the action. +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +failures=0 +fail() { echo "[doc-pins-test] FAIL: $*" >&2; failures=$((failures + 1)); } +ok() { echo "[doc-pins-test] ok: $*"; } +gitq() { git -c user.name=t -c user.email=t@example.invalid -c commit.gpgsign=false -c tag.gpgsign=false "$@" >/dev/null 2>&1; } + +# fixture [tags...] +fixture() { + local d="$tmp/$1" channel="$2" stable="$3" pin="$4"; shift 4 + mkdir -p "$d/origin.git" "$d/work/scripts" + gitq -C "$d/origin.git" init --bare + gitq -C "$d/work" init -b main + cp "$ROOT_DIR/scripts/check-doc-pins.sh" "$d/work/scripts/" + printf 'stable_version: %s\nrelease_version: %s\nrelease_channel: %s\n' "$stable" "$stable" "$channel" >"$d/work/release.yaml" + printf '# x\n```yaml\n - %s\n```\n' "$pin" >"$d/work/README.md" + # The consumer canary's stable pin names the release the stable window keeps + # (CANARY, default v0.3.7); NO_CANARY leaves the file out. + if [[ -z "${NO_CANARY:-}" ]]; then + mkdir -p "$d/work/.github/workflows" + printf 'jobs:\n s:\n steps:\n # bpfcompat-pin: stable\n - uses: Kernel-Guard/bpfcompat@%s # %s\n' \ + "$(printf 'a%.0s' {1..40})" "${CANARY:-v0.3.7}" >"$d/work/.github/workflows/consumer-canary.yml" + fi + gitq -C "$d/work" add -A + gitq -C "$d/work" commit -m base + for t in "$@"; do gitq -C "$d/work" tag -a "$t" -m "$t"; done + gitq -C "$d/work" remote add origin "$d/origin.git" + gitq -C "$d/work" push origin main --tags + # The CI checkout carries no tags locally; the check must ask the remote. + for t in "$@"; do gitq -C "$d/work" tag -d "$t"; done + W="$d/work" +} +# template [file]: an integration template SHA-pinned to , +# committed on top of the fixture (the version's tag is on the base commit). +template() { + mkdir -p "$W/docs/integrations" + printf 'jobs:\n t:\n steps:\n - uses: Kernel-Guard/bpfcompat@%s # %s\n' \ + "$(git -C "$W" rev-list --max-parents=0 HEAD)" "$1" >"$W/docs/integrations/${2:-t}.yml" + gitq -C "$W" add -A + gitq -C "$W" commit -m "template $1" +} +# tag_head : tag the current HEAD on the remote (the tag build of it). +tag_head() { + gitq -C "$W" tag -a "$1" -m "$1" + gitq -C "$W" push origin "$1" + gitq -C "$W" tag -d "$1" +} +expect() { # [stderr-substring] + local want="$1" desc="$2" needle="${3:-}" rc=0 + bash "$W/scripts/check-doc-pins.sh" >"$tmp/out" 2>&1 || rc=$? + if [[ "$want" == pass && $rc -eq 0 ]] || [[ "$want" == fail && $rc -ne 0 && ( -z "$needle" || $(grep -cF -- "$needle" "$tmp/out") -gt 0 ) ]]; then + ok "$desc" + else + fail "$desc (rc=$rc)"; sed 's/^/ | /' "$tmp/out" >&2 + fi +} + +fixture released prerelease 0.3.7 'uses: Kernel-Guard/bpfcompat@v0.3.7' v0.3.7 +expect pass "prerelease channel: pin on an existing release tag" + +fixture stablepr stable 0.4.0 'uses: Kernel-Guard/bpfcompat@v0.4.0' v0.3.7 +expect pass "stable PR: docs name v0.4.0 while its tag positively does not exist yet" + +fixture stabletagged stable 0.4.0 'uses: Kernel-Guard/bpfcompat@v0.4.0' v0.3.7 v0.4.0 +expect pass "stable channel after the tag: the pin resolves normally" + +fixture otherversion stable 0.4.0 'uses: Kernel-Guard/bpfcompat@v0.4.1' v0.3.7 +expect fail "stable PR: a pin on another missing version still fails" "pins v0.4.1, which is not a released tag" + +fixture rcchannel prerelease 0.4.0 'uses: Kernel-Guard/bpfcompat@v0.4.0' v0.3.7 +expect fail "prerelease channel: a missing tag is never accepted" "pins v0.4.0, which is not a released tag" + +fixture shapin stable 0.4.0 "uses: Kernel-Guard/bpfcompat@$(printf 'a%.0s' {1..40}) # v0.4.0" v0.3.7 +expect fail "stable PR: a SHA pin claiming the unreleased version fails" "claims v0.4.0, but that tag does not exist" + +fixture offline stable 0.4.0 'uses: Kernel-Guard/bpfcompat@v0.4.0' v0.3.7 +git -C "$W" remote set-url origin "$tmp/offline/does-not-exist.git" +expect fail "stable PR: an unreachable remote fails closed" "pins v0.4.0, which is not a released tag" + +# The integration templates are SHA-pinned (docs/integrations/README.md), so +# they cannot name vX.Y.Z before its tag exists: during the stable release +# window they stay on the most recent earlier stable release. +fixture tplpr stable 0.4.0 'uses: Kernel-Guard/bpfcompat@v0.4.0' v0.3.6 v0.3.7 +template v0.3.7 +expect pass "stable PR: templates keep their SHA pin on the newest earlier stable" + +fixture tplold stable 0.4.0 'uses: Kernel-Guard/bpfcompat@v0.4.0' v0.3.6 v0.3.7 +template v0.3.6 +expect fail "stable PR: a template on an older stable than the newest fails" "docs pin more than one bpfcompat version" + +fixture tplthree stable 0.4.0 'uses: Kernel-Guard/bpfcompat@v0.4.0' v0.3.6 v0.3.7 +template v0.3.7 a +template v0.3.6 b +expect fail "stable PR: three documented versions fail" "docs pin more than one bpfcompat version" + +fixture tplbuild stable 0.4.0 'uses: Kernel-Guard/bpfcompat@v0.4.0' v0.3.7 +template v0.3.7 +tag_head v0.4.0 +expect pass "tag build: the new tag is HEAD, templates still on the previous stable" + +fixture tplafter stable 0.4.0 'uses: Kernel-Guard/bpfcompat@v0.4.0' v0.3.7 v0.4.0 +template v0.3.7 +expect fail "after the tag: templates must be repinned to the new release" "docs pin more than one bpfcompat version" + +fixture tplform stable 0.4.0 'uses: Kernel-Guard/bpfcompat@v0.4.0' v0.3.7 +mkdir -p "$W/docs"; printf 'jobs:\n t:\n steps:\n - uses: "Kernel-Guard/bpfcompat@v0.3.7"\n' >"$W/docs/stale.yml" +expect fail "stable PR: a tag-form pin on the previous stable is not a SHA-pinned template" "docs pin more than one bpfcompat version" + +CANARY=v0.3.6 fixture tplcanary stable 0.4.0 'uses: Kernel-Guard/bpfcompat@v0.4.0' v0.3.6 v0.3.7 +template v0.3.7 +expect fail "stable PR: templates must stay on the release the canary pins" "docs pin more than one bpfcompat version" + +fixture tplbuildwrong stable 0.4.0 'uses: Kernel-Guard/bpfcompat@v0.4.0' v0.3.6 v0.3.7 +template v0.3.6 +tag_head v0.4.0 +expect fail "tag build: templates on another release than the canary's fail" "docs pin more than one bpfcompat version" + +CANARY='v0.3.7 # v0.3.6' fixture tpltwocomments stable 0.4.0 'uses: Kernel-Guard/bpfcompat@v0.4.0' v0.3.6 v0.3.7 +template v0.3.6 +expect fail "stable PR: the canary's first version comment counts, as in check-canary-pins" "docs pin more than one bpfcompat version" + +NO_CANARY=1 fixture tplnocanary stable 0.4.0 'uses: Kernel-Guard/bpfcompat@v0.4.0' v0.3.7 +template v0.3.7 +expect fail "stable PR: without a canary stable pin there is no window" "docs pin more than one bpfcompat version" + +fixture tplrc prerelease 0.3.7 'uses: Kernel-Guard/bpfcompat@v0.3.7' v0.3.6 v0.3.7 +template v0.3.6 +expect fail "prerelease channel: two documented versions fail as before" "docs pin more than one bpfcompat version" + +if [[ $failures -ne 0 ]]; then echo "[doc-pins-test] $failures failure(s)" >&2; exit 1; fi +echo "[doc-pins-test] PASS" diff --git a/scripts/check-release-consistency_test.sh b/scripts/check-release-consistency_test.sh index b0c907d..495f9f6 100755 --- a/scripts/check-release-consistency_test.sh +++ b/scripts/check-release-consistency_test.sh @@ -26,6 +26,9 @@ restore() { rm -rf "$tmp" } trap restore EXIT +current_stable="$( + awk -F': ' '$1 == "stable_version" {print $2; exit}' release.yaml +)" current_release="$( awk -F': ' '$1 == "release_version" {print $2; exit}' release.yaml )" @@ -51,9 +54,12 @@ EOF } BPFCOMPAT_RELEASE_METADATA=release.yaml "$script" >"$tmp/current.log" -grep -Fq 'channel=prerelease' "$tmp/current.log" +current_channel="$(awk -F': ' '$1 == "release_channel" {print $2; exit}' release.yaml)" +grep -Fq "channel=${current_channel}" "$tmp/current.log" -write_metadata 0.3.7 0.3.7 stable +# The current stable release as a stable channel: docs and canary pins must +# already agree with it (the stable PR changes stable_version, so this follows). +write_metadata "$current_stable" "$current_stable" stable BPFCOMPAT_RELEASE_METADATA="$tmp/release.yaml" "$script" >"$tmp/stable.log" grep -Fq 'channel=stable' "$tmp/stable.log"