diff --git a/.github/workflows/release-gate.yml b/.github/workflows/release-gate.yml new file mode 100644 index 0000000000..b8bbff58c4 --- /dev/null +++ b/.github/workflows/release-gate.yml @@ -0,0 +1,161 @@ +name: release-gate + +# Required status check on release PRs (development -> master). Verifies, before a release can +# merge, that what master will become is already approved: the version moves forward, the tree's +# hash is in the signed document central serves, and the old-fluxbench fallback list carries it. +# +# This check is the gate the human sees; release-tag.yml re-verifies the merged tree before +# cutting the tag and GitHub Release. Enforcement comes from branch protection listing this check +# as required -- the workflow itself has no power to block a merge. +# +# Deliberately strict: central not serving a validly signed document is a red run, not a skip. A +# gate that passes when its source is missing is not a gate. +# +# Read-only: no secrets, no pushes. +# +# Tests: tests/ci/release-workflows.sh -- runs offline, no secrets. It extracts steps from this +# file verbatim, so renaming one breaks it loudly rather than silently skipping it. + +on: + pull_request: + branches: [master] + +permissions: + contents: read + +concurrency: + group: release-gate-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + gate: + runs-on: ubuntu-22.04 + steps: + # The default pull_request checkout is the merge preview -- the tree master will actually + # hold after the merge -- which is the thing worth judging, not the PR head alone. + - uses: actions/checkout@v7 + + - name: Version moves forward + run: | + git fetch --quiet --depth 1 origin master + BASE=$(git show origin/master:package.json | jq -r .version) + CANDIDATE=$(jq -r .version package.json) + echo "master: ${BASE} candidate: ${CANDIDATE}" + # Strictly newer, compared numerically per part. String inequality would call 8.17.10 + # older than 8.17.9. + # + # The shape is checked before anything is compared. Splitting on dots and comparing + # whatever falls out lets a version the parser cannot read be judged by whichever part + # happens to differ: "8.18" has no third part and passes, "8.18.0-rc1" passes on its + # minor while "8.17.2-rc1" is refused on a NaN. Refuse what cannot be read instead. + node -e ' + const parse = (label, value) => { + const parts = /^(\d+)\.(\d+)\.(\d+)$/.exec(value); + if (!parts) { + throw new Error(`the ${label} version is not three numeric parts: ${value}`); + } + return parts.slice(1).map(Number); + }; + const base = parse("master", process.argv[1]); + const head = parse("candidate", process.argv[2]); + const delta = head[0] - base[0] || head[1] - base[1] || head[2] - base[2]; + if (!(delta > 0)) { + throw new Error("the candidate version does not move past master -- bump package.json in the release PR"); + } + ' "$BASE" "$CANDIDATE" + + # The tree judged below is the merge preview, which is a ref nowhere. Its hash is in the + # signed document only because some branch tip already holds the same ZelBack subtree -- + # normally this PR's head, because master carries no code of its own: every commit landing + # directly on master has been version metadata or operational data under helpers/, outside + # the hashed tree. + # + # When master DOES carry ZelBack changes the head lacks, the merged tree exists on no branch, + # no signing run can ever cover it, and the check below fails telling the reader to wait for + # a signing run that will never come. Refuse here instead, and name the thing that fixes it. + # + # Deliberately compares the ZelBack subtree, not the whole tree: master diverging under + # helpers/ is routine and cannot change the hash. + - name: The merged ZelBack is a tree the signer can have seen + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -eo pipefail + git fetch --quiet --depth 1 origin "refs/pull/${PR_NUMBER}/head" + PREVIEW=$(git rev-parse 'HEAD:ZelBack') + HEAD_TREE=$(git rev-parse 'FETCH_HEAD:ZelBack') + if [ "$PREVIEW" != "$HEAD_TREE" ]; then + echo "the merge preview's ZelBack is ${PREVIEW}, this PR head's is ${HEAD_TREE}." + echo "master carries ZelBack changes that are not on ${GITHUB_HEAD_REF}, so the merged" + echo "tree exists on no branch and no signing run can ever cover it." + echo "fix: merge master into ${GITHUB_HEAD_REF} and re-run -- its tip will then carry the" + echo "merged tree and the signer will pick it up. Waiting will not help." + exit 1 + fi + echo "the merged ZelBack is this PR head's ZelBack (${PREVIEW})" + + - name: Tree hash is in the signed document central serves + run: | + set -eo pipefail + NEW_HASH=$(find ./ZelBack -type f -exec md5sum {} + | awk '{print $1}' | LC_ALL=C sort | md5sum | awk '{printf $1}') + # d41d8... is the md5 of an empty stream, produced whenever nothing was hashed: ZelBack + # absent (pipefail catches that) or present holding no regular files (find exits 0 and + # emits nothing, which pipefail cannot see). The signer refuses to list that value, so + # the membership check below would fail anyway -- but it would fail as "not in the signed + # document", sending the reader to the signing chain instead of the missing directory. + if [ "$NEW_HASH" = d41d8cd98f00b204e9800998ecf8427e ]; then + echo 'nothing was hashed -- this checkout has no ZelBack files' + exit 1 + fi + echo "tree hash: ${NEW_HASH}" + # exported for the node script below; GITHUB_ENV only reaches subsequent steps + export NEW_HASH + echo "NEW_HASH=${NEW_HASH}" >> "$GITHUB_ENV" + + curl -sS -m 30 -o hashlist-signed.json https://hashes.runonflux.io/hashlist + + # Verify the signature the way a consumer does, against the published public keys + # (RunOnFlux/fluxhashes SIGNING.md), so a stale deploy answering every path with the + # unsigned array -- or anything else that is not the signed document -- is a red run. + # + # Single-quoted, with the hash read from the environment: inside a double-quoted shell + # string the ${...} below would be the shell's, not node's. + node -e ' + const crypto = require("crypto"); + const fs = require("fs"); + const PINNED_PUBLIC_KEYS = [ + "14837066068b258bfbd0749702056f7065361af44aed48761834744391cbbaaa", + "fee7b0ccf2323954af68a249eaa61f957239eb222329e08a5b6a50ced649bae8", + ]; + const SPKI_ED25519_PREFIX = Buffer.from("302a300506032b6570032100", "hex"); + const document = JSON.parse(fs.readFileSync("hashlist-signed.json", "utf8")); + const payload = Buffer.from(document.payload_b64, "base64"); + const signature = Buffer.from(document.sig_b64, "base64"); + const verified = PINNED_PUBLIC_KEYS.some((hex) => crypto.verify( + null, + payload, + crypto.createPublicKey({ + key: Buffer.concat([SPKI_ED25519_PREFIX, Buffer.from(hex, "hex")]), + format: "der", + type: "spki", + }), + signature, + )); + if (!verified) { + throw new Error("central is not serving a validly signed hash list"); + } + const { seq, hashes } = JSON.parse(payload.toString("utf8")); + if (!hashes.includes(process.env.NEW_HASH)) { + throw new Error(`the tree hash is not in the signed document (seq ${seq}) -- wait for the signing run to cover the release candidate`); + } + console.log(`signed document seq ${seq} carries the tree hash`); + ' + + # Old fluxbench falls back to helpers/hashes.json via GitHub raw when central is unreachable. + # The entry rides the release PR instead of a commit straight to master. This step retires + # with old fluxbench. + - name: Fallback list carries the hash + run: | + jq -e --arg h "$NEW_HASH" 'index($h) != null' helpers/hashes.json > /dev/null \ + || { echo "helpers/hashes.json does not contain ${NEW_HASH} -- add it in the release PR"; exit 1; } + echo "helpers/hashes.json carries the tree hash" diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml new file mode 100644 index 0000000000..70930931d2 --- /dev/null +++ b/.github/workflows/release-tag.yml @@ -0,0 +1,207 @@ +name: release-tag + +# Runs when a release lands on master: re-verifies the merged tree against the signed document, +# takes an advisory look at live-node distribution, then cuts the tag and the GitHub Release and +# records the tag on the provenance row in fluxhashes. +# +# The pre-merge gate (release-gate.yml) judged the merge preview; this judges what master actually +# holds, which is the authoritative check -- it covers the only divergence branch protection +# cannot. The tag and Release are outputs of a verified release: nothing in the fleet follows +# them, the fleet follows master. +# +# A red run here means the release is stuck loudly with no tag -- the fleet is already on the +# verified merge tree, so nothing is half-made. +# +# Tests: tests/ci/release-workflows.sh -- runs offline, no secrets. It extracts steps from this file +# verbatim, so renaming one breaks it loudly rather than silently skipping it. Run it after any +# change here. + +on: + push: + branches: [master] + +permissions: + contents: write + +concurrency: + group: release-tag + cancel-in-progress: false + +jobs: + tag: + # Only the real repository. A fork pushing to its own master has neither our credentials + # nor a tree on the signed list, so every run there is noise. Matches nodejs.yml. + if: github.repository == 'RunOnFlux/flux' + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 2 + + # A push that does not change the version is not a release. HEAD^1 is master's previous tip + # whether the push was a merge or a plain commit. + - name: Did the version change? + id: version + run: | + CURRENT=$(jq -r .version package.json) + PREVIOUS=$(git show 'HEAD^1:package.json' 2>/dev/null | jq -r .version || echo "") + echo "previous: ${PREVIOUS:-none} current: ${CURRENT}" + if [ "$CURRENT" = "$PREVIOUS" ]; then + echo "version unchanged, not a release" + echo "release=false" >> "$GITHUB_OUTPUT" + else + echo "release=true" >> "$GITHUB_OUTPUT" + echo "version=${CURRENT}" >> "$GITHUB_OUTPUT" + fi + + # Idempotent on a re-run of THIS commit, loud on anything else. A tag carrying the release + # version but pointing somewhere else is not a re-run -- it is a hand-cut tag, or a master + # that was rolled back behind one. Treating that as "already done" would skip the merged-tree + # verification below, which is the only check covering what master actually holds, and report + # the release green with no tag and nothing verified. + - name: Tag already cut? + id: existing + if: steps.version.outputs.release == 'true' + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + set -eo pipefail + # An annotated tag advertises a peeled ^{} ref holding the commit; a lightweight tag has + # no ^{} line and points at the commit directly. Everything this workflow cuts is + # annotated; every tag cut by hand is lightweight, and at the time of writing all 312 + # tags in this repository are lightweight -- so reading only one form finds neither + # reliably. ls-remote exits 0 with no output when nothing matches, so an empty answer + # means "no such tag" while a genuine transport failure dies here on pipefail. + PEELED=$(git ls-remote --tags origin "refs/tags/v${VERSION}^{}" | cut -f1) + DIRECT=$(git ls-remote --tags origin "refs/tags/v${VERSION}" | cut -f1) + EXISTING=${PEELED:-$DIRECT} + if [ -z "$EXISTING" ]; then + echo "done=false" >> "$GITHUB_OUTPUT" + elif [ "$EXISTING" = "$GITHUB_SHA" ]; then + echo "tag v${VERSION} already points at this commit -- re-run, nothing to do" + echo "done=true" >> "$GITHUB_OUTPUT" + else + echo "tag v${VERSION} already exists at ${EXISTING}, which is not this commit (${GITHUB_SHA})" + echo "resolve the tag before re-running: this release has not been verified against the signed document" + exit 1 + fi + + # The merge tree normally equals the PR head tree the gate already saw signed, so this + # passes on the first poll. The loop covers the divergent case: the on-push publish and the + # signing run pick the new tree up within minutes, and this waits for them rather than + # failing a release over a race. + - name: Merged tree is in the signed document central serves + if: steps.version.outputs.release == 'true' && steps.existing.outputs.done == 'false' + run: | + set -eo pipefail + NEW_HASH=$(find ./ZelBack -type f -exec md5sum {} + | awk '{print $1}' | LC_ALL=C sort | md5sum | awk '{printf $1}') + # d41d8... is the md5 of an empty stream, produced whenever nothing was hashed: ZelBack + # absent (pipefail catches that) or present holding no regular files (find exits 0 and + # emits nothing, which pipefail cannot see). The signer refuses to list that value, so + # the membership check below would fail anyway -- but it would fail as "not in the signed + # document", sending the reader to the signing chain instead of the missing directory. + if [ "$NEW_HASH" = d41d8cd98f00b204e9800998ecf8427e ]; then + echo 'nothing was hashed -- this checkout has no ZelBack files' + exit 1 + fi + echo "merged tree hash: ${NEW_HASH}" + # exported for the node script below; GITHUB_ENV only reaches subsequent steps + export NEW_HASH + echo "NEW_HASH=${NEW_HASH}" >> "$GITHUB_ENV" + + for attempt in $(seq 1 30); do + curl -sS -m 30 -o hashlist-signed.json https://hashes.runonflux.io/hashlist || true + if node -e ' + const crypto = require("crypto"); + const fs = require("fs"); + const PINNED_PUBLIC_KEYS = [ + "14837066068b258bfbd0749702056f7065361af44aed48761834744391cbbaaa", + "fee7b0ccf2323954af68a249eaa61f957239eb222329e08a5b6a50ced649bae8", + ]; + const SPKI_ED25519_PREFIX = Buffer.from("302a300506032b6570032100", "hex"); + const document = JSON.parse(fs.readFileSync("hashlist-signed.json", "utf8")); + const payload = Buffer.from(document.payload_b64, "base64"); + const signature = Buffer.from(document.sig_b64, "base64"); + const verified = PINNED_PUBLIC_KEYS.some((hex) => crypto.verify( + null, + payload, + crypto.createPublicKey({ + key: Buffer.concat([SPKI_ED25519_PREFIX, Buffer.from(hex, "hex")]), + format: "der", + type: "spki", + }), + signature, + )); + if (!verified) throw new Error("not a validly signed document"); + const { seq, hashes } = JSON.parse(payload.toString("utf8")); + if (!hashes.includes(process.env.NEW_HASH)) throw new Error(`not in seq ${seq}`); + console.log(`signed document seq ${seq} carries the merged tree hash`); + ' 2> poll-error.txt; then + exit 0 + fi + # the message line, not the 15-line trace, times thirty attempts. Node prints the + # file marker first, so the first line is never the message; head -1 is the fallback + # for a failure that is not a Node error at all, so the log cannot go silent. + echo "attempt ${attempt}: $(grep -m1 -E '^[A-Za-z]*Error: ' poll-error.txt || head -1 poll-error.txt) -- retrying in 30s" + sleep 30 + done + + echo "the signed document never covered the merged tree; no tag is cut" + exit 1 + + # Advisory until the fleet serves /flux/hashlist: results are logged, never fatal. Once the + # FluxOS side is deployed, enforcement is turning the failure at the end into exit 1. + - name: Live-node sample (advisory) + if: steps.version.outputs.release == 'true' && steps.existing.outputs.done == 'false' + run: | + curl -sS -m 60 -o nodelist.json https://api.runonflux.io/daemon/viewdeterministiczelnodelist || { echo "ADVISORY: node list unavailable"; exit 0; } + jq -r '.data[].ip | select(. != null and . != "")' nodelist.json | shuf -n 10 > sample.txt || true + SERVING=0 + while read -r ADDR; do + HOST=${ADDR%:*}; PORT=${ADDR##*:}; [ "$HOST" = "$PORT" ] && PORT=16127 + BODY=$(curl -sS -m 5 "http://${HOST}:${PORT}/flux/hashlist" 2>/dev/null || true) + PAYLOAD=$(printf '%s' "$BODY" | jq -r 'try (.payload_b64 // .data.payload_b64) // empty' 2>/dev/null || true) + if [ -n "$PAYLOAD" ] && printf '%s' "$PAYLOAD" | base64 -d 2>/dev/null | grep -q "$NEW_HASH"; then + SERVING=$((SERVING + 1)) + fi + done < sample.txt + echo "ADVISORY: $(wc -l < sample.txt | tr -d ' ') nodes sampled, ${SERVING} serving a document with the merged tree hash" + + - name: Cut the tag and the GitHub Release + if: steps.version.outputs.release == 'true' && steps.existing.outputs.done == 'false' + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.version.outputs.version }} + run: | + git config user.email runonfluxbot@gmail.com + git config user.name runonfluxbot + git tag -a "v${VERSION}" -m "FluxOS v${VERSION}" + git push --quiet origin "refs/tags/v${VERSION}" + gh release create "v${VERSION}" --title "FluxOS v${VERSION}" --generate-notes --verify-tag + echo "released v${VERSION}" + + # The tag above was pushed with GITHUB_TOKEN, which triggers no workflows -- so the signer + # would only pick it up on its next run's ls-remote delta. This dispatch makes the + # annotation immediate; the signer resolves the tag against its own view of this repository + # and annotates the provenance row itself. Nothing here writes to fluxhashes. + - name: Mint the dispatch token + if: steps.version.outputs.release == 'true' && steps.existing.outputs.done == 'false' + id: dispatch-token + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ secrets.FLUXHASHES_APP_ID }} + private-key: ${{ secrets.FLUXHASHES_APP_KEY }} + owner: RunOnFlux + repositories: fluxhashes + - name: Request the tag annotation + if: steps.version.outputs.release == 'true' && steps.existing.outputs.done == 'false' + env: + GH_TOKEN: ${{ steps.dispatch-token.outputs.token }} + VERSION: ${{ steps.version.outputs.version }} + run: | + gh api -X POST repos/RunOnFlux/fluxhashes/actions/workflows/sign-hashlist.yml/dispatches \ + -f ref=master \ + -f "inputs[commit]=${GITHUB_SHA}" \ + -f "inputs[ref]=v${VERSION}" \ + -f "inputs[ref_type]=tag" \ + -f "inputs[claimed_hash]=${NEW_HASH}" diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000000..668bd1d23b --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,112 @@ +# Releasing FluxOS + +A release is a reviewed pull request from `development` into `master`. That has not changed, and +neither has the review or the merge button. + +What has changed is that a release now has to prove, before it merges, that its code is on the +signed list of approved FluxOS trees — and that the tag and the GitHub Release are cut for you +afterwards instead of by hand. + +## Why there is a list at all + +Every node checks its own code against a published list of approved fingerprints. A fingerprint is +an md5 over the `ZelBack` directory and nothing else — not `helpers/`, not `package.json`, not the +workflows. + +A robot in [RunOnFlux/fluxhashes](https://github.com/RunOnFlux/fluxhashes) builds that list. Every +time a branch or tag in this repository moves, it fingerprints the tree at that branch's tip, adds +it to the list, and signs the result. It publishes to `hashes.runonflux.io`. + +That is the whole reason releasing has a gate: the fleet follows `master`, so nothing should reach +`master` whose fingerprint is not already published and signed. + +## Cutting a release + +**1. Bump the version on `development`.** `package.json`, as before. + +**2. Add the fingerprint to `helpers/hashes.json` on `development`.** This is the step that used to +happen *after* the merge, as a commit straight to `master`. It now rides the release PR. + +Compute it from a clean checkout of `development`: + +```sh +find ./ZelBack -type f -exec md5sum {} + | awk '{print $1}' | LC_ALL=C sort | md5sum | awk '{printf $1}' +``` + +Adding it to `helpers/hashes.json` does not change the fingerprint, because the fingerprint only +covers `ZelBack`. So there is no chicken-and-egg — compute it, commit it, and it is still correct. + +(`helpers/hashes.json` is the fallback list the older benchmark reads when it cannot reach central. +This step retires when that version of the benchmark does.) + +**3. Open the PR, `development` → `master`, and get it reviewed.** As before. + +**4. Merge it.** As before. + +## What the checks do + +Two workflows. Neither of them can merge anything or push anything; the gate is read-only. + +**`release-gate`** runs on the pull request and judges the *merge preview* — what `master` will +actually hold once the PR merges, not the PR branch alone. It checks four things: + +| check | what it means | +|---|---| +| the version moves forward | `package.json` is strictly newer than `master`'s, compared numerically | +| the merged `ZelBack` is a tree the signer can have seen | `master` holds no `ZelBack` changes that `development` is missing | +| the fingerprint is in the signed document | central is serving a validly signed list, and the merged tree's fingerprint is in it | +| the fallback list carries it | `helpers/hashes.json` has the entry from step 2 | + +**`release-tag`** runs after the merge, on `master`. It re-verifies the merged tree against the +signed document — this is the authoritative check, because it judges what `master` actually holds — +then cuts the tag and the GitHub Release, and asks the robot to record the tag against the +fingerprint. + +A push to `master` that does not change the version is not a release, and `release-tag` does +nothing. + +## When something goes red + +**"the candidate version does not move past master"** — bump `package.json` in the release PR. + +**"master carries ZelBack changes that are not on development"** — someone committed code straight +to `master`. The merged tree then exists on no branch, so the robot has never fingerprinted it and +never will. Merge `master` into `development` and re-run. Waiting will not help. + +**"the tree hash is not in the signed document"** — the robot has not caught up with +`development`'s tip yet. It reconciles on every push and on a daily sweep, so this normally clears +within minutes. Re-run the check. + +**"central is not serving a validly signed hash list"** — `hashes.runonflux.io` is not answering +with a signed document. That is an infrastructure problem, not a problem with your release. The +gate is deliberately strict here: a gate that passes when its source is missing is not a gate. + +**"helpers/hashes.json does not contain …"** — step 2 was missed. + +**`release-tag` fails after the merge** — the release is on `master` and the fleet has it, but no +tag was cut. Nothing is half-made: `master` holds the tree the gate already verified. Read the run +to see which check failed before deciding whether to re-run it or cut the tag by hand. + +**`release-tag` says a tag already exists at another commit** — a tag with this version number was +cut by hand, or `master` was rolled back behind one. Resolve the tag, then re-run. The workflow +refuses rather than skipping, because skipping would mean the merged tree was never verified. + +## Things that are no longer done by hand + +- Adding the fingerprint to `helpers/hashes.json` after the merge. It rides the PR now. +- Creating the `vX.Y.Z` tag. +- Creating the GitHub Release. + +## Please do not commit to `master` + +Every commit that lands on `master` outside a release PR makes `master` and `development` diverge. +Commits that only touch `helpers/` or `package.json` are harmless to the gate, since the +fingerprint only covers `ZelBack` — but a commit that touches `ZelBack` produces a merged tree that +exists on no branch, which nothing can ever sign, and blocks the next release until `master` is +merged back into `development`. + +## Tests + +The steps in both workflows are covered by `tests/ci/release-workflows.sh`. It runs offline with no +secrets, extracts the steps from the workflow files verbatim rather than copying them, and +mutation-tests its own assertions. Run it after changing either workflow. diff --git a/tests/ci/release-workflows.sh b/tests/ci/release-workflows.sh new file mode 100755 index 0000000000..b070a9a0c4 --- /dev/null +++ b/tests/ci/release-workflows.sh @@ -0,0 +1,484 @@ +#!/usr/bin/env bash +# +# Tests for the release workflows: .github/workflows/release-gate.yml and release-tag.yml. +# +# Three steps are covered, each of which fails silently or misleadingly when it is wrong: +# +# release-tag "Tag already cut?" -- decides whether a release that has just landed on master +# still needs verifying and tagging. Wrong in the permissive direction, the job skips the +# merged-tree verification, cuts no tag, and reports green. +# +# release-gate "Version moves forward" -- a version the comparison cannot read must be refused, +# not judged by whichever part happens to differ. +# +# release-gate "The merged ZelBack is a tree the signer can have seen" -- refuses a release whose +# merged tree exists on no branch, because no signing run can ever cover it. Wrong, and a +# release stalls behind a message telling the reader to wait for something that never comes. +# +# Requires: git, python3 with pyyaml, node, jq. +# +# The step under test is EXTRACTED VERBATIM from the workflow, never retyped, so what runs here is +# what ships. It carries no ${{ }} (the version arrives through env:), so it runs as a plain +# script against a local bare repository standing in for origin. +# +# Two kinds of check: +# * behaviour -- the step's own decisions, eight cases +# * contract -- that the rest of the job still reads the output the way the step writes it, +# which is where a plausible-looking change does its damage +# +# Then every check is mutation-tested: five defects are reintroduced one at a time and the suite +# must go red for each. A suite that stays green under a mutation is not testing what it claims. +# +# Verified separately against real GitHub on 2026-09-01, because a local bare repo cannot prove it: +# annotated tags advertise a peeled refs/tags/^{} entry over https and the pattern form +# matches it (github.com/git/git, tag v2.39.2); lightweight tags advertise no such entry. At that +# date RunOnFlux/flux held 312 tags and not one was annotated -- which is why reading only the +# peeled form finds none of the tags cut by hand. +# +# No network, no secrets. Everything lives under a mktemp -d removed on exit. +# +# Usage: tests/ci/release-workflows.sh [path-to-repo] + +set -uo pipefail + +REPO=${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)} +TAG_WF="$REPO/.github/workflows/release-tag.yml" +GATE_WF="$REPO/.github/workflows/release-gate.yml" + +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +PASS=0 +FAIL=0 +FAILED_CASES=() + +# ---------------------------------------------------------------- extract the step, verbatim +extract_step() { # + python3 - "$@" <<'PY' +import sys, yaml +wf, job, name, out = sys.argv[1:5] +doc = yaml.safe_load(open(wf)) +steps = [s for s in doc['jobs'][job]['steps'] if s.get('name') == name] +assert len(steps) == 1, f"expected one {name!r} step in {job}, found {len(steps)}" +run = steps[0]['run'] +assert '${{' not in run, f"{name!r} interpolates ${{{{ }}}} into the shell -- it cannot be run verbatim" +open(out, 'w').write(run) +PY +} + +# ---------------------------------------------------------------- the world the step runs against +# A bare repo as origin, holding two commits, plus a clone whose origin points at it. +build_world() { + rm -rf "$WORK/origin.git" "$WORK/clone" + mkdir -p "$WORK/src" + ( + cd "$WORK/src" && rm -rf .git ./* + git init -q . && git config user.email t@t && git config user.name t + echo one > f && git add f && git commit -qm one + echo two > f && git add f && git commit -qm two + ) + git clone -q --bare "$WORK/src" "$WORK/origin.git" + git clone -q "$WORK/origin.git" "$WORK/clone" + HEAD_SHA=$(git -C "$WORK/clone" rev-parse HEAD) + OTHER_SHA=$(git -C "$WORK/clone" rev-parse 'HEAD^') +} + +tag_on_origin() { # + local name=$1 sha=$2 kind=$3 + if [ "$kind" = annotated ]; then + git -C "$WORK/src" tag -a "$name" -m "annotated $name" "$sha" -f + else + git -C "$WORK/src" tag "$name" "$sha" -f + fi + git -C "$WORK/src" push -q --force "$WORK/origin.git" "refs/tags/$name" +} + +# ---------------------------------------------------------------- reporting +ok() { PASS=$((PASS + 1)); printf ' ok %s\n' "$1"; } +bad() { FAIL=$((FAIL + 1)); FAILED_CASES+=("$1"); printf ' FAIL %s\n' "$1"; shift; printf ' %s\n' "$@"; } + +# ---------------------------------------------------------------- one behaviour case +# run_case