From b6ed0a60705df3674b49d6488c5bdeeeba4eb6c4 Mon Sep 17 00:00:00 2001 From: aose-yuu Date: Mon, 2 Nov 2026 20:02:39 +0900 Subject: [PATCH 1/5] chore: limit pre-commit checks to staged files --- .vite-hooks/pre-commit | 1 - 1 file changed, 1 deletion(-) diff --git a/.vite-hooks/pre-commit b/.vite-hooks/pre-commit index 0ef6019..1fab909 100755 --- a/.vite-hooks/pre-commit +++ b/.vite-hooks/pre-commit @@ -1,4 +1,3 @@ #!/bin/sh set -e vp staged -vp test run From eef2350200ff02e93b94d01380e4450d473b2b07 Mon Sep 17 00:00:00 2001 From: aose-yuu Date: Mon, 2 Nov 2026 20:02:39 +0900 Subject: [PATCH 2/5] fix: validate release preparation and retry publication safely --- .github/workflows/publish.yml | 28 +-- changelog.config.ts | 22 -- package.json | 5 +- pnpm-lock.yaml | 3 + scripts/push-release.sh | 9 - scripts/release-version.sh | 16 -- scripts/release.js | 229 ++++++++++++++++++ scripts/release.test.js | 421 ++++++++++++++++++++++++++++++++++ 8 files changed, 660 insertions(+), 73 deletions(-) delete mode 100644 changelog.config.ts delete mode 100644 scripts/push-release.sh delete mode 100644 scripts/release-version.sh create mode 100644 scripts/release.js create mode 100644 scripts/release.test.js diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 415b229..2d6a7ba 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -13,34 +13,14 @@ jobs: concurrency: publish-${{ github.ref }} steps: - uses: actions/checkout@v6 + with: + fetch-depth: 0 - uses: voidzero-dev/setup-vp@cab1ab9eaff52d4857875f4f2c016e36af8f2c76 # v1.22.0 with: node-version-file: .node-version cache: true run-install: false - run: vp install --frozen-lockfile - - run: vp run cicheck - - name: Detect already-published version - id: npmcheck - run: | - set -euo pipefail - NAME=$(node -p "require('./package.json').name") - VERSION=$(node -p "require('./package.json').version") - if npm view "$NAME@$VERSION" version >/dev/null 2>&1; then - echo "published=true" >> "$GITHUB_OUTPUT" - else - echo "published=false" >> "$GITHUB_OUTPUT" - fi - - - name: Pack publishable tarball (resolve pnpm catalogs) - if: steps.npmcheck.outputs.published != 'true' - run: pnpm pack --out "$RUNNER_TEMP/sigrea-package.tgz" - - - name: npm publish (OIDC trusted publishing) - if: steps.npmcheck.outputs.published != 'true' - run: npm publish "$RUNNER_TEMP/sigrea-package.tgz" --access public - - - name: Sync GitHub Release notes via changelogen + - run: node scripts/release.js publish env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: vp exec changelogen gh release ${{ github.ref_name }} --token "${GITHUB_TOKEN}" + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/changelog.config.ts b/changelog.config.ts deleted file mode 100644 index a4c53b1..0000000 --- a/changelog.config.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { ChangelogConfig } from "changelogen"; - -const config: Partial = { - repo: "sigrea/react", - output: "CHANGELOG.md", - types: { - feat: { title: "🚀 Enhancements", semver: "minor" }, - perf: { title: "🔥 Performance", semver: "patch" }, - fix: { title: "🩹 Fixes", semver: "patch" }, - refactor: { title: "💅 Refactors", semver: "patch" }, - docs: { title: "📖 Documentation", semver: "patch" }, - build: { title: "📦 Build", semver: "patch" }, - types: { title: "🌊 Types", semver: "patch" }, - chore: { title: "🏡 Chore" }, - examples: { title: "🏀 Examples" }, - test: { title: "✅ Tests" }, - style: { title: "🎨 Styles" }, - ci: { title: "🤖 CI" }, - }, -}; - -export default config; diff --git a/package.json b/package.json index 22d0eed..726a852 100644 --- a/package.json +++ b/package.json @@ -48,8 +48,8 @@ "prepare": "vp config --no-agent", "playground:build": "vp build --config playground/vite.config.ts", "notes": "vp exec changelogen --no-output", - "release_version": "sh scripts/release-version.sh", - "push_release": "sh scripts/push-release.sh" + "release_version": "node scripts/release.js prepare", + "push_release": "node scripts/release.js push" }, "devDependencies": { "@arethetypeswrong/core": "^0.18.5", @@ -63,6 +63,7 @@ "jsdom": "^24.1.3", "react": "^19.0.0", "react-dom": "^19.0.0", + "semver": "^7.7.3", "typescript": "5.9.3", "vite": "catalog:", "vite-plus": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f90bcc..a31a981 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,6 +60,9 @@ importers: react-dom: specifier: ^19.0.0 version: 19.2.0(react@19.2.0) + semver: + specifier: ^7.7.3 + version: 7.7.3 typescript: specifier: 5.9.3 version: 5.9.3 diff --git a/scripts/push-release.sh b/scripts/push-release.sh deleted file mode 100644 index a6421f9..0000000 --- a/scripts/push-release.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/sh -set -eu -test "$(git rev-parse --abbrev-ref HEAD)" = "main" -printf 'Push main and tags to origin? [y/N] ' -read -r answer -case "$answer" in - y|Y|yes|YES) git push origin main --follow-tags ;; - *) echo 'Cancelled.'; exit 1 ;; -esac diff --git a/scripts/release-version.sh b/scripts/release-version.sh deleted file mode 100644 index 5e9afab..0000000 --- a/scripts/release-version.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/sh -set -eu -test "$(git rev-parse --abbrev-ref HEAD)" = "main" -test -n "${SIGREA_RELEASE_VERSION:-}" || { echo "SIGREA_RELEASE_VERSION is required, for example SIGREA_RELEASE_VERSION=0.7.1 vp run release_version." >&2; exit 1; } -test -z "$(git status --porcelain)" || { echo "Git status must be clean before release." >&2; git status --short; exit 1; } -vp run cicheck -test -z "$(git status --porcelain)" || { echo "cicheck changed files." >&2; git status --short; exit 1; } -vp exec changelogen --clean --release -r "$SIGREA_RELEASE_VERSION" -vp fmt -if ! git diff --quiet; then - git add -A - git commit --amend --no-edit - git tag -fa "v$SIGREA_RELEASE_VERSION" -m "v$SIGREA_RELEASE_VERSION" -fi -test "$(git cat-file -t "v$SIGREA_RELEASE_VERSION")" = "tag" -test -z "$(git status --porcelain)" || { echo "Release changed files unexpectedly." >&2; git status --short; exit 1; } diff --git a/scripts/release.js b/scripts/release.js new file mode 100644 index 0000000..7abc6a6 --- /dev/null +++ b/scripts/release.js @@ -0,0 +1,229 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import { + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { createInterface } from "node:readline/promises"; +import { parseChangelogMarkdown } from "changelogen"; +import semver from "semver"; + +const run = (command, args, options = {}) => + execFileSync(command, args, { stdio: "inherit", ...options }); +const git = (...args) => execFileSync("git", args, { encoding: "utf8" }).trim(); +const readPackage = () => JSON.parse(readFileSync("package.json", "utf8")); + +function stableVersion(version) { + if ( + !version || + semver.valid(version) !== version || + semver.prerelease(version) + ) { + throw new Error("Use a stable x.y.z version."); + } + return version; +} + +function cleanMain() { + if (git("branch", "--show-current") !== "main") { + throw new Error("Run this command on main."); + } + if (git("status", "--porcelain")) { + throw new Error("Git status must be clean."); + } +} + +function releaseNotes(version) { + const release = parseChangelogMarkdown( + readFileSync("CHANGELOG.md", "utf8"), + ).releases.find((entry) => entry.version === version); + if (!release?.body?.trim()) { + throw new Error(`Missing changelog entry for ${version}.`); + } + return release.body; +} + +function prepareRelease(version = process.env.SIGREA_RELEASE_VERSION) { + cleanMain(); + stableVersion(version); + if (!semver.gt(version, readPackage().version)) { + throw new Error( + "The release version must be greater than the current version.", + ); + } + run("git", ["fetch", "origin", "main", "--tags"]); + if (git("rev-parse", "HEAD") !== git("rev-parse", "origin/main")) { + throw new Error("main must match origin/main before preparing a release."); + } + const tag = `v${version}`; + if (git("tag", "--list", tag)) { + throw new Error(`${tag} already exists.`); + } + run("vp", ["run", "cicheck"]); + cleanMain(); + run("vp", ["exec", "changelogen", "--bump", "-r", version]); + if (readPackage().version !== version) { + throw new Error("Changelogen did not update the package version."); + } + releaseNotes(version); + run("vp", ["fmt", "package.json"]); + run("git", ["add", "--", "package.json", "CHANGELOG.md"]); + run("git", ["commit", "-m", `chore(release): ${tag}`]); + cleanMain(); + run("git", ["tag", "-a", tag, "-m", tag]); +} + +async function pushRelease() { + cleanMain(); + const tag = `v${stableVersion(readPackage().version)}`; + if ( + git("cat-file", "-t", `refs/tags/${tag}`) !== "tag" || + git("rev-parse", `refs/tags/${tag}^{commit}`) !== git("rev-parse", "HEAD") + ) { + throw new Error(`${tag} must be an annotated tag pointing to HEAD.`); + } + run("git", ["fetch", "origin", "main", "--tags"]); + const remote = git("rev-parse", "origin/main"); + if ( + remote !== git("rev-parse", "HEAD") && + remote !== git("rev-parse", "HEAD^") + ) { + throw new Error( + "origin/main changed or main contains extra unpublished commits.", + ); + } + const prompt = createInterface({ + input: process.stdin, + output: process.stdout, + }); + try { + const answer = await prompt.question( + `Push main and ${tag} to origin? [y/N] `, + ); + if (!/^(y|yes)$/i.test(answer.trim())) throw new Error("Cancelled."); + } finally { + prompt.close(); + } + run("git", [ + "push", + "--atomic", + "--no-follow-tags", + "origin", + "refs/heads/main:refs/heads/main", + `refs/tags/${tag}:refs/tags/${tag}`, + ]); +} + +function alreadyPublished(name, version) { + const result = spawnSync( + "npm", + ["view", `${name}@${version}`, "version", "--json"], + { + encoding: "utf8", + }, + ); + if (result.error) throw result.error; + const response = JSON.parse(result.stdout || "null"); + if (result.status === 0 && response === version) return true; + if (result.status !== 0 && response?.error?.code === "E404") return false; + throw new Error( + result.stderr || "Unable to determine the published npm version.", + ); +} + +function publishRelease() { + const { name, version } = readPackage(); + stableVersion(version); + const tag = `v${version}`; + if ( + process.env.GITHUB_REF_TYPE !== "tag" || + process.env.GITHUB_REF_NAME !== tag + ) { + throw new Error("The Git tag must match the package version."); + } + const repo = process.env.GITHUB_REPOSITORY; + if (!repo) throw new Error("GITHUB_REPOSITORY is required."); + git("merge-base", "--is-ancestor", "HEAD", "origin/main"); + const notes = releaseNotes(version); + const directory = mkdtempSync(join(tmpdir(), "sigrea-release-")); + try { + if (!alreadyPublished(name, version)) { + run("vp", ["check"]); + run("vp", ["test", "run"]); + const tarball = join(directory, "package.tgz"); + run("pnpm", ["pack", "--out", tarball]); + run("tar", ["-xzf", tarball, "-C", directory]); + const unpacked = join(directory, "package"); + symlinkSync( + resolve("node_modules"), + join(unpacked, "node_modules"), + "dir", + ); + run("npm", ["run", "smoke"], { cwd: unpacked }); + run("npm", ["publish", tarball, "--access", "public"]); + } + const notesFile = join(directory, "notes.md"); + writeFileSync(notesFile, notes); + const existing = spawnSync( + "gh", + ["api", "--include", `repos/${repo}/releases/tags/${tag}`], + { + encoding: "utf8", + }, + ); + if (existing.error) throw existing.error; + if (existing.status === 0) { + run("gh", [ + "release", + "edit", + tag, + "--repo", + repo, + "--notes-file", + notesFile, + ]); + } else if (/^HTTP\/\S+ 404\b/m.test(existing.stdout)) { + run("gh", [ + "release", + "create", + tag, + "--repo", + repo, + "--verify-tag", + "--title", + tag, + "--notes-file", + notesFile, + ]); + } else { + throw new Error( + existing.stderr || "Unable to look up the GitHub Release.", + ); + } + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +try { + switch (process.argv[2]) { + case "prepare": + prepareRelease(); + break; + case "push": + await pushRelease(); + break; + case "publish": + publishRelease(); + break; + default: + throw new Error("Usage: node scripts/release.js "); + } +} catch (error) { + console.error(error.message); + process.exitCode = 1; +} diff --git a/scripts/release.test.js b/scripts/release.test.js new file mode 100644 index 0000000..728f60d --- /dev/null +++ b/scripts/release.test.js @@ -0,0 +1,421 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import { + appendFileSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, expect, test } from "vite-plus/test"; + +const repository = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const temporaryDirectories = []; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +function fixture() { + const directory = mkdtempSync(join(tmpdir(), "sigrea-release-test-")); + temporaryDirectories.push(directory); + const cwd = join(directory, "work"); + const remote = join(directory, "origin.git"); + const bin = join(directory, "bin"); + const stateFile = join(directory, "state.json"); + const logFile = join(directory, "commands.jsonl"); + mkdirSync(cwd); + mkdirSync(bin); + writeFileSync(logFile, ""); + writeFileSync( + stateFile, + JSON.stringify({ published: false, githubStatus: 404 }), + ); + const git = (...args) => + execFileSync("git", args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + git("init", "--bare", remote); + git("init", "-b", "main"); + git("config", "user.name", "Release fixture"); + git("config", "user.email", "fixture@example.invalid"); + git("config", "commit.gpgSign", "false"); + git("config", "tag.gpgSign", "false"); + git("config", "core.hooksPath", join(directory, "hooks")); + git("remote", "add", "origin", remote); + writeFileSync(join(cwd, ".gitignore"), "node_modules\n"); + writeFileSync( + join(cwd, "package.json"), + JSON.stringify({ + name: "@sigrea/fixture", + version: "1.0.0", + type: "module", + repository: "https://github.com/sigrea/fixture.git", + exports: { import: "./index.mjs", require: "./index.cjs" }, + scripts: { + smoke: `node --input-type=module -e "import { value } from '@sigrea/fixture'; if (value !== 1) process.exit(1)" && node -e "if (require('@sigrea/fixture').value !== 1) process.exit(1)"`, + }, + }), + ); + writeFileSync( + join(cwd, "CHANGELOG.md"), + "# Changelog\n\n## v1.0.0\n\nInitial release.\n", + ); + git("add", ".gitignore", "package.json", "CHANGELOG.md"); + git("commit", "-m", "feat: initial fixture"); + git("tag", "-a", "v1.0.0", "-m", "v1.0.0"); + git("push", "-u", "origin", "main", "refs/tags/v1.0.0"); + symlinkSync( + join(repository, "node_modules"), + join(cwd, "node_modules"), + "dir", + ); + const mock = String.raw`#!${process.execPath} +import { appendFileSync, readFileSync, writeFileSync, mkdirSync, copyFileSync } from 'node:fs'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { basename, dirname, join } from 'node:path'; +const tool = basename(process.argv[1]); +const args = process.argv.slice(2); +const state = JSON.parse(readFileSync(process.env.RELEASE_TEST_STATE, 'utf8')); +appendFileSync(process.env.RELEASE_TEST_LOG, JSON.stringify({tool, args, cwd:process.cwd()})+'\n'); +const save = () => writeFileSync(process.env.RELEASE_TEST_STATE, JSON.stringify(state)); +if (tool === 'vp') { + if (state.checkFails && (args[0] === 'check' || args[1] === 'cicheck')) process.exit(1); + if (state.dirtyCheck && args[1] === 'cicheck') appendFileSync('CHANGELOG.md','changed by check'); + if (args[0] === 'exec') { + if (!args.includes('--bump') || args.includes('--release')) throw new Error('Only local changelog updates are allowed'); + execFileSync(process.execPath, [process.env.RELEASE_TEST_CHANGELOGEN, ...args.slice(2)], {stdio:'inherit'}); + } + if (args[0] === 'fmt') writeFileSync('package.json', JSON.stringify(JSON.parse(readFileSync('package.json','utf8')), null, '\t')+'\n'); +} else if (tool === 'pnpm') { + const packageDir = join(dirname(args[2]), 'pack-source-root'); + const packed = join(packageDir, 'package'); mkdirSync(packed, {recursive:true}); + copyFileSync('package.json', join(packed,'package.json')); + writeFileSync(join(packed,'index.mjs'), 'export const value = '+(state.brokenTarball ? 0 : 1)+';'); + writeFileSync(join(packed,'index.cjs'), 'exports.value = '+(state.brokenTarball ? 0 : 1)+';'); + execFileSync('tar',['-czf',args[2],'-C',packageDir,'package']); +} else if (tool === 'npm') { + if (args[0] === 'view') { + if (state.npmError) { console.log(JSON.stringify({error:{code:state.npmError}})); process.exit(1); } + if (!state.published) { console.log(JSON.stringify({error:{code:'E404'}})); process.exit(1); } + console.log(JSON.stringify(JSON.parse(readFileSync('package.json','utf8')).version)); + } else if (args[0] === 'run') { + const pkg=JSON.parse(readFileSync('package.json','utf8')); + const result=spawnSync('/bin/sh',['-c',pkg.scripts.smoke],{stdio:'inherit'}); process.exit(result.status); + } else if (args[0] === 'publish') { + if (state.publishFails) process.exit(1); + state.published=true; save(); + } +} else if (tool === 'gh') { + if (args[0] === 'api') { + console.log('HTTP/2.0 '+state.githubStatus+' Status\n\n{}'); + if (state.githubStatus !== 200) process.exit(1); + } else { + if (state.failRelease) process.exit(1); + state.githubStatus=200; save(); + } +} +`; + // Keep all process doubles and mutable state outside the Git worktree. + for (const tool of ["vp", "pnpm", "npm", "gh"]) { + writeFileSync(join(bin, tool), mock, { mode: 0o755 }); + } + const env = { + ...process.env, + PATH: `${bin}:${process.env.PATH}`, + SIGREA_RELEASE_VERSION: "1.0.1", + GITHUB_REF_TYPE: "tag", + GITHUB_REF_NAME: "v1.0.1", + GITHUB_REPOSITORY: "sigrea/fixture", + GITHUB_TOKEN: "", + GH_TOKEN: "", + RELEASE_TEST_STATE: stateFile, + RELEASE_TEST_LOG: logFile, + RELEASE_TEST_CHANGELOGEN: join( + repository, + "node_modules/changelogen/dist/cli.mjs", + ), + }; + return { + cwd, + remote, + git, + state(update) { + writeFileSync( + stateFile, + JSON.stringify({ + ...JSON.parse(readFileSync(stateFile, "utf8")), + ...update, + }), + ); + }, + events() { + return readFileSync(logFile, "utf8") + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)); + }, + run(action, override = {}, input = "y\n") { + return spawnSync( + process.execPath, + [join(repository, "scripts/release.js"), action], + { + cwd, + env: { ...env, ...override }, + encoding: "utf8", + input, + timeout: 15000, + }, + ); + }, + }; +} + +function prepare(f) { + const result = f.run("prepare"); + expect(result.status, result.stdout + result.stderr).toBe(0); +} + +test("prepares one formatted commit and an annotated tag without publishing", () => { + const f = fixture(); + const before = f.git("rev-parse", "HEAD"); + prepare(f); + expect(f.git("rev-list", "--count", `${before}..HEAD`)).toBe("1"); + expect(f.git("show", "HEAD:package.json")).toContain('\n\t"name"'); + expect(f.git("cat-file", "-t", "v1.0.1")).toBe("tag"); + expect(f.git("rev-parse", "v1.0.1^{commit}")).toBe( + f.git("rev-parse", "HEAD"), + ); + expect(f.git("status", "--porcelain")).toBe(""); + expect(f.events().every((event) => event.tool === "vp")).toBe(true); +}); + +test.each(["not-a-version", "v1.0.1", "1.0.0", "0.9.9", "1.0.1-beta.1", ""])( + "rejects version %j before changing the tree", + (version) => { + const f = fixture(); + const before = f.git("rev-parse", "HEAD"); + expect( + f.run("prepare", { SIGREA_RELEASE_VERSION: version }).status, + ).not.toBe(0); + expect(f.git("rev-parse", "HEAD")).toBe(before); + expect(f.git("status", "--porcelain")).toBe(""); + expect(f.events()).toEqual([]); + }, +); + +test.each(["dirty", "branch", "tag", "remote-tag", "ahead", "behind"])( + "rejects a %s preparation state", + (state) => { + const f = fixture(); + if (state === "dirty") appendFileSync(join(f.cwd, "CHANGELOG.md"), "dirty"); + if (state === "branch") f.git("switch", "-c", "feature"); + if (state === "tag" || state === "remote-tag") { + f.git("tag", "-a", "v1.0.1", "-m", "already exists"); + if (state === "remote-tag") { + f.git("push", "origin", "refs/tags/v1.0.1"); + f.git("tag", "-d", "v1.0.1"); + } + } + if (state === "ahead" || state === "behind") { + appendFileSync(join(f.cwd, "CHANGELOG.md"), "another commit"); + f.git("commit", "-am", "docs: another change"); + if (state === "behind") { + f.git("push", "origin", "main"); + f.git("switch", "--detach", "HEAD^"); + f.git("branch", "-f", "main", "HEAD"); + f.git("switch", "main"); + } + } + const before = f.git("rev-parse", "HEAD"); + const status = f.git("status", "--porcelain"); + expect(f.run("prepare").status).not.toBe(0); + expect(f.git("rev-parse", "HEAD")).toBe(before); + expect(f.git("status", "--porcelain")).toBe(status); + expect(f.events()).toEqual([]); + }, +); + +test.each([{ checkFails: true }, { dirtyCheck: true }])( + "stops when the pre-release check fails or changes files: %j", + (state) => { + const f = fixture(); + const before = f.git("rev-parse", "HEAD"); + f.state(state); + expect(f.run("prepare").status).not.toBe(0); + expect(f.git("rev-parse", "HEAD")).toBe(before); + expect(f.events()).toHaveLength(1); + expect(f.git("tag", "--list", "v1.0.1")).toBe(""); + }, +); + +test("pushes only the release tag even when followTags is configured", () => { + const f = fixture(); + prepare(f); + f.git("tag", "-a", "unrelated", "-m", "unrelated"); + f.git("config", "push.followTags", "true"); + const result = f.run("push"); + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(f.git("ls-remote", "origin", "refs/tags/v1.0.1")).not.toBe(""); + expect(f.git("ls-remote", "origin", "refs/tags/unrelated")).toBe(""); + expect(f.git("rev-parse", "origin/main")).toBe(f.git("rev-parse", "HEAD")); +}); + +test("does not push the tag when the remote rejects main", () => { + const f = fixture(); + prepare(f); + const before = f.git("rev-parse", "origin/main"); + writeFileSync( + join(f.remote, "hooks/update"), + '#!/bin/sh\n[ "$1" != refs/heads/main ]\n', + { mode: 0o755 }, + ); + expect(f.run("push").status).not.toBe(0); + expect(f.git("ls-remote", "origin", "refs/tags/v1.0.1")).toBe(""); + expect(f.git("ls-remote", "origin", "refs/heads/main").split("\t")[0]).toBe( + before, + ); +}); + +test("rejects a release tag pointing to a different commit", () => { + const f = fixture(); + prepare(f); + f.git("commit", "--allow-empty", "-m", "chore: another commit"); + expect(f.run("push").status).not.toBe(0); + expect(f.git("ls-remote", "origin", "refs/tags/v1.0.1")).toBe(""); +}); + +test.each(["lightweight", "remote-changed", "cancel"])( + "rejects unsafe or cancelled push: %s", + (state) => { + const f = fixture(); + prepare(f); + if (state === "lightweight") { + f.git("tag", "-d", "v1.0.1"); + f.git("tag", "v1.0.1"); + } + if (state === "remote-changed") { + f.git("commit", "--allow-empty", "-m", "chore: remote change"); + f.git("push", "origin", "main"); + f.git("switch", "--detach", "HEAD^"); + f.git("branch", "-f", "main", "HEAD"); + f.git("switch", "main"); + } + const result = f.run("push", {}, state === "cancel" ? "n\n" : "y\n"); + expect(result.status).not.toBe(0); + expect(f.git("ls-remote", "origin", "refs/tags/v1.0.1")).toBe(""); + }, +); + +test.each(["version", "off-main", "notes"])( + "rejects invalid publish input: %s", + (state) => { + const f = fixture(); + pushed(f); + if (state === "version") { + expect(f.run("publish", { GITHUB_REF_NAME: "v9.9.9" }).status).not.toBe( + 0, + ); + } else { + if (state === "off-main") + f.git("update-ref", "refs/remotes/origin/main", "HEAD^"); + if (state === "notes") + writeFileSync(join(f.cwd, "CHANGELOG.md"), "# Changelog\n"); + expect(f.run("publish").status).not.toBe(0); + } + expect( + f.events().some((event) => event.tool === "npm" || event.tool === "gh"), + ).toBe(false); + }, +); + +function pushed(f) { + prepare(f); + expect(f.run("push").status).toBe(0); +} + +test("checks the packed ESM/CJS package before publishing and creating the release", () => { + const f = fixture(); + pushed(f); + const result = f.run("publish"); + expect(result.status, result.stdout + result.stderr).toBe(0); + const events = f.events(); + expect(events.filter((event) => event.tool === "pnpm")).toHaveLength(1); + const smoke = events.findIndex( + (event) => event.tool === "npm" && event.args[0] === "run", + ); + const publish = events.findIndex( + (event) => event.tool === "npm" && event.args[0] === "publish", + ); + expect(smoke).toBeGreaterThan(-1); + expect(publish).toBeGreaterThan(smoke); + expect(events[smoke].cwd).not.toBe(f.cwd); + expect(events.at(-1).args.slice(0, 2)).toEqual(["release", "create"]); +}); + +test("does not publish a broken tarball", () => { + const f = fixture(); + pushed(f); + f.state({ brokenTarball: true }); + expect(f.run("publish").status).not.toBe(0); + expect( + f + .events() + .some((event) => event.tool === "npm" && event.args[0] === "publish"), + ).toBe(false); +}); + +test.each(["ECONNRESET", "E403"])( + "does not treat npm %s as an unpublished version", + (npmError) => { + const f = fixture(); + pushed(f); + f.state({ npmError }); + expect(f.run("publish").status).not.toBe(0); + expect( + f.events().some((event) => event.tool === "pnpm" || event.tool === "gh"), + ).toBe(false); + }, +); + +test("does not create a GitHub Release if npm publish fails", () => { + const f = fixture(); + pushed(f); + f.state({ publishFails: true }); + expect(f.run("publish").status).not.toBe(0); + expect(f.events().some((event) => event.tool === "gh")).toBe(false); +}); + +test("retries release creation after publishing without publishing npm twice", () => { + const f = fixture(); + pushed(f); + f.state({ failRelease: true }); + expect(f.run("publish").status).not.toBe(0); + f.state({ failRelease: false }); + const retry = f.run("publish"); + expect(retry.status, retry.stdout + retry.stderr).toBe(0); + expect( + f + .events() + .filter((event) => event.tool === "npm" && event.args[0] === "publish"), + ).toHaveLength(1); + expect(f.run("publish").status).toBe(0); + expect(f.events().at(-1).args.slice(0, 2)).toEqual(["release", "edit"]); +}); + +test("does not create a release when the GitHub lookup fails", () => { + const f = fixture(); + pushed(f); + f.state({ published: true, githubStatus: 503 }); + expect(f.run("publish").status).not.toBe(0); + expect(f.events().at(-1).args[0]).toBe("api"); +}); From c5929be78b0fee9345d8ba29ef441578a4dc008f Mon Sep 17 00:00:00 2001 From: aose-yuu Date: Mon, 2 Nov 2026 20:02:40 +0900 Subject: [PATCH 3/5] docs: consolidate release and contributor guidance --- .github/pull_request_template.md | 15 ++------- CONTRIBUTING.md | 55 +++++++++++++++++--------------- 2 files changed, 31 insertions(+), 39 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index a050833..e173628 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,15 +1,4 @@ - - -## Purpose - - ## Summary - - -## Verification - -- [ ] `vp run cicheck` + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 07f8702..d876af0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,8 +1,6 @@ # Contributing to @sigrea/react -Thank you for contributing! This project uses a PR-based workflow with required CI checks. - -## Requirements +## Development Install [Vite+](https://viteplus.dev/guide/). Node.js and pnpm versions are pinned in `.node-version` and `package.json`. @@ -12,38 +10,43 @@ vp env install vp install --frozen-lockfile ``` -## Commands - -- `vp check` — format, lint, and TypeScript checks -- `vp test run` — unit tests +- `vp check` — formatting, lint, and TypeScript checks +- `vp test run` — tests - `vp test run --coverage` — coverage - `vp pack` — ESM/CJS bundles and declarations - `vp fmt` — format source files - `vp run cicheck` — checks, tests, packaging, and smoke checks -The pre-commit hook fixes staged files and runs the test suite. - -## Commit Convention - -Conventional Commits are required. Examples: `feat: ...`, `fix(scope): ...`, `docs: ...`. -The main branch uses squash merges, so the PR title becomes the final commit message. - -## Changelog Entries +The pre-commit hook fixes staged files. Run `vp run cicheck` before requesting review. +Use Conventional Commits for PR titles; squash merges use the title as the commit +message, and changelogen reads it for release notes. -Changelogen reads Conventional Commits directly, so please keep commit messages descriptive. Any user-facing change (features, fixes, deprecations, breaking updates) should have a clear `feat`, `fix`, or similar commit. Non-user-facing changes can use `chore`, `test`, etc. For PRs that squash multiple commits, summarize the user impact in the PR description so release managers can double-check the changelog entry. +Describe the problem and resulting behavior in the PR. Include manual verification +or remaining gaps when relevant; routine CI results do not need repeating. -## Pull Requests +## Releases -Ensure the following before requesting review: -`vp run cicheck` passes and the PR title follows Conventional Commits. +Releases publish stable `x.y.z` versions through GitHub Actions using npm OIDC. -## Release Workflow +1. Start on a clean `main` matching `origin/main`. Preview notes with `vp run notes`. +2. Run `SIGREA_RELEASE_VERSION=x.y.z vp run release_version`. It checks the version + and tags, runs `cicheck`, updates the version and CHANGELOG, formats the package, + and creates one commit and an annotated tag. It does not publish a GitHub Release. +3. Run `vp run push_release`. It verifies the tag and remote main, then asks before + pushing main and that tag atomically. Other local tags are not pushed. +4. The tag workflow checks the version and main ancestry, tests the package, packs + it once, and smoke-tests the extracted tarball before publishing it to npm. + It then creates or updates the GitHub Release from CHANGELOG using `gh`. -This repository uses [changelogen](https://github.com/unjs/changelogen) to update `CHANGELOG.md` and create the release commit plus tag. Releases use an explicit version so maintainers do not depend on an inferred bump. +The target version must be newer than the current package version and its tag must +not exist. If preparation fails after editing files, inspect those changes before +retrying; the script does not discard changes or rewrite commits and tags. -1. Ensure `main` is up to date and clean. Run `vp run notes` if you want to preview the generated notes without touching the tree. -2. Run `SIGREA_RELEASE_VERSION=x.y.z vp run release_version`. This runs `vp run cicheck`, updates the changelog, amends the release commit if formatting changes are needed, and creates the annotated `vX.Y.Z` tag. -3. Push the commit and tag together with `vp run push_release`. The task uses `git push origin main --follow-tags`. -4. Tag pushes run `.github/workflows/publish.yml` in the `release` environment and publish to npm through OIDC. +## Recovering a release -If the publish workflow fails, fix the root cause and re-run the job from the GitHub Actions UI. Avoid creating a new tag unless you intend to cut a new release. If you must roll back, delete the tag locally and remotely, revert the release commit, and start over from step 1. +- If the publish workflow fails before npm publication, fix the cause and rerun the + failed job on the same tag. If the tagged source needs changing, cut a new version. +- If npm publication succeeds but the GitHub Release step fails, rerun the job. + It skips the published npm version and finishes the GitHub Release. +- If a published package is faulty, publish a corrected version. Deleting a Git tag + or unpublishing npm does not make that version reusable. Never move a pushed tag. From 91f5b180a4d6278ed3c291e03682eeab46718d3b Mon Sep 17 00:00:00 2001 From: aose-yuu Date: Mon, 2 Nov 2026 20:25:18 +0900 Subject: [PATCH 4/5] docs: simplify release instructions --- .github/pull_request_template.md | 3 +- CONTRIBUTING.md | 61 +++++++++++++++++--------------- scripts/release.test.js | 2 +- 3 files changed, 35 insertions(+), 31 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index e173628..da744e0 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,4 +1,3 @@ ## Summary - + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d876af0..c1ec758 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,8 +2,8 @@ ## Development -Install [Vite+](https://viteplus.dev/guide/). Node.js and pnpm versions are pinned -in `.node-version` and `package.json`. +Install [Vite+](https://viteplus.dev/guide/). Node.js and pnpm versions are set in +`.node-version` and `package.json`. ```sh vp env install @@ -17,36 +17,41 @@ vp install --frozen-lockfile - `vp fmt` — format source files - `vp run cicheck` — checks, tests, packaging, and smoke checks -The pre-commit hook fixes staged files. Run `vp run cicheck` before requesting review. -Use Conventional Commits for PR titles; squash merges use the title as the commit -message, and changelogen reads it for release notes. +The pre-commit hook checks and fixes staged files. Run `vp run cicheck` before +opening a PR. -Describe the problem and resulting behavior in the PR. Include manual verification -or remaining gaps when relevant; routine CI results do not need repeating. +Use a Conventional Commit title, such as `fix: stop a watcher after disposal`. +The title becomes the squash commit message and is used to generate the changelog. +Mention any breaking changes, manual checks, or known issues in the PR description. -## Releases +## Releasing -Releases publish stable `x.y.z` versions through GitHub Actions using npm OIDC. +Releases use stable `x.y.z` versions. Start on a clean `main` matching `origin/main`: -1. Start on a clean `main` matching `origin/main`. Preview notes with `vp run notes`. -2. Run `SIGREA_RELEASE_VERSION=x.y.z vp run release_version`. It checks the version - and tags, runs `cicheck`, updates the version and CHANGELOG, formats the package, - and creates one commit and an annotated tag. It does not publish a GitHub Release. -3. Run `vp run push_release`. It verifies the tag and remote main, then asks before - pushing main and that tag atomically. Other local tags are not pushed. -4. The tag workflow checks the version and main ancestry, tests the package, packs - it once, and smoke-tests the extracted tarball before publishing it to npm. - It then creates or updates the GitHub Release from CHANGELOG using `gh`. +```sh +vp run notes +SIGREA_RELEASE_VERSION=x.y.z vp run release_version +vp run push_release +``` + +`notes` previews the changelog. `release_version` runs the checks, updates the +version and CHANGELOG, then creates a commit and an annotated tag. The version +must be newer than the current version, and the tag must not already exist. + +`push_release` asks before pushing main and the release tag together. If either +update fails, neither is pushed. It does not push other local tags. + +The tag workflow publishes to npm using OIDC, then creates or updates the GitHub +Release from CHANGELOG with `gh`. + +If preparation fails, check `git status` and `git log` before retrying. It may have +already changed the version or created a commit. -The target version must be newer than the current package version and its tag must -not exist. If preparation fails after editing files, inspect those changes before -retrying; the script does not discard changes or rewrite commits and tags. +## Failed releases -## Recovering a release +Rerun a failed publish job on the same tag. If the version is already on npm, the +job skips npm publication and updates the GitHub Release. -- If the publish workflow fails before npm publication, fix the cause and rerun the - failed job on the same tag. If the tagged source needs changing, cut a new version. -- If npm publication succeeds but the GitHub Release step fails, rerun the job. - It skips the published npm version and finishes the GitHub Release. -- If a published package is faulty, publish a corrected version. Deleting a Git tag - or unpublishing npm does not make that version reusable. Never move a pushed tag. +If the tagged code needs changing, cut a new version. Do not move a tag that has +already been pushed. Published npm versions cannot be reused, even after +unpublishing. diff --git a/scripts/release.test.js b/scripts/release.test.js index 728f60d..2b513c7 100644 --- a/scripts/release.test.js +++ b/scripts/release.test.js @@ -124,7 +124,7 @@ if (tool === 'vp') { } } `; - // Keep all process doubles and mutable state outside the Git worktree. + // Keep stubs and their state outside the repo so git status stays clean. for (const tool of ["vp", "pnpm", "npm", "gh"]) { writeFileSync(join(bin, tool), mock, { mode: 0o755 }); } From b649fb76b881053eb0fd412d1d2c89a898c98f7c Mon Sep 17 00:00:00 2001 From: aose-yuu Date: Tue, 3 Nov 2026 00:43:57 +0900 Subject: [PATCH 5/5] fix: preserve existing release notes on reruns --- CONTRIBUTING.md | 6 +++--- scripts/release.js | 42 ++++++++++++++++------------------------- scripts/release.test.js | 17 +++++++++++++++-- 3 files changed, 34 insertions(+), 31 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c1ec758..74ce1e6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,8 +41,8 @@ must be newer than the current version, and the tag must not already exist. `push_release` asks before pushing main and the release tag together. If either update fails, neither is pushed. It does not push other local tags. -The tag workflow publishes to npm using OIDC, then creates or updates the GitHub -Release from CHANGELOG with `gh`. +The tag workflow publishes to npm using OIDC, then creates the GitHub Release +from CHANGELOG with `gh`. Reruns leave existing release notes alone. If preparation fails, check `git status` and `git log` before retrying. It may have already changed the version or created a commit. @@ -50,7 +50,7 @@ already changed the version or created a commit. ## Failed releases Rerun a failed publish job on the same tag. If the version is already on npm, the -job skips npm publication and updates the GitHub Release. +job skips npm publication and creates the GitHub Release if it is missing. If the tagged code needs changing, cut a new version. Do not move a tag that has already been pushed. Published npm versions cannot be reused, even after diff --git a/scripts/release.js b/scripts/release.js index 7abc6a6..df15808 100644 --- a/scripts/release.js +++ b/scripts/release.js @@ -166,8 +166,6 @@ function publishRelease() { run("npm", ["run", "smoke"], { cwd: unpacked }); run("npm", ["publish", tarball, "--access", "public"]); } - const notesFile = join(directory, "notes.md"); - writeFileSync(notesFile, notes); const existing = spawnSync( "gh", ["api", "--include", `repos/${repo}/releases/tags/${tag}`], @@ -176,34 +174,26 @@ function publishRelease() { }, ); if (existing.error) throw existing.error; - if (existing.status === 0) { - run("gh", [ - "release", - "edit", - tag, - "--repo", - repo, - "--notes-file", - notesFile, - ]); - } else if (/^HTTP\/\S+ 404\b/m.test(existing.stdout)) { - run("gh", [ - "release", - "create", - tag, - "--repo", - repo, - "--verify-tag", - "--title", - tag, - "--notes-file", - notesFile, - ]); - } else { + if (existing.status === 0) return; + if (!/^HTTP\/\S+ 404\b/m.test(existing.stdout)) { throw new Error( existing.stderr || "Unable to look up the GitHub Release.", ); } + const notesFile = join(directory, "notes.md"); + writeFileSync(notesFile, notes); + run("gh", [ + "release", + "create", + tag, + "--repo", + repo, + "--verify-tag", + "--title", + tag, + "--notes-file", + notesFile, + ]); } finally { rmSync(directory, { recursive: true, force: true }); } diff --git a/scripts/release.test.js b/scripts/release.test.js index 2b513c7..75ded00 100644 --- a/scripts/release.test.js +++ b/scripts/release.test.js @@ -120,6 +120,7 @@ if (tool === 'vp') { if (state.githubStatus !== 200) process.exit(1); } else { if (state.failRelease) process.exit(1); + state.releaseBody=readFileSync(args[args.indexOf('--notes-file')+1], 'utf8'); state.githubStatus=200; save(); } } @@ -157,6 +158,9 @@ if (tool === 'vp') { }), ); }, + releaseBody() { + return JSON.parse(readFileSync(stateFile, "utf8")).releaseBody; + }, events() { return readFileSync(logFile, "utf8") .trim() @@ -408,8 +412,7 @@ test("retries release creation after publishing without publishing npm twice", ( .events() .filter((event) => event.tool === "npm" && event.args[0] === "publish"), ).toHaveLength(1); - expect(f.run("publish").status).toBe(0); - expect(f.events().at(-1).args.slice(0, 2)).toEqual(["release", "edit"]); + expect(f.releaseBody()).toBeTruthy(); }); test("does not create a release when the GitHub lookup fails", () => { @@ -419,3 +422,13 @@ test("does not create a release when the GitHub lookup fails", () => { expect(f.run("publish").status).not.toBe(0); expect(f.events().at(-1).args[0]).toBe("api"); }); + +test("leaves existing GitHub Release notes unchanged on a rerun", () => { + const f = fixture(); + pushed(f); + const notes = "Release notes edited by the maintainer."; + f.state({ published: true, githubStatus: 200, releaseBody: notes }); + const result = f.run("publish"); + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(f.releaseBody()).toBe(notes); +});