ceremony/.github/workflows/labels.yml
dan-claude-bot 45aa806207 labels: detach the reconcile sweep from PR-triggered runs (#209)
The sweep rode the same workflow run as the PR event that woke it, so
every displacement in the shared labels-reconcile queue recorded a
CANCELLED reconcile check on some PR — fake red CI that held review
requests. The reconcile + issueflow jobs move, unchanged, to a new
reusable labels-sweep.yml behind their own caller; labels.yml gains a
trigger job that dispatches the consumer's sweep caller with the plain
GITHUB_TOKEN (workflow_dispatch is a documented no-retrigger exemption)
on every event that used to run reconcile. A displaced sweep now cancels
on the Actions tab, attached to no PR; PR checks show scope + trigger.

Because every trigger-driven wake arrives as workflow_dispatch, the event
name alone no longer separates the operator's manual bootstrap from an
event-woken sweep: the sweep caller's bootstrap dispatch input does — the
trigger passes no, a bare manual dispatch defaults to yes. The sweep
reusable also takes pr_workflow_name, exported as SELF_WORKFLOW for the
#208 reconciler (harmless to earlier ones; zero file overlap with #208).

The trigger is deliberately loud: a pin bumped without the sweep caller,
its bootstrap input, or actions: write on the labels caller goes red at
the trigger job instead of silently never sweeping again — documented in
docs/CONSUMERS.md with the split stubs and the atomic-adoption note.

Refs #209

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 16:20:00 +00:00

126 lines
6.2 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. 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.4.0"
jobs:
scope:
# Not on labeled/unlabeled: those events change no paths, so scope has
# nothing new to derive — and label churn is precisely what they are.
# review_requested/review_request_removed likewise change no paths — they
# exist to wake 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 }}
run: gh workflow run "$SWEEP_WORKFLOW" -R "$GITHUB_REPOSITORY" -f bootstrap=no