Skip to content
Open
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
8 changes: 6 additions & 2 deletions .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@
"commit": false,
"fixed": [],
"linked": [],
"access": "restricted",
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
"ignore": [],
"snapshot": {
"useCalculatedVersion": true,
"prereleaseTemplate": "{tag}.{datetime}"
}
}
258 changes: 258 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
---
name: Release

# Publishes the package to npm. A module made from this template inherits both
# channels and turns them on by completing the setup in RELEASING.md.
#
# dev: every push to main with a pending changeset publishes a snapshot under
# the `dev` dist-tag. `latest` never moves, nothing is committed and no git tag
# is made. Install one with `<package>@dev`.
#
# release: while changesets are pending, keeps a "version packages" pull
# request open against main. Merging it is the release decision: the push that
# follows finds a version npm does not have, publishes it to `latest`, tags it
# and creates a GitHub Release.
#
# Building and publishing are separate jobs. The build job runs repository
# code, install scripts included, so it holds no publishing rights: it ends by
# packing a tarball. The publish job holds the OIDC permission and runs nothing
# from the repository: it downloads that tarball and hands it to npm.
#
# Publishing authenticates through npm trusted publishing (OIDC), so there is
# no token. It stays off until the repository variable NPM_PUBLISH is `true`.
# Until then the packed tarball is the dry run.

on:
push:
branches: [main]
pull_request:
branches: [main]
paths:
- .github/workflows/release.yml
- .changeset/config.json
- scripts/release-check.mjs
- package.json
workflow_dispatch:

permissions:
contents: read

# Never cancel a publish half way through.
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false

jobs:
# Runs on pull requests too, as a rehearsal. No id-token here: a lifecycle
# script in a pull request must not be able to reach npm.
snapshot:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
count: ${{ steps.pending.outputs.count }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false

- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version-file: .nvmrc
cache: npm

- run: corepack enable && corepack prepare --activate

- run: npm ci

- name: Count pending changesets
id: pending
run: |
count=$(find .changeset -maxdepth 1 -name '*.md' ! -name 'README.md' | wc -l)
echo "count=$count" >> "$GITHUB_OUTPUT"
echo "Pending changesets: $count" >> "$GITHUB_STEP_SUMMARY"

# A snapshot cannot be cut in pre mode. The checkout is thrown away, so
# dropping the marker here changes nothing on the branch.
- name: Version the snapshot
if: steps.pending.outputs.count != '0'
run: |
rm -f .changeset/pre.json
npx changeset version --snapshot dev

- name: Build
if: steps.pending.outputs.count != '0'
run: npm run build

- name: Check the release
if: steps.pending.outputs.count != '0'
run: npm run release:check

- name: Pack the tarball
if: steps.pending.outputs.count != '0'
run: |
mkdir dev-package
npm pack --pack-destination dev-package
node -p "const p = require('./package.json'); p.name + '@' + p.version" >> "$GITHUB_STEP_SUMMARY"

- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
if: steps.pending.outputs.count != '0'
with:
name: dev-package
path: dev-package
retention-days: 7
if-no-files-found: error

# No checkout and no install: nothing from the repository runs with the
# OIDC permission. Trusted publishing needs npm 11.5.1 or later, which needs
# a newer Node than the one the package builds on.
dev:
needs: snapshot
if: >-
github.event_name != 'pull_request' &&
github.ref == 'refs/heads/main' &&
vars.NPM_PUBLISH == 'true' &&
needs.snapshot.outputs.count != '0'
runs-on: ubuntu-latest
permissions:
id-token: write
steps:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
registry-url: https://registry.npmjs.org

# An exact version, and no install scripts: this job can publish.
- name: Update npm
run: npm install --global --ignore-scripts npm@11.19.1

- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: dev-package
path: dev-package

# The leading ./ matters: npm reads a bare "dir/file.tgz" as the GitHub
# shorthand "user/repo" and tries to clone it.
- name: Publish under the dev tag
run: |
npm publish ./dev-package/*.tgz --tag dev --access public
echo "Published under the dev tag." >> "$GITHUB_STEP_SUMMARY"

# Opens or updates the version pull request, and packs a version npm does
# not have yet. It runs repository code, so it holds no publishing rights.
version:
if: github.event_name == 'push'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
outputs:
tag: ${{ steps.unpublished.outputs.tag }}
version: ${{ steps.unpublished.outputs.version }}
steps:
# GitHub runs no checks on a pull request opened with the workflow's own
# token, and a protected main requires them. An app token gets them run.
# Without the app the pull request still opens, and its checks need a
# manual run.
- uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
id: app
if: vars.RELEASE_APP_ID != ''
with:
app-id: ${{ vars.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}

- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0
# The install and the build run package code next. The pull request
# step below talks to the API with its own token, so git needs none.
persist-credentials: false

- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version-file: .nvmrc
cache: npm

- run: corepack enable && corepack prepare --activate

- run: npm ci

# No publish script: this step only opens or updates the pull request.
# The title is the squash commit's subject, so it has to be conventional.
- uses: changesets/action@ae32849d5ba541f9ae29e40e22a623bc13562f51 # v2.1.2
id: changesets
with:
github-token: ${{ steps.app.outputs.token || github.token }}
pr-title: 'chore(release): version packages'
commit-message: 'chore(release): version packages'

- name: Find a version npm does not have
id: unpublished
if: steps.changesets.outputs.has-changesets == 'false'
run: |
tag=$(node scripts/release-check.mjs --unpublished)
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"

- name: Build
if: steps.unpublished.outputs.tag != ''
run: npm run build

- name: Check the release
if: steps.unpublished.outputs.tag != ''
run: npm run release:check

- name: Pack the tarball
if: steps.unpublished.outputs.tag != ''
run: |
mkdir release-package
npm pack --pack-destination release-package

- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
if: steps.unpublished.outputs.tag != ''
with:
name: release-package
path: release-package
retention-days: 30
if-no-files-found: error

# No checkout and no install: nothing from the repository runs with the
# OIDC permission. A rerun skips whatever an earlier attempt published.
release:
needs: version
if: needs.version.outputs.tag != '' && vars.NPM_PUBLISH == 'true'
runs-on: ubuntu-latest
permissions:
id-token: write
contents: write
steps:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
registry-url: https://registry.npmjs.org

# An exact version, and no install scripts: this job can publish.
- name: Update npm
run: npm install --global --ignore-scripts npm@11.19.1

- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: release-package
path: release-package

- name: Publish, tag and create the GitHub Release
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
TAG: ${{ needs.version.outputs.tag }}
VERSION: ${{ needs.version.outputs.version }}
run: |
if [ -n "$(npm view "$TAG" version 2>/dev/null)" ]; then
echo "$TAG is already on npm."
else
npm publish ./release-package/*.tgz --access public
fi
if ! gh release view "v$VERSION" > /dev/null 2>&1; then
gh release create "v$VERSION" --target "$GITHUB_SHA" --generate-notes
fi
echo "Published v$VERSION." >> "$GITHUB_STEP_SUMMARY"
24 changes: 12 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,18 +32,18 @@ wholesale is worse than it looks, because you inherit this template's coverage f

## What you get

| | |
| ------------ | -------------------------------------------------------------------------------------------- |
| Build | siroc, producing ESM and SSR bundles |
| Unit tests | Jest, with an enforced coverage floor |
| Visual tests | Playwright against the example application, three viewports, committed baselines |
| Lint | ESLint, Prettier, markdownlint, cspell, yamllint, knip |
| Secrets | gitleaks, with a canary that proves the scanner still detects |
| Commits | Conventional Commits, checked by a hook and over the merge-request range |
| CI | GitLab and GitHub Actions, running the same set |
| Preview | A manual job serving the example application through a Cloudflare tunnel and posting the URL |
| Releases | Changesets |
| Environment | A devcontainer for VS Code, Codespaces and DevPod |
| | |
| ------------ | ---------------------------------------------------------------------------------------------------- |
| Build | siroc, producing ESM and SSR bundles |
| Unit tests | Jest, with an enforced coverage floor |
| Visual tests | Playwright against the example application, three viewports, committed baselines |
| Lint | ESLint, Prettier, markdownlint, cspell, yamllint, knip |
| Secrets | gitleaks, with a canary that proves the scanner still detects |
| Commits | Conventional Commits, checked by a hook and over the merge-request range |
| CI | GitLab and GitHub Actions, running the same set |
| Preview | A manual job serving the example application through a Cloudflare tunnel and posting the URL |
| Releases | Changesets, with `dev` snapshots and stable releases published from CI: [RELEASING.md](RELEASING.md) |
| Environment | A devcontainer for VS Code, Codespaces and DevPod |

## Commands

Expand Down
61 changes: 61 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Releasing

The package publishes to npm from `.github/workflows/release.yml`. Nobody runs `npm publish` by hand. A module made from this template inherits the workflow, and turns it on with the [one-time setup](#one-time-setup).

| Channel | npm dist-tag | When it publishes | What it is for |
| ----------- | ------------ | --------------------------------------------------------- | ------------------------------------- |
| Development | `dev` | Every push to `main` with a pending changeset | Trying unreleased work on a real site |
| Stable | `latest` | When you merge the pull request that versions the package | Everyone else |

## Development releases

A push to `main` with a pending changeset cuts a snapshot and publishes it under the `dev` tag:

```bash
npm install <package>@dev
```

A snapshot version reads `0.1.0-dev.20260920005709`: the version the pending changesets add up to, then the tag and a timestamp. Nothing is committed, no git tag is made, and `latest` does not move.

To follow the channel on a site, let Renovate track the tag:

```json
{
"packageRules": [{ "matchPackageNames": ["<package>"], "followTag": "dev" }]
}
```

## Stable releases

1. Merge pull requests that carry a changeset. Add one with `npm run changeset`.
2. The workflow opens a pull request titled `chore(release): version packages`, and keeps it up to date. It holds the version bump and the changelog entry.
3. Merge that pull request when the release is ready. This is the release decision.
4. The push that follows publishes the new version to `latest`. It also pushes a `v<version>` tag, with a GitHub Release.

## Build and publish jobs

Each channel runs as a build job followed by a publish job.

| Job | Runs repository code | Can publish to npm |
| ------- | ----------------------------- | ------------------ |
| Build | Yes, install scripts included | No |
| Publish | No, it checks out nothing | Yes |

The build job ends by packing a tarball, and the publish job hands that tarball to npm. A pull request rehearses the build job only, so code in a pull request never runs with publishing rights.

## The pre-publish gate

`npm run release:check` runs before every publish, on both channels. It refuses a release when:

- a dependency uses a specifier that only resolves on the author's machine, such as `link:` or `workspace:`
- the version is at or below the one npm already has
- an entry in `files` was not built

## One-time setup

Publishing uses npm trusted publishing, so there is no npm token to store or rotate. Until the setup is complete the workflow stops after packing: the tarball it would have published is attached to the run as an artifact, and nothing reaches npm.

1. Publish the first version by hand, from a clean build: `npm publish --access public`. A package that does not exist on npm yet cannot name a trusted publisher.
2. On the npm website, open the package, then **Settings**, then **Trusted publisher**. Choose GitHub Actions, and enter the organization, the repository and the workflow filename `release.yml`. Leave the environment empty. Under **Allowed actions**, tick **Allow npm publish**: the workflow publishes directly, and a publisher limited to staged publishing refuses it.
3. Create a GitHub App with read and write access to **Contents** and **Pull requests**, and install it on the repository. Store its ID as the repository variable `RELEASE_APP_ID` and its private key as the secret `RELEASE_APP_PRIVATE_KEY`. GitHub doesn't run checks on a pull request opened with the workflow's own token, so a protected `main` would never see them pass.
4. Set the repository variable `NPM_PUBLISH` to `true`.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,7 @@
"main": "dist/index.ssr.js",
"module": "dist/index.esm.js",
"files": [
"dist",
"templates"
"dist"
],
"scripts": {
"build": "siroc build",
Expand Down Expand Up @@ -48,6 +47,7 @@
"lint:prose": "PROSE_LINT_GLOB='!{.changeset,example/drupal}/**' bash .gitlab/scripts/lint-prose.sh --all",
"lint:prose:install": "bash .gitlab/scripts/install-vale.sh .vale/bin .vale/styles",
"postinstall": "node scripts/postinstall.mjs",
"release:check": "node scripts/release-check.mjs",
"serve": "node scripts/serve.js example/nuxt/dist 3000",
"test": "jest",
"test:e2e": "playwright test --grep-invert @visual",
Expand Down
Loading
Loading