diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2d44983..2aebc8a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,24 +12,24 @@ To test individual modules, cd into them and run `yarn test`. For example, runni # Publishing a Release -### Release Branch - -1. Checkout main locally -2. Increment the version number in modules/react-arborist/package.json -3. Create a branch called release/v0.0.0 -4. Open a PR to main -5. Test, review, and merge, delete branch - -### Create Github Release - -1. Create a release based on main -2. Assign a new tag to be created with v0.0.0 -3. Title the release "Version 0.0.0" -4. Write release notes -5. Publish -6. Check that it successfully published to npmjs - -The Github actions workflow will publish to npm. +Releases are driven by `bin/release.mjs`, invoked via `yarn release`. The script bumps the version, runs tests, and pushes a `v*` tag. The tag push triggers `.github/workflows/publish.yml`, which publishes to npm via Trusted Publishing (OIDC) — no token needed. + +1. On `main`, with a clean working tree, update `CHANGELOG.md` with a new section for the upcoming version. Commit and push. +2. Run `yarn release ` from the repo root. The script will: + - Verify you're on `main`, the working tree is clean, and you're in sync with the remote + - Run tests and build + - Bump `modules/react-arborist/package.json`, commit it, and tag `vX.Y.Z` + - Push the commit and tag to `origin` + - Open a GitHub Release draft in your browser (via `gh`) +3. Edit the release draft, paste in the changelog entry, and publish. +4. Watch `gh run watch` — the publish workflow will build and `npm publish` via OIDC. Confirm the new version on https://www.npmjs.com/package/react-arborist. + +Flags: + +- `--preview` — dry-run; the script still reads git state and builds, but no commit, tag, push, or release draft is created. +- `--any-branch` — skip both the `main` branch check and the remote sync check (useful for testing on a branch that isn't pushed). +- `--no-tests` — skip `yarn test`. +- `--yes` — skip the interactive confirmation. # Publish the Demo Site diff --git a/bin/release.mjs b/bin/release.mjs new file mode 100755 index 0000000..e04481f --- /dev/null +++ b/bin/release.mjs @@ -0,0 +1,130 @@ +#!/usr/bin/env node +import { execSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; +import { createInterface } from "node:readline/promises"; +import { stdin as input, stdout as output } from "node:process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const pkgPath = path.join(rootDir, "modules/react-arborist/package.json"); + +const args = process.argv.slice(2); +const flags = { + preview: args.includes("--preview"), + anyBranch: args.includes("--any-branch"), + skipTests: args.includes("--no-tests"), + yes: args.includes("--yes") || args.includes("-y"), +}; +const versionArg = args.find((a) => !a.startsWith("-")); + +function out(cmd) { + return execSync(cmd, { cwd: rootDir, encoding: "utf8" }).trim(); +} + +function run(cmd) { + if (flags.preview) { + console.log(` [preview] ${cmd}`); + return; + } + execSync(cmd, { cwd: rootDir, stdio: "inherit" }); +} + +function step(name) { + console.log(`\n→ ${name}`); +} + +function fail(msg) { + console.error(`✖ ${msg}`); + process.exit(1); +} + +function bump(current, kind) { + if (/^\d+\.\d+\.\d+$/.test(kind)) return kind; + const [maj, min, pat] = current.split(".").map(Number); + if (kind === "patch") return `${maj}.${min}.${pat + 1}`; + if (kind === "minor") return `${maj}.${min + 1}.0`; + if (kind === "major") return `${maj + 1}.0.0`; + fail(`Invalid version: "${kind}". Use patch, minor, major, or X.Y.Z.`); +} + +if (!versionArg) { + fail("Usage: yarn release [--preview] [--any-branch] [--no-tests] [--yes]"); +} + +step("Checking branch"); +const branch = out("git rev-parse --abbrev-ref HEAD"); +if (branch !== "main" && !flags.anyBranch) { + fail(`Not on main (currently on ${branch}). Use --any-branch to override.`); +} +console.log(` on ${branch}`); + +step("Checking working tree"); +if (out("git status --porcelain")) { + fail("Working tree not clean. Commit or stash first."); +} +console.log(" clean"); + +if (flags.anyBranch) { + console.log("\n→ Skipping remote sync check (--any-branch)"); +} else { + step("Fetching origin"); + execSync("git fetch origin", { cwd: rootDir, stdio: "inherit" }); + const local = out(`git rev-parse ${branch}`); + const remote = out(`git rev-parse origin/${branch}`); + if (local !== remote) { + fail(`Local ${branch} (${local.slice(0, 7)}) differs from origin/${branch} (${remote.slice(0, 7)}).`); + } + console.log(" in sync"); +} + +if (!flags.skipTests) { + step("Running tests"); + run("yarn test"); +} + +step("Building library"); +run("yarn build-lib"); + +const pkg = JSON.parse(readFileSync(pkgPath, "utf8")); +const oldVersion = pkg.version; +const newVersion = bump(oldVersion, versionArg); +const tag = `v${newVersion}`; + +console.log(`\nVersion: ${oldVersion} → ${newVersion} (tag: ${tag})`); + +if (out(`git tag -l ${tag}`)) { + fail(`Tag ${tag} already exists.`); +} + +if (!flags.preview && !flags.yes) { + const rl = createInterface({ input, output }); + const answer = await rl.question(`Continue? (y/N) `); + rl.close(); + if (answer.trim().toLowerCase() !== "y") { + console.log("Aborted."); + process.exit(0); + } +} + +step(`Bumping ${pkgPath} to ${newVersion}`); +if (flags.preview) { + console.log(` [preview] write version=${newVersion}`); +} else { + pkg.version = newVersion; + writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n"); +} + +step("Committing"); +run(`git commit -am ${tag}`); + +step(`Tagging ${tag}`); +run(`git tag ${tag}`); + +step("Pushing commit + tag"); +run(`git push origin ${branch} --follow-tags`); + +step("Opening GitHub release draft"); +run(`gh release create ${tag} --draft --title ${tag} --notes "" --web`); + +console.log(`\nReleased ${tag}. Watch the publish workflow with: gh run watch`); diff --git a/package.json b/package.json index 5c92c2d..4ccb892 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "clean": "yarn workspace react-arborist clean", "showcase": "yarn workspace showcase start", "start": "run-s clean build-lib && run-p watch showcase", - "publish": "sh bin/publish" + "publish": "sh bin/publish", + "release": "node bin/release.mjs" }, "private": true, "packageManager": "yarn@4.0.2",