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.2" 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: GITHUB_TOKEN: ${{ github.token }} SWEEP_WORKFLOW: ${{ inputs.sweep_workflow }} DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} run: | # REST, not `gh` (#205). The workflow-dispatch endpoint has the SAME # shape on both forges — # POST {api}/repos/{owner}/{repo}/actions/workflows/{file}/dispatches # {"ref": "", "inputs": {...}} -> 204, empty body # — so this step no longer decides a forge at all. That is why the # `CEREMONY_FORGE_CLIENT: gh` declaration and both inline refusals are # gone rather than ported: there is nothing left to refuse. Measured # on this instance (Forgejo 8.0.3+gitea-1.22.0) and published in its # own swagger; run 459 was raised this way. # # STILL LOUD on failure, per this job's contract: a consumer missing # the sweep caller, its `bootstrap` input, or `actions: write` must # fail HERE and visibly, not sweep silently never again. # NEVER "probably github" (lib/forge.sh). Defaulting an unset # GITHUB_API_URL to api.github.com would send this forge's dispatch # to GitHub and report success — the same unset-environment guess # #201 just refused for docs-sync. The API root is injected by the # forge running us; if it is absent we do not know where we are, and # a guess is worse than a red trigger # (@codex-reviewer-andresmgsl, #205 review). api="${GITHUB_API_URL:-}" if [ -z "$api" ]; then echo "::error::labels: the sweep was NOT woken — GITHUB_API_URL is unset, so the forge's API root is unknown. Refusing to guess a forge." exit 1 fi # `gh workflow run` defaulted the ref to the repository's default # branch; REST has no default and 400s without one. Prefer the event # payload, fall back to an API read: on a `pull_request_target` run # GITHUB_REF_NAME is `/merge`, which is not a branch and would # dispatch nothing. branch="${DEFAULT_BRANCH:-}" if [ -z "$branch" ]; then branch="$(curl -fsS -H "Authorization: Bearer $GITHUB_TOKEN" \ "$api/repos/$GITHUB_REPOSITORY" | jq -r '.default_branch // empty')" fi if [ -z "$branch" ]; then echo "::error::labels: the sweep was NOT woken — could not determine the default branch to dispatch $SWEEP_WORKFLOW on." exit 1 fi out="$(mktemp)" err="$(mktemp)" trap 'rm -f "$out" "$err"' EXIT # A transport failure is named, not merely propagated. Letting `set # -e` carry curl's own exit code out of the assignment DID fail the # job — the invariant holds — but it failed with a bare status and no # sentence, which is the opposite of this step owning its diagnostic. if ! code="$(curl -sS -o "$out" -w '%{http_code}' -X POST \ -H "Authorization: Bearer $GITHUB_TOKEN" \ -H 'Content-Type: application/json' \ -d "$(jq -nc --arg ref "$branch" '{ref: $ref, inputs: {bootstrap: "no"}}')" \ "$api/repos/$GITHUB_REPOSITORY/actions/workflows/$SWEEP_WORKFLOW/dispatches" \ 2>"$err")"; then echo "::error::labels: the sweep was NOT woken — the request to $api never completed: $(tr -d '\n' <"$err")" exit 1 fi if [ "$code" != "204" ]; then # Own the diagnostic rather than pass the status through. This # Forgejo answers an unknown workflow name — and a bare ref that # does not resolve — with `500` and an EMPTY body, so the raw # status alone sends the reader looking for a server fault that is # not there. echo "::error::labels: the sweep was NOT woken — POST $api/repos/$GITHUB_REPOSITORY/actions/workflows/$SWEEP_WORKFLOW/dispatches (ref=$branch) returned HTTP $code: $(tr -d '\n' <"$out")" echo "::error::labels: check that $SWEEP_WORKFLOW exists on $branch, declares a \`bootstrap\` workflow_dispatch input, and that this caller grants \`actions: write\`. An empty 500 body from Forgejo means the workflow name or the ref did not resolve." exit 1 fi echo "labels: sweep dispatched — $SWEEP_WORKFLOW on $branch (bootstrap=no)"