diff --git a/.changeset/config.json b/.changeset/config.json index 0bcc642..5046e5c 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -3,7 +3,7 @@ "changelog": "@changesets/cli/changelog", "commit": false, "linked": [], - "access": "restricted", + "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", "ignore": [] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd344dc..14bbe93 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,9 @@ on: branches: - main +permissions: + contents: read + jobs: test: name: Test @@ -12,15 +15,15 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 with: package_json_path: package.json - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: '22' cache: 'pnpm' @@ -36,4 +39,3 @@ jobs: - name: Test run: pnpm test - diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index cfa393b..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: Release - -on: - push: - branches: - - main - -concurrency: ${{ github.workflow }}-${{ github.ref }} - -jobs: - release: - name: Release - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - id-token: write - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '22' - cache: 'pnpm' - registry-url: 'https://registry.npmjs.org' - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Create Release Pull Request or Publish - id: changesets - uses: changesets/action@v1 - with: - publish: pnpm release:force - version: pnpm bump - commit: 'chore: release packages' - title: 'chore: release packages' - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - - - name: Get version and create git tag - if: steps.changesets.outputs.published == 'true' - id: version - run: | - VERSION=$(node -p "require('./package.json').version") - TAG_NAME="v${VERSION}" - echo "version=${VERSION}" >> $GITHUB_OUTPUT - echo "tag=${TAG_NAME}" >> $GITHUB_OUTPUT - - # Configure git - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - # Create and push tag - git tag -a "${TAG_NAME}" -m "Release ${TAG_NAME}" - git push origin "${TAG_NAME}" - - - name: Install GitHub CLI - if: steps.changesets.outputs.published == 'true' - run: | - type -p curl >/dev/null || (apt update && apt install curl -y) - curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \ - && sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg \ - && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null \ - && sudo apt update \ - && sudo apt install gh -y - - - name: Create GitHub Release - if: steps.changesets.outputs.published == 'true' - run: | - node scripts/create-github-release.js - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - diff --git a/.nvmrc b/.nvmrc index a77793e..2bd5a0a 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -lts/hydrogen +22 diff --git a/biome.json b/biome.json index 64ac2e5..a8288d7 100644 --- a/biome.json +++ b/biome.json @@ -10,6 +10,7 @@ "src/**/*.ts", "src/**/*.d.ts", "scripts/**/*.js", + "scripts/**/*.mjs", "scripts/**/*.ts" ] } diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..5683f50 --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,74 @@ +# Releasing ndepe + +Releases are prepared and published locally. GitHub Actions only validates pull requests and has no publishing credentials or write permissions. + +## Feature development + +Every pull request with a user-visible change must include one or more changesets: + +```bash +pnpm change +``` + +Commit the generated `.changeset/*.md` files with the feature. Changesets accumulate on `main`; merging a feature pull request does not create a version pull request or publish anything. + +## Prepare a release + +The maintainer starts a short release window and pauses feature merges. From a clean and current `main`, run: + +```bash +pnpm release:prepare +``` + +The command verifies Node.js 22, pnpm 10, GitHub CLI authentication, Git state, frozen dependencies, pending changesets, and the release branch. It then consumes every pending changeset, creates `release/vX.Y.Z`, commits the version files, pushes the branch, and opens a Version PR. + +Review the version, changelog, deleted changesets, and CI result. The Version PR must contain no source changes and must be merged with **Squash Merge**. + +## Publish a release + +Keep the release window open. Immediately after the Version PR is merged: + +```bash +git switch main +git pull --ff-only origin main +pnpm release +``` + +The command requires a local npm login with 2FA and rejects `NPM_TOKEN` and `NODE_AUTH_TOKEN`. It installs the lockfile-pinned dependencies with lifecycle scripts disabled, verifies that `HEAD` is exactly the Version commit, runs lint/build/tests and package smoke tests, then asks you to type the complete version. + +After confirmation it publishes the generated tarball, creates and pushes an annotated Tag, and creates the GitHub Release from the matching changelog section. End the release window only after all three commands succeed. + +Stable SemVer releases are supported. Prereleases are intentionally not supported in the first version of this flow. + +## Recovery + +The script never overwrites an npm version or Tag and does not perform automatic rollback. If npm publishing succeeds but a later step fails, do not rerun the complete release command. Inspect the printed version and commit, then complete the missing steps manually: + +```bash +git tag -a vX.Y.Z -m "vX.Y.Z" # Skip if the local Tag exists +git push origin refs/tags/vX.Y.Z:refs/tags/vX.Y.Z + +# Copy the matching CHANGELOG section to /tmp/release-notes.md first. +gh release create vX.Y.Z --verify-tag --title "vX.Y.Z" \ + --notes-file /tmp/release-notes.md --latest --repo web-infra-dev/nde +``` + +Before running recovery commands, verify npm contains the expected version and that any existing Tag points to the intended release commit. Never move an existing remote Tag automatically. + +If the package was published under the wrong dist-tag, restore the stable tag explicitly: + +```bash +npm dist-tag add ndepe@X.Y.Z latest +``` + +## Repository setup + +Repository administrators must configure these controls outside the repository: + +- Require pull requests and the CI check for `main`. +- Allow and use Squash Merge for Version PRs. +- Restrict creation of `v*` Tags to release maintainers. +- Remove the obsolete `NPM_TOKEN` GitHub secret. +- On npm, require 2FA for publishing and disallow automation tokens. + +Historical Tags are not moved or recreated. The local flow starts with the next release. diff --git a/package.json b/package.json index 7e70fcf..8245553 100644 --- a/package.json +++ b/package.json @@ -15,19 +15,22 @@ "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", + "files": [ + "dist", + "LICENSE", + "README.md" + ], "scripts": { "build": "rslib build", "build:watch": "rslib build -w", - "bump": "changeset version", "change": "changeset", "prepare": "pnpm run build && husky install", - "prepublishOnly": "pnpm run build", - "release:github": "node scripts/create-github-release.js", - "release:local": "npx zx scripts/release.mjs", - "release:force": "pnpm publish --no-git-checks", + "prepublishOnly": "node -e \"console.error('Direct publishing is disabled. Use pnpm release.'); process.exit(1)\"", + "release:prepare": "node scripts/release-prepare.mjs", + "release": "node scripts/release.mjs", "reset": "rimraf ./**/node_modules", - "test": "pnpm run test:setup && vitest --run", - "test:setup": "cd tests/fixtures/project1 && [ -d node_modules ] || pnpm install --frozen-lockfile" + "test": "pnpm run test:setup && vitest --run && node --test scripts/*.node-test.mjs", + "test:setup": "cd tests/fixtures/project1 && [ -d node_modules ] || pnpm install --frozen-lockfile --ignore-scripts" }, "lint-staged": { "*.{js,ts,jsx,tsx}": [ @@ -59,8 +62,7 @@ "rimraf": "~3.0.2", "ts-node": "^10.9.2", "typescript": "~5.0.4", - "vitest": "^2.0.5", - "zx": "^8.5.5" + "vitest": "^2.0.5" }, "packageManager": "pnpm@10.0.0", "publishConfig": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 95339ac..51bec0e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -78,9 +78,6 @@ importers: vitest: specifier: ^2.0.5 version: 2.0.5(@types/node@16.11.68) - zx: - specifier: ^8.5.5 - version: 8.5.5 packages: diff --git a/scripts/create-github-release.js b/scripts/create-github-release.js deleted file mode 100755 index 6b04b40..0000000 --- a/scripts/create-github-release.js +++ /dev/null @@ -1,152 +0,0 @@ -#!/usr/bin/env node - -const { execSync } = require("node:child_process"); -const fs = require("node:fs"); -const path = require("node:path"); - -// Get package version -const packageJson = JSON.parse(fs.readFileSync("package.json", "utf8")); -const version = packageJson.version; -const tagName = `v${version}`; - -// Check if tag exists -try { - execSync(`git rev-parse ${tagName}`, { stdio: "pipe" }); -} catch (error) { - console.error( - `❌ Tag ${tagName} does not exist. Please create the tag first.`, - ); - process.exit(1); -} - -// Get latest release notes from CHANGELOG.md -function getLatestReleaseNotes() { - const changelogPath = path.join(process.cwd(), "CHANGELOG.md"); - - if (!fs.existsSync(changelogPath)) { - console.log("⚠️ CHANGELOG.md not found, using default release notes"); - return `Release ${tagName}`; - } - - const changelog = fs.readFileSync(changelogPath, "utf8"); - const lines = changelog.split("\n"); - - const releaseNotes = []; - let inCurrentVersion = false; - let foundVersion = false; - - for (const line of lines) { - // Look for version header (e.g., ## 0.1.9 or ## [0.1.9]) - if ( - line.match(new RegExp(`##\\s*\\[?${version.replace(/\./g, "\\.")}\\]?`)) - ) { - inCurrentVersion = true; - foundVersion = true; - continue; - } - - // Stop at next version header - if (inCurrentVersion && line.startsWith("## ")) { - break; - } - - // Collect release notes - if (inCurrentVersion) { - releaseNotes.push(line); - } - } - - if (!foundVersion) { - console.log( - `⚠️ Version ${version} not found in CHANGELOG.md, using default release notes`, - ); - return `Release ${tagName}`; - } - - return releaseNotes.join("\n").trim() || `Release ${tagName}`; -} - -// Create GitHub release -function createGitHubRelease() { - const releaseNotes = getLatestReleaseNotes(); - - console.log(`🚀 Creating GitHub release for ${tagName}...`); - - try { - // Check if gh CLI is installed - execSync("gh --version", { stdio: "pipe" }); - - // Check if release already exists - try { - execSync(`gh release view ${tagName}`, { stdio: "pipe" }); - console.log(`⚠️ Release ${tagName} already exists. Skipping creation.`); - console.log( - `🔗 View at: https://github.com/${getRepoInfo()}/releases/tag/${tagName}`, - ); - return; - } catch (error) { - // Release doesn't exist, continue with creation - } - - console.log(`📝 Release notes:\n${releaseNotes}\n`); - - // Write release notes to temporary file to avoid command line escaping issues - const tempFile = path.join(process.cwd(), ".release-notes-temp.md"); - fs.writeFileSync(tempFile, releaseNotes, "utf8"); - - try { - // Create release using GitHub CLI with notes from file - const command = `gh release create ${tagName} --title "Release ${tagName}" --notes-file "${tempFile}" --latest`; - - console.log(`🔧 Executing: ${command}`); - - execSync(command, { - stdio: "inherit", - shell: true, - env: { ...process.env }, - }); - - console.log(`✅ GitHub release ${tagName} created successfully!`); - console.log( - `🔗 View at: https://github.com/${getRepoInfo()}/releases/tag/${tagName}`, - ); - } finally { - // Clean up temporary file - if (fs.existsSync(tempFile)) { - fs.unlinkSync(tempFile); - } - } - } catch (error) { - if (error.message.includes("gh: command not found")) { - console.error( - "❌ GitHub CLI (gh) is not installed. Please install it first:", - ); - console.error(" brew install gh"); - console.error(" or visit: https://cli.github.com/"); - } else if (error.message.includes("already exists")) { - console.log(`⚠️ Release ${tagName} already exists. Skipping creation.`); - console.log( - `🔗 View at: https://github.com/${getRepoInfo()}/releases/tag/${tagName}`, - ); - return; - } else { - console.error("❌ Error creating GitHub release:", error.message); - } - process.exit(1); - } -} - -// Get repository info from package.json -function getRepoInfo() { - const repoUrl = packageJson.repository?.url || ""; - const match = repoUrl.match( - /github\.com[\/:](.+?)(?:\.git)?(?:\/tree\/\w+)?$/, - ); - return match ? match[1] : "unknown/repo"; -} - -if (require.main === module) { - createGitHubRelease(); -} - -module.exports = { createGitHubRelease, getLatestReleaseNotes }; diff --git a/scripts/release-prepare.mjs b/scripts/release-prepare.mjs new file mode 100644 index 0000000..28be8d3 --- /dev/null +++ b/scripts/release-prepare.mjs @@ -0,0 +1,144 @@ +#!/usr/bin/env node + +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { + assertAuthenticatedTools, + assertCleanLatestMain, + assertCleanMain, + installFrozenDependencies, + parseChangesetStatus, + readPackageJson, + run, + validateReleaseDiff, +} from "./release-utils.mjs"; + +function branchExists(branchName) { + const local = + run( + "git", + ["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], + { + allowExitCodes: [0, 1], + }, + ).status === 0; + const remote = + run( + "git", + [ + "ls-remote", + "--exit-code", + "--heads", + "origin", + `refs/heads/${branchName}`, + ], + { allowExitCodes: [0, 2] }, + ).status === 0; + return local || remote; +} + +async function prepareRelease() { + let branchName; + let pushed = false; + const temporaryDirectory = await mkdtemp( + path.join(os.tmpdir(), "nde-release-prepare-"), + ); + + try { + if (process.argv.length > 2) + throw new Error("release:prepare does not accept arguments"); + assertAuthenticatedTools(); + assertCleanLatestMain(); + installFrozenDependencies(); + assertCleanMain(); + const packageJson = await readPackageJson(); + const statusFile = path.join(temporaryDirectory, "changeset-status.json"); + run("pnpm", ["exec", "changeset", "status", "--output", statusFile]); + const { version, changesetCount } = parseChangesetStatus( + await readFile(statusFile, "utf8"), + packageJson.name, + ); + const tagName = `v${version}`; + branchName = `release/${tagName}`; + if (branchExists(branchName)) + throw new Error(`Release branch ${branchName} already exists`); + + console.log( + `Preparing ${packageJson.name}@${version} from ${changesetCount} changeset(s).`, + ); + run("git", ["switch", "-c", branchName], { stdio: "inherit" }); + run("pnpm", ["exec", "changeset", "version"], { stdio: "inherit" }); + + const versionedPackage = await readPackageJson(); + if (versionedPackage.version !== version) { + throw new Error( + `Expected version ${version}, found ${versionedPackage.version}`, + ); + } + const validated = validateReleaseDiff( + run("git", ["diff", "--name-status", "-M", "HEAD"]).stdout, + ); + if (validated.deletedChangesets.length !== changesetCount) { + throw new Error(`Expected ${changesetCount} deleted changesets`); + } + const changedFiles = validated.entries.flatMap((entry) => entry.paths); + run("git", ["add", "--", ...changedFiles]); + const leftovers = [ + run("git", ["diff", "--name-only"]).stdout.trim(), + run("git", ["ls-files", "--others", "--exclude-standard"]).stdout.trim(), + ].filter(Boolean); + if (leftovers.length) + throw new Error(`Unexpected release files:\n${leftovers.join("\n")}`); + + const title = `chore: release ${tagName}`; + run("git", ["commit", "-m", title], { stdio: "inherit" }); + run("git", ["push", "origin", `HEAD:refs/heads/${branchName}`], { + stdio: "inherit", + }); + pushed = true; + const body = [ + `## Release ${tagName}`, + "", + `Aggregates ${changesetCount} pending changeset(s).`, + "", + "- [ ] Version and changelog are correct", + "- [ ] Only release metadata changed", + "- [ ] CI passes", + "- [ ] Merge with Squash Merge", + ].join("\n"); + run( + "gh", + [ + "pr", + "create", + "--base", + "main", + "--head", + branchName, + "--title", + title, + "--body", + body, + ], + { stdio: "inherit" }, + ); + console.log(`Version PR created for ${tagName}.`); + } catch (error) { + console.error(`Release preparation failed: ${error.message}`); + if (pushed) { + console.error( + `The branch ${branchName} was pushed. Create its PR manually with gh pr create.`, + ); + } else if (branchName) { + console.error( + `The local branch ${branchName} was preserved for inspection.`, + ); + } + process.exitCode = 1; + } finally { + await rm(temporaryDirectory, { recursive: true, force: true }); + } +} + +prepareRelease(); diff --git a/scripts/release-utils.mjs b/scripts/release-utils.mjs new file mode 100644 index 0000000..f503181 --- /dev/null +++ b/scripts/release-utils.mjs @@ -0,0 +1,230 @@ +import { spawnSync } from "node:child_process"; +import { readFile, readdir } from "node:fs/promises"; +import path from "node:path"; +import { isDeepStrictEqual } from "node:util"; + +export const NPM_REGISTRY = "https://registry.npmjs.org"; +const STABLE_SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; +const RELEASE_FILES = new Set([ + "package.json", + "CHANGELOG.md", + "pnpm-lock.yaml", +]); + +export class CommandError extends Error { + constructor(command, result) { + const details = [result.stderr, result.stdout] + .filter(Boolean) + .join("\n") + .trim(); + super(`Command failed: ${command}${details ? `\n${details}` : ""}`); + this.name = "CommandError"; + this.status = result.status; + this.stdout = result.stdout; + this.stderr = result.stderr; + } +} + +export function run(command, args = [], options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd ?? process.cwd(), + env: { ...process.env, ...options.env }, + encoding: "utf8", + stdio: options.stdio ?? "pipe", + }); + if (result.error) throw result.error; + + const output = { + status: result.status ?? 1, + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + }; + if (!(options.allowExitCodes ?? [0]).includes(output.status)) { + throw new CommandError([command, ...args].join(" "), output); + } + return output; +} + +export function assertStableVersion(version) { + if (!STABLE_SEMVER.test(version)) { + throw new Error( + `Only stable SemVer releases are supported; found ${version}`, + ); + } +} + +export function assertVersionIncreased(previousVersion, nextVersion) { + assertStableVersion(previousVersion); + assertStableVersion(nextVersion); + const previous = previousVersion.split(".").map(Number); + const next = nextVersion.split(".").map(Number); + for (let index = 0; index < 3; index += 1) { + if (next[index] === previous[index]) continue; + if (next[index] > previous[index]) return; + break; + } + throw new Error( + `Version must increase from ${previousVersion} to ${nextVersion}`, + ); +} + +export function assertOnlyPackageVersionChanged(previousPackage, nextPackage) { + const previous = { ...previousPackage, version: nextPackage.version }; + if (!isDeepStrictEqual(previous, nextPackage)) { + throw new Error("Version commits may only change package.json version"); + } +} + +export function assertAuthenticatedTools({ npm = false } = {}) { + const nodeMajor = Number(process.version.slice(1).split(".")[0]); + const pnpmVersion = run("pnpm", ["--version"]).stdout.trim(); + if (nodeMajor !== 22) + throw new Error(`Node.js 22 is required; found ${process.version}`); + if (Number(pnpmVersion.split(".")[0]) !== 10) { + throw new Error(`pnpm 10 is required; found ${pnpmVersion}`); + } + run("gh", ["auth", "status", "--hostname", "github.com"]); + if (npm) run("npm", ["whoami", "--registry", NPM_REGISTRY]); +} + +export function installFrozenDependencies() { + run("pnpm", ["install", "--frozen-lockfile", "--ignore-scripts"], { + stdio: "inherit", + env: { NPM_CONFIG_IGNORE_SCRIPTS: "true" }, + }); +} + +export function assertCleanLatestMain() { + const branch = run("git", ["branch", "--show-current"]).stdout.trim(); + if (branch !== "main") + throw new Error( + `Releases must run from main; found ${branch || "detached HEAD"}`, + ); + const status = run("git", ["status", "--porcelain"]).stdout.trim(); + if (status) throw new Error(`Working tree is not clean:\n${status}`); + + run("git", ["fetch", "origin", "main"]); + const head = run("git", ["rev-parse", "HEAD"]).stdout.trim(); + const remote = run("git", ["rev-parse", "origin/main"]).stdout.trim(); + if (head !== remote) + throw new Error( + `Local main (${head}) does not match origin/main (${remote})`, + ); + return head; +} + +export function assertCleanMain() { + const branch = run("git", ["branch", "--show-current"]).stdout.trim(); + const status = run("git", ["status", "--porcelain"]).stdout.trim(); + if (branch !== "main" || status) + throw new Error("Build or tests changed the release working tree"); +} + +export async function readPackageJson(root = process.cwd()) { + return JSON.parse(await readFile(path.join(root, "package.json"), "utf8")); +} + +export async function listChangesets(root = process.cwd()) { + const entries = await readdir(path.join(root, ".changeset"), { + withFileTypes: true, + }); + return entries + .filter( + (entry) => + entry.isFile() && + entry.name.endsWith(".md") && + entry.name !== "README.md", + ) + .map((entry) => `.changeset/${entry.name}`) + .sort(); +} + +export function parseChangesetStatus(raw, packageName) { + const status = JSON.parse(raw); + if (!status.changesets?.length) + throw new Error("No pending changesets. Run 'pnpm change' first."); + const release = status.releases?.find((item) => item.name === packageName); + if (!release?.newVersion) + throw new Error( + `Changesets did not calculate a release for ${packageName}`, + ); + assertStableVersion(release.newVersion); + return { + changesetCount: status.changesets.length, + version: release.newVersion, + }; +} + +export function validateReleaseDiff(raw) { + const entries = raw + .split("\n") + .filter(Boolean) + .map((line) => { + const [status, ...paths] = line.split("\t"); + return { status, paths }; + }); + let packageChanged = false; + let changelogChanged = false; + const deletedChangesets = []; + + for (const entry of entries) { + if (entry.paths.length !== 1) + throw new Error( + `Renamed files are not allowed: ${entry.paths.join(" -> ")}`, + ); + const [file] = entry.paths; + if (file.startsWith(".changeset/")) { + if (entry.status !== "D" || !file.endsWith(".md")) { + throw new Error( + `Unexpected changeset change: ${entry.status}\t${file}`, + ); + } + deletedChangesets.push(file); + continue; + } + if (!RELEASE_FILES.has(file) || !entry.status.startsWith("M")) { + throw new Error(`Unexpected release change: ${entry.status}\t${file}`); + } + packageChanged ||= file === "package.json"; + changelogChanged ||= file === "CHANGELOG.md"; + } + if (!packageChanged || !changelogChanged || deletedChangesets.length === 0) { + throw new Error( + "Release diff must update package.json and CHANGELOG.md and delete changesets", + ); + } + return { entries, deletedChangesets }; +} + +export function extractReleaseNotes(changelog, version) { + const header = new RegExp( + `^##\\s+\\[?${version.replaceAll(".", "\\.")}\\]?\\s*$`, + ); + const lines = changelog.split(/\r?\n/); + const start = lines.findIndex((line) => header.test(line)); + if (start === -1) + throw new Error(`Version ${version} was not found in CHANGELOG.md`); + const next = lines.findIndex( + (line, index) => index > start && line.startsWith("## "), + ); + const notes = lines + .slice(start + 1, next === -1 ? undefined : next) + .join("\n") + .trim(); + if (!notes) throw new Error(`Version ${version} has no release notes`); + return notes; +} + +export function isNpmNotFoundError(error) { + if (!(error instanceof CommandError)) return false; + return /(?:^|\n)npm (?:ERR!|error) code E404(?:\n|$)/i.test( + `${error.stderr}\n${error.stdout}`, + ); +} + +export function parseNpmPack(raw) { + const pack = JSON.parse(raw)?.[0]; + if (!pack?.filename || !pack?.integrity) + throw new Error("npm pack did not return filename and integrity"); + return { filename: pack.filename, integrity: pack.integrity }; +} diff --git a/scripts/release-utils.node-test.mjs b/scripts/release-utils.node-test.mjs new file mode 100644 index 0000000..0edc9d6 --- /dev/null +++ b/scripts/release-utils.node-test.mjs @@ -0,0 +1,109 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + CommandError, + assertOnlyPackageVersionChanged, + assertStableVersion, + assertVersionIncreased, + extractReleaseNotes, + isNpmNotFoundError, + parseChangesetStatus, + parseNpmPack, + validateReleaseDiff, +} from "./release-utils.mjs"; + +test("aggregates multiple changesets into one release", () => { + const result = parseChangesetStatus( + JSON.stringify({ + changesets: [{ id: "one" }, { id: "two" }], + releases: [{ name: "ndepe", newVersion: "0.2.0" }], + }), + "ndepe", + ); + assert.deepEqual(result, { changesetCount: 2, version: "0.2.0" }); +}); + +test("rejects missing changesets and prereleases", () => { + assert.throws( + () => + parseChangesetStatus( + JSON.stringify({ changesets: [], releases: [] }), + "ndepe", + ), + /No pending changesets/, + ); + assert.throws(() => assertStableVersion("1.0.0-beta.1"), /stable SemVer/); +}); + +test("requires the package version to increase", () => { + assert.doesNotThrow(() => assertVersionIncreased("0.1.13", "0.2.0")); + assert.throws( + () => assertVersionIncreased("0.2.0", "0.1.13"), + /Version must increase/, + ); +}); + +test("allows only the version field to change in package.json", () => { + const previous = { + name: "ndepe", + version: "0.1.13", + main: "./dist/index.js", + }; + assert.doesNotThrow(() => + assertOnlyPackageVersionChanged(previous, { + ...previous, + version: "0.2.0", + }), + ); + assert.throws( + () => + assertOnlyPackageVersionChanged(previous, { + ...previous, + version: "0.2.0", + name: "wrong-package", + }), + /may only change/, + ); +}); + +test("accepts only release metadata and deleted changesets", () => { + const diff = [ + "D\t.changeset/one.md", + "D\t.changeset/two.md", + "M\tCHANGELOG.md", + "M\tpackage.json", + ].join("\n"); + assert.equal(validateReleaseDiff(diff).deletedChangesets.length, 2); + assert.throws( + () => validateReleaseDiff(`${diff}\nM\tsrc/index.ts`), + /Unexpected release change/, + ); +}); + +test("extracts only the current changelog section", () => { + const changelog = "# ndepe\n\n## 0.2.0\n\n- current\n\n## 0.1.13\n\n- old\n"; + assert.equal(extractReleaseNotes(changelog, "0.2.0"), "- current"); +}); + +test("treats only npm E404 as an absent version", () => { + const missing = new CommandError("npm view", { + status: 1, + stdout: "", + stderr: "npm error code E404\n", + }); + const network = new CommandError("npm view", { + status: 1, + stdout: "", + stderr: "npm error code EAI_AGAIN\n", + }); + assert.equal(isNpmNotFoundError(missing), true); + assert.equal(isNpmNotFoundError(network), false); +}); + +test("requires npm pack filename and integrity", () => { + assert.deepEqual( + parseNpmPack('[{"filename":"ndepe-0.2.0.tgz","integrity":"sha512-value"}]'), + { filename: "ndepe-0.2.0.tgz", integrity: "sha512-value" }, + ); + assert.throws(() => parseNpmPack('[{"filename":"ndepe.tgz"}]'), /integrity/); +}); diff --git a/scripts/release.mjs b/scripts/release.mjs old mode 100755 new mode 100644 index 0eafc8e..4b2c709 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -1,151 +1,335 @@ -#!/usr/bin/env zx +#!/usr/bin/env node -import 'zx/globals' -import { readdir } from 'fs/promises' -import { existsSync } from 'fs' -import fs from 'fs/promises' +import { + access, + copyFile, + cp, + mkdir, + mkdtemp, + rm, + writeFile, +} from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { createInterface } from "node:readline/promises"; +import { pathToFileURL } from "node:url"; +import { + CommandError, + NPM_REGISTRY, + assertAuthenticatedTools, + assertCleanLatestMain, + assertCleanMain, + assertOnlyPackageVersionChanged, + assertVersionIncreased, + extractReleaseNotes, + installFrozenDependencies, + isNpmNotFoundError, + listChangesets, + parseNpmPack, + run, + validateReleaseDiff, +} from "./release-utils.mjs"; -$.verbose = true +function verifyReleaseCommit(commit) { + const parents = run("git", ["rev-list", "--parents", "-n", "1", commit]) + .stdout.trim() + .split(/\s+/); + if (parents.length !== 2) + throw new Error("The Version PR must be squash merged"); -const colors = { - red: (text) => chalk.red(text), - green: (text) => chalk.green(text), - yellow: (text) => chalk.yellow(text), + const packageJson = JSON.parse( + run("git", ["show", `${commit}:package.json`]).stdout, + ); + const previousPackage = JSON.parse( + run("git", ["show", `${commit}^:package.json`]).stdout, + ); + assertVersionIncreased(previousPackage.version, packageJson.version); + assertOnlyPackageVersionChanged(previousPackage, packageJson); + const diff = validateReleaseDiff( + run("git", ["diff", "--name-status", "-M", `${commit}^`, commit]).stdout, + ); + const changelog = run("git", ["show", `${commit}:CHANGELOG.md`]).stdout; + return { + packageJson, + version: packageJson.version, + tagName: `v${packageJson.version}`, + changesetCount: diff.deletedChangesets.length, + notes: extractReleaseNotes(changelog, packageJson.version), + }; } -console.log(colors.green('🚀 Starting release process...')) - -let tagCreated = false -let commitCreated = false -let tagName = '' - -// Cleanup function for rollback -async function cleanup() { - if (tagCreated) { - console.log(colors.yellow(`🔄 Removing tag ${tagName}...`)) - try { - await $`git tag -d ${tagName}` - } catch (error) { - console.log(colors.red('⚠️ Failed to remove tag')) - } - } - - if (commitCreated) { - console.log(colors.yellow('🔄 Rolling back commit...')) - try { - await $`git reset --hard HEAD~1` - } catch (error) { - console.log(colors.red('⚠️ Failed to rollback commit')) - } - } +function repositorySlug(packageJson) { + const repository = + typeof packageJson.repository === "string" + ? packageJson.repository + : packageJson.repository?.url; + const match = repository?.match( + /github\.com[/:]([^/]+\/[^/.]+?)(?:\.git)?(?:\/tree\/[^/]+)?$/, + ); + if (!match) throw new Error("package.json repository must point to GitHub"); + return match[1]; } -try { - const currentBranch = (await $`git branch --show-current`).stdout.trim() - if (currentBranch !== 'main') { - console.log(colors.red(`❌ Error: You must be on the main branch to release. Current branch: ${currentBranch}`)) - process.exit(1) - } - - // Check if working directory is clean before starting - const gitStatus = (await $`git status --porcelain`).stdout.trim() - if (gitStatus) { - console.log(colors.red('❌ Error: Working directory is not clean. Please commit or stash your changes first.')) - console.log(colors.yellow('Uncommitted changes:')) - console.log(gitStatus) - process.exit(1) - } - - console.log(colors.yellow('📥 Pulling latest changes...')) - await $`git pull origin main` - - console.log(colors.yellow('🧪 Running tests...')) - await $`pnpm test` - - console.log(colors.yellow('🔨 Building project...')) - await $`pnpm build` - - const changesetFiles = existsSync('.changeset') ? - (await readdir('.changeset')).filter(file => file.endsWith('.md') && file !== 'README.md') : [] - - if (changesetFiles.length === 0) { - console.log(colors.red("❌ Error: No changesets found. Please create a changeset first using 'pnpm change'")) - process.exit(1) - } - - console.log(colors.yellow('❓ Do you want to proceed with the release? (y/N)')) - const confirm = await question('> ') - if (confirm.toLowerCase() !== 'y') { - console.log(colors.red('❌ Release cancelled.')) - process.exit(1) - } - - console.log(colors.yellow('📈 Versioning packages...')) - await $`pnpm bump` - - // Get the new version from package.json - const packageJson = JSON.parse(await fs.readFile('package.json', 'utf8')) - const newVersion = packageJson.version - tagName = `v${newVersion}` - - console.log(colors.yellow(`📝 Committing version changes for ${tagName}...`)) - await $`git add .` - await $`git commit -m "chore: release ${tagName}"` - commitCreated = true - - console.log(colors.yellow(`🏷️ Creating git tag ${tagName}...`)) - await $`git tag ${tagName}` - tagCreated = true - - console.log(colors.yellow('📋 About to publish:')) - await $`git log --oneline -n 5` - - console.log(colors.yellow('❓ Ready to publish and push to GitHub? (y/N)')) - const finalConfirm = await question('> ') - if (finalConfirm.toLowerCase() !== 'y') { - console.log(colors.red('❌ Release cancelled.')) - await cleanup() - process.exit(1) - } - - // Check for npm token in environment variable - const npmToken = process.env.NPM_TOKEN - - if (npmToken) { - console.log(colors.green('🔑 Using NPM_TOKEN from environment variable')) - // Configure npm registry auth token via user-level config - // Use automation token (no 2FA required) from: https://www.npmjs.com/settings//tokens - // This doesn't modify project .npmrc file, only sets user-level config - await $`pnpm config set //registry.npmjs.org/:_authToken ${npmToken}` - console.log(colors.green('✅ Configured npm authentication')) - } - - console.log(colors.yellow('📤 Publishing to npm...')) - await $`pnpm publish` - - console.log(colors.yellow('🔗 Pushing to GitHub...')) - await $`git push origin main --follow-tags` - - console.log(colors.yellow('📋 Creating GitHub Release...')) - await $`node scripts/create-github-release.js` - - console.log(colors.green('✅ Release completed successfully!')) - console.log(colors.green(`🎉 Check GitHub releases and npm for version ${newVersion}.`)) - -} catch (error) { - console.log(colors.red(`❌ Error during release: ${error.message}`)) - console.log(colors.red(` ${error.stack || ''}`)) - - // Only cleanup if we haven't pushed to remote yet - const gitRemoteStatus = await $`git status --porcelain -b`.catch(() => ({ stdout: '' })) - const hasUnpushedCommits = gitRemoteStatus.stdout.includes('[ahead') - - if (hasUnpushedCommits || (!gitRemoteStatus.stdout.includes('origin'))) { - console.log(colors.yellow('🔄 Attempting to rollback local changes...')) - await cleanup() - } else { - console.log(colors.yellow('⚠️ Changes have been pushed to remote. Manual intervention may be required.')) - } - - process.exit(1) +function assertOrigin(slug) { + const origin = run("git", ["remote", "get-url", "origin"]).stdout.trim(); + const normalizedOrigin = origin + .replace(/^git@github\.com:/, "https://github.com/") + .replace(/^ssh:\/\/git@github\.com\//, "https://github.com/") + .replace(/\.git$/, "") + .replace(/\/$/, ""); + if (normalizedOrigin !== `https://github.com/${slug}`) { + throw new Error(`origin does not point to ${slug}`); + } +} + +function assertVersionDoesNotExist(packageName, version, tagName, slug) { + try { + run("npm", [ + "view", + `${packageName}@${version}`, + "version", + "--registry", + NPM_REGISTRY, + ]); + throw new Error(`${packageName}@${version} already exists on npm`); + } catch (error) { + if (!isNpmNotFoundError(error)) throw error; + } + + const localTag = + run("git", ["show-ref", "--verify", "--quiet", `refs/tags/${tagName}`], { + allowExitCodes: [0, 1], + }).status === 0; + const remoteTag = + run( + "git", + ["ls-remote", "--exit-code", "--tags", "origin", `refs/tags/${tagName}`], + { allowExitCodes: [0, 2] }, + ).status === 0; + if (localTag || remoteTag) throw new Error(`Tag ${tagName} already exists`); + + try { + run("gh", ["api", `repos/${slug}/releases/tags/${tagName}`, "--silent"]); + throw new Error(`GitHub Release ${tagName} already exists`); + } catch (error) { + if ( + !(error instanceof CommandError) || + !/HTTP 404/.test(`${error.stderr}\n${error.stdout}`) + ) { + throw error; + } + } +} + +export async function buildPackage(root, packageJson) { + const safeEnv = { NPM_CONFIG_IGNORE_SCRIPTS: "true" }; + run("pnpm", ["exec", "biome", "check", "."], { + stdio: "inherit", + env: safeEnv, + }); + run("pnpm", ["build"], { stdio: "inherit", env: safeEnv }); + run("pnpm", ["test"], { stdio: "inherit", env: safeEnv }); + assertCleanMain(); + + const temporaryDirectory = await mkdtemp( + path.join(os.tmpdir(), "nde-release-"), + ); + const staging = path.join(temporaryDirectory, "staging"); + const artifacts = path.join(temporaryDirectory, "artifacts"); + const consumer = path.join(temporaryDirectory, "consumer"); + try { + await Promise.all([mkdir(staging), mkdir(artifacts), mkdir(consumer)]); + await writeFile( + path.join(staging, "package.json"), + `${JSON.stringify(packageJson, null, 2)}\n`, + ); + await Promise.all([ + cp(path.join(root, "dist"), path.join(staging, "dist"), { + recursive: true, + }), + copyFile(path.join(root, "README.md"), path.join(staging, "README.md")), + copyFile(path.join(root, "LICENSE"), path.join(staging, "LICENSE")), + ]); + await Promise.all([ + access(path.join(staging, "dist/index.js")), + access(path.join(staging, "dist/index.mjs")), + access(path.join(staging, "dist/index.d.ts")), + ]); + + const pack = parseNpmPack( + run( + "npm", + [ + "pack", + staging, + "--ignore-scripts", + "--json", + "--pack-destination", + artifacts, + ], + { env: safeEnv }, + ).stdout, + ); + const tarball = path.join(artifacts, pack.filename); + await writeFile(path.join(consumer, "package.json"), '{"private":true}\n'); + run( + "npm", + [ + "install", + "--ignore-scripts", + "--no-package-lock", + "--no-save", + tarball, + ], + { cwd: consumer, stdio: "inherit", env: safeEnv }, + ); + run( + "node", + [ + "--eval", + `const pkg=require(${JSON.stringify(packageJson.name)}); if(typeof pkg.nodeDepEmit!=='function') process.exit(1)`, + ], + { cwd: consumer }, + ); + return { ...pack, tarball, temporaryDirectory }; + } catch (error) { + await rm(temporaryDirectory, { recursive: true, force: true }); + throw error; + } +} + +async function confirmVersion(version) { + if (!process.stdin.isTTY || !process.stdout.isTTY) + throw new Error("Publishing requires an interactive terminal"); + const prompt = createInterface({ + input: process.stdin, + output: process.stdout, + }); + try { + const answer = await prompt.question(`Type ${version} to publish: `); + if (answer.trim() !== version) throw new Error("Release cancelled"); + } finally { + prompt.close(); + } +} + +async function release() { + let pack; + let publishAttempted = false; + let npmPublished = false; + let tagCreated = false; + let tagPushed = false; + let releaseInfo; + try { + if (process.argv.length > 2) + throw new Error("release does not accept arguments"); + if (process.env.NPM_TOKEN || process.env.NODE_AUTH_TOKEN) { + throw new Error( + "Unset NPM_TOKEN and NODE_AUTH_TOKEN; use npm login with 2FA", + ); + } + assertAuthenticatedTools({ npm: true }); + const commit = assertCleanLatestMain(); + installFrozenDependencies(); + assertCleanMain(); + releaseInfo = verifyReleaseCommit(commit); + const pendingChangesets = await listChangesets(); + if (pendingChangesets.length) + throw new Error("Pending changesets remain on main"); + + const { packageJson, version, tagName, changesetCount, notes } = + releaseInfo; + const slug = repositorySlug(packageJson); + assertOrigin(slug); + assertVersionDoesNotExist(packageJson.name, version, tagName, slug); + pack = await buildPackage(process.cwd(), packageJson); + + console.log(`Ready to publish ${packageJson.name}@${version}`); + console.log(`Commit: ${commit}`); + console.log(`Changesets: ${changesetCount}`); + console.log(`Tarball integrity: ${pack.integrity}`); + await confirmVersion(version); + if (assertCleanLatestMain() !== commit) { + throw new Error("main changed during release verification"); + } + + publishAttempted = true; + run( + "npm", + [ + "publish", + pack.tarball, + "--ignore-scripts", + "--tag", + "latest", + "--registry", + NPM_REGISTRY, + ], + { stdio: "inherit", env: { NPM_CONFIG_IGNORE_SCRIPTS: "true" } }, + ); + npmPublished = true; + run("git", ["tag", "-a", tagName, commit, "-m", tagName]); + tagCreated = true; + run( + "git", + ["push", "origin", `refs/tags/${tagName}:refs/tags/${tagName}`], + { + stdio: "inherit", + }, + ); + tagPushed = true; + const notesFile = path.join(pack.temporaryDirectory, "release-notes.md"); + await writeFile(notesFile, `${notes}\n`); + run( + "gh", + [ + "release", + "create", + tagName, + "--verify-tag", + "--title", + tagName, + "--notes-file", + notesFile, + "--latest", + "--repo", + slug, + ], + { stdio: "inherit" }, + ); + console.log(`Release ${tagName} completed.`); + } catch (error) { + console.error(`Release failed: ${error.message}`); + if (publishAttempted && !npmPublished) { + console.error( + "npm publish returned an error. Check npm before retrying the release.", + ); + } + if (npmPublished && releaseInfo) { + console.error( + `npm is already published. Do not publish ${releaseInfo.version} again.`, + ); + if (tagPushed) + console.error(`Create GitHub Release ${releaseInfo.tagName} manually.`); + else if (tagCreated) + console.error( + `Push local Tag ${releaseInfo.tagName}, then create its GitHub Release manually.`, + ); + else + console.error( + `Create and push Tag ${releaseInfo.tagName}, then create its GitHub Release manually.`, + ); + } + process.exitCode = 1; + } finally { + if (pack) + await rm(pack.temporaryDirectory, { recursive: true, force: true }); + } +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + release(); }