diff --git a/.github/workflows/labels-sweep.yml b/.github/workflows/labels-sweep.yml index defca41..760590b 100644 --- a/.github/workflows/labels-sweep.yml +++ b/.github/workflows/labels-sweep.yml @@ -34,6 +34,19 @@ 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; a called + workflow cannot read the caller's dispatch inputs on every forge + (Forgejo drops them at the workflow_call boundary — ceremony#215, + probe runs 6/7 vs merged runs 459/523), so the value must arrive + through this declared boundary, never via github.event.inputs. + 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 +99,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 — which Forgejo leaves empty at the + # workflow_call boundary even though the top-level caller receives the + # value in both contexts (ceremony#215; probe runs 6/7 measured the + # boundary, merged runs 459/523 paid for it: every dispatch-woken + # sweep bootstrapped). The caller passes the value through + # `with.bootstrap`; anything not exactly "yes" is fed through for + # labels-reconcile's own yes|no 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 +122,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..e2dcca3 100644 --- a/.github/workflows/self-labels-sweep.yml +++ b/.github/workflows/self-labels-sweep.yml @@ -44,3 +44,10 @@ 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: the called workflow cannot read this caller's dispatch inputs on + # this forge (ceremony#215). 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/changelog.d/215.md b/changelog.d/215.md new file mode 100644 index 0000000..d94f219 --- /dev/null +++ b/changelog.d/215.md @@ -0,0 +1,29 @@ +### Fixed + +- The sweep's `bootstrap` value crosses the `workflow_call` boundary as a + declared input passed by the caller — a called workflow cannot read the + caller's dispatch inputs on this forge (#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`, the caller and stub pass-throughs byte-exact, and + the four value paths driven through the shipped expressions into the + action's real validator (#215). diff --git a/docs/CONSUMERS.md b/docs/CONSUMERS.md index e5a3d59..e5153f6 100644 --- a/docs/CONSUMERS.md +++ b/docs/CONSUMERS.md @@ -458,8 +458,14 @@ permissions: jobs: sweep: uses: heavy-duty/ceremony/.github/workflows/labels-sweep.yml@ + with: + # Pass the dispatch input through the workflow_call boundary — the + # called workflow cannot read this caller's dispatch inputs on every + # forge (ceremony#215). 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