diff --git a/.atomic/workflows/publish-release.ts b/.atomic/workflows/publish-release.ts index a7bd7166d..83fc8aaa6 100644 --- a/.atomic/workflows/publish-release.ts +++ b/.atomic/workflows/publish-release.ts @@ -126,18 +126,21 @@ function packageManifestPaths(): readonly string[] { return paths; } +// main is versionless: it carries this placeholder and the real version is +// stamped only onto an off-main tag by scripts/cut-release.ts. The release-notes +// PR this workflow opens against main must therefore touch CHANGELOGs only — +// never package manifests, lockfiles, or generated version files. +const PLACEHOLDER_VERSION = "0.0.0"; + function releaseChangedFileAllowed(path: string): boolean { - return path === "package.json" - || path === "bun.lock" - || path === "Cargo.toml" - || path === "Cargo.lock" - || path === "packages/natives/native/index.js" - || /^packages\/[^/]+\/(?:package\.json|README\.md|CHANGELOG\.md)$/u.test(path); + return path === "CHANGELOG.md" + || /^packages\/[^/]+\/CHANGELOG\.md$/u.test(path); } async function verifyReleasePreparation( release: ValidatedRelease, sourceHeadOid: string, + checkManifestVersions = true, ): Promise { const branch = runCommand(["git", "branch", "--show-current"]); const head = runCommand(["git", "rev-parse", "HEAD"]); @@ -172,8 +175,10 @@ async function verifyReleasePreparation( continue; } - if (typeof manifest.version === "string" && manifest.version !== release.version) { - failures.push(`${manifestPath} version was ${manifest.version}, expected ${release.version}`); + if (checkManifestVersions && typeof manifest.version === "string" && manifest.version !== PLACEHOLDER_VERSION) { + failures.push( + `${manifestPath} version was ${manifest.version}, expected the ${PLACEHOLDER_VERSION} placeholder. main is versionless; do not run bump-version in this flow (the release version is stamped onto the tag by scripts/cut-release.ts).`, + ); } if (manifestPath === "packages/coding-agent/package.json" && manifest.name !== "@bastani/atomic") { @@ -247,6 +252,7 @@ function runLocalReleaseChecks(release: ValidatedRelease): GateVerification { function captureReleasePrReference( release: ValidatedRelease, expectedHeadRefOid: string, + baseRef: string, ): PullRequestReferenceVerification { const prView = runCommand([ "gh", @@ -270,7 +276,7 @@ function captureReleasePrReference( const referenceVerification = verifyReleasePullRequestReferenceJson( parsed.value, release.branch, - "main", + baseRef, expectedHeadRefOid, "OPEN", ); @@ -318,6 +324,7 @@ function captureReleasePrReference( function verifyReleasePrChecksPassed( release: ValidatedRelease, prReference: Extract, + baseRef: string, ): GateVerification { const prView = runCommand([ "gh", @@ -338,7 +345,7 @@ function verifyReleasePrChecksPassed( const refreshedReference = verifyReleasePullRequestReferenceJson( parsedPr.value, release.branch, - "main", + baseRef, prReference.headRefOid, "OPEN", ); @@ -378,6 +385,7 @@ function verifyReleasePrMerged( release: ValidatedRelease, prSelector: string, expectedHeadRefOid: string | undefined, + baseRef: string, ): PullRequestMergeVerification { const prView = runCommand([ "gh", @@ -398,7 +406,7 @@ function verifyReleasePrMerged( const parsed = parseJsonCommand(prView, "GitHub PR merge verification returned invalid JSON."); if (!parsed.ok) return { ok: false, summary: parsed.summary }; - const mergeVerification = verifyPullRequestMergedJson(parsed.value, release.branch, "main", expectedHeadRefOid); + const mergeVerification = verifyPullRequestMergedJson(parsed.value, release.branch, baseRef, expectedHeadRefOid); if (!mergeVerification.ok) { return { ok: false, @@ -434,30 +442,30 @@ function verifyReleasePrMerged( }; } -function verifyMainReadyForTag(release: ValidatedRelease, mergeCommitOid: string): MainReadyVerification { +function verifyMainReadyForTag(release: ValidatedRelease, mergeCommitOid: string, baseRef: string): MainReadyVerification { const branch = runCommand(["git", "branch", "--show-current"]); const head = runCommand(["git", "rev-parse", "HEAD"]); - const originMain = runCommand(["git", "rev-parse", "origin/main"]); + const originMain = runCommand(["git", "rev-parse", `origin/${baseRef}`]); const status = runCommand(["git", "status", "--short"]); const mergeBase = runCommand(["git", "merge-base", "--is-ancestor", mergeCommitOid, "HEAD"]); const localTag = runCommand(["git", "rev-parse", "--verify", `refs/tags/${release.version}`]); const remoteTag = runCommand(["git", "ls-remote", "--tags", "origin", `refs/tags/${release.version}`]); const failures: string[] = []; - if (branch.exitCode !== 0 || branch.stdout !== "main") failures.push(`current branch was ${branch.stdout || "missing"}, expected main`); - if (head.exitCode !== 0 || head.stdout.length === 0) failures.push("local main HEAD could not be resolved"); - if (originMain.exitCode !== 0 || originMain.stdout.length === 0) failures.push("origin/main could not be resolved"); + if (branch.exitCode !== 0 || branch.stdout !== baseRef) failures.push(`current branch was ${branch.stdout || "missing"}, expected ${baseRef}`); + if (head.exitCode !== 0 || head.stdout.length === 0) failures.push(`local ${baseRef} HEAD could not be resolved`); + if (originMain.exitCode !== 0 || originMain.stdout.length === 0) failures.push(`origin/${baseRef} could not be resolved`); if (head.stdout.length > 0 && originMain.stdout.length > 0 && head.stdout !== originMain.stdout) { - failures.push(`local main HEAD ${head.stdout} did not match origin/main ${originMain.stdout}`); + failures.push(`local ${baseRef} HEAD ${head.stdout} did not match origin/${baseRef} ${originMain.stdout}`); } if (status.exitCode !== 0 || status.stdout.length > 0) failures.push("worktree is not clean before tagging"); - if (mergeBase.exitCode !== 0) failures.push(`merge commit ${mergeCommitOid} is not an ancestor of local main HEAD`); + if (mergeBase.exitCode !== 0) failures.push(`merge commit ${mergeCommitOid} is not an ancestor of local ${baseRef} HEAD`); if (localTag.exitCode === 0) failures.push(`local tag ${release.version} already exists`); if (remoteTag.exitCode !== 0) failures.push(`remote tag lookup for ${release.version} failed`); if (remoteTag.stdout.length > 0) failures.push(`remote tag ${release.version} already exists`); const summary = [ - failures.length === 0 ? "Main is ready for release tagging." : "Main is not ready for release tagging.", + failures.length === 0 ? `${baseRef} is ready for release tagging.` : `${baseRef} is not ready for release tagging.`, failures.length === 0 ? undefined : failures.map((failure) => `- ${failure}`).join("\n"), commandSummary(branch), commandSummary(head), @@ -472,34 +480,64 @@ function verifyMainReadyForTag(release: ValidatedRelease, mergeCommitOid: string return { ok: true, summary, mainOid: head.stdout }; } -function verifyReleaseTagPublished(release: ValidatedRelease, expectedTagTargetOid: string): TagPublicationVerification { - const localTag = runCommand(["git", "rev-parse", `${release.version}^{}`]); +function verifyReleaseTagPublished(release: ValidatedRelease, expectedParentOid: string): TagPublicationVerification { + // The tag does not point at a commit on main. cut-release.ts stamps the real + // version onto a throwaway "Release" commit whose parent is the merged main + // HEAD, then tags that commit. Verify: (1) local + remote tag resolve to the + // same release commit, (2) its parent is the verified main commit, and (3) the + // tagged @bastani/atomic manifest carries the target version (proving the stamp). + const localTag = runCommand(["git", "rev-parse", `${release.version}^{commit}`]); + const releaseCommitOid = localTag.stdout; + const tagParent = runCommand(["git", "rev-parse", `${release.version}^{commit}^`]); + const taggedManifest = runCommand(["git", "show", `${release.version}:packages/coding-agent/package.json`]); const remoteTag = runCommand(["git", "ls-remote", "--tags", "origin", `refs/tags/${release.version}`]); const remoteTagTargetOid = remoteTag.stdout.split(/\s+/u)[0] ?? ""; const failures: string[] = []; - if (localTag.exitCode !== 0 || localTag.stdout !== expectedTagTargetOid) { - failures.push(`local tag target was ${localTag.stdout || "missing"}, expected ${expectedTagTargetOid}`); + if (localTag.exitCode !== 0 || releaseCommitOid.length === 0) { + failures.push("local release tag commit could not be resolved"); + } + if (tagParent.exitCode !== 0 || tagParent.stdout !== expectedParentOid) { + failures.push(`release commit parent was ${tagParent.stdout || "missing"}, expected the verified main commit ${expectedParentOid}`); } - if (remoteTag.exitCode !== 0 || remoteTagTargetOid !== expectedTagTargetOid) { - failures.push(`remote tag target was ${remoteTagTargetOid || "missing"}, expected ${expectedTagTargetOid}`); + + let stampedVersion: string | undefined; + if (taggedManifest.exitCode === 0) { + try { + stampedVersion = (JSON.parse(taggedManifest.stdout) as { version?: string }).version; + } catch { + stampedVersion = undefined; + } + } + if (stampedVersion !== release.version) { + failures.push(`tagged @bastani/atomic version was ${stampedVersion ?? "unparseable"}, expected ${release.version}`); + } + + if (remoteTag.exitCode !== 0 || remoteTagTargetOid.length === 0) { + failures.push(`remote tag ${release.version} was missing on origin`); + } else if (releaseCommitOid.length > 0 && remoteTagTargetOid !== releaseCommitOid) { + failures.push(`remote tag target was ${remoteTagTargetOid}, expected the release commit ${releaseCommitOid}`); } const summary = [ failures.length === 0 ? "Release tag publication is deterministically verified." : "Release tag publication is not verified.", + releaseCommitOid.length === 0 ? undefined : `releaseCommitOid: ${releaseCommitOid}`, + `expectedParentOid: ${expectedParentOid}`, failures.length === 0 ? undefined : failures.map((failure) => `- ${failure}`).join("\n"), commandSummary(localTag), + commandSummary(tagParent), commandSummary(remoteTag), ].filter((line): line is string => line !== undefined).join("\n\n"); - if (failures.length > 0) return { ok: false, summary }; - return { ok: true, summary, tagTargetOid: expectedTagTargetOid }; + if (failures.length > 0 || releaseCommitOid.length === 0) return { ok: false, summary }; + return { ok: true, summary, tagTargetOid: releaseCommitOid }; } -async function verifyPublishWorkflowSucceeded( - release: ValidatedRelease, +async function verifyWorkflowRunSucceeded( expectedHeadSha: string, + options: { readonly workflowFile: string; readonly expectedHeadBranch: string }, ): Promise { + const { workflowFile, expectedHeadBranch } = options; let runList: CommandResult | undefined; let selectedRun: ReturnType | undefined; @@ -509,7 +547,7 @@ async function verifyPublishWorkflowSucceeded( "run", "list", "--workflow", - "publish.yml", + workflowFile, "--event", "push", "--json", @@ -528,7 +566,7 @@ async function verifyPublishWorkflowSucceeded( const parsedList = parseJsonCommand(runList, "GitHub Actions publish run lookup returned invalid JSON."); if (!parsedList.ok) return { ok: false, summary: parsedList.summary }; - selectedRun = selectPublishWorkflowRunJson(parsedList.value, release.version); + selectedRun = selectPublishWorkflowRunJson(parsedList.value, expectedHeadBranch); if (selectedRun.ok) break; if (attempt < 6) await Bun.sleep(10_000); } @@ -589,7 +627,7 @@ async function verifyPublishWorkflowSucceeded( }; } - const publishVerification = verifyPublishWorkflowRunJson(parsedView.value, release.version, expectedHeadSha); + const publishVerification = verifyPublishWorkflowRunJson(parsedView.value, expectedHeadBranch, expectedHeadSha); if (!publishVerification.ok) { return { ok: false, @@ -615,26 +653,73 @@ async function verifyPublishWorkflowSucceeded( }; } -function releaseInstructions(release: ValidatedRelease): string { +function verifyPublishWorkflowSucceeded( + release: ValidatedRelease, + expectedHeadSha: string, +): Promise { + return verifyWorkflowRunSucceeded(expectedHeadSha, { + workflowFile: "publish.yml", + expectedHeadBranch: release.version, + }); +} + +function verifyReleaseBranchCiSucceeded( + release: ValidatedRelease, + branchHeadSha: string, +): Promise { + return verifyWorkflowRunSucceeded(branchHeadSha, { + workflowFile: "test.yml", + expectedHeadBranch: release.branch, + }); +} + +function releaseInstructions(release: ValidatedRelease, baseRef: string): string { return [ `Release kind: ${release.kind}`, `Target version: ${release.version}`, - `Release branch to create from current HEAD: ${release.branch}`, + `Base branch (release-notes PR target and tag base): ${baseRef}`, + `Release-notes branch to create from current HEAD: ${release.branch}`, "Repository rules:", "- Use Bun commands, not npm/yarn/pnpm/npx, for local development steps.", "- Never include a leading v in the version or tag.", + `- ${baseRef} is versionless: every packages/*/package.json stays at the 0.0.0 placeholder. Do NOT run scripts/bump-version.ts and do NOT change any package version in this flow.`, + `- The real version is materialized only on a throwaway off-${baseRef} tag commit produced by \`scripts/cut-release.ts\`; it is never merged into ${baseRef}.`, "- Do not modify already released changelog sections; add entries only under each package CHANGELOG.md `## [Unreleased]` section.", - `- Use \`bun run scripts/bump-version.ts ${release.version}\` and then \`bun install\` for version bumps.`, "- If credentials, git state, CI, or publish checks block safe progress, report the blocker clearly and stop rather than fabricating success.", ].join("\n"); } export default defineWorkflow("publish-release") - .description("Automate Atomic release/prerelease branch, PR, merge, tag, and publish monitoring.") + .description("Automate Atomic versionless-main release: open a release-notes PR to main, then stamp and tag the release off-main with cut-release.ts, and monitor publishing.") .input("target_version", Type.String({ description: "Version to publish, without a leading v." })) .input("release_kind", Type.Union([Type.Literal("release"), Type.Literal("prerelease")], { description: "Release type; release requires MAJOR.MINOR.PATCH and prerelease requires MAJOR.MINOR.PATCH-alpha.REVISION.", })) + .input( + "base_ref", + Type.String({ + default: "main", + description: + "Branch to release from: the release-notes PR merges into it and the tag is cut from it. Defaults to main. Set this to release from a maintenance/integration branch instead of main.", + }), + ) + .input( + "base_ref", + Type.String({ + default: "main", + description: + "Branch to release from: the release-notes PR merges into it and the tag is cut from it. Defaults to main. Set this to release from a maintenance/integration branch instead of main.", + }), + ) + .input( + "from_ref", + Type.Optional( + Type.String({ + description: + "Optional: cut an ephemeral release from any commit/tag/branch. The workflow auto-creates release/ (or prerelease/) from this ref, commits the CHANGELOG entry on it, gates on that branch's CI, cuts and publishes the tag, then deletes the branch. The changelog lives on the tag only and main is untouched. When set, base_ref is ignored.", + }), + ), + ) .output("status", statusSchema) .output("target_version", Type.String({ description: "Validated version supplied to the release workflow." })) .output("release_kind", releaseKindSchema) @@ -644,7 +729,8 @@ export default defineWorkflow("publish-release") .output("summary", Type.String({ description: "Compact release execution summary." })) .run(async (ctx) => { const release = validateReleaseRequest(ctx.inputs.release_kind, ctx.inputs.target_version); - const baseInstructions = releaseInstructions(release); + const baseRef = ctx.inputs.base_ref.trim() || "main"; + const baseInstructions = releaseInstructions(release, baseRef); const sourceHead = runCommand(["git", "rev-parse", "HEAD"]); if (sourceHead.exitCode !== 0 || sourceHead.stdout.length === 0) { @@ -656,6 +742,199 @@ export default defineWorkflow("publish-release") ); } + const fromRef = ctx.inputs.from_ref?.trim(); + if (fromRef) { + // Ephemeral release from an arbitrary ref: auto-create release/ from + // from_ref, put the changelog on it, gate on that branch's CI, cut+publish the + // tag off it, then delete the branch. The changelog lives only on the tag and + // base_ref/main is never touched. + runCommand(["git", "fetch", "--quiet", "--tags", "origin"]); + const fromRefCommit = runCommand(["git", "rev-parse", "--verify", `${fromRef}^{commit}`]); + if (fromRefCommit.exitCode !== 0 || fromRefCommit.stdout.length === 0) { + return blockedOutput( + release, + "resolve-from-ref", + `git rev-parse resolves from_ref ${fromRef} to a commit before creating the ephemeral release branch`, + commandSummary(fromRefCommit), + ); + } + const fromRefOid = fromRefCommit.stdout; + const ephemeralInstructions = [ + `Release kind: ${release.kind}`, + `Target version: ${release.version}`, + `Ephemeral release branch (auto-created from from_ref, deleted after publish): ${release.branch}`, + `Source ref: ${fromRef} (${fromRefOid})`, + "Repository rules:", + "- Use Bun commands, not npm/yarn/pnpm/npx, for local steps.", + "- Never include a leading v in the version or tag.", + "- Do NOT run scripts/bump-version.ts and do NOT change any package version; cut-release.ts stamps the version onto the tag commit.", + "- The changelog lives only on the release tag; main/base_ref is never touched.", + "- If credentials, git state, or CI block safe progress, report the blocker and stop rather than fabricating success.", + ].join("\n"); + + const prepare = await ctx.task("create-ephemeral-release-branch", { + prompt: [ + "Create the ephemeral release branch from the source ref and put the changelog on it.", + "", + ephemeralInstructions, + "", + "Required actions:", + `1. Ensure ${fromRef} is available locally (\`git fetch origin\` as needed), then create and switch to branch \`${release.branch}\` at commit \`${fromRefOid}\` (e.g. \`git switch -c ${release.branch} ${fromRefOid}\`). If the branch already exists, stop and report BLOCKED.`, + `2. Read package changelogs, especially \`packages/*/CHANGELOG.md\`, and move the \`## [Unreleased]\` entries into a new \`## [${release.version}]\` section dated today, per AGENTS.md Changelog guidance. Do NOT change any package version.`, + "3. Inspect the diff and ensure it contains only CHANGELOG.md changes.", + `4. Commit on \`${release.branch}\` with a message such as \`docs: release notes for ${release.version}\`, then push with \`git push -u origin ${release.branch}\`.`, + "", + "Final response format:", + "- Include the created branch, its HEAD commit, `git status --short`, changed files, the push result, and any blockers.", + "- The workflow body verifies the branch, its CI, the tag, and cleanup deterministically after each stage.", + ].join("\n"), + }); + + const preparationVerification = await verifyReleasePreparation(release, fromRefOid, false); + if (!preparationVerification.ok) { + return blockedOutput( + release, + "verify-ephemeral-release-branch", + `current branch is ${release.branch}, the worktree is clean, and only CHANGELOG files changed vs ${fromRef}`, + [preparationVerification.summary, "", "Create-branch stage output:", excerpt(prepare.text, 2_000)].join("\n"), + ); + } + + const remoteBranch = runCommand(["git", "ls-remote", "--heads", "origin", release.branch]); + const remoteHeadOid = remoteBranch.stdout.split(/\s+/u)[0] ?? ""; + if (remoteBranch.exitCode !== 0 || remoteHeadOid !== preparationVerification.releaseCommitOid) { + return blockedOutput( + release, + "verify-ephemeral-branch-pushed", + `origin/${release.branch} exists and points at the release-notes commit ${preparationVerification.releaseCommitOid}`, + [ + `remote ${release.branch} head: ${remoteHeadOid || "missing"}`, + `expected: ${preparationVerification.releaseCommitOid}`, + commandSummary(remoteBranch), + "", + "Create-branch stage output:", + excerpt(prepare.text, 2_000), + ].join("\n"), + ); + } + + const ciWait = await ctx.task("wait-for-release-branch-ci", { + prompt: [ + `Wait for required CI checks on branch \`${release.branch}\` (commit \`${preparationVerification.releaseCommitOid}\`). Do not cut a tag yet.`, + "", + ephemeralInstructions, + "", + "Required actions:", + `1. Wait for the Tests workflow on \`${release.branch}\` to finish, e.g. \`gh run list --branch ${release.branch} --workflow test.yml\` then \`gh run watch --exit-status\` for the run whose headSha is \`${preparationVerification.releaseCommitOid}\`.`, + "2. If required checks fail, report the failing check names and URLs. Do not cut a tag.", + "3. If checks pass, summarize the evidence and stop.", + "", + "Final response format:", + "- Include the run id/URL, status, conclusion, headSha, commands run, and any blockers.", + "- The workflow body performs the deterministic branch-CI gate after this stage.", + ].join("\n"), + }); + + const branchCi = await verifyReleaseBranchCiSucceeded(release, preparationVerification.releaseCommitOid); + if (!branchCi.ok) { + return blockedOutput( + release, + "verify-release-branch-ci", + `the Tests workflow run for ${release.branch} has headSha ${preparationVerification.releaseCommitOid}, status completed, and conclusion success`, + [branchCi.summary, "", "CI wait stage output:", excerpt(ciWait.text, 2_000)].join("\n"), + "failed", + ); + } + + const pushTag = await ctx.task("cut-release-tag", { + prompt: [ + `Cut the release tag off \`${release.branch}\`. This is the sole publish trigger stage.`, + "", + ephemeralInstructions, + "", + "Deterministic branch-CI gate:", + excerpt(branchCi.summary), + "", + "Required actions:", + `1. Verify you are on clean local \`${release.branch}\` at commit \`${preparationVerification.releaseCommitOid}\`.`, + `2. Run \`bun run scripts/cut-release.ts ${release.version} --base ${release.branch} --push --yes\`. This stamps the real version onto a throwaway off-branch "Release ${release.version}" commit (parent = ${release.branch} HEAD), tags it, and pushes ONLY the tag.`, + `3. Do not push ${release.branch}. Do not force-push or overwrite an existing tag. Do not run scripts/bump-version.ts.`, + "", + "Final response format:", + `- Include the pushed tag, the release commit SHA and its parent (must equal ${release.branch} HEAD), local/remote tag evidence, the publish run URL if available, and any blockers.`, + ].join("\n"), + }); + + const tagVerification = verifyReleaseTagPublished(release, preparationVerification.releaseCommitOid); + if (!tagVerification.ok) { + return blockedOutput( + release, + "verify-release-tag-published", + `local and remote release tag exist, the release commit parent is the ${release.branch} commit, and the tagged @bastani/atomic manifest carries the target version`, + [tagVerification.summary, "", "Cut-release stage output:", excerpt(pushTag.text, 2_000)].join("\n"), + "failed", + ); + } + + const publishVerification = await verifyPublishWorkflowSucceeded(release, tagVerification.tagTargetOid); + if (!publishVerification.ok) { + return blockedOutput( + release, + "verify-publish-workflow-succeeded", + "GitHub Actions Publish run for the release tag has matching headSha, status completed, and conclusion success", + [publishVerification.summary, "", "Cut-release stage output:", excerpt(pushTag.text, 2_000)].join("\n"), + "failed", + ); + } + + const cleanup = await ctx.task("delete-ephemeral-release-branch", { + prompt: [ + `The release is published. Delete the now-unneeded branch \`${release.branch}\`; the tag \`${release.version}\` keeps its commits alive.`, + "", + "Required actions:", + `1. Run \`git push origin --delete ${release.branch}\` to delete the remote branch.`, + `2. Optionally delete the local branch (\`git branch -D ${release.branch}\` after switching away). Do NOT delete the tag.`, + "", + "Final response format:", + "- Include the delete command result and confirmation that the tag still exists.", + ].join("\n"), + }); + + const remoteBranchAfter = runCommand(["git", "ls-remote", "--heads", "origin", release.branch]); + const branchDeleted = remoteBranchAfter.exitCode === 0 && remoteBranchAfter.stdout.trim().length === 0; + const cleanupNote = branchDeleted + ? `Ephemeral branch ${release.branch} deleted from origin.` + : `WARNING: ephemeral branch ${release.branch} may still exist on origin; delete it manually with \`git push origin --delete ${release.branch}\` (the release itself is already published).`; + + const ephemeralSummary = [ + `publish-release (ephemeral) completed for ${release.kind} ${release.version}.`, + `Source ref: ${fromRef} (${fromRefOid})`, + `Release branch: ${release.branch} (auto-created, ${branchDeleted ? "deleted" : "NOT deleted"})`, + `Tag: ${release.version} -> release commit ${tagVerification.tagTargetOid}`, + publishVerification.runUrl === undefined ? "Publish run: see cut-release stage output" : `Publish run: ${publishVerification.runUrl}`, + cleanupNote, + "", + "Stage summaries:", + "## deterministic-branch-ci", + excerpt(branchCi.summary, 800), + "## deterministic-release-tag", + excerpt(tagVerification.summary, 800), + "## deterministic-publish-run", + excerpt(publishVerification.summary, 800), + "## delete-ephemeral-release-branch", + excerpt(cleanup.text, 800), + ].join("\n"); + + return { + status: "completed", + target_version: release.version, + release_kind: release.kind, + branch: release.branch, + tag: release.version, + summary: ephemeralSummary, + }; + } + const prepare = await ctx.task("prepare-release-branch-and-metadata", { prompt: [ "Prepare the release branch and metadata changes for this Atomic repository.", @@ -665,11 +944,11 @@ export default defineWorkflow("publish-release") "Required actions:", "1. Inspect `git status --short`, `git branch --show-current`, `git rev-parse HEAD`, `git log -1 --oneline`, and `git remote -v` to record the source branch and exact source commit.", "2. Ensure you are starting from a safe state for a release. If unrelated uncommitted changes already exist before your release edits, stop and report BLOCKED with the exact files.", - `3. Create and switch to branch \`${release.branch}\` from the recorded source commit \`${sourceHead.stdout}\` if it does not already exist; if it exists, verify it is the intended same-version release branch before continuing.`, - "4. Read package changelogs, especially `packages/*/CHANGELOG.md`, and update only `## [Unreleased]` sections according to AGENTS.md Changelog guidance.", - `5. Run \`bun run scripts/bump-version.ts ${release.version}\` and then \`bun install\`.`, - "6. Inspect the resulting diff and ensure it contains only release metadata/changelog/version/lockfile changes.", - `7. Commit all release changes on \`${release.branch}\` with a concise conventional message such as \`chore: release ${release.version}\`.`, + `3. Create and switch to branch \`${release.branch}\` from the recorded source commit \`${sourceHead.stdout}\` if it does not already exist; if it exists, verify it is the intended same-version release-notes branch before continuing.`, + `4. Read package changelogs, especially \`packages/*/CHANGELOG.md\`, and move the \`## [Unreleased]\` entries into a new \`## [${release.version}]\` section dated today, per AGENTS.md Changelog guidance.`, + "5. Do NOT bump versions: main is versionless and every package manifest must stay at the 0.0.0 placeholder. Do not run scripts/bump-version.ts and do not touch package.json, bun.lock, Cargo.*, or generated version files.", + "6. Inspect the resulting diff and ensure it contains only CHANGELOG.md changes.", + `7. Commit the changelog changes on \`${release.branch}\` with a concise conventional message such as \`docs: release notes for ${release.version}\`.`, "", "Final response format:", "- Summarize source branch, source HEAD, created/current release branch, release commit hash, `git status --short`, changed files, commands run, and any blockers.", @@ -711,7 +990,7 @@ export default defineWorkflow("publish-release") `1. Use \`git branch --show-current\` plus \`git rev-parse HEAD\` to verify the current branch is \`${release.branch}\` at commit \`${preparationVerification.releaseCommitOid}\`.`, `2. Push branch with \`git push -u origin ${release.branch}\`.`, "3. Use `gh auth status` and `gh repo view` or equivalent non-destructive checks to confirm GitHub access.", - `4. Create a PR from \`${release.branch}\` to \`main\` with title \`Release ${release.version}\` if one does not already exist. If a PR already exists for the branch, reuse it.`, + `4. Create a PR from \`${release.branch}\` to \`${baseRef}\` with title \`Release ${release.version}\` if one does not already exist. If a PR already exists for the branch, reuse it.`, "5. Include release kind, version, changelog/version bump summary, and validation commands in the PR body.", "", "Final response format:", @@ -721,7 +1000,7 @@ export default defineWorkflow("publish-release") ].join("\n"), }); - const prReference = captureReleasePrReference(release, preparationVerification.releaseCommitOid); + const prReference = captureReleasePrReference(release, preparationVerification.releaseCommitOid, baseRef); if (!prReference.ok) { return blockedOutput( release, @@ -752,7 +1031,7 @@ export default defineWorkflow("publish-release") ].join("\n"), }); - const ciVerification = verifyReleasePrChecksPassed(release, prReference); + const ciVerification = verifyReleasePrChecksPassed(release, prReference, baseRef); if (!ciVerification.ok) { return blockedOutput( release, @@ -783,7 +1062,7 @@ export default defineWorkflow("publish-release") ].join("\n"), }); - const mergeVerification = verifyReleasePrMerged(release, prReference.prUrl, prReference.headRefOid); + const mergeVerification = verifyReleasePrMerged(release, prReference.prUrl, prReference.headRefOid, baseRef); if (!mergeVerification.ok) { return blockedOutput( release, @@ -795,7 +1074,7 @@ export default defineWorkflow("publish-release") const syncMain = await ctx.task("sync-main-after-merge", { prompt: [ - "Sync local main after the release PR merge. Do not create or push a tag.", + `Sync local ${baseRef} after the release PR merge. Do not create or push a tag.`, "", baseInstructions, "", @@ -803,29 +1082,29 @@ export default defineWorkflow("publish-release") excerpt(mergeVerification.summary), "", "Required actions:", - "1. Switch to `main` and run `git pull origin main`.", - `2. Confirm the merged release commit for ${release.version} is present on local main with command-backed evidence such as \`git rev-parse HEAD\` and \`git merge-base --is-ancestor ${mergeVerification.mergeCommitOid} HEAD\`.`, + `1. Switch to \`${baseRef}\` and run \`git pull origin ${baseRef}\`.`, + `2. Confirm the merged release commit for ${release.version} is present on local ${baseRef} with command-backed evidence such as \`git rev-parse HEAD\` and \`git merge-base --is-ancestor ${mergeVerification.mergeCommitOid} HEAD\`.`, `3. Confirm tag \`${release.version}\` does not already exist locally or on origin. Do not create the tag in this stage.`, "", "Final response format:", - "- Include local main HEAD, origin/main evidence, worktree status, tag existence checks, commands run, and any blockers.", - "- The workflow body performs a deterministic main/tag-readiness gate after this stage.", + `- Include local ${baseRef} HEAD, origin/${baseRef} evidence, worktree status, tag existence checks, commands run, and any blockers.`, + "- The workflow body performs a deterministic base-branch/tag-readiness gate after this stage.", ].join("\n"), }); - const mainReady = verifyMainReadyForTag(release, mergeVerification.mergeCommitOid); + const mainReady = verifyMainReadyForTag(release, mergeVerification.mergeCommitOid, baseRef); if (!mainReady.ok) { return blockedOutput( release, "verify-main-ready-for-tag", - "local main is clean, matches origin/main, contains the merge commit, and the release tag does not already exist", + `local ${baseRef} is clean, matches origin/${baseRef}, contains the merge commit, and the release tag does not already exist`, [mainReady.summary, "", "Sync-main stage output:", excerpt(syncMain.text, 2_000)].join("\n"), ); } - const pushTag = await ctx.task("push-release-tag", { + const pushTag = await ctx.task("cut-release-tag", { prompt: [ - "Create and push the release tag. This is the sole publish trigger stage.", + `Cut the release tag off ${baseRef}. This is the sole publish trigger stage. ${baseRef} is never bumped.`, "", baseInstructions, "", @@ -833,13 +1112,13 @@ export default defineWorkflow("publish-release") excerpt(mainReady.summary), "", "Required actions:", - `1. Verify you are still on clean local \`main\` at commit \`${mainReady.mainOid}\`.`, - `2. Run \`git tag ${release.version}\` and \`git push origin ${release.version}\`.`, - "3. Do not force-push or overwrite an existing tag.", + `1. Verify you are on clean local \`${baseRef}\` at commit \`${mainReady.mainOid}\`.`, + `2. Run \`bun run scripts/cut-release.ts ${release.version} --base ${baseRef} --push --yes\`. This stamps the real version onto a throwaway off-${baseRef} "Release ${release.version}" commit (parent = the current ${baseRef} commit), tags it, and pushes ONLY the tag.`, + `3. Do not push ${baseRef}. Do not force-push or overwrite an existing tag. Do not run scripts/bump-version.ts.`, "4. You may start monitoring the publish workflow, but the workflow body will verify the tag and publish run deterministically after this stage.", "", "Final response format:", - "- Include pushed tag, local/remote tag SHA evidence, GitHub Actions run URL/status if available, commands run, and any observed blockers.", + `- Include the pushed tag, the off-${baseRef} release commit SHA and its parent (which must equal the ${baseRef} commit above), local/remote tag SHA evidence, GitHub Actions run URL/status if available, commands run, and any observed blockers.`, ].join("\n"), }); @@ -848,8 +1127,8 @@ export default defineWorkflow("publish-release") return blockedOutput( release, "verify-release-tag-published", - "local and remote release tag exist and point to the verified main commit", - [tagVerification.summary, "", "Push-tag stage output:", excerpt(pushTag.text, 2_000)].join("\n"), + "local and remote release tag exist, the release commit parent is the verified main commit, and the tagged @bastani/atomic manifest carries the target version", + [tagVerification.summary, "", "Cut-release stage output:", excerpt(pushTag.text, 2_000)].join("\n"), "failed", ); } @@ -860,7 +1139,7 @@ export default defineWorkflow("publish-release") release, "verify-publish-workflow-succeeded", "GitHub Actions Publish run for the release tag has matching headSha, status completed, and conclusion success", - [publishVerification.summary, "", "Push-tag stage output:", excerpt(pushTag.text, 2_000)].join("\n"), + [publishVerification.summary, "", "Cut-release stage output:", excerpt(pushTag.text, 2_000)].join("\n"), "failed", ); } diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8f3eea35b..593b005f5 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -363,6 +363,14 @@ jobs: exit 1 fi + # main is versionless: it carries the 0.0.0 development placeholder and the + # real version is materialized only on a tagged "Release X" commit produced by + # scripts/cut-release.ts. Refuse to publish the placeholder if it is ever tagged. + if [ "$version" = "0.0.0" ] || [ "$version" = "0.0.0-dev" ]; then + echo "$PACKAGE_DIR/package.json is at the development placeholder ($version); refusing to publish. Cut a release with: bun run scripts/cut-release.ts " >&2 + exit 1 + fi + if [ "$RELEASE_TAG" != "$expected_tag" ]; then echo "Tag $RELEASE_TAG does not match $PACKAGE_DIR/package.json version $version (expected $expected_tag)." >&2 exit 1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 830715664..d8f4911a5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,7 +8,7 @@ permissions: on: push: - branches: [main] + branches: [main, "release/**", "prerelease/**"] pull_request: branches: [main] diff --git a/CLAUDE.md b/CLAUDE.md index 917af7c2d..358640b5d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,11 +116,21 @@ atomic: ## Releasing -Atomic mirrors pi's tag-driven release flow: bump versions locally, commit, push a `` git tag (no leading `v`, for example `0.8.24` or `0.8.24-alpha.1`), and CI publishes to npm with OIDC provenance and creates the GitHub Release with cross-compiled binaries attached. +Atomic uses a **versionless `main`** release flow (modeled on openai/codex): every `packages/*/package.json` on `main` stays at the `0.0.0` placeholder, and the real version is materialized **only** on a throwaway, off-`main` `Release ` commit that is tagged but never merged back. Pushing the `` tag (no leading `v`, for example `0.8.24` or `0.8.24-alpha.1`) triggers CI, which publishes to npm with OIDC provenance (stable `` → `@latest`, prerelease `-alpha.N` → `@next`) and creates the GitHub Release with cross-compiled binaries attached. Because `main` carries no version, you can cut a stable release and an ahead-of-stable prerelease line from the same trunk without branch gymnastics. + +Cut a release with `scripts/cut-release.ts`, which stamps the version onto the off-`main` tag commit and (with `--push`) pushes only the tag: + +```sh +bun run scripts/cut-release.ts 0.8.31 # stable -> @latest +bun run scripts/cut-release.ts 0.9.0-alpha.1 # prerelease -> @next +bun run scripts/cut-release.ts 0.8.31 --base main --push +``` + +`main` is never advanced; the script creates the release commit in a detached git worktree, tags it, and abandons the worktree (the tag keeps the commit alive). ### Agent publishing requests -If a user asks you to publish the package or create a release/prerelease, run the `publish-release` workflow using your workflow tool. +If a user asks you to publish the package or create a release/prerelease, run the `publish-release` workflow using your workflow tool. That workflow opens a CHANGELOG-only release-notes PR to `main`, then stamps and tags the release off-`main` via `cut-release.ts` — it never bumps the version on `main`. It also accepts an optional `base_ref` input (default `main`) to release from a maintenance/integration branch instead of `main`, and an optional `from_ref` input to cut an **ephemeral** release from any commit/tag/branch: the workflow auto-creates `release/` (or `prerelease/`) from that ref, gates on that branch's CI, cuts and publishes the tag, then deletes the branch (the changelog lives on the tag only; `main` is untouched). ## Docs @@ -158,17 +168,20 @@ Use these sections under `## [Unreleased]`: - **Internal changes (from issues)**: `Fixed foo bar ([#123](https://github.com/earendil-works/pi-mono/issues/123))` - **External contributions**: `Added feature X ([#456](https://github.com/earendil-works/pi-mono/pull/456) by [@username](https://github.com/username))` -## Bumping Versions +## Versionless main & bumping + +`main` is versionless: every `packages/*/package.json` (plus `bun.lock` workspace entries, the `@bastani/atomic-natives` dependency pin, `packages/natives/native/index.js` checks, and the Cargo manifests/lock) stays at the `0.0.0` placeholder. **Do not bump the version on `main`.** -Use the top-level `scripts/bump-version.ts` script to update every `packages/*/package.json` version and package README badge: +`scripts/bump-version.ts` is the low-level stamper that rewrites every versioned manifest. It is invoked by `scripts/cut-release.ts` inside a throwaway worktree to materialize the real version on the tagged release commit. You normally never run it directly against `main`; the only direct use is resetting the placeholder if it ever drifts: ```sh -# Explicit version -bun run scripts/bump-version.ts 0.1.0 -bun run scripts/bump-version.ts 0.1.0-alpha.1 -``` +# stamp a real version onto the off-main tag commit (preferred) +bun run scripts/cut-release.ts 0.1.0 +bun run scripts/cut-release.ts 0.1.0-alpha.1 -Run `bun install` afterward to refresh `bun.lock`. +# low-level: reset main back to the versionless placeholder +bun run scripts/bump-version.ts 0.0.0 && bun install +``` ## CI diff --git a/Cargo.lock b/Cargo.lock index c855bca43..f11c0f5c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "atomic-natives" -version = "0.8.31-alpha.5" +version = "0.0.0" dependencies = [ "bytes", "h2", diff --git a/Cargo.toml b/Cargo.toml index 9d2e1a189..b57e880ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/atomic-natives"] resolver = "3" [workspace.package] -version = "0.8.31-alpha.5" +version = "0.0.0" edition = "2024" license = "MIT" authors = ["Bastani"] diff --git a/DEV_SETUP.md b/DEV_SETUP.md index 99f868afe..d172283ef 100644 --- a/DEV_SETUP.md +++ b/DEV_SETUP.md @@ -240,21 +240,19 @@ Examples import the workspace package `@bastani/workflows`. ## Releasing -Atomic uses a tag-driven release flow: push a `` git tag (no leading `v`, for example `0.8.24` or `0.8.24-alpha.1`) and CI cross-compiles binaries, publishes to npm with OIDC provenance, and creates the GitHub Release with binaries attached. +Atomic uses a **versionless `main`** release flow: `main` stays at the `0.0.0` placeholder and the real version is materialized only on a throwaway, off-`main` `Release ` commit that is tagged but never merged. Pushing the `` tag (no leading `v`, for example `0.8.24` or `0.8.24-alpha.1`) makes CI cross-compile binaries, publish to npm with OIDC provenance, and create the GitHub Release with binaries attached. ### Workflow -1. Run `bun run scripts/bump-version.ts ` (e.g. `0.8.0` or `0.8.0-alpha.1`), then `bun install`. -2. Move the `[Unreleased]` section in `packages/coding-agent/CHANGELOG.md` to a new `## [] - ` section. CI extracts release notes from this section. -3. Run `bun run typecheck`, `cd packages/coding-agent && bun run build`, and `bun run test:all`. -4. Commit `packages/*/package.json`, `packages/*/README.md`, `packages/coding-agent/CHANGELOG.md`, and `bun.lock` with `chore(release): bump to `. -5. Tag and push: +1. Land the CHANGELOG move on `main` like any other change: move the `[Unreleased]` section in `packages/coding-agent/CHANGELOG.md` into a new `## [] - ` section (CI extracts release notes from it). **Do not bump any `package.json` version** — `main` is versionless. +2. From a clean `main`, cut the release. This stamps the version onto an off-`main` `Release ` commit, tags it, and pushes only the tag: ```sh - git tag - git push origin main - git push origin + bun run scripts/cut-release.ts --base main --push ``` -6. The tag push triggers `.github/workflows/publish.yml`, which publishes `@bastani/atomic` to npm with OIDC provenance and creates the GitHub Release with six binary archives attached (darwin/linux/windows × arm64/x64). + `main` is never advanced; the script does the stamp in a detached git worktree and abandons it (the tag keeps the commit alive). Omit `--push` to inspect the tag locally first, then `git push origin `. +3. The tag push triggers `.github/workflows/publish.yml`, which builds from the tagged (real-version) commit and publishes `@bastani/atomic` to npm with OIDC provenance — stable `` → `@latest`, prerelease `-alpha.N` → `@next` — and creates the GitHub Release with six binary archives attached (darwin/linux/windows × arm64/x64). + +To run the full guarded automation (release-notes PR + cut-release + publish monitoring), use the `publish-release` Atomic workflow instead of the manual steps above. Bun is the development/test/runtime path. **npm is still the registry publication tool** because npm's provenance flow signs the published tarball via OIDC. Provenance is enabled in CI; no `NPM_TOKEN` is needed. diff --git a/bun.lock b/bun.lock index 7595ce289..91f3c8cae 100644 --- a/bun.lock +++ b/bun.lock @@ -12,12 +12,12 @@ }, "packages/coding-agent": { "name": "@bastani/atomic", - "version": "0.8.31-alpha.5", + "version": "0.0.0", "bin": { "atomic": "dist/cli.js", }, "dependencies": { - "@bastani/atomic-natives": "0.8.31-alpha.1", + "@bastani/atomic-natives": "0.0.0", "@bufbuild/protobuf": "^2.0.0", "@earendil-works/pi-agent-core": "^0.79.7", "@earendil-works/pi-ai": "^0.79.7", @@ -66,9 +66,9 @@ }, "packages/cursor": { "name": "@bastani/cursor", - "version": "0.8.31-alpha.5", + "version": "0.0.0", "dependencies": { - "@bastani/atomic-natives": "0.8.31-alpha.5", + "@bastani/atomic-natives": "0.0.0", "@bufbuild/protobuf": "^2.0.0", }, "peerDependencies": { @@ -80,7 +80,7 @@ }, "packages/intercom": { "name": "@bastani/intercom", - "version": "0.8.31-alpha.5", + "version": "0.0.0", "dependencies": { "typebox": "^1.1.24", }, @@ -95,7 +95,7 @@ }, "packages/mcp": { "name": "@bastani/mcp", - "version": "0.8.31-alpha.5", + "version": "0.0.0", "dependencies": { "@modelcontextprotocol/ext-apps": "^1.7.2", "@modelcontextprotocol/sdk": "^1.25.1", @@ -117,14 +117,14 @@ }, "packages/natives": { "name": "@bastani/atomic-natives", - "version": "0.8.31-alpha.5", + "version": "0.0.0", "devDependencies": { "@napi-rs/cli": "3.7.0", }, }, "packages/subagents": { "name": "@bastani/subagents", - "version": "0.8.31-alpha.5", + "version": "0.0.0", "dependencies": { "jiti": "^2.7.0", "typebox": "^1.1.24", @@ -144,7 +144,7 @@ }, "packages/web-access": { "name": "@bastani/web-access", - "version": "0.8.31-alpha.5", + "version": "0.0.0", "dependencies": { "@mozilla/readability": "^0.6.0", "linkedom": "^0.18.12", @@ -163,7 +163,7 @@ }, "packages/workflows": { "name": "@bastani/workflows", - "version": "0.8.31-alpha.5", + "version": "0.0.0", "dependencies": { "jiti": "^2.7.0", "typebox": "^1.1.24", diff --git a/docs/ci.md b/docs/ci.md index ea13b2e3f..52f0809d1 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -131,19 +131,18 @@ The publish pipeline (`publish.yml`) runs when: | `..` (e.g. `0.8.0`) | `latest` | normal release, marked latest | | `..-` (e.g. `0.8.0-alpha.1`) | `next` | prerelease, not marked latest | -The tag must match `packages/coding-agent/package.json` exactly (no leading `v`). All `packages/*` package versions stay in sync via `scripts/bump-version.ts`. +`main` is **versionless**: every `packages/*/package.json` on `main` sits at the `0.0.0` placeholder. The real version exists only on the tagged, off-`main` `Release ` commit produced by `scripts/cut-release.ts`, where the tag matches `packages/coding-agent/package.json` exactly (no leading `v`) and all `packages/*` versions are stamped in sync. publish.yml checks out that tagged commit, so its `validate tag matches package.json` gate sees the real version, not the placeholder. The pipeline also refuses to publish the `0.0.0` placeholder if it is ever tagged directly. -### Version Bump +### Cutting a release (versionless main) -Use the top-level script: +`main` never carries a real version, so releasing does not bump `main`. Instead, `scripts/cut-release.ts` materializes the version on a throwaway, off-`main` `Release ` commit and tags it: ```sh -bun run scripts/bump-version.ts 0.8.0 -bun run scripts/bump-version.ts 0.8.0-alpha.1 -bun install +bun run scripts/cut-release.ts 0.8.0 --base main --push +bun run scripts/cut-release.ts 0.8.0-alpha.1 --base main --push ``` -The script updates every `packages/*/package.json` version and any package README version badge. Run `bun install` afterward so `bun.lock` records the same workspace versions. +Internally the script validates a clean tree, creates a detached `git worktree` at the base commit, stamps every versioned manifest with `scripts/bump-version.ts` (all `packages/*/package.json`, the `@bastani/atomic-natives` pin, `packages/natives/native/index.js`, and the Cargo manifests/lock), commits `Release `, tags it, removes the worktree, and pushes only the tag. `main` is never advanced and the tag's commit is the only place the real version lives. `bun.lock` keeps `main`'s `0.0.0` workspace placeholders — it is not shipped in the npm tarball and `bun install --frozen-lockfile` tolerates the version-string mismatch. ### Publish Flow @@ -282,16 +281,9 @@ The meaningful pre-publish checks are: ## Release Checklist -1. Bump versions on `main` (or a short-lived PR branch): +1. Move the `[Unreleased]` section in `packages/coding-agent/CHANGELOG.md` to `## [0.8.0] - ` and land it on `main` like any normal change. The publish workflow uses this section as the GitHub Release body. **Do not bump any `package.json` version — `main` is versionless.** - ```sh - bun run scripts/bump-version.ts 0.8.0 - bun install - ``` - -2. Move the `[Unreleased]` section in `packages/coding-agent/CHANGELOG.md` to `## [0.8.0] - `. The publish workflow uses this section as the GitHub Release body. - -3. Run local validation: +2. Run local validation (optional; CI repeats it from the tagged commit): ```sh bun run typecheck @@ -321,19 +313,16 @@ The meaningful pre-publish checks are: rm -rf "$tmpdir" ``` - On Windows, substitute `--platform windows-x64`, extract `atomic-windows-x64.zip`, and run `atomic.exe --version` plus the equivalent `atomic.exe --no-session` smoke. + On Windows, substitute `--platform windows-x64`, extract `atomic-windows-x64.zip`, and run `atomic.exe --version` plus the equivalent `atomic.exe --no-session` smoke. (A `main` build reports the `0.0.0` placeholder for `--version`; a release build from the tag reports the real version.) -4. Commit and tag: +3. From a clean `main`, cut and push the release tag. This stamps the version onto an off-`main` `Release 0.8.0` commit, tags it, and pushes only the tag (the publish trigger): ```sh - git add packages/*/package.json packages/coding-agent/CHANGELOG.md bun.lock - git add packages/*/README.md # only if the version bump script changed README badges - git commit -m "chore(release): bump to 0.8.0" - git tag 0.8.0 - git push origin main - git push origin 0.8.0 + bun run scripts/cut-release.ts 0.8.0 --base main --push ``` -5. Confirm `publish.yml` runs docs link validation plus Mintlify syntax and broken-link checks, cross-compiles binaries, publishes `@bastani/atomic-natives` and `@bastani/atomic` to npm with OIDC provenance, and creates the GitHub Release with binaries attached. + Omit `--push` to inspect the tag locally first (`git show 0.8.0`, `git log --oneline -1 0.8.0`), then `git push origin 0.8.0`. `main` is never advanced. + +4. Confirm `publish.yml` checks out the tag, runs docs link validation plus Mintlify syntax and broken-link checks, cross-compiles binaries, publishes `@bastani/atomic-natives` and `@bastani/atomic` to npm with OIDC provenance, and creates the GitHub Release with binaries attached. -For prereleases, substitute `0.8.0-alpha.1` and tag `0.8.0-alpha.1`. +For prereleases, substitute `0.8.0-alpha.1`. To run the fully guarded automation (release-notes PR + cut-release + publish monitoring) instead of these manual steps, use the `publish-release` Atomic workflow. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6e89e6e79..2d7b29438 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -19,6 +19,7 @@ - Bumped the bundled upstream pi runtime libraries `@earendil-works/pi-agent-core`, `@earendil-works/pi-ai`, and `@earendil-works/pi-tui` from `^0.79.6` to `^0.79.7` so Atomic inherits upstream v0.79.7 TUI color-scheme, Warp image, generated model catalog, and agent-core fixes. - Reserved `/` in theme names for automatic light/dark theme settings. - Replaced the bundled `browser` skill / `browse` CLI with the `playwright-cli` skill and `playwright-cli` command across `@bastani/atomic`, and bundled the new `effective-liteparse` document-extraction skill. The builtin `ralph`, `goal`, and `open-claude-design` workflows and the `debugger`/`codebase-online-researcher` subagents now drive browsers via `playwright-cli`; `open-claude-design`'s deterministic setup step ensures `playwright-cli` (`npm install -g @playwright/cli@latest`) and renames its `browse_cli_status` output to `playwright_cli_status`; and `ralph` now records a `playwright-cli` QA end-to-end proof video (`qa_video_path`) for UI-applicable/full-stack changes, references it in the implementation notes, and attaches or links it to the final pull request when `create_pr=true`. Updated the user-facing docs (workflows, SDK bash-policy examples, quickstart skills, README) to match. +- Switched the repository to a **versionless `main`** release model (modeled on openai/codex): every `packages/*/package.json` on `main` now stays at the `0.0.0` placeholder, and the real version is materialized only on a throwaway, off-`main` `Release ` commit created and tagged by the new top-level `scripts/cut-release.ts` (which stamps the version inside a detached git worktree via `scripts/bump-version.ts` and pushes only the tag — `main` is never bumped). This lets a stable release and an ahead-of-stable prerelease line be cut from the same trunk without release branches, mirroring how the npm `@latest`/`@next` dist-tags are derived from the tag shape. `publish.yml` still builds and publishes from the tagged (real-version) commit and now additionally refuses to publish the `0.0.0` placeholder, and the `publish-release` Atomic workflow now lands a CHANGELOG-only release-notes PR on `main` and then stamps/tags the release off-`main` via `cut-release.ts` instead of merging a version bump into `main`, accepts an optional `base_ref` input (default `main`) so a release can be cut from a maintenance/integration branch, and accepts an optional `from_ref` input that cuts an ephemeral release from any commit/tag/branch (auto-creating a CI-gated `release/`/`prerelease/` branch, cutting the tag off it, then deleting it — the changelog lives on the tag only). CI `test.yml` now also runs on `release/**`/`prerelease/**` pushes so those ephemeral branches are gated. End users installing from npm are unaffected; only local/`main` dev builds report `0.0.0` for `--version`. ### Fixed diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 5e60c5646..13cdcc07d 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,6 +1,6 @@ { "name": "@bastani/atomic", - "version": "0.8.31-alpha.5", + "version": "0.0.0", "description": "Atomic coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "atomicConfig": { @@ -68,7 +68,7 @@ "prepublishOnly": "bun run clean && bun run build" }, "dependencies": { - "@bastani/atomic-natives": "0.8.31-alpha.5", + "@bastani/atomic-natives": "0.0.0", "@bufbuild/protobuf": "^2.0.0", "@earendil-works/pi-agent-core": "^0.79.7", "@earendil-works/pi-ai": "^0.79.7", diff --git a/packages/cursor/package.json b/packages/cursor/package.json index ed322c83d..848bfb60a 100644 --- a/packages/cursor/package.json +++ b/packages/cursor/package.json @@ -1,6 +1,6 @@ { "name": "@bastani/cursor", - "version": "0.8.31-alpha.5", + "version": "0.0.0", "private": true, "description": "Experimental first-party Atomic extension for Cursor OAuth, model discovery, and streaming provider registration.", "contributors": [ @@ -40,7 +40,7 @@ } }, "dependencies": { - "@bastani/atomic-natives": "0.8.31-alpha.5", + "@bastani/atomic-natives": "0.0.0", "@bufbuild/protobuf": "^2.0.0" } } diff --git a/packages/intercom/package.json b/packages/intercom/package.json index d6a09e432..e4aa9de67 100644 --- a/packages/intercom/package.json +++ b/packages/intercom/package.json @@ -1,6 +1,6 @@ { "name": "@bastani/intercom", - "version": "0.8.31-alpha.5", + "version": "0.0.0", "private": true, "description": "Atomic extension providing a private coordination channel between parent and child agent sessions. Fork of: https://github.com/nicobailon/pi-intercom", "contributors": [ diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 256a1f436..39d631c95 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@bastani/mcp", - "version": "0.8.31-alpha.5", + "version": "0.0.0", "private": true, "description": "Atomic extension that adapts MCP (Model Context Protocol) servers into the coding agent. Fork of: https://github.com/nicobailon/pi-mcp-adapter", "contributors": [ diff --git a/packages/natives/native/index.js b/packages/natives/native/index.js index 2c150598e..e7ef52efa 100644 --- a/packages/natives/native/index.js +++ b/packages/natives/native/index.js @@ -77,8 +77,8 @@ function requireNative() { try { const binding = require('@bastani/atomic-natives-android-arm64') const bindingPackageVersion = require('@bastani/atomic-natives-android-arm64/package.json').version - if (bindingPackageVersion !== '0.8.31-alpha.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.8.31-alpha.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -93,8 +93,8 @@ function requireNative() { try { const binding = require('@bastani/atomic-natives-android-arm-eabi') const bindingPackageVersion = require('@bastani/atomic-natives-android-arm-eabi/package.json').version - if (bindingPackageVersion !== '0.8.31-alpha.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.8.31-alpha.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -114,8 +114,8 @@ function requireNative() { try { const binding = require('@bastani/atomic-natives-win32-x64-gnu') const bindingPackageVersion = require('@bastani/atomic-natives-win32-x64-gnu/package.json').version - if (bindingPackageVersion !== '0.8.31-alpha.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.8.31-alpha.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -130,8 +130,8 @@ function requireNative() { try { const binding = require('@bastani/atomic-natives-win32-x64-msvc') const bindingPackageVersion = require('@bastani/atomic-natives-win32-x64-msvc/package.json').version - if (bindingPackageVersion !== '0.8.31-alpha.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.8.31-alpha.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -147,8 +147,8 @@ function requireNative() { try { const binding = require('@bastani/atomic-natives-win32-ia32-msvc') const bindingPackageVersion = require('@bastani/atomic-natives-win32-ia32-msvc/package.json').version - if (bindingPackageVersion !== '0.8.31-alpha.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.8.31-alpha.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -163,8 +163,8 @@ function requireNative() { try { const binding = require('@bastani/atomic-natives-win32-arm64-msvc') const bindingPackageVersion = require('@bastani/atomic-natives-win32-arm64-msvc/package.json').version - if (bindingPackageVersion !== '0.8.31-alpha.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.8.31-alpha.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -182,8 +182,8 @@ function requireNative() { try { const binding = require('@bastani/atomic-natives-darwin-universal') const bindingPackageVersion = require('@bastani/atomic-natives-darwin-universal/package.json').version - if (bindingPackageVersion !== '0.8.31-alpha.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.8.31-alpha.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -198,8 +198,8 @@ function requireNative() { try { const binding = require('@bastani/atomic-natives-darwin-x64') const bindingPackageVersion = require('@bastani/atomic-natives-darwin-x64/package.json').version - if (bindingPackageVersion !== '0.8.31-alpha.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.8.31-alpha.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -214,8 +214,8 @@ function requireNative() { try { const binding = require('@bastani/atomic-natives-darwin-arm64') const bindingPackageVersion = require('@bastani/atomic-natives-darwin-arm64/package.json').version - if (bindingPackageVersion !== '0.8.31-alpha.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.8.31-alpha.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -234,8 +234,8 @@ function requireNative() { try { const binding = require('@bastani/atomic-natives-freebsd-x64') const bindingPackageVersion = require('@bastani/atomic-natives-freebsd-x64/package.json').version - if (bindingPackageVersion !== '0.8.31-alpha.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.8.31-alpha.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -250,8 +250,8 @@ function requireNative() { try { const binding = require('@bastani/atomic-natives-freebsd-arm64') const bindingPackageVersion = require('@bastani/atomic-natives-freebsd-arm64/package.json').version - if (bindingPackageVersion !== '0.8.31-alpha.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.8.31-alpha.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -271,8 +271,8 @@ function requireNative() { try { const binding = require('@bastani/atomic-natives-linux-x64-musl') const bindingPackageVersion = require('@bastani/atomic-natives-linux-x64-musl/package.json').version - if (bindingPackageVersion !== '0.8.31-alpha.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.8.31-alpha.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -287,8 +287,8 @@ function requireNative() { try { const binding = require('@bastani/atomic-natives-linux-x64-gnu') const bindingPackageVersion = require('@bastani/atomic-natives-linux-x64-gnu/package.json').version - if (bindingPackageVersion !== '0.8.31-alpha.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.8.31-alpha.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -305,8 +305,8 @@ function requireNative() { try { const binding = require('@bastani/atomic-natives-linux-arm64-musl') const bindingPackageVersion = require('@bastani/atomic-natives-linux-arm64-musl/package.json').version - if (bindingPackageVersion !== '0.8.31-alpha.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.8.31-alpha.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -321,8 +321,8 @@ function requireNative() { try { const binding = require('@bastani/atomic-natives-linux-arm64-gnu') const bindingPackageVersion = require('@bastani/atomic-natives-linux-arm64-gnu/package.json').version - if (bindingPackageVersion !== '0.8.31-alpha.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.8.31-alpha.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -339,8 +339,8 @@ function requireNative() { try { const binding = require('@bastani/atomic-natives-linux-arm-musleabihf') const bindingPackageVersion = require('@bastani/atomic-natives-linux-arm-musleabihf/package.json').version - if (bindingPackageVersion !== '0.8.31-alpha.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.8.31-alpha.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -355,8 +355,8 @@ function requireNative() { try { const binding = require('@bastani/atomic-natives-linux-arm-gnueabihf') const bindingPackageVersion = require('@bastani/atomic-natives-linux-arm-gnueabihf/package.json').version - if (bindingPackageVersion !== '0.8.31-alpha.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.8.31-alpha.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -373,8 +373,8 @@ function requireNative() { try { const binding = require('@bastani/atomic-natives-linux-loong64-musl') const bindingPackageVersion = require('@bastani/atomic-natives-linux-loong64-musl/package.json').version - if (bindingPackageVersion !== '0.8.31-alpha.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.8.31-alpha.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -389,8 +389,8 @@ function requireNative() { try { const binding = require('@bastani/atomic-natives-linux-loong64-gnu') const bindingPackageVersion = require('@bastani/atomic-natives-linux-loong64-gnu/package.json').version - if (bindingPackageVersion !== '0.8.31-alpha.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.8.31-alpha.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -407,8 +407,8 @@ function requireNative() { try { const binding = require('@bastani/atomic-natives-linux-riscv64-musl') const bindingPackageVersion = require('@bastani/atomic-natives-linux-riscv64-musl/package.json').version - if (bindingPackageVersion !== '0.8.31-alpha.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.8.31-alpha.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -423,8 +423,8 @@ function requireNative() { try { const binding = require('@bastani/atomic-natives-linux-riscv64-gnu') const bindingPackageVersion = require('@bastani/atomic-natives-linux-riscv64-gnu/package.json').version - if (bindingPackageVersion !== '0.8.31-alpha.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.8.31-alpha.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -440,8 +440,8 @@ function requireNative() { try { const binding = require('@bastani/atomic-natives-linux-ppc64-gnu') const bindingPackageVersion = require('@bastani/atomic-natives-linux-ppc64-gnu/package.json').version - if (bindingPackageVersion !== '0.8.31-alpha.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.8.31-alpha.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -456,8 +456,8 @@ function requireNative() { try { const binding = require('@bastani/atomic-natives-linux-s390x-gnu') const bindingPackageVersion = require('@bastani/atomic-natives-linux-s390x-gnu/package.json').version - if (bindingPackageVersion !== '0.8.31-alpha.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.8.31-alpha.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -476,8 +476,8 @@ function requireNative() { try { const binding = require('@bastani/atomic-natives-openharmony-arm64') const bindingPackageVersion = require('@bastani/atomic-natives-openharmony-arm64/package.json').version - if (bindingPackageVersion !== '0.8.31-alpha.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.8.31-alpha.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -492,8 +492,8 @@ function requireNative() { try { const binding = require('@bastani/atomic-natives-openharmony-x64') const bindingPackageVersion = require('@bastani/atomic-natives-openharmony-x64/package.json').version - if (bindingPackageVersion !== '0.8.31-alpha.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.8.31-alpha.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -508,8 +508,8 @@ function requireNative() { try { const binding = require('@bastani/atomic-natives-openharmony-arm') const bindingPackageVersion = require('@bastani/atomic-natives-openharmony-arm/package.json').version - if (bindingPackageVersion !== '0.8.31-alpha.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.8.31-alpha.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { diff --git a/packages/natives/package.json b/packages/natives/package.json index e97b7bc29..734868c13 100644 --- a/packages/natives/package.json +++ b/packages/natives/package.json @@ -1,6 +1,6 @@ { "name": "@bastani/atomic-natives", - "version": "0.8.31-alpha.5", + "version": "0.0.0", "description": "Native Rust bindings for Atomic via N-API", "homepage": "https://github.com/bastani-inc/atomic", "author": "Bastani", diff --git a/packages/subagents/package.json b/packages/subagents/package.json index 87c54750b..bd738e88b 100644 --- a/packages/subagents/package.json +++ b/packages/subagents/package.json @@ -1,6 +1,6 @@ { "name": "@bastani/subagents", - "version": "0.8.31-alpha.5", + "version": "0.0.0", "private": true, "description": "Atomic extension for delegating tasks to subagents with chains, parallel execution, and TUI clarification. Fork of: https://github.com/nicobailon/pi-subagents", "contributors": [ diff --git a/packages/web-access/package.json b/packages/web-access/package.json index 6d36299a5..9ee2c4267 100644 --- a/packages/web-access/package.json +++ b/packages/web-access/package.json @@ -1,6 +1,6 @@ { "name": "@bastani/web-access", - "version": "0.8.31-alpha.5", + "version": "0.0.0", "private": true, "description": "Atomic extension for web search, URL fetching, GitHub repo cloning, PDF/video extraction. Fork of: https://github.com/nicobailon/pi-web-access", "contributors": [ diff --git a/packages/workflows/package.json b/packages/workflows/package.json index d0856317e..2aa612232 100644 --- a/packages/workflows/package.json +++ b/packages/workflows/package.json @@ -1,6 +1,6 @@ { "name": "@bastani/workflows", - "version": "0.8.31-alpha.5", + "version": "0.0.0", "private": true, "description": "Atomic extension for multi-stage workflow authoring and execution.", "contributors": [ diff --git a/scripts/cut-release.ts b/scripts/cut-release.ts new file mode 100644 index 000000000..202d894ef --- /dev/null +++ b/scripts/cut-release.ts @@ -0,0 +1,187 @@ +#!/usr/bin/env bun +/** + * Cut a release without ever moving the working branch. + * + * Atomic keeps `main` versionless: every package manifest (plus the + * lockfile, the native binding checks, and README badges) sits at the `0.0.0` + * placeholder. The real version is materialized **only** on a throwaway + * `Release ` commit that is created off the chosen base, tagged, and + * then abandoned. The commit is reachable solely through the tag — it is never + * merged back into `main`. This mirrors how openai/codex tags releases. + * + * Mechanically: + * 1. validate the version + a clean working tree + * 2. `git worktree add --detach ` (default base: current HEAD) + * 3. stamp the real version into the worktree via scripts/bump-version.ts + * (bun.lock keeps main's 0.0.0 placeholders; `bun install --frozen-lockfile` + * tolerates the workspace version-string mismatch, so the lockfile is left as-is) + * 4. commit `Release ` and tag `` inside the worktree + * 5. remove the worktree — the tag (and its commit) persist in the repo + * + * Because publish.yml checks out the *tagged commit* (which now carries the + * real version) every existing version validation passes unchanged. + * + * Usage: + * bun run scripts/cut-release.ts [--base ] [--push] [--yes] + * + * Examples: + * bun run scripts/cut-release.ts 0.8.31 + * bun run scripts/cut-release.ts 0.9.0-alpha.1 + * bun run scripts/cut-release.ts 0.8.31 --base main --push + */ + +import { $ } from "bun"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +const STRICT_RELEASE_VERSION_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-alpha\.([1-9]\d*))?$/; +const PLACEHOLDER_VERSIONS = new Set(["0.0.0", "0.0.0-dev"]); + +const ROOT = resolve(import.meta.dir, ".."); + +interface Options { + version: string; + base: string | undefined; + push: boolean; + yes: boolean; +} + +function parseArgs(): Options { + const argv = process.argv.slice(2); + let version: string | undefined; + let base: string | undefined; + let push = false; + let yes = false; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--base") { + base = argv[++i]; + } else if (arg === "--push") { + push = true; + } else if (arg === "--yes" || arg === "-y") { + yes = true; + } else if (arg.startsWith("-")) { + fail(`Unknown flag: ${arg}`); + } else if (version === undefined) { + version = arg; + } else { + fail(`Unexpected extra argument: ${arg}`); + } + } + + if (!version) { + fail("Usage: bun run scripts/cut-release.ts [--base ] [--push] [--yes]"); + } + + return { version: version as string, base, push, yes }; +} + +function fail(message: string): never { + console.error(`Error: ${message}`); + process.exit(1); +} + +function validateVersion(version: string): void { + if (PLACEHOLDER_VERSIONS.has(version)) { + fail(`"${version}" is the development placeholder and must never be released.`); + } + if (!STRICT_RELEASE_VERSION_RE.test(version)) { + fail( + `"${version}" is not a valid release version. Expected MAJOR.MINOR.PATCH or MAJOR.MINOR.PATCH-alpha.REVISION (e.g. 0.8.31 or 0.9.0-alpha.1).`, + ); + } +} + +async function gitText(args: string[], cwd: string = ROOT): Promise { + return (await $`git -C ${cwd} ${args}`.text()).trim(); +} + +async function main(): Promise { + const { version, base, push, yes } = parseArgs(); + validateVersion(version); + + // Refuse to operate on a dirty tree — the worktree is created from committed + // state, so uncommitted edits would silently be excluded from the release. + const dirty = await gitText(["status", "--porcelain"]); + if (dirty) { + fail("Working tree is not clean. Commit or stash changes before cutting a release."); + } + + // The tag is the release. Never clobber an existing one. + const existingTag = await $`git -C ${ROOT} tag --list ${version}`.text(); + if (existingTag.trim()) { + fail(`Tag ${version} already exists.`); + } + + await $`git -C ${ROOT} worktree prune`.quiet(); + + const baseRef = base ?? "HEAD"; + const baseSha = await gitText(["rev-parse", "--verify", `${baseRef}^{commit}`]).catch(() => { + return fail(`Base ref "${baseRef}" could not be resolved.`); + }); + const branch = await gitText(["rev-parse", "--abbrev-ref", "HEAD"]); + + const name = (await $`git -C ${ROOT} config user.name`.nothrow().text()).trim() || "atomic-release"; + const email = + (await $`git -C ${ROOT} config user.email`.nothrow().text()).trim() || + "atomic-release@users.noreply.github.com"; + + console.log(`Cutting release ${version}`); + console.log(` base: ${baseRef} (${baseSha.slice(0, 9)})`); + console.log(` branch: ${branch} (left untouched)\n`); + + if (!yes) { + console.log("Pass --yes to skip this notice. Proceeding in 1.5s...\n"); + await Bun.sleep(1500); + } + + const tmpRoot = mkdtempSync(join(tmpdir(), "atomic-release-")); + const worktreeDir = join(tmpRoot, "wt"); + let worktreeAdded = false; + + try { + await $`git -C ${ROOT} worktree add --detach ${worktreeDir} ${baseSha}`.quiet(); + worktreeAdded = true; + + // Stamp the real version into the detached worktree only. + await $`bun run ${join(ROOT, "scripts/bump-version.ts")} ${version} --root ${worktreeDir}`; + + // bun.lock intentionally keeps main's 0.0.0 workspace placeholders: it is not + // shipped in the npm tarball and `bun install --frozen-lockfile` tolerates the + // mismatch, so there is no need to relock (which also avoids a network round-trip). + await $`git -C ${worktreeDir} add -A`; + await $`git -C ${worktreeDir} -c user.name=${name} -c user.email=${email} commit --no-verify -m ${`Release ${version}`}`.quiet(); + // Lightweight tag, matching the repo's publish trigger + verification convention. + await $`git -C ${worktreeDir} -c user.name=${name} -c user.email=${email} tag ${version}`.quiet(); + } finally { + if (worktreeAdded) { + await $`git -C ${ROOT} worktree remove --force ${worktreeDir}`.nothrow().quiet(); + } + rmSync(tmpRoot, { recursive: true, force: true }); + } + + // Sanity-check the tagged tree carries the real version (and main does not). + const taggedVersion = JSON.parse( + await $`git -C ${ROOT} show ${`${version}:packages/coding-agent/package.json`}`.text(), + ).version as string; + if (taggedVersion !== version) { + fail(`Tagged commit version ${taggedVersion} does not match ${version} — aborting.`); + } + + const tagSha = await gitText(["rev-list", "-n", "1", version]); + console.log(`\nCreated tag ${version} -> ${tagSha.slice(0, 9)} (Release ${version})`); + console.log(`${branch} stays versionless; the release commit lives only on the tag.\n`); + + if (push) { + console.log(`Pushing tag ${version}...`); + await $`git -C ${ROOT} push origin ${version}`; + console.log("Done. CI publish.yml will build and publish from the tag."); + } else { + console.log("Next: push the tag to trigger the publish pipeline:"); + console.log(` git push origin ${version}`); + } +} + +await main();