Skip to content

Commit 7911485

Browse files
ci(half-state-patrol): a declared anchor-optional input lets a board with no anchor issue run summary-only (default off) (#20812)
Fixes #20798 Clause-②: no. CI wiring, no published contract, per ruling `5905998989`. This executes decision A of the decision card #20793 (ruling `5905998989`; the maintainer's answer, verbatim: "A: opt-in action input (Recommended)"), inside the maintainer's order on #18471. objectstack-ai/objectui#11174 is waiting on this card and switches onto the action once this lands, with the opt-in on and a sha pinned at that time. ## What changes **`.github/actions/half-state-patrol/action.yml`: one new declared input, `anchor-optional`, default `'false'`.** - **Locate the patrol sources** judges the value once, on every event, and publishes it as `anchor_optional`. It accepts the six YAML 1.2 core-schema boolean spellings that `core.getBooleanInput` takes. Any other value is `::error::` + exit 1. - **Resolve the anchor issue** gains `id: anchor` and one new block in front of the existing ones. An empty (or whitespace-only) `anchor-issue` with the opt-in on writes `configured=false`, prints a `::notice::`, and exits 0. The existing empty-anchor block that follows it (`::error::` + exit 1, `action.yml:313-315` on `main`) is byte-identical. The non-numeric check is unchanged and runs in both modes. A usable anchor writes `configured=true`. - **Update the pinned anchor issue**: its guard gains `&& steps.anchor.outputs.configured == 'true'`, so the opt-in never reaches the PATCH. H1 holds: without this guard, the opt-in PATCHes issue 0 (see the ablation below). - **Publish the rendered body to the run summary** adds one line, and only on the explicit `configured == 'false'` reading. The findings go to the run summary as before, since this step is `if: always()`. - The header gains a section, "An empty anchor: loud by default, summary-only by declaration". The action's top-level `description` names the summary-only delivery. - For every valid value, the pull_request path is unchanged: both anchor steps are still skipped there. The one exception is the value check above, which is a judgment call stated under Acceptance notes. `scripts/pm/check-half-states.mjs` is untouched. **`.github/workflows/half-state-patrol.yml` (objectstack's own caller): prose only, and the default is kept.** The sibling-install section now names the opt-in for a board that has no anchor by decision. The anchor-resolution comment says this caller keeps `anchor-optional` at its loud default. The `with:` block is unchanged. ## The input as declared ```yaml anchor-optional: description: >- `true` declares that an EMPTY `anchor-issue` is this caller's supported configuration: that one case becomes a `::notice::` and a passing run, with the findings delivered to the run summary only. It exists for one consumer, objectui, whose maintainer declined an anchor there (#20793, decision A). The default, `false`, stays LOUD -- an empty anchor fails the run -- because without the declaration an empty value reads the same as a caller that lost its variable, and objectstack's own caller must go red when that happens. It never widens: a non-numeric `anchor-issue` fails either way, and a value other than true or false is refused on every run, pull_request included. required: false default: 'false' ``` ## The three modes: a runnable probe of the action's own shell There is no action or workflow test harness for this action in the repo. H2 is falsified: `git grep -n "anchor-issue\|closed-floor"` finds only the action itself and the caller's prose. No test, doc or script reads the input list. So this is measured with a probe, `probe-anchor-modes.py`, whose full source is at the end of this body. How it works: - It loads `action.yml` and runs every step's real `run:` body in order. Each body is template-expanded and run the way the runner's `shell: bash` runs it (`bash --noprofile --norc -eo pipefail`). - It evaluates each `if:` with a small GitHub-expression evaluator. As the runner does, it wraps the expression in `success() && (...)` unless it names a status function. - It uses real `GITHUB_OUTPUT`, `GITHUB_STEP_SUMMARY` and `RUNNER_TEMP` files. - It stubs two steps that this diff does not change: the sweep (a stub report plus the scenario's exit code) and the github-script PATCH. For the PATCH it prints the issue number that `Number(ANCHOR_ISSUE)` would target, computed by node. Run at `c35b098b1`: `python3 probe-anchor-modes.py .github/actions/half-state-patrol/action.yml .` (exit 0). | scenario | event | anchor-issue | anchor-optional | Locate | Resolve | Update (the PATCH) | summary line | job | |---|---|---|---|---|---|---|---|---| | off+empty | schedule | `''` | unset | exit 0 | exit 1 `::error::` | skipped | none | FAILURE | | off-explicit+empty | schedule | `''` | `false` | exit 0 | exit 1 `::error::` | skipped | none | FAILURE | | **on+empty** | schedule | `''` | `true` | exit 0 | exit 0 `::notice::` | skipped | "No anchor issue configured ..." | **success** | | on+whitespace | workflow_dispatch | `' '` | `true` | exit 0 | exit 0 `::notice::` | skipped | "No anchor issue configured ..." | success | | off+non-numeric | schedule | `'abc'` | unset | exit 0 | exit 1 `::error::` | skipped | none | FAILURE | | on+non-numeric | schedule | `'abc'` | `true` | exit 0 | exit 1 `::error::` | skipped | none | FAILURE | | off+number | schedule | `'9857'` | unset | exit 0 | exit 0 | runs, PATCHes issue 9857 | none | success | | on+number | schedule | `'9857'` | `true` | exit 0 | exit 0 | runs, PATCHes issue 9857 | none | success | | pr+off+empty | pull_request | `''` | unset | exit 0 | skipped | skipped | "Anchor write skipped ..." | success | | pr+on+empty | pull_request | `''` | `true` | exit 0 | skipped | skipped | "Anchor write skipped ..." | success | | bad-optional | schedule | `''` | `yes` | exit 1 `::error::` | skipped | skipped | none | FAILURE | | pr+bad-optional | pull_request | `''` | `yes` | exit 1 `::error::` | skipped | skipped | "Anchor write skipped ..." | FAILURE | | on+empty, sweep exit 3 | schedule | `''` | `true` | exit 0 | exit 0 `::notice::` | skipped | "No anchor issue configured ..." | FAILURE (the final "sweep could not run" step) | | off+empty, sweep exit 3 | schedule | `''` | unset | exit 0 | exit 1 `::error::` | skipped | none | FAILURE | The rows the card requires, quoted from that run: ```text === off+empty | event=schedule anchor-issue='' anchor-optional=UNSET(default 'false') sweep-exit=0 - Locate the patrol sources exit 0 outputs {'anchor_optional': 'false'} - Check the workspace serves the swept board exit 0 - Run the live sweep exit 0 outputs {'exit_code': '0'} (stub) check-half-states exited 0 - Resolve the anchor issue exit 1 ::error::No anchor issue configured for objectstack-ai/objectui. The sweep RAN (see the run summary) but has nowhere to land. Open a `tracking`-labeled anchor issue in this repo and pass its number as the 'anchor-issue' input of the half-state-patrol action. - Update the pinned anchor issue skipped - Publish the rendered body to the run summary exit 0 - Fail the run if the sweep could not run skipped summary: ### Half-state patrol — sweep exit 0 JOB: FAILURE === on+empty | event=schedule anchor-issue='' anchor-optional='true' sweep-exit=0 - Locate the patrol sources exit 0 outputs {'anchor_optional': 'true'} - Check the workspace serves the swept board exit 0 - Run the live sweep exit 0 outputs {'exit_code': '0'} (stub) check-half-states exited 0 - Resolve the anchor issue exit 0 outputs {'configured': 'false'} ::notice::No anchor issue configured for objectstack-ai/objectui, which is a supported configuration for this caller: it declares 'anchor-optional: true' on the half-state-patrol action. The sweep RAN and its rendered body is in this run's summary; nothing was written to any issue, and the missing anchor does not fail this run. To deliver findings to a pinned issue instead, open a `tracking`-labeled anchor issue in this repo and pass its number as the 'anchor-issue' input. - Update the pinned anchor issue skipped - Publish the rendered body to the run summary exit 0 - Fail the run if the sweep could not run skipped summary: ### Half-state patrol — sweep exit 0 summary: _No anchor issue configured (`anchor-optional` is on and `anchor-issue` is empty): this summary is the ONLY delivery of the findings below — it notifies nobody, by accepted trade. Pass a `tracking` issue's number as `anchor-issue` to have them land on a pinned card instead._ JOB: success === off+non-numeric | event=schedule anchor-issue='abc' anchor-optional=UNSET(default 'false') sweep-exit=0 - Locate the patrol sources exit 0 outputs {'anchor_optional': 'false'} - Check the workspace serves the swept board exit 0 - Run the live sweep exit 0 outputs {'exit_code': '0'} (stub) check-half-states exited 0 - Resolve the anchor issue exit 1 ::error::The 'anchor-issue' input is 'abc', which is not an issue number. - Update the pinned anchor issue skipped - Publish the rendered body to the run summary exit 0 - Fail the run if the sweep could not run skipped summary: ### Half-state patrol — sweep exit 0 JOB: FAILURE === on+non-numeric | event=schedule anchor-issue='abc' anchor-optional='true' sweep-exit=0 - Locate the patrol sources exit 0 outputs {'anchor_optional': 'true'} - Check the workspace serves the swept board exit 0 - Run the live sweep exit 0 outputs {'exit_code': '0'} (stub) check-half-states exited 0 - Resolve the anchor issue exit 1 ::error::The 'anchor-issue' input is 'abc', which is not an issue number. - Update the pinned anchor issue skipped - Publish the rendered body to the run summary exit 0 - Fail the run if the sweep could not run skipped summary: ### Half-state patrol — sweep exit 0 JOB: FAILURE ``` **The default mode is unchanged, measured against `main`.** The same probe was run on the base blob (`41dcf1188:.github/actions/half-state-patrol/action.yml`, blob `fc455f1ef`) and on this head, for off+empty, off+non-numeric, off+number, pr+off+empty and off+empty with sweep exit 3. The outputs were compared after dropping the `outputs {...}` annotation, since the head's steps now also publish `anchor_optional` and `configured`. Across all 66 lines, each scenario differs in one line only: the scenario header, which reads `UNSET(default '-')` on base (no such input) and `UNSET(default 'false')` on head. Every step line, annotation, summary line and job verdict is identical. **Ablation of the Update guard (H1).** This was run on a scratch copy of the committed `action.yml`, never on the tree. The copy had the guard's `&& steps.anchor.outputs.configured == 'true'` removed; `grep -c` found that text 0 times in the copy and 1 time in HEAD. The probe then read: ```text === on+empty | event=schedule anchor-issue='' anchor-optional='true' sweep-exit=0 - Update the pinned anchor issue exit 0 (stub) actions/github-script@v9 would PATCH issue #0 === on+whitespace | event=workflow_dispatch anchor-issue=' ' anchor-optional='true' sweep-exit=0 - Update the pinned anchor issue exit 0 (stub) actions/github-script@v9 would PATCH issue #0 ``` So the opt-in needs the guard. On a real runner that PATCH answers 404 and turns the run red, which is the state this card exists to remove. ## Gates (local, at `c35b098b1`) - The gate list comes from `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 39 commands, derived from merge base `41dcf1188` over the 2 changed paths. - All 39 exit 0 on the final run. `node scripts/pm/dispatch-gates.mjs --ran ran.list` reconciles them as `39 derived, 39 run, 0 NOT-MEASURED, 0 UNRUN`, with an exit code recorded for every one. `pnpm check:pm-dispatch-gates` is longer than the container's foreground cap, so it ran detached, as its header prescribes, under a foreground `tail --pid` wait. It printed `✓ check:pm-dispatch-gates --self-test: the exit contract holds in all three directions.` and `✓ dispatch-gates self-test: 1976 cases pass.` (1018.6s), with 0 `✗` lines. - `pnpm install` for the root package only (`--filter @objectstack/spec-monorepo`). 15 gates first exited 3 (`PREREQUISITE NOT MET`, the `yaml` dependency) and are green on the re-run after it. - Workflow-valued families: `check-half-states.mjs` and `check-issue-citations.mjs --census` are NOT MEASURED locally. Their argv carries runner-only values. This PR's own `half-state-patrol.yml` run exercises the PR path on a real runner, because the action's directory is a trigger path. That run includes the new value check at its default. - `actionlint`: NOT MEASURED. It is not on PATH and not in `node_modules/.bin`. `check:workflow-status-functions` and `check:workflow-step-name-quoting` are green. - Changeset: none, because nothing publishes. Both changed paths are owned only by the private root package `@objectstack/spec-monorepo`. `pnpm ls -r` finds 81 workspace packages, 69 public, and none of them owns a changed path. `check-empty-changeset.mjs --base 41dcf11` is green (0 changesets added). `check-changeset-no-major.mjs --base 41dcf11 --event EVENT` is green, reading this body's `Clause-②: no` line. The disposition is `skip-changeset`. This dispatch did not allow label writes, so the seat applies it, and `Check Changeset` reads red until then. ## Acceptance notes - **One judgment call, stated rather than buried.** The value check on `anchor-optional` sits in "Locate the patrol sources", so it runs on pull_request runs too. - For every valid value, the pull_request path is unchanged. The pr+off+empty and pr+on+empty rows match base, and both anchor steps are still skipped. - Only a misspelt opt-in behaves differently: it is refused on the PR that adds it, instead of passing that PR's run and first failing on the next scheduled run. - If the seat reads "the pull_request path stays unchanged" as ruling this out, the alternative is a mechanical move of the `case` into the Resolve step (non-PR only). - **The notice differs from objectui#8740's wording in one clause.** It says "the missing anchor does not fail this run" rather than "this run is NOT a failure". With the opt-in on, a sweep that could not run still fails the run in its final step (probe row "on+empty, sweep exit 3"). The notice names the setting (`anchor-optional: true`) where objectui's named its repository variable. - **Not exercised on a real runner: the non-PR modes.** A `workflow_dispatch` of this branch would PATCH objectstack's live anchor. The first live reading of on+empty will be objectui's switch (objectstack-ai/objectui#11174). - A composite action does not refuse an input it does not declare. A caller that pins a sha from before this change and passes `anchor-optional` gets only the runner's "Unexpected input" warning, and the empty anchor still fails loudly. That is safe, but objectui's switch has to pin a sha that contains this change. ## The probe (`probe-anchor-modes.py`) ```python #!/usr/bin/env python3 # probe-anchor-modes.py ACTION_YML REPO_ROOT [scenario ...] # # Runs the half-state-patrol composite action's REAL `run:` bodies, in order, under # the runner's step rules, once per anchor scenario: # - each `run:` is template-expanded (${{ ... }}) and executed as the runner's # `shell: bash` does: bash --noprofile --norc -eo pipefail FILE; # - each `if:` is evaluated by a small GitHub-expression evaluator, wrapped in # success() && (...) unless it names a status function, as the runner does; # - GITHUB_OUTPUT / GITHUB_STEP_SUMMARY / RUNNER_TEMP are real files. # Two steps are STUBBED, both unchanged by the diff under test: the sweep (writes a # report and publishes the scenario's exit code) and the github-script PATCH # (prints the issue number Number(ANCHOR_ISSUE) would PATCH, computed by node). import os, re, subprocess, sys, tempfile, yaml ACTION, ROOT = sys.argv[1], os.path.abspath(sys.argv[2]) ONLY = sys.argv[3:] action = yaml.safe_load(open(ACTION)) STEPS = action['runs']['steps'] DEFAULTS = {k: str(v.get('default', '')) for k, v in action['inputs'].items()} TOK = re.compile(r"\s*(\|\||&&|==|!=|\(|\)|'(?:[^']|'')*'|[A-Za-z_][\w.-]*\(\)|[A-Za-z_][\w.-]*)") def truthy(v): return v not in ('', None, False, 0) def text(v): return 'true' if v is True else 'false' if v is False else '' if v is None else str(v) def evaluate(expr, ctx): s, toks, i = expr.strip(), [], 0 while i != len(s): m = TOK.match(s, i) if not m: raise ValueError('cannot tokenize: ' + s[i:]) toks.append(m.group(1)); i = m.end() pos = [0] peek = lambda: toks[pos[0]] if pos[0] != len(toks) else None def take(): pos[0] += 1 return toks[pos[0] - 1] def primary(): t = take() if t == '(': v = orx(); take(); return v if t.startswith("'"): return t[1:-1].replace("''", "'") if t.endswith('()'): return {'always()': True, 'success()': ctx['job_ok'], 'failure()': not ctx['job_ok']}[t] v = ctx for part in t.split('.'): v = v.get(part, '') if isinstance(v, dict) else '' return v def cmp(): a = primary() if peek() in ('==', '!='): op, b = take(), primary() eq = text(a).lower() == text(b).lower() # GitHub compares strings case-insensitively return eq if op == '==' else not eq return a def andx(): v = cmp() while peek() == '&&': take(); r = cmp(); v = r if truthy(v) else v return v def orx(): v = andx() while peek() == '||': take(); r = andx(); v = v if truthy(v) else r return v return orx() def expand(s, ctx): return re.sub(r'\$\{\{(.*?)\}\}', lambda m: text(evaluate(m.group(1), ctx)), str(s), flags=re.S) def should_run(step, ctx): cond = step.get('if') if cond is None: return ctx['job_ok'] c = str(cond).strip() if c.startswith('${{'): c = c[3:-2] if not re.search(r'\b(always|success|failure|cancelled)\(\)', c): c = 'success() && (' + c + ')' return truthy(evaluate(c, ctx)) def scenario(name, event, anchor, optional, sweep_exit=0): tmp = tempfile.mkdtemp(prefix='hsp-probe-') summary = os.path.join(tmp, 'summary.md'); open(summary, 'w').close() inputs = dict(DEFAULTS, **{'github-token': 'probe-token', 'anchor-issue': anchor}) if optional is not None: inputs['anchor-optional'] = optional ctx = {'job_ok': True, 'inputs': inputs, 'steps': {}, 'github': { 'event_name': event, 'repository': 'objectstack-ai/objectui', 'workspace': ROOT, 'action_path': os.path.join(ROOT, '.github/actions/half-state-patrol'), 'run_id': '1', 'server_url': 'https://github.com', 'sha': '0' * 40}} print('=== %s | event=%s anchor-issue=%r anchor-optional=%s sweep-exit=%d' % (name, event, anchor, 'UNSET(default %r)' % DEFAULTS.get('anchor-optional', '-') if optional is None else repr(optional), sweep_exit)) for step in STEPS: sid, label = step.get('id', ''), step['name'] if not should_run(step, ctx): print(' - %-48s skipped' % label) ctx['steps'][sid] = {'outputs': {}} continue env = dict(os.environ, RUNNER_TEMP=tmp, GITHUB_STEP_SUMMARY=summary, GITHUB_OUTPUT=os.path.join(tmp, 'out-' + (sid or 'x')), GITHUB_REPOSITORY='objectstack-ai/objectui') open(env['GITHUB_OUTPUT'], 'w').close() for k, v in (step.get('env') or {}).items(): env[k] = expand(v, ctx) if sid == 'sweep': # STUB: the sweep itself is not under test open(os.path.join(tmp, 'report.md'), 'w').write('os-half-state-sweep (probe stub report)\n') open(os.path.join(tmp, 'report.err'), 'w').write('') open(env['GITHUB_OUTPUT'], 'w').write('exit_code=%d\n' % sweep_exit) code, out = 0, '(stub) check-half-states exited %d' % sweep_exit elif 'uses' in step: # STUB: the github-script PATCH n = subprocess.run(['node', '-p', 'Number(process.env.ANCHOR_ISSUE)'], env=env, capture_output=True, text=True).stdout.strip() code, out = 0, '(stub) %s would PATCH issue #%s' % (step['uses'], n) else: script = os.path.join(tmp, 'step.sh') open(script, 'w').write(expand(step['run'], ctx)) cwd = expand(step.get('working-directory', ROOT), ctx) p = subprocess.run(['bash', '--noprofile', '--norc', '-eo', 'pipefail', script], cwd=cwd, env=env, capture_output=True, text=True) code, out = p.returncode, (p.stdout + p.stderr).strip() outputs = dict(l.split('=', 1) for l in open(env['GITHUB_OUTPUT']).read().splitlines() if '=' in l) ctx['steps'][sid] = {'outputs': outputs} if code != 0: ctx['job_ok'] = False shown = {k: v for k, v in outputs.items() if k != 'root'} print(' - %-48s exit %d%s' % (label, code, (' outputs ' + repr(shown)) if shown else '')) for line in out.splitlines(): if line.startswith(('::', '(stub)', 'anchor:')): print(' ' + line) for line in open(summary).read().splitlines(): if line.startswith(('###', '_')): print(' summary: ' + line) print(' JOB: %s' % ('success' if ctx['job_ok'] else 'FAILURE')) print() SCENARIOS = [ ('off+empty', 'schedule', '', None), ('off-explicit+empty', 'schedule', '', 'false'), ('on+empty', 'schedule', '', 'true'), ('on+whitespace', 'workflow_dispatch', ' ', 'true'), ('off+non-numeric', 'schedule', 'abc', None), ('on+non-numeric', 'schedule', 'abc', 'true'), ('off+number', 'schedule', '9857', None), ('on+number', 'schedule', '9857', 'true'), ('pr+off+empty', 'pull_request', '', None), ('pr+on+empty', 'pull_request', '', 'true'), ('bad-optional', 'schedule', '', 'yes'), ('pr+bad-optional', 'pull_request', '', 'yes'), ('on+empty+sweep-died', 'schedule', '', 'true', 3), ('off+empty+sweep-died', 'schedule', '', None, 3), ] for s in SCENARIOS: if not ONLY or s[0] in ONLY: scenario(*s) ``` --- _Generated by [Claude Code](https://claude.ai/code/session_01TdiauJaVCHuj45EzZGUxHh)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 261c529 commit 7911485

2 files changed

Lines changed: 81 additions & 7 deletions

File tree

‎.github/actions/half-state-patrol/action.yml‎

Lines changed: 75 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -121,12 +121,25 @@
121121
# board identifier that can be typed is a board identifier that can be typed
122122
# wrong, and a patrol sweeping the repo it was installed FROM would file a fully
123123
# green report about the wrong board.
124+
#
125+
# ## An empty anchor: loud by default, summary-only by declaration (#20793)
126+
#
127+
# An empty `anchor-issue` cannot tell "this board has no anchor" from "this
128+
# caller lost its variable", and only the second is a lapse, so by default it
129+
# fails the run. A caller whose board has no anchor BY DECISION says so by name
130+
# (`anchor-optional: true`) and gets the other reading: a `::notice::`, a
131+
# passing run, and the findings in the run summary only -- which notifies
132+
# nobody, the accepted trade of that configuration. ⛔ The opt-in never widens:
133+
# a non-numeric anchor fails in both modes, and so does a sweep that could not
134+
# run.
124135

125136
name: Half-state patrol
126137
description: >-
127138
Sweep the calling repository's board for half-states and rewrite that
128-
repository's pinned anchor issue with the result. Report-only: findings never
129-
fail the run, but a sweep that could not run does.
139+
repository's pinned anchor issue with the result -- or, for a caller that
140+
declares `anchor-optional` and passes no anchor, deliver it to the run summary
141+
only. Report-only: findings never fail the run, but a sweep that could not run
142+
does.
130143
131144
inputs:
132145
github-token:
@@ -142,9 +155,24 @@ inputs:
142155
body this patrol owns end-to-end. An issue number is only ever meaningful
143156
in the repo it was minted in, so there is no default and no fallback: left
144157
empty, the run says so loudly and fails rather than guessing a number and
145-
rewriting an unrelated card four times a day.
158+
rewriting an unrelated card four times a day -- unless the caller has
159+
declared `anchor-optional`, below.
146160
required: false
147161
default: ''
162+
anchor-optional:
163+
description: >-
164+
`true` declares that an EMPTY `anchor-issue` is this caller's supported
165+
configuration: that one case becomes a `::notice::` and a passing run,
166+
with the findings delivered to the run summary only. It exists for one
167+
consumer, objectui, whose maintainer declined an anchor there (#20793,
168+
decision A). The default, `false`, stays LOUD -- an empty anchor fails the
169+
run -- because without the declaration an empty value reads the same as a
170+
caller that lost its variable, and objectstack's own caller must go red
171+
when that happens. It never widens: a non-numeric `anchor-issue` fails
172+
either way, and a value other than true or false is refused on every
173+
run, pull_request included.
174+
required: false
175+
default: 'false'
148176
closed-floor:
149177
description: >-
150178
Optional `YYYY-MM-DD` floor for the sweeper's closed-card pass, passed
@@ -169,6 +197,7 @@ runs:
169197
env:
170198
ACTION_PATH: ${{ github.action_path }}
171199
GH_TOKEN_IN: ${{ inputs.github-token }}
200+
ANCHOR_OPTIONAL_IN: ${{ inputs.anchor-optional }}
172201
run: |
173202
set -euo pipefail
174203
@@ -201,6 +230,24 @@ runs:
201230
exit 1
202231
fi
203232
233+
# `anchor-optional` is judged HERE, on every event, although only the
234+
# anchor steps read it: a pull_request run skips those steps, so a
235+
# misspelt opt-in judged there would pass the PR that adds it and first
236+
# fail on the scheduled run after. The accepted spellings are the YAML
237+
# 1.2 core-schema booleans `core.getBooleanInput` takes; anything else
238+
# is refused rather than read as `false`, because a misspelt `true` read
239+
# that way goes red with the no-anchor error, which names the wrong
240+
# remedy.
241+
case "$ANCHOR_OPTIONAL_IN" in
242+
true|True|TRUE) anchor_optional=true ;;
243+
false|False|FALSE) anchor_optional=false ;;
244+
*)
245+
echo "::error::The 'anchor-optional' input is '$ANCHOR_OPTIONAL_IN'. It takes true or false: true when this repository's board has no anchor issue by decision, false (the default) otherwise."
246+
exit 1
247+
;;
248+
esac
249+
echo "anchor_optional=$anchor_optional" >> "$GITHUB_OUTPUT"
250+
204251
# The runtime floor travels WITH the sweeper, read from the .nvmrc of
205252
# the tree this action shipped from. Deliberately not a literal typed
206253
# here: a second hand-written Node pin is the exact drift
@@ -304,24 +351,41 @@ runs:
304351
# findings (the same "land the truth, then raise the alarm" order the final
305352
# step keeps), and skipped on a pull_request run, which never writes an
306353
# anchor at all.
354+
#
355+
# The one exception to "loudly" is DECLARED, never inferred: a caller that
356+
# passed `anchor-optional: true` gets a `::notice::` and exit 0 on an empty
357+
# anchor, and this step publishes `configured` so the write below runs
358+
# exactly when delivery was asked for. ⛔ Do not re-derive emptiness in any
359+
# other step: a second spelling of it would drift, and the drift that
360+
# matters is a run that reports "no anchor" and then PATCHes issue 0.
361+
id: anchor
307362
if: github.event_name != 'pull_request'
308363
shell: bash
309364
working-directory: ${{ github.workspace }}
310365
env:
311366
ANCHOR_ISSUE: ${{ inputs.anchor-issue }}
367+
ANCHOR_OPTIONAL: ${{ steps.sources.outputs.anchor_optional }}
312368
run: |
369+
if [ -z "${ANCHOR_ISSUE//[[:space:]]/}" ] && [ "$ANCHOR_OPTIONAL" = true ]; then
370+
echo "configured=false" >> "$GITHUB_OUTPUT"
371+
echo "::notice::No anchor issue configured for ${{ github.repository }}, which is a supported configuration for this caller: it declares 'anchor-optional: true' on the half-state-patrol action. The sweep RAN and its rendered body is in this run's summary; nothing was written to any issue, and the missing anchor does not fail this run. To deliver findings to a pinned issue instead, open a \`tracking\`-labeled anchor issue in this repo and pass its number as the 'anchor-issue' input."
372+
exit 0
373+
fi
313374
if [ -z "${ANCHOR_ISSUE//[[:space:]]/}" ]; then
314375
echo "::error::No anchor issue configured for ${{ github.repository }}. The sweep RAN (see the run summary) but has nowhere to land. Open a \`tracking\`-labeled anchor issue in this repo and pass its number as the 'anchor-issue' input of the half-state-patrol action."
315376
exit 1
316377
fi
317378
case "$ANCHOR_ISSUE" in
318379
*[!0-9]*|'') echo "::error::The 'anchor-issue' input is '$ANCHOR_ISSUE', which is not an issue number."; exit 1 ;;
319380
esac
381+
echo "configured=true" >> "$GITHUB_OUTPUT"
320382
echo "anchor: #$ANCHOR_ISSUE in ${{ github.repository }}"
321383
322384
- name: Update the pinned anchor issue
323-
# A pull_request run proves the sweep; it must not touch the board.
324-
if: github.event_name != 'pull_request'
385+
# A pull_request run proves the sweep; it must not touch the board. And a
386+
# declared anchor-less install has no board write to attempt: the guard
387+
# reads the resolve step's OUTPUT rather than testing the input again.
388+
if: github.event_name != 'pull_request' && steps.anchor.outputs.configured == 'true'
325389
uses: actions/github-script@v9
326390
env:
327391
SWEEP_EXIT: ${{ steps.sweep.outputs.exit_code }}
@@ -401,6 +465,12 @@ runs:
401465
if [ "${{ github.event_name }}" = "pull_request" ]; then
402466
echo "_Anchor write skipped: a pull_request run proves the sweep without touching the board._"
403467
echo
468+
elif [ "${{ steps.anchor.outputs.configured }}" = "false" ]; then
469+
# ⛔ Only on the EXPLICIT not-configured reading. An empty anchor
470+
# without the opt-in, or a non-numeric one, sets no output and fails
471+
# the run, and must not be described here as "nothing was asked for".
472+
echo "_No anchor issue configured (\`anchor-optional\` is on and \`anchor-issue\` is empty): this summary is the ONLY delivery of the findings below — it notifies nobody, by accepted trade. Pass a \`tracking\` issue's number as \`anchor-issue\` to have them land on a pinned card instead._"
473+
echo
404474
fi
405475
echo '<details><summary>Rendered anchor body</summary>'
406476
echo

‎.github/workflows/half-state-patrol.yml‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,10 @@ name: Half-State Patrol
116116
#
117117
# plus one `tracking`-labeled anchor issue opened in that repo, whose number is
118118
# what `anchor-issue` carries. ⛔ A sha, never `@main`: `@main` is the drift this
119-
# change removes, re-entered from the other side.
119+
# change removes, re-entered from the other side. A board with no anchor BY
120+
# DECISION adds `anchor-optional: true` instead of opening one, and its findings
121+
# then reach run summaries only; the action's header says why that is an opt-in
122+
# (#20793), and this caller keeps the default.
120123
#
121124
# ⚠️ The paragraph this replaces was a LIST of files to copy, and it is worth
122125
# recording why a list is not the fix. It said TWO files until 2026-09-03 while
@@ -279,7 +282,8 @@ jobs:
279282
#
280283
# Resolution: the repository variable `HALF_STATE_ANCHOR_ISSUE` if
281284
# set, else this repo's own pinned number, else EMPTY — and empty
282-
# makes the action refuse to write rather than guess. The literal is
285+
# makes the action refuse to write rather than guess, because this
286+
# caller leaves `anchor-optional` at its loud default. The literal is
283287
# guarded by the repository name even though this file no longer
284288
# travels: the cheapest way to adopt the patrol is still to copy this
285289
# caller, and an unguarded fallback is what would let such a copy

0 commit comments

Comments
 (0)