mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 17:10:50 -10:00
ci(release): one parity gate for RC and release; refuse a tag without an RC receipt
PLAN 0.10.1 CI item C. release-candidate.yml had no parity job, so RC 35707500620 went green on3e8bf29946, Release 35728585975 then failed parity on the same SHA, and v0.10.0 was re-pointed to1be1a703b, which the runbook forbids. - The parity job moves verbatim into a reusable workflow, .github/workflows/release-parity.yml (workflow_call, same steps, pins, cache key and 45-minute cap). release.yml and release-candidate.yml both call it as a job named "Parity". - release.yml's resolve job now runs scripts/release/require-rc-receipt.sh before any build: it requires a successful, manually dispatched release-candidate.yml run for the exact tag SHA whose Parity job concluded success (a green run with a skipped Parity does not count). resolve gains actions: read for that lookup. Missing receipt fails fast with the RC command to run; the message says not to move the tag. - release-workflows.test.js asserts the shared gate, both callers, the "Parity" name the receipt matches, the receipt step and its permission; timeout and hermetic-invocation checks now read release-parity.yml. - RELEASE_RUNBOOK.md states the RC parity run and the receipt rule. Artifact promotion instead of the release rebuild stays in 0.11. Checks run: - actionlint 1.7.12 with the CI ignore set: 0 findings, all workflows - shellcheck on both new scripts: 0 findings - bash scripts/release/require-rc-receipt.test.sh: passed (no runs, skipped Parity, other SHA, green receipt, malformed SHA x2); fails when the Parity conclusion filter is removed - node .github/scripts/release-workflows.test.js: passed; fails against the pre-change release-candidate.yml - Not proven here: a live RC run showing Parity, and a dry-run Release refusing a SHA without a receipt. Both need a hosted dispatch. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
479db053b0
commit
feb55b3280
8 files changed
+310
-91
No files matched your search
@@ -35,6 +35,7 @@ const nightly = read(".github/workflows/nightly.yml");
|
||||
const candidate = read(".github/workflows/release-candidate.yml");
|
||||
const artifacts = read(".github/workflows/release-artifacts.yml");
|
||||
const release = read(".github/workflows/release.yml");
|
||||
const parityWorkflow = read(".github/workflows/release-parity.yml");
|
||||
const republish = read(".github/workflows/release-republish.yml");
|
||||
const releaseDockerfile = read("packaging/docker/Dockerfile.release");
|
||||
const cnb = read(".cnb.yml");
|
||||
@@ -374,8 +375,22 @@ for (const block of rustCacheBlocks) {
|
||||
assert.doesNotMatch(block, /github\.(event|ref|sha)|inputs\./);
|
||||
}
|
||||
|
||||
const parity = release.match(/\n parity:\n([\s\S]*?)\n artifacts:\n/);
|
||||
assert.ok(parity, "public release must retain a parity job");
|
||||
// One parity gate, called by the release candidate and the public release,
|
||||
// and the release refuses a tag without a green RC receipt for its exact SHA.
|
||||
const parity = parityWorkflow.match(/\n parity:\n([\s\S]*)$/);
|
||||
assert.ok(parity, "release-parity.yml must define the parity job");
|
||||
assert.match(parityWorkflow, /^on:\n workflow_call:\n/m, "parity must be a reusable workflow");
|
||||
for (const [name, source] of [["release.yml", release], ["release-candidate.yml", candidate]]) {
|
||||
const caller = source.match(/\n parity:\n([\s\S]*?)\n\n/);
|
||||
assert.ok(caller, `${name} must run the parity job`);
|
||||
assert.match(caller[1], /name: Parity\n/, `${name}: the RC receipt check matches the "Parity" job name`);
|
||||
assert.match(caller[1], /uses: \.\/\.github\/workflows\/release-parity\.yml/, `${name} must call the shared parity gate`);
|
||||
}
|
||||
assert.match(
|
||||
namedStep(release, "Require a green release-candidate receipt for this exact SHA"),
|
||||
/require-rc-receipt\.sh "\$\{GITHUB_REPOSITORY\}" "\$\{SHA\}"/,
|
||||
);
|
||||
assert.match(release, /^ resolve:\n(?:.*\n)*? actions: read\n/m, "resolve needs actions: read for the RC receipt");
|
||||
assert.doesNotMatch(
|
||||
parity[1],
|
||||
/ref: \$\{\{ needs\.resolve\.outputs\.sha \}\}/,
|
||||
@@ -508,11 +523,17 @@ assert.doesNotMatch(
|
||||
|
||||
// Cover every test invocation, including named parity and narrow crate gates.
|
||||
// These launchers protect production dependencies as well as cfg(test) code.
|
||||
// `release` is 4 rather than 3: parity runs the workspace under nextest for the
|
||||
// same one-process-per-test isolation CI's lanes use, and keeps a separate
|
||||
// doctest invocation because nextest does not run doctests.
|
||||
// `release parity` is 4 rather than 3: parity runs the workspace under nextest
|
||||
// for the same one-process-per-test isolation CI's lanes use, and keeps a
|
||||
// separate doctest invocation because nextest does not run doctests. release.yml
|
||||
// itself runs none: its parity job calls release-parity.yml.
|
||||
let hermeticInvocations = 0;
|
||||
for (const [label, workflow, expected] of [["CI", ci, 5], ["release", release, 4], ["CNB", cnb, 3]]) {
|
||||
for (const [label, workflow, expected] of [
|
||||
["CI", ci, 5],
|
||||
["release", release, 0],
|
||||
["release parity", parityWorkflow, 4],
|
||||
["CNB", cnb, 3],
|
||||
]) {
|
||||
const commands = workflow.split("\n").filter((line) =>
|
||||
!line.trimStart().startsWith("#") && /\bcargo (?:test|nextest run)\b/.test(line),
|
||||
);
|
||||
@@ -667,6 +688,7 @@ for (const [name, source] of [
|
||||
["release-candidate.yml", candidate],
|
||||
["release-artifacts.yml", artifacts],
|
||||
["release.yml", release],
|
||||
["release-parity.yml", parityWorkflow],
|
||||
["release-republish.yml", republish],
|
||||
["ci.yml", ci],
|
||||
["nightly.yml", nightly],
|
||||
@@ -709,7 +731,7 @@ assert.equal(jobTimeout(nightly, "build"), 90);
|
||||
assert.equal(jobTimeout(release, "resolve"), 10);
|
||||
// The v0.9.12 tag push finished every parity step and was then cancelled at
|
||||
// 20 minutes inside rust-cache's post-run save; 45 keeps that margin.
|
||||
assert.equal(jobTimeout(release, "parity"), 45);
|
||||
assert.equal(jobTimeout(parityWorkflow, "parity"), 45);
|
||||
|
||||
console.log(
|
||||
"Workflow contracts OK: 6-target/12-asset single-runtime nightly and exact-head 7-target/34-asset release candidate.",
|
||||
|
||||
@@ -235,6 +235,7 @@ jobs:
|
||||
bash scripts/release/install-dogfood.test.sh
|
||||
bash scripts/release/prepare-release.test.sh
|
||||
bash scripts/release/prune-actions-caches.test.sh
|
||||
bash scripts/release/require-rc-receipt.test.sh
|
||||
bash scripts/release/require-release-tag-checkout.test.sh
|
||||
bash scripts/release/validate-crate-publish-order.test.sh
|
||||
python3 scripts/release/publish-crates.test.py
|
||||
|
||||
@@ -109,6 +109,15 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: npm run check
|
||||
|
||||
parity:
|
||||
# The same gate release.yml runs before publish. release.yml refuses a tag
|
||||
# unless an RC run for that exact SHA has this job green
|
||||
# (scripts/release/require-rc-receipt.sh matches the "Parity" name).
|
||||
name: Parity
|
||||
needs: resolve
|
||||
if: ${{ !cancelled() && needs.resolve.result == 'success' }}
|
||||
uses: ./.github/workflows/release-parity.yml
|
||||
|
||||
artifacts:
|
||||
needs: [resolve, web]
|
||||
if: ${{ !cancelled() && needs.resolve.result == 'success' && needs.web.result == 'success' }}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
name: Release parity
|
||||
|
||||
# The one parity gate. release-candidate.yml and release.yml both call this,
|
||||
# so the SHA an RC validates has passed exactly the gate that publish runs.
|
||||
# Before this was shared, the RC skipped parity: RC 35707500620 was green on
|
||||
# 3e8bf29946, Release 35728585975 then failed parity on that SHA, and v0.10.0
|
||||
# was re-pointed to 1be1a703b, which docs/RELEASE_RUNBOOK.md forbids.
|
||||
#
|
||||
# Checks out the caller's GITHUB_SHA. Callers must verify that SHA first.
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
RUSTFLAGS: -Dwarnings
|
||||
|
||||
jobs:
|
||||
parity:
|
||||
name: Workspace parity
|
||||
timeout-minutes: 45
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Every caller's resolve job already proved GITHUB_SHA equals the
|
||||
# candidate or tag commit. Do not interpolate a SHA into checkout or
|
||||
# cache keys —
|
||||
# CodeQL treats a *sha* ref as an untrusted checkout on workflow_dispatch
|
||||
# (default-branch cache write).
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
- uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master 2026-07-18
|
||||
with:
|
||||
toolchain: stable
|
||||
components: clippy, rustfmt
|
||||
- uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
id: sccache
|
||||
continue-on-error: true
|
||||
- name: Enable sccache
|
||||
if: steps.sccache.outcome == 'success'
|
||||
shell: bash
|
||||
run: |
|
||||
{
|
||||
echo "SCCACHE_GHA_ENABLED=true"
|
||||
echo "RUSTC_WRAPPER=sccache"
|
||||
echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1"
|
||||
} >> "${GITHUB_ENV}"
|
||||
- name: Install Linux system dependencies
|
||||
run: |
|
||||
for i in 1 2 3 4 5; do
|
||||
sudo apt-get update && break
|
||||
echo "apt-get update failed (attempt $i); retrying in 15s"
|
||||
sleep 15
|
||||
done
|
||||
sudo apt-get install -y libdbus-1-dev pkg-config
|
||||
# Restore after the trusted lockfile is on disk. Key is OS + arch +
|
||||
# explicit stable toolchain + rust-cache's Cargo.lock / rust-toolchain
|
||||
# hash. Never interpolate github.event, github.ref, github.sha, or inputs.
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
with:
|
||||
cache-bin: false
|
||||
prefix-key: v1-${{ runner.os }}-${{ runner.arch }}-stable
|
||||
- uses: taiki-e/install-action@e88e69ecdb9658bd172693dcdc2c84e7f0ab6a11 # nextest
|
||||
with:
|
||||
tool: nextest
|
||||
- name: Format check
|
||||
run: cargo fmt --all -- --check
|
||||
- name: Compile check
|
||||
run: cargo check --workspace --all-targets --locked
|
||||
- name: OHOS dependency graph
|
||||
run: ./scripts/release/check-ohos-deps.sh
|
||||
- name: Clippy
|
||||
run: |
|
||||
cargo clippy --workspace --all-targets --all-features --locked -- \
|
||||
-D warnings \
|
||||
-A clippy::uninlined_format_args \
|
||||
-A clippy::too_many_arguments \
|
||||
-A clippy::unnecessary_map_or \
|
||||
-A clippy::collapsible_if \
|
||||
-A clippy::assertions_on_constants
|
||||
- name: Workspace tests
|
||||
# Same test binaries as `cargo test`, run by cargo-nextest: one process
|
||||
# per test. This gate used libtest, where every test shares one process,
|
||||
# so a test that mutates process-global state leaks into its neighbours.
|
||||
# It failed on five such tests — deterministically, and on a different
|
||||
# set on different machines — while CI's nextest lanes were green on the
|
||||
# same source, and every one of them passes in isolation. Match the lane
|
||||
# that gates every merge; doctests are the next step, because nextest
|
||||
# does not run them.
|
||||
run: sh scripts/with-hermetic-test-home.sh cargo nextest run --workspace --all-features --locked --profile ci
|
||||
env:
|
||||
# Match the CI test lane: test threads get the same stack the product
|
||||
# gives itself (main.rs CODEWHALE_MAIN_STACK_BYTES). See the note in
|
||||
# ci.yml's "Run tests" step. Without it this gate runs the deep
|
||||
# engine/runtime futures on a stack that never ships.
|
||||
RUST_MIN_STACK: '16777216'
|
||||
- name: Workspace doctests
|
||||
run: sh scripts/with-hermetic-test-home.sh cargo test --workspace --all-features --locked --doc
|
||||
env:
|
||||
RUST_MIN_STACK: '16777216'
|
||||
- name: Protocol schema parity
|
||||
run: sh scripts/with-hermetic-test-home.sh cargo test -p codewhale-protocol --test parity_protocol --locked
|
||||
- name: State persistence parity
|
||||
run: sh scripts/with-hermetic-test-home.sh cargo test -p codewhale-state --test parity_state --locked
|
||||
- name: Lockfile drift guard
|
||||
run: git diff --exit-code -- Cargo.lock
|
||||
@@ -26,6 +26,10 @@ jobs:
|
||||
resolve:
|
||||
timeout-minutes: 10
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
# Read release-candidate.yml runs for the RC receipt check below.
|
||||
actions: read
|
||||
outputs:
|
||||
tag: ${{ steps.release.outputs.tag }}
|
||||
sha: ${{ steps.release.outputs.sha }}
|
||||
@@ -107,6 +111,16 @@ jobs:
|
||||
./scripts/release/check-versions.sh --require-dated-release
|
||||
- name: Require release source on main
|
||||
run: ./scripts/release/ensure-release-on-main.sh "${{ steps.release.outputs.sha }}"
|
||||
- name: Require a green release-candidate receipt for this exact SHA
|
||||
# Fail fast, before the long parity and artifact builds: the tag must
|
||||
# point at a commit a release-candidate run already validated,
|
||||
# Parity included. A missing receipt means run the RC on this SHA,
|
||||
# never move the tag (v0.10.0 was re-pointed after the RC skipped
|
||||
# parity).
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
SHA: ${{ steps.release.outputs.sha }}
|
||||
run: ./scripts/release/require-rc-receipt.sh "${GITHUB_REPOSITORY}" "${SHA}"
|
||||
- name: Refuse an existing public asset set
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -114,90 +128,9 @@ jobs:
|
||||
run: node scripts/release/ensure-release-assets-absent.js "${GITHUB_REPOSITORY}" "${TAG}"
|
||||
|
||||
parity:
|
||||
timeout-minutes: 45
|
||||
name: Parity
|
||||
needs: resolve
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# resolve already proved GITHUB_SHA equals the tag commit. Do not
|
||||
# interpolate needs.resolve.outputs.sha into checkout or cache keys —
|
||||
# CodeQL treats a *sha* ref as an untrusted checkout on workflow_dispatch
|
||||
# (default-branch cache write).
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
- uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master 2026-07-18
|
||||
with:
|
||||
toolchain: stable
|
||||
components: clippy, rustfmt
|
||||
- uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
id: sccache
|
||||
continue-on-error: true
|
||||
- name: Enable sccache
|
||||
if: steps.sccache.outcome == 'success'
|
||||
shell: bash
|
||||
run: |
|
||||
{
|
||||
echo "SCCACHE_GHA_ENABLED=true"
|
||||
echo "RUSTC_WRAPPER=sccache"
|
||||
echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1"
|
||||
} >> "${GITHUB_ENV}"
|
||||
- name: Install Linux system dependencies
|
||||
run: |
|
||||
for i in 1 2 3 4 5; do
|
||||
sudo apt-get update && break
|
||||
echo "apt-get update failed (attempt $i); retrying in 15s"
|
||||
sleep 15
|
||||
done
|
||||
sudo apt-get install -y libdbus-1-dev pkg-config
|
||||
# Restore after the trusted lockfile is on disk. Key is OS + arch +
|
||||
# explicit stable toolchain + rust-cache's Cargo.lock / rust-toolchain
|
||||
# hash. Never interpolate github.event, github.ref, github.sha, or inputs.
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
with:
|
||||
cache-bin: false
|
||||
prefix-key: v1-${{ runner.os }}-${{ runner.arch }}-stable
|
||||
- uses: taiki-e/install-action@e88e69ecdb9658bd172693dcdc2c84e7f0ab6a11 # nextest
|
||||
with:
|
||||
tool: nextest
|
||||
- name: Format check
|
||||
run: cargo fmt --all -- --check
|
||||
- name: Compile check
|
||||
run: cargo check --workspace --all-targets --locked
|
||||
- name: OHOS dependency graph
|
||||
run: ./scripts/release/check-ohos-deps.sh
|
||||
- name: Clippy
|
||||
run: |
|
||||
cargo clippy --workspace --all-targets --all-features --locked -- \
|
||||
-D warnings \
|
||||
-A clippy::uninlined_format_args \
|
||||
-A clippy::too_many_arguments \
|
||||
-A clippy::unnecessary_map_or \
|
||||
-A clippy::collapsible_if \
|
||||
-A clippy::assertions_on_constants
|
||||
- name: Workspace tests
|
||||
# Same test binaries as `cargo test`, run by cargo-nextest: one process
|
||||
# per test. This gate used libtest, where every test shares one process,
|
||||
# so a test that mutates process-global state leaks into its neighbours.
|
||||
# It failed on five such tests — deterministically, and on a different
|
||||
# set on different machines — while CI's nextest lanes were green on the
|
||||
# same source, and every one of them passes in isolation. Match the lane
|
||||
# that gates every merge; doctests are the next step, because nextest
|
||||
# does not run them.
|
||||
run: sh scripts/with-hermetic-test-home.sh cargo nextest run --workspace --all-features --locked --profile ci
|
||||
env:
|
||||
# Match the CI test lane: test threads get the same stack the product
|
||||
# gives itself (main.rs CODEWHALE_MAIN_STACK_BYTES). See the note in
|
||||
# ci.yml's "Run tests" step. Without it this gate runs the deep
|
||||
# engine/runtime futures on a stack that never ships.
|
||||
RUST_MIN_STACK: '16777216'
|
||||
- name: Workspace doctests
|
||||
run: sh scripts/with-hermetic-test-home.sh cargo test --workspace --all-features --locked --doc
|
||||
env:
|
||||
RUST_MIN_STACK: '16777216'
|
||||
- name: Protocol schema parity
|
||||
run: sh scripts/with-hermetic-test-home.sh cargo test -p codewhale-protocol --test parity_protocol --locked
|
||||
- name: State persistence parity
|
||||
run: sh scripts/with-hermetic-test-home.sh cargo test -p codewhale-state --test parity_state --locked
|
||||
- name: Lockfile drift guard
|
||||
run: git diff --exit-code -- Cargo.lock
|
||||
uses: ./.github/workflows/release-parity.yml
|
||||
|
||||
artifacts:
|
||||
needs: [parity, resolve]
|
||||
|
||||
@@ -161,7 +161,13 @@ gates. A mismatch fails before those gates start; it never silently tests a
|
||||
different head.
|
||||
|
||||
`release-candidate.yml` also fails unless the selected ref resolves to the
|
||||
exact requested SHA. It invokes the same reusable artifact workflow as the
|
||||
exact requested SHA. It runs the same parity gate as the public release
|
||||
(`release-parity.yml`: fmt, check, clippy, workspace nextest, doctests,
|
||||
protocol and state parity), and `release.yml` refuses to start unless a green
|
||||
release-candidate run with a green Parity job exists for the exact tag SHA
|
||||
(`scripts/release/require-rc-receipt.sh`). Tag the SHA the RC validated; if
|
||||
the receipt check fails, run the RC on that SHA rather than moving the tag.
|
||||
It invokes the same reusable artifact workflow as the
|
||||
public release, building all seven targets (including Android arm64 and native
|
||||
Windows arm64), staging `codewhale` and `codew` (single binary), building the
|
||||
NSIS installer and nine platform archives, and validating the authoritative
|
||||
|
||||
Executable
+55
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
# Refuse a release unless a green release-candidate run validated the exact
|
||||
# commit, including its Parity job.
|
||||
#
|
||||
# Usage: require-rc-receipt.sh <owner/repo> <40-char sha>
|
||||
#
|
||||
# The receipt is a completed, successful `release-candidate.yml` run for
|
||||
# <sha>, dispatched by hand, whose Parity job (the shared
|
||||
# release-parity.yml gate) concluded success. A green run whose Parity job
|
||||
# was skipped is not a receipt. Requires `gh` authenticated with actions:read.
|
||||
#
|
||||
# RC_RECEIPT_GH overrides the gh executable (tests only).
|
||||
set -euo pipefail
|
||||
|
||||
repo="${1:-}"
|
||||
sha="${2:-}"
|
||||
gh_bin="${RC_RECEIPT_GH:-gh}"
|
||||
|
||||
if [[ -z "${repo}" || -z "${sha}" ]]; then
|
||||
echo "usage: $0 <owner/repo> <sha>" >&2
|
||||
exit 2
|
||||
fi
|
||||
if ! [[ "${repo}" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]]; then
|
||||
echo "::error::Repository '${repo}' must be owner/name." >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ "${#sha}" -ne 40 || "${sha}" =~ [^0-9a-f] ]]; then
|
||||
echo "::error::Release SHA must be a full 40-character lowercase commit SHA, got '${sha}'." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# sha is validated hex, so it is safe to place inside the jq program.
|
||||
runs="$("${gh_bin}" api \
|
||||
"repos/${repo}/actions/workflows/release-candidate.yml/runs?head_sha=${sha}&status=success&per_page=100" \
|
||||
--jq ".workflow_runs[] | select(.head_sha == \"${sha}\" and .conclusion == \"success\" and .event == \"workflow_dispatch\") | [.id, .html_url] | @tsv")"
|
||||
|
||||
while IFS=$'\t' read -r run_id run_url; do
|
||||
[[ -n "${run_id}" ]] || continue
|
||||
if ! [[ "${run_id}" =~ ^[0-9]+$ ]]; then
|
||||
echo "::error::Unexpected run id '${run_id}' from the Actions API." >&2
|
||||
exit 1
|
||||
fi
|
||||
# Jobs from a reusable workflow are named "<caller name> / <job name>".
|
||||
parity_green="$("${gh_bin}" api \
|
||||
"repos/${repo}/actions/runs/${run_id}/jobs?filter=latest&per_page=100" \
|
||||
--jq '[.jobs[] | select((.name == "Parity" or (.name | startswith("Parity / "))) and .conclusion == "success")] | length')"
|
||||
if [[ "${parity_green}" =~ ^[0-9]+$ && "${parity_green}" -gt 0 ]]; then
|
||||
echo "Release-candidate receipt for ${sha}: ${run_url} (Parity green)"
|
||||
exit 0
|
||||
fi
|
||||
echo "::warning::Release-candidate run ${run_url} is green but has no successful Parity job; it is not a receipt." >&2
|
||||
done <<< "${runs}"
|
||||
|
||||
echo "::error::No green release-candidate run with a passing Parity job for ${sha}. Validate that exact commit first: gh workflow run release-candidate.yml --ref main -f expected_sha=${sha}. Never move a tag to a different SHA to get past this." >&2
|
||||
exit 1
|
||||
Executable
+86
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
script="${repo_root}/scripts/release/require-rc-receipt.sh"
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "${tmp_dir}"' EXIT
|
||||
|
||||
sha="0123456789abcdef0123456789abcdef01234567"
|
||||
|
||||
# Fake gh: answers the two API calls from fixture files and applies the
|
||||
# caller's --jq filter with the real jq, so the filters themselves are tested.
|
||||
fake_gh="${tmp_dir}/gh"
|
||||
cat > "${fake_gh}" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
[[ "$1" == "api" ]] || { echo "unexpected: $*" >&2; exit 9; }
|
||||
path="$2"
|
||||
filter="$4"
|
||||
case "${path}" in
|
||||
*/workflows/release-candidate.yml/runs\?*) fixture="${FIXTURE_DIR}/runs.json" ;;
|
||||
*/actions/runs/*/jobs\?*)
|
||||
run_id="${path#*/actions/runs/}"
|
||||
run_id="${run_id%%/*}"
|
||||
fixture="${FIXTURE_DIR}/jobs-${run_id}.json"
|
||||
;;
|
||||
*) echo "unexpected path: ${path}" >&2; exit 9 ;;
|
||||
esac
|
||||
jq -r "${filter}" "${fixture}"
|
||||
EOF
|
||||
chmod +x "${fake_gh}"
|
||||
export RC_RECEIPT_GH="${fake_gh}"
|
||||
export FIXTURE_DIR="${tmp_dir}"
|
||||
|
||||
expect_pass() {
|
||||
local label="$1"
|
||||
if ! "${script}" owner/repo "${sha}" >"${tmp_dir}/out" 2>&1; then
|
||||
echo "FAIL (${label}): expected a receipt" >&2
|
||||
cat "${tmp_dir}/out" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
expect_fail() {
|
||||
local label="$1"
|
||||
if "${script}" owner/repo "${2:-${sha}}" >"${tmp_dir}/out" 2>&1; then
|
||||
echo "FAIL (${label}): expected refusal" >&2
|
||||
cat "${tmp_dir}/out" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 1. No RC run at all: refuse.
|
||||
echo '{"workflow_runs":[]}' > "${tmp_dir}/runs.json"
|
||||
expect_fail "no runs"
|
||||
grep -q "No green release-candidate run" "${tmp_dir}/out"
|
||||
|
||||
# 2. Green RC run whose Parity job was skipped: refuse.
|
||||
cat > "${tmp_dir}/runs.json" <<EOF
|
||||
{"workflow_runs":[{"id":11,"head_sha":"${sha}","conclusion":"success","event":"workflow_dispatch","html_url":"https://example.invalid/11"}]}
|
||||
EOF
|
||||
echo '{"jobs":[{"name":"Parity / Workspace parity","conclusion":"skipped"},{"name":"Verify exact candidate web surface","conclusion":"success"}]}' > "${tmp_dir}/jobs-11.json"
|
||||
expect_fail "parity skipped"
|
||||
|
||||
# 3. A run for a different SHA never counts, even if the API returned it.
|
||||
cat > "${tmp_dir}/runs.json" <<'EOF'
|
||||
{"workflow_runs":[{"id":12,"head_sha":"ffffffffffffffffffffffffffffffffffffffff","conclusion":"success","event":"workflow_dispatch","html_url":"https://example.invalid/12"}]}
|
||||
EOF
|
||||
echo '{"jobs":[{"name":"Parity / Workspace parity","conclusion":"success"}]}' > "${tmp_dir}/jobs-12.json"
|
||||
expect_fail "other sha"
|
||||
|
||||
# 4. Green RC run with green Parity on the exact SHA: receipt.
|
||||
cat > "${tmp_dir}/runs.json" <<EOF
|
||||
{"workflow_runs":[
|
||||
{"id":11,"head_sha":"${sha}","conclusion":"success","event":"workflow_dispatch","html_url":"https://example.invalid/11"},
|
||||
{"id":13,"head_sha":"${sha}","conclusion":"success","event":"workflow_dispatch","html_url":"https://example.invalid/13"}
|
||||
]}
|
||||
EOF
|
||||
echo '{"jobs":[{"name":"Parity / Workspace parity","conclusion":"success"}]}' > "${tmp_dir}/jobs-13.json"
|
||||
expect_pass "green parity"
|
||||
grep -q "https://example.invalid/13 (Parity green)" "${tmp_dir}/out"
|
||||
|
||||
# 5. Malformed SHA is rejected before any API call.
|
||||
expect_fail "short sha" "abc123"
|
||||
expect_fail "uppercase sha" "0123456789ABCDEF0123456789ABCDEF01234567"
|
||||
|
||||
echo "require-rc-receipt tests passed"
|
||||
Reference in new issue
Block a user