diff --git a/README.md b/README.md index 44f54861..d8e27eab 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/package.json b/package.json index 2eaf9006..d036f27c 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/scripts/preflight-release.mjs b/scripts/preflight-release.mjs new file mode 100644 index 00000000..6a70b9a7 --- /dev/null +++ b/scripts/preflight-release.mjs @@ -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}`); diff --git a/scripts/prepare-release.mjs b/scripts/prepare-release.mjs new file mode 100644 index 00000000..edf48d9e --- /dev/null +++ b/scripts/prepare-release.mjs @@ -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`);