Merge main into release/0.4.1 — carry #191's doors and #194's drill record
All checks were successful
CI / test (pull_request) Successful in 1m29s
CI / release-exercise (pull_request) Successful in 10s
CI / self-guards (pull_request) Successful in 6s
CI / action-exercise (pull_request) Successful in 5s
CI / docs-sync-exercise (pull_request) Successful in 6s
labels / labels (pull_request) Successful in 1m28s
All checks were successful
CI / test (pull_request) Successful in 1m29s
CI / release-exercise (pull_request) Successful in 10s
CI / self-guards (pull_request) Successful in 6s
CI / action-exercise (pull_request) Successful in 5s
CI / docs-sync-exercise (pull_request) Successful in 6s
labels / labels (pull_request) Successful in 1m28s
The section is re-assembled: main's merge base now carries two fragments (188.md and 191.md), so 0.4.1 publishes with the door port it actually ships, and neither fragment survives its own release. drills/0.4.1.md takes main's two-run record, with run 1's disclosures restored — the scratch repo it used, its candidate ref, and the ~8 minutes it spent public to read job logs. A later success does not retire a disclosed deviation.
This commit is contained in:
commit
9d816293ee
12 changed files with 740 additions and 118 deletions
20
.github/workflows/release-exercise.yml
vendored
20
.github/workflows/release-exercise.yml
vendored
|
|
@ -94,12 +94,25 @@ jobs:
|
|||
# consumes the tool end to end instead of hand-writing its output.
|
||||
# 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.
|
||||
# release-labeled PR) so nothing here talks to a forge.
|
||||
#
|
||||
# The stub is gh-shaped, so the facts step below pins
|
||||
# CEREMONY_FORGE=github: since #191 facts.sh selects a backend, and
|
||||
# on a Forgejo runner it would otherwise pick the forgejo backend,
|
||||
# which speaks curl and would walk straight past this stub to the
|
||||
# real instance — reading the exercise's fixture SHA against the
|
||||
# live repository and refusing it. The exercise rehearses the
|
||||
# WIRING; which backend answers is lib/forge.sh's own contract,
|
||||
# covered in test/forge*.test.sh.
|
||||
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
|
||||
# The label read is GET commits/{sha}/pulls — a JSON array (#191).
|
||||
if [ "$1" = api ]; then
|
||||
echo '[{"merged_at":"2026-01-01T00:00:00Z","labels":[{"name":"release"}]}]'
|
||||
exit 0
|
||||
fi
|
||||
echo "gh stub: unexpected call: gh $*" >&2
|
||||
exit 97
|
||||
EOF
|
||||
|
|
@ -136,6 +149,9 @@ jobs:
|
|||
# back to the merge commit's first parent (#1 constraint 10).
|
||||
EVENT_BEFORE: ""
|
||||
VERSION_SOURCE: file
|
||||
# The stub above is gh-shaped; pin the backend that uses it.
|
||||
CEREMONY_FORGE: github
|
||||
GITHUB_REPOSITORY: fixture/fixture
|
||||
# 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).
|
||||
|
|
|
|||
44
.github/workflows/release.yml
vendored
44
.github/workflows/release.yml
vendored
|
|
@ -54,7 +54,7 @@ name: release
|
|||
# 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`
|
||||
# pull-requests: write # the label read; the bump-fallback PR
|
||||
# issues: write # --label on that fallback PR rides the issues API
|
||||
# jobs:
|
||||
# release:
|
||||
|
|
@ -87,7 +87,7 @@ name: release
|
|||
# ## 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
|
||||
# doors invoke it — after the tag exists, before the publish — 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.
|
||||
|
|
@ -217,7 +217,14 @@ jobs:
|
|||
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
|
||||
# shellcheck source=/dev/null
|
||||
. "$CEREMONY_DIR/lib/forge.sh"
|
||||
forge_select ""
|
||||
if ! exists="$(forge_release_exists "$VER")"; then
|
||||
echo "could not read whether release '$VER' exists — refusing rather than assuming it does not (#191)." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$exists" = yes ]; then
|
||||
echo "release '$VER' already exists — refusing to re-release, creating nothing." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
|
@ -231,8 +238,10 @@ jobs:
|
|||
# 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"
|
||||
# shellcheck source=/dev/null
|
||||
. "$CEREMONY_DIR/lib/forge.sh"
|
||||
forge_select ""
|
||||
forge_tag_create "$VER" "$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 —
|
||||
|
|
@ -253,9 +262,10 @@ jobs:
|
|||
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[@]}"
|
||||
# shellcheck source=/dev/null
|
||||
. "$CEREMONY_DIR/lib/forge.sh"
|
||||
forge_select ""
|
||||
forge_release_create "$VER" "$VER" "$RUNNER_TEMP/notes.md" "${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
|
||||
|
|
@ -294,10 +304,13 @@ jobs:
|
|||
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
|
||||
# shellcheck source=/dev/null
|
||||
. "$CEREMONY_DIR/lib/forge.sh"
|
||||
forge_select ""
|
||||
forge_pr_create "chore/bump-$next" main \
|
||||
"chore: bump main to $next" \
|
||||
"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." \
|
||||
release
|
||||
fi
|
||||
|
||||
release-on-tag:
|
||||
|
|
@ -366,6 +379,7 @@ jobs:
|
|||
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[@]}"
|
||||
# shellcheck source=/dev/null
|
||||
. "$CEREMONY_DIR/lib/forge.sh"
|
||||
forge_select ""
|
||||
forge_release_create "$VER" "$VER" "$RUNNER_TEMP/notes.md" "${assets[@]}"
|
||||
|
|
|
|||
31
CHANGELOG.md
31
CHANGELOG.md
|
|
@ -13,6 +13,24 @@ section here (`bin/changelog-assemble`, #112).
|
|||
|
||||
### Added
|
||||
|
||||
- `forge_release_exists`, `forge_commit_pulls`, `forge_tag_create`,
|
||||
`forge_release_create` and `forge_pr_create` on both backends, so the
|
||||
release path names no client (#191).
|
||||
|
||||
- The forgejo backend serves one PR object at `/commits/{sha}/pull` where
|
||||
GitHub serves an array at `/pulls`; both verbs emit the array shape, so
|
||||
the call site carries one expression (#191).
|
||||
|
||||
- Forgejo creates tags at `POST /tags` — it serves `/git/refs` GET-only,
|
||||
so GitHub's ref-POST would have 404'd there forever (#191).
|
||||
|
||||
- `forgejo_api_base` refuses when `REPO` is empty. Every verb interpolates
|
||||
it and every call reaches the network through there, so `repos//…` —
|
||||
whose 404 reads as "no release" and "no PRs" — is now impossible (#191).
|
||||
|
||||
- Release asset names are percent-encoded. The hook contract permits any
|
||||
filename, and the name travels as a query value: a space made curl reject
|
||||
the URL and `&`/`#`/`+`/`%` silently renamed the asset (#191).
|
||||
- `lib/forge.sh` — the forge selector: `forge_detect` names the forge from
|
||||
the runner's own environment, `forge_client` names the client it needs, and
|
||||
`forge_preflight` refuses loudly before any sweep when the two disagree
|
||||
|
|
@ -32,6 +50,10 @@ section here (`bin/changelog-assemble`, #112).
|
|||
|
||||
### Changed
|
||||
|
||||
- `docs/CONSUMERS.md`'s artifact-hook recovery no longer tells operators to
|
||||
run `gh release create` by hand — on a Forgejo runner there is no `gh`.
|
||||
It names the forge-neutral tag-door path first, with both clients shown
|
||||
(#191).
|
||||
- `issueflow-reconcile` gathers open and merged PRs over REST instead of
|
||||
`gh api graphql`. Forgejo serves no GraphQL at all, so the two queries were
|
||||
replaced rather than translated; both forges return `number` and `body`
|
||||
|
|
@ -47,6 +69,15 @@ section here (`bin/changelog-assemble`, #112).
|
|||
|
||||
### Fixed
|
||||
|
||||
- The release doors run on a Forgejo consumer. `lib/facts.sh` and
|
||||
`release.yml` gathered and published through `gh`, which the runner image
|
||||
does not ship, so the merge door read `labeled=no` for a correctly
|
||||
labeled ceremony PR and the tag door died at the publish (#191).
|
||||
|
||||
- A release fact that could not be read is no longer reported as a definite
|
||||
`no`. A completed read finding no label is still `no` and still
|
||||
fail-closed; a read that did not complete refuses and emits no fact
|
||||
(#191).
|
||||
- `labels-scope` no longer fails to compile its jq program on jq 1.6, which
|
||||
the Forgejo runner image ships: `label` is a reserved word in jq's grammar,
|
||||
so `$label` is a syntax error there and every scope derivation died before
|
||||
|
|
|
|||
|
|
@ -247,8 +247,9 @@ 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.
|
||||
both doors invoke it — after the tag exists, before the release is
|
||||
published — 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
|
||||
|
|
@ -259,9 +260,28 @@ Contract for hook authors:
|
|||
|
||||
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.
|
||||
tag — the tag door publishes for it. That path is forge-neutral and is the
|
||||
one to prefer.
|
||||
|
||||
If you must publish by hand instead, use whatever your forge provides;
|
||||
ceremony itself no longer names a client here, because on a Forgejo runner
|
||||
there is no `gh` to name (#191):
|
||||
|
||||
```sh
|
||||
# GitHub
|
||||
gh release create "$VER" --verify-tag --title "$VER" \
|
||||
--notes-file notes.md -R "$OWNER/$REPO"
|
||||
|
||||
# Forgejo / Gitea — POST /repos/{owner}/{repo}/releases
|
||||
curl -sS -X POST -H "Authorization: token $TOKEN" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$(jq -nc --arg t "$VER" --rawfile b notes.md \
|
||||
'{tag_name:$t,name:$t,body:$b}')" \
|
||||
"$FORGE/api/v1/repos/$OWNER/$REPO/releases"
|
||||
```
|
||||
|
||||
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. Worked examples land with the conversions: cast's tgz
|
||||
|
|
|
|||
174
drills/0.4.1.md
174
drills/0.4.1.md
|
|
@ -1,79 +1,125 @@
|
|||
# 0.4.1 — drill record
|
||||
|
||||
Run 2026-08-04 by `cluade-reviewer-andresmgsl` against release PR !190 head
|
||||
`9a229ee643291c27dbd404fa97e4dcc7e6ed3b17`.
|
||||
Two runs. The first, 2026-08-04 against release PR !190 head `9a229ee`,
|
||||
**failed**: both doors were inoperable and the release could not publish at
|
||||
all. The second, after #191 landed as `fda5657`, **passed**. Both are
|
||||
recorded, because the first is why the second exists.
|
||||
|
||||
**This drill FAILED. Both doors of the release workflow are inoperable on
|
||||
`forgejo.heavyduty.builders`.** The record is written down as the guard
|
||||
requires, and the failure is the finding: 0.4.1 cannot publish from this
|
||||
instance until `lib/facts.sh` and the publish steps are ported off `gh`.
|
||||
## Run 2 — against merged `main` `fda5657` (the one that counts)
|
||||
|
||||
Where: disposable private repo `heavy-duty/ceremony-drill-0.4.1`, armed at
|
||||
`0.4.1-dev` with the `docs/CONSUMERS.md` release caller, a fragment-mode
|
||||
fixture (`changelog.d/README.md` + one fragment), and a non-blank drill
|
||||
record. Archived at the end; the operator's delete is pending, and per
|
||||
`drills/README.md` cleanup gates nothing.
|
||||
Where: disposable private repo `heavy-duty/ceremony-drill-0.4.1-final`,
|
||||
armed at `0.4.1-dev`, carrying the `docs/CONSUMERS.md` release caller, a
|
||||
fragment-mode fixture, and — unlike run 1 — an **artifact hook** dropping two
|
||||
deliberately awkward filenames, `drill asset.tgz` and `a&b.tgz`. Archived at
|
||||
the end; the operator's delete is pending, and cleanup gates nothing.
|
||||
|
||||
## Deviations
|
||||
Candidate ref: `cluade-reviewer-andresmgsl/ceremony@drill-main`, parent
|
||||
`fda5657`, whose only extra commit rewrites both `CEREMONY_SELF_REF`
|
||||
carriers to that SHA — `release.yml`'s self-checkout is hardcoded to
|
||||
`heavy-duty/ceremony`, so only a SHA that resolves there can stand in for a
|
||||
tag that does not exist yet. No `0.4.1` branch was created on
|
||||
`heavy-duty/ceremony`.
|
||||
|
||||
* **Candidate ref.** The pure consumer path cannot resolve
|
||||
`CEREMONY_SELF_REF: "0.4.1"` before the candidate creates that tag, and
|
||||
`release.yml`'s self-checkout is hardcoded to `heavy-duty/ceremony`. No
|
||||
`0.4.1` branch was created there. The caller used
|
||||
`cluade-reviewer-andresmgsl/ceremony@drill-0.4.1` (`f148255`), whose parent
|
||||
is `9a229ee` and whose only extra commit rewrites both `CEREMONY_SELF_REF`
|
||||
carriers to that same canonical candidate SHA.
|
||||
* **Log access.** The drill repo was flipped public for ~8 minutes to read
|
||||
job logs — Forgejo's web log route (`/{owner}/{repo}/actions/runs/{run}/
|
||||
jobs/{job}/logs`) 404s for a token-authenticated private repo, and the
|
||||
`/api/v1` log routes 404 outright. Restored to private, then archived.
|
||||
Recorded because it is a real deviation from "scratch **private** repo".
|
||||
| # | probe | run | result |
|
||||
|---|---|---|---|
|
||||
| 1 | merge-door ceremony | 637 | ✅ exactly one release `0.4.1`; body is the version's own changelog section; tag and release on merge commit `4a83fa1b`; **main re-armed to `0.4.2-dev`**; both assets uploaded |
|
||||
| 2 | mislabeled ordinary PR | 652 | ✅ green no-op in **7s** on merge commit `e71df4e3` — release count stayed **2**, no tag created |
|
||||
| 3 | bare version, no `release` label | 638 | ✅ refused at `b091aff2` — release count stayed **1** |
|
||||
| 4 | re-run of the completed ceremony | 654 | ✅ refused in **8s** on merge commit `82e7d11b` — release count stayed **2**, and tag `0.4.1` **stayed on `4a83fa1b`**, the original merge commit |
|
||||
| 5 | tag door, matching tag | 639 | ✅ `0.5.0` published with its own section and both assets; **main VERSION untouched** |
|
||||
| 6 | mismatched tag | 640 | ✅ `9.9.9` refused — release count stayed **2**; the tag exists, the release does not |
|
||||
|
||||
## Probes
|
||||
Probes 2 and 4 were run last, at 15:23–15:28Z, on the same consumer: it was
|
||||
un-archived for them and archived again at the end. Nothing else about the
|
||||
run changed — same candidate ref, same caller pin.
|
||||
|
||||
**Probe 4 diverges in mechanism, not in what it proves.** The 0.3.0 and 0.4.0
|
||||
siblings re-ran the completed ceremony's workflow run (GitHub's "attempt 2").
|
||||
Forgejo 8.0.3 exposes no run-rerun API — there are no `actions/runs/{id}`
|
||||
routes in its swagger at all — so the ceremony was re-run by reproducing its
|
||||
input instead: main re-armed to `0.4.1-dev` (setup run 653), then a second
|
||||
`release`-labeled PR stamping bare `0.4.1` merged on top. That is the same
|
||||
state the door refuses on, reached by a push rather than a re-trigger, and it
|
||||
is stricter than the sibling shape in one way — it re-enters through
|
||||
`facts` → `decide` rather than replaying a decided run.
|
||||
|
||||
Which refusal fired is measurable even without run logs. Replaying the
|
||||
door's own inputs against the live consumer at `82e7d11b`:
|
||||
|
||||
```
|
||||
facts: ver='0.4.1' base_ver='0.4.1-dev' released='' labeled='yes'
|
||||
decide: ceremony=yes
|
||||
```
|
||||
|
||||
So `decide` said **go** — a labeled bare transition is row 6 — and the stop
|
||||
came from the merge door's own pre-publish assert, `release.yml:216-219`,
|
||||
whose comment names this exact probe ("what makes a re-run of a completed
|
||||
ceremony refuse instead of clobber"): the tag existed, so it refused before
|
||||
`forge_tag_create` ever ran. The second net behind it is #191's own verb,
|
||||
and it reads this consumer correctly:
|
||||
|
||||
```
|
||||
forge_release_exists 0.4.1 -> yes 0.5.0 -> yes 9.9.9 -> no
|
||||
```
|
||||
|
||||
`9.9.9` is the probe-6 receipt in the same breath: the tag exists, the
|
||||
release does not.
|
||||
|
||||
Evidence for every probe here is the run conclusion plus the repository
|
||||
state before and after — this instance serves no run logs (the API 404s on
|
||||
every log route), so no probe's result is quoted from a log line.
|
||||
|
||||
End state, as observed when this was written: releases `0.4.1` and `0.5.0`
|
||||
and nothing else; tags `0.4.1` on `4a83fa1b`, `0.5.0` and `9.9.9` on
|
||||
`b091aff2`; the consumer's `main` left at bare `0.4.1` where probe 4 stopped
|
||||
it, private and archived.
|
||||
|
||||
**The asset names survived intact**: `a&b.tgz` and `drill asset.tgz` both
|
||||
appear under those exact names on both releases. Before #191's fix the space
|
||||
made curl reject the URL outright and the `&` split the query — the failure
|
||||
landing *after* the tag exists, mid-publish, which is the worst place this
|
||||
door has.
|
||||
|
||||
## Run 1 — against `9a229ee`, before the fix (FAILED)
|
||||
|
||||
Recorded because the failure is the reason #191 exists — including its
|
||||
deviations, which no later success retires.
|
||||
|
||||
Where: disposable private repo `heavy-duty/ceremony-drill-0.4.1` — a
|
||||
different consumer from run 2's, archived at the end with the operator's
|
||||
delete pending. Candidate ref
|
||||
`cluade-reviewer-andresmgsl/ceremony@drill-0.4.1` (`f148255`), parent
|
||||
`9a229ee`, its only extra commit rewriting both `CEREMONY_SELF_REF` carriers
|
||||
to that SHA.
|
||||
|
||||
**Deviation, disclosed and not retired by run 2:** run 1's scratch repo was
|
||||
flipped **public for roughly 8 minutes** to read job logs — Forgejo's web log
|
||||
route 404s for a token-authenticated private repo and the `/api/v1` log
|
||||
routes 404 outright — then restored to private and archived. That is a real
|
||||
departure from `drills/README.md`'s "scratch **private** repo", and it stays
|
||||
in the record. Run 2 did not repeat it: it read no logs at all, which is why
|
||||
every run-2 row is a repository-state measurement.
|
||||
|
||||
| # | probe | run | result |
|
||||
|---|---|---|---|
|
||||
| 1 | merge-door ceremony | 12 (and 7, at `0.4.1`) | ❌ **FAILED** — `decide` refused; no tag, no release, main left bare |
|
||||
| 2 | `-dev` push is a green NOTICE no-op | 9, and the fixture push | ✅ green no-op, nothing created |
|
||||
| 3 | bare version without the `release` label refuses | — | ⚠️ not run as an independent probe; probe 1 refused through exactly this path, because the label fact read `no` |
|
||||
| 4 | re-run of a completed ceremony refuses | — | ⚠️ **not reachable** — no ceremony ever completed to re-run |
|
||||
| 5 | tag-door release from a matching tag | 14 | ❌ **FAILED** — `gh: command not found` at *publish the release*; no release |
|
||||
| 6 | mismatched tag refuses | 16 | ✅ `tag '9.9.9' does not match the tree's version '0.4.2' — creating nothing` |
|
||||
| 2 | `-dev` push no-op | 9, and the fixture push | ✅ green no-op, nothing created |
|
||||
| 3 | bare version, no `release` label | — | ⚠️ not run independently; probe 1 refused through exactly this path, because the label fact read `no` |
|
||||
| 4 | re-run of a completed ceremony | — | ⚠️ **not reachable** — no ceremony ever completed to re-run |
|
||||
| 5 | tag door | 14 | ❌ **FAILED** — `gh: command not found` at *publish the release*; no release |
|
||||
| 6 | mismatched tag | 16 | ✅ `tag '9.9.9' does not match the tree's version '0.4.2' — creating nothing` |
|
||||
|
||||
Release count in the scratch repo at the end: **0**. Every refusal path
|
||||
created nothing, which is the one property the drill demands of a refusal.
|
||||
Root cause, both doors: `lib/facts.sh` and `release.yml` spoke `gh`, which
|
||||
the runner image does not ship. `facts.sh` read *any* failure as a definite
|
||||
`no`, so a missing binary silently demoted a release ceremony to "a bare
|
||||
push". Release count across the whole of run 1: **0**.
|
||||
|
||||
## Finding 1 — the merge door cannot read its own label (`lib/facts.sh`)
|
||||
## What changed between them
|
||||
|
||||
Probe 1, reproduced twice (`0.4.1`, then `0.4.2` after re-arming):
|
||||
#191, merged as `fda5657`: both doors onto `lib/forge.sh`; a read that did
|
||||
not complete refuses instead of fabricating a `no`; `forgejo_api_base`
|
||||
refuses an empty `REPO` so `repos//…` cannot be addressed; release asset
|
||||
names percent-encoded.
|
||||
|
||||
```
|
||||
facts: ver=0.4.2 base_ver=0.4.2-dev released= labeled=no
|
||||
decide: the version transitioned ('0.4.2-dev' -> '0.4.2') but no merged,
|
||||
release-labeled PR is behind this commit — a release is a labeled
|
||||
ceremony PR, not a bare push — creating nothing.
|
||||
```
|
||||
|
||||
The PR *was* labeled `release` and *was* merged. `lib/facts.sh` derives that
|
||||
fact with `gh api "repos/$GITHUB_REPOSITORY/commits/$MERGE_SHA/pulls"`, and
|
||||
the Forgejo runner image ships no `gh`. Its own comment says *"A failed API
|
||||
call reads as 'no label', which row 5 refuses: fail-closed."* — so the guard
|
||||
behaved exactly as designed on a fact that was wrong.
|
||||
|
||||
`#188` ported the reconcilers and `labels-scope` onto `lib/forge.sh`;
|
||||
`lib/facts.sh` was outside that scope and is still gh-only.
|
||||
|
||||
## Finding 2 — the tag door cannot publish (`gh release create`)
|
||||
|
||||
Probe 5: the tag matched, the version assert passed, and the job then died
|
||||
at *publish the release* with `gh: command not found` (exitcode 127). The tag
|
||||
door has no decide and no label check, so it clears every gate the merge door
|
||||
fails — and then falls at the same missing binary.
|
||||
|
||||
## Consequence
|
||||
|
||||
Merging !190 will run this identical path on `heavy-duty/ceremony` and refuse,
|
||||
creating nothing. **There is no door through which 0.4.1 can publish on this
|
||||
instance today.** The fix is the same shape as #188's, one layer out:
|
||||
`lib/facts.sh` and `release.yml`'s `gh release view` / `gh release create`
|
||||
call sites onto `forge_*`. That is new work, not a re-review of !190.
|
||||
Every refusal path, in both runs, created nothing. That property never
|
||||
broke — what broke was the doors' ability to say yes.
|
||||
|
|
|
|||
62
lib/facts.sh
62
lib/facts.sh
|
|
@ -4,7 +4,7 @@
|
|||
# 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:
|
||||
# talks to git and the forge shim, and prints the facts in $GITHUB_OUTPUT form:
|
||||
#
|
||||
# ver=… base_ver=… released=(yes|no|empty) labeled=(yes|no|empty)
|
||||
#
|
||||
|
|
@ -16,7 +16,8 @@
|
|||
# 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)
|
||||
# GH_TOKEN for the forge client (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,
|
||||
|
|
@ -24,8 +25,11 @@
|
|||
# decides on the two versions alone and never touches the API.
|
||||
set -euo pipefail
|
||||
|
||||
_facts_lib="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=lib/version.sh
|
||||
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/version.sh"
|
||||
. "$_facts_lib/version.sh"
|
||||
# shellcheck source=lib/forge.sh
|
||||
. "$_facts_lib/forge.sh"
|
||||
|
||||
: "${VERSION_SOURCE:?facts: VERSION_SOURCE is required}"
|
||||
: "${MERGE_SHA:?facts: MERGE_SHA is required}"
|
||||
|
|
@ -94,25 +98,45 @@ fi
|
|||
released=""
|
||||
labeled=""
|
||||
if ! version_is_dev "$ver"; then
|
||||
# The forge is selected only in the states that consult the API — a -dev
|
||||
# tree, every ordinary merge, still decides on the two versions alone and
|
||||
# touches no forge at all (#8's tolerance for empty facts).
|
||||
# The forgejo backend addresses the repo through REPO; the github backend
|
||||
# reads GITHUB_REPOSITORY directly. Set it here from the one this script
|
||||
# already documents, so the two backends address the same repository —
|
||||
# missing it made every forgejo read refuse with "REPO: unbound variable"
|
||||
# (caught by release-exercise on !193).
|
||||
REPO="${REPO:-${GITHUB_REPOSITORY:?facts: GITHUB_REPOSITORY is required for the API facts}}"
|
||||
export REPO
|
||||
# "" means decide from the environment; forge_select takes an explicit
|
||||
# forge only in tests.
|
||||
forge_select "" || exit 1
|
||||
|
||||
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
|
||||
# Row 4's input. Before #191 any failure here read as "not released",
|
||||
# which is safe only because row 4 refuses either way. It is still a
|
||||
# lie about what was observed, so an unreadable answer refuses.
|
||||
if ! released="$(forge_release_exists "$ver")"; then
|
||||
echo "facts: could not read whether '$ver' is already released — refusing rather than reporting 'no' (#191)" >&2
|
||||
exit 1
|
||||
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
|
||||
# Row 5's input, and the one that cost a release: a push event carries
|
||||
# no PR payload, so the label is read from the API. The old code turned
|
||||
# ANY failure into labeled=no, and on a Forgejo runner — no `gh` — that
|
||||
# demoted a correctly labeled, correctly merged ceremony PR into "a bare
|
||||
# push", refusing the release and creating nothing. Measured in the
|
||||
# 0.4.1 drill, twice (drills/0.4.1.md).
|
||||
#
|
||||
# Now: a completed read that finds no merged release-labeled PR is still
|
||||
# `no` and still fail-closed. A read that did not complete refuses.
|
||||
if ! pulls="$(forge_commit_pulls "$MERGE_SHA")"; then
|
||||
echo "facts: could not read the pull requests behind '$MERGE_SHA' — refusing rather than reporting 'no label' (#191)" >&2
|
||||
exit 1
|
||||
fi
|
||||
# One jq expression for both forges: the backends agree on the shape.
|
||||
if printf '%s' "$pulls" \
|
||||
| jq -e '[.[] | select(.merged_at != null) | .labels[].name] | index("release") != null' >/dev/null 2>&1; then
|
||||
labeled=yes
|
||||
else
|
||||
labeled=no
|
||||
|
|
|
|||
|
|
@ -18,6 +18,30 @@ forgejo_api_base() {
|
|||
echo "forgejo_api_base: no GITHUB_API_URL or CEREMONY_FORGE_API — cannot reach the forge (#188)" >&2
|
||||
return 1
|
||||
fi
|
||||
# Every verb in this backend interpolates $REPO into its path, and every
|
||||
# one of them reaches the network through here — so this is the one place
|
||||
# that can make `repos//…` impossible.
|
||||
#
|
||||
# THE TRAP, measured on this instance with REPO unset (#191, caught by
|
||||
# @kimi on !193 before it shipped):
|
||||
#
|
||||
# forge_release_exists 0.4.1 -> "no", rc 0 (repos//releases/tags/0.4.1
|
||||
# 404s; a repo-less path read
|
||||
# as "the release does not
|
||||
# exist" — and the
|
||||
# nothing-exists assert would
|
||||
# then proceed to CREATE)
|
||||
# forge_commit_pulls <sha> -> "[]", rc 0 (a commit that HAS a merged
|
||||
# PR behind it, read as none)
|
||||
#
|
||||
# A workflow `run:` shell carries no `set -u`, so an unset REPO expands
|
||||
# empty and 404s into a fabricated fact instead of crashing. That is the
|
||||
# exact failure #191 exists to remove, so it refuses here rather than
|
||||
# anywhere later.
|
||||
if [ -z "${REPO:-}" ]; then
|
||||
echo "forgejo_api_base: REPO is empty — refusing to address 'repos//…', whose 404 would read as a fact (#191)" >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s\n' "${base%/}"
|
||||
}
|
||||
|
||||
|
|
@ -481,3 +505,170 @@ forge_pr_activity() {
|
|||
--jq '.[].created_at' || return 1
|
||||
done < <(jq -r '.[] | select((.comments_count // 0) > 0) | .id' <<<"$reviews")
|
||||
}
|
||||
|
||||
# --- the release door's facts (#191) --------------------------------------
|
||||
# Two reads the merge and tag doors depend on. Both answer a QUESTION, and
|
||||
# both distinguish "the read completed and the answer is no" from "the read
|
||||
# did not complete" — the distinction lib/facts.sh got wrong before #191,
|
||||
# where any failure became a definite `no` and a release ceremony was
|
||||
# silently demoted to a bare push.
|
||||
#
|
||||
# Measured on forgejo.heavyduty.builders (8.0.3+gitea-1.22.0), 2026-08-04:
|
||||
#
|
||||
# GET /repos/{o}/{r}/releases/tags/0.4.0 -> 200 (present)
|
||||
# GET /repos/{o}/{r}/releases/tags/9.9.9 -> 404 (absent — a real answer)
|
||||
#
|
||||
# GET /repos/{o}/{r}/commits/{sha}/pull -> 200, a SINGLE PR object
|
||||
# GET /repos/{o}/{r}/commits/{sha}/pulls -> 404 page not found
|
||||
# ...on a commit with no PR -> 404 {"message":"pull request
|
||||
# does not exist …"}
|
||||
#
|
||||
# The singular/plural split is the asymmetry: GitHub serves an ARRAY at
|
||||
# /pulls, Forgejo serves one OBJECT at /pull. Both verbs below emit the
|
||||
# GitHub shape — a JSON array — so lib/facts.sh carries one jq expression
|
||||
# for both forges, which is the whole point of the shim.
|
||||
|
||||
# forgejo_read_code <endpoint> <body-file> — the raw GET, printing the HTTP
|
||||
# status on stdout. Separate from forge_api because these two call sites
|
||||
# must SEE a 404 rather than have it collapsed into a failure.
|
||||
forgejo_read_code() {
|
||||
local endpoint="$1" body="$2" base token hdr rc
|
||||
base="$(forgejo_api_base)" || return 1
|
||||
token="${GH_TOKEN:-${GITHUB_TOKEN:-${FORGEJO_TOKEN:-}}}"
|
||||
hdr="$(mktemp)"
|
||||
curl -sS -D "$hdr" -o "$body" -H "Authorization: token $token" "$base/$endpoint"
|
||||
rc=$?
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
rm -f "$hdr"
|
||||
echo "forge: GET $endpoint failed to send" >&2
|
||||
return 1
|
||||
fi
|
||||
tr -d '\r' <"$hdr" | awk '/^HTTP\// { c = $2 } END { print c }'
|
||||
rm -f "$hdr"
|
||||
}
|
||||
|
||||
# forge_release_exists <tag> — prints `yes` or `no`. A non-zero exit means
|
||||
# the read did not complete and the answer is UNKNOWN; the caller must not
|
||||
# treat that as `no` (#191).
|
||||
forge_release_exists() {
|
||||
local tag="${1:?forge_release_exists: tag required}" body code
|
||||
body="$(mktemp)"
|
||||
code="$(forgejo_read_code "repos/$REPO/releases/tags/$tag" "$body")" || { rm -f "$body"; return 1; }
|
||||
rm -f "$body"
|
||||
case "$code" in
|
||||
2*) echo yes ;;
|
||||
404) echo no ;;
|
||||
*)
|
||||
echo "forge_release_exists: HTTP $code reading release '$tag' — the answer is unknown, not 'no'" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# forge_commit_pulls <sha> — the pull requests whose merge produced <sha>, as
|
||||
# a JSON ARRAY in GitHub's shape. An empty array is a completed read that
|
||||
# found nothing; a non-zero exit is a read that did not complete.
|
||||
forge_commit_pulls() {
|
||||
local sha="${1:?forge_commit_pulls: sha required}" body code out
|
||||
body="$(mktemp)"
|
||||
code="$(forgejo_read_code "repos/$REPO/commits/$sha/pull" "$body")" || { rm -f "$body"; return 1; }
|
||||
case "$code" in
|
||||
2*)
|
||||
# One object -> a one-element array, so the call site's jq is the
|
||||
# same expression it runs against GitHub.
|
||||
if ! out="$(jq -c '[.]' <"$body" 2>/dev/null)"; then
|
||||
rm -f "$body"
|
||||
echo "forge_commit_pulls: unreadable JSON for '$sha'" >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s\n' "$out"
|
||||
;;
|
||||
404) printf '[]\n' ;;
|
||||
*)
|
||||
rm -f "$body"
|
||||
echo "forge_commit_pulls: HTTP $code reading the PR for '$sha' — the answer is unknown, not 'none'" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
rm -f "$body"
|
||||
}
|
||||
|
||||
# --- the release door's writes (#191) -------------------------------------
|
||||
# Confirmed against this instance's own swagger, 2026-08-04:
|
||||
#
|
||||
# POST /repos/{o}/{r}/tags -> exists (tag creation)
|
||||
# GET /repos/{o}/{r}/git/refs -> GET ONLY (no POST)
|
||||
# POST /repos/{o}/{r}/releases -> exists
|
||||
# POST /repos/{o}/{r}/releases/{id}/assets -> exists
|
||||
#
|
||||
# The asymmetry worth naming: GitHub creates a tag by POSTing a ref to
|
||||
# /git/refs; Forgejo does not serve POST there at all and creates tags at
|
||||
# /tags instead. A 1:1 port of the gh call would 404 forever.
|
||||
|
||||
# forge_tag_create <tag> <sha>
|
||||
forge_tag_create() {
|
||||
local tag="${1:?forge_tag_create: tag required}" sha="${2:?forge_tag_create: sha required}"
|
||||
forgejo_write POST "repos/$REPO/tags" \
|
||||
"$(jq -nc --arg t "$tag" --arg s "$sha" '{tag_name:$t,target:$s}')" >/dev/null
|
||||
}
|
||||
|
||||
# forgejo_urlencode <string> — percent-encode one query VALUE. jq is already
|
||||
# a hard dependency of this backend, and @uri is its one correct answer; a
|
||||
# hand-rolled sed class is how the next unescaped character gets through.
|
||||
forgejo_urlencode() {
|
||||
jq -rn --arg s "${1-}" '$s|@uri'
|
||||
}
|
||||
|
||||
# forge_release_create <tag> <title> <notes-file> [asset…] — publishes, then
|
||||
# uploads each asset to the created release. The release id comes back from
|
||||
# the create, so no second lookup is needed.
|
||||
forge_release_create() {
|
||||
local tag="${1:?forge_release_create: tag required}" title="${2:?forge_release_create: title required}"
|
||||
local notes="${3:?forge_release_create: notes file required}" out id base token
|
||||
shift 3
|
||||
out="$(forgejo_write POST "repos/$REPO/releases" \
|
||||
"$(jq -nc --arg t "$tag" --arg n "$title" --rawfile b "$notes" \
|
||||
'{tag_name:$t,name:$n,body:$b,draft:false,prerelease:false}')")" || return 1
|
||||
id="$(printf '%s' "$out" | jq -r '.id // empty')"
|
||||
[ -n "$id" ] || { echo "forge_release_create: the create returned no release id" >&2; return 1; }
|
||||
[ "$#" -gt 0 ] || return 0
|
||||
base="$(forgejo_api_base)" || return 1
|
||||
token="${GH_TOKEN:-${GITHUB_TOKEN:-${FORGEJO_TOKEN:-}}}"
|
||||
local f name
|
||||
for f in "$@"; do
|
||||
[ -e "$f" ] || continue
|
||||
# The asset name is a QUERY VALUE, and the hook contract permits any
|
||||
# file the consumer drops in RELEASE_ASSETS_DIR. Raw interpolation broke
|
||||
# on a space (curl exits 3 on the malformed URL) and silently changed
|
||||
# the name on '&', '#', '+' and '%' — `gh release create` handled those,
|
||||
# so a 1:1 port had to as well (#191, found by @codex on !193).
|
||||
name="$(forgejo_urlencode "$(basename "$f")")"
|
||||
curl -sS -f -X POST -H "Authorization: token $token" \
|
||||
-F "attachment=@$f" \
|
||||
"$base/repos/$REPO/releases/$id/assets?name=$name" >/dev/null \
|
||||
|| { echo "forge_release_create: asset upload failed for '$f'" >&2; return 1; }
|
||||
done
|
||||
}
|
||||
|
||||
# forge_pr_create <head> <base> <title> <body> <label…> — POST /pulls takes
|
||||
# label IDs, not names (the same asymmetry the issue-label writes carry), so
|
||||
# the names are resolved first through forgejo_label_ids.
|
||||
forge_pr_create() {
|
||||
local head="${1:?forge_pr_create: head required}" base="${2:?forge_pr_create: base required}"
|
||||
local title="${3:?forge_pr_create: title required}" body="${4:?forge_pr_create: body required}"
|
||||
shift 4
|
||||
local ids='[]' map name id
|
||||
if [ "$#" -gt 0 ]; then
|
||||
map="$(forgejo_label_ids)" || return 1
|
||||
ids='['
|
||||
for name in "$@"; do
|
||||
id="$(printf '%s\n' "$map" | awk -F'\t' -v n="$name" '$1 == n { print $2; exit }')"
|
||||
[ -n "$id" ] || { echo "forge_pr_create: no label '$name' in this repo" >&2; return 1; }
|
||||
ids="$ids$id,"
|
||||
done
|
||||
ids="${ids%,}]"
|
||||
fi
|
||||
forgejo_write POST "repos/$REPO/pulls" \
|
||||
"$(jq -nc --arg h "$head" --arg b "$base" --arg t "$title" --arg d "$body" \
|
||||
--argjson l "$ids" '{head:$h,base:$b,title:$t,body:$d,labels:$l}')" >/dev/null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -156,3 +156,80 @@ forge_pr_activity() {
|
|||
forge_api --paginate "repos/$REPO/pulls/$n/comments" --jq '.[].created_at' || return 1
|
||||
forge_api --paginate "repos/$REPO/pulls/$n/commits" --jq '.[].commit.committer.date' || return 1
|
||||
}
|
||||
|
||||
# --- the release door's facts (#191) --------------------------------------
|
||||
# The github twins of the forgejo backend's two release-door reads. Term 5
|
||||
# discipline applies: these are the `gh` calls lib/facts.sh carried before
|
||||
# the port, with one behaviour added — a read that did not complete is
|
||||
# reported as such instead of collapsing into a definite `no`.
|
||||
|
||||
# forge_release_exists <tag> — prints `yes` or `no`; non-zero exit means the
|
||||
# read did not complete and the answer is UNKNOWN (#191).
|
||||
forge_release_exists() {
|
||||
local tag="${1:?forge_release_exists: tag required}" errf err rc
|
||||
errf="$(mktemp)"
|
||||
if gh api "repos/$GITHUB_REPOSITORY/releases/tags/$tag" --jq .tag_name >/dev/null 2>"$errf"; then
|
||||
rm -f "$errf"
|
||||
echo yes
|
||||
return 0
|
||||
fi
|
||||
rc=$?
|
||||
err="$(cat "$errf")"; rm -f "$errf"
|
||||
# gh's 404 text is stable and is the only failure that is an ANSWER.
|
||||
case "$err" in
|
||||
*"HTTP 404"*) echo no; return 0 ;;
|
||||
esac
|
||||
echo "forge_release_exists: gh exited $rc reading release '$tag' — the answer is unknown, not 'no': $err" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# forge_commit_pulls <sha> — the pull requests whose merge produced <sha>, as
|
||||
# a JSON array. GitHub serves the array directly; the forgejo twin builds
|
||||
# one from its single-object endpoint so this call site is identical.
|
||||
forge_commit_pulls() {
|
||||
local sha="${1:?forge_commit_pulls: sha required}" errf out rc err
|
||||
errf="$(mktemp)"
|
||||
if out="$(gh api "repos/$GITHUB_REPOSITORY/commits/$sha/pulls" 2>"$errf")"; then
|
||||
rm -f "$errf"
|
||||
printf '%s\n' "$out"
|
||||
return 0
|
||||
fi
|
||||
rc=$?
|
||||
err="$(cat "$errf")"; rm -f "$errf"
|
||||
case "$err" in
|
||||
*"HTTP 404"*) printf '[]\n'; return 0 ;;
|
||||
esac
|
||||
echo "forge_commit_pulls: gh exited $rc reading the PRs for '$sha' — the answer is unknown, not 'none': $err" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# --- the release door's writes (#191) -------------------------------------
|
||||
# The gh calls the workflow carried before the port, moved behind the shim
|
||||
# so the call sites stop naming a client. Term 5: same flags, same order.
|
||||
|
||||
# forge_tag_create <tag> <sha>
|
||||
forge_tag_create() {
|
||||
local tag="${1:?forge_tag_create: tag required}" sha="${2:?forge_tag_create: sha required}"
|
||||
gh api "repos/$GITHUB_REPOSITORY/git/refs" -f "ref=refs/tags/$tag" -f "sha=$sha" >/dev/null
|
||||
}
|
||||
|
||||
# forge_release_create <tag> <title> <notes-file> [asset…]
|
||||
forge_release_create() {
|
||||
local tag="${1:?forge_release_create: tag required}" title="${2:?forge_release_create: title required}"
|
||||
local notes="${3:?forge_release_create: notes file required}"
|
||||
shift 3
|
||||
gh release create "$tag" --verify-tag --title "$title" \
|
||||
--notes-file "$notes" -R "$GITHUB_REPOSITORY" "$@"
|
||||
}
|
||||
|
||||
# forge_pr_create <head> <base> <title> <body> <label…> — the release's
|
||||
# bump-fallback PR (#191). gh takes repeated --label flags.
|
||||
forge_pr_create() {
|
||||
local head="${1:?forge_pr_create: head required}" base="${2:?forge_pr_create: base required}"
|
||||
local title="${3:?forge_pr_create: title required}" body="${4:?forge_pr_create: body required}"
|
||||
shift 4
|
||||
local args=() l
|
||||
for l in "$@"; do args+=(--label "$l"); done
|
||||
gh pr create -R "$GITHUB_REPOSITORY" --head "$head" --base "$base" \
|
||||
--title "$title" --body "$body" "${args[@]}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -126,6 +126,12 @@ FORGE_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||
# environment decides via forge_detect.
|
||||
forge_select() {
|
||||
local forge="${1:-}"
|
||||
# One default for every consumer: the forgejo backend addresses the repo
|
||||
# through REPO, the github backend reads GITHUB_REPOSITORY. Defaulting
|
||||
# here means no call site — workflow step or script — can forget it and
|
||||
# get a repo-less path (#191). The reconcilers still assert their own.
|
||||
REPO="${REPO:-${GITHUB_REPOSITORY:-}}"
|
||||
export REPO
|
||||
if [ -z "$forge" ]; then
|
||||
forge="$(forge_detect)" || return 1
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -29,20 +29,39 @@ ZEROS="0000000000000000000000000000000000000000"
|
|||
mkdir -p "$TMP/stub"
|
||||
cat >"$TMP/stub/gh" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
# Every mode below answers the call shape the shim now makes (#191):
|
||||
# labeled -> gh api repos/{r}/commits/{sha}/pulls (a JSON ARRAY)
|
||||
# released -> gh api repos/{r}/releases/tags/{tag}
|
||||
# The *-unreadable modes are the ones that matter: they fail the way a real
|
||||
# client fails when it cannot reach the forge, and must NOT be reported as a
|
||||
# definite answer.
|
||||
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
|
||||
labeled-yes)
|
||||
echo '[{"merged_at":"2026-01-01T00:00:00Z","labels":[{"name":"release"}]}]'
|
||||
;;
|
||||
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
|
||||
labeled-no)
|
||||
echo '[{"merged_at":"2026-01-01T00:00:00Z","labels":[{"name":"enhancement"}]}]'
|
||||
;;
|
||||
labeled-none)
|
||||
# A completed read that found no PR at all — still an answer.
|
||||
echo '[]'
|
||||
;;
|
||||
labeled-unmerged)
|
||||
# A PR carrying the label but never merged: the label alone is not a
|
||||
# ceremony (the `merged_at != null` half of the contract).
|
||||
echo '[{"merged_at":null,"labels":[{"name":"release"}]}]'
|
||||
;;
|
||||
labeled-unreadable | released-unreadable)
|
||||
echo "gh: Connection refused (HTTP 000)" >&2
|
||||
exit 1
|
||||
;;
|
||||
released-yes)
|
||||
echo "$2" | grep -q 'releases/tags/' || { echo "gh stub: expected a releases/tags read, got: gh $*" >&2; exit 97; }
|
||||
echo "0.0.0"
|
||||
;;
|
||||
released-no)
|
||||
echo "gh: Not Found (HTTP 404)" >&2
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
echo "gh stub: gh must not be called in this state (gh $*)" >&2
|
||||
|
|
@ -74,6 +93,7 @@ facts_in() {
|
|||
shift
|
||||
(cd "$TMP/$dir" \
|
||||
&& env PATH="$TMP/stub:$PATH" GITHUB_REPOSITORY=fixture/fixture GH_TOKEN=stub \
|
||||
CEREMONY_FORGE=github \
|
||||
"$@" bash "$FACTS")
|
||||
}
|
||||
|
||||
|
|
@ -214,4 +234,37 @@ 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"
|
||||
|
||||
# --- #191: a read that did not complete is not an answer ------------------
|
||||
# The bug this suite missed before: lib/facts.sh turned ANY failure of the
|
||||
# label read into `labeled=no`, and decide's row 5 then refused a correctly
|
||||
# labeled, correctly merged ceremony PR as "a bare push". On a Forgejo
|
||||
# runner — no `gh` on the image — that was every release. Measured twice in
|
||||
# the 0.4.1 drill (drills/0.4.1.md) before it was fixed.
|
||||
#
|
||||
# The contract now: a COMPLETED read that finds nothing is still `no` and
|
||||
# still fail-closed; a read that could not complete refuses, loudly, and
|
||||
# emits no fact at all.
|
||||
|
||||
check "a completed read with no PR behind the commit is labeled=no" 0 "labeled=no" \
|
||||
facts_in ceremony VERSION_SOURCE=file MERGE_SHA="$head_sha" EVENT_BEFORE="$base_sha" GH_STUB=labeled-none
|
||||
check "a labeled but UNMERGED PR is labeled=no" 0 "labeled=no" \
|
||||
facts_in ceremony VERSION_SOURCE=file MERGE_SHA="$head_sha" EVENT_BEFORE="$base_sha" GH_STUB=labeled-unmerged
|
||||
|
||||
check "an unreadable label read refuses instead of saying no" 1 "refusing rather than reporting 'no label'" \
|
||||
facts_in ceremony VERSION_SOURCE=file MERGE_SHA="$head_sha" EVENT_BEFORE="$base_sha" GH_STUB=labeled-unreadable
|
||||
# ...and emits no fact: a refusal that still printed labeled=no would be the
|
||||
# same bug wearing a diagnostic.
|
||||
check "the refusal emits no labeled fact at all" 1 "" \
|
||||
facts_in ceremony VERSION_SOURCE=file MERGE_SHA="$head_sha" EVENT_BEFORE="$base_sha" GH_STUB=labeled-unreadable
|
||||
if facts_in ceremony VERSION_SOURCE=file MERGE_SHA="$head_sha" EVENT_BEFORE="$base_sha" GH_STUB=labeled-unreadable 2>/dev/null | grep -q '^labeled='; then
|
||||
echo "FAIL: the refusal printed a labeled= line" >&2
|
||||
FAIL=$((FAIL + 1))
|
||||
else
|
||||
echo "ok: no labeled= line survives the refusal"
|
||||
PASS=$((PASS + 1))
|
||||
fi
|
||||
|
||||
check "an unreadable release read refuses instead of saying no" 1 "refusing rather than reporting 'no'" \
|
||||
facts_in window VERSION_SOURCE=file MERGE_SHA="$win_head" EVENT_BEFORE="$win_base" GH_STUB=released-unreadable
|
||||
|
||||
summary
|
||||
|
|
|
|||
|
|
@ -44,6 +44,12 @@ check "select with no argument reads the environment" 0 "" \
|
|||
. "$ROOT/lib/forge-github.sh"
|
||||
. "$ROOT/lib/forge-forgejo.sh"
|
||||
|
||||
# Every /api/v1 call ceremony makes is repo-scoped, and since #191 the
|
||||
# backend refuses to build `repos//…` — so the suite names a repo up front,
|
||||
# the way every real caller does.
|
||||
REPO=o/r
|
||||
export REPO
|
||||
|
||||
check "github: a bare path gets a query" 0 "" \
|
||||
eq 'repos/o/r/issues?per_page=100' github_page_url 'repos/o/r/issues'
|
||||
check "github: an existing query is preserved" 0 "" \
|
||||
|
|
@ -616,4 +622,137 @@ check "...and never fetches a zero-comment review" 1 "" \
|
|||
check "...and never hits the flat /pulls/{n}/comments endpoint" 1 "" \
|
||||
grep -E '/pulls/[0-9]+/comments(\?|$)' "$activity_calls"
|
||||
|
||||
# --- the release door's verbs, both backends (#191) -----------------------
|
||||
# The five verbs the release path now goes through. These carry two measured
|
||||
# asymmetries that would 404 forever if wrong, and neither is visible to a
|
||||
# github-only suite:
|
||||
#
|
||||
# PRs behind a commit GitHub GET /commits/{sha}/pulls -> ARRAY
|
||||
# Forgejo GET /commits/{sha}/pull -> ONE OBJECT
|
||||
# (the plural 404s)
|
||||
# tag creation GitHub POST /git/refs
|
||||
# Forgejo POST /tags (/git/refs is GET-only)
|
||||
|
||||
# release_stub <code> <body> — a curl stub answering one canned response and
|
||||
# recording the method+path it was asked for.
|
||||
release_stub() {
|
||||
# Globals, not locals: the curl closure below runs long after this
|
||||
# function returns, exactly as stub_writes does above.
|
||||
STUB_CODE="$1" STUB_BODY="$2"
|
||||
: >"$WRITES"
|
||||
# shellcheck disable=SC2317 # invoked indirectly, by the forge verbs
|
||||
curl() {
|
||||
local hdr="" out="" method=GET url="" payload=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-D) hdr="$2"; shift ;;
|
||||
-o) out="$2"; shift ;;
|
||||
-X) method="$2"; shift ;;
|
||||
-d) payload="$2"; shift ;;
|
||||
-F) payload="$payload -F $2"; shift ;;
|
||||
-H) shift ;;
|
||||
-*) ;;
|
||||
*) url="$1" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
[ -n "$hdr" ] && printf 'HTTP/1.1 %s x\r\n\r\n' "$STUB_CODE" >"$hdr"
|
||||
[ -n "$out" ] && printf '%s' "$STUB_BODY" >"$out"
|
||||
printf '%s %s %s\n' "$method" "${url##*/api/v1/}" "$payload" >>"$WRITES"
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
GITHUB_API_URL=https://forge.example/api/v1
|
||||
export GITHUB_API_URL
|
||||
|
||||
# Helpers so the assertions run in THIS shell, where the verbs are defined.
|
||||
pulls_is_array() { forge_commit_pulls "$1" | jq -e 'type == "array" and length == 1' >/dev/null && echo array-of-1; }
|
||||
writes_after() { "$@" >/dev/null 2>&1; cat "$WRITES"; }
|
||||
repo_empty_release() { REPO='' forge_release_exists 1.2.3; }
|
||||
repo_empty_pulls() { REPO='' forge_commit_pulls deadbeef; }
|
||||
|
||||
release_stub 200 '{"number":7,"merged_at":"2026-01-01T00:00:00Z","labels":[{"name":"release"}]}'
|
||||
check "forgejo: one PR object becomes a one-element array" 0 '"number":7' \
|
||||
forge_commit_pulls deadbeef
|
||||
check "forgejo: the array is what the call site's jq expects" 0 "array-of-1" \
|
||||
pulls_is_array deadbeef
|
||||
check "forgejo: it reads the SINGULAR path" 0 "commits/deadbeef/pull " \
|
||||
writes_after forge_commit_pulls deadbeef
|
||||
|
||||
release_stub 404 '{"message":"pull request does not exist"}'
|
||||
check "forgejo: 404 is an empty array, not a failure" 0 "[]" forge_commit_pulls deadbeef
|
||||
|
||||
release_stub 500 '{}'
|
||||
check "forgejo: a 500 refuses rather than saying 'none'" 1 "the answer is unknown, not 'none'" \
|
||||
forge_commit_pulls deadbeef
|
||||
|
||||
release_stub 200 '{"tag_name":"1.2.3"}'
|
||||
check "forgejo: a present release is yes" 0 "yes" forge_release_exists 1.2.3
|
||||
release_stub 404 '{}'
|
||||
check "forgejo: an absent release is no" 0 "no" forge_release_exists 1.2.3
|
||||
release_stub 503 '{}'
|
||||
check "forgejo: an unreadable release refuses, not 'no'" 1 "the answer is unknown, not 'no'" \
|
||||
forge_release_exists 1.2.3
|
||||
|
||||
# THE MUST-FAIL (#191, found by @kimi on !193 before it shipped): with REPO
|
||||
# empty every path becomes repos//… , whose 404 would read as a fact — "no"
|
||||
# and "[]" with rc 0. That is the bug this issue exists to remove.
|
||||
release_stub 404 '{}'
|
||||
check "REPO empty refuses instead of fabricating 'no'" 1 "refusing to address 'repos//" \
|
||||
repo_empty_release
|
||||
check "REPO empty refuses instead of fabricating '[]'" 1 "refusing to address 'repos//" \
|
||||
repo_empty_pulls
|
||||
|
||||
release_stub 201 '{"id":42}'
|
||||
check "forgejo: a tag is created at /tags, not /git/refs" 0 "POST repos/o/r/tags" \
|
||||
writes_after forge_tag_create 1.2.3 cafebabe
|
||||
release_stub 201 '{"id":42}'
|
||||
check "forgejo: the tag body names the target sha" 0 '"target":"cafebabe"' \
|
||||
writes_after forge_tag_create 1.2.3 cafebabe
|
||||
|
||||
printf 'notes body\n' >"$TMP/notes.md"
|
||||
release_stub 201 '{"id":42}'
|
||||
check "forgejo: the publish POSTs to /releases with the notes as body" 0 '"body":"notes body' \
|
||||
writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md"
|
||||
|
||||
# Assets: the hook contract permits any filename the consumer drops in
|
||||
# RELEASE_ASSETS_DIR, and the asset name travels as a QUERY VALUE. Raw
|
||||
# interpolation exits 3 on a space and silently renames on '&' / '#' / '+' /
|
||||
# '%' — `gh release create` handled those, so the forgejo twin must too
|
||||
# (#191, @codex on !193).
|
||||
check "the encoder escapes a space" 0 "release%20asset.tgz" \
|
||||
forgejo_urlencode 'release asset.tgz'
|
||||
check "the encoder escapes the query delimiters" 0 "a%26b%23c%2Bd%25e.tgz" \
|
||||
forgejo_urlencode 'a&b#c+d%e.tgz'
|
||||
|
||||
printf 'x\n' >"$TMP/release asset.tgz"
|
||||
printf 'y\n' >"$TMP/a&b.tgz"
|
||||
release_stub 201 '{"id":42}'
|
||||
check "an asset with a space uploads under the encoded name" 0 "assets?name=release%20asset.tgz" \
|
||||
writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/release asset.tgz"
|
||||
release_stub 201 '{"id":42}'
|
||||
check "an asset with '&' does not become two parameters" 0 "assets?name=a%26b.tgz" \
|
||||
writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
|
||||
release_stub 201 '{"id":42}'
|
||||
check "the upload targets the created release id" 0 "releases/42/assets" \
|
||||
writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
|
||||
release_stub 201 '{"id":42}'
|
||||
check "the asset rides as a multipart attachment" 0 "attachment=@" \
|
||||
writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
|
||||
|
||||
# --- the github twins address their own paths ----------------------------
|
||||
. "$ROOT/lib/forge-github.sh"
|
||||
GITHUB_REPOSITORY=o/r
|
||||
export GITHUB_REPOSITORY
|
||||
GH_CALLS="$TMP/ghcalls"
|
||||
# shellcheck disable=SC2317 # invoked indirectly, by the forge verbs
|
||||
gh() { printf '%s\n' "$*" >>"$GH_CALLS"; case "$*" in *commits/*) echo '[]' ;; esac; return 0; }
|
||||
gh_after() { : >"$GH_CALLS"; "$@" >/dev/null 2>&1; cat "$GH_CALLS"; }
|
||||
|
||||
check "github: the tag goes to /git/refs" 0 "git/refs" \
|
||||
gh_after forge_tag_create 1.2.3 cafebabe
|
||||
check "github: PRs behind a commit use the PLURAL path" 0 "commits/deadbeef/pulls" \
|
||||
gh_after forge_commit_pulls deadbeef
|
||||
|
||||
summary
|
||||
|
|
|
|||
|
|
@ -29,7 +29,11 @@ trap 'rm -rf "$TMP"' EXIT
|
|||
mkdir -p "$TMP/stub"
|
||||
cat >"$TMP/stub/gh" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
if [ "$1" = api ]; then echo true; exit 0; fi
|
||||
# The label read is now GET commits/{sha}/pulls, a JSON array (#191).
|
||||
if [ "$1" = api ]; then
|
||||
echo '[{"merged_at":"2026-01-01T00:00:00Z","labels":[{"name":"release"}]}]'
|
||||
exit 0
|
||||
fi
|
||||
echo "gh stub: unexpected call: gh $*" >&2
|
||||
exit 97
|
||||
EOF
|
||||
|
|
@ -83,6 +87,7 @@ chain() {
|
|||
(
|
||||
cd "${3:-$TMP/repo}" || exit 1
|
||||
facts_out="$(env PATH="${4:-$TMP/stub}:$PATH" GITHUB_REPOSITORY=fixture/fixture \
|
||||
CEREMONY_FORGE=github \
|
||||
GH_TOKEN=stub VERSION_SOURCE=file MERGE_SHA="$1" EVENT_BEFORE="$2" \
|
||||
bash "$FACTS")" || exit 1
|
||||
printf '%s\n' "$facts_out"
|
||||
|
|
|
|||
Loading…
Reference in a new issue