feat: the reusable release workflow — both doors, one implementation #39
9 changed files with 1295 additions and 0 deletions
94
.github/scripts/self-ref-check.sh
vendored
Normal file
94
.github/scripts/self-ref-check.sh
vendored
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
#!/usr/bin/env bash
|
||||
# The self-ref pin guard (issue #9; #1 D3). The reusable workflows carry a
|
||||
# literal `CEREMONY_SELF_REF: "X.Y.Z"` — the ref consumers' runs check this
|
||||
# repo out at — because a called workflow file arrives without its
|
||||
# repository, and checkout `ref:`s must be literals. A stale pin must fail
|
||||
# CI HERE, not a consumer's release. The rules, keyed on this repo's own
|
||||
# version state (they presume #11's dogfood tree; before it, no VERSION
|
||||
# exists and the rules cannot bind yet):
|
||||
#
|
||||
# * bare VERSION → pin == VERSION (the ceremony PR stamps the pin to the
|
||||
# version it releases — the third stamp, alongside VERSION and the
|
||||
# changelog).
|
||||
# * -dev VERSION → pin == the newest stamped `## X.Y.Z` heading in
|
||||
# CHANGELOG.md (the last release — reverse-chronological, so the first
|
||||
# bare-X.Y.Z heading from the top; whole-field match, so an rc heading
|
||||
# never satisfies it). No stamped heading yet (the pre-first-release
|
||||
# tree) → pin == VERSION with -dev stripped.
|
||||
#
|
||||
# The self-consumption bypass in release.yml means the pin is never
|
||||
# load-bearing for this repo's own releases — only for consumers'.
|
||||
#
|
||||
# Usage: self-ref-check.sh [tree-dir] (default: the repo root — the CI
|
||||
# step; tests point it at fixture trees)
|
||||
set -euo pipefail
|
||||
|
||||
# shellcheck source=lib/version.sh
|
||||
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/version.sh"
|
||||
|
||||
tree="${1:-.}"
|
||||
|
||||
fail() {
|
||||
printf '%s\n' "$@" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Every workflow carrying the pin must agree — labels.yml carries its own
|
||||
# copy of the same env (one pin governs machinery and doctrine alike).
|
||||
shopt -s nullglob
|
||||
pins=()
|
||||
carriers=()
|
||||
for wf in "$tree"/.github/workflows/*.yml; do
|
||||
pin="$(awk -F'"' '/^[[:space:]]*CEREMONY_SELF_REF:/ { print $2; exit }' "$wf")"
|
||||
[ -n "$pin" ] || continue
|
||||
pins+=("$pin")
|
||||
carriers+=("$wf")
|
||||
done
|
||||
|
||||
if [ "${#pins[@]}" -eq 0 ]; then
|
||||
fail "self-ref-check: no CEREMONY_SELF_REF found under $tree/.github/workflows — the guard has nothing to guard, which is itself a failure (the reusable workflows carry the pin)."
|
||||
fi
|
||||
|
||||
pin="${pins[0]}"
|
||||
for i in "${!pins[@]}"; do
|
||||
if [ "${pins[$i]}" != "$pin" ]; then
|
||||
for j in "${!pins[@]}"; do
|
||||
echo " ${carriers[$j]}: ${pins[$j]}" >&2
|
||||
done
|
||||
fail "self-ref-check: the pins disagree — every workflow must name the same ceremony release."
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ! -f "$tree/VERSION" ]; then
|
||||
# The pre-dogfood window: #11 adds VERSION and CHANGELOG.md, and this
|
||||
# branch dies with it (VERSION, once added, never leaves). Until then the
|
||||
# rules have no version state to key on.
|
||||
echo "NOTICE: no VERSION file — the pre-dogfood tree (#11 adds it). The pin rules key on the version state and cannot bind yet; pin is '$pin', unchecked."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ver="$(version_read file "$tree")"
|
||||
|
||||
if version_is_dev "$ver"; then
|
||||
want=""
|
||||
if [ -f "$tree/CHANGELOG.md" ]; then
|
||||
# mawk-compatible; whole-field match so 0.7.0-rc1 never satisfies the
|
||||
# bare shape (#1 constraint 7).
|
||||
want="$(awk '$1 == "##" && $2 ~ /^[0-9]+\.[0-9]+\.[0-9]+$/ { print $2; exit }' "$tree/CHANGELOG.md")"
|
||||
fi
|
||||
if [ -n "$want" ]; then
|
||||
reason="the newest stamped CHANGELOG.md heading (the last release)"
|
||||
else
|
||||
want="${ver%-dev}"
|
||||
reason="VERSION with -dev stripped (no release stamped yet)"
|
||||
fi
|
||||
else
|
||||
want="$ver"
|
||||
reason="VERSION on a bare tree (the ceremony PR stamps the pin to the version it releases)"
|
||||
fi
|
||||
|
||||
if [ "$pin" != "$want" ]; then
|
||||
fail "self-ref-check: CEREMONY_SELF_REF is '$pin' but must be '$want' — $reason. A stale pin fails CI here, not a consumer's release."
|
||||
fi
|
||||
|
||||
echo "self-ref-check: pin '$pin' agrees with the tree ($reason)."
|
||||
16
.github/workflows/ci.yml
vendored
16
.github/workflows/ci.yml
vendored
|
|
@ -27,6 +27,10 @@ jobs:
|
|||
sudo install actionlint /usr/local/bin/actionlint
|
||||
- name: Actionlint
|
||||
run: bash .github/scripts/actionlint-all.sh
|
||||
- name: Self-ref pin
|
||||
# The pin rules (issue #9; #1 D3): a stale CEREMONY_SELF_REF fails
|
||||
# CI here, not a consumer's release.
|
||||
run: bash .github/scripts/self-ref-check.sh
|
||||
- name: Tests
|
||||
env:
|
||||
# The npm-backed version_write case may skip locally when npm is
|
||||
|
|
@ -35,6 +39,18 @@ jobs:
|
|||
CEREMONY_REQUIRE_NPM: 1
|
||||
run: bash test/run.sh
|
||||
|
||||
# The release exercise (issue #9's scratch caller) on every PR, so the
|
||||
# parse proof and the merge door's step-replay are standing, reviewable
|
||||
# evidence — not a dispatch someone must remember to run. PR-ONLY, and
|
||||
# the gate is load-bearing: this CI also runs on push to main, and a
|
||||
# workflow_call from THAT context would hand release.yml a genuine
|
||||
# push+refs/heads/main event — the merge door's exact gate — opening a
|
||||
# live door from CI. A pull_request event can never satisfy either
|
||||
# door's `if:`.
|
||||
release-exercise:
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: ./.github/workflows/release-exercise.yml
|
||||
|
||||
# Exercises the composite actions the way a consumer does — action.yml
|
||||
# resolving, $GITHUB_ACTION_PATH, the relative lib sourcing — which the
|
||||
# test suite, driving the scripts directly, cannot prove (issue #5's
|
||||
|
|
|
|||
201
.github/workflows/release-exercise.yml
vendored
Normal file
201
.github/workflows/release-exercise.yml
vendored
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
name: release exercise
|
||||
# The scratch caller (issue #9's acceptance criterion): dry wiring only —
|
||||
# nothing is ever tagged, published, or bumped. Three jobs, three claims:
|
||||
#
|
||||
# * `call` — a workflow_call `uses:` validates and parses the called file
|
||||
# when the run starts, so a green run proves release.yml parses and its
|
||||
# input contract wires. Both jobs inside it are gated on the push event
|
||||
# (rig's form), so a non-push caller — dispatch here, pull_request via
|
||||
# ci.yml — skips them by design.
|
||||
# * `step-replay` — the merge door's early step sequence executed for
|
||||
# real (round 1's blocking catch: `call` proves the parse but runs
|
||||
# zero steps): the two-checkout dance including the `path:
|
||||
# .ceremony-src` checkout, both branches of the self-consumption
|
||||
# bypass, the CEREMONY_DIR / RELEASE_ASSETS_DIR wiring, then facts →
|
||||
# decide → notes through the real $GITHUB_OUTPUT step plumbing — all
|
||||
# against a constructed fixture tree with a stubbed gh, so a wrong
|
||||
# `path:`, an inverted bypass, or a CEREMONY_DIR pointing nowhere fails
|
||||
# HERE, not in a consumer's release. The steps are release.yml's own,
|
||||
# copied 1:1 where the context allows; where it cannot, the deviation
|
||||
# is commented at the step.
|
||||
# * `fixture-chain` — the same script chain offline, via the contract
|
||||
# test CI runs on every PR (test/release-chain.test.sh).
|
||||
#
|
||||
# Runs on workflow_dispatch, and on every PR via ci.yml's workflow_call
|
||||
# (PR-only there, on purpose — see ci.yml's gate comment). The live doors
|
||||
# remain the stated honest gap, closed by #11 (ceremony's own 0.1.0
|
||||
# release calls this exact workflow by local path) and the #13 pilot's
|
||||
# rehearsal.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
call:
|
||||
# Dry: version-source exercises the input contract; the doors stay shut
|
||||
# on a non-push event. The real caller stub — triggers, permissions,
|
||||
# the pinned ref — lives in release.yml's header and docs/CONSUMERS.md.
|
||||
uses: ./.github/workflows/release.yml
|
||||
with:
|
||||
version-source: file
|
||||
|
||||
step-replay:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
# release.yml keys its bypass on `github.repository ==
|
||||
# 'heavy-duty/ceremony'`; the matrix stands in for that condition so
|
||||
# BOTH branches run from this one repo — the dogfood repo can never
|
||||
# take the consumer branch for real, and vice versa.
|
||||
shape: [dogfood, consumer]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# release.yml's first checkout, verbatim: the pushed head and its
|
||||
# first parent.
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 2
|
||||
- uses: actions/checkout@v4
|
||||
# release.yml's second checkout — the consumer path's pinned
|
||||
# ceremony source, same `path:` wiring. One forced deviation: the
|
||||
# ref is github.sha, not CEREMONY_SELF_REF — the pinned tag cannot
|
||||
# exist before the first release (the exact deadlock the bypass
|
||||
# solves), and the pin's VALUE is already guarded by
|
||||
# .github/scripts/self-ref-check.sh in CI. What this step proves is
|
||||
# the wiring: the checkout lands in .ceremony-src and every later
|
||||
# step resolves libs through it.
|
||||
if: matrix.shape == 'consumer'
|
||||
with:
|
||||
repository: ${{ github.repository }}
|
||||
ref: ${{ github.sha }}
|
||||
path: .ceremony-src
|
||||
- name: wire CEREMONY_DIR and the assets dir
|
||||
env:
|
||||
SHAPE: ${{ matrix.shape }}
|
||||
# release.yml's wiring step with the matrix standing in for the
|
||||
# GITHUB_REPOSITORY test (comment on the matrix above).
|
||||
run: |
|
||||
if [ "$SHAPE" = "dogfood" ]; then
|
||||
echo "CEREMONY_DIR=$GITHUB_WORKSPACE" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "CEREMONY_DIR=$GITHUB_WORKSPACE/.ceremony-src" >> "$GITHUB_ENV"
|
||||
fi
|
||||
mkdir -p "$RUNNER_TEMP/release-assets"
|
||||
echo "RELEASE_ASSETS_DIR=$RUNNER_TEMP/release-assets" >> "$GITHUB_ENV"
|
||||
- name: construct the fixture consumer tree and the gh stub
|
||||
# The fixture release.yml's steps run against (below): a base at
|
||||
# 0.6.9-dev with an armed changelog, then the ceremony merge —
|
||||
# VERSION bumped bare, Unreleased stamped. Same shape as
|
||||
# test/release-chain.test.sh. The gh stub answers the one API fact
|
||||
# the ceremony path consults (the merged release-labeled PR) so
|
||||
# nothing here talks to GitHub.
|
||||
run: |
|
||||
mkdir -p "$RUNNER_TEMP/stub"
|
||||
cat > "$RUNNER_TEMP/stub/gh" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
if [ "$1" = api ]; then echo true; exit 0; fi
|
||||
echo "gh stub: unexpected call: gh $*" >&2
|
||||
exit 97
|
||||
EOF
|
||||
chmod +x "$RUNNER_TEMP/stub/gh"
|
||||
echo "$RUNNER_TEMP/stub" >> "$GITHUB_PATH"
|
||||
git init -q "$RUNNER_TEMP/fixture"
|
||||
cd "$RUNNER_TEMP/fixture"
|
||||
git config user.email fixture@example.invalid
|
||||
git config user.name fixture
|
||||
printf '0.6.9-dev\n' > VERSION
|
||||
cat > CHANGELOG.md <<'EOF'
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
- The entry this release ships.
|
||||
|
||||
## 0.6.8 — 2026-07-01
|
||||
|
||||
- An older entry.
|
||||
EOF
|
||||
git add VERSION CHANGELOG.md
|
||||
git commit -qm "base"
|
||||
printf '0.7.0\n' > VERSION
|
||||
cat > CHANGELOG.md <<'EOF'
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 0.7.0 — 2026-07-21
|
||||
|
||||
- The entry this release ships.
|
||||
|
||||
## 0.6.8 — 2026-07-01
|
||||
|
||||
- An older entry.
|
||||
EOF
|
||||
git add VERSION CHANGELOG.md
|
||||
git commit -qm "release: 0.7.0"
|
||||
echo "FIXTURE_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
|
||||
- name: gather the facts — version, base version, released, labeled
|
||||
id: facts
|
||||
working-directory: ${{ runner.temp }}/fixture
|
||||
env:
|
||||
MERGE_SHA: ${{ env.FIXTURE_SHA }}
|
||||
# Empty exercises the branch-create fallback: facts.sh must fall
|
||||
# back to the merge commit's first parent (#1 constraint 10).
|
||||
EVENT_BEFORE: ""
|
||||
VERSION_SOURCE: file
|
||||
# release.yml's step verbatim — same invocation, same
|
||||
# $GITHUB_OUTPUT plumbing — cwd'd at the fixture instead of the
|
||||
# workspace (the one thing a replay cannot inherit).
|
||||
run: bash "$CEREMONY_DIR/lib/facts.sh" >> "$GITHUB_OUTPUT"
|
||||
- name: 'decide: ceremony, or release-flow work under the label?'
|
||||
id: decide
|
||||
env:
|
||||
VER: ${{ steps.facts.outputs.ver }}
|
||||
BASE_VER: ${{ steps.facts.outputs.base_ver }}
|
||||
RELEASED: ${{ steps.facts.outputs.released }}
|
||||
LABELED: ${{ steps.facts.outputs.labeled }}
|
||||
# release.yml's step verbatim.
|
||||
run: |
|
||||
out="$(bash "$CEREMONY_DIR/lib/decide.sh")"
|
||||
printf '%s\n' "$out"
|
||||
printf '%s\n' "$out" | grep '^ceremony=' >> "$GITHUB_OUTPUT"
|
||||
- name: release notes — the version's own changelog section
|
||||
if: steps.decide.outputs.ceremony == 'yes'
|
||||
working-directory: ${{ runner.temp }}/fixture
|
||||
env:
|
||||
VER: ${{ steps.facts.outputs.ver }}
|
||||
# release.yml's step verbatim, cwd'd at the fixture.
|
||||
run: |
|
||||
# shellcheck source=/dev/null
|
||||
. "$CEREMONY_DIR/lib/changelog.sh"
|
||||
changelog_section CHANGELOG.md "$VER" > "$RUNNER_TEMP/notes.md"
|
||||
if [ ! -s "$RUNNER_TEMP/notes.md" ]; then
|
||||
echo "CHANGELOG.md has no '## $VER' section at the merge commit — the ceremony PR must stamp it; refusing to publish an empty release" >&2
|
||||
exit 1
|
||||
fi
|
||||
cat "$RUNNER_TEMP/notes.md"
|
||||
- name: the chain must land where the fixture says it lands
|
||||
env:
|
||||
CEREMONY: ${{ steps.decide.outputs.ceremony }}
|
||||
VER: ${{ steps.facts.outputs.ver }}
|
||||
BASE_VER: ${{ steps.facts.outputs.base_ver }}
|
||||
# Not a release.yml step — the replay's own assertion that the real
|
||||
# steps produced the facts and verdict the fixture encodes, so a
|
||||
# green job means the wiring carried real values, not empties.
|
||||
run: |
|
||||
[ "$VER" = "0.7.0" ] || { echo "ver: got '$VER'" >&2; exit 1; }
|
||||
[ "$BASE_VER" = "0.6.9-dev" ] || { echo "base_ver: got '$BASE_VER'" >&2; exit 1; }
|
||||
[ "$CEREMONY" = "yes" ] || { echo "ceremony: got '$CEREMONY'" >&2; exit 1; }
|
||||
grep -q "The entry this release ships" "$RUNNER_TEMP/notes.md" \
|
||||
|| { echo "notes.md missing the fixture's entry" >&2; exit 1; }
|
||||
echo "step-replay ($CEREMONY_DIR): facts -> decide -> notes carried real values end to end"
|
||||
|
||||
fixture-chain:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: the merge door's script chain against a fixture ceremony
|
||||
run: bash test/release-chain.test.sh
|
||||
369
.github/workflows/release.yml
vendored
Normal file
369
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
name: release
|
||||
# THE reusable release workflow — two doors into one act, implemented once
|
||||
# for the whole family (issue #9; lineage box#83/#96 · rig#32/#47 ·
|
||||
# cast#96/#111 — this essay is condensed from those three sources, and every
|
||||
# rule in it was bought with an incident).
|
||||
#
|
||||
# ## The two doors
|
||||
#
|
||||
# * The MERGE door: a release is a PR — `release: X.Y.Z`, carrying the
|
||||
# hand-set `release` label, bumping the version from X.Y.Z-dev to bare
|
||||
# X.Y.Z and stamping the changelog — and MERGING it is the ship decision.
|
||||
# The label is the intent, the version transition is the interlock: the
|
||||
# 5-state table (lib/decide.sh, issue #8) tells a ceremony apart from
|
||||
# release-flow work under the same label, turns every legitimate
|
||||
# non-ceremony into a green NOTICE no-op, and refuses every half-ceremony
|
||||
# loudly, creating nothing. The job then tags the merge commit via the API
|
||||
# and publishes in the SAME job, on purpose: a GITHUB_TOKEN-created tag
|
||||
# fires no workflows (GitHub's anti-recursion), so that tag can never
|
||||
# re-enter the tag door below and double-publish — this job is the
|
||||
# release's only chance to publish, and the nothing-exists assert covers a
|
||||
# manual tag racing the merge. Afterwards the job re-arms main itself:
|
||||
# bump to X.Y.(Z+1)-dev, pushed directly with the job's token (fires
|
||||
# nothing), falling back to a labeled PR if branch protection refuses —
|
||||
# loudly, never leaving main armed to impersonate the release.
|
||||
#
|
||||
# * The TAG door: a bare X.Y.Z tag push (no 'v' prefix — box's 0.6.0 set
|
||||
# the scheme) is the documented manual fallback and backfill. The tag must
|
||||
# name the tree's own version; a mismatch fails loudly and creates
|
||||
# nothing. No decide and no label check — the tag is the operator's
|
||||
# explicit act — and no bump: the fallback does not rewrite main (cast's
|
||||
# precedent).
|
||||
#
|
||||
# Both doors publish the release body from the version's own CHANGELOG.md
|
||||
# section (lib/changelog.sh — the one canonical extractor): the curated
|
||||
# prose, never the generated PR list. Assets come only from the consumer's
|
||||
# optional artifact hook (below); with no hook, GitHub's source tarball for
|
||||
# the tag IS the package (box, rig).
|
||||
#
|
||||
# ## The caller contract
|
||||
#
|
||||
# This is the consumer's ENTIRE release.yml (also in docs/CONSUMERS.md).
|
||||
# Triggers and permissions MUST live in the caller — a called workflow
|
||||
# cannot define them:
|
||||
#
|
||||
# name: release
|
||||
# on:
|
||||
# # ONE push key, both filters — YAML maps are last-key-wins; a second
|
||||
# # sibling `push:` silently replaces the first and kills a door (rig's
|
||||
# # review catch: the tag fallback had stopped triggering).
|
||||
# push:
|
||||
# tags: ["**"] # every tag — a wrong tag must FAIL the assert
|
||||
# # loudly below, never be skipped by a shape
|
||||
# # filter that didn't match
|
||||
# branches: [main]
|
||||
# permissions:
|
||||
# contents: write # tag ref create + release create + the bump push
|
||||
# pull-requests: write # the label read; the bump-fallback `gh pr create`
|
||||
# issues: write # --label on that fallback PR rides the issues API
|
||||
# jobs:
|
||||
# release:
|
||||
# uses: heavy-duty/ceremony/.github/workflows/release.yml@<pinned-tag>
|
||||
# with:
|
||||
# version-source: file # or: package-json
|
||||
#
|
||||
# The called workflow runs in the CALLER's context: the caller's event
|
||||
# payload (github.ref / github.sha / github.event.before), the caller's
|
||||
# GITHUB_TOKEN, the caller's permission grant. The doors split on the pushed
|
||||
# ref exactly as the sources did, and the anti-recursion property is
|
||||
# unchanged: tags and pushes created with GITHUB_TOKEN fire no workflows.
|
||||
# The merge door MUST keep riding `push` to main, never `pull_request`: a
|
||||
# pull_request run from a public FORK gets a READ-ONLY token that
|
||||
# `permissions:` cannot raise (box#97) — and every ceremony PR in this org
|
||||
# is cross-repo from a bot fork — so the asserts would pass and the tag
|
||||
# create would 403, red on main, every release.
|
||||
#
|
||||
# ## The self-ref pin (#1 D3)
|
||||
#
|
||||
# A called workflow file arrives alone; it does not bring its repository.
|
||||
# So each door checks out heavy-duty/ceremony at the literal pinned
|
||||
# CEREMONY_SELF_REF below (into .ceremony-src, inside the workspace) to get
|
||||
# lib/ at run time — except on the dogfood path: when the caller IS
|
||||
# heavy-duty/ceremony, the workspace already holds this repo at the merge
|
||||
# commit, libs included, and fetching tag X.Y.Z from the very run that
|
||||
# creates it would deadlock (#11). Every script call goes through
|
||||
# CEREMONY_DIR, so the bypass is one `if:` plus one env line.
|
||||
#
|
||||
# ## The artifact hook (#1 D4)
|
||||
#
|
||||
# If the consumer carries .github/actions/release-artifact/action.yml, both
|
||||
# doors invoke it — after the tag exists, before `gh release create` — with
|
||||
# `version` as input and RELEASE_ASSETS_DIR exported; every file the hook
|
||||
# drops there is uploaded as a release asset. Exit non-zero to abort the
|
||||
# release. No hook → no assets.
|
||||
#
|
||||
# ## What is honestly untested
|
||||
#
|
||||
# Every decision this workflow takes lives in a tested script: version state
|
||||
# (lib/version.sh), the 5-state verdict (lib/decide.sh), fact gathering
|
||||
# (lib/facts.sh), notes extraction (lib/changelog.sh), and the facts →
|
||||
# decide → notes chain is rehearsed end-to-end against fixtures
|
||||
# (test/release-chain.test.sh). The merge door's early step sequence — both
|
||||
# checkout shapes, both branches of the self-consumption bypass, the
|
||||
# CEREMONY_DIR wiring, and the facts → decide → notes steps with their real
|
||||
# $GITHUB_OUTPUT plumbing — is executed against a fixture by
|
||||
# release-exercise.yml's step-replay job, on every PR via ci.yml. What
|
||||
# remains, honestly untested until it runs live: the doors themselves —
|
||||
# door gating on a real push event, tag create, publish, and bump. That gap
|
||||
# is closed by #11 (ceremony's own 0.1.0 release runs this exact workflow
|
||||
# via a local-path call) and by the #13 pilot's rehearsal.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version-source:
|
||||
description: >-
|
||||
Where the tree's version lives: "file" (a VERSION file — box, rig,
|
||||
incubator) or "package-json" (the version field, lockfile kept in
|
||||
sync on bump — cast)
|
||||
type: string
|
||||
required: false
|
||||
default: file
|
||||
|
||||
env:
|
||||
# A called workflow arrives without its repository. This literal pin is
|
||||
# stamped by ceremony's own release PR to the version being released —
|
||||
# one more line in the same ritual as stamping the changelog (#11) — and
|
||||
# .github/scripts/self-ref-check.sh fails ceremony's own CI when it is
|
||||
# stale: a stale pin dies here, not in a consumer's release. checkout's
|
||||
# `ref:` accepts ${{ env }}; `uses:` strings do not — which is why the
|
||||
# shared logic arrives as script files via checkout, not as inner `uses:`
|
||||
# references.
|
||||
CEREMONY_SELF_REF: "0.1.0"
|
||||
VERSION_SOURCE: ${{ inputs.version-source }}
|
||||
|
||||
jobs:
|
||||
release-on-merge:
|
||||
# The merge door. Gated on the push EVENT as well as the ref (rig's
|
||||
# form): a workflow_dispatch of a caller sitting on main — this repo's
|
||||
# own release-exercise.yml — must stay dry wiring, never a live door.
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# The pushed head is what ships; its first parent (fetch-depth: 2)
|
||||
# is main the instant before the PR landed, which the version
|
||||
# transition is measured against (lib/facts.sh adds the
|
||||
# belt-and-braces fetch of event.before — cast's precedent).
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 2
|
||||
- uses: actions/checkout@v4
|
||||
# The self-consumption bypass (load-bearing — without it, ceremony's
|
||||
# own release deadlocks): on the dogfood path the workspace IS this
|
||||
# repo at the merge commit, libs included, so nothing is fetched —
|
||||
# the 0.1.0 run would otherwise check out tag 0.1.0, which is
|
||||
# created only AFTER that very run succeeds (#11).
|
||||
if: github.repository != 'heavy-duty/ceremony'
|
||||
with:
|
||||
repository: heavy-duty/ceremony
|
||||
ref: ${{ env.CEREMONY_SELF_REF }}
|
||||
path: .ceremony-src
|
||||
- name: wire CEREMONY_DIR and the assets dir
|
||||
run: |
|
||||
if [ "$GITHUB_REPOSITORY" = "heavy-duty/ceremony" ]; then
|
||||
echo "CEREMONY_DIR=$GITHUB_WORKSPACE" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "CEREMONY_DIR=$GITHUB_WORKSPACE/.ceremony-src" >> "$GITHUB_ENV"
|
||||
fi
|
||||
mkdir -p "$RUNNER_TEMP/release-assets"
|
||||
echo "RELEASE_ASSETS_DIR=$RUNNER_TEMP/release-assets" >> "$GITHUB_ENV"
|
||||
- name: gather the facts — version, base version, released, labeled
|
||||
id: facts
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
MERGE_SHA: ${{ github.sha }}
|
||||
EVENT_BEFORE: ${{ github.event.before }}
|
||||
# Facts on stdout in $GITHUB_OUTPUT form, diagnostics on stderr;
|
||||
# the API facts are gathered only in the states that consult them.
|
||||
run: bash "$CEREMONY_DIR/lib/facts.sh" >> "$GITHUB_OUTPUT"
|
||||
- name: 'decide: ceremony, or release-flow work under the label?'
|
||||
id: decide
|
||||
env:
|
||||
VER: ${{ steps.facts.outputs.ver }}
|
||||
BASE_VER: ${{ steps.facts.outputs.base_ver }}
|
||||
RELEASED: ${{ steps.facts.outputs.released }}
|
||||
LABELED: ${{ steps.facts.outputs.labeled }}
|
||||
# The 5-state table lives in lib/decide.sh (issue #8) — pure, so it
|
||||
# is contract-tested offline. `ceremony=no` ends this job green (the
|
||||
# NOTICE already printed); a refusal is red with nothing created.
|
||||
run: |
|
||||
out="$(bash "$CEREMONY_DIR/lib/decide.sh")"
|
||||
printf '%s\n' "$out"
|
||||
printf '%s\n' "$out" | grep '^ceremony=' >> "$GITHUB_OUTPUT"
|
||||
- name: release notes — the version's own changelog section
|
||||
if: steps.decide.outputs.ceremony == 'yes'
|
||||
env:
|
||||
VER: ${{ steps.facts.outputs.ver }}
|
||||
run: |
|
||||
# shellcheck source=/dev/null
|
||||
. "$CEREMONY_DIR/lib/changelog.sh"
|
||||
changelog_section CHANGELOG.md "$VER" > "$RUNNER_TEMP/notes.md"
|
||||
if [ ! -s "$RUNNER_TEMP/notes.md" ]; then
|
||||
echo "CHANGELOG.md has no '## $VER' section at the merge commit — the ceremony PR must stamp it; refusing to publish an empty release" >&2
|
||||
exit 1
|
||||
fi
|
||||
cat "$RUNNER_TEMP/notes.md"
|
||||
- name: nothing may exist yet — no tag, no release (re-runs refuse loudly)
|
||||
if: steps.decide.outputs.ceremony == 'yes'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VER: ${{ steps.facts.outputs.ver }}
|
||||
# What makes a re-run of a completed ceremony refuse instead of
|
||||
# clobber, and what catches a manual tag racing the merge.
|
||||
run: |
|
||||
if git ls-remote --exit-code origin "refs/tags/$VER" >/dev/null 2>&1; then
|
||||
echo "tag '$VER' already exists — this release already happened, or a manual tag won the race; refusing to re-release, creating nothing." >&2
|
||||
exit 1
|
||||
fi
|
||||
if gh release view "$VER" -R "$GITHUB_REPOSITORY" --json name >/dev/null 2>&1; then
|
||||
echo "release '$VER' already exists — refusing to re-release, creating nothing." >&2
|
||||
exit 1
|
||||
fi
|
||||
- name: tag the merge commit — same job as the publish, on purpose
|
||||
if: steps.decide.outputs.ceremony == 'yes'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VER: ${{ steps.facts.outputs.ver }}
|
||||
MERGE_SHA: ${{ github.sha }}
|
||||
# A GITHUB_TOKEN-created tag triggers nothing (anti-recursion), so
|
||||
# the tag door cannot double-fire off this tag — and this job is
|
||||
# the only chance to publish (the sources' central comment).
|
||||
run: |
|
||||
gh api "repos/$GITHUB_REPOSITORY/git/refs" \
|
||||
-f "ref=refs/tags/$VER" -f "sha=$MERGE_SHA"
|
||||
- name: artifact hook — the consumer's own release-artifact action
|
||||
# Runs after the tag exists, before the publish (#1 D4). The local
|
||||
# path resolves in the consumer checkout at the workspace root —
|
||||
# legal in a called workflow because the action is on disk. Hook
|
||||
# contract: drop finished files into $RELEASE_ASSETS_DIR; exit
|
||||
# non-zero to abort the release (docs/CONSUMERS.md).
|
||||
if: steps.decide.outputs.ceremony == 'yes' && hashFiles('.github/actions/release-artifact/action.yml') != ''
|
||||
uses: ./.github/actions/release-artifact
|
||||
with:
|
||||
version: ${{ steps.facts.outputs.ver }}
|
||||
- name: publish the release
|
||||
if: steps.decide.outputs.ceremony == 'yes'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VER: ${{ steps.facts.outputs.ver }}
|
||||
run: |
|
||||
assets=()
|
||||
for f in "$RELEASE_ASSETS_DIR"/*; do
|
||||
if [ -e "$f" ]; then assets+=("$f"); fi
|
||||
done
|
||||
gh release create "$VER" --verify-tag --title "$VER" \
|
||||
--notes-file "$RUNNER_TEMP/notes.md" -R "$GITHUB_REPOSITORY" \
|
||||
"${assets[@]}"
|
||||
# The post-release bump, folded into the release act (the sources'
|
||||
# operator decision: a mechanical one-liner deserves no PR of its
|
||||
# own). X.Y.(Z+1)-dev is arithmetic, not judgment (version_next_dev
|
||||
# refuses anything but bare X.Y.Z). A GITHUB_TOKEN push fires no
|
||||
# workflows (anti-recursion), so the bump triggers neither this door
|
||||
# nor a red run; should branch protection refuse the direct push, the
|
||||
# step opens the bump PR itself and says so, loudly, instead of
|
||||
# leaving main armed to impersonate the release.
|
||||
- name: bump main to the next -dev — the release re-arms main itself
|
||||
if: steps.decide.outputs.ceremony == 'yes'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VER: ${{ steps.facts.outputs.ver }}
|
||||
run: |
|
||||
# shellcheck source=/dev/null
|
||||
. "$CEREMONY_DIR/lib/version.sh"
|
||||
next="$(version_next_dev "$VER")"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
# main may have moved since the merge; release+1 lands on the
|
||||
# newer head — the intended arithmetic either way (cast's
|
||||
# comment).
|
||||
git fetch origin main
|
||||
git checkout -B main origin/main
|
||||
version_write "$VERSION_SOURCE" "$next"
|
||||
# NEVER `git add -A` here: .ceremony-src sits UNTRACKED in this
|
||||
# workspace on the consumer path, and -A would commit the whole
|
||||
# ceremony checkout into the consumer's main. Exactly the files
|
||||
# the bump wrote, nothing else.
|
||||
case "$VERSION_SOURCE" in
|
||||
file) git add VERSION ;;
|
||||
package-json) git add package.json package-lock.json ;;
|
||||
esac
|
||||
git commit -m "chore: bump main to $next — a dev install must not impersonate $VER"
|
||||
if ! git push origin main; then
|
||||
echo "direct push refused (branch protection?) — opening the bump PR instead" >&2
|
||||
git checkout -b "chore/bump-$next"
|
||||
git push origin "chore/bump-$next"
|
||||
gh pr create -R "$GITHUB_REPOSITORY" --head "chore/bump-$next" \
|
||||
--title "chore: bump main to $next" \
|
||||
--body "The post-release re-arm, opened by release.yml because the direct push was refused. One version bump, nothing else — never leave main armed to impersonate $VER." \
|
||||
--label release
|
||||
fi
|
||||
|
||||
release-on-tag:
|
||||
# The tag door — the manual fallback and backfill. The tag is the
|
||||
# operator's explicit act: no decide, no label check — and no bump
|
||||
# (cast's precedent: the fallback does not rewrite main). Event-gated
|
||||
# like the merge door: dispatch runs stay dry.
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v4
|
||||
# The self-consumption bypass — see the merge door's twin step.
|
||||
if: github.repository != 'heavy-duty/ceremony'
|
||||
with:
|
||||
repository: heavy-duty/ceremony
|
||||
ref: ${{ env.CEREMONY_SELF_REF }}
|
||||
path: .ceremony-src
|
||||
- name: wire CEREMONY_DIR and the assets dir
|
||||
run: |
|
||||
if [ "$GITHUB_REPOSITORY" = "heavy-duty/ceremony" ]; then
|
||||
echo "CEREMONY_DIR=$GITHUB_WORKSPACE" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "CEREMONY_DIR=$GITHUB_WORKSPACE/.ceremony-src" >> "$GITHUB_ENV"
|
||||
fi
|
||||
mkdir -p "$RUNNER_TEMP/release-assets"
|
||||
echo "RELEASE_ASSETS_DIR=$RUNNER_TEMP/release-assets" >> "$GITHUB_ENV"
|
||||
- name: the tag must name the tree's own version
|
||||
id: assert
|
||||
run: |
|
||||
# shellcheck source=/dev/null
|
||||
. "$CEREMONY_DIR/lib/version.sh"
|
||||
ver="$(version_read "$VERSION_SOURCE")"
|
||||
if [ "$GITHUB_REF_NAME" != "$ver" ]; then
|
||||
echo "tag '$GITHUB_REF_NAME' does not match the tree's version '$ver' — creating nothing." >&2
|
||||
echo "A release is a PR, then a tag: the release PR bumps the version and stamps the changelog; the tag goes on its MERGE commit. Delete this tag and re-tag the right commit." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "ver=$ver" >> "$GITHUB_OUTPUT"
|
||||
- name: release notes — the version's own changelog section
|
||||
env:
|
||||
VER: ${{ steps.assert.outputs.ver }}
|
||||
run: |
|
||||
# shellcheck source=/dev/null
|
||||
. "$CEREMONY_DIR/lib/changelog.sh"
|
||||
changelog_section CHANGELOG.md "$VER" > "$RUNNER_TEMP/notes.md"
|
||||
if [ ! -s "$RUNNER_TEMP/notes.md" ]; then
|
||||
echo "CHANGELOG.md has no '## $VER' section — stamp the Unreleased section in the release PR before tagging; refusing to publish an empty release" >&2
|
||||
exit 1
|
||||
fi
|
||||
cat "$RUNNER_TEMP/notes.md"
|
||||
- name: artifact hook — the consumer's own release-artifact action
|
||||
# After the tag exists (it fired this door), before the publish —
|
||||
# the same contract as the merge door's twin step.
|
||||
if: hashFiles('.github/actions/release-artifact/action.yml') != ''
|
||||
uses: ./.github/actions/release-artifact
|
||||
with:
|
||||
version: ${{ steps.assert.outputs.ver }}
|
||||
- name: publish the release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VER: ${{ steps.assert.outputs.ver }}
|
||||
run: |
|
||||
assets=()
|
||||
for f in "$RELEASE_ASSETS_DIR"/*; do
|
||||
if [ -e "$f" ]; then assets+=("$f"); fi
|
||||
done
|
||||
gh release create "$VER" --verify-tag --title "$VER" \
|
||||
--notes-file "$RUNNER_TEMP/notes.md" -R "$GITHUB_REPOSITORY" \
|
||||
"${assets[@]}"
|
||||
|
|
@ -1,5 +1,72 @@
|
|||
# Consumer setup
|
||||
|
||||
## Release workflow
|
||||
|
||||
The reusable release workflow implements both doors of the ceremony — the
|
||||
merge door (merging the `release`-labeled ceremony PR ships it) and the tag
|
||||
door (a bare `X.Y.Z` tag push as the manual fallback and backfill). The
|
||||
design essay lives in the workflow's own header comment; the doctrine in
|
||||
issue #1.
|
||||
|
||||
The consumer's **entire** `release.yml`:
|
||||
|
||||
```yaml
|
||||
name: release
|
||||
# Triggers and permissions MUST live here (a called workflow cannot define them):
|
||||
on:
|
||||
# ONE push key, both filters — YAML maps are last-key-wins; a second sibling
|
||||
# `push:` silently replaces the first and kills a door (rig's review catch).
|
||||
push:
|
||||
tags: ["**"] # every tag — a wrong tag must FAIL the assert loudly,
|
||||
# never be skipped by a shape filter that didn't match
|
||||
branches: [main]
|
||||
permissions:
|
||||
contents: write # tag ref create + release create + the bump push
|
||||
pull-requests: write # decide's label read; the bump-fallback `gh pr create`
|
||||
issues: write # --label on that fallback PR rides the issues API
|
||||
jobs:
|
||||
release:
|
||||
uses: heavy-duty/ceremony/.github/workflows/release.yml@<pinned-tag>
|
||||
with:
|
||||
version-source: file # or: package-json
|
||||
```
|
||||
|
||||
`version-source` is the only input: `file` (a `VERSION` file — box, rig,
|
||||
incubator) or `package-json` (the version field, lockfile kept in sync on
|
||||
the post-release bump — cast). Everything else a repo might vary is a change
|
||||
to the ceremony itself, made in this repo, once.
|
||||
|
||||
Keep the merge door on `push` to `main` — never `pull_request`: a
|
||||
`pull_request` run from a public fork gets a read-only `GITHUB_TOKEN` that
|
||||
`permissions:` cannot raise (box#97), and every ceremony PR in this org is
|
||||
cross-repo from a bot fork.
|
||||
|
||||
Bootstrap the version at `X.Y.Z-dev`, not bare: a first version that never
|
||||
carried `-dev` hits the decide table's refuse row and has to ship by the
|
||||
tag door instead (the known first-release edge, cast#111).
|
||||
|
||||
### The artifact hook
|
||||
|
||||
If the repository contains `.github/actions/release-artifact/action.yml`,
|
||||
both doors invoke it — after the tag exists, before `gh release create` —
|
||||
with the release `version` as input and `RELEASE_ASSETS_DIR` exported.
|
||||
Contract for hook authors:
|
||||
|
||||
- Drop finished files into `$RELEASE_ASSETS_DIR`; every file there is
|
||||
uploaded as a release asset.
|
||||
- Exit non-zero to abort the release.
|
||||
- The hook owns its own toolchain (checkout is done; install node, docker,
|
||||
whatever it needs, itself).
|
||||
|
||||
A failed hook leaves the tag created but no release published. Recovery is
|
||||
the tag door's semantics: fix the cause, then delete and re-push the same
|
||||
tag (the tag door publishes for it), or run `gh release create` by hand from
|
||||
a fixed tree. The merge door's nothing-exists assert will refuse a re-run of
|
||||
the completed merge, by design.
|
||||
|
||||
No hook → no assets: for a pure-bash tree, GitHub's source tarball for the
|
||||
tag IS the package.
|
||||
|
||||
## Labels automation
|
||||
|
||||
The reusable labels workflow owns two independent jobs: additive path-based
|
||||
|
|
|
|||
108
lib/facts.sh
Normal file
108
lib/facts.sh
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
#!/usr/bin/env bash
|
||||
# lib/facts.sh — the merge door's fact gathering (issue #9).
|
||||
#
|
||||
# lib/decide.sh (issue #8) is pure: it consumes four facts and renders the
|
||||
# 5-state verdict. This script is the impure half that establishes those
|
||||
# facts. It runs inside the consumer's checkout (the working directory),
|
||||
# talks to git and gh, and prints the facts in $GITHUB_OUTPUT form:
|
||||
#
|
||||
# ver=… base_ver=… released=(yes|no|empty) labeled=(yes|no|empty)
|
||||
#
|
||||
# stdout carries exclusively those lines — the release workflow appends the
|
||||
# whole stream to $GITHUB_OUTPUT — so every diagnostic goes to stderr.
|
||||
#
|
||||
# Env in:
|
||||
# VERSION_SOURCE file | package-json (the workflow's one input)
|
||||
# MERGE_SHA the pushed head (github.sha)
|
||||
# EVENT_BEFORE github.event.before — may be empty or all-zeros
|
||||
# GITHUB_REPOSITORY for the two API facts
|
||||
# GH_TOKEN for gh (unused when no API state is consulted)
|
||||
#
|
||||
# The API calls run only in the states that consult them (decide tolerates
|
||||
# empty facts — issue #8): RELEASED only for a bare unchanged version,
|
||||
# LABELED only for a bare transition. A -dev tree — every ordinary merge —
|
||||
# decides on the two versions alone and never touches the API.
|
||||
set -euo pipefail
|
||||
|
||||
# shellcheck source=lib/version.sh
|
||||
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/version.sh"
|
||||
|
||||
: "${VERSION_SOURCE:?facts: VERSION_SOURCE is required}"
|
||||
: "${MERGE_SHA:?facts: MERGE_SHA is required}"
|
||||
|
||||
case "$VERSION_SOURCE" in
|
||||
file) src=VERSION ;;
|
||||
package-json) src=package.json ;;
|
||||
*)
|
||||
echo "facts: unknown VERSION_SOURCE '$VERSION_SOURCE' — expected file or package-json" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
ver="$(version_read "$VERSION_SOURCE")"
|
||||
|
||||
# event.before is all-zeros on a branch-create push, and absent outside push
|
||||
# events; the pushed head's first parent is main the instant before, either
|
||||
# way (#1 constraint 10; cast's `*[!0]*` test — "contains a non-zero char").
|
||||
base_sha="${EVENT_BEFORE:-}"
|
||||
case "$base_sha" in
|
||||
*[!0]*) ;;
|
||||
*) base_sha="$(git rev-parse "$MERGE_SHA^1")" ;;
|
||||
esac
|
||||
|
||||
# Belt-and-braces (cast's precedent): the workflow's fetch-depth: 2 resolves
|
||||
# the first parent, but event.before can predate it when pushes raced. If
|
||||
# the fetch still cannot produce it, the git show below is the loud failure.
|
||||
git cat-file -e "$base_sha" 2>/dev/null \
|
||||
|| git fetch --depth=1 origin "$base_sha" >&2 \
|
||||
|| true
|
||||
|
||||
base_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$base_dir"' EXIT
|
||||
|
||||
if git show "$base_sha:$src" >"$base_dir/$src" 2>/dev/null; then
|
||||
base_ver="$(version_read "$VERSION_SOURCE" "$base_dir")"
|
||||
else
|
||||
# The base tree has no version source at all: the merge that ADDS the
|
||||
# version machinery (a consumer's adoption PR, a greenfield repo's first
|
||||
# caller). "(none)" is not a version, so decide sees a changed version
|
||||
# and the table still governs: a -dev head is work (row 2, the guided
|
||||
# bootstrap path), a bare head still demands the merged release label
|
||||
# (rows 5–6). Nothing releases silently either way.
|
||||
base_ver="(none)"
|
||||
fi
|
||||
|
||||
released=""
|
||||
labeled=""
|
||||
if ! version_is_dev "$ver"; then
|
||||
if [ "$base_ver" = "$ver" ]; then
|
||||
# Any gh failure reads as "not released" — the sources' semantics; the
|
||||
# verdict this feeds (row 4) is a refusal, and the ceremony path
|
||||
# re-checks existence in the nothing-exists assert before creating
|
||||
# anything.
|
||||
if gh release view "$ver" -R "$GITHUB_REPOSITORY" --json name >/dev/null 2>&1; then
|
||||
released=yes
|
||||
else
|
||||
released=no
|
||||
fi
|
||||
else
|
||||
# The sources' exact jq: merged PRs only, `release` among the label
|
||||
# names. Read via the API because a push event carries no PR payload —
|
||||
# and the PR itself lives on a fork (the trigger comment in the
|
||||
# workflow). A failed API call reads as "no label", which row 5
|
||||
# refuses: fail-closed.
|
||||
if gh api "repos/$GITHUB_REPOSITORY/commits/$MERGE_SHA/pulls" \
|
||||
-q '[.[] | select(.merged_at != null) | .labels[].name] | index("release") != null' \
|
||||
| grep -qx true; then
|
||||
labeled=yes
|
||||
else
|
||||
labeled=no
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "facts: ver='$ver' base_ver='$base_ver' released='$released' labeled='$labeled'" >&2
|
||||
printf 'ver=%s\n' "$ver"
|
||||
printf 'base_ver=%s\n' "$base_ver"
|
||||
printf 'released=%s\n' "$released"
|
||||
printf 'labeled=%s\n' "$labeled"
|
||||
175
test/facts.test.sh
Normal file
175
test/facts.test.sh
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
#!/usr/bin/env bash
|
||||
# Contract tests for lib/facts.sh (issue #9) — the merge door's impure half.
|
||||
# Constructed git repos stand in for the consumer checkout, and a gh stub on
|
||||
# PATH stands in for the API, so every fact row is proven offline —
|
||||
# including the rows that must NOT touch the API at all (the stub's default
|
||||
# mode fails the test if gh is called). set -u, not -e: failing commands are
|
||||
# behavior for the harness to inspect.
|
||||
set -u
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
# shellcheck source=test/harness.sh
|
||||
. "$ROOT/test/harness.sh"
|
||||
|
||||
FACTS="$ROOT/lib/facts.sh"
|
||||
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
ZEROS="0000000000000000000000000000000000000000"
|
||||
|
||||
# The gh stub: behavior selected per call site by GH_STUB. The default
|
||||
# refuses — the -dev rows and every ordinary merge must never call the API.
|
||||
mkdir -p "$TMP/stub"
|
||||
cat >"$TMP/stub/gh" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
case "${GH_STUB:-none}" in
|
||||
labeled-yes | labeled-no)
|
||||
if [ "$1" != api ]; then
|
||||
echo "gh stub: expected an api call, got: gh $*" >&2
|
||||
exit 97
|
||||
fi
|
||||
[ "${GH_STUB}" = labeled-yes ] && echo true || echo false
|
||||
;;
|
||||
released-yes | released-no)
|
||||
if [ "$1" != release ]; then
|
||||
echo "gh stub: expected a release call, got: gh $*" >&2
|
||||
exit 97
|
||||
fi
|
||||
[ "${GH_STUB}" = released-yes ] && exit 0 || exit 1
|
||||
;;
|
||||
*)
|
||||
echo "gh stub: gh must not be called in this state (gh $*)" >&2
|
||||
exit 97
|
||||
;;
|
||||
esac
|
||||
EOF
|
||||
chmod +x "$TMP/stub/gh"
|
||||
|
||||
# repo <name> — a fixture git repo; commit <name> <msg> [file content...]
|
||||
repo() {
|
||||
git init -q "$TMP/$1"
|
||||
git -C "$TMP/$1" config user.email fixture@example.invalid
|
||||
git -C "$TMP/$1" config user.name fixture
|
||||
}
|
||||
|
||||
# commit <repo> <file> <content> — write one file, commit, print the sha.
|
||||
commit() {
|
||||
printf '%s\n' "$3" >"$TMP/$1/$2"
|
||||
git -C "$TMP/$1" add "$2"
|
||||
git -C "$TMP/$1" commit -qm "set $2"
|
||||
git -C "$TMP/$1" rev-parse HEAD
|
||||
}
|
||||
|
||||
# facts_in <repo> <env assignments...> — run facts.sh inside the fixture
|
||||
# with the stub first on PATH.
|
||||
facts_in() {
|
||||
local dir="$1"
|
||||
shift
|
||||
(cd "$TMP/$dir" \
|
||||
&& env PATH="$TMP/stub:$PATH" GITHUB_REPOSITORY=fixture/fixture GH_TOKEN=stub \
|
||||
"$@" bash "$FACTS")
|
||||
}
|
||||
|
||||
# --- the ceremony transition (bare, changed → labeled consulted) -------------
|
||||
|
||||
repo ceremony
|
||||
base_sha="$(commit ceremony VERSION 0.6.9-dev)"
|
||||
head_sha="$(commit ceremony VERSION 0.7.0)"
|
||||
|
||||
check "transition: ver is the head's" 0 "ver=0.7.0" \
|
||||
facts_in ceremony VERSION_SOURCE=file MERGE_SHA="$head_sha" EVENT_BEFORE="$base_sha" GH_STUB=labeled-yes
|
||||
check "transition: base_ver is the base's" 0 "base_ver=0.6.9-dev" \
|
||||
facts_in ceremony VERSION_SOURCE=file MERGE_SHA="$head_sha" EVENT_BEFORE="$base_sha" GH_STUB=labeled-yes
|
||||
check "transition: labeled=yes from the API" 0 "labeled=yes" \
|
||||
facts_in ceremony VERSION_SOURCE=file MERGE_SHA="$head_sha" EVENT_BEFORE="$base_sha" GH_STUB=labeled-yes
|
||||
# The quoted stderr summary is the emptiness assertion: released='' can
|
||||
# only appear when the API was genuinely skipped (a call would have set
|
||||
# yes/no, or tripped the stub's default refusal).
|
||||
check "transition: released stays empty (not consulted)" 0 "released=''" \
|
||||
facts_in ceremony VERSION_SOURCE=file MERGE_SHA="$head_sha" EVENT_BEFORE="$base_sha" GH_STUB=labeled-yes
|
||||
check "transition: labeled=no from the API" 0 "labeled=no" \
|
||||
facts_in ceremony VERSION_SOURCE=file MERGE_SHA="$head_sha" EVENT_BEFORE="$base_sha" GH_STUB=labeled-no
|
||||
|
||||
# event.before all-zeros (branch-create push) and empty (non-push caller)
|
||||
# both fall back to the head's first parent (#1 constraint 10).
|
||||
check "all-zeros event.before falls back to the first parent" 0 "base_ver=0.6.9-dev" \
|
||||
facts_in ceremony VERSION_SOURCE=file MERGE_SHA="$head_sha" EVENT_BEFORE="$ZEROS" GH_STUB=labeled-yes
|
||||
check "empty event.before falls back to the first parent" 0 "base_ver=0.6.9-dev" \
|
||||
facts_in ceremony VERSION_SOURCE=file MERGE_SHA="$head_sha" EVENT_BEFORE= GH_STUB=labeled-yes
|
||||
|
||||
# --- the -dev rows: no API call, ever (the stub would exit 97) ---------------
|
||||
|
||||
repo dev-work
|
||||
dev_base="$(commit dev-work VERSION 0.7.1-dev)"
|
||||
dev_head="$(commit dev-work notes.txt "ordinary work")"
|
||||
|
||||
check "-dev unchanged: no API calls made" 0 "base_ver=0.7.1-dev" \
|
||||
facts_in dev-work VERSION_SOURCE=file MERGE_SHA="$dev_head" EVENT_BEFORE="$dev_base"
|
||||
check "-dev unchanged: released and labeled stay empty" 0 "released='' labeled=''" \
|
||||
facts_in dev-work VERSION_SOURCE=file MERGE_SHA="$dev_head" EVENT_BEFORE="$dev_base"
|
||||
|
||||
repo dev-bump
|
||||
bump_base="$(commit dev-bump VERSION 0.7.0)"
|
||||
bump_head="$(commit dev-bump VERSION 0.7.1-dev)"
|
||||
check "the post-release bump (bare -> -dev): no API calls" 0 "ver=0.7.1-dev" \
|
||||
facts_in dev-bump VERSION_SOURCE=file MERGE_SHA="$bump_head" EVENT_BEFORE="$bump_base"
|
||||
|
||||
# --- bare, unchanged → released consulted, labeled skipped -------------------
|
||||
|
||||
repo window
|
||||
win_base="$(commit window VERSION 0.7.0)"
|
||||
win_head="$(commit window notes.txt "post-release window work")"
|
||||
|
||||
check "bare unchanged: released=yes from the API" 0 "released=yes" \
|
||||
facts_in window VERSION_SOURCE=file MERGE_SHA="$win_head" EVENT_BEFORE="$win_base" GH_STUB=released-yes
|
||||
check "bare unchanged: released=no from the API" 0 "released=no" \
|
||||
facts_in window VERSION_SOURCE=file MERGE_SHA="$win_head" EVENT_BEFORE="$win_base" GH_STUB=released-no
|
||||
check "bare unchanged: labeled stays empty (not consulted)" 0 "labeled=''" \
|
||||
facts_in window VERSION_SOURCE=file MERGE_SHA="$win_head" EVENT_BEFORE="$win_base" GH_STUB=released-yes
|
||||
|
||||
# --- a base tree with no version source at all -------------------------------
|
||||
|
||||
# The merge that ADDS the version machinery (a consumer's adoption PR).
|
||||
# "(none)" is not a version, so decide still governs: -dev head is work,
|
||||
# bare head still demands the label. Nothing releases silently.
|
||||
repo adoption
|
||||
adopt_base="$(commit adoption README.md "pre-ceremony tree")"
|
||||
adopt_head="$(commit adoption VERSION 0.1.0-dev)"
|
||||
|
||||
check "absent-at-base reads as (none), -dev head consults no API" 0 "base_ver=(none)" \
|
||||
facts_in adoption VERSION_SOURCE=file MERGE_SHA="$adopt_head" EVENT_BEFORE="$adopt_base"
|
||||
|
||||
repo adoption-bare
|
||||
adoptb_base="$(commit adoption-bare README.md "pre-ceremony tree")"
|
||||
adoptb_head="$(commit adoption-bare VERSION 0.1.0)"
|
||||
check "absent-at-base with a bare head still asks for the label" 0 "labeled=yes" \
|
||||
facts_in adoption-bare VERSION_SOURCE=file MERGE_SHA="$adoptb_head" EVENT_BEFORE="$adoptb_base" GH_STUB=labeled-yes
|
||||
|
||||
# --- the package-json backend ------------------------------------------------
|
||||
|
||||
repo pkg
|
||||
pkg_base="$(commit pkg package.json '{ "name": "fixture", "version": "1.1.9-dev" }')"
|
||||
pkg_head="$(commit pkg package.json '{ "name": "fixture", "version": "1.2.0" }')"
|
||||
|
||||
check "package-json: head version via node" 0 "ver=1.2.0" \
|
||||
facts_in pkg VERSION_SOURCE=package-json MERGE_SHA="$pkg_head" EVENT_BEFORE="$pkg_base" GH_STUB=labeled-yes
|
||||
check "package-json: base version via node" 0 "base_ver=1.1.9-dev" \
|
||||
facts_in pkg VERSION_SOURCE=package-json MERGE_SHA="$pkg_head" EVENT_BEFORE="$pkg_base" GH_STUB=labeled-yes
|
||||
|
||||
# --- refusals ----------------------------------------------------------------
|
||||
|
||||
check "missing VERSION_SOURCE refuses" 1 "VERSION_SOURCE is required" \
|
||||
facts_in ceremony MERGE_SHA="$head_sha" EVENT_BEFORE="$base_sha"
|
||||
check "missing MERGE_SHA refuses" 1 "MERGE_SHA is required" \
|
||||
facts_in ceremony VERSION_SOURCE=file EVENT_BEFORE="$base_sha"
|
||||
check "unknown backend refuses" 1 "unknown VERSION_SOURCE" \
|
||||
facts_in ceremony VERSION_SOURCE=carrier-pigeon MERGE_SHA="$head_sha" EVENT_BEFORE="$base_sha"
|
||||
|
||||
repo no-version
|
||||
nv_base="$(commit no-version README.md "a tree")"
|
||||
nv_head="$(commit no-version README.md "with no version at the head either")"
|
||||
check "no version at the head fails loudly" 1 "no such file" \
|
||||
facts_in no-version VERSION_SOURCE=file MERGE_SHA="$nv_head" EVENT_BEFORE="$nv_base"
|
||||
|
||||
summary
|
||||
128
test/release-chain.test.sh
Normal file
128
test/release-chain.test.sh
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
#!/usr/bin/env bash
|
||||
# The merge door's script chain, composed end-to-end (issue #9): facts →
|
||||
# decide → notes against a constructed fixture ceremony, exactly the way
|
||||
# release.yml wires them (facts' $GITHUB_OUTPUT lines become decide's env;
|
||||
# the notes come from the one canonical extractor). facts.test.sh proves the
|
||||
# fact rows and decide's own suite proves the table; this file proves the
|
||||
# HANDOFF between them. Also run by release-exercise.yml on dispatch. set
|
||||
# -u, not -e: failing commands are behavior for the harness to inspect.
|
||||
set -u
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
# shellcheck source=test/harness.sh
|
||||
. "$ROOT/test/harness.sh"
|
||||
|
||||
FACTS="$ROOT/lib/facts.sh"
|
||||
DECIDE="$ROOT/lib/decide.sh"
|
||||
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
# A gh stub for the one API fact the ceremony path needs: the merged,
|
||||
# release-labeled PR behind the commit.
|
||||
mkdir -p "$TMP/stub"
|
||||
cat >"$TMP/stub/gh" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
if [ "$1" = api ]; then echo true; exit 0; fi
|
||||
echo "gh stub: unexpected call: gh $*" >&2
|
||||
exit 97
|
||||
EOF
|
||||
chmod +x "$TMP/stub/gh"
|
||||
|
||||
# The fixture: a base tree at 0.6.9-dev with an armed changelog, then the
|
||||
# ceremony merge — VERSION bumped bare, Unreleased stamped, re-armed above.
|
||||
git init -q "$TMP/repo"
|
||||
git -C "$TMP/repo" config user.email fixture@example.invalid
|
||||
git -C "$TMP/repo" config user.name fixture
|
||||
|
||||
printf '0.6.9-dev\n' >"$TMP/repo/VERSION"
|
||||
cat >"$TMP/repo/CHANGELOG.md" <<'EOF'
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
- The entry this release ships.
|
||||
|
||||
## 0.6.8 — 2026-07-01
|
||||
|
||||
- An older entry.
|
||||
EOF
|
||||
git -C "$TMP/repo" add VERSION CHANGELOG.md
|
||||
git -C "$TMP/repo" commit -qm "base"
|
||||
BASE_SHA="$(git -C "$TMP/repo" rev-parse HEAD)"
|
||||
|
||||
printf '0.7.0\n' >"$TMP/repo/VERSION"
|
||||
cat >"$TMP/repo/CHANGELOG.md" <<'EOF'
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 0.7.0 — 2026-07-21
|
||||
|
||||
- The entry this release ships.
|
||||
|
||||
## 0.6.8 — 2026-07-01
|
||||
|
||||
- An older entry.
|
||||
EOF
|
||||
git -C "$TMP/repo" add VERSION CHANGELOG.md
|
||||
git -C "$TMP/repo" commit -qm "release: 0.7.0"
|
||||
MERGE_SHA="$(git -C "$TMP/repo" rev-parse HEAD)"
|
||||
|
||||
# chain <merge_sha> <event_before> — facts, then decide fed from facts'
|
||||
# output lines, then the notes extraction, printing each stage's result.
|
||||
chain() {
|
||||
(
|
||||
cd "$TMP/repo" || exit 1
|
||||
facts_out="$(env PATH="$TMP/stub:$PATH" GITHUB_REPOSITORY=fixture/fixture \
|
||||
GH_TOKEN=stub VERSION_SOURCE=file MERGE_SHA="$1" EVENT_BEFORE="$2" \
|
||||
bash "$FACTS")" || exit 1
|
||||
printf '%s\n' "$facts_out"
|
||||
ver="$(printf '%s\n' "$facts_out" | awk -F= '$1 == "ver" { print $2 }')"
|
||||
base_ver="$(printf '%s\n' "$facts_out" | awk -F= '$1 == "base_ver" { print $2 }')"
|
||||
released="$(printf '%s\n' "$facts_out" | awk -F= '$1 == "released" { print $2 }')"
|
||||
labeled="$(printf '%s\n' "$facts_out" | awk -F= '$1 == "labeled" { print $2 }')"
|
||||
decide_out="$(env VER="$ver" BASE_VER="$base_ver" RELEASED="$released" \
|
||||
LABELED="$labeled" bash "$DECIDE")" || exit 1
|
||||
printf '%s\n' "$decide_out"
|
||||
case "$decide_out" in
|
||||
*ceremony=yes*)
|
||||
# shellcheck source=lib/changelog.sh
|
||||
. "$ROOT/lib/changelog.sh"
|
||||
notes="$(changelog_section CHANGELOG.md "$ver")"
|
||||
if [ -z "$notes" ]; then
|
||||
echo "chain: the changelog section for $ver is empty" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf 'notes: %s\n' "$notes"
|
||||
;;
|
||||
esac
|
||||
)
|
||||
}
|
||||
|
||||
check "the ceremony merge decides ceremony=yes" 0 "ceremony=yes" \
|
||||
chain "$MERGE_SHA" "$BASE_SHA"
|
||||
check "the notes are the stamped section's prose" 0 \
|
||||
"notes: - The entry this release ships." chain "$MERGE_SHA" "$BASE_SHA"
|
||||
|
||||
# The same chain on an ordinary merge: -dev, unchanged — a green NOTICE
|
||||
# no-op that never consults the API (the stub would refuse a release view).
|
||||
printf 'ordinary work\n' >"$TMP/repo/notes.txt"
|
||||
git -C "$TMP/repo" add notes.txt
|
||||
git -C "$TMP/repo" commit -qm "ordinary work"
|
||||
WORK_SHA="$(git -C "$TMP/repo" rev-parse HEAD)"
|
||||
printf '0.7.1-dev\n' >"$TMP/repo/VERSION"
|
||||
git -C "$TMP/repo" add VERSION
|
||||
git -C "$TMP/repo" commit -qm "chore: bump main to 0.7.1-dev"
|
||||
BUMP_SHA="$(git -C "$TMP/repo" rev-parse HEAD)"
|
||||
printf 'more ordinary work\n' >"$TMP/repo/notes.txt"
|
||||
git -C "$TMP/repo" add notes.txt
|
||||
git -C "$TMP/repo" commit -qm "more ordinary work"
|
||||
WORK2_SHA="$(git -C "$TMP/repo" rev-parse HEAD)"
|
||||
|
||||
check "the post-release bump decides ceremony=no" 0 "ceremony=no" \
|
||||
chain "$BUMP_SHA" "$WORK_SHA"
|
||||
check "an ordinary -dev merge decides ceremony=no" 0 "ceremony=no" \
|
||||
chain "$WORK2_SHA" "$BUMP_SHA"
|
||||
|
||||
summary
|
||||
137
test/self-ref.test.sh
Normal file
137
test/self-ref.test.sh
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
#!/usr/bin/env bash
|
||||
# Contract tests for .github/scripts/self-ref-check.sh (issue #9; #1 D3) —
|
||||
# the pin rules, driven against constructed fixture trees. The CI step runs
|
||||
# the same script against the real tree. set -u, not -e: failing commands
|
||||
# are behavior for the harness to inspect.
|
||||
set -u
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
# shellcheck source=test/harness.sh
|
||||
. "$ROOT/test/harness.sh"
|
||||
|
||||
CHECK="$ROOT/.github/scripts/self-ref-check.sh"
|
||||
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
# tree <name> <pin...> — a fixture tree whose workflows carry the pins (one
|
||||
# workflow file per pin, mimicking release.yml + labels.yml).
|
||||
tree() {
|
||||
local name="$1"
|
||||
shift
|
||||
mkdir -p "$TMP/$name/.github/workflows"
|
||||
local i=0 pin
|
||||
for pin in "$@"; do
|
||||
i=$((i + 1))
|
||||
printf 'name: w%s\nenv:\n CEREMONY_SELF_REF: "%s"\n' "$i" "$pin" \
|
||||
>"$TMP/$name/.github/workflows/w$i.yml"
|
||||
done
|
||||
}
|
||||
|
||||
run_check() {
|
||||
bash "$CHECK" "$TMP/$1"
|
||||
}
|
||||
|
||||
# --- the pre-dogfood window (no VERSION yet — the tree #9 lands on) ---------
|
||||
|
||||
tree pre-dogfood 0.1.0
|
||||
check "no VERSION: pin unchecked, loud notice, green" 0 "cannot bind yet" \
|
||||
run_check pre-dogfood
|
||||
|
||||
# --- a bare tree: pin must equal VERSION -------------------------------------
|
||||
|
||||
tree bare-good 0.3.0
|
||||
printf '0.3.0\n' >"$TMP/bare-good/VERSION"
|
||||
check "bare tree, pin == VERSION passes" 0 "agrees" run_check bare-good
|
||||
|
||||
tree bare-stale 0.2.0
|
||||
printf '0.3.0\n' >"$TMP/bare-stale/VERSION"
|
||||
check "bare tree, stale pin fails" 1 "must be '0.3.0'" run_check bare-stale
|
||||
|
||||
# --- a -dev tree: pin must equal the newest stamped heading ------------------
|
||||
|
||||
tree dev-good 0.3.0
|
||||
printf '0.4.0-dev\n' >"$TMP/dev-good/VERSION"
|
||||
cat >"$TMP/dev-good/CHANGELOG.md" <<'EOF'
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Pending entry.
|
||||
|
||||
## 0.3.0 — 2026-07-20
|
||||
|
||||
- The shipped entry.
|
||||
|
||||
## 0.2.0 — 2026-07-01
|
||||
|
||||
- Older entry.
|
||||
EOF
|
||||
check "-dev tree, pin == newest stamped heading passes" 0 "agrees" \
|
||||
run_check dev-good
|
||||
|
||||
tree dev-stale 0.2.0
|
||||
printf '0.4.0-dev\n' >"$TMP/dev-stale/VERSION"
|
||||
cp "$TMP/dev-good/CHANGELOG.md" "$TMP/dev-stale/CHANGELOG.md"
|
||||
check "-dev tree, pin behind the last release fails" 1 "must be '0.3.0'" \
|
||||
run_check dev-stale
|
||||
|
||||
# Whole-field match: an rc heading never satisfies the bare X.Y.Z shape —
|
||||
# the newest BARE heading below it is the last release.
|
||||
tree dev-rc 0.3.0
|
||||
printf '0.4.0-dev\n' >"$TMP/dev-rc/VERSION"
|
||||
cat >"$TMP/dev-rc/CHANGELOG.md" <<'EOF'
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 0.4.0-rc1 — 2026-07-21
|
||||
|
||||
- The candidate's entry.
|
||||
|
||||
## 0.3.0 — 2026-07-20
|
||||
|
||||
- The shipped entry.
|
||||
EOF
|
||||
check "-dev tree: an rc heading is skipped, the bare one governs" 0 "agrees" \
|
||||
run_check dev-rc
|
||||
|
||||
# --- the pre-first-release tree: pin == VERSION with -dev stripped -----------
|
||||
|
||||
tree first-good 0.1.0
|
||||
printf '0.1.0-dev\n' >"$TMP/first-good/VERSION"
|
||||
printf '# Changelog\n\n## Unreleased\n\n- Everything so far.\n' \
|
||||
>"$TMP/first-good/CHANGELOG.md"
|
||||
check "pre-first-release: pin == VERSION minus -dev passes" 0 "agrees" \
|
||||
run_check first-good
|
||||
|
||||
tree first-stale 0.0.1
|
||||
printf '0.1.0-dev\n' >"$TMP/first-stale/VERSION"
|
||||
check "pre-first-release: any other pin fails" 1 "must be '0.1.0'" \
|
||||
run_check first-stale
|
||||
|
||||
# --- degenerate trees --------------------------------------------------------
|
||||
|
||||
mkdir -p "$TMP/no-pin/.github/workflows"
|
||||
printf 'name: w\non: push\n' >"$TMP/no-pin/.github/workflows/w.yml"
|
||||
check "no pin anywhere fails — the guard must have something to guard" 1 \
|
||||
"nothing to guard" run_check no-pin
|
||||
|
||||
tree split-pin 0.3.0 0.2.0
|
||||
printf '0.3.0\n' >"$TMP/split-pin/VERSION"
|
||||
check "disagreeing pins across workflows fail" 1 "disagree" run_check split-pin
|
||||
|
||||
tree empty-version 0.1.0
|
||||
: >"$TMP/empty-version/VERSION"
|
||||
check "an empty VERSION fails loudly" 1 "is empty" run_check empty-version
|
||||
|
||||
# --- the real tree -----------------------------------------------------------
|
||||
|
||||
# Whatever state the repo is in (pre-dogfood today, versioned after #11),
|
||||
# the guard must hold on it — this is the CI step's exact invocation.
|
||||
real_check() {
|
||||
(cd "$ROOT" && bash "$CHECK")
|
||||
}
|
||||
check "the real tree passes its own guard" 0 "" real_check
|
||||
|
||||
summary
|
||||
Loading…
Reference in a new issue