ceremony/.github/workflows/labels.yml
cluade-reviewer-andresmgsl adf907c963 fix(198): the action fails closed, the caller decides scheduling, the guard decides the forge (#198)
@codex-reviewer-andresmgsl's second review, both points taken.

The refs action goes back to `forge_preflight || exit 1`. 97e63ac had it exit 0
with a notice so the PR check would not be red, and that conflated two
different questions: "this action cannot produce a verdict" is the ACTION's
contract and must stay a refusal, while "this check should not block the
board" is the CALLER's decision. The caller now carries it —
refs-guard.yml skips unless github.server_url is github.com, mirroring
forge_detect positively. A skipped check is a green head; an action that
reports success it did not earn is not. The leaked preflight_err temp file
goes with the revert.

The workflow guard asked the wrong question. `command -v gh` alone passes the
moment a Forgejo runner image happens to ship gh, and then dispatches against
a forge that cannot serve it — the client/forge mismatch forge_preflight
exists to prevent. It decides the FORGE first now, mirroring forge_detect
positively, and the binary second. The source guard splits to match: a
declaration guarded only by binary presence is reported, with a fixture that
fails on exactly that shape.

The warning text was also wrong on the facts, as noted: issue-event sweeps ARE
this caller's event-driven wakes, so they are precisely what is lost. It now
says the hourly scheduled sweep survives and every event-driven wake through
this caller does not, until #205.

Point 1 of that review — jq 1.6 accepting an empty payload — was already fixed
in 728102a, pushed before the review landed.

Verified under the runner's jq 1.6 as well as 1.7: 28 test files, 0 failed
both ways. shellcheck 0.10.0 (CI's pin), actionlint, self-ref, marker,
vendored, changelog-armed all clean with every file tracked.

Refs #198
2026-08-05 12:30:28 +00:00

158 lines
8.6 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 reconcile sweep lived here until #209. Riding the PR-triggered run
# meant every displacement in the sweep's shared concurrency queue recorded
# a CANCELLED `reconcile` check on some PR — read as red CI by every human
# and agent, though the surviving sweep does the displaced run's work. Two
# field facts made that untenable (crew#250): a displaced run cannot be
# rerun — `gh run rerun`, `--failed`, and `--job` all refuse — so a victim
# PR has no manual escape hatch; and the displacing burst is deterministic,
# 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.
#
# This cannot loop: the trigger's dispatch and the reconciler's label
# writes both use GITHUB_TOKEN. GitHub does not create workflow runs from
# GITHUB_TOKEN-raised events — workflow_dispatch and repository_dispatch
# are the two documented exemptions, which is exactly why the trigger can
# wake the sweep with no PAT anywhere in the path — and the sweep itself
# dispatches nothing. Agent writes use a PAT and therefore do trigger —
# exactly the asymmetry wanted.
on:
workflow_call:
inputs:
sweep_workflow:
description: >-
Filename of the consumer's sweep caller — the workflow that
calls labels-sweep.yml (docs/CONSUMERS.md carries the stub).
The trigger job dispatches it by this name. Override it only
when the caller file is not named labels-sweep.yml (ceremony's
own dogfood names it self-labels-sweep.yml).
type: string
required: false
default: labels-sweep.yml
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.0"
jobs:
scope:
# Not on labeled/unlabeled: those events change no paths, so scope has
# nothing new to derive — and label churn is precisely what they are.
# review_requested/review_request_removed likewise change no paths — they
# exist to wake the sweep (#137) — and running labeler on them widens
# exactly the window #130 documents, where a label written during a
# scope run is clobbered.
if: >-
github.event_name == 'pull_request_target' &&
github.event.action != 'labeled' &&
github.event.action != 'unlabeled' &&
github.event.action != 'review_requested' &&
github.event.action != 'review_request_removed'
runs-on: ubuntu-latest
concurrency:
group: labels-scope-${{ github.event.pull_request.number }}
cancel-in-progress: true
steps:
# actions/labeler@v5 held this seat until #130. Even with
# sync-labels: false it wrote the WHOLE label set — PUT of
# (labels-fetched-at-job-start derived) — so a label applied while
# the job ran was silently removed: ceremony#128 lost its `release`,
# the merge door's declared-intent read, two seconds after the
# builder set it. v6/v7 write the same way, so the step was replaced
# rather than repinned. labels-scope reads the consumer's
# .github/labeler.yml and the changed paths via the API, and its
# only write is an additive POST of the derived scopes: a label
# applied mid-job survives by construction.
#
# Still no PR code: both checkouts below fetch the ceremony
# implementation only. The dogfood checkout rides github.sha — the
# base-branch commit the workflow file itself came from, so the
# script and workflow can never skew — and doubles as the #11
# bootstrap: ceremony's own labels must work before any release tag
# exists for the pinned checkout to fetch.
- uses: actions/checkout@v4
if: github.repository == 'heavy-duty/ceremony'
with:
repository: ${{ github.repository }}
ref: ${{ github.sha }}
- uses: actions/checkout@v4
if: github.repository != 'heavy-duty/ceremony'
with:
repository: heavy-duty/ceremony
ref: ${{ env.CEREMONY_SELF_REF }}
- uses: ./actions/labels-scope
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
# the BASE branch commit — a PR must not label itself by editing
# the mapping it is judged by
CONFIG_REF: ${{ github.sha }}
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.
#
# LOUD on failure — never `|| true`: a red trigger is the
# misconfiguration alarm. A consumer that bumps the pin without adding
# the sweep caller (workflow-not-found), without its declared
# `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.
runs-on: ubuntu-latest
steps:
- name: dispatch the sweep
env:
GH_TOKEN: ${{ github.token }}
SWEEP_WORKFLOW: ${{ inputs.sweep_workflow }}
# This step speaks gh and says so, the same declaration
# actions/refs-not-closing carries (#198 spec 4). A workflow has no
# shell to call forge_preflight from, so the refusal is inline
# below; #205 owns the REST port that removes both.
CEREMONY_FORGE_CLIENT: gh
run: |
# Two questions, not one. @codex-reviewer-andresmgsl: a guard that
# only asks `command -v gh` passes the moment a Forgejo runner image
# happens to ship gh — and then runs a GitHub dispatch against a
# forge that cannot serve it, which is the client/forge mismatch
# forge_preflight exists to prevent. So the FORGE is decided first,
# mirroring forge_detect positively (only github.com is accepted;
# anything else, known or not, is refused — "Never 'probably
# github'"), and the binary is checked second.
#
# A warning, not a failure: this trigger is the misconfiguration
# alarm for a CONSUMER's missing sweep caller, and reddening every
# sweep on a forge for a gap #205 already owns would drown that
# signal. #205 ports the dispatch to REST and removes all of this.
if [ "${GITHUB_SERVER_URL:-}" != "https://github.com" ]; then
echo "::warning::labels: the sweep was NOT woken from this trigger — it dispatches with \`gh\` against GitHub, and this is not a GitHub forge (GITHUB_SERVER_URL=${GITHUB_SERVER_URL:-unset}). #205 ports it to REST. The hourly SCHEDULED sweep still runs; every event-driven wake through this caller — issue events included — is unavailable until then."
exit 0
fi
if ! command -v gh >/dev/null 2>&1; then
echo "::warning::labels: the sweep was NOT woken from this trigger — this runner does not carry \`gh\`. #205 ports the dispatch to REST. The hourly SCHEDULED sweep still runs; every event-driven wake through this caller is unavailable until then."
exit 0
fi
gh workflow run "$SWEEP_WORKFLOW" -R "$GITHUB_REPOSITORY" -f bootstrap=no