From ecfea49b3a9b3d9f55848dcd3ea93e016d2a7afd Mon Sep 17 00:00:00 2001 From: murashit Date: Wed, 20 May 2026 14:12:19 +0900 Subject: [PATCH] Add Jujutsu release workflow --- .gitignore | 1 + docs/release.md | 17 +++++---- scripts/preflight-release.mjs | 67 +++++++++++++++++++++++++++-------- 3 files changed, 64 insertions(+), 21 deletions(-) diff --git a/.gitignore b/.gitignore index 444a9551..66a929ff 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ +.jj/ main.js data.json *.log diff --git a/docs/release.md b/docs/release.md index aef805be..10334d7e 100644 --- a/docs/release.md +++ b/docs/release.md @@ -2,19 +2,22 @@ GitHub Releases attach only `main.js`, `manifest.json`, and `styles.css` as Obsidian install assets. `LICENSE` and `NOTICE` are kept in the repository and source archives for license distribution. +Release work is Jujutsu-first in a colocated Git repository; Git is still used to push the tag that triggers the GitHub Release workflow. If the checkout has not been initialized for Jujutsu yet, run `jj git init --colocate` and `jj bookmark track main --remote=origin` once. + Create a release by preparing the next version, editing the generated release notes, committing the release changes, then running the preflight before pushing the matching tag: ```sh npm run release:prepare -- X.Y.Z # Edit .github/release-notes/X.Y.Z.md. -git status --short -git add package.json package-lock.json manifest.json versions.json .github/release-notes/X.Y.Z.md -git commit -m "Bump version to X.Y.Z" +jj status +jj commit -m "Bump version to X.Y.Z" +jj bookmark move main --to @- npm run release:preflight -git tag X.Y.Z -git push origin main X.Y.Z +jj tag set X.Y.Z -r main +jj git push --remote origin --bookmark main +git push origin X.Y.Z ``` -`release:prepare` updates the version files and creates a `## Changes` release notes template. `release:preflight` verifies the local Git state, release metadata, lockfile, and full build once after the release commit is on `main`. +`release:prepare` updates the version files and creates a `## Changes` release notes template. `release:preflight` verifies the local Jujutsu/Git state, release metadata, lockfile, and full build once after the release commit is on `main`. -The release workflow runs `npm ci`, `npm run release:check`, `npm run check`, attaches the install assets, and generates GitHub artifact attestations for them. The release notes file is required and must contain a single `## Changes` section. If a tag-triggered release fails before creating the GitHub Release, fix the commit, move the local tag with `git tag -f X.Y.Z`, then update the remote tag with `git push --force origin X.Y.Z`. +The release workflow runs `npm ci`, `npm run release:check`, `npm run check`, attaches the install assets, and generates GitHub artifact attestations for them. The release notes file is required and must contain a single `## Changes` section. If a tag-triggered release fails before creating the GitHub Release, fix the commit, move the local tag with `jj tag set --allow-move -r main X.Y.Z`, then update the remote tag with `git push --force origin X.Y.Z`. diff --git a/scripts/preflight-release.mjs b/scripts/preflight-release.mjs index 6a70b9a7..2d51b410 100644 --- a/scripts/preflight-release.mjs +++ b/scripts/preflight-release.mjs @@ -31,25 +31,64 @@ function maybeRun(command, args) { return result.status === 0 ? result.stdout.trim() : null; } -const branch = run("git", ["branch", "--show-current"], { capture: true }); -if (branch !== "main") fail(`release must be prepared from main, got ${branch || "(detached HEAD)"}`); +function assertGitReleaseState(packageVersion) { + const branch = run("git", ["branch", "--show-current"], { capture: true }); + if (branch !== "main") fail(`release must be prepared from main, got ${branch || "(detached HEAD)"}`); -const status = run("git", ["status", "--short"], { capture: true }); -if (status) fail(`working tree must be clean before release preflight\n${status}`); + const status = run("git", ["status", "--short"], { capture: true }); + if (status) fail(`working tree must be clean before release preflight\n${status}`); -const upstream = maybeRun("git", ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]); -if (!upstream) fail("main must have an upstream tracking branch"); + const upstream = maybeRun("git", ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]); + if (!upstream) fail("main must have an upstream tracking branch"); -const upstreamAncestor = spawnSync("git", ["merge-base", "--is-ancestor", upstream, "HEAD"], { - encoding: "utf8", - stdio: "pipe", - shell: false, -}); -if (upstreamAncestor.status !== 0) fail(`HEAD must contain ${upstream}; pull or rebase before tagging`); + const upstreamAncestor = spawnSync("git", ["merge-base", "--is-ancestor", upstream, "HEAD"], { + encoding: "utf8", + stdio: "pipe", + shell: false, + }); + if (upstreamAncestor.status !== 0) fail(`HEAD must contain ${upstream}; pull or rebase before tagging`); + + const existingTag = run("git", ["tag", "--list", packageVersion], { capture: true }); + if (existingTag) fail(`local tag ${packageVersion} already exists`); +} + +function jjCommitId(revision) { + return run("jj", ["log", "-r", revision, "--no-graph", "-T", 'commit_id ++ "\\n"'], { capture: true }); +} + +function jjRevisionCount(revision) { + return Number(run("jj", ["log", "-r", revision, "--count"], { capture: true })); +} + +function assertJujutsuReleaseState(packageVersion) { + const workingCopyDiff = run("jj", ["diff", "--summary", "-r", "@"], { capture: true }); + if (workingCopyDiff) fail(`Jujutsu working-copy commit must be empty before release preflight\n${workingCopyDiff}`); + + const mainCommit = jjCommitId("main"); + const workingCopyParent = jjCommitId("@-"); + if (mainCommit !== workingCopyParent) { + fail("main bookmark must point to the release commit and be the parent of the empty working-copy commit"); + } + + const existingTag = run("jj", ["tag", "list", packageVersion], { capture: true }); + if (existingTag) fail(`local tag ${packageVersion} already exists`); + + if (jjRevisionCount("main@origin & ::main") !== 1) { + fail("main@origin must be an ancestor of main; fetch or rebase before tagging"); + } + + const unpushedCommits = jjRevisionCount("main@origin..main"); + if (unpushedCommits !== 1) { + fail(`main must be exactly one release commit ahead of main@origin, got ${unpushedCommits}`); + } +} const packageVersion = run("node", ["-p", "require('./package.json').version"], { capture: true }); -const existingTag = run("git", ["tag", "--list", packageVersion], { capture: true }); -if (existingTag) fail(`local tag ${packageVersion} already exists`); +if (maybeRun("jj", ["root"])) { + assertJujutsuReleaseState(packageVersion); +} else { + assertGitReleaseState(packageVersion); +} run("npm", ["run", "release:check"]); run("npm", ["ci", "--dry-run"]);