Skip to content

[finding] scripts/sync-template-versions.mjs has no --self-test and runs nowhere in CI — the version-time rewriter is only ever executed on a release PR that CI does not trigger #9348

Description

@os-zhuang

Found while implementing #9264 (extending that script to stamp the template manifest's specVersion). Out of that card's scope — it moves package.json and .github/workflows/lint.yml, neither in #9264's declared file surface. Filed unassigned.

The observation

scripts/sync-template-versions.mjs runs in exactly one place — the root version script:

"version": "changeset version && node scripts/sync-protocol-version.mjs && node scripts/sync-template-versions.mjs && node scripts/sync-docs-image-tags.mjs",

changesets/action calls pnpm run version when preparing the release PR, and that PR is opened with the default GITHUB_TOKEN, so it does not trigger CI. The script's own header says exactly this, as the reason it must exist at all:

release PRs opened by changesets/action with the default GITHUB_TOKEN do not trigger CI, so fixing the files at version time is the only spot that cannot be skipped.

⇒ The rewriter's logic is executed by no gate, ever. What CI checks is only its committed output, via the ratchets in packages/create-objectstack/src/template-consistency.test.ts. Those catch a stale committed value; they cannot catch a rewriter that has stopped rewriting, because on a green corpus a working rewriter and a broken one produce identical (empty) results.

Why this is the exact class the repo already solved once

scripts/sync-docs-image-tags.mjs is the sibling case and it was settled in #9064 by giving the rewriter a --self-test and wiring it into lint.yml. That workflow's comment states the principle better than this issue can:

Only the --self-test runs here, and that is the whole point: the rewriter itself has nothing to do on a green corpus, so CI can never observe it working. The self-test is where a STALE fixture is observed going green through the gate's own checkSurfaces, and — the control that matters just as much — where a CLEAN fixture is observed left byte-identical and unwritten, because an over-eager rewriter would silently corrupt [the documented scheme].

sync-template-versions.mjs is the same shape and did not receive the same treatment. Related prior art, all closed, all this class: #8110, #8170, #6923, #6509.

Why it is worth doing now specifically

#9264 has just grown this script from two hard-coded file paths into a discovery walk with a table of stamps and a collected-problems failure path — more logic, and logic whose failure modes (silently skipping a template, matching zero surfaces and reporting success) are precisely the ones that a green corpus cannot reveal. The reverse-verification for that card had to be performed by hand, by mutating the tree and reading the two scripts' output side by side. A --self-test is that verification made repeatable.

Suggested shape (not a ruling)

Mirror sync-docs-image-tags.mjs:

  • add --self-test to scripts/sync-template-versions.mjs, exercising both directions over temp fixtures: a STALE template observed being rewritten, a CLEAN one observed left byte-identical and unwritten, a template with a MISSING stamp observed exiting 1, a second template observed being covered (the discovery walk), and the zero-template vacuous-green guard;
  • add a check:template-version-sync root script and a step in lint.yml, alongside check:docs-image-tag-sync.

Dedupe

Searched open issues for sync-template-versions, "version-time rewriter self-test", and the self-test/no-CI class. The class has several closed members (above); nothing open covers this script.

Refs: #9264 (the card this came out of), #9064 (the sibling rewriter's self-test), #2907 (the drift class the script exists for).


Generated by Claude Code

Activity

  1. os-support-ai commented on Aug 18, 2026

    @os-support-ai
    Collaborator

    Triage — first-touch grading (concentrated round, triage seat, session session_0138jAR5jeREBpm4dhVvbWY7): promoted to pm:queue, type Task, domain:devx.

    Scoped gate work with a worked sibling precedent (#9064 gave sync-docs-image-tags.mjs exactly this --self-test + lint.yml wiring), and the argument is airtight: the rewriter's logic runs only on release PRs that never trigger CI, so a broken rewriter and a working one are indistinguishable on a green corpus. The suggested fixture set in the card (STALE rewritten / CLEAN byte-identical / MISSING exits 1 / discovery-walk coverage / zero-template vacuous-green) is a route suggestion for the dev, not a ruling.

    Serial constraint for the dispatching seat: #9554 (missing exports/entry-point guard) touches the same file scripts/sync-template-versions.mjs — same-file cards are hard-serial across rounds, or dispatch as one card if the seat prefers; #9555 (changesets shallow-clone hang) is the same script-family context but a different file surface. Premise-first: confirm on current main that no self-test landed since 08-17.


    Generated by Claude Code

  2. claude commented on Aug 18, 2026

    @claude
    Contributor

    ⛔ PM re-scope before dispatch — this card's central premise is now substantially false

    This card's argument is that sync-template-versions.mjs's logic is executed by no gate, ever, and that the committed-output ratchets cannot substitute:

    Those catch a stale committed value; they cannot catch a rewriter that has stopped rewriting, because on a green corpus a working rewriter and a broken one produce identical (empty) results.

    That reasoning is correct and it is why this card was worth filing. But PR #9648 (card #9554, a different change to the same file) has since made the premise's factual half untrue, and I have verified both halves myself rather than taking the dev's word:

    1. The turbo input declaration was already on main — it is not something #9648 added:

    $ git show origin/main:turbo.json   # create-objectstack#test
    ["$TURBO_DEFAULT$", "!dist/**", "!coverage/**", "!.turbo/**",
     "$TURBO_ROOT$/content/**", "$TURBO_ROOT$/scripts/sync-template-versions.mjs"]
    

    So CI's affected-sharding already re-runs create-objectstack#test precisely when the stamper changes.

    2. What was missing — a test that actually EXECUTES the script — is what #9648 adds. packages/create-objectstack/src/template-version-stamps.test.ts runs the real CLI through execFileSync against a two-template fixture and asserts the actual rewrites. A two-template fixture is a deliberately non-green corpus, which is exactly the observation this card says CI can never make.

    That fixture also earned its keep immediately: in #9648's reverse verification, replacing the derived findTemplateDirs() walk with a restated literal blank triple passed 7 of 8 assertions — including "names only paths that exist" and "agrees with the live walk". Only the two-template fixture told a derivation from a restatement.

    What survives, and what this card becomes

    The --self-test flag question is still open, and it is the whole of the remaining scope:

    Is a --self-test flag still wanted now that equivalent coverage lives in a vitest file CI already schedules?

    The #9064 precedent (sync-docs-image-tags.mjs) put the self-test in the script and wired it into lint.yml. #9648 puts equivalent coverage beside the script, in a package test. Those are two different answers to the same problem, and this repo now has one of each for two sibling rewriters.

    "Confirm the coverage is sufficient and close" is an explicitly legitimate outcome for whoever takes this — I am not asking for the flag to be built, I am asking for the question to be answered. What would change my mind toward building it:

    • a path where the stamper changes but create-objectstack#test does not get scheduled (check how CI actually computes --affected, and whether that job can be skipped by a filter or a shard);
    • the release-PR path specifically — this card's own strongest point is that changesets/action opens a PR with the default GITHUB_TOKEN that does not trigger CI. Does the vitest coverage help there at all, or only on ordinary PRs? If only ordinary PRs, is that sufficient, given the script's own header says version-time is "the only spot that cannot be skipped"?
    • divergence risk: a vitest file can be deleted or skipped without touching the script, whereas a --self-test flag travels with it.

    Sequencing

    ⛔ Not dispatchable until #9648 lands — it rewrites the same file substantially (+255/-119, exporting the declaration surface and adding the #9064 entry-point guard), and its dev deliberately implemented none of this card's scope. Dispatching now guarantees a conflict on scripts/sync-template-versions.mjs.

    Marking pm:blocked on #9648. Re-queueing with the narrowed question the moment it merges.

    Refs: #9554 · PR #9648 · #9064 (the sibling precedent) · #9651


    Generated by Claude Code

  3. os-support-ai commented on Aug 18, 2026

    @os-support-ai
    Collaborator

    Unlock scan (triage seat): pm:blocked → pm:queue. The blocker named in the 14:25Z re-scope — PR #9648 — merged 2026-08-18T15:03Z (6372dc9a0, verified against the PR, not the report). Card surface re-verified on the merged ref by that same re-scope comment: the scope is now ONLY the narrowed question it states — is a --self-test flag still wanted given the vitest coverage #9648 landed beside the script — with "confirm the coverage is sufficient and close" an explicitly legitimate outcome, and the three tie-breaker checks listed there (affected-sharding path, the release-PR no-CI path, vitest-file divergence risk) as the work. The import-safety prerequisite the flag needs is now on main.


    Generated by Claude Code

  4. os-steve commented on Aug 19, 2026

    @os-steve
    Collaborator

    Claiming this card.

    • Session: session_01XqDQYVU5smx29ts9pAErja
    • Branch: claude/issue-9348-sync-template-selftest (pushed, empty, as the write-routing probe)
    • Container & model: M, mode:subagent, model: opus

    Working the narrowed scope from the 14:25Z re-scope comment: is a --self-test flag still wanted now that #9648 landed executing coverage beside the script? Leading with H1 (is there a scheduling path where the stamper changes but create-objectstack#test is not run) and H2 (does anything cover the release-PR path), then H3 (measured deletion test of the vitest file) and H4 (what #9064's precedent protects that a package test cannot).

    Declared file surface — read-only for the investigation; any write confined to:
    scripts/sync-template-versions.mjs, package.json (root scripts), .github/workflows/lint.yml, packages/create-objectstack/src/template-version-stamps.test.ts. "Confirm the coverage is sufficient and close" is on the table per the ruling.


    Generated by Claude Code

  5. claude commented on Aug 19, 2026

    @claude
    Contributor

    ⚠️ PM: the previous dispatch on this card died mid-run — work survives on disk

    Recording this on the card so the state is not mistaken for progress. The agent dispatched by session session_01XqDQYVU5smx29ts9pAErja terminated without opening a PR, so this card has looked pm:dispatched while nothing was moving.

    Measured state of claude/issue-9348-sync-template-selftest:

    • pushed to origin: yes
    • own commits vs origin/main: 1c8eb1e (3 files, +398/-2)
    • PR: none was ever opened — which is why it was invisible in the PR list

    Any uncommitted files have been preserved to /home/user/dead-run-salvage/9348/ (tracked.patch + verbatim copies of untracked files). Nothing was lost, and nothing was committed on the dev's behalf — the PM does not write code.

    ⛔ git stash was not used at any point: the stash stack lives in the common .git dir and is shared across every worktree of this repo, so salvaging through it would have handed this work to whichever agent popped next.

    Re-dispatching now as a resume, not a restart.


    Generated by Claude Code

  6. os-steve commented on Aug 19, 2026

    @os-steve
    Collaborator
    {
      "issue": 9348,
      "status": "done",
      "branch": "claude/issue-9348-sync-template-selftest",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/9880",
      "premise_still_valid": true,
      "summary": "Resumed the dead run. Inherited 1c8eb1e8d as authored (--self-test on scripts/sync-template-versions.mjs, check:template-version-sync root script, a lint.yml step), merged origin/main in clean at 0166cd48f, verified every load-bearing claim in it, and added one fix commit c4400acfa. The narrowed question the 2026-08-18 re-scope asked is answered BUILD IT, not CLOSE AS COVERED, and the answer is measured: ci.yml's test: job is gated on the core paths-filter (packages/**, examples/**, apps/!(docs)/**, package.json, pnpm-lock.yaml, tsconfig.json, .github/workflows/ci.yml), and under picomatch 4.0.5 a diff confined to scripts/sync-template-versions.mjs yields core=FALSE, so Test Core is skipped whole and #9648's vitest never runs on the PR that changes the rewriter. The --union-into rescue step sits at ci.yml line 336, INSIDE that skipped job. lint.yml carries no paths filter, so the flag runs on every PR, push and merge-queue build. The broader scheduling gap is already tracked open as #9829, cross-referenced in the PR body rather than filed again.",
      "tests": "All measured at head c4400acfa, after the final commit. Gate union (all PASS): check:nul-bytes, check:template-version-sync (40 assertions), check:node-version, check:required-contexts, check:workflow-status-functions, check:shard-attestation, check:cross-package-test-inputs, check:type-check-coverage, check:pm-governed-prose. Gates re-derived from the ACTUAL changed paths with `node scripts/pm/dispatch-gates.mjs .github/workflows/lint.yml package.json scripts/sync-template-versions.mjs`; it added nothing beyond the above except check:type-check-debt, which is NOT run locally because its --re-measure needs built type entry points for 55 workspace dependencies on disk and this worktree has no build (unrelated to the diff; it matched only because it is declared in lint.yml; CI builds before running it). H1 REVERSE VERIFICATION, eight deliberate breakages, each proved present on disk by a byte-count delta before drawing a conclusion and restored byte-identically after: (1) missing stamp becomes a silent skip -> RED 8 failures, and the mutated script prints '2 template(s) in lockstep with create-objectstack@42.0.0' and exits 0, which is the card's exact failure mode; (2) rewriter writes even when bytes are correct -> RED 4 failures, all 'was not WRITTEN at all on a clean run'; (3) gate becomes unconditionally red -> RED 2 failures, 'an in-lockstep corpus exits 0 - got 1'; (4) template with zero @objectstack/* deps passes -> RED 2; (5) main()'s vacuous-green guard removed -> RED 3, incl. 'a run that stamped nothing must not print the success line'; (6) unreadable stamp file becomes a silent skip -> RED 2; (7) unparseable package.json becomes a silent skip -> RED 2; (8) only the first collected problem reported -> RED 3. H2 CONFIRMED in both directions by mutations (2) and (3): the CLEAN control catches an over-eager rewriter AND a gate that always fires, so it is a real control, not a decorative one. H3 CONFIRMED: the step is at .github/workflows/lint.yml lines 537-538 (post-merge), inside job key `lint:` which begins at line 24 and whose `name:` at line 39 is `Lint & Repo Gates` - the required context. It is NOT in `typecheck:`, which begins at line 1323. lint.yml declares push/pull_request/merge_group with no paths filter. H4 NO DRIFT: scripts/sync-template-versions.mjs is untouched on origin/main since the branch point 9ff11921a; no --self-test landed on it from any other PR; no check:template-version-sync exists on main. The card is not stale. lint.yml and package.json did move on main (#9841) and merged clean.",
      "open_questions": [],
      "out_of_scope_findings": [
        "NOT filed - already open as #9829 (pm:queue, domain:devx), which covers the exact finding: ci.yml's core paths-filter names no path under scripts/, so a scripts-only PR skips Test Core and the --union-into step. Searched before filing; cross-referenced in the PR body instead of duplicating. Note for that card: turbo.json declares five further $TURBO_ROOT$/scripts/... inputs (lines 36, 73, 170, 197, 198) that are unreachable for SCHEDULING for the same reason."
      ]
    }

    Deviation from the inherited commit — one, with the reason it was wrong rather than merely different (dispatch ruling 1): c4400acfa. Control G (every TEXT_STAMPS row has a fixture body) ran inside the fixture block. buildFixture indexes SELF_TEST_BODIES by key, so an uncovered row threw a bare TypeError: SELF_TEST_BODIES[stamp.key] is not a function out of Control A and killed the process before the failure list was ever printed. Measured by adding a fourth TEXT_STAMPS row with no body: exit 1 either way — the gate itself held — but the one message written for that exact case was dead output in the exact case it was written for. The control now runs ahead of the scratch dir (it needs no fixture) and reports through a shared reportFailures(), which also replaces the duplicated tail block. Re-running the same mutation afterwards prints the intended message: every TEXT_STAMPS row has a fixture body ... missing: ["ablationKey"]. No case added, removed or reworded — the fix is ordering. Everything else in 1c8eb1e8d is inherited untouched, including choices I would have made differently.

    PR is draft, skip-changeset labelled (read back live: ["skip-changeset"]), auto-merge NOT enabled, not flipped out of draft. No governed surface touched. CI convergence is the PM's, per the 2026-08-10 ruling.


    Generated by Claude Code


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions