Compare commits
No commits in common. "bf1666bd4f638ad46fe57a0725202f7dcaf0f85c" and "84bb1a424dfe467358e99d4c6ab4e76ac89cc328" have entirely different histories.
bf1666bd4f
...
84bb1a424d
25 changed files with 1007 additions and 1943 deletions
107
.github/workflows/ci.yml
vendored
107
.github/workflows/ci.yml
vendored
|
|
@ -50,3 +50,110 @@ jobs:
|
||||||
# push+refs/heads/main event — the merge door's exact gate — opening a
|
# 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
|
# live door from CI. A pull_request event can never satisfy either
|
||||||
# door's `if:`.
|
# door's `if:`.
|
||||||
|
release-exercise:
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
uses: ./.github/workflows/release-exercise.yml
|
||||||
|
|
||||||
|
# The self-guards (issue #11): this repo eats exactly what it serves. The
|
||||||
|
# guard actions run against the REAL tree — VERSION, CHANGELOG.md,
|
||||||
|
# drills/, .github/workflows/ — through the same `uses:` steps every
|
||||||
|
# consumer's CI carries.
|
||||||
|
# These steps are also the composite-action wiring proof (issue #5's
|
||||||
|
# acceptance criterion: action.yml resolving, $GITHUB_ACTION_PATH, the
|
||||||
|
# relative lib sourcing) that action-exercise carried with scratch files
|
||||||
|
# while this repo had no tree of its own to guard; the armed and
|
||||||
|
# drill-recorded scratch steps moved here per the armed step's own
|
||||||
|
# eviction note — the file backend hardcodes the VERSION name, so a
|
||||||
|
# scratch write would SHADOW the real file, not sit beside it.
|
||||||
|
self-guards:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# The monotonic guard compares HEAD against the merge base; a
|
||||||
|
# shallow checkout cannot resolve it, and in CI that is a hard
|
||||||
|
# failure, not a skip (the action's description).
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: ./actions/changelog-armed
|
||||||
|
- uses: ./actions/changelog-monotonic
|
||||||
|
- uses: ./actions/changelog-assembled
|
||||||
|
- uses: ./actions/drill-recorded
|
||||||
|
- uses: ./actions/runner-isolated
|
||||||
|
|
||||||
|
# Exercises changelog-monotonic the way a consumer does, against a
|
||||||
|
# CONSTRUCTED history. The self-guards job above runs the same action on
|
||||||
|
# the real tree, but there its containment half is only as interesting as
|
||||||
|
# the PR's own diff; this job commits a known base and an insert-above
|
||||||
|
# edit on top, so a real, non-vacuous containment run is standing
|
||||||
|
# evidence on every PR. (Armed and drill-recorded moved to self-guards —
|
||||||
|
# the real tree now exercises them; monotonic stays because it reads no
|
||||||
|
# version source, so it is immune to the VERSION-shadowing problem that
|
||||||
|
# evicted the other two.)
|
||||||
|
action-exercise:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Construct a scratch history for the monotonic guard
|
||||||
|
# The monotonic guard's input is a DIFF, so its exercise needs
|
||||||
|
# history, not just a file: commit a scratch changelog, mark that
|
||||||
|
# commit as the fixture base, then commit an insert-above edit on
|
||||||
|
# top — a real containment run, not just an action.yml parse. The
|
||||||
|
# base ref is the in-job branch, passed explicitly, because this
|
||||||
|
# job's shallow PR checkout carries no origin/main for the input's
|
||||||
|
# default to resolve (consumers get that via fetch-depth: 0, per
|
||||||
|
# the action's description). Scratch-named file so the real
|
||||||
|
# CHANGELOG.md is never shadowed; the commits live only in this
|
||||||
|
# job's checkout and are never pushed.
|
||||||
|
run: |
|
||||||
|
git config user.name ceremony-ci
|
||||||
|
git config user.email ceremony-ci@users.noreply.github.com
|
||||||
|
printf '# Changelog\n\n## Unreleased\n\n## 0.1.0 — 2026-07-01\n\n- Shipped entry.\n' > CHANGELOG.monotonic.scratch.md
|
||||||
|
git add CHANGELOG.monotonic.scratch.md
|
||||||
|
git commit -m 'fixture: monotonic base'
|
||||||
|
git branch monotonic-fixture-base
|
||||||
|
printf '# Changelog\n\n## Unreleased\n\n- Entry inserted above.\n\n## 0.1.0 — 2026-07-01\n\n- Shipped entry.\n' > CHANGELOG.monotonic.scratch.md
|
||||||
|
git commit -am 'fixture: insert above'
|
||||||
|
- uses: ./actions/changelog-monotonic
|
||||||
|
with:
|
||||||
|
changelog: CHANGELOG.monotonic.scratch.md
|
||||||
|
base-ref: monotonic-fixture-base
|
||||||
|
|
||||||
|
# Exercises actions/docs-sync the way a consumer does (issue #19's
|
||||||
|
# acceptance criterion). Its own job, unlike the exercises above: the
|
||||||
|
# composite reads the CONSUMER's tree at the workspace root, and a
|
||||||
|
# `uses:` step cannot change directory — so the fixture consumer must BE
|
||||||
|
# the workspace root, with ceremony itself checked out to a subdirectory
|
||||||
|
# (that path also serves as the action reference and the --source
|
||||||
|
# override; no ref carrying docs/VENDORED.txt exists to fetch until this
|
||||||
|
# lands, and the exercised bytes should be THIS PR's anyway).
|
||||||
|
docs-sync-exercise:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
path: ceremony-src
|
||||||
|
- name: Construct a fixture consumer at the workspace root
|
||||||
|
# The pin ref is scratch — --source overrides the fetch, but the
|
||||||
|
# pin line itself is still parsed and required (one pin governs
|
||||||
|
# machinery and doctrine; a consumer without one has nothing for
|
||||||
|
# the mirror to be verified against).
|
||||||
|
run: |
|
||||||
|
mkdir -p .github/workflows
|
||||||
|
printf '%s\n' \
|
||||||
|
'name: release' \
|
||||||
|
'on:' \
|
||||||
|
' push:' \
|
||||||
|
' branches: [main]' \
|
||||||
|
'jobs:' \
|
||||||
|
' release:' \
|
||||||
|
' uses: heavy-duty/ceremony/.github/workflows/release.yml@0.0.0-fixture' \
|
||||||
|
> .github/workflows/release.yml
|
||||||
|
- name: Bootstrap the mirror (--fix)
|
||||||
|
uses: ./ceremony-src/actions/docs-sync
|
||||||
|
with:
|
||||||
|
mode: fix
|
||||||
|
source: ceremony-src
|
||||||
|
- name: Verify the mirror (--check, the mode consumers run)
|
||||||
|
uses: ./ceremony-src/actions/docs-sync
|
||||||
|
with:
|
||||||
|
source: ceremony-src
|
||||||
|
|
|
||||||
149
.github/workflows/labels.yml
vendored
Normal file
149
.github/workflows/labels.yml
vendored
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
name: labels
|
||||||
|
# Reusable half of the labels automation. Triggers and permissions live in
|
||||||
|
# the caller; docs/CONSUMERS.md carries the complete caller stub.
|
||||||
|
#
|
||||||
|
# The caller uses pull_request_target, not pull_request: every PR in this
|
||||||
|
# family arrives from a fork, where pull_request runs with a READ-ONLY token
|
||||||
|
# and cannot label anything. _target is safe in this workflow because no PR
|
||||||
|
# code is ever checked out or executed — scope reads changed paths and the
|
||||||
|
# path mapping via the API and checks out only the ceremony implementation,
|
||||||
|
# and reconcile checks out the BASE branch only. Keep it that way.
|
||||||
|
#
|
||||||
|
# There is no pull_request_review_target, so a review landing cannot wake this
|
||||||
|
# workflow directly — which is why the caller's cron is load-bearing, not a
|
||||||
|
# safety net (#199 relaxed it from */15 to hourly, but did NOT drop it). The
|
||||||
|
# cron is the sweep's only discovery path for every transition no subscribed
|
||||||
|
# event carries: a verdict landing, blocker:ci-red set/cleared, a
|
||||||
|
# blocker:conflict when another PR merges under this one, and the time-based
|
||||||
|
# stale / 48h claim-reclaim. Where an event IS subscribed the wake is direct —
|
||||||
|
# the handoff sets state:needs-human and the caller's `labeled` event confirms
|
||||||
|
# or corrects that optimistic write within seconds.
|
||||||
|
#
|
||||||
|
# This cannot loop: reconciler writes use GITHUB_TOKEN, and GitHub does not
|
||||||
|
# create workflow runs from GITHUB_TOKEN-triggered events. Agent writes use a
|
||||||
|
# PAT and therefore do trigger — exactly the asymmetry wanted.
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
|
||||||
|
env:
|
||||||
|
# A called workflow arrives without its repository. Keep this literal pin
|
||||||
|
# aligned with the ceremony release consumed by callers (issue #9 D3).
|
||||||
|
CEREMONY_SELF_REF: "0.4.0"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
scope:
|
||||||
|
# Not on labeled/unlabeled: those events change no paths, so scope has
|
||||||
|
# nothing new to derive — and label churn is precisely what they are.
|
||||||
|
# review_requested/review_request_removed likewise change no paths — they
|
||||||
|
# exist to wake reconcile (#137) — and running labeler on them widens
|
||||||
|
# exactly the window #130 documents, where a label written during a
|
||||||
|
# scope run is clobbered.
|
||||||
|
if: >-
|
||||||
|
github.event_name == 'pull_request_target' &&
|
||||||
|
github.event.action != 'labeled' &&
|
||||||
|
github.event.action != 'unlabeled' &&
|
||||||
|
github.event.action != 'review_requested' &&
|
||||||
|
github.event.action != 'review_request_removed'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
concurrency:
|
||||||
|
group: labels-scope-${{ github.event.pull_request.number }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
steps:
|
||||||
|
# actions/labeler@v5 held this seat until #130. Even with
|
||||||
|
# sync-labels: false it wrote the WHOLE label set — PUT of
|
||||||
|
# (labels-fetched-at-job-start ∪ derived) — so a label applied while
|
||||||
|
# the job ran was silently removed: ceremony#128 lost its `release`,
|
||||||
|
# the merge door's declared-intent read, two seconds after the
|
||||||
|
# builder set it. v6/v7 write the same way, so the step was replaced
|
||||||
|
# rather than repinned. labels-scope reads the consumer's
|
||||||
|
# .github/labeler.yml and the changed paths via the API, and its
|
||||||
|
# only write is an additive POST of the derived scopes: a label
|
||||||
|
# applied mid-job survives by construction.
|
||||||
|
#
|
||||||
|
# Still no PR code: both checkouts below fetch the ceremony
|
||||||
|
# implementation only. The dogfood checkout rides github.sha — the
|
||||||
|
# base-branch commit the workflow file itself came from, so the
|
||||||
|
# script and workflow can never skew — and doubles as the #11
|
||||||
|
# bootstrap: ceremony's own labels must work before any release tag
|
||||||
|
# exists for the pinned checkout to fetch.
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
if: github.repository == 'heavy-duty/ceremony'
|
||||||
|
with:
|
||||||
|
repository: ${{ github.repository }}
|
||||||
|
ref: ${{ github.sha }}
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
if: github.repository != 'heavy-duty/ceremony'
|
||||||
|
with:
|
||||||
|
repository: heavy-duty/ceremony
|
||||||
|
ref: ${{ env.CEREMONY_SELF_REF }}
|
||||||
|
- uses: ./actions/labels-scope
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
|
# the BASE branch commit — a PR must not label itself by editing
|
||||||
|
# the mapping it is judged by
|
||||||
|
CONFIG_REF: ${{ github.sha }}
|
||||||
|
|
||||||
|
reconcile:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# ONE shared group: every reconcile sweeps every open PR, so cron and
|
||||||
|
# PR-event runs must serialize or two sweeps race the same PR's labels
|
||||||
|
# and both pass the request-the-human-once guard.
|
||||||
|
concurrency:
|
||||||
|
group: labels-reconcile
|
||||||
|
cancel-in-progress: false
|
||||||
|
steps:
|
||||||
|
# pull_request_target is required for fork PR write permission. It is
|
||||||
|
# safe here because no PR code is ever checked out or executed:
|
||||||
|
# labels-scope reads the mapping and changed paths via the API, and
|
||||||
|
# reconcile checks out the BASE branch only. Keep it that way.
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: ${{ github.repository }}
|
||||||
|
ref: ${{ github.event.repository.default_branch }}
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
# The self-consumption bypass — release.yml's twin, and load-bearing
|
||||||
|
# for the same reason (#11): ceremony's own labels bootstrap must
|
||||||
|
# run BEFORE any release tag exists for this checkout to fetch — the
|
||||||
|
# release label the merge door reads is created by that dispatch, so
|
||||||
|
# without the bypass the first release deadlocks on its own pin. The
|
||||||
|
# base-branch checkout above already IS ceremony on the dogfood
|
||||||
|
# path.
|
||||||
|
if: github.repository != 'heavy-duty/ceremony'
|
||||||
|
with:
|
||||||
|
repository: heavy-duty/ceremony
|
||||||
|
ref: ${{ env.CEREMONY_SELF_REF }}
|
||||||
|
path: .ceremony-src
|
||||||
|
# Two steps, mutually exclusive `if:`s, because a `uses:` path must be
|
||||||
|
# a literal — the same fork release.yml's CEREMONY_DIR env line
|
||||||
|
# papers over for `run:` steps, which composite `uses:` has no
|
||||||
|
# equivalent of.
|
||||||
|
- name: reconcile state + stale
|
||||||
|
if: github.repository != 'heavy-duty/ceremony'
|
||||||
|
uses: ./.ceremony-src/actions/labels-reconcile
|
||||||
|
with:
|
||||||
|
bootstrap: ${{ github.event_name == 'workflow_dispatch' && 'yes' || 'no' }}
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
- name: reconcile state + stale (dogfood — the workspace IS ceremony)
|
||||||
|
if: github.repository == 'heavy-duty/ceremony'
|
||||||
|
uses: ./actions/labels-reconcile
|
||||||
|
with:
|
||||||
|
bootstrap: ${{ github.event_name == 'workflow_dispatch' && 'yes' || 'no' }}
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
- name: reconcile issue flow
|
||||||
|
if: github.repository != 'heavy-duty/ceremony'
|
||||||
|
uses: ./.ceremony-src/actions/issueflow-reconcile
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
- name: reconcile issue flow (dogfood — the workspace IS ceremony)
|
||||||
|
if: github.repository == 'heavy-duty/ceremony'
|
||||||
|
uses: ./actions/issueflow-reconcile
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
211
.github/workflows/release-exercise.yml
vendored
Normal file
211
.github/workflows/release-exercise.yml
vendored
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
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 armed the fragment way (#112) — changelog.d/ with its
|
||||||
|
# marker and one fragment — then the ceremony merge: VERSION bumped
|
||||||
|
# bare and the section stamped by the REAL assembler, the command
|
||||||
|
# the real ceremony PR runs by hand (#112 D12), so the exercise
|
||||||
|
# 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.
|
||||||
|
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
|
||||||
|
|
||||||
|
## 0.6.8 — 2026-07-01
|
||||||
|
|
||||||
|
- An older entry.
|
||||||
|
EOF
|
||||||
|
mkdir changelog.d
|
||||||
|
printf '# changelog.d/ — assembled at release (heavy-duty/ceremony#112); the marker keeps the directory tracked.\n' > changelog.d/README.md
|
||||||
|
printf -- '- The entry this release ships.\n' > changelog.d/42.md
|
||||||
|
git add VERSION CHANGELOG.md changelog.d
|
||||||
|
git commit -qm "base"
|
||||||
|
printf '0.7.0\n' > VERSION
|
||||||
|
bash "$CEREMONY_DIR/bin/changelog-assemble" 0.7.0 2026-07-21
|
||||||
|
git add -A
|
||||||
|
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"
|
||||||
|
if ! diagnosis="$(changelog_section_problem CHANGELOG.md "$VER")"; 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
|
||||||
|
printf '%s\n' "$diagnosis" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
changelog_section CHANGELOG.md "$VER" > "$RUNNER_TEMP/notes.md"
|
||||||
|
cat "$RUNNER_TEMP/notes.md"
|
||||||
|
- name: an entry-less stamped fixture is refused by the notes predicate
|
||||||
|
working-directory: ${{ runner.temp }}/fixture
|
||||||
|
env:
|
||||||
|
VER: ${{ steps.facts.outputs.ver }}
|
||||||
|
run: |
|
||||||
|
cp CHANGELOG.md "$RUNNER_TEMP/CHANGELOG.good.md"
|
||||||
|
awk -v ver="$VER" '
|
||||||
|
/^## / { in_section = ($2 == ver) }
|
||||||
|
in_section && /^[[:space:]]*[-*][[:space:]]/ { next }
|
||||||
|
{ print }
|
||||||
|
' "$RUNNER_TEMP/CHANGELOG.good.md" > CHANGELOG.md
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
. "$CEREMONY_DIR/lib/changelog.sh"
|
||||||
|
if diagnosis="$(changelog_section_problem CHANGELOG.md "$VER")"; then
|
||||||
|
echo "entry-less stamped section unexpectedly passed" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf '%s\n' "$diagnosis" | grep -F "section '$VER' has no entries"
|
||||||
|
cp "$RUNNER_TEMP/CHANGELOG.good.md" CHANGELOG.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
|
||||||
371
.github/workflows/release.yml
vendored
Normal file
371
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,371 @@
|
||||||
|
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.4.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"
|
||||||
|
if ! diagnosis="$(changelog_section_problem CHANGELOG.md "$VER")"; 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
|
||||||
|
printf '%s\n' "$diagnosis" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
changelog_section CHANGELOG.md "$VER" > "$RUNNER_TEMP/notes.md"
|
||||||
|
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"
|
||||||
|
if ! diagnosis="$(changelog_section_problem CHANGELOG.md "$VER")"; then
|
||||||
|
echo "CHANGELOG.md has no '## $VER' section — run changelog-assemble in the release PR before tagging; refusing to publish an empty release" >&2
|
||||||
|
printf '%s\n' "$diagnosis" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
changelog_section CHANGELOG.md "$VER" > "$RUNNER_TEMP/notes.md"
|
||||||
|
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[@]}"
|
||||||
55
.github/workflows/self-labels.yml
vendored
Normal file
55
.github/workflows/self-labels.yml
vendored
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
name: labels
|
||||||
|
# Ceremony's own caller for the labels automation — the dogfood of issue
|
||||||
|
# #11, wearing the same local-`uses:` deviation as self-release.yml and the
|
||||||
|
# same warning: consumers must NEVER copy the local form (it rides main,
|
||||||
|
# unpinned — correct only for the repo that IS the source). Consumers write:
|
||||||
|
# uses: heavy-duty/ceremony/.github/workflows/labels.yml@<pinned-tag>
|
||||||
|
on:
|
||||||
|
# The consumer owns this cadence (#203). Hourly is the recommended default
|
||||||
|
# when no other engine drives board state: the cron is then the sweep's ONLY
|
||||||
|
# wake for four transition classes — a review verdict landing (there is no
|
||||||
|
# pull_request_review trigger here), blocker:ci-red set or cleared (no
|
||||||
|
# check_suite/check_run/workflow_run), a blocker:conflict when ANOTHER PR
|
||||||
|
# merges under this one, and the time-based stale / 48h claim-reclaim. The
|
||||||
|
# events below carry the rest in seconds. Hourly trades ≤1h of latency on
|
||||||
|
# those four while cutting nominal scheduled sweeps from four an hour to one
|
||||||
|
# at GitHub's 1-minute billing floor. Do not delete the cron: it is their
|
||||||
|
# discovery path. If another engine writes some of those transitions, only
|
||||||
|
# the classes with no other writer bound the cadence; relax it only as that
|
||||||
|
# list shrinks.
|
||||||
|
schedule: [{cron: "0 * * * *"}]
|
||||||
|
# A manual full-board sweep, including taxonomy bootstrap on a fresh repo.
|
||||||
|
workflow_dispatch:
|
||||||
|
# Narrowed (#199) to the actions that carry a queue-state change the hourly
|
||||||
|
# cron cannot wait one cadence for — dropping only labeled/unlabeled/assigned/
|
||||||
|
# unassigned, which feed validation and the 48h claim clock (caught within one
|
||||||
|
# cadence) and were the dominant issues-churn source. Kept: `opened` (the
|
||||||
|
# mint→needs-triage check, issueflow's opened-only path), `closed` (the
|
||||||
|
# blocker-closes→ready self-heal, crew#96/#98), `edited` (a body rewrite of the
|
||||||
|
# `Blocked by #N` declaration the sweep parses — issueflow-reconcile.sh:179),
|
||||||
|
# `reopened` (a closed issue re-entering the queue wearing labels derived when
|
||||||
|
# it closed). The must-fail in #199 is exactly "a queue-state transition waits
|
||||||
|
# on the schedule when an event could have carried it", so edited/reopened stay
|
||||||
|
# on events. The PR handoff wake is pull_request_target:labeled, NOT issues, so
|
||||||
|
# this does not touch the handoff.
|
||||||
|
issues:
|
||||||
|
types: [opened, closed, edited, reopened]
|
||||||
|
pull_request_target:
|
||||||
|
# Every PR arrives from a fork, so these carry the head/draft/review facts
|
||||||
|
# the sweep derives state:* from. labeled/unlabeled are the handoff wake —
|
||||||
|
# the author's optimistic state:needs-human write, confirmed or corrected
|
||||||
|
# here in seconds (#11); synchronize re-derives on every push;
|
||||||
|
# review_requested/review_request_removed wake the sweep that clears (or
|
||||||
|
# restores) blocker:unrequested — without them the one event that makes
|
||||||
|
# the label false could not clear it, and a quiet repo wore the red flag
|
||||||
|
# until the advisory cron (#137).
|
||||||
|
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled, review_requested, review_request_removed]
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
checks: read # mergeability/check-rollup read for PR state
|
||||||
|
statuses: read # commit-status rollup read for PR state
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
jobs:
|
||||||
|
labels:
|
||||||
|
uses: ./.github/workflows/labels.yml
|
||||||
27
.github/workflows/self-release.yml
vendored
Normal file
27
.github/workflows/self-release.yml
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
name: release
|
||||||
|
# Ceremony's own caller — the dogfood of issue #11. This is the consumer
|
||||||
|
# stub from docs/CONSUMERS.md with ONE deviation, and consumers must NEVER
|
||||||
|
# copy it: `uses:` below is a LOCAL path, so every run executes THIS tree's
|
||||||
|
# release.yml. No pin is the point here — ceremony's own release cannot
|
||||||
|
# check out a tag that the very run creates (#9's self-consumption bypass
|
||||||
|
# is the same fact one layer down) — and would be a bug anywhere else: a
|
||||||
|
# consumer without a pin rides main and eats every unreleased change.
|
||||||
|
# Consumers write:
|
||||||
|
# uses: heavy-duty/ceremony/.github/workflows/release.yml@<pinned-tag>
|
||||||
|
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: ./.github/workflows/release.yml
|
||||||
|
with:
|
||||||
|
version-source: file
|
||||||
|
|
@ -27,10 +27,6 @@ TRIAGE_ACTORS=()
|
||||||
# The needs-ruling invariants (#52) — one implementation for both surfaces.
|
# The needs-ruling invariants (#52) — one implementation for both surfaces.
|
||||||
# shellcheck source=lib/ruling.sh
|
# shellcheck source=lib/ruling.sh
|
||||||
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/ruling.sh"
|
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/ruling.sh"
|
||||||
# shellcheck source=lib/forge.sh
|
|
||||||
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/forge.sh"
|
|
||||||
# shellcheck source=lib/closes_references.sh
|
|
||||||
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/closes_references.sh"
|
|
||||||
|
|
||||||
log() { printf 'issueflow: %s\n' "$*"; }
|
log() { printf 'issueflow: %s\n' "$*"; }
|
||||||
run() { if [ -n "${DRY_RUN:-}" ]; then log "DRY_RUN: $*"; else "$@"; fi; }
|
run() { if [ -n "${DRY_RUN:-}" ]; then log "DRY_RUN: $*"; else "$@"; fi; }
|
||||||
|
|
@ -275,12 +271,12 @@ offsite_resolved_decision() { # PR states on stdin -> NUDGE | QUIET
|
||||||
ensure_comment() { # $1 issue, $2 marker, $3 message
|
ensure_comment() { # $1 issue, $2 marker, $3 message
|
||||||
local n="$1" marker="$2" message="$3"
|
local n="$1" marker="$2" message="$3"
|
||||||
if issue_comment_has_marker "$n" "$marker"; then return; fi
|
if issue_comment_has_marker "$n" "$marker"; then return; fi
|
||||||
run forge_issue_comment "$n" "<!-- issueflow:$marker -->
|
run gh issue comment "$n" -R "$REPO" --body "<!-- issueflow:$marker -->
|
||||||
$message" >/dev/null
|
$message" >/dev/null
|
||||||
}
|
}
|
||||||
|
|
||||||
issue_comment_has_marker() { # $1 issue, $2 marker
|
issue_comment_has_marker() { # $1 issue, $2 marker
|
||||||
forge_api --paginate "repos/$REPO/issues/$1/comments" --jq '.[].body' \
|
gh api --paginate "repos/$REPO/issues/$1/comments" --jq '.[].body' \
|
||||||
| grep -qF "<!-- issueflow:$2 -->"
|
| grep -qF "<!-- issueflow:$2 -->"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -288,7 +284,7 @@ reference_states() {
|
||||||
local ref state
|
local ref state
|
||||||
while IFS= read -r ref; do
|
while IFS= read -r ref; do
|
||||||
[ -n "$ref" ] || continue
|
[ -n "$ref" ] || continue
|
||||||
state="$(forge_api "repos/$REPO/issues/$ref" --jq '.state' 2>/dev/null || echo UNKNOWN)"
|
state="$(gh api "repos/$REPO/issues/$ref" --jq '.state' 2>/dev/null || echo UNKNOWN)"
|
||||||
case "$state" in open) echo OPEN ;; closed) echo CLOSED ;; *) echo UNKNOWN ;; esac
|
case "$state" in open) echo OPEN ;; closed) echo CLOSED ;; *) echo UNKNOWN ;; esac
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
@ -299,23 +295,23 @@ offsite_pr_states() {
|
||||||
[ -n "$ref" ] || continue
|
[ -n "$ref" ] || continue
|
||||||
repo="${ref%#*}"
|
repo="${ref%#*}"
|
||||||
number="${ref##*#}"
|
number="${ref##*#}"
|
||||||
state="$(forge_api "repos/$repo/pulls/$number" --jq '.state' 2>/dev/null || echo UNKNOWN)"
|
state="$(gh api "repos/$repo/pulls/$number" --jq '.state' 2>/dev/null || echo UNKNOWN)"
|
||||||
case "$state" in open) echo OPEN ;; closed) echo CLOSED ;; *) echo UNKNOWN ;; esac
|
case "$state" in open) echo OPEN ;; closed) echo CLOSED ;; *) echo UNKNOWN ;; esac
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
offsite_timeline() { # unreadable timelines are deliberately silent
|
offsite_timeline() { # unreadable timelines are deliberately silent
|
||||||
forge_api --paginate "repos/$REPO/issues/$1/timeline" 2>/dev/null || return 1
|
gh api --paginate "repos/$REPO/issues/$1/timeline" 2>/dev/null || return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
last_issue_activity() {
|
last_issue_activity() {
|
||||||
local n="$1" created="$2" latest
|
local n="$1" created="$2" latest
|
||||||
latest="$({
|
latest="$({
|
||||||
printf '%s\n' "$created"
|
printf '%s\n' "$created"
|
||||||
forge_api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at'
|
gh api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at'
|
||||||
# Assignment is the claim itself. Ignoring it would let an old issue be
|
# Assignment is the claim itself. Ignoring it would let an old issue be
|
||||||
# reclaimed in the seconds between assignment and its required draft PR.
|
# reclaimed in the seconds between assignment and its required draft PR.
|
||||||
forge_api --paginate "repos/$REPO/issues/$n/timeline" \
|
gh api --paginate "repos/$REPO/issues/$n/timeline" \
|
||||||
--jq '.[] | select(.event == "assigned") | .created_at'
|
--jq '.[] | select(.event == "assigned") | .created_at'
|
||||||
} \
|
} \
|
||||||
| sort | tail -n1)"
|
| sort | tail -n1)"
|
||||||
|
|
@ -329,7 +325,7 @@ reconcile_issue() {
|
||||||
decision="$(queue_decision <<<"$ISSUE_LABELS")"
|
decision="$(queue_decision <<<"$ISSUE_LABELS")"
|
||||||
case "$decision" in
|
case "$decision" in
|
||||||
ADD_NEEDS_TRIAGE)
|
ADD_NEEDS_TRIAGE)
|
||||||
run forge_issue_edit "$n" --add-label needs-triage >/dev/null
|
run gh issue edit "$n" -R "$REPO" --add-label needs-triage >/dev/null
|
||||||
log "#$n: needs-triage (no queue state)" ;;
|
log "#$n: needs-triage (no queue state)" ;;
|
||||||
FLAG_CONFLICT)
|
FLAG_CONFLICT)
|
||||||
ensure_comment "$n" queue-conflict \
|
ensure_comment "$n" queue-conflict \
|
||||||
|
|
@ -362,10 +358,10 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in
|
||||||
# would create an impossible parked-for state (#175 D4).
|
# would create an impossible parked-for state (#175 D4).
|
||||||
has_issue_label attention && remove_claimed=claimed,attention
|
has_issue_label attention && remove_claimed=claimed,attention
|
||||||
if [ -n "$owners" ]; then
|
if [ -n "$owners" ]; then
|
||||||
run forge_issue_edit "$n" --remove-assignee "$owners" \
|
run gh issue edit "$n" -R "$REPO" --remove-assignee "$owners" \
|
||||||
--remove-label "$remove_claimed" --add-label post-merge >/dev/null
|
--remove-label "$remove_claimed" --add-label post-merge >/dev/null
|
||||||
else
|
else
|
||||||
run forge_issue_edit "$n" \
|
run gh issue edit "$n" -R "$REPO" \
|
||||||
--remove-label "$remove_claimed" --add-label post-merge >/dev/null
|
--remove-label "$remove_claimed" --add-label post-merge >/dev/null
|
||||||
fi
|
fi
|
||||||
log "#$n: merged Refs PR -> post-merge; claim released"
|
log "#$n: merged Refs PR -> post-merge; claim released"
|
||||||
|
|
@ -391,10 +387,10 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in
|
||||||
'This claim has no linked open PR and no activity for 48 hours. The sweep is reclaiming it for the ready queue.'
|
'This claim has no linked open PR and no activity for 48 hours. The sweep is reclaiming it for the ready queue.'
|
||||||
owners="$(jq -r '[.assignees[].login] | join(",")' <<<"$ISSUE_JSON")"
|
owners="$(jq -r '[.assignees[].login] | join(",")' <<<"$ISSUE_JSON")"
|
||||||
if [ -n "$owners" ]; then
|
if [ -n "$owners" ]; then
|
||||||
run forge_issue_edit "$n" --remove-assignee "$owners" \
|
run gh issue edit "$n" -R "$REPO" --remove-assignee "$owners" \
|
||||||
--remove-label claimed --add-label ready >/dev/null
|
--remove-label claimed --add-label ready >/dev/null
|
||||||
else
|
else
|
||||||
run forge_issue_edit "$n" --remove-label claimed --add-label ready >/dev/null
|
run gh issue edit "$n" -R "$REPO" --remove-label claimed --add-label ready >/dev/null
|
||||||
fi
|
fi
|
||||||
log "#$n: stale claim reclaimed -> ready" ;;
|
log "#$n: stale claim reclaimed -> ready" ;;
|
||||||
esac
|
esac
|
||||||
|
|
@ -433,7 +429,7 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in
|
||||||
READY)
|
READY)
|
||||||
ensure_comment "$n" blockers-cleared \
|
ensure_comment "$n" blockers-cleared \
|
||||||
'Every issue named by `Blocked by` is closed. The sweep is moving this issue to `ready`.'
|
'Every issue named by `Blocked by` is closed. The sweep is moving this issue to `ready`.'
|
||||||
run forge_issue_edit "$n" --remove-label blocked --add-label ready >/dev/null
|
run gh issue edit "$n" -R "$REPO" --remove-label blocked --add-label ready >/dev/null
|
||||||
log "#$n: blockers closed -> ready" ;;
|
log "#$n: blockers closed -> ready" ;;
|
||||||
esac
|
esac
|
||||||
elif has_issue_label epic; then
|
elif has_issue_label epic; then
|
||||||
|
|
@ -455,7 +451,7 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in
|
||||||
# An already-applied stale comes off: waiting on a human is legitimately
|
# An already-applied stale comes off: waiting on a human is legitimately
|
||||||
# quiet (#50 D10), and nothing on the issue side ever puts stale back.
|
# quiet (#50 D10), and nothing on the issue side ever puts stale back.
|
||||||
if has_issue_label stale; then
|
if has_issue_label stale; then
|
||||||
run forge_issue_edit "$n" --remove-label stale >/dev/null
|
run gh issue edit "$n" -R "$REPO" --remove-label stale >/dev/null
|
||||||
log "#$n: unstale (a ruling is pending)"
|
log "#$n: unstale (a ruling is pending)"
|
||||||
fi
|
fi
|
||||||
[ -n "${age:-}" ] \
|
[ -n "${age:-}" ] \
|
||||||
|
|
@ -466,7 +462,7 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in
|
||||||
|
|
||||||
reconcile_opened_issue() {
|
reconcile_opened_issue() {
|
||||||
local n="$1" author triage=false labels remove="" label
|
local n="$1" author triage=false labels remove="" label
|
||||||
ISSUE_JSON="$(forge_api "repos/$REPO/issues/$n")"
|
ISSUE_JSON="$(gh api "repos/$REPO/issues/$n")"
|
||||||
# The stand-downs return 0 explicitly: a bare return carries the failed
|
# The stand-downs return 0 explicitly: a bare return carries the failed
|
||||||
# test's status, which under execution is live `set -e` — and it killed the
|
# test's status, which under execution is live `set -e` — and it killed the
|
||||||
# run on every triage-authored mint, before one issue was reconciled (#91).
|
# run on every triage-authored mint, before one issue was reconciled (#91).
|
||||||
|
|
@ -480,72 +476,55 @@ reconcile_opened_issue() {
|
||||||
done
|
done
|
||||||
remove="${remove#,}"
|
remove="${remove#,}"
|
||||||
if [ -n "$remove" ]; then
|
if [ -n "$remove" ]; then
|
||||||
run forge_issue_edit "$n" --add-label needs-triage --remove-label "$remove" >/dev/null
|
run gh issue edit "$n" -R "$REPO" --add-label needs-triage --remove-label "$remove" >/dev/null
|
||||||
else
|
else
|
||||||
run forge_issue_edit "$n" --add-label needs-triage >/dev/null
|
run gh issue edit "$n" -R "$REPO" --add-label needs-triage >/dev/null
|
||||||
fi
|
fi
|
||||||
log "#$n: needs-triage (opened by $author)"
|
log "#$n: needs-triage (opened by $author)"
|
||||||
}
|
}
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
# See labels-reconcile's twin (#188). This one already failed loudly on
|
local owner name
|
||||||
# Forgejo — but with `line 408: gh: command not found`, which names the
|
|
||||||
# symptom and not the cause, and only after the sibling step had already
|
|
||||||
# reported a green blind sweep.
|
|
||||||
# The forge is decided once, here, before anything reads the board, and
|
|
||||||
# the backend that can speak it is loaded (#188). The CEREMONY_FORGE_CLIENT
|
|
||||||
# wrapper that stood here died with the call-site port: it declared "this
|
|
||||||
# code uses gh", which stopped being true the moment every site went
|
|
||||||
# through the shim, and leaving it would have defaulted the forgejo path
|
|
||||||
# into the very client its own preflight refuses.
|
|
||||||
forge_preflight || return 1
|
|
||||||
# "" means decide from the environment; forge_select takes an explicit
|
|
||||||
# forge only in tests.
|
|
||||||
forge_select "" || return 1
|
|
||||||
|
|
||||||
REPO="${REPO:?set REPO to owner/name}"
|
REPO="${REPO:?set REPO to owner/name}"
|
||||||
LABELS_CONF="${LABELS_CONF:-.github/labels.conf}"
|
LABELS_CONF="${LABELS_CONF:-.github/labels.conf}"
|
||||||
load_issueflow_config "$LABELS_CONF"
|
load_issueflow_config "$LABELS_CONF"
|
||||||
if [ "${EVENT_NAME:-}" = issues ] && [ "${EVENT_ACTION:-}" = opened ]; then
|
if [ "${EVENT_NAME:-}" = issues ] && [ "${EVENT_ACTION:-}" = opened ]; then
|
||||||
reconcile_opened_issue "${EVENT_ISSUE:?set EVENT_ISSUE for issues:opened}"
|
reconcile_opened_issue "${EVENT_ISSUE:?set EVENT_ISSUE for issues:opened}"
|
||||||
fi
|
fi
|
||||||
# owner/name split out here until #188 — the GraphQL query took them as
|
owner="${REPO%%/*}"
|
||||||
# separate variables. REST takes the owner/name path whole, so it is gone.
|
name="${REPO#*/}"
|
||||||
# Both gathers were `gh api graphql` until #188. Forgejo has NO GraphQL
|
OPEN_PR_ISSUES="$(gh api graphql --paginate -f owner="$owner" -f name="$name" -f query='
|
||||||
# API — a real forgejo-runner job even arrives with GITHUB_GRAPHQL_URL set
|
query($owner: String!, $name: String!, $endCursor: String) {
|
||||||
# to the empty string (probe task 278) — so these could not be translated
|
repository(owner: $owner, name: $name) {
|
||||||
# to a Forgejo endpoint; there is none. They are REST + a parser this repo
|
pullRequests(first: 100, states: OPEN, after: $endCursor) {
|
||||||
# owns, over `number` and `body`, which /api/v3 and /api/v1 both return in
|
nodes { closingIssuesReferences(first: 100) { nodes { number } } }
|
||||||
# the same shape (measured on both, 2026-08-02).
|
pageInfo { hasNextPage endCursor }
|
||||||
#
|
}
|
||||||
# Bodies travel base64 because they contain newlines: jq's @tsv escapes a
|
}
|
||||||
# newline to a literal backslash-n, which a line-oriented parser reads as
|
}' --jq '.data.repository.pullRequests.nodes[].closingIssuesReferences.nodes[].number' \
|
||||||
# one line and silently loses every declaration after the first. The old
|
| sort -nu)"
|
||||||
# GraphQL gather sidestepped that with `split("\n")[]`; base64 is the same
|
MERGED_REF_PR_RECORDS="$(gh api graphql --paginate -f owner="$owner" -f name="$name" -f query='
|
||||||
# protection without needing the split to be correct.
|
query($owner: String!, $name: String!, $endCursor: String) {
|
||||||
OPEN_PR_ISSUES="$(forge_api --paginate "repos/$REPO/pulls?state=open" \
|
repository(owner: $owner, name: $name) {
|
||||||
--jq '.[] | .body // "" | @base64' \
|
pullRequests(first: 100, states: MERGED, after: $endCursor) {
|
||||||
| while IFS= read -r b64; do
|
nodes { number body }
|
||||||
[ -n "$b64" ] && printf '%s' "$b64" | base64 -d | closes_references
|
pageInfo { hasNextPage endCursor }
|
||||||
done | sort -nu)"
|
}
|
||||||
# closes_references, not refs_references: GitHub's closingIssuesReferences
|
}
|
||||||
# meant the CLOSING relation specifically, and reading Refs as closing
|
}' --jq '.data.repository.pullRequests.nodes[]
|
||||||
# would make every referenced issue look closeable — the distinction #151
|
| .number as $pr | .body | split("\n")[]
|
||||||
# was reopened by hand over.
|
| [$pr, .] | @tsv' \
|
||||||
MERGED_REF_PR_RECORDS="$(forge_api --paginate "repos/$REPO/pulls?state=closed" \
|
| while IFS=$'\t' read -r pr body; do
|
||||||
--jq '.[] | select(.merged_at != null) | "\(.number)\t\(.body // "" | @base64)"' \
|
|
||||||
| while IFS=$'\t' read -r pr b64; do
|
|
||||||
[ -n "$b64" ] || continue
|
|
||||||
while IFS= read -r issue; do
|
while IFS= read -r issue; do
|
||||||
[ -n "$issue" ] && printf '%s\t%s\n' "$issue" "$pr"
|
[ -n "$issue" ] && printf '%s\t%s\n' "$issue" "$pr"
|
||||||
done < <(printf '%s' "$b64" | base64 -d | refs_references)
|
done < <(refs_references <<<"$body")
|
||||||
done)"
|
done)"
|
||||||
|
|
||||||
local n
|
local n
|
||||||
for n in $(forge_api --paginate "repos/$REPO/issues?state=open" \
|
for n in $(gh api --paginate "repos/$REPO/issues?state=open&per_page=100" \
|
||||||
--jq '.[] | select(has("pull_request") | not) | .number'); do
|
--jq '.[] | select(has("pull_request") | not) | .number'); do
|
||||||
(
|
(
|
||||||
ISSUE_JSON="$(forge_api "repos/$REPO/issues/$n")"
|
ISSUE_JSON="$(gh api "repos/$REPO/issues/$n")"
|
||||||
jq -e 'has("pull_request") | not' <<<"$ISSUE_JSON" >/dev/null || exit 0
|
jq -e 'has("pull_request") | not' <<<"$ISSUE_JSON" >/dev/null || exit 0
|
||||||
ISSUE_LABELS="$(jq -r '.labels[].name' <<<"$ISSUE_JSON")"
|
ISSUE_LABELS="$(jq -r '.labels[].name' <<<"$ISSUE_JSON")"
|
||||||
reconcile_issue "$n"
|
reconcile_issue "$n"
|
||||||
|
|
|
||||||
|
|
@ -53,8 +53,6 @@ STALE_AFTER=$((48 * 3600))
|
||||||
# The needs-ruling invariants (#52) — one implementation for both surfaces.
|
# The needs-ruling invariants (#52) — one implementation for both surfaces.
|
||||||
# shellcheck source=lib/ruling.sh
|
# shellcheck source=lib/ruling.sh
|
||||||
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/ruling.sh"
|
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/ruling.sh"
|
||||||
# shellcheck source=lib/forge.sh
|
|
||||||
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/forge.sh"
|
|
||||||
|
|
||||||
log() { printf 'labels: %s\n' "$*"; }
|
log() { printf 'labels: %s\n' "$*"; }
|
||||||
|
|
||||||
|
|
@ -187,35 +185,6 @@ set_required_bots() { # the PR author is recused by construction
|
||||||
|
|
||||||
requested() { grep -qxF "$1" <<<"$REQUESTED"; }
|
requested() { grep -qxF "$1" <<<"$REQUESTED"; }
|
||||||
|
|
||||||
# outstanding_requests <requested-logins> — the portable "who still owes a
|
|
||||||
# verdict on THIS head" (issue #188, term 4).
|
|
||||||
#
|
|
||||||
# GitHub clears requested_reviewers when a verdict lands, so on that forge the
|
|
||||||
# field already answers this question and the filter below removes nothing.
|
|
||||||
# **Forgejo does not clear it.** Measured 2026-08-02: rig!140 listed all three
|
|
||||||
# panelists with all three verdicts in, and rig!146 still lists three while
|
|
||||||
# MERGED — the field is stale even on a closed PR, so it over-counts forever.
|
|
||||||
#
|
|
||||||
# Reading it raw on Forgejo pins a PR at state:bots-reviewing for life and
|
|
||||||
# stops blocker:unrequested from ever being true: the sweep believes a round
|
|
||||||
# is permanently live. So the requested set is intersected with "has not
|
|
||||||
# submitted a verdict for the current head", which is derived from
|
|
||||||
# /pulls/{n}/reviews — the read that is true on both forges.
|
|
||||||
#
|
|
||||||
# Pure over REVIEWS_JSON/HEAD_SHA so the fixtures can drive it; a reviewer
|
|
||||||
# whose only verdict is STALE still owes one, which is why this asks
|
|
||||||
# bot_verdict rather than merely "has any review".
|
|
||||||
outstanding_requests() {
|
|
||||||
local login
|
|
||||||
while IFS= read -r login; do
|
|
||||||
[ -n "$login" ] || continue
|
|
||||||
case "$(bot_verdict "$login")" in
|
|
||||||
APPROVE | BLOCK | FEEDBACK) continue ;;
|
|
||||||
esac
|
|
||||||
printf '%s\n' "$login"
|
|
||||||
done <<<"${1-}"
|
|
||||||
}
|
|
||||||
|
|
||||||
checks_state() { # rollup JSON on stdin → SUCCESS | FAILURE | PENDING | NONE | UNREADABLE
|
checks_state() { # rollup JSON on stdin → SUCCESS | FAILURE | PENDING | NONE | UNREADABLE
|
||||||
# UNREADABLE is the absence of the key itself, which is what a failed fetch
|
# UNREADABLE is the absence of the key itself, which is what a failed fetch
|
||||||
# leaves behind — distinct from a present-but-empty rollup, which honestly
|
# leaves behind — distinct from a present-but-empty rollup, which honestly
|
||||||
|
|
@ -545,7 +514,7 @@ $(configured_label_rows "$LABELS_CONF")"
|
||||||
fi
|
fi
|
||||||
while IFS='|' read -r name color desc; do
|
while IFS='|' read -r name color desc; do
|
||||||
[ -n "$name" ] || continue
|
[ -n "$name" ] || continue
|
||||||
run forge_label_create "$name" "$color" "$desc"
|
run gh label create "$name" -R "$REPO" --color "$color" --description "$desc" --force
|
||||||
done <<<"$rows"
|
done <<<"$rows"
|
||||||
|
|
||||||
# LABELS.md publishes the defaults as deleted at bootstrap; until #93
|
# LABELS.md publishes the defaults as deleted at bootstrap; until #93
|
||||||
|
|
@ -559,7 +528,7 @@ $(configured_label_rows "$LABELS_CONF")"
|
||||||
# the taxonomy it can create. Either way: log the name, keep going.
|
# the taxonomy it can create. Either way: log the name, keep going.
|
||||||
while IFS= read -r name; do
|
while IFS= read -r name; do
|
||||||
[ -n "$name" ] || continue
|
[ -n "$name" ] || continue
|
||||||
run forge_label_delete "$name" \
|
run gh label delete "$name" -R "$REPO" --yes \
|
||||||
|| log "retire: '$name' not deleted (already absent, or refused) — continuing"
|
|| log "retire: '$name' not deleted (already absent, or refused) — continuing"
|
||||||
done <<<"$(retired_label_names)"
|
done <<<"$(retired_label_names)"
|
||||||
}
|
}
|
||||||
|
|
@ -588,10 +557,10 @@ tree_version() { # $1 = ref → that tree's version via the API, or nothing
|
||||||
# Every failure path prints nothing: the caller treats "could not read"
|
# Every failure path prints nothing: the caller treats "could not read"
|
||||||
# as "not release-shaped" rather than warning on a guess.
|
# as "not release-shaped" rather than warning on a guess.
|
||||||
local ref="$1" ver
|
local ref="$1" ver
|
||||||
ver="$(forge_api "repos/$REPO/contents/VERSION?ref=$ref" --jq '.content' 2>/dev/null \
|
ver="$(gh api "repos/$REPO/contents/VERSION?ref=$ref" --jq '.content' 2>/dev/null \
|
||||||
| base64 -d 2>/dev/null | tr -d '[:space:]')"
|
| base64 -d 2>/dev/null | tr -d '[:space:]')"
|
||||||
if [ -z "$ver" ]; then
|
if [ -z "$ver" ]; then
|
||||||
ver="$(forge_api "repos/$REPO/contents/package.json?ref=$ref" --jq '.content' 2>/dev/null \
|
ver="$(gh api "repos/$REPO/contents/package.json?ref=$ref" --jq '.content' 2>/dev/null \
|
||||||
| base64 -d 2>/dev/null | jq -r '.version // empty' 2>/dev/null)"
|
| base64 -d 2>/dev/null | jq -r '.version // empty' 2>/dev/null)"
|
||||||
fi
|
fi
|
||||||
[ -z "$ver" ] || printf '%s\n' "$ver"
|
[ -z "$ver" ] || printf '%s\n' "$ver"
|
||||||
|
|
@ -612,7 +581,7 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
|
||||||
# concurrency group in labels.yml. With a comment-only bot on the panel
|
# concurrency group in labels.yml. With a comment-only bot on the panel
|
||||||
# this path stays cold and the AUTHOR requests the human.
|
# this path stays cold and the AUTHOR requests the human.
|
||||||
if [ "$desired" = state:needs-human ] && human_request_needed; then
|
if [ "$desired" = state:needs-human ] && human_request_needed; then
|
||||||
run forge_request_reviewer "$n" "$HUMAN"
|
run gh api "repos/$REPO/pulls/$n/requested_reviewers" -f "reviewers[]=$HUMAN" --silent
|
||||||
log "#$n: requested $HUMAN (round passed)"
|
log "#$n: requested $HUMAN (round passed)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -674,7 +643,7 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
|
||||||
if [ "$skip_edit" = false ] && { ! has_label "$desired" || [ -n "$remove" ] || [ -n "$add" ]; }; then
|
if [ "$skip_edit" = false ] && { ! has_label "$desired" || [ -n "$remove" ] || [ -n "$add" ]; }; then
|
||||||
args=(--add-label "$desired${add:+,$add}")
|
args=(--add-label "$desired${add:+,$add}")
|
||||||
[ -n "$remove" ] && args+=(--remove-label "$remove")
|
[ -n "$remove" ] && args+=(--remove-label "$remove")
|
||||||
if run forge_issue_edit "$n" "${args[@]}" >/dev/null; then
|
if run gh issue edit "$n" -R "$REPO" "${args[@]}" >/dev/null; then
|
||||||
log "#$n: state -> $desired${add:+ +$add}${remove:+ (cleared $remove)}"
|
log "#$n: state -> $desired${add:+ +$add}${remove:+ (cleared $remove)}"
|
||||||
else
|
else
|
||||||
# a deleted label must not wedge the sweep — dispatch heals the taxonomy
|
# a deleted label must not wedge the sweep — dispatch heals the taxonomy
|
||||||
|
|
@ -697,7 +666,7 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
|
||||||
# the moment the PR is no longer the thing a human should merge next, the
|
# the moment the PR is no longer the thing a human should merge next, the
|
||||||
# claim is removed. Setting it stays with whoever owns the queue.
|
# claim is removed. Setting it stays with whoever owns the queue.
|
||||||
if has_label merge-next && [ "$desired" != state:needs-human ]; then
|
if has_label merge-next && [ "$desired" != state:needs-human ]; then
|
||||||
run forge_issue_edit "$n" --remove-label merge-next >/dev/null
|
run gh issue edit "$n" -R "$REPO" --remove-label merge-next >/dev/null
|
||||||
log "#$n: cleared merge-next (state is $desired, not mergeable-by-a-human)"
|
log "#$n: cleared merge-next (state is $desired, not mergeable-by-a-human)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -706,9 +675,9 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
|
||||||
{
|
{
|
||||||
jq -r '.created_at' <<<"$PR_JSON"
|
jq -r '.created_at' <<<"$PR_JSON"
|
||||||
jq -r '.[].submitted_at' <<<"$REVIEWS_JSON"
|
jq -r '.[].submitted_at' <<<"$REVIEWS_JSON"
|
||||||
forge_api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at'
|
gh api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at'
|
||||||
forge_api --paginate "repos/$REPO/pulls/$n/comments" --jq '.[].created_at'
|
gh api --paginate "repos/$REPO/pulls/$n/comments" --jq '.[].created_at'
|
||||||
forge_api --paginate "repos/$REPO/pulls/$n/commits" --jq '.[].commit.committer.date'
|
gh api --paginate "repos/$REPO/pulls/$n/commits" --jq '.[].commit.committer.date'
|
||||||
} | sort | tail -n1
|
} | sort | tail -n1
|
||||||
)"
|
)"
|
||||||
last_activity_epoch="$(date -d "$last_activity" +%s)"
|
last_activity_epoch="$(date -d "$last_activity" +%s)"
|
||||||
|
|
@ -717,11 +686,11 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
|
||||||
# (#50 D10). The 7-day nudge is #52's, once for both surfaces.
|
# (#50 D10). The 7-day nudge is #52's, once for both surfaces.
|
||||||
if has_label blocked || has_label needs-ruling || [ "$age" -le "$STALE_AFTER" ]; then
|
if has_label blocked || has_label needs-ruling || [ "$age" -le "$STALE_AFTER" ]; then
|
||||||
if has_label stale; then
|
if has_label stale; then
|
||||||
run forge_issue_edit "$n" --remove-label stale >/dev/null
|
run gh issue edit "$n" -R "$REPO" --remove-label stale >/dev/null
|
||||||
log "#$n: unstale"
|
log "#$n: unstale"
|
||||||
fi
|
fi
|
||||||
elif ! has_label stale; then
|
elif ! has_label stale; then
|
||||||
run forge_issue_edit "$n" --add-label stale >/dev/null
|
run gh issue edit "$n" -R "$REPO" --add-label stale >/dev/null
|
||||||
log "#$n: stale ($((age / 3600))h quiet)"
|
log "#$n: stale ($((age / 3600))h quiet)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -736,21 +705,6 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
|
||||||
}
|
}
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
# BEFORE anything reads the board (#188). Every call site below is still
|
|
||||||
# `gh`, so that is what this declares — honestly, which is the point: on
|
|
||||||
# a Forgejo consumer the preflight refuses here instead of letting the
|
|
||||||
# sweep run blind and print "reconciled." over zero PRs (rig run 979).
|
|
||||||
# The forge is decided once, here, before anything reads the board, and
|
|
||||||
# the backend that can speak it is loaded (#188). The CEREMONY_FORGE_CLIENT
|
|
||||||
# wrapper that stood here died with the call-site port: it declared "this
|
|
||||||
# code uses gh", which stopped being true the moment every site went
|
|
||||||
# through the shim, and leaving it would have defaulted the forgejo path
|
|
||||||
# into the very client its own preflight refuses.
|
|
||||||
forge_preflight || return 1
|
|
||||||
# "" means decide from the environment; forge_select takes an explicit
|
|
||||||
# forge only in tests.
|
|
||||||
forge_select "" || return 1
|
|
||||||
|
|
||||||
REPO="${REPO:?set REPO to owner/name}"
|
REPO="${REPO:?set REPO to owner/name}"
|
||||||
LABELS_CONF="${LABELS_CONF:-.github/labels.conf}"
|
LABELS_CONF="${LABELS_CONF:-.github/labels.conf}"
|
||||||
load_config "$LABELS_CONF"
|
load_config "$LABELS_CONF"
|
||||||
|
|
@ -763,7 +717,7 @@ main() {
|
||||||
|
|
||||||
# The repo's label set, read ONCE per sweep — reconcile_pr filters every
|
# The repo's label set, read ONCE per sweep — reconcile_pr filters every
|
||||||
# add against it, because one unknown name fails the whole edit call.
|
# add against it, because one unknown name fails the whole edit call.
|
||||||
REPO_LABELS="$(forge_label_list 2>/dev/null || echo "")"
|
REPO_LABELS="$(gh label list -R "$REPO" --limit 200 --json name --jq '.[].name' 2>/dev/null || echo "")"
|
||||||
[ -z "$REPO_LABELS" ] && log "WARNING: could not read the label set — applying labels unfiltered"
|
[ -z "$REPO_LABELS" ] && log "WARNING: could not read the label set — applying labels unfiltered"
|
||||||
missing_core_labels_warning "$(core_label_rows)" "$REPO_LABELS"
|
missing_core_labels_warning "$(core_label_rows)" "$REPO_LABELS"
|
||||||
|
|
||||||
|
|
@ -774,20 +728,17 @@ main() {
|
||||||
status=0
|
status=0
|
||||||
output="$(
|
output="$(
|
||||||
(
|
(
|
||||||
PR_JSON="$(forge_api "repos/$REPO/pulls/$n")"
|
PR_JSON="$(gh api "repos/$REPO/pulls/$n")"
|
||||||
DRAFT="$(jq -r '.draft' <<<"$PR_JSON")"
|
DRAFT="$(jq -r '.draft' <<<"$PR_JSON")"
|
||||||
AUTHOR="$(jq -r '.user.login' <<<"$PR_JSON")"
|
AUTHOR="$(jq -r '.user.login' <<<"$PR_JSON")"
|
||||||
set_required_bots "$AUTHOR"
|
set_required_bots "$AUTHOR"
|
||||||
HEAD_SHA="$(jq -r '.head.sha' <<<"$PR_JSON")"
|
HEAD_SHA="$(jq -r '.head.sha' <<<"$PR_JSON")"
|
||||||
BASE_SHA="$(jq -r '.base.sha' <<<"$PR_JSON")"
|
BASE_SHA="$(jq -r '.base.sha' <<<"$PR_JSON")"
|
||||||
LABELS="$(jq -r '.labels[].name' <<<"$PR_JSON")"
|
LABELS="$(jq -r '.labels[].name' <<<"$PR_JSON")"
|
||||||
|
REQUESTED="$(jq -r '.requested_reviewers[].login' <<<"$PR_JSON")"
|
||||||
# PENDING reviews are unsubmitted drafts in someone's browser — not a verdict
|
# PENDING reviews are unsubmitted drafts in someone's browser — not a verdict
|
||||||
REVIEWS_JSON="$(forge_api --paginate "repos/$REPO/pulls/$n/reviews" --jq '.[]' \
|
REVIEWS_JSON="$(gh api --paginate "repos/$REPO/pulls/$n/reviews" --jq '.[]' \
|
||||||
| jq -s '[.[] | select(.state != "PENDING")]')"
|
| jq -s '[.[] | select(.state != "PENDING")]')"
|
||||||
# Read AFTER the reviews, because the raw field is not portable: Forgejo
|
|
||||||
# never clears it, so it is intersected with who still owes a verdict on
|
|
||||||
# this head (#188 term 4). A no-op on GitHub, which clears it itself.
|
|
||||||
REQUESTED="$(outstanding_requests "$(jq -r '.requested_reviewers[].login' <<<"$PR_JSON")")"
|
|
||||||
# mergeability + the check rollup, the two facts the state machine was
|
# mergeability + the check rollup, the two facts the state machine was
|
||||||
# blind to (#136). `gh pr view` rather than the REST PR object: the API's
|
# blind to (#136). `gh pr view` rather than the REST PR object: the API's
|
||||||
# `mergeable` is a tri-state boolean that GitHub computes lazily, while
|
# `mergeable` is a tri-state boolean that GitHub computes lazily, while
|
||||||
|
|
@ -802,7 +753,7 @@ main() {
|
||||||
# D2), never left to interleave raw into the per-PR output block,
|
# D2), never left to interleave raw into the per-PR output block,
|
||||||
# where an unlucky line could collide with a matched string.
|
# where an unlucky line could collide with a matched string.
|
||||||
GH_VIEW_ERR_FILE="$(mktemp)"
|
GH_VIEW_ERR_FILE="$(mktemp)"
|
||||||
GH_VIEW="$(forge_pr_view "$n" 2>"$GH_VIEW_ERR_FILE" || echo '{}')"
|
GH_VIEW="$(gh pr view "$n" -R "$REPO" --json mergeable,statusCheckRollup 2>"$GH_VIEW_ERR_FILE" || echo '{}')"
|
||||||
GH_VIEW_ERR="$(cat "$GH_VIEW_ERR_FILE")"
|
GH_VIEW_ERR="$(cat "$GH_VIEW_ERR_FILE")"
|
||||||
rm -f "$GH_VIEW_ERR_FILE"
|
rm -f "$GH_VIEW_ERR_FILE"
|
||||||
MERGEABLE="$(jq -r '.mergeable // "UNKNOWN"' <<<"$GH_VIEW")"
|
MERGEABLE="$(jq -r '.mergeable // "UNKNOWN"' <<<"$GH_VIEW")"
|
||||||
|
|
@ -832,7 +783,7 @@ main() {
|
||||||
elif [ "$status" -ne 0 ]; then
|
elif [ "$status" -ne 0 ]; then
|
||||||
log "#$n: reconcile failed — continuing with the remaining PRs"
|
log "#$n: reconcile failed — continuing with the remaining PRs"
|
||||||
fi
|
fi
|
||||||
done < <(forge_pr_list)
|
done < <(gh pr list -R "$REPO" --state open --limit 100 --json number --jq '.[].number')
|
||||||
blind_sweep_warning "$unreadable" "$total" "$sampled_reason"
|
blind_sweep_warning "$unreadable" "$total" "$sampled_reason"
|
||||||
log "reconciled."
|
log "reconciled."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,6 @@ else
|
||||||
set -u
|
set -u
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# shellcheck source=lib/forge.sh
|
|
||||||
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/forge.sh"
|
|
||||||
|
|
||||||
# labels-scope.sh — the additive half of the labels automation: derive
|
# labels-scope.sh — the additive half of the labels automation: derive
|
||||||
# scope:* labels from a PR's changed paths and ADD them, touching nothing
|
# scope:* labels from a PR's changed paths and ADD them, touching nothing
|
||||||
# else. This seat belonged to actions/labeler@v5 until #130: even under
|
# else. This seat belonged to actions/labeler@v5 until #130: even under
|
||||||
|
|
@ -128,21 +125,6 @@ derive_labels() { # $1 = "label<TAB>glob" lines, $2 = changed files (one per
|
||||||
}
|
}
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
# See labels-reconcile's twin (#188). This action's degraded read was the
|
|
||||||
# quietest of the three: an unreadable mapping and an absent one produced
|
|
||||||
# the same "nothing to derive" no-op, so on Forgejo a PR simply got no
|
|
||||||
# scope labels and nothing said why.
|
|
||||||
# The forge is decided once, here, before anything reads the board, and
|
|
||||||
# the backend that can speak it is loaded (#188). The CEREMONY_FORGE_CLIENT
|
|
||||||
# wrapper that stood here died with the call-site port: it declared "this
|
|
||||||
# code uses gh", which stopped being true the moment every site went
|
|
||||||
# through the shim, and leaving it would have defaulted the forgejo path
|
|
||||||
# into the very client its own preflight refuses.
|
|
||||||
forge_preflight || return 1
|
|
||||||
# "" means decide from the environment; forge_select takes an explicit
|
|
||||||
# forge only in tests.
|
|
||||||
forge_select "" || return 1
|
|
||||||
|
|
||||||
REPO="${REPO:?set REPO to owner/name}"
|
REPO="${REPO:?set REPO to owner/name}"
|
||||||
PR_NUMBER="${PR_NUMBER:?set PR_NUMBER to the pull request number}"
|
PR_NUMBER="${PR_NUMBER:?set PR_NUMBER to the pull request number}"
|
||||||
CONFIG_REF="${CONFIG_REF:?set CONFIG_REF to the base commit the mapping is read at}"
|
CONFIG_REF="${CONFIG_REF:?set CONFIG_REF to the base commit the mapping is read at}"
|
||||||
|
|
@ -152,13 +134,13 @@ main() {
|
||||||
# No mapping is a consumer that has not adopted scope labels — an
|
# No mapping is a consumer that has not adopted scope labels — an
|
||||||
# advisory no-op, not a red run (scopes locate, they do not alert). A
|
# advisory no-op, not a red run (scopes locate, they do not alert). A
|
||||||
# mapping that EXISTS but does not parse still fails loudly below.
|
# mapping that EXISTS but does not parse still fails loudly below.
|
||||||
if ! config="$(forge_api "repos/$REPO/contents/$CONFIG_PATH?ref=$CONFIG_REF" \
|
if ! config="$(gh api "repos/$REPO/contents/$CONFIG_PATH?ref=$CONFIG_REF" \
|
||||||
--jq '.content' 2>/dev/null | base64 -d)" || [ -z "$config" ]; then
|
--jq '.content' 2>/dev/null | base64 -d)" || [ -z "$config" ]; then
|
||||||
log "no $CONFIG_PATH at $CONFIG_REF — nothing to derive"
|
log "no $CONFIG_PATH at $CONFIG_REF — nothing to derive"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
tsv="$(parse_labeler_config <<<"$config")"
|
tsv="$(parse_labeler_config <<<"$config")"
|
||||||
files="$(forge_api --paginate "repos/$REPO/pulls/$PR_NUMBER/files" --jq '.[].filename')"
|
files="$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/files" --jq '.[].filename')"
|
||||||
labels="$(derive_labels "$tsv" "$files")"
|
labels="$(derive_labels "$tsv" "$files")"
|
||||||
|
|
||||||
if [ -z "$labels" ]; then
|
if [ -z "$labels" ]; then
|
||||||
|
|
@ -166,8 +148,8 @@ main() {
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
local args=()
|
local args=()
|
||||||
while IFS= read -r label; do args+=("$label"); done <<<"$labels"
|
while IFS= read -r label; do args+=(-f "labels[]=$label"); done <<<"$labels"
|
||||||
run forge_labels_add "$PR_NUMBER" "${args[@]}"
|
run gh api "repos/$REPO/issues/$PR_NUMBER/labels" "${args[@]}" --silent
|
||||||
log "#$PR_NUMBER: scopes -> $(paste -sd, <<<"$labels") (additive POST; already-present names are no-ops)"
|
log "#$PR_NUMBER: scopes -> $(paste -sd, <<<"$labels") (additive POST; already-present names are no-ops)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
### Added
|
|
||||||
|
|
||||||
- `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
|
|
||||||
(#188).
|
|
||||||
- The reconcilers and `labels-scope` run that preflight first, so a
|
|
||||||
GitHub-shaped client on a Forgejo instance is a named refusal instead of a
|
|
||||||
sweep that reads nothing and reports success (#188).
|
|
||||||
- `lib/closes_references.sh` — the closing-keyword parser, sibling of
|
|
||||||
`refs_references`, so "which issues does this PR close" is answered from a
|
|
||||||
PR body rather than from GitHub's GraphQL API (#188).
|
|
||||||
- `lib/forge-github.sh` and `lib/forge-forgejo.sh` — one call surface, two
|
|
||||||
backends, selected by `forge_select`; no forge branching at the call sites
|
|
||||||
(#188).
|
|
||||||
- The forgejo backend proves each paginated gather complete against the
|
|
||||||
server's `x-total-count` and refuses loudly when it cannot — a missing
|
|
||||||
header is a refusal, not a pass (#188).
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- `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`
|
|
||||||
from `/pulls` in the same shape (#188).
|
|
||||||
|
|
||||||
- `forge_api` owns the page size, because each forge silently ignores the
|
|
||||||
other's parameter: `per_page=100` reads 30 items on Forgejo and `limit=100`
|
|
||||||
reads 30 on GitHub, both HTTP 200. No call site names one (#188).
|
|
||||||
|
|
||||||
- Outstanding review requests are derived from the reviews on the current head
|
|
||||||
rather than from `requested_reviewers`, which Forgejo never clears — read
|
|
||||||
raw there, a PR would sit at `state:bots-reviewing` forever (#188).
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- `labels-reconcile` and `labels-scope` no longer exit 0 on a Forgejo
|
|
||||||
consumer having read zero facts — measured on `heavy-duty/rig`, where the
|
|
||||||
sweep printed `reconciled.` over an empty PR list and scope reported "no
|
|
||||||
labeler.yml" for a file that exists (#188).
|
|
||||||
|
|
@ -396,13 +396,6 @@ never before it and never through mixed refs.
|
||||||
Both actor lists are whitespace-separated. `triage-actors` names the identities
|
Both actor lists are whitespace-separated. `triage-actors` names the identities
|
||||||
allowed to mint issues without the sweep applying `needs-triage`. Label rows use exactly
|
allowed to mint issues without the sweep applying `needs-triage`. Label rows use exactly
|
||||||
`name|color|description`; blank lines are ignored and extra pipes are refused.
|
`name|color|description`; blank lines are ignored and extra pipes are refused.
|
||||||
|
|
||||||
**Every account in `panel=` must be able to read the repository.** Requesting a
|
|
||||||
review from someone without read access is refused by the forge, not silently
|
|
||||||
dropped — on Forgejo with `422 Reviewer can't read`, naming the account
|
|
||||||
(#188). On a public repo this is satisfied already; on a **private** consumer
|
|
||||||
it is a real failure mode when a panel member is not on the collaborator
|
|
||||||
list, and the sweep will report it rather than sweep blind.
|
|
||||||
There are no comment lines: every non-blank line must be the `panel=`
|
There are no comment lines: every non-blank line must be the `panel=`
|
||||||
setting, the `triage-actors=` setting, or a label row, so `#`-prefixed prose
|
setting, the `triage-actors=` setting, or a label row, so `#`-prefixed prose
|
||||||
is a parse failure, not a comment (rig #13's conversion found this the hard
|
is a parse failure, not a comment (rig #13's conversion found this the hard
|
||||||
|
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# lib/closes_references.sh — "which issues does this PR body close?", parsed
|
|
||||||
# here rather than asked of a forge (issue #188, term 3).
|
|
||||||
#
|
|
||||||
# Sourced, never executed: no set -e/-u — the sourcing script owns its shell
|
|
||||||
# options, as lib/version.sh and lib/forge.sh do.
|
|
||||||
#
|
|
||||||
# WHY THIS EXISTS. issueflow-reconcile asked GitHub's GraphQL API for
|
|
||||||
# `closingIssuesReferences` — GitHub's own parse of the closing keywords in
|
|
||||||
# a PR body. **Forgejo has no GraphQL API at all**, and the runner confirms
|
|
||||||
# it from the other side: a real forgejo-runner job arrives with
|
|
||||||
# GITHUB_GRAPHQL_URL set to the empty string (probe task 278, 2026-08-02).
|
|
||||||
# So that call site could not be translated to a Forgejo endpoint — there is
|
|
||||||
# nothing to translate it to. It had to be replaced by a parse this repo
|
|
||||||
# owns, over a field both forges already return:
|
|
||||||
# `GET /repos/{owner}/{repo}/pulls` carries `number` and `body` on
|
|
||||||
# /api/v3 and /api/v1 alike (measured on both).
|
|
||||||
#
|
|
||||||
# That the replacement is honest is the point. The sibling half of the same
|
|
||||||
# GraphQL query, MERGED_REF_PR_RECORDS, was ALREADY a body parse — it pulled
|
|
||||||
# `number` and `body` and ran them through refs_references. GraphQL was
|
|
||||||
# buying pagination convenience there, nothing semantic. This file makes the
|
|
||||||
# other half symmetric: one parser this repo controls and can test, for both
|
|
||||||
# link kinds, on both forges.
|
|
||||||
#
|
|
||||||
# THE ACCEPTED DELTA, stated so it is not rediscovered as a bug: GitHub also
|
|
||||||
# records closing links attached through the pull request's development
|
|
||||||
# sidebar, which live in no body and which no body parse can see. This
|
|
||||||
# family declares its links in the body — that is what BUILDER.md's PR
|
|
||||||
# template asks for — so the delta is zero in practice here. A consumer that
|
|
||||||
# links through the sidebar would see those issues go unclosed by the sweep;
|
|
||||||
# they would need to say so in the body instead.
|
|
||||||
#
|
|
||||||
# DEPENDENCY: issue_references, from issueflow-reconcile.sh — the LOCAL /
|
|
||||||
# CROSS classifier that keeps rig#112 from ever being read as local #112
|
|
||||||
# (#61). Bash resolves function calls at call time, so the order of sourcing
|
|
||||||
# does not matter; both must simply be defined before closes_references runs.
|
|
||||||
# refs_references depends on it exactly the same way.
|
|
||||||
|
|
||||||
# closes_references — PR body on stdin -> local issue numbers this body
|
|
||||||
# declares it CLOSES, sorted, unique.
|
|
||||||
#
|
|
||||||
# The keyword set is GitHub's documented one, all three verbs in all three
|
|
||||||
# tenses. Matching is case-insensitive because bodies are written by humans
|
|
||||||
# and agents both ("Closes", "closes", "CLOSES").
|
|
||||||
#
|
|
||||||
# Deliberately NOT matched: "Refs #N". That is the other relation entirely —
|
|
||||||
# refs_references owns it, and conflating them would make every referenced
|
|
||||||
# issue look closeable, which is the post-merge transition #151 had to be
|
|
||||||
# reopened by hand over.
|
|
||||||
closes_references() {
|
|
||||||
awk '
|
|
||||||
{
|
|
||||||
line = $0
|
|
||||||
lower = tolower(line)
|
|
||||||
# Every occurrence contributes, not just the first: a body that says
|
|
||||||
# "Closes #1. Closes #2." declares two, and binding to the first
|
|
||||||
# occurrence dropped the later ones — the same defect #184 fixed in
|
|
||||||
# blocked_reference_records, kept fixed here by construction.
|
|
||||||
while (match(lower, /(^|[^[:alnum:]_-])(close[sd]?|fix(e[sd])?|resolve[sd]?)[[:space:]:]+/)) {
|
|
||||||
# BOTH cursors advance together. Advancing only `lower` left the
|
|
||||||
# next match offset indexing the ORIGINAL line, so the second
|
|
||||||
# declaration on a line came back as garbage — caught by the
|
|
||||||
# "two closes on one line" case, which is why it is a case.
|
|
||||||
rest = substr(line, RSTART + RLENGTH)
|
|
||||||
line = rest
|
|
||||||
lower = tolower(rest)
|
|
||||||
if (rest ~ /^(#|([[:alnum:]_.-]+\/)?[[:alnum:]_.-]+#)[0-9]+/) {
|
|
||||||
token = rest
|
|
||||||
# Stop at the first thing that cannot be part of a reference, so
|
|
||||||
# "Closes #12, and more prose" yields #12 and not the sentence.
|
|
||||||
sub(/[^[:alnum:]_.\/#-].*/, "", token)
|
|
||||||
print token
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
' | issue_references \
|
|
||||||
| awk -F '\t' '$1 == "LOCAL" { print $2 }' | sort -nu
|
|
||||||
}
|
|
||||||
|
|
@ -1,440 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# lib/forge-forgejo.sh — the Forgejo backend: /api/v1 over curl + jq
|
|
||||||
# (issue #188, term 1). Sourced by lib/forge.sh when forge_detect says
|
|
||||||
# forgejo; never sourced directly, and never at the same time as the github
|
|
||||||
# backend — they define the same verbs on purpose.
|
|
||||||
#
|
|
||||||
# curl+jq rather than a CLI because that is what the runner has. The image
|
|
||||||
# this instance runs jobs in (ghcr.io/catthehacker/ubuntu:act-22.04, probe
|
|
||||||
# task 278) carries curl, jq and node, and has neither `gh` nor `stoke`.
|
|
||||||
|
|
||||||
# forgejo_api_base — the /api/v1 root, from the runner's own environment.
|
|
||||||
# GITHUB_API_URL already IS the /api/v1 root on a Forgejo runner (measured:
|
|
||||||
# https://forgejo.heavyduty.builders/api/v1). CEREMONY_FORGE_API overrides
|
|
||||||
# it for tests and for anyone driving this outside Actions.
|
|
||||||
forgejo_api_base() {
|
|
||||||
local base="${CEREMONY_FORGE_API:-${GITHUB_API_URL:-}}"
|
|
||||||
if [ -z "$base" ]; then
|
|
||||||
echo "forgejo_api_base: no GITHUB_API_URL or CEREMONY_FORGE_API — cannot reach the forge (#188)" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
printf '%s\n' "${base%/}"
|
|
||||||
}
|
|
||||||
|
|
||||||
# forgejo_page_url <endpoint> <page> — pure, so the page-size contract is
|
|
||||||
# testable without a network. Returns the endpoint with this backend's OWN
|
|
||||||
# paging parameters applied.
|
|
||||||
#
|
|
||||||
# THE TRAP THIS EXISTS TO REMOVE, measured 2026-08-02 against
|
|
||||||
# heavy-duty/rig (137 issues and PRs) and heavy-duty/ceremony on GitHub:
|
|
||||||
#
|
|
||||||
# ?per_page=100 GitHub: 100 items Forgejo: 30 items (IGNORED)
|
|
||||||
# ?limit=100 GitHub: 30 items Forgejo: 50 items (capped)
|
|
||||||
#
|
|
||||||
# Each forge silently ignores the other's page-size parameter, answers
|
|
||||||
# HTTP 200 with valid JSON, and says nothing. Every call site in this repo
|
|
||||||
# was written GitHub-shaped, so a verbatim port would have swept 30 of
|
|
||||||
# rig's 137 and printed "reconciled." — acceptance criterion 2 failing
|
|
||||||
# green, and the same "degraded read that does not report it degraded"
|
|
||||||
# failure class this whole issue exists to kill.
|
|
||||||
#
|
|
||||||
# So NO CALL SITE NAMES A PAGE SIZE. The backend owns it. Fixing the
|
|
||||||
# boundary once beats fixing nine call sites and trusting the tenth — the
|
|
||||||
# same argument that chose shape C over B, one level down.
|
|
||||||
#
|
|
||||||
# 50 is not a preference: Forgejo caps a page at MAX_RESPONSE_ITEMS (50 on
|
|
||||||
# this instance) whatever you ask for, so asking for more cannot help and
|
|
||||||
# pagination is mandatory rather than an optimisation.
|
|
||||||
forgejo_page_url() {
|
|
||||||
local endpoint="${1:?forgejo_page_url: endpoint required}" page="${2:?forgejo_page_url: page required}"
|
|
||||||
# Strip any page-size parameter a caller left behind, in either dialect,
|
|
||||||
# rather than trusting that none did: this function is the one place that
|
|
||||||
# decides paging, and a stray per_page= would be exactly the silent
|
|
||||||
# truncation above.
|
|
||||||
local clean="$endpoint"
|
|
||||||
clean="$(printf '%s' "$clean" | sed -E 's/([?&])(per_page|limit|page)=[0-9]+/\1/g; s/[?&]+$//; s/([?&])&+/\1/g')"
|
|
||||||
case "$clean" in
|
|
||||||
*\?) printf '%slimit=50&page=%s\n' "$clean" "$page" ;;
|
|
||||||
*\?*) printf '%s&limit=50&page=%s\n' "$clean" "$page" ;;
|
|
||||||
*) printf '%s?limit=50&page=%s\n' "$clean" "$page" ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
# forge_api [--paginate] <endpoint> [--jq <expr>]
|
|
||||||
#
|
|
||||||
# --paginate walks page= until a short page, then PROVES the walk was
|
|
||||||
# complete by comparing what it collected against the server's declared
|
|
||||||
# x-total-count. @kimi-reviewer-andresmgsl's hardening (#4699): a MISSING
|
|
||||||
# header is a loud refusal, not a pass. Header exposure is a server setting
|
|
||||||
# (access-control-expose-headers), and an instance that withholds it would
|
|
||||||
# make the completeness check compare null to a number — the guard itself
|
|
||||||
# degrading silently, which is the failure class re-entering through the
|
|
||||||
# door built to stop it.
|
|
||||||
forge_api() {
|
|
||||||
local paginate=false endpoint="" jqexpr="" have_jq=false
|
|
||||||
while [ $# -gt 0 ]; do
|
|
||||||
case "$1" in
|
|
||||||
--paginate) paginate=true ;;
|
|
||||||
--jq) jqexpr="$2"; have_jq=true; shift ;;
|
|
||||||
-*) ;;
|
|
||||||
*) [ -n "$endpoint" ] || endpoint="$1" ;;
|
|
||||||
esac
|
|
||||||
shift
|
|
||||||
done
|
|
||||||
[ -n "$endpoint" ] || { echo "forge_api: endpoint required" >&2; return 1; }
|
|
||||||
|
|
||||||
local base token
|
|
||||||
base="$(forgejo_api_base)" || return 1
|
|
||||||
token="${GH_TOKEN:-${GITHUB_TOKEN:-${FORGEJO_TOKEN:-}}}"
|
|
||||||
|
|
||||||
local hdr body
|
|
||||||
hdr="$(mktemp)"; body="$(mktemp)"
|
|
||||||
# shellcheck disable=SC2064 # the paths are fixed at trap time on purpose
|
|
||||||
trap "rm -f '$hdr' '$body'" RETURN
|
|
||||||
|
|
||||||
if [ "$paginate" = false ]; then
|
|
||||||
if ! curl -sS -D "$hdr" -o "$body" \
|
|
||||||
-H "Authorization: token $token" -H 'Accept: application/json' \
|
|
||||||
"$base/$endpoint"; then
|
|
||||||
echo "forge_api: request failed: $endpoint" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
forgejo_http_ok "$hdr" "$endpoint" || return 1
|
|
||||||
if [ "$have_jq" = true ]; then jq -r "$jqexpr" <"$body"; else cat "$body"; fi
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Paginated: accumulate into ONE array and apply --jq once at the end.
|
|
||||||
# gh --paginate applies --jq per page and concatenates; for the `.[] | …`
|
|
||||||
# shapes every call site here uses, the two are identical, and merging
|
|
||||||
# first is what makes the completeness assert possible at all.
|
|
||||||
local page=1 total="" got=0 n all="[]" pagejson
|
|
||||||
while :; do
|
|
||||||
if ! curl -sS -D "$hdr" -o "$body" \
|
|
||||||
-H "Authorization: token $token" -H 'Accept: application/json' \
|
|
||||||
"$base/$(forgejo_page_url "$endpoint" "$page")"; then
|
|
||||||
echo "forge_api: request failed: $endpoint (page $page)" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
forgejo_http_ok "$hdr" "$endpoint" || return 1
|
|
||||||
|
|
||||||
# Re-read on EVERY page, not once (#4712). A board that changes size
|
|
||||||
# under the walk was invisible: page 1 declaring 4 and page 2 declaring
|
|
||||||
# 9 stopped at 4 believing itself whole. A moving total means the read
|
|
||||||
# cannot have been atomic, so it is refused rather than reconciled.
|
|
||||||
local page_total
|
|
||||||
page_total="$(forgejo_total_count "$hdr")" || return 1
|
|
||||||
if [ -z "$total" ]; then
|
|
||||||
total="$page_total"
|
|
||||||
elif [ "$page_total" != "$total" ]; then
|
|
||||||
cat >&2 <<EOF
|
|
||||||
forge_api: the declared total for '$endpoint' changed between pages — $total then $page_total (#188).
|
|
||||||
The collection moved under the walk, so no page set can be proven whole.
|
|
||||||
Refusing rather than reconciling a board that is already out of date.
|
|
||||||
EOF
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
pagejson="$(cat "$body")"
|
|
||||||
# A 200 whose body is not a collection counted as zero items (#4712),
|
|
||||||
# so an error object or a scalar arriving where a list belongs read as
|
|
||||||
# a complete EMPTY collection whenever the declared total was 0.
|
|
||||||
if [ "$(jq -r 'type' <<<"$pagejson" 2>/dev/null)" != array ]; then
|
|
||||||
cat >&2 <<EOF
|
|
||||||
forge_api: '$endpoint' did not return a collection (#188).
|
|
||||||
Expected a JSON array; got: $(head -c 200 <<<"$pagejson")
|
|
||||||
Refusing: a body this shim cannot count must not be counted as empty.
|
|
||||||
EOF
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
n="$(jq 'length' <<<"$pagejson")"
|
|
||||||
[ "$n" -gt 0 ] || break
|
|
||||||
all="$(jq -s '.[0] + .[1]' <<<"$all"$'\n'"$pagejson")"
|
|
||||||
got=$((got + n))
|
|
||||||
[ "$got" -lt "$total" ] || break
|
|
||||||
page=$((page + 1))
|
|
||||||
done
|
|
||||||
|
|
||||||
# The assert. A short read here is the silent-truncation bug arriving by
|
|
||||||
# another route, so it is fatal rather than a warning.
|
|
||||||
if [ "$got" -ne "$total" ]; then
|
|
||||||
cat >&2 <<EOF
|
|
||||||
forge_api: incomplete gather for '$endpoint' — collected $got of $total declared (#188).
|
|
||||||
Refusing rather than reconciling a partial board: a sweep over part of the
|
|
||||||
queue that reports success is the failure this shim exists to prevent.
|
|
||||||
EOF
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$have_jq" = true ]; then jq -r "$jqexpr" <<<"$all"; else printf '%s\n' "$all"; fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# forgejo_total_count <header-file> — the declared size of the collection.
|
|
||||||
# Absent is fatal (#4699): without it the completeness assert cannot run,
|
|
||||||
# and an assert that cannot run must not silently pass.
|
|
||||||
forgejo_total_count() {
|
|
||||||
local hdr="$1" total
|
|
||||||
total="$(tr -d '\r' <"$hdr" | awk 'tolower($1) == "x-total-count:" { print $2 }' | tail -n1)"
|
|
||||||
if [ -z "$total" ]; then
|
|
||||||
cat >&2 <<EOF
|
|
||||||
forge_api: this forge did not send x-total-count — cannot prove the gather is complete (#188).
|
|
||||||
The header is exposed by a server setting (access-control-expose-headers).
|
|
||||||
Refusing: an unprovable read must not be reported as a whole one.
|
|
||||||
EOF
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
# Validate before it reaches arithmetic (#4712). `X-Total-Count:
|
|
||||||
# not-a-number` used to sail through and become the bound the walk was
|
|
||||||
# compared against — a guard whose own input was never checked.
|
|
||||||
case "$total" in
|
|
||||||
'' | *[!0-9]*)
|
|
||||||
cat >&2 <<EOF
|
|
||||||
forge_api: x-total-count is not a non-negative integer: '$total' (#188).
|
|
||||||
Refusing: the completeness bound must be a number, or the assert that
|
|
||||||
uses it proves nothing.
|
|
||||||
EOF
|
|
||||||
return 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
printf '%s\n' "$total"
|
|
||||||
}
|
|
||||||
|
|
||||||
# forgejo_http_ok <header-file> <endpoint> — a non-2xx is named, not
|
|
||||||
# swallowed. gh exits non-zero on HTTP failure; curl does not without -f,
|
|
||||||
# and -f would throw away the body that says why.
|
|
||||||
forgejo_http_ok() {
|
|
||||||
local hdr="$1" endpoint="$2" code
|
|
||||||
code="$(tr -d '\r' <"$hdr" | awk '/^HTTP\// { c = $2 } END { print c }')"
|
|
||||||
case "$code" in
|
|
||||||
2*) return 0 ;;
|
|
||||||
*)
|
|
||||||
echo "forge_api: HTTP $code from '$endpoint'" >&2
|
|
||||||
return 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- the verbs the reconcilers use, over /api/v1 --------------------------
|
|
||||||
# Three asymmetries with gh, all measured against this instance on
|
|
||||||
# 2026-08-02 using a scratch repo (never a live board):
|
|
||||||
#
|
|
||||||
# 1. Adding labels takes NAMES POST /issues/{n}/labels {"labels":["x"]} -> 200
|
|
||||||
# Removing one takes a numeric ID DELETE /issues/{n}/labels/x -> 422
|
|
||||||
# DELETE /issues/{n}/labels/149 -> 204
|
|
||||||
# So a removal must resolve name -> id first. gh hides this; the shim
|
|
||||||
# cannot.
|
|
||||||
#
|
|
||||||
# 2. Assignees are SET, not added and removed. PATCH /issues/{n} takes the
|
|
||||||
# whole list ({"assignees":[]} clears it, 201), so --remove-assignee is
|
|
||||||
# a read-modify-write rather than a delete.
|
|
||||||
#
|
|
||||||
# 3. There is no statusCheckRollup. The portable equivalent is the
|
|
||||||
# combined commit status, GET /commits/{sha}/status, which returns
|
|
||||||
# {state, statuses[]}.
|
|
||||||
|
|
||||||
# forgejo_label_ids — name<TAB>id for every label in the repo, read once per
|
|
||||||
# call site that needs it. Paginated through forge_api, so a repo with more
|
|
||||||
# than one page of labels cannot silently lose the tail (#188).
|
|
||||||
forgejo_label_ids() {
|
|
||||||
forge_api --paginate "repos/$REPO/labels" --jq '.[] | "\(.name)\t\(.id)"'
|
|
||||||
}
|
|
||||||
|
|
||||||
# forge_issue_edit <n> [--add-label X]… [--remove-label X]… [--add-assignee U]… [--remove-assignee U]…
|
|
||||||
# gh's flag surface, translated. Accepts comma-separated values, as gh does.
|
|
||||||
forge_issue_edit() {
|
|
||||||
local n="${1:?forge_issue_edit: number required}"
|
|
||||||
shift
|
|
||||||
local add_labels=() rm_labels=() add_assignees=() rm_assignees=() v
|
|
||||||
# Unknown flags REFUSE (#4743). The github backend forwards whatever it is
|
|
||||||
# given to `gh`, which fails on a flag it does not know; dropping it here
|
|
||||||
# instead would turn a port typo into a green no-op — a mutation that
|
|
||||||
# silently did not happen, which is precisely this issue's failure class
|
|
||||||
# arriving inside the fix for it.
|
|
||||||
while [ $# -gt 0 ]; do
|
|
||||||
case "$1" in
|
|
||||||
--add-label | --remove-label | --add-assignee | --remove-assignee)
|
|
||||||
if [ "$#" -lt 2 ]; then
|
|
||||||
echo "forge_issue_edit: $1 requires a value (#188)" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
IFS=, read -ra v <<<"$2"
|
|
||||||
case "$1" in
|
|
||||||
--add-label) add_labels+=("${v[@]}") ;;
|
|
||||||
--remove-label) rm_labels+=("${v[@]}") ;;
|
|
||||||
--add-assignee) add_assignees+=("${v[@]}") ;;
|
|
||||||
--remove-assignee) rm_assignees+=("${v[@]}") ;;
|
|
||||||
esac
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "forge_issue_edit: unknown flag '$1' — refusing rather than silently skipping the edit (#188)" >&2
|
|
||||||
return 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
shift
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ "${#add_labels[@]}" -gt 0 ]; then
|
|
||||||
local payload
|
|
||||||
payload="$(printf '%s\n' "${add_labels[@]}" | jq -R . | jq -sc '{labels: .}')"
|
|
||||||
forgejo_write POST "repos/$REPO/issues/$n/labels" "$payload" >/dev/null || return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "${#rm_labels[@]}" -gt 0 ]; then
|
|
||||||
local ids id name
|
|
||||||
ids="$(forgejo_label_ids)" || return 1
|
|
||||||
for name in "${rm_labels[@]}"; do
|
|
||||||
id="$(awk -F '\t' -v want="$name" '$1 == want { print $2; exit }' <<<"$ids")"
|
|
||||||
# A label the repo does not have is not an error: the reconcilers call
|
|
||||||
# --remove-label unconditionally to converge state, and gh's own
|
|
||||||
# behaviour there is a no-op.
|
|
||||||
[ -n "$id" ] || continue
|
|
||||||
forgejo_write DELETE "repos/$REPO/issues/$n/labels/$id" '' >/dev/null || return 1
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "${#add_assignees[@]}" -gt 0 ] || [ "${#rm_assignees[@]}" -gt 0 ]; then
|
|
||||||
local current want payload
|
|
||||||
current="$(forge_api "repos/$REPO/issues/$n" --jq '[.assignees[]?.login] | join("\n")')" || return 1
|
|
||||||
want="$(
|
|
||||||
{
|
|
||||||
printf '%s\n' "$current"
|
|
||||||
[ "${#add_assignees[@]}" -gt 0 ] && printf '%s\n' "${add_assignees[@]}"
|
|
||||||
} | grep -v '^$' | sort -u
|
|
||||||
)"
|
|
||||||
if [ "${#rm_assignees[@]}" -gt 0 ]; then
|
|
||||||
want="$(grep -vxF -f <(printf '%s\n' "${rm_assignees[@]}") <<<"$want" || true)"
|
|
||||||
fi
|
|
||||||
payload="$(printf '%s' "$want" | jq -R . | jq -sc '{assignees: [.[] | select(. != "")]}')"
|
|
||||||
forgejo_write PATCH "repos/$REPO/issues/$n" "$payload" >/dev/null || return 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
forge_issue_comment() {
|
|
||||||
local n="${1:?forge_issue_comment: number required}" body="${2?forge_issue_comment: body required}"
|
|
||||||
forgejo_write POST "repos/$REPO/issues/$n/comments" "$(jq -nc --arg b "$body" '{body: $b}')" >/dev/null
|
|
||||||
}
|
|
||||||
|
|
||||||
forge_pr_list() {
|
|
||||||
forge_api --paginate "repos/$REPO/pulls?state=open" --jq '.[].number'
|
|
||||||
}
|
|
||||||
|
|
||||||
# forge_pr_view <n> — the {mergeable, statusCheckRollup} shape the state
|
|
||||||
# machine reads, assembled from the two places Forgejo keeps it. The rollup
|
|
||||||
# is mapped into the node shape checks_state already parses, so the decision
|
|
||||||
# code is untouched.
|
|
||||||
forge_pr_view() {
|
|
||||||
local n="${1:?forge_pr_view: number required}" pr sha status
|
|
||||||
pr="$(forge_api "repos/$REPO/pulls/$n")" || return 1
|
|
||||||
sha="$(jq -r '.head.sha // ""' <<<"$pr")"
|
|
||||||
[ -n "$sha" ] || { echo "forge_pr_view: PR $n has no head sha" >&2; return 1; }
|
|
||||||
status="$(forge_api "repos/$REPO/commits/$sha/status")" || return 1
|
|
||||||
jq -n --argjson pr "$pr" --argjson st "$status" '
|
|
||||||
{
|
|
||||||
mergeable: (if $pr.mergeable == true then "MERGEABLE"
|
|
||||||
elif $pr.mergeable == false then "CONFLICTING"
|
|
||||||
else "UNKNOWN" end),
|
|
||||||
statusCheckRollup: [
|
|
||||||
$st.statuses[]? | {
|
|
||||||
__typename: "StatusContext",
|
|
||||||
context: .context,
|
|
||||||
state: (.status | ascii_upcase),
|
|
||||||
# checks_state groups repeated contexts and takes the NEWEST by
|
|
||||||
# [.startedAt, .createdAt, .completedAt]. Without a timestamp the
|
|
||||||
# winner would be decided by incidental array order, so a stale
|
|
||||||
# re-run could outrank the live verdict (#4743). The combined
|
|
||||||
# status carries both fields; measured on this instance.
|
|
||||||
createdAt: .created_at,
|
|
||||||
completedAt: .updated_at
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}'
|
|
||||||
}
|
|
||||||
|
|
||||||
forge_label_list() { forge_api --paginate "repos/$REPO/labels" --jq '.[].name'; }
|
|
||||||
|
|
||||||
# forge_label_create — an UPSERT, matching `gh label create --force` (#4743).
|
|
||||||
# bootstrap_labels creates every declared label on every workflow_dispatch, so
|
|
||||||
# the second dispatch must update rather than conflict; a plain POST onto an
|
|
||||||
# existing name aborts the bootstrap under set -e.
|
|
||||||
forge_label_create() {
|
|
||||||
local name="${1:?}" color="${2:?}" desc="${3:-}" ids id payload
|
|
||||||
payload="$(jq -nc --arg n "$name" --arg c "$color" --arg d "$desc" '{name:$n,color:$c,description:$d}')"
|
|
||||||
ids="$(forgejo_label_ids)" || return 1
|
|
||||||
id="$(awk -F '\t' -v want="$name" '$1 == want { print $2; exit }' <<<"$ids")"
|
|
||||||
if [ -n "$id" ]; then
|
|
||||||
forgejo_write PATCH "repos/$REPO/labels/$id" "$payload" >/dev/null
|
|
||||||
else
|
|
||||||
forgejo_write POST "repos/$REPO/labels" "$payload" >/dev/null
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
forge_label_delete() {
|
|
||||||
local name="${1:?}" ids id
|
|
||||||
ids="$(forgejo_label_ids)" || return 1
|
|
||||||
id="$(awk -F '\t' -v want="$name" '$1 == want { print $2; exit }' <<<"$ids")"
|
|
||||||
[ -n "$id" ] || return 0
|
|
||||||
forgejo_write DELETE "repos/$REPO/labels/$id" '' >/dev/null
|
|
||||||
}
|
|
||||||
|
|
||||||
# forgejo_write <method> <endpoint> <json-body> — every mutation goes through
|
|
||||||
# here so a non-2xx is named rather than swallowed, the same contract
|
|
||||||
# forgejo_http_ok gives reads.
|
|
||||||
forgejo_write() {
|
|
||||||
local method="$1" endpoint="$2" payload="$3" base token hdr body rc
|
|
||||||
base="$(forgejo_api_base)" || return 1
|
|
||||||
token="${GH_TOKEN:-${GITHUB_TOKEN:-${FORGEJO_TOKEN:-}}}"
|
|
||||||
hdr="$(mktemp)"; body="$(mktemp)"
|
|
||||||
if [ -n "$payload" ]; then
|
|
||||||
curl -sS -X "$method" -D "$hdr" -o "$body" \
|
|
||||||
-H "Authorization: token $token" -H 'Content-Type: application/json' \
|
|
||||||
-d "$payload" "$base/$endpoint"
|
|
||||||
else
|
|
||||||
curl -sS -X "$method" -D "$hdr" -o "$body" \
|
|
||||||
-H "Authorization: token $token" "$base/$endpoint"
|
|
||||||
fi
|
|
||||||
rc=$?
|
|
||||||
if [ "$rc" -ne 0 ]; then
|
|
||||||
rm -f "$hdr" "$body"
|
|
||||||
echo "forge: $method $endpoint failed to send" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
if ! forgejo_http_ok "$hdr" "$method $endpoint"; then
|
|
||||||
head -c 300 "$body" >&2; echo >&2
|
|
||||||
rm -f "$hdr" "$body"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
cat "$body"
|
|
||||||
rm -f "$hdr" "$body"
|
|
||||||
}
|
|
||||||
|
|
||||||
# forge_labels_add <n> <label…> — the additive label write (ceremony#128; see
|
|
||||||
# the github twin). POST /issues/{n}/labels adds the named labels and removes
|
|
||||||
# nothing, and it takes NAMES — measured, unlike the removal path, which
|
|
||||||
# needs ids.
|
|
||||||
forge_labels_add() {
|
|
||||||
local n="${1:?forge_labels_add: number required}"
|
|
||||||
shift
|
|
||||||
[ "$#" -gt 0 ] || return 0
|
|
||||||
forgejo_write POST "repos/$REPO/issues/$n/labels" \
|
|
||||||
"$(printf '%s\n' "$@" | jq -R . | jq -sc '{labels: .}')" >/dev/null
|
|
||||||
}
|
|
||||||
|
|
||||||
# forge_request_reviewer <n> <user> — ask <user> for a verdict.
|
|
||||||
#
|
|
||||||
# This endpoint DOES exist here, contrary to an earlier reading of mine
|
|
||||||
# (#4698) which recorded requested_reviewers as having no sub-resource at
|
|
||||||
# all. What is true is narrower: Forgejo serves POST and DELETE on it and no
|
|
||||||
# GET, so a GET probe answers 404 — and a POST naming a user who does not
|
|
||||||
# exist answers 404 as well, for a different reason. Measured on a scratch
|
|
||||||
# repo: POST with a real user who lacks read access is 422 ("Reviewer can't
|
|
||||||
# read"), and 201 once they have it.
|
|
||||||
#
|
|
||||||
# The READ stays retired regardless (term 4): the field is stale here even on
|
|
||||||
# merged PRs, so outstanding verdicts come from /pulls/{n}/reviews at the
|
|
||||||
# current head SHA. It is the write that has an answer.
|
|
||||||
forge_request_reviewer() {
|
|
||||||
local n="${1:?}" user="${2:?}"
|
|
||||||
forgejo_write POST "repos/$REPO/pulls/$n/requested_reviewers" \
|
|
||||||
"$(jq -nc --arg u "$user" '{reviewers: [$u]}')" >/dev/null
|
|
||||||
}
|
|
||||||
|
|
@ -1,136 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# lib/forge-github.sh — the GitHub backend (issue #188, term 1). Sourced by
|
|
||||||
# lib/forge.sh when forge_detect says github; never at the same time as the
|
|
||||||
# forgejo backend — they define the same verbs on purpose.
|
|
||||||
#
|
|
||||||
# This file is the CURRENT call set, extracted 1:1 and nothing more. Term 5
|
|
||||||
# of the frozen Spec is "GitHub consumers are unchanged", and the cheapest
|
|
||||||
# way to keep that true is for every verb here to be a thin pass-through to
|
|
||||||
# the `gh` invocation the call site used before the port. No behaviour is
|
|
||||||
# added, fixed or tidied on this path; anything that looks like an
|
|
||||||
# improvement here is a regression risk against a forge nobody is currently
|
|
||||||
# reporting bugs on.
|
|
||||||
|
|
||||||
# forge_api [--paginate] <endpoint> [--jq <expr>]
|
|
||||||
#
|
|
||||||
# The one deliberate difference from a pure pass-through: the caller no
|
|
||||||
# longer names a page size, because the page-size parameter is not portable
|
|
||||||
# and is therefore the backend's to own (#188).
|
|
||||||
#
|
|
||||||
# ?per_page=100 GitHub: 100 items Forgejo: 30 items (IGNORED)
|
|
||||||
# ?limit=100 GitHub: 30 items Forgejo: 50 items (capped)
|
|
||||||
#
|
|
||||||
# Both answer HTTP 200 either way, so a call site that names one is a silent
|
|
||||||
# truncation waiting for the other forge. per_page=100 is injected here —
|
|
||||||
# exactly what the call sites said before — so the GitHub path is unchanged
|
|
||||||
# in behaviour while the parameter stops being a call-site concern.
|
|
||||||
forge_api() {
|
|
||||||
local paginate=false endpoint="" jqexpr="" have_jq=false
|
|
||||||
while [ $# -gt 0 ]; do
|
|
||||||
case "$1" in
|
|
||||||
--paginate) paginate=true ;;
|
|
||||||
--jq) jqexpr="$2"; have_jq=true; shift ;;
|
|
||||||
-*) ;;
|
|
||||||
*) [ -n "$endpoint" ] || endpoint="$1" ;;
|
|
||||||
esac
|
|
||||||
shift
|
|
||||||
done
|
|
||||||
[ -n "$endpoint" ] || { echo "forge_api: endpoint required" >&2; return 1; }
|
|
||||||
|
|
||||||
if [ "$paginate" = true ]; then
|
|
||||||
endpoint="$(github_page_url "$endpoint")"
|
|
||||||
if [ "$have_jq" = true ]; then
|
|
||||||
gh api --paginate "$endpoint" --jq "$jqexpr"
|
|
||||||
else
|
|
||||||
gh api --paginate "$endpoint"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
if [ "$have_jq" = true ]; then
|
|
||||||
gh api "$endpoint" --jq "$jqexpr"
|
|
||||||
else
|
|
||||||
gh api "$endpoint"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# github_page_url <endpoint> — pure, so the page-size contract is testable
|
|
||||||
# without a network. Strips any page-size parameter a caller left behind in
|
|
||||||
# either dialect, then applies GitHub's own.
|
|
||||||
github_page_url() {
|
|
||||||
local endpoint="${1:?github_page_url: endpoint required}" clean
|
|
||||||
clean="$(printf '%s' "$endpoint" | sed -E 's/([?&])(per_page|limit|page)=[0-9]+/\1/g; s/[?&]+$//; s/([?&])&+/\1/g')"
|
|
||||||
case "$clean" in
|
|
||||||
*\?) printf '%sper_page=100\n' "$clean" ;;
|
|
||||||
*\?*) printf '%s&per_page=100\n' "$clean" ;;
|
|
||||||
*) printf '%s?per_page=100\n' "$clean" ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- the verbs the reconcilers use, extracted 1:1 -------------------------
|
|
||||||
# Every one of these is the exact `gh` invocation the call site carried
|
|
||||||
# before the port. Term 5 is kept by making this file boring.
|
|
||||||
|
|
||||||
# forge_issue_edit <n> <gh-style flags…> — labels and assignees on an issue
|
|
||||||
# or a PR (gh treats them interchangeably, and so do the call sites).
|
|
||||||
forge_issue_edit() {
|
|
||||||
local n="${1:?forge_issue_edit: number required}"
|
|
||||||
shift
|
|
||||||
gh issue edit "$n" -R "$REPO" "$@"
|
|
||||||
}
|
|
||||||
|
|
||||||
# forge_issue_comment <n> <body>
|
|
||||||
forge_issue_comment() {
|
|
||||||
local n="${1:?forge_issue_comment: number required}" body="${2?forge_issue_comment: body required}"
|
|
||||||
gh issue comment "$n" -R "$REPO" --body "$body"
|
|
||||||
}
|
|
||||||
|
|
||||||
# forge_pr_list — open PR numbers, one per line. Note this used
|
|
||||||
# `gh pr list --limit 100`: a page size in gh's OWN flag namespace, which no
|
|
||||||
# URL-parameter strip could have caught, so it moves behind the shim with
|
|
||||||
# the rest (#188).
|
|
||||||
forge_pr_list() {
|
|
||||||
gh pr list -R "$REPO" --state open --limit 100 --json number --jq '.[].number'
|
|
||||||
}
|
|
||||||
|
|
||||||
# forge_pr_view <n> — {mergeable, statusCheckRollup} as JSON, or non-zero
|
|
||||||
# with the reason on stderr. `gh pr view` rather than the REST PR object:
|
|
||||||
# the API's `mergeable` is a tri-state boolean GitHub computes lazily, while
|
|
||||||
# this returns the MERGEABLE/CONFLICTING/UNKNOWN string the UI shows.
|
|
||||||
forge_pr_view() {
|
|
||||||
local n="${1:?forge_pr_view: number required}"
|
|
||||||
gh pr view "$n" -R "$REPO" --json mergeable,statusCheckRollup
|
|
||||||
}
|
|
||||||
|
|
||||||
# forge_label_list — every label name in the repo.
|
|
||||||
forge_label_list() {
|
|
||||||
gh label list -R "$REPO" --limit 200 --json name --jq '.[].name'
|
|
||||||
}
|
|
||||||
|
|
||||||
forge_label_create() {
|
|
||||||
local name="${1:?}" color="${2:?}" desc="${3:-}"
|
|
||||||
gh label create "$name" -R "$REPO" --color "$color" --description "$desc" --force
|
|
||||||
}
|
|
||||||
|
|
||||||
forge_label_delete() {
|
|
||||||
local name="${1:?}"
|
|
||||||
gh label delete "$name" -R "$REPO" --yes
|
|
||||||
}
|
|
||||||
|
|
||||||
# forge_labels_add <n> <label…> — an ADDITIVE label write, and deliberately
|
|
||||||
# not forge_issue_edit --add-label. The distinction is ceremony#128: the
|
|
||||||
# labeler action computed (labels-at-job-start ∪ derived) and PUT the whole
|
|
||||||
# set, so a label applied while the job ran was silently removed. This is the
|
|
||||||
# raw POST, which adds the named labels, ignores ones already present, and
|
|
||||||
# removes nothing — a concurrent label survives by construction.
|
|
||||||
forge_labels_add() {
|
|
||||||
local n="${1:?forge_labels_add: number required}" args=() label
|
|
||||||
shift
|
|
||||||
for label in "$@"; do args+=(-f "labels[]=$label"); done
|
|
||||||
gh api "repos/$REPO/issues/$n/labels" "${args[@]}" --silent
|
|
||||||
}
|
|
||||||
|
|
||||||
# forge_request_reviewer <n> <user> — ask <user> for a verdict.
|
|
||||||
forge_request_reviewer() {
|
|
||||||
local n="${1:?}" user="${2:?}"
|
|
||||||
gh api "repos/$REPO/pulls/$n/requested_reviewers" -f "reviewers[]=$user" --silent
|
|
||||||
}
|
|
||||||
218
lib/forge.sh
218
lib/forge.sh
|
|
@ -1,218 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# lib/forge.sh — one forge abstraction, two backends (issue #188).
|
|
||||||
#
|
|
||||||
# Sourced, never executed: no set -e/-u here — the sourcing script owns its
|
|
||||||
# own shell options, exactly as lib/version.sh does. This file is the
|
|
||||||
# selector only; the backends live beside it in lib/forge-github.sh and
|
|
||||||
# lib/forge-forgejo.sh, and nothing here talks to a network.
|
|
||||||
#
|
|
||||||
# WHY THIS FILE EXISTS, stated once. Until #188 the reconcilers were `gh`
|
|
||||||
# all the way down — 61 runtime call sites, no indirection, no forge check.
|
|
||||||
# Pointed at a Forgejo instance (heavy-duty/rig, which moved here and runs
|
|
||||||
# its CI on a Forgejo Actions runner) they did not fail usefully. Measured
|
|
||||||
# against forgejo.heavyduty.builders on 2026-08-02, at ceremony 84bb1a4:
|
|
||||||
#
|
|
||||||
# labels-scope exit 0 "no .github/labeler.yml at main — nothing
|
|
||||||
# to derive" — the file exists (HTTP 200)
|
|
||||||
# labels-reconcile exit 0 "reconciled." — having enumerated ZERO PRs
|
|
||||||
# issueflow-reconcile exit 1 "unexpected end of JSON input"
|
|
||||||
#
|
|
||||||
# Two of the three reported SUCCESS having read nothing. labels-reconcile's
|
|
||||||
# own blind-sweep warning (#96) could not fire, because it counts unreadable
|
|
||||||
# PRs against a list `gh pr list` never produced — and a process
|
|
||||||
# substitution's failure does not trip set -e, so `total` stayed 0 and the
|
|
||||||
# sweep called itself reconciled. rig run 979 is the log.
|
|
||||||
#
|
|
||||||
# The tempting fix — install gh on the runner — makes it WORSE. gh speaks
|
|
||||||
# GitHub's /api/v3 against api.github.com; Forgejo serves /api/v1 and no
|
|
||||||
# GraphQL at all. With gh present and GH_HOST set to the Forgejo host, the
|
|
||||||
# one loud failure goes quiet (`gh pr list` hits /api/graphql -> HTTP 405,
|
|
||||||
# prints nothing, exits into the same empty loop) and all three actions go
|
|
||||||
# green while reading nothing. That is this repo's own doctrine — an
|
|
||||||
# unreadable rollup reads as "nothing is failing" — being violated by the
|
|
||||||
# repo that wrote it.
|
|
||||||
#
|
|
||||||
# So: the forge is decided ONCE, before any sweep, and a client that cannot
|
|
||||||
# speak it refuses loudly. Never "probably github".
|
|
||||||
|
|
||||||
# forge_detect — print "github" or "forgejo"; exit 1 loudly when it cannot
|
|
||||||
# tell. Order matters and every signal below was measured, not read from
|
|
||||||
# docs: a real forgejo-runner v6.3.1 job on forgejo.heavyduty.builders
|
|
||||||
# (probe task 278, 2026-08-02) dumped its environment, and a GitHub-hosted
|
|
||||||
# runner's is the control.
|
|
||||||
#
|
|
||||||
# The trap that makes this non-obvious: **the Forgejo runner populates the
|
|
||||||
# whole GITHUB_* namespace.** GITHUB_ACTIONS=true, GITHUB_REPOSITORY,
|
|
||||||
# GITHUB_SHA, GITHUB_TOKEN — all set, all correct-looking. Detecting on
|
|
||||||
# "GITHUB_ACTIONS is set" would answer "github" on both forges, which is
|
|
||||||
# precisely the bug. What actually differs:
|
|
||||||
#
|
|
||||||
# signal GitHub Forgejo (measured)
|
|
||||||
# GITHUB_API_URL https://api.github.com https://<host>/api/v1
|
|
||||||
# GITHUB_GRAPHQL_URL https://api.github.com/… (empty)
|
|
||||||
# GITEA_ACTIONS (unset) true
|
|
||||||
#
|
|
||||||
# GITHUB_GRAPHQL_URL being empty on Forgejo is not a curiosity — it is the
|
|
||||||
# forge telling us the two `gh api graphql` sites #188 retired can never
|
|
||||||
# work here. It is deliberately NOT a detection signal, though: an empty
|
|
||||||
# variable is also what a hand-rolled harness leaves behind, and a signal
|
|
||||||
# that fires on absence is a signal that fires by accident.
|
|
||||||
forge_detect() {
|
|
||||||
# 1. The explicit override outranks every probe — the escape hatch for a
|
|
||||||
# forge this file has not met, and the handle the tests drive. A typo
|
|
||||||
# in it is fatal on purpose: the operator said something and it was
|
|
||||||
# wrong, and falling through to a probe that guesses right by accident
|
|
||||||
# would hide that until the guess was wrong too.
|
|
||||||
if [ -n "${CEREMONY_FORGE:-}" ]; then
|
|
||||||
case "$CEREMONY_FORGE" in
|
|
||||||
github | forgejo) printf '%s\n' "$CEREMONY_FORGE"; return 0 ;;
|
|
||||||
*)
|
|
||||||
echo "forge_detect: unknown forge: CEREMONY_FORGE=$CEREMONY_FORGE (expected github or forgejo)" >&2
|
|
||||||
return 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 2. Forgejo's and Gitea's own positive marker. Unambiguous where a
|
|
||||||
# hand-set GITHUB_API_URL might not be, so it is read first.
|
|
||||||
if [ "${GITEA_ACTIONS:-}" = true ] || [ "${FORGEJO_ACTIONS:-}" = true ]; then
|
|
||||||
printf 'forgejo\n'
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 3. The API URL's shape. /api/v3 is GitHub's (github.com and GitHub
|
|
||||||
# Enterprise Server alike — GHES is a github backend on a non-github.com
|
|
||||||
# host, and routing it to the forgejo backend would regress term 5's
|
|
||||||
# "GitHub consumers are unchanged"). /api/v1 is the Gitea shape Forgejo
|
|
||||||
# serves.
|
|
||||||
case "${GITHUB_API_URL:-}" in
|
|
||||||
https://api.github.com | https://api.github.com/*) printf 'github\n'; return 0 ;;
|
|
||||||
*/api/v3 | */api/v3/*) printf 'github\n'; return 0 ;;
|
|
||||||
*/api/v1 | */api/v1/*) printf 'forgejo\n'; return 0 ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# 4. Last resort, the server host. Only github.com itself is conclusive
|
|
||||||
# here: a bare hostname says nothing about which API it serves.
|
|
||||||
case "${GITHUB_SERVER_URL:-}" in
|
|
||||||
https://github.com | https://github.com/*) printf 'github\n'; return 0 ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# 5. Refuse. "Nothing to read" is not "probably github" — guessing here
|
|
||||||
# reinstates the exact blind sweep this file exists to end. Name what
|
|
||||||
# was inspected and the escape hatch, so the log answers "why" without
|
|
||||||
# a second run (#101 D5, one layer up: report, do not diagnose).
|
|
||||||
cat >&2 <<EOF
|
|
||||||
forge_detect: cannot determine which forge this is — refusing to guess (#188).
|
|
||||||
GITHUB_API_URL='${GITHUB_API_URL:-}'
|
|
||||||
GITHUB_SERVER_URL='${GITHUB_SERVER_URL:-}'
|
|
||||||
GITEA_ACTIONS='${GITEA_ACTIONS:-}'
|
|
||||||
Set CEREMONY_FORGE=github or CEREMONY_FORGE=forgejo to say so explicitly.
|
|
||||||
EOF
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Where the backends live. Captured at source time, not call time: a
|
|
||||||
# function that resolves BASH_SOURCE later would resolve its own file, not
|
|
||||||
# this one.
|
|
||||||
FORGE_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
|
|
||||||
# forge_select [forge] — source the backend for this forge, so the forge_*
|
|
||||||
# verbs exist. Exactly one backend is ever loaded; both define the same
|
|
||||||
# names, which is what keeps the branching out of the 61 call sites (term 1)
|
|
||||||
# and the single-forge assumption from growing back.
|
|
||||||
#
|
|
||||||
# Idempotent, because the actions call it once and the tests call it per
|
|
||||||
# case. Pass a forge explicitly to load a specific backend; omit it and the
|
|
||||||
# environment decides via forge_detect.
|
|
||||||
forge_select() {
|
|
||||||
local forge="${1:-}"
|
|
||||||
if [ -z "$forge" ]; then
|
|
||||||
forge="$(forge_detect)" || return 1
|
|
||||||
fi
|
|
||||||
case "$forge" in
|
|
||||||
github | forgejo) ;;
|
|
||||||
*)
|
|
||||||
echo "forge_select: unknown forge: $forge (expected github or forgejo)" >&2
|
|
||||||
return 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
# shellcheck source=/dev/null
|
|
||||||
. "$FORGE_LIB_DIR/forge-$forge.sh" || return 1
|
|
||||||
# Read by callers and tests to assert which backend is loaded, so the
|
|
||||||
# choice is inspectable rather than implied by which functions exist.
|
|
||||||
# shellcheck disable=SC2034 # consumed by sourcing scripts, not this file
|
|
||||||
FORGE="$forge"
|
|
||||||
}
|
|
||||||
|
|
||||||
# forge_client <forge> — print the client that backend requires.
|
|
||||||
#
|
|
||||||
# github -> gh the current call set, extracted 1:1 (term 5)
|
|
||||||
# forgejo -> rest /api/v1 over curl+jq
|
|
||||||
#
|
|
||||||
# forgejo is "rest" by MEASUREMENT, not preference. The image the Forgejo
|
|
||||||
# instance actually runs jobs in (ghcr.io/catthehacker/ubuntu:act-22.04,
|
|
||||||
# probe task 278) carries curl, jq and node — and has neither `gh` NOR
|
|
||||||
# `stoke` on PATH. That second absence is what retired option A from the
|
|
||||||
# ruling: porting the call sites to the stoke CLI would have put a binary
|
|
||||||
# on the critical path that the runner does not have and that would need
|
|
||||||
# installing before every job.
|
|
||||||
forge_client() {
|
|
||||||
case "${1:?forge_client: forge required}" in
|
|
||||||
github) printf 'gh\n' ;;
|
|
||||||
forgejo) printf 'rest\n' ;;
|
|
||||||
*)
|
|
||||||
echo "forge_client: unknown forge: $1 (expected github or forgejo)" >&2
|
|
||||||
return 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
# forge_preflight — the gate. Run it BEFORE any sweep: it decides the forge
|
|
||||||
# and proves the client can speak it, or exits non-zero with a named reason.
|
|
||||||
#
|
|
||||||
# CEREMONY_FORGE_CLIENT declares the client the caller will actually use —
|
|
||||||
# how a call site that still hard-codes `gh` announces itself honestly while
|
|
||||||
# the backends are being ported. Two checks run, in order:
|
|
||||||
#
|
|
||||||
# 1. the declaration, when made, must match what this forge needs;
|
|
||||||
# 2. that client's binaries must actually be on PATH — checked whether or
|
|
||||||
# not a declaration was made, because a call site that declares the
|
|
||||||
# right client on a runner that lacks it is still a blind sweep waiting
|
|
||||||
# to happen.
|
|
||||||
forge_preflight() {
|
|
||||||
local forge want
|
|
||||||
forge="$(forge_detect)" || return 1
|
|
||||||
want="$(forge_client "$forge")" || return 1
|
|
||||||
|
|
||||||
if [ -n "${CEREMONY_FORGE_CLIENT:-}" ] && [ "$CEREMONY_FORGE_CLIENT" != "$want" ]; then
|
|
||||||
cat >&2 <<EOF
|
|
||||||
forge_preflight: this is a '$forge' forge and the '$CEREMONY_FORGE_CLIENT' client cannot speak it (#188).
|
|
||||||
gh speaks GitHub's /api/v3 against api.github.com; Forgejo serves /api/v1
|
|
||||||
and has no GraphQL surface at all. Pointing one at the other does not
|
|
||||||
fail usefully — it reads nothing and reports success.
|
|
||||||
This forge needs the '$want' client.
|
|
||||||
EOF
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Then prove the tools are actually here — declared or not. A missing
|
|
||||||
# binary is the rig failure verbatim, "line 692: gh: command not found",
|
|
||||||
# and it must be a refusal before the sweep, not a 127 halfway through
|
|
||||||
# one. Checked on BOTH paths deliberately: a call site that declares the
|
|
||||||
# right client on a runner that lacks it is still a blind sweep waiting
|
|
||||||
# to happen.
|
|
||||||
local missing_bins=() bin
|
|
||||||
case "$want" in
|
|
||||||
gh) command -v gh >/dev/null 2>&1 || missing_bins+=(gh) ;;
|
|
||||||
rest) for bin in curl jq; do command -v "$bin" >/dev/null 2>&1 || missing_bins+=("$bin"); done ;;
|
|
||||||
esac
|
|
||||||
if [ "${#missing_bins[@]}" -gt 0 ]; then
|
|
||||||
cat >&2 <<EOF
|
|
||||||
forge_preflight: this is a '$forge' forge, which needs the '$want' client, and ${missing_bins[*]} is not installed (#188).
|
|
||||||
Refusing before the sweep: a reconciler that cannot read the board must
|
|
||||||
not report that it reconciled one.
|
|
||||||
EOF
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
@ -209,7 +209,7 @@ reconcile_ruling() { # $1 item number, $2 last real-activity epoch, $3 now
|
||||||
# comment, which only these facts identify, and half-verdicts on half-read
|
# comment, which only these facts identify, and half-verdicts on half-read
|
||||||
# facts is the exact shape the reconciler's standing rule forbids.
|
# facts is the exact shape the reconciler's standing rule forbids.
|
||||||
local flags newest setter labeled_at labeled_epoch
|
local flags newest setter labeled_at labeled_epoch
|
||||||
if ! flags="$(forge_api --paginate "repos/$REPO/issues/$n/timeline" \
|
if ! flags="$(gh api --paginate "repos/$REPO/issues/$n/timeline" \
|
||||||
--jq '.[] | select(.event == "labeled" and .label.name == "needs-ruling")
|
--jq '.[] | select(.event == "labeled" and .label.name == "needs-ruling")
|
||||||
| [.actor.login, .created_at] | @tsv' 2>/dev/null)"; then
|
| [.actor.login, .created_at] | @tsv' 2>/dev/null)"; then
|
||||||
log "#$n: ruling timeline unreadable — no verdict invented this pass"
|
log "#$n: ruling timeline unreadable — no verdict invented this pass"
|
||||||
|
|
@ -230,7 +230,7 @@ reconcile_ruling() { # $1 item number, $2 last real-activity epoch, $3 now
|
||||||
# the whole file is line-oriented, so the row format stays TSV and the
|
# the whole file is line-oriented, so the row format stays TSV and the
|
||||||
# body is decoded at its points of use (#73). Do not switch rows to JSON.
|
# body is decoded at its points of use (#73). Do not switch rows to JSON.
|
||||||
local comments
|
local comments
|
||||||
if ! comments="$(forge_api --paginate "repos/$REPO/issues/$n/comments" \
|
if ! comments="$(gh api --paginate "repos/$REPO/issues/$n/comments" \
|
||||||
--jq '.[] | [.user.login, .created_at, .html_url,
|
--jq '.[] | [.user.login, .created_at, .html_url,
|
||||||
((.body // "") | @base64)] | @tsv' 2>/dev/null)"; then
|
((.body // "") | @base64)] | @tsv' 2>/dev/null)"; then
|
||||||
log "#$n: ruling comments unreadable — no verdict invented this pass"
|
log "#$n: ruling comments unreadable — no verdict invented this pass"
|
||||||
|
|
@ -268,7 +268,7 @@ reconcile_ruling() { # $1 item number, $2 last real-activity epoch, $3 now
|
||||||
# ---- the bare-flag check (#50 D4, mechanical proxy) ----
|
# ---- the bare-flag check (#50 D4, mechanical proxy) ----
|
||||||
if [ "$(ruling_bare_decision "$setter" "$labeled_epoch" <<<"$authored")" = BARE ]; then
|
if [ "$(ruling_bare_decision "$setter" "$labeled_epoch" <<<"$authored")" = BARE ]; then
|
||||||
if [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_bare")" = POST ]; then
|
if [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_bare")" = POST ]; then
|
||||||
run forge_issue_comment "$n" "$RULING_BARE_MARKER
|
run gh issue comment "$n" -R "$REPO" --body "$RULING_BARE_MARKER
|
||||||
The ruling flag on this item was set by @$setter with no accompanying
|
The ruling flag on this item was set by @$setter with no accompanying
|
||||||
escalation comment. Setting it requires the escalation contract — the
|
escalation comment. Setting it requires the escalation contract — the
|
||||||
**question**, the **options**, and a **recommendation** — posted by the
|
**question**, the **options**, and a **recommendation** — posted by the
|
||||||
|
|
@ -299,7 +299,7 @@ still owed." >/dev/null
|
||||||
if [ "$shape" != SHAPED ] \
|
if [ "$shape" != SHAPED ] \
|
||||||
&& [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_shape")" = POST ]; then
|
&& [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_shape")" = POST ]; then
|
||||||
local missing="${shape#MALFORMED }"
|
local missing="${shape#MALFORMED }"
|
||||||
run forge_issue_comment "$n" "$RULING_SHAPE_MARKER
|
run gh issue comment "$n" -R "$REPO" --body "$RULING_SHAPE_MARKER
|
||||||
@$setter — the [escalation comment]($esc_url) accompanying this ruling flag
|
@$setter — the [escalation comment]($esc_url) accompanying this ruling flag
|
||||||
is missing required field labels: **$missing**. The contract's shape is
|
is missing required field labels: **$missing**. The contract's shape is
|
||||||
fixed because this machinery checks for it (heavy-duty/ceremony#50 D12):
|
fixed because this machinery checks for it (heavy-duty/ceremony#50 D12):
|
||||||
|
|
@ -327,7 +327,7 @@ enforced." >/dev/null
|
||||||
esac
|
esac
|
||||||
if [ "$rung" = RUNG12 ] \
|
if [ "$rung" = RUNG12 ] \
|
||||||
&& [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_rung12")" = POST ]; then
|
&& [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_rung12")" = POST ]; then
|
||||||
run forge_issue_comment "$n" "$RULING_RUNG12_MARKER
|
run gh issue comment "$n" -R "$REPO" --body "$RULING_RUNG12_MARKER
|
||||||
@$setter — this ruling is 12 hours past its \`labeled\` event: the ladder's
|
@$setter — this ruling is 12 hours past its \`labeled\` event: the ladder's
|
||||||
12h rung ([BUILDER.md — the ruling ask](https://github.com/heavy-duty/ceremony/blob/main/BUILDER.md#the-ruling-ask),
|
12h rung ([BUILDER.md — the ruling ask](https://github.com/heavy-duty/ceremony/blob/main/BUILDER.md#the-ruling-ask),
|
||||||
heavy-duty/ceremony#50 D13). Mechanically read, the escalation carries
|
heavy-duty/ceremony#50 D13). Mechanically read, the escalation carries
|
||||||
|
|
@ -342,7 +342,7 @@ reset on activity; this comment fires once per flag episode." >/dev/null
|
||||||
fi
|
fi
|
||||||
if [ "$rung" = RUNG24 ] \
|
if [ "$rung" = RUNG24 ] \
|
||||||
&& [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_rung24")" = POST ]; then
|
&& [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_rung24")" = POST ]; then
|
||||||
run forge_issue_comment "$n" "$RULING_RUNG24_MARKER
|
run gh issue comment "$n" -R "$REPO" --body "$RULING_RUNG24_MARKER
|
||||||
@$setter — this ruling is 24 hours past its \`labeled\` event: the ladder's
|
@$setter — this ruling is 24 hours past its \`labeled\` event: the ladder's
|
||||||
24h rung ([BUILDER.md — the ruling ask](https://github.com/heavy-duty/ceremony/blob/main/BUILDER.md#the-ruling-ask),
|
24h rung ([BUILDER.md — the ruling ask](https://github.com/heavy-duty/ceremony/blob/main/BUILDER.md#the-ruling-ask),
|
||||||
heavy-duty/ceremony#50 D13). Mechanically read, the escalation carries
|
heavy-duty/ceremony#50 D13). Mechanically read, the escalation carries
|
||||||
|
|
@ -375,7 +375,7 @@ timer." >/dev/null
|
||||||
else
|
else
|
||||||
esc_line="No escalation comment accompanies the flag — the contract (question, options, recommendation) is still owed by the flag-setter."
|
esc_line="No escalation comment accompanies the flag — the contract (question, options, recommendation) is still owed by the flag-setter."
|
||||||
fi
|
fi
|
||||||
run forge_issue_comment "$n" "@$decider — a ruling on this item has been pending with no activity for ${days} days. $esc_line
|
run gh issue comment "$n" -R "$REPO" --body "@$decider — a ruling on this item has been pending with no activity for ${days} days. $esc_line
|
||||||
|
|
||||||
Per heavy-duty/ceremony#50 D6/D7 the flag-setter ($setter) owns closing this out: judge when agreement is reached, record the ruling as a decision in one comment, remove the label, and return the item to its flow in that same comment.
|
Per heavy-duty/ceremony#50 D6/D7 the flag-setter ($setter) owns closing this out: judge when agreement is reached, record the ruling as a decision in one comment, remove the label, and return the item to its flow in that same comment.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# Contract tests for lib/closes_references.sh (issue #188, term 3).
|
|
||||||
# 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"
|
|
||||||
# issue_references (the LOCAL/CROSS classifier) lives here; closes_references
|
|
||||||
# calls it, exactly as refs_references does.
|
|
||||||
# shellcheck source=actions/issueflow-reconcile/issueflow-reconcile.sh
|
|
||||||
. "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh"
|
|
||||||
# shellcheck source=lib/closes_references.sh
|
|
||||||
. "$ROOT/lib/closes_references.sh"
|
|
||||||
|
|
||||||
# closes <want-newline-separated> <body> — the parse of <body> is exactly
|
|
||||||
# <want>. Exact, not substring: "12" is contained in "123".
|
|
||||||
closes() {
|
|
||||||
local want="$1" body="$2" got
|
|
||||||
got="$(printf '%s' "$body" | closes_references)"
|
|
||||||
[ "$got" = "$want" ]
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- the three verbs, the three tenses ----------------------------------
|
|
||||||
# GitHub's documented keyword set. All of them, because a body that says
|
|
||||||
# "Fixed #4" and goes unclosed is a silent failure of the post-merge
|
|
||||||
# transition, not a loud one.
|
|
||||||
|
|
||||||
check "closes" 0 "" closes 1 'Closes #1'
|
|
||||||
check "close" 0 "" closes 1 'Close #1'
|
|
||||||
check "closed" 0 "" closes 1 'Closed #1'
|
|
||||||
check "fixes" 0 "" closes 2 'Fixes #2'
|
|
||||||
check "fix" 0 "" closes 2 'Fix #2'
|
|
||||||
check "fixed" 0 "" closes 2 'Fixed #2'
|
|
||||||
check "resolves" 0 "" closes 3 'Resolves #3'
|
|
||||||
check "resolve" 0 "" closes 3 'Resolve #3'
|
|
||||||
check "resolved" 0 "" closes 3 'Resolved #3'
|
|
||||||
check "case-insensitive" 0 "" closes 4 'CLOSES #4'
|
|
||||||
check "lowercase" 0 "" closes 4 'closes #4'
|
|
||||||
check "colon form" 0 "" closes 5 'Closes: #5'
|
|
||||||
|
|
||||||
# --- Refs is NOT a closing link -----------------------------------------
|
|
||||||
# The relation this file must not swallow. refs_references owns Refs, and
|
|
||||||
# conflating them makes every referenced issue look closeable — the
|
|
||||||
# post-merge transition #151 was reopened by hand over exactly that
|
|
||||||
# distinction.
|
|
||||||
|
|
||||||
check "Refs is not a close" 0 "" closes '' 'Refs #7'
|
|
||||||
check "Refs and Closes in one body keeps only the close" 0 "" \
|
|
||||||
closes 8 $'Refs #7\nCloses #8'
|
|
||||||
|
|
||||||
# --- cross-repo references stay out (#61) -------------------------------
|
|
||||||
# rig#112 must never be read as local #112. The classifier is shared with
|
|
||||||
# refs_references precisely so this rule has one implementation.
|
|
||||||
|
|
||||||
check "qualified reference is not local" 0 "" closes '' 'Closes rig#112'
|
|
||||||
check "owner-qualified reference is not local" 0 "" \
|
|
||||||
closes '' 'Closes heavy-duty/rig#112'
|
|
||||||
check "a local and a cross reference keep only the local" 0 "" \
|
|
||||||
closes 9 $'Closes rig#112\nCloses #9'
|
|
||||||
|
|
||||||
# --- every occurrence contributes ---------------------------------------
|
|
||||||
# Binding to the first occurrence is the defect #184 fixed in
|
|
||||||
# blocked_reference_records; this parser must not reintroduce it.
|
|
||||||
|
|
||||||
check "two closes on one line" 0 "" closes $'1\n2' 'Closes #1. Closes #2.'
|
|
||||||
check "two closes on two lines" 0 "" closes $'1\n2' $'Closes #1\nCloses #2'
|
|
||||||
check "sorted and deduplicated" 0 "" closes $'2\n10' $'Closes #10\nCloses #2\nCloses #10'
|
|
||||||
|
|
||||||
# --- prose must not be swallowed ----------------------------------------
|
|
||||||
|
|
||||||
check "trailing prose is not part of the reference" 0 "" \
|
|
||||||
closes 12 'Closes #12, and adds the guard'
|
|
||||||
check "a sentence terminator ends the reference" 0 "" closes 13 'Closes #13.'
|
|
||||||
check "no reference means no output" 0 "" closes '' 'Closes the door behind it'
|
|
||||||
check "a bare issue mention is not a close" 0 "" closes '' 'See #14 for context'
|
|
||||||
# "unclosed" contains "close" — a naive word match would fire on it.
|
|
||||||
check "a word merely containing a verb does not fire" 0 "" \
|
|
||||||
closes '' 'This left #15 unclosed'
|
|
||||||
|
|
||||||
# --- the shapes a real PR body carries ----------------------------------
|
|
||||||
|
|
||||||
check "the template's leading declaration" 0 "" \
|
|
||||||
closes 188 $'Closes #188\n\n## Acceptance criteria\n\n- [ ] a thing'
|
|
||||||
check "an empty body yields nothing" 0 "" closes '' ''
|
|
||||||
|
|
||||||
summary
|
|
||||||
|
|
@ -10,12 +10,6 @@ set -u
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
# shellcheck source=test/harness.sh
|
# shellcheck source=test/harness.sh
|
||||||
. "$ROOT/test/harness.sh"
|
. "$ROOT/test/harness.sh"
|
||||||
# The suite drives the GITHUB backend: its gh() stubs ARE the forge boundary
|
|
||||||
# now, and forge_api/forge_issue_edit/... resolve to the gh invocations those
|
|
||||||
# stubs already intercept (#188). Without this the verbs are simply undefined.
|
|
||||||
# shellcheck source=lib/forge.sh
|
|
||||||
. "$ROOT/lib/forge.sh"
|
|
||||||
forge_select github
|
|
||||||
|
|
||||||
FACTS="$ROOT/lib/facts.sh"
|
FACTS="$ROOT/lib/facts.sh"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,439 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# Contract tests for lib/forge-github.sh and lib/forge-forgejo.sh
|
|
||||||
# (issue #188, term 1). set -u, not -e.
|
|
||||||
set -u
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
# shellcheck source=test/harness.sh
|
|
||||||
. "$ROOT/test/harness.sh"
|
|
||||||
# shellcheck source=lib/forge.sh
|
|
||||||
. "$ROOT/lib/forge.sh"
|
|
||||||
|
|
||||||
TMP="$(mktemp -d)"
|
|
||||||
trap 'rm -rf "$TMP"' EXIT
|
|
||||||
|
|
||||||
eq() {
|
|
||||||
local want="$1" got
|
|
||||||
shift
|
|
||||||
got="$("$@")" || return 1
|
|
||||||
[ "$got" = "$want" ]
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- forge_select: exactly one backend, chosen deliberately -------------
|
|
||||||
|
|
||||||
check "select github loads the github backend" 0 "" \
|
|
||||||
bash -c '. '"$ROOT"'/lib/forge.sh; forge_select github; declare -f github_page_url >/dev/null'
|
|
||||||
check "select forgejo loads the forgejo backend" 0 "" \
|
|
||||||
bash -c '. '"$ROOT"'/lib/forge.sh; forge_select forgejo; declare -f forgejo_page_url >/dev/null'
|
|
||||||
check "select refuses an unknown forge" 1 "unknown forge" \
|
|
||||||
bash -c '. '"$ROOT"'/lib/forge.sh; forge_select gitlab'
|
|
||||||
# shellcheck disable=SC2016 # $FORGE expands in the isolated bash -c process
|
|
||||||
check "select with no argument reads the environment" 0 "" \
|
|
||||||
bash -c 'CEREMONY_FORGE=forgejo; . '"$ROOT"'/lib/forge.sh; forge_select; [ "$FORGE" = forgejo ]'
|
|
||||||
|
|
||||||
# --- the page-size contract, both dialects ------------------------------
|
|
||||||
# The trap, measured 2026-08-02: each forge silently ignores the OTHER's
|
|
||||||
# page-size parameter and answers HTTP 200 with fewer items.
|
|
||||||
#
|
|
||||||
# ?per_page=100 GitHub 100 Forgejo 30 (ignored)
|
|
||||||
# ?limit=100 GitHub 30 Forgejo 50 (capped)
|
|
||||||
#
|
|
||||||
# So no call site names one, and these two functions are the only places
|
|
||||||
# that decide. Pure on purpose: the contract is testable without a network.
|
|
||||||
|
|
||||||
. "$ROOT/lib/forge-github.sh"
|
|
||||||
. "$ROOT/lib/forge-forgejo.sh"
|
|
||||||
|
|
||||||
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 "" \
|
|
||||||
eq 'repos/o/r/issues?state=open&per_page=100' github_page_url 'repos/o/r/issues?state=open'
|
|
||||||
check "forgejo: a bare path gets a query" 0 "" \
|
|
||||||
eq 'repos/o/r/issues?limit=50&page=1' forgejo_page_url 'repos/o/r/issues' 1
|
|
||||||
check "forgejo: an existing query is preserved" 0 "" \
|
|
||||||
eq 'repos/o/r/issues?state=open&limit=50&page=2' forgejo_page_url 'repos/o/r/issues?state=open' 2
|
|
||||||
|
|
||||||
# A caller that names a page size anyway must not be able to reintroduce the
|
|
||||||
# truncation — the parameter is stripped in BOTH dialects, on both backends,
|
|
||||||
# because the whole point is that the boundary decides and the call site
|
|
||||||
# cannot override it by accident.
|
|
||||||
check "github strips a stray per_page" 0 "" \
|
|
||||||
eq 'repos/o/r/issues?state=open&per_page=100' github_page_url 'repos/o/r/issues?state=open&per_page=30'
|
|
||||||
check "github strips a stray limit" 0 "" \
|
|
||||||
eq 'repos/o/r/issues?state=open&per_page=100' github_page_url 'repos/o/r/issues?state=open&limit=100'
|
|
||||||
check "forgejo strips a stray per_page" 0 "" \
|
|
||||||
eq 'repos/o/r/issues?state=open&limit=50&page=1' forgejo_page_url 'repos/o/r/issues?state=open&per_page=100' 1
|
|
||||||
check "forgejo strips a stray limit" 0 "" \
|
|
||||||
eq 'repos/o/r/issues?state=open&limit=50&page=1' forgejo_page_url 'repos/o/r/issues?state=open&limit=100' 1
|
|
||||||
check "stripping the only parameter leaves a clean query" 0 "" \
|
|
||||||
eq 'repos/o/r/issues?limit=50&page=1' forgejo_page_url 'repos/o/r/issues?per_page=100' 1
|
|
||||||
|
|
||||||
# --- the forgejo gather: complete, or loudly refused --------------------
|
|
||||||
# curl is stubbed as a function so these are hermetic. Each case writes the
|
|
||||||
# headers and body a real Forgejo would.
|
|
||||||
|
|
||||||
# fake_forge <total-spec> <pages…> — install a curl stub serving <pages> as
|
|
||||||
# successive page bodies, declaring <total-spec> in x-total-count. An empty
|
|
||||||
# string omits the header entirely (@kimi's #4699 case). A comma-separated
|
|
||||||
# spec declares a DIFFERENT total per page ("4,9"), which is
|
|
||||||
# @codex-reviewer-andresmgsl's changing-between-pages case (#4700 / #4712):
|
|
||||||
# a server whose count moves under the walk cannot have been read whole.
|
|
||||||
fake_forge() {
|
|
||||||
FAKE_TOTAL="$1"; shift
|
|
||||||
FAKE_PAGES=("$@")
|
|
||||||
FAKE_CALLS=0
|
|
||||||
# shellcheck disable=SC2317 # the stub is invoked indirectly, by forge_api
|
|
||||||
curl() {
|
|
||||||
local hdr="" out="" url=""
|
|
||||||
while [ $# -gt 0 ]; do
|
|
||||||
case "$1" in
|
|
||||||
-D) hdr="$2"; shift ;;
|
|
||||||
-o) out="$2"; shift ;;
|
|
||||||
-H) shift ;;
|
|
||||||
-*) ;;
|
|
||||||
*) url="$1" ;;
|
|
||||||
esac
|
|
||||||
shift
|
|
||||||
done
|
|
||||||
local page=1
|
|
||||||
case "$url" in *page=*) page="${url##*page=}"; page="${page%%&*}" ;; esac
|
|
||||||
local total="$FAKE_TOTAL"
|
|
||||||
case "$FAKE_TOTAL" in
|
|
||||||
*,*)
|
|
||||||
total="$(printf '%s' "$FAKE_TOTAL" | cut -d, -f"$page")"
|
|
||||||
[ -n "$total" ] || total="$(printf '%s' "$FAKE_TOTAL" | cut -d, -f1)"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
{
|
|
||||||
printf 'HTTP/1.1 200 OK\r\n'
|
|
||||||
[ -n "$total" ] && printf 'X-Total-Count: %s\r\n' "$total"
|
|
||||||
printf '\r\n'
|
|
||||||
} >"$hdr"
|
|
||||||
if [ "$page" -le "${#FAKE_PAGES[@]}" ]; then
|
|
||||||
printf '%s' "${FAKE_PAGES[$((page - 1))]}" >"$out"
|
|
||||||
else
|
|
||||||
printf '[]' >"$out"
|
|
||||||
fi
|
|
||||||
FAKE_CALLS=$((FAKE_CALLS + 1))
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export CEREMONY_FORGE_API=https://forge.example/api/v1
|
|
||||||
|
|
||||||
# One page, and the count agrees with the declared total.
|
|
||||||
fake_forge 2 '[{"number":1},{"number":2}]'
|
|
||||||
check "a complete single-page gather returns its items" 0 "" \
|
|
||||||
eq $'1\n2' forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
|
|
||||||
|
|
||||||
# Two pages that add up. The walk must not stop at the first page merely
|
|
||||||
# because it came back non-empty — rig has 137 issues across 3 pages, which
|
|
||||||
# is the case this models.
|
|
||||||
fake_forge 4 '[{"number":1},{"number":2}]' '[{"number":3},{"number":4}]'
|
|
||||||
check "a multi-page gather walks every page" 0 "" \
|
|
||||||
eq $'1\n2\n3\n4' forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
|
|
||||||
|
|
||||||
# The whole reason the assert exists: a server that declares more than it
|
|
||||||
# hands over must not produce a "successful" partial sweep.
|
|
||||||
fake_forge 137 '[{"number":1},{"number":2}]'
|
|
||||||
check "a short gather is refused, not reconciled" 1 "incomplete gather" \
|
|
||||||
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
|
|
||||||
check "...and the refusal names both counts" 1 "collected 2 of 137" \
|
|
||||||
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
|
|
||||||
|
|
||||||
# @kimi-reviewer-andresmgsl's hardening (#4699): the guard must not be able
|
|
||||||
# to degrade silently either. A Forgejo that does not expose x-total-count
|
|
||||||
# leaves the assert with nothing to compare, and an assert that cannot run
|
|
||||||
# must refuse rather than pass.
|
|
||||||
fake_forge '' '[{"number":1},{"number":2}]'
|
|
||||||
check "a missing x-total-count refuses" 1 "did not send x-total-count" \
|
|
||||||
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
|
|
||||||
check "...and says why it cannot prove completeness" 1 "cannot prove the gather is complete" \
|
|
||||||
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
|
|
||||||
|
|
||||||
# @codex-reviewer-andresmgsl's #4712 findings. Each one is a route by which
|
|
||||||
# an unprovable read could still have been reported as a whole one — the
|
|
||||||
# guard leaking the failure class it was built to stop, which is why they
|
|
||||||
# are refusals rather than warnings.
|
|
||||||
|
|
||||||
# A total that is not a number went straight into arithmetic. Reproduced on
|
|
||||||
# ab23a3b: `X-Total-Count: not-a-number` returned rc=0 with that string as
|
|
||||||
# the total.
|
|
||||||
fake_forge 'not-a-number' '[{"number":1}]'
|
|
||||||
check "a non-numeric total is refused" 1 "not a non-negative integer" \
|
|
||||||
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
|
|
||||||
check "...and the refusal quotes what arrived" 1 "not-a-number" \
|
|
||||||
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
|
|
||||||
fake_forge '12x' '[{"number":1}]'
|
|
||||||
check "a partly-numeric total is refused" 1 "not a non-negative integer" \
|
|
||||||
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
|
|
||||||
fake_forge '-3' '[{"number":1}]'
|
|
||||||
check "a negative total is refused" 1 "not a non-negative integer" \
|
|
||||||
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
|
|
||||||
|
|
||||||
# A total that MOVES under the walk. The loop read it once, so a board
|
|
||||||
# changing size mid-gather was invisible: page 1 said 4, page 2 said 9, and
|
|
||||||
# the walk stopped at 4 believing itself complete.
|
|
||||||
fake_forge '4,9' '[{"number":1},{"number":2}]' '[{"number":3},{"number":4}]'
|
|
||||||
check "a total that changes between pages is refused" 1 "changed between pages" \
|
|
||||||
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
|
|
||||||
# The distinguishing text, not a substring that survives losing half the
|
|
||||||
# message: "4" alone stayed green if the later total vanished, which is what
|
|
||||||
# @codex-reviewer-andresmgsl (#4727) and @grok-reviewer-andresmgsl (#4734)
|
|
||||||
# both caught. A test named "names BOTH totals" must fail when one goes.
|
|
||||||
check "...and the refusal names both totals" 1 "4 then 9" \
|
|
||||||
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
|
|
||||||
|
|
||||||
# A 200 whose body is not a collection. `length` on a non-array counted 0,
|
|
||||||
# so an object or a scalar arriving where a list belongs read as a complete
|
|
||||||
# EMPTY collection when the declared total was 0 — silence dressed as a
|
|
||||||
# clean sweep.
|
|
||||||
fake_forge 0 '{"message":"Not found"}'
|
|
||||||
check "a non-array body is refused" 1 "did not return a collection" \
|
|
||||||
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
|
|
||||||
fake_forge 0 '"a string"'
|
|
||||||
check "a scalar body is refused" 1 "did not return a collection" \
|
|
||||||
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
|
|
||||||
# A genuinely empty collection is still fine — the refusal must not fire on
|
|
||||||
# a repo that legitimately has nothing.
|
|
||||||
fake_forge 0 '[]'
|
|
||||||
check "an empty collection is not an error" 0 "" \
|
|
||||||
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
|
|
||||||
|
|
||||||
# --- HTTP failures are named, not swallowed -----------------------------
|
|
||||||
# gh exits non-zero on an HTTP error; curl does not without -f, and -f
|
|
||||||
# discards the body that explains why. So the status is read explicitly.
|
|
||||||
fake_forge 1 '[{"number":1}]'
|
|
||||||
# shellcheck disable=SC2317 # invoked indirectly, by forge_api
|
|
||||||
curl() {
|
|
||||||
local hdr="" out=""
|
|
||||||
while [ $# -gt 0 ]; do
|
|
||||||
case "$1" in -D) hdr="$2"; shift ;; -o) out="$2"; shift ;; esac
|
|
||||||
shift
|
|
||||||
done
|
|
||||||
printf 'HTTP/1.1 404 Not Found\r\n\r\n' >"$hdr"
|
|
||||||
printf '{"message":"Not found"}' >"$out"
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
check "a 404 is a named failure" 1 "HTTP 404" forge_api 'repos/o/r/issues/9999'
|
|
||||||
check "a 404 names the endpoint" 1 "repos/o/r/issues/9999" forge_api 'repos/o/r/issues/9999'
|
|
||||||
|
|
||||||
# --- forge_issue_edit: a typo must not become a green no-op --------------
|
|
||||||
# @codex-reviewer-andresmgsl (#4743). The github backend hands whatever it is
|
|
||||||
# given to `gh`, which fails on a flag it does not know. Dropping it here
|
|
||||||
# instead turned a mis-typed port site into a mutation that silently did not
|
|
||||||
# happen — this issue's own failure class, arriving inside the fix for it.
|
|
||||||
|
|
||||||
check "an unknown edit flag refuses" 1 "unknown flag" forge_issue_edit 1 --typo value
|
|
||||||
check "...and names the flag it refused" 1 "--typo" forge_issue_edit 1 --typo value
|
|
||||||
check "a flag with no value refuses" 1 "requires a value" forge_issue_edit 1 --add-label
|
|
||||||
|
|
||||||
# --- forge_label_create: an upsert, like gh's --force --------------------
|
|
||||||
# bootstrap_labels creates every declared label on EVERY workflow_dispatch,
|
|
||||||
# so a plain POST onto an existing name aborts the bootstrap under set -e
|
|
||||||
# from the second dispatch onward (#4743).
|
|
||||||
|
|
||||||
WRITES="$TMP/writes"
|
|
||||||
stub_writes() {
|
|
||||||
: >"$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 ;;
|
|
||||||
-H) shift ;;
|
|
||||||
-*) ;;
|
|
||||||
*) url="$1" ;;
|
|
||||||
esac
|
|
||||||
shift
|
|
||||||
done
|
|
||||||
printf 'HTTP/1.1 200 OK\r\nX-Total-Count: %s\r\n\r\n' "${FAKE_LABEL_N:-1}" >"$hdr"
|
|
||||||
case "$url" in
|
|
||||||
*"/labels?"* | */labels) printf '%s' "${FAKE_LABELS:-[]}" >"$out" ;;
|
|
||||||
*) printf '{}' >"$out" ;;
|
|
||||||
esac
|
|
||||||
[ "$method" = GET ] || printf '%s %s %s\n' "$method" "${url##*/api/v1/}" "$payload" >>"$WRITES"
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# The label does not exist yet -> POST (create).
|
|
||||||
FAKE_LABELS='[]' FAKE_LABEL_N=0 stub_writes
|
|
||||||
FAKE_LABELS='[]' FAKE_LABEL_N=0 REPO=o/r forge_label_create ready 0e8a16 'in the queue'
|
|
||||||
check "creating a new label POSTs" 0 "" grep -q '^POST repos/o/r/labels ' "$WRITES"
|
|
||||||
|
|
||||||
# The label already exists -> PATCH (update), which is what --force does.
|
|
||||||
FAKE_LABELS='[{"name":"ready","id":7}]' FAKE_LABEL_N=1 stub_writes
|
|
||||||
FAKE_LABELS='[{"name":"ready","id":7}]' FAKE_LABEL_N=1 REPO=o/r forge_label_create ready 0e8a16 'new text'
|
|
||||||
check "recreating an existing label PATCHes it" 0 "" \
|
|
||||||
grep -q '^PATCH repos/o/r/labels/7 ' "$WRITES"
|
|
||||||
check "...and does not POST a duplicate" 1 "" grep -q '^POST repos/o/r/labels ' "$WRITES"
|
|
||||||
check "...carrying the updated description" 0 "" grep -q 'new text' "$WRITES"
|
|
||||||
|
|
||||||
# --- forge_issue_edit on forgejo: the two asymmetries, hermetically ------
|
|
||||||
# Promised with the call-site port (@grok-reviewer-andresmgsl #4741 note 2,
|
|
||||||
# #4751 item 2). Live scratch-repo evidence proved these work; these prove
|
|
||||||
# they keep working, and pin the SHAPE of the requests.
|
|
||||||
|
|
||||||
# Removal resolves name -> id, because Forgejo takes names on add and only a
|
|
||||||
# numeric id on remove. Measured: DELETE .../labels/probe:one -> 422,
|
|
||||||
# DELETE .../labels/149 -> 204.
|
|
||||||
FAKE_LABELS='[{"name":"stale","id":11},{"name":"ready","id":12}]' FAKE_LABEL_N=2 stub_writes
|
|
||||||
FAKE_LABELS='[{"name":"stale","id":11},{"name":"ready","id":12}]' FAKE_LABEL_N=2 REPO=o/r forge_issue_edit 5 --remove-label stale
|
|
||||||
check "removing a label resolves its numeric id" 0 "" grep -q '^DELETE repos/o/r/issues/5/labels/11 ' "$WRITES"
|
|
||||||
check "...and never sends the name as the path segment" 1 "" grep -q 'labels/stale' "$WRITES"
|
|
||||||
|
|
||||||
# A label the repo does not have is a no-op, matching gh: the reconcilers
|
|
||||||
# call --remove-label unconditionally to converge state.
|
|
||||||
FAKE_LABELS='[{"name":"ready","id":12}]' FAKE_LABEL_N=1 stub_writes
|
|
||||||
FAKE_LABELS='[{"name":"ready","id":12}]' FAKE_LABEL_N=1 REPO=o/r forge_issue_edit 5 --remove-label nonexistent
|
|
||||||
check "removing an absent label writes nothing" 0 "" test ! -s "$WRITES"
|
|
||||||
|
|
||||||
# Adding takes names directly — no lookup, one request.
|
|
||||||
FAKE_LABELS='[]' FAKE_LABEL_N=0 stub_writes
|
|
||||||
FAKE_LABELS='[]' FAKE_LABEL_N=0 REPO=o/r forge_issue_edit 5 --add-label "ready,stale"
|
|
||||||
check "adding labels posts them by name" 0 "" grep -q '^POST repos/o/r/issues/5/labels .*"ready"' "$WRITES"
|
|
||||||
check "...comma-separated values are split, as gh splits them" 0 "" grep -q '"stale"' "$WRITES"
|
|
||||||
|
|
||||||
# Assignees are SET, not added/removed: PATCH takes the whole list. So a
|
|
||||||
# removal is a read-modify-write, and a naive translation would have cleared
|
|
||||||
# every OTHER assignee as a side effect of removing one.
|
|
||||||
assignee_stub() {
|
|
||||||
: >"$WRITES"
|
|
||||||
# shellcheck disable=SC2317 # invoked indirectly, by forge_issue_edit
|
|
||||||
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 ;;
|
|
||||||
-H) shift ;; -*) ;; *) url="$1" ;;
|
|
||||||
esac
|
|
||||||
shift
|
|
||||||
done
|
|
||||||
printf 'HTTP/1.1 200 OK\r\nX-Total-Count: 0\r\n\r\n' >"$hdr"
|
|
||||||
printf '{"assignees":[{"login":"alice"},{"login":"bob"}]}' >"$out"
|
|
||||||
[ "$method" = GET ] || printf '%s %s %s\n' "$method" "${url##*/api/v1/}" "$payload" >>"$WRITES"
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assignee_stub
|
|
||||||
REPO=o/r forge_issue_edit 5 --remove-assignee alice
|
|
||||||
check "removing one assignee PATCHes the surviving list" 0 "" grep -q '^PATCH repos/o/r/issues/5 .*"bob"' "$WRITES"
|
|
||||||
check "...and the removed one is gone from it" 1 "" grep -q '"alice"' "$WRITES"
|
|
||||||
|
|
||||||
assignee_stub
|
|
||||||
REPO=o/r forge_issue_edit 5 --add-assignee carol
|
|
||||||
check "adding an assignee keeps the existing ones" 0 "" grep -qE '^PATCH repos/o/r/issues/5 .*"alice".*"bob".*"carol"|^PATCH repos/o/r/issues/5 .*"alice".*"carol".*"bob"' "$WRITES"
|
|
||||||
|
|
||||||
# --- forge_labels_add / forge_request_reviewer, both backends ------------
|
|
||||||
# @codex-reviewer-andresmgsl #4780 item 3. These two writes came in with the
|
|
||||||
# call-site port and had no boundary pins of their own.
|
|
||||||
|
|
||||||
# ceremony#128 is the whole reason forge_labels_add exists as its own verb.
|
|
||||||
# The labeler action computed (labels-at-job-start union derived) and PUT the
|
|
||||||
# whole set, so a label applied while the job ran was silently removed —
|
|
||||||
# ceremony#128 lost its `release` label, the merge door's declared-intent
|
|
||||||
# read, two seconds after the builder set it. This write must therefore be an
|
|
||||||
# ADDITIVE POST and must never read-modify-write.
|
|
||||||
FAKE_LABELS='[{"name":"scope:docs","id":21}]' FAKE_LABEL_N=1 stub_writes
|
|
||||||
FAKE_LABELS='[{"name":"scope:docs","id":21}]' FAKE_LABEL_N=1 \
|
|
||||||
REPO=o/r forge_labels_add 7 scope:docs scope:cli
|
|
||||||
check "labels_add POSTs to the issue labels collection" 0 "" \
|
|
||||||
grep -q '^POST repos/o/r/issues/7/labels ' "$WRITES"
|
|
||||||
check "...carrying every name in one request" 0 "" \
|
|
||||||
grep -q '"scope:docs","scope:cli"' "$WRITES"
|
|
||||||
# The regression that would reopen ceremony#128: any PUT, or a GET-then-write.
|
|
||||||
check "...and never PUTs the whole set (ceremony#128)" 1 "" grep -q '^PUT ' "$WRITES"
|
|
||||||
check "...exactly one write, so nothing is read-modify-written" 0 "" \
|
|
||||||
test "$(wc -l <"$WRITES")" -eq 1
|
|
||||||
|
|
||||||
FAKE_LABELS='[]' FAKE_LABEL_N=0 stub_writes
|
|
||||||
FAKE_LABELS='[]' FAKE_LABEL_N=0 REPO=o/r forge_labels_add 7
|
|
||||||
check "labels_add with no labels writes nothing" 0 "" test ! -s "$WRITES"
|
|
||||||
|
|
||||||
# The reviewer payload shape. Measured against this instance: the endpoint
|
|
||||||
# serves post and delete only, and takes {"reviewers":[...]}.
|
|
||||||
FAKE_LABELS='[]' FAKE_LABEL_N=0 stub_writes
|
|
||||||
FAKE_LABELS='[]' FAKE_LABEL_N=0 REPO=o/r forge_request_reviewer 9 danmt
|
|
||||||
check "request_reviewer POSTs to requested_reviewers" 0 "" \
|
|
||||||
grep -q '^POST repos/o/r/pulls/9/requested_reviewers ' "$WRITES"
|
|
||||||
check "...with the reviewers array payload" 0 "" \
|
|
||||||
grep -q '{"reviewers":\["danmt"\]}' "$WRITES"
|
|
||||||
|
|
||||||
# The github twin is a 1:1 gh pass-through (term 5), so its parity is pinned
|
|
||||||
# by the command it builds rather than by an HTTP shape.
|
|
||||||
gh_calls="$TMP/ghcalls"
|
|
||||||
: >"$gh_calls"
|
|
||||||
# shellcheck disable=SC2317 # invoked indirectly, by the github verbs
|
|
||||||
gh() { printf '%s\n' "$*" >>"$gh_calls"; }
|
|
||||||
# A subshell so the github backend does not stay loaded over the forgejo
|
|
||||||
# cases below; REPO is deliberately scoped to it for the same reason.
|
|
||||||
(
|
|
||||||
forge_select github
|
|
||||||
# shellcheck disable=SC2030 # scoping REPO to this subshell is the point
|
|
||||||
REPO=o/r
|
|
||||||
forge_labels_add 7 scope:docs scope:cli
|
|
||||||
forge_request_reviewer 9 danmt
|
|
||||||
)
|
|
||||||
check "github labels_add uses the additive api POST, not issue edit" 0 "" \
|
|
||||||
grep -q 'api repos/o/r/issues/7/labels -f labels\[\]=scope:docs -f labels\[\]=scope:cli' "$gh_calls"
|
|
||||||
check "...and never routes through issue edit --add-label" 1 "" \
|
|
||||||
grep -q 'issue edit' "$gh_calls"
|
|
||||||
check "github request_reviewer posts the reviewer" 0 "" \
|
|
||||||
grep -q 'api repos/o/r/pulls/9/requested_reviewers -f reviewers\[\]=danmt' "$gh_calls"
|
|
||||||
unset -f gh
|
|
||||||
. "$ROOT/lib/forge-forgejo.sh"
|
|
||||||
|
|
||||||
# --- forge_pr_view: newest verdict per context must win ------------------
|
|
||||||
# checks_state groups repeated contexts and selects the newest by
|
|
||||||
# [.startedAt, .createdAt, .completedAt]. Mapping only {context,state} left
|
|
||||||
# the winner to incidental array order, so a stale re-run could outrank the
|
|
||||||
# live one (#4743). Forgejo's combined status carries created_at/updated_at
|
|
||||||
# — measured on this instance, where Actions DO land as commit statuses
|
|
||||||
# (rig main: "ci / check (push)" success, with created_at).
|
|
||||||
pr_view_stub() {
|
|
||||||
# shellcheck disable=SC2317 # invoked indirectly, by forge_pr_view
|
|
||||||
curl() {
|
|
||||||
local hdr="" out="" url=""
|
|
||||||
while [ $# -gt 0 ]; do
|
|
||||||
case "$1" in -D) hdr="$2"; shift ;; -o) out="$2"; shift ;; -H) shift ;; *) url="$1" ;; esac
|
|
||||||
shift
|
|
||||||
done
|
|
||||||
printf 'HTTP/1.1 200 OK\r\nX-Total-Count: 1\r\n\r\n' >"$hdr"
|
|
||||||
case "$url" in
|
|
||||||
*/status) printf '%s' "$FAKE_STATUS" >"$out" ;;
|
|
||||||
*) printf '{"head":{"sha":"abc"},"mergeable":true}' >"$out" ;;
|
|
||||||
esac
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
# The FAILURE is older but listed second — array order would pick it.
|
|
||||||
FAKE_STATUS='{"state":"failure","statuses":[
|
|
||||||
{"context":"ci / check","status":"success","created_at":"2026-08-02T10:00:00Z","updated_at":"2026-08-02T10:00:00Z"},
|
|
||||||
{"context":"ci / check","status":"failure","created_at":"2026-08-02T09:00:00Z","updated_at":"2026-08-02T09:00:00Z"}]}'
|
|
||||||
pr_view_stub
|
|
||||||
view_json="$(REPO=o/r forge_pr_view 5)"
|
|
||||||
check "pr_view maps createdAt" 0 "" \
|
|
||||||
grep -q '"createdAt": "2026-08-02T10:00:00Z"' <<<"$view_json"
|
|
||||||
check "pr_view maps completedAt" 0 "" \
|
|
||||||
grep -q '"completedAt":' <<<"$view_json"
|
|
||||||
check "pr_view maps mergeable to the UI string" 0 "" \
|
|
||||||
grep -q '"mergeable": "MERGEABLE"' <<<"$view_json"
|
|
||||||
# The real proof: feed it to the production classifier and confirm the newer
|
|
||||||
# SUCCESS wins over the older FAILURE regardless of array order.
|
|
||||||
# shellcheck source=actions/labels-reconcile/labels-reconcile.sh
|
|
||||||
. "$ROOT/actions/labels-reconcile/labels-reconcile.sh"
|
|
||||||
classified="$(checks_state <<<"$view_json")"
|
|
||||||
check "the newest verdict per context wins, not the array order" 0 "" \
|
|
||||||
test "$classified" = SUCCESS
|
|
||||||
|
|
||||||
# --- the api base must be known -----------------------------------------
|
|
||||||
check "no api base refuses" 1 "cannot reach the forge" \
|
|
||||||
bash -c 'unset CEREMONY_FORGE_API GITHUB_API_URL; . '"$ROOT"'/lib/forge-forgejo.sh; forgejo_api_base'
|
|
||||||
|
|
||||||
summary
|
|
||||||
|
|
@ -1,143 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# Contract tests for lib/forge.sh (issue #188). 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"
|
|
||||||
# shellcheck source=lib/forge.sh
|
|
||||||
. "$ROOT/lib/forge.sh"
|
|
||||||
|
|
||||||
# eq <want> <cmd...> — succeeds AND prints exactly <want>. check()'s
|
|
||||||
# substring match cannot prove "forgejo" was not printed as "forgejox".
|
|
||||||
eq() {
|
|
||||||
local want="$1" got
|
|
||||||
shift
|
|
||||||
got="$("$@")" || return 1
|
|
||||||
[ "$got" = "$want" ]
|
|
||||||
}
|
|
||||||
|
|
||||||
# detect_in <env-assignments…> — run forge_detect in a clean environment
|
|
||||||
# carrying only the named vars, so a leaked GITHUB_* from the CI running
|
|
||||||
# THIS suite cannot decide the answer. Every case below is hermetic.
|
|
||||||
detect_in() {
|
|
||||||
env -i PATH="$PATH" "$@" bash -c '. '"$ROOT"'/lib/forge.sh; forge_detect'
|
|
||||||
}
|
|
||||||
|
|
||||||
preflight_in() {
|
|
||||||
env -i PATH="$PATH" "$@" bash -c '. '"$ROOT"'/lib/forge.sh; forge_preflight'
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- forge_detect: the explicit override --------------------------------
|
|
||||||
# CEREMONY_FORGE outranks every probe. It is the escape hatch for a forge
|
|
||||||
# whose env this file has not met yet, and the handle the tests below use
|
|
||||||
# to drive the backends without a live instance.
|
|
||||||
|
|
||||||
check "override: github" 0 "" eq github detect_in CEREMONY_FORGE=github
|
|
||||||
check "override: forgejo" 0 "" eq forgejo detect_in CEREMONY_FORGE=forgejo
|
|
||||||
check "override refuses an unknown forge" 1 "unknown forge" \
|
|
||||||
detect_in CEREMONY_FORGE=gitlab
|
|
||||||
# A typo must not silently fall through to a probe that guesses right by
|
|
||||||
# accident: the operator said something, and it was wrong.
|
|
||||||
check "override outranks the env" 1 "unknown forge" \
|
|
||||||
detect_in CEREMONY_FORGE=gitlab GITHUB_API_URL=https://api.github.com
|
|
||||||
|
|
||||||
# --- forge_detect: GITHUB_API_URL, the load-bearing signal ---------------
|
|
||||||
# Measured on forgejo.heavyduty.builders 2026-08-02 with a real
|
|
||||||
# forgejo-runner v6.3.1 job (probe run, task 278). The Forgejo runner
|
|
||||||
# populates the GITHUB_* namespace — GITHUB_ACTIONS=true and all — so
|
|
||||||
# "GITHUB_ACTIONS is set" proves nothing at all. What differs is where
|
|
||||||
# those URLs point:
|
|
||||||
#
|
|
||||||
# GitHub GITHUB_API_URL=https://api.github.com
|
|
||||||
# Forgejo GITHUB_API_URL=https://forgejo.heavyduty.builders/api/v1
|
|
||||||
#
|
|
||||||
# That is the whole bug this issue exists for, in one variable: gh speaks
|
|
||||||
# /api/v3 against api.github.com, and neither half is true here.
|
|
||||||
|
|
||||||
check "api url: api.github.com is github" 0 "" \
|
|
||||||
eq github detect_in GITHUB_API_URL=https://api.github.com
|
|
||||||
check "api url: /api/v1 is forgejo" 0 "" \
|
|
||||||
eq forgejo detect_in GITHUB_API_URL=https://forgejo.heavyduty.builders/api/v1
|
|
||||||
# GitHub Enterprise Server: a self-hosted GitHub still speaks /api/v3, and
|
|
||||||
# it is a github backend on a non-github.com host. Getting this wrong would
|
|
||||||
# route a GHES consumer to the forgejo backend and break term 5.
|
|
||||||
check "api url: GHES /api/v3 is github" 0 "" \
|
|
||||||
eq github detect_in GITHUB_API_URL=https://ghe.example.com/api/v3
|
|
||||||
|
|
||||||
# --- forge_detect: GITEA_ACTIONS, the positive marker --------------------
|
|
||||||
# The Forgejo runner also exports GITEA_ACTIONS=true (measured, task 278),
|
|
||||||
# which GitHub never sets. It is checked BEFORE the URL shape because it is
|
|
||||||
# unambiguous where a hand-set GITHUB_API_URL might not be.
|
|
||||||
|
|
||||||
check "gitea marker alone is enough" 0 "" eq forgejo detect_in GITEA_ACTIONS=true
|
|
||||||
check "gitea marker outranks a github-shaped api url" 0 "" \
|
|
||||||
eq forgejo detect_in GITEA_ACTIONS=true GITHUB_API_URL=https://api.github.com
|
|
||||||
|
|
||||||
# --- forge_detect: refusing to guess ------------------------------------
|
|
||||||
# Nothing to read is NOT "probably github". A wrong guess here is exactly
|
|
||||||
# the silent blind sweep #188 measured; the whole point of this file is
|
|
||||||
# that an unknown forge is loud.
|
|
||||||
|
|
||||||
check "bare environment refuses" 1 "cannot determine which forge" detect_in
|
|
||||||
check "refusal names what it looked at" 1 "GITHUB_API_URL" detect_in
|
|
||||||
check "refusal names the escape hatch" 1 "CEREMONY_FORGE" detect_in
|
|
||||||
|
|
||||||
# --- forge_preflight: the must-fail case --------------------------------
|
|
||||||
# The Test plan's named must-fail: "point it at a Forgejo instance with a
|
|
||||||
# GitHub-shaped client and assert it refuses loudly rather than sweeping
|
|
||||||
# blind."
|
|
||||||
#
|
|
||||||
# Measured before this guard existed, against this instance:
|
|
||||||
# labels-scope exit 0 "no .github/labeler.yml — nothing to derive" (it exists)
|
|
||||||
# labels-reconcile exit 0 "reconciled." (zero PRs read)
|
|
||||||
# issueflow-reconcile exit 1 "unexpected end of JSON input"
|
|
||||||
# Two of three swept blind and reported success. gh present made it WORSE:
|
|
||||||
# it silenced the one loud failure. Hence: refuse before the sweep, not
|
|
||||||
# after — and say which forge and which client, so the log answers "why"
|
|
||||||
# without a second run (#101 D5's report-do-not-diagnose, one layer up).
|
|
||||||
|
|
||||||
check "forgejo + gh-only client refuses" 1 "cannot speak" \
|
|
||||||
preflight_in CEREMONY_FORGE=forgejo CEREMONY_FORGE_CLIENT=gh
|
|
||||||
check "the refusal names the forge" 1 "forgejo" \
|
|
||||||
preflight_in CEREMONY_FORGE=forgejo CEREMONY_FORGE_CLIENT=gh
|
|
||||||
# The interpolated client, not the bare string "gh" — which also appears in
|
|
||||||
# the explanatory prose ("gh speaks GitHub's /api/v3…"), so the old assertion
|
|
||||||
# stayed green even if the client name never reached the message. Same class
|
|
||||||
# as the "names both totals" weakness the panel caught in the backend suite
|
|
||||||
# (#4727 / #4734); found by auditing this file for the same shape.
|
|
||||||
check "the refusal names the client" 1 "the 'gh' client cannot speak it" \
|
|
||||||
preflight_in CEREMONY_FORGE=forgejo CEREMONY_FORGE_CLIENT=gh
|
|
||||||
# The refusal must be actionable, not merely loud: #188's whole cost was a
|
|
||||||
# red check that told nobody what to do.
|
|
||||||
check "the refusal names the issue" 1 "#188" \
|
|
||||||
preflight_in CEREMONY_FORGE=forgejo CEREMONY_FORGE_CLIENT=gh
|
|
||||||
|
|
||||||
# --- forge_preflight: the passing pairs ---------------------------------
|
|
||||||
|
|
||||||
check "github + gh passes" 0 "" preflight_in CEREMONY_FORGE=github CEREMONY_FORGE_CLIENT=gh
|
|
||||||
check "forgejo + rest passes" 0 "" preflight_in CEREMONY_FORGE=forgejo CEREMONY_FORGE_CLIENT=rest
|
|
||||||
# The mirror of the must-fail: a Forgejo client against GitHub is just as
|
|
||||||
# wrong, and symmetric refusal is cheaper than explaining why only one
|
|
||||||
# direction is checked.
|
|
||||||
check "github + rest refuses" 1 "cannot speak" \
|
|
||||||
preflight_in CEREMONY_FORGE=github CEREMONY_FORGE_CLIENT=rest
|
|
||||||
|
|
||||||
# --- forge_preflight: it refuses when the forge itself is unknown --------
|
|
||||||
# Detection failure must not be swallowed into a pass — that would restore
|
|
||||||
# the blind sweep through the back door.
|
|
||||||
check "unknown forge fails the preflight" 1 "cannot determine which forge" preflight_in
|
|
||||||
|
|
||||||
# --- forge_client: what each backend actually needs ----------------------
|
|
||||||
# Measured in the runner image the Forgejo instance actually uses
|
|
||||||
# (ghcr.io/catthehacker/ubuntu:act-22.04, task 278): gh ABSENT, stoke
|
|
||||||
# ABSENT, curl and jq present. So the forgejo backend is REST-over-curl by
|
|
||||||
# necessity, not preference — this is the measurement that retired option
|
|
||||||
# A (port to stoke) as well: the CLI is not on the runner either.
|
|
||||||
|
|
||||||
check "github backend wants gh" 0 "" eq gh forge_client github
|
|
||||||
check "forgejo backend wants rest" 0 "" eq rest forge_client forgejo
|
|
||||||
check "forge_client refuses an unknown backend" 1 "unknown forge" forge_client gitlab
|
|
||||||
|
|
||||||
summary
|
|
||||||
|
|
@ -34,13 +34,3 @@ summary() {
|
||||||
[ "$FAIL" -eq 0 ]
|
[ "$FAIL" -eq 0 ]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# forge_stub_path <endpoint> — strip the paging parameters the forge shim
|
|
||||||
# injects (#188) so a fixture keyed on the logical endpoint still matches.
|
|
||||||
# The page size moved OUT of the call sites and into the backend, which means
|
|
||||||
# every stub now sees "?per_page=100" appended to a paginated read; without
|
|
||||||
# this, a fixture lookup misses and the stub answers "unreadable", which the
|
|
||||||
# production code correctly reports as a degraded read.
|
|
||||||
forge_stub_path() {
|
|
||||||
printf '%s' "$1" | sed -E 's/([?&])(per_page|limit|page)=[0-9]+/\1/g; s/[?&]+$//; s/([?&])&+/\1/g'
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,6 @@ set -u
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
# shellcheck source=test/harness.sh
|
# shellcheck source=test/harness.sh
|
||||||
source "$ROOT/test/harness.sh"
|
source "$ROOT/test/harness.sh"
|
||||||
# The suite drives the GITHUB backend: its gh() stubs ARE the forge boundary
|
|
||||||
# now, and forge_api/forge_issue_edit/... resolve to the gh invocations those
|
|
||||||
# stubs already intercept (#188). Without this the verbs are undefined.
|
|
||||||
# shellcheck source=lib/forge.sh
|
|
||||||
. "$ROOT/lib/forge.sh"
|
|
||||||
forge_select github
|
|
||||||
# shellcheck source=actions/issueflow-reconcile/issueflow-reconcile.sh
|
# shellcheck source=actions/issueflow-reconcile/issueflow-reconcile.sh
|
||||||
source "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh"
|
source "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh"
|
||||||
|
|
||||||
|
|
@ -188,7 +182,7 @@ chmod +x "$TMP/gh"
|
||||||
# shellcheck disable=SC2016 # expansions belong to the isolated bash -c process
|
# shellcheck disable=SC2016 # expansions belong to the isolated bash -c process
|
||||||
check "cross-repo warning is idempotent across two sweeps" 0 "" \
|
check "cross-repo warning is idempotent across two sweeps" 0 "" \
|
||||||
env PATH="$TMP:$PATH" GH_COMMENTS="$TMP/comments" bash -c \
|
env PATH="$TMP:$PATH" GH_COMMENTS="$TMP/comments" bash -c \
|
||||||
'source "$1"; forge_select github; REPO=heavy-duty/ceremony
|
'source "$1"; REPO=heavy-duty/ceremony
|
||||||
ensure_comment 99 blocked-cross-repo "cross-repo warning"
|
ensure_comment 99 blocked-cross-repo "cross-repo warning"
|
||||||
ensure_comment 99 blocked-cross-repo "cross-repo warning"
|
ensure_comment 99 blocked-cross-repo "cross-repo warning"
|
||||||
test "$(grep -cF "<!-- issueflow:blocked-cross-repo -->" "$GH_COMMENTS")" -eq 1' \
|
test "$(grep -cF "<!-- issueflow:blocked-cross-repo -->" "$GH_COMMENTS")" -eq 1' \
|
||||||
|
|
@ -240,7 +234,6 @@ check "claimed plus attention is a healthy issue" 0 "KEEP" \
|
||||||
INOW=2000000000
|
INOW=2000000000
|
||||||
iso_at() { date -u -d "@$1" +%Y-%m-%dT%H:%M:%SZ; }
|
iso_at() { date -u -d "@$1" +%Y-%m-%dT%H:%M:%SZ; }
|
||||||
|
|
||||||
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
|
|
||||||
issue_stub_gh() {
|
issue_stub_gh() {
|
||||||
if [ "$1" = api ]; then
|
if [ "$1" = api ]; then
|
||||||
shift
|
shift
|
||||||
|
|
@ -253,7 +246,6 @@ issue_stub_gh() {
|
||||||
esac
|
esac
|
||||||
shift
|
shift
|
||||||
done
|
done
|
||||||
endpoint="$(forge_stub_path "$endpoint")"
|
|
||||||
file="$TMP/$(printf '%s' "$endpoint" | tr '/' '_').json"
|
file="$TMP/$(printf '%s' "$endpoint" | tr '/' '_').json"
|
||||||
printf '%s\n' "$endpoint" >>"$TMP/api-calls"
|
printf '%s\n' "$endpoint" >>"$TMP/api-calls"
|
||||||
[ ! -f "$file.error" ] || return 1
|
[ ! -f "$file.error" ] || return 1
|
||||||
|
|
@ -294,7 +286,6 @@ issue_probe() { # $1 issue, $2 labels, $3 assignees, $4 open PR, $5 merged PR, $
|
||||||
MERGED_REF_PR_RECORDS=""
|
MERGED_REF_PR_RECORDS=""
|
||||||
fi
|
fi
|
||||||
run() { "$@"; }
|
run() { "$@"; }
|
||||||
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
|
|
||||||
gh() { issue_stub_gh "$@"; }
|
gh() { issue_stub_gh "$@"; }
|
||||||
reconcile_issue "$1" 2>&1
|
reconcile_issue "$1" 2>&1
|
||||||
)
|
)
|
||||||
|
|
@ -540,7 +531,6 @@ jq -n --arg flag "$(iso_at $((INOW - 8 * 86400)))" \
|
||||||
jq -n --arg at "$(iso_at $((INOW - 8 * 86400 - 60)))" \
|
jq -n --arg at "$(iso_at $((INOW - 8 * 86400 - 60)))" \
|
||||||
'[{"user":{"login":"setter"},"created_at":$at,"html_url":"https://x/esc24","body":"question, options, recommendation"}]' \
|
'[{"user":{"login":"setter"},"created_at":$at,"html_url":"https://x/esc24","body":"question, options, recommendation"}]' \
|
||||||
>"$(cfix 24)"
|
>"$(cfix 24)"
|
||||||
# shellcheck disable=SC2317 # reached through the forge backend (#188)
|
|
||||||
churn_last="$( (REPO=owner/repo; gh() { issue_stub_gh "$@"; }
|
churn_last="$( (REPO=owner/repo; gh() { issue_stub_gh "$@"; }
|
||||||
last_issue_activity 24 "$(iso_at $((INOW - 10 * 86400)))") )"
|
last_issue_activity 24 "$(iso_at $((INOW - 10 * 86400)))") )"
|
||||||
check "last activity ignores the 2-day-old label churn" 0 "" \
|
check "last activity ignores the 2-day-old label churn" 0 "" \
|
||||||
|
|
@ -576,11 +566,6 @@ if [ "$1" = api ]; then
|
||||||
esac
|
esac
|
||||||
shift
|
shift
|
||||||
done
|
done
|
||||||
# Inlined, not the suite's helper: this stub is a standalone executable on
|
|
||||||
# PATH and cannot see a shell function from the test process. Strips the
|
|
||||||
# paging the forge shim injects so fixtures stay keyed on the logical
|
|
||||||
# endpoint (#188).
|
|
||||||
endpoint="$(printf '%s' "$endpoint" | sed -E 's/([?&])(per_page|limit|page)=[0-9]+/\1/g; s/[?&]+$//; s/([?&])&+/\1/g')"
|
|
||||||
file="$GH_FIXTURES/$(printf '%s' "$endpoint" | tr '/?&=' '____').json"
|
file="$GH_FIXTURES/$(printf '%s' "$endpoint" | tr '/?&=' '____').json"
|
||||||
[ ! -f "$file.error" ] || exit 1
|
[ ! -f "$file.error" ] || exit 1
|
||||||
if [ -f "$file" ]; then payload="$(cat "$file")"; else payload='[]'; fi
|
if [ -f "$file" ]; then payload="$(cat "$file")"; else payload='[]'; fi
|
||||||
|
|
@ -592,22 +577,13 @@ echo "gh stub: unexpected call: gh $*" >&2
|
||||||
exit 97
|
exit 97
|
||||||
EOF
|
EOF
|
||||||
chmod +x "$ARRIVAL/stub/gh"
|
chmod +x "$ARRIVAL/stub/gh"
|
||||||
# The two PR gathers were GraphQL until #188; they are REST now, so the
|
printf '%s\n' \
|
||||||
# fixtures are the /pulls list both forges return. Empty by default — the
|
'{"data":{"repository":{"pullRequests":{"nodes":[],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \
|
||||||
# merged-Refs case below fills the closed one.
|
>"$ARRIVAL/fixtures/graphql.json"
|
||||||
printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_open.json"
|
|
||||||
printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_closed.json"
|
|
||||||
arrival_fixture() { printf '%s\n' "$1" >"$ARRIVAL/fixtures/repos_owner_repo_issues_91.json"; }
|
arrival_fixture() { printf '%s\n' "$1" >"$ARRIVAL/fixtures/repos_owner_repo_issues_91.json"; }
|
||||||
# CEREMONY_FORGE=github below, and at the executable-sweep driver further
|
|
||||||
# down: these fixtures ARE a GitHub board (a gh stub on PATH answering
|
|
||||||
# /api/v3 shapes), so the suite says so at the forge boundary rather than
|
|
||||||
# letting main()'s preflight infer a forge from whatever env the CI job
|
|
||||||
# leaked (#188). Stubbing `gh` and staying silent about the forge is the
|
|
||||||
# boundary this issue moved.
|
|
||||||
arrival_run() {
|
arrival_run() {
|
||||||
: >"$ARRIVAL/fixtures/edits"
|
: >"$ARRIVAL/fixtures/edits"
|
||||||
env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$ARRIVAL/fixtures" \
|
env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$ARRIVAL/fixtures" \
|
||||||
CEREMONY_FORGE=github \
|
|
||||||
REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \
|
REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \
|
||||||
EVENT_NAME=issues EVENT_ACTION=opened EVENT_ISSUE=91 \
|
EVENT_NAME=issues EVENT_ACTION=opened EVENT_ISSUE=91 \
|
||||||
bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh"
|
bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh"
|
||||||
|
|
@ -643,17 +619,13 @@ check "...and the sweep still runs" 0 "" \
|
||||||
grep -qF 'issueflow: reconciled.' <<<"$pr_out"
|
grep -qF 'issueflow: reconciled.' <<<"$pr_out"
|
||||||
|
|
||||||
# The merged-Refs transition must survive the executable's set -e path too.
|
# The merged-Refs transition must survive the executable's set -e path too.
|
||||||
# Keep this at main() granularity: the PR gather and loop are the code a
|
# Keep this at main() granularity: the GraphQL gather and loop are the code
|
||||||
# sourced decision probe cannot exercise (#91's lesson).
|
# a sourced decision probe cannot exercise (#91's lesson).
|
||||||
#
|
|
||||||
# merged_at is what makes this PR merged rather than merely closed — the
|
|
||||||
# REST replacement for GraphQL's states: MERGED filter (#188). Both forges
|
|
||||||
# return the field, and both return null on a closed-unmerged PR.
|
|
||||||
printf '%s\n' \
|
printf '%s\n' \
|
||||||
'[{"number":400,"body":"Refs #40","merged_at":"2026-07-30T00:00:00Z"},{"number":401,"body":"Refs #40","merged_at":null}]' \
|
'{"data":{"repository":{"pullRequests":{"nodes":[{"number":400,"body":"Refs #40","closingIssuesReferences":{"nodes":[]}}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \
|
||||||
>"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_closed.json"
|
>"$ARRIVAL/fixtures/graphql.json"
|
||||||
printf '[{"number":40}]\n' \
|
printf '[{"number":40}]\n' \
|
||||||
>"$ARRIVAL/fixtures/repos_owner_repo_issues_state_open.json"
|
>"$ARRIVAL/fixtures/repos_owner_repo_issues_state_open_per_page_100.json"
|
||||||
jq -n --arg at "$(iso_at "$INOW")" \
|
jq -n --arg at "$(iso_at "$INOW")" \
|
||||||
'{number:40,user:{login:"triage-one"},created_at:$at,body:"- [x] built\n- [ ] verify live label",labels:[{name:"claimed"}],assignees:[{login:"builder"}]}' \
|
'{number:40,user:{login:"triage-one"},created_at:$at,body:"- [x] built\n- [ ] verify live label",labels:[{name:"claimed"}],assignees:[{login:"builder"}]}' \
|
||||||
>"$ARRIVAL/fixtures/repos_owner_repo_issues_40.json"
|
>"$ARRIVAL/fixtures/repos_owner_repo_issues_40.json"
|
||||||
|
|
@ -661,71 +633,18 @@ printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_issues_40_comments.json"
|
||||||
: >"$ARRIVAL/fixtures/edits"
|
: >"$ARRIVAL/fixtures/edits"
|
||||||
subprocess_out="$(
|
subprocess_out="$(
|
||||||
env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$ARRIVAL/fixtures" \
|
env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$ARRIVAL/fixtures" \
|
||||||
CEREMONY_FORGE=github \
|
|
||||||
REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \
|
REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \
|
||||||
bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1
|
bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1
|
||||||
)"
|
)"
|
||||||
subprocess_rc=$?
|
subprocess_rc=$?
|
||||||
check "executable sweep transitions merged Refs work" 0 "" \
|
check "executable sweep transitions merged Refs work" 0 "" \
|
||||||
test "$subprocess_rc" -eq 0
|
test "$subprocess_rc" -eq 0
|
||||||
check "...reaches the transition through the REST gather and the issue loop" 0 "" \
|
check "...reaches the transition through GraphQL and the issue loop" 0 "" \
|
||||||
grep -qF '#40: merged Refs PR -> post-merge; claim released' <<<"$subprocess_out"
|
grep -qF '#40: merged Refs PR -> post-merge; claim released' <<<"$subprocess_out"
|
||||||
check "...and performs the release edit from the executable path" 0 "" \
|
check "...and performs the release edit from the executable path" 0 "" \
|
||||||
grep -qF -- 'issue edit 40 -R owner/repo --remove-assignee builder --remove-label claimed --add-label post-merge' \
|
grep -qF -- 'issue edit 40 -R owner/repo --remove-assignee builder --remove-label claimed --add-label post-merge' \
|
||||||
"$ARRIVAL/fixtures/edits"
|
"$ARRIVAL/fixtures/edits"
|
||||||
|
|
||||||
# -- the OPEN-pull gather, at main() granularity ----------------------------
|
|
||||||
# The closed/merged half above proves one REST path; this proves the other,
|
|
||||||
# which is a DIFFERENT pipeline: `.body | @base64` -> base64 -d ->
|
|
||||||
# closes_references -> OPEN_PR_ISSUES. The 27 parser cases in
|
|
||||||
# test/closes_references.test.sh cannot reach it — they test the parser, not
|
|
||||||
# the encoding and wiring around it (#188).
|
|
||||||
#
|
|
||||||
# Both directions in ONE sweep, so neither assertion can pass vacuously:
|
|
||||||
# #50 IS closed by an open PR -> the claim is KEPT, no reclaim edit
|
|
||||||
# #51 is closed by nothing -> the claim is RECLAIMED
|
|
||||||
# A break anywhere in the pipeline reclaims #50 too, and the first check
|
|
||||||
# fails. A break that reclaims nothing fails the second.
|
|
||||||
#
|
|
||||||
# `Closes #50` sits on the THIRD line of the body on purpose. jq's @tsv
|
|
||||||
# escapes a newline to a literal backslash-n, so a line-oriented parser
|
|
||||||
# reading an @tsv-encoded body sees one line and drops everything after the
|
|
||||||
# first — with the declaration on line 3, that defect reclaims #50 and this
|
|
||||||
# case goes red. On line 1 it would pass either way, which is the definition
|
|
||||||
# of a vacuous test.
|
|
||||||
printf '%s\n' \
|
|
||||||
'[{"number":500,"body":"## Summary\nSome prose about the work.\nCloses #50\n","merged_at":null}]' \
|
|
||||||
>"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_open.json"
|
|
||||||
printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_closed.json"
|
|
||||||
printf '[{"number":50},{"number":51}]\n' \
|
|
||||||
>"$ARRIVAL/fixtures/repos_owner_repo_issues_state_open.json"
|
|
||||||
# Both claims are two hours quiet against a ONE-hour stale bound, so the
|
|
||||||
# reclaim clock has genuinely expired for whichever of them no open PR
|
|
||||||
# rescues. The clock is injected rather than real: INOW is a fixed epoch in
|
|
||||||
# 2033, so without ISSUEFLOW_NOW the subprocess reads its own wall clock,
|
|
||||||
# dates these claims in the future, and both survive on a negative age —
|
|
||||||
# which is a green test proving nothing.
|
|
||||||
for n in 50 51; do
|
|
||||||
jq -n --arg at "$(iso_at $((INOW - 7200)))" --argjson n "$n" \
|
|
||||||
'{number:$n,user:{login:"triage-one"},created_at:$at,body:"- [x] built",labels:[{name:"claimed"}],assignees:[{login:"builder"}]}' \
|
|
||||||
>"$ARRIVAL/fixtures/repos_owner_repo_issues_$n.json"
|
|
||||||
printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_issues_${n}_comments.json"
|
|
||||||
done
|
|
||||||
: >"$ARRIVAL/fixtures/edits"
|
|
||||||
open_pr_out="$(
|
|
||||||
env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$ARRIVAL/fixtures" \
|
|
||||||
CEREMONY_FORGE=github ISSUEFLOW_NOW="$INOW" ISSUEFLOW_STALE_HOURS=1 \
|
|
||||||
REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \
|
|
||||||
bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1
|
|
||||||
)"
|
|
||||||
check "the open-pull gather completes" 0 "" \
|
|
||||||
grep -qF 'issueflow: reconciled.' <<<"$open_pr_out"
|
|
||||||
check "a claim closed by an open PR survives the base64 round trip" 1 "" \
|
|
||||||
grep -qE 'issue edit 50 .*--remove-label claimed' "$ARRIVAL/fixtures/edits"
|
|
||||||
check "...while the claim no open PR closes is reclaimed in the same sweep" 0 "" \
|
|
||||||
grep -qE 'issue edit 51 .*--remove-label claimed --add-label ready' \
|
|
||||||
"$ARRIVAL/fixtures/edits"
|
|
||||||
|
|
||||||
# D2 preserved: only the deliberate stand-downs changed; a genuine failure on
|
# D2 preserved: only the deliberate stand-downs changed; a genuine failure on
|
||||||
# the arrival path still kills the run loudly.
|
# the arrival path still kills the run loudly.
|
||||||
: >"$ARRIVAL/fixtures/repos_owner_repo_issues_91.json.error"
|
: >"$ARRIVAL/fixtures/repos_owner_repo_issues_91.json.error"
|
||||||
|
|
|
||||||
|
|
@ -13,19 +13,6 @@ export LC_ALL=C
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
# shellcheck source=actions/labels-reconcile/labels-reconcile.sh
|
# shellcheck source=actions/labels-reconcile/labels-reconcile.sh
|
||||||
. actions/labels-reconcile/labels-reconcile.sh
|
. actions/labels-reconcile/labels-reconcile.sh
|
||||||
# This suite drives the GITHUB backend: its gh() stubs ARE the forge boundary
|
|
||||||
# now, and forge_label_delete/forge_issue_comment/... resolve to the gh
|
|
||||||
# invocations those stubs already intercept (#188). main() selects a backend
|
|
||||||
# itself, but these probes call the pure functions directly, so the suite has
|
|
||||||
# to say which forge it is standing in.
|
|
||||||
forge_select github
|
|
||||||
# This suite predates test/harness.sh and carries its own expect(), so it does
|
|
||||||
# not get harness.sh's helper — define it here rather than pulling in a second
|
|
||||||
# assertion vocabulary. Strips the paging the shim injects so a fixture keyed
|
|
||||||
# on the logical endpoint still matches (#188).
|
|
||||||
forge_stub_path() {
|
|
||||||
printf '%s' "$1" | sed -E 's/([?&])(per_page|limit|page)=[0-9]+/\1/g; s/[?&]+$//; s/([?&])&+/\1/g'
|
|
||||||
}
|
|
||||||
load_config .github/labels.conf
|
load_config .github/labels.conf
|
||||||
set_required_bots codex-bot-andresmgsl
|
set_required_bots codex-bot-andresmgsl
|
||||||
|
|
||||||
|
|
@ -525,7 +512,6 @@ reconcile_probe() { # $1 = REPO_LABELS content → the log lines reconcile_pr em
|
||||||
MERGEABLE=MERGEABLE CHECKS=SUCCESS
|
MERGEABLE=MERGEABLE CHECKS=SUCCESS
|
||||||
PR_JSON='{"created_at":"2020-01-01T00:00:00Z"}'
|
PR_JSON='{"created_at":"2020-01-01T00:00:00Z"}'
|
||||||
run() { :; } # swallow mutations
|
run() { :; } # swallow mutations
|
||||||
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
|
|
||||||
gh() { :; } # no network
|
gh() { :; } # no network
|
||||||
reconcile_pr 777 2>&1
|
reconcile_pr 777 2>&1
|
||||||
)
|
)
|
||||||
|
|
@ -624,7 +610,6 @@ ruling_probe() { # $1 = the PR's labels → the log lines reconcile_pr emits
|
||||||
MERGEABLE=MERGEABLE CHECKS=SUCCESS
|
MERGEABLE=MERGEABLE CHECKS=SUCCESS
|
||||||
PR_JSON='{"created_at":"2020-01-01T00:00:00Z"}'
|
PR_JSON='{"created_at":"2020-01-01T00:00:00Z"}'
|
||||||
run() { :; } # swallow mutations
|
run() { :; } # swallow mutations
|
||||||
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
|
|
||||||
gh() { :; } # no network
|
gh() { :; } # no network
|
||||||
reconcile_pr 888 2>&1
|
reconcile_pr 888 2>&1
|
||||||
)
|
)
|
||||||
|
|
@ -674,7 +659,6 @@ ruling_sweep_probe() { # $1 = the PR's labels → reconcile_pr's log lines
|
||||||
MERGEABLE=MERGEABLE CHECKS=SUCCESS
|
MERGEABLE=MERGEABLE CHECKS=SUCCESS
|
||||||
PR_JSON="$(jq -n --arg at "$(iso_at $((RNOW - 10 * 86400)))" '{created_at: $at}')"
|
PR_JSON="$(jq -n --arg at "$(iso_at $((RNOW - 10 * 86400)))" '{created_at: $at}')"
|
||||||
run() { "$@"; } # mutations reach the stub and are recorded, not swallowed
|
run() { "$@"; } # mutations reach the stub and are recorded, not swallowed
|
||||||
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
|
|
||||||
gh() {
|
gh() {
|
||||||
if [ "$1" = api ]; then
|
if [ "$1" = api ]; then
|
||||||
shift
|
shift
|
||||||
|
|
@ -687,7 +671,6 @@ ruling_sweep_probe() { # $1 = the PR's labels → reconcile_pr's log lines
|
||||||
esac
|
esac
|
||||||
shift
|
shift
|
||||||
done
|
done
|
||||||
endpoint="$(forge_stub_path "$endpoint")"
|
|
||||||
file="$RTMP/$(printf '%s' "$endpoint" | tr '/' '_').json"
|
file="$RTMP/$(printf '%s' "$endpoint" | tr '/' '_').json"
|
||||||
# A missing fixture is an empty collection — projected through the
|
# A missing fixture is an empty collection — projected through the
|
||||||
# caller's --jq exactly like real gh, so '.[].foo' yields no lines.
|
# caller's --jq exactly like real gh, so '.[].foo' yields no lines.
|
||||||
|
|
@ -755,10 +738,6 @@ blind_main_probe() {
|
||||||
GITHUB_EVENT_NAME=schedule
|
GITHUB_EVENT_NAME=schedule
|
||||||
REPO=owner/repo
|
REPO=owner/repo
|
||||||
LABELS_CONF=.github/labels.conf
|
LABELS_CONF=.github/labels.conf
|
||||||
# This probe IS a GitHub board — say so at the forge boundary rather
|
|
||||||
# than leaving main()'s preflight to infer one (#188).
|
|
||||||
CEREMONY_FORGE=github
|
|
||||||
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
|
|
||||||
gh() {
|
gh() {
|
||||||
if [ "$1" = label ] && [ "$2" = list ]; then
|
if [ "$1" = label ] && [ "$2" = list ]; then
|
||||||
core_label_rows | cut -d'|' -f1
|
core_label_rows | cut -d'|' -f1
|
||||||
|
|
@ -842,10 +821,10 @@ expected_upserts="$({ core_label_rows; configured_label_rows .github/labels.conf
|
||||||
)
|
)
|
||||||
expect "a dispatch deletes the six in the same run as the upserts" \
|
expect "a dispatch deletes the six in the same run as the upserts" \
|
||||||
"$RETIRED_WANT" \
|
"$RETIRED_WANT" \
|
||||||
"$(sed -n 's/^forge_label_delete \(.*\)$/\1/p' "$BOOT/happy")"
|
"$(sed -n 's/^gh label delete \(.*\) -R owner\/repo --yes$/\1/p' "$BOOT/happy")"
|
||||||
expect "...and the recorded upsert set is unchanged from today's" \
|
expect "...and the recorded upsert set is unchanged from today's" \
|
||||||
"$expected_upserts" \
|
"$expected_upserts" \
|
||||||
"$(sed -n 's/^forge_label_create \([^ ]*\) .*/\1/p' "$BOOT/happy")"
|
"$(sed -n 's/^gh label create \([^ ]*\) .*/\1/p' "$BOOT/happy")"
|
||||||
|
|
||||||
# -- a missing label is success: gh exits non-zero with not-found, and the
|
# -- a missing label is success: gh exits non-zero with not-found, and the
|
||||||
# guard keeps that from aborting the dispatch. Red without the guard.
|
# guard keeps that from aborting the dispatch. Red without the guard.
|
||||||
|
|
@ -916,7 +895,7 @@ boot_dry_probe() {
|
||||||
}
|
}
|
||||||
dry_out="$(boot_dry_probe)"
|
dry_out="$(boot_dry_probe)"
|
||||||
expect "DRY_RUN narrates each deletion" \
|
expect "DRY_RUN narrates each deletion" \
|
||||||
6 "$(grep -c '^labels: DRY_RUN: forge_label_delete' <<<"$dry_out")"
|
6 "$(grep -c '^labels: DRY_RUN: gh label delete' <<<"$dry_out")"
|
||||||
expect "...and performs none" \
|
expect "...and performs none" \
|
||||||
no "$(test -f "$BOOT/dry-real" && echo yes || echo no)"
|
no "$(test -f "$BOOT/dry-real" && echo yes || echo no)"
|
||||||
|
|
||||||
|
|
@ -943,7 +922,6 @@ printf 'panel=bot-a bot-b bot-c\n' >"$EXEC/labels.conf"
|
||||||
exec_env() { # $1 = event name → the real script, executed under the PATH stub
|
exec_env() { # $1 = event name → the real script, executed under the PATH stub
|
||||||
: >"$EXEC/record"
|
: >"$EXEC/record"
|
||||||
env PATH="$EXEC/stub:$PATH" GH_RECORD="$EXEC/record" \
|
env PATH="$EXEC/stub:$PATH" GH_RECORD="$EXEC/record" \
|
||||||
CEREMONY_FORGE=github \
|
|
||||||
REPO=owner/repo LABELS_CONF="$EXEC/labels.conf" GITHUB_EVENT_NAME="$1" \
|
REPO=owner/repo LABELS_CONF="$EXEC/labels.conf" GITHUB_EVENT_NAME="$1" \
|
||||||
bash actions/labels-reconcile/labels-reconcile.sh
|
bash actions/labels-reconcile/labels-reconcile.sh
|
||||||
}
|
}
|
||||||
|
|
@ -968,51 +946,5 @@ for ev in schedule pull_request_target; do
|
||||||
expect "...and deletes nothing" \
|
expect "...and deletes nothing" \
|
||||||
no "$(grep -q '^delete ' "$EXEC/record" && echo yes || echo no)"
|
no "$(grep -q '^delete ' "$EXEC/record" && echo yes || echo no)"
|
||||||
done
|
done
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# outstanding_requests — the portable "who still owes a verdict" (#188 term 4)
|
|
||||||
#
|
|
||||||
# GitHub clears requested_reviewers when a verdict lands; Forgejo never does.
|
|
||||||
# Measured 2026-08-02: rig!140 listed all three panelists with all three
|
|
||||||
# verdicts in, and rig!146 still lists three while MERGED. Read raw on
|
|
||||||
# Forgejo, that pins a PR at state:bots-reviewing for life and stops
|
|
||||||
# blocker:unrequested from ever being true.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
HEAD_SHA=head1
|
|
||||||
REVIEWS_JSON="$(reviews \
|
|
||||||
"$(rev "$BOT1" APPROVED head1 "" 2026-08-01T00:00:00Z)" \
|
|
||||||
"$(rev "$BOT2" CHANGES_REQUESTED head1 "" 2026-08-01T00:00:00Z)" \
|
|
||||||
"$(rev "$BOT3" APPROVED head0 "" 2026-07-01T00:00:00Z)")"
|
|
||||||
|
|
||||||
expect "a head-current approval is no longer outstanding" "" \
|
|
||||||
"$(outstanding_requests "$BOT1")"
|
|
||||||
expect "a blocking verdict is not outstanding either — it is answered" "" \
|
|
||||||
"$(outstanding_requests "$BOT2")"
|
|
||||||
# The one that matters: an approval of an OLDER head is not a verdict on this
|
|
||||||
# head, so that reviewer still owes one. Treating STALE as answered would let
|
|
||||||
# a stale round read as complete.
|
|
||||||
expect "a stale approval still owes a verdict" "$BOT3" \
|
|
||||||
"$(outstanding_requests "$BOT3")"
|
|
||||||
expect "a reviewer who never reviewed still owes one" "nobody" \
|
|
||||||
"$(outstanding_requests "nobody")"
|
|
||||||
|
|
||||||
# The Forgejo shape, end to end: the field lists all three long after every
|
|
||||||
# verdict landed. Only the stale one may survive the filter.
|
|
||||||
expect "the never-cleared forgejo field collapses to who actually owes" \
|
|
||||||
"$BOT3" "$(outstanding_requests "$BOT1
|
|
||||||
$BOT2
|
|
||||||
$BOT3")"
|
|
||||||
|
|
||||||
# The GitHub shape: the field is already accurate, so the filter is a no-op
|
|
||||||
# on the set GitHub would have produced (term 5 — behaviour unchanged).
|
|
||||||
expect "on a github-shaped field the filter removes nothing" "nobody" \
|
|
||||||
"$(outstanding_requests "nobody")"
|
|
||||||
expect "an empty request list stays empty" "" "$(outstanding_requests "")"
|
|
||||||
|
|
||||||
# The summary and the gate belong at the TRUE end of the file. They sat in the
|
|
||||||
# middle until #188: eight outstanding_requests expects were appended after
|
|
||||||
# them, so a failure there printed FAIL, was left out of the totals, and the
|
|
||||||
# suite still exited 0 (@codex-reviewer-andresmgsl #4780 item 2). Anything
|
|
||||||
# appended below this line is ungated — so nothing goes below it.
|
|
||||||
printf 'labels-reconcile tests: %d passed, %d failed\n' "$pass" "$fail"
|
printf 'labels-reconcile tests: %d passed, %d failed\n' "$pass" "$fail"
|
||||||
[ "$fail" -eq 0 ]
|
[ "$fail" -eq 0 ]
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,6 @@ set -u
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
# shellcheck source=test/harness.sh
|
# shellcheck source=test/harness.sh
|
||||||
. "$ROOT/test/harness.sh"
|
. "$ROOT/test/harness.sh"
|
||||||
# The suite drives the GITHUB backend: its gh() stubs ARE the forge boundary
|
|
||||||
# now, and forge_api/forge_issue_edit/... resolve to the gh invocations those
|
|
||||||
# stubs already intercept (#188). Without this the verbs are simply undefined.
|
|
||||||
# shellcheck source=lib/forge.sh
|
|
||||||
. "$ROOT/lib/forge.sh"
|
|
||||||
forge_select github
|
|
||||||
|
|
||||||
FACTS="$ROOT/lib/facts.sh"
|
FACTS="$ROOT/lib/facts.sh"
|
||||||
DECIDE="$ROOT/lib/decide.sh"
|
DECIDE="$ROOT/lib/decide.sh"
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,6 @@ set -u
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
# shellcheck source=test/harness.sh
|
# shellcheck source=test/harness.sh
|
||||||
source "$ROOT/test/harness.sh"
|
source "$ROOT/test/harness.sh"
|
||||||
# The suite drives the GITHUB backend: its gh() stubs ARE the forge boundary
|
|
||||||
# now, and forge_api/forge_issue_edit/... resolve to the gh invocations those
|
|
||||||
# stubs already intercept (#188). Without this the verbs are undefined.
|
|
||||||
# shellcheck source=lib/forge.sh
|
|
||||||
. "$ROOT/lib/forge.sh"
|
|
||||||
forge_select github
|
|
||||||
# shellcheck source=lib/ruling.sh
|
# shellcheck source=lib/ruling.sh
|
||||||
source "$ROOT/lib/ruling.sh"
|
source "$ROOT/lib/ruling.sh"
|
||||||
|
|
||||||
|
|
@ -129,7 +123,6 @@ run() { "$@"; }
|
||||||
|
|
||||||
iso() { date -u -d "@$1" +%Y-%m-%dT%H:%M:%SZ; }
|
iso() { date -u -d "@$1" +%Y-%m-%dT%H:%M:%SZ; }
|
||||||
|
|
||||||
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
|
|
||||||
gh() {
|
gh() {
|
||||||
if [ "$1" = api ]; then
|
if [ "$1" = api ]; then
|
||||||
shift
|
shift
|
||||||
|
|
@ -142,7 +135,6 @@ gh() {
|
||||||
esac
|
esac
|
||||||
shift
|
shift
|
||||||
done
|
done
|
||||||
endpoint="$(forge_stub_path "$endpoint")"
|
|
||||||
file="$TMP/$(printf '%s' "$endpoint" | tr '/' '_').json"
|
file="$TMP/$(printf '%s' "$endpoint" | tr '/' '_').json"
|
||||||
[ -f "$file" ] || return 1
|
[ -f "$file" ] || return 1
|
||||||
if [ -n "$jqexpr" ]; then jq -r "$jqexpr" "$file"; else cat "$file"; fi
|
if [ -n "$jqexpr" ]; then jq -r "$jqexpr" "$file"; else cat "$file"; fi
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue