Add Jujutsu release workflow

This commit is contained in:
murashit 2026-05-20 14:12:19 +09:00
parent d59a307f8a
commit ecfea49b3a
3 changed files with 64 additions and 21 deletions

1
.gitignore vendored
View file

@ -1,4 +1,5 @@
node_modules/
.jj/
main.js
data.json
*.log

View file

@ -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`.

View file

@ -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"]);