diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0f3a378 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,84 @@ +name: Release + +# Publishes the `angular-movement` library to npm and creates a GitHub Release. +# +# How to cut a release: +# 1. Bump the version in projects/movement/package.json and update CHANGELOG.md. +# 2. Commit, then tag and push: git tag v0.6.0 && git push origin v0.6.0 +# 3. This workflow verifies, builds, publishes to npm, and creates the GitHub Release. +# +# Required secret: NPM_TOKEN (an npm "Automation" access token with publish rights). + +on: + push: + tags: + - 'v*.*.*' + +permissions: + contents: write # create the GitHub Release + id-token: write # npm provenance (public repo) + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +env: + NODE_VERSION: 22 + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + registry-url: 'https://registry.npmjs.org' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Verify tag matches library version + run: | + PKG_VERSION=$(node -p "require('./projects/movement/package.json').version") + TAG_VERSION=${GITHUB_REF_NAME#v} + echo "package.json=$PKG_VERSION tag=$TAG_VERSION" + if [ "$PKG_VERSION" != "$TAG_VERSION" ]; then + echo "::error::Tag v$TAG_VERSION does not match projects/movement/package.json version $PKG_VERSION. Bump the package version before tagging." + exit 1 + fi + + - name: Lint + run: pnpm run lint + + - name: Test library + run: pnpm run test:coverage + + - name: Build library + run: pnpm exec ng build movement + + - name: Validate package contents + run: pnpm --dir dist/movement pack --dry-run + + - name: Publish to npm + run: | + echo "//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}" > .npmrc + pnpm publish ./dist/movement --no-git-checks --access public --provenance + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "${GITHUB_REF_NAME}" \ + --title "${GITHUB_REF_NAME}" \ + --generate-notes \ + --verify-tag diff --git a/CHANGELOG.md b/CHANGELOG.md index 49aeaa9..7abff12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ policy, and roadmap. - GitHub issue forms, pull request template, and Dependabot configuration. - `engines/transform-state.ts` and `composeElementKeyframes()` for unified transform composition. +- `Release` GitHub Actions workflow that publishes the library to npm (with provenance) and creates + a GitHub Release when a `v*.*.*` tag is pushed (requires the `NPM_TOKEN` secret). +- `pnpm release ` script that bumps the library version, rolls the + `CHANGELOG.md` Unreleased section, commits, and tags (supports `--dry-run` and `--push`). +- Redesigned, more visual root README with a screenshot hero, badge row, feature grid, and + collapsible recipes. ### Changed diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index c9913ba..f62e35b 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -35,7 +35,28 @@ Use this checklist before publishing `angular-movement`. - [ ] Pull request checklist matches the checks maintainers expect. - [ ] CI and deploy workflows do not require secrets for normal pull request validation. -## Publish +## Publish via CI (recommended) + +The [`Release` workflow](.github/workflows/release.yml) publishes to npm and creates the GitHub +Release automatically when a version tag is pushed. + +- [ ] One-time setup: add an npm **Automation** token as the `NPM_TOKEN` repository secret. +- [ ] Make sure the `CHANGELOG.md` **Unreleased** section lists everything in this release. +- [ ] Run the release script — it bumps `projects/movement/package.json`, rolls the changelog + (`Unreleased` → `[X.Y.Z] - `), commits `chore(release): vX.Y.Z`, and tags: + + ```bash + pnpm release minor # or: patch | major | an explicit 0.6.0 + pnpm release minor --dry-run # preview first, writes nothing + pnpm release minor --push # also pushes the commit + tag (triggers CI) + ``` + +- [ ] If you didn't pass `--push`, trigger CI with `git push --follow-tags`. +- [ ] Watch the run: it verifies the tag/version match, lints, tests, builds, validates the package, + publishes to npm with provenance, and creates the GitHub Release. +- [ ] Verify the published package page and install command. + +## Publish manually (fallback) - [ ] Confirm npm auth with `npm whoami`. - [ ] Run `pnpm run pack:check`. diff --git a/package.json b/package.json index 376790e..e970844 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "deploy": "pnpm run deploy:cloudflare", "pack:check": "ng build movement && pnpm --dir dist/movement pack --dry-run", "lib:publish": "ng build movement && pnpm publish ./dist/movement --no-git-checks", + "release": "node scripts/release.mjs", "lint": "ng lint", "prepare": "husky", "format": "prettier --write '**/*.{ts,html,css,scss,md,json}'" diff --git a/scripts/release.mjs b/scripts/release.mjs new file mode 100644 index 0000000..becce5b --- /dev/null +++ b/scripts/release.mjs @@ -0,0 +1,193 @@ +#!/usr/bin/env node +/** + * Release automation for the `angular-movement` library. + * + * Does the manual pre-release chores in one command: + * 1. Bumps the version in projects/movement/package.json + * 2. Rolls CHANGELOG.md: "Unreleased" -> "[X.Y.Z] - YYYY-MM-DD", opens a fresh Unreleased + * 3. Commits (chore(release): vX.Y.Z) and creates an annotated tag vX.Y.Z + * 4. With --push, pushes the commit + tag, which triggers .github/workflows/release.yml + * (npm publish + GitHub Release). + * + * Usage: + * pnpm release [--push] [--dry-run] [--verify] + * + * Examples: + * pnpm release minor # 0.5.0 -> 0.6.0, commit + tag locally + * pnpm release 0.6.0 --push # set exact version, then push commit + tag + * pnpm release patch --dry-run # preview everything, change nothing + */ +import { execSync } from 'node:child_process'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const LIB_PKG = resolve(ROOT, 'projects/movement/package.json'); +const CHANGELOG = resolve(ROOT, 'CHANGELOG.md'); + +const args = process.argv.slice(2); +const flags = new Set(args.filter((a) => a.startsWith('--'))); +const bumpArg = args.find((a) => !a.startsWith('--')); +const DRY = flags.has('--dry-run'); +const PUSH = flags.has('--push'); +const VERIFY = flags.has('--verify'); + +const c = { + reset: '\x1b[0m', + bold: '\x1b[1m', + dim: '\x1b[2m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + cyan: '\x1b[36m', +}; +const log = (m) => console.log(m); +const info = (m) => log(`${c.cyan}›${c.reset} ${m}`); +const ok = (m) => log(`${c.green}✓${c.reset} ${m}`); +const warn = (m) => log(`${c.yellow}!${c.reset} ${m}`); +const fail = (m) => { + log(`${c.red}✗ ${m}${c.reset}`); + process.exit(1); +}; + +function sh(cmd, opts = {}) { + return execSync(cmd, { cwd: ROOT, stdio: 'pipe', encoding: 'utf8', ...opts }).trim(); +} +function shLive(cmd) { + execSync(cmd, { cwd: ROOT, stdio: 'inherit' }); +} + +function parseSemver(v) { + const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(v); + if (!m) return null; + return { major: +m[1], minor: +m[2], patch: +m[3] }; +} +function gt(a, b) { + if (a.major !== b.major) return a.major > b.major; + if (a.minor !== b.minor) return a.minor > b.minor; + return a.patch > b.patch; +} + +// ---- 0. Validate input -------------------------------------------------- +if (!bumpArg) { + fail('Missing version. Usage: pnpm release [--push] [--dry-run] [--verify]'); +} + +const pkgText = readFileSync(LIB_PKG, 'utf8'); +const currentVersion = JSON.parse(pkgText).version; +const current = parseSemver(currentVersion); +if (!current) fail(`Current library version "${currentVersion}" is not plain semver.`); + +let next; +if (['patch', 'minor', 'major'].includes(bumpArg)) { + next = { ...current }; + if (bumpArg === 'major') (next.major += 1), (next.minor = 0), (next.patch = 0); + if (bumpArg === 'minor') (next.minor += 1), (next.patch = 0); + if (bumpArg === 'patch') next.patch += 1; +} else { + next = parseSemver(bumpArg); + if (!next) fail(`"${bumpArg}" is not a bump keyword or an X.Y.Z version.`); +} +const nextVersion = `${next.major}.${next.minor}.${next.patch}`; +const tag = `v${nextVersion}`; + +if (!gt(next, current)) fail(`Target ${nextVersion} is not greater than current ${currentVersion}.`); + +// ---- 1. Preconditions --------------------------------------------------- +const branch = sh('git rev-parse --abbrev-ref HEAD'); +const dirty = sh('git status --porcelain'); +if (dirty && !DRY) { + fail('Working tree is not clean. Commit or stash your changes before releasing.'); +} +try { + sh(`git rev-parse -q --verify "refs/tags/${tag}"`); + fail(`Tag ${tag} already exists.`); +} catch { + /* tag does not exist — good */ +} + +// ---- 2. Read + validate CHANGELOG Unreleased --------------------------- +const changelog = readFileSync(CHANGELOG, 'utf8'); +const unreleasedRe = /^## Unreleased[ \t]*$/m; +if (!unreleasedRe.test(changelog)) fail('No "## Unreleased" heading found in CHANGELOG.md.'); +const afterUnreleased = changelog.slice(changelog.search(unreleasedRe)); +const nextHeadingIdx = afterUnreleased.slice(1).search(/^## /m); +const unreleasedBody = + nextHeadingIdx === -1 ? afterUnreleased : afterUnreleased.slice(0, nextHeadingIdx + 1); +if (!/^-\s+\S/m.test(unreleasedBody)) { + warn('The Unreleased section has no bullet entries — releasing an empty changelog section.'); +} + +// ---- 3. Compute edits --------------------------------------------------- +const date = (() => { + const d = new Date(); + const p = (n) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`; +})(); + +const newPkgText = pkgText.replace( + /("version":\s*")\d+\.\d+\.\d+(")/, + `$1${nextVersion}$2`, +); +if (newPkgText === pkgText) fail('Could not find a "version" field to update in the library package.json.'); + +const newChangelog = changelog.replace(unreleasedRe, `## Unreleased\n\n## [${nextVersion}] - ${date}`); + +// ---- Summary ------------------------------------------------------------ +log(''); +log(`${c.bold}Release plan${c.reset}`); +info(`branch: ${branch}`); +info(`version: ${c.dim}${currentVersion}${c.reset} → ${c.green}${nextVersion}${c.reset}`); +info(`tag: ${tag}`); +info(`date: ${date}`); +log(''); +log(`${c.dim}Changelog entries moving into [${nextVersion}]:${c.reset}`); +log( + unreleasedBody + .replace(unreleasedRe, '') + .trim() + .split('\n') + .map((l) => ` ${l}`) + .join('\n') || ' (none)', +); +log(''); + +if (DRY) { + warn('Dry run — no files written, nothing committed.'); + log(`Would run:`); + log(` ${c.dim}git add projects/movement/package.json CHANGELOG.md${c.reset}`); + log(` ${c.dim}git commit -m "chore(release): ${tag}"${c.reset}`); + log(` ${c.dim}git tag -a ${tag} -m "${tag}"${c.reset}`); + if (PUSH) log(` ${c.dim}git push --follow-tags${c.reset}`); + process.exit(0); +} + +// ---- 4. Optional verification ------------------------------------------ +if (VERIFY) { + info('Running verification (lint, tests, pack check)…'); + shLive('pnpm run lint'); + shLive('pnpm run test:coverage'); + shLive('pnpm run pack:check'); + ok('Verification passed.'); +} + +// ---- 5. Write, commit, tag --------------------------------------------- +writeFileSync(LIB_PKG, newPkgText); +writeFileSync(CHANGELOG, newChangelog); +ok(`Bumped version and rolled changelog to ${nextVersion}.`); + +sh('git add projects/movement/package.json CHANGELOG.md'); +shLive(`git commit -m "chore(release): ${tag}"`); +sh(`git tag -a ${tag} -m "${tag}"`); +ok(`Committed and tagged ${tag}.`); + +if (PUSH) { + info('Pushing commit and tag…'); + shLive('git push --follow-tags'); + ok(`Pushed. The Release workflow will publish ${tag} to npm and create the GitHub Release.`); +} else { + log(''); + info('Not pushed yet. To trigger the release, run:'); + log(` ${c.bold}git push --follow-tags${c.reset}`); +}