Skip to content

fix(ci): derive a gate's population through composite actions, not just workflows - #19284

Merged
os-elon-musk merged 1 commit into
mainfrom
claude/issue-19229-derive-through-composite-actions
Sep 20, 2026
Merged

os-elon-musk merged 1 commit into
mainfrom
claude/issue-19229-derive-through-composite-actions

Conversation

@os-try-charles

@os-try-charles os-try-charles commented Sep 20, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #19229

Authored by Claude Code, session session_017ef78bLdybu3AffehKkhfk.

Six gates rooted their population at .github/workflows and none read .github/actions/**, so a command executed through a composite action was audited by nothing while every one of them printed a scope line that reads as coverage. The positive control is not vacuous: .github/actions/setup-pnpm/action.yml carries six run: steps today, and that file's own header named this gap as the reason it deliberately holds no setup-node step.

What the repair is

scripts/pm/dispatch-gates.mjs now follows uses: ./.github/actions/NAME out of a workflow and reads that action's runs: steps, so a command executed through a composite action is derived exactly as one executed inline.

  • Attribution never moves. The invocation is attributed to the CALLING workflow, because the caller is what CI schedules and what a paths: filter narrows. An action declares no on: block at all, so attributing to it would invent a schedule nobody wrote. The action file rides alongside as viaAction provenance a reader can open.
  • Recursive, cycle-safe. An action may uses: a sibling; a one-hop follow would re-open the same blind spot one level down.
  • A uses: ./… with no action file behind it is a refusal, not a skip. GitHub refuses to start such a job, so a derivation that dropped it quietly would describe a CI this repo does not have.
  • Only ./.github/actions/** is followed. A third-party action's steps are not in this tree. A local action outside that root is a MISSING lead and is refused deliberately: the module's declared inherited population has to stay exactly equal to the trees it really opens, and a follow that could open any directory a workflow names could not be declared at all.

The repair the card forbids was not taken: the four live-specimen CONTROL assertions on the sweeper family are untouched — no assertion was re-pointed at a different value-bearing family.

The other five gates — each judged from its source, not from the card's grep

gate verdict reading
scripts/check-node-version.mjs extended Its population is uses: actions/setup-node@ steps, and a composite action is a legal place to write one. The cost was already being paid in the tree rather than merely risked: setup-pnpm/action.yml and three workflows carry comments shaping the composition around this gate's blind spot. Both roots are read; both counts are printed.
scripts/check-workflow-step-name-quoting.mjs extended Its subject is # inside an unquoted - name: plain scalar. A composite action's steps carry - name: scalars parsed by the same YAML, in the same repo, under the same house style of writing issue numbers into step names. setup-pnpm/action.yml alone carried eight step names this gate could not see.
scripts/check-self-test-wired.mjs extended Its subject is "a script CI RUNS whose self-test CI must run too". A step in .github/actions/** is run by CI in the calling job exactly as an inline one is, so rooting the corpus at .github/workflows alone made a directory boundary into a coverage boundary — and every #4690 floor here fires on an EMPTY population, never on one that is complete-minus-one.
scripts/check-self-test-workflow-commands.mjs extended It consumes collectPopulation from the gate above and adds no walk of its own (its own-source pin forbids one). What it owns is the DECLARATION: it now declares and pins .github/actions beside .github/workflows, so the live coupling covers both roots instead of naming half the corpus to the dispatch derivation.
scripts/check-step-collectors.mjs UNJUDGED row traced, then extended The card recorded this row as UNJUDGED because it names neither path spelling. Traced: its root is assembled as join('.github', 'workflows') (line 210 on the filing tree), which is why a literal grep found zero — the population was workflows-only all along. It genuinely belongs: the runner writes a composite action's run: body to a file and executes bash -e on it, so the abort-on-first-failure masking is the same defect in the same shell. The judgement is shared and only the walk to the steps differs — stepGroups() reads jobs.JOB_ID.steps and runs.steps alike.

.github/actions/ absent is not a refusal in any of the five: a repo may legitimately hold no composite action. What keeps the second root from going quiet is a LIVE assertion in each gate's own --self-test (and, for check-node-version.mjs, which ships no self-test, the firing/dark control pair recorded below).

Firing and dark controls

Each extension has both: the hazard placed inside a composite action is flagged, and the SAME tree with the action file removed is green — which is what makes the first a reading about the second root rather than about the fixture.

  • check-workflow-step-name-quoting — battery 7, five cases: the # hazard inside action.yml is flagged and named by its own path; the same tree with no .github/actions/ is green AND not a refusal; a README.md beside an action is not an action.
  • check-self-test-wired — battery the composite action corpus, six cases: a --self-test run only inside an action counts as WIRED and the attribution names the action FILE; the same tree without it reports exactly one self-test-not-run finding.
  • check-step-collectors — the bare sequence inside a composite action is flagged as runs (composite); the same pair routed through a collector is green; the real root walk finds a NESTED action.yml and names its path.
  • check-node-version — no --self-test ships, so the pair was driven by hand in a throwaway git tree (recorded here, ⛔ no temporary file left in the repo):
FIRING  .github/actions/fixture/nested/action.yml pins node-version '20' against .nvmrc 22
        -> exit 1, "• .github/actions/fixture/nested/action.yml:8 -- pins Node 20, but .nvmrc says 22"
DARK    same tree, .github/actions removed
        -> exit 0, "OK (1 setup-node step(s) across 1 workflow(s) and 0 composite action(s))"
  • dispatch-gates — a fixture caller that invokes no check of its own derives two families only because the action's steps were read; with the reader answering null for that directory, zero families are derived and the absence is NAMED. Plus the live reading: the discovery pass really opens .github/actions/setup-pnpm/action.yml and really reads its six run: steps.

⚠️ A measured boundary, stated rather than implied

A script path that reaches its command through a step env: value is derived by NEITHER spelling — written inline in a workflow, or written in a composite action. The composite follow makes an action's step read exactly like an inline step, including where an inline step is already not derived. That is a different blind spot and it is pinned here so a green follow is not read as coverage of it.

This matters for the card's own beneficiary. Measured against PR #19225 at its head 68ca79ec9 (read-only; that PR was not touched):

uses found            .github/actions/half-state-patrol
runs: steps read      6
families via action   (none)

The action spells the sweeper as node "$SWEEPER" with SWEEPER: ${{ steps.sources.outputs.root }}/scripts/pm/check-half-states.mjs in the step's env:. The follow reaches the steps; the matcher cannot name a script whose path is an unresolvable expression. So the four pinned CONTROL assertions on that family are restored by this change only if the invocation is spelled so a reader can see it — that is #19225's own repair to make, and it is reported rather than taken here.

Tests

All readings taken on this branch at bc1662577.

The long battery, before and after. node scripts/pm/dispatch-gates.mjs --self-test, run detached with tail --pid and the exit code captured by redirect, never through a pipe:

BEFORE  origin/main e6a03e649   exit 0   1866 cases pass   613 s
AFTER   this branch bc1662577   exit 0   1883 cases pass   633 s      (+17 new cases)

Reverse verification — the new cases can fail. The composite follow was neutralised in the PRODUCTION path (an early return inside followCompositeActions), the mutation proved on disk before the reading was taken, and the file restored from HEAD afterwards:

on-disk proof   OS_ABLATION_19229 occurrences 0 -> 1
blob            HEAD=ee5794e17f01e0f64ef97d204aeaccbebed27b33  mutated=4ba2eb4b6d4e096dbad65212efc0372c55955c87
live reading    discoverFamilies().compositeActions  ->  []        (was ['.github/actions/setup-pnpm/action.yml'])
ablated run     exit 1 — 7 of 1883 case(s) failed
                  the command executed THROUGH a composite action is derived / the CALLING-workflow attribution
                  the absence is NAMED / the follow recurses / a cycle terminates
                  the live discovery really opens the tree / really reads the steps in it
restored        blob ee5794e17f01e0f64ef97d204aeaccbebed27b33 == HEAD, `git diff HEAD` empty

⛔ No temporary file is left in the repo: the ablation ran from a script outside it, carried a shell trap on EXIT, INT and TERM that restores the file, and restored with git checkout HEAD -- PATH (never a bare git checkout --, which restores from the index).

Derived gate families. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack over the real change set (10 paths, three-dot vs merge base e6a03e649) derived 51 families; all 51 were run with the exit code captured before any pipe, and reconciled back through --ran:

✓ dispatch-gates --ran: 51 derived famil(ies) accounted for — 51 run,
  0 NOT-MEASURED (a DERIVED zero — all 51 recorded an exit code and none of them is 3).

Every one exited 0, including all five extended gates and their self-tests, check:nul-bytes, check:watch-hint-literal, check:declared-population-live and check:pm-dispatch-gates.

pnpm lint is CI's repo-wide run, not this PR's.

Acceptance notes

  • .github/actions/setup-pnpm/action.yml and three workflows carried comments naming the old blind spot as a constraint. This diff is what makes them false, so they are corrected in it. ⛔ The separation itself is kept — the pins are already in place and moving them buys nothing — it is simply no longer forced.
  • PR ci(pm): make the half-state patrol callable instead of copied #19225's action.yml carries the same now-stale sentence about check-node-version.mjs. Not touched: that file belongs to an open PR.
  • skip-changeset: measured, not assumed — 70 published packages, zero files[] entries naming scripts/ or .github/, root package private: true.

Generated by Claude Code

…st workflows

Six gates rooted their population at `.github/workflows` and none read
`.github/actions/**`, so a command executed through a composite action was
audited by nothing while every one of them printed a scope line that reads as
coverage. The positive control is not vacuous:
`.github/actions/setup-pnpm/action.yml` already carries six `run:` steps.

- `scripts/pm/dispatch-gates.mjs` now follows `uses: ./.github/actions/NAME`
  out of a workflow and reads that action's `runs:` steps, recursively and
  cycle-safe, attributing the invocation to the CALLING workflow (which is what
  CI schedules) with the action file carried beside it as `viaAction`.
- The other five gates are judged from their own sources and extended where the
  population genuinely belongs: the Node pin census, the step-name quoting
  scan, the shared self-test population two gates consume, and the
  `bash -e` masking scan all read both roots now.
- Four comments in `.github/**` that named the old blind spot as a constraint
  are corrected, because this diff is what makes them false.

Co-Authored-By: Claude <noreply@anthropic.com>
@os-try-charles os-try-charles added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 20, 2026 — with Claude
@os-elon-musk
os-elon-musk marked this pull request as ready for review September 20, 2026 13:41
@os-elon-musk
os-elon-musk added this pull request to the merge queue Sep 20, 2026
Merged via the queue into main with commit c334ba0 Sep 20, 2026
56 checks passed
@os-elon-musk
os-elon-musk deleted the claude/issue-19229-derive-through-composite-actions branch September 20, 2026 14:18
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…n the caller (objectstack-ai#19718)

Fixes objectstack-ai#19396

Comment-only, in two files. The stated reason for keeping
`actions/setup-node` and the closed-card sweep in the CALLING workflow
was a census argument, and PR objectstack-ai#19284 made it false. Each bullet now
carries a constraint that was measured on this tree instead.

## The premise, re-measured on the base (`a251aaa19`)

All four censuses reach composite actions. Their own scope lines, exit
code captured before any pipe:

```
check-node-version                EXIT=0  OK (43 setup-node step(s) across 38 workflow(s) and 2 composite action(s), all on Node 22).
check-self-test-wired             EXIT=0  scope: 311 file(s) under scripts/, 243 carrying `--self-test` in code ...; 226 of those are run by 38 workflow(s) and 2 composite action(s); ...
check-workflow-step-name-quoting  EXIT=0  OK (scanned 38 workflow file(s) + 2 composite action file(s), 729 step name(s) -- 9 already quoted, 720 unquoted-and-safe).
check-step-collectors             EXIT=0  511 `run:` steps across 38 workflow(s) and 2 composite action(s); 5 step(s) run 2+ independent self-tests, all of them through a collector.
```

`premise_still_valid: true` — zero composite actions would have refuted
the card; both counts read 2.

Two further readings, so the repair rests on measurement rather than on
the card's guess:

- **`scripts/check-node-version.mjs` builds one list,
`[...workflowFiles, ...actionFiles]`, and its own header retires the
constraint by name:** *"With both roots read, the constraint is gone:
put the step wherever the composition wants it."*
- **The consequence the sweep bullet drew is false too, measured with
the gate's own exported reader.** Read-only probe, nothing in the tree
touched: the closed-card step's real bytes handed to
`collectInvocations` twice, once as a workflow and once as a composite
action file.

```
workflow          named=true selfTested=true  attribution=["half-state-patrol.yml"]
composite action  named=true selfTested=true  attribution=[".github/actions/half-state-patrol/action.yml"]
```

So a moved step stays in the population, under the composite action's
own name.

## The constraint that IS measurable, per step

**`actions/setup-node` — the action holds no Node pin, and a
`setup-node` step in it would need one.** The floor asserted in `Locate
the patrol sources` is read from the `.nvmrc` that shipped with the
action (`action.yml` lines 202-217, already in the file and still true).
`check-node-version.mjs` holds every `setup-node` pin in this repo —
workflows and composite actions alike — equal to that same `.nvmrc`. So
a `setup-node` step inside the composite would install the very version
the floor is then compared against: `have` and `floor` derived from one
file, equal by construction, the comparison unable to fail, and no
longer a reading about the CALLER's runner at all. That holds for a
sibling pinning the action too, since both the pin and the `.nvmrc`
travel at the same sha.

This is *not* the card's guess ("a sibling repository's Node pin has to
be the sibling's own"), and the guess is not measurable today: **no
sibling calls this action.** `objectui` carries its own
`.github/workflows/half-state-patrol.yml` that runs its own copy of the
sweeper, holds no `.github/actions/` directory at all, and names this
action nowhere. The only caller on either tree is this repo's own `uses:
./.github/actions/half-state-patrol`.

No gate requires the pin to be a literal in the caller:
`check-node-version` accepts `node-version-file: .nvmrc` as the ideal
form, fails an unresolvable expression, and cannot reach a sibling repo
at all.

**The closed-card sweep — the repair is a deletion, which is the card's
second exit.** A true reason already sat in both files, first in the
sentence: it is the one step of the old file that was never
repo-agnostic, it WRITES to cards under a ruling this repo's board took,
and no sibling has taken it. The false half (the `check-self-test-wired`
population argument) is deleted rather than rewritten, so what remains
is what still holds.

## Proof that this is comment-only

Parsed YAML compared against the base blob with the repo's own
`yaml@2.9.0`, plus a control leg that must differ so the comparison is
not one that cannot fail:

```
IDENTICAL  .github/actions/half-state-patrol/action.yml
   raw bytes  before=23411 after=23573 (delta 162)
   parsed JSON length before=10752 after=10752  sha-equal=true
IDENTICAL  .github/workflows/half-state-patrol.yml
   raw bytes  before=30837 after=30419 (delta -418)
   parsed JSON length before=5245 after=5245  sha-equal=true
CONTROL LEG (one real YAML byte changed, in memory): identical=false — must be false
```

And every changed line is a comment line:

```
changed lines: 56
NON-comment changed lines: (count: 0)
```

No step moved; no `run:`, `uses:`, `with:`, `if:` or `paths:` byte
changed.

**Line budget:** `action.yml` block 19 lines to 21 (+2); workflow
`setup-node` comment 5 to 5 (0); workflow sweep comment 9 to 3 (-6). Net
**-4** across the two files, against the suggested ceiling of +12 / -14.

## Gates

Derived with `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` (no paths; the script took the
change set from the merge base itself), then reconciled with `--ran`.
Exit codes captured before any pipe. Readings are quoted in the report
comment on objectstack-ai#19396; the union was re-run on the final commit.

`.github/**` ships in no package, so this carries `skip-changeset`: all
70 non-private packages declare an explicit `files[]` and not one entry
names `.github` or the repo root.

## Acceptance notes

- **`.github/actions/setup-pnpm` is NOT a stale sibling sentence** —
checked because the edited sentence cited it. It was already repaired
(lines 50-58): it records the blind spot as CLOSED and says the
separation is kept for no constraint at all. That difference is now
stated in `action.yml`, so a reader who follows the pointer does not
generalise setup-pnpm's retired reason to this action, which has a live
one.
- **`objectui` has not adopted this composite action, and its copy of
the sweeper diverges from the one the action ships** — 13,194 lines
against 36,717 at `objectui@0cf2d6644` and `a251aaa19`. That is the copy
drift this action exists to delete, one board along. Out of scope here
and reported for the filing seat rather than touched.

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

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

Labels

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

Projects

None yet

3 participants