fix(labels): pass bootstrap through the workflow_call boundary it was lost at

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 sweep's gate
read exactly that, so every dispatch-woken sweep bootstrapped — runs 459 and
523, ~20 label upserts per board event — while the trigger honestly logged
bootstrap=no.

The bridge, per the #6361 contract: labels-sweep.yml declares
workflow_call.inputs.bootstrap (string, default "no"); the dogfood caller and
the published CONSUMERS.md stub pass it via with.bootstrap with empty mapped
to "no" at the caller — kimi's edge: on schedule the top-level context is
empty, and an empty that slipped through would have turned every cron into a
bootstrap. The gate feeds the declared input to labels-reconcile unchanged,
so an invalid value meets the action's own yes|no refusal.

test/labels-bootstrap.test.sh pins every hop: the declared boundary, both
gates as the identity, no expression reading github.event.inputs (scoped to
${{ }} bodies — the file's prose names the context to explain it), the two
pass-throughs byte-exact, and the four value paths driven through the shipped
expressions into the action's real validator. Mutations: dropping the
declaration reds 4, dropping the pass-through reds 3, restoring the old gate
reds 2.

Refs #215
This commit is contained in:
clad2 2026-08-05 21:06:29 +00:00
parent b9a940ae2a
commit baa683211e
5 changed files with 161 additions and 10 deletions

View file

@ -34,6 +34,19 @@ name: labels-sweep
on: on:
workflow_call: workflow_call:
inputs: 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: pr_workflow_name:
description: >- description: >-
The `name:` of the consumer's PR-facing labels caller, exported 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 # papers over for `run:` steps, which composite `uses:` has no
# equivalent of. # equivalent of.
# #
# bootstrap: every trigger-driven wake arrives as workflow_dispatch # bootstrap: read from the DECLARED workflow_call input and nothing
# too (that is how the trigger's dispatch wakes the caller), so the event # else. The old gate read `github.event.inputs.bootstrap` from inside
# name alone no longer separates the operator's manual full-board # this called workflow — which Forgejo leaves empty at the
# bootstrap from an event-woken sweep — the caller's `bootstrap` # workflow_call boundary even though the top-level caller receives the
# dispatch input does: the trigger passes "no", a bare manual # value in both contexts (ceremony#215; probe runs 6/7 measured the
# dispatch defaults to "yes". A caller reached on any other event # boundary, merged runs 459/523 paid for it: every dispatch-woken
# (the cron) has no input and stays "no". # 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 - name: reconcile state + stale
if: github.repository != 'heavy-duty/ceremony' if: github.repository != 'heavy-duty/ceremony'
uses: ./.ceremony-src/actions/labels-reconcile uses: ./.ceremony-src/actions/labels-reconcile
with: with:
bootstrap: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.bootstrap != 'no' && 'yes' || 'no' }} bootstrap: ${{ inputs.bootstrap }}
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }} REPO: ${{ github.repository }}
@ -106,7 +122,7 @@ jobs:
if: github.repository == 'heavy-duty/ceremony' if: github.repository == 'heavy-duty/ceremony'
uses: ./actions/labels-reconcile uses: ./actions/labels-reconcile
with: with:
bootstrap: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.bootstrap != 'no' && 'yes' || 'no' }} bootstrap: ${{ inputs.bootstrap }}
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }} REPO: ${{ github.repository }}

View file

@ -44,3 +44,10 @@ jobs:
# pr_workflow_name keeps its default: ceremony's PR-facing caller is # pr_workflow_name keeps its default: ceremony's PR-facing caller is
# named `labels` (self-labels.yml). # named `labels` (self-labels.yml).
uses: ./.github/workflows/labels-sweep.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' }}

29
changelog.d/215.md Normal file
View file

@ -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).

View file

@ -458,8 +458,14 @@ permissions:
jobs: jobs:
sweep: sweep:
uses: heavy-duty/ceremony/.github/workflows/labels-sweep.yml@<pinned-tag> uses: heavy-duty/ceremony/.github/workflows/labels-sweep.yml@<pinned-tag>
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`, # If this repo's PR-facing labels caller is named anything but `labels`,
# pass that name: `with: { pr_workflow_name: <name> }`. The sweep exports # pass that name alongside: `pr_workflow_name: <name>`. The sweep exports
# it as SELF_WORKFLOW so the label machinery's own check entries (scope, # 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 # trigger) never count toward blocker:ci-red — a red trigger means "fix
# the caller", which no PR edit can do (#208 reads it). # the caller", which no PR edit can do (#208 reads it).

93
test/labels-bootstrap.test.sh Executable file
View file

@ -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