diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4c4c36d..4a3cb03 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,21 +1,131 @@ name: Publish -# Publishes visual-editor/dist to npm with trusted publishing (OIDC) -# A manual run must target a tag ("Use workflow from" > tag) +# Releases @boxraiser/visual-editor when the version of +# visual-editor/package.json changes on main: tests, tag, npm publication with +# trusted publishing (OIDC) and provenance, then the GitHub release. +# +# The file name is declared as the trusted publisher on npmjs.com: renaming it +# breaks the publication. +# +# Other entry points: +# - manual run on main: releases the current version if it has no tag yet +# - tag X.Y.Z pushed by hand, or manual run on a tag: publishes that tag +# (fallback, also used to retry a failed publication) on: push: + branches: [main] + paths: [visual-editor/package.json] tags: - '[0-9]+.[0-9]+.[0-9]+*' workflow_dispatch: -permissions: - contents: read - id-token: write +permissions: {} + +concurrency: + group: publish + cancel-in-progress: false jobs: + prepare: + name: Check the version + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + release: ${{ steps.check.outputs.release }} + version: ${{ steps.check.outputs.version }} + create_tag: ${{ steps.check.outputs.create_tag }} + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: Use Node.js + uses: actions/setup-node@v7 + with: + node-version: 24 + package-manager-cache: false + - name: Decide what to release + id: check + env: + EVENT: ${{ github.event_name }} + REF_TYPE: ${{ github.ref_type }} + REF_NAME: ${{ github.ref_name }} + BEFORE: ${{ github.event.before }} + run: | + set -euo pipefail + fail() { echo "::error::$1"; exit 1; } + version=$(node -p "require('./visual-editor/package.json').version") + name=$(node -p "require('./visual-editor/package.json').name") + + if [ "$REF_TYPE" = tag ]; then + [ "$version" = "$REF_NAME" ] || fail "Tag $REF_NAME does not match package version $version" + create_tag=false + elif [ "$REF_NAME" = main ]; then + if [ "$EVENT" = push ] && git cat-file -e "$BEFORE^{commit}" 2>/dev/null; then + previous=$(git show "$BEFORE:visual-editor/package.json" | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).version") + if [ "$previous" = "$version" ]; then + echo "Version $version unchanged, nothing to release" + echo "release=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "Version $previous -> $version" + fi + [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "Version $version is not X.Y.Z" + if git ls-remote --exit-code --tags origin "refs/tags/$version" > /dev/null; then + fail "Tag $version already exists: bump the version, or run this workflow on the tag to retry" + fi + create_tag=true + else + fail "Run this workflow on main or on a tag" + fi + + published=$(npm view "$name@$version" version 2>/dev/null || true) + [ -z "$published" ] || fail "$name@$version is already on npm" + + { + echo "release=true" + echo "version=$version" + echo "create_tag=$create_tag" + } >> "$GITHUB_OUTPUT" + + test: + name: Tests + needs: prepare + if: needs.prepare.outputs.release == 'true' + permissions: + contents: read + uses: ./.github/workflows/test.yml + + tag: + name: Create the tag + needs: [prepare, test] + if: needs.prepare.outputs.create_tag == 'true' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + # Fails if the tag exists. A tag created with GITHUB_TOKEN does not + # start another run of this workflow. + - name: Tag the released commit + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ needs.prepare.outputs.version }} + run: gh api "repos/$GITHUB_REPOSITORY/git/refs" -f ref="refs/tags/$VERSION" -f sha="$GITHUB_SHA" + publish: - if: github.ref_type == 'tag' + name: Publish to npm + needs: [prepare, test, tag] + if: >- + !cancelled() + && needs.prepare.outputs.release == 'true' + && needs.test.result == 'success' + && (needs.tag.result == 'success' + || (needs.tag.result == 'skipped' && needs.prepare.outputs.create_tag == 'false')) runs-on: ubuntu-latest + permissions: + contents: read + id-token: write steps: - name: Checkout uses: actions/checkout@v7 @@ -29,11 +139,18 @@ jobs: node-version: 24 registry-url: https://registry.npmjs.org package-manager-cache: false - - name: Check the tag matches the package version + - name: Check the version + env: + VERSION: ${{ needs.prepare.outputs.version }} run: | version=$(node -p "require('./visual-editor/package.json').version") - if [ "$version" != "$GITHUB_REF_NAME" ]; then - echo "Tag $GITHUB_REF_NAME does not match package version $version" + name=$(node -p "require('./visual-editor/package.json').name") + if [ "$version" != "$VERSION" ]; then + echo "::error::Checked out version $version, expected $VERSION" + exit 1 + fi + if [ -n "$(npm view "$name@$version" version 2>/dev/null || true)" ]; then + echo "::error::$name@$version is already on npm" exit 1 fi - name: Install dependencies @@ -46,3 +163,36 @@ jobs: env: # Fallback only: empty unless an NPM_TOKEN secret exists, OIDC is used first NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + release: + name: Create the GitHub release + needs: [prepare, publish] + if: ${{ !cancelled() && needs.publish.result == 'success' }} + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: Use Node.js + uses: actions/setup-node@v7 + with: + node-version: 24 + package-manager-cache: false + # Changelog section of the version, or the commits since the previous tag + - name: Write the release notes + env: + VERSION: ${{ needs.prepare.outputs.version }} + run: node scripts/release-notes.mjs "$VERSION" "$GITHUB_SHA" | tee notes.md + - name: Create the release + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ needs.prepare.outputs.version }} + run: | + if gh release view "$VERSION" > /dev/null 2>&1; then + echo "Release $VERSION already exists" + else + gh release create "$VERSION" --verify-tag --title "$VERSION" --notes-file notes.md + fi diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c0766a7..92a3585 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,6 +4,8 @@ on: push: branches: [main] pull_request: + # Called by publish.yml before a release + workflow_call: permissions: contents: read diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e810a91..3a9d102 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,3 +24,13 @@ To run Cypress against another port: start `php -S localhost:8765` in `visual-ed - Commit messages follow the existing style: `feat:`, `fix:`, `chore:`, `docs:`, `test:`... - Keep the public API and the stored JSON format stable. `cypress/e2e/compat.cy.js` replays how an existing host uses the library: if it fails, the change breaks existing users and must be discussed first. - Add or update the documentation in `docs/docs/` and an entry in `docs/docs/changelog.mdx`. + +## Releasing + +A release is a pull request that changes the version: + +```bash +pnpm release:prepare patch # or minor, major, X.Y.Z +``` + +The script bumps `visual-editor/package.json` and adds the version section to `docs/docs/changelog.mdx`, prefilled with the commits since the last tag. Edit the entries, commit `bump: X.Y.Z` and open the pull request to `main`. When it is merged, the `Publish` workflow runs the tests, creates the `X.Y.Z` tag, publishes the package to npm with provenance and creates the GitHub release from the changelog section. Details and the fallbacks are in [RELEASING.md](RELEASING.md). diff --git a/RELEASING.md b/RELEASING.md index 336cf34..e2e7598 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,6 +1,6 @@ # Releasing -Releases are published to npm by GitHub Actions on every `X.Y.Z` tag (`.github/workflows/publish.yml`) with npm trusted publishing (OIDC): no npm token is stored in the repository, and the package gets a provenance attestation. +Releases are automated by `.github/workflows/publish.yml`. The package is published to npm with trusted publishing (OIDC): no npm token is stored in the repository, and the package gets a provenance attestation. ## One time setup @@ -12,18 +12,24 @@ On npmjs.com, in the settings of `@boxraiser/visual-editor`, add a trusted publi - Workflow filename: `publish.yml` - Environment: leave empty +The workflow file must keep this name, renaming it breaks the publication. + Fallback, only if trusted publishing is not available: create a granular npm access token with publish rights on the package and store it as the `NPM_TOKEN` repository secret. The workflow uses it when OIDC is not possible. ## Release a version -1. Update `version` in `visual-editor/package.json` and add the entry in `docs/docs/changelog.mdx`, then merge to `main`. -2. Tag the merged commit with the bare version and push the tag: +1. Run `pnpm release:prepare patch` (or `minor`, `major`, `X.Y.Z`): it bumps `visual-editor/package.json` and adds the version section to `docs/docs/changelog.mdx` with the commits since the last tag. +2. Edit the changelog entries, commit `bump: X.Y.Z` and open a pull request to `main`. +3. Merge it. On the push to `main` that changes the version, the `Publish` workflow: + - stops if the tag `X.Y.Z` exists or if the version is already on npm, + - runs the tests (`test.yml`: type check, build, vitest, Cypress), + - creates the `X.Y.Z` tag on the merged commit, + - builds `visual-editor/dist` and publishes it with `npm publish --provenance`, + - creates the GitHub release, with the changelog section of the version as notes (the commits since the previous tag when the section is missing). - ```bash - git tag 0.2.1 - git push origin 0.2.1 - ``` +A push to `main` that touches `visual-editor/package.json` without changing the version releases nothing. -3. The `Publish` workflow builds `visual-editor/dist` and publishes it. It fails if the tag does not match the package version. +## Fallbacks -If a run fails after the tag is pushed, fix the cause and run the `Publish` workflow again from the Actions tab with "Use workflow from" set to the tag (manual runs are only allowed on tags). +- **Release the current version by hand**: run the `Publish` workflow from the Actions tab on `main`. It releases the version of `visual-editor/package.json` if it has no tag yet. +- **Retry a failed publication**: once the tag exists, run the `Publish` workflow with "Use workflow from" set to the tag, or push a tag yourself (`git tag X.Y.Z && git push origin X.Y.Z`). On a tag, the workflow checks that it matches the package version, runs the tests, publishes and creates the release if it is missing. If only the release step failed, re-run the failed job. diff --git a/package.json b/package.json index 1cb224e..1e7800a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,10 @@ { "private": true, "packageManager": "pnpm@10.34.6", + "scripts": { + "release:prepare": "node scripts/release-prepare.mjs", + "release:notes": "node scripts/release-notes.mjs" + }, "pnpm": { "onlyBuiltDependencies": [ "cypress", diff --git a/scripts/changelog.mjs b/scripts/changelog.mjs new file mode 100644 index 0000000..d44219a --- /dev/null +++ b/scripts/changelog.mjs @@ -0,0 +1,57 @@ +// Shared helpers for the release scripts, no dependencies +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' + +export const root = fileURLToPath(new URL('..', import.meta.url)) +export const packagePath = `${root}visual-editor/package.json` +export const changelogPath = `${root}docs/docs/changelog.mdx` + +export const versionPattern = /^\d+\.\d+\.\d+$/ + +export function readVersion() { + return JSON.parse(readFileSync(packagePath, 'utf8')).version +} + +// Body of the "## X.Y.Z - date" section, empty string when it is missing +export function changelogSection(changelog, version) { + const lines = changelog.split('\n') + const escaped = version.replaceAll('.', '\\.') + const heading = new RegExp(`^## ${escaped}(\\s|$)`) + const start = lines.findIndex((line) => heading.test(line)) + if (start === -1) { + return '' + } + const next = lines.findIndex((line, i) => i > start && /^## /.test(line)) + return lines + .slice(start + 1, next === -1 ? undefined : next) + .join('\n') + .trim() +} + +function git(...args) { + return execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim() +} + +// Latest X.Y.Z tag reachable from ref, null when there is none +export function previousTag(ref = 'HEAD') { + try { + return git( + 'describe', + '--tags', + '--abbrev=0', + '--match', + '[0-9]*.[0-9]*.[0-9]*', + ref + ) + } catch { + return null + } +} + +// Commit subjects in from..to (or up to "to" when from is null), oldest first +export function commitSubjects(from, to = 'HEAD') { + const range = from ? `${from}..${to}` : to + const out = git('log', '--reverse', '--no-merges', '--format=%s', range) + return out ? out.split('\n') : [] +} diff --git a/scripts/release-notes.mjs b/scripts/release-notes.mjs new file mode 100644 index 0000000..694186b --- /dev/null +++ b/scripts/release-notes.mjs @@ -0,0 +1,30 @@ +// Prints the release notes of a version: its changelog section, or the +// commit subjects since the previous tag when the changelog has none. +// Usage: node scripts/release-notes.mjs [ref] +import { readFileSync } from 'node:fs' +import { + changelogPath, + changelogSection, + commitSubjects, + previousTag, + versionPattern, +} from './changelog.mjs' + +const [version, ref = 'HEAD'] = process.argv.slice(2) +if (!versionPattern.test(version ?? '')) { + console.error('Usage: node scripts/release-notes.mjs [ref]') + process.exit(1) +} + +const section = changelogSection(readFileSync(changelogPath, 'utf8'), version) +if (section) { + console.log(section) +} else { + // The tag of the version itself may already exist: start from the one before + const from = previousTag(`${ref}^`) + const subjects = commitSubjects(from, ref) + console.log( + subjects.map((subject) => `- ${subject}`).join('\n') || + '- No changes listed' + ) +} diff --git a/scripts/release-prepare.mjs b/scripts/release-prepare.mjs new file mode 100644 index 0000000..3cbe2f8 --- /dev/null +++ b/scripts/release-prepare.mjs @@ -0,0 +1,82 @@ +// Bumps visual-editor/package.json and adds the version section to +// docs/docs/changelog.mdx, prefilled with the commits since the last tag. +// Usage: pnpm release:prepare +import { readFileSync, writeFileSync } from 'node:fs' +import { + changelogPath, + changelogSection, + commitSubjects, + packagePath, + previousTag, + readVersion, + versionPattern, +} from './changelog.mjs' + +const bump = process.argv[2] +const current = readVersion() + +function nextVersion(version, kind) { + const [major, minor, patch] = version.split('.').map(Number) + switch (kind) { + case 'major': + return `${major + 1}.0.0` + case 'minor': + return `${major}.${minor + 1}.0` + case 'patch': + return `${major}.${minor}.${patch + 1}` + default: + return versionPattern.test(kind ?? '') ? kind : null + } +} + +if (!versionPattern.test(current)) { + console.error(`The current version ${current} is not X.Y.Z`) + process.exit(1) +} +const version = nextVersion(current, bump) +if (!version) { + console.error('Usage: pnpm release:prepare ') + process.exit(1) +} + +const changelog = readFileSync(changelogPath, 'utf8') +if ( + changelogSection(changelog, version) || + changelog.includes(`\n## ${version} `) +) { + console.error(`The changelog already has a ${version} section`) + process.exit(1) +} + +// Text replacement keeps the formatting of package.json +const pkg = readFileSync(packagePath, 'utf8') +writeFileSync( + packagePath, + pkg.replace(/("version":\s*")[^"]+(")/, `$1${version}$2`) +) + +// "fix: something" becomes "- Fix: something" +const entries = commitSubjects(previousTag()) + .filter((subject) => !/^chore\(deps/.test(subject)) + .map((subject) => { + const match = subject.match(/^(\w+)(?:\([^)]*\))?!?:\s*(.+)$/) + if (!match) { + return `- ${subject}` + } + const type = match[1].charAt(0).toUpperCase() + match[1].slice(1) + return `- ${type}: ${match[2]}` + }) +const date = new Date().toISOString().slice(0, 10).replaceAll('-', '/') +const section = `## ${version} - ${date}\n\n${entries.join('\n') || '- '}\n\n` +const heading = '# Changelog\n\n' +if (!changelog.includes(heading)) { + console.error(`No "# Changelog" heading in ${changelogPath}`) + process.exit(1) +} +writeFileSync(changelogPath, changelog.replace(heading, heading + section)) + +console.log(`${current} -> ${version}`) +console.log( + `Edit the changelog entries, commit "bump: ${version}" and open a pull request to main:` +) +console.log('the release is tagged and published when it is merged.')