labels-sweep — pass bootstrap through the workflow_call boundary it was lost at (#215) #218
8 changed files with 216 additions and 29 deletions
35
.github/workflows/labels-sweep.yml
vendored
35
.github/workflows/labels-sweep.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
8
.github/workflows/self-labels-sweep.yml
vendored
8
.github/workflows/self-labels-sweep.yml
vendored
|
|
@ -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' }}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
47
changelog.d/215.md
Normal file
47
changelog.d/215.md
Normal file
|
|
@ -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).
|
||||
|
|
@ -458,8 +458,15 @@ permissions:
|
|||
jobs:
|
||||
sweep:
|
||||
uses: heavy-duty/ceremony/.github/workflows/labels-sweep.yml@<pinned-tag>
|
||||
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: <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,
|
||||
# trigger) never count toward blocker:ci-red — a red trigger means "fix
|
||||
# the caller", which no PR edit can do (#208 reads it).
|
||||
|
|
|
|||
93
test/labels-bootstrap.test.sh
Executable file
93
test/labels-bootstrap.test.sh
Executable 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue