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 on
3e8bf29946, Release 35728585975 then failed parity on the same SHA, and
v0.10.0 was re-pointed to 1be1a703b, 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:
CodeWhale BotandClaude Opus 5.5 committed 2026-09-22 16:21:24 -07:00
1 parent 479db053b0
commit feb55b3280
8 files changed
+310 -91

No files matched your search

+29 -7
View File
@@ -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.",
+1
View File
@@ -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
+9
View File
@@ -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' }}
+107
View File
@@ -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
+16 -83
View File
@@ -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]
+7 -1
View File
@@ -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
+55
View File
@@ -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
+86
View File
@@ -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"