From 754d174fd5c6bf6b547e4125e49c2f2fcb4943dd Mon Sep 17 00:00:00 2001 From: Francois Date: Fri, 23 Oct 2026 13:22:51 +0200 Subject: [PATCH] ci: release automatically when the version changes on main The Publish workflow now runs on a push to main that changes the version of visual-editor/package.json: it refuses an existing tag or a version already on npm, runs the test workflow, creates the X.Y.Z tag, publishes with trusted publishing and provenance, then creates the GitHub release from the changelog section (the commits since the previous tag when it is missing). The file keeps its name, declared as the npm trusted publisher. A manual run on main releases the current version, a tag pushed by hand or a manual run on a tag stays the fallback. pnpm release:prepare bumps the version and adds the changelog section, prefilled with the commits since the last tag. --- .github/workflows/publish.yml | 168 ++++++++++++++++++++++++++++++++-- .github/workflows/test.yml | 2 + CONTRIBUTING.md | 10 ++ RELEASING.md | 24 +++-- package.json | 4 + scripts/changelog.mjs | 57 ++++++++++++ scripts/release-notes.mjs | 30 ++++++ scripts/release-prepare.mjs | 82 +++++++++++++++++ 8 files changed, 359 insertions(+), 18 deletions(-) create mode 100644 scripts/changelog.mjs create mode 100644 scripts/release-notes.mjs create mode 100644 scripts/release-prepare.mjs 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.')