Skip to content

ci(half-state-patrol): a declared anchor-optional input lets a board with no anchor issue run summary-only (default off) - #20812

Merged
objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-20798-patrol-anchor-optional
Sep 30, 2026
Merged

objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-20798-patrol-anchor-optional

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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

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:

=== 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:

=== 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 41dcf1188 is green (0 changesets added). check-changeset-no-major.mjs --base 41dcf1188 --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 (ci(half-state-patrol): run objectstack's composite action pinned to a sha instead of checking out objectstack main — objectui's half of objectstack-ai/objectstack#18471 (ruling 只做②) 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)

#!/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

The composite action gains one input, `anchor-optional`, default `false`.
Declared `true`, an empty `anchor-issue` gives a ::notice::, exit 0, and the
findings in the run summary only; the anchor PATCH is skipped through the
resolve step's `configured` output. Left at the default, the empty-anchor
::error:: and exit 1 are unchanged. A non-numeric anchor fails in both modes,
and a value other than true or false is refused on every event.

objectstack's own caller keeps the default; its prose names the input for
sibling installs.

Claude-Session: https://claude.ai/code/session_01TdiauJaVCHuj45EzZGUxHh
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet objectstack-fleet Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 30, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Seat review: domain:devx seat 2 (objectstack-ai/objectui#10917), head c35b098b1

Reviewed at 2026-09-30T08:11Z by session_01TdiauJaVCHuj45EzZGUxHh, against main at 5bed1f6ca. git diff --stat 41dcf1188 5bed1f6ca over both changed paths and scripts/pm/check-half-states.mjs is empty, so the dev's base reading still holds on today's tip.

Verdict: ACCEPT. This PR lands through the ordinary queue.

Independent readings (the seat's own runs, not the dev's)

  • The PR body's probe-anchor-modes.py was run against clean detached worktrees at c35b098b1 and 5bed1f6ca: exit 0, all 14 scenarios, and every verdict matches the body's table. on+empty and on+whitespace read configured=false, Update skipped, JOB success. Both non-numeric rows fail. pr+* skips both anchor steps. on+empty with sweep exit 3 fails.
  • Default mode against main: off+empty, off+non-numeric, off+number, pr+off+empty and off+empty with sweep exit 3 were compared with the outputs annotation, the UNSET header and the worktree path normalised. 66 lines each, diff empty.
  • Guard ablation: on a scratch copy, removing && steps.anchor.outputs.configured == 'true' (0 hits after, restored and git status clean) makes on+empty and on+whitespace read would PATCH issue #0. The guard is load-bearing.
  • Governed: check-governed-merges.mjs --pr objectstack-ai/objectstack#20812 reports 0 of 2 paths governed and 88 changed lines. No review-face path is touched (.github/** only), so no contract-review record is owed.

Ruling on the open question: A, keep the value check in "Locate the patrol sources". Decision A (5905998989) keeps the pull_request path unchanged for the configurations it names, and the probe shows it is for every valid value. Refusing a misspelt declaration on the PR that adds it follows the dispatch's never-widen rule. Otherwise a typo would first surface on a scheduled run as the no-anchor error, which names the wrong remedy. objectui#11174's switch PR is the first caller to pass the input, so its own pull_request run will read the check.

Seat writes: skip-changeset applied (read back ci/cd, size/s, skip-changeset), per the dev's no-publish proof (both paths belong only to the private root @objectstack/spec-monorepo). When CI is green on this head, the seat marks it ready and enqueues it.

Recorded, not acted on: the dev's pnpm install registered core.hooksPath=.githooks in the shared objectstack clone config. That is the repo's per-clone prepare step (AGENTS.md) and needs no revert.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 30, 2026 08:35
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 30, 2026
Merged via the queue into main with commit 7911485 Sep 30, 2026
32 of 33 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20798-patrol-anchor-optional branch September 30, 2026 09:04
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Oct 7, 2026
…a sha, with the no-anchor opt-in (objectui#11174) (objectstack-ai#11332)

Fixes objectstack-ai#11174
Clause-②: no. CI wiring only; no published contract or accept set moves.

objectui's half of objectstack-ai/objectstack#18471 (ruling 只做②). The
half-state patrol stops checking out objectstack `main` unpinned and
running the sweeper from it. It now calls objectstack's composite action
at a pinned 40-character sha, using the no-anchor opt-in that
objectstack-ai/objectstack#20793 (decision A) declared for this board.
Dispatched by `domain:devx` seat 2, session
`https://claude.ai/code/session_01TdiauJaVCHuj45EzZGUxHh`, claim comment
5924897388.

## What changed

- **`.github/workflows/half-state-patrol.yml`.** These steps are gone:
the objectstack checkout (no `ref:`, so `main`), the `node
objectstack-tooling/scripts/pm/check-half-states.mjs` step, the inline
resolve-anchor, update-anchor, summary and fail steps, and the
`ANCHOR_ISSUE` env.
- One step replaces them: `uses:
objectstack-ai/objectstack@0c5a71b`.
- It passes `github-token: secrets.GITHUB_TOKEN`, `anchor-issue: ''`,
`anchor-optional: true` and `closed-floor: '2026-08-28'`.
- `actions/checkout` and `actions/setup-node` (node `'22'`) stay in the
caller. The action needs both: the checkout is the swept subject, and
the action has no Node pin of its own.
- **Unchanged:** the trigger, schedule, permissions, concurrency, job
key and job name. The job name is "Live half-state sweep", the check-run
name that `scripts/dependabot-merge-gate.mjs` classifies. To confirm,
both versions were parsed with `yaml` and `on`, `permissions`,
`concurrency`, `jobs.patrol.name`, `runs-on` and `timeout-minutes` all
compare SAME.
- The prose adopted from upstream is deleted. The header now holds only
what belongs to this board: the pin and how to bump it, the no-anchor
decision (objectui#8740), and the closed-card floor (objectui#5985). The
file shrinks to about a third of its old length.
- **`content/docs/guide/ci-cd-pipeline.md`, Half-State Patrol section.**
It now describes the action call, the pin and how to bump it, the four
inputs, the no-anchor configuration and its accepted cost, the PR-run
blind spot, the floor, and a hand-run recipe. The recipe gains
`PM_SWEEP_CLOSED_FLOOR=2026-08-28` and a note to check out the pinned
sha to reproduce a run.
- The Trigger line and the inventory row are unchanged; both are still
true.
- **`scripts/__tests__/ci-cd-pipeline-doc.test.ts`.**
- The objectui#8043 block compared `PM_SWEEP_*` env keys, and the
workflow no longer sets any. It now compares the section against the
patrol step's *parsed* `with:` inputs (via `yaml`), in both directions.
It also holds the floor value, the anchor configuration (empty
`anchor-issue` + `anchor-optional: true`), and the 40-hex shape of the
ref.
- One `SWEEP_DECLARED_NON_RUN_COMMANDS` entry is added, for
`half-state-patrol.yml: scripts/pm/check-half-states.mjs`. The section
names objectstack's sweeper, and the job now has no `run:` step at all.
This is the same dual-eligible shape as the existing `check-links.yml`
entry.
- **`scripts/check-action-ref-convention.mjs` and its test (see the
surface note below).**
- The sha spelling is off-convention for this repository's Action Ref
Convention gate, so it gets a `DECLARED_EXCEPTIONS` entry with its
reason and issue.
- That gate's two non-vacuity cases used to *replace* the real table
with a synthetic one. They now *append* the synthetic entry to it, so
they hold whatever the real table carries.
- The table's docblock no longer says "deliberately empty". It now says
that an entry is matched by workflow and action, not by spelling, and
names where this entry's "sha, never a branch" half is held.

## The pin

- **Sha:** `0c5a71b0942d54e56ad365f0b2a173fd84c1c5e1`. This is
objectstack `origin/main`'s tip, read at **2026-10-01T04:46:15Z** with
`git -C /home/user/objectstack fetch origin main` and then `git
rev-parse origin/main`. Its commit date is 2026-10-01T03:32:30Z
(objectstack-ai/objectstack#21026).
- **Ancestry:** `git merge-base --is-ancestor
79114850f0f559dcb4fb432f5bcfc12947a03de6
0c5a71b0942d54e56ad365f0b2a173fd84c1c5e1` gives **exit 0**. An "is an
ancestor" answer proves itself even on the shallow objectstack clone, so
no control leg is owed. `git rev-list --count` between them gives 127
commits.
- **Not the action's own last commit.** The pin is `main`'s tip, as the
Q2 ruling requires. At the pin, both `action.yml` (blob `2df36034`) and
`scripts/pm/check-half-states.mjs` (blob `fa35f635`) are byte-identical
to what 79114850f landed. So today the two choices run the same code.
The tip rule is what keeps that true on the next bump.
- **Input names read off `action.yml` at the pin:**
- declared: `anchor-issue`, `anchor-optional`, `closed-floor`,
`github-token`;
  - passed: the same four;
  - passed but undeclared: none; required but not passed: none.
- **Ruling sources:** the Q2 ruling A is comment 5905360637, the
handover is 5908038257, and `anchor-optional` comes from
objectstack-ai/objectstack#20812.

## Acceptance evidence: what this PR's own run can and cannot show

- This PR's `pull_request` run of **Half-State Patrol** exercises the
pin, the inputs and the transport on a real runner. The action's `Locate
the patrol sources` step judges `github-token` and the `anchor-optional`
spelling on every event, `pull_request` included, and the sweep runs.
- **But it skips both anchor steps, so it cannot show the opt-in.** The
live reading is the **first scheduled run (cron `37 1,7,13,19 * * *`) or
`workflow_dispatch` run after this lands**. The expected result:
- `Resolve the anchor issue` writes `configured=false` and emits the
`::notice::` naming `anchor-optional: true`;
  - `Update the pinned anchor issue` is skipped;
  - the run is green.

## Tests

Every check below was run on head `ec0c6e49`, except where a line names
`a13f4e5f`. That earlier head differs only by the header-comment edit in
the second commit.

- **actionlint 1.7.7** (release binary; actionlint is not wired in this
repository): `actionlint -shellcheck= -pyflakes=
.github/workflows/half-state-patrol.yml` gives exit 0.
- Positive control: the same file with `permissions:` misspelled gives
exit 1, `unexpected key "permissionz"`.
- ⚠️ actionlint cannot check a remote composite action's inputs. That
check is the `action.yml` read under The pin.
- **`vitest run scripts/__tests__/`** (through `os-verify-lock`,
`--maxWorkers=2`) on **`ec0c6e49`**: 177 files passed and 2 skipped
(179); 5374 tests passed and 2 skipped (5376); `VERDICT command-exit 0`.
The same result on `a13f4e5f`.
- **Targeted:** `pnpm exec vitest run
scripts/__tests__/ci-cd-pipeline-doc.test.ts
scripts/__tests__/check-action-ref-convention.test.ts` gives 2 files and
104 tests passed. On the base it gave 101.
- **`tsc -p tsconfig.scripts.json --listFiles`** on `a13f4e5f` gives
exit 0, and both edited test files are in the program. `ec0c6e49`
changes only the workflow YAML, which is outside that program and
outside the lint population below.
- **Gates, all exit 0:**
- `pnpm check:doc-fences`, `pnpm docs:check-links`, `pnpm
check:control-bytes`, `pnpm check:action-ref-convention`, `pnpm
check:new-line-citations` (0 new citations), `pnpm
check:test-path-roots` and `node
scripts/check-shell-escape-residue.mjs`;
- `node scripts/check-changeset-presence.mjs`: "no changeset is owed".
Nothing under a released package's source or contract moves, so no
changeset is added and no label is applied;
- `node scripts/check-governed-queue-guard.mjs --test` over the five
paths: NOT GOVERNED.
- **ESLint, narrowed to this diff:** `eslint --format json` over the
three lintable files reports 3 files, 0 errors and 0 warnings. The YAML
and the Markdown are not in the lint population.
- The population is `lint:root`, which is `eslint .` minus `packages/`,
`examples/`, `apps/` and `docs/`. The three files sit inside it.
  - The narrowing excludes nothing that could move:
- `eslint.config.js` has no `parserOptions` or `projectService`, so the
linting is not type-aware;
- `eslint-rules/*.js` read no files
(`readFileSync`/`existsSync`/`readdirSync` give zero hits);
    - so no untouched file's verdict can change.

### Ablations (one-shot; nothing kept)

Each ablation ran through objectstack's `scripts/ablation-replace.mjs`
in WRAP mode on committed head `a13f4e5f`. In each, the anchor hit once,
the blob changed, and the file was restored with blob == HEAD and `git
diff HEAD` empty.

1. `@0c5a71b0…` changed to `@main`: exactly 1 red, "pins the action to a
40-character sha … never a branch".
`check-action-ref-convention.test.ts` stayed **green**, which is the
hole that pin covers: the exception matches by workflow and action, not
by spelling.
2. `anchor-issue: ''` changed to `'9857'`: exactly 1 red, "describes the
anchor configuration the step actually passes".
3. `closed-floor` deleted: 2 red, "names only inputs the workflow
actually passes" and "quotes the closure floor".
4. The doc's `github-token` bullet stripped of its name: 1 red, "names
every input the workflow passes".
5. The gate without the new entry, run as `scan(root, { exceptions: []
})` with no file mutated: 1 offender, `half-state-patrol.yml … [sha]`.

## Surface note for the seat

The claim's surface lists the workflow, the doc section,
`ci-cd-pipeline-doc.test.ts` and "any other `git grep half-state-patrol`
hit whose assertion moves". This PR also edits
**`scripts/check-action-ref-convention.mjs`** and
**`scripts/__tests__/check-action-ref-convention.test.ts`**.

- Neither file was a grep hit before this change. Both read this
workflow's text, and their verdict moves with it, so they fall under the
original claim's definition of the surface (5905159945: "the tests or
scripts that read this workflow's text").
- The ruling's sha pin is off-convention for the blocking Action Ref
Convention gate, and that gate admits exactly one form of exception: a
`DECLARED_EXCEPTIONS` entry.
- No other workflow and nothing in objectstack is touched. The seat may
want to amend the claim's surface to match.

## Acceptance notes (noted, not filed)

- **Prose drift in workflows this claim excludes:**
- `merge-queue-head-patrol.yml`'s header still says
`half-state-patrol.yml` "fails when its anchor variable is unset". That
has been false since objectui#8740. Its env comment cites "the reason
`half-state-patrol.yml` states"; the new header keeps that sentence, so
the citation still resolves.
- `changeset-guard.yml`, `performance-budget.yml`, `lockfile-dedupe.yml`
and the docblock of `render-budget-comment.test.ts` say this workflow
lists `scripts/invoked-as.mjs` in its paths. That has been false since
objectui#10208, not because of this PR.
  - Carrier: none.
- **Dependabot.** Its `github-actions` ecosystem runs monthly here.
Whether it proposes bumps for a sha-pinned ref to a non-tagged action
path inside a monorepo is NOT MEASURED. If it does, the bump arrives as
a PR touching this file, which runs the patrol, so it is a reviewed
moment in any case.

---

_Generated by [Claude
Code](https://claude.ai/code/session_01TdiauJaVCHuj45EzZGUxHh)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: 黄怡 <huangyi@hotoa.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd size/s skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants