Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <patch|minor|major|X.Y.Z>` 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

Expand Down
23 changes: 22 additions & 1 deletion RELEASE_CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -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] - <date>`), 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`.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}'"
Expand Down
193 changes: 193 additions & 0 deletions scripts/release.mjs
Original file line number Diff line number Diff line change
@@ -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 <patch|minor|major|X.Y.Z> [--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 <patch|minor|major|X.Y.Z> [--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}`);
}
Loading