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
168 changes: 159 additions & 9 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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
2 changes: 2 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ on:
push:
branches: [main]
pull_request:
# Called by publish.yml before a release
workflow_call:

permissions:
contents: read
Expand Down
10 changes: 10 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
24 changes: 15 additions & 9 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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.
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
57 changes: 57 additions & 0 deletions scripts/changelog.mjs
Original file line number Diff line number Diff line change
@@ -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') : []
}
30 changes: 30 additions & 0 deletions scripts/release-notes.mjs
Original file line number Diff line number Diff line change
@@ -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 <version> [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 <X.Y.Z> [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'
)
}
Loading
Loading