Skip to content

chore(release)!: adopt versionless-main release model - #1450

Merged
lavaman131 merged 3 commits into
mainfrom
release-model/versionless-main
Jun 21, 2026
Merged

chore(release)!: adopt versionless-main release model#1450
lavaman131 merged 3 commits into
mainfrom
release-model/versionless-main

Conversation

@lavaman131

@lavaman131 lavaman131 commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adopts a versionless `main` release model (inspired by openai/codex): all `packages/*/package.json` manifests on `main` permanently hold the `0.0.0` placeholder. The real version is materialized only on a throwaway, off-`main` `Release ` commit that is tagged but never merged back. Stable releases and ahead-of-stable prereleases can coexist on the same trunk with no branch gymnastics.

Also adds two new `publish-release` workflow inputs: optional `base_ref` (release from a maintenance/integration branch instead of `main`) and optional `from_ref` (ephemeral release-from-any-ref path — the workflow auto-creates the release branch, gates on its CI, publishes the tag, then deletes the branch; `main` is never touched).

Key Changes

`scripts/cut-release.ts` (new)

  • Validates a clean working tree and a non-placeholder, non-duplicate version
  • Creates a detached `git worktree` at the chosen base ref (default: `HEAD`)
  • Stamps the real version via `scripts/bump-version.ts` inside the worktree only — `main` is never touched
  • Commits `Release ` and creates a lightweight tag; removes the worktree after
  • `bun.lock` intentionally stays at `0.0.0` workspace placeholders — it is not shipped in the npm tarball and `bun install --frozen-lockfile` tolerates the mismatch

`packages/*/package.json`, `bun.lock`, Cargo manifests

  • All package manifests, `bun.lock` workspace entries, the `@bastani/atomic-natives` pin, `packages/natives/native/index.js`, and the Cargo manifests/lock reset to the `0.0.0` placeholder

`.github/workflows/publish.yml`

  • Guard added: refuses to publish if `packages/coding-agent/package.json` is at `0.0.0` or `0.0.0-dev`, preventing accidental placeholder releases

`.atomic/workflows/publish-release.ts`

  • Added optional `base_ref` input (default: `main`) — releases from a maintenance or integration branch instead of `main`
  • Added optional `from_ref` input — ephemeral release path: workflow auto-creates `release/` (or `prerelease/`) from the given ref, commits the CHANGELOG entry on it, gates on that branch's CI, cuts and publishes the tag, then deletes the branch; `main`/`base_ref` is never touched and the changelog lives only on the tag
  • Release-prep agent now opens a CHANGELOG-only PR to `main` — no version bumps, no manifest changes
  • `releaseChangedFileAllowed` now permits only `CHANGELOG.md` files (previously allowed `package.json`, `bun.lock`, `Cargo.*`, etc.)
  • `verifyReleasePreparation` now asserts manifests stay at `0.0.0` instead of matching the target version
  • Tag stage replaced with `cut-release-tag` step invoking `scripts/cut-release.ts --base <base_ref> --push --yes`
  • `verifyReleaseTagPublished` now checks: release commit's parent == verified base-ref HEAD, and `@bastani/atomic` manifest in the tagged tree carries the target version

Docs

  • `CLAUDE.md`: Updated `## Releasing` and `## Bumping Versions` (now `## Versionless main & bumping`) sections with the new flow and `cut-release.ts` usage examples
  • `DEV_SETUP.md`: Rewritten release workflow steps — CHANGELOG move on `main`, then `cut-release.ts --push`; no version-bump commit
  • `docs/ci.md`: Updated tag naming, replaced `## Version Bump` with `## Cutting a release (versionless main)`, and removed version-bump commit steps from the release checklist

Breaking Changes / Migration Notes

  • `main` no longer carries a real version — local builds and `atomic --version` from `main` will report `0.0.0`. Expected; only visible to developers building from source.
  • Do not run `scripts/bump-version.ts` directly on `main` — use `scripts/cut-release.ts` to cut releases and `scripts/bump-version.ts 0.0.0` only to restore the placeholder if it drifts.
  • `publish-release` workflow no longer bumps versions or creates a version-bump commit; the release-notes PR touches only changelogs.

Verification

  • `bun run typecheck` clean
  • 2508 unit tests pass (`bun run test:unit`)
  • prek hooks pass
  • `cut-release.ts` validated end-to-end with a throwaway tag: parent == `main` HEAD, branch untouched, tagged tree carries the real version everywhere, `main` stays `0.0.0`

- main carries the 0.0.0 placeholder across all packages/*/package.json,
  bun.lock, the @bastani/atomic-natives pin, native/index.js, and Cargo manifests/lock
- add scripts/cut-release.ts: stamps the real version onto a throwaway off-main
  'Release <version>' commit in a detached git worktree, tags it, pushes only the
  tag; main is never advanced (commit uses --no-verify since a worktree has no node_modules)
- publish.yml: refuse to publish the 0.0.0 placeholder (tag checkout already builds
  the real-version commit, so existing version validations are unchanged)
- publish-release workflow: open a CHANGELOG-only release-notes PR to main, then
  cut+tag off-main via cut-release.ts; verify tag parent == main HEAD and the tagged
  @bastani/atomic version == target instead of merging a version bump into main
- docs: CLAUDE.md/AGENTS.md, DEV_SETUP.md, docs/ci.md, coding-agent CHANGELOG

Assistant-model: Claude Opus 4.8
@claude

claude Bot commented Jun 20, 2026

Copy link
Copy Markdown

PR Review: chore(release): adopt versionless-main release model

Reviewed the full diff (19 files, +382/-152). A well-designed, well-documented change — the codex-style versionless-main model is coherently implemented across the script, the publish workflow, the Actions guard, and the docs. The publish.yml placeholder guard, the upgraded tag verification (parent + stamped-version checks), and the clean-tree / existing-tag / finally-based worktree cleanup in cut-release.ts are all solid. Findings below, by priority.

🟡 VERIFY BEFORE MERGE — bun install --frozen-lockfile from the tagged commit

This is the one path the PRs local validation ("2508 unit tests pass", "cut-release validated end-to-end") does NOT appear to exercise. On the tagged commit, bump-version.ts rewrites both: (a) every workspace version field, AND (b) the first-party dependency specifier @bastani/atomic-natives in packages/coding-agent/package.json (see bumpFirstPartyDependencyRanges, scripts/bump-version.ts:204) — while bun.lock is intentionally left at the 0.0.0 placeholders. publish.yml then runs bun install --frozen-lockfile (lines 212, 304) from that tagged commit. A specifier drift (not just a version-field drift) is exactly what frozen-lockfile is strictest about.

Mitigating evidence (supports the authors claim): the OLD bun.lock already carried a specifier mismatch — coding-agent depended on @bastani/atomic-natives 0.8.31-alpha.1 in the lockfile while package.json pinned 0.8.31-alpha.5 (visible in this diff) — and releases published fine, so bun very likely tolerates this for workspace deps. Still, because this is the single failure mode that would break EVERY publish and it is not covered by the local run, please confirm with a throwaway prerelease-tag dry-run that actually reaches publish.ymls install step (or, belt-and-suspenders, relock inside the worktree before committing — at the cost of the network round-trip the comment deliberately avoids).

🟢 MINOR

  1. The --yes / 1.5s notice is not a real confirmation (cut-release.ts). Without --yes it prints "Proceeding in 1.5s...", sleeps, and proceeds unconditionally — no abort opportunity, so it reads like a prompt but never blocks. Make it an actual TTY confirm, or reword to "No confirmation prompt; proceeding in 1.5s (Ctrl-C to abort)".

  2. Existing-tag check is local-only (git tag --list version). A tag that exists only on origin passes the check then fails at git push — safe (non-fast-forward) but late. When --push is set, consider a git ls-remote --tags origin version pre-check to fail fast.

  3. --base main can silently cut from a stale local main. The publish-release workflow guards this (tag parent must equal merged main HEAD), but a human running cut-release.ts v --base main directly gets no fetch / ahead-behind check. Worth a git fetch + divergence warning, or at least a doc note that main must be up to date first.

  4. JSON.parse(...).version as string in the cut-release sanity-check leans on JSON.parses implicit any, which CLAUDE.md discourages. The publish-release.ts counterpart wraps parsing in try/catch with a typed cast (good); the cut-release check could do the same.

  5. No test for cut-release.ts while bump-version.ts has test/unit/bump-version-script.test.ts. Git side effects are hard to unit-test, but parseArgs and validateVersion are pure — extracting and testing them (unknown-flag handling, placeholder rejection, alpha-revision regex) would lock in the CLI contract cheaply.

  6. releaseChangedFileAllowed now also allows a root CHANGELOG.md (publish-release.ts). Per CLAUDE.md changelogs are per-package (packages/*/CHANGELOG.md); if no root CHANGELOG.md exists this allowance is dead. Confirm intent or drop it.

CONFIRMED WHILE REVIEWING

  • publish.yml checks out the tag on both push and workflow_dispatch (ref: github.event.inputs.tag || github.ref_name), so the build sees the real version. OK
  • The 0.0.0 / 0.0.0-dev publish guard and the strict version regex in both scripts line up. OK
  • Bun template interpolation is used throughout cut-release.ts and the version is regex-validated before use, so there is no shell-injection surface. OK

Nice work overall — the design is sound and the docs (CLAUDE.md, DEV_SETUP.md, ci.md, CHANGELOG) are thorough and consistent. The frozen-lockfile dry-run is the one thing I would want green before relying on this for a real publish.

Reviewed by Claude Opus 4.8

Thread a base_ref parameter through the publish-release workflow so a release
can be cut from a maintenance/integration branch instead of main:
- new optional input base_ref (default "main")
- release-notes PR now targets base_ref; the base-readiness gate verifies
  current branch == base_ref, local == origin/base_ref, merge-commit ancestry
- cut-release-tag stage runs cut-release.ts --base ${base_ref}
- verifyReleaseTagPublished still checks tag parent == the resolved base HEAD

Default behavior (main) is unchanged. The lib verification helpers already
parameterized the base branch, so no lib/test changes were needed.

Assistant-model: Claude Opus 4.8
@claude

claude Bot commented Jun 20, 2026

Copy link
Copy Markdown

Review: versionless-main release model

Thorough, well-documented change. Decoupling version from branch is clean, the worktree-based stamping is the right mechanism, and the docs (CLAUDE.md, DEV_SETUP.md, docs/ci.md) are kept impressively in sync. Defense-in-depth (the publish.yml placeholder guard + the rewritten verifyReleaseTagPublished parent/stamp checks) is a nice touch. A few things worth addressing before merge.

🔴 Highest risk — verify bun install --frozen-lockfile against a tagged tree

cut-release.ts deliberately leaves bun.lock at the 0.0.0 placeholders while every package.json (including the @bastani/atomic-natives dependency specifier) is stamped to the real version. Both publish.yml:212 and :304 run bun install --frozen-lockfile after checking out the tagged commit.

The PR claims --frozen-lockfile "tolerates the mismatch," but the verification notes only describe local checks (parent SHA, tree contents) — not an actual frozen install against the tag. The concern is concrete: in the tagged tree the coding-agent manifest says @bastani/atomic-natives: 0.8.31 while bun.lock records 0.0.0 for that dependency. A changed dependency specifier (not just a workspace version field) is exactly what --frozen-lockfile is built to reject as "lockfile out of date." If it rejects, every release breaks in CI at the install step, before build/publish.

Please confirm by pushing a throwaway tag through publish.yml (or run bun install --frozen-lockfile against a cut tag locally) before merging. If it fails, the safe fix is a normal bun install inside the worktree before committing so bun.lock is relocked — the "avoid a network round-trip" optimization is not worth a broken release path. (I could not run the frozen install in this sandbox to settle it empirically.)

🟠 No test coverage for scripts/cut-release.ts

bump-version.ts has test/unit/bump-version-script.test.ts, but the new cut-release.ts has zero tests despite encoding the entire release-cutting contract. Worth covering the argument/validation logic (easily unit-testable if parseArgs/validateVersion are exported):

  • rejects placeholders (0.0.0, 0.0.0-dev) and non-strict versions (0.8, 1.0.0-beta.1, v1.0.0)
  • accepts MAJOR.MINOR.PATCH and -alpha.N
  • --base/--push/--yes/-y parsing, unknown-flag + duplicate-positional rejection

An end-to-end test against a temp git repo (clean-tree refusal, existing-tag refusal, correct release-commit parent, main untouched) would be high-value given this is release-critical.

🟠 publish-release workflow: new verification helpers are untested

publish-release.ts gained meaningful logic — releaseChangedFileAllowed now permits only CHANGELOGs, verifyReleasePreparation asserts the 0.0.0 placeholder, and verifyReleaseTagPublished was rewritten to check the release-commit parent and the stamped manifest version. test/unit/publish-release-helpers.test.ts does not appear to exercise these new branches. The parent/stamp gate is subtle and is the check that proves the right commit was tagged — it deserves direct coverage.

🟡 Minor

  • publish.yml placeholder guard — the 0.0.0-dev arm is dead code. The guard (publish.yml:367) checks 0.0.0 or 0.0.0-dev, but the preceding release_version_re regex already rejects 0.0.0-dev, so that arm is unreachable here. Harmless; 0.0.0 is the meaningful case (it passes the regex).
  • Lightweight tag + -c user.name/email: git ... tag <version> makes a lightweight tag with no tagger identity, so the -c user.name/email on the tag command have no effect (they only matter for the commit). Dead config, not a bug.
  • Duplicated version regex: STRICT_RELEASE_VERSION_RE/placeholder logic is duplicated between cut-release.ts and the workflow. Consider a single shared source so they cannot drift.

✅ Looks correct

  • bump-version.ts --root threads cleanly through findRepoRoot/cargo/readme discovery, so worktree stamping targets the right files.
  • $-template usage is injection-safe; version is regex-validated before any git call; dirty-tree and existing-tag refusals are correct.
  • worktree prune up front + try/finally removal + rmSync cleanup is solid — a mid-stamp failure leaves no tag and no leaked worktree.
  • The post-cut git show <tag>:.../package.json sanity check is good belt-and-suspenders.
  • First-party @bastani/atomic-natives* ranges and platform optionalDeps are handled by shouldBumpFirstPartyDependency, so the tagged tree stays internally consistent.

Overall a strong, careful PR — the one item I would gate merge on is empirically confirming the frozen-lockfile behavior, since that is the single point that would silently break the whole release pipeline.

🤖 Automated review — generated with Claude Code.

Add an optional from_ref input that releases from any commit/tag/branch
without touching main:
- auto-create release/<version> (or prerelease/<version>) from from_ref and
  commit the CHANGELOG entry on it (changelog lives on the tag only)
- gate on that branch's own test.yml CI run (headBranch + headSha + success)
- cut + publish the tag off the branch (cut-release.ts --base <branch>)
- delete the ephemeral branch after publish (the tag preserves the commits)

Supporting changes:
- generalize verifyPublishWorkflowSucceeded -> verifyWorkflowRunSucceeded so it
  also gates the branch test run; add verifyReleaseBranchCiSucceeded wrapper
- verifyReleasePreparation gains a checkManifestVersions flag (off for ephemeral,
  since an arbitrary from_ref may carry real versions that cut-release stamps over)
- test.yml now triggers on push to release/** and prerelease/** so the branch gate
  has a CI run to wait on

When from_ref is unset the existing main/base_ref flow is unchanged.

Assistant-model: Claude Opus 4.8
@claude claude Bot changed the title chore(release): adopt versionless-main release model chore(release)!: adopt versionless-main release model Jun 21, 2026
@claude

claude Bot commented Jun 21, 2026

Copy link
Copy Markdown

Review: versionless-main release model

Thorough, well-documented change — the design (stamp the real version only onto a throwaway off-main tag commit) is coherent, the docs/CHANGELOG are excellent, and the publish-release parameterization (base_ref, from_ref) is threaded carefully. A few things I'd want resolved before relying on this for a real publish.

🔴 High — verify the --frozen-lockfile claim against a stamped tagged tree (load-bearing)

The whole model rests on this line from the PR description:

bun.lock intentionally stays at the 0.0.0 workspace placeholders … bun install --frozen-lockfile tolerates the mismatch

But cut-release.ts stamps via bump-version.ts, which rewrites the internal dependency range too, not just the version field — see bumpFirstPartyDependencyRanges (scripts/bump-version.ts:204-217): @bastani/atomic-natives goes from 0.0.0 → e.g. 0.8.31 in the tagged packages/coding-agent/package.json. Meanwhile bun.lock still records that workspace entry at 0.0.0 (both the version field and the @bastani/atomic-natives range, bun.lock:12-20).

publish.yml then runs bun install --frozen-lockfile on the checked-out tag (.github/workflows/publish.yml:212 and :304). Bun normally treats a changed dependency range or a changed workspace version as lockfile drift and aborts under --frozen-lockfile (error: lockfile had changes, but --frozen-lockfile was set). If that happens, every publish fails at the install step.

I could not run bun install --frozen-lockfile against a stamped tree during review (network is sandboxed here), and the PR's verification section covers typecheck / unit tests / prek — all of which run on main, where 0.0.0 is internally consistent — but not the one path that actually exercises the mismatch (CI install on the tagged tree). Please confirm one of:

  1. Push a throwaway test tag through the real publish.yml (or locally: bun run scripts/cut-release.ts <v>git checkout <v>bun install --frozen-lockfile) and confirm the install succeeds; or
  2. Sidestep the mismatch entirely by declaring the internal dep as "@bastani/atomic-natives": "workspace:*" so the stamp never changes the range (npm publish rewrites workspace:* to the concrete version automatically, and bump-version would leave it alone). That keeps bun.lock genuinely consistent on both main and the tag.

This is the one item I'd consider blocking until demonstrated green.

🟠 Medium — duplicate base_ref input registration

.atomic/workflows/publish-release.ts:698-713 registers base_ref twice with identical schemas (looks like a copy-paste from the commit-2 rebase). It's not fatal — define-workflow.ts:173 does { ...state.inputs, [key]: schema }, so the second silently overrides the first — but it's dead/confusing code that should be deleted (keep one).

🟡 Low

  • No tests for cut-release.ts. bump-version.ts has test/unit/bump-version-script.test.ts, but the new orchestration script has none. The pure, easily-testable bits are worth covering: parseArgs (flag handling / unknown-flag rejection), validateVersion (placeholder + strict-regex rejection), and the dirty-tree / existing-tag refusals.
  • --yes isn't a real confirmation gate. Without it, cut-release.ts prints Proceeding in 1.5s... and proceeds anyway — there's no abort path, so the flag only suppresses a sleep. The notice reads like a confirmation prompt. Either make it an actual interactive prompt or reword the message so it doesn't imply you can cancel.
  • test.yml now double-triggers CI in the default flow. Adding release/** / prerelease/** to the push triggers (.github/workflows/test.yml:10) is needed for the from_ref branch gate, but the default flow also pushes its release-notes branch as release/<version> (lib/publish-release.ts:123). That branch will now fire both a push run and the existing pull_request run — duplicate CI on every normal release. Harmless but wasteful; worth a note or a path filter.
  • cut-release.ts existing-tag check is local-only. git tag --list <version> only checks local tags; a tag that exists only on origin is caught later by git push failing. The workflow guards the remote separately, but a manual cut-release.ts … --push could create a local tag then error at push. Minor.

✅ Things that look correct

  • verifyReleaseTagPublished parent/stamp/remote checks are consistent with lightweight tags (cut-release.ts uses git tag, so ls-remote returns the commit SHA directly — no ^{} deref needed).
  • verifyWorkflowRunSucceeded generalization (headBranch = tag for publish.yml, = branch for test.yml) matches how Actions reports tag-push runs.
  • Branch naming ${kind}/${version} lines up with the new test.yml globs.
  • Published artifacts stay correct: the tag stamps both @bastani/atomic and @bastani/atomic-natives to the real version, so end users get matching pins; 0.0.0 only ever appears on dev main builds, and the new publish.yml placeholder guard (:366-372) backstops accidental placeholder tags.

Nice work overall — resolving the frozen-lockfile question is really the only thing standing between this and a confident merge.

🤖 Generated with Claude Code

@lavaman131
lavaman131 merged commit 9707f6f into main Jun 21, 2026
11 checks passed
lavaman131 added a commit that referenced this pull request Jun 21, 2026
Resolve versionless-main (#1450) vs file-length split conflict:
- re-apply #1450's versionless / base_ref / from_ref / ephemeral logic onto the
  split publish-release lib modules (helpers, gates) and the workflow file
- extract the ephemeral release flow into lib/publish-release-ephemeral.ts so
  every file stays under the 500-line limit
- combine both CHANGELOG [Unreleased] entries

Assistant-model: Claude Opus 4.8
@lavaman131
lavaman131 deleted the release-model/versionless-main branch June 21, 2026 00:43
lavaman131 added a commit that referenced this pull request Jun 29, 2026
* chore(release): adopt versionless-main release model

- main carries the 0.0.0 placeholder across all packages/*/package.json,
  bun.lock, the @bastani/atomic-natives pin, native/index.js, and Cargo manifests/lock
- add scripts/cut-release.ts: stamps the real version onto a throwaway off-main
  'Release <version>' commit in a detached git worktree, tags it, pushes only the
  tag; main is never advanced (commit uses --no-verify since a worktree has no node_modules)
- publish.yml: refuse to publish the 0.0.0 placeholder (tag checkout already builds
  the real-version commit, so existing version validations are unchanged)
- publish-release workflow: open a CHANGELOG-only release-notes PR to main, then
  cut+tag off-main via cut-release.ts; verify tag parent == main HEAD and the tagged
  @bastani/atomic version == target instead of merging a version bump into main
- docs: CLAUDE.md/AGENTS.md, DEV_SETUP.md, docs/ci.md, coding-agent CHANGELOG

Assistant-model: Claude Opus 4.8

* feat(publish-release): add optional base_ref input (defaults to main)

Thread a base_ref parameter through the publish-release workflow so a release
can be cut from a maintenance/integration branch instead of main:
- new optional input base_ref (default "main")
- release-notes PR now targets base_ref; the base-readiness gate verifies
  current branch == base_ref, local == origin/base_ref, merge-commit ancestry
- cut-release-tag stage runs cut-release.ts --base ${base_ref}
- verifyReleaseTagPublished still checks tag parent == the resolved base HEAD

Default behavior (main) is unchanged. The lib verification helpers already
parameterized the base branch, so no lib/test changes were needed.

Assistant-model: Claude Opus 4.8

* feat(publish-release): add from_ref ephemeral release-from-any-ref path

Add an optional from_ref input that releases from any commit/tag/branch
without touching main:
- auto-create release/<version> (or prerelease/<version>) from from_ref and
  commit the CHANGELOG entry on it (changelog lives on the tag only)
- gate on that branch's own test.yml CI run (headBranch + headSha + success)
- cut + publish the tag off the branch (cut-release.ts --base <branch>)
- delete the ephemeral branch after publish (the tag preserves the commits)

Supporting changes:
- generalize verifyPublishWorkflowSucceeded -> verifyWorkflowRunSucceeded so it
  also gates the branch test run; add verifyReleaseBranchCiSucceeded wrapper
- verifyReleasePreparation gains a checkManifestVersions flag (off for ephemeral,
  since an arbitrary from_ref may carry real versions that cut-release stamps over)
- test.yml now triggers on push to release/** and prerelease/** so the branch gate
  has a CI run to wait on

When from_ref is unset the existing main/base_ref flow is unchanged.

Assistant-model: Claude Opus 4.8
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