Add release prepare and preflight scripts

This commit is contained in:
murashit 2026-05-15 10:24:10 +09:00
parent 39c48f5a62
commit ddf155f917
4 changed files with 159 additions and 4 deletions

View file

@ -113,17 +113,21 @@ The generation script uses `codex app-server generate-ts --experimental` because
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.
Create a release by bumping `package.json`, `package-lock.json`, `manifest.json`, and `versions.json`, adding hand-written notes at `.github/release-notes/X.Y.Z.md`, then checking the release metadata, lockfile, and build before pushing the matching tag:
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:check
npm ci --dry-run
npm run check
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"
npm run release:preflight
git tag X.Y.Z
git push origin main 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`.
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`.
## License

View file

@ -22,6 +22,8 @@
"generate:app-server-types": "codex app-server generate-ts --experimental --out src/generated/app-server && node scripts/normalize-generated-types.mjs",
"lint": "eslint src tests --max-warnings=0",
"release:check": "node scripts/check-release.mjs",
"release:preflight": "node scripts/preflight-release.mjs",
"release:prepare": "node scripts/prepare-release.mjs",
"test": "vitest run",
"typecheck": "tsc -p tsconfig.json --noEmit",
"check": "npm run typecheck && npm run test && npm run lint && npm run format:check && npm run build:prod"

View file

@ -0,0 +1,58 @@
import { spawnSync } from "node:child_process";
function fail(message) {
console.error(`release preflight failed: ${message}`);
process.exit(1);
}
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
encoding: "utf8",
stdio: options.capture ? "pipe" : "inherit",
shell: false,
});
if (result.error) fail(`${command} ${args.join(" ")}: ${result.error.message}`);
if (result.status !== 0) {
if (options.capture) {
const output = `${result.stdout || ""}${result.stderr || ""}`.trim();
if (output) console.error(output);
}
fail(`${command} ${args.join(" ")} exited with ${result.status}`);
}
return options.capture ? result.stdout.trim() : "";
}
function maybeRun(command, args) {
const result = spawnSync(command, args, {
encoding: "utf8",
stdio: "pipe",
shell: false,
});
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)"}`);
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 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 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`);
run("npm", ["run", "release:check"]);
run("npm", ["ci", "--dry-run"]);
run("npm", ["run", "check"]);
console.log(`release preflight passed for ${packageVersion}`);

View file

@ -0,0 +1,91 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
function fail(message) {
console.error(`release prepare failed: ${message}`);
process.exit(1);
}
async function readJson(file) {
return JSON.parse(await readFile(file, "utf8"));
}
async function writeJson(file, value) {
await writeFile(file, `${JSON.stringify(value, null, 2)}\n`);
}
function parseVersion(version) {
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
if (!match) return null;
return {
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3]),
};
}
function compareVersions(a, b) {
return a.major - b.major || a.minor - b.minor || a.patch - b.patch;
}
function isExpectedNextVersion(previous, current) {
if (current.major === previous.major && current.minor === previous.minor) {
return current.patch === previous.patch + 1;
}
if (current.major === previous.major && current.minor === previous.minor + 1) {
return current.patch === 0;
}
if (current.major === previous.major + 1) {
return current.minor === 0 && current.patch === 0;
}
return false;
}
const releaseVersion = process.argv[2];
if (!releaseVersion) fail("usage: npm run release:prepare -- X.Y.Z");
const currentVersion = parseVersion(releaseVersion);
if (!currentVersion) fail(`release version must be X.Y.Z, got ${releaseVersion}`);
const packageJson = await readJson("package.json");
const packageLockJson = await readJson("package-lock.json");
const manifestJson = await readJson("manifest.json");
const versionsJson = await readJson("versions.json");
const versionKeys = Object.keys(versionsJson);
const previousVersionKey = versionKeys.at(-1);
const previousVersion = parseVersion(previousVersionKey);
if (!previousVersion) fail(`versions.json contains invalid latest version ${previousVersionKey}`);
if (compareVersions(previousVersion, currentVersion) >= 0) {
fail(`release version ${releaseVersion} must be newer than ${previousVersionKey}`);
}
if (!isExpectedNextVersion(previousVersion, currentVersion)) {
fail(`release version ${releaseVersion} is not the expected next version after ${previousVersionKey}`);
}
if (versionsJson[releaseVersion] !== undefined) fail(`versions.json already contains ${releaseVersion}`);
packageJson.version = releaseVersion;
packageLockJson.version = releaseVersion;
if (!packageLockJson.packages?.[""]) fail('package-lock.json is missing packages[""]');
packageLockJson.packages[""].version = releaseVersion;
manifestJson.version = releaseVersion;
versionsJson[releaseVersion] = manifestJson.minAppVersion;
await writeJson("package.json", packageJson);
await writeJson("package-lock.json", packageLockJson);
await writeJson("manifest.json", manifestJson);
await writeJson("versions.json", versionsJson);
const notesDir = path.join(".github", "release-notes");
const notesPath = path.join(notesDir, `${releaseVersion}.md`);
await mkdir(notesDir, { recursive: true });
try {
await readFile(notesPath, "utf8");
fail(`${notesPath} already exists`);
} catch (error) {
if (error.code !== "ENOENT") throw error;
}
await writeFile(notesPath, "## Changes\n\n- \n");
console.log(`prepared release ${releaseVersion}`);
console.log(`edit ${notesPath}, then run npm run release:preflight after committing`);