forked from heavy-duty/ceremony
Compare commits
83 commits
build/231-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 91aee7f842 | |||
|
|
0b1fa70d60 | ||
|
|
4bce62e1fa | ||
|
|
4ce43c4a3b | ||
|
|
dc29fdc84c | ||
|
|
328c8707df | ||
| 85290031b2 | |||
|
|
d944bddecc | ||
|
|
715663cf53 | ||
|
|
e2aa834601 | ||
|
|
f19658ea82 | ||
|
|
79e747b163 | ||
|
|
7bd331a44d | ||
|
|
153a408e10 | ||
|
|
3104aac6f3 | ||
| 58ab50361d | |||
|
|
1aa03cad77 | ||
|
|
fe4ec57ff2 | ||
|
|
20cba4583d | ||
|
|
734676ab7d | ||
|
|
c4315c2cfa | ||
|
|
2ab9902c38 | ||
| f5c02fee8f | |||
|
|
7e96748193 | ||
|
|
bcbcd90047 | ||
| 8f0ef79620 | |||
|
|
12a7fcb688 | ||
|
|
03cb69deba | ||
| d439ff6c08 | |||
|
|
53b7856012 | ||
|
|
0ea0cf50af | ||
| 0533766a42 | |||
|
|
f221647fe3 | ||
| aa167fd4ef | |||
|
|
13add81d62 | ||
|
|
988d8a2cce | ||
|
|
bb984de133 | ||
| 484eb79623 | |||
| f6f2ec7fff | |||
|
|
54a9334363 | ||
|
|
4bdb80bf80 | ||
|
|
21871de5ee | ||
|
|
9f54eb98bf | ||
|
|
1cd8a6996f | ||
|
|
a0ffc4bbd8 | ||
|
|
17b99183ca | ||
| 6dc8bf6558 | |||
|
|
7fa202acb5 | ||
|
|
0790745645 | ||
|
|
7da89a46aa | ||
|
|
7690c15e1a | ||
|
|
e639e67f09 | ||
|
|
ffbc1afc3d | ||
|
|
311ef304fc | ||
| e55e99663e | |||
|
|
5823f3d7b7 | ||
|
|
0f3d3b36eb | ||
|
|
a2b9b30930 | ||
| a1bac15a8b | |||
| 46458ba8cd | |||
|
|
1164640a08 | ||
|
|
40ebcea462 | ||
|
|
8c0f5d53d7 | ||
|
|
4fb01e8b9f | ||
|
|
d712f0636f | ||
|
|
ca7ce6e919 | ||
|
|
5232027361 | ||
|
|
6b2b467b7c | ||
|
|
b105939d95 | ||
|
|
b2048f63bd | ||
| 5a8fce8375 | |||
| 5be223a020 | |||
|
|
f0f3907618 | ||
|
|
809b7e907a | ||
|
|
fdb7d7577b | ||
|
|
aa818d5a93 | ||
|
|
05f182fe29 | ||
|
|
22865aba54 | ||
|
|
f05e83a562 | ||
|
|
6f5ce8f994 | ||
|
|
0160f6a883 | ||
| 7bdae45c98 | |||
|
|
d0f5e40fa1 |
51 changed files with 1719 additions and 423 deletions
19
.github/DISCUSSION_TEMPLATE/ideas.yml
vendored
19
.github/DISCUSSION_TEMPLATE/ideas.yml
vendored
|
|
@ -1,19 +0,0 @@
|
|||
# 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
15
.github/DISCUSSION_TEMPLATE/q-a.yml
vendored
|
|
@ -1,15 +0,0 @@
|
|||
# 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,13 +1,12 @@
|
|||
# The new-issue interception (issue #24, decision 1): interception over
|
||||
# instruction — prose alone has already proven insufficient everywhere else
|
||||
# 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.
|
||||
# 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.
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Ideas, bugs, questions — start a Discussion
|
||||
url: https://github.com/heavy-duty/ceremony/discussions
|
||||
- name: Ideas, bugs, questions — file a Proposal
|
||||
url: https://forgejo.heavyduty.builders/heavy-duty/ceremony/issues/new?template=proposal.yml
|
||||
about: >-
|
||||
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).
|
||||
Anyone may file a proposal. Triage converts it into a work issue or
|
||||
refuses it with reasons; only triage mints work issues (TRIAGE.md).
|
||||
|
|
|
|||
34
.github/ISSUE_TEMPLATE/proposal.yml
vendored
Normal file
34
.github/ISSUE_TEMPLATE/proposal.yml
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# 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 issues —
|
||||
everyone else starts a Discussion. Triage may still compose by hand when
|
||||
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 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 discussion it came from, the code
|
||||
Why this exists, with links — the proposal 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,6 +46,7 @@ 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
|
||||
|
|
|
|||
1
.github/scripts/release-path.sh
vendored
1
.github/scripts/release-path.sh
vendored
|
|
@ -19,6 +19,7 @@ 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/labels-sweep.yml
vendored
13
.github/workflows/labels-sweep.yml
vendored
|
|
@ -3,9 +3,11 @@ 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). 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.
|
||||
# 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).
|
||||
#
|
||||
# Detached on purpose (#209): every sweep covers every open PR and all
|
||||
# sweeps serialize through ONE shared concurrency group, so GitHub's
|
||||
|
|
@ -24,7 +26,8 @@ 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 board event, the same reason cron runs never bootstrapped.
|
||||
# for every issue and same-repository PR wake, 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
|
||||
|
|
@ -63,7 +66,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.1"
|
||||
CEREMONY_SELF_REF: "0.6.3"
|
||||
|
||||
jobs:
|
||||
reconcile:
|
||||
|
|
|
|||
62
.github/workflows/labels.yml
vendored
62
.github/workflows/labels.yml
vendored
|
|
@ -2,12 +2,16 @@ name: labels
|
|||
# Reusable half of the labels automation. Triggers and permissions live in
|
||||
# the caller; docs/CONSUMERS.md carries the complete caller stub.
|
||||
#
|
||||
# The caller uses pull_request_target, not pull_request: every PR in this
|
||||
# family arrives from a fork, where pull_request runs with a READ-ONLY token
|
||||
# and cannot label anything. _target is safe in this workflow because no PR
|
||||
# code is ever checked out or executed — scope reads changed paths and the
|
||||
# path mapping via the API and checks out only the ceremony implementation.
|
||||
# Keep it that way.
|
||||
# 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 reconcile sweep lived here until #209. Riding the PR-triggered run
|
||||
# meant every displacement in the sweep's shared concurrency queue recorded
|
||||
|
|
@ -19,10 +23,14 @@ 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 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.
|
||||
# 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.
|
||||
#
|
||||
# This cannot loop: the trigger's dispatch and the reconciler's label
|
||||
# writes both use GITHUB_TOKEN. GitHub does not create workflow runs from
|
||||
|
|
@ -48,7 +56,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.1"
|
||||
CEREMONY_SELF_REF: "0.6.3"
|
||||
|
||||
jobs:
|
||||
scope:
|
||||
|
|
@ -60,6 +68,7 @@ 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' &&
|
||||
|
|
@ -106,15 +115,12 @@ jobs:
|
|||
CONFIG_REF: ${{ github.sha }}
|
||||
|
||||
trigger:
|
||||
# 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.
|
||||
# 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.
|
||||
#
|
||||
# LOUD on failure — never `|| true`: a red trigger is the
|
||||
# misconfiguration alarm. A consumer that bumps the pin without adding
|
||||
|
|
@ -122,6 +128,9 @@ 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
|
||||
|
|
@ -199,3 +208,16 @@ jobs:
|
|||
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"
|
||||
|
|
|
|||
53
.github/workflows/release.yml
vendored
53
.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.1"
|
||||
CEREMONY_SELF_REF: "0.6.3"
|
||||
VERSION_SOURCE: ${{ inputs.version-source }}
|
||||
|
||||
jobs:
|
||||
|
|
@ -205,31 +205,40 @@ jobs:
|
|||
fi
|
||||
changelog_section CHANGELOG.md "$VER" > "$RUNNER_TEMP/notes.md"
|
||||
cat "$RUNNER_TEMP/notes.md"
|
||||
- name: nothing may exist yet — no tag, no release (re-runs refuse loudly)
|
||||
- name: preflight — resume this merge, refuse every other collision
|
||||
id: preflight
|
||||
if: steps.decide.outputs.ceremony == 'yes'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VER: ${{ steps.facts.outputs.ver }}
|
||||
# What makes a re-run of a completed ceremony refuse instead of
|
||||
# clobber, and what catches a manual tag racing the merge.
|
||||
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).
|
||||
run: |
|
||||
if git ls-remote --exit-code origin "refs/tags/$VER" >/dev/null 2>&1; then
|
||||
echo "tag '$VER' already exists — this release already happened, or a manual tag won the race; refusing to re-release, creating nothing." >&2
|
||||
exit 1
|
||||
fi
|
||||
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 ! exists="$(forge_release_exists "$VER")"; then
|
||||
if ! released="$(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
|
||||
# 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"
|
||||
- name: tag the merge commit — same job as the publish, on purpose
|
||||
if: steps.decide.outputs.ceremony == 'yes'
|
||||
if: steps.decide.outputs.ceremony == 'yes' && steps.preflight.outputs.resume != 'yes'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VER: ${{ steps.facts.outputs.ver }}
|
||||
|
|
@ -363,6 +372,22 @@ 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.
|
||||
|
|
|
|||
16
.github/workflows/self-labels-sweep.yml
vendored
16
.github/workflows/self-labels-sweep.yml
vendored
|
|
@ -7,13 +7,16 @@ 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 four transition classes — a review verdict landing (there is no
|
||||
# wake for 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. 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
|
||||
# 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
|
||||
# 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
|
||||
|
|
@ -22,8 +25,9 @@ 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 board 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 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
|
||||
# caller contract: a dispatch naming an undeclared input is refused, and
|
||||
# the trigger job goes loudly red.
|
||||
workflow_dispatch:
|
||||
|
|
|
|||
26
.github/workflows/self-labels.yml
vendored
26
.github/workflows/self-labels.yml
vendored
|
|
@ -8,8 +8,12 @@ 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. A board event
|
||||
# below still yields a sweep within seconds, one dispatch hop later.
|
||||
# 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).
|
||||
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/
|
||||
|
|
@ -26,14 +30,16 @@ on:
|
|||
issues:
|
||||
types: [opened, closed, edited, reopened]
|
||||
pull_request_target:
|
||||
# Every PR arrives from a fork, so these carry the head/draft/review facts
|
||||
# the sweep derives state:* from. labeled/unlabeled are the handoff wake —
|
||||
# the author's optimistic state:needs-human write, confirmed or corrected
|
||||
# here in seconds (#11); synchronize re-derives on every push;
|
||||
# review_requested/review_request_removed wake the sweep that clears (or
|
||||
# restores) blocker:unrequested — without them the one event that makes
|
||||
# the label false could not clear it, and a quiet repo wore the red flag
|
||||
# until the advisory cron (#137).
|
||||
# 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).
|
||||
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled, review_requested, review_request_removed]
|
||||
permissions:
|
||||
contents: read
|
||||
|
|
|
|||
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 discussions into buildable issues — or refuse well; you are the only door issues come through |
|
||||
| **triage** agent | [TRIAGE.md](TRIAGE.md) | turn proposals into buildable work issues — or refuse well; you are the only door work 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 discussions or the
|
||||
asked to implement an issue → builder; asked to process proposals 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
|
||||
|
||||
```
|
||||
discussion ──▶ triage ──▶ issue ──▶ build ──▶ review ──▶ human merge ──▶ release
|
||||
(anyone) (agent) (queue) (agent) (agents) (human) (ceremony)
|
||||
proposal ──▶ triage ──▶ work issue ──▶ build ──▶ review ──▶ human merge ──▶ release
|
||||
(anyone) (agent) (queue) (agent) (agents) (human) (ceremony)
|
||||
```
|
||||
|
||||
Two rules bind every role:
|
||||
|
||||
- **Only triage mints issues.** Found work? Open or extend a discussion.
|
||||
- **Only triage mints work issues.** Found work? File or extend a proposal.
|
||||
- **Only humans merge.** Convergence ends at `state:needs-human`, never at
|
||||
a merge button.
|
||||
|
||||
|
|
|
|||
|
|
@ -140,8 +140,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 discussion, or a comment on the relevant issue;
|
||||
you do not mint issues — nobody but triage does — and you do not fix
|
||||
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
|
||||
drive-by findings in the same PR.
|
||||
|
||||
## The review round
|
||||
|
|
|
|||
63
CHANGELOG.md
63
CHANGELOG.md
|
|
@ -17,10 +17,65 @@ 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'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).
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -10,22 +10,22 @@ the two is a bug.
|
|||
Work moves through one pipeline, and every stage has an owner:
|
||||
|
||||
```
|
||||
discussion ──▶ triage ──▶ issue ──▶ build ──▶ review ──▶ human merge ──▶ release
|
||||
(anyone) (agent) (queue) (agent) (agents) (human) (ceremony)
|
||||
proposal ──▶ triage ──▶ work issue ──▶ build ──▶ review ──▶ human merge ──▶ release
|
||||
(anyone) (agent) (queue) (agent) (agents) (human) (ceremony)
|
||||
```
|
||||
|
||||
- **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
|
||||
- **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
|
||||
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 discussion.
|
||||
back into a proposal.
|
||||
- **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 discussion (what is worth doing, and any
|
||||
- **Humans decide twice**: in the proposal (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
|
||||
|
|
@ -48,7 +48,7 @@ the box is the blast-radius boundary; roles are what a session is told, and
|
|||
|
||||
| identity | box (rig tenant) | standing work |
|
||||
|---|---|---|
|
||||
| `claude-bot-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. |
|
||||
| `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 |
|
||||
|
|
@ -62,7 +62,7 @@ panel-minus-author, and three 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
|
||||
discussions and merge; the roster is config, not doctrine — swapping a
|
||||
proposals 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.
|
||||
|
||||
|
|
@ -143,7 +143,8 @@ 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;
|
||||
- **Discussions enabled**, so the triage door exists.
|
||||
- **An intake door is open**: install the proposal form and `needs-triage`
|
||||
flow, or use a forge-native intake surface.
|
||||
|
||||
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, discussions without triage's voice, unread
|
||||
queue-unlabeled strays, proposals 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,8 +27,10 @@ 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); the `labeled` event fires the sweep that validates the
|
||||
write within seconds.
|
||||
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).
|
||||
|
||||
## PR blockers — what is in the way? (facts, as many as apply)
|
||||
|
||||
|
|
@ -49,7 +51,7 @@ strips it on sight).
|
|||
|
||||
| Label | Color | Means | Set by |
|
||||
|---|---|---|---|
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
|
@ -240,4 +242,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` is a discussion, not an issue.
|
||||
deleted at bootstrap — a `question` belongs in a proposal, not a work 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-L301) asserts its
|
||||
way to certainty, tags the merge commit, publishes the GitHub release with
|
||||
merge, [release.yml](.github/workflows/release.yml#L136-L310) asserts its
|
||||
way to certainty, tags the merge commit, publishes the forge 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,12 +127,13 @@ 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
|
||||
([`gh release create --verify-tag`](.github/workflows/release.yml#L246-L258))
|
||||
([`forge_release_create`](.github/workflows/release.yml#L264-L277))
|
||||
can fail on the API call or the assets. Either leaves the same state — a tag
|
||||
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.
|
||||
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.
|
||||
|
||||
## The two doors
|
||||
|
||||
|
|
@ -141,18 +142,20 @@ refusal is the single failure in 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-L301)). Use it for every
|
||||
([release.yml](.github/workflows/release.yml#L136-L310)). 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#L303-L371)) — publishes the
|
||||
([release.yml](.github/workflows/release.yml#L325-L410)) — 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 two asserts: **the tag names the
|
||||
and no label check — what is left is three asserts: **the tag names the
|
||||
tree's own version**
|
||||
([L328–L339](.github/workflows/release.yml#L328-L339)) and **the tagged
|
||||
([L350–L361](.github/workflows/release.yml#L350-L361)), **the tagged
|
||||
tree carries a publishable `## X.Y.Z` section**
|
||||
([L340–L352](.github/workflows/release.yml#L340-L352)); either failing
|
||||
([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
|
||||
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
|
||||
|
|
@ -470,7 +473,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-L301))
|
||||
### The merge door refused ([release.yml](.github/workflows/release.yml#L136-L310))
|
||||
|
||||
> CHANGELOG.md has no '## $VER' section at the merge commit — the ceremony PR must stamp it; refusing to publish an empty release
|
||||
|
||||
|
|
@ -480,22 +483,21 @@ 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.
|
||||
|
||||
> 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.
|
||||
> 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.
|
||||
|
||||
[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.
|
||||
[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.
|
||||
|
||||
> direct push refused (branch protection?) — opening the bump PR instead
|
||||
|
||||
[L293–L301](.github/workflows/release.yml#L293-L301) — loud, but not a
|
||||
[L302–L310](.github/workflows/release.yml#L302-L310) — 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
|
||||
|
|
@ -503,29 +505,35 @@ 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#L303-L371))
|
||||
### The tag door refused ([release.yml](.github/workflows/release.yml#L325-L410))
|
||||
|
||||
> 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.
|
||||
|
||||
[L333–L337](.github/workflows/release.yml#L333-L337). The message is the
|
||||
[L356–L359](.github/workflows/release.yml#L356-L359). 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
|
||||
|
||||
[L346–L349](.github/workflows/release.yml#L346-L349). The tagged tree was
|
||||
[L368–L374](.github/workflows/release.yml#L368-L374). 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.
|
||||
|
||||
### The re-arm refused ([release.yml](.github/workflows/release.yml#L267-L301))
|
||||
> 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 bump belongs to the merge door alone — the tag door deliberately does not
|
||||
rewrite main ([L303–L307](.github/workflows/release.yml#L303-L307)) — and it
|
||||
rewrite main ([L325–L329](.github/workflows/release.yml#L325-L329)) — 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 ([L266](.github/workflows/release.yml#L266)). That is the one failure
|
||||
shipped ([L275](.github/workflows/release.yml#L275)). 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
|
||||
|
|
@ -573,7 +581,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 ([L293–L301](.github/workflows/release.yml#L293-L301)).
|
||||
failing ([L302–L310](.github/workflows/release.yml#L302-L310)).
|
||||
|
||||
### Red main that is not the release workflow
|
||||
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ window stops advancing along that edge (#329).
|
|||
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 issues and `post-merge` is its completion queue (#329).
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 discussion where humans had their say. If you think the
|
||||
above it, in a proposal where humans had their say. If you think the
|
||||
spec itself is wrong, say so with reasons — as a comment pointing at the
|
||||
discussion, while still reviewing the implementation against the spec as
|
||||
proposal, 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.
|
||||
|
|
|
|||
29
TRIAGE.md
29
TRIAGE.md
|
|
@ -1,22 +1,25 @@
|
|||
# TRIAGE.md — the triage role
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Why this door exists
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Your inputs
|
||||
|
||||
- **Every open discussion** in the repo you serve.
|
||||
- **Stray issues** — anything filed directly, by anyone. Label it
|
||||
- **Every open proposal** in the repo you serve.
|
||||
- **Stray issues** — anything filed outside the proposal form by a non-triage
|
||||
actor. Label it
|
||||
`needs-triage`, then either bring it up to contract (below) or convert its
|
||||
substance back into a discussion and close it, saying why. Route the work
|
||||
substance into a proposal and close it, saying why. Route the work
|
||||
without shaming the filer.
|
||||
|
||||
## For each discussion, converge on exactly one outcome
|
||||
## For each proposal, 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.
|
||||
|
|
@ -32,9 +35,9 @@ one **without asking anything**. One accountable role keeps builders from guessi
|
|||
[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 discussion is where humans decide; wait there. When the decision
|
||||
The proposal is where humans decide; wait there. When the decision
|
||||
blocks something already on the board — an existing issue, or minted work
|
||||
a discussion's ruling gates — set `needs-ruling` on it too, so the board
|
||||
a proposal'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
|
||||
|
|
@ -61,7 +64,7 @@ one **without asking anything**. One accountable role keeps builders from guessi
|
|||
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 discussion is not.
|
||||
outcome; a zombie proposal is not.
|
||||
5. **Accept.** It justifies work → mint the issue(s). The contract below is
|
||||
the bar.
|
||||
|
||||
|
|
@ -71,7 +74,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 discussion it came from,
|
||||
- **Context**: why this exists, with links — the proposal 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
|
||||
|
|
@ -159,4 +162,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 an issue to "discuss" something — that is a discussion.
|
||||
- Mint a work issue to explore an idea — file a proposal instead.
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.6.2-dev
|
||||
0.6.4-dev
|
||||
|
|
|
|||
|
|
@ -33,6 +33,12 @@ 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}}"
|
||||
|
|
@ -169,6 +175,37 @@ 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
|
||||
|
|
|
|||
|
|
@ -208,14 +208,14 @@ claim_reclaim_marker() { # $1 = last activity epoch
|
|||
refs_references() { # PR body on stdin -> local issue numbers named by Refs
|
||||
awk '
|
||||
{
|
||||
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
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
' | issue_references \
|
||||
|
|
|
|||
|
|
@ -270,7 +270,8 @@ 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 (the release-shape guard's ref)
|
||||
# BASE_SHA the PR's base branch head
|
||||
# MERGE_BASE_SHA the PR's merge base (the release-shape guard's ref)
|
||||
# REQUESTED newline-separated logins with a review currently requested
|
||||
# REVIEWS_JSON JSON array of submitted, gradeable reviews
|
||||
# MERGEABLE MERGEABLE | CONFLICTING | UNKNOWN (GitHub's own verdict)
|
||||
|
|
@ -282,35 +283,6 @@ 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
|
||||
|
|
@ -744,7 +716,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 or conversion to a discussion
|
||||
needs-triage|FBCA04|Did not come through triage — owes normalization into work or a reasoned refusal
|
||||
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
|
||||
|
|
@ -752,7 +724,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` is a discussion
|
||||
retired_label_names() { # the GitHub defaults LABELS.md retires — a `question` belongs in a proposal, not a work issue
|
||||
# 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.
|
||||
|
|
@ -947,7 +919,8 @@ 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 "$BASE_SHA")"
|
||||
release_shape_warning "$n" "$(tree_version "$HEAD_SHA")" \
|
||||
"$(tree_version "${MERGE_BASE_SHA:-$BASE_SHA}")"
|
||||
fi
|
||||
|
||||
# ---- merge-next: cleared, never set ----------------------------------
|
||||
|
|
@ -1060,6 +1033,7 @@ 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).
|
||||
|
|
@ -1070,10 +1044,10 @@ main() {
|
|||
or .state == "REQUEST_CHANGES"
|
||||
or .state == "COMMENTED"
|
||||
or .state == "COMMENT")]')"
|
||||
# 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")")"
|
||||
# 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")"
|
||||
# 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,3 +0,0 @@
|
|||
### Changed
|
||||
|
||||
- `docs/RUNNER-PROBES.md` records the delivered 0.6.1 consumer exercise in the standing Forgejo runner venue (#217).
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
### Changed
|
||||
|
||||
- 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).
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
### Changed
|
||||
|
||||
- Release windows now read membership from a dedicated `## Members` record, with CommonMark-bounded rows and no fallback to predecessor gates (#230).
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
### Fixed
|
||||
|
||||
- Forgejo review requests no longer count as verdicts, while its blocking and comment states now grade like their GitHub equivalents (#235).
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
### Fixed
|
||||
|
||||
- Forgejo drafts and fast-forward conflict-check windows no longer surface as merge conflicts when the API has not distinguished one (#236).
|
||||
7
changelog.d/269.md
Normal file
7
changelog.d/269.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
### 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).
|
||||
3
changelog.d/271.md
Normal file
3
changelog.d/271.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
### Fixed
|
||||
|
||||
- Forgejo release publishing now stages drafts until assets upload, rolls back failures, and recovers stranded same-tag drafts before retrying (#271).
|
||||
3
changelog.d/273.md
Normal file
3
changelog.d/273.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
### Fixed
|
||||
|
||||
- Merge-door release reruns resume after a matching stranded tag while completed or conflicting releases still refuse with precise diagnostics (#273).
|
||||
3
changelog.d/275.md
Normal file
3
changelog.d/275.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
### Fixed
|
||||
|
||||
- Compare release-shaped pull requests with their merge base so later base-branch releases do not create phantom version-change warnings (#275).
|
||||
|
|
@ -337,14 +337,18 @@ 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.
|
||||
Two 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
|
||||
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
|
||||
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).
|
||||
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.
|
||||
- **`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
|
||||
|
|
@ -356,8 +360,10 @@ 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; PR checks show `scope` and the green
|
||||
`trigger` only.
|
||||
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.
|
||||
|
||||
The consumer keeps its path mapping in `.github/labeler.yml` and its
|
||||
review panel plus scope taxonomy in `.github/labels.conf`.
|
||||
|
|
@ -384,11 +390,14 @@ The complete event-facing caller is:
|
|||
name: labels
|
||||
on:
|
||||
pull_request_target:
|
||||
# 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.
|
||||
# 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.
|
||||
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.
|
||||
|
|
@ -425,22 +434,24 @@ 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 four transition classes — a review verdict landing (no
|
||||
# pull_request_review trigger on the labels caller), blocker:ci-red
|
||||
# wake for a review verdict landing (the labels caller has no
|
||||
# pull_request_review trigger), blocker:ci-red
|
||||
# set/cleared, blocker:conflict when another PR merges under this one, and
|
||||
# time-based stale / 48h claim-reclaim. The labels caller's events carry the
|
||||
# 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
|
||||
# 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 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 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.
|
||||
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 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.
|
||||
# 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.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bootstrap:
|
||||
|
|
@ -477,17 +488,22 @@ 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, and without it every
|
||||
event run goes red at the trigger.
|
||||
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.
|
||||
|
||||
**The failure mode to know before bumping**: a consumer that bumps its pin
|
||||
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.
|
||||
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.
|
||||
|
||||
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
|
||||
|
|
@ -538,20 +554,25 @@ carrying the split:
|
|||
`actions: read` today (crew does); the trigger job's dispatch is a
|
||||
write. The sweep caller keeps `actions: read`.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
`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
|
||||
`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
|
||||
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) — 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.
|
||||
`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.
|
||||
|
||||
`.github/labels.conf` has one mandatory panel setting, one mandatory
|
||||
`triage-actors` setting, zero or more optional per-author panel rows, and
|
||||
|
|
@ -584,7 +605,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 issues without the sweep applying `needs-triage`. Label rows use exactly
|
||||
allowed to mint work 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
|
||||
|
|
@ -830,15 +851,16 @@ published release.
|
|||
|
||||
## Adopting the agent team flow
|
||||
|
||||
The team flow (discussion → triage → issue → build → review → human
|
||||
The team flow (proposal → triage → work 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:
|
||||
|
||||
- [ ] **Enable Discussions** — the triage door exists or the pipeline
|
||||
has no intake.
|
||||
- [ ] **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.
|
||||
- [ ] **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
|
||||
|
|
@ -872,7 +894,8 @@ 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 issues; everyone else opens discussions.
|
||||
triage mints work issues; anyone may file a proposal, which triage
|
||||
converts or refuses.
|
||||
|
||||
### The pin-bump procedure
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,47 @@ 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
|
||||
|
|
|
|||
38
drills/0.6.2.md
Normal file
38
drills/0.6.2.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# 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.
|
||||
74
drills/0.6.3.md
Normal file
74
drills/0.6.3.md
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
# 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`: `.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
|
||||
`.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
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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 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 published 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] <endpoint> [--jq <expr>]
|
||||
# forge_api [--paginate | --paginate-exhaustive] <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,11 +94,26 @@ 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 endpoint="" jqexpr="" have_jq=false
|
||||
local paginate=false paginate_exhaustive=false method=GET 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" ;;
|
||||
|
|
@ -106,6 +121,14 @@ 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
|
||||
|
|
@ -116,7 +139,7 @@ forge_api() {
|
|||
# shellcheck disable=SC2064 # the paths are fixed at trap time on purpose
|
||||
trap "rm -f '$hdr' '$body'" RETURN
|
||||
|
||||
if [ "$paginate" = false ]; then
|
||||
if [ "$paginate" = false ] && [ "$paginate_exhaustive" = false ]; then
|
||||
if ! curl -sS -D "$hdr" -o "$body" \
|
||||
-H "Authorization: token $token" -H 'Accept: application/json' \
|
||||
"$base/$endpoint"; then
|
||||
|
|
@ -142,21 +165,23 @@ forge_api() {
|
|||
fi
|
||||
forgejo_http_ok "$hdr" "GET $endpoint" || return 1
|
||||
|
||||
# Re-read on EVERY page, not once (#4712). A board that changes size
|
||||
# under the walk was invisible: page 1 declaring 4 and page 2 declaring
|
||||
# 9 stopped at 4 believing itself whole. A moving total means the read
|
||||
# cannot have been atomic, so it is refused rather than reconciled.
|
||||
local page_total
|
||||
page_total="$(forgejo_total_count "$hdr")" || return 1
|
||||
if [ -z "$total" ]; then
|
||||
total="$page_total"
|
||||
elif [ "$page_total" != "$total" ]; then
|
||||
cat >&2 <<EOF
|
||||
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
|
||||
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
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
pagejson="$(cat "$body")"
|
||||
|
|
@ -175,13 +200,17 @@ EOF
|
|||
[ "$n" -gt 0 ] || break
|
||||
all="$(jq -s '.[0] + .[1]' <<<"$all"$'\n'"$pagejson")"
|
||||
got=$((got + n))
|
||||
[ "$got" -lt "$total" ] || break
|
||||
if [ "$paginate_exhaustive" = true ]; then
|
||||
[ "$n" -eq 50 ] || break
|
||||
else
|
||||
[ "$got" -lt "$total" ] || break
|
||||
fi
|
||||
page=$((page + 1))
|
||||
done
|
||||
|
||||
# The assert. A short read here is the silent-truncation bug arriving by
|
||||
# another route, so it is fatal rather than a warning.
|
||||
if [ "$got" -ne "$total" ]; then
|
||||
if [ "$paginate_exhaustive" = false ] && [ "$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
|
||||
|
|
@ -439,6 +468,10 @@ forge_pr_view() {
|
|||
$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
|
||||
|
|
@ -452,6 +485,15 @@ 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).
|
||||
|
|
@ -552,7 +594,13 @@ 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
|
||||
raw="$(forge_api --paginate "repos/$REPO/issues/$n/timeline")" || return 1
|
||||
# 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
|
||||
jq '
|
||||
[.[]
|
||||
| select(.type == "label")
|
||||
|
|
@ -624,22 +672,31 @@ forgejo_read_code() {
|
|||
rm -f "$hdr"
|
||||
}
|
||||
|
||||
# forge_release_exists <tag> — prints `yes` or `no`. A non-zero exit means
|
||||
# the read did not complete and the answer is UNKNOWN; the caller must not
|
||||
# treat that as `no` (#191).
|
||||
# forge_release_exists <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() {
|
||||
local tag="${1:?forge_release_exists: tag required}" body code
|
||||
local tag="${1:?forge_release_exists: tag required}" body code draft
|
||||
body="$(mktemp)"
|
||||
code="$(forgejo_read_code "repos/$REPO/releases/tags/$tag" "$body")" || { rm -f "$body"; return 1; }
|
||||
rm -f "$body"
|
||||
case "$code" in
|
||||
2*) echo yes ;;
|
||||
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
|
||||
;;
|
||||
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
|
||||
|
|
@ -717,20 +774,74 @@ forgejo_urlencode() {
|
|||
jq -rn --arg s "${1-}" '$s|@uri'
|
||||
}
|
||||
|
||||
# forge_release_create <tag> <title> <notes-file> [asset…] — publishes, then
|
||||
# uploads each asset to the created release. The release id comes back from
|
||||
# the create, so no second lookup is needed.
|
||||
# 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() {
|
||||
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:false,prerelease:false}')")" || return 1
|
||||
'{tag_name:$t,name:$n,body:$b,draft:true,prerelease:false}')")" || return 1
|
||||
id="$(printf '%s' "$out" | jq -r '.id // empty')"
|
||||
[ -n "$id" ] || { echo "forge_release_create: the create returned no release id" >&2; return 1; }
|
||||
[ "$#" -gt 0 ] || return 0
|
||||
base="$(forgejo_api_base)" || return 1
|
||||
if ! base="$(forgejo_api_base)"; then
|
||||
forgejo_release_cleanup_draft "$id" "$tag"
|
||||
return 1
|
||||
fi
|
||||
token="${GH_TOKEN:-${GITHUB_TOKEN:-${FORGEJO_TOKEN:-}}}"
|
||||
local f name
|
||||
for f in "$@"; do
|
||||
|
|
@ -744,8 +855,16 @@ 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; return 1; }
|
||||
|| {
|
||||
echo "forge_release_create: asset upload failed for '$f'" >&2
|
||||
forgejo_release_cleanup_draft "$id" "$tag"
|
||||
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,6 +101,12 @@ 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'
|
||||
|
|
|
|||
81
lib/preflight.sh
Executable file
81
lib/preflight.sh
Executable file
|
|
@ -0,0 +1,81 @@
|
|||
#!/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,6 +217,49 @@ 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"
|
||||
|
|
|
|||
|
|
@ -581,6 +581,93 @@ 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
|
||||
|
|
@ -606,8 +693,11 @@ pr_view_stub() {
|
|||
}
|
||||
# The FAILURE is older but listed second — array order would pick it.
|
||||
FAKE_STATUS='{"state":"failure","statuses":[
|
||||
{"context":"ci / check","status":"success","created_at":"2026-08-02T10:00:00Z","updated_at":"2026-08-02T10:00:00Z"},
|
||||
{"context":"ci / check","status":"failure","created_at":"2026-08-02T09:00:00Z","updated_at":"2026-08-02T09:00:00Z"}]}'
|
||||
{"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}'
|
||||
pr_view_stub
|
||||
view_json="$(REPO=o/r forge_pr_view 5)"
|
||||
|
|
@ -615,6 +705,20 @@ 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"
|
||||
|
||||
|
|
@ -659,6 +763,7 @@ 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=""
|
||||
|
|
@ -666,14 +771,44 @@ timeline_stub() {
|
|||
case "$1" in -D) hdr="$2"; shift ;; -o) out="$2"; shift ;; -H) shift ;; *) url="$1" ;; esac
|
||||
shift
|
||||
done
|
||||
printf 'HTTP/1.1 200 OK\r\nX-Total-Count: %s\r\n\r\n' "${FAKE_TL_N:-2}" >"$hdr"
|
||||
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"
|
||||
case "$url" in
|
||||
*timeline*) printf '%s' "$FAKE_TIMELINE" >"$out" ;;
|
||||
*timeline*) printf '%s' "$page_body" >"$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"},
|
||||
|
|
@ -690,6 +825,61 @@ 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
|
||||
|
|
@ -762,16 +952,25 @@ 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> — a curl stub answering one canned response and
|
||||
# recording the method+path it was asked for.
|
||||
# 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() {
|
||||
# Globals, not locals: the curl closure below runs long after this
|
||||
# function returns, exactly as stub_writes does above.
|
||||
STUB_CODE="$1" STUB_BODY="$2"
|
||||
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"
|
||||
: >"$WRITES"
|
||||
# shellcheck disable=SC2317 # invoked indirectly, by the forge verbs
|
||||
curl() {
|
||||
local hdr="" out="" method=GET url="" payload=""
|
||||
local hdr="" out="" method=GET url="" payload="" fail_on_http=false
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-D) hdr="$2"; shift ;;
|
||||
|
|
@ -779,18 +978,32 @@ 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
|
||||
[ -n "$hdr" ] && printf 'HTTP/1.1 %s x\r\n\r\n' "$STUB_CODE" >"$hdr"
|
||||
[ -n "$out" ] && printf '%s' "$STUB_BODY" >"$out"
|
||||
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"
|
||||
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
|
||||
|
|
@ -833,6 +1046,10 @@ 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 '{}'
|
||||
|
|
@ -856,9 +1073,22 @@ check "forgejo: the tag body names the target sha" 0 '"target":"cafebabe"' \
|
|||
writes_after forge_tag_create 1.2.3 cafebabe
|
||||
|
||||
printf 'notes body\n' >"$TMP/notes.md"
|
||||
release_stub 201 '{"id":42}'
|
||||
release_writes_after() { "$@" >/dev/null 2>&1; grep -v '^GET ' "$WRITES"; }
|
||||
|
||||
release_stub 201 '{"id":42}' 200 '{}'
|
||||
release_read 404 '{}'
|
||||
check "forgejo: the publish POSTs to /releases with the notes as body" 0 '"body":"notes body' \
|
||||
writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md"
|
||||
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
|
||||
|
||||
# Assets: the hook contract permits any filename the consumer drops in
|
||||
# RELEASE_ASSETS_DIR, and the asset name travels as a QUERY VALUE. Raw
|
||||
|
|
@ -872,18 +1102,179 @@ 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}'
|
||||
release_stub 201 '{"id":42}' 201 '{}' 200 '{}'
|
||||
release_read 404 '{}'
|
||||
check "an asset with a space uploads under the encoded name" 0 "assets?name=release%20asset.tgz" \
|
||||
writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/release asset.tgz"
|
||||
release_stub 201 '{"id":42}'
|
||||
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 '{}'
|
||||
check "an asset with '&' does not become two parameters" 0 "assets?name=a%26b.tgz" \
|
||||
writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
|
||||
release_stub 201 '{"id":42}'
|
||||
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 '{}'
|
||||
check "the upload targets the created release id" 0 "releases/42/assets" \
|
||||
writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
|
||||
release_stub 201 '{"id":42}'
|
||||
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 '{}'
|
||||
check "the asset rides as a multipart attachment" 0 "attachment=@" \
|
||||
writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
|
||||
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
|
||||
|
||||
# --- the github twins address their own paths ----------------------------
|
||||
. "$ROOT/lib/forge-github.sh"
|
||||
|
|
|
|||
|
|
@ -101,6 +101,28 @@ 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"
|
||||
|
|
|
|||
|
|
@ -67,6 +67,19 @@ 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
|
||||
|
|
@ -162,6 +175,93 @@ 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)"
|
||||
|
|
@ -172,20 +272,39 @@ $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. Those rows
|
||||
# are not submitted verdicts (#235): they must leave all three logins
|
||||
# outstanding, so an opening round stays with the panel rather than falling
|
||||
# through to the builder as three comment-only answers.
|
||||
# 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="$(outstanding_requests "$BOT1
|
||||
REQUESTED="$BOT1
|
||||
$BOT2
|
||||
$BOT3" 2>"$RTMP/request-round-log")"
|
||||
$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.
|
||||
|
|
@ -557,6 +676,61 @@ 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.
|
||||
|
|
@ -1640,8 +1814,8 @@ 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 outstanding_requests
|
||||
# boundary proves REQUEST_REVIEW never reaches the grader; the COMMENT and
|
||||
# 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() {
|
||||
|
|
@ -1671,8 +1845,12 @@ review_filter_probe() {
|
|||
*) 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
|
||||
outstanding_requests() {
|
||||
forge_pr_review_requests() {
|
||||
printf '%s\n' "$REVIEWS_JSON" >"$RTMP/gradeable-reviews.json"
|
||||
}
|
||||
main >/dev/null
|
||||
|
|
@ -1682,59 +1860,9 @@ 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")"
|
||||
|
||||
REVIEWS_JSON="$(reviews \
|
||||
"$(rev "$BOT1" REQUEST_REVIEW "" "" 2026-08-22T00:46:05Z)")"
|
||||
expect "a Forgejo request row is not an answer and leaves the login outstanding" \
|
||||
"$BOT1" "$(outstanding_requests "$BOT1" 2>"$RTMP/request-outstanding-log")"
|
||||
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)")"
|
||||
|
||||
# The Forgejo shape, end to end: the field lists all three long after every
|
||||
# verdict landed. Only the stale one may survive the filter.
|
||||
expect "the never-cleared forgejo field collapses to who actually owes" \
|
||||
"$BOT3" "$(outstanding_requests "$BOT1
|
||||
$BOT2
|
||||
$BOT3")"
|
||||
|
||||
# The GitHub shape: the field is already accurate, so the filter is a no-op
|
||||
# on the set GitHub would have produced (term 5 — behaviour unchanged).
|
||||
expect "on a github-shaped field the filter removes nothing" "nobody" \
|
||||
"$(outstanding_requests "nobody")"
|
||||
expect "an empty request list stays empty" "" "$(outstanding_requests "")"
|
||||
|
||||
# The summary and the gate belong at the TRUE end of the file. They sat in the
|
||||
# middle until #188: eight outstanding_requests expects were appended after
|
||||
# them, so a failure there printed FAIL, was left out of the totals, and the
|
||||
# suite still exited 0 (@codex-reviewer-andresmgsl #4780 item 2). Anything
|
||||
# appended below this line is ungated — so nothing goes below it.
|
||||
# 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.
|
||||
printf 'labels-reconcile tests: %d passed, %d failed\n' "$pass" "$fail"
|
||||
[ "$fail" -eq 0 ]
|
||||
|
|
|
|||
|
|
@ -150,4 +150,33 @@ 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,6 +31,15 @@ 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" \
|
||||
|
|
|
|||
121
test/preflight.test.sh
Executable file
121
test/preflight.test.sh
Executable file
|
|
@ -0,0 +1,121 @@
|
|||
#!/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
|
||||
|
|
@ -97,6 +97,30 @@ 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"
|
||||
|
|
@ -106,6 +130,7 @@ 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
|
||||
|
|
@ -121,61 +146,65 @@ 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/facts.sh\nlib/changelog.sh\nlib/forge.sh' \
|
||||
$'.github/workflows/release.yml\nbin/\nlib/version.sh\nlib/decide.sh\nlib/preflight.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"\n' \
|
||||
printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\nrun: . "%s"\nrun: . "%s"\nrun: . "%s"\n' \
|
||||
"\$CEREMONY_DIR/lib/facts.sh" "\$CEREMONY_DIR/lib/decide.sh" \
|
||||
"\$CEREMONY_DIR/lib/changelog.sh" "\$CEREMONY_DIR/lib/version.sh" \
|
||||
"\$CEREMONY_DIR/lib/ruling.sh" \
|
||||
"\$CEREMONY_DIR/lib/preflight.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 its missing path" 1 \
|
||||
"missing dependency: lib/ruling.sh" path_check "$tree"
|
||||
check "a new workflow library fails with only its missing path" 0 "" \
|
||||
path_check_reports_only "$tree" \
|
||||
"release-path: missing dependency: lib/ruling.sh"
|
||||
|
||||
# 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"\n' \
|
||||
printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\nrun: . "%s"\n' \
|
||||
"\$CEREMONY_DIR/lib/facts.sh" "\$CEREMONY_DIR/lib/decide.sh" \
|
||||
"\$CEREMONY_DIR/lib/changelog.sh" \
|
||||
"\$CEREMONY_DIR/lib/preflight.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 its missing path" 1 \
|
||||
"missing dependency: lib/ruling.sh" path_check "$tree"
|
||||
check "a new sibling library fails with only its missing path" 0 "" \
|
||||
path_check_reports_only "$tree" \
|
||||
"release-path: missing dependency: lib/ruling.sh"
|
||||
|
||||
# A manifest may not rot into a safe-looking superset.
|
||||
tree="$(fixture extra)"
|
||||
printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\n' \
|
||||
printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\nrun: . "%s"\n' \
|
||||
"\$CEREMONY_DIR/lib/facts.sh" "\$CEREMONY_DIR/lib/decide.sh" \
|
||||
"\$CEREMONY_DIR/lib/changelog.sh" \
|
||||
"\$CEREMONY_DIR/lib/preflight.sh" "\$CEREMONY_DIR/lib/changelog.sh" \
|
||||
>"$tree/.github/workflows/release.yml"
|
||||
sed -i 's| lib/forge.sh$| lib/forge.sh \\|' \
|
||||
sed -i '$ s|$| \\|' \
|
||||
"$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 as stale" 1 "stale path: lib/ruling.sh" \
|
||||
path_check "$tree"
|
||||
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"
|
||||
|
||||
# Transitive sourcing is part of the derivation, not decoration.
|
||||
tree="$(fixture transitive)"
|
||||
printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\n' \
|
||||
printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\nrun: . "%s"\n' \
|
||||
"\$CEREMONY_DIR/lib/facts.sh" "\$CEREMONY_DIR/lib/decide.sh" \
|
||||
"\$CEREMONY_DIR/lib/changelog.sh" \
|
||||
"\$CEREMONY_DIR/lib/preflight.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 as a stale path" 1 \
|
||||
"stale path: lib/version.sh" path_check "$tree"
|
||||
check "removing facts' version source fails with only its stale path" 0 "" \
|
||||
path_check_reports_only "$tree" "release-path: stale path: lib/version.sh"
|
||||
|
||||
summary
|
||||
|
|
|
|||
Loading…
Reference in a new issue