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