diff --git a/.github/workflows/labels-sweep.yml b/.github/workflows/labels-sweep.yml index defca41..705f4bf 100644 --- a/.github/workflows/labels-sweep.yml +++ b/.github/workflows/labels-sweep.yml @@ -34,6 +34,20 @@ name: labels-sweep on: workflow_call: inputs: + bootstrap: + description: >- + Bootstrap the label taxonomy before sweeping. The CALLER passes + this through from its own workflow_dispatch input. The measured + invariant (ceremony#215): the value must be DECLARED here and + EXPLICITLY passed — on this instance the called workflow did not + see the caller's event inputs as an implicit substitute (runs + 459/523 bootstrapped on a bootstrap=no dispatch) while the + top-level caller received the value in both contexts (probe runs + 6/7). Absent means "no": an event- or cron-woken sweep must never + re-upsert ~20 labels. + type: string + required: false + default: "no" pr_workflow_name: description: >- The `name:` of the consumer's PR-facing labels caller, exported @@ -86,18 +100,21 @@ jobs: # papers over for `run:` steps, which composite `uses:` has no # equivalent of. # - # bootstrap: every trigger-driven wake arrives as workflow_dispatch - # too (that is how the trigger's dispatch wakes the caller), so the event - # name alone no longer separates the operator's manual full-board - # bootstrap from an event-woken sweep — the caller's `bootstrap` - # dispatch input does: the trigger passes "no", a bare manual - # dispatch defaults to "yes". A caller reached on any other event - # (the cron) has no input and stays "no". + # bootstrap: read from the DECLARED workflow_call input and nothing + # else. The old gate read `github.event.inputs.bootstrap` from inside + # this called workflow, and on this instance that context arrived + # empty (runs 459/523: every dispatch-woken sweep bootstrapped on a + # bootstrap=no body) while the top-level caller received the value in + # both contexts (probe runs 6/7) — ceremony#215. The reliable channel + # is declare-and-pass, so that is the only one used. The caller passes + # the value through `with.bootstrap`; anything not exactly yes|no is + # fed through for labels-reconcile's own validation to judge, so a + # typo refuses loudly instead of silently bootstrapping. - name: reconcile state + stale if: github.repository != 'heavy-duty/ceremony' uses: ./.ceremony-src/actions/labels-reconcile with: - bootstrap: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.bootstrap != 'no' && 'yes' || 'no' }} + bootstrap: ${{ inputs.bootstrap }} env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} @@ -106,7 +123,7 @@ jobs: if: github.repository == 'heavy-duty/ceremony' uses: ./actions/labels-reconcile with: - bootstrap: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.bootstrap != 'no' && 'yes' || 'no' }} + bootstrap: ${{ inputs.bootstrap }} env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} diff --git a/.github/workflows/self-labels-sweep.yml b/.github/workflows/self-labels-sweep.yml index 94ae4a3..b929cb5 100644 --- a/.github/workflows/self-labels-sweep.yml +++ b/.github/workflows/self-labels-sweep.yml @@ -44,3 +44,11 @@ jobs: # pr_workflow_name keeps its default: ceremony's PR-facing caller is # named `labels` (self-labels.yml). uses: ./.github/workflows/labels-sweep.yml + with: + # The dispatch input crosses the workflow_call boundary HERE, or not + # at all: on this instance the called workflow did not see this + # caller's event inputs implicitly (ceremony#215), so declare-and-pass + # is the only channel used. On `schedule` the top-level context is + # empty, and empty maps to "no" EXPLICITLY — a cron that bootstraps is + # the failure kimi named before it could exist. + bootstrap: ${{ inputs.bootstrap || 'no' }} diff --git a/actions/labels-reconcile/action.yml b/actions/labels-reconcile/action.yml index 36257fe..90a5f53 100644 --- a/actions/labels-reconcile/action.yml +++ b/actions/labels-reconcile/action.yml @@ -23,7 +23,8 @@ runs: BOOTSTRAP: ${{ inputs.bootstrap }} LABELS_CONF: ${{ github.workspace }}/.github/labels.conf run: | - if [ "$BOOTSTRAP" = yes ]; then - export GITHUB_EVENT_NAME=workflow_dispatch - fi + # BOOTSTRAP passes through as-is: the script gates on the input. The + # export-the-event-name hack that lived here died with ceremony#215 — + # the script keyed on GITHUB_EVENT_NAME, which #209 made true for + # every machine wake, so "no" could never mean no. bash "$GITHUB_ACTION_PATH/labels-reconcile.sh" diff --git a/actions/labels-reconcile/labels-reconcile.sh b/actions/labels-reconcile/labels-reconcile.sh index 449ab12..72e8375 100755 --- a/actions/labels-reconcile/labels-reconcile.sh +++ b/actions/labels-reconcile/labels-reconcile.sh @@ -28,8 +28,9 @@ fi # stale approval must never promote unreviewed code to the human. # # DRY_RUN=1 narrates every mutation instead of performing it (how this script -# is rehearsed against the live repo). A workflow_dispatch run also bootstraps -# the taxonomy (label create --force) — that heal is dispatch-only; the cron +# is rehearsed against the live repo). A run with BOOTSTRAP=yes also +# bootstraps the taxonomy (label create --force) — the operator's manual +# dispatch defaults the input to yes; every machine wake passes no. The cron # sweep tolerates a missing label rather than recreating it. # # The state machine below is pure (globals in, state out) and covered by @@ -1021,8 +1022,15 @@ main() { load_config "$LABELS_CONF" NOW="$(date +%s)" - if [ "${GITHUB_EVENT_NAME:-}" = workflow_dispatch ]; then - log "workflow_dispatch: bootstrapping the taxonomy" + # The bootstrap keys on the INPUT, never the event name. It used to test + # GITHUB_EVENT_NAME = workflow_dispatch — correct while an operator's manual + # dispatch was the only dispatch there was, and wrong from #209 on, when the + # trigger job made EVERY event-woken sweep a workflow_dispatch run: the + # bootstrap=no input became inert by construction, and every board event + # re-upserted the taxonomy (ceremony#215 — runs 459/523, then venue drill + # runs 16/17, which bootstrapped on a delivered "no" and caught this). + if [ "${BOOTSTRAP:-no}" = yes ]; then + log "bootstrap=yes: bootstrapping the taxonomy" bootstrap_labels fi diff --git a/changelog.d/215.md b/changelog.d/215.md new file mode 100644 index 0000000..160ee13 --- /dev/null +++ b/changelog.d/215.md @@ -0,0 +1,47 @@ +### Fixed + +- The sweep's `bootstrap` value crosses the `workflow_call` boundary as a + declared input, explicitly passed by the caller — the one channel measured + to work; the called workflow did not see the caller's event inputs as an + implicit substitute on this instance (#215). + +- Before the bridge, `github.event.inputs` was empty inside the called + workflow, so every dispatch-woken sweep bootstrapped: ~20 label upserts on + each board event (#215). + +- The caller maps an empty top-level value to `no` explicitly, so a + cron-woken sweep can never bootstrap; the declared input also defaults to + `no`, so a consumer that passes nothing gets the safe path (#215). + +- The gate feeds the declared input to `labels-reconcile` unchanged, so an + invalid value meets the action's own `yes|no` refusal instead of being + silently coerced (#215). + +- `docs/CONSUMERS.md`'s published sweep stub carries the same pass-through — + without it every consumer inherits the defect ceremony fixed for + itself (#215). + +### Added + +- `test/labels-bootstrap.test.sh` pins the bridge at every hop: the declared + boundary, both gate sites as the identity, no expression reading + `github.event.inputs`, and the caller and stub pass-throughs + byte-exact (#215). + +- The same test drives the four value paths — schedule-empty, `no`, `yes`, + invalid — through the shipped expressions into the action's real + validator (#215). + +- The taxonomy bootstrap keys on the `BOOTSTRAP` input, never the event name. + It tested `GITHUB_EVENT_NAME = workflow_dispatch` — correct while an + operator's manual dispatch was the only dispatch there was, inert-by- + construction from #209 on, when every machine wake became a dispatch + event (#215). + +- The venue drill caught that: with the bridge delivering `no` perfectly, + drill runs 16/17 still bootstrapped, because the script never read the + input the whole chain existed to deliver (#215). + +- `test/labels-reconcile.test.sh` pins the regression pair exactly: a + `workflow_dispatch` event with `BOOTSTRAP=no` (or unset) creates and + deletes nothing; only `BOOTSTRAP=yes` bootstraps (#215). diff --git a/docs/CONSUMERS.md b/docs/CONSUMERS.md index e5a3d59..9afc321 100644 --- a/docs/CONSUMERS.md +++ b/docs/CONSUMERS.md @@ -458,8 +458,15 @@ permissions: jobs: sweep: uses: heavy-duty/ceremony/.github/workflows/labels-sweep.yml@ + with: + # Pass the dispatch input through the workflow_call boundary + # explicitly — a called workflow must not rely on reading the caller's + # event inputs (ceremony#215 measured that failing). Empty (schedule) + # maps to "no" explicitly, so a cron-woken sweep never re-upserts the + # taxonomy. + bootstrap: ${{ inputs.bootstrap || 'no' }} # If this repo's PR-facing labels caller is named anything but `labels`, - # pass that name: `with: { pr_workflow_name: }`. The sweep exports + # pass that name alongside: `pr_workflow_name: `. The sweep exports # it as SELF_WORKFLOW so the label machinery's own check entries (scope, # trigger) never count toward blocker:ci-red — a red trigger means "fix # the caller", which no PR edit can do (#208 reads it). diff --git a/test/labels-bootstrap.test.sh b/test/labels-bootstrap.test.sh new file mode 100755 index 0000000..f8c0158 --- /dev/null +++ b/test/labels-bootstrap.test.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# The bootstrap bridge across the workflow_call boundary (#215). +# +# The defect: a called workflow cannot read the caller's dispatch inputs on +# this forge — `github.event.inputs.*` is empty inside `workflow_call` even +# though the top-level caller receives the value in both contexts (probe runs +# 6/7). The old gate read exactly that, so every dispatch-woken sweep +# bootstrapped (runs 459/523). The fix moves the value through a DECLARED +# `workflow_call` input, passed by the caller, with empty mapped to "no" at +# the caller so a cron can never bootstrap. +# +# These cases pin the wiring at every hop and drive the four value paths +# through the semantics of the exact expressions shipped — extracted from the +# YAML, never retyped, so an edited expression is an edited test input. +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=test/harness.sh +. "$ROOT/test/harness.sh" + +REUSABLE="$ROOT/.github/workflows/labels-sweep.yml" +CALLER="$ROOT/.github/workflows/self-labels-sweep.yml" +CONSUMERS="$ROOT/docs/CONSUMERS.md" + +# --- the declared boundary --------------------------------------------------- +decl() { yq -r ".on.workflow_call.inputs.bootstrap.$1 // \"\"" "$REUSABLE"; } +declares_boundary() { [ -n "$(yq -r '.on.workflow_call.inputs.bootstrap // ""' "$REUSABLE")" ]; } +check "labels-sweep.yml declares bootstrap as a workflow_call input" 0 "" declares_boundary +check "...typed string" 0 "string" decl type +check "...defaulting to no — an absent pass-through must never bootstrap" 0 "no" \ + decl default + +# --- the gate reads the declared input, and nothing else --------------------- +gate_exprs() { yq -r '.jobs[].steps[] | select(.with.bootstrap != null) | .with.bootstrap' "$REUSABLE"; } +gates_are_identity() { + [ "$(gate_exprs | sort -u)" = "\${{ inputs.bootstrap }}" ] \ + && [ "$(gate_exprs | wc -l)" -eq 2 ] +} +check "both gate sites feed the DECLARED input, unchanged" 0 "" gates_are_identity +# The forbidden context is only live inside an expression: the file NAMES it +# in comments and in the declared input's description to explain the defect, +# and both are prose. Matching raw text asserted on the explanation — the +# adjacent-assertion trap this suite keeps re-learning — so the predicate is +# scoped to `${{ … }}` bodies. +reads_event_inputs() { grep -qE '\$\{\{[^}]*github\.event\.inputs' "$REUSABLE"; } +check "no expression in the reusable reads github.event.inputs — the context this forge empties" 1 "" \ + reads_event_inputs + +# --- the caller passes it through, empty mapped to no ------------------------ +CALLER_EXPR="$(yq -r '.jobs.sweep.with.bootstrap // ""' "$CALLER")" +check "self-labels-sweep.yml passes with.bootstrap through the boundary" 0 "" \ + test -n "$CALLER_EXPR" +check "...with the exact empty-guard expression" 0 "" \ + test "$CALLER_EXPR" = "\${{ inputs.bootstrap || 'no' }}" + +# The published stub must carry the same bridge, or every consumer inherits +# the defect ceremony just fixed for itself. +check "the CONSUMERS.md sweep stub passes bootstrap through the boundary" 0 \ + "bootstrap: \${{ inputs.bootstrap || 'no' }}" \ + grep -F "bootstrap: \${{ inputs.bootstrap || 'no' }}" "$CONSUMERS" + +# --- the four value paths, through the shipped expressions ------------------- +# Evaluate the caller expression's semantics for a given top-level value. The +# expression is asserted byte-exact above, so modelling `x || 'no'` here is +# modelling the string the tree actually ships, not a hope about it. +caller_pass() { [ -n "$1" ] && printf '%s' "$1" || printf 'no'; } +# The reusable's gate is asserted to be the identity; the value then meets +# actions/labels-reconcile's REAL validate step, extracted and executed. +VALIDATE="$(mktemp)" +trap 'rm -f "$VALIDATE"' EXIT +{ + printf '%s\n' '#!/usr/bin/env bash' + yq -r '.runs.steps[] | select(.name == "validate bootstrap input") | .run' \ + "$ROOT/actions/labels-reconcile/action.yml" +} >"$VALIDATE" +chmod +x "$VALIDATE" +path() { BOOTSTRAP="$(caller_pass "$1")" bash "$VALIDATE"; } + +check "schedule (empty top-level context) validates as a non-bootstrap sweep" 0 "" path "" +check "a REST event wake passing no validates as a non-bootstrap sweep" 0 "" path no +check "a manual dispatch passing yes validates as a bootstrap" 0 "" path yes +invalid_path() { BOOTSTRAP="maybe" bash "$VALIDATE"; } +check "an invalid value reaches the validator UNSANITIZED and refuses" 2 \ + "bootstrap must be 'yes' or 'no'" invalid_path + +# ...and the non-bootstrap/bootstrap split is what the validator's callers +# act on: prove the two accepted values are distinguished, not merely both +# accepted, by pinning what each resolves to after the caller pass. +check "empty and no resolve identically — the cron can never bootstrap" 0 "" \ + test "$(caller_pass "")" = "$(caller_pass no)" +check "...and yes stays yes through the pass" 0 "" test "$(caller_pass yes)" = yes + +summary diff --git a/test/labels-reconcile.test.sh b/test/labels-reconcile.test.sh index d9e9a9a..4874afb 100755 --- a/test/labels-reconcile.test.sh +++ b/test/labels-reconcile.test.sh @@ -883,7 +883,6 @@ expect "attention diagnosis caused no PR mutation" "$attention_mutations_before" # -- the sweep wiring observes the existing per-PR skip without writing ------- blind_main_probe() { ( - GITHUB_EVENT_NAME=schedule REPO=owner/repo LABELS_CONF=.github/labels.conf # This probe IS a GitHub board — say so at the forge boundary rather @@ -942,7 +941,6 @@ expect "exactly the blind PRs match the counted shape whole-line — no more, no # shape, where the probes could not reach the per-PR path at all. unrequested_main_probe() { # $1 = read | denied, the head-commit read's outcome ( - GITHUB_EVENT_NAME=schedule REPO=owner/repo LABELS_CONF=.github/labels.conf # main() preflights the forge before it reads anything, so a probe that @@ -1148,17 +1146,18 @@ EOF chmod +x "$EXEC/stub/gh" printf 'panel=bot-a bot-b bot-c\n' >"$EXEC/labels.conf" -exec_env() { # $1 = event name → the real script, executed under the PATH stub +exec_env() { # $1 = event name, $2 = BOOTSTRAP value ("" = unset) : >"$EXEC/record" env PATH="$EXEC/stub:$PATH" GH_RECORD="$EXEC/record" \ CEREMONY_FORGE=github \ REPO=owner/repo LABELS_CONF="$EXEC/labels.conf" GITHUB_EVENT_NAME="$1" \ + ${2:+BOOTSTRAP="$2"} \ bash actions/labels-reconcile/labels-reconcile.sh } exec_rc=0 -exec_out="$(exec_env workflow_dispatch 2>&1)" || exec_rc=$? -expect "an executed dispatch with all six absent completes green" 0 "$exec_rc" +exec_out="$(exec_env workflow_dispatch yes 2>&1)" || exec_rc=$? +expect "an executed bootstrap=yes with all six absent completes green" 0 "$exec_rc" expect "...reaching the end of the sweep" \ yes "$(grep -q 'reconciled.' <<<"$exec_out" && echo yes || echo no)" expect "...having attempted all six deletions" \ @@ -1167,12 +1166,18 @@ expect "...and created the full taxonomy" \ "$(core_label_rows | cut -d'|' -f1)" \ "$(sed -n 's/^create //p' "$EXEC/record")" -# -- bootstrap is dispatch-only, deletes included: the cron and -# pull_request_target paths touch no label -for ev in schedule pull_request_target; do +# -- the bootstrap keys on the INPUT, never the event (ceremony#215): from +# #209 on, EVERY machine wake is a workflow_dispatch event, so an event +# gate made bootstrap=no inert — runs 459/523 and venue drill runs 16/17 +# bootstrapped on a delivered "no". The regression case is exactly that +# pair: dispatch event, no. +for pair in "workflow_dispatch:no" "workflow_dispatch:" "schedule:no" "schedule:" "pull_request_target:"; do + ev="${pair%%:*}"; bs="${pair#*:}" ev_rc=0 - exec_env "$ev" >/dev/null 2>&1 || ev_rc=$? - expect "the $ev path completes green" 0 "$ev_rc" + exec_env "$ev" "$bs" >/dev/null 2>&1 || ev_rc=$? + expect "the $ev event with BOOTSTRAP='${bs:-unset}' completes green" 0 "$ev_rc" + expect "...and creates nothing" \ + no "$(grep -q '^create ' "$EXEC/record" && echo yes || echo no)" expect "...and deletes nothing" \ no "$(grep -q '^delete ' "$EXEC/record" && echo yes || echo no)" done @@ -1471,7 +1476,6 @@ expect "...and a malformed panel= line in that same file is refused, not passed" # --------------------------------------------------------------------------- write_fail_probe() { # $1 = ok | fail — whether the label edit write succeeds ( - GITHUB_EVENT_NAME=schedule REPO=owner/repo LABELS_CONF=.github/labels.conf CEREMONY_FORGE=github @@ -1541,7 +1545,9 @@ expect "...and does print reconciled." \ # exited 0 (@codex-reviewer-andresmgsl, #192 review). stale_fail_probe() { ( - GITHUB_EVENT_NAME=schedule + # No event name: the script stopped reading GITHUB_EVENT_NAME when the + # bootstrap moved to the BOOTSTRAP input (#215); default no is the + # non-bootstrap sweep this probe simulates. REPO=owner/repo LABELS_CONF=.github/labels.conf CEREMONY_FORGE=github