Skip to content

🛡️ Hold every publishable manifest to one version before the tag - #845

Merged
taras merged 2 commits into
mainfrom
agent/publish-version-lockstep
Sep 22, 2026
Merged

taras merged 2 commits into
mainfrom
agent/publish-version-lockstep

Conversation

@taras

@taras taras commented Sep 22, 2026

Copy link
Copy Markdown
Owner

Why

v0.13.0 published five binaries and zero packages. The 0.13.0 release branch
was bumped before #831 added packages/git and merged after it, so git stayed
at 0.12.1 while every sibling reached 0.13.0.

The two tag-time gates then disagreed about that. publish-packages.yml reads
all eleven manifests, found git, and refused. release.yml reads
packages/cli/deno.json alone, matched the tag, and published the half that
cannot be taken back. Both gates also run after the tag is pushed, which is
after the drift has already been merged.

What changes

Before:

  • release.yml validates the tag against packages/cli/deno.json.
  • Nothing checks version lockstep on a pull request.
  • packages/git declares 0.12.1; bun.lock has no entry for it at all.

After:

  • release.yml validates the tag against every publishable manifest, walked
    from packages/*.
  • scripts/lib/version-lockstep.ts answers the same question on every pull
    request, against this repository.
  • packages/git declares 0.13.0 and bun.lock records it.

How it works

root deno.json workspace globs → listWorkspacePaths → each packages/*/package.json
  → @executablemd and not private → both manifests' versions + bun.lock entry
  → findings[]

Membership comes from the workspace, never a list. publishableMembers reuses
listWorkspacePaths (scripts/lib/workspace.ts) — the same walk bumpManifests
uses — so what the bump stamps and what the check reads cannot come apart.
Identity and exclusion both come from package.json, because a private member
omits deno.json's name and exports, which leaves package.json as the
only manifest every member fills in.

release.yml's preflight performs the same walk in shell rather than importing
the module: it runs before deno task deps, on a checkout with nothing
installed.

Review guide

Start with: scripts/lib/version-lockstep.ts

Then review:

  1. versionLockstepFindings — the invariant, as the messages it emits
  2. .github/workflows/release.yml — the same walk, in shell, at tag time
  3. scripts/lib/bun-lockfile.ts — why bun.lock needs a parse of its own
  4. specs/release-process-spec.md §2 and §3

Look carefully at:

  • The lockfile comparison is skipped while the manifests disagree, so one
    behind-the-times member does not also report eleven lockfile mismatches.

What must stay true

  • Every non-private @executablemd member declares one version across both its
    manifests — enforced by versionLockstepFindings, checked by
    scripts/tests/version-lockstep.test.ts.
  • Both tag-time gates refuse the same set — enforced by walking packages/* in
    release.yml, checked by the tag-time version gates suite in
    scripts/tests/publish-workflow-membership.test.ts.

How to verify it

  • holds this workspace in lockstep proves the real tree is consistent and
    fails if any member drifts. Reverting packages/git to 0.12.1 makes it
    report the workspace declares more than one version: 0.12.1 (packages/git/…)
    — the exact v0.13.0 defect.
  • reports a lockfile entry left at the previous release fails if bun.lock
    restamping is skipped; setting git's entry back to 0.12.1 reports
    bun.lock records packages/git at 0.12.1, not 0.13.0.
  • reaches the same set by walking the workspace before publishing binaries
    fails if release.yml narrows again; replacing the glob with
    packages/cli/package.json makes it fail.
  • keeps a comma that closes nothing because it is inside a string fails if
    the lockfile parse stops being string-aware.
  • The preflight loop itself, run against this tree: TAG=v0.13.0 exits 0;
    TAG=v0.14.0 exits 1; and with git back at 0.12.1, TAG=v0.13.0 exits 1
    naming both of git's manifests.

Run: deno test --allow-all --frozen scripts/tests/version-lockstep.test.ts scripts/tests/publish-workflow-membership.test.ts — 5 passed (21 steps).
Also green under Node (tsx --test, 11 pass) and Bun (bun test, 11 pass),
plus deno task lint, deno task check, and deno task gen:publish-workflow
leaving no diff.

Scope

Included

  • The PR-time lockstep check and its suite.
  • release.yml's preflight widened to every publishable manifest.
  • packages/git brought to 0.13.0, with its bun.lock entry — the check
    guards a workspace it passes.
  • Release spec §2 and §3 brought back into line (AGENTS.md code rule 8).

Intentionally unchanged

  • publish-packages.yml keeps its generated, explicit manifest list. It is
    already correct, CI already fails when it goes stale against the manifests,
    and the new suite holds it to the full member set.
  • The npm propagation race that failed cli on 0.12.0 and 0.12.1 is Build tagged npm packages without waiting for sibling registry propagation #843, not
    this PR. @executablemd/cli stays at 0.11.0 on npm until that lands or the
    job is re-run per spec §7.
  • No release is cut here. Bumping to 0.13.1 is the next PR.

New abstractions

  • publishableMembers / versionLockstepFindings (scripts/lib/version-lockstep.ts)
    exist because three places need one answer to "who is in the release, and at
    what version": the new suite, and the prose in §2 that both tag gates
    implement.
  • parseBunLockfile (scripts/lib/bun-lockfile.ts) exists because bun.lock
    is the only file here that JSON.parse refuses, and nothing else in the
    repository reads it.
  • Each new abstraction has multiple concrete uses or a clear justification.
  • No speculative functionality is included.

Generated or mechanical changes

  • packages/git/{deno.json,package.json}: version field only.
  • bun.lock: four insertions recording packages/git — the root
    devDependency, the cli dependency, the member entry, and the resolution line.
    Hand-written in the shape the 0.13.0 release commit used, not regenerated.

Risks and limitations

  • bun.lock is stale well beyond packages/git. bun install --lockfile-only also wants to add semver, @types/semver,
    @agentclientprotocol/sdk to acp, the whole dependency block for
    code-review-agent, cli's devDependencies and test-support's, and to move
    @effectionx/node from 0.2.4 to the 0.2.5 the root package.json pins.
    I did not take that: it is a dependency change, which AGENTS.md makes an
    explicit act, and it does not belong in a gates PR. The new check only reads
    workspace member versions, which is all it claims — it would not have
    caught any of the above, and does not now. Worth its own issue.
  • The lockstep check catches a stale release branch only when it runs on the
    merge result. On an ordinary PR it proves the branch, not the merge — so
    "Require branches to be up to date before merging" (or a merge queue) is what
    closes the v0.13.0 case exactly. Without it the drift is still caught on
    main, one commit later, and still long before a tag.

Scope confirmation

  • Every changed file supports the purpose described above.
  • Unrelated cleanup and formatting changes are excluded.
  • Generated or mechanical changes are clearly identified.
  • The description matches the final diff and test results.

v0.13.0 published five binaries and not one package. The release branch had
been bumped before `packages/git` existed and merged after it, so git stayed at
0.12.1 while every sibling moved to 0.13.0 — and the two tag-time gates
disagreed about that. `publish-packages.yml` reads all eleven manifests and
refused. `release.yml` read `packages/cli/deno.json` alone, matched, and
published the irreversible half.

Both gates now read the same set, and a third reads it before the tag exists.
`scripts/lib/version-lockstep.ts` walks the workspace and reports every
manifest that declares a different version from its siblings, and every
`bun.lock` workspace entry gone stale or missing; its suite runs it against
this repository, which is where the answer has to be true. `release.yml`'s
preflight walks `packages/*` instead of naming one manifest, so a package added
later joins the gate by existing.

`packages/git` reaches 0.13.0 here, with the `bun.lock` entry it never had, so
the workspace the new check guards is one it passes.
Review found the correction incomplete: the new check and the widened preflight
read membership from `package.json`'s name, while the publish generator, the
npm builder and `bumpManifests` read it from `deno.json`'s. A member the two
manifests name differently therefore publishes packages while both gates ignore
it — the same partial release the PR set out to prevent, reached another way.

`scripts/lib/publishable-members.ts` now holds that rule once: identity from
`deno.json`'s name, exclusion from `package.json`'s `private`, both manifests
required. The lockstep check, `release.yml`'s preflight and the two membership
assertions in the workflow suite all read it.

The repository's own manifests agree about every name, so nothing in the tree
can tell the two rules apart. `publishable-membership-agreement.test.ts` builds
workspaces where they disagree on purpose and runs all three selectors over
them — `publishableMembers`, the preflight's own shell lifted out of
`release.yml`, and the real generator document over the fixture, since an eval
block's selection rule cannot be imported (#237). Reverting either selector to
`package.json` makes it fail.
@taras
taras merged commit 101cf25 into main Sep 22, 2026
43 of 44 checks passed
@taras
taras deleted the agent/publish-version-lockstep branch September 22, 2026 09:03
@taras taras mentioned this pull request Sep 22, 2026
4 tasks done
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant