Compare commits
No commits in common. "main" and "build/209-commit-at" have entirely different histories.
main
...
build/209-
66 changed files with 866 additions and 4976 deletions
19
.github/DISCUSSION_TEMPLATE/ideas.yml
vendored
Normal file
19
.github/DISCUSSION_TEMPLATE/ideas.yml
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# Light on purpose: discussions are where ambiguity is ALLOWED — a form
|
||||
# that demands rigor at the door defeats the room's purpose (issue #24,
|
||||
# decision 4). If these prompts fight the flow in practice, delete them
|
||||
# before adding fields to them.
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: What's the itch?
|
||||
description: >-
|
||||
Vague is fine — a bug, an idea, a "we should…". Triage turns this
|
||||
into work (or an answer); you don't have to.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: What would "done" feel like?
|
||||
description: Optional — a sketch of the outcome, if you can already see it.
|
||||
validations:
|
||||
required: false
|
||||
15
.github/DISCUSSION_TEMPLATE/q-a.yml
vendored
Normal file
15
.github/DISCUSSION_TEMPLATE/q-a.yml
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# Light on purpose — same reasoning as ideas.yml (issue #24, decision 4):
|
||||
# optional prompts only, delete before hardening.
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: What's the question?
|
||||
description: Ask it plainly — context and links help, none are required.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: What have you tried or read already?
|
||||
description: Optional — saves the answerer a lap.
|
||||
validations:
|
||||
required: false
|
||||
15
.github/ISSUE_TEMPLATE/config.yml
vendored
15
.github/ISSUE_TEMPLATE/config.yml
vendored
|
|
@ -1,12 +1,13 @@
|
|||
# The new-issue interception (issue #24, decision 1): interception over
|
||||
# instruction — prose alone has already proven insufficient everywhere else
|
||||
# in this org. Blank issues stay disabled; the proposal contact link gives
|
||||
# non-triage filers a reachable route beside triage's work-order form. That
|
||||
# chooser is native forge behavior, no automation needed.
|
||||
# in this org. With blank issues disabled and exactly one form, a "New
|
||||
# issue" click shows the discussion link first; that auto-suggest is native
|
||||
# GitHub behavior, no automation needed.
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Ideas, bugs, questions — file a Proposal
|
||||
url: https://forgejo.heavyduty.builders/heavy-duty/ceremony/issues/new?template=proposal.yml
|
||||
- name: Ideas, bugs, questions — start a Discussion
|
||||
url: https://github.com/heavy-duty/ceremony/discussions
|
||||
about: >-
|
||||
Anyone may file a proposal. Triage converts it into a work issue or
|
||||
refuses it with reasons; only triage mints work issues (TRIAGE.md).
|
||||
Humans (and agents) never file issues here — discussions are where
|
||||
intent lives. Triage converges every discussion to an outcome, and
|
||||
only triage mints issues (TRIAGE.md).
|
||||
|
|
|
|||
34
.github/ISSUE_TEMPLATE/proposal.yml
vendored
34
.github/ISSUE_TEMPLATE/proposal.yml
vendored
|
|
@ -1,34 +0,0 @@
|
|||
# This intake form applies no labels: queue labels are triage's explicit act
|
||||
# (LABELS.md), and the issue-flow sweep catches non-triage authors, so the form
|
||||
# must not pre-judge the proposal's queue state (#24 D2).
|
||||
name: Proposal (anyone)
|
||||
description: >-
|
||||
Share an idea, bug, question, or rough "we should…" for triage to convert
|
||||
into work or refuse with reasons.
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: >-
|
||||
Proposals are the low-bar intake door. Say what you noticed and why it
|
||||
might matter; triage will decide whether it becomes a work issue.
|
||||
- type: textarea
|
||||
id: noticed
|
||||
attributes:
|
||||
label: What did you notice?
|
||||
description: A rough idea, bug, question, or "we should…" is enough.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: why-it-matters
|
||||
attributes:
|
||||
label: Why might it matter?
|
||||
description: Optional — describe the impact or opportunity if you can.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: known-context
|
||||
attributes:
|
||||
label: What do you already know?
|
||||
description: Optional — add links, examples, constraints, or prior art.
|
||||
validations:
|
||||
required: false
|
||||
6
.github/ISSUE_TEMPLATE/work-order.yml
vendored
6
.github/ISSUE_TEMPLATE/work-order.yml
vendored
|
|
@ -6,8 +6,8 @@
|
|||
# that (issue #24, decision 2).
|
||||
name: Work order (triage only)
|
||||
description: >-
|
||||
The issue contract (TRIAGE.md) as a form. Only triage mints work issues —
|
||||
everyone else files a proposal. Triage may still compose by hand when
|
||||
The issue contract (TRIAGE.md) as a form. Only triage mints issues —
|
||||
everyone else starts a Discussion. Triage may still compose by hand when
|
||||
the form fights it: `gh issue create --body-file` bypasses forms and stays
|
||||
legitimate for the triage identity.
|
||||
body:
|
||||
|
|
@ -25,7 +25,7 @@ body:
|
|||
attributes:
|
||||
label: Context
|
||||
description: >-
|
||||
Why this exists, with links — the proposal it came from, the code
|
||||
Why this exists, with links — the discussion it came from, the code
|
||||
it touches (permalinks at a pinned SHA, so line references cannot
|
||||
rot), prior art in sibling repos.
|
||||
validations:
|
||||
|
|
|
|||
1
.github/labeler.yml
vendored
1
.github/labeler.yml
vendored
|
|
@ -46,7 +46,6 @@ scope:release-flow:
|
|||
- CHANGELOG.md
|
||||
- drills/**
|
||||
- test/decide.test.sh
|
||||
- test/preflight.test.sh
|
||||
- test/facts.test.sh
|
||||
- test/release-chain.test.sh
|
||||
- test/version.test.sh
|
||||
|
|
|
|||
4
.github/labels.conf
vendored
4
.github/labels.conf
vendored
|
|
@ -1,5 +1,5 @@
|
|||
panel=codex-bot-andresmgsl glm-bot-andresmgsl claude-bot-andresmgsl kimi-bot-andresmgsl
|
||||
triage-actors=claude-bot-andresmgsl
|
||||
panel=codex-reviewer-andresmgsl kimi-reviewer-andresmgsl cluade-reviewer-andresmgsl
|
||||
triage-actors=cluade-reviewer-andresmgsl
|
||||
scope:release-flow|C5DEF5|The reusable release workflow, decide, the doors
|
||||
scope:guards|C5DEF5|changelog-armed / changelog-monotonic / drill-recorded
|
||||
scope:labels|C5DEF5|The labels workflow, reconciler, the taxonomy
|
||||
|
|
|
|||
1
.github/scripts/release-path.sh
vendored
1
.github/scripts/release-path.sh
vendored
|
|
@ -19,7 +19,6 @@ printf '%s\n' \
|
|||
bin/ \
|
||||
lib/version.sh \
|
||||
lib/decide.sh \
|
||||
lib/preflight.sh \
|
||||
lib/facts.sh \
|
||||
lib/changelog.sh \
|
||||
lib/forge.sh
|
||||
|
|
|
|||
13
.github/workflows/ci.yml
vendored
13
.github/workflows/ci.yml
vendored
|
|
@ -52,19 +52,6 @@ jobs:
|
|||
# at the root that nobody added to docs/VENDORED.txt is invisible to
|
||||
# every consumer's docs-sync, so it fails CI here instead.
|
||||
run: bash .github/scripts/vendored-check.sh
|
||||
- name: Fetch the recorded upstream commit
|
||||
# test/upstream-delta.test.sh REFUSES when the recorded object is
|
||||
# absent rather than calling it unverifiable (#200). "Runs offline"
|
||||
# means the test reads local evidence — it does not mean CI may omit
|
||||
# the evidence and pass. This step supplies it; the test never reaches
|
||||
# the network itself.
|
||||
run: |
|
||||
ref="$(grep -vE '^[[:space:]]*(#|$)' .upstream-ref | head -n1)"
|
||||
git fetch --no-tags --depth=1 \
|
||||
https://github.com/heavy-duty/ceremony.git "$ref" || {
|
||||
echo "::error::could not fetch the recorded upstream commit $ref" >&2
|
||||
exit 1
|
||||
}
|
||||
- name: Tests
|
||||
env:
|
||||
# The npm-backed version_write case may skip locally when npm is
|
||||
|
|
|
|||
48
.github/workflows/labels-sweep.yml
vendored
48
.github/workflows/labels-sweep.yml
vendored
|
|
@ -3,11 +3,9 @@ name: labels-sweep
|
|||
# jobs that rode labels.yml until #209. Triggers and permissions live in the
|
||||
# caller; docs/CONSUMERS.md carries the complete caller stub
|
||||
# (workflow_dispatch plus the hourly cron, which relocated here with the
|
||||
# sweep). Issue events and same-repository PR events still yield a sweep within
|
||||
# seconds: labels.yml's trigger job dispatches this workflow's caller on those
|
||||
# events. Fork-headed PR events carry a read-only token on this Forgejo, so
|
||||
# state, blocker, and handoff reconciliation waits for the caller's scheduled
|
||||
# cadence; the sweep does not apply path-derived scope labels (#241).
|
||||
# sweep). Board events still yield a sweep within seconds: labels.yml's
|
||||
# trigger job dispatches this workflow's caller on every event it used to
|
||||
# run reconcile on.
|
||||
#
|
||||
# Detached on purpose (#209): every sweep covers every open PR and all
|
||||
# sweeps serialize through ONE shared concurrency group, so GitHub's
|
||||
|
|
@ -26,8 +24,7 @@ name: labels-sweep
|
|||
# taxonomy (its `bootstrap` input defaults to "yes"), exactly what
|
||||
# dispatching the labels caller did before the split. The trigger job's
|
||||
# dispatches carry bootstrap=no — ~20 label upserts per sweep is too chatty
|
||||
# for every issue and same-repository PR wake, the same reason cron runs never
|
||||
# bootstrapped.
|
||||
# for every board event, the same reason cron runs never bootstrapped.
|
||||
#
|
||||
# This cannot loop: reconciler writes use GITHUB_TOKEN, and GitHub does not
|
||||
# create workflow runs from GITHUB_TOKEN-raised events (the trigger's
|
||||
|
|
@ -37,20 +34,6 @@ name: labels-sweep
|
|||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
bootstrap:
|
||||
description: >-
|
||||
Bootstrap the label taxonomy before sweeping. The CALLER passes
|
||||
this through from its own workflow_dispatch input. The measured
|
||||
invariant (ceremony#215): the value must be DECLARED here and
|
||||
EXPLICITLY passed — on this instance the called workflow did not
|
||||
see the caller's event inputs as an implicit substitute (runs
|
||||
459/523 bootstrapped on a bootstrap=no dispatch) while the
|
||||
top-level caller received the value in both contexts (probe runs
|
||||
6/7). Absent means "no": an event- or cron-woken sweep must never
|
||||
re-upsert ~20 labels.
|
||||
type: string
|
||||
required: false
|
||||
default: "no"
|
||||
pr_workflow_name:
|
||||
description: >-
|
||||
The `name:` of the consumer's PR-facing labels caller, exported
|
||||
|
|
@ -66,7 +49,7 @@ on:
|
|||
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.6.3"
|
||||
CEREMONY_SELF_REF: "0.6.0"
|
||||
|
||||
jobs:
|
||||
reconcile:
|
||||
|
|
@ -103,21 +86,18 @@ jobs:
|
|||
# papers over for `run:` steps, which composite `uses:` has no
|
||||
# equivalent of.
|
||||
#
|
||||
# bootstrap: read from the DECLARED workflow_call input and nothing
|
||||
# else. The old gate read `github.event.inputs.bootstrap` from inside
|
||||
# this called workflow, and on this instance that context arrived
|
||||
# empty (runs 459/523: every dispatch-woken sweep bootstrapped on a
|
||||
# bootstrap=no body) while the top-level caller received the value in
|
||||
# both contexts (probe runs 6/7) — ceremony#215. The reliable channel
|
||||
# is declare-and-pass, so that is the only one used. The caller passes
|
||||
# the value through `with.bootstrap`; anything not exactly yes|no is
|
||||
# fed through for labels-reconcile's own validation to judge, so a
|
||||
# typo refuses loudly instead of silently bootstrapping.
|
||||
# bootstrap: every trigger-driven wake arrives as workflow_dispatch
|
||||
# too (that is how `gh workflow run` wakes the caller), so the event
|
||||
# name alone no longer separates the operator's manual full-board
|
||||
# bootstrap from an event-woken sweep — the caller's `bootstrap`
|
||||
# dispatch input does: the trigger passes "no", a bare manual
|
||||
# dispatch defaults to "yes". A caller reached on any other event
|
||||
# (the cron) has no input and stays "no".
|
||||
- name: reconcile state + stale
|
||||
if: github.repository != 'heavy-duty/ceremony'
|
||||
uses: ./.ceremony-src/actions/labels-reconcile
|
||||
with:
|
||||
bootstrap: ${{ inputs.bootstrap }}
|
||||
bootstrap: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.bootstrap != 'no' && 'yes' || 'no' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
|
|
@ -126,7 +106,7 @@ jobs:
|
|||
if: github.repository == 'heavy-duty/ceremony'
|
||||
uses: ./actions/labels-reconcile
|
||||
with:
|
||||
bootstrap: ${{ inputs.bootstrap }}
|
||||
bootstrap: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.bootstrap != 'no' && 'yes' || 'no' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
|
|
|
|||
155
.github/workflows/labels.yml
vendored
155
.github/workflows/labels.yml
vendored
|
|
@ -2,16 +2,12 @@ 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, so same-repository PRs
|
||||
# keep the base repository's write token without running PR code. On this
|
||||
# Forgejo, unlike GitHub, fork-headed _target runs still receive a read-only
|
||||
# token. Those runs therefore attempt no writes. The scheduled sweep later
|
||||
# reconciles state, blockers, and handoff, but it does not apply path-derived
|
||||
# scope labels; consumers that require those labels on fork heads apply them
|
||||
# manually. The explicit fork_head job below records that disposition as a
|
||||
# successful check. Both write paths execute only for same-repository heads.
|
||||
# Scope reads changed paths and the path mapping through the API and checks out
|
||||
# only the ceremony implementation. Keep it that way (#241).
|
||||
# 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.
|
||||
# Keep it that way.
|
||||
#
|
||||
# The reconcile sweep lived here until #209. Riding the PR-triggered run
|
||||
# meant every displacement in the sweep's shared concurrency queue recorded
|
||||
|
|
@ -23,14 +19,10 @@ name: labels
|
|||
# one `review_requested` event per panelist per request, so every review
|
||||
# round displaces runs and the rate scales with panel size. The
|
||||
# sweep now lives in labels-sweep.yml behind its own caller, and the
|
||||
# trigger job below is its instant wake: it fires on every issue event and
|
||||
# same-repository PR event this caller subscribes to, preserving that part of
|
||||
# the surface that used to run reconcile directly. Same-repository wake latency
|
||||
# (#137) remains seconds-scale, while a displaced sweep cancels on the Actions
|
||||
# tab, attached to no PR. Fork-headed runs cannot dispatch with their read-only
|
||||
# token, so state, blocker, and handoff reconciliation waits for the scheduled
|
||||
# sweep; path-derived scope labels are not applied to fork heads. PR checks show
|
||||
# scope + trigger for same-repository heads, or fork_head for fork heads.
|
||||
# trigger job below is its wake: it fires on every event this caller
|
||||
# subscribes — the exact surface that used to run reconcile directly — so
|
||||
# the wake latency (#137) is unchanged, while a displaced sweep cancels on
|
||||
# the Actions tab, attached to no PR. PR checks show scope + trigger only.
|
||||
#
|
||||
# This cannot loop: the trigger's dispatch and the reconciler's label
|
||||
# writes both use GITHUB_TOKEN. GitHub does not create workflow runs from
|
||||
|
|
@ -56,7 +48,7 @@ on:
|
|||
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.6.3"
|
||||
CEREMONY_SELF_REF: "0.6.0"
|
||||
|
||||
jobs:
|
||||
scope:
|
||||
|
|
@ -68,7 +60,6 @@ jobs:
|
|||
# scope run is clobbered.
|
||||
if: >-
|
||||
github.event_name == 'pull_request_target' &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
github.event.action != 'labeled' &&
|
||||
github.event.action != 'unlabeled' &&
|
||||
github.event.action != 'review_requested' &&
|
||||
|
|
@ -115,12 +106,15 @@ jobs:
|
|||
CONFIG_REF: ${{ github.sha }}
|
||||
|
||||
trigger:
|
||||
# The sweep's instant wake (#209) keeps the whole non-PR event surface and
|
||||
# same-repository PRs. Fork-headed PRs are excluded because this Forgejo
|
||||
# gives their pull_request_target run a read-only token (#241); fork_head
|
||||
# records which reconciliation waits for the sweep and that path-derived
|
||||
# scope labels are not applied there. Non-PR events include workflow_dispatch:
|
||||
# excluding it would make a dispatched labels caller silently do nothing.
|
||||
# The sweep's wake (#209). No `if:`: reconcile carried none, so the
|
||||
# trigger keeps the whole event surface the caller subscribes —
|
||||
# workflow_dispatch of the labels caller itself included. That cannot
|
||||
# double-fire bootstrap: this dispatch always carries bootstrap=no, so
|
||||
# a dispatched labels caller yields one plain sweep, and the taxonomy
|
||||
# bootstrap fires solely on a manual dispatch of the sweep caller
|
||||
# (whose input defaults to "yes"). Excluding workflow_dispatch here
|
||||
# would instead make a dispatched labels caller do nothing at all —
|
||||
# a silent no-op run is worse than a redundant sweep.
|
||||
#
|
||||
# LOUD on failure — never `|| true`: a red trigger is the
|
||||
# misconfiguration alarm. A consumer that bumps the pin without adding
|
||||
|
|
@ -128,96 +122,37 @@ jobs:
|
|||
# `bootstrap` input (unexpected input), or without `actions: write`
|
||||
# on this caller (permission denied) fails HERE, visibly on the PR,
|
||||
# instead of silently never sweeping again.
|
||||
if: >-
|
||||
github.event_name != 'pull_request_target' ||
|
||||
github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: dispatch the sweep
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
SWEEP_WORKFLOW: ${{ inputs.sweep_workflow }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
# This step speaks gh and says so, the same declaration
|
||||
# actions/refs-not-closing carries (#198 spec 4). A workflow has no
|
||||
# shell to call forge_preflight from, so the refusal is inline
|
||||
# below; #205 owns the REST port that removes both.
|
||||
CEREMONY_FORGE_CLIENT: gh
|
||||
run: |
|
||||
# REST, not `gh` (#205). The workflow-dispatch endpoint has the SAME
|
||||
# shape on both forges —
|
||||
# POST {api}/repos/{owner}/{repo}/actions/workflows/{file}/dispatches
|
||||
# {"ref": "<branch>", "inputs": {...}} -> 204, empty body
|
||||
# — so this step no longer decides a forge at all. That is why the
|
||||
# `CEREMONY_FORGE_CLIENT: gh` declaration and both inline refusals are
|
||||
# gone rather than ported: there is nothing left to refuse. Measured
|
||||
# on this instance (Forgejo 8.0.3+gitea-1.22.0) and published in its
|
||||
# own swagger; run 459 was raised this way.
|
||||
# Two questions, not one. @codex-reviewer-andresmgsl: a guard that
|
||||
# only asks `command -v gh` passes the moment a Forgejo runner image
|
||||
# happens to ship gh — and then runs a GitHub dispatch against a
|
||||
# forge that cannot serve it, which is the client/forge mismatch
|
||||
# forge_preflight exists to prevent. So the FORGE is decided first,
|
||||
# mirroring forge_detect positively (only github.com is accepted;
|
||||
# anything else, known or not, is refused — "Never 'probably
|
||||
# github'"), and the binary is checked second.
|
||||
#
|
||||
# STILL LOUD on failure, per this job's contract: a consumer missing
|
||||
# the sweep caller, its `bootstrap` input, or `actions: write` must
|
||||
# fail HERE and visibly, not sweep silently never again.
|
||||
# NEVER "probably github" (lib/forge.sh). Defaulting an unset
|
||||
# GITHUB_API_URL to api.github.com would send this forge's dispatch
|
||||
# to GitHub and report success — the same unset-environment guess
|
||||
# #201 just refused for docs-sync. The API root is injected by the
|
||||
# forge running us; if it is absent we do not know where we are, and
|
||||
# a guess is worse than a red trigger
|
||||
# (@codex-reviewer-andresmgsl, #205 review).
|
||||
api="${GITHUB_API_URL:-}"
|
||||
if [ -z "$api" ]; then
|
||||
echo "::error::labels: the sweep was NOT woken — GITHUB_API_URL is unset, so the forge's API root is unknown. Refusing to guess a forge."
|
||||
exit 1
|
||||
# A warning, not a failure: this trigger is the misconfiguration
|
||||
# alarm for a CONSUMER's missing sweep caller, and reddening every
|
||||
# sweep on a forge for a gap #205 already owns would drown that
|
||||
# signal. #205 ports the dispatch to REST and removes all of this.
|
||||
if [ "${GITHUB_SERVER_URL:-}" != "https://github.com" ]; then
|
||||
echo "::warning::labels: the sweep was NOT woken from this trigger — it dispatches with \`gh\` against GitHub, and this is not a GitHub forge (GITHUB_SERVER_URL=${GITHUB_SERVER_URL:-unset}). #205 ports it to REST. The hourly SCHEDULED sweep still runs; every event-driven wake through this caller — issue events included — is unavailable until then."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# `gh workflow run` defaulted the ref to the repository's default
|
||||
# branch; REST has no default and 400s without one. Prefer the event
|
||||
# payload, fall back to an API read: on a `pull_request_target` run
|
||||
# GITHUB_REF_NAME is `<n>/merge`, which is not a branch and would
|
||||
# dispatch nothing.
|
||||
branch="${DEFAULT_BRANCH:-}"
|
||||
if [ -z "$branch" ]; then
|
||||
branch="$(curl -fsS -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||
"$api/repos/$GITHUB_REPOSITORY" | jq -r '.default_branch // empty')"
|
||||
if ! command -v gh >/dev/null 2>&1; then
|
||||
echo "::warning::labels: the sweep was NOT woken from this trigger — this runner does not carry \`gh\`. #205 ports the dispatch to REST. The hourly SCHEDULED sweep still runs; every event-driven wake through this caller is unavailable until then."
|
||||
exit 0
|
||||
fi
|
||||
if [ -z "$branch" ]; then
|
||||
echo "::error::labels: the sweep was NOT woken — could not determine the default branch to dispatch $SWEEP_WORKFLOW on."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
out="$(mktemp)"
|
||||
err="$(mktemp)"
|
||||
trap 'rm -f "$out" "$err"' EXIT
|
||||
# A transport failure is named, not merely propagated. Letting `set
|
||||
# -e` carry curl's own exit code out of the assignment DID fail the
|
||||
# job — the invariant holds — but it failed with a bare status and no
|
||||
# sentence, which is the opposite of this step owning its diagnostic.
|
||||
if ! code="$(curl -sS -o "$out" -w '%{http_code}' -X POST \
|
||||
-H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$(jq -nc --arg ref "$branch" '{ref: $ref, inputs: {bootstrap: "no"}}')" \
|
||||
"$api/repos/$GITHUB_REPOSITORY/actions/workflows/$SWEEP_WORKFLOW/dispatches" \
|
||||
2>"$err")"; then
|
||||
echo "::error::labels: the sweep was NOT woken — the request to $api never completed: $(tr -d '\n' <"$err")"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$code" != "204" ]; then
|
||||
# Own the diagnostic rather than pass the status through. This
|
||||
# Forgejo answers an unknown workflow name — and a bare ref that
|
||||
# does not resolve — with `500` and an EMPTY body, so the raw
|
||||
# status alone sends the reader looking for a server fault that is
|
||||
# not there.
|
||||
echo "::error::labels: the sweep was NOT woken — POST $api/repos/$GITHUB_REPOSITORY/actions/workflows/$SWEEP_WORKFLOW/dispatches (ref=$branch) returned HTTP $code: $(tr -d '\n' <"$out")"
|
||||
echo "::error::labels: check that $SWEEP_WORKFLOW exists on $branch, declares a \`bootstrap\` workflow_dispatch input, and that this caller grants \`actions: write\`. An empty 500 body from Forgejo means the workflow name or the ref did not resolve."
|
||||
exit 1
|
||||
fi
|
||||
echo "labels: sweep dispatched — $SWEEP_WORKFLOW on $branch (bootstrap=no)"
|
||||
|
||||
fork_head:
|
||||
# This Forgejo keeps pull_request_target read-only for fork heads (#241),
|
||||
# so name the deliberately unsupported scope write as well as the deferred
|
||||
# state machine instead of letting a green no-op promise full labelling.
|
||||
if: >-
|
||||
github.event_name == 'pull_request_target' &&
|
||||
github.event.pull_request.head.repo.full_name != github.repository
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: explain deferred fork labels
|
||||
run: >-
|
||||
echo "labels: fork head has a read-only token; state, blocker, and handoff reconciliation deferred to the scheduled sweep; path-derived scope labels are not applied to fork heads"
|
||||
gh workflow run "$SWEEP_WORKFLOW" -R "$GITHUB_REPOSITORY" -f bootstrap=no
|
||||
|
|
|
|||
26
.github/workflows/refs-guard.yml
vendored
26
.github/workflows/refs-guard.yml
vendored
|
|
@ -12,22 +12,18 @@ permissions:
|
|||
|
||||
jobs:
|
||||
refs-not-closing:
|
||||
# The action is portable (#199): its gather is two REST reads through the
|
||||
# shim plus this repo's own closing-keyword parser, so it produces a real
|
||||
# verdict on either forge. It still refuses by name when it cannot read —
|
||||
# that is its contract, and its contract test.
|
||||
# The action is gh-only until #199: its whole gather is a GraphQL query,
|
||||
# and Forgejo serves no GraphQL at all. The ACTION refuses by name on a
|
||||
# backend it cannot speak (that is its contract, and its contract test);
|
||||
# scheduling it where it can only refuse is this workflow's decision, and
|
||||
# a permanently red required check would block every merge on this forge
|
||||
# for a gap #199 already owns. So the job does not run there — a skipped
|
||||
# check is a green head, an invented verdict is not.
|
||||
#
|
||||
# #199 removed the forge gate that used to sit here. While the action's
|
||||
# only gather was GraphQL it could do nothing but refuse on Forgejo, and
|
||||
# scheduling a permanently red required check would have blocked every
|
||||
# merge on this forge; a skipped check is a green head, an invented
|
||||
# verdict is not. The gather is REST now, so the job RUNS here and
|
||||
# produces verdicts again.
|
||||
#
|
||||
# Deleting the action's client declaration without deleting this gate
|
||||
# would have left it portable and never scheduled — a guard that passes
|
||||
# by never running, which is this repo's blind-sweep shape wearing a
|
||||
# different hat (@kimi-reviewer-andresmgsl, #198).
|
||||
# The condition mirrors lib/forge.sh's forge_detect positively: only
|
||||
# github.com is accepted, and anything else — Forgejo, or a host this
|
||||
# file has not met — is not run. "Never 'probably github'."
|
||||
if: ${{ github.server_url == 'https://github.com' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
|
|
|||
59
.github/workflows/release.yml
vendored
59
.github/workflows/release.yml
vendored
|
|
@ -129,7 +129,7 @@ env:
|
|||
# `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.6.3"
|
||||
CEREMONY_SELF_REF: "0.6.0"
|
||||
VERSION_SOURCE: ${{ inputs.version-source }}
|
||||
|
||||
jobs:
|
||||
|
|
@ -205,40 +205,31 @@ jobs:
|
|||
fi
|
||||
changelog_section CHANGELOG.md "$VER" > "$RUNNER_TEMP/notes.md"
|
||||
cat "$RUNNER_TEMP/notes.md"
|
||||
- name: preflight — resume this merge, refuse every other collision
|
||||
id: preflight
|
||||
- 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 }}
|
||||
MERGE_SHA: ${{ github.sha }}
|
||||
# The pure table in lib/preflight.sh distinguishes a stranded run of
|
||||
# this door from a completed release or a tag at another commit (#273).
|
||||
# What makes a re-run of a completed ceremony refuse instead of
|
||||
# clobber, and what catches a manual tag racing the merge.
|
||||
run: |
|
||||
tag_read_rc=0
|
||||
tag_refs="$(git ls-remote --exit-code origin "refs/tags/$VER" "refs/tags/$VER^{}")" || tag_read_rc=$?
|
||||
case "$tag_read_rc" in
|
||||
0) tag_shas="$(printf '%s\n' "$tag_refs" | awk 'NF { print $1 }')" ;;
|
||||
2) tag_shas="" ;;
|
||||
*)
|
||||
echo "could not read tag '$VER' from origin (git ls-remote exit $tag_read_rc) — refusing rather than assuming it does not exist." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
# shellcheck source=/dev/null
|
||||
. "$CEREMONY_DIR/lib/forge.sh"
|
||||
forge_select ""
|
||||
if ! released="$(forge_release_exists "$VER")"; then
|
||||
echo "could not read whether release '$VER' exists — refusing rather than assuming it does not (#191)." >&2
|
||||
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
|
||||
# shellcheck source=/dev/null
|
||||
. "$CEREMONY_DIR/lib/preflight.sh"
|
||||
out="$(TAG_SHAS="$tag_shas" RELEASED="$released" release_preflight)"
|
||||
printf '%s\n' "$out"
|
||||
printf '%s\n' "$out" | grep '^resume=' >> "$GITHUB_OUTPUT"
|
||||
. "$CEREMONY_DIR/lib/forge.sh"
|
||||
forge_select ""
|
||||
if ! exists="$(forge_release_exists "$VER")"; then
|
||||
echo "could not read whether release '$VER' exists — refusing rather than assuming it does not (#191)." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$exists" = yes ]; then
|
||||
echo "release '$VER' already exists — refusing to re-release, creating nothing." >&2
|
||||
exit 1
|
||||
fi
|
||||
- name: tag the merge commit — same job as the publish, on purpose
|
||||
if: steps.decide.outputs.ceremony == 'yes' && steps.preflight.outputs.resume != 'yes'
|
||||
if: steps.decide.outputs.ceremony == 'yes'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VER: ${{ steps.facts.outputs.ver }}
|
||||
|
|
@ -372,22 +363,6 @@ jobs:
|
|||
fi
|
||||
changelog_section CHANGELOG.md "$VER" > "$RUNNER_TEMP/notes.md"
|
||||
cat "$RUNNER_TEMP/notes.md"
|
||||
- name: no published release may exist
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VER: ${{ steps.assert.outputs.ver }}
|
||||
run: |
|
||||
# shellcheck source=/dev/null
|
||||
. "$CEREMONY_DIR/lib/forge.sh"
|
||||
forge_select ""
|
||||
if ! exists="$(forge_release_exists "$VER")"; then
|
||||
echo "could not read whether release '$VER' exists — refusing rather than assuming it does not (#191)." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$exists" = yes ]; then
|
||||
echo "release '$VER' already exists — refusing to re-release, creating nothing." >&2
|
||||
exit 1
|
||||
fi
|
||||
- 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.
|
||||
|
|
|
|||
24
.github/workflows/self-labels-sweep.yml
vendored
24
.github/workflows/self-labels-sweep.yml
vendored
|
|
@ -7,16 +7,13 @@ name: labels-sweep
|
|||
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 a review verdict landing (there is no
|
||||
# wake for four transition classes — a review verdict landing (there is no
|
||||
# pull_request_review trigger on the labels caller), 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, plus every state, blocker, and handoff transition for a
|
||||
# fork-headed PR on this Forgejo because its pull_request_target token is
|
||||
# read-only (#241). The sweep never applies path-derived scope labels. Issue
|
||||
# events and same-repository PR events carry the rest in seconds, one
|
||||
# trigger-job dispatch away. Hourly trades ≤1h of latency on the scheduled
|
||||
# classes while cutting nominal scheduled sweeps from four an hour to one at
|
||||
# claim-reclaim. The labels caller's events carry the rest in seconds, one
|
||||
# trigger-job dispatch away. 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
|
||||
|
|
@ -25,9 +22,8 @@ on:
|
|||
# A manual full-board sweep. A bare dispatch (input default "yes") also
|
||||
# bootstraps the taxonomy on a fresh repo — what dispatching the labels
|
||||
# caller did before #209. The reusable's trigger job wakes this workflow
|
||||
# with bootstrap=no on every issue and same-repository PR event — an
|
||||
# event-woken sweep must not re-upsert ~20 labels each time — so declaring
|
||||
# this input is part of the
|
||||
# with bootstrap=no on every board event — an event-woken sweep must not
|
||||
# re-upsert ~20 labels each time — so declaring this input is part of the
|
||||
# caller contract: a dispatch naming an undeclared input is refused, and
|
||||
# the trigger job goes loudly red.
|
||||
workflow_dispatch:
|
||||
|
|
@ -48,11 +44,3 @@ jobs:
|
|||
# pr_workflow_name keeps its default: ceremony's PR-facing caller is
|
||||
# named `labels` (self-labels.yml).
|
||||
uses: ./.github/workflows/labels-sweep.yml
|
||||
with:
|
||||
# The dispatch input crosses the workflow_call boundary HERE, or not
|
||||
# at all: on this instance the called workflow did not see this
|
||||
# caller's event inputs implicitly (ceremony#215), so declare-and-pass
|
||||
# is the only channel used. On `schedule` the top-level context is
|
||||
# empty, and empty maps to "no" EXPLICITLY — a cron that bootstraps is
|
||||
# the failure kimi named before it could exist.
|
||||
bootstrap: ${{ inputs.bootstrap || 'no' }}
|
||||
|
|
|
|||
28
.github/workflows/self-labels.yml
vendored
28
.github/workflows/self-labels.yml
vendored
|
|
@ -8,12 +8,8 @@ name: labels
|
|||
# Since #209 this caller carries the PR/issue event surface only. The
|
||||
# reconcile sweep no longer rides these runs — the reusable's trigger job
|
||||
# dispatches the sweep caller (self-labels-sweep.yml here), which owns the
|
||||
# hourly cron and the manual/bootstrap workflow_dispatch. Issue events and
|
||||
# same-repository PR events below still yield a sweep within seconds, one
|
||||
# dispatch hop later. Fork-headed PRs carry a read-only token on this Forgejo,
|
||||
# so their successful labels run leaves state, blocker, and handoff
|
||||
# reconciliation to the hourly sweep; path-derived scope labels are not
|
||||
# applied to those heads (#241).
|
||||
# hourly cron and the manual/bootstrap workflow_dispatch. A board event
|
||||
# below still yields a sweep within seconds, one dispatch hop later.
|
||||
on:
|
||||
# Narrowed (#199) to the actions that carry a queue-state change the hourly
|
||||
# cron cannot wait one cadence for — dropping only labeled/unlabeled/assigned/
|
||||
|
|
@ -30,22 +26,20 @@ on:
|
|||
issues:
|
||||
types: [opened, closed, edited, reopened]
|
||||
pull_request_target:
|
||||
# These carry the head/draft/review facts the sweep derives state:* from.
|
||||
# Same-repository heads wake that sweep in seconds; fork heads cannot write
|
||||
# with this Forgejo's read-only token, so state, blocker, and handoff
|
||||
# reconciliation waits for the scheduled cadence. The sweep does not apply
|
||||
# path-derived scope labels to those heads (#241).
|
||||
# labeled/unlabeled are the same-repository 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 clear (or restore) blocker:unrequested on that
|
||||
# same instant path (#137).
|
||||
# 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
|
||||
actions: write # the trigger job's dispatch of the sweep caller (#209, #205)
|
||||
actions: write # the trigger job's `gh workflow run` dispatch of the sweep caller (#209)
|
||||
issues: write
|
||||
pull-requests: write
|
||||
jobs:
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
# The upstream commit this tree carries (docs/UPSTREAM-SYNC.md).
|
||||
# Full 40-char SHA, immutable: captured at fetch, merged, then recorded —
|
||||
# NOT re-read from gh/main later, which moves. Read by
|
||||
# test/upstream-delta.test.sh, which REFUSES when the object is absent
|
||||
# rather than calling it unverifiable.
|
||||
# github.com/heavy-duty/ceremony
|
||||
8c3a4d1dee2bdb5ac06a632a285bb65ab2615214
|
||||
10
AGENTS.md
10
AGENTS.md
|
|
@ -14,7 +14,7 @@ reviewer here"). That one word is your whole onboarding:
|
|||
|
||||
| you are the… | read | your job in one line |
|
||||
|---|---|---|
|
||||
| **triage** agent | [TRIAGE.md](TRIAGE.md) | turn proposals into buildable work issues — or refuse well; you are the only door work issues come through |
|
||||
| **triage** agent | [TRIAGE.md](TRIAGE.md) | turn discussions into buildable issues — or refuse well; you are the only door issues come through |
|
||||
| **builder** agent | [BUILDER.md](BUILDER.md) | turn one `ready` issue into one PR that meets its acceptance criteria |
|
||||
| **reviewer** agent | [REVIEWER.md](REVIEWER.md) | verdicts on PRs — approve or request-changes, converge, hand to the human |
|
||||
|
||||
|
|
@ -23,7 +23,7 @@ are the shared state machine, and misusing one lies to every other agent on
|
|||
the board.
|
||||
|
||||
**Not told a role?** Infer it from the task: asked to review a PR → reviewer;
|
||||
asked to implement an issue → builder; asked to process proposals or the
|
||||
asked to implement an issue → builder; asked to process discussions or the
|
||||
backlog → triage. Still ambiguous → ask before acting. Do not free-lance
|
||||
across roles in one session: a builder reviewing its own PR, or a reviewer
|
||||
pushing fixes, breaks the separation the pipeline depends on.
|
||||
|
|
@ -31,13 +31,13 @@ pushing fixes, breaks the separation the pipeline depends on.
|
|||
## The pipeline you are part of
|
||||
|
||||
```
|
||||
proposal ──▶ triage ──▶ work issue ──▶ build ──▶ review ──▶ human merge ──▶ release
|
||||
(anyone) (agent) (queue) (agent) (agents) (human) (ceremony)
|
||||
discussion ──▶ triage ──▶ issue ──▶ build ──▶ review ──▶ human merge ──▶ release
|
||||
(anyone) (agent) (queue) (agent) (agents) (human) (ceremony)
|
||||
```
|
||||
|
||||
Two rules bind every role:
|
||||
|
||||
- **Only triage mints work issues.** Found work? File or extend a proposal.
|
||||
- **Only triage mints issues.** Found work? Open or extend a discussion.
|
||||
- **Only humans merge.** Convergence ends at `state:needs-human`, never at
|
||||
a merge button.
|
||||
|
||||
|
|
|
|||
33
BUILDER.md
33
BUILDER.md
|
|
@ -33,10 +33,7 @@ triage bug, and the move is to say so on the issue, not to guess.
|
|||
re-requests posted, by head and not by verdict (steps 1–2). A red check
|
||||
at the head takes it out of this shape: the next move is yours;
|
||||
3. every remaining acceptance criterion is operator-owned, stated so by
|
||||
triage on the issue. **An operator-owned remainder parks the claim and
|
||||
never the handoff**: this shape is reached only from the far side of
|
||||
shape 4, because it is the state finishing the work puts you in and
|
||||
would otherwise excuse the handoff it should follow (#336);
|
||||
triage on the issue;
|
||||
4. it is **handed off** — round passed, no `blocker:*` standing,
|
||||
`state:needs-human` set per Handoff, the merge the human's. Shapes 2
|
||||
and 4 are sequential and never overlap;
|
||||
|
|
@ -140,8 +137,8 @@ triage bug, and the move is to say so on the issue, not to guess.
|
|||
its red-flag profile are in REVIEWER.md §What you review against, item 2
|
||||
(#216).
|
||||
- **Scope discipline: the PR does the issue — whole, and nothing else.**
|
||||
Adjacent problems go to a proposal, or a comment on the relevant issue;
|
||||
you do not mint work issues — nobody but triage does — and you do not fix
|
||||
Adjacent problems go to a discussion, or a comment on the relevant issue;
|
||||
you do not mint issues — nobody but triage does — and you do not fix
|
||||
drive-by findings in the same PR.
|
||||
|
||||
## The review round
|
||||
|
|
@ -177,9 +174,7 @@ such as the panel roster live in that repo's own CONTRIBUTING.)
|
|||
blocking (#139, #276). **Then classify that entry by `conclusion`, never
|
||||
`status`**, which can disagree with it (#259). No conclusion is not
|
||||
green: a configured run in progress is waited on, and waiting is
|
||||
compliance, not a stall — the wait is the **request's**, and never a
|
||||
reason to withhold the declaration that a round was answered (step 2).
|
||||
Cancelled or stale is not green, *stale* being a
|
||||
compliance, not a stall. Cancelled or stale is not green, *stale* being a
|
||||
superseded head's check, which a head-scoped rollup never shows. Skipped
|
||||
or neutral is green, those being deliberate "passed / not applicable"
|
||||
conclusions. No checks configured is green — the third ruled case, not an
|
||||
|
|
@ -205,19 +200,8 @@ such as the panel roster live in that repo's own CONTRIBUTING.)
|
|||
head, and its mechanism is crew's to describe (#94). **The re-request
|
||||
carries the same green-check-at-head precondition**, argued exception
|
||||
included: a fix push whose check comes up red is your next fix, not the
|
||||
panel's. **Where an engine mediates the request, that precondition binds
|
||||
the engine's act and not yours**: declaring a round answered is not
|
||||
requesting the panel, so declare it as soon as the round's fixes are
|
||||
pushed and stop. The engine holds the request while the head is pending
|
||||
or red, so an early declaration cannot produce an early request while a
|
||||
withheld one is indistinguishable from a session that died (#330).
|
||||
**Never wait on an event you have no wake for** — where the engine is
|
||||
what observes the check settling, the wait is the engine's to keep
|
||||
(#330). **Never block on a producer you cannot prove alive either**:
|
||||
where a job signals its own completion, that signal is the wake and the
|
||||
finished output is read afterwards, because a follow on a file nothing is
|
||||
writing cannot tell *not yet* from *never* (#336). Prefer verification
|
||||
over argument — add the test that settles the doubt.
|
||||
panel's. Prefer verification over argument — add the test that settles
|
||||
the doubt.
|
||||
3. Never dismiss a review, never merge, never mark your own work as passed.
|
||||
A blocking point you disagree with is answered with evidence or escalated
|
||||
in the PR; silence and force-forward are not options, and a panel
|
||||
|
|
@ -234,9 +218,8 @@ may draft a PR but only the builder undrafts it. **Where a draft suppressed
|
|||
the checks, green is proven at the flip and the request still follows it** —
|
||||
marking ready runs the checks the draft held back, so the order is flip, let
|
||||
the head answer, then request, step 1's precondition and not a second one.
|
||||
Waiting there is compliance — again the request's wait, not the
|
||||
declaration's — and `blocker:unrequested` does not fire while a head's
|
||||
checks are pending or red (#236).
|
||||
Waiting there is compliance, and `blocker:unrequested` does not fire while a
|
||||
head's checks are pending or red (#236).
|
||||
|
||||
## The ruling ask
|
||||
|
||||
|
|
|
|||
479
CHANGELOG.md
479
CHANGELOG.md
|
|
@ -17,481 +17,10 @@ This tree is `heavy-duty/ceremony` on
|
|||
numbers (#197 D2). Two trees therefore answer to the same number, differing
|
||||
by the forge-compatibility delta. **This tree carries upstream through
|
||||
`8c3a4d1`** (upstream `0.6.0`, merged by #198); the `0.4.1` section below is
|
||||
this forge's own release, not upstream's, and upstream `0.6.1` through `0.6.3`
|
||||
were adopted by port rather than merge. Upstream's separate `0.4.1` section
|
||||
is deliberately not carried — the tag published here is the one this section
|
||||
is the body of. Each sync updates this line (docs/UPSTREAM-SYNC.md, #200).
|
||||
|
||||
## 0.6.3 — 2026-08-26
|
||||
|
||||
### Changed
|
||||
|
||||
- The shipped 0.6.2 changelog section now carries #238's entry, which its release PR's merge base could not see; the published 0.6.2 release body is left as tagged, so tree and publication differ by that one line (#238, #231).
|
||||
- Replace the unavailable intake rule with a proposal form that triage converts into work or refuses with reasons (#247).
|
||||
|
||||
### Fixed
|
||||
|
||||
- The `needs-triage` label now directs untriaged issues toward work normalization or a reasoned refusal instead of an unavailable discussion path (#265).
|
||||
- Release checks now refuse a target-head fragment that the candidate did not consume, preventing late merges from misattributing shipped changes (#253).
|
||||
- Kept drill doctrine's release-path instructions aligned with the executable manifest by removing its duplicate path list (#251).
|
||||
- Preserve Forgejo workflow names in status rollups so the label reconciler excludes only its own checks. (#243).
|
||||
- Fork-headed label runs stay green without attempting forbidden writes, while same-repository heads keep instant scope and reconciliation wakes (#241).
|
||||
- Read Forgejo timelines to exhaustion so busy issues retain their newest label events despite dishonest total-count headers (#240).
|
||||
- Refs-based issue-flow transitions now bind each declaration to its immediately following reference token, so later issue prose cannot release or preserve unrelated claims (#234).
|
||||
|
||||
## 0.6.2 — 2026-08-24
|
||||
|
||||
### Changed
|
||||
|
||||
- `upstream-0.6.1` was ported in #229: CONTRIBUTING routes the vendored set
|
||||
through `docs/VENDORED.txt` — upstream#316 and upstream#311; BUILDER.md
|
||||
scopes the green-check precondition to its act, upstream#330; RELEASES.md
|
||||
adds the post-merge gate-member split, upstream#329 (#246).
|
||||
|
||||
- `upstream-0.6.2` was ported in #229: BUILDER.md orders parked claims so an
|
||||
operator-owned remainder parks the claim, never the handoff — upstream#336
|
||||
(#246).
|
||||
|
||||
- `upstream-0.6.3` was ported in #230: release windows read `## Members` with
|
||||
no gate fallback, carriers leave their own gates, stale board flags stay
|
||||
silent — upstream#343 and upstream#327 — and the parser accepts CommonMark
|
||||
rows (#246).
|
||||
|
||||
- Upstream logic was ported onto this forge's Forgejo-adapted issue-flow
|
||||
reconciler, test, and CONTRIBUTING; those files were never overwritten with
|
||||
upstream bytes (#246).
|
||||
|
||||
- Upstream's drill-record fixes and the upstream `0.7.0`–`0.7.4` line are
|
||||
deferred to the next sync campaign (#246).
|
||||
|
||||
- No upstream ancestry moves in this release: `.upstream-ref` remains
|
||||
`8c3a4d1` from upstream `0.6.0`, merged by #198; `upstream-0.6.3` is the
|
||||
content baseline, not a merge-base (#246).
|
||||
- Release windows now read membership from a dedicated `## Members` record, with CommonMark-bounded rows and no fallback to predecessor gates (#230).
|
||||
- Forge consumers now receive the upstream 0.6.1 and 0.6.2 doctrine for vendored-set routing, review-round signalling, operator-owned remainders, producer liveness, and post-merge release edges (#229).
|
||||
- `docs/RUNNER-PROBES.md` records the delivered 0.6.1 consumer exercise in the standing Forgejo runner venue (#217).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Review-round state now reads each forge's live review-request set directly, so stale Forgejo approvals no longer hand an in-progress fix round back to the panel (#238).
|
||||
- Forgejo drafts and fast-forward conflict-check windows no longer surface as merge conflicts when the API has not distinguished one (#236).
|
||||
- Forgejo review requests no longer count as verdicts, while its blocking and comment states now grade like their GitHub equivalents (#235).
|
||||
|
||||
## 0.6.1 — 2026-08-09
|
||||
|
||||
### Added
|
||||
|
||||
- `test/labels-bootstrap.test.sh` pins the bridge at every hop: the declared
|
||||
boundary, both gate sites as the identity, no expression reading
|
||||
`github.event.inputs`, and the caller and stub pass-throughs
|
||||
byte-exact (#215).
|
||||
|
||||
- The same test drives the four value paths — schedule-empty, `no`, `yes`,
|
||||
invalid — through the shipped expressions into the action's real
|
||||
validator (#215).
|
||||
|
||||
- The taxonomy bootstrap keys on the `BOOTSTRAP` input, never the event name.
|
||||
It tested `GITHUB_EVENT_NAME = workflow_dispatch` — correct while an
|
||||
operator's manual dispatch was the only dispatch there was, inert-by-
|
||||
construction from #209 on, when every machine wake became a dispatch
|
||||
event (#215).
|
||||
|
||||
- The venue drill caught that: with the bridge delivering `no` perfectly,
|
||||
drill runs 16/17 still bootstrapped, because the script never read the
|
||||
input the whole chain existed to deliver (#215).
|
||||
|
||||
- `test/labels-reconcile.test.sh` pins the regression pair exactly: a
|
||||
`workflow_dispatch` event with `BOOTSTRAP=no` (or unset) creates and
|
||||
deletes nothing; only `BOOTSTRAP=yes` bootstraps (#215).
|
||||
- A gather-level case drives the real board read against a Forgejo-shaped
|
||||
fixture — every entry carrying the key. The existing discriminator cases
|
||||
assert `jq` expressions in isolation and passed throughout this regression
|
||||
(#210).
|
||||
|
||||
- A source pin forbids `has("pull_request")` on this surface, because the rule
|
||||
was stated in a comment and violated forty lines below it. It strips comments,
|
||||
so the #188 warning that explains the trap is allowed to stay (#210).
|
||||
|
||||
- All three sites are covered behaviourally, not only by the pin: the board
|
||||
gather, the release-body gather through an observable window flag, and the
|
||||
per-issue payload check (#210).
|
||||
- `test/forge-backends.test.sh` pins each backend's path **and** field, because
|
||||
a stubbed `forge_api` cannot catch a wrong path — which is how this shipped
|
||||
and why a live sweep was what found it (#209).
|
||||
- `test/labels-dispatch.test.sh` extracts the shipped step and executes it
|
||||
against a recording stub, asserting the method, endpoint, ref and
|
||||
`inputs.bootstrap` actually sent (#205).
|
||||
|
||||
- That test also drives the failure path: any non-204 still fails the job, so
|
||||
the misconfiguration alarm the trigger exists to be cannot decay into a
|
||||
warning (#205).
|
||||
|
||||
- An unset `GITHUB_API_URL` refuses before any request instead of defaulting
|
||||
to `api.github.com`. Guessing sent this forge's dispatch to GitHub and
|
||||
reported success — the "Never 'probably github'" rule, and the same
|
||||
unset-environment refusal #201 established for docs-sync (#205).
|
||||
|
||||
- A dispatch that never reaches the forge names the failure. Letting `set -e`
|
||||
carry curl's exit code out did fail the job, but with a bare status and no
|
||||
sentence (#205).
|
||||
|
||||
- `docs/CONSUMERS.md` and both caller comments describe the REST dispatch, and
|
||||
the manual bootstrap command carries a forge-neutral form beside the `gh`
|
||||
one — a cross-forge runbook that directs this forge to a missing binary is
|
||||
wrong even where the surrounding prose is right (#205).
|
||||
- `docs/RUNNER-PROBES.md` documents the standing runner-probe venue,
|
||||
`heavy-duty/ceremony-runner-probe` — the place runner-only facts are measured
|
||||
on demand, ruled as option A by the operator (#202).
|
||||
|
||||
- `drills/README.md` cross-links it beside the disposal rule, so the exception
|
||||
is visible where the dangerous habit lives (#202).
|
||||
|
||||
- The runbook states that the drill disposal rule does **not** apply to it.
|
||||
Archiving it defeats its purpose, and that is exactly how the three existing
|
||||
drill repos each became unavailable (#202).
|
||||
|
||||
- It records that a probe must run as an Actions job under the workflow token:
|
||||
the same call answers 500 there and 204 under a PAT, so a probe run any other
|
||||
way produces a confident wrong answer (#202).
|
||||
|
||||
- Creating the repo is recorded as the operator's step, measured rather than
|
||||
assumed: a fleet identity gets 403 on org repo creation and 201 in its own
|
||||
namespace (#202).
|
||||
|
||||
- It carries an executable two-layer arming procedure: an immutable candidate
|
||||
code SHA and an armed workflow commit on top of it. A single layer is
|
||||
self-referential — rewriting a workflow makes a new commit, and a commit
|
||||
cannot contain its own object ID (#202).
|
||||
|
||||
- Callers are pinned by layer: composite actions to the candidate code SHA,
|
||||
reusable workflows to the armed SHA, which is the only revision whose inner
|
||||
checkout points at the fork (#202).
|
||||
|
||||
- The arming gate asserts what each carrier IS, not only that the old literal
|
||||
is gone: every `repository:` equals the fork, every `CEREMONY_SELF_REF` value
|
||||
equal the candidate code SHA, and callers match the layer they belong to
|
||||
(#202).
|
||||
|
||||
- It enumerates the carriers from the tree rather than encoding a count, and
|
||||
distinguishes ceremony's internal self-checkouts from the consumer checkouts
|
||||
that must stay `${{ github.repository }}` (#202).
|
||||
|
||||
- Both published snippets are ShellCheck-clean when extracted and linted
|
||||
directly, not merely as part of the repository sweep (#202).
|
||||
|
||||
- The checker validates the MANIFEST against the target it was given, so a
|
||||
manifest that describes a wrong arming consistently — wrong fork, or the
|
||||
armed SHA where the candidate belongs — refuses instead of matching a tree
|
||||
rewritten to the same wrong value (#202).
|
||||
|
||||
- The manifest is generated from the PRE-arming tree, which is the only order
|
||||
that enumerates the carriers that must change (#202).
|
||||
|
||||
- Both published snippets were driven against a constructed candidate/probe
|
||||
pair: deletion, both role swaps, wrong owner, wrong
|
||||
SHA, wrong path, a deleted caller class and an extra carrier all refuse, and
|
||||
the armed control passes (#202).
|
||||
|
||||
- The manifest records complete caller coordinates, so a path swapped under the
|
||||
right owner and SHA is caught (#202).
|
||||
|
||||
- Generator and checker share one domain — ceremony callers — so a third-party
|
||||
`actions/checkout` is neither manifested nor reported as unrecognised (#202).
|
||||
|
||||
- Probe results are written to an issue in the probe repo and carried to the
|
||||
ceremony issue by a human, so the probe holds no path that can write to the
|
||||
live board (#202).
|
||||
- `test/docs-sync.test.sh` drives the fetch path, which had no coverage at all:
|
||||
every existing row passes `--source`, which overrides the fetch entirely
|
||||
(#201).
|
||||
|
||||
- A stubbed `curl` records the requested URL and serves a tarball, so which
|
||||
forge a pin resolves against is a tested decision rather than plumbing (#201).
|
||||
- `docs/UPSTREAM-SYNC.md` — the recurring upstream sync as a runbook: the
|
||||
standing resolutions, which side wins each and the issue that decided it
|
||||
(#200).
|
||||
|
||||
- It names the step the 0.6.0 sync nearly shipped without: auditing what the
|
||||
merge brought in that did **not** conflict. `git merge` asks no question
|
||||
about a function upstream added to a file this tree owns (#200).
|
||||
|
||||
- It records that the same mechanic applies to state, not just to call sites: a
|
||||
resolved region can silently remove a producer whose consumers auto-merged,
|
||||
and every one of those consumers degrades to empty rather than erroring
|
||||
(#200).
|
||||
|
||||
- It says to verify with the runner's tooling, because "green locally" was
|
||||
wrong three times in one sync — untracked files, a pinned linter, and a
|
||||
pinned `jq` whose empty-input exit code differs (#200).
|
||||
|
||||
- It says every branch open across a sync is stale afterwards — Forgejo never
|
||||
re-tests an open PR when main moves, so a prior approval is evidence about a
|
||||
tree that no longer exists (#200).
|
||||
|
||||
- It says to audit post-merge runs by executed steps rather than colour, and to
|
||||
inventory what the sync changed about workflow triggers and jobs first (#200).
|
||||
|
||||
- `.upstream-ref` records the upstream commit this tree carries, in
|
||||
machine-readable form beside the CHANGELOG's prose (#200).
|
||||
|
||||
- `test/upstream-delta.test.sh` fails the PR that scatters a forge decision
|
||||
into a file the inventory does not name. Discovery is derived from the tree,
|
||||
so a composite `action.yml` or a `.yaml` workflow is seen without anyone
|
||||
remembering to add a glob (#200).
|
||||
|
||||
- Discovery is git's, not the filesystem's: `ls-files`, so the tarballs `ci.yml`
|
||||
extracts into the checkout and any developer cache are not parsed as source
|
||||
(#200).
|
||||
|
||||
- It refuses when the recorded commit is missing, absent from the object store,
|
||||
or not an ancestor — three distinct refusals, none of them a skip. `ci.yml`
|
||||
fetches that exact object so the test reads local evidence without CI
|
||||
omitting it (#200).
|
||||
|
||||
- Its mutation cases drive the real check against a constructed tree, so
|
||||
replacing the guard with `return 0` reds five of them (#200).
|
||||
|
||||
- `docs/CONSUMERS.md` states that two ceremonies answer to the same version
|
||||
number, and how a consumer says which one it pinned (#200).
|
||||
- `lib/issue_references.sh` — the LOCAL/CROSS classifier, moved out of
|
||||
`actions/issueflow-reconcile`'s executable so a second caller can use it
|
||||
without sourcing a reconciler, which would run one (#199, #61).
|
||||
|
||||
- `test/refs-not-closing.test.sh` drives the action's boundary on **both**
|
||||
backends with stubs at the transport, proving one fixture yields the same
|
||||
verdict on each — including a closing keyword that appears only in a commit
|
||||
message (#199).
|
||||
- This tree carries upstream ceremony through `8c3a4d1` (upstream `0.6.0`):
|
||||
`lib/attention.sh`, `lib/read.sh`, `actions/refs-not-closing`, the guarded
|
||||
reads, and the ruling and window rules (#198).
|
||||
|
||||
- `test/no-runtime-gh.test.sh` — the forge-portability guard: no runtime `gh`
|
||||
outside `lib/forge-github.sh` unless the file declares
|
||||
`CEREMONY_FORGE_CLIENT=gh` (#198).
|
||||
|
||||
- `CHANGELOG.md` names the upstream commit this tree carries, so a drill
|
||||
record can say which `0.6.0` it exercised (#197, #198).
|
||||
- `test/labels.test.sh` holds the conf's roster and `CONTRIBUTING.md`'s roster
|
||||
table to the same set, in both directions, so a roster edit that touches one
|
||||
file and not the other goes red instead of drifting quietly (#195).
|
||||
- `test/forge-backends.test.sh` pins the replacement contract: preserve
|
||||
unrelated labels across a combined add+remove, an absent removal as a
|
||||
successful no-op, the empty set as a full clear, and `forge_labels_add`
|
||||
still `POST`-only, per ceremony#128 (#192).
|
||||
|
||||
- `test/labels-reconcile.test.sh` drives a failing write through `main()` — the
|
||||
swallow was in the loop, where a fixture-level probe cannot reach (#192).
|
||||
|
||||
### Changed
|
||||
|
||||
- The review panel restores `kimi-reviewer-andresmgsl` alongside GLM. The
|
||||
four-identity panel now requires three cross-vendor approvals after the PR
|
||||
author recuses (#224).
|
||||
- The review panel names `glm-reviewer-andresmgsl` in place of
|
||||
`kimi-reviewer-andresmgsl` while that identity is unavailable. The panel
|
||||
stays three, so a PR still requires two cross-vendor approvals (#222).
|
||||
|
||||
- `test/labels.test.sh`'s table-side roster mutation names an identity the
|
||||
table actually carries. Naming a departed one would mutate nothing and the
|
||||
case would pass while proving nothing — #195's rot class one layer down
|
||||
(#222).
|
||||
- This forge's release line runs `0.4.1 → 0.6.1`: versions 0.5.0 and 0.6.0
|
||||
arrived here by merge from the read-only upstream and were never released
|
||||
on this forge (#220).
|
||||
|
||||
- The `## 0.6.0` section this changelog carries is upstream's — its entries
|
||||
describe upstream's work under upstream's issue numbers. The forge port's
|
||||
own work ships first in 0.6.1 (#220).
|
||||
- `docs/RUNNER-PROBES.md` records the venue's first delivered drills — the
|
||||
#192 asymmetry re-observed on demand under the workflow token, the dispatch
|
||||
route's 204 under both identities, and #215's boundary finding — each with
|
||||
the probe-issue URL it is recorded in (#202).
|
||||
|
||||
- Two venue lessons join the runbook where the next probe author will look:
|
||||
findings must be written to issues because the venue's log route 404s for
|
||||
non-admin reads, and report content sent to the forge must never contain a
|
||||
credential expression or value (#202).
|
||||
|
||||
### Fixed
|
||||
|
||||
- The sweep's `bootstrap` value crosses the `workflow_call` boundary as a
|
||||
declared input, explicitly passed by the caller — the one channel measured
|
||||
to work; the called workflow did not see the caller's event inputs as an
|
||||
implicit substitute on this instance (#215).
|
||||
|
||||
- Before the bridge, `github.event.inputs` was empty inside the called
|
||||
workflow, so every dispatch-woken sweep bootstrapped: ~20 label upserts on
|
||||
each board event (#215).
|
||||
|
||||
- The caller maps an empty top-level value to `no` explicitly, so a
|
||||
cron-woken sweep can never bootstrap; the declared input also defaults to
|
||||
`no`, so a consumer that passes nothing gets the safe path (#215).
|
||||
|
||||
- The gate feeds the declared input to `labels-reconcile` unchanged, so an
|
||||
invalid value meets the action's own `yes|no` refusal instead of being
|
||||
silently coerced (#215).
|
||||
|
||||
- `docs/CONSUMERS.md`'s published sweep stub carries the same pass-through —
|
||||
without it every consumer inherits the defect ceremony fixed for
|
||||
itself (#215).
|
||||
- `issueflow-reconcile` sees this forge's issues again. The board gather used
|
||||
`has("pull_request")`, and every Forgejo entry carries that key — so it
|
||||
selected zero rows on every sweep while printing `reconciled.` (#210).
|
||||
|
||||
- Three sites take `.pull_request == null`, the discriminator the file's own
|
||||
comment already specified and that one of its four call sites already used
|
||||
(#210).
|
||||
|
||||
- `post-merge` transitions can fire again: they could not, because the sweep
|
||||
saw no issues to transition (#210).
|
||||
- `blocker:unrequested` is judged on this forge again. The head-commit date was
|
||||
read from `repos/{o}/{r}/commits/{sha}`, which Forgejo answers **404** — so
|
||||
every sweep degraded and left the blocker unjudged (#209).
|
||||
|
||||
- `forge_commit_at` is a verb on both backends: GitHub serves a single commit at
|
||||
the bare path with the date nested, Forgejo at `git/commits/{sha}` with it
|
||||
under `.created`. The caller asks for one timestamp and knows neither shape
|
||||
(#209).
|
||||
- `.github/workflows/labels.yml` wakes the sweep over REST instead of
|
||||
`gh workflow run`, so a board event reconciles within seconds on any forge
|
||||
rather than waiting up to an hour for the scheduled sweep (#205).
|
||||
|
||||
- The workflow-dispatch endpoint has the same shape on both forges, so that
|
||||
step no longer decides one: the `CEREMONY_FORGE_CLIENT=gh` declaration and
|
||||
both inline refusals are gone rather than ported (#205).
|
||||
|
||||
- The dispatch supplies its `ref` explicitly, because REST has no default
|
||||
branch where `gh workflow run` had one, and refuses without it (#205).
|
||||
|
||||
- It takes that ref from the repository, never from `GITHUB_REF_NAME` — on a
|
||||
`pull_request_target` run that is `<n>/merge`, which is not a branch (#205).
|
||||
|
||||
- A failed dispatch names the endpoint, the ref and the status, and says that
|
||||
an empty `500` body from Forgejo means the workflow name or the ref did not
|
||||
resolve — a bare status sends the reader after a server fault that is not
|
||||
there (#205).
|
||||
- `actions/docs-sync` fetches the doctrine mirror from the forge named by
|
||||
`GITHUB_SERVER_URL` instead of a hard-coded `github.com` (#201).
|
||||
|
||||
- The same pin ref names a different tree on each forge, so a consumer's mirror
|
||||
was verified against a tree it never pinned — and with HTTP 200, so `--check`
|
||||
reported drift that could not be fixed (#201).
|
||||
|
||||
- A fetch that cannot name its forge now refuses instead of guessing: no
|
||||
`GITHUB_SERVER_URL` and no `--source` exits naming the variable, having
|
||||
reached for no network (#201).
|
||||
|
||||
- A failed fetch names the URL it actually tried, and asks whether the ref
|
||||
exists on that forge rather than in the abstract (#201).
|
||||
- `actions/refs-not-closing` gathers over REST through the forge shim instead
|
||||
of one GraphQL query, so it produces a real verdict on Forgejo — which
|
||||
serves no GraphQL surface at all — rather than refusing (#199).
|
||||
|
||||
- The closing set is parsed by `lib/closes_references.sh` over the PR body
|
||||
**and** every commit message, unioned. Forgejo honours closing keywords in
|
||||
commit messages, so a body-only port would miss a PR that closes an issue
|
||||
from a commit subject (#199).
|
||||
|
||||
- The `hasNextPage` refusal is carried onto the paginated commit read: an
|
||||
incomplete gather refuses instead of returning a partial verdict, reusing
|
||||
the backend's `x-total-count` completeness proof (#199).
|
||||
|
||||
- A failed read never reaches the parser. An unread body parses to an empty
|
||||
closing set, which is a passing verdict the action never earned (#199).
|
||||
|
||||
- `.github/workflows/refs-guard.yml` no longer gates the job on the forge.
|
||||
A portable action behind a github-only gate is a guard that passes by never
|
||||
running (#199, #198).
|
||||
|
||||
- The `CEREMONY_FORGE_CLIENT=gh` declaration is gone, and the contract test
|
||||
asserts its absence: an opt-out with no `gh` behind it is a standing
|
||||
permission slip (#199).
|
||||
- Eight runtime `gh` call sites arrived with the merge outside every conflict
|
||||
hunk, in functions upstream added to files this tree already owned. Seven
|
||||
are ported onto the shim; the eighth is named with its reason (#198).
|
||||
|
||||
- The open-PR gather reads `Refs`, not only closing keywords. Reading one side
|
||||
for closing links and the other for `Refs` is what released a live claim in
|
||||
crew#321, and this tree carried that shape (#198).
|
||||
|
||||
- The merged record gains `merged_at`, so `post_merge_pr_for_issue` answers
|
||||
the PR that merged last rather than the highest-numbered one. Without the
|
||||
column every sort key ties and the old order returns silently (#198).
|
||||
|
||||
- The open gather feeds `open_pr_issues` one record per physical body line. A
|
||||
whole decoded body as one record loses every declaration including the
|
||||
first, and reclaims a claim a live PR was holding (#198).
|
||||
|
||||
- The post-merge nudge links the issue on the forge in play rather than a
|
||||
hard-coded `github.com` (#198).
|
||||
|
||||
- `actions/refs-not-closing` reports and skips on a forge it cannot speak,
|
||||
naming the client and #199, instead of standing red on every PR. It reaches
|
||||
the forge zero times, so no verdict is produced either way (#198).
|
||||
|
||||
- `.github/workflows/labels.yml`'s sweep dispatch declares the client it
|
||||
speaks and decides the FORGE before the binary, so a Forgejo runner that
|
||||
happens to ship `gh` cannot dispatch against a forge that cannot serve it.
|
||||
#205 ports it to REST (#198).
|
||||
|
||||
- `actions/refs-not-closing` fails closed on a forge it cannot speak, and
|
||||
`.github/workflows/refs-guard.yml` carries the scheduling decision — the
|
||||
action never reports a success it did not earn (#198).
|
||||
|
||||
- `issue_payload_valid` refuses an empty payload on jq 1.6 as well as 1.7.
|
||||
`jq -e` exits 4 on empty input under 1.7 and **0** under 1.6, and this
|
||||
instance's runner carries 1.6 — so the guard #247 D3 added to refuse an
|
||||
unreadable read was accepting one here (#198).
|
||||
|
||||
- The post-merge nudge strips a trailing slash from the server URL, so a forge
|
||||
URL carrying one does not render `//owner/repo` (#198).
|
||||
|
||||
- `.github/scripts/release-path.sh` names `lib/forge.sh`: #191 put the shim on
|
||||
the release doors' executable path here, so a doors-unchanged record that
|
||||
omitted it was measuring the wrong set (#198).
|
||||
- `.github/labels.conf` names identities that exist on the forge this repo
|
||||
lives on. All five it named before were absent, so `panel=` could never
|
||||
converge a review round and `triage-actors=` made every issue a stray mint
|
||||
nobody could normalize (#195).
|
||||
|
||||
- `CONTRIBUTING.md`'s roster table matches the conf: three identities, the
|
||||
human row is `andres`, and the approval count states what panel-minus-author
|
||||
actually resolves to on this roster rather than a stale three (#195).
|
||||
- Label removal on Forgejo is a full-set `PUT`, not a per-label `DELETE`. The
|
||||
workflow token gets HTTP 500 on every `DELETE .../labels/{id}` on this
|
||||
instance, so the state machine could only ever ADD labels (#192).
|
||||
|
||||
- Every `state:*` transition that needs the previous state cleared, and every
|
||||
`blocker:*` that should lift, can now actually clear. They were inert (#192).
|
||||
|
||||
- A label edit that fails is fatal to `labels-reconcile`, matching
|
||||
`issueflow-reconcile`. One cause had two contradictory policies (#192).
|
||||
|
||||
- A failed write reaches the sweep's exit code: per-PR tolerance is kept for
|
||||
READS, but a sweep that could not write exits non-zero and its output carries
|
||||
no `reconciled.` token at all (#192).
|
||||
|
||||
- Every label mutation goes through one checked helper, so clearing
|
||||
`merge-next` or either `stale` edit fails the sweep too — not only the
|
||||
primary state edit (#192).
|
||||
|
||||
- A preserved label keeps the id the issue payload already carried, so
|
||||
preservation does not depend on a repository-wide list that has nothing to do
|
||||
with the issue (#192).
|
||||
|
||||
- A removal that changes nothing writes nothing, rather than replacing the set
|
||||
with itself and opening a race for no state change (#192).
|
||||
|
||||
- Every failure diagnostic on the forgejo backend names the verb as well as the
|
||||
path and the status. A read used to say `HTTP 500 from 'repos/…'`, which
|
||||
cannot be told from a failed write of the same path (#192).
|
||||
|
||||
- The diagnostic names what was attempted and that it did not happen, instead
|
||||
of blaming a missing label and telling the operator to bootstrap — a cause it
|
||||
had not established (#192, #101).
|
||||
|
||||
- An add-label the repo does not carry refuses before any write, so a
|
||||
replacement `PUT` can never drop a label nobody asked to remove (#192).
|
||||
this forge's own release, not upstream's, and upstream's separate `0.4.1`
|
||||
section is deliberately not carried — the tag published here is the one this
|
||||
section is the body of. Each sync updates this line
|
||||
(docs/UPSTREAM-SYNC.md, #200).
|
||||
|
||||
## 0.6.0 — 2026-08-05
|
||||
|
||||
|
|
|
|||
|
|
@ -10,22 +10,22 @@ the two is a bug.
|
|||
Work moves through one pipeline, and every stage has an owner:
|
||||
|
||||
```
|
||||
proposal ──▶ triage ──▶ work issue ──▶ build ──▶ review ──▶ human merge ──▶ release
|
||||
(anyone) (agent) (queue) (agent) (agents) (human) (ceremony)
|
||||
discussion ──▶ triage ──▶ issue ──▶ build ──▶ review ──▶ human merge ──▶ release
|
||||
(anyone) (agent) (queue) (agent) (agents) (human) (ceremony)
|
||||
```
|
||||
|
||||
- **Proposals are where intent lives.** Anyone — human or agent — who has an
|
||||
idea, a bug, a question, or a "we should…" files a **proposal**, not a work
|
||||
issue. Proposals are allowed to be vague; that is what they are for.
|
||||
- **Work issues are minted only by triage.** Nobody else writes work issues —
|
||||
not humans, not builders, not reviewers. A work issue is a work order with a quality
|
||||
- **Discussions are where intent lives.** Anyone — human or agent — who has an
|
||||
idea, a bug, a question, or a "we should…" opens a **discussion**, not an
|
||||
issue. Discussions are allowed to be vague; that is what they are for.
|
||||
- **Issues are minted only by triage.** Nobody else writes issues — not
|
||||
humans, not builders, not reviewers. An issue is a work order with a quality
|
||||
bar (the issue contract in [TRIAGE.md](TRIAGE.md)), and the bar holds
|
||||
because exactly one role is accountable for it. An issue that appears
|
||||
through any other door gets `needs-triage` and is normalized or converted
|
||||
back into a proposal.
|
||||
back into a discussion.
|
||||
- **Builders turn one issue into one PR.** [BUILDER.md](BUILDER.md).
|
||||
- **Reviewers converge on a verdict.** [REVIEWER.md](REVIEWER.md).
|
||||
- **Humans decide twice**: in the proposal (what is worth doing, and any
|
||||
- **Humans decide twice**: in the discussion (what is worth doing, and any
|
||||
call triage escalates back) and at the merge (whether it ships). Everything
|
||||
between those two points is agent work by default.
|
||||
- **Merging a release PR ships it** — the release ceremony this repo's
|
||||
|
|
@ -41,28 +41,27 @@ only ceremony-specific facts such as the roster and code conventions.
|
|||
|
||||
### Roster
|
||||
|
||||
Four identities share the work (org team `agents`), each living in its own
|
||||
Three identities share the work (org team `agents`), each living in its own
|
||||
[box](https://github.com/heavy-duty/box) — one box per credential, because
|
||||
the box is the blast-radius boundary; roles are what a session is told, and
|
||||
[AGENTS.md](AGENTS.md) routes from there:
|
||||
|
||||
| identity | box (rig tenant) | standing work |
|
||||
|---|---|---|
|
||||
| `claude-bot-andresmgsl` | `triage` (claude-box) | **triage** — the only door work issues come through; this identity mints work issues and nothing else writes them (#18's `triage-actors`) — and review. It does not build. |
|
||||
| `codex-bot-andresmgsl` | codex-box | build + review |
|
||||
| `glm-bot-andresmgsl` | glm-box | review |
|
||||
| `kimi-bot-andresmgsl` | kimi-box | review |
|
||||
| `cluade-reviewer-andresmgsl` | `triage` (claude-box) | **triage** — the only door issues come through; this identity mints issues and nothing else writes them (#18's `triage-actors`) — and review. It does not build. |
|
||||
| `codex-reviewer-andresmgsl` | codex-box | build + review |
|
||||
| `kimi-reviewer-andresmgsl` | kimi-box | review |
|
||||
|
||||
**The review panel for any PR is every bench identity except its author** —
|
||||
recusal by construction, enforced by the reconciler (#10): the required
|
||||
verdicts are the panel minus the PR's author. On this roster that resolves
|
||||
to **three** cross-vendor approvals of the current head, because the only
|
||||
to **two** cross-vendor approvals of the current head, because the only
|
||||
builder is itself on the panel and recuses from its own PRs; the rule is
|
||||
panel-minus-author, and three is what it currently comes to, not a second
|
||||
panel-minus-author, and two is what it currently comes to, not a second
|
||||
rule. Builders and triage default to different models so the issue contract
|
||||
is honestly exercised — a spec gap should surface as a question on the
|
||||
issue, not be silently filled by shared priors. Humans (`andres`) decide in
|
||||
proposals and merge; the roster is config, not doctrine — swapping a
|
||||
discussions and merge; the roster is config, not doctrine — swapping a
|
||||
vendor is an edit to this table (and to `panel=` in
|
||||
`.github/labels.conf` once #10 lands), nothing more.
|
||||
|
||||
|
|
@ -97,13 +96,14 @@ table repeats it (#104).
|
|||
|
||||
## Doctrine conventions
|
||||
|
||||
The vendored role files — the set [`docs/VENDORED.txt`](docs/VENDORED.txt)
|
||||
declares — state each normative rule completely, keep at most one sentence of
|
||||
why, and cite its record only with a bare parenthetical such as `(#N)`,
|
||||
`(#N D3)`, or `(#N, #M)`. Incident narrative — timestamps, actors, quoted
|
||||
comments, measured counts, and links to specific comments — belongs in that
|
||||
record. If a rule cannot be followed without chasing its cite, the rule is
|
||||
under-stated: fix the statement, not the citation. (#280)
|
||||
The vendored role files — `AGENTS.md`, `TRIAGE.md`, `BUILDER.md`,
|
||||
`REVIEWER.md`, `LABELS.md`, and `RELEASES.md` — state each normative rule
|
||||
completely, keep at most one sentence of why, and cite its record only with a
|
||||
bare parenthetical such as `(#N)`, `(#N D3)`, or `(#N, #M)`. Incident
|
||||
narrative — timestamps, actors, quoted comments, measured counts, and links to
|
||||
specific comments — belongs in that record. If a rule cannot be followed
|
||||
without chasing its cite, the rule is under-stated: fix the statement, not the
|
||||
citation. (#280)
|
||||
|
||||
Normative text in those files does not cite issues from other repositories.
|
||||
Consumers read the vendored bytes outside this organization's context, and a
|
||||
|
|
@ -118,12 +118,21 @@ every session. (#280)
|
|||
|
||||
## How the other repos use this
|
||||
|
||||
Two consumption modes, split by what has a runtime: **machinery by
|
||||
reference**, fetched at run time from the ref a caller pins, and **doctrine
|
||||
as a mirror** — the set [`docs/VENDORED.txt`](docs/VENDORED.txt) declares,
|
||||
vendored at `.ceremony/` and held to the pin by a guard (issue #19). The
|
||||
[README](README.md) states both modes in full, and why they differ; what
|
||||
follows is only what they leave a governed repo to carry.
|
||||
Two consumption modes, split by what has a runtime:
|
||||
|
||||
- **Machinery is consumed by reference.** Workflows and actions are fetched
|
||||
by GitHub at run time from the ref the caller pins — no copy exists in the
|
||||
consumer.
|
||||
- **Doctrine is consumed as a machine-verified mirror.** A document's only
|
||||
"runtime" is an agent reading the working tree of the repo it stands in —
|
||||
a doc that requires a cross-repo fetch before it governs is a doc that
|
||||
sometimes goes unread. So the agent-facing set — **AGENTS.md, TRIAGE.md,
|
||||
BUILDER.md, REVIEWER.md, LABELS.md, RELEASES.md** — is vendored into each governed
|
||||
repo at **`.ceremony/`**, byte-identical to this repo at the pinned ref,
|
||||
by the sync tool (issue #19). A CI guard diffs the mirror against the pin
|
||||
on every PR: hand-editing a vendored file, or bumping the pin without
|
||||
re-syncing, goes red. It is a copy that cannot drift — which is the only
|
||||
kind of copy this org allows.
|
||||
|
||||
A governed repo (box, rig, cast, incubator, …) therefore carries:
|
||||
|
||||
|
|
@ -143,8 +152,7 @@ A governed repo (box, rig, cast, incubator, …) therefore carries:
|
|||
- the **`scope:*` label set** (`.github/labels.conf` + `.github/labeler.yml`),
|
||||
- the **drill meaning** (`drills/README.md`),
|
||||
- the repo's own code conventions;
|
||||
- **An intake door is open**: install the proposal form and `needs-triage`
|
||||
flow, or use a forge-native intake surface.
|
||||
- **Discussions enabled**, so the triage door exists.
|
||||
|
||||
One pin governs both the machinery and the doctrine: the ref a repo's
|
||||
workflows call is the ref its `.ceremony/` mirror is verified against.
|
||||
|
|
|
|||
2
FLEET.md
2
FLEET.md
|
|
@ -136,7 +136,7 @@ The earlier form of this file folded handoff and rebase into the other
|
|||
builder wakes; they are duties of their own.
|
||||
|
||||
- **Triage signals**, per registry repo: `needs-triage` issues,
|
||||
queue-unlabeled strays, proposals without triage's voice, unread
|
||||
queue-unlabeled strays, discussions without triage's voice, unread
|
||||
`@`-mentions (their own session), and `blocked` issues whose named blockers
|
||||
have all landed — a lead the session verifies, never a label the engine
|
||||
flips. Backlog hygiene (stale claims, label invariants) runs hourly,
|
||||
|
|
|
|||
10
LABELS.md
10
LABELS.md
|
|
@ -27,10 +27,8 @@ and the reconciler recomputes it from GitHub's own facts.
|
|||
`state:needs-human` means exactly one thing — a human could merge this now —
|
||||
so it requires zero blockers and head-current approvals; anything less and
|
||||
the reconciler takes it back. The author sets it at handoff (the one
|
||||
hand-set state). On a same-repository head, the `labeled` event fires the
|
||||
sweep that validates the write within seconds; on a fork head whose
|
||||
`pull_request_target` token is read-only, validation waits for the scheduled
|
||||
sweep cadence (#241).
|
||||
hand-set state); the `labeled` event fires the sweep that validates the
|
||||
write within seconds.
|
||||
|
||||
## PR blockers — what is in the way? (facts, as many as apply)
|
||||
|
||||
|
|
@ -51,7 +49,7 @@ strips it on sight).
|
|||
|
||||
| Label | Color | Means | Set by |
|
||||
|---|---|---|---|
|
||||
| `needs-triage` | `#FBCA04` | a proposal or stray issue that did not come through triage — it owes normalization into work or a reasoned refusal | anyone who spots one; cleared by triage |
|
||||
| `needs-triage` | `#FBCA04` | an issue that did not come through triage — it owes normalization or conversion back to a discussion | anyone who spots one; cleared by triage |
|
||||
| `ready` | `#0E8A16` | triaged, spec complete, unblocked — a builder can start now and succeed | triage |
|
||||
| `claimed` | `#1D76DB` | a builder owns it: assignee set, a draft PR expected shortly | the claiming builder |
|
||||
| `blocked` | `#6A737D` | waiting on another issue or PR (`Blocked by #N` in the body names it) | triage; anyone may correct it |
|
||||
|
|
@ -242,4 +240,4 @@ full-board reconcile. The sweep warns when the core taxonomy declares a label
|
|||
the repository lacks. The same workflow reconciles issue-flow labels on issue
|
||||
events and during the scheduled sweep. Default GitHub labels (`duplicate`,
|
||||
`invalid`, `question`, `wontfix`, `help wanted`, `good first issue`) are
|
||||
deleted at bootstrap — a `question` belongs in a proposal, not a work issue.
|
||||
deleted at bootstrap — a `question` is a discussion, not an issue.
|
||||
|
|
|
|||
72
README.md
72
README.md
|
|
@ -108,8 +108,8 @@ workflow that carries it.
|
|||
consumer's release, when it is stale.)
|
||||
|
||||
**The merge is the ship decision; the tag is transcription.** After the
|
||||
merge, [release.yml](.github/workflows/release.yml#L136-L310) asserts its
|
||||
way to certainty, tags the merge commit, publishes the forge release with
|
||||
merge, [release.yml](.github/workflows/release.yml#L136-L301) asserts its
|
||||
way to certainty, tags the merge commit, publishes the GitHub release with
|
||||
the version's own changelog section as the body — the curated prose, never
|
||||
the generated PR list ([lib/changelog.sh](lib/changelog.sh) is the one
|
||||
canonical extractor, and [bin/changelog-section](bin/changelog-section) is
|
||||
|
|
@ -127,13 +127,12 @@ steps run past the tag, and what a failure at each leaves behind is what
|
|||
sorts them. Two fail before the release exists: the consumer's
|
||||
[artifact hook](docs/CONSUMERS.md#the-artifact-hook) sits between the tag and
|
||||
the publish, so its non-zero exit aborts, and the publish itself
|
||||
([`forge_release_create`](.github/workflows/release.yml#L264-L277))
|
||||
([`gh release create --verify-tag`](.github/workflows/release.yml#L246-L258))
|
||||
can fail on the API call or the assets. Either leaves the same state — a tag
|
||||
standing and no release — which the merge-door preflight recognizes and a
|
||||
re-run resumes. The tag door remains the fallback when the original run is no
|
||||
longer reachable or the release must come from a fixed tree. The third is the
|
||||
re-arm, which runs after the publish, and its refusal is the single failure in
|
||||
this file that leaves a real release behind.
|
||||
standing and no release — which the
|
||||
[nothing-exists assert](#the-merge-door-refused-releaseyml) names and the tag
|
||||
door recovers. The third is the re-arm, which runs after the publish, and its
|
||||
refusal is the single failure in this file that leaves a real release behind.
|
||||
|
||||
## The two doors
|
||||
|
||||
|
|
@ -142,20 +141,18 @@ this file that leaves a real release behind.
|
|||
`release`-labeled PR whose version transitioned to bare is the ceremony,
|
||||
everything legitimate that isn't one is a green no-op, and every
|
||||
half-ceremony dies loudly
|
||||
([release.yml](.github/workflows/release.yml#L136-L310)). Use it for every
|
||||
([release.yml](.github/workflows/release.yml#L136-L301)). Use it for every
|
||||
normal release.
|
||||
|
||||
- **The tag door — the fallback and the backfill.** A bare `X.Y.Z` tag push
|
||||
— **no `v` prefix**, box's 0.6.0 set the scheme
|
||||
([release.yml](.github/workflows/release.yml#L325-L410)) — publishes the
|
||||
([release.yml](.github/workflows/release.yml#L303-L371)) — publishes the
|
||||
same way. The tag is the operator's explicit act, so there is no decide
|
||||
and no label check — what is left is three asserts: **the tag names the
|
||||
and no label check — what is left is two asserts: **the tag names the
|
||||
tree's own version**
|
||||
([L350–L361](.github/workflows/release.yml#L350-L361)), **the tagged
|
||||
([L328–L339](.github/workflows/release.yml#L328-L339)) and **the tagged
|
||||
tree carries a publishable `## X.Y.Z` section**
|
||||
([L362–L374](.github/workflows/release.yml#L362-L374)), and **no published
|
||||
release already exists for the tag**
|
||||
([L375–L390](.github/workflows/release.yml#L375-L390)); any failure
|
||||
([L340–L352](.github/workflows/release.yml#L340-L352)); either failing
|
||||
refuses, creating nothing. No `-dev` bump either
|
||||
— the fallback does not rewrite main (cast's precedent). Use it when the
|
||||
merge path is red, for backfills, and for the
|
||||
|
|
@ -473,7 +470,7 @@ so this line can only appear when some *other* caller invokes `version_read`
|
|||
directly with a backend that is neither `file` nor `package-json`. Fix that
|
||||
caller.
|
||||
|
||||
### The merge door refused ([release.yml](.github/workflows/release.yml#L136-L310))
|
||||
### The merge door refused ([release.yml](.github/workflows/release.yml#L136-L301))
|
||||
|
||||
> CHANGELOG.md has no '## $VER' section at the merge commit — the ceremony PR must stamp it; refusing to publish an empty release
|
||||
|
||||
|
|
@ -483,21 +480,22 @@ without its stamp (a state the
|
|||
the PR — red main here means it was overridden). Stamp the section on main,
|
||||
then publish by the tag door.
|
||||
|
||||
> release '$VER' already exists — this release already happened; refusing to re-release, creating nothing.
|
||||
> tag '$VER' already exists at <tag sha> but this run would tag <MERGE_SHA> — a manual tag won the race, or it names a different commit; refusing to re-release, creating nothing. Delete that tag, or re-tag the merge commit.
|
||||
> NOTICE: tag '$VER' already stands at this merge commit and no release exists — a previous run of this door tagged and then failed to publish. Resuming: the tag is not recreated; the artifact hook and the publish run.
|
||||
> tag '$VER' already exists — this release already happened, or a manual tag won the race; refusing to re-release, creating nothing.
|
||||
> release '$VER' already exists — refusing to re-release, creating nothing.
|
||||
|
||||
[L208–L239](.github/workflows/release.yml#L208-L239), the merge-door
|
||||
preflight — the published-release refusal prevents clobbering, the
|
||||
different-commit refusal diagnoses a racing or manual tag with both SHAs, and
|
||||
the notice resumes this door after its tag succeeded but the artifact hook or
|
||||
publish failed. Re-run the merge-door job first. If that run is no longer
|
||||
reachable or the tree itself needs repair, use the tag-door fallback: delete
|
||||
and re-push the tag from the fixed tree, or run `forge_release_create` by hand.
|
||||
[L208–L223](.github/workflows/release.yml#L208-L223), the nothing-exists
|
||||
assert — what makes a re-run of a completed ceremony refuse instead of
|
||||
clobber, and what catches a manual tag racing the merge. If the release
|
||||
truly exists, there is nothing to do: this red is the system declining to do
|
||||
the thing twice. If the tag exists but the release does not (a manual tag
|
||||
won the race, or
|
||||
[a failed artifact hook](docs/CONSUMERS.md#the-artifact-hook), or the publish
|
||||
step itself failing after the tag), recover by the tag door: delete and
|
||||
re-push the tag, or `gh release create` by hand from a fixed tree.
|
||||
|
||||
> direct push refused (branch protection?) — opening the bump PR instead
|
||||
|
||||
[L302–L310](.github/workflows/release.yml#L302-L310) — loud, but not a
|
||||
[L293–L301](.github/workflows/release.yml#L293-L301) — loud, but not a
|
||||
refusal: the post-release `-dev` bump could not push directly, so the run
|
||||
opened a `release`-labeled bump PR itself. Your move: merge it promptly —
|
||||
until it lands, main is sitting bare, where a dev install impersonates the
|
||||
|
|
@ -505,35 +503,29 @@ release and the
|
|||
[armed guard's window](#changelog-armed--main-never-sits-disarmed) stays
|
||||
open.
|
||||
|
||||
### The tag door refused ([release.yml](.github/workflows/release.yml#L325-L410))
|
||||
### The tag door refused ([release.yml](.github/workflows/release.yml#L303-L371))
|
||||
|
||||
> tag '$GITHUB_REF_NAME' does not match the tree's version '$ver' — creating nothing.
|
||||
> 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.
|
||||
|
||||
[L356–L359](.github/workflows/release.yml#L356-L359). The message is the
|
||||
[L333–L337](.github/workflows/release.yml#L333-L337). The message is the
|
||||
remedy.
|
||||
|
||||
> CHANGELOG.md has no '## $VER' section — run changelog-assemble in the release PR before tagging; refusing to publish an empty release
|
||||
|
||||
[L368–L374](.github/workflows/release.yml#L368-L374). The tagged tree was
|
||||
[L346–L349](.github/workflows/release.yml#L346-L349). The tagged tree was
|
||||
never stamped. Assemble the section
|
||||
([docs/CONSUMERS.md](docs/CONSUMERS.md#assembling-a-release-section)), then
|
||||
delete and re-push the tag.
|
||||
|
||||
> release '$VER' already exists — refusing to re-release, creating nothing.
|
||||
|
||||
[L375–L390](.github/workflows/release.yml#L375-L390). A published release is
|
||||
never replaced by the fallback. If it is correct, there is nothing to do; if
|
||||
it is wrong, correct that published artifact deliberately before retrying.
|
||||
|
||||
### The re-arm refused ([release.yml](.github/workflows/release.yml#L276-L310))
|
||||
### The re-arm refused ([release.yml](.github/workflows/release.yml#L267-L301))
|
||||
|
||||
The bump belongs to the merge door alone — the tag door deliberately does not
|
||||
rewrite main ([L325–L329](.github/workflows/release.yml#L325-L329)) — and it
|
||||
rewrite main ([L303–L307](.github/workflows/release.yml#L303-L307)) — and it
|
||||
runs *after* the tag, the notes and the publish. So a refusal here leaves a
|
||||
real release standing behind a main that never re-armed — the release exists,
|
||||
and main is left *armed to impersonate* it, still reading the version it just
|
||||
shipped ([L275](.github/workflows/release.yml#L275)). That is the one failure
|
||||
shipped ([L266](.github/workflows/release.yml#L266)). That is the one failure
|
||||
in this catalog whose remedy is a manual bump, not a re-run.
|
||||
|
||||
> version_next_dev: refusing '$ver' — expected bare X.Y.Z
|
||||
|
|
@ -581,7 +573,7 @@ In every case the remedy has the same shape — bump `VERSION` (or the
|
|||
shipped version was bare, and where it was an rc, whatever you have decided
|
||||
comes next. Note that a *push* refusal is not one of these — branch
|
||||
protection is expected, and the step opens the bump PR itself rather than
|
||||
failing ([L302–L310](.github/workflows/release.yml#L302-L310)).
|
||||
failing ([L293–L301](.github/workflows/release.yml#L293-L301)).
|
||||
|
||||
### Red main that is not the release workflow
|
||||
|
||||
|
|
|
|||
108
RELEASES.md
108
RELEASES.md
|
|
@ -40,59 +40,6 @@ decision during release-init. The double gate on
|
|||
out-of-chain track on [heavy-duty/crew#348](https://github.com/heavy-duty/crew/issues/348)
|
||||
are worked examples of exceptions declared where they apply.
|
||||
|
||||
## The membership record
|
||||
|
||||
A release issue's `Blocked by` line answers the predecessor gate above and
|
||||
nothing else. Which issues are *in* the release is a separate record on the
|
||||
same issue, and the sweep reads it by heading (#343):
|
||||
|
||||
- the heading is literally `## Members`, matched case-insensitively, tolerant
|
||||
of any run of whitespace between the `##` and the word and of trailing
|
||||
whitespace after it, and the record runs to the next heading — the same
|
||||
shape `## Task list` already has;
|
||||
- one member per list row, under any Markdown list marker and only those:
|
||||
`-`, `*`, `+`, and 1 to 9 digits followed by `.` or `)` all open a row,
|
||||
because a row is whatever a reader sees as one — and a tenth digit opens
|
||||
nothing, CommonMark's ordered marker being at most nine digits, so
|
||||
`1234567890. #412` is narration and enrols no member. Indentation is bounded
|
||||
the same way: up to three spaces still open a row, four or more open nothing,
|
||||
a leading tab counting as four. The record is **flat** — one member per
|
||||
top-level row — and past that bound a line is not one: standing alone it is
|
||||
an indented code block, and under a row it is a sub-bullet annotating that
|
||||
member, and neither is a member itself. Below the bound it enrols, an
|
||||
indented row being the same bytes as a top-level one. The member is the
|
||||
row's first token after the list marker and an optional checkbox, and it is
|
||||
a bare local `#<number>`: `- #253` and `- [ ] #253` both enrol #253.
|
||||
Everything after that token is prose and contributes nothing, so a row is
|
||||
free to cite the PR that closed it, a sibling repository, or an issue it
|
||||
names as explicitly *not* a member;
|
||||
- a row whose first token is anything else — a qualified `repo#N`, a number
|
||||
with punctuation attached, or ordinary prose — contributes no member. The
|
||||
parse stays silent rather than guessing;
|
||||
- a qualified reference is never a member: a window is one repository's DAG,
|
||||
decided against one board read;
|
||||
- a row naming the release issue itself contributes no member. The sink is
|
||||
never one of its own members;
|
||||
- **there is no fallback to the gate.** A release issue with no members
|
||||
section enumerates no membership, is not a standing window, and draws no
|
||||
window flag. A repository whose epics predate this record gets silence,
|
||||
never a false flag, until its next release-init writes one.
|
||||
|
||||
Why a heading and not a marker phrase: the `Blocked by` parse unions every
|
||||
occurrence of its marker and runs each clause to a sentence terminator, which
|
||||
is the right error direction for a `blocked` issue and the wrong one for a
|
||||
release body that is mostly narration *about* its members. Why the first token
|
||||
and not every reference in the row: a real member row cites merged PRs, other
|
||||
repositories and explicit non-members, and reading the whole row enrols all of
|
||||
them.
|
||||
|
||||
The cost is named rather than hidden: a version epic maintains two lists — the
|
||||
`## Members` record and the `## Task list` progress view — and triage writes
|
||||
both in the same flip. The purchase is that the progress view stays a progress
|
||||
view, prose-rich and free to carry several issues in one row or to omit a
|
||||
member that is not in the build queue, while membership is a machine record
|
||||
with exactly one shape.
|
||||
|
||||
## Release-init
|
||||
|
||||
The predecessor closing and clearing the next epic's declared gate is the
|
||||
|
|
@ -107,9 +54,7 @@ steps:
|
|||
written. Each member initially declares `Blocked by <the epic>`.
|
||||
2. Graph hard `Blocked by` edges and same-file clusters on the epic.
|
||||
3. Write the waves into the epic body as checklists in claim order, with a
|
||||
separate verification lane and the progress view under `## Task list`, and
|
||||
write the window's membership under `## Members` — release-init is where
|
||||
that record is first written, and until it exists no window stands.
|
||||
separate verification lane and the progress view under `## Task list`.
|
||||
4. Ask the operator to bless the order, then have triage open the first wave
|
||||
by applying the flip mechanics below. The operator's blessing is the one
|
||||
step this chain never automates.
|
||||
|
|
@ -129,50 +74,18 @@ interleaving unrelated windows blurs both the release story and the evidence
|
|||
behind it. Gates open windows; they do not silently admit members, so builders
|
||||
still see one deliberately ordered queue.
|
||||
|
||||
While a window stands — an open release-labeled issue whose membership record
|
||||
holds at least one open member — its members form a DAG whose sink is the
|
||||
release issue. Every member reaches that sink. Members declare only their
|
||||
immediate predecessors; ordering edges live on members, while the sink records
|
||||
membership only, in the record above and nowhere else; and the `ready` set is
|
||||
exactly the graph's current sources. Every close
|
||||
While a window stands — an open release-labeled issue with a non-empty
|
||||
enumerated gate — its members form a DAG whose sink is the release issue.
|
||||
Every member reaches that sink. Members declare only their immediate
|
||||
predecessors; ordering edges live on members, while the sink records membership
|
||||
only; and the `ready` set is exactly the graph's current sources. Every close
|
||||
releases exactly its declared successors, and that whole set is concurrently
|
||||
claimable: a member may have multiple successors, while the collision rule
|
||||
already orders any that share a deliverable. Insertion re-points downstream
|
||||
edges rather than merely appending membership at the sink. It follows that
|
||||
every `ready` issue is a member. `epic` and `post-merge` issues are exempt
|
||||
every `ready` issue is a gate member. `epic` and `post-merge` issues are exempt
|
||||
because neither is claimable (#292).
|
||||
|
||||
A member that lands `post-merge` releases nothing: that exemption is about
|
||||
claimability, while a `post-merge` issue is still open and an open predecessor
|
||||
holds its successors, so every successor declaring on it stays held and the
|
||||
window stops advancing along that edge (#329).
|
||||
|
||||
**When a member reaches `post-merge` and any open declaration names it, triage
|
||||
splits the remainder**: mint a fresh issue carrying the outstanding criteria
|
||||
verbatim, naming its owner and its wake condition and citing the original, then
|
||||
close the original on what it delivered. Triage owns this because only triage
|
||||
mints work issues and `post-merge` is its completion queue (#329).
|
||||
|
||||
**The release edge is the original's close, never the remainder's.** Each
|
||||
successor's declaration names the original's number, so closing the new issue
|
||||
releases nothing (#329).
|
||||
|
||||
**Split only when an open declaration names the issue.** The trigger is a check
|
||||
rather than a judgement — run the blocker parse over every open `blocked` body
|
||||
and see whether this number appears — because an issue that strands nothing is
|
||||
`post-merge` working as intended (#329).
|
||||
|
||||
**Never close work out from under a builder.** Where the original is assigned,
|
||||
`claimed`, or carrying an open PR, amend its body to hand the outstanding
|
||||
criteria to the new issue and let its holder close it, so the release edge above
|
||||
is reached without taking the work from them (#329).
|
||||
|
||||
**Do not instead teach the blocker parse that `post-merge` counts as landed.**
|
||||
That promotes a successor while its predecessor still owes acceptance criteria,
|
||||
inverts a parser whose deliberate error direction is to hold or flag a reference
|
||||
it cannot read rather than release it, and needs label data a reference-state
|
||||
lookup does not carry (#329).
|
||||
|
||||
The operator may declare a parallel track at init when its footprint is
|
||||
disjoint from the primary window: another repository, another artifact, or
|
||||
provably non-overlapping clusters. The declaration names the boundary and any
|
||||
|
|
@ -191,13 +104,6 @@ says they no longer apply. Preserve the history only after rewriting the
|
|||
marker into non-parseable prose, then verify that the parser returns an empty
|
||||
set for the release gate.
|
||||
|
||||
**The same flip adds the member's row to the release issue's membership
|
||||
record.** That write is not bookkeeping to catch up on later: the record is
|
||||
the only thing that makes the window stand, so a member flipped `ready`
|
||||
without a row is, to the sweep, an unblocked non-member — the exact state the
|
||||
window flag exists to report. Verify the flip by reading the record back and
|
||||
finding the new member's row in it (#343).
|
||||
|
||||
Release membership is a decision, never a sweep default. Triage performs each
|
||||
flip only after the operator blesses the wave; the issue-flow sweep may resolve
|
||||
ordinary issue dependencies, but it does not choose a release's contents.
|
||||
|
|
|
|||
|
|
@ -123,9 +123,9 @@ saw Y" outranks one that says "this looks like it might".
|
|||
## What you do not do
|
||||
|
||||
- **Re-litigate the spec.** The issue's decisions were made in triage and,
|
||||
above it, in a proposal where humans had their say. If you think the
|
||||
above it, in a discussion where humans had their say. If you think the
|
||||
spec itself is wrong, say so with reasons — as a comment pointing at the
|
||||
proposal, while still reviewing the implementation against the spec as
|
||||
discussion, while still reviewing the implementation against the spec as
|
||||
written. Spec changes go through triage, not through a review round.
|
||||
- **Merge, or tell the builder to merge.** Convergence hands the PR to a
|
||||
human; only humans merge.
|
||||
|
|
|
|||
37
TRIAGE.md
37
TRIAGE.md
|
|
@ -1,25 +1,22 @@
|
|||
# TRIAGE.md — the triage role
|
||||
|
||||
You are the only door work issues come through. Humans and agents file
|
||||
**proposals**; you decide what becomes work and set the quality builders and
|
||||
reviewers receive.
|
||||
You are the only door issues come through. Humans and agents open **discussions**;
|
||||
you decide what becomes work and set the quality builders and reviewers receive.
|
||||
|
||||
## Why this door exists
|
||||
|
||||
Proposals may be ambiguous; work issues may not: a builder must be able to
|
||||
execute one **without asking anything**. One accountable role keeps builders
|
||||
from guessing.
|
||||
Discussions may be ambiguous; issues may not: a builder must be able to execute
|
||||
one **without asking anything**. One accountable role keeps builders from guessing.
|
||||
|
||||
## Your inputs
|
||||
|
||||
- **Every open proposal** in the repo you serve.
|
||||
- **Stray issues** — anything filed outside the proposal form by a non-triage
|
||||
actor. Label it
|
||||
- **Every open discussion** in the repo you serve.
|
||||
- **Stray issues** — anything filed directly, by anyone. Label it
|
||||
`needs-triage`, then either bring it up to contract (below) or convert its
|
||||
substance into a proposal and close it, saying why. Route the work
|
||||
substance back into a discussion and close it, saying why. Route the work
|
||||
without shaming the filer.
|
||||
|
||||
## For each proposal, converge on exactly one outcome
|
||||
## For each discussion, converge on exactly one outcome
|
||||
|
||||
1. **Answer.** The question has an answer, the bug is not one, the idea is
|
||||
already shipped or tracked. Link the code, doc, or issue; mark answered.
|
||||
|
|
@ -35,9 +32,9 @@ from guessing.
|
|||
[BUILDER.md's canonical ruling template](BUILDER.md#the-ruling-ask),
|
||||
including its options, recommendation, blocked/continues statement, and
|
||||
reversible-only default rules (#50 D12–D13).
|
||||
The proposal is where humans decide; wait there. When the decision
|
||||
The discussion is where humans decide; wait there. When the decision
|
||||
blocks something already on the board — an existing issue, or minted work
|
||||
a proposal's ruling gates — set `needs-ruling` on it too, so the board
|
||||
a discussion's ruling gates — set `needs-ruling` on it too, so the board
|
||||
shows where the human's turn is; the issue keeps its queue label.
|
||||
When you direct a builder to hold a claim, say the claim is **parked**,
|
||||
name what it waits on, and set `attention` so the assignee's ack is visible
|
||||
|
|
@ -64,7 +61,7 @@ from guessing.
|
|||
This is not a substitute for minting work or for `needs-ruling`.
|
||||
4. **Decline.** Real idea, wrong repo or wrong time. Say why plainly, link
|
||||
where it belongs if anywhere, close. A refusal with reasons is a good
|
||||
outcome; a zombie proposal is not.
|
||||
outcome; a zombie discussion is not.
|
||||
5. **Accept.** It justifies work → mint the issue(s). The contract below is
|
||||
the bar.
|
||||
|
||||
|
|
@ -74,7 +71,7 @@ Every issue you mint carries, in this order:
|
|||
|
||||
- **A title that names the deliverable** — "lib/version.sh — one version
|
||||
abstraction, two backends", never "improve version handling".
|
||||
- **Context**: why this exists, with links — the proposal it came from,
|
||||
- **Context**: why this exists, with links — the discussion it came from,
|
||||
the code it touches (permalinks at a pinned SHA, so line references cannot
|
||||
rot), prior art in sibling repos.
|
||||
- **The spec**: decisions made, not options listed. If the spec still has an
|
||||
|
|
@ -109,11 +106,9 @@ Every issue you mint carries, in this order:
|
|||
new issue, dropping any predecessor the new issue now reaches (inserting X
|
||||
into A → B makes A → X → B, so B drops A); a member that must land after the
|
||||
new issue but already reaches it through another member declares nothing
|
||||
new; and the release issue adds a row for the new issue to its membership
|
||||
record, which records membership only and is the only place the sweep reads
|
||||
it — a release issue's `Blocked by` line answers its predecessor gate and
|
||||
never its membership (#292, #343). Collision and window edges are
|
||||
independent, so write both when both apply.
|
||||
new; and the release issue adds the new issue to its gate, recording
|
||||
membership only. Collision and window edges are independent, so write both
|
||||
when both apply (#292).
|
||||
- **Labels**: type (`bug`/`enhancement`/`documentation`), `scope:*`, and
|
||||
exactly one of `ready` / `blocked` (see [LABELS.md](LABELS.md)).
|
||||
|
||||
|
|
@ -162,4 +157,4 @@ Repositories that adopt version epics follow [RELEASES.md](RELEASES.md).
|
|||
- Assign a builder — builders pick and claim ([BUILDER.md](BUILDER.md)).
|
||||
- Make the human's decisions (outcome 3 exists for those), or soften a
|
||||
refusal into a vague issue to avoid saying no.
|
||||
- Mint a work issue to explore an idea — file a proposal instead.
|
||||
- Mint an issue to "discuss" something — that is a discussion.
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.6.4-dev
|
||||
0.6.1-dev
|
||||
|
|
|
|||
|
|
@ -33,12 +33,6 @@ set -euo pipefail
|
|||
# no '## ' heading, and changelog_section extracts the body below HEAD's
|
||||
# heading — so the date HEAD stamped into its heading never enters the
|
||||
# comparison, and a date difference can never masquerade as a prose one.
|
||||
#
|
||||
# This guard narrows, but cannot close, the target-movement window: it sees a
|
||||
# fragment present when CI reads the target ref, but one can still land after
|
||||
# the final run and before merge. Requiring release PRs to be up to date with
|
||||
# their target branch before merge is the repository setting that closes that
|
||||
# residual window (#253).
|
||||
|
||||
base_ref="${1:-${CHANGELOG_ASSEMBLED_BASE:-origin/main}}"
|
||||
changelog="${2:-${CHANGELOG:-CHANGELOG.md}}"
|
||||
|
|
@ -175,37 +169,6 @@ frag_count="$(printf '%s' "$base_frags" | grep -c . || true)"
|
|||
|
||||
failures=0
|
||||
|
||||
# Refusal: the target branch gained a fragment after this release PR's merge
|
||||
# base, so the ceremony could not have consumed it. Merging this tree would
|
||||
# strand that fragment for the next release and misattribute when it shipped.
|
||||
stranded=""
|
||||
while IFS= read -r -d '' entry; do
|
||||
meta="${entry%%$'\t'*}"
|
||||
path="${entry#*$'\t'}"
|
||||
otype="$(printf '%s\n' "$meta" | awk '{ print $2 }')"
|
||||
name="${path##*/}"
|
||||
case "$otype:$name" in
|
||||
blob:README.md) ;;
|
||||
blob:*.md)
|
||||
if ! printf '%s' "$base_frags" | grep -Fxq "$path"; then
|
||||
stranded="${stranded} ${path}"$'\n'
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done < <(git ls-tree -z "$base_ref" -- "$dir/")
|
||||
if [ -n "$stranded" ]; then
|
||||
{
|
||||
echo "changelog-assembled: fragment(s) on target '$base_ref' were not consumed by this release PR:"
|
||||
echo
|
||||
printf '%s' "$stranded"
|
||||
echo
|
||||
echo " Merging now would strand these entries for the next release and"
|
||||
echo " misattribute when they shipped."
|
||||
echo " Fix: rebase onto the target head and re-run bin/changelog-assemble '$ver'."
|
||||
} >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
# Refusal: a fragment the ceremony consumed is still present on HEAD. The
|
||||
# ceremony deletes exactly what it assembles (#112) — a fragment that
|
||||
# survives its own release sits in the directory and is assembled AGAIN
|
||||
|
|
|
|||
|
|
@ -148,32 +148,11 @@ if [ -n "$source_dir" ]; then
|
|||
else
|
||||
# The repo is public: a plain tarball fetch, no auth, no git. Works for a
|
||||
# tag, a branch, or a commit SHA alike.
|
||||
#
|
||||
# THE FORGE COMES FROM THE ENVIRONMENT, NEVER FROM THIS FILE (#201).
|
||||
# heavy-duty/ceremony exists on two forges and the same ref names a
|
||||
# DIFFERENT TREE on each: `0.4.1` on this forge carries lib/forge.sh,
|
||||
# lib/forge-github.sh and lib/forge-forgejo.sh; GitHub's `0.4.1` carries
|
||||
# none of them. A hard-coded host therefore verified a consumer's mirror
|
||||
# against a tree it never pinned — and did it with HTTP 200, so --check
|
||||
# reported drift the consumer could not fix and --fix would have rewritten
|
||||
# a correct mirror into the wrong one. The version numbers agreeing is the
|
||||
# hazard, not the protection (#197 decision 2).
|
||||
#
|
||||
# GITHUB_SERVER_URL is what Actions injects on both forges, and
|
||||
# lib/forge.sh already selects the whole backend on it — so a consumer run
|
||||
# that reached this line has it. Unset means we do not know which forge the
|
||||
# pin refers to, and guessing is what this issue is about: refuse instead,
|
||||
# the same way the pin itself is never guessed.
|
||||
[ -n "${GITHUB_SERVER_URL:-}" ] || die \
|
||||
"docs-sync: GITHUB_SERVER_URL is unset, so the forge holding" \
|
||||
" heavy-duty/ceremony@$ref is unknown — and the same ref names a" \
|
||||
" different tree on each forge. Set it to the forge this consumer is" \
|
||||
" pinned against, or pass --source <dir>. This tool never guesses a forge."
|
||||
fetch_tmp="$(mktemp -d)"
|
||||
url="${GITHUB_SERVER_URL%/}/heavy-duty/ceremony/archive/${ref}.tar.gz"
|
||||
url="https://github.com/heavy-duty/ceremony/archive/${ref}.tar.gz"
|
||||
curl -fsSL "$url" | tar -xz --strip-components=1 -C "$fetch_tmp" || die \
|
||||
"docs-sync: cannot fetch heavy-duty/ceremony@$ref ($url) —" \
|
||||
" does the pinned ref exist on that forge?"
|
||||
" does the pinned ref exist?"
|
||||
src="$fetch_tmp"
|
||||
origin="heavy-duty/ceremony@$ref"
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -29,8 +29,6 @@ TRIAGE_ACTORS=()
|
|||
. "$(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/issue_references.sh
|
||||
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/issue_references.sh"
|
||||
# shellcheck source=lib/closes_references.sh
|
||||
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/closes_references.sh"
|
||||
# The attention target invariants (#232) — diagnosis only, both surfaces.
|
||||
|
|
@ -208,14 +206,14 @@ claim_reclaim_marker() { # $1 = last activity epoch
|
|||
refs_references() { # PR body on stdin -> local issue numbers named by Refs
|
||||
awk '
|
||||
{
|
||||
rest = tolower($0)
|
||||
while (match(rest, /(^|[^[:alnum:]_-])refs[[:space:]:]+(#|([[:alnum:]_.-]+\/)?[[:alnum:]_.-]+#)[0-9]+/)) {
|
||||
token = substr(rest, RSTART, RLENGTH)
|
||||
sub(/^.*refs[[:space:]:]+/, "", token)
|
||||
print token
|
||||
# Retain the token final byte so ^ cannot turn a concatenated
|
||||
# alphanumeric suffix into a fresh keyword boundary (#234).
|
||||
rest = substr(rest, RSTART + RLENGTH - 1)
|
||||
line = $0
|
||||
lower = tolower(line)
|
||||
if (match(lower, /(^|[^[:alnum:]_-])refs[[:space:]:]+/)) {
|
||||
line = substr(line, RSTART + RLENGTH)
|
||||
if (line ~ /^(#|([[:alnum:]_.-]+\/)?[[:alnum:]_.-]+#)[0-9]+/) {
|
||||
sub(/[.(;].*/, "", line)
|
||||
print line
|
||||
}
|
||||
}
|
||||
}
|
||||
' | issue_references \
|
||||
|
|
@ -269,6 +267,16 @@ post_merge_transition_marker() { # $1 merged PR number
|
|||
printf 'post-merge-transition-pr-%s\n' "$1"
|
||||
}
|
||||
|
||||
issue_references() { # text on stdin -> LOCAL/CROSS<TAB>reference
|
||||
# A qualified reference belongs to another repository. Classify the whole
|
||||
# token before extracting numbers so rig#112 can never become local #112.
|
||||
{ grep -Eo '([[:alnum:]_.-]+/)?[[:alnum:]_.-]+#[0-9]+|#[0-9]+' || true; } \
|
||||
| awk '
|
||||
index($0, "#") == 1 { print "LOCAL\t" substr($0, 2); next }
|
||||
{ print "CROSS\t" $0 }
|
||||
'
|
||||
}
|
||||
|
||||
blocked_reference_records() { # body on stdin -> classified reference records
|
||||
# Every occurrence of the marker contributes a clause. Binding to the first
|
||||
# occurrence alone dropped the later sentences of a repeated declaration
|
||||
|
|
@ -517,14 +525,6 @@ window_in_scope() { # $1 = comma-joined labels -> 0 subject to the window rule
|
|||
unblocked_claimable "$1"
|
||||
}
|
||||
|
||||
board_flags_in_scope() { # $1 = queue state concluded by this issue's pass
|
||||
# The board snapshot decides which issues might owe a flag, but this pass
|
||||
# speaks only about the queue state it leaves behind (#327 D2). A derived
|
||||
# claimed -> post-merge transition therefore cannot post the snapshot's
|
||||
# now-false claim that the issue is still unblocked and claimable.
|
||||
unblocked_claimable "$1"
|
||||
}
|
||||
|
||||
collision_key_index() { # board records on stdin -> "key<TAB>number" in scope
|
||||
local n labels title key
|
||||
while IFS=$'\t' read -r n labels title; do
|
||||
|
|
@ -559,114 +559,20 @@ collision_flags() { # key index on stdin -> "number<TAB>key=carrier[,key=carrier
|
|||
'
|
||||
}
|
||||
|
||||
window_flags() { # $1 window members, $2 window carriers; records on stdin -> numbers
|
||||
local n labels title members="$1" carriers="$2"
|
||||
window_flags() { # $1 gate members, $2 window carriers; records on stdin -> numbers
|
||||
local n labels title gate="$1" carriers="$2"
|
||||
[ -n "$carriers" ] || return 0
|
||||
while IFS=$'\t' read -r n labels title; do
|
||||
[ -n "$n" ] || continue
|
||||
window_in_scope "$labels" || continue
|
||||
grep -qxF "$n" <<<"$members" && continue
|
||||
# The carrier is the graph's SINK, so the flag excludes it explicitly;
|
||||
# membership parsing is a separate decision and cannot prove this guard.
|
||||
grep -qxF "$n" <<<"$gate" && continue
|
||||
# The release issue is the graph's SINK, never one of its own members
|
||||
# (#292 D2), so it can never be its own non-member.
|
||||
grep -qxF "$n" <<<"$carriers" && continue
|
||||
printf '%s\n' "$n"
|
||||
done
|
||||
}
|
||||
|
||||
membership_references() { # release body on stdin -> its enumerated members
|
||||
# The membership record (#343 D2), read by HEADING and never by a marker
|
||||
# phrase. `blocked_reference_records` unions every occurrence of its marker
|
||||
# and runs each clause to its own sentence terminator — deliberate, and the
|
||||
# right error direction for a `blocked` issue, but the wrong one here: a
|
||||
# release body is mostly narration ABOUT its members, so a phrase parser
|
||||
# takes references out of the prose. That is the mechanism that put
|
||||
# heavy-duty/crew's `0.2.0` epic inside its own gate. The heading match is
|
||||
# anchored for the same reason: crew#346 carries a literal
|
||||
# `## The members, in claim order` heading, which a substring match reads as
|
||||
# the record and an anchored one does not.
|
||||
#
|
||||
# One member per row, and it is the row's FIRST token after the list marker
|
||||
# and an optional checkbox. `epic_references` prints the whole row and takes
|
||||
# every local reference in it, which is right for a progress view and wrong
|
||||
# here — measured on crew#346, whose member rows carry merged PR numbers,
|
||||
# another repository's issues, and one issue annotated in its own row as
|
||||
# explicitly NOT a member of the window. A first token that is not a bare
|
||||
# local `#<number>` contributes nothing: silence, not a guess. A qualified
|
||||
# reference is never a member either, because a window is one repository's
|
||||
# DAG decided against one board read.
|
||||
#
|
||||
# A row is any Markdown list row, so the marker class is the whole CommonMark
|
||||
# set and exactly it — `-`, `*`, `+`, and 1 to 9 digits then `.` or `)`
|
||||
# (CommonMark 5.2). Recognising only some of them would drop a row a human
|
||||
# wrote, and reads, as a member: silence is the correct answer to a row whose
|
||||
# first token is not a bare local reference, and the wrong one to a member
|
||||
# enumerated under a marker this parse did not know. Recognising MORE than
|
||||
# them is the same error mirrored: `1234567890. #412` is not a list row to
|
||||
# any renderer, so reading it as one takes a member out of narration, and one
|
||||
# phantom open member keeps a window standing and suppresses its non-member
|
||||
# flag. The bound is written twice, in the row match and in the strip, and
|
||||
# both are pinned. `epic_references` matches a narrower class; it is a
|
||||
# progress view with its own fixtures and is byte-unchanged here (#343 D7).
|
||||
#
|
||||
# Indentation is bounded the same way and for the same reason: at most three
|
||||
# spaces open a row (CommonMark 4.4), and a leading tab is four columns of it
|
||||
# wherever indentation decides block structure. Past that bound the line is
|
||||
# not a top-level row, and which non-row it is depends on context this parse
|
||||
# does not carry — GitHub renders ` - #412` after `## Members` as
|
||||
# `<pre><code>` and the same bytes under a `- #N` row as a nested `<li>`. The
|
||||
# record is FLAT, so both are silence: an indented code block is not a row at
|
||||
# all, a sub-bullet annotating a member row is not a second member, and
|
||||
# enrolling either is the tenth digit's phantom-member direction one axis
|
||||
# over. Below the bound the answer goes the other way for the same reason: one
|
||||
# to three spaces is byte-identical to a top-level row a human indented, so it
|
||||
# enrols, and the sub-row that shape can also be is the price (#348).
|
||||
awk '
|
||||
tolower($0) ~ /^##[[:space:]]+members[[:space:]]*$/ { in_record = 1; next }
|
||||
in_record && /^#/ { exit }
|
||||
in_record && /^ {0,3}([-*+]|[0-9]{1,9}[.)])[[:space:]]+/ {
|
||||
row = $0
|
||||
sub(/^ {0,3}([-*+]|[0-9]{1,9}[.)])[[:space:]]+/, "", row)
|
||||
sub(/^\[[ xX]\][[:space:]]+/, "", row)
|
||||
split(row, token, "[[:space:]]+")
|
||||
if (token[1] ~ /^#[0-9]+$/) print substr(token[1], 2)
|
||||
}
|
||||
' | sort -nu
|
||||
}
|
||||
|
||||
release_window_records() { # $1 carrier, $2 open numbers; refs on stdin -> carrier<TAB>member
|
||||
# A carrier is never a member of its own window (#327 D1, which #343 D5
|
||||
# inherits rather than re-decides). Remove it before deciding whether any
|
||||
# open member makes the window stand, and before returning every non-self
|
||||
# reference that contributes to WINDOW_MEMBERS. One function, so the two
|
||||
# readings below can never drift apart on that guard.
|
||||
local carrier="$1" open_numbers="$2" members member
|
||||
members="$(awk -v carrier="$carrier" '$0 != carrier')"
|
||||
[ -n "$members" ] || return 0
|
||||
grep -qxF -f <(printf '%s\n' "$open_numbers") <<<"$members" || return 0
|
||||
while IFS= read -r member; do
|
||||
[ -n "$member" ] && printf '%s\t%s\n' "$carrier" "$member"
|
||||
done <<<"$members"
|
||||
}
|
||||
|
||||
release_window_gate() { # $1 carrier, $2 open issue numbers; body on stdin -> carrier<TAB>member
|
||||
# #327 D1's reading of a release issue's `Blocked by` set, kept whole and
|
||||
# kept driven. The window stopped consuming it at #343 D3 — a release
|
||||
# epic's declaration answers its predecessor gate and nothing else — so
|
||||
# what this keeps standing is the self-exclusion guard's other half: the
|
||||
# gate side and the membership side share release_window_records, and this
|
||||
# is where a change to it that only the gate could see reds.
|
||||
blocked_references | release_window_records "$1" "$2"
|
||||
}
|
||||
|
||||
release_window_members() { # $1 carrier, $2 open issue numbers; body on stdin -> carrier<TAB>member
|
||||
# What the carrier decision reads (#343 D3). No fallback to the gate when
|
||||
# the record is absent (#343 D4): a release issue enumerating no membership
|
||||
# is not a carrier, and the board draws no window flag. A fallback would
|
||||
# reinstate the misreading for precisely the bodies that have not been
|
||||
# migrated, which is where it does its damage.
|
||||
membership_references | release_window_records "$1" "$2"
|
||||
}
|
||||
|
||||
window_state() { # $1 = window carriers -> the rendered state, "#249" | "#249, #250"
|
||||
awk 'NF { printf "%s#%s", (shown++ ? ", " : ""), $1 } END { printf "\n" }' <<<"$1"
|
||||
}
|
||||
|
|
@ -830,7 +736,7 @@ last_issue_comment_activity() { # $1 issue, $2 created_at → epoch; non-zero on
|
|||
issue_activity_at "$1" "$2" comments-only
|
||||
}
|
||||
|
||||
reconcile_board_flags() { # $1 issue, $2 concluded queue state — board flags (#293)
|
||||
reconcile_board_flags() { # $1 = issue — the collision and window flags (#293)
|
||||
# Dedup is the declaration echo's, per family (#293 D4): the marker is
|
||||
# keyed to the offending state's VALUE and compared against this family's
|
||||
# last word on the thread, so a state that changes speaks and a state that
|
||||
|
|
@ -845,7 +751,6 @@ reconcile_board_flags() { # $1 issue, $2 concluded queue state — board flags (
|
|||
# board fact that is true right now, and a board where the fact never
|
||||
# changed has nothing new to say.
|
||||
local n="$1" state marker rendered
|
||||
board_flags_in_scope "$2" || return 0
|
||||
state="$(flag_for_issue "$n" "${COLLISION_FLAGS:-}")"
|
||||
if [ -n "$state" ]; then
|
||||
marker="$(state_marker collision "$state")"
|
||||
|
|
@ -883,22 +788,20 @@ marker carries the collision itself, so an unchanged one never re-posts.*" >/dev
|
|||
if state_echo_needed "$n" window-nonmember "$marker"; then
|
||||
run forge_issue_comment "$n" "<!-- issueflow:$marker -->
|
||||
A release window is standing ($state) and this issue is neither one of its
|
||||
members nor an \`epic\` or \`post-merge\` issue.
|
||||
gate members nor an \`epic\` or \`post-merge\` issue.
|
||||
|
||||
#292's invariant: during a standing window — an open \`release\`-labeled issue
|
||||
with a non-empty membership record — the \`ready\` set is a subset of that
|
||||
record, \`epic\` and \`post-merge\` exempt. Every mint during a window is a
|
||||
membership call, binary, made at mint time: **behind the gate**, this issue's
|
||||
own Dependencies declare the release issue as a blocker and the sweep releases
|
||||
it when the release closes; or **into the graph**, three writes in one tick —
|
||||
this issue declares its immediate predecessors, every member whose immediate
|
||||
predecessor it becomes re-points to it, and the release issue gains a row for
|
||||
this issue in its membership record. Silence is not a state.
|
||||
with a non-empty gate — the \`ready\` set is a subset of the gate, \`epic\` and
|
||||
\`post-merge\` exempt. Every mint during a window is a membership call, binary,
|
||||
made at mint time: **behind the gate**, this issue's own Dependencies declare
|
||||
the release issue as a blocker and the sweep releases it when the release
|
||||
closes; or **into the graph**, three writes in one tick — this issue declares
|
||||
its immediate predecessors, every member whose immediate predecessor it
|
||||
becomes re-points to it, and the release issue gains \`Blocked by #N\`, which
|
||||
records membership and nothing else. Silence is not a state.
|
||||
|
||||
Membership is read from the release issue's own \`## Members\` record: the rows
|
||||
under that heading, one member each, the row's first token a bare \`#N\` and
|
||||
everything after it prose. A \`Blocked by\` declaration on a release issue
|
||||
answers its predecessor gate and never its membership (#343).
|
||||
The gate is read from the release issue's own \`Blocked by\` declarations — the
|
||||
same parse every \`blocked\` issue is gated on, echoed on that issue.
|
||||
|
||||
*Comment only: nothing on this path writes a label or changes a state. The
|
||||
marker carries the window itself, so an unchanged one never re-posts.*" >/dev/null
|
||||
|
|
@ -916,14 +819,9 @@ reconcile_issue() {
|
|||
local merged_ref_pr="" transition_marker="" transition_handled=false parsed_set="" parse_marker=""
|
||||
local unchecked="" remove_claimed=claimed
|
||||
local attention_active=true attention_suppression=""
|
||||
local concluded_queue_state=""
|
||||
for label in needs-triage epic "${QUEUE_LABELS[@]}"; do
|
||||
has_issue_label "$label" && concluded_queue_state="$label"
|
||||
done
|
||||
decision="$(queue_decision <<<"$ISSUE_LABELS")"
|
||||
case "$decision" in
|
||||
ADD_NEEDS_TRIAGE)
|
||||
concluded_queue_state=needs-triage
|
||||
run forge_issue_edit "$n" --add-label needs-triage >/dev/null
|
||||
log "#$n: needs-triage (no queue state)" ;;
|
||||
FLAG_CONFLICT)
|
||||
|
|
@ -977,7 +875,6 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in
|
|||
--remove-label "$remove_claimed" --add-label post-merge >/dev/null
|
||||
fi
|
||||
log "#$n: merged Refs PR -> post-merge; claim released"
|
||||
concluded_queue_state=post-merge
|
||||
attention_active=false
|
||||
else
|
||||
created="$(jq -r '.created_at' <<<"$ISSUE_JSON")"
|
||||
|
|
@ -1008,7 +905,6 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in
|
|||
else
|
||||
run forge_issue_edit "$n" --remove-label claimed --add-label ready >/dev/null
|
||||
fi
|
||||
concluded_queue_state=ready
|
||||
log "#$n: stale claim reclaimed -> ready" ;;
|
||||
esac
|
||||
[ "$decision" != FLAG_UNASSIGNED ] || attention_suppression=claimed-unassigned
|
||||
|
|
@ -1132,7 +1028,6 @@ itself, so a parse unchanged since the last echo never re-posts.*" >/dev/null
|
|||
ensure_comment "$n" blockers-cleared \
|
||||
'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
|
||||
concluded_queue_state=ready
|
||||
log "#$n: blockers closed -> ready" ;;
|
||||
esac
|
||||
elif has_issue_label epic; then
|
||||
|
|
@ -1149,7 +1044,7 @@ itself, so a parse unchanged since the last echo never re-posts.*" >/dev/null
|
|||
|
||||
1. Mint the window's members.
|
||||
2. Graph hard dependencies and same-file clusters.
|
||||
3. Write ordered waves, the \`## Members\` record, and the progress task list.
|
||||
3. Write ordered waves and the progress task list.
|
||||
4. Ask the operator to bless the order, then open the first wave.
|
||||
5. Ship the release, close this epic, and trigger the next window.
|
||||
|
||||
|
|
@ -1179,7 +1074,7 @@ See \`$release_doctrine_path\`. The operator blessing the order is the one step
|
|||
# compose with every queue state, and FLAG_CONFLICT's early return still
|
||||
# short-circuits them, because a board lying about its queue state is
|
||||
# repaired before anything is derived from it.
|
||||
reconcile_board_flags "$n" "$concluded_queue_state"
|
||||
reconcile_board_flags "$n"
|
||||
|
||||
# ---- the ruling invariants (#52), on any queue state ----
|
||||
# The flag composes with the queue labels (#50 D8), so this runs after the
|
||||
|
|
@ -1264,10 +1159,7 @@ reconcile_issue_pass() { # $1 = issue — one issue's whole pass, in its own sub
|
|||
|| skip_issue "$n" "could not read the issue: $(read_failure_reason "$READ_FAILURE_STDERR")"
|
||||
issue_payload_valid "$n" <<<"$ISSUE_JSON" \
|
||||
|| skip_issue "$n" "the issue read answered a payload that is not issue #$n carrying a label array"
|
||||
# `.pull_request == null`, never `has("pull_request") | not` (#188, #210):
|
||||
# every Forgejo entry CARRIES the key, valued null on an issue, so the
|
||||
# has() form selects zero rows here — silently, forever.
|
||||
jq -e '.pull_request == null' <<<"$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")"
|
||||
reconcile_issue "$n" || exit $?
|
||||
commit_staged_effects
|
||||
|
|
@ -1362,7 +1254,7 @@ main() {
|
|||
done < <(printf '%s' "$b64" | base64 -d | refs_references)
|
||||
done)"
|
||||
|
||||
local n tail_line issue_numbers board_json release_numbers rn window_records body
|
||||
local n tail_line issue_numbers board_json release_bodies rn rbody gate body
|
||||
local window_rendered=""
|
||||
SKIPPED_COUNT=0
|
||||
SKIPPED_ISSUES=""
|
||||
|
|
@ -1381,45 +1273,36 @@ main() {
|
|||
log "could not read the issue board: $(read_failure_reason "$READ_FAILURE_STDERR")"
|
||||
return 1
|
||||
fi
|
||||
# `.pull_request == null`, never `has("pull_request") | not` (#188, #210).
|
||||
BOARD_RECORDS="$(jq -r '.[] | select(.pull_request == null)
|
||||
BOARD_RECORDS="$(jq -r '.[] | select(has("pull_request") | not)
|
||||
| [(.number | tostring), ((.labels // []) | map(.name) | join(",")), (.title // "")]
|
||||
| @tsv' \
|
||||
<<<"$board_json")"
|
||||
issue_numbers="$(cut -f1 <<<"$BOARD_RECORDS")"
|
||||
# A standing window is an open `release`-labeled issue whose MEMBERSHIP
|
||||
# RECORD still holds an OPEN member (#292 D1 as #343 D3 re-reads it). The
|
||||
# board read IS the open set, so membership decides openness with no extra
|
||||
# call — and an all-closed record is exactly the emptied window the
|
||||
# release's own `blocked` -> `ready` promotion answers, which is why a
|
||||
# `ready` release leaves the flag dormant rather than flagging the board.
|
||||
#
|
||||
# The record is read by heading, so each body must reach the parse with its
|
||||
# LINE STRUCTURE INTACT. Taking it from the board payload by issue number
|
||||
# preserves that structure without fetching a second, disagreeing board.
|
||||
release_numbers="$(jq -r '.[] | select(.pull_request == null)
|
||||
# A standing window is an open `release`-labeled issue whose gate still
|
||||
# holds an OPEN member (#292 D1). The board read IS the open set, so
|
||||
# membership decides openness with no extra call — and an all-closed gate
|
||||
# is exactly the emptied gate the release's own `blocked` -> `ready`
|
||||
# promotion answers, which is why a `ready` release leaves the flag
|
||||
# dormant rather than flagging the whole board.
|
||||
release_bodies="$(jq -r '.[] | select(has("pull_request") | not)
|
||||
| select((.labels // []) | map(.name) | index("release"))
|
||||
| .number' \
|
||||
| [(.number | tostring), ((.body // "") | gsub("[\t\r\n]"; " "))] | @tsv' \
|
||||
<<<"$board_json")"
|
||||
WINDOW_CARRIERS=""
|
||||
WINDOW_MEMBERS=""
|
||||
WINDOW_GATE=""
|
||||
if [ -n "$issue_numbers" ]; then
|
||||
window_records="$(
|
||||
while IFS= read -r rn; do
|
||||
[ -n "$rn" ] || continue
|
||||
jq -r --argjson n "$rn" '.[] | select(.pull_request == null)
|
||||
| select(.number == $n) | .body // ""' <<<"$board_json" \
|
||||
| release_window_members "$rn" "$issue_numbers"
|
||||
done <<<"$release_numbers"
|
||||
)"
|
||||
# One record per parsed non-self member keeps the carrier decision and
|
||||
# its WINDOW_MEMBERS contribution coupled to the extracted function.
|
||||
WINDOW_CARRIERS="$(cut -f1 <<<"$window_records" | awk 'NF' | sort -nu)"
|
||||
WINDOW_MEMBERS="$(cut -f2 <<<"$window_records" | awk 'NF' | sort -nu)"
|
||||
while IFS=$'\t' read -r rn rbody; do
|
||||
[ -n "$rn" ] || continue
|
||||
gate="$(blocked_references <<<"$rbody")"
|
||||
[ -n "$gate" ] || continue
|
||||
grep -qxF -f <(printf '%s\n' "$issue_numbers") <<<"$gate" || continue
|
||||
WINDOW_CARRIERS="${WINDOW_CARRIERS}${rn}"$'\n'
|
||||
WINDOW_GATE="${WINDOW_GATE}${gate}"$'\n'
|
||||
done <<<"$release_bodies"
|
||||
fi
|
||||
[ -z "$WINDOW_CARRIERS" ] || window_rendered="$(window_state "$WINDOW_CARRIERS")"
|
||||
COLLISION_FLAGS="$(collision_key_index <<<"$BOARD_RECORDS" | collision_flags)"
|
||||
WINDOW_FLAGS="$(window_flags "$WINDOW_MEMBERS" "$WINDOW_CARRIERS" <<<"$BOARD_RECORDS" \
|
||||
WINDOW_FLAGS="$(window_flags "$WINDOW_GATE" "$WINDOW_CARRIERS" <<<"$BOARD_RECORDS" \
|
||||
| awk -v state="$window_rendered" 'NF { print $1 "\t" state }')"
|
||||
if [ -z "$issue_numbers" ]; then
|
||||
log "no open issues."
|
||||
|
|
|
|||
|
|
@ -23,8 +23,7 @@ runs:
|
|||
BOOTSTRAP: ${{ inputs.bootstrap }}
|
||||
LABELS_CONF: ${{ github.workspace }}/.github/labels.conf
|
||||
run: |
|
||||
# BOOTSTRAP passes through as-is: the script gates on the input. The
|
||||
# export-the-event-name hack that lived here died with ceremony#215 —
|
||||
# the script keyed on GITHUB_EVENT_NAME, which #209 made true for
|
||||
# every machine wake, so "no" could never mean no.
|
||||
if [ "$BOOTSTRAP" = yes ]; then
|
||||
export GITHUB_EVENT_NAME=workflow_dispatch
|
||||
fi
|
||||
bash "$GITHUB_ACTION_PATH/labels-reconcile.sh"
|
||||
|
|
|
|||
|
|
@ -28,9 +28,8 @@ fi
|
|||
# stale approval must never promote unreviewed code to the human.
|
||||
#
|
||||
# DRY_RUN=1 narrates every mutation instead of performing it (how this script
|
||||
# is rehearsed against the live repo). A run with BOOTSTRAP=yes also
|
||||
# bootstraps the taxonomy (label create --force) — the operator's manual
|
||||
# dispatch defaults the input to yes; every machine wake passes no. The cron
|
||||
# is rehearsed against the live repo). A workflow_dispatch run also bootstraps
|
||||
# the taxonomy (label create --force) — that heal is dispatch-only; the cron
|
||||
# sweep tolerates a missing label rather than recreating it.
|
||||
#
|
||||
# The state machine below is pure (globals in, state out) and covered by
|
||||
|
|
@ -270,10 +269,9 @@ set_required_bots() { # the PR author is recused by construction
|
|||
# The state machine. Pure functions over these globals, set per PR:
|
||||
# DRAFT true|false
|
||||
# HEAD_SHA the PR's current head commit
|
||||
# BASE_SHA the PR's base branch head
|
||||
# MERGE_BASE_SHA the PR's merge base (the release-shape guard's ref)
|
||||
# BASE_SHA the PR's base branch head (the release-shape guard's ref)
|
||||
# REQUESTED newline-separated logins with a review currently requested
|
||||
# REVIEWS_JSON JSON array of submitted, gradeable reviews
|
||||
# REVIEWS_JSON JSON array of submitted (non-PENDING) reviews
|
||||
# MERGEABLE MERGEABLE | CONFLICTING | UNKNOWN (GitHub's own verdict)
|
||||
# CHECKS SUCCESS | FAILURE | PENDING | NONE (the check rollup)
|
||||
# LABELS newline-separated labels currently on the PR
|
||||
|
|
@ -283,6 +281,35 @@ set_required_bots() { # the PR author is recused by construction
|
|||
|
||||
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
|
||||
# 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
|
||||
|
|
@ -416,25 +443,18 @@ bot_verdict() { # $1 = login → MISSING | BLOCK | APPROVE | STALE | FEEDBACK
|
|||
if [ -z "$review" ]; then echo MISSING; return; fi
|
||||
state="$(jq -r '.state' <<<"$review")"
|
||||
commit="$(jq -r '.commit_id' <<<"$review")"
|
||||
# This case grades a submitted verdict. The ingestion allow-list answers the
|
||||
# separate question of whether a row is a submitted review at all (#235).
|
||||
case "$state" in
|
||||
CHANGES_REQUESTED | REQUEST_CHANGES)
|
||||
# blocks at ANY head — both forges' semantic: only a newer review from
|
||||
# the same reviewer clears it
|
||||
CHANGES_REQUESTED)
|
||||
# blocks at ANY head — GitHub's own semantic: only a newer review
|
||||
# from the same reviewer clears it
|
||||
echo BLOCK ;;
|
||||
APPROVED)
|
||||
if [ "$commit" = "$HEAD_SHA" ]; then echo APPROVE; else echo STALE; fi ;;
|
||||
COMMENTED | COMMENT)
|
||||
# A comment is a non-verdict. The machine does not read bodies — if the
|
||||
# comment is really an agreement, the AUTHOR says so by requesting the
|
||||
# human's review.
|
||||
echo FEEDBACK ;;
|
||||
*)
|
||||
# An unknown state is not evidence that a reviewer answered. Keep the
|
||||
# round open and make the next forge vocabulary surprise visible (#235).
|
||||
log "$1: unrecognised review state $state" >&2
|
||||
echo MISSING ;;
|
||||
# COMMENTED and anything else: a non-verdict. The machine does not
|
||||
# read bodies — if the comment is really an agreement, the AUTHOR
|
||||
# says so by requesting the human's review.
|
||||
echo FEEDBACK ;;
|
||||
esac
|
||||
}
|
||||
|
||||
|
|
@ -716,7 +736,7 @@ offsite|CFD3D7|Issue deliverable is a PR in another repository — claim clock p
|
|||
needs-ruling|D4C5F9|A human decision is pending — question, options and a recommendation are in the comment
|
||||
attention|D93F0B|A demand is parked here for the assignee: pick up the thread, ack by removing this label
|
||||
release|0E8A16|Release flow and version/packaging work
|
||||
needs-triage|FBCA04|Did not come through triage — owes normalization into work or a reasoned refusal
|
||||
needs-triage|FBCA04|Did not come through triage — owes normalization or conversion to a discussion
|
||||
ready|0E8A16|Triaged, spec complete, unblocked — a builder can start now and succeed
|
||||
claimed|1D76DB|A builder owns it: assignee set, draft PR expected shortly
|
||||
post-merge|006B75|Refs-linked PR merged; post-merge criteria remain and triage owns completion
|
||||
|
|
@ -724,7 +744,7 @@ epic|5319E7|Organizes other issues via a dependency-ordered task list — builde
|
|||
EOF
|
||||
}
|
||||
|
||||
retired_label_names() { # the GitHub defaults LABELS.md retires — a `question` belongs in a proposal, not a work issue
|
||||
retired_label_names() { # the GitHub defaults LABELS.md retires — a `question` is a discussion
|
||||
# One registry, kept beside core_label_rows() for the same reason those rows
|
||||
# are not in labels.conf: a rule that must hold in every governed repo
|
||||
# cannot live in a per-repo file. The six names match LABELS.md exactly.
|
||||
|
|
@ -919,8 +939,7 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
|
|||
# Drafts are exempt (the build phase is the builder's); the version
|
||||
# reads cost two API calls and only on PRs missing the label.
|
||||
if [ "$DRAFT" != true ] && ! has_label release; then
|
||||
release_shape_warning "$n" "$(tree_version "$HEAD_SHA")" \
|
||||
"$(tree_version "${MERGE_BASE_SHA:-$BASE_SHA}")"
|
||||
release_shape_warning "$n" "$(tree_version "$HEAD_SHA")" "$(tree_version "$BASE_SHA")"
|
||||
fi
|
||||
|
||||
# ---- merge-next: cleared, never set ----------------------------------
|
||||
|
|
@ -1002,15 +1021,8 @@ main() {
|
|||
load_config "$LABELS_CONF"
|
||||
NOW="$(date +%s)"
|
||||
|
||||
# The bootstrap keys on the INPUT, never the event name. It used to test
|
||||
# GITHUB_EVENT_NAME = workflow_dispatch — correct while an operator's manual
|
||||
# dispatch was the only dispatch there was, and wrong from #209 on, when the
|
||||
# trigger job made EVERY event-woken sweep a workflow_dispatch run: the
|
||||
# bootstrap=no input became inert by construction, and every board event
|
||||
# re-upserted the taxonomy (ceremony#215 — runs 459/523, then venue drill
|
||||
# runs 16/17, which bootstrapped on a delivered "no" and caught this).
|
||||
if [ "${BOOTSTRAP:-no}" = yes ]; then
|
||||
log "bootstrap=yes: bootstrapping the taxonomy"
|
||||
if [ "${GITHUB_EVENT_NAME:-}" = workflow_dispatch ]; then
|
||||
log "workflow_dispatch: bootstrapping the taxonomy"
|
||||
bootstrap_labels
|
||||
fi
|
||||
|
||||
|
|
@ -1033,21 +1045,14 @@ main() {
|
|||
set_required_bots "$AUTHOR"
|
||||
HEAD_SHA="$(jq -r '.head.sha' <<<"$PR_JSON")"
|
||||
BASE_SHA="$(jq -r '.base.sha' <<<"$PR_JSON")"
|
||||
MERGE_BASE_SHA="$(jq -r '.merge_base // empty' <<<"$PR_JSON")"
|
||||
LABELS="$(jq -r '.labels[].name' <<<"$PR_JSON")"
|
||||
# This allow-list answers whether a row is a submitted, gradeable review;
|
||||
# bot_verdict separately answers what that submitted verdict says (#235).
|
||||
# PENDING drafts and Forgejo REQUEST_REVIEW request rows are not reviews.
|
||||
# PENDING reviews are unsubmitted drafts in someone's browser — not a verdict
|
||||
REVIEWS_JSON="$(forge_api --paginate "repos/$REPO/pulls/$n/reviews" --jq '.[]' \
|
||||
| jq -s '[.[] | select(.state == "APPROVED"
|
||||
or .state == "CHANGES_REQUESTED"
|
||||
or .state == "REQUEST_CHANGES"
|
||||
or .state == "COMMENTED"
|
||||
or .state == "COMMENT")]')"
|
||||
# Read AFTER the reviews: review_filter_probe captures REVIEWS_JSON at
|
||||
# this boundary. The request set itself comes from the backend's exact
|
||||
# live representation rather than being derived from verdicts (#238).
|
||||
REQUESTED="$(forge_pr_review_requests "$n")"
|
||||
| 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
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -1,87 +1,76 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# The composite action's executable boundary (#218). Keeping the gather here
|
||||
# lets the offline contract test replace the forge and prove that failed and
|
||||
# partial reads cannot accidentally produce a green verdict.
|
||||
# The composite action's executable boundary (#218). Keeping the GraphQL
|
||||
# gather here lets the offline contract test replace `gh` and prove that
|
||||
# failed and partial reads cannot accidentally produce a green verdict.
|
||||
|
||||
# THIS ACTION IS STILL gh-ONLY, AND SAYS SO (#198 spec 4, #199 ports it).
|
||||
# Its entire gather is a single GraphQL query issued through `gh`, and
|
||||
# Forgejo serves no
|
||||
# GraphQL surface at all — `/api/graphql` 404s on this instance, and a real
|
||||
# forgejo-runner job arrives with GITHUB_GRAPHQL_URL set to the empty string
|
||||
# (lib/forge.sh's header). There is no endpoint to translate this to, so
|
||||
# unlike every other call site the merge touched it cannot be ported here;
|
||||
# it has to be re-expressed over REST, which is #199.
|
||||
#
|
||||
# THE GATHER IS REST, THROUGH THE SHIM (#199). It was a single GraphQL query
|
||||
# issued through `gh`, asking GitHub for `closingIssuesReferences` — its own
|
||||
# parse of the closing keywords. Forgejo serves no GraphQL surface at all:
|
||||
# `/api/graphql` 404s on this instance, and a real forgejo-runner job arrives
|
||||
# with GITHUB_GRAPHQL_URL set to the empty string (lib/forge.sh's header).
|
||||
# There was nothing to translate it to, so it is re-expressed — exactly as
|
||||
# #188 re-expressed its own two GraphQL sites — over two reads both backends
|
||||
# already serve, plus a parser this repo owns.
|
||||
#
|
||||
# WHAT THE GRAPH GAVE THAT TWO READS MUST REPLACE. This file used to call the
|
||||
# graph "authoritative because it includes both closing keywords and sidebar
|
||||
# links". Those two halves resolve differently here:
|
||||
#
|
||||
# sidebar links Forgejo has no such concept — an issue is closed by a
|
||||
# keyword, never by a manual link. Nothing is lost.
|
||||
# commit messages Forgejo DOES honour closing keywords in commit messages.
|
||||
# A body-only parse would miss a PR that closes an issue
|
||||
# from a commit subject and let through exactly the
|
||||
# contradiction this action exists to catch.
|
||||
#
|
||||
# Hence both reads, unioned. The commit half is not optional.
|
||||
# Until then the declaration is the honest move: CEREMONY_FORGE_CLIENT names
|
||||
# the client this file actually speaks, and forge_preflight refuses loudly on
|
||||
# a forge that cannot serve it — rather than reading nothing and reporting a
|
||||
# verdict. That is lib/forge.sh's own rule, "Never 'probably github'",
|
||||
# applied to the one action that has not caught up yet.
|
||||
# shellcheck source=lib/forge.sh
|
||||
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/forge.sh"
|
||||
# shellcheck source=lib/issue_references.sh
|
||||
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/issue_references.sh"
|
||||
# shellcheck source=lib/closes_references.sh
|
||||
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/closes_references.sh"
|
||||
|
||||
# No CEREMONY_FORGE_CLIENT declaration any more (#199 removes #198's): this
|
||||
# file speaks the shim, not a client. Fail CLOSED at the action boundary all
|
||||
# the same — "this action cannot produce a verdict" is the ACTION's contract
|
||||
# and stays a refusal, while "this check should not block the board" is the
|
||||
# CALLER's decision (@codex-reviewer-andresmgsl, #198).
|
||||
export CEREMONY_FORGE_CLIENT=gh
|
||||
# Fail CLOSED, at the action boundary. An earlier head here exited 0 with a
|
||||
# notice so the PR check would not be red; @codex-reviewer-andresmgsl was
|
||||
# right that this conflates two different questions. "This action cannot
|
||||
# produce a verdict" is the ACTION's contract and must stay a refusal; "this
|
||||
# check should not block the board" is the CALLER's decision, and it belongs
|
||||
# in .github/workflows/refs-guard.yml, which skips on a backend this action
|
||||
# cannot speak until #199 ports it.
|
||||
forge_preflight || exit 1
|
||||
forge_select "" || exit 1
|
||||
|
||||
REPO="${REPO:-${GITHUB_REPOSITORY:-}}"
|
||||
[ -n "$REPO" ] || {
|
||||
echo "refs-not-closing: set REPO or GITHUB_REPOSITORY to owner/name" >&2
|
||||
exit 1
|
||||
}
|
||||
owner="${GITHUB_REPOSITORY%%/*}"
|
||||
name="${GITHUB_REPOSITORY#*/}"
|
||||
[ -n "${PR_NUMBER:-}" ] || {
|
||||
echo "refs-not-closing: pull request number is unavailable" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# GraphQL variables are literal API syntax; the shell must not expand them.
|
||||
# shellcheck disable=SC2016
|
||||
facts="$(gh api graphql \
|
||||
-f query='query($owner: String!, $name: String!, $number: Int!) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
pullRequest(number: $number) {
|
||||
body
|
||||
closingIssuesReferences(first: 100) {
|
||||
nodes { number }
|
||||
pageInfo { hasNextPage }
|
||||
}
|
||||
}
|
||||
}
|
||||
}' \
|
||||
-F owner="$owner" -F name="$name" -F number="$PR_NUMBER")"
|
||||
|
||||
body_file="$(mktemp)"
|
||||
closing_file="$(mktemp)"
|
||||
trap 'rm -f "$body_file" "$closing_file"' EXIT
|
||||
|
||||
# A read that fails must never reach the parser: an empty body parses to an
|
||||
# empty closing set, which is a PASSING verdict this action never earned.
|
||||
# `set -e` covers the assignment, and the explicit checks below name which
|
||||
# read failed rather than leaving the operator to guess.
|
||||
if ! forge_api "repos/$REPO/pulls/$PR_NUMBER" --jq '.body // ""' >"$body_file"; then
|
||||
echo "refs-not-closing: could not read PR $PR_NUMBER's body — refusing a verdict" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --paginate carries the completeness proof: the forgejo backend walks pages
|
||||
# and then compares what it collected against the server's declared
|
||||
# x-total-count, refusing a short gather (#188, #4699). That IS this action's
|
||||
# `hasNextPage` refusal, relocated rather than reinvented — upstream refused
|
||||
# past 100 closing references rather than issue a partial verdict, and an
|
||||
# incomplete commit read is the same failure wearing REST's clothes.
|
||||
commits_file="$(mktemp)"
|
||||
trap 'rm -f "$body_file" "$closing_file" "$commits_file"' EXIT
|
||||
if ! forge_api --paginate "repos/$REPO/pulls/$PR_NUMBER/commits" \
|
||||
--jq '.[].commit.message' >"$commits_file"; then
|
||||
echo "refs-not-closing: could not read PR $PR_NUMBER's commits completely — refusing a partial verdict" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The union. closes_references is line-oriented, so concatenating the body and
|
||||
# every commit message and parsing once IS the union of parsing each — and it
|
||||
# keeps one parse to reason about instead of two that could drift.
|
||||
cat "$body_file" "$commits_file" | closes_references >"$closing_file"
|
||||
jq -er '
|
||||
.data.repository.pullRequest
|
||||
| if . == null then error("pull request was not returned") else .body // "" end
|
||||
' <<<"$facts" >"$body_file"
|
||||
jq -r '
|
||||
.data.repository.pullRequest.closingIssuesReferences
|
||||
| if . == null then
|
||||
error("closing issue references were not returned")
|
||||
elif .pageInfo.hasNextPage then
|
||||
error("more than 100 closing issue references; refusing a partial verdict")
|
||||
else
|
||||
.nodes[].number
|
||||
end
|
||||
' <<<"$facts" >"$closing_file"
|
||||
|
||||
mapfile -t closing_issues <"$closing_file"
|
||||
bash "$GITHUB_ACTION_PATH/refs-not-closing.sh" \
|
||||
|
|
|
|||
47
changelog.d/192.md
Normal file
47
changelog.d/192.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
### Fixed
|
||||
|
||||
- Label removal on Forgejo is a full-set `PUT`, not a per-label `DELETE`. The
|
||||
workflow token gets HTTP 500 on every `DELETE .../labels/{id}` on this
|
||||
instance, so the state machine could only ever ADD labels (#192).
|
||||
|
||||
- Every `state:*` transition that needs the previous state cleared, and every
|
||||
`blocker:*` that should lift, can now actually clear. They were inert (#192).
|
||||
|
||||
- A label edit that fails is fatal to `labels-reconcile`, matching
|
||||
`issueflow-reconcile`. One cause had two contradictory policies (#192).
|
||||
|
||||
- A failed write reaches the sweep's exit code: per-PR tolerance is kept for
|
||||
READS, but a sweep that could not write exits non-zero and its output carries
|
||||
no `reconciled.` token at all (#192).
|
||||
|
||||
- Every label mutation goes through one checked helper, so clearing
|
||||
`merge-next` or either `stale` edit fails the sweep too — not only the
|
||||
primary state edit (#192).
|
||||
|
||||
- A preserved label keeps the id the issue payload already carried, so
|
||||
preservation does not depend on a repository-wide list that has nothing to do
|
||||
with the issue (#192).
|
||||
|
||||
- A removal that changes nothing writes nothing, rather than replacing the set
|
||||
with itself and opening a race for no state change (#192).
|
||||
|
||||
- Every failure diagnostic on the forgejo backend names the verb as well as the
|
||||
path and the status. A read used to say `HTTP 500 from 'repos/…'`, which
|
||||
cannot be told from a failed write of the same path (#192).
|
||||
|
||||
- The diagnostic names what was attempted and that it did not happen, instead
|
||||
of blaming a missing label and telling the operator to bootstrap — a cause it
|
||||
had not established (#192, #101).
|
||||
|
||||
- An add-label the repo does not carry refuses before any write, so a
|
||||
replacement `PUT` can never drop a label nobody asked to remove (#192).
|
||||
|
||||
### Added
|
||||
|
||||
- `test/forge-backends.test.sh` pins the replacement contract: preserve
|
||||
unrelated labels across a combined add+remove, an absent removal as a
|
||||
successful no-op, the empty set as a full clear, and `forge_labels_add`
|
||||
still `POST`-only, per ceremony#128 (#192).
|
||||
|
||||
- `test/labels-reconcile.test.sh` drives a failing write through `main()` — the
|
||||
swallow was in the loop, where a fixture-level probe cannot reach (#192).
|
||||
16
changelog.d/195.md
Normal file
16
changelog.d/195.md
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
### Fixed
|
||||
|
||||
- `.github/labels.conf` names identities that exist on the forge this repo
|
||||
lives on. All five it named before were absent, so `panel=` could never
|
||||
converge a review round and `triage-actors=` made every issue a stray mint
|
||||
nobody could normalize (#195).
|
||||
|
||||
- `CONTRIBUTING.md`'s roster table matches the conf: three identities, the
|
||||
human row is `andres`, and the approval count states what panel-minus-author
|
||||
actually resolves to on this roster rather than a stale three (#195).
|
||||
|
||||
### Added
|
||||
|
||||
- `test/labels.test.sh` holds the conf's roster and `CONTRIBUTING.md`'s roster
|
||||
table to the same set, in both directions, so a roster edit that touches one
|
||||
file and not the other goes red instead of drifting quietly (#195).
|
||||
58
changelog.d/198.md
Normal file
58
changelog.d/198.md
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
### Added
|
||||
|
||||
- This tree carries upstream ceremony through `8c3a4d1` (upstream `0.6.0`):
|
||||
`lib/attention.sh`, `lib/read.sh`, `actions/refs-not-closing`, the guarded
|
||||
reads, and the ruling and window rules (#198).
|
||||
|
||||
- `test/no-runtime-gh.test.sh` — the forge-portability guard: no runtime `gh`
|
||||
outside `lib/forge-github.sh` unless the file declares
|
||||
`CEREMONY_FORGE_CLIENT=gh` (#198).
|
||||
|
||||
- `CHANGELOG.md` names the upstream commit this tree carries, so a drill
|
||||
record can say which `0.6.0` it exercised (#197, #198).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Eight runtime `gh` call sites arrived with the merge outside every conflict
|
||||
hunk, in functions upstream added to files this tree already owned. Seven
|
||||
are ported onto the shim; the eighth is named with its reason (#198).
|
||||
|
||||
- The open-PR gather reads `Refs`, not only closing keywords. Reading one side
|
||||
for closing links and the other for `Refs` is what released a live claim in
|
||||
crew#321, and this tree carried that shape (#198).
|
||||
|
||||
- The merged record gains `merged_at`, so `post_merge_pr_for_issue` answers
|
||||
the PR that merged last rather than the highest-numbered one. Without the
|
||||
column every sort key ties and the old order returns silently (#198).
|
||||
|
||||
- The open gather feeds `open_pr_issues` one record per physical body line. A
|
||||
whole decoded body as one record loses every declaration including the
|
||||
first, and reclaims a claim a live PR was holding (#198).
|
||||
|
||||
- The post-merge nudge links the issue on the forge in play rather than a
|
||||
hard-coded `github.com` (#198).
|
||||
|
||||
- `actions/refs-not-closing` reports and skips on a forge it cannot speak,
|
||||
naming the client and #199, instead of standing red on every PR. It reaches
|
||||
the forge zero times, so no verdict is produced either way (#198).
|
||||
|
||||
- `.github/workflows/labels.yml`'s sweep dispatch declares the client it
|
||||
speaks and decides the FORGE before the binary, so a Forgejo runner that
|
||||
happens to ship `gh` cannot dispatch against a forge that cannot serve it.
|
||||
#205 ports it to REST (#198).
|
||||
|
||||
- `actions/refs-not-closing` fails closed on a forge it cannot speak, and
|
||||
`.github/workflows/refs-guard.yml` carries the scheduling decision — the
|
||||
action never reports a success it did not earn (#198).
|
||||
|
||||
- `issue_payload_valid` refuses an empty payload on jq 1.6 as well as 1.7.
|
||||
`jq -e` exits 4 on empty input under 1.7 and **0** under 1.6, and this
|
||||
instance's runner carries 1.6 — so the guard #247 D3 added to refuse an
|
||||
unreadable read was accepting one here (#198).
|
||||
|
||||
- The post-merge nudge strips a trailing slash from the server URL, so a forge
|
||||
URL carrying one does not render `//owner/repo` (#198).
|
||||
|
||||
- `.github/scripts/release-path.sh` names `lib/forge.sh`: #191 put the shim on
|
||||
the release doors' executable path here, so a doors-unchanged record that
|
||||
omitted it was measuring the wrong set (#198).
|
||||
16
changelog.d/209.md
Normal file
16
changelog.d/209.md
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
### Fixed
|
||||
|
||||
- `blocker:unrequested` is judged on this forge again. The head-commit date was
|
||||
read from `repos/{o}/{r}/commits/{sha}`, which Forgejo answers **404** — so
|
||||
every sweep degraded and left the blocker unjudged (#209).
|
||||
|
||||
- `forge_commit_at` is a verb on both backends: GitHub serves a single commit at
|
||||
the bare path with the date nested, Forgejo at `git/commits/{sha}` with it
|
||||
under `.created`. The caller asks for one timestamp and knows neither shape
|
||||
(#209).
|
||||
|
||||
### Added
|
||||
|
||||
- `test/forge-backends.test.sh` pins each backend's path **and** field, because
|
||||
a stubbed `forge_api` cannot catch a wrong path — which is how this shipped
|
||||
and why a live sweep was what found it (#209).
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
### Fixed
|
||||
|
||||
- The upstream deferral record now names its floor and a dated measurement instead of a frozen `0.7.4` ceiling that expired before it was written (#269).
|
||||
|
||||
### Changed
|
||||
|
||||
- `docs/UPSTREAM-SYNC.md` now records unconditionally that the next sync campaign merges rather than ports, and so advances the ancestry baseline (#269).
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
### Fixed
|
||||
|
||||
- Forgejo release publishing now stages drafts until assets upload, rolls back failures, and recovers stranded same-tag drafts before retrying (#271).
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
### Fixed
|
||||
|
||||
- Merge-door release reruns resume after a matching stranded tag while completed or conflicting releases still refuse with precise diagnostics (#273).
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
### Fixed
|
||||
|
||||
- Compare release-shaped pull requests with their merge base so later base-branch releases do not create phantom version-change warnings (#275).
|
||||
|
|
@ -337,18 +337,12 @@ The labels automation is two reusable workflows since #209, adopted
|
|||
together at the same pin:
|
||||
|
||||
- **`labels.yml`** — the event-facing half, called on PR and issue events.
|
||||
Same-repository PRs keep two write-capable jobs: additive path-based
|
||||
`scope:*` labels, and a few-seconds `trigger` job that wakes the sweep by
|
||||
dispatching the consumer's sweep caller (a REST `POST` to the forge's own
|
||||
`${GITHUB_API_URL}/repos/{owner}/{repo}/actions/workflows/{file}/dispatches`,
|
||||
plain `GITHUB_TOKEN` — `workflow_dispatch` is
|
||||
Two jobs: additive path-based `scope:*` labels, and a few-seconds
|
||||
`trigger` job that wakes the sweep by dispatching the consumer's sweep
|
||||
caller (`gh workflow run`, plain `GITHUB_TOKEN` — `workflow_dispatch` is
|
||||
one of the two documented exemptions from the token's no-retrigger rule,
|
||||
so no PAT anywhere in the path and no loop: the sweep dispatches
|
||||
nothing). On this Forgejo a fork-headed `pull_request_target` token is
|
||||
read-only, so those two jobs do not run. A successful `fork_head` job names
|
||||
the disposition: the scheduled sweep later reconciles state, blockers, and
|
||||
handoff, while path-derived `scope:*` labels are not applied to fork heads.
|
||||
Apply those scope labels manually when an outside contribution needs them.
|
||||
nothing).
|
||||
- **`labels-sweep.yml`** — the reconcile sweep: PR state, blockers,
|
||||
handoff, stale status, the issue work queue, and the `needs-ruling`
|
||||
invariants on both surfaces — the bare-flag check and the 7-day
|
||||
|
|
@ -360,10 +354,8 @@ together at the same pin:
|
|||
as fake red CI that GitHub refuses to rerun (crew#250: `gh run rerun`
|
||||
and its `--failed`/`--job` forms all decline a queue-displaced run).
|
||||
Behind its own caller, a displaced sweep cancels on the
|
||||
Actions tab, attached to no PR. Same-repository PR checks show `scope` and
|
||||
the green `trigger`; fork-headed PRs show the green `fork_head` disposition
|
||||
and wait for scheduled state, blocker, and handoff reconciliation. The sweep
|
||||
does not supply their path-derived scope labels.
|
||||
Actions tab, attached to no PR; PR checks show `scope` and the green
|
||||
`trigger` only.
|
||||
|
||||
The consumer keeps its path mapping in `.github/labeler.yml` and its
|
||||
review panel plus scope taxonomy in `.github/labels.conf`.
|
||||
|
|
@ -390,14 +382,11 @@ The complete event-facing caller is:
|
|||
name: labels
|
||||
on:
|
||||
pull_request_target:
|
||||
# These carry the head/draft/review facts state:* derives from. Same-repo
|
||||
# heads take the instant write + sweep-dispatch path; this Forgejo gives
|
||||
# fork heads a read-only token, so state, blocker, and handoff reconciliation
|
||||
# waits for the scheduled sweep; path-derived scope labels require a manual
|
||||
# write when wanted.
|
||||
# labeled/unlabeled are the same-repo handoff wake; synchronize re-derives
|
||||
# on every push. review_requested/review_request_removed shipped in 0.3.0
|
||||
# (ceremony#137) and wake the same-repo sweep when the panel is asked.
|
||||
# Fork PRs; these carry the head/draft/review facts state:* derives from.
|
||||
# labeled/unlabeled are the handoff wake (state:needs-human confirmed here);
|
||||
# synchronize re-derives on every push. review_requested/review_request_removed
|
||||
# (shipped in 0.3.0, ceremony#137) wake the sweep that clears
|
||||
# blocker:unrequested when the panel is asked.
|
||||
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled, review_requested, review_request_removed]
|
||||
# Available at 0.2.0 and later (the first tag carrying ceremony#32); a
|
||||
# consumer pinned to 0.1.0 omits this block.
|
||||
|
|
@ -415,7 +404,7 @@ permissions:
|
|||
contents: read
|
||||
checks: read # mergeability/check-rollup read for PR state
|
||||
statuses: read # commit-status rollup read for PR state
|
||||
actions: write # the trigger job's dispatch of the sweep caller (#209, #205)
|
||||
actions: write # the trigger job's `gh workflow run` dispatch of the sweep caller (#209)
|
||||
issues: write
|
||||
pull-requests: write
|
||||
jobs:
|
||||
|
|
@ -434,24 +423,22 @@ name: labels-sweep
|
|||
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 a review verdict landing (the labels caller has no
|
||||
# pull_request_review trigger), blocker:ci-red
|
||||
# wake for four transition classes — a review verdict landing (no
|
||||
# pull_request_review trigger on the labels caller), blocker:ci-red
|
||||
# set/cleared, blocker:conflict when another PR merges under this one, and
|
||||
# time-based stale / 48h claim-reclaim, plus every state, blocker, and handoff
|
||||
# transition for a fork-headed PR on this Forgejo. The sweep never applies
|
||||
# path-derived scope labels. Issue events and same-repo PR events carry the
|
||||
# time-based stale / 48h claim-reclaim. The labels caller's events carry the
|
||||
# rest in seconds, one trigger-job dispatch away. Hourly trades ≤1h of
|
||||
# latency on the scheduled classes while cutting nominal
|
||||
# sweeps from four an hour to one at GitHub's 1-minute 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.
|
||||
# latency on those four while cutting nominal scheduled sweeps from four an
|
||||
# hour to one at GitHub's 1-minute 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. A bare dispatch (input default "yes") also
|
||||
# bootstraps the taxonomy on a fresh repo. The labels caller's trigger job
|
||||
# wakes this workflow with bootstrap=no on every issue and same-repo PR
|
||||
# event, so the declared input is part of the contract: a dispatch naming an
|
||||
# undeclared input is refused, and the trigger job goes loudly red.
|
||||
# wakes this workflow with bootstrap=no on every board event, so the
|
||||
# declared input is part of the contract: a dispatch naming an undeclared
|
||||
# input is refused, and the trigger job goes loudly red.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bootstrap:
|
||||
|
|
@ -469,15 +456,8 @@ permissions:
|
|||
jobs:
|
||||
sweep:
|
||||
uses: heavy-duty/ceremony/.github/workflows/labels-sweep.yml@<pinned-tag>
|
||||
with:
|
||||
# Pass the dispatch input through the workflow_call boundary
|
||||
# explicitly — a called workflow must not rely on reading the caller's
|
||||
# event inputs (ceremony#215 measured that failing). Empty (schedule)
|
||||
# maps to "no" explicitly, so a cron-woken sweep never re-upserts the
|
||||
# taxonomy.
|
||||
bootstrap: ${{ inputs.bootstrap || 'no' }}
|
||||
# If this repo's PR-facing labels caller is named anything but `labels`,
|
||||
# pass that name alongside: `pr_workflow_name: <name>`. The sweep exports
|
||||
# pass that name: `with: { pr_workflow_name: <name> }`. The sweep exports
|
||||
# it as SELF_WORKFLOW so the label machinery's own check entries (scope,
|
||||
# trigger) never count toward blocker:ci-red — a red trigger means "fix
|
||||
# the caller", which no PR edit can do (#208 reads it).
|
||||
|
|
@ -488,22 +468,17 @@ repositories allow check data to be read regardless, but a private consumer
|
|||
needs the explicit reads above; without them the failure appears as an empty
|
||||
`state:*` axis on the board rather than a red workflow run. The labels
|
||||
caller's `actions: write` is different — it is required everywhere, public
|
||||
repos included: the trigger job's dispatch is a write. Without it, issue and
|
||||
same-repository PR event runs go red at the trigger. Fork-headed PR runs do
|
||||
not enter that write path on this Forgejo; they remain green and depend on a
|
||||
healthy scheduled sweep for state, blocker, and handoff reconciliation. That
|
||||
sweep does not apply their path-derived scope labels.
|
||||
repos included: the trigger job's `gh workflow run` is a write, and without
|
||||
it every event run goes red at the trigger.
|
||||
|
||||
**The failure mode to know before bumping**: a consumer that bumps its pin
|
||||
to a #209-carrying tag without adding the sweep caller gets a loud red trigger
|
||||
on every issue and same-repository PR event (workflow-not-found; likewise on a
|
||||
sweep caller missing its `bootstrap` input, or a labels caller missing
|
||||
`actions: write`). Fork-headed PR runs deliberately skip that trigger and stay
|
||||
green, so their correctness is proven by the sweep caller's presence and its
|
||||
latest scheduled run, not by the PR check alone. Never read a green
|
||||
`fork_head` disposition as evidence that the scheduled sweep exists. Make the
|
||||
adoption one atomic PR — pin bump, sweep caller file, and `actions: write` line
|
||||
together.
|
||||
to a #209-carrying tag without adding the sweep caller keeps green-looking
|
||||
silence nowhere — the trigger job goes **red on every PR and issue event**
|
||||
(workflow-not-found; likewise on a sweep caller missing its `bootstrap`
|
||||
input, or a labels caller missing `actions: write`), and event-woken sweeps
|
||||
stop until the caller lands. That loudness is deliberate: never read
|
||||
silence, or a green `scope` alone, as health. Make the adoption one atomic
|
||||
PR — pin bump, sweep caller file, `actions: write` line together.
|
||||
|
||||
The `issues:` trigger is available at `0.2.0` and later — `0.2.0` is the
|
||||
first tag carrying ceremony#32. A consumer pinned to `0.1.0` omits it. Adopt
|
||||
|
|
@ -551,28 +526,23 @@ carrying the split:
|
|||
fires both callers into the one shared `labels-reconcile` group — so
|
||||
displacement goes **up**, and the fix reads as the bug getting worse.
|
||||
4. **`actions: write` on the labels caller** — consumers carry
|
||||
`actions: read` today (crew does); the trigger job's dispatch is a
|
||||
write. The sweep caller keeps `actions: read`.
|
||||
`actions: read` today (crew does); the trigger job's `gh workflow run`
|
||||
is a write. The sweep caller keeps `actions: read`.
|
||||
|
||||
Bump without the sweep caller and the trigger job goes red on every issue and
|
||||
same-repository PR event. Fork-headed PRs stay green, receive state, blocker,
|
||||
and handoff reconciliation only from the scheduled sweep, and never receive
|
||||
path-derived scope labels automatically; apply those manually when wanted.
|
||||
Never split these four edits across PRs.
|
||||
Bump without the sweep caller and the trigger job goes red on every PR
|
||||
and issue event — the loud failure mode above — so never split these
|
||||
four edits across PRs.
|
||||
|
||||
`pull_request_target` is intentional: same-repository PRs keep the base
|
||||
repository's write token without executing PR code. This Forgejo still gives
|
||||
fork-headed `_target` runs a read-only token, so they attempt no writes. The
|
||||
scheduled sweep later reconciles state, blockers, and handoff; it does not
|
||||
apply path-derived scope labels to those heads. The reusable workflows check
|
||||
`pull_request_target` is intentional: fork PRs need the base repository's
|
||||
token to write labels. The reusable workflows execute no PR code. They check
|
||||
out only the consumer's base branch and the pinned ceremony implementation.
|
||||
The #52 ruling invariants ride exactly these triggers — but the caller above
|
||||
is no longer the #18 shape, so adopting current triggers is a stub edit, not
|
||||
a bare pin bump. `review_requested` and `review_request_removed` on
|
||||
`pull_request_target:` shipped in `0.3.0` (ceremony#137). It clears
|
||||
`blocker:unrequested` the moment the panel is asked on a same-repository head;
|
||||
fork heads wait for the sweep cadence on this Forgejo. A consumer picks the
|
||||
events up by pinning `0.3.0` or later, never through mixed refs.
|
||||
`pull_request_target:` shipped in `0.3.0` (ceremony#137) — the wake that
|
||||
clears `blocker:unrequested` the moment the panel is asked, without which a
|
||||
quiet repo wears that flag until the backstop cron; a consumer picks them up
|
||||
by pinning `0.3.0` or later, never through mixed refs.
|
||||
|
||||
`.github/labels.conf` has one mandatory panel setting, one mandatory
|
||||
`triage-actors` setting, zero or more optional per-author panel rows, and
|
||||
|
|
@ -605,7 +575,7 @@ goes down. Add the row only at or after the pin bump that carries it, never
|
|||
before it and never through mixed refs.
|
||||
|
||||
Both actor lists are whitespace-separated. `triage-actors` names the identities
|
||||
allowed to mint work 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.
|
||||
|
||||
**Every account in `panel=` must be able to read the repository.** Requesting a
|
||||
|
|
@ -626,25 +596,10 @@ to bootstrap labels on a fresh repository. A bare dispatch is also the
|
|||
operator's general manual full-board sweep — the answer when the board
|
||||
looks wrong now rather than after the next scheduled cadence:
|
||||
|
||||
On GitHub, with the `gh` CLI:
|
||||
|
||||
```sh
|
||||
gh workflow run labels-sweep.yml -R <owner>/<repo>
|
||||
```
|
||||
|
||||
On any forge — including Forgejo, whose runners carry no `gh` — the same
|
||||
dispatch over REST, which is what the trigger job itself sends (#205):
|
||||
|
||||
```sh
|
||||
curl -sS -X POST \
|
||||
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"ref":"main","inputs":{"bootstrap":"yes"}}' \
|
||||
"$API/repos/<owner>/<repo>/actions/workflows/labels-sweep.yml/dispatches"
|
||||
```
|
||||
|
||||
`$API` is the forge's API root — `https://api.github.com` on GitHub,
|
||||
`<instance>/api/v1` on Forgejo — and success is `204` with an empty body.
|
||||
|
||||
Ceremony dogfoods the callers under the filenames `self-labels.yml` and
|
||||
`self-labels-sweep.yml`, so the equivalent command in this repository
|
||||
substitutes that filename. Scheduled and trigger-driven runs only
|
||||
|
|
@ -732,26 +687,6 @@ Bumping the pin re-syncs the mirror in the same PR —
|
|||
|
||||
## Version pinning
|
||||
|
||||
**Two ceremonies answer to the same version number.** `heavy-duty/ceremony`
|
||||
exists on GitHub and on `forgejo.heavyduty.builders`, and the forge tree tracks
|
||||
upstream's version numbers deliberately (ceremony#197 D2) — so `0.6.0` names a
|
||||
different tree on each, differing by the forge-compatibility delta. They are
|
||||
not forks that drifted: the forge tree carries upstream's content and adds to
|
||||
it (`docs/UPSTREAM-SYNC.md`).
|
||||
|
||||
What that means for a consumer:
|
||||
|
||||
- **Name the forge you pinned, not just the tag.** `heavy-duty/ceremony@0.6.0`
|
||||
is ambiguous on its own; the host in your `uses:` line is what disambiguates
|
||||
it, so do not describe your pin anywhere without it.
|
||||
- **A tag that exists upstream may not exist here yet.** The forge tree's
|
||||
`CEREMONY_SELF_REF` takes upstream's number as soon as the sync lands, which
|
||||
is *before* the release ceremony cuts that tag here. Do not bump a pin to a
|
||||
version whose tag you have not confirmed on the forge you consume from.
|
||||
- **The forge tree's `CHANGELOG.md` header names the upstream commit it
|
||||
carries**, and `.upstream-ref` records the same SHA. That is how you tell
|
||||
which `0.6.0` you are actually running.
|
||||
|
||||
- **Pin an exact ceremony release tag** — `@0.1.0`, never a branch and
|
||||
never a moving major pointer: the family pins things and reviews
|
||||
updates ([#1 D2](https://github.com/heavy-duty/ceremony/issues/1)).
|
||||
|
|
@ -851,16 +786,15 @@ published release.
|
|||
|
||||
## Adopting the agent team flow
|
||||
|
||||
The team flow (proposal → triage → work issue → build → review → human
|
||||
The team flow (discussion → triage → issue → build → review → human
|
||||
merge) is **optional per repo and separable from the release ceremony**:
|
||||
a repo can adopt release-only and take the team flow later — incubator's
|
||||
initial posture (#16). The model is this repo's own
|
||||
[CONTRIBUTING](../CONTRIBUTING.md) ("How the other repos use this");
|
||||
this is the checklist:
|
||||
|
||||
- [ ] **Open the intake door** — install `proposal.yml` and the automatic
|
||||
`needs-triage` flow. A repo whose forge provides Discussions may keep
|
||||
them as its intake door and point `config.yml`'s contact link there.
|
||||
- [ ] **Enable Discussions** — the triage door exists or the pipeline
|
||||
has no intake.
|
||||
- [ ] **Vendor the doctrine**: run `docs-sync --fix` (#19) to materialize
|
||||
`.ceremony/{AGENTS,TRIAGE,BUILDER,REVIEWER,LABELS}.md` —
|
||||
byte-identical to this repo at the pinned ref — plus the generated
|
||||
|
|
@ -894,8 +828,7 @@ this is the checklist:
|
|||
`workflow_dispatch` once ([above](#labels-automation)), or the hand
|
||||
commands in [LABELS.md](../LABELS.md).
|
||||
- [ ] **State the single-writer rule** in the repo's own docs: only
|
||||
triage mints work issues; anyone may file a proposal, which triage
|
||||
converts or refuses.
|
||||
triage mints issues; everyone else opens discussions.
|
||||
|
||||
### The pin-bump procedure
|
||||
|
||||
|
|
|
|||
|
|
@ -1,404 +0,0 @@
|
|||
# Runner probes
|
||||
|
||||
**Not a drill.** A drill rehearses the release doors on a disposable repo and
|
||||
ends. This is the opposite shape: one **standing** repo that exists so that
|
||||
runner-only facts can be measured on demand, and it is **never archived**.
|
||||
|
||||
`heavy-duty/ceremony-runner-probe` — private, standing, reset between probes.
|
||||
Ruled by the operator as option (A) of ceremony#202 (#5631).
|
||||
|
||||
## Why a standing repo, when drills are disposable
|
||||
|
||||
Some facts are only true inside Actions, under the token Actions injects, and
|
||||
no local harness or PAT can reproduce them. The worked example is ceremony#192:
|
||||
|
||||
```
|
||||
DELETE /issues/{n}/labels/{id} -> 500 under ${{ github.token }} in a workflow
|
||||
DELETE /issues/{n}/labels/{id} -> 204 under a maintainer PAT, same call
|
||||
```
|
||||
|
||||
A probe that runs anywhere else passes and proves nothing. Before this venue
|
||||
existed the answer was "un-archive a drill repo", which was requested three
|
||||
times in two days across two issues and never became anything — the three
|
||||
drill repos (`ceremony-drill-0.4.1`, `-0.4.1-final`, `-191`) are all archived,
|
||||
and each was minted for one probe and then wanted again.
|
||||
|
||||
## The disposal rule above does NOT apply here
|
||||
|
||||
The rehearsal section says the builder archives the scratch repo and the
|
||||
operator deletes it. **That rule is for drills.** Archiving this repo defeats
|
||||
its entire purpose, and it is the failure mode the three archived drill repos
|
||||
demonstrate — each was archived correctly, by the rule, and each then had to be
|
||||
un-archived or replaced.
|
||||
|
||||
So: never archive it, never delete it, and if you find it archived, un-archive
|
||||
it rather than minting a fourth one.
|
||||
|
||||
## Standing it up is the operator's step
|
||||
|
||||
Bot identities cannot create repositories in `heavy-duty`. Measured
|
||||
2026-08-05 with a fleet identity holding the `repo` scope:
|
||||
|
||||
```
|
||||
POST /api/v1/orgs/heavy-duty/repos -> 403 "not allowed to create repository in organization"
|
||||
POST /api/v1/user/repos -> 201 (personal namespace only)
|
||||
```
|
||||
|
||||
This is the same shape as the drill delete: a deliberate permission boundary,
|
||||
not a misconfiguration. Do not retry it, and do not work around it by putting
|
||||
the venue in a personal namespace — **not because a personal namespace is
|
||||
proven unable to reach the org's runner** (that was not measured; the probe
|
||||
repository above was deleted immediately, so nothing about runner or secret
|
||||
reach was established), but because @andres ruled an **org-owned standing
|
||||
venue** (#5631). A personally-owned repo is a different thing from the one that
|
||||
was decided on, and cannot satisfy #202's named acceptance target.
|
||||
|
||||
If runner or secret reach turns out to matter, measure it once the venue
|
||||
exists rather than assuming it here.
|
||||
|
||||
## Running a probe
|
||||
|
||||
1. Reset the repo to a clean state — the probe's own fixtures only, no
|
||||
leftovers from the last one. A probe that inherits state is a probe whose
|
||||
result you cannot attribute.
|
||||
2. **Arm it against the candidate** (below) — two layers, candidate code and
|
||||
armed workflow — if the probe is about ceremony's own machinery rather than
|
||||
about a bare API call.
|
||||
3. **Run it as an Actions job under `${{ github.token }}`.** This is the whole
|
||||
point of the venue and the one step that cannot be shortcut. A `curl` from a
|
||||
laptop with a PAT answers a different question — see the 204/500 split
|
||||
above — and a probe run that way is worse than no probe, because it produces
|
||||
a confident wrong answer.
|
||||
4. **The job writes its raw results into an issue in the PROBE repo** —
|
||||
`heavy-duty/ceremony-runner-probe` — not into ceremony. Logs age out;
|
||||
ceremony#192's run 701 survived only because the job wrote its findings
|
||||
into an issue it created.
|
||||
5. **A human then records the probe issue's URL and the Actions run number on
|
||||
the ceremony issue the probe serves.** That hop is deliberate and is the
|
||||
whole of the boundary: the probe workflow holds no credential and no code
|
||||
path that can write to `heavy-duty/ceremony`, so "the probe reports its
|
||||
findings" and "the probe cannot touch the live board" stay compatible
|
||||
rather than contradicting each other (@codex-reviewer-andresmgsl, #202
|
||||
review).
|
||||
|
||||
## Arming a candidate ref
|
||||
|
||||
A probe that exercises ceremony's own machinery needs the candidate tree
|
||||
reachable from a `uses:` line. This is the fork-ref shape `drills/README.md`
|
||||
step 2 points at, written out — and it has **two layers**, which is the part
|
||||
that is easy to get wrong and impossible to fix afterwards.
|
||||
|
||||
**Why two.** The candidate's own workflows contain
|
||||
`repository: heavy-duty/ceremony` beside `ref: ${{ env.CEREMONY_SELF_REF }}`,
|
||||
so they must be rewritten to point at the fork and at the candidate. But
|
||||
rewriting them **creates a new commit**, and a commit cannot contain its own
|
||||
object ID. A single-layer arming is therefore self-referential: pin the callers
|
||||
to the pre-rewrite SHA and they load the *unarmed* workflows; pin them to the
|
||||
post-rewrite one and you are asking a commit to embed itself
|
||||
(@codex-reviewer-andresmgsl, #202 review).
|
||||
|
||||
So:
|
||||
|
||||
| layer | what it is | what it carries |
|
||||
|---|---|---|
|
||||
| **candidate code SHA** | the immutable tree under test | `actions/`, `lib/` — untouched |
|
||||
| **armed workflow SHA** | a small child commit on top of it | workflows rewritten to the fork + `CEREMONY_SELF_REF` = the candidate code SHA |
|
||||
|
||||
### The procedure
|
||||
|
||||
1. **Push the candidate tree** to a fork under the identity that will run the
|
||||
probe — one branch, `<identity>/ceremony@probe-<issue>` — and record its
|
||||
SHA. Steps 1 and 2 advance the tip of that **same** branch; there are two
|
||||
commits, not two branches. That is
|
||||
the **candidate code SHA**. Never create a branch on
|
||||
`heavy-duty/ceremony` named like a tag: it shadows that tag for every
|
||||
consumer until somebody remembers to delete it.
|
||||
2. **Write the manifest FIRST, from the pre-arming tree, then commit the
|
||||
arming.** The manifest enumerates the carriers *that must change*, so it is
|
||||
generated before they do — running it afterwards would enumerate
|
||||
already-rewritten rows and lose the canonical internal-checkout ones
|
||||
entirely (@codex-reviewer-andresmgsl, #202 review). In that same
|
||||
fork branch rewrite, for **every** carrier the manifest below enumerates:
|
||||
ceremony's own internal `repository:` checkouts → `<identity>/ceremony`, and
|
||||
**every** `CEREMONY_SELF_REF` value → the **candidate code SHA** from step 1.
|
||||
There were three self-ref carriers on `main` at the time of writing and the
|
||||
count is not a constant — derive it, do not remember it
|
||||
(@glm-reviewer-andresmgsl, @codex-reviewer-andresmgsl, #202 review). The
|
||||
**consumer** checkouts (`${{ github.repository }}`) are left alone. Record
|
||||
the resulting SHA: that is the **armed workflow SHA**.
|
||||
3. **Pin the probe repo's callers by layer**, because they are not the same
|
||||
thing:
|
||||
- composite-action callers →
|
||||
`<identity>/ceremony/actions/<name>@<candidate-code-sha>`;
|
||||
- reusable-workflow callers →
|
||||
`<identity>/ceremony/.github/workflows/<file>@<armed-workflow-sha>`, since
|
||||
that is the only revision whose inner checkout is rewritten.
|
||||
4. **Gate the arming against a MANIFEST, byte for byte.** Every weaker shape
|
||||
has a hole, and each of these was found in a published draft of this file
|
||||
(@codex-reviewer-andresmgsl, #202 review):
|
||||
|
||||
| weaker check | what slips through |
|
||||
|---|---|
|
||||
| "the old literal is absent" | a carrier rewritten to the wrong fork, or to the *armed* SHA |
|
||||
| "every extracted value equals X" | a carrier that **vanished** — nothing to compare |
|
||||
| "each value is one of {fork, dynamic}" | a **role swap**: an internal checkout made dynamic, a consumer checkout pointed at the fork |
|
||||
| "the SHA suffix matches" | `wrong-owner/ceremony/actions/foo@<right-sha>` |
|
||||
| "known callers match" | an **unrecognised** caller, or none at all |
|
||||
| "the owner and the sha are right for the kind" | a **layer swap**: `…/actions/x@<armed>` labelled a workflow caller satisfies both |
|
||||
|
||||
So the arming step **writes a manifest** — one line per carrier, `path`,
|
||||
`kind`, `full expected value` — and the gate compares the tree's actual
|
||||
carriers against it as a set. A deletion, a role swap, a wrong fork, a wrong
|
||||
SHA, an extra carrier and a missing caller are then all the same kind of
|
||||
failure: the sets differ.
|
||||
|
||||
**Generate it while arming**, from the tree you are arming, so the manifest
|
||||
cannot drift from the repository:
|
||||
|
||||
```sh
|
||||
#!/usr/bin/env bash
|
||||
# write-manifest <candidate-checkout> <probe-checkout> <fork> <code-sha> <armed-sha>
|
||||
#
|
||||
# Run against the PRE-ARMING tree and the UNPINNED probe: this records what
|
||||
# each carrier must BECOME, so it has to see them before they change.
|
||||
#
|
||||
# `|| true` on every extraction, for the same reason the checker needs it:
|
||||
# git grep exits 1 on no-match and `set -e` would abort BEFORE the manifest
|
||||
# is written — silently, which is how the first version of this generator
|
||||
# produced no file and no diagnostic when a probe exercised only one layer
|
||||
# (@codex-reviewer-andresmgsl). A probe need not use both.
|
||||
set -euo pipefail
|
||||
candidate="$1"; probe="$2"; fork="$3"; code_sha="$4"; armed_sha="$5"
|
||||
# shellcheck disable=SC2016 # `${{ github.repository }}` is literal YAML, not a shell expansion
|
||||
{
|
||||
git -C "$candidate" grep -n 'CEREMONY_SELF_REF:' -- .github/workflows \
|
||||
| cut -d: -f1,2 | sed "s|$|\tself_ref\t$code_sha|" || true
|
||||
git -C "$candidate" grep -n 'repository: heavy-duty/ceremony' -- .github/workflows \
|
||||
| cut -d: -f1,2 | sed "s|$|\tinternal_repo\t$fork|" || true
|
||||
git -C "$candidate" grep -n 'repository: ${{ github.repository }}' -- .github/workflows \
|
||||
| cut -d: -f1,2 | sed 's|$|\tconsumer_repo\t${{ github.repository }}|' || true
|
||||
# Callers record the COMPLETE expected coordinate, not just the sha: the
|
||||
# path is as rewritable as the owner, and a manifest that stores only the
|
||||
# suffix cannot notice `…/actions/wrong-one@<right-sha>`.
|
||||
git -C "$probe" grep -nE 'uses:[[:space:]]*[^[:space:]]*/ceremony/\.github/workflows/' -- .github \
|
||||
| sed -E "s|^([^:]+):([0-9]+):.*/ceremony/(\.github/workflows/[^@[:space:]]+)@.*|\\1:\\2\\tworkflow_caller\\t$fork/\\3@$armed_sha|" || true
|
||||
git -C "$probe" grep -nE 'uses:[[:space:]]*[^[:space:]]*/ceremony/actions/' -- .github \
|
||||
| sed -E "s|^([^:]+):([0-9]+):.*/ceremony/(actions/[^@[:space:]]+)@.*|\\1:\\2\\taction_caller\\t$fork/\\3@$code_sha|" || true
|
||||
} | sort >manifest.tsv
|
||||
|
||||
# Zero ceremony callers is a refusal by name; one layer only is fine.
|
||||
callers="$(grep -cE '(workflow|action)_caller' manifest.tsv || true)"
|
||||
[ "$callers" -gt 0 ] || { echo "manifest: no ceremony callers found in $probe" >&2; exit 1; }
|
||||
```
|
||||
|
||||
Then arm — rewrite and commit — and check the result against it:
|
||||
|
||||
```sh
|
||||
#!/usr/bin/env bash
|
||||
# check-arming <armed-checkout> <probe-checkout> <fork> <code-sha> <armed-sha> <manifest.tsv>
|
||||
set -euo pipefail
|
||||
armed="$1"; probe="$2"; fork="$3"; code_sha="$4"; armed_sha="$5"; manifest="$6"
|
||||
fail() { echo "arming incomplete: $*" >&2; exit 1; }
|
||||
|
||||
# `|| true` on every extraction: git grep exits 1 when nothing matches, and
|
||||
# under `set -e` that would kill this script BEFORE the comparison — so a
|
||||
# carrier class that vanished ENTIRELY produced silence instead of a
|
||||
# refusal. Silence is the worst of the three outcomes; the comparison below
|
||||
# is what must report it.
|
||||
[ "$(grep -cE '(workflow|action)_caller' "$manifest" || true)" -gt 0 ] \
|
||||
|| fail "manifest names no ceremony callers — it cannot prove an arming"
|
||||
|
||||
# THE MANIFEST ITSELF IS CHECKED AGAINST THE TARGET, not trusted. Comparing
|
||||
# only tree-vs-manifest proves consistency, and a manifest generated with the
|
||||
# armed SHA where the candidate SHA belonged — or with the wrong fork —
|
||||
# describes a WRONG arming perfectly. The tree would then match it and the
|
||||
# gate would pass (@codex-reviewer-andresmgsl, #202 review).
|
||||
# shellcheck disable=SC2016 # `${{ github.repository }}` below is literal YAML
|
||||
while IFS=$'\t' read -r loc kind want; do
|
||||
case "$kind" in
|
||||
self_ref) [ "$want" = "$code_sha" ] || fail "manifest $loc: self_ref should be the CANDIDATE sha" ;;
|
||||
internal_repo) [ "$want" = "$fork" ] || fail "manifest $loc: internal repo should be $fork" ;;
|
||||
consumer_repo) [ "$want" = '${{ github.repository }}' ] \
|
||||
|| fail "manifest $loc: consumer checkout must stay dynamic" ;;
|
||||
# THE KIND MUST BIND TO THE PATH CLASS, not only to the owner and the
|
||||
# sha. The path class is what SAYS which layer a caller is, so checking
|
||||
# the sha against the kind while letting the kind float free accepts a
|
||||
# consistent layer swap — `…/actions/x@<armed>` declared workflow_caller
|
||||
# passes every owner and sha test (@codex-reviewer-andresmgsl, #202
|
||||
# review). Decompose once, then let the kind fix BOTH coordinates.
|
||||
workflow_caller|action_caller)
|
||||
owner="${want%%/ceremony/*}"; rest="${want#*/ceremony/}"
|
||||
path="${rest%@*}"; sha="${want##*@}"
|
||||
[ "$owner/ceremony" = "$fork" ] \
|
||||
|| fail "manifest $loc: caller owner should be $fork"
|
||||
case "$kind" in
|
||||
workflow_caller)
|
||||
case "$path" in .github/workflows/?*) : ;;
|
||||
*) fail "manifest $loc: workflow_caller must resolve at .github/workflows/<file>, not '$path'" ;;
|
||||
esac
|
||||
[ "$sha" = "$armed_sha" ] || fail "manifest $loc: workflow caller should be the ARMED sha" ;;
|
||||
action_caller)
|
||||
case "$path" in actions/?*) : ;;
|
||||
*) fail "manifest $loc: action_caller must resolve at actions/<name>, not '$path'" ;;
|
||||
esac
|
||||
[ "$sha" = "$code_sha" ] || fail "manifest $loc: action caller should be the CANDIDATE sha" ;;
|
||||
esac ;;
|
||||
*) fail "manifest $loc: unknown kind '$kind'" ;;
|
||||
esac
|
||||
done <"$manifest"
|
||||
|
||||
actual="$(mktemp)"; trap 'rm -f "$actual"' EXIT
|
||||
{
|
||||
git -C "$armed" grep -nP '(?<=CEREMONY_SELF_REF: ")[^"]+' -- .github/workflows \
|
||||
| sed -E 's/^([^:]+):([0-9]+):.*CEREMONY_SELF_REF: "([^"]*)".*/\1:\2\tself_ref\t\3/' || true
|
||||
git -C "$armed" grep -nE 'repository: .+' -- .github/workflows \
|
||||
| sed -E 's|^([^:]+):([0-9]+):[[:space:]]*repository:[[:space:]]*(.*)$|\1:\2\t__repo__\t\3|' || true
|
||||
# Only CEREMONY callers, matching the generator's domain exactly — a
|
||||
# third-party `actions/checkout` is not this gate's business, and
|
||||
# extracting it here while the generator ignores it made every probe fail
|
||||
# as an "unrecognised carrier" (@codex-reviewer-andresmgsl). A wrong OWNER
|
||||
# is still caught: `wrong-owner/ceremony/...` matches this pattern.
|
||||
git -C "$probe" grep -nE 'uses:[[:space:]]*[^[:space:]]*/ceremony/' -- .github \
|
||||
| sed -E 's|^([^:]+):([0-9]+):[[:space:]]*-?[[:space:]]*uses:[[:space:]]*(.*)$|\1:\2\t__uses__\t\3|' || true
|
||||
} | sort >"$actual"
|
||||
|
||||
# every manifest line must be present with its EXACT expected value, and the
|
||||
# kinds must match — a role swap changes the kind, not just the value.
|
||||
while IFS=$'\t' read -r loc kind want; do
|
||||
case "$kind" in
|
||||
self_ref) have="$(awk -F'\t' -v l="$loc" '$1==l && $2=="self_ref"{print $3}' "$actual")" ;;
|
||||
internal_repo|consumer_repo)
|
||||
have="$(awk -F'\t' -v l="$loc" '$1==l && $2=="__repo__"{print $3}' "$actual")" ;;
|
||||
workflow_caller|action_caller)
|
||||
have="$(awk -F'\t' -v l="$loc" '$1==l && $2=="__uses__"{print $3}' "$actual")" ;;
|
||||
esac
|
||||
[ -n "$have" ] || fail "carrier vanished: $loc ($kind)"
|
||||
# ONE comparison for every kind: the manifest already carries the complete
|
||||
# expected value, so owner, path AND sha are checked at once. Checking the
|
||||
# owner and the sha separately let `…/actions/wrong-one@<right-sha>`
|
||||
# through (@codex-reviewer-andresmgsl).
|
||||
[ "$have" = "$want" ] || fail "$loc ($kind): expected '$want', found '$have'"
|
||||
done <"$manifest"
|
||||
|
||||
# and nothing UNRECOGNISED: every uses:/repository: in the trees must appear
|
||||
# in the manifest, so an added carrier is a failure rather than a silence.
|
||||
while IFS=$'\t' read -r loc _ _; do
|
||||
grep -qF "$loc"$'\t' "$manifest" || fail "carrier not in manifest: $loc"
|
||||
done <"$actual"
|
||||
```
|
||||
|
||||
**Why a manifest rather than a longer list of assertions.** The carrier set
|
||||
is a property of the tree at the moment of arming; any list written into
|
||||
this document is stale the next time a workflow is added. The manifest is
|
||||
generated from the tree, recorded in the result issue (step 6), and is the
|
||||
thing a later reader compares against — so "what was armed" is evidence
|
||||
rather than recollection.
|
||||
|
||||
5. **Invoke the probe by the event it is about**, and record which: a
|
||||
`workflow_dispatch`, or the real board event under test. A probe that fires
|
||||
a different event than the one under test proves something else.
|
||||
6. **The result issue records all of it**: the fork repository, the candidate
|
||||
code SHA, the armed workflow SHA, every rewritten carrier, the workflow
|
||||
invoked and the run number. Those are what make the result reproducible;
|
||||
without the two SHAs distinguished, a later reader cannot tell which tree
|
||||
answered.
|
||||
7. **Reset removes the candidate-specific EXECUTABLE state**: the caller stubs,
|
||||
the probe workflow, and the fork's probe branch — whose tip carries both the
|
||||
candidate commit and the armed commit on top of it — so the next probe
|
||||
cannot inherit a pin it did not choose. **Result issues are never deleted.**
|
||||
They may be closed or relabelled; deleting them would recreate the
|
||||
expiring-log problem this venue exists to avoid.
|
||||
|
||||
## Who may reset it
|
||||
|
||||
**Operator-owned until ruled otherwise.** #202's task 4 asks who may reset the
|
||||
venue, and creating the repo is the operator's step, so the access policy is
|
||||
his to set at the same time (@codex-reviewer-andresmgsl, #202 review).
|
||||
|
||||
Two levels, deliberately separated:
|
||||
|
||||
- **content reset** — removing probe branches, workflows and fixtures; the
|
||||
ordinary between-probes operation. It does **not** include deleting result
|
||||
issues, which are the evidence and are immutable once written
|
||||
(@codex-reviewer-andresmgsl, #202 review);
|
||||
- **archive / delete / admin** — which is where the drill rule's damage came
|
||||
from, and which no bot identity should hold here.
|
||||
|
||||
If fleet identities are given push access for content reset, this section
|
||||
records that; until then, ask.
|
||||
|
||||
## What must never happen here
|
||||
|
||||
No probe touches `heavy-duty/ceremony`'s board. No labels, no comments, no
|
||||
runs attributable to a probe. The venue exists so that the live board does not
|
||||
have to be the test fixture.
|
||||
|
||||
## The probes this venue owes — and the records of those delivered
|
||||
|
||||
Delivered probes stay listed with their record: the venue's value is that a
|
||||
claim like "the asymmetry reproduces" carries a URL a reader can open, not a
|
||||
memory.
|
||||
|
||||
- **ceremony#192** — DELIVERED, first drill (2026-08-05). Under
|
||||
`${{ github.token }}` in the venue:
|
||||
`DELETE /issues/{n}/labels/{id}` → **500**, the label still on the issue
|
||||
afterward — the failure observable in the set, not merely a status — then
|
||||
`PUT` full-set clear → **200**, set actually empty. Record:
|
||||
[probe issue #1](https://forgejo.heavyduty.builders/heavy-duty/ceremony-runner-probe/issues/1)
|
||||
(run 1) and
|
||||
[probe issue #2](https://forgejo.heavyduty.builders/heavy-duty/ceremony-runner-probe/issues/2)
|
||||
(run 4 — the clean independent repeat after the redaction incident below).
|
||||
- **ceremony#205** — DELIVERED with a correction to the line above's
|
||||
premise. The dispatch route answers **204** to a valid body carrying the
|
||||
bare resolvable ref `main` — under the workflow token
|
||||
([ceremony#205 comment #6262](https://forgejo.heavyduty.builders/heavy-duty/ceremony/issues/205#issuecomment-6262),
|
||||
run 504, and again as
|
||||
[probe issue #4](https://forgejo.heavyduty.builders/heavy-duty/ceremony-runner-probe/issues/4),
|
||||
run 6) and under a PAT
|
||||
([probe issue #5](https://forgejo.heavyduty.builders/heavy-duty/ceremony-runner-probe/issues/5),
|
||||
run 7, and ceremony run 459). The earlier opaque `500` came from a bare
|
||||
UNRESOLVABLE ref or an unknown/unparseable workflow — the diagnostic !213
|
||||
ships now names this; a fully-qualified bad ref gets a clean 404 instead.
|
||||
The `GET /actions/workflows` listing still 404s. Claims here are limited to
|
||||
what those runs measured.
|
||||
- **ceremony#215** — the discriminator drill: REST-body `inputs` DO reach a
|
||||
top-level dispatched workflow, both contexts
|
||||
(`inputs.*` and `github.event.inputs.*`), both identities. What loses the
|
||||
value is the `workflow_call` boundary — a called workflow does not see the
|
||||
caller's `event.inputs` on this instance. Records:
|
||||
[probe issues #4 and #5](https://forgejo.heavyduty.builders/heavy-duty/ceremony-runner-probe/issues/4)
|
||||
(runs 6 and 7).
|
||||
- **ceremony#217** — DELIVERED (2026-08-09). The standing venue used the
|
||||
published consumer callers without rewrites or bypasses:
|
||||
`labels.yml@0.6.1` and `labels-sweep.yml@0.6.1`. Opening fixture issue #7
|
||||
drove event caller
|
||||
[run 23](https://forgejo.heavyduty.builders/heavy-duty/ceremony-runner-probe/actions/runs/23)
|
||||
and its dispatched sweep
|
||||
[run 24](https://forgejo.heavyduty.builders/heavy-duty/ceremony-runner-probe/actions/runs/24);
|
||||
the sweep wrote `needs-triage` on that issue under the workflow token. A
|
||||
separate manual sweep was green in
|
||||
[run 25](https://forgejo.heavyduty.builders/heavy-duty/ceremony-runner-probe/actions/runs/25).
|
||||
Observer
|
||||
[run 30](https://forgejo.heavyduty.builders/heavy-duty/ceremony-runner-probe/actions/runs/30)
|
||||
recorded the released tag SHA `338cf5f754f0e87feefe9231b47910fb236ab4d0`,
|
||||
both caller runs and the resulting label set in
|
||||
[probe issue #10](https://forgejo.heavyduty.builders/heavy-duty/ceremony-runner-probe/issues/10).
|
||||
The event caller loaded its reusable workflow at `@0.6.1`; both sweep runs
|
||||
completed the reusable workflow's internal `CEREMONY_SELF_REF` checkout at
|
||||
the real `0.6.1` tag. There was no candidate SHA rewrite and no dogfood
|
||||
bypass.
|
||||
|
||||
Two venue lessons from the first drills, kept where the next probe author will
|
||||
look:
|
||||
|
||||
- **Rule 4 is load-bearing on this instance**: the probe repo's web log route
|
||||
404s for non-admin reads, and a log-only observation (run 2) was lost where
|
||||
issue-written ones were not.
|
||||
- **Report content sent to the forge must never contain a credential
|
||||
expression OR a credential value** — moving a secret from `${{ … }}` syntax
|
||||
into a shell variable does not make it safe to emit. Name identities in
|
||||
literal prose ("the workflow token"), allowlist the fields a report emits,
|
||||
and treat anything else as unsendable. The incident this teaches from: an
|
||||
escaped `${{ github.token }}` in a comment template was expanded by the
|
||||
runner into the recorded text (the run's ephemeral token); it was redacted
|
||||
in place and the drill repeated clean (probe issue #2, run 4).
|
||||
|
|
@ -1,308 +0,0 @@
|
|||
# Syncing this tree with upstream ceremony
|
||||
|
||||
`heavy-duty/ceremony` exists on two forges and they diverge in opposite
|
||||
directions on purpose:
|
||||
|
||||
- **upstream** — `github.com/heavy-duty/ceremony`, where new ceremony features
|
||||
are written. **Read-only from here.** No issue, PR, comment, review or
|
||||
release is ever created there.
|
||||
- **this tree** — `forgejo.heavyduty.builders/heavy-duty/ceremony`, which
|
||||
carries upstream's content plus the forge-compatibility delta and never
|
||||
writes back.
|
||||
|
||||
This document is the procedure for bringing upstream's work across. It is
|
||||
written to be followed without prior context; where it states a resolution, the
|
||||
resolution is standing and does not get re-decided each sync.
|
||||
|
||||
Worked example throughout: the `0.6.0` sync (#197, #198), which merged upstream
|
||||
`8c3a4d1` onto `dad99dd` and took four heads to get green.
|
||||
|
||||
## 0.6.2 port record
|
||||
|
||||
On 2026-08-24 this tree released the content carried by upstream
|
||||
`upstream-0.6.1` through `upstream-0.6.3`. The content baseline is
|
||||
`upstream-0.6.3`; the changes were ported onto the Forgejo-adapted tree by
|
||||
#229 and #230 rather than merged from upstream.
|
||||
|
||||
The ancestry baseline therefore remains the full `.upstream-ref` value
|
||||
`8c3a4d1dee2bdb5ac06a632a285bb65ab2615214` (upstream `0.6.0`, merged by
|
||||
#198). No upstream ancestry moved in this release. Tags are disambiguated as
|
||||
`upstream-0.6.x` for upstream's line and bare `0.6.x` for releases cut on this
|
||||
forge.
|
||||
|
||||
Upstream's drill-record fixes and the upstream `0.7.x` line remain deferred to
|
||||
the next sync campaign. That line has no ceiling this file can hold still —
|
||||
upstream tags roughly one release a week — so what is recorded here is its
|
||||
floor and the date it was last measured: `0.7.0` onward, `0.7.6` newest as
|
||||
measured 2026-08-27. Run `git ls-remote --tags` for today's ceiling. The
|
||||
sentence this replaced froze `0.7.4` and was already a release behind on the
|
||||
day it was written.
|
||||
|
||||
## The next campaign merges
|
||||
|
||||
The next sync campaign is a merge, not a port, and it therefore advances
|
||||
`.upstream-ref` to the commit it merges. The operator ruled this on #268 on
|
||||
2026-08-27, when release-init found no forge-local work to fill an `0.6.4` and
|
||||
opened no window.
|
||||
|
||||
The reason is the ancestry baseline. `.upstream-ref` has been pinned at
|
||||
`8c3a4d1` — upstream `0.6.0`, merged by #198 — because 0.6.2 came across as a
|
||||
port and a port moves no ancestry. Another port would leave it pinned and
|
||||
guarantee a third, larger campaign against a delta that is still growing.
|
||||
|
||||
Size it before starting. With upstream's tags fetched read-only per step 1,
|
||||
`git diff --shortstat 8c3a4d1dee2bdb5ac06a632a285bb65ab2615214 0.7.6`
|
||||
was 92 files and +25,121/-971 over 488 commits when measured 2026-08-27, and
|
||||
the three action scripts the forge delta is heaviest in all move:
|
||||
`runner-isolated` +1619, `issueflow-reconcile` +913, `labels-reconcile` +889.
|
||||
Step 4 of the procedure below — the audit of what merged *without* conflicting
|
||||
— is where that size is actually paid for.
|
||||
|
||||
## The standing resolutions
|
||||
|
||||
These recur every sync. They are decided; re-deciding them is the cost this
|
||||
list exists to remove.
|
||||
|
||||
| what | which side wins | decided by |
|
||||
|---|---|---|
|
||||
| `VERSION` | **upstream** — this tree tracks upstream's version numbers | #197 D2 |
|
||||
| `CEREMONY_SELF_REF` (both carriers) | **upstream** | #197 D2 |
|
||||
| `.github/labels.conf` | **this tree** — upstream's roster names identities that do not exist here | #195 |
|
||||
| `drills/*.md` | **this tree** — a drill record is a record of a run *here* | #198 |
|
||||
| `CHANGELOG.md` | **both**, upstream's new sections above this tree's | #198 |
|
||||
| a section for a version **both** trees released | **this tree's** — ours is the published body of the tag that exists here | #198 |
|
||||
|
||||
Two consequences worth stating plainly:
|
||||
|
||||
- **Two trees answer to the same version number**, differing by the forge
|
||||
delta. That is accepted, not accidental (#197 D2). The mitigation is
|
||||
provenance in prose: `CHANGELOG.md`'s header names the upstream commit this
|
||||
tree carries, and `.upstream-ref` records it in machine-readable form.
|
||||
- **A tag that exists upstream may not exist here.** `CEREMONY_SELF_REF` takes
|
||||
upstream's number, and both workflows carry the self-consumption bypass
|
||||
(`if: github.repository != 'heavy-duty/ceremony'`), so ceremony's own CI is
|
||||
unaffected. But **no consumer may bump its pin to that number until the
|
||||
release ceremony cuts the tag here.**
|
||||
|
||||
## The procedure
|
||||
|
||||
### 1. Add the upstream remote, read-only, and confirm the merge base
|
||||
|
||||
```sh
|
||||
git remote add gh https://github.com/heavy-duty/ceremony.git # if absent
|
||||
git fetch gh
|
||||
upstream_sha="$(git rev-parse gh/main)" # capture ONCE, in full
|
||||
git merge-base main "$upstream_sha"
|
||||
```
|
||||
|
||||
**Capture the full SHA immediately and use that value everywhere after** — the
|
||||
merge, the provenance, the `.upstream-ref` write. `gh/main` is a moving
|
||||
pointer: while this sync was being reviewed upstream advanced from `8c3a4d1`
|
||||
to `08e2912`, and re-reading `gh/main` at recording time would have written a
|
||||
commit this tree does not contain. The recorded ref is *what was merged*, never
|
||||
*what upstream is now*.
|
||||
|
||||
**Confirm the merge base against `.upstream-ref` before merging anything.** If
|
||||
it is not what the last sync recorded, something moved — stop and re-measure
|
||||
rather than proceeding. A sync that starts from an unexpected base is a sync
|
||||
whose conflict count means nothing.
|
||||
|
||||
### 2. Merge, never rebase
|
||||
|
||||
```sh
|
||||
git merge "$upstream_sha"
|
||||
```
|
||||
|
||||
One merge commit, conflicts resolved once (#197 D1). Rebasing the forge-only
|
||||
commits onto upstream would rewrite every SHA, re-resolve the same conflicts
|
||||
once per commit, and break any pin to them. A fresh re-import would discard the
|
||||
provenance in this repo's issue comments, which is where its documentation
|
||||
actually lives.
|
||||
|
||||
### 3. Resolve the conflicts
|
||||
|
||||
Apply the standing resolutions above. What is left is genuinely new and needs
|
||||
judgement — in the `0.6.0` sync that was 5 hunks of 18.
|
||||
|
||||
### 4. Audit what the merge brought in that did NOT conflict
|
||||
|
||||
**This is the step the `0.6.0` sync nearly shipped without, and the one this
|
||||
document exists for.**
|
||||
|
||||
`git merge` takes upstream's side wherever only upstream moved a region. So a
|
||||
function upstream *added* to a file this tree already owns arrives with **no
|
||||
conflict and no question asked**. Reviewing the conflict hunks cannot find
|
||||
them: four reviewers read the same diff and each found a different subset.
|
||||
|
||||
In the `0.6.0` sync that was **eight** runtime `gh` call sites, in three files
|
||||
and two file types, every one of which #188 had previously removed.
|
||||
|
||||
So, after resolving:
|
||||
|
||||
```sh
|
||||
bash test/no-runtime-gh.test.sh
|
||||
```
|
||||
|
||||
That guard is the mechanical form of #197's acceptance bar — no runtime `gh`
|
||||
outside `lib/forge-github.sh` unless the file declares
|
||||
`CEREMONY_FORGE_CLIENT=gh` **and** refuses when it cannot run. Do not satisfy
|
||||
it by adding an exemption; a declaration without a refusal is a permission slip
|
||||
for `gh: command not found`.
|
||||
|
||||
Then check the **variables** the same way, because the same mechanic applies to
|
||||
state: if a conflicted region assigns something that auto-merged code consumes,
|
||||
resolving it "to this tree's side" silently removes the producer. Every one of
|
||||
those consumers degrades to empty rather than erroring, so nothing goes red.
|
||||
The `0.6.0` sync had three such seams. Enumerate what each resolved region
|
||||
assigns, and confirm each still has a producer.
|
||||
|
||||
### 5. Port or declare every new `gh` call site
|
||||
|
||||
Where a `forge_*` verb exists, port it in the merge itself. Where none does,
|
||||
the file **declares** `CEREMONY_FORGE_CLIENT=gh` and refuses loudly, and the
|
||||
port gets its own issue (#199 for `refs-not-closing`, #205 for the sweep
|
||||
dispatch). "Never 'probably github'" applies to the sync as much as to a
|
||||
runtime probe.
|
||||
|
||||
A workflow cannot call `forge_preflight`, so it declares in its `env:` block
|
||||
and refuses inline — deciding the **forge** first and the **binary** second. A
|
||||
guard that only asks whether `gh` is installed passes the moment a runner image
|
||||
ships it.
|
||||
|
||||
### 6. Record the provenance
|
||||
|
||||
- `CHANGELOG.md`'s header: which upstream commit this tree now carries.
|
||||
- `.upstream-ref`: the same **full 40-character** SHA, machine-readable,
|
||||
checked by `test/upstream-delta.test.sh` — which refuses when the object is
|
||||
absent or is not an ancestor, rather than reporting it unverifiable. `ci.yml`
|
||||
fetches that exact object before the suite runs.
|
||||
- A `changelog.d/` fragment for the sync issue.
|
||||
|
||||
### 7. Verify — and verify where it will actually run
|
||||
|
||||
`test/run.sh` green on your machine is the weakest of the checks below. The
|
||||
`0.6.0` sync was "green locally" and red on the runner **three times, for three
|
||||
different reasons**:
|
||||
|
||||
| what was green locally | why the runner disagreed |
|
||||
|---|---|
|
||||
| `shellcheck-all.sh` | it lints **tracked** files, and the new guard was untracked |
|
||||
| the whole suite | CI pins **shellcheck 0.10.0**; a different local version reports differently |
|
||||
| `issue_payload_valid` | `jq -e` on empty input exits **4** on jq 1.7 and **0** on jq 1.6 — and the runner image ships 1.6 |
|
||||
|
||||
That last one was not a test problem: on jq 1.6 the guard that refuses an
|
||||
unreadable read was *accepting* one. **The distance between your environment
|
||||
and the runner's is part of the sync's risk surface, not an inconvenience.**
|
||||
|
||||
So verify with the runner's own tooling:
|
||||
|
||||
```sh
|
||||
git add -A # or shellcheck sees nothing new
|
||||
CEREMONY_REQUIRE_NPM=1 CEREMONY_REQUIRE_YQ=1 bash test/run.sh
|
||||
bash .github/scripts/shellcheck-all.sh # pinned 0.10.0, as ci.yml installs
|
||||
bash .github/scripts/actionlint-all.sh
|
||||
bash .github/scripts/self-ref-check.sh
|
||||
bash .github/scripts/marker-check.sh
|
||||
bash .github/scripts/vendored-check.sh
|
||||
bash actions/changelog-armed/changelog-armed.sh
|
||||
```
|
||||
|
||||
and run the suite once under the runner's `jq` as well as your own.
|
||||
|
||||
### Every branch that was open during the sync is now stale
|
||||
|
||||
Forgejo tests branch heads; it never tests what two branches produce together,
|
||||
and it never re-tests an open PR when `main` moves under it. So after a sync
|
||||
lands, **every PR that was open across it is green against a tree that no
|
||||
longer exists** — its run did not contain the test files and rules the sync
|
||||
introduced.
|
||||
|
||||
Both halves of that bit in this sync:
|
||||
|
||||
- `#206` and `#207` were cut from the pre-sync base. Their green suites had 22
|
||||
test files; the merged tree has 28.
|
||||
- `#206`'s changelog fragment was individually green and made the **combined**
|
||||
tree red, because the terminal-citation rule (#262) arrives *with* the sync
|
||||
and the fragment was written against a base without it.
|
||||
|
||||
So, for each PR still open:
|
||||
|
||||
```sh
|
||||
git merge origin/main # in the branch — do not rewrite its commits
|
||||
CEREMONY_REQUIRE_NPM=1 CEREMONY_REQUIRE_YQ=1 bash test/run.sh
|
||||
```
|
||||
|
||||
or, if you are only checking rather than updating, merge them into a scratch
|
||||
worktree together and run the full current suite and static guards there. A
|
||||
prior approval is evidence about the tree it was given on; after a sync it is
|
||||
not evidence about the tree the operator would merge.
|
||||
|
||||
### 8. After it merges — audit by executed steps, never by colour
|
||||
|
||||
The sync issue uses `Refs`, not `Closes`, and stays open until a real sweep on
|
||||
the merged `main` is linked to it.
|
||||
|
||||
**A green run is not that evidence.** In this sync the first post-merge run was
|
||||
green and had reconciled *nothing*: upstream's #209 restructure moved reconcile
|
||||
out of the labels caller and behind a dispatch this forge cannot perform, so
|
||||
the only job that ran was the refusal. Green, correct, and proof of the
|
||||
refusal path only.
|
||||
|
||||
So before citing any run:
|
||||
|
||||
1. **Inventory what the sync changed about workflow triggers and jobs** — which
|
||||
jobs exist now, which events fire them, and which of those this forge can
|
||||
actually serve. A restructure upstream can move work between workflows
|
||||
without touching a line of the code that does it.
|
||||
2. **Read the run's executed steps**, not its status. Name the job that did the
|
||||
thing, and quote the line that shows it did.
|
||||
3. A green *skipped-or-refusing* path is valid evidence **for that path**, and
|
||||
never evidence that the work happened.
|
||||
|
||||
Neither of these is caught by the no-runtime-`gh` scan in step 4: in this sync
|
||||
both failures occurred with that guard green and CI green.
|
||||
|
||||
## Where the forge delta lives
|
||||
|
||||
Forge-specific behaviour is confined to the files below. Keeping it there is
|
||||
what makes each sync cost 18 hunks instead of hundreds, and
|
||||
`test/upstream-delta.test.sh` fails the PR that scatters it into a new file.
|
||||
|
||||
**What that guard actually checks**, stated precisely so the table is not read
|
||||
as a stronger promise than it is: it walks every tracked file except prose
|
||||
(`*.md`), the test harness and `changelog.d/`, and flags any that **decides**
|
||||
the forge — the selector's verbs, `CEREMONY_FORGE*`, or a server-URL comparison
|
||||
written inline. Discovery is derived from the tree rather than from a list of
|
||||
directories and extensions, so a composite `action.yml` or a `.yaml` workflow
|
||||
is seen without anyone remembering to add it.
|
||||
|
||||
It is a check on *forge decisions in executable and configuration files*. It is
|
||||
**not** a diff against upstream, so it cannot see a file that differs from
|
||||
upstream for some other forge-specific reason — `drills/` and
|
||||
`.github/labels.conf` are in the table for that kind of reason and are listed
|
||||
by judgement, not by scan.
|
||||
|
||||
| file | what is forge-specific about it |
|
||||
|---|---|
|
||||
| `lib/forge.sh` | the selector: `forge_detect`, `forge_client`, `forge_preflight` |
|
||||
| `lib/forge-github.sh` | the gh backend — the one file allowed to speak `gh` |
|
||||
| `lib/forge-forgejo.sh` | the Forgejo backend, `/api/v1` over curl + jq |
|
||||
| `lib/closes_references.sh` | the closing-keyword parser that replaced GraphQL |
|
||||
| `.github/labels.conf` | this instance's roster |
|
||||
| `drills/` | records of runs on this instance |
|
||||
| `actions/refs-not-closing/run.sh` | declares `CEREMONY_FORGE_CLIENT=gh` — its gather is GraphQL, which Forgejo does not serve. #199 removes the declaration |
|
||||
| `.github/workflows/labels.yml` | the sweep dispatch decides the forge inline and declares a client; a workflow has no shell to call `forge_preflight` from. #205 ports it |
|
||||
| `.github/workflows/refs-guard.yml` | schedules its job on GitHub only, so an action that can only refuse here does not stand red. #199 removes the gate |
|
||||
| `.github/workflows/release-exercise.yml` | pins `CEREMONY_FORGE: github` deliberately: the exercise drives the GitHub path |
|
||||
| `actions/docs-sync/docs-sync.sh` | fetches the doctrine mirror from the forge in `GITHUB_SERVER_URL`, and refuses rather than guessing one (#201) |
|
||||
|
||||
Four of those are **temporary** and say which issue removes them. That is the
|
||||
point of listing them rather than exempting them: a forge-delta location with
|
||||
no exit is indistinguishable from one nobody noticed.
|
||||
|
||||
A file that merely **calls** the shim is not a delta location — every
|
||||
reconciler and `release.yml` call `forge_select`, and that is what the shim is
|
||||
for. A file that **decides** or **declares** is, and belongs here.
|
||||
|
||||
If a sync needs forge branching somewhere else, that is a design decision, not
|
||||
a detail: add the file to the inventory in the same PR, with the reason.
|
||||
116
drills/0.6.1.md
116
drills/0.6.1.md
|
|
@ -1,116 +0,0 @@
|
|||
# 0.6.1 — drill record
|
||||
|
||||
Run 2026-08-09 by `codex-reviewer-andresmgsl` against release PR !226,
|
||||
candidate branch `release-0.6.1`, canonical candidate SHA
|
||||
`ba3b17af88e0fe1ccae9eefd4b37bf2666f6cdad`. All six probes ran on this
|
||||
Forgejo instance. Each result below was read from its own run and from the
|
||||
repository state after that run.
|
||||
|
||||
## Scope ruling — a full rehearsal was owed
|
||||
|
||||
The last rehearsed tag was `0.6.0`. The release path at this candidate is the
|
||||
exact output of `.github/scripts/release-path.sh`. Its measured diff from
|
||||
`0.6.0` is not pin-only: `.github/workflows/release.yml`, `lib/facts.sh`, and
|
||||
the new `lib/forge.sh` changed. These are release-door bytes, so the
|
||||
doors-unchanged record shape is refused and this release carries a full live
|
||||
rehearsal.
|
||||
|
||||
## Where
|
||||
|
||||
Disposable **private** repository `heavy-duty/ceremony-drill-0.6.1`, created
|
||||
by the operator on 2026-08-09 after the fleet identity's personal repository
|
||||
could not see the organization runner. It carries the `docs/CONSUMERS.md`
|
||||
release caller with `version-source: file`, a fragment-mode fixture armed at
|
||||
`0.6.1-dev`, and a non-blank `drills/0.6.1.md`. The `release` label existed
|
||||
before the first probe PR.
|
||||
|
||||
**Disposal as observed when this record was written:** the repository is
|
||||
private and **not yet archived**. This builder's archive PATCH returned 403
|
||||
because it has push but not admin permission. The operator was asked to
|
||||
archive it; delete remains the operator's later step. No archive or delete is
|
||||
claimed, and cleanup gates neither review nor merge.
|
||||
|
||||
## Candidate-ref deviation
|
||||
|
||||
The candidate tree pins `CEREMONY_SELF_REF` to `0.6.1`, a tag that did not
|
||||
exist before this release. No branch named `0.6.1` was created on
|
||||
`heavy-duty/ceremony`.
|
||||
|
||||
The scratch caller instead uses
|
||||
`codex-reviewer-andresmgsl/ceremony/.github/workflows/release.yml@drill/0.6.1`.
|
||||
That ref is commit `f766752090429241c20a0d86aba6f679d713fc2c`; its parent is the canonical
|
||||
candidate SHA above, and its only change rewrites all three
|
||||
`CEREMONY_SELF_REF` carriers to that canonical SHA. The release path is
|
||||
therefore byte-identical to the candidate except for the required candidate
|
||||
pin rewrite.
|
||||
|
||||
## Probes
|
||||
|
||||
| # | probe | run | result |
|
||||
|---|---|---|---|
|
||||
| 1 | merge-door ceremony | 5 | ✅ one `0.6.1` release; tag points to merge commit `438097fc75b1d294b448830a3a79c00b0ee7b83f`; main re-armed to `0.6.2-dev` |
|
||||
| 2 | release-labelled ordinary PR | 2 | ✅ green no-op; zero tags and zero releases |
|
||||
| 3 | bare-version PR without `release` | 3 | ✅ refused; zero tags and zero releases |
|
||||
| 4 | completed ceremony repeated | 7 | ✅ refused; release count stayed one and tag `0.6.1` stayed on the original merge commit |
|
||||
| 5 | manual matching tag | 8 | ✅ `0.6.2` published from its own changelog section; main was unchanged |
|
||||
| 6 | mismatched tag | 9 | ✅ refused before publication; no `9.9.9` release, and the operator-created probe tag was removed afterwards |
|
||||
|
||||
### Probe 1 — merge door
|
||||
|
||||
PR #4 transitioned `VERSION` from `0.6.1-dev` to `0.6.1`, assembled both
|
||||
fixture fragments, and carried the `release` label. Run 5 succeeded. The
|
||||
published body is exactly the assembled `0.6.1` section, tag `0.6.1` points
|
||||
to PR #4's merge commit, and the workflow pushed `0.6.2-dev` to main at
|
||||
`1c74d76f035da4a13f7af0d2a43d611092061204`.
|
||||
|
||||
### Probe 2 — labelled ordinary work
|
||||
|
||||
PR #1 carried the `release` label and added only `changelog.d/2.md`.
|
||||
`VERSION` stayed `0.6.1-dev`. Run 2 succeeded as a no-op; the tag and release
|
||||
lists were both empty afterwards.
|
||||
|
||||
### Probe 3 — bare version without declared intent
|
||||
|
||||
PR #2 assembled the two fragments and transitioned to bare `0.6.1`, but had
|
||||
no `release` label. Run 3 failed. Both tag and release lists remained empty.
|
||||
Setup PR #3 then reverted that probe and restored the armed fixture; run 4
|
||||
was green before the ceremony probe began.
|
||||
|
||||
### Probe 4 — completed ceremony repeated
|
||||
|
||||
Forgejo exposes no run-rerun API, so this probe reproduced the completed
|
||||
door's input. Setup PR #5 restored `0.6.1-dev` and run 6 was green. Release-
|
||||
labelled PR #6 transitioned to bare `0.6.1` again. Run 7 failed at the
|
||||
already-existing tag guard. The release count stayed one and tag `0.6.1`
|
||||
still pointed to the original ceremony merge commit, not PR #6.
|
||||
|
||||
### Probe 5 — matching manual tag
|
||||
|
||||
Side branch `probe5-tag` carried bare `VERSION 0.6.2` and a publishable
|
||||
`0.6.2` changelog section. Tag `0.6.2` was created at
|
||||
`a4ba62aa83fdd2e259ed3c3e906b13eaab1abcd0`. Run 8 succeeded and published
|
||||
only that section. Main stayed at the state left by probe 4; the tag door did
|
||||
not push a version change to it.
|
||||
|
||||
### Probe 6 — mismatched tag
|
||||
|
||||
Tag `9.9.9` was created at the same `0.6.2` tree. Run 9 failed before
|
||||
publication. The release list remained exactly `0.6.1` and `0.6.2`. The
|
||||
operator-created `9.9.9` ref was then deleted; the remaining tag list was
|
||||
exactly `0.6.1` and `0.6.2`.
|
||||
|
||||
## Runs that are setup, not probes
|
||||
|
||||
- Run 1: the initial `0.6.1-dev` fixture push; green no-op.
|
||||
- Run 4: PR #3 restored the armed fixture after probe 3; green.
|
||||
- Run 6: PR #5 restored `0.6.1-dev` to reproduce the completed ceremony
|
||||
input for probe 4; green.
|
||||
|
||||
## What this rehearsal establishes
|
||||
|
||||
Both release doors ran against the 0.6.1 candidate's Forgejo implementation.
|
||||
The merge door published once, tagged the reviewed merge commit, and re-armed
|
||||
main. It stayed a green no-op for labelled ordinary work, refused a bare
|
||||
unlabelled transition, and refused a repeated ceremony. The tag door
|
||||
published a matching tag without changing main and refused a mismatched tag
|
||||
without publishing. Every refusal created no tag or release of its own.
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
# 0.6.2 — drill record
|
||||
|
||||
Measured 2026-08-24 on release PR !250, candidate branch
|
||||
`build/231-release-0-6-2`. The release-path measurement was taken at
|
||||
`fdb7d7577b4b30c11f2db9b47f278e2a16783c8c`; this record is the only later
|
||||
candidate-tree change and `drills/` is outside the release path.
|
||||
|
||||
## Scope ruling — doors unchanged, no disposable-repo rehearsal
|
||||
|
||||
The last rehearsed tag is `0.6.1`. All three doors-unchanged conditions in
|
||||
`drills/README.md` hold at this candidate tree:
|
||||
|
||||
1. `git diff 0.6.1..HEAD -- $(sh .github/scripts/release-path.sh)` changes
|
||||
only the `CEREMONY_SELF_REF` pin in `.github/workflows/release.yml`, from
|
||||
`0.6.1` to `0.6.2`. No release-door logic, decision, fact gathering,
|
||||
version handling, changelog handling, or publish step changed.
|
||||
2. The measured release path is exactly the output of
|
||||
`.github/scripts/release-path.sh`:
|
||||
|
||||
```text
|
||||
.github/workflows/release.yml
|
||||
bin/
|
||||
lib/version.sh
|
||||
lib/decide.sh
|
||||
lib/facts.sh
|
||||
lib/changelog.sh
|
||||
lib/forge.sh
|
||||
```
|
||||
|
||||
3. `drills/0.6.1.md` records a full six-probe rehearsal. The Forgejo release
|
||||
API reported `0.6.1` published at `2026-08-09T19:46:56Z`, neither draft nor
|
||||
prerelease, and `origin/main:VERSION` read `0.6.2-dev`, confirming that main
|
||||
was re-armed after that release.
|
||||
|
||||
A new disposable-repo rehearsal would therefore execute the same release-door
|
||||
bytes as the full 0.6.1 rehearsal. This record asserts only the mechanically
|
||||
checked doors-unchanged case; the release panel may still rule that a full
|
||||
rehearsal is owed.
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
# 0.6.3 — drill record
|
||||
|
||||
Measured 2026-08-26 on the `release-0.6.3` candidate branch, canonical
|
||||
candidate SHA `03cb69d`. All three measurements below were taken at that head,
|
||||
not copied from an earlier record.
|
||||
|
||||
## Scope ruling — doors unchanged, no disposable-repo rehearsal
|
||||
|
||||
**The last rehearsed tag is `0.6.1`, not `0.6.2`.** `drills/0.6.2.md` is itself
|
||||
a doors-unchanged assertion, and `drills/README.md` anchors the baseline to the
|
||||
last *rehearsed* tag precisely so one such assertion cannot chain from another
|
||||
while the doors drift a small diff at a time. The baseline used here is
|
||||
therefore `0.6.1`, which widens the measured window rather than narrowing it.
|
||||
|
||||
All three conditions hold at the candidate head:
|
||||
|
||||
1. `git diff 0.6.1..HEAD -- $(sh .github/scripts/release-path.sh)` changes only
|
||||
the `CEREMONY_SELF_REF` pin line in `.github/workflows/release.yml`, from
|
||||
`0.6.1` to `0.6.3`:
|
||||
|
||||
```diff
|
||||
@@ -129,7 +129,7 @@ env:
|
||||
- CEREMONY_SELF_REF: "0.6.1"
|
||||
+ CEREMONY_SELF_REF: "0.6.3"
|
||||
```
|
||||
|
||||
No release-door logic, decision table, fact gathering, version handling,
|
||||
changelog handling, forge adapter or publish step changed across two
|
||||
releases. The measured diff is one line.
|
||||
|
||||
2. The measured release path is exactly the output of
|
||||
`.github/scripts/release-path.sh` at this head:
|
||||
|
||||
```text
|
||||
.github/workflows/release.yml
|
||||
bin/
|
||||
lib/version.sh
|
||||
lib/decide.sh
|
||||
lib/facts.sh
|
||||
lib/changelog.sh
|
||||
lib/forge.sh
|
||||
```
|
||||
|
||||
3. `drills/0.6.1.md` records a **full rehearsal** — run 2026-08-09 by
|
||||
`codex-reviewer-andresmgsl` against release PR !226 — its release is
|
||||
published, and `main` was re-armed to `0.6.2-dev` after it by
|
||||
`5693bee chore: bump main to 0.6.2-dev`.
|
||||
|
||||
## What this release contains
|
||||
|
||||
Nine fragments consumed into `## 0.6.3`: #234, #240, #241, #243, #247, #251,
|
||||
#253, #263, #265. None of them touches the release path; the list is
|
||||
board-flow, labels-reconcile, docs and forge-adapter work.
|
||||
|
||||
## What is NOT asserted
|
||||
|
||||
This record asserts only that a fresh disposable-repo rehearsal would execute
|
||||
the same release-door bytes as `0.6.1`'s did. It does not assert that the
|
||||
doors were re-exercised, and it is not a substitute for the rehearsal owed at
|
||||
the next release-path change. Per `drills/README.md`, the release PR's panel
|
||||
verifies this claim like any other evidence, and **if any reviewer rules a full
|
||||
drill owed, that verdict wins.**
|
||||
|
||||
## Note on 0.6.2's red `self-guards`
|
||||
|
||||
Recorded here because the next reader of `drills/` will see it. The `0.6.2`
|
||||
tag's commit `5a8fce83` is red on `CI / self-guards` — `changelog-armed`
|
||||
correctly refused a tree in which `changelog.d/238.md` was still unconsumed,
|
||||
because that fragment landed on `main` after `changelog-assemble 0.6.2` had
|
||||
already run in the release branch. The content shipped correct (the #238 entry
|
||||
is in the published `0.6.2` notes) and the strand was consumed on `main` by
|
||||
`f221647`. The systemic guard against the race landed in !255, *"refuse release
|
||||
PRs that strand target fragments"*, which is why this candidate was checked
|
||||
against it before opening.
|
||||
|
|
@ -84,10 +84,10 @@ drill owed, that verdict wins.
|
|||
except the `CEREMONY_SELF_REF` pin line in
|
||||
`.github/workflows/release.yml`.
|
||||
2. The release path is exactly the output of
|
||||
`.github/scripts/release-path.sh`. Run the script and paste its output into
|
||||
the record; measure the diff with
|
||||
`git diff <last-rehearsed-tag>..HEAD -- $(sh .github/scripts/release-path.sh)`.
|
||||
The script's contract test keeps its list and the workflow's direct and
|
||||
`.github/scripts/release-path.sh`: `.github/workflows/release.yml`, `bin/`,
|
||||
`lib/version.sh`, `lib/decide.sh`, `lib/facts.sh`, and
|
||||
`lib/changelog.sh`. The script is the record author's copy-paste source;
|
||||
its contract test keeps this inline list and the workflow's direct and
|
||||
transitive dependencies in agreement.
|
||||
3. The last rehearsed tag's own record is a full rehearsal, its release is
|
||||
published, and `main` was re-armed to `-dev` after it.
|
||||
|
|
@ -110,11 +110,3 @@ non-empty even though neither release door reads that file (#217, #237).
|
|||
missing or blank. A waived drill is still a record: the file says WAIVED and
|
||||
why — a maintainer's call, visible and reviewable in the release PR's diff,
|
||||
never a silent skip.
|
||||
|
||||
---
|
||||
|
||||
**Standing runner probes are not drills.** The disposal rule above — builder
|
||||
archives, operator deletes — is for the disposable scratch repo a drill runs
|
||||
in. `heavy-duty/ceremony-runner-probe` is the opposite shape: it stands, and
|
||||
archiving it is the failure mode that made all three previous drill repos
|
||||
unavailable. See [docs/RUNNER-PROBES.md](../docs/RUNNER-PROBES.md) (#202).
|
||||
|
|
|
|||
|
|
@ -25,12 +25,12 @@ forgejo_api_base() {
|
|||
# THE TRAP, measured on this instance with REPO unset (#191, caught by
|
||||
# @kimi on !193 before it shipped):
|
||||
#
|
||||
# forge_release_exists 0.4.1 -> "no", rc 0 (repos//releases/tags/0.4.1
|
||||
# 404s; a repo-less path read
|
||||
# as "the published release
|
||||
# does not exist" — and the
|
||||
# nothing-exists assert would
|
||||
# then proceed to CREATE)
|
||||
# forge_release_exists 0.4.1 -> "no", rc 0 (repos//releases/tags/0.4.1
|
||||
# 404s; a repo-less path read
|
||||
# as "the release does not
|
||||
# exist" — and the
|
||||
# nothing-exists assert would
|
||||
# then proceed to CREATE)
|
||||
# forge_commit_pulls <sha> -> "[]", rc 0 (a commit that HAS a merged
|
||||
# PR behind it, read as none)
|
||||
#
|
||||
|
|
@ -84,7 +84,7 @@ forgejo_page_url() {
|
|||
esac
|
||||
}
|
||||
|
||||
# forge_api [--paginate | --paginate-exhaustive] <endpoint> [--jq <expr>]
|
||||
# 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
|
||||
|
|
@ -94,26 +94,11 @@ forgejo_page_url() {
|
|||
# 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.
|
||||
#
|
||||
# --paginate-exhaustive is the narrow alternative for an endpoint whose
|
||||
# x-total-count is known not to describe the collection. It proves completion
|
||||
# by reading through the first short page and never consults that header.
|
||||
forge_api() {
|
||||
local paginate=false paginate_exhaustive=false method=GET endpoint="" jqexpr="" have_jq=false
|
||||
local paginate=false endpoint="" jqexpr="" have_jq=false
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--paginate) paginate=true ;;
|
||||
--paginate-exhaustive) paginate_exhaustive=true ;;
|
||||
-X | --method)
|
||||
[ "$#" -ge 2 ] || { echo "forge_api: $1 requires a value" >&2; return 1; }
|
||||
method="$2"
|
||||
shift
|
||||
;;
|
||||
-X?*) method="${1#-X}" ;;
|
||||
--method=*)
|
||||
method="${1#*=}"
|
||||
[ -n "$method" ] || { echo "forge_api: --method requires a value" >&2; return 1; }
|
||||
;;
|
||||
--jq) jqexpr="$2"; have_jq=true; shift ;;
|
||||
-*) ;;
|
||||
*) [ -n "$endpoint" ] || endpoint="$1" ;;
|
||||
|
|
@ -121,14 +106,6 @@ forge_api() {
|
|||
shift
|
||||
done
|
||||
[ -n "$endpoint" ] || { echo "forge_api: endpoint required" >&2; return 1; }
|
||||
if [ "$paginate" = true ] && [ "$paginate_exhaustive" = true ]; then
|
||||
echo "forge_api: --paginate and --paginate-exhaustive are mutually exclusive" >&2
|
||||
return 1
|
||||
fi
|
||||
if { [ "$paginate" = true ] || [ "$paginate_exhaustive" = true ]; } && [ "$method" != GET ]; then
|
||||
echo "forge_api: pagination is available only for GET requests" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local base token
|
||||
base="$(forgejo_api_base)" || return 1
|
||||
|
|
@ -139,7 +116,7 @@ forge_api() {
|
|||
# shellcheck disable=SC2064 # the paths are fixed at trap time on purpose
|
||||
trap "rm -f '$hdr' '$body'" RETURN
|
||||
|
||||
if [ "$paginate" = false ] && [ "$paginate_exhaustive" = false ]; then
|
||||
if [ "$paginate" = false ]; then
|
||||
if ! curl -sS -D "$hdr" -o "$body" \
|
||||
-H "Authorization: token $token" -H 'Accept: application/json' \
|
||||
"$base/$endpoint"; then
|
||||
|
|
@ -165,23 +142,21 @@ forge_api() {
|
|||
fi
|
||||
forgejo_http_ok "$hdr" "GET $endpoint" || return 1
|
||||
|
||||
if [ "$paginate_exhaustive" = false ]; then
|
||||
# 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
|
||||
# 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
|
||||
return 1
|
||||
fi
|
||||
|
||||
pagejson="$(cat "$body")"
|
||||
|
|
@ -200,17 +175,13 @@ EOF
|
|||
[ "$n" -gt 0 ] || break
|
||||
all="$(jq -s '.[0] + .[1]' <<<"$all"$'\n'"$pagejson")"
|
||||
got=$((got + n))
|
||||
if [ "$paginate_exhaustive" = true ]; then
|
||||
[ "$n" -eq 50 ] || break
|
||||
else
|
||||
[ "$got" -lt "$total" ] || break
|
||||
fi
|
||||
[ "$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 [ "$paginate_exhaustive" = false ] && [ "$got" -ne "$total" ]; then
|
||||
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
|
||||
|
|
@ -458,20 +429,13 @@ forge_pr_view() {
|
|||
status="$(forge_api "repos/$REPO/commits/$sha/status")" || return 1
|
||||
jq -n --argjson pr "$pr" --argjson st "$status" '
|
||||
{
|
||||
# Forgejo folds checking, conflict, check error, and WIP into false.
|
||||
# Draft must win because WIP makes the boolean carry no merge result (#236).
|
||||
mergeable: (if $pr.draft == true then "UNKNOWN"
|
||||
elif $pr.mergeable == true then "MERGEABLE"
|
||||
elif $pr.merge_base == $pr.base.sha then "UNKNOWN"
|
||||
else "CONFLICTING" end),
|
||||
mergeable: (if $pr.mergeable == true then "MERGEABLE"
|
||||
elif $pr.mergeable == false then "CONFLICTING"
|
||||
else "UNKNOWN" end),
|
||||
statusCheckRollup: [
|
||||
$st.statuses[]? | {
|
||||
__typename: "StatusContext",
|
||||
context: .context,
|
||||
# Forgejo carries the workflow name only as the context prefix;
|
||||
# no separator means no proven workflow, so never guess (#243).
|
||||
workflowName: ((.context // "")
|
||||
| if contains(" / ") then split(" / ")[0] else "" end),
|
||||
state: (.status | ascii_upcase),
|
||||
# checks_state groups repeated contexts and takes the NEWEST by
|
||||
# [.startedAt, .createdAt, .completedAt]. Without a timestamp the
|
||||
|
|
@ -485,15 +449,6 @@ forge_pr_view() {
|
|||
}'
|
||||
}
|
||||
|
||||
# forge_pr_review_requests <n> — logins with a live review request.
|
||||
# Forgejo review.go deletes REQUEST_REVIEW rows when the reviewer submits any
|
||||
# review, so these rows are the exact live set rather than review history (#238).
|
||||
forge_pr_review_requests() {
|
||||
local n="${1:?forge_pr_review_requests: number required}"
|
||||
forge_api --paginate "repos/$REPO/pulls/$n/reviews" \
|
||||
--jq '.[] | select(.state == "REQUEST_REVIEW") | .user.login' | sort -u
|
||||
}
|
||||
|
||||
forge_label_list() { forge_api --paginate "repos/$REPO/labels" --jq '.[].name'; }
|
||||
|
||||
# forge_label_create — an UPSERT, matching `gh label create --force` (#4743).
|
||||
|
|
@ -594,13 +549,7 @@ forge_request_reviewer() {
|
|||
# empty timeline — the two states the ruling ladder must tell apart (#4853).
|
||||
forge_timeline() {
|
||||
local n="${1:?forge_timeline: number required}" raw
|
||||
# Measured on this instance: limit=10 reports x-total-count=10 and limit=50
|
||||
# reports 50, while crew!96 held 151 events and strict pagination returned
|
||||
# only its first 50. No other measured endpoint echoes its page size this
|
||||
# way. Timelines are append-only, so exhaustion can include concurrent new
|
||||
# events but cannot create a deletion hole; that is why only this call site
|
||||
# may bypass the header-bound completeness proof (#240).
|
||||
raw="$(forge_api --paginate-exhaustive "repos/$REPO/issues/$n/timeline")" || return 1
|
||||
raw="$(forge_api --paginate "repos/$REPO/issues/$n/timeline")" || return 1
|
||||
jq '
|
||||
[.[]
|
||||
| select(.type == "label")
|
||||
|
|
@ -672,31 +621,22 @@ forgejo_read_code() {
|
|||
rm -f "$hdr"
|
||||
}
|
||||
|
||||
# forge_release_exists <tag> — prints `yes` for a published release and `no`
|
||||
# for a draft or a 404. A non-zero exit means the read did not complete and
|
||||
# the answer is UNKNOWN; the caller must not treat that as `no` (#191, #271).
|
||||
# forge_release_exists <tag> — prints `yes` or `no`. A non-zero exit means
|
||||
# the read did not complete and the answer is UNKNOWN; the caller must not
|
||||
# treat that as `no` (#191).
|
||||
forge_release_exists() {
|
||||
local tag="${1:?forge_release_exists: tag required}" body code draft
|
||||
local tag="${1:?forge_release_exists: tag required}" body code
|
||||
body="$(mktemp)"
|
||||
code="$(forgejo_read_code "repos/$REPO/releases/tags/$tag" "$body")" || { rm -f "$body"; return 1; }
|
||||
rm -f "$body"
|
||||
case "$code" in
|
||||
2*)
|
||||
if ! draft="$(jq -r 'if has("draft") then .draft else false end' "$body" 2>/dev/null)" \
|
||||
|| [[ "$draft" != true && "$draft" != false ]]; then
|
||||
rm -f "$body"
|
||||
echo "forge_release_exists: unreadable draft state for release '$tag' — the answer is unknown, not 'no'" >&2
|
||||
return 1
|
||||
fi
|
||||
[ "$draft" = true ] && echo no || echo yes
|
||||
;;
|
||||
2*) echo yes ;;
|
||||
404) echo no ;;
|
||||
*)
|
||||
rm -f "$body"
|
||||
echo "forge_release_exists: HTTP $code reading release '$tag' — the answer is unknown, not 'no'" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
rm -f "$body"
|
||||
}
|
||||
|
||||
# forge_commit_pulls <sha> — the pull requests whose merge produced <sha>, as
|
||||
|
|
@ -774,74 +714,20 @@ forgejo_urlencode() {
|
|||
jq -rn --arg s "${1-}" '$s|@uri'
|
||||
}
|
||||
|
||||
# forgejo_release_cleanup_draft <id> <tag> — best-effort rollback after a
|
||||
# post-create failure. The caller has already printed the original failure;
|
||||
# cleanup can add evidence but must never replace that diagnosis (#271).
|
||||
forgejo_release_cleanup_draft() {
|
||||
local id="${1:?forgejo_release_cleanup_draft: id required}"
|
||||
local tag="${2:?forgejo_release_cleanup_draft: tag required}" cleanup
|
||||
if ! cleanup="$(forgejo_write DELETE "repos/$REPO/releases/$id" '' 2>&1)"; then
|
||||
[ -z "$cleanup" ] || printf '%s\n' "$cleanup" >&2
|
||||
echo "forge_release_create: cleanup failed; stranded draft $id for tag '$tag'" >&2
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# forge_release_create <tag> <title> <notes-file> [asset…] — creates a draft,
|
||||
# uploads every asset, then publishes it. Any failure after create removes the
|
||||
# draft, so the function leaves either a complete published release or nothing.
|
||||
# forge_release_create <tag> <title> <notes-file> [asset…] — publishes, then
|
||||
# uploads each asset to the created release. The release id comes back from
|
||||
# the create, so no second lookup is needed.
|
||||
forge_release_create() {
|
||||
local tag="${1:?forge_release_create: tag required}" title="${2:?forge_release_create: title required}"
|
||||
local notes="${3:?forge_release_create: notes file required}" out id base token
|
||||
local existing code draft existing_id
|
||||
shift 3
|
||||
|
||||
# A previous rollback whose DELETE failed must not wedge the next attempt.
|
||||
# Remove only a draft for this exact tag; a published release is never
|
||||
# touched and its create will retain Forgejo's ordinary conflict refusal.
|
||||
existing="$(mktemp)"
|
||||
code="$(forgejo_read_code "repos/$REPO/releases/tags/$tag" "$existing")" || { rm -f "$existing"; return 1; }
|
||||
case "$code" in
|
||||
2*)
|
||||
if ! draft="$(jq -r 'if has("draft") then .draft else false end' "$existing" 2>/dev/null)" \
|
||||
|| [[ "$draft" != true && "$draft" != false ]]; then
|
||||
rm -f "$existing"
|
||||
echo "forge_release_create: unreadable draft state for release '$tag' — refusing to replace it" >&2
|
||||
return 1
|
||||
fi
|
||||
if [ "$draft" = true ]; then
|
||||
existing_id="$(jq -r '.id // empty' "$existing")"
|
||||
if [ -z "$existing_id" ]; then
|
||||
rm -f "$existing"
|
||||
echo "forge_release_create: the stranded draft for tag '$tag' has no release id — refusing to replace it" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "forge_release_create: removing stranded draft $existing_id for tag '$tag' before publish" >&2
|
||||
if ! forgejo_write DELETE "repos/$REPO/releases/$existing_id" '' >/dev/null; then
|
||||
rm -f "$existing"
|
||||
echo "forge_release_create: could not remove stranded draft $existing_id for tag '$tag'" >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
404) ;;
|
||||
*)
|
||||
rm -f "$existing"
|
||||
echo "forge_release_create: HTTP $code checking for a stranded draft for tag '$tag' — refusing to publish" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
rm -f "$existing"
|
||||
|
||||
out="$(forgejo_write POST "repos/$REPO/releases" \
|
||||
"$(jq -nc --arg t "$tag" --arg n "$title" --rawfile b "$notes" \
|
||||
'{tag_name:$t,name:$n,body:$b,draft:true,prerelease:false}')")" || return 1
|
||||
'{tag_name:$t,name:$n,body:$b,draft:false,prerelease:false}')")" || return 1
|
||||
id="$(printf '%s' "$out" | jq -r '.id // empty')"
|
||||
[ -n "$id" ] || { echo "forge_release_create: the create returned no release id" >&2; return 1; }
|
||||
if ! base="$(forgejo_api_base)"; then
|
||||
forgejo_release_cleanup_draft "$id" "$tag"
|
||||
return 1
|
||||
fi
|
||||
[ "$#" -gt 0 ] || return 0
|
||||
base="$(forgejo_api_base)" || return 1
|
||||
token="${GH_TOKEN:-${GITHUB_TOKEN:-${FORGEJO_TOKEN:-}}}"
|
||||
local f name
|
||||
for f in "$@"; do
|
||||
|
|
@ -855,16 +741,8 @@ forge_release_create() {
|
|||
curl -sS -f -X POST -H "Authorization: token $token" \
|
||||
-F "attachment=@$f" \
|
||||
"$base/repos/$REPO/releases/$id/assets?name=$name" >/dev/null \
|
||||
|| {
|
||||
echo "forge_release_create: asset upload failed for '$f'" >&2
|
||||
forgejo_release_cleanup_draft "$id" "$tag"
|
||||
return 1
|
||||
}
|
||||
|| { echo "forge_release_create: asset upload failed for '$f'" >&2; return 1; }
|
||||
done
|
||||
if ! forgejo_write PATCH "repos/$REPO/releases/$id" '{"draft":false}' >/dev/null; then
|
||||
forgejo_release_cleanup_draft "$id" "$tag"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# forge_pr_create <head> <base> <title> <body> <label…> — POST /pulls takes
|
||||
|
|
|
|||
|
|
@ -101,12 +101,6 @@ forge_pr_view() {
|
|||
gh pr view "$n" -R "$REPO" --json mergeable,statusCheckRollup
|
||||
}
|
||||
|
||||
# forge_pr_review_requests <n> — logins with a live review request.
|
||||
forge_pr_review_requests() {
|
||||
local n="${1:?forge_pr_review_requests: number required}"
|
||||
forge_api "repos/$REPO/pulls/$n" --jq '.requested_reviewers[].login' | sort -u
|
||||
}
|
||||
|
||||
# forge_label_list — every label name in the repo.
|
||||
forge_label_list() {
|
||||
gh label list -R "$REPO" --limit 200 --json name --jq '.[].name'
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# lib/issue_references.sh — the LOCAL / CROSS reference classifier (#61).
|
||||
#
|
||||
# Sourced, never executed: no set -e/-u — the sourcing script owns its shell
|
||||
# options, as lib/closes_references.sh and lib/forge.sh do.
|
||||
#
|
||||
# WHY IT LIVES HERE. It was defined inside actions/issueflow-reconcile's
|
||||
# executable, and lib/closes_references.sh's header recorded the resulting
|
||||
# wart in prose: "DEPENDENCY: issue_references, from issueflow-reconcile.sh".
|
||||
# That was tolerable while the reconciler was its only caller. #199 makes
|
||||
# actions/refs-not-closing a second one, and a composite action cannot source
|
||||
# another action's program to borrow one function — sourcing a reconciler
|
||||
# runs a reconciler.
|
||||
#
|
||||
# So the dependency the comment described is now a file, and both callers
|
||||
# source it the same way. Nothing about the classifier changed.
|
||||
#
|
||||
# A qualified reference belongs to another repository. The whole token is
|
||||
# classified BEFORE any number is extracted, so `rig#112` can never be read
|
||||
# as local `#112` — which is the entire point of the function.
|
||||
|
||||
issue_references() { # text on stdin -> LOCAL/CROSS<TAB>reference
|
||||
{ grep -Eo '([[:alnum:]_.-]+/)?[[:alnum:]_.-]+#[0-9]+|#[0-9]+' || true; } \
|
||||
| awk '
|
||||
index($0, "#") == 1 { print "LOCAL\t" substr($0, 2); next }
|
||||
{ print "CROSS\t" $0 }
|
||||
'
|
||||
}
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# lib/preflight.sh — the merge door's resume decision, pure and exhaustively
|
||||
# tested (issue #273).
|
||||
#
|
||||
# A merge-door run creates the tag before the artifact hook and release. A
|
||||
# failed hook or publish therefore leaves a tag but no release. Re-running the
|
||||
# same merge commit must resume after that irreversible step; a published
|
||||
# release or a tag naming another commit must still refuse.
|
||||
#
|
||||
# Pure: no repository or forge reads. The workflow establishes four facts:
|
||||
#
|
||||
# VER the version being released
|
||||
# MERGE_SHA the commit this door would tag
|
||||
# TAG_SHAS object names returned for the direct and peeled tag refs,
|
||||
# newline-separated; empty means the tag is absent
|
||||
# RELEASED yes|no — whether a published release for VER exists
|
||||
#
|
||||
# Output: resume=yes or resume=no on stdout, notices to stdout, refusals to
|
||||
# stderr, return 1 on refusal.
|
||||
#
|
||||
# The decision table (this IS the spec — issue #273):
|
||||
#
|
||||
# | # | RELEASED | TAG_SHAS contains MERGE_SHA | result |
|
||||
# |---|----------|------------------------------|---------------------------|
|
||||
# | 1 | yes | either | REFUSE: already released |
|
||||
# | 2 | no | empty | resume=no: ordinary run |
|
||||
# | 3 | no | yes | resume=yes + resume NOTICE |
|
||||
# | 4 | no | non-empty, no | REFUSE: tag is elsewhere |
|
||||
|
||||
release_preflight() {
|
||||
local tag_sha sha
|
||||
|
||||
if [ -z "${VER:-}" ]; then
|
||||
printf '%s\n' "VER is empty — the caller failed to establish the release version. Refusing to decide — creating nothing." >&2
|
||||
return 1
|
||||
fi
|
||||
if [ -z "${MERGE_SHA:-}" ]; then
|
||||
printf '%s\n' "MERGE_SHA is empty — the caller failed to establish the merge commit. Refusing to decide — creating nothing." >&2
|
||||
return 1
|
||||
fi
|
||||
if [ -z "${RELEASED:-}" ]; then
|
||||
printf '%s\n' "RELEASED is empty — the caller failed to establish whether release '$VER' exists. Refusing to decide — creating nothing." >&2
|
||||
return 1
|
||||
fi
|
||||
case "$RELEASED" in
|
||||
yes | no) ;;
|
||||
*)
|
||||
printf '%s\n' "RELEASED='$RELEASED' — expected yes or no. Refusing to decide — creating nothing." >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Row 1 comes first: deleting a tag under a standing release never makes
|
||||
# that release safe to recreate.
|
||||
if [ "$RELEASED" = yes ]; then
|
||||
printf '%s\n' "release '$VER' already exists — this release already happened; refusing to re-release, creating nothing." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Row 2: an absent tag is the ordinary first run.
|
||||
if [ -z "${TAG_SHAS:-}" ]; then
|
||||
printf '%s\n' 'resume=no'
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Row 3: compare each object name as a whole line. For an annotated tag the
|
||||
# direct ref names the tag object and the peeled ref names MERGE_SHA.
|
||||
while IFS= read -r sha; do
|
||||
if [ "$sha" = "$MERGE_SHA" ]; then
|
||||
printf '%s\n' "NOTICE: tag '$VER' already stands at this merge commit and no release exists — a previous run of this door tagged and then failed to publish. Resuming: the tag is not recreated; the artifact hook and the publish run."
|
||||
printf '%s\n' 'resume=yes'
|
||||
return 0
|
||||
fi
|
||||
done <<<"$TAG_SHAS"
|
||||
|
||||
# Row 4: the first object name is enough to diagnose the conflicting tag;
|
||||
# MERGE_SHA is printed beside it so the operator sees both sides.
|
||||
tag_sha="${TAG_SHAS%%$'\n'*}"
|
||||
printf '%s\n' "tag '$VER' already exists at $tag_sha but this run would tag $MERGE_SHA — a manual tag won the race, or it names a different commit; refusing to re-release, creating nothing. Delete that tag, or re-tag the merge commit." >&2
|
||||
return 1
|
||||
}
|
||||
|
|
@ -217,49 +217,6 @@ check "a surviving fragment with its entry present fails" 1 "STILL PRESENT" \
|
|||
check "the survivor refusal names the file" 1 "changelog.d/9.md" \
|
||||
run survivor base
|
||||
|
||||
# A release PR can be faithful to its merge base while the target branch moves
|
||||
# ahead and gains a fragment during review. That target-only fragment was not
|
||||
# available to the ceremony, so merging the PR would strand it for the next
|
||||
# release. The guard must read the target ref as well as their merge base.
|
||||
seed_flat target-stranded
|
||||
ceremony target-stranded 0.2.0 2026-07-24
|
||||
commit_head target-stranded
|
||||
git -C "$TMP/target-stranded" switch -q base
|
||||
printf -- '- Landed while the release was under review (#30).\n' \
|
||||
>"$TMP/target-stranded/changelog.d/30.md"
|
||||
git -C "$TMP/target-stranded" add -A
|
||||
git -C "$TMP/target-stranded" commit -qm target-fragment
|
||||
git -C "$TMP/target-stranded" switch -q main
|
||||
check "a target-head fragment the release did not consume fails" 1 \
|
||||
"changelog.d/30.md" run target-stranded base
|
||||
check "the target-stranding refusal names the rebase remedy" 1 \
|
||||
"rebase onto the target head" run target-stranded base
|
||||
check "the target-stranding refusal names the assembler re-run" 1 \
|
||||
"changelog-assemble '0.2.0'" run target-stranded base
|
||||
|
||||
# Removing the target-only fragment makes the same diverged fixture green:
|
||||
# target drift itself is not the failure, only a stranded fragment is.
|
||||
git -C "$TMP/target-stranded" switch -q base
|
||||
rm "$TMP/target-stranded/changelog.d/30.md"
|
||||
git -C "$TMP/target-stranded" add -A
|
||||
git -C "$TMP/target-stranded" commit -qm target-fragment-removed
|
||||
git -C "$TMP/target-stranded" switch -q main
|
||||
check "the same target fixture is green once no fragment is stranded" 0 \
|
||||
"byte-for-byte" run target-stranded base
|
||||
|
||||
# Spell out the common harmless case independently: the target branch moved,
|
||||
# but the advancing commit added no fragment.
|
||||
seed_flat target-unrelated
|
||||
ceremony target-unrelated 0.2.0 2026-07-24
|
||||
commit_head target-unrelated
|
||||
git -C "$TMP/target-unrelated" switch -q base
|
||||
printf 'unrelated target change\n' >"$TMP/target-unrelated/code.txt"
|
||||
git -C "$TMP/target-unrelated" add -A
|
||||
git -C "$TMP/target-unrelated" commit -qm target-unrelated
|
||||
git -C "$TMP/target-unrelated" switch -q main
|
||||
check "a target head advanced without a fragment stays green" 0 \
|
||||
"byte-for-byte" run target-unrelated base
|
||||
|
||||
# Fragments consumed, section never stamped: the prose went nowhere.
|
||||
seed_flat halfdone
|
||||
rm "$TMP/halfdone/changelog.d/12.md" "$TMP/halfdone/changelog.d/9.md"
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
#!/usr/bin/env bash
|
||||
# Contract tests for actions/docs-sync (issue #19). Constructed SOURCE trees
|
||||
# (a fake ceremony: manifest + docs) and CONSUMER trees (a release.yml
|
||||
# caller with the pin line), driven offline via --source. The fetch path is
|
||||
# driven too, against a PATH-stubbed curl rather than the network (#201) —
|
||||
# which forge a pin resolves against is a decision, not plumbing. The fake
|
||||
# caller with the pin line), driven offline via --source — the fetch path
|
||||
# needs the network and is exercised by consumers, not here. The fake
|
||||
# source's doc set is deliberately NOT the real five: a script that
|
||||
# hardcodes the vendored list instead of reading the manifest fails these
|
||||
# rows. set -u, not -e: failing commands are behavior for the harness to
|
||||
|
|
@ -300,80 +299,4 @@ check "unknown flag refused" 1 "unknown argument" \
|
|||
check "--source without a directory refused" 1 "no such directory" \
|
||||
in_consumer env-wired --check --source "$TMP/does-not-exist"
|
||||
|
||||
# --- the fetch path: which forge, and never a guessed one (#201) ---------------
|
||||
|
||||
# The fetch path had no coverage at all before this: every row above passes
|
||||
# --source, which overrides the fetch entirely, so the URL the tool actually
|
||||
# builds was asserted nowhere. It is asserted here with a PATH-stubbed curl
|
||||
# that records the URL and serves a tarball of the fake source tree — no
|
||||
# network, and the real tar pipeline still runs, so --strip-components stays
|
||||
# honest. CURL_FAIL makes the stub fail the way a missing ref does.
|
||||
FETCHBIN="$TMP/fetchbin"
|
||||
mkdir -p "$FETCHBIN"
|
||||
cat >"$FETCHBIN/curl" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' "${!#}" >>"$CURL_URL_LOG"
|
||||
[ -z "${CURL_FAIL:-}" ] || exit 22
|
||||
exec tar -cz -C "$(dirname "$CURL_SRC")" "$(basename "$CURL_SRC")"
|
||||
STUB
|
||||
chmod +x "$FETCHBIN/curl"
|
||||
|
||||
export CURL_URL_LOG="$TMP/curl-urls" CURL_SRC="$SRC"
|
||||
|
||||
consumer fetched 0.4.1
|
||||
|
||||
# fetch_sync <server-url> <args...> — the fetch path, no --source. Truncates
|
||||
# the URL log first so requested_url always answers about this run.
|
||||
fetch_sync() {
|
||||
local server="$1"
|
||||
shift
|
||||
: >"$CURL_URL_LOG"
|
||||
(cd "$TMP/fetched" && PATH="$FETCHBIN:$PATH" GITHUB_SERVER_URL="$server" \
|
||||
bash "$SCRIPT" "$@")
|
||||
}
|
||||
requested_url() { cat "$CURL_URL_LOG"; }
|
||||
|
||||
check "the fetch mirrors the pin fetched from the forge in the environment" 0 \
|
||||
"added .ceremony/RULES.md" fetch_sync https://forgejo.example.test --fix
|
||||
check "...and the URL asked for names that forge, not a built-in one" 0 \
|
||||
"https://forgejo.example.test/heavy-duty/ceremony/archive/0.4.1.tar.gz" \
|
||||
requested_url
|
||||
|
||||
# One pin ref, two forges, two trees — the whole reason #201 exists. The same
|
||||
# consumer must fetch from whichever forge it is running on.
|
||||
fetch_sync https://github.com --fix >/dev/null 2>&1
|
||||
check "the same pin on another forge fetches from that forge instead" 0 \
|
||||
"https://github.com/heavy-duty/ceremony/archive/0.4.1.tar.gz" requested_url
|
||||
|
||||
fetch_sync https://forgejo.example.test/ --fix >/dev/null 2>&1
|
||||
check "a trailing slash on the server URL does not double the separator" 0 \
|
||||
"https://forgejo.example.test/heavy-duty/ceremony/archive/0.4.1.tar.gz" \
|
||||
requested_url
|
||||
|
||||
# Unset is not github.com. A tool that never guesses a ref must not guess a
|
||||
# forge either — and it must refuse BEFORE reaching for the network.
|
||||
no_server_sync() {
|
||||
: >"$CURL_URL_LOG"
|
||||
(cd "$TMP/fetched" && PATH="$FETCHBIN:$PATH" \
|
||||
env -u GITHUB_SERVER_URL bash "$SCRIPT" --check)
|
||||
}
|
||||
check "no GITHUB_SERVER_URL and no --source → refuse, naming the variable" 1 \
|
||||
"GITHUB_SERVER_URL is unset" no_server_sync
|
||||
check "...and the refusal says it never guesses a forge" 1 \
|
||||
"never guesses a forge" no_server_sync
|
||||
nothing_fetched() { [ ! -s "$CURL_URL_LOG" ]; }
|
||||
check "...and nothing was fetched before refusing" 0 "" nothing_fetched
|
||||
|
||||
# A ref that does not resolve on the forge in play: the message must name the
|
||||
# URL actually attempted, so "does the pinned ref exist" is answerable.
|
||||
fetch_fail() {
|
||||
: >"$CURL_URL_LOG"
|
||||
(cd "$TMP/fetched" && PATH="$FETCHBIN:$PATH" CURL_FAIL=1 \
|
||||
GITHUB_SERVER_URL=https://forgejo.example.test bash "$SCRIPT" --check)
|
||||
}
|
||||
check "a failed fetch names the URL it tried" 1 \
|
||||
"https://forgejo.example.test/heavy-duty/ceremony/archive/0.4.1.tar.gz" fetch_fail
|
||||
check "...and asks about the ref on that forge, not in the abstract" 1 \
|
||||
"exist on that forge" fetch_fail
|
||||
|
||||
summary
|
||||
|
|
|
|||
|
|
@ -581,93 +581,6 @@ check "...never deriving them from reviews, as forgejo must" 1 "" \
|
|||
unset -f gh
|
||||
. "$ROOT/lib/forge-forgejo.sh"
|
||||
|
||||
# --- forge_pr_review_requests: the forge's live request set -------------
|
||||
# A stale verdict is not itself evidence that anybody was re-requested. The
|
||||
# backend reads the forge's request representation directly, so the state
|
||||
# machine can distinguish the builder's ball from the panel's (#238).
|
||||
review_requests_stub() {
|
||||
fake_forge "$FAKE_REVIEWS_N" "$FAKE_REVIEWS"
|
||||
}
|
||||
|
||||
# crew!96 after the builder pushed: two approvals and one requested change
|
||||
# belong to the older head, but no live REQUEST_REVIEW row exists.
|
||||
FAKE_REVIEWS_N=3
|
||||
FAKE_REVIEWS='[
|
||||
{"user":{"login":"kimi-bot"},"state":"APPROVED","commit_id":"old","submitted_at":"2026-08-22T23:05:01Z"},
|
||||
{"user":{"login":"glm-bot"},"state":"APPROVED","commit_id":"old","submitted_at":"2026-08-22T23:14:45Z"},
|
||||
{"user":{"login":"claude-bot"},"state":"REQUEST_CHANGES","commit_id":"old","submitted_at":"2026-08-22T23:03:39Z"}
|
||||
]'
|
||||
review_requests_stub
|
||||
check "supersedes 'a stale approval still owes a verdict': one never re-requested is not requested" 0 "" \
|
||||
eq "" forge_pr_review_requests 96
|
||||
|
||||
# rig!146 after merge: all three panelists submitted, so the reviews endpoint
|
||||
# carries no REQUEST_REVIEW row even though requested_reviewers stayed stale.
|
||||
FAKE_REVIEWS_N=3
|
||||
FAKE_REVIEWS='[
|
||||
{"user":{"login":"kimi-bot"},"state":"APPROVED","commit_id":"merged","submitted_at":"2026-08-02T14:05:01Z"},
|
||||
{"user":{"login":"glm-bot"},"state":"APPROVED","commit_id":"merged","submitted_at":"2026-08-02T14:14:45Z"},
|
||||
{"user":{"login":"claude-bot"},"state":"APPROVED","commit_id":"merged","submitted_at":"2026-08-02T14:03:39Z"}
|
||||
]'
|
||||
review_requests_stub
|
||||
check "supersedes 'the never-cleared forgejo field collapses to who actually owes': a merged PR yields nobody" 0 "" \
|
||||
eq "" forge_pr_review_requests 146
|
||||
|
||||
# crew!97's opening request rows. Deliberately unordered with one duplicate:
|
||||
# the public contract is sorted unique logins, independent of API row order.
|
||||
FAKE_REVIEWS_N=5
|
||||
FAKE_REVIEWS='[
|
||||
{"user":{"login":"kimi-bot"},"state":"REQUEST_REVIEW","commit_id":"","submitted_at":"2026-08-22T22:51:12Z"},
|
||||
{"user":{"login":"claude-bot"},"state":"REQUEST_REVIEW","commit_id":"","submitted_at":"2026-08-22T22:51:11Z"},
|
||||
{"user":{"login":"codex-bot"},"state":"REQUEST_REVIEW","commit_id":"","submitted_at":"2026-08-22T22:51:11Z"},
|
||||
{"user":{"login":"glm-bot"},"state":"REQUEST_REVIEW","commit_id":"","submitted_at":"2026-08-22T22:51:12Z"},
|
||||
{"user":{"login":"kimi-bot"},"state":"REQUEST_REVIEW","commit_id":"","submitted_at":"2026-08-22T22:51:13Z"}
|
||||
]'
|
||||
review_requests_stub
|
||||
check "forgejo returns the four opening request logins sorted and deduplicated" 0 "" \
|
||||
eq $'claude-bot\ncodex-bot\nglm-bot\nkimi-bot' forge_pr_review_requests 97
|
||||
|
||||
FAKE_REVIEWS_N=2
|
||||
FAKE_REVIEWS='[
|
||||
{"user":{"login":"claude-bot"},"state":"REQUEST_CHANGES","commit_id":"old","submitted_at":"2026-08-22T23:03:39Z"},
|
||||
{"user":{"login":"claude-bot"},"state":"REQUEST_REVIEW","commit_id":"","submitted_at":"2026-08-22T23:16:17Z"}
|
||||
]'
|
||||
review_requests_stub
|
||||
check "supersedes: a reviewer re-requested while holding REQUEST_CHANGES is requested" 0 "" \
|
||||
eq "claude-bot" forge_pr_review_requests 97
|
||||
|
||||
FAKE_REVIEWS_N=1
|
||||
FAKE_REVIEWS='[{"user":{"login":"kimi-bot"},"state":"REQUEST_REVIEW","commit_id":"","submitted_at":"2026-08-22T22:51:12Z"}]'
|
||||
review_requests_stub
|
||||
check "supersedes 'a Forgejo request row is not an answer and leaves the login outstanding': its login is requested" 0 "" \
|
||||
eq "kimi-bot" forge_pr_review_requests 97
|
||||
|
||||
# GitHub already exposes the exact live set on the PR object. Stub only the
|
||||
# network boundary and assert the same backend-neutral output contract.
|
||||
. "$ROOT/lib/forge-github.sh"
|
||||
# shellcheck disable=SC2317 # invoked indirectly, by forge_api
|
||||
gh() {
|
||||
[ "$1" = api ] || return 1
|
||||
shift
|
||||
local jqexpr="" endpoint=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--jq) jqexpr="$2"; shift ;;
|
||||
-*) ;;
|
||||
*) [ -n "$endpoint" ] || endpoint="$1" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
[ "$endpoint" = repos/o/r/pulls/55 ] || return 1
|
||||
jq -r "$jqexpr" <<'JSON'
|
||||
{"requested_reviewers":[{"login":"glm-bot"},{"login":"claude-bot"}]}
|
||||
JSON
|
||||
}
|
||||
check "github returns requested_reviewers from the PR object unchanged" 0 "" \
|
||||
eq $'claude-bot\nglm-bot' forge_pr_review_requests 55
|
||||
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
|
||||
|
|
@ -686,66 +599,23 @@ pr_view_stub() {
|
|||
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 '%s' "$FAKE_PR" >"$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":"labels / labels (pull_request)","status":"success","created_at":"2026-08-02T10:00:00Z","updated_at":"2026-08-02T10:00:00Z"},
|
||||
{"context":"labels / labels (pull_request)","status":"failure","created_at":"2026-08-02T09:00:00Z","updated_at":"2026-08-02T09:00:00Z"},
|
||||
{"context":"CI / test (pull_request)","status":"success","created_at":"2026-08-02T10:00:00Z","updated_at":"2026-08-02T10:00:00Z"},
|
||||
{"context":"Refs guard / refs-not-closing (pull_request)","status":"success","created_at":"2026-08-02T10:00:00Z","updated_at":"2026-08-02T10:00:00Z"},
|
||||
{"context":"codecov","status":"success","created_at":"2026-08-02T10:00:00Z","updated_at":"2026-08-02T10:00:00Z"}]}'
|
||||
FAKE_PR='{"head":{"sha":"abc"},"base":{"sha":"base"},"merge_base":"ancestor","draft":false,"mergeable":true}'
|
||||
{"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 derives workflowName from the first context separator" 0 "" \
|
||||
jq -e '[.statusCheckRollup[]
|
||||
| select(.context == "labels / labels (pull_request)"
|
||||
or .context == "CI / test (pull_request)"
|
||||
or .context == "Refs guard / refs-not-closing (pull_request)")
|
||||
| [.workflowName, .context]]
|
||||
| unique == [
|
||||
["CI", "CI / test (pull_request)"],
|
||||
["Refs guard", "Refs guard / refs-not-closing (pull_request)"],
|
||||
["labels", "labels / labels (pull_request)"]
|
||||
]' >/dev/null <<<"$view_json"
|
||||
check "pr_view leaves workflowName empty when the context has no separator" 0 "" \
|
||||
jq -e 'any(.statusCheckRollup[];
|
||||
.context == "codecov" and .workflowName == "")' >/dev/null <<<"$view_json"
|
||||
check "pr_view maps mergeable to the UI string" 0 "" \
|
||||
grep -q '"mergeable": "MERGEABLE"' <<<"$view_json"
|
||||
|
||||
# Forgejo folds conflict-checking, conflict-check errors, real conflicts, and
|
||||
# WIP into one false boolean. These fixtures pin the distinctions the API
|
||||
# object still lets the backend report honestly (#236).
|
||||
FAKE_PR='{"head":{"sha":"abc"},"base":{"sha":"base"},"merge_base":"ancestor","draft":true,"mergeable":false}'
|
||||
view_json="$(REPO=o/r forge_pr_view 5)"
|
||||
check "pr_view reports draft mergeability as unknown" 0 "" \
|
||||
grep -q '"mergeable": "UNKNOWN"' <<<"$view_json"
|
||||
|
||||
FAKE_PR='{"head":{"sha":"abc"},"base":{"sha":"base"},"merge_base":"base","draft":false,"mergeable":false}'
|
||||
view_json="$(REPO=o/r forge_pr_view 5)"
|
||||
check "pr_view reports a fast-forward false mergeability as unknown" 0 "" \
|
||||
grep -q '"mergeable": "UNKNOWN"' <<<"$view_json"
|
||||
|
||||
FAKE_PR='{"head":{"sha":"abc"},"base":{"sha":"base"},"merge_base":"ancestor","draft":false,"mergeable":false}'
|
||||
view_json="$(REPO=o/r forge_pr_view 5)"
|
||||
check "pr_view preserves a distinguishable real conflict" 0 "" \
|
||||
grep -q '"mergeable": "CONFLICTING"' <<<"$view_json"
|
||||
|
||||
# Unreachable on Forgejo: Mergeable() includes !IsWorkInProgress(). This
|
||||
# ordering fixture exists only to pin draft ahead of mergeable true (#236).
|
||||
FAKE_PR='{"head":{"sha":"abc"},"base":{"sha":"base"},"merge_base":"ancestor","draft":true,"mergeable":true}'
|
||||
view_json="$(REPO=o/r forge_pr_view 5)"
|
||||
check "pr_view reads draft before the mergeable boolean" 0 "" \
|
||||
grep -q '"mergeable": "UNKNOWN"' <<<"$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
|
||||
|
|
@ -763,7 +633,6 @@ check "no api base refuses" 1 "cannot reach the forge" \
|
|||
# .user.login -> .actor.login. Mutation-verified: collapsing add/remove or
|
||||
# emitting .user instead of .actor each reds its own case (#4853).
|
||||
timeline_stub() {
|
||||
: >"$timeline_calls"
|
||||
# shellcheck disable=SC2317 # invoked indirectly, by forge_api
|
||||
curl() {
|
||||
local hdr="" out="" url=""
|
||||
|
|
@ -771,44 +640,14 @@ timeline_stub() {
|
|||
case "$1" in -D) hdr="$2"; shift ;; -o) out="$2"; shift ;; -H) shift ;; *) url="$1" ;; esac
|
||||
shift
|
||||
done
|
||||
printf '%s\n' "$url" >>"$timeline_calls"
|
||||
local page=1 page_body="$FAKE_TIMELINE" page_total="${FAKE_TL_N:-2}"
|
||||
case "$url" in *page=*) page="${url##*page=}"; page="${page%%&*}" ;; esac
|
||||
if [ "${#FAKE_TL_PAGES[@]}" -gt 0 ]; then
|
||||
if [ "$page" -le "${#FAKE_TL_PAGES[@]}" ]; then
|
||||
page_body="${FAKE_TL_PAGES[$((page - 1))]}"
|
||||
else
|
||||
page_body='[]'
|
||||
fi
|
||||
page_total="$(jq 'length' <<<"$page_body")"
|
||||
fi
|
||||
{
|
||||
printf 'HTTP/1.1 200 OK\r\n'
|
||||
[ "${FAKE_TL_HEADERS:-yes}" = no ] || printf 'X-Total-Count: %s\r\n' "$page_total"
|
||||
printf '\r\n'
|
||||
} >"$hdr"
|
||||
printf 'HTTP/1.1 200 OK\r\nX-Total-Count: %s\r\n\r\n' "${FAKE_TL_N:-2}" >"$hdr"
|
||||
case "$url" in
|
||||
*timeline*) printf '%s' "$page_body" >"$out" ;;
|
||||
*timeline*) printf '%s' "$FAKE_TIMELINE" >"$out" ;;
|
||||
*) printf '[]' >"$out" ;;
|
||||
esac
|
||||
return 0
|
||||
}
|
||||
}
|
||||
timeline_page() {
|
||||
jq -nc --argjson first "$1" --argjson count "$2" '
|
||||
[range($first; $first + $count)
|
||||
| {
|
||||
type: "label",
|
||||
body: "1",
|
||||
user: {login: "setter"},
|
||||
label: {name: "needs-ruling"},
|
||||
created_at: ("event-" + tostring)
|
||||
}]
|
||||
'
|
||||
}
|
||||
timeline_calls="$TMP/timeline_calls"
|
||||
FAKE_TL_PAGES=()
|
||||
FAKE_TL_HEADERS=yes
|
||||
FAKE_TIMELINE='[
|
||||
{"type":"label","body":"1","user":{"login":"setter"},"label":{"name":"needs-ruling"},"created_at":"2026-08-02T14:58:13Z"},
|
||||
{"type":"label","body":"","user":{"login":"setter"},"label":{"name":"needs-ruling"},"created_at":"2026-08-02T15:22:22Z"},
|
||||
|
|
@ -825,61 +664,6 @@ check "forge_timeline drops non-label events" 0 "" \
|
|||
test "$(jq '[.[] | select(.event == null or .event == "")] | length' <<<"$tl")" = 0
|
||||
check "forge_timeline uses .actor.login, not a bare .user" 0 "" \
|
||||
jq -e 'all(.[]; has("actor") and (.user|not))' <<<"$tl" >/dev/null
|
||||
|
||||
# Forgejo's timeline endpoint lies consistently: x-total-count echoes the
|
||||
# current page size. With 151 events its pages declare 50, 50, 50 and 1, so
|
||||
# strict pagination stops successfully after page 1 and drops the newest 101
|
||||
# events. Exhaustion is safe only here because timelines are append-only.
|
||||
FAKE_TL_PAGES=(
|
||||
"$(timeline_page 1 50)"
|
||||
"$(timeline_page 51 50)"
|
||||
"$(timeline_page 101 50)"
|
||||
"$(timeline_page 151 1)"
|
||||
)
|
||||
timeline_stub
|
||||
tl="$(REPO=o/r forge_timeline 188)"
|
||||
check "forge_timeline exhausts all pages despite per-page total headers" 0 "" \
|
||||
test "$(jq 'length' <<<"$tl")" = 151
|
||||
check "forge_timeline retains the newest event beyond page one" 0 "" \
|
||||
jq -e 'any(.[]; .created_at == "event-151")' <<<"$tl" >/dev/null
|
||||
check "a short final page terminates without an extra empty-page read" 0 "" \
|
||||
test "$(wc -l <"$timeline_calls")" = 4
|
||||
|
||||
# A collection exactly divisible by the page size needs one final empty read;
|
||||
# stopping after the second full page cannot prove exhaustion.
|
||||
FAKE_TL_PAGES=("$(timeline_page 1 50)" "$(timeline_page 51 50)")
|
||||
timeline_stub
|
||||
exhaustive_count="$(REPO=o/r forge_api --paginate-exhaustive 'repos/o/r/issues/188/timeline' --jq 'length')"
|
||||
check "exhaustive pagination terminates after an empty page" 0 "" \
|
||||
test "$exhaustive_count" = 100
|
||||
check "an exactly-full exhaustive gather reads the empty third page" 0 "" \
|
||||
test "$(wc -l <"$timeline_calls")" = 3
|
||||
|
||||
# The exhaustive path's completeness proof is the short page itself; it must
|
||||
# never consult the endpoint's missing or dishonest total header.
|
||||
FAKE_TL_PAGES=("$(timeline_page 1 50)" "$(timeline_page 51 1)")
|
||||
FAKE_TL_HEADERS=no
|
||||
timeline_stub
|
||||
check "exhaustive pagination needs no x-total-count header" 0 "" \
|
||||
eq 51 forge_api --paginate-exhaustive 'repos/o/r/issues/188/timeline' --jq 'length'
|
||||
check "the same missing-header fixture is still refused by strict pagination" 1 \
|
||||
"did not send x-total-count" \
|
||||
forge_api --paginate 'repos/o/r/issues/188/timeline' --jq 'length'
|
||||
FAKE_TL_HEADERS=yes
|
||||
|
||||
check "strict and exhaustive pagination are mutually exclusive" 1 "mutually exclusive" \
|
||||
forge_api --paginate --paginate-exhaustive 'repos/o/r/issues/188/timeline'
|
||||
check "exhaustive pagination refuses a non-GET method" 1 "GET" \
|
||||
forge_api --paginate-exhaustive -X POST 'repos/o/r/issues/188/timeline'
|
||||
check "exhaustive pagination refuses compact -XPOST too" 1 "GET" \
|
||||
forge_api --paginate-exhaustive -XPOST 'repos/o/r/issues/188/timeline'
|
||||
check "exhaustive pagination refuses --method=POST too" 1 "GET" \
|
||||
forge_api --paginate-exhaustive --method=POST 'repos/o/r/issues/188/timeline'
|
||||
check "the exhaustive flag has exactly one production call site" 0 "" \
|
||||
test "$(grep -c 'paginate-exhaustive' "$ROOT/lib/forge-forgejo.sh")" = 5
|
||||
check "only forge_timeline invokes exhaustive pagination" 0 "" \
|
||||
test "$(grep -c 'forge_api --paginate-exhaustive' "$ROOT/lib/forge-forgejo.sh")" = 1
|
||||
|
||||
# Unreadable: curl fails. Status must surface through forge_timeline itself
|
||||
# (not a later jq), or the ruling ladder invents a verdict on a half-read.
|
||||
# shellcheck disable=SC2317
|
||||
|
|
@ -952,25 +736,16 @@ check "...and never hits the flat /pulls/{n}/comments endpoint" 1 "" \
|
|||
# tag creation GitHub POST /git/refs
|
||||
# Forgejo POST /tags (/git/refs is GET-only)
|
||||
|
||||
# release_stub <code> <body> [<code> <body>…] — a curl stub answering a
|
||||
# sequence of canned responses and recording the method+path it was asked for.
|
||||
# The cursor lives in a file because forge_release_create captures
|
||||
# forgejo_write in a command substitution; an in-memory counter would advance
|
||||
# only in that subshell and every later request would replay response one.
|
||||
# release_stub <code> <body> — a curl stub answering one canned response and
|
||||
# recording the method+path it was asked for.
|
||||
release_stub() {
|
||||
# Globals, not locals: the curl closure below runs long after this
|
||||
# function returns, exactly as stub_writes does above.
|
||||
STUB_CODES=() STUB_BODIES=()
|
||||
while [ "$#" -gt 0 ]; do
|
||||
STUB_CODES+=("$1") STUB_BODIES+=("$2")
|
||||
shift 2
|
||||
done
|
||||
STUB_READ_CODE="${STUB_CODES[0]}" STUB_READ_BODY="${STUB_BODIES[0]}"
|
||||
printf '0\n' >"$TMP/release-stub-cursor"
|
||||
STUB_CODE="$1" STUB_BODY="$2"
|
||||
: >"$WRITES"
|
||||
# shellcheck disable=SC2317 # invoked indirectly, by the forge verbs
|
||||
curl() {
|
||||
local hdr="" out="" method=GET url="" payload="" fail_on_http=false
|
||||
local hdr="" out="" method=GET url="" payload=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-D) hdr="$2"; shift ;;
|
||||
|
|
@ -978,32 +753,18 @@ release_stub() {
|
|||
-X) method="$2"; shift ;;
|
||||
-d) payload="$2"; shift ;;
|
||||
-F) payload="$payload -F $2"; shift ;;
|
||||
-f) fail_on_http=true ;;
|
||||
-H) shift ;;
|
||||
-*) ;;
|
||||
*) url="$1" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
local index code body
|
||||
if [ "$method" = GET ]; then
|
||||
code="$STUB_READ_CODE" body="$STUB_READ_BODY"
|
||||
else
|
||||
index="$(cat "$TMP/release-stub-cursor")"
|
||||
code="${STUB_CODES[$index]:-${STUB_CODES[${#STUB_CODES[@]} - 1]}}"
|
||||
body="${STUB_BODIES[$index]:-${STUB_BODIES[${#STUB_BODIES[@]} - 1]}}"
|
||||
printf '%s\n' "$((index + 1))" >"$TMP/release-stub-cursor"
|
||||
fi
|
||||
[ -n "$hdr" ] && printf 'HTTP/1.1 %s x\r\n\r\n' "$code" >"$hdr"
|
||||
[ -n "$out" ] && printf '%s' "$body" >"$out"
|
||||
[ -n "$hdr" ] && printf 'HTTP/1.1 %s x\r\n\r\n' "$STUB_CODE" >"$hdr"
|
||||
[ -n "$out" ] && printf '%s' "$STUB_BODY" >"$out"
|
||||
printf '%s %s %s\n' "$method" "${url##*/api/v1/}" "$payload" >>"$WRITES"
|
||||
if [ "$fail_on_http" = true ]; then
|
||||
case "$code" in 2*) ;; *) return 22 ;; esac
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
}
|
||||
release_read() { STUB_READ_CODE="$1" STUB_READ_BODY="$2"; }
|
||||
|
||||
GITHUB_API_URL=https://forge.example/api/v1
|
||||
export GITHUB_API_URL
|
||||
|
|
@ -1046,10 +807,6 @@ check "forgejo: a 500 refuses rather than saying 'none'" 1 "the answer is unknow
|
|||
|
||||
release_stub 200 '{"tag_name":"1.2.3"}'
|
||||
check "forgejo: a present release is yes" 0 "yes" forge_release_exists 1.2.3
|
||||
release_stub 200 '{"tag_name":"1.2.3","draft":true}'
|
||||
check "forgejo: a draft is not a present release" 0 "no" forge_release_exists 1.2.3
|
||||
release_stub 200 '{"tag_name":"1.2.3","draft":false}'
|
||||
check "forgejo: a published release is present" 0 "yes" forge_release_exists 1.2.3
|
||||
release_stub 404 '{}'
|
||||
check "forgejo: an absent release is no" 0 "no" forge_release_exists 1.2.3
|
||||
release_stub 503 '{}'
|
||||
|
|
@ -1073,22 +830,9 @@ check "forgejo: the tag body names the target sha" 0 '"target":"cafebabe"' \
|
|||
writes_after forge_tag_create 1.2.3 cafebabe
|
||||
|
||||
printf 'notes body\n' >"$TMP/notes.md"
|
||||
release_writes_after() { "$@" >/dev/null 2>&1; grep -v '^GET ' "$WRITES"; }
|
||||
|
||||
release_stub 201 '{"id":42}' 200 '{}'
|
||||
release_read 404 '{}'
|
||||
release_stub 201 '{"id":42}'
|
||||
check "forgejo: the publish POSTs to /releases with the notes as body" 0 '"body":"notes body' \
|
||||
release_writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md"
|
||||
check "the create stages a draft" 0 '"draft":true' cat "$WRITES"
|
||||
zero_asset_writes() {
|
||||
release_stub 201 '{"id":42}' 200 '{}'
|
||||
release_read 404 '{}'
|
||||
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" >/dev/null 2>&1
|
||||
local got
|
||||
got="$(grep -v '^GET ' "$WRITES" | sed -E 's/ (\{.*|$)//')"
|
||||
[ "$got" = $'POST repos/o/r/releases\nPATCH repos/o/r/releases/42' ] || { printf '%s\n' "$got"; return 1; }
|
||||
}
|
||||
check "the zero-asset path creates then publishes" 0 "" zero_asset_writes
|
||||
writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md"
|
||||
|
||||
# Assets: the hook contract permits any filename the consumer drops in
|
||||
# RELEASE_ASSETS_DIR, and the asset name travels as a QUERY VALUE. Raw
|
||||
|
|
@ -1102,179 +846,18 @@ check "the encoder escapes the query delimiters" 0 "a%26b%23c%2Bd%25e.tgz" \
|
|||
|
||||
printf 'x\n' >"$TMP/release asset.tgz"
|
||||
printf 'y\n' >"$TMP/a&b.tgz"
|
||||
release_stub 201 '{"id":42}' 201 '{}' 200 '{}'
|
||||
release_read 404 '{}'
|
||||
release_stub 201 '{"id":42}'
|
||||
check "an asset with a space uploads under the encoded name" 0 "assets?name=release%20asset.tgz" \
|
||||
release_writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/release asset.tgz"
|
||||
release_stub 201 '{"id":42}' 201 '{}' 200 '{}'
|
||||
release_read 404 '{}'
|
||||
writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/release asset.tgz"
|
||||
release_stub 201 '{"id":42}'
|
||||
check "an asset with '&' does not become two parameters" 0 "assets?name=a%26b.tgz" \
|
||||
release_writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
|
||||
release_stub 201 '{"id":42}' 201 '{}' 200 '{}'
|
||||
release_read 404 '{}'
|
||||
writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
|
||||
release_stub 201 '{"id":42}'
|
||||
check "the upload targets the created release id" 0 "releases/42/assets" \
|
||||
release_writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
|
||||
release_stub 201 '{"id":42}' 201 '{}' 200 '{}'
|
||||
release_read 404 '{}'
|
||||
writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
|
||||
release_stub 201 '{"id":42}'
|
||||
check "the asset rides as a multipart attachment" 0 "attachment=@" \
|
||||
release_writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
|
||||
|
||||
happy_asset_writes() {
|
||||
release_stub 201 '{"id":42}' 201 '{}' 201 '{}' 200 '{}'
|
||||
release_read 404 '{}'
|
||||
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/release asset.tgz" "$TMP/a&b.tgz" >/dev/null 2>&1
|
||||
local got want
|
||||
got="$(grep -v '^GET ' "$WRITES" | sed -E 's/ (\{.*| -F.*|$)//')"
|
||||
want=$'POST repos/o/r/releases\nPOST repos/o/r/releases/42/assets?name=release%20asset.tgz\nPOST repos/o/r/releases/42/assets?name=a%26b.tgz\nPATCH repos/o/r/releases/42'
|
||||
[ "$got" = "$want" ] || { printf '%s\n' "$got"; return 1; }
|
||||
}
|
||||
check "assets upload in argument order before the release is published" 0 "" happy_asset_writes
|
||||
|
||||
upload_failure() {
|
||||
release_stub 201 '{"id":42}' 500 '{}' 204 '{}'
|
||||
release_read 404 '{}'
|
||||
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
|
||||
}
|
||||
check "an upload failure returns non-zero" 1 "asset upload failed" upload_failure
|
||||
check "an upload failure deletes the draft" 0 "DELETE repos/o/r/releases/42" cat "$WRITES"
|
||||
|
||||
flip_failure() {
|
||||
release_stub 201 '{"id":42}' 500 '{}' 204 '{}'
|
||||
release_read 404 '{}'
|
||||
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md"
|
||||
}
|
||||
check "a publish flip failure returns non-zero" 1 "HTTP 500" flip_failure
|
||||
check "a publish flip failure deletes the draft" 0 "DELETE repos/o/r/releases/42" cat "$WRITES"
|
||||
|
||||
create_failure() {
|
||||
release_stub 500 '{}'
|
||||
release_read 404 '{}'
|
||||
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md"
|
||||
}
|
||||
check "a create failure returns non-zero" 1 "HTTP 500" create_failure
|
||||
check "a create failure has nothing to delete" 1 "" grep -q '^DELETE ' "$WRITES"
|
||||
|
||||
missing_release_id() {
|
||||
release_stub 201 '{}'
|
||||
release_read 404 '{}'
|
||||
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md"
|
||||
}
|
||||
check "a create without an id keeps its refusal" 1 "returned no release id" missing_release_id
|
||||
check "a create without an id deletes nothing unknown" 1 "" grep -q '^DELETE ' "$WRITES"
|
||||
|
||||
stranded_cleanup_failure() {
|
||||
release_stub 201 '{"id":42}' 500 '{"message":"upload refused"}' 500 '{"message":"delete refused"}'
|
||||
release_read 404 '{}'
|
||||
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
|
||||
}
|
||||
check "cleanup failure preserves the original failure" 1 "asset upload failed" stranded_cleanup_failure
|
||||
check "cleanup failure also names the stranded draft" 1 "stranded draft 42 for tag '1.2.3'" stranded_cleanup_failure
|
||||
|
||||
release_stub 204 '{}' 201 '{"id":42}' 200 '{}'
|
||||
release_read 200 '{"id":41,"tag_name":"1.2.3","draft":true}'
|
||||
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" >/dev/null 2>&1
|
||||
stranded_draft_order() {
|
||||
local got
|
||||
got="$(grep -v '^GET ' "$WRITES" | head -2 | sed -E 's/ (\{.*|$)//')"
|
||||
[ "$got" = $'DELETE repos/o/r/releases/41\nPOST repos/o/r/releases' ] || { printf '%s\n' "$got"; return 1; }
|
||||
}
|
||||
check "a stranded same-tag draft is deleted before create" 0 "" stranded_draft_order
|
||||
|
||||
release_stub 409 '{}'
|
||||
release_read 200 '{"id":41,"tag_name":"1.2.3","draft":false}'
|
||||
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" >/dev/null 2>&1
|
||||
check "a published same-tag release is never deleted" 1 "" grep -q '^DELETE ' "$WRITES"
|
||||
|
||||
# The merge door's preflight step is extracted and executed. Its network
|
||||
# edges are stubbed at the boundary, while the real pure decision library
|
||||
# consumes the gathered facts (#273).
|
||||
MERGE_PREFLIGHT="$TMP/merge-preflight.sh"
|
||||
{
|
||||
printf '%s\n' '#!/usr/bin/env bash' 'set -e'
|
||||
yq -r '.jobs.release-on-merge.steps[] | select(.id == "preflight") | .run' \
|
||||
"$ROOT/.github/workflows/release.yml"
|
||||
} >"$MERGE_PREFLIGHT"
|
||||
chmod +x "$MERGE_PREFLIGHT"
|
||||
|
||||
mkdir -p "$TMP/merge-preflight-lib/lib" "$TMP/merge-preflight-bin"
|
||||
ln -s "$ROOT/lib/preflight.sh" "$TMP/merge-preflight-lib/lib/preflight.sh"
|
||||
# shellcheck disable=SC2016 # expanded when the generated helper runs
|
||||
printf '%s\n' \
|
||||
'forge_select() { :; }' \
|
||||
'forge_release_exists() { case "$RELEASE_EXISTS" in error) return 1 ;; *) echo "$RELEASE_EXISTS" ;; esac; }' \
|
||||
>"$TMP/merge-preflight-lib/lib/forge.sh"
|
||||
# shellcheck disable=SC2016 # expanded when the PATH stub is executed
|
||||
printf '%s\n' \
|
||||
'#!/usr/bin/env bash' \
|
||||
'case "$GIT_LS_REMOTE" in' \
|
||||
' absent) exit 2 ;;' \
|
||||
' error) exit 128 ;;' \
|
||||
' *) printf "%s\n" "$GIT_LS_REMOTE" ;;' \
|
||||
'esac' >"$TMP/merge-preflight-bin/git"
|
||||
chmod +x "$TMP/merge-preflight-bin/git"
|
||||
|
||||
merge_preflight_extracted() { [ "$(wc -l <"$MERGE_PREFLIGHT")" -ge 8 ]; }
|
||||
run_merge_preflight() {
|
||||
: >"$TMP/merge-preflight-output"
|
||||
CEREMONY_DIR="$TMP/merge-preflight-lib" \
|
||||
GITHUB_OUTPUT="$TMP/merge-preflight-output" \
|
||||
PATH="$TMP/merge-preflight-bin:$PATH" \
|
||||
VER=1.2.3 MERGE_SHA=1111111111111111111111111111111111111111 \
|
||||
RELEASE_EXISTS="$1" GIT_LS_REMOTE="$2" "$MERGE_PREFLIGHT"
|
||||
}
|
||||
merge_preflight_output_is() {
|
||||
run_merge_preflight "$1" "$2" >/dev/null && \
|
||||
[ "$(cat "$TMP/merge-preflight-output")" = "$3" ]
|
||||
}
|
||||
|
||||
check "the merge door's preflight is extracted" 0 "" merge_preflight_extracted
|
||||
check "the merge door refuses an unreadable release state" 1 \
|
||||
"refusing rather than assuming" run_merge_preflight error absent
|
||||
check "the merge door refuses an unreadable tag state" 1 \
|
||||
"could not read tag '1.2.3'" run_merge_preflight no error
|
||||
check "ls-remote exit 2 is tag absence and proceeds" 0 "" \
|
||||
merge_preflight_output_is no absent resume=no
|
||||
check "a matching peeled ref records a resume" 0 "" \
|
||||
merge_preflight_output_is no \
|
||||
$'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\trefs/tags/1.2.3\n1111111111111111111111111111111111111111\trefs/tags/1.2.3^{}' \
|
||||
resume=yes
|
||||
tag_step_uses_preflight() {
|
||||
yq -r '.jobs.release-on-merge.steps[] | select(.name | test("tag the merge commit")) | .if' \
|
||||
"$ROOT/.github/workflows/release.yml" | grep -q 'steps.preflight.outputs.resume'
|
||||
}
|
||||
check "the merge-door tag step is conditioned on the preflight output" 0 "" \
|
||||
tag_step_uses_preflight
|
||||
|
||||
# The tag door's published-release guard is extracted and executed, like the
|
||||
# other workflow-shell contracts in this repository. The tag itself is this
|
||||
# door's premise, so only a published release refuses; adding the merge door's
|
||||
# ls-remote half here would destroy the documented delete-and-re-push recovery.
|
||||
TAG_GUARD="$TMP/tag-release-guard.sh"
|
||||
{
|
||||
printf '%s\n' '#!/usr/bin/env bash' 'set -e'
|
||||
yq -r '.jobs.release-on-tag.steps[] | select(.name == "no published release may exist") | .run' \
|
||||
"$ROOT/.github/workflows/release.yml"
|
||||
} >"$TAG_GUARD"
|
||||
chmod +x "$TAG_GUARD"
|
||||
mkdir -p "$TMP/tag-guard-lib/lib"
|
||||
# shellcheck disable=SC2016 # expanded when the generated helper runs, not while this test writes it
|
||||
printf '%s\n' \
|
||||
'forge_select() { :; }' \
|
||||
'forge_release_exists() { case "$RELEASE_EXISTS" in error) return 1 ;; *) echo "$RELEASE_EXISTS" ;; esac; }' \
|
||||
>"$TMP/tag-guard-lib/lib/forge.sh"
|
||||
|
||||
tag_guard_extracted() { [ "$(wc -l <"$TAG_GUARD")" -ge 8 ]; }
|
||||
run_tag_guard() {
|
||||
CEREMONY_DIR="$TMP/tag-guard-lib" VER=1.2.3 RELEASE_EXISTS="$1" "$TAG_GUARD"
|
||||
}
|
||||
tag_door_has_no_tag_exists_guard() {
|
||||
! yq -r '.jobs.release-on-tag.steps[].run // ""' "$ROOT/.github/workflows/release.yml" | grep -q 'ls-remote'
|
||||
}
|
||||
check "the tag door's published-release guard is extracted" 0 "" tag_guard_extracted
|
||||
check "the tag door refuses an existing published release" 1 "release '1.2.3' already exists" run_tag_guard yes
|
||||
check "the tag door proceeds when no published release exists" 0 "" run_tag_guard no
|
||||
check "the tag door refuses when release state is unreadable" 1 "refusing rather than assuming" run_tag_guard error
|
||||
check "the tag door carries no tag-exists guard" 0 "" tag_door_has_no_tag_exists_guard
|
||||
writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
|
||||
|
||||
# --- the github twins address their own paths ----------------------------
|
||||
. "$ROOT/lib/forge-github.sh"
|
||||
|
|
|
|||
|
|
@ -101,28 +101,6 @@ check "empty labels do not exempt a claimed issue" 0 "SWEEP" claim_clock_exempt
|
|||
refs_body=$'Refs #12\nAlso refs: #8 and heavy-duty/rig#4.\nCloses #99\nNot refs-ish #7\nfix refs parsing from #200\nCloses #40; refs: none\nRefs #175 (split from #150)'
|
||||
check "Refs parser returns only references owned by a valid Refs marker" 0 "" \
|
||||
test "$(refs_references <<<"$refs_body")" = $'8\n12\n175'
|
||||
# A Refs declaration binds one token, never the prose that follows it; every
|
||||
# later occurrence starts a fresh declaration (#234).
|
||||
# shellcheck disable=SC2016 # backticks are the fixture's literal Markdown
|
||||
crew_round_line='- **Claude verified the safe `Refs #52`, TDD provenance, and inherited #65 CI failure.**'
|
||||
check "Refs parser ignores issue prose after the bound token" 0 "" \
|
||||
test "$(refs_references <<<"$crew_round_line")" = 52
|
||||
check "Refs comma-list narrowing is deliberate" 0 "" \
|
||||
test "$(refs_references <<<'Refs #8, #9')" = 8
|
||||
check "a cross-repo Refs token stays non-local" 0 "" \
|
||||
test -z "$(refs_references <<<'Refs heavy-duty/rig#4')"
|
||||
check "prose after a cross-repo Refs token stays non-local" 0 "" \
|
||||
test -z "$(refs_references <<<'Refs heavy-duty/rig#4, #12')"
|
||||
check "parenthesized prose after a local Refs token is ignored" 0 "" \
|
||||
test "$(refs_references <<<'Refs #175 (split from #150)')" = 175
|
||||
check "every Refs occurrence on one line contributes its bound token" 0 "" \
|
||||
test "$(refs_references <<<'Refs #8. Refs #9.')" = $'8\n9'
|
||||
check "a concatenated Refs spelling is not a second keyword occurrence" 0 "" \
|
||||
test "$(refs_references <<<'Refs #8Refs #9')" = 8
|
||||
check "lowercase refs in unrelated prose declares nothing" 0 "" \
|
||||
test -z "$(refs_references <<<'fix refs parsing from #200')"
|
||||
check "Refs without a following token declares nothing" 0 "" \
|
||||
test -z "$(refs_references <<<'Closes #40; refs: none')"
|
||||
open_records=$'BODY\tRefs #5\nCLOSING\t9\nBODY\tRefs heavy-duty/rig#112\nBODY\tRefs #5\nCLOSING\t5'
|
||||
check "open PR linkage unions closing and local Refs body references" 0 $'5\n9' \
|
||||
open_pr_issues <<<"$open_records"
|
||||
|
|
@ -524,11 +502,6 @@ check "a release epic with every declared blocker closed announces init" 0 "" \
|
|||
grep -qF '<!-- issueflow:release-init-due -->' "$TMP/posted-53"
|
||||
check "the init announce names all five steps" 0 "5" \
|
||||
grep -cE '^[1-5]\. ' "$TMP/posted-53"
|
||||
# Release-init is where the membership record is first written, so step 3
|
||||
# names it beside the waves and the progress task list.
|
||||
# shellcheck disable=SC2016 # backticks are the comment body's own Markdown
|
||||
check "step 3 names the membership record it first writes" 0 "" \
|
||||
grep -qF '3. Write ordered waves, the `## Members` record' "$TMP/posted-53"
|
||||
# shellcheck disable=SC2016 # backticks are the literal portable doctrine citation
|
||||
check "the init announce cites the portable vendored doctrine path" 0 "" \
|
||||
grep -qF 'See `.ceremony/RELEASES.md`.' "$TMP/posted-53"
|
||||
|
|
@ -741,16 +714,9 @@ check "the flag-free control is reclaimed (the clock still runs elsewhere)" 0 ""
|
|||
|
||||
# -- merged Refs work releases the claim before the reclaim clock ------------
|
||||
printf '[]\n' >"$(cfix 35)"
|
||||
COLLISION_FLAGS=$'35\tissueflow-reconcile=34'
|
||||
WINDOW_FLAGS=$'35\t#50'
|
||||
transition="$(issue_probe 35 claimed 1 false 350 $'- [x] built\n- [ ] verify dispatch\n * [ ] confirm warning clears')"
|
||||
unset COLLISION_FLAGS WINDOW_FLAGS
|
||||
check "merged Refs + unchecked criteria transitions in the sweep body" 0 "" \
|
||||
grep -q 'merged Refs PR -> post-merge; claim released' <<<"$transition"
|
||||
check "a pass concluding post-merge draws no precomputed collision flag" 1 "" \
|
||||
grep -q 'collision flag' <<<"$transition"
|
||||
check "...and no precomputed window flag" 1 "" \
|
||||
grep -q 'window flag' <<<"$transition"
|
||||
# shellcheck disable=SC2016 # positional parameters belong to bash -c
|
||||
check "...names every remaining criterion verbatim in the comment" 0 "" \
|
||||
bash -c 'grep -qF -- "- [ ] verify dispatch" "$1" &&
|
||||
|
|
@ -1813,151 +1779,6 @@ check "a PR reads as a PR on either shape" 0 "pr" \
|
|||
check "the old has() test misreads a forgejo issue as a PR" 0 "pr" \
|
||||
bash -c 'echo "{\"number\":1,\"pull_request\":null}" | jq -e "has(\"pull_request\") | not" >/dev/null && echo issue || echo pr'
|
||||
|
||||
# ...AND THE GATHER, because the rows above assert jq expressions in isolation
|
||||
# and passed all the way through #210 — a sweep that saw zero issues on every
|
||||
# pass and printed `reconciled.` The 0.6.0 merge reintroduced the has() form in
|
||||
# the board gather; these cases could not see it because they never ran it.
|
||||
#
|
||||
# The fixture is FORGEJO-SHAPED: every entry carries `pull_request`, valued
|
||||
# null on an issue and an object on a PR. On a GitHub-shaped board (key absent
|
||||
# on issues) both discriminators agree, which is why this needs its own board.
|
||||
FORGEJO_BOARD="$TMP/forgejo-board"
|
||||
mkdir -p "$FORGEJO_BOARD"
|
||||
cp "$ARRIVAL/labels.conf" "$FORGEJO_BOARD/labels.conf" 2>/dev/null || true
|
||||
printf '[]\n' >"$FORGEJO_BOARD/repos_owner_repo_pulls_state_open.json"
|
||||
printf '[]\n' >"$FORGEJO_BOARD/repos_owner_repo_pulls_state_closed.json"
|
||||
printf '%s\n' \
|
||||
'[{"number":60,"pull_request":null,"labels":[],"title":"an issue with no queue state"},
|
||||
{"number":61,"pull_request":{"merged":false},"labels":[],"title":"a pull request"}]' \
|
||||
>"$FORGEJO_BOARD/repos_owner_repo_issues_state_open.json"
|
||||
jq -n --arg at "$(iso_at "$INOW")" \
|
||||
'{number:60,user:{login:"triage-one"},created_at:$at,body:"",pull_request:null,
|
||||
labels:[],assignees:[]}' \
|
||||
>"$FORGEJO_BOARD/repos_owner_repo_issues_60.json"
|
||||
printf '[]\n' >"$FORGEJO_BOARD/repos_owner_repo_issues_60_comments.json"
|
||||
forgejo_board_run() {
|
||||
: >"$FORGEJO_BOARD/edits"
|
||||
env PATH="$ARRIVAL/stub:$PATH" CEREMONY_FORGE=github GH_FIXTURES="$FORGEJO_BOARD" \
|
||||
ISSUEFLOW_NOW="$INOW" REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \
|
||||
bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1
|
||||
}
|
||||
fjb_out="$(forgejo_board_run)"
|
||||
check "a forgejo-shaped board is NOT read as empty" 1 "" \
|
||||
grep -qF 'issueflow: no open issues.' <<<"$fjb_out"
|
||||
check "...the sweep completes over it" 0 "" \
|
||||
grep -qF 'issueflow: reconciled.' <<<"$fjb_out"
|
||||
# TRAVERSAL, not merely a non-empty gather: the null-valued row has a
|
||||
# deterministic outcome — no queue state means needs-triage is minted — so this
|
||||
# proves reconcile_issue_pass actually ran over it, which "the board is not
|
||||
# empty" does not (@codex-reviewer-andresmgsl, #210 review).
|
||||
check "...the null-valued row is TRAVERSED, with an observable outcome" 0 "" \
|
||||
grep -qE '^issueflow: #60: needs-triage' <<<"$fjb_out"
|
||||
check "...and the object-valued PR row is not reconciled as an issue" 1 "" \
|
||||
grep -qE '^issueflow: #61' <<<"$fjb_out"
|
||||
|
||||
# release_numbers is the THIRD producer and has its own issue-shape filter. A `release`
|
||||
# issue on a forgejo-shaped board must reach the window gather, or the #292
|
||||
# flags are decided over an empty set (@codex-reviewer-andresmgsl, #210).
|
||||
printf '%s\n' \
|
||||
'[{"number":60,"pull_request":null,"labels":[{"name":"ready"}],"title":"an issue"},
|
||||
{"number":62,"pull_request":null,"labels":[{"name":"release"}],"title":"Release 9.9.9","body":"Blocked by #59.\n\n## Members\n- #60"},
|
||||
{"number":63,"pull_request":null,"labels":[{"name":"ready"}],"title":"a claimable non-member"},
|
||||
{"number":61,"pull_request":{"merged":false},"labels":[],"title":"a pull request"}]' \
|
||||
>"$FORGEJO_BOARD/repos_owner_repo_issues_state_open.json"
|
||||
jq -n --arg at "$(iso_at "$INOW")" \
|
||||
'{number:63,user:{login:"triage-one"},created_at:$at,body:"",pull_request:null,
|
||||
labels:[{name:"ready"}],assignees:[]}' \
|
||||
>"$FORGEJO_BOARD/repos_owner_repo_issues_63.json"
|
||||
printf '[]\n' >"$FORGEJO_BOARD/repos_owner_repo_issues_63_comments.json"
|
||||
jq -n --arg at "$(iso_at "$INOW")" \
|
||||
'{number:62,user:{login:"triage-one"},created_at:$at,body:"Blocked by #59.\n\n## Members\n- #60",pull_request:null,
|
||||
labels:[{name:"release"}],assignees:[]}' \
|
||||
>"$FORGEJO_BOARD/repos_owner_repo_issues_62.json"
|
||||
printf '[]\n' >"$FORGEJO_BOARD/repos_owner_repo_issues_62_comments.json"
|
||||
fjb2_out="$(forgejo_board_run)"
|
||||
# The observable effect of release_numbers being NON-empty: an open `release`
|
||||
# issue whose membership record still holds an open member makes every claimable non-member
|
||||
# draw a window flag. With that gather empty there are no carriers and no flag,
|
||||
# so this row discriminates the site rather than merely reaching it.
|
||||
check "a release issue on a forgejo-shaped board reaches the window gather" 0 "" \
|
||||
grep -qE '#63: window flag' <<<"$fjb2_out"
|
||||
check "...and the sweep still completes" 0 "" \
|
||||
grep -qF 'issueflow: reconciled.' <<<"$fjb2_out"
|
||||
|
||||
# The THIRD site is reconcile_issue_pass's per-issue payload check. Same key,
|
||||
# scalar rather than a list: null means issue, an object means PR.
|
||||
# THE SCALAR SITE, ISOLATED. `BOARD_RECORDS` filters an object-valued row out
|
||||
# of the LIST before the per-issue guard ever sees it, so a board fixture alone
|
||||
# cannot prove the scalar stand-down (@codex-reviewer-andresmgsl, #210 review).
|
||||
#
|
||||
# The fixture that isolates it: the LIST row is null-valued, so the board
|
||||
# gather admits it — and the INDIVIDUAL payload the sweep then fetches is
|
||||
# object-valued. Only reconcile_issue_pass's own guard can stand that down.
|
||||
printf '%s\n' \
|
||||
'[{"number":65,"pull_request":null,"labels":[],"title":"list says issue, payload says PR"}]' \
|
||||
>"$FORGEJO_BOARD/repos_owner_repo_issues_state_open.json"
|
||||
jq -n --arg at "$(iso_at "$INOW")" \
|
||||
'{number:65,user:{login:"triage-one"},created_at:$at,body:"",
|
||||
pull_request:{merged:false},labels:[],assignees:[]}' \
|
||||
>"$FORGEJO_BOARD/repos_owner_repo_issues_65.json"
|
||||
printf '[]\n' >"$FORGEJO_BOARD/repos_owner_repo_issues_65_comments.json"
|
||||
fjb3_out="$(forgejo_board_run)"
|
||||
check "the per-issue guard stands down an object-valued payload" 1 "" \
|
||||
grep -qE '^issueflow: #65: needs-triage' <<<"$fjb3_out"
|
||||
check "...and the sweep still completes" 0 "" \
|
||||
grep -qF 'issueflow: reconciled.' <<<"$fjb3_out"
|
||||
|
||||
# The same fixture with a null-valued payload MUST reconcile — otherwise the row
|
||||
# above would pass on a guard that stands everything down.
|
||||
jq -n --arg at "$(iso_at "$INOW")" \
|
||||
'{number:65,user:{login:"triage-one"},created_at:$at,body:"",
|
||||
pull_request:null,labels:[],assignees:[]}' \
|
||||
>"$FORGEJO_BOARD/repos_owner_repo_issues_65.json"
|
||||
fjb4_out="$(forgejo_board_run)"
|
||||
check "...while a null-valued payload at the same site reconciles" 0 "" \
|
||||
grep -qE '^issueflow: #65: needs-triage' <<<"$fjb4_out"
|
||||
|
||||
# And the GitHub shape — key absent entirely — is still an issue.
|
||||
jq -n --arg at "$(iso_at "$INOW")" \
|
||||
'{number:65,user:{login:"triage-one"},created_at:$at,body:"",labels:[],assignees:[]}' \
|
||||
>"$FORGEJO_BOARD/repos_owner_repo_issues_65.json"
|
||||
fjb5_out="$(forgejo_board_run)"
|
||||
check "...and a github-shaped payload (key absent) reconciles too" 0 "" \
|
||||
grep -qE '^issueflow: #65: needs-triage' <<<"$fjb5_out"
|
||||
|
||||
# -- the rule is pinned at the source, because a comment did not hold --------
|
||||
# `.pull_request == null` is stated in this file's own header AND at
|
||||
# issueflow-reconcile.sh:1113 — and the merge put `has("pull_request")` back 40
|
||||
# lines below that comment, in three places. Prose is not a guard (#210).
|
||||
# In-process, not `bash -c`: a subshell cannot see this file's functions, and a
|
||||
# pin that silently inspected nothing would be the same defect one level up.
|
||||
no_has_pull_request() {
|
||||
local hits
|
||||
hits="$(sed 's/[[:space:]]#.*$//; s/^[[:space:]]*#.*$//' \
|
||||
"$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" \
|
||||
| grep -n 'has("pull_request")')"
|
||||
[ -z "$hits" ] || { printf 'executable has("pull_request") at:\n%s\n' "$hits" >&2; return 1; }
|
||||
}
|
||||
check "no executable has(\"pull_request\") survives on this surface" 0 "" \
|
||||
no_has_pull_request
|
||||
# The controls, because this guard MUST tolerate the #188 comment that explains
|
||||
# why the form is wrong — a raw grep would either fail forever or pressure a
|
||||
# builder into deleting the very warning that prevents recurrence
|
||||
# (@codex-reviewer-andresmgsl, #210 review).
|
||||
DTMP="$(mktemp -d)"; trap 'rm -rf "$DTMP"' EXIT
|
||||
strip_and_find() { # $1 = file -> 0 when an EXECUTABLE use survives
|
||||
sed 's/[[:space:]]#.*$//; s/^[[:space:]]*#.*$//' "$1" | grep -q 'has("pull_request")'
|
||||
}
|
||||
# shellcheck disable=SC2016 # fixture CONTENT: the literal text a scanned file would hold
|
||||
printf '%s\n' '#!/usr/bin/env bash' \
|
||||
'# `.pull_request == null`, NOT has("pull_request") | not (#188)' \
|
||||
'jq -e ".pull_request == null" <<<"$J"' >"$DTMP/prose.sh"
|
||||
check "the explanatory #188 comment is allowed" 1 "" strip_and_find "$DTMP/prose.sh"
|
||||
# single-quoted so the fixture holds the LITERAL form the guard looks for
|
||||
printf '%s\n' '#!/usr/bin/env bash' \
|
||||
"jq -r '.[] | select(has(\"pull_request\") | not)' <<<\"\$J\"" >"$DTMP/exec.sh"
|
||||
check "...while an executable jq filter is rejected" 0 "" strip_and_find "$DTMP/exec.sh"
|
||||
|
||||
# -- 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 ->
|
||||
|
|
@ -2418,215 +2239,6 @@ check "...with the older carrier still asked for nothing" 1 "" \
|
|||
collision_flags_issue 257 \
|
||||
<<<$'257\tready\tissueflow-reconcile.sh + issueflow-reconcile.test.sh — one\n284\tready\tactions/issueflow-reconcile — two'
|
||||
|
||||
# -- a release carrier is not a member of its own gate (#327 D1) -----------
|
||||
self_gate_body='A member narrates Blocked by #163.'
|
||||
check "a self-only parsed gate does not make its release issue a carrier" 1 "" \
|
||||
grep -q . < <(release_window_gate 163 $'163\n164' <<<"$self_gate_body")
|
||||
mixed_gate_body='Blocked by #163, #164, #165.'
|
||||
check "an open non-self member still makes the release issue a carrier" 0 \
|
||||
$'163\t164\n163\t165' \
|
||||
release_window_gate 163 $'163\n164' <<<"$mixed_gate_body"
|
||||
# shellcheck disable=SC2016 # awk fields belong to awk, not the shell
|
||||
check "the carrier number never contributes to its own WINDOW_GATE" 1 "" \
|
||||
awk -F '\t' '$2 == 163 { found = 1 } END { exit !found }' \
|
||||
< <(release_window_gate 163 $'163\n164' <<<"$mixed_gate_body")
|
||||
|
||||
# -- the membership record, read by heading (#343 D2) -----------------------
|
||||
# The record is a machine record with one shape, and every case below is a
|
||||
# shape a real release body already carries. The corpus is heavy-duty/crew#346
|
||||
# — 21 members, a literal "## The members, in claim order" narration heading,
|
||||
# rows citing merged PRs and another repository, one row annotating an issue as
|
||||
# explicitly NOT a member, a verification lane that is not in the build queue,
|
||||
# and a "## Task list" progress view beside all of it.
|
||||
# Bracketed, so the assertion is the WHOLE set and not a prefix of it: a
|
||||
# substring match on a bare list would let an extra member in silently, which
|
||||
# is the one direction every mutation below travels.
|
||||
membership_set() { # release body on stdin -> its members as one bracketed line
|
||||
printf '[%s]\n' "$(membership_references | tr '\n' ' ' | sed 's/[[:space:]]*$//')"
|
||||
}
|
||||
membership_body="$(printf '%s\n' \
|
||||
'The 0.6.0 window. Blocked by #249.' \
|
||||
'A quoted declaration in narration: "Blocked by #906" is what the epic says.' \
|
||||
'' \
|
||||
'## The members, in claim order' \
|
||||
'- #900 — a narration heading, not the record' \
|
||||
'' \
|
||||
'## Members' \
|
||||
'- [ ] #253 — landed as #901, ports heavy-duty/crew#346, and #902 is not a member' \
|
||||
'- #257' \
|
||||
'- [x] #264 — landed' \
|
||||
'- crew#348 — a parallel track in another repository' \
|
||||
'- #266, #276 — two references on one row' \
|
||||
'- the verification lane, not in the build queue' \
|
||||
'- #249 — the sink itself' \
|
||||
'' \
|
||||
'## Task list' \
|
||||
'- [ ] #281 — the progress view')"
|
||||
check "the record enrols exactly its rows' bare first tokens" 0 "[249 253 257 264]" \
|
||||
membership_set <<<"$membership_body"
|
||||
# The heading is ANCHORED. crew#346 carries this exact narration heading, so a
|
||||
# substring or prefix match reads it as the record and enrols its rows.
|
||||
check "a narrated members heading is not the record" 1 "" \
|
||||
grep -qx 900 < <(membership_references <<<"$membership_body")
|
||||
# The row's FIRST token, not every reference in it. epic_references prints the
|
||||
# whole row and takes them all, which is right for a progress view and wrong
|
||||
# here: these three are a merged PR, a sibling repository, and an issue the row
|
||||
# itself names as a non-member.
|
||||
check "a row's prose PR reference is not a member" 1 "" \
|
||||
grep -qx 901 < <(membership_references <<<"$membership_body")
|
||||
check "...nor an issue the row names as explicitly not a member" 1 "" \
|
||||
grep -qx 902 < <(membership_references <<<"$membership_body")
|
||||
check "...nor a qualified reference in the prose" 1 "" \
|
||||
grep -qx 346 < <(membership_references <<<"$membership_body")
|
||||
# Silence, not a guess: a first token that is not a bare local reference
|
||||
# contributes nothing, whether it is qualified, punctuated, or prose.
|
||||
check "a qualified first token contributes no member" 1 "" \
|
||||
grep -qx 348 < <(membership_references <<<"$membership_body")
|
||||
check "a punctuated first token contributes no member" 1 "" \
|
||||
grep -qx 266 < <(membership_references <<<"$membership_body")
|
||||
check "...and the second reference on that row contributes none either" 1 "" \
|
||||
grep -qx 276 < <(membership_references <<<"$membership_body")
|
||||
# The record ends at the next heading, so the progress view beside it is not
|
||||
# membership — that separation is the whole reason the two lists are distinct.
|
||||
check "a task-list reference is not a member" 1 "" \
|
||||
grep -qx 281 < <(membership_references <<<"$membership_body")
|
||||
check "...nor a reference in narration outside the record" 1 "" \
|
||||
grep -qx 906 < <(membership_references <<<"$membership_body")
|
||||
check "an unchecked row and a checked row enrol alike" 0 $'253\n264' \
|
||||
membership_references <<<"$membership_body"
|
||||
check "a bare row with no checkbox enrols too" 0 "257" \
|
||||
membership_references <<<"$membership_body"
|
||||
# Case-insensitive, trailing whitespace tolerated — the shape `## Task list`
|
||||
# already has, stated once in RELEASES.md and implemented once here.
|
||||
check "the heading matches case-insensitively with trailing whitespace" 0 "412" \
|
||||
membership_references <<<"$(printf '%s\n' '## MEMBERS ' '- #412 — admitted')"
|
||||
# Every CommonMark list marker opens a row, and only those. A row is whatever a
|
||||
# reader sees as one, so a marker class narrower than the set Markdown renders
|
||||
# would drop a member a human wrote — and take the standing window down with
|
||||
# it, which is silence in the one place D2 does not want silence. A class WIDER
|
||||
# than it is the same error mirrored, and the more dangerous direction: a line
|
||||
# no renderer reads as a row becomes a member, and one phantom open member
|
||||
# keeps a window standing and suppresses its non-member flag. Only the first
|
||||
# token rule decides what a row MEANS; the marker class decides what a row IS.
|
||||
marker_body="$(printf '%s\n' \
|
||||
'## Members' \
|
||||
'- #412 — a hyphen row' \
|
||||
'* #413 — an asterisk row' \
|
||||
'+ #414 — a plus row' \
|
||||
'1. #415 — an ordered row' \
|
||||
'2) #416 — an ordered row, the paren form' \
|
||||
'123456789. #419 — nine digits, the widest ordered marker there is' \
|
||||
'1234567890. #420 — ten digits, which CommonMark does not render as a row' \
|
||||
' #417 — no marker at all, so not a row')"
|
||||
check "every Markdown list marker opens a member row" 0 \
|
||||
"[412 413 414 415 416 419]" \
|
||||
membership_set <<<"$marker_body"
|
||||
check "a plus row enrols its member" 0 "" \
|
||||
grep -qx 414 < <(membership_references <<<"$marker_body")
|
||||
check "an ordered row enrols its member" 0 "" \
|
||||
grep -qx 415 < <(membership_references <<<"$marker_body")
|
||||
check "...and so does its paren form" 0 "" \
|
||||
grep -qx 416 < <(membership_references <<<"$marker_body")
|
||||
# The bound is CommonMark 5.2's: an ordered marker is 1 to 9 digits then `.` or
|
||||
# `)`. Both sides of it are asserted, because one alone is met by a class that
|
||||
# is merely different rather than right — the 9-digit row is the widest marker
|
||||
# a renderer accepts and must enrol, the 10-digit line is not a list row at all
|
||||
# and must contribute nothing. The bound is written twice in the parser, in the
|
||||
# row match and in the marker strip; a widening of either reds the pair.
|
||||
check "the widest ordered marker CommonMark allows enrols its member" 0 "" \
|
||||
grep -qx 419 < <(membership_references <<<"$marker_body")
|
||||
check "a tenth digit is not an ordered marker, so the line is not a row" 1 "" \
|
||||
grep -qx 420 < <(membership_references <<<"$marker_body")
|
||||
# The marker is what makes the line a row, so a bare reference on its own line
|
||||
# is narration inside the record, not a member. Indented deliberately: at
|
||||
# column 0 a `#` would end the record as a heading, and this assertion is about
|
||||
# the marker, not the terminator.
|
||||
check "a line with no list marker is not a row" 1 "" \
|
||||
grep -qx 417 < <(membership_references <<<"$marker_body")
|
||||
# Indentation bounds the row the way the digit count bounds the marker, and both
|
||||
# sides are asserted for the same reason: a bound met on one side alone is a
|
||||
# class merely different rather than right. Three spaces still open a row —
|
||||
# CommonMark 4.4 allows up to three, and refusing them would drop a member a
|
||||
# human wrote and reads. A fourth does not, and what it means depends on context
|
||||
# the line itself does not carry: GitHub renders ` - #N` after `## Members`
|
||||
# as `<pre><code>`, and the same bytes under a `- #N` row as a nested `<li>`.
|
||||
# The record is FLAT, so both are silence: an indented code block is not a row at
|
||||
# all, and a sub-bullet annotating a member row is not a second member. Enrolling
|
||||
# either is the phantom-member direction — an open reference taken from non-row
|
||||
# content keeps a window standing and suppresses its non-member flag. A leading
|
||||
# tab is four columns wherever indentation decides block structure, so it falls
|
||||
# under the same bound.
|
||||
indent_body="$(printf '%s\n' \
|
||||
'## Members' \
|
||||
'- #421 — column zero' \
|
||||
' - #422 — three spaces, the deepest indentation that still opens a row' \
|
||||
' - #423 — four spaces: a sub-row here, an indented code block alone' \
|
||||
$'\t- #424 — a tab, the same four columns, so neither is it')"
|
||||
check "the record admits exactly its unindented and shallowly indented rows" 0 \
|
||||
"[421 422]" \
|
||||
membership_set <<<"$indent_body"
|
||||
check "three spaces still open a row" 0 "" \
|
||||
grep -qx 422 < <(membership_references <<<"$indent_body")
|
||||
check "a row indented past the bound is a sub-row, and not a second member" 1 "" \
|
||||
grep -qx 423 < <(membership_references <<<"$indent_body")
|
||||
check "...and neither is the tab-indented one" 1 "" \
|
||||
grep -qx 424 < <(membership_references <<<"$indent_body")
|
||||
# The same bytes with no row above them, which is the shape the panel found: no
|
||||
# list is open, so the renderer reads an indented code block and there is nothing
|
||||
# for the record to enrol. A body whose record is entirely non-rows enumerates
|
||||
# no membership, and D4 then applies to it like any other empty record.
|
||||
code_block_body="$(printf '%s\n' \
|
||||
'## Members' \
|
||||
' - #425 — four spaces with no list open: an indented code block' \
|
||||
$'\t- #426 — and a tab, the same block')"
|
||||
check "an indented code block inside the record enrols nobody" 0 "[]" \
|
||||
membership_set <<<"$code_block_body"
|
||||
# The terminator itself, pinned where it can be seen: the record ends at the
|
||||
# next line starting with `#`, the shape `## Task list` already has. A bare
|
||||
# unindented reference is therefore the end of the record, not a member of it,
|
||||
# and the rows after it are outside.
|
||||
check "an unindented bare reference ends the record" 0 "[412]" \
|
||||
membership_set <<<"$(printf '%s\n' '## Members' '- #412' '#417' '- #418')"
|
||||
|
||||
# -- the carrier decision reads the record (#343 D3, D4, D5) ----------------
|
||||
check "an open member in the record makes the release issue a carrier" 0 \
|
||||
$'249\t253\n249\t257' \
|
||||
release_window_members 249 $'249\n253' \
|
||||
<<<"$(printf '%s\n' '## Members' '- #253' '- #257')"
|
||||
# D4: no fallback. This is THE defect's own state — #317 from its mint until
|
||||
# #249 closed at 2026-08-05T11:12Z, and crew's fifteen version epics at 0.6.0
|
||||
# adoption: a version epic declaring its predecessor exactly as *Gates*
|
||||
# instructs and enumerating nothing. Falling back to the gate here restores
|
||||
# the reading that made a shut window stand.
|
||||
check "a declared open predecessor with no record is not a carrier (D4)" 1 "" \
|
||||
grep -q . < <(release_window_members 317 $'249\n317\n343' <<<'Blocked by #249.')
|
||||
check "...and an empty record is not a carrier either" 1 "" \
|
||||
grep -q . < <(release_window_members 249 $'249\n253' \
|
||||
<<<"$(printf '%s\n' '## Members' '' '## Task list' '- [ ] #253')")
|
||||
check "...nor is a record whose every member has closed" 1 "" \
|
||||
grep -q . < <(release_window_members 249 $'249\n264' \
|
||||
<<<"$(printf '%s\n' '## Members' '- #218' '- #230')")
|
||||
# D5: #327's self-exclusion, inherited rather than re-decided. Both readings
|
||||
# share release_window_records, so the guard cannot hold on one side only.
|
||||
check "a membership row naming the carrier contributes no member (D5)" 1 "" \
|
||||
grep -q . < <(release_window_members 249 $'249\n253' \
|
||||
<<<"$(printf '%s\n' '## Members' '- #249 — the sink itself')")
|
||||
# shellcheck disable=SC2016 # awk fields belong to awk, not the shell
|
||||
check "...and never contributes to WINDOW_MEMBERS beside a real member" 1 "" \
|
||||
awk -F '\t' '$2 == 249 { found = 1 } END { exit !found }' \
|
||||
< <(release_window_members 249 $'249\n253' \
|
||||
<<<"$(printf '%s\n' '## Members' '- #249' '- #253')")
|
||||
|
||||
# The snapshot may nominate a flag before this issue's own queue branch runs;
|
||||
# the pure second gate reads the state that branch actually concluded.
|
||||
check "a pass concluding ready still permits both board flags" 0 "" \
|
||||
board_flags_in_scope ready
|
||||
check "a pass concluding claimed still permits both board flags" 0 "" \
|
||||
board_flags_in_scope claimed
|
||||
check "a pass concluding post-merge silences both board flags" 1 "" \
|
||||
board_flags_in_scope post-merge
|
||||
|
||||
|
||||
# -- the window decision (#292 D1) ------------------------------------------
|
||||
window_board=$'249\tblocked,release\tRelease 0.6.0 — the board empties\n253\tclaimed\tissueflow-reconcile — a member\n264\tready\tTRIAGE.md — a non-member\n270\tepic\tsome epic — exempt\n271\tpost-merge\tsome item — exempt\n272\tblocked\tsome issue — already placed'
|
||||
check "a ready non-member is flagged during a standing window" 0 "264" \
|
||||
|
|
@ -2717,7 +2329,7 @@ board_run() {
|
|||
# them — six `ready` non-members against a standing gate, and one deliverable
|
||||
# carried three times in two spellings.
|
||||
board_issue 249 blocked,release 'Release 0.6.0 — the board empties into the tag' \
|
||||
"$(printf '%s\n' 'Blocked by #253.' '' '## Members' '- #253')"
|
||||
'Blocked by #253.'
|
||||
board_issue 253 claimed 'issueflow-reconcile — a release epic announces its own release-init' '' 1
|
||||
board_issue 257 ready 'actions/issueflow-reconcile — a failed board read sweeps an empty board'
|
||||
board_issue 264 ready 'TRIAGE.md — the no-assignee clause scopes to the flag'
|
||||
|
|
@ -2777,18 +2389,7 @@ check "the window comment names #292's invariant" 0 "" \
|
|||
grep -qF "#292's invariant" "$BOARD/edits"
|
||||
# shellcheck disable=SC2016 # backticks are the comment body's own Markdown
|
||||
check "...and states the subset rule with its exemptions" 0 "" \
|
||||
grep -qF 'the `ready` set is a subset of that' "$BOARD/edits"
|
||||
# shellcheck disable=SC2016 # backticks are the comment body's own Markdown
|
||||
check "...and tells triage where membership is actually read from" 0 "" \
|
||||
grep -qF 'Membership is read from the release issue'"'"'s own `## Members` record' \
|
||||
"$BOARD/edits"
|
||||
# shellcheck disable=SC2016 # backticks are the comment body's own Markdown
|
||||
check "...and says what a release issue's Blocked by line does answer" 0 "" \
|
||||
grep -qF 'answers its predecessor gate and never its membership' "$BOARD/edits"
|
||||
check "...and asks the third write for a row, not a declaration" 0 "" \
|
||||
grep -qF 'the release issue gains a row for' "$BOARD/edits"
|
||||
check "no window comment sends triage to a Blocked by declaration" 1 "" \
|
||||
grep -qF 'The gate is read from the release issue' "$BOARD/edits"
|
||||
grep -qF 'the `ready` set is a subset of the gate' "$BOARD/edits"
|
||||
check "both comments carry idempotency markers (D4)" 0 "" \
|
||||
grep -qF '<!-- issueflow:collision-' "$BOARD/edits"
|
||||
check "...the window one too" 0 "" grep -qF '<!-- issueflow:window-nonmember-' "$BOARD/edits"
|
||||
|
|
@ -2853,8 +2454,7 @@ check "...while every other collision on the board still speaks" 0 "2" \
|
|||
# oldest-first, the reconciler chain chained, and every one of them a gate
|
||||
# member. Every flag above must go quiet, or the flag is reporting the fix.
|
||||
board_issue 249 blocked,release 'Release 0.6.0 — the board empties into the tag' \
|
||||
"$(printf '%s\n' 'Blocked by #253.' '' '## Members' \
|
||||
'- #253' '- #257' '- #264' '- #266' '- #276' '- #281' '- #282' '- #284')"
|
||||
'Blocked by #253, #257, #264, #266, #276, #281, #282, #284.'
|
||||
board_issue 253 claimed 'issueflow-reconcile — a release epic announces its own release-init' '' 1
|
||||
board_issue 257 blocked 'actions/issueflow-reconcile — a failed board read sweeps an empty board' \
|
||||
'Blocked by #253.'
|
||||
|
|
@ -2877,23 +2477,21 @@ check "...and no window flag either" 1 "" grep -qF ': window flag' <<<"$ruled_ou
|
|||
check "...and still reports a whole pass" 0 'issueflow: reconciled.' \
|
||||
printf '%s\n' "$ruled_out"
|
||||
|
||||
# -- an emptied window leaves the flag dormant (test plan) ------------------
|
||||
# A membership RECORD never empties: #249 names fifteen members and still
|
||||
# names fifteen after all fifteen close. So the precondition is the record's OPEN
|
||||
# -- an emptied gate leaves the window flag dormant (test plan) -------------
|
||||
# A gate DECLARATION never empties: #249 names fifteen members and still names
|
||||
# fifteen after all fifteen close. So the precondition is the gate's OPEN
|
||||
# members, not its parse — read straight off the board, which already is the
|
||||
# open set. Under the declaration reading the release issue, now `ready`, is
|
||||
# itself an open unblocked non-`epic` non-member, and D3 would flag the sink
|
||||
# at the exact moment the window ends.
|
||||
board_issue 249 ready,release 'Release 0.6.0 — the board empties into the tag' \
|
||||
"$(printf '%s\n' 'Blocked by #217.' '' '## Members' \
|
||||
'- #218' '- #230' '- #232' '- #236' '- #237' '- #238' '- #241' '- #242' \
|
||||
'- #247' '- #248' '- #251' '- #252' '- #253' '- #254' '- #257')"
|
||||
'Blocked by #218, #230, #232, #236, #237, #238, #241, #242, #247, #248, #251, #252, #253, #254, #257.'
|
||||
board_issue 264 ready 'TRIAGE.md — the no-assignee clause scopes to the flag'
|
||||
# shellcheck disable=SC2016 # the backticks are the real issue title's Markdown
|
||||
board_issue 266 ready 'TRIAGE.md — the epic task-list heading is literally `## Task list`'
|
||||
board_assemble 249 264 266
|
||||
empty_gate_out="$(board_run)"
|
||||
check "a fifteen-member record with every member closed leaves D3 dormant" 1 "" \
|
||||
check "a fifteen-member declaration with every member closed leaves D3 dormant" 1 "" \
|
||||
grep -qF ': window flag' <<<"$empty_gate_out"
|
||||
check "...and the release issue is never flagged as its own non-member" 1 "" \
|
||||
grep -qF 'issueflow: #249' <<<"$empty_gate_out"
|
||||
|
|
@ -2940,7 +2538,7 @@ printf '%s\n' \
|
|||
'{"data":{"repository":{"pullRequests":{"nodes":[{"number":403,"body":"","closingIssuesReferences":{"nodes":[{"number":402}]}}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \
|
||||
>"$BOARD/repos_owner_repo_pulls_state_open.json"
|
||||
board_issue 249 blocked,release 'Release 0.6.0 — the board empties into the tag' \
|
||||
"$(printf '%s\n' 'Blocked by #253.' '' '## Members' '- #253')"
|
||||
'Blocked by #253.'
|
||||
board_issue 253 claimed 'issueflow-reconcile — a member holding the window open' '' 1
|
||||
board_issue 402 claimed 'REVIEWER.md — a non-member with a builder and a round' '' 1
|
||||
board_assemble 249 253 402
|
||||
|
|
@ -2988,7 +2586,7 @@ printf '%s\n' \
|
|||
'{"data":{"repository":{"pullRequests":{"nodes":[],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \
|
||||
>"$BOARD/repos_owner_repo_pulls_state_open.json"
|
||||
board_issue 249 blocked,release 'Release 0.6.0 — the board empties into the tag' \
|
||||
"$(printf '%s\n' 'Blocked by #307.' '' '## Members' '- #293' '- #307')"
|
||||
'Blocked by #293, #307.'
|
||||
board_issue 293 claimed 'issueflow-reconcile — the sweep flags what the window and collision rules forbid' '' 1
|
||||
board_issue 307 blocked 'test/issueflow-reconcile.test.sh — the ruling pre-read is unpinned' \
|
||||
'Blocked by #293.'
|
||||
|
|
@ -3001,37 +2599,6 @@ check "...and no window flag: the claimed member is a member" 1 "" \
|
|||
check "...and still reports a whole pass" 0 'issueflow: reconciled.' \
|
||||
printf '%s\n' "$today_out"
|
||||
|
||||
# -- a predecessor gate is not a membership record (#343) ------------------
|
||||
board_issue 317 epic,release '0.7.0 — rc becomes native' 'Blocked by #249.'
|
||||
board_issue 249 epic,release 'Release 0.6.0 — the predecessor, still open' ''
|
||||
board_issue 343 ready 'RELEASES.md + TRIAGE.md — membership gets its own record'
|
||||
board_issue 345 ready 'actions/issueflow-reconcile — a failed dependency read'
|
||||
board_assemble 317 249 343 345
|
||||
shut_window_out="$(board_run)"
|
||||
check "the shut window's board replays green" 0 "" test $? -eq 0
|
||||
check "an epic declaring an open predecessor stands no window" 1 "" \
|
||||
grep -qF ': window flag' <<<"$shut_window_out"
|
||||
check "...so the ready issues it would have accused are left alone" 1 "" \
|
||||
grep -qE '#(343|345): window flag' <<<"$shut_window_out"
|
||||
check "...and no window state naming it is ever rendered" 1 "" \
|
||||
grep -qF 'under #317' <<<"$shut_window_out"
|
||||
check "...and the board is still swept whole" 0 'issueflow: reconciled.' \
|
||||
printf '%s\n' "$shut_window_out"
|
||||
|
||||
# Over-correction control: once the record enumerates an open member, the
|
||||
# window stands and still flags the unblocked non-member.
|
||||
board_issue 317 epic,release '0.7.0 — rc becomes native' \
|
||||
"$(printf '%s\n' 'Blocked by #249.' '' '## Members' '- #343 — the first member')"
|
||||
board_assemble 317 249 343 345
|
||||
opened_window_out="$(board_run)"
|
||||
check "the same epic enumerating an open member does stand a window" 0 \
|
||||
'issueflow: #345: window flag — an unblocked non-member under #317' \
|
||||
printf '%s\n' "$opened_window_out"
|
||||
check "...and its enumerated member is not flagged" 1 "" \
|
||||
grep -qF 'issueflow: #343: window flag' <<<"$opened_window_out"
|
||||
check "...one window flag on that board, and only one" 0 "1" \
|
||||
flag_count window "$opened_window_out"
|
||||
|
||||
# -- the invariant is enforced at the source, not remembered ----------------
|
||||
# Staging only holds while every mutation goes through run(). A future call
|
||||
# site reaching gh directly would reopen this hole silently, so it is pinned
|
||||
|
|
|
|||
|
|
@ -1,93 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# The bootstrap bridge across the workflow_call boundary (#215).
|
||||
#
|
||||
# The defect: a called workflow cannot read the caller's dispatch inputs on
|
||||
# this forge — `github.event.inputs.*` is empty inside `workflow_call` even
|
||||
# though the top-level caller receives the value in both contexts (probe runs
|
||||
# 6/7). The old gate read exactly that, so every dispatch-woken sweep
|
||||
# bootstrapped (runs 459/523). The fix moves the value through a DECLARED
|
||||
# `workflow_call` input, passed by the caller, with empty mapped to "no" at
|
||||
# the caller so a cron can never bootstrap.
|
||||
#
|
||||
# These cases pin the wiring at every hop and drive the four value paths
|
||||
# through the semantics of the exact expressions shipped — extracted from the
|
||||
# YAML, never retyped, so an edited expression is an edited test input.
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
# shellcheck source=test/harness.sh
|
||||
. "$ROOT/test/harness.sh"
|
||||
|
||||
REUSABLE="$ROOT/.github/workflows/labels-sweep.yml"
|
||||
CALLER="$ROOT/.github/workflows/self-labels-sweep.yml"
|
||||
CONSUMERS="$ROOT/docs/CONSUMERS.md"
|
||||
|
||||
# --- the declared boundary ---------------------------------------------------
|
||||
decl() { yq -r ".on.workflow_call.inputs.bootstrap.$1 // \"\"" "$REUSABLE"; }
|
||||
declares_boundary() { [ -n "$(yq -r '.on.workflow_call.inputs.bootstrap // ""' "$REUSABLE")" ]; }
|
||||
check "labels-sweep.yml declares bootstrap as a workflow_call input" 0 "" declares_boundary
|
||||
check "...typed string" 0 "string" decl type
|
||||
check "...defaulting to no — an absent pass-through must never bootstrap" 0 "no" \
|
||||
decl default
|
||||
|
||||
# --- the gate reads the declared input, and nothing else ---------------------
|
||||
gate_exprs() { yq -r '.jobs[].steps[] | select(.with.bootstrap != null) | .with.bootstrap' "$REUSABLE"; }
|
||||
gates_are_identity() {
|
||||
[ "$(gate_exprs | sort -u)" = "\${{ inputs.bootstrap }}" ] \
|
||||
&& [ "$(gate_exprs | wc -l)" -eq 2 ]
|
||||
}
|
||||
check "both gate sites feed the DECLARED input, unchanged" 0 "" gates_are_identity
|
||||
# The forbidden context is only live inside an expression: the file NAMES it
|
||||
# in comments and in the declared input's description to explain the defect,
|
||||
# and both are prose. Matching raw text asserted on the explanation — the
|
||||
# adjacent-assertion trap this suite keeps re-learning — so the predicate is
|
||||
# scoped to `${{ … }}` bodies.
|
||||
reads_event_inputs() { grep -qE '\$\{\{[^}]*github\.event\.inputs' "$REUSABLE"; }
|
||||
check "no expression in the reusable reads github.event.inputs — the context this forge empties" 1 "" \
|
||||
reads_event_inputs
|
||||
|
||||
# --- the caller passes it through, empty mapped to no ------------------------
|
||||
CALLER_EXPR="$(yq -r '.jobs.sweep.with.bootstrap // ""' "$CALLER")"
|
||||
check "self-labels-sweep.yml passes with.bootstrap through the boundary" 0 "" \
|
||||
test -n "$CALLER_EXPR"
|
||||
check "...with the exact empty-guard expression" 0 "" \
|
||||
test "$CALLER_EXPR" = "\${{ inputs.bootstrap || 'no' }}"
|
||||
|
||||
# The published stub must carry the same bridge, or every consumer inherits
|
||||
# the defect ceremony just fixed for itself.
|
||||
check "the CONSUMERS.md sweep stub passes bootstrap through the boundary" 0 \
|
||||
"bootstrap: \${{ inputs.bootstrap || 'no' }}" \
|
||||
grep -F "bootstrap: \${{ inputs.bootstrap || 'no' }}" "$CONSUMERS"
|
||||
|
||||
# --- the four value paths, through the shipped expressions -------------------
|
||||
# Evaluate the caller expression's semantics for a given top-level value. The
|
||||
# expression is asserted byte-exact above, so modelling `x || 'no'` here is
|
||||
# modelling the string the tree actually ships, not a hope about it.
|
||||
caller_pass() { [ -n "$1" ] && printf '%s' "$1" || printf 'no'; }
|
||||
# The reusable's gate is asserted to be the identity; the value then meets
|
||||
# actions/labels-reconcile's REAL validate step, extracted and executed.
|
||||
VALIDATE="$(mktemp)"
|
||||
trap 'rm -f "$VALIDATE"' EXIT
|
||||
{
|
||||
printf '%s\n' '#!/usr/bin/env bash'
|
||||
yq -r '.runs.steps[] | select(.name == "validate bootstrap input") | .run' \
|
||||
"$ROOT/actions/labels-reconcile/action.yml"
|
||||
} >"$VALIDATE"
|
||||
chmod +x "$VALIDATE"
|
||||
path() { BOOTSTRAP="$(caller_pass "$1")" bash "$VALIDATE"; }
|
||||
|
||||
check "schedule (empty top-level context) validates as a non-bootstrap sweep" 0 "" path ""
|
||||
check "a REST event wake passing no validates as a non-bootstrap sweep" 0 "" path no
|
||||
check "a manual dispatch passing yes validates as a bootstrap" 0 "" path yes
|
||||
invalid_path() { BOOTSTRAP="maybe" bash "$VALIDATE"; }
|
||||
check "an invalid value reaches the validator UNSANITIZED and refuses" 2 \
|
||||
"bootstrap must be 'yes' or 'no'" invalid_path
|
||||
|
||||
# ...and the non-bootstrap/bootstrap split is what the validator's callers
|
||||
# act on: prove the two accepted values are distinguished, not merely both
|
||||
# accepted, by pinning what each resolves to after the caller pass.
|
||||
check "empty and no resolve identically — the cron can never bootstrap" 0 "" \
|
||||
test "$(caller_pass "")" = "$(caller_pass no)"
|
||||
check "...and yes stays yes through the pass" 0 "" test "$(caller_pass yes)" = yes
|
||||
|
||||
summary
|
||||
|
|
@ -1,188 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# The sweep dispatch in .github/workflows/labels.yml (#205).
|
||||
#
|
||||
# This EXTRACTS the shipped step's `run:` script and EXECUTES it against a
|
||||
# recording stub, rather than grepping the YAML for strings. A grep here would
|
||||
# pass on a step that assembles a perfect request and never sends it — the
|
||||
# shape of defect this repo keeps finding in its own tests. So every case
|
||||
# asserts on what the step actually sent, or on what it actually did when the
|
||||
# forge refused.
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
# shellcheck source=test/harness.sh
|
||||
. "$ROOT/test/harness.sh"
|
||||
|
||||
WORKFLOW="$ROOT/.github/workflows/labels.yml"
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "${TMP:?}"' EXIT
|
||||
|
||||
# --- the step under test, taken from the shipped workflow --------------------
|
||||
STEP="$TMP/step.sh"
|
||||
{
|
||||
printf '%s\n' '#!/usr/bin/env bash' 'set -e'
|
||||
yq -r '.jobs.trigger.steps[] | select(.name == "dispatch the sweep") | .run' "$WORKFLOW"
|
||||
} >"$STEP"
|
||||
chmod +x "$STEP"
|
||||
|
||||
step_extracted() { [ "$(wc -l <"$STEP")" -ge 10 ]; }
|
||||
check "the step's script was extracted from the shipped workflow" 0 "" step_extracted
|
||||
|
||||
# --- stubs -------------------------------------------------------------------
|
||||
# `curl` records every invocation and answers with the code the case wants. It
|
||||
# parses only what the step actually passes, so a step that stopped sending
|
||||
# `-d`, or changed the method, fails here rather than recording nothing.
|
||||
make_curl() { # make_curl <http-code> <body>
|
||||
cat >"$TMP/bin/curl" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
method=GET; data=; url=; out=; write=
|
||||
while [ \$# -gt 0 ]; do
|
||||
case "\$1" in
|
||||
-X) method="\$2"; shift 2 ;;
|
||||
-d) data="\$2"; shift 2 ;;
|
||||
-o) out="\$2"; shift 2 ;;
|
||||
-w) write="\$2"; shift 2 ;;
|
||||
-H) printf '%s\n' "HEADER \$2" >>"$TMP/calls"; shift 2 ;;
|
||||
-fsS|-sS|-s) shift ;;
|
||||
*) url="\$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
printf '%s\n' "METHOD \$method" "URL \$url" "DATA \$data" >>"$TMP/calls"
|
||||
# the default-branch read is a plain GET whose stdout the step pipes to jq
|
||||
if [ "\$method" = GET ]; then printf '{"default_branch":"trunk"}'; exit 0; fi
|
||||
[ -n "\$out" ] && printf '%s' '$2' >"\$out"
|
||||
[ -n "\$write" ] && printf '%s' '$1'
|
||||
exit 0
|
||||
STUB
|
||||
chmod +x "$TMP/bin/curl"
|
||||
}
|
||||
|
||||
run_step() { # run_step <http-code> <body> [env assignments...]
|
||||
local code="$1" body="$2"
|
||||
shift 2
|
||||
rm -rf "${TMP:?}/bin"
|
||||
mkdir -p "$TMP/bin"
|
||||
: >"$TMP/calls"
|
||||
make_curl "$code" "$body"
|
||||
env PATH="$TMP/bin:$PATH" \
|
||||
GITHUB_TOKEN=tok \
|
||||
GITHUB_API_URL=https://forge.example/api/v1 \
|
||||
GITHUB_REPOSITORY=owner/repo \
|
||||
SWEEP_WORKFLOW=self-labels-sweep.yml \
|
||||
"$@" \
|
||||
"$STEP"
|
||||
}
|
||||
|
||||
sent() { grep -h "^$1 " "$TMP/calls" | tail -1 | cut -d' ' -f2-; }
|
||||
sent_field() { jq -r "$1" <<<"$(sent DATA)"; }
|
||||
|
||||
# --- the success path --------------------------------------------------------
|
||||
check "a 204 dispatch succeeds, naming what it woke and where" 0 \
|
||||
"sweep dispatched — self-labels-sweep.yml on main" \
|
||||
run_step 204 "" DEFAULT_BRANCH=main
|
||||
|
||||
posted() { [ "$(sent METHOD)" = POST ]; }
|
||||
check "...by POST, not GET" 0 "" posted
|
||||
|
||||
right_endpoint() {
|
||||
[ "$(sent URL)" = \
|
||||
"https://forge.example/api/v1/repos/owner/repo/actions/workflows/self-labels-sweep.yml/dispatches" ]
|
||||
}
|
||||
check "...to the dispatches endpoint of the workflow it was told to wake" 0 "" right_endpoint
|
||||
|
||||
ref_is_main() { [ "$(sent_field .ref)" = main ]; }
|
||||
check "...carrying a ref, because REST has no default and refuses without one" 0 "" ref_is_main
|
||||
|
||||
bootstrap_is_string_no() { [ "$(sent_field '.inputs.bootstrap')" = no ]; }
|
||||
check "...and bootstrap=no as a STRING input, not a bare flag" 0 "" bootstrap_is_string_no
|
||||
|
||||
bearer_sent() { grep -qF 'HEADER Authorization: Bearer tok' "$TMP/calls"; }
|
||||
check "...under the bearer header both forges accept" 0 "" bearer_sent
|
||||
|
||||
# --- the ref it must NOT inherit ---------------------------------------------
|
||||
# On pull_request_target GITHUB_REF_NAME is `<n>/merge`. A step that reaches
|
||||
# for it dispatches at something that is not a branch — and the forge answers
|
||||
# that with the opaque 500, so it would look like an outage.
|
||||
check "a pull_request_target run still dispatches at the branch" 0 "" \
|
||||
run_step 204 "" DEFAULT_BRANCH=main GITHUB_REF_NAME=203/merge
|
||||
ref_is_not_a_merge_ref() { case "$(sent_field .ref)" in *merge*) return 1 ;; *) return 0 ;; esac; }
|
||||
check "...never at its merge ref" 0 "" ref_is_not_a_merge_ref
|
||||
|
||||
# --- the fallback ------------------------------------------------------------
|
||||
check "an absent default branch is read from the forge, not guessed" 0 "" \
|
||||
run_step 204 "" DEFAULT_BRANCH=
|
||||
ref_is_trunk() { [ "$(sent_field .ref)" = trunk ]; }
|
||||
check "...and the dispatch uses what the read returned" 0 "" ref_is_trunk
|
||||
|
||||
# --- failure is loud, and the diagnostic is owned ----------------------------
|
||||
# `gh workflow run` failing WAS the misconfiguration alarm. The port keeps that
|
||||
# contract: a consumer missing the caller, its input, or `actions: write` must
|
||||
# fail here rather than sweep silently never again.
|
||||
check "an empty 500 fails the step — the alarm still rings" 1 \
|
||||
"/self-labels-sweep.yml/dispatches (ref=main)" \
|
||||
run_step 500 "" DEFAULT_BRANCH=main
|
||||
check "...explaining Forgejo's EMPTY 500 rather than passing it through" 1 \
|
||||
"empty 500 body from Forgejo means the workflow name or the ref did not resolve" \
|
||||
run_step 500 "" DEFAULT_BRANCH=main
|
||||
check "...and naming the consumer causes the alarm exists for" 1 "actions: write" \
|
||||
run_step 500 "" DEFAULT_BRANCH=main
|
||||
check "any non-204 fails, not only the statuses the API documents" 1 "forbidden" \
|
||||
run_step 403 '{"message":"forbidden"}' DEFAULT_BRANCH=main
|
||||
|
||||
# --- the API root is not guessed ---------------------------------------------
|
||||
# Defaulting an unset GITHUB_API_URL to api.github.com sent this forge's
|
||||
# dispatch to GitHub and reported success (@codex-reviewer-andresmgsl). The
|
||||
# teeth are the call count: refusing AFTER a request is not refusing.
|
||||
run_step_no_api() {
|
||||
rm -rf "${TMP:?}/bin"; mkdir -p "$TMP/bin"; : >"$TMP/calls"
|
||||
make_curl 204 ""
|
||||
# -u, because plain `env` PRESERVES the parent environment: on the runner
|
||||
# every step arrives with GITHUB_API_URL set — the premise of the fix under
|
||||
# test — so without the unset this case inherits it, the refusal path never
|
||||
# executes, and the case passes only in a dev shell that lacks the variable
|
||||
# (@kimi-reviewer-andresmgsl, run 468).
|
||||
env -u GITHUB_API_URL \
|
||||
PATH="$TMP/bin:$PATH" GITHUB_TOKEN=tok GITHUB_REPOSITORY=owner/repo \
|
||||
SWEEP_WORKFLOW=self-labels-sweep.yml DEFAULT_BRANCH=main "$STEP"
|
||||
}
|
||||
check "an unset GITHUB_API_URL refuses rather than guessing GitHub" 1 \
|
||||
"GITHUB_API_URL is unset" run_step_no_api
|
||||
no_calls_made() { [ ! -s "$TMP/calls" ]; }
|
||||
check "...having made zero requests: refusing after a POST is not refusing" 0 "" no_calls_made
|
||||
|
||||
# --- a transport failure is not silence --------------------------------------
|
||||
# The old `|| true` invariant was asserted by grepping the gh line this port
|
||||
# removed, so it passed on any REST implementation including one that swallows
|
||||
# a failed POST (@codex-reviewer-andresmgsl). Driven instead: curl itself exits
|
||||
# non-zero, which `-w` cannot report because nothing is written.
|
||||
make_failing_curl() {
|
||||
printf '%s\n' '#!/usr/bin/env bash' 'echo "curl: (7) failed to connect" >&2' 'exit 7' \
|
||||
>"$TMP/bin/curl"
|
||||
chmod +x "$TMP/bin/curl"
|
||||
}
|
||||
run_step_curl_dies() {
|
||||
rm -rf "${TMP:?}/bin"; mkdir -p "$TMP/bin"; : >"$TMP/calls"
|
||||
make_failing_curl
|
||||
env PATH="$TMP/bin:$PATH" GITHUB_TOKEN=tok \
|
||||
GITHUB_API_URL=https://forge.example/api/v1 GITHUB_REPOSITORY=owner/repo \
|
||||
SWEEP_WORKFLOW=self-labels-sweep.yml DEFAULT_BRANCH=main "$STEP"
|
||||
}
|
||||
check "a POST that never reaches the forge fails the step, and says so" 1 \
|
||||
"never completed" run_step_curl_dies
|
||||
|
||||
# A code-aware guard alongside the behavioural one: no swallowing operator on
|
||||
# the dispatch itself.
|
||||
never_silenced() { sed 's/#.*//' "$STEP" | grep -qE '\|\|[[:space:]]*true'; }
|
||||
check "...and the step carries no || true" 1 "" never_silenced
|
||||
|
||||
# --- what the port removed ---------------------------------------------------
|
||||
# Strip comments first: the step's prose NAMES `gh workflow run` and
|
||||
# CEREMONY_FORGE_CLIENT to explain what it replaced, so a raw grep asserts on
|
||||
# the explanation instead of the code.
|
||||
step_code() { sed 's/#.*//' "$STEP"; }
|
||||
invokes_gh() { step_code | grep -qE '(^|[^[:alnum:]_])gh[[:space:]]'; }
|
||||
decides_forge() { step_code | grep -qE 'GITHUB_SERVER_URL|CEREMONY_FORGE_CLIENT'; }
|
||||
check "the step no longer INVOKES gh, its comments about it aside" 1 "" invokes_gh
|
||||
check "...and no longer decides a forge, because REST needs no branch" 1 "" decides_forge
|
||||
|
||||
summary
|
||||
|
|
@ -67,19 +67,6 @@ rev() { # $1=login $2=state $3=commit $4=body $5=submitted_at → one review obj
|
|||
|
||||
reviews() { jq -s '.' <<<"$*"; } # collect review objects into an array
|
||||
|
||||
# The approximation replaced by #238, retained only to make crew!96's
|
||||
# before/after regression explicit. Production reads the forge's live set.
|
||||
inferred_requests_before_238() {
|
||||
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-}"
|
||||
}
|
||||
|
||||
# The blocker:unrequested quiescence inputs (#236 D2). Every fixture below
|
||||
# inherits a readable, settled world — a head commit an hour before this
|
||||
# sweep's clock — so the cases written before #236 assert exactly what they
|
||||
|
|
@ -175,93 +162,6 @@ expect "an unreadable head version is silent — never nag on a guess" "" \
|
|||
expect "a bare head over an unreadable base still warns" yes \
|
||||
"$(release_shape_warning 41 2.0.0 "" | grep -qF '::warning::' && echo yes || echo no)"
|
||||
|
||||
# The pure matrix above proves the warning predicate. These two fixtures drive
|
||||
# the sweep boundary that chooses WHICH base tree feeds it (#275): the
|
||||
# reporting Forgejo payload exposes the PR's moving base tip beside its fixed
|
||||
# merge base, and using the former manufactures a downgrade after an
|
||||
# intervening release. A missing merge-base field deliberately keeps the
|
||||
# specified base-tip fallback for backends that do not supply that fact.
|
||||
release_shape_ref_probe() { # $1 = phantom | bump | fallback
|
||||
(
|
||||
# shellcheck disable=SC2030 # this probe intentionally isolates its repository fixture
|
||||
REPO=owner/repo
|
||||
LABELS_CONF="$FIXTURE_CONF"
|
||||
CEREMONY_FORGE=github
|
||||
mode="$1"
|
||||
refs="$RTMP/release-shape-$mode-refs"
|
||||
: >"$refs"
|
||||
case "$mode" in
|
||||
phantom)
|
||||
head_ver=1.3.0 base_tip_ver=1.4.0 merge_base_ver=1.3.0
|
||||
merge_base_json='"merge-base"' ;;
|
||||
bump)
|
||||
head_ver=1.4.0 base_tip_ver=1.3.0 merge_base_ver=1.3.0
|
||||
merge_base_json='"merge-base"' ;;
|
||||
fallback)
|
||||
head_ver=1.4.0 base_tip_ver=1.4.0 merge_base_ver=unused
|
||||
merge_base_json=null ;;
|
||||
*) return 2 ;;
|
||||
esac
|
||||
# shellcheck disable=SC2317 # reached through the GitHub backend selected above
|
||||
gh() {
|
||||
if [ "$1" = label ] && [ "$2" = list ]; then
|
||||
core_label_rows | cut -d'|' -f1
|
||||
return 0
|
||||
fi
|
||||
if [ "$1" = pr ] && [ "$2" = list ]; then
|
||||
printf '701\n'
|
||||
return 0
|
||||
fi
|
||||
if [ "$1" = pr ] && [ "$2" = view ]; then
|
||||
jq -n '{mergeable:"MERGEABLE",statusCheckRollup:[]}'
|
||||
return 0
|
||||
fi
|
||||
if [ "$1" = issue ] && [ "$2" = edit ]; then return 0; fi
|
||||
case "$(forge_stub_path "$*")" in
|
||||
*'repos/owner/repo/pulls/701 --jq .requested_reviewers'*) return 0 ;;
|
||||
*repos/owner/repo/pulls/701/reviews*) return 0 ;;
|
||||
*repos/owner/repo/pulls/701)
|
||||
jq -n --argjson merge_base "$merge_base_json" \
|
||||
'{draft:false,user:{login:"fixture-builder"},
|
||||
head:{sha:"head"},base:{sha:"base-tip"},merge_base:$merge_base,
|
||||
labels:[{name:"state:addressing"},{name:"blocked"}],
|
||||
requested_reviewers:[],created_at:"2026-08-31T20:00:00Z"}' ;;
|
||||
*repos/owner/repo/commits/head*) printf '2026-08-31T20:00:00Z\n' ;;
|
||||
*repos/owner/repo/contents/VERSION\?ref=head*)
|
||||
printf 'head\n' >>"$refs"
|
||||
printf '%s' "$head_ver" | base64 ;;
|
||||
*repos/owner/repo/contents/VERSION\?ref=base-tip*)
|
||||
printf 'base-tip\n' >>"$refs"
|
||||
printf '%s' "$base_tip_ver" | base64 ;;
|
||||
*repos/owner/repo/contents/VERSION\?ref=merge-base*)
|
||||
printf 'merge-base\n' >>"$refs"
|
||||
printf '%s' "$merge_base_ver" | base64 ;;
|
||||
*) printf '[]\n' ;;
|
||||
esac
|
||||
}
|
||||
main
|
||||
)
|
||||
}
|
||||
|
||||
phantom_shape="$(release_shape_ref_probe phantom)"
|
||||
expect "an unchanged branch cut before a later base release emits no release-shape warning" \
|
||||
no "$(grep -q 'release-shaped' <<<"$phantom_shape" && echo yes || echo no)"
|
||||
expect "the unchanged-branch guard compares head with the PR merge base" \
|
||||
$'head\nmerge-base' "$(cat "$RTMP/release-shape-phantom-refs")"
|
||||
|
||||
bump_shape="$(release_shape_ref_probe bump)"
|
||||
expect "a genuine version bump relative to the merge base keeps the warning text" \
|
||||
yes "$(grep -qF '#701 is release-shaped (version 1.3.0 -> 1.4.0 at its head)' \
|
||||
<<<"$bump_shape" && echo yes || echo no)"
|
||||
expect "the genuine-bump guard also compares head with the PR merge base" \
|
||||
$'head\nmerge-base' "$(cat "$RTMP/release-shape-bump-refs")"
|
||||
|
||||
fallback_shape="$(release_shape_ref_probe fallback)"
|
||||
expect "a null merge base falls back to the base tip without guessing a warning" \
|
||||
no "$(grep -q 'release-shaped' <<<"$fallback_shape" && echo yes || echo no)"
|
||||
expect "the null merge-base fallback compares head with the base tip" \
|
||||
$'head\nbase-tip' "$(cat "$RTMP/release-shape-fallback-refs")"
|
||||
|
||||
# -- drafts are building, whoever is requested --------------------------------
|
||||
DRAFT=true HEAD_SHA=head1 REQUESTED="" REVIEWS_JSON='[]'
|
||||
expect "draft PR is building" state:building "$(decide_state)"
|
||||
|
|
@ -272,39 +172,6 @@ $BOT2
|
|||
$BOT3" REVIEWS_JSON='[]'
|
||||
expect "requested bots mean bots-reviewing" state:bots-reviewing "$(decide_state)"
|
||||
|
||||
# Forgejo materializes each live request as a REQUEST_REVIEW row. The backend
|
||||
# returns those three logins directly; the rows are not submitted verdicts
|
||||
# (#235), so an opening round stays with the panel rather than falling through
|
||||
# to the builder as three comment-only answers.
|
||||
REVIEWS_JSON="$(reviews \
|
||||
"$(rev "$BOT1" REQUEST_REVIEW "" "" t1)" \
|
||||
"$(rev "$BOT2" REQUEST_REVIEW "" "" t2)" \
|
||||
"$(rev "$BOT3" REQUEST_REVIEW "" "" t3)")"
|
||||
REQUESTED="$BOT1
|
||||
$BOT2
|
||||
$BOT3"
|
||||
expect "three Forgejo request rows keep the opening round with the panel" \
|
||||
state:bots-reviewing "$(round_state)"
|
||||
|
||||
# crew!96 after a fix push: every submitted verdict belongs to the older head,
|
||||
# and the forge carries no live request row. The old intersection mistakes the
|
||||
# two stale approvals for requests; the exact empty set correctly gives the
|
||||
# incomplete round back to the builder (#238).
|
||||
HEAD_SHA=head2
|
||||
REVIEWS_JSON="$(reviews \
|
||||
"$(rev "$BOT1" APPROVED head1 "" 2026-08-22T23:05:01Z)" \
|
||||
"$(rev "$BOT2" APPROVED head1 "" 2026-08-22T23:14:45Z)" \
|
||||
"$(rev "$BOT3" REQUEST_CHANGES head1 "" 2026-08-22T23:03:39Z)")"
|
||||
REQUESTED="$(inferred_requests_before_238 "$BOT1
|
||||
$BOT2
|
||||
$BOT3")"
|
||||
expect "crew!96 old inferred requests hand the fix round to the panel" \
|
||||
state:bots-reviewing "$(round_state)"
|
||||
REQUESTED=""
|
||||
expect "crew!96 exact empty requests return the fix round to the builder" \
|
||||
state:addressing "$(round_state)"
|
||||
HEAD_SHA=head1
|
||||
|
||||
# -- a bot that never reviewed keeps the round open ---------------------------
|
||||
# With a live request that is the bots' ball; with NO request outstanding it
|
||||
# is the agent's, because nothing is coming until somebody asks.
|
||||
|
|
@ -348,25 +215,6 @@ REVIEWS_JSON="$(reviews \
|
|||
"$(rev "$BOT3" APPROVED head1 "" t3)")"
|
||||
expect "changes-requested blocks even from an old head" state:addressing "$(decide_state)"
|
||||
|
||||
# bot_verdict grades submitted states from both forges (#235). Each direct
|
||||
# assertion names one arm so a later vocabulary regression cannot hide behind
|
||||
# round_state's shared BLOCK/FEEDBACK handling.
|
||||
expect "GitHub CHANGES_REQUESTED grades as a block" BLOCK \
|
||||
"$(bot_verdict "$BOT1")"
|
||||
REVIEWS_JSON="$(reviews "$(rev "$BOT1" REQUEST_CHANGES old1 "blockers below" t1)")"
|
||||
expect "Forgejo REQUEST_CHANGES grades as a block" BLOCK \
|
||||
"$(bot_verdict "$BOT1")"
|
||||
REVIEWS_JSON="$(reviews "$(rev "$BOT1" COMMENT head1 "non-blocking note" t1)")"
|
||||
expect "Forgejo COMMENT grades as feedback" FEEDBACK \
|
||||
"$(bot_verdict "$BOT1")"
|
||||
REVIEWS_JSON="$(reviews "$(rev "$BOT1" FUTURE_FORGE_STATE head1 "" t1)")"
|
||||
bot_verdict "$BOT1" >"$RTMP/unknown-verdict" 2>"$RTMP/unknown-verdict-log"
|
||||
expect "an unrecognised review state is conservatively missing" MISSING \
|
||||
"$(cat "$RTMP/unknown-verdict")"
|
||||
expect "an unrecognised review state logs the login and spelling" yes \
|
||||
"$(grep -qF "$BOT1: unrecognised review state FUTURE_FORGE_STATE" \
|
||||
"$RTMP/unknown-verdict-log" && echo yes || echo no)"
|
||||
|
||||
# -- a stale approval must not promote unreviewed code ------------------------
|
||||
REVIEWS_JSON="$(reviews \
|
||||
"$(rev "$BOT1" APPROVED old1 "" t1)" \
|
||||
|
|
@ -405,24 +253,6 @@ REQUESTED="$HUMAN"
|
|||
expect "re-requested human is needs-human again" state:needs-human "$(decide_state)"
|
||||
REQUESTED=""
|
||||
|
||||
# Forgejo's human-block spelling carries the same meaning (#235). This is
|
||||
# independently observable because only BLOCK prevents state:needs-human once
|
||||
# every bot approves.
|
||||
REVIEWS_JSON="$(reviews \
|
||||
"$(rev "$BOT1" APPROVED head1 "" t1)" \
|
||||
"$(rev "$BOT2" APPROVED head1 "" t2)" \
|
||||
"$(rev "$BOT3" APPROVED head1 "" t3)" \
|
||||
"$(rev "$HUMAN" REQUEST_CHANGES head1 "not yet" t4)")"
|
||||
expect "Forgejo human request-changes with bots approving is addressing" \
|
||||
state:addressing "$(decide_state)"
|
||||
REVIEWS_JSON="$(reviews \
|
||||
"$(rev "$BOT1" APPROVED head1 "" t1)" \
|
||||
"$(rev "$BOT2" APPROVED head1 "" t2)" \
|
||||
"$(rev "$BOT3" APPROVED head1 "" t3)" \
|
||||
"$(rev "$HUMAN" APPROVED head1 "" t4)")"
|
||||
expect "the same Forgejo-shaped fixture with human approval reaches needs-human" \
|
||||
state:needs-human "$(decide_state)"
|
||||
|
||||
# -- an old human comment must not wedge the handoff (codex, #85 round 3) -----
|
||||
REVIEWS_JSON="$(reviews \
|
||||
"$(rev "$HUMAN" COMMENTED old1 "early thoughts" t0)" \
|
||||
|
|
@ -676,61 +506,6 @@ expect "a rollup of ONLY self entries is honestly NONE, never SUCCESS" NONE \
|
|||
expect "a genuine foreign FAILURE still blocks beside a cancelled self entry" FAILURE \
|
||||
"$(rollup "[$(run_ a FAILURE),\
|
||||
$(rec_ CANCELLED 2026-08-01T15:17:56Z 2026-08-01T15:17:59Z)]" | checks_state)"
|
||||
|
||||
# Forgejo exposes Actions checks only as commit statuses. Drive the real
|
||||
# backend mapper here: fixtures that hand-write workflowName repeat the field
|
||||
# omission that hid #243 instead of proving the adapter supplies it.
|
||||
forgejo_rollup() {
|
||||
local statuses="$1"
|
||||
(
|
||||
# shellcheck source=lib/forge-forgejo.sh
|
||||
. lib/forge-forgejo.sh
|
||||
forge_api() {
|
||||
case "$1" in
|
||||
repos/o/r/pulls/5)
|
||||
printf '%s\n' '{"head":{"sha":"abc"},"base":{"sha":"base"},"merge_base":"ancestor","draft":false,"mergeable":true}'
|
||||
;;
|
||||
repos/o/r/commits/abc/status)
|
||||
printf '%s\n' "$statuses"
|
||||
;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
REPO=o/r forge_pr_view 5
|
||||
)
|
||||
}
|
||||
|
||||
forgejo_self_red='{"state":"failure","statuses":[
|
||||
{"context":"labels / labels (pull_request)","status":"failure","created_at":"2026-08-23T18:32:00Z","updated_at":"2026-08-23T18:32:00Z"},
|
||||
{"context":"CI / test (pull_request)","status":"pending","created_at":"2026-08-23T18:32:00Z","updated_at":"2026-08-23T18:32:00Z"}]}'
|
||||
expect "Forgejo StatusContexts exclude a self FAILURE and retain pending work" PENDING \
|
||||
"$(forgejo_rollup "$forgejo_self_red" | checks_state)"
|
||||
|
||||
forgejo_self_only='{"state":"failure","statuses":[
|
||||
{"context":"labels / labels (pull_request)","status":"failure","created_at":"2026-08-23T18:32:00Z","updated_at":"2026-08-23T18:32:00Z"}]}'
|
||||
expect "Forgejo StatusContexts containing only the self workflow are NONE" NONE \
|
||||
"$(forgejo_rollup "$forgejo_self_only" | checks_state)"
|
||||
|
||||
forgejo_foreign_red='{"state":"failure","statuses":[
|
||||
{"context":"labels / labels (pull_request)","status":"failure","created_at":"2026-08-23T18:32:00Z","updated_at":"2026-08-23T18:32:00Z"},
|
||||
{"context":"CI / test (pull_request)","status":"failure","created_at":"2026-08-23T18:32:00Z","updated_at":"2026-08-23T18:32:00Z"}]}'
|
||||
expect "Forgejo StatusContexts retain a genuine foreign FAILURE" FAILURE \
|
||||
"$(forgejo_rollup "$forgejo_foreign_red" | checks_state)"
|
||||
|
||||
forgejo_bare_red='{"state":"failure","statuses":[
|
||||
{"context":"codecov","status":"failure","created_at":"2026-08-23T18:32:00Z","updated_at":"2026-08-23T18:32:00Z"}]}'
|
||||
expect "a bare Forgejo context survives a named self-workflow exclusion" FAILURE \
|
||||
"$(forgejo_rollup "$forgejo_bare_red" | checks_state)"
|
||||
|
||||
forgejo_nested_name='{"state":"failure","statuses":[
|
||||
{"context":"labels / nested / check","status":"failure","created_at":"2026-08-23T18:32:00Z","updated_at":"2026-08-23T18:32:00Z"}]}'
|
||||
SELF_WORKFLOW="labels / nested"
|
||||
expect "a Forgejo workflow name containing a separator is never guessed" FAILURE \
|
||||
"$(forgejo_rollup "$forgejo_nested_name" | checks_state)"
|
||||
|
||||
SELF_WORKFLOW=""
|
||||
expect "a bare Forgejo context also survives an empty self-workflow exclusion" FAILURE \
|
||||
"$(forgejo_rollup "$forgejo_bare_red" | checks_state)"
|
||||
# ...and an empty self filters NOTHING: outside Actions no workflow name is
|
||||
# ambient, and the exclusion must never drop entries on a guess — the same
|
||||
# displaced-self rollup keeps blocking there, all-cancelled context intact.
|
||||
|
|
@ -1108,6 +883,7 @@ expect "attention diagnosis caused no PR mutation" "$attention_mutations_before"
|
|||
# -- the sweep wiring observes the existing per-PR skip without writing -------
|
||||
blind_main_probe() {
|
||||
(
|
||||
GITHUB_EVENT_NAME=schedule
|
||||
REPO=owner/repo
|
||||
LABELS_CONF=.github/labels.conf
|
||||
# This probe IS a GitHub board — say so at the forge boundary rather
|
||||
|
|
@ -1166,6 +942,7 @@ expect "exactly the blind PRs match the counted shape whole-line — no more, no
|
|||
# shape, where the probes could not reach the per-PR path at all.
|
||||
unrequested_main_probe() { # $1 = read | denied, the head-commit read's outcome
|
||||
(
|
||||
GITHUB_EVENT_NAME=schedule
|
||||
REPO=owner/repo
|
||||
LABELS_CONF=.github/labels.conf
|
||||
# main() preflights the forge before it reads anything, so a probe that
|
||||
|
|
@ -1371,18 +1148,17 @@ EOF
|
|||
chmod +x "$EXEC/stub/gh"
|
||||
printf 'panel=bot-a bot-b bot-c\n' >"$EXEC/labels.conf"
|
||||
|
||||
exec_env() { # $1 = event name, $2 = BOOTSTRAP value ("" = unset)
|
||||
exec_env() { # $1 = event name → the real script, executed under the PATH stub
|
||||
: >"$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" \
|
||||
${2:+BOOTSTRAP="$2"} \
|
||||
bash actions/labels-reconcile/labels-reconcile.sh
|
||||
}
|
||||
|
||||
exec_rc=0
|
||||
exec_out="$(exec_env workflow_dispatch yes 2>&1)" || exec_rc=$?
|
||||
expect "an executed bootstrap=yes with all six absent completes green" 0 "$exec_rc"
|
||||
exec_out="$(exec_env workflow_dispatch 2>&1)" || exec_rc=$?
|
||||
expect "an executed dispatch with all six absent completes green" 0 "$exec_rc"
|
||||
expect "...reaching the end of the sweep" \
|
||||
yes "$(grep -q 'reconciled.' <<<"$exec_out" && echo yes || echo no)"
|
||||
expect "...having attempted all six deletions" \
|
||||
|
|
@ -1391,18 +1167,12 @@ expect "...and created the full taxonomy" \
|
|||
"$(core_label_rows | cut -d'|' -f1)" \
|
||||
"$(sed -n 's/^create //p' "$EXEC/record")"
|
||||
|
||||
# -- the bootstrap keys on the INPUT, never the event (ceremony#215): from
|
||||
# #209 on, EVERY machine wake is a workflow_dispatch event, so an event
|
||||
# gate made bootstrap=no inert — runs 459/523 and venue drill runs 16/17
|
||||
# bootstrapped on a delivered "no". The regression case is exactly that
|
||||
# pair: dispatch event, no.
|
||||
for pair in "workflow_dispatch:no" "workflow_dispatch:" "schedule:no" "schedule:" "pull_request_target:"; do
|
||||
ev="${pair%%:*}"; bs="${pair#*:}"
|
||||
# -- bootstrap is dispatch-only, deletes included: the cron and
|
||||
# pull_request_target paths touch no label
|
||||
for ev in schedule pull_request_target; do
|
||||
ev_rc=0
|
||||
exec_env "$ev" "$bs" >/dev/null 2>&1 || ev_rc=$?
|
||||
expect "the $ev event with BOOTSTRAP='${bs:-unset}' completes green" 0 "$ev_rc"
|
||||
expect "...and creates nothing" \
|
||||
no "$(grep -q '^create ' "$EXEC/record" && echo yes || echo no)"
|
||||
exec_env "$ev" >/dev/null 2>&1 || ev_rc=$?
|
||||
expect "the $ev path completes green" 0 "$ev_rc"
|
||||
expect "...and deletes nothing" \
|
||||
no "$(grep -q '^delete ' "$EXEC/record" && echo yes || echo no)"
|
||||
done
|
||||
|
|
@ -1701,6 +1471,7 @@ expect "...and a malformed panel= line in that same file is refused, not passed"
|
|||
# ---------------------------------------------------------------------------
|
||||
write_fail_probe() { # $1 = ok | fail — whether the label edit write succeeds
|
||||
(
|
||||
GITHUB_EVENT_NAME=schedule
|
||||
REPO=owner/repo
|
||||
LABELS_CONF=.github/labels.conf
|
||||
CEREMONY_FORGE=github
|
||||
|
|
@ -1770,9 +1541,7 @@ expect "...and does print reconciled." \
|
|||
# exited 0 (@codex-reviewer-andresmgsl, #192 review).
|
||||
stale_fail_probe() {
|
||||
(
|
||||
# No event name: the script stopped reading GITHUB_EVENT_NAME when the
|
||||
# bootstrap moved to the BOOTSTRAP input (#215); default no is the
|
||||
# non-bootstrap sweep this probe simulates.
|
||||
GITHUB_EVENT_NAME=schedule
|
||||
REPO=owner/repo
|
||||
LABELS_CONF=.github/labels.conf
|
||||
CEREMONY_FORGE=github
|
||||
|
|
@ -1813,56 +1582,50 @@ expect "...with no 'reconciled.' token in the output" \
|
|||
expect "...naming the attempt that did not happen" \
|
||||
yes "$(grep -q 'label edit FAILED' <<<"$sf_out" && echo yes || echo no)"
|
||||
|
||||
# Drive the ingestion expression through main(), independently of
|
||||
# bot_verdict (#235). Capturing REVIEWS_JSON at the review-request read that
|
||||
# follows it proves REQUEST_REVIEW never reaches the grader; the COMMENT and
|
||||
# APPROVED controls prove both gradeable Forgejo states and rows generally
|
||||
# survive the filter.
|
||||
review_filter_probe() {
|
||||
(
|
||||
REPO=owner/repo
|
||||
LABELS_CONF="$FIXTURE_CONF"
|
||||
CEREMONY_FORGE=github
|
||||
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
|
||||
gh() {
|
||||
if [ "$1" = label ] && [ "$2" = list ]; then core_label_rows | cut -d'|' -f1; return 0; fi
|
||||
if [ "$1" = pr ] && [ "$2" = list ]; then printf '%s\n' 601; return 0; fi
|
||||
if [ "$1" = pr ] && [ "$2" = view ]; then
|
||||
jq -n '{mergeable:"MERGEABLE",statusCheckRollup:[]}'
|
||||
return 0
|
||||
fi
|
||||
if [ "$1" = issue ] && [ "$2" = edit ]; then return 0; fi
|
||||
case "$(forge_stub_path "$*")" in
|
||||
*repos/owner/repo/pulls/601/reviews*)
|
||||
jq -nc \
|
||||
'{user:{login:"fixture-bot-one"},state:"REQUEST_REVIEW",commit_id:"",submitted_at:"2026-08-22T00:46:05Z"},
|
||||
{user:{login:"fixture-bot-three"},state:"COMMENT",commit_id:"head1",submitted_at:"2026-08-22T00:46:35Z"},
|
||||
{user:{login:"fixture-bot-two"},state:"APPROVED",commit_id:"head1",submitted_at:"2026-08-22T00:47:05Z"}' ;;
|
||||
*/pulls/601)
|
||||
jq -n '{draft:true,user:{login:"fixture-builder"},head:{sha:"head1"},base:{sha:"base1"},
|
||||
labels:[{name:"state:building"}],requested_reviewers:[],
|
||||
created_at:"2026-08-22T00:45:00Z"}' ;;
|
||||
*) printf '[]\n' ;;
|
||||
esac
|
||||
}
|
||||
# main normally re-sources the selected backend. This probe already runs
|
||||
# with the GitHub backend selected at suite startup; keeping that selection
|
||||
# stable lets the verb override below observe the post-REVIEWS_JSON boundary.
|
||||
forge_select() { return 0; }
|
||||
# shellcheck disable=SC2317 # main invokes the probe override indirectly
|
||||
forge_pr_review_requests() {
|
||||
printf '%s\n' "$REVIEWS_JSON" >"$RTMP/gradeable-reviews.json"
|
||||
}
|
||||
main >/dev/null
|
||||
)
|
||||
}
|
||||
review_filter_probe
|
||||
expect "REQUEST_REVIEW is removed before REVIEWS_JSON reaches the grader" \
|
||||
COMMENT,APPROVED "$(jq -r 'map(.state) | join(",")' "$RTMP/gradeable-reviews.json")"
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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, so a later failure 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.
|
||||
# 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"
|
||||
[ "$fail" -eq 0 ]
|
||||
|
|
|
|||
|
|
@ -70,24 +70,13 @@ check "the sweep keeps the ONE shared concurrency group" 0 "group: labels-reconc
|
|||
grep -F 'group: labels-reconcile' "$SWEEP"
|
||||
check "labels.yml carries the trigger job" 0 " trigger:" \
|
||||
grep -E '^ trigger:' "$REUSABLE"
|
||||
# #205 ported this dispatch from `gh workflow run` to REST. The assertion is
|
||||
# the same one it always was — the sweep caller is woken BY NAME and never
|
||||
# bootstrapped — but it now has to hold against a request rather than a CLI
|
||||
# line. What the step actually SENDS is driven in test/labels-dispatch.test.sh;
|
||||
# these two keep the wiring pinned here alongside the rest of the trigger.
|
||||
# shellcheck disable=SC2016 # $SWEEP_WORKFLOW is the workflow's own env var, asserted literally
|
||||
check "the trigger dispatches the sweep caller by name" 0 \
|
||||
'actions/workflows/$SWEEP_WORKFLOW/dispatches' \
|
||||
grep -F '/dispatches' "$REUSABLE"
|
||||
check "...never bootstrapping" 0 'bootstrap: "no"' \
|
||||
grep -F 'bootstrap' "$REUSABLE"
|
||||
# The never-silenced invariant moved to test/labels-dispatch.test.sh, where it
|
||||
# is BEHAVIOURAL: a curl that dies at the transport must fail the extracted
|
||||
# step, plus a code-aware no-`|| true` guard on the step itself. The check that
|
||||
# lived here grepped the `gh workflow run` line #205 removed, so after the port
|
||||
# it passed on every implementation including one that swallows a failed POST —
|
||||
# a green assertion whose name claimed an invariant its implementation could
|
||||
# not observe (@codex-reviewer-andresmgsl, #213 review).
|
||||
check "the trigger dispatches the sweep caller, never bootstrapping" 0 \
|
||||
'gh workflow run "$SWEEP_WORKFLOW" -R "$GITHUB_REPOSITORY" -f bootstrap=no' \
|
||||
grep -F 'gh workflow run' "$REUSABLE"
|
||||
# shellcheck disable=SC2016 # $1 expands in the nested bash, not here
|
||||
check "the trigger dispatch is never silenced with || true" 1 "" \
|
||||
bash -c 'grep -F "gh workflow run" "$1" | grep -qF "|| true"' _ "$REUSABLE"
|
||||
check "the sweep caller filename input defaults to labels-sweep.yml" 0 \
|
||||
"default: labels-sweep.yml" grep -F 'default: labels-sweep.yml' "$REUSABLE"
|
||||
# the dogfood callers wear the split: the event caller names its deviant
|
||||
|
|
@ -150,33 +139,4 @@ done
|
|||
check "pull_request_target keeps the labeled handoff wake" 0 "labeled" \
|
||||
trigger_types "$SELF" pull_request_target
|
||||
|
||||
# ---- fork heads carry a read-only token on this Forgejo (#241) --------------
|
||||
# Same-repo heads keep the existing immediate scope + sweep-dispatch path. A
|
||||
# fork-headed pull_request_target run must attempt no write: both write-capable
|
||||
# jobs exclude it, while one successful job explains exactly what the scheduled
|
||||
# sweep does and does not supply. Require each full normalised expression to
|
||||
# appear intact, so deleting or inverting one of its clauses fails the guard.
|
||||
job_if_expression() { # $1 = file, $2 = job
|
||||
yq -r ".jobs.$2.if // \"\"" "$1" |
|
||||
tr '\n' ' ' |
|
||||
awk '{$1=$1; print}'
|
||||
}
|
||||
check "scope writes only for a same-repo PR head" 0 \
|
||||
"github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.full_name == github.repository && github.event.action != 'labeled' && github.event.action != 'unlabeled' && github.event.action != 'review_requested' && github.event.action != 'review_request_removed'" \
|
||||
job_if_expression "$REUSABLE" scope
|
||||
check "the sweep trigger preserves non-PR events and excludes fork heads" 0 \
|
||||
"github.event_name != 'pull_request_target' || github.event.pull_request.head.repo.full_name == github.repository" \
|
||||
job_if_expression "$REUSABLE" trigger
|
||||
check "a fork-headed PR selects the successful explanation job" 0 \
|
||||
"github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.full_name != github.repository" \
|
||||
job_if_expression "$REUSABLE" fork_head
|
||||
|
||||
fork_head_step() {
|
||||
yq -r '.jobs.fork_head.steps[] | select(.name == "explain deferred fork labels") | .run' \
|
||||
"$REUSABLE" | bash
|
||||
}
|
||||
check "the fork path distinguishes swept state from unsupported scope writes" 0 \
|
||||
"read-only token; state, blocker, and handoff reconciliation deferred to the scheduled sweep; path-derived scope labels are not applied to fork heads" \
|
||||
fork_head_step
|
||||
|
||||
summary
|
||||
|
|
|
|||
|
|
@ -31,15 +31,6 @@ attention_row='attention|D93F0B|A demand is parked here for the assignee: pick u
|
|||
check "attention core row is emitted once, byte-exact" 0 "1" bash -c \
|
||||
'source "$1"; core_label_rows | grep -cxF "$2"' _ \
|
||||
"$ROOT/actions/labels-reconcile/labels-reconcile.sh" "$attention_row"
|
||||
needs_triage_row='needs-triage|FBCA04|Did not come through triage — owes normalization into work or a reasoned refusal'
|
||||
# shellcheck disable=SC2016 # expansion belongs to the nested bash
|
||||
check "needs-triage core row is emitted once, byte-exact" 0 "1" bash -c \
|
||||
'source "$1"; core_label_rows | grep -cxF "$2"' _ \
|
||||
"$ROOT/actions/labels-reconcile/labels-reconcile.sh" "$needs_triage_row"
|
||||
# shellcheck disable=SC2016 # expansion belongs to the nested bash
|
||||
check "core label rows carry no stale discussion path" 1 "" bash -c \
|
||||
'source "$1"; core_label_rows | grep -i discussion' _ \
|
||||
"$ROOT/actions/labels-reconcile/labels-reconcile.sh"
|
||||
# shellcheck disable=SC2016 # fields are intentionally split in the nested shell
|
||||
check "attention description survives label field splitting" 0 \
|
||||
"A demand is parked here for the assignee: pick up the thread, ack by removing this label" \
|
||||
|
|
@ -255,7 +246,7 @@ sed 's/^panel=/panel=ghost-bot /' "$CONF" >"$mut_conf"
|
|||
check "an identity in the conf but not the table goes red" 1 "ghost-bot" \
|
||||
roster_in_sync "$mut_conf" "$CONTRIB"
|
||||
# shellcheck disable=SC2016 # the backticks are the table's, not a subshell
|
||||
sed 's/^| `glm-bot-andresmgsl`/| `ghost-bot`/' "$CONTRIB" >"$mut_contrib"
|
||||
sed 's/^| `kimi-reviewer-andresmgsl`/| `ghost-bot`/' "$CONTRIB" >"$mut_contrib"
|
||||
check "an identity in the table but not the conf goes red" 1 "ghost-bot" \
|
||||
roster_in_sync "$CONF" "$mut_contrib"
|
||||
|
||||
|
|
|
|||
|
|
@ -205,20 +205,13 @@ printf '%s\n' 'jobs:' ' t:' ' steps:' ' - env:' \
|
|||
' gh workflow run x' >"$TMP/declared-both.yml"
|
||||
check "...and a declaration guarding BOTH forge and binary is" 0 "" \
|
||||
refuses_when_unavailable "$TMP/declared-both.yml"
|
||||
# labels.yml WAS the real customer for that pair. #205 ported its dispatch to
|
||||
# REST, so it no longer speaks gh and must no longer declare a client — the
|
||||
# exemption is spent, not inherited. Asserting its ABSENCE is what stops the
|
||||
# declaration coming back as cover for a re-added `gh` call: an opt-out with no
|
||||
# gh behind it is a standing permission slip.
|
||||
check "labels.yml no longer declares a client, because it speaks none (#205)" 1 "" \
|
||||
# The shipped workflow is the real customer for that pair.
|
||||
check "labels.yml declares the client it speaks" 0 "" \
|
||||
declares_gh_client "$ROOT/.github/workflows/labels.yml"
|
||||
# shellcheck disable=SC2016 # `$SWEEP_WORKFLOW` is the literal the YAML must
|
||||
# carry: the endpoint has to be built from the caller's input, not hardcoded.
|
||||
labels_yml_dispatches_by_rest() {
|
||||
grep -qF 'actions/workflows/$SWEEP_WORKFLOW/dispatches' \
|
||||
"$ROOT/.github/workflows/labels.yml"
|
||||
}
|
||||
check "...and dispatches the sweep over REST instead" 0 "" labels_yml_dispatches_by_rest
|
||||
check "...decides the forge before dispatching" 0 "" \
|
||||
refuses_wrong_forge "$ROOT/.github/workflows/labels.yml"
|
||||
check "...and checks the binary too, rather than dying on command not found" 0 "" \
|
||||
refuses_missing_binary "$ROOT/.github/workflows/labels.yml"
|
||||
check "...and an undeclared one does not" 1 "" declares_gh_client "$TMP/bad.sh"
|
||||
# A mention of the variable in prose is not a declaration.
|
||||
printf '%s\n' '#!/usr/bin/env bash' '# CEREMONY_FORGE_CLIENT=gh would opt out' \
|
||||
|
|
|
|||
|
|
@ -1,121 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Contract tests for lib/preflight.sh (issue #273) — every row of the
|
||||
# merge-door resume table, offline. set -u, not -e: refusals 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"
|
||||
|
||||
PREFLIGHT="$ROOT/lib/preflight.sh"
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
VER=1.2.3
|
||||
MERGE_SHA=1111111111111111111111111111111111111111
|
||||
FOREIGN_SHA=2222222222222222222222222222222222222222
|
||||
|
||||
# preflight <VER> <MERGE_SHA> <TAG_SHAS> <RELEASED> — run the pure decision
|
||||
# with exactly the four gathered facts in its environment.
|
||||
preflight() {
|
||||
# shellcheck disable=SC2016 # PREFLIGHT expands inside the isolated child
|
||||
env VER="$1" MERGE_SHA="$2" TAG_SHAS="$3" RELEASED="$4" \
|
||||
PREFLIGHT="$PREFLIGHT" bash -c '. "$PREFLIGHT"; release_preflight'
|
||||
}
|
||||
|
||||
preflight_stdout() {
|
||||
preflight "$@" 2>/dev/null
|
||||
}
|
||||
|
||||
preflight_stderr() {
|
||||
{ preflight "$@" >/dev/null; } 2>&1
|
||||
}
|
||||
|
||||
refuses_without_output() {
|
||||
local out rc
|
||||
out="$(preflight "$@" 2>/dev/null)"
|
||||
rc=$?
|
||||
[ "$rc" -eq 1 ] && [ -z "$out" ]
|
||||
}
|
||||
|
||||
# --- the four table rows ----------------------------------------------------
|
||||
|
||||
check "row 1: a published release refuses even with no tag" 1 \
|
||||
"release '$VER' already exists — this release already happened" \
|
||||
preflight_stderr "$VER" "$MERGE_SHA" "" yes
|
||||
check "row 1: a published release refuses with the matching tag" 1 \
|
||||
"release '$VER' already exists — this release already happened" \
|
||||
preflight_stderr "$VER" "$MERGE_SHA" "$MERGE_SHA" yes
|
||||
check "row 1: a published release refuses with a foreign tag" 1 \
|
||||
"release '$VER' already exists — this release already happened" \
|
||||
preflight_stderr "$VER" "$MERGE_SHA" "$FOREIGN_SHA" yes
|
||||
check "row 1: refusal emits no workflow output" 0 "" \
|
||||
refuses_without_output "$VER" "$MERGE_SHA" "$MERGE_SHA" yes
|
||||
|
||||
check "row 2: an ordinary first run proceeds" 0 "resume=no" \
|
||||
preflight_stdout "$VER" "$MERGE_SHA" "" no
|
||||
|
||||
check "row 3: the matching tag resumes" 0 "resume=yes" \
|
||||
preflight_stdout "$VER" "$MERGE_SHA" "$MERGE_SHA" no
|
||||
check "row 3: resume notice names the previous failed publish" 0 \
|
||||
"a previous run of this door tagged and then failed to publish" \
|
||||
preflight_stdout "$VER" "$MERGE_SHA" "$MERGE_SHA" no
|
||||
check "row 3: an annotated tag resumes when the peeled ref matches" 0 \
|
||||
"resume=yes" preflight_stdout "$VER" "$MERGE_SHA" \
|
||||
"$FOREIGN_SHA"$'\n'"$MERGE_SHA" no
|
||||
check "row 3: an annotated tag resumes when the direct ref matches" 0 \
|
||||
"resume=yes" preflight_stdout "$VER" "$MERGE_SHA" \
|
||||
"$MERGE_SHA"$'\n'"$FOREIGN_SHA" no
|
||||
|
||||
check "row 4: a foreign tag refuses" 1 "tag '$VER' already exists" \
|
||||
preflight_stderr "$VER" "$MERGE_SHA" "$FOREIGN_SHA" no
|
||||
check "row 4: the refusal names the foreign tag SHA" 1 "$FOREIGN_SHA" \
|
||||
preflight_stderr "$VER" "$MERGE_SHA" "$FOREIGN_SHA" no
|
||||
check "row 4: the refusal names the merge SHA" 1 "$MERGE_SHA" \
|
||||
preflight_stderr "$VER" "$MERGE_SHA" "$FOREIGN_SHA" no
|
||||
check "row 4: refusal emits no workflow output" 0 "" \
|
||||
refuses_without_output "$VER" "$MERGE_SHA" "$FOREIGN_SHA" no
|
||||
|
||||
# A ref object that merely contains MERGE_SHA is not the merge commit. Each
|
||||
# ls-remote object name is compared as a whole line.
|
||||
PREFIX_SHA="${MERGE_SHA%?}"
|
||||
check "a prefix of MERGE_SHA does not resume" 1 "already exists at $PREFIX_SHA" \
|
||||
preflight_stderr "$VER" "$MERGE_SHA" "$PREFIX_SHA" no
|
||||
check "a line containing MERGE_SHA does not resume" 1 \
|
||||
"already exists at x${MERGE_SHA}y" \
|
||||
preflight_stderr "$VER" "$MERGE_SHA" "x${MERGE_SHA}y" no
|
||||
|
||||
# --- fact validation --------------------------------------------------------
|
||||
|
||||
check "empty VER refuses" 1 "VER is empty" \
|
||||
preflight_stderr "" "$MERGE_SHA" "" no
|
||||
check "empty MERGE_SHA refuses" 1 "MERGE_SHA is empty" \
|
||||
preflight_stderr "$VER" "" "" no
|
||||
check "empty RELEASED refuses" 1 "RELEASED is empty" \
|
||||
preflight_stderr "$VER" "$MERGE_SHA" "" ""
|
||||
check "malformed RELEASED refuses" 1 "RELEASED='maybe' — expected yes or no" \
|
||||
preflight_stderr "$VER" "$MERGE_SHA" "" maybe
|
||||
|
||||
# --- stream discipline and purity ------------------------------------------
|
||||
|
||||
notice_stays_on_stdout() {
|
||||
local stdout stderr
|
||||
stdout="$(preflight "$VER" "$MERGE_SHA" "$MERGE_SHA" no 2>"$TMP/preflight.err")"
|
||||
stderr="$(cat "$TMP/preflight.err")"
|
||||
[ -n "$stdout" ] && [ -z "$stderr" ]
|
||||
}
|
||||
refusal_stays_on_stderr() {
|
||||
local stdout stderr rc
|
||||
stdout="$(preflight "$VER" "$MERGE_SHA" "$FOREIGN_SHA" no 2>"$TMP/preflight.err")"
|
||||
rc=$?
|
||||
stderr="$(cat "$TMP/preflight.err")"
|
||||
[ "$rc" -eq 1 ] && [ -z "$stdout" ] && [ -n "$stderr" ]
|
||||
}
|
||||
no_tool_calls() {
|
||||
! grep -v '^[[:space:]]*#' "$PREFLIGHT" | grep -Ewq 'git|gh|curl|wget'
|
||||
}
|
||||
check "resume notice and output stay on stdout" 0 "" notice_stays_on_stdout
|
||||
check "refusal stays on stderr" 0 "" refusal_stays_on_stderr
|
||||
check "preflight calls no git/gh/network tools" 0 "" no_tool_calls
|
||||
|
||||
summary
|
||||
|
|
@ -94,151 +94,96 @@ check "missing body is a loud failure" 1 "missing or unreadable" \
|
|||
check "invalid closing set is a loud failure" 1 "invalid closing issue" \
|
||||
guard ref-5 nope
|
||||
|
||||
# The action owns the network boundary. #199 made that boundary REST through
|
||||
# the shim, so it is driven here on BOTH backends with stubs at the transport
|
||||
# — a fake `gh api` for the github backend, a fake `curl` for the forgejo one.
|
||||
# Stubbing the shim itself would prove only that the test can stub the shim.
|
||||
# The action owns the network boundary. Drive its executable entrypoint with
|
||||
# a fake `gh` so failures are behavioral assertions, not YAML text guesses.
|
||||
mkdir -p "$TMP/bin"
|
||||
|
||||
# The fixture, one PR, expressed once and served by both stubs. The body
|
||||
# REFERENCES #5 while a commit CLOSES it: the contradiction this action
|
||||
# exists to catch, and the case a body-only port would miss (#199).
|
||||
PR_BODY_DEFAULT='Refs #5'
|
||||
COMMIT_MSG_DEFAULT='Closes #5
|
||||
|
||||
body text'
|
||||
|
||||
cat >"$TMP/bin/gh" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -u
|
||||
[ -z "${FORGE_CALL_LOG:-}" ] || printf '%s\n' "$*" >>"$FORGE_CALL_LOG"
|
||||
endpoint=""; jqexpr=""
|
||||
shift # `api`
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--paginate) ;;
|
||||
--jq) jqexpr="$2"; shift ;;
|
||||
-*) ;;
|
||||
*) [ -n "$endpoint" ] || endpoint="$1" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
case "${FAKE_FORGE_MODE:-success}" in
|
||||
body-fails) case "$endpoint" in */commits*) ;; *) echo "fake body read failed" >&2; exit 42 ;; esac ;;
|
||||
commits-fails) case "$endpoint" in */commits*) echo "fake commit read failed" >&2; exit 42 ;; esac ;;
|
||||
# Every call is recorded, so a probe can assert the gather did NOT run — a
|
||||
# refusal that still reads is not a refusal (#198).
|
||||
[ -z "${GH_CALL_LOG:-}" ] || printf '%s\n' "$*" >>"$GH_CALL_LOG"
|
||||
case "${FAKE_GH_MODE:-success}" in
|
||||
failure)
|
||||
echo "fake GraphQL read failed" >&2
|
||||
exit 42
|
||||
;;
|
||||
partial)
|
||||
has_next=true
|
||||
;;
|
||||
success)
|
||||
has_next=false
|
||||
;;
|
||||
*)
|
||||
echo "unknown fake mode: ${FAKE_GH_MODE:-}" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
case "$endpoint" in
|
||||
*/commits*) out="$(jq -nc --arg m "${FAKE_COMMIT_MSG:-}" '[{commit:{message:$m}}]')" ;;
|
||||
*) out="$(jq -nc --arg b "${FAKE_PR_BODY:-}" '{body:$b}')" ;;
|
||||
esac
|
||||
if [ -n "$jqexpr" ]; then printf '%s' "$out" | jq -r "$jqexpr"; else printf '%s' "$out"; fi
|
||||
printf '{"data":{"repository":{"pullRequest":{"body":"Refs #5","closingIssuesReferences":{"nodes":[],"pageInfo":{"hasNextPage":%s}}}}}}\n' "$has_next"
|
||||
EOF
|
||||
chmod +x "$TMP/bin/gh"
|
||||
|
||||
# curl as the forgejo backend calls it: -D <headers> -o <body> <url>. The
|
||||
# x-total-count header is what forge_api's completeness proof compares
|
||||
# against, so `partial` here is a REAL short gather, not a flag the test
|
||||
# invents (#188, #4699).
|
||||
cat >"$TMP/bin/curl" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -u
|
||||
hdr=""; body=""; url=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-D) hdr="$2"; shift 2 ;;
|
||||
-o) body="$2"; shift 2 ;;
|
||||
-H) shift 2 ;;
|
||||
-sS|-s|-S) shift ;;
|
||||
*) url="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
[ -z "${FORGE_CALL_LOG:-}" ] || printf '%s\n' "$url" >>"$FORGE_CALL_LOG"
|
||||
case "${FAKE_FORGE_MODE:-success}" in
|
||||
body-fails) case "$url" in *"/commits"*) ;; *) echo "fake body read failed" >&2; exit 42 ;; esac ;;
|
||||
commits-fails) case "$url" in *"/commits"*) echo "fake commit read failed" >&2; exit 42 ;; esac ;;
|
||||
esac
|
||||
total=1
|
||||
case "$url" in
|
||||
*"/commits"*)
|
||||
payload="$(jq -nc --arg m "${FAKE_COMMIT_MSG:-}" '[{commit:{message:$m}}]')"
|
||||
# `partial`: declare 9, serve 1 and then nothing. Serving the same item on
|
||||
# every page instead would NOT be a short gather — forge_api walks until a
|
||||
# short page, so a stub that never runs out lets it collect exactly the
|
||||
# declared total and pass honestly. The first draft of this stub did that
|
||||
# and the case passed while proving nothing.
|
||||
if [ "${FAKE_FORGE_MODE:-success}" = partial ]; then
|
||||
total=9
|
||||
grep -q 'page=1\b' <<<"$url" || payload='[]'
|
||||
fi
|
||||
;;
|
||||
*) payload="$(jq -nc --arg b "${FAKE_PR_BODY:-}" '{body:$b}')" ;;
|
||||
esac
|
||||
printf 'HTTP/1.1 200 OK\r\nx-total-count: %s\r\n\r\n' "$total" >"$hdr"
|
||||
printf '%s' "$payload" >"$body"
|
||||
EOF
|
||||
chmod +x "$TMP/bin/curl"
|
||||
|
||||
# boundary <forge> <mode> [body] [commit-message]
|
||||
boundary() {
|
||||
local forge="$1" mode="$2"
|
||||
local body="${3-$PR_BODY_DEFAULT}" msg="${4-$COMMIT_MSG_DEFAULT}"
|
||||
env PATH="$TMP/bin:$PATH" \
|
||||
FAKE_FORGE_MODE="$mode" FAKE_PR_BODY="$body" FAKE_COMMIT_MSG="$msg" \
|
||||
CEREMONY_FORGE="$forge" \
|
||||
CEREMONY_FORGE_API="https://forge.example/api/v1" GH_TOKEN=tok \
|
||||
REPO="heavy-duty/ceremony" GITHUB_REPOSITORY="heavy-duty/ceremony" \
|
||||
PR_NUMBER=268 \
|
||||
action_boundary() {
|
||||
local mode="$1"
|
||||
# CEREMONY_FORGE=github is the environment this matrix has always assumed
|
||||
# implicitly — it stubs `gh`. It is explicit now only because the entrypoint
|
||||
# declares CEREMONY_FORGE_CLIENT=gh and preflights it (#198 spec 4); the
|
||||
# incident matrix below is unchanged.
|
||||
env PATH="$TMP/bin:$PATH" FAKE_GH_MODE="$mode" \
|
||||
CEREMONY_FORGE=github \
|
||||
GITHUB_REPOSITORY="heavy-duty/ceremony" PR_NUMBER=268 \
|
||||
GITHUB_ACTION_PATH="$ROOT/actions/refs-not-closing" \
|
||||
bash "$ENTRYPOINT"
|
||||
}
|
||||
|
||||
# THE PORTABILITY CLAIM, driven rather than asserted: one fixture, both
|
||||
# backends, same verdict. This is the acceptance criterion #199 states.
|
||||
check "a Refs promise contradicted by a commit is caught — github backend" 1 \
|
||||
"#5" boundary github success
|
||||
check "...and identically on the forgejo backend" 1 \
|
||||
"#5" boundary forgejo success
|
||||
# The declared-client refusal (#198 spec 4). This action is the one call site
|
||||
# the 0.6.0 merge could NOT port — Forgejo serves no GraphQL at all — so on a
|
||||
# Forgejo forge it must refuse by name, never produce a verdict from a graph
|
||||
# it did not read. #199 removes the declaration by making the gather REST.
|
||||
forgejo_boundary() {
|
||||
env PATH="$TMP/bin:$PATH" FAKE_GH_MODE=success GH_CALL_LOG="$TMP/gh-calls" \
|
||||
CEREMONY_FORGE=forgejo \
|
||||
GITHUB_REPOSITORY="heavy-duty/ceremony" PR_NUMBER=268 \
|
||||
GITHUB_ACTION_PATH="$ROOT/actions/refs-not-closing" \
|
||||
bash "$ENTRYPOINT"
|
||||
}
|
||||
# The contract on a forge this action cannot speak: refuse, by name, non-zero,
|
||||
# and read nothing. FAIL CLOSED — an earlier head made this exit 0 so the PR
|
||||
# check would not be red, which conflated the ACTION's contract with the
|
||||
# CALLER's scheduling decision (@codex-reviewer-andresmgsl, #198). The caller
|
||||
# is .github/workflows/refs-guard.yml, which skips on a backend this action
|
||||
# cannot speak; the action itself never reports success it did not earn.
|
||||
check "on a forgejo forge the action refuses, non-zero" 1 \
|
||||
"cannot speak it" forgejo_boundary
|
||||
check "...naming the client it declared" 1 "'gh' client" forgejo_boundary
|
||||
check "...and the client the forge actually needs" 1 "'rest' client" forgejo_boundary
|
||||
# The teeth: it must not have READ anything. The stub counts its own calls, so
|
||||
# a gather that ran despite the refusal is visible here.
|
||||
forgejo_read_count() {
|
||||
: >"$TMP/gh-calls"
|
||||
forgejo_boundary >/dev/null 2>&1
|
||||
wc -l <"$TMP/gh-calls"
|
||||
}
|
||||
check "...and reached the forge zero times" 0 "0" forgejo_read_count
|
||||
# The caller carries the scheduling half, positively: only github.com runs it.
|
||||
check "the caller skips the job on any non-github forge" 0 \
|
||||
"github.server_url == 'https://github.com'" \
|
||||
grep -F "if:" "$ROOT/.github/workflows/refs-guard.yml"
|
||||
|
||||
# The half a body-only port would miss: the keyword is ONLY in the commit.
|
||||
check "a closing keyword only in a commit message is detected — github" 1 \
|
||||
"#5" boundary github success 'Refs #5' 'Closes #5'
|
||||
check "...and on forgejo" 1 \
|
||||
"#5" boundary forgejo success 'Refs #5' 'Closes #5'
|
||||
# ...and the same PR passes when nothing closes it, so the case above is
|
||||
# detecting the keyword rather than just failing on every input.
|
||||
check "a body that only references, with no closing keyword anywhere, passes" 0 \
|
||||
"" boundary forgejo success 'Refs #5' 'plain commit subject'
|
||||
check "action boundary fails when GraphQL read fails" 42 \
|
||||
"fake GraphQL read failed" action_boundary failure
|
||||
check "action boundary refuses a partial closing-reference page" 5 \
|
||||
"refusing a partial verdict" action_boundary partial
|
||||
check "action boundary accepts a complete GraphQL read" 0 \
|
||||
"no Refs target" action_boundary success
|
||||
|
||||
# A failed read must never reach the parser: an empty body parses to an empty
|
||||
# closing set, which is a PASSING verdict the action never earned.
|
||||
check "a failed body read refuses, non-zero — github" 1 \
|
||||
"refusing a verdict" boundary github body-fails
|
||||
check "...and on forgejo" 1 "refusing a verdict" boundary forgejo body-fails
|
||||
check "a failed commit read refuses, non-zero" 1 \
|
||||
"refusing a partial verdict" boundary github commits-fails
|
||||
|
||||
# The `hasNextPage` refusal, relocated (#199 spec 3): a short paginated gather
|
||||
# is the REST equivalent, and the forgejo backend's x-total-count proof is
|
||||
# what catches it. This is the case that would silently pass if the port had
|
||||
# dropped --paginate.
|
||||
check "an incomplete commit read refuses a partial verdict" 1 \
|
||||
"refusing a partial verdict" boundary forgejo partial
|
||||
|
||||
# Strip comments first. The entrypoint's prose NAMES gh, GraphQL and
|
||||
# CEREMONY_FORGE_CLIENT to explain what it replaced, so a raw grep asserts on
|
||||
# the explanation rather than the code — it passes with the call still there.
|
||||
entrypoint_code() { sed 's/#.*//' "$ENTRYPOINT"; }
|
||||
invokes_gh() { entrypoint_code | grep -qE '(^|[^[:alnum:]_])gh[[:space:]]'; }
|
||||
holds_graphql() { entrypoint_code | grep -qi 'graphql'; }
|
||||
declares_client() { entrypoint_code | grep -q 'CEREMONY_FORGE_CLIENT'; }
|
||||
check "the entrypoint invokes no gh (#199)" 1 "" invokes_gh
|
||||
check "...and holds no GraphQL" 1 "" holds_graphql
|
||||
check "...and declares no forge client, because it speaks the shim" 1 "" declares_client
|
||||
# The caller must SCHEDULE it now. A portable action behind a github-only gate
|
||||
# is a guard that passes by never running (@kimi-reviewer-andresmgsl, #198).
|
||||
check "the caller no longer gates the job on the forge" 1 "" \
|
||||
grep -F "github.server_url" "$ROOT/.github/workflows/refs-guard.yml"
|
||||
one_graphql_read() {
|
||||
[ "$(grep -c "gh api graphql" "$ENTRYPOINT")" -eq 1 ]
|
||||
printf '1\n'
|
||||
}
|
||||
|
||||
check "action performs exactly one GraphQL read" 0 "1" \
|
||||
one_graphql_read
|
||||
check "composite delegates to the tested entrypoint" 0 "run.sh" \
|
||||
grep -F "run: bash \"\$GITHUB_ACTION_PATH/run.sh\"" "$ACTION"
|
||||
|
||||
|
|
|
|||
|
|
@ -97,30 +97,6 @@ path_check() {
|
|||
[ -z "$missing" ] && [ -z "$extra" ]
|
||||
}
|
||||
|
||||
path_check_reports_only() {
|
||||
local tree="$1" expected="$2" output rc
|
||||
output="$(path_check "$tree" 2>&1)"
|
||||
rc=$?
|
||||
if [ "$rc" -ne 1 ] || [ "$output" != "$expected" ]; then
|
||||
printf 'expected only: %s\ngot (exit %s): %s\n' \
|
||||
"$expected" "$rc" "$output" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
readme_has_no_path_enumeration() {
|
||||
local token found=no
|
||||
for token in \
|
||||
bin/ lib/version.sh lib/decide.sh lib/facts.sh lib/changelog.sh lib/forge.sh
|
||||
do
|
||||
if grep -qF "$token" "$ROOT/drills/README.md"; then
|
||||
printf 'drill doctrine enumerates release path: %s\n' "$token" >&2
|
||||
found=yes
|
||||
fi
|
||||
done
|
||||
[ "$found" = no ]
|
||||
}
|
||||
|
||||
fixture() {
|
||||
local name="$1" tree
|
||||
tree="$TMP/$name"
|
||||
|
|
@ -130,7 +106,6 @@ fixture() {
|
|||
"\$ROOT/lib/changelog.sh" >"$tree/bin/assemble"
|
||||
printf '#!/usr/bin/env bash\n' >"$tree/lib/changelog.sh"
|
||||
printf '#!/usr/bin/env bash\n' >"$tree/lib/decide.sh"
|
||||
printf '#!/usr/bin/env bash\n' >"$tree/lib/preflight.sh"
|
||||
# facts.sh sources BOTH on this tree: version.sh, and the forge shim #191
|
||||
# put on the doors' path so a Forgejo consumer can publish (#198). The
|
||||
# synthetic tree mirrors the real one, or every fixture below reports
|
||||
|
|
@ -146,65 +121,61 @@ fixture() {
|
|||
|
||||
# Exact output is the record author's copy-paste source.
|
||||
check "manifest prints the specified ordered release path" 0 \
|
||||
$'.github/workflows/release.yml\nbin/\nlib/version.sh\nlib/decide.sh\nlib/preflight.sh\nlib/facts.sh\nlib/changelog.sh\nlib/forge.sh' \
|
||||
$'.github/workflows/release.yml\nbin/\nlib/version.sh\nlib/decide.sh\nlib/facts.sh\nlib/changelog.sh\nlib/forge.sh' \
|
||||
bash "$PATH_SCRIPT"
|
||||
check "real workflow and transitive dependencies match the manifest" 0 "" \
|
||||
path_check "$ROOT"
|
||||
check "drill doctrine does not duplicate the executable release path" 0 "" \
|
||||
readme_has_no_path_enumeration
|
||||
|
||||
# A door growing a dependency must name the missing path (#237 D7).
|
||||
tree="$(fixture missing)"
|
||||
printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\nrun: . "%s"\nrun: . "%s"\nrun: . "%s"\n' \
|
||||
printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\nrun: . "%s"\nrun: . "%s"\n' \
|
||||
"\$CEREMONY_DIR/lib/facts.sh" "\$CEREMONY_DIR/lib/decide.sh" \
|
||||
"\$CEREMONY_DIR/lib/preflight.sh" "\$CEREMONY_DIR/lib/changelog.sh" \
|
||||
"\$CEREMONY_DIR/lib/version.sh" "\$CEREMONY_DIR/lib/ruling.sh" \
|
||||
"\$CEREMONY_DIR/lib/changelog.sh" "\$CEREMONY_DIR/lib/version.sh" \
|
||||
"\$CEREMONY_DIR/lib/ruling.sh" \
|
||||
>"$tree/.github/workflows/release.yml"
|
||||
printf '#!/usr/bin/env bash\n' >"$tree/lib/ruling.sh"
|
||||
check "a new workflow library fails with only its missing path" 0 "" \
|
||||
path_check_reports_only "$tree" \
|
||||
"release-path: missing dependency: lib/ruling.sh"
|
||||
check "a new workflow library fails with its missing path" 1 \
|
||||
"missing dependency: lib/ruling.sh" path_check "$tree"
|
||||
|
||||
# A library growing a sibling dependency in the production idiom must also
|
||||
# name the missing path; a literal lib/ marker in a comment is not evidence.
|
||||
tree="$(fixture missing-transitive)"
|
||||
printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\nrun: . "%s"\n' \
|
||||
printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\n' \
|
||||
"\$CEREMONY_DIR/lib/facts.sh" "\$CEREMONY_DIR/lib/decide.sh" \
|
||||
"\$CEREMONY_DIR/lib/preflight.sh" "\$CEREMONY_DIR/lib/changelog.sh" \
|
||||
"\$CEREMONY_DIR/lib/changelog.sh" \
|
||||
>"$tree/.github/workflows/release.yml"
|
||||
printf '# shellcheck source=lib/ruling.sh\n. "%s"\n' \
|
||||
"\$(cd \"\$(dirname \"\${BASH_SOURCE[0]}\")\" && pwd)/ruling.sh" \
|
||||
>>"$tree/lib/facts.sh"
|
||||
printf '#!/usr/bin/env bash\n' >"$tree/lib/ruling.sh"
|
||||
check "a new sibling library fails with only its missing path" 0 "" \
|
||||
path_check_reports_only "$tree" \
|
||||
"release-path: missing dependency: lib/ruling.sh"
|
||||
check "a new sibling library fails with its missing path" 1 \
|
||||
"missing dependency: lib/ruling.sh" path_check "$tree"
|
||||
|
||||
# A manifest may not rot into a safe-looking superset.
|
||||
tree="$(fixture extra)"
|
||||
printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\nrun: . "%s"\n' \
|
||||
printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\n' \
|
||||
"\$CEREMONY_DIR/lib/facts.sh" "\$CEREMONY_DIR/lib/decide.sh" \
|
||||
"\$CEREMONY_DIR/lib/preflight.sh" "\$CEREMONY_DIR/lib/changelog.sh" \
|
||||
"\$CEREMONY_DIR/lib/changelog.sh" \
|
||||
>"$tree/.github/workflows/release.yml"
|
||||
sed -i '$ s|$| \\|' \
|
||||
sed -i 's| lib/forge.sh$| lib/forge.sh \\|' \
|
||||
"$tree/.github/scripts/release-path.sh"
|
||||
printf ' lib/ruling.sh\n' >>"$tree/.github/scripts/release-path.sh"
|
||||
printf '#!/usr/bin/env bash\n' >"$tree/lib/ruling.sh"
|
||||
check "a path no door reads fails with only its stale path" 0 "" \
|
||||
path_check_reports_only "$tree" "release-path: stale path: lib/ruling.sh"
|
||||
check "a path no door reads fails as stale" 1 "stale path: lib/ruling.sh" \
|
||||
path_check "$tree"
|
||||
|
||||
# Transitive sourcing is part of the derivation, not decoration.
|
||||
tree="$(fixture transitive)"
|
||||
printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\nrun: . "%s"\n' \
|
||||
printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\n' \
|
||||
"\$CEREMONY_DIR/lib/facts.sh" "\$CEREMONY_DIR/lib/decide.sh" \
|
||||
"\$CEREMONY_DIR/lib/preflight.sh" "\$CEREMONY_DIR/lib/changelog.sh" \
|
||||
"\$CEREMONY_DIR/lib/changelog.sh" \
|
||||
>"$tree/.github/workflows/release.yml"
|
||||
# Only the version source is dropped; the forge source #191 added stays, or
|
||||
# the fixture reports two stale paths and proves neither of them (#198).
|
||||
printf '#!/usr/bin/env bash\n# shellcheck source=lib/forge.sh\n. "%s"\n' \
|
||||
"\$(cd \"\$(dirname \"\${BASH_SOURCE[0]}\")\" && pwd)/forge.sh" \
|
||||
>"$tree/lib/facts.sh"
|
||||
check "removing facts' version source fails with only its stale path" 0 "" \
|
||||
path_check_reports_only "$tree" "release-path: stale path: lib/version.sh"
|
||||
check "removing facts' version source fails as a stale path" 1 \
|
||||
"stale path: lib/version.sh" path_check "$tree"
|
||||
|
||||
summary
|
||||
|
|
|
|||
|
|
@ -1,306 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# The delta-inventory guard (#200, enforcing #197 D3's standing constraint).
|
||||
#
|
||||
# Forge-specific behaviour lives in the files docs/UPSTREAM-SYNC.md names,
|
||||
# and nowhere else.
|
||||
#
|
||||
# WHY. This tree carries upstream's content plus a forge delta, forever — the
|
||||
# sync is recurring and upstream is read-only (#197 D3). What makes that
|
||||
# tractable is not luck: the 0.6.0 sync cost 18 conflict hunks across 10 files
|
||||
# because the delta sits in six files upstream never touches. Scatter
|
||||
# `forge_detect` into a seventh and every future sync pays for it, in a place
|
||||
# nobody chose.
|
||||
#
|
||||
# So this fails on the PR that scatters it, rather than on the sync after next
|
||||
# — the same reason changelog-monotonic guards a property no single tree can
|
||||
# be asked about.
|
||||
#
|
||||
# IT RUNS OFFLINE, AND IT DOES NOT PASS BY ABSENCE. Tests must not need the
|
||||
# network, and a guard that goes green when it cannot see its input is the
|
||||
# blind-sweep shape this repo keeps writing issues about. So the inventory is
|
||||
# read from the document and the tree is scanned for forge markers — both local
|
||||
# — and a missing or empty `.upstream-ref` is a REFUSAL, not a skip.
|
||||
set -u
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
# shellcheck source=test/harness.sh
|
||||
. "$ROOT/test/harness.sh"
|
||||
|
||||
DOC="$ROOT/docs/UPSTREAM-SYNC.md"
|
||||
REF="$ROOT/.upstream-ref"
|
||||
|
||||
# --- the recorded upstream ref ------------------------------------------------
|
||||
|
||||
check "the sync document exists" 0 "" test -f "$DOC"
|
||||
check "the upstream ref is recorded" 0 "" test -f "$REF"
|
||||
|
||||
# THE OBJECT IS MANDATORY. An earlier head accepted "UNVERIFIABLE-HERE" when
|
||||
# upstream's commit was not in the local store, which contradicted this issue's
|
||||
# own "must not pass by absence" and made the check decorative in exactly the
|
||||
# environment it matters in (@codex-reviewer-andresmgsl, #200 review).
|
||||
#
|
||||
# "Runs offline" means the TEST reads local evidence, not that CI may omit the
|
||||
# evidence and pass. ci.yml fetches the recorded object before running the
|
||||
# suite; if it is missing here, that is a refusal.
|
||||
recorded_ref() { grep -vE '^[[:space:]]*(#|$)' "$REF" | head -n1; }
|
||||
ref_is_recorded() { [ -n "$(recorded_ref)" ]; }
|
||||
check "...and is not blank — an unrecorded ref is a refusal, not a skip" 0 "" \
|
||||
ref_is_recorded
|
||||
# Full 40, not a short prefix: a short SHA is ambiguous across a growing repo
|
||||
# and cannot be compared byte-wise between the runbook, the CHANGELOG and here.
|
||||
ref_is_full_sha() { printf '%s' "$(recorded_ref)" | grep -qE '^[0-9a-f]{40}$'; }
|
||||
check "...and is a FULL 40-character SHA" 0 "" ref_is_full_sha
|
||||
|
||||
ref_object_present() { git -C "$ROOT" cat-file -e "$(recorded_ref)^{commit}" 2>/dev/null; }
|
||||
check "the recorded commit is present locally — absent is a refusal, not a skip" 0 "" \
|
||||
ref_object_present
|
||||
ref_is_ancestor() { git -C "$ROOT" merge-base --is-ancestor "$(recorded_ref)" HEAD 2>/dev/null; }
|
||||
check "...and is an ancestor of HEAD, so main really carries what is recorded" 0 "" \
|
||||
ref_is_ancestor
|
||||
|
||||
# --- the inventory ------------------------------------------------------------
|
||||
|
||||
# The document's inventory table: the first `code` span of each table row in
|
||||
# the "Where the forge delta lives" section.
|
||||
inventory() {
|
||||
awk '/^## Where the forge delta lives/ { on = 1; next }
|
||||
on && /^## / { on = 0 }
|
||||
on && /^\| `/ { gsub(/^\| `/, ""); sub(/`.*$/, ""); print }' "$DOC"
|
||||
}
|
||||
|
||||
inventory_size() { inventory | wc -l; }
|
||||
inventory_is_populated() { [ "$(inventory_size)" -ge 6 ]; }
|
||||
check "the inventory names at least the six known delta files" 0 "" \
|
||||
inventory_is_populated
|
||||
|
||||
# Exact for a file entry, `dir/` + one path separator for a directory entry.
|
||||
# Prefix matching accepted `drills-old/x` for `drills/` and `lib/forge.sh.backup`
|
||||
# for `lib/forge.sh` (@codex-reviewer-andresmgsl, #200 review).
|
||||
in_inventory() { # $1 = repo-relative path
|
||||
local entry
|
||||
while IFS= read -r entry; do
|
||||
[ -n "$entry" ] || continue
|
||||
case "$entry" in
|
||||
*/) case "$1" in "$entry"*) return 0 ;; esac ;;
|
||||
*) [ "$1" = "$entry" ] && return 0 ;;
|
||||
esac
|
||||
done < <(inventory)
|
||||
return 1
|
||||
}
|
||||
check "a listed file is recognised" 0 "" in_inventory lib/forge.sh
|
||||
check "...including one listed as a directory" 0 "" in_inventory drills/0.4.1.md
|
||||
check "an unlisted file is not" 1 "" in_inventory lib/version.sh
|
||||
|
||||
# --- the scan -----------------------------------------------------------------
|
||||
|
||||
# What "forge-specific" means, mechanically: the selector's own verbs, the
|
||||
# environment override that drives them, and — on a workflow, which has no
|
||||
# shell to call the selector from — a forge decision written inline.
|
||||
#
|
||||
# The earlier version scanned only shell under lib/ actions/ bin/ .github/scripts.
|
||||
# That missed three categories the inventory itself claims to govern: workflows,
|
||||
# .github/labels.conf and drills/. Merged main already had blind spots there —
|
||||
# labels.yml decides on GITHUB_SERVER_URL and declares a client, refs-guard.yml
|
||||
# carries a positive GitHub-only scheduling decision
|
||||
# (@codex-reviewer-andresmgsl, #200 review).
|
||||
FORGE_MARKERS='forge_detect|forge_select|forge_preflight|forge_client|CEREMONY_FORGE\b|CEREMONY_FORGE_CLIENT|GITHUB_SERVER_URL|github\.server_url'
|
||||
|
||||
# SCAN_ROOT is a parameter so the teeth below can drive the REAL check against
|
||||
# a constructed tree. The previous mutation proved the predicates and never
|
||||
# invoked no_unlisted — the guard could have been `return 0` and still passed.
|
||||
scan_root() { printf '%s' "${SCAN_ROOT:-$ROOT}"; }
|
||||
|
||||
# DISCOVERY IS DERIVED FROM THE TREE, not from a list of directories, depths
|
||||
# and extensions. The earlier version hand-picked five globs and therefore
|
||||
# could not see `actions/*/action.yml` — this repository's normal composite
|
||||
# structure, where a client declaration is exactly a forge decision — or a
|
||||
# workflow written `.yaml` rather than `.yml`. @codex-reviewer-andresmgsl
|
||||
# constructed both and the guard stayed 21/21 green, which is the whole
|
||||
# argument against maintaining a glob list.
|
||||
#
|
||||
# So: walk everything, then EXCLUDE by class, and let content classify the
|
||||
# rest. Excluding is safer than including because a new file type arrives
|
||||
# scanned rather than invisible.
|
||||
#
|
||||
# test/ the harness stubs and asserts these tokens by design
|
||||
# changelog.d/ prose fragments
|
||||
# *.md prose. `drills/` stays in the INVENTORY because its records
|
||||
# are forge-specific by content, but a record mentioning a
|
||||
# selector verb in prose is not a decision, and scanning prose
|
||||
# for decisions is the mistake this guard's own comment
|
||||
# handling exists to avoid.
|
||||
#
|
||||
# DISCOVERY IS GIT'S, NOT THE FILESYSTEM'S. An earlier head said "tracked" and
|
||||
# used `find`, which walks the working directory and knows nothing about the
|
||||
# index. That is not pedantry: `ci.yml` extracts shellcheck and actionlint
|
||||
# tarballs INTO the checkout before the suite runs, and any developer cache
|
||||
# sits there too. @codex-reviewer-andresmgsl reproduced a false red with one
|
||||
# untracked file. `git ls-files -z` makes "tracked" executable rather than
|
||||
# prose.
|
||||
scanned_paths() {
|
||||
local root; root="$(scan_root)"
|
||||
git -C "$root" ls-files -z 2>/dev/null \
|
||||
| tr '\0' '\n' \
|
||||
| grep -vE '^(test/|changelog\.d/)' \
|
||||
| grep -vE '\.md$' \
|
||||
| sort
|
||||
}
|
||||
|
||||
forge_specific_files() {
|
||||
local root rel; root="$(scan_root)"
|
||||
while IFS= read -r rel; do
|
||||
[ -n "$rel" ] || continue
|
||||
sed 's/[[:space:]]#.*$//; s/^[[:space:]]*#.*$//' "$root/$rel" 2>/dev/null \
|
||||
| grep -qE "$FORGE_MARKERS" && printf '%s\n' "$rel"
|
||||
done < <(scanned_paths)
|
||||
return 0
|
||||
}
|
||||
|
||||
# A file that merely CALLS the shim is not where the forge is decided — every
|
||||
# reconciler calls forge_preflight and that is the point of the shim. A file
|
||||
# that DECLARES a client is a different thing and is NOT exempt: it is a
|
||||
# deliberate forge-delta location and belongs in the inventory
|
||||
# (@codex-reviewer-andresmgsl).
|
||||
SHIM_CONSUMERS='actions/issueflow-reconcile/issueflow-reconcile.sh
|
||||
actions/labels-reconcile/labels-reconcile.sh
|
||||
actions/labels-scope/labels-scope.sh
|
||||
lib/facts.sh
|
||||
lib/ruling.sh
|
||||
lib/attention.sh
|
||||
.github/workflows/release.yml'
|
||||
|
||||
declares_a_client() {
|
||||
sed 's/[[:space:]]#.*$//; s/^[[:space:]]*#.*$//' "$(scan_root)/$1" \
|
||||
| grep -qE 'CEREMONY_FORGE_CLIENT[=:]'
|
||||
}
|
||||
|
||||
unlisted_deciders() {
|
||||
local rel
|
||||
while IFS= read -r rel; do
|
||||
[ -n "$rel" ] || continue
|
||||
if grep -qxF "$rel" <<<"$SHIM_CONSUMERS" && ! declares_a_client "$rel"; then
|
||||
continue
|
||||
fi
|
||||
in_inventory "$rel" && continue
|
||||
printf '%s\n' "$rel"
|
||||
done < <(forge_specific_files)
|
||||
}
|
||||
|
||||
no_unlisted() {
|
||||
local found
|
||||
found="$(unlisted_deciders)"
|
||||
[ -z "$found" ] || {
|
||||
printf 'forge-specific but not in docs/UPSTREAM-SYNC.md:\n' >&2
|
||||
printf ' %s\n' "$found" >&2
|
||||
return 1
|
||||
}
|
||||
}
|
||||
check "every forge-deciding file is named in the inventory" 0 "" no_unlisted
|
||||
|
||||
# --- teeth --------------------------------------------------------------------
|
||||
# These drive the REAL top-level check against a constructed tree, via
|
||||
# SCAN_ROOT. The earlier version asserted the predicates separately and never
|
||||
# invoked no_unlisted — so the guard could have been replaced with `return 0`
|
||||
# and both "must-fail" rows would still have passed
|
||||
# (@codex-reviewer-andresmgsl, #200 review). A mutation test that cannot fail
|
||||
# when the thing it guards is deleted is the shape this repo keeps filing
|
||||
# issues about.
|
||||
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
# A tiny GIT repository, because discovery is git's now: a fixture that is only
|
||||
# a directory would be invisible to `ls-files` and every must-fail below would
|
||||
# pass vacuously.
|
||||
fixture_tree() { # -> a tracked tree with no forge-deciding files
|
||||
local t="$TMP/tree"
|
||||
rm -rf "$t"
|
||||
mkdir -p "$t/lib" "$t/.github/workflows"
|
||||
printf '#!/usr/bin/env bash\necho hello\n' >"$t/lib/plain.sh"
|
||||
printf 'name: ci\non: [push]\n' >"$t/.github/workflows/plain.yml"
|
||||
git -C "$t" init -q 2>/dev/null
|
||||
git -C "$t" add -A 2>/dev/null
|
||||
printf '%s' "$t"
|
||||
}
|
||||
track() { git -C "$root" add -A 2>/dev/null; }
|
||||
|
||||
root="$(fixture_tree)"
|
||||
clean_tree_passes() { SCAN_ROOT="$root" no_unlisted; }
|
||||
check "the guard is green on a tree with no forge decisions" 0 "" clean_tree_passes
|
||||
|
||||
# MUST FAIL: a shell file that decides the forge, in no inventory entry.
|
||||
# shellcheck disable=SC2016 # fixture CONTENT: the literal text a scanned file would hold
|
||||
printf '%s\n' '#!/usr/bin/env bash' 'case "$(forge_detect)" in forgejo) : ;; esac' \
|
||||
>"$root/lib/scattered.sh"
|
||||
track; scattered_shell() { SCAN_ROOT="$root" no_unlisted; }
|
||||
check "a new SHELL file deciding the forge fails the real guard" 1 "" scattered_shell
|
||||
check "...and the failure names the file" 1 "lib/scattered.sh" scattered_shell
|
||||
rm -f "$root/lib/scattered.sh"; track
|
||||
|
||||
# MUST FAIL on a NON-SHELL surface too, so coverage cannot regress to the old
|
||||
# shell-only glob: a workflow deciding on the server URL.
|
||||
# shellcheck disable=SC2016 # fixture CONTENT: the literal text a scanned file would hold
|
||||
printf '%s\n' 'name: x' 'on: [push]' 'jobs:' ' j:' ' steps:' \
|
||||
' - run: [ "$GITHUB_SERVER_URL" = https://github.com ] || exit 0' \
|
||||
>"$root/.github/workflows/scattered.yml"
|
||||
track; scattered_workflow() { SCAN_ROOT="$root" no_unlisted; }
|
||||
check "a new WORKFLOW deciding the forge fails it too" 1 "" scattered_workflow
|
||||
check "...naming that file" 1 ".github/workflows/scattered.yml" scattered_workflow
|
||||
rm -f "$root/.github/workflows/scattered.yml"; track
|
||||
|
||||
# @codex-reviewer-andresmgsl's two reproductions, verbatim as fixtures. Both
|
||||
# passed the hand-picked-glob version 21/21, which is why discovery is derived
|
||||
# from the tree now. Deleting a discovery class must make these red.
|
||||
mkdir -p "$root/actions/unlisted-forge-decision"
|
||||
printf '%s\n' 'name: x' 'runs:' ' using: composite' ' steps:' \
|
||||
' - shell: bash' ' env:' ' CEREMONY_FORGE_CLIENT: gh' \
|
||||
' run: true' >"$root/actions/unlisted-forge-decision/action.yml"
|
||||
track; composite_action_seen() { SCAN_ROOT="$root" no_unlisted; }
|
||||
check "a forge declaration in actions/*/action.yml fails the guard" 1 \
|
||||
"actions/unlisted-forge-decision/action.yml" composite_action_seen
|
||||
rm -rf "$root/actions/unlisted-forge-decision"; track
|
||||
|
||||
# ...and a workflow written .yaml rather than .yml — `*.yml` was never a
|
||||
# complete workflow surface.
|
||||
# shellcheck disable=SC2016 # fixture CONTENT: the literal text a scanned file would hold
|
||||
printf '%s\n' 'name: x' 'on: [push]' 'jobs:' ' j:' \
|
||||
" if: github.server_url == 'https://github.com'" ' steps: []' \
|
||||
>"$root/.github/workflows/unlisted-forge-decision.yaml"
|
||||
track; yaml_workflow_seen() { SCAN_ROOT="$root" no_unlisted; }
|
||||
check "...and one in a .yaml workflow does too" 1 \
|
||||
".github/workflows/unlisted-forge-decision.yaml" yaml_workflow_seen
|
||||
rm -f "$root/.github/workflows/unlisted-forge-decision.yaml"; track
|
||||
check "...leaving the fixture tree green again" 0 "" clean_tree_passes
|
||||
|
||||
# A declaration is a delta location even in a file that would otherwise read as
|
||||
# a shim consumer, so the consumer allow-list cannot hide one.
|
||||
mkdir -p "$root/lib"
|
||||
printf '%s\n' '#!/usr/bin/env bash' 'CEREMONY_FORGE_CLIENT=gh' >"$root/lib/facts.sh"
|
||||
track; declared_not_exempt() { SCAN_ROOT="$root" no_unlisted; }
|
||||
check "a shim consumer that DECLARES a client is not exempt" 1 "lib/facts.sh" \
|
||||
declared_not_exempt
|
||||
rm -f "$root/lib/facts.sh"; track
|
||||
track
|
||||
check "...and the tree is green again once it is gone" 0 "" clean_tree_passes
|
||||
|
||||
# UNTRACKED input is not source. ci.yml extracts shellcheck and actionlint
|
||||
# tarballs into the checkout before the suite runs, and a developer cache sits
|
||||
# there too; parsing either is a false red on something outside the repository
|
||||
# property (@codex-reviewer-andresmgsl, #200 review — reproduced with one file).
|
||||
printf 'CEREMONY_FORGE_CLIENT=gh\n' >"$root/local-tool-cache.txt"
|
||||
check "an UNTRACKED marker-bearing file is ignored" 0 "" clean_tree_passes
|
||||
check "...and is still ignored once it carries a decision" 0 "" clean_tree_passes
|
||||
track
|
||||
check "...but the moment it is TRACKED the guard sees it" 1 "local-tool-cache.txt" \
|
||||
clean_tree_passes
|
||||
git -C "$root" rm -q --cached local-tool-cache.txt 2>/dev/null
|
||||
rm -f "$root/local-tool-cache.txt"
|
||||
check "...and removing it restores green" 0 "" clean_tree_passes
|
||||
|
||||
# Path matching, both boundaries.
|
||||
check "a directory entry does not match a sibling with the same prefix" 1 "" \
|
||||
in_inventory drills-old/0.4.1.md
|
||||
check "a file entry does not match a longer path" 1 "" in_inventory lib/forge.sh.backup
|
||||
check "...while the real ones still match" 0 "" in_inventory drills/0.4.1.md
|
||||
|
||||
summary
|
||||
Loading…
Reference in a new issue