From e98206f9d213fb33220f30ac2476828c5b4742cb Mon Sep 17 00:00:00 2001 From: Stuart Clark Date: Sun, 20 Sep 2026 00:58:40 +0000 Subject: [PATCH 1/5] ci(release): publish dev snapshots and stable releases from CI --- .changeset/config.json | 8 +- .github/workflows/release.yml | 194 ++++++++++++++++++++++++++++++++ README.md | 24 ++-- RELEASING.md | 50 +++++++++ package.json | 4 +- scripts/release-check.mjs | 202 ++++++++++++++++++++++++++++++++++ test/release-check.test.js | 152 +++++++++++++++++++++++++ 7 files changed, 618 insertions(+), 16 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 RELEASING.md create mode 100644 scripts/release-check.mjs create mode 100644 test/release-check.test.js diff --git a/.changeset/config.json b/.changeset/config.json index 1af1a48..568784b 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -4,8 +4,12 @@ "commit": false, "fixed": [], "linked": [], - "access": "restricted", + "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": [] + "ignore": [], + "snapshot": { + "useCalculatedVersion": true, + "prereleaseTemplate": "{tag}.{datetime}" + } } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0b768f9 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,194 @@ +--- +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 `@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. +# +# Publishing authenticates through npm trusted publishing (OIDC), so there is +# no token. It stays a dry run until the repository variable NPM_PUBLISH is +# `true`. + +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: + dev: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + env: + PUBLISH: ${{ github.event_name != 'pull_request' && github.ref == 'refs/heads/main' && vars.NPM_PUBLISH == 'true' }} + 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 + + # Trusted publishing needs npm 11.5.1 or later, which needs a newer Node + # than the one the package builds on. + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + if: steps.pending.outputs.count != '0' + with: + node-version: 22 + registry-url: https://registry.npmjs.org + + - name: Update npm + if: steps.pending.outputs.count != '0' + run: npm install --global npm@^11.5.1 + + - name: Pack without publishing + if: steps.pending.outputs.count != '0' && env.PUBLISH != 'true' + run: | + npm pack --dry-run + echo "Dry run: nothing was published." >> "$GITHUB_STEP_SUMMARY" + + - name: Publish under the dev tag + if: steps.pending.outputs.count != '0' && env.PUBLISH == 'true' + run: node node_modules/@changesets/cli/bin.js publish --tag dev --no-git-tag + + - name: Report the version + if: steps.pending.outputs.count != '0' + run: node -p "const p = require('./package.json'); p.name + '@' + p.version" >> "$GITHUB_STEP_SUMMARY" + + release: + if: github.event_name == 'push' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + id-token: write + 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 + token: ${{ steps.app.outputs.token || github.token }} + + - 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: echo "tag=$(node scripts/release-check.mjs --unpublished)" >> "$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 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + if: steps.unpublished.outputs.tag != '' + with: + node-version: 22 + registry-url: https://registry.npmjs.org + + - name: Update npm + if: steps.unpublished.outputs.tag != '' + run: npm install --global npm@^11.5.1 + + - name: Pack without publishing + if: steps.unpublished.outputs.tag != '' && vars.NPM_PUBLISH != 'true' + run: | + npm pack --dry-run + echo "Dry run: nothing was published." >> "$GITHUB_STEP_SUMMARY" + + - name: Publish + if: steps.unpublished.outputs.tag != '' && vars.NPM_PUBLISH == 'true' + run: node node_modules/@changesets/cli/bin.js publish + + - name: Tag and create the GitHub Release + if: steps.unpublished.outputs.tag != '' && vars.NPM_PUBLISH == 'true' + env: + GH_TOKEN: ${{ steps.app.outputs.token || github.token }} + run: | + git push origin --tags + version=$(node -p "require('./package.json').version") + gh release create "v$version" --generate-notes --verify-tag + echo "Published v$version." >> "$GITHUB_STEP_SUMMARY" diff --git a/README.md b/README.md index bd822ca..6505958 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..f952911 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,50 @@ +# 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 @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": [""], "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` tag, with a GitHub Release. + +## 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 packs the package with `npm pack --dry-run` and publishes nothing. + +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. +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`. diff --git a/package.json b/package.json index 7e04bca..ad50f4b 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,7 @@ "main": "dist/index.ssr.js", "module": "dist/index.esm.js", "files": [ - "dist", - "templates" + "dist" ], "scripts": { "build": "siroc build", @@ -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", diff --git a/scripts/release-check.mjs b/scripts/release-check.mjs new file mode 100644 index 0000000..eec86ac --- /dev/null +++ b/scripts/release-check.mjs @@ -0,0 +1,202 @@ +/** + * Pre-publish gate: refuse a release that would publish a broken package. + * The release workflow runs it after versioning and building, before any + * publish. + * + * node scripts/release-check.mjs [--offline] [--skip-files] [--unpublished] + * + * --offline skip the npm registry comparison. + * --skip-files skip the built-files check, for when no build has run. + * --unpublished print `name@version` when npm does not have this version + * yet, and check nothing. + * + * It checks three things. A dependency specifier such as `link:` or + * `workspace:` resolves on the author's machine and nowhere else. A version + * at or below the published one either fails to publish or, as a snapshot, + * sorts below the release it was cut after. And a build that emits nothing + * still exits zero, so a `files` entry has to exist and hold something. + */ + +import fs from 'node:fs' +import https from 'node:https' +import path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const FIELDS = ['dependencies', 'peerDependencies', 'optionalDependencies'] +const UNPUBLISHABLE = + /^(workspace|link|file|portal|git|git\+ssh|git\+https|github|https?):/ +const VERSION = /^(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?$/ + +/** + * Splits a version into its numeric core and whether it is a prerelease. + * + * @param {string} version - The version to parse. + * @returns {object|null} `{ core, prerelease }`, or `null` when it is invalid. + */ +export function parseVersion(version) { + const match = VERSION.exec(version) + if (!match) return null + return { + core: match.slice(1, 4).map(Number), + prerelease: Boolean(match[4]), + } +} + +// Compares two numeric cores: negative, zero or positive, as a sort would. +const compareCores = (a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2] + +/** + * Checks a manifest against the rules. + * + * @param {object} options - The check input. + * @param {object} options.manifest - The parsed package.json. + * @param {string} options.dir - The package directory. + * @param {object|null} [options.record] - `{ latest, versions }` from npm, or `null` when unpublished. Omit to skip the registry rule. + * @param {boolean} [options.files] - Whether to check the `files` entries. + * @returns {string[]} One message per problem. + */ +export function checkManifest({ manifest, dir, record, files = true }) { + const problems = [] + const parsed = parseVersion(manifest.version) + + if (!parsed) { + return [`"${manifest.version}" is not a valid version.`] + } + + for (const field of FIELDS) { + for (const [dep, range] of Object.entries(manifest[field] || {})) { + if (UNPUBLISHABLE.test(range)) { + problems.push( + `${field}.${dep} is "${range}", which does not resolve from npm.` + ) + } + } + } + + if (record && !record.versions.includes(manifest.version)) { + const latest = parseVersion(record.latest) + const order = latest ? compareCores(parsed.core, latest.core) : 1 + if ( + order < 0 || + (order === 0 && (parsed.prerelease || !latest.prerelease)) + ) { + problems.push( + `${manifest.version} does not rise above the published ${record.latest}.` + ) + } + } + + if (files) { + for (const entry of manifest.files || []) { + const target = path.join(dir, entry) + if (!fs.existsSync(target)) { + problems.push(`files entry "${entry}" does not exist. Build first.`) + } else if ( + fs.statSync(target).isDirectory() && + fs.readdirSync(target).length === 0 + ) { + problems.push(`files entry "${entry}" is empty.`) + } + } + } + + return problems +} + +/** + * Fetches the published versions of a package. + * + * @param {string} name - The package name. + * @returns {Promise} `{ latest, versions }`, or `null` when npm has no such package. + */ +export function fetchRecord(name) { + const url = `https://registry.npmjs.org/${name.replace('/', '%2f')}` + const headers = { accept: 'application/vnd.npm.install-v1+json' } + + return new Promise((resolve, reject) => { + https + .get(url, { headers }, (response) => { + if (response.statusCode === 404) { + response.resume() + return resolve(null) + } + if (response.statusCode !== 200) { + response.resume() + return reject( + new Error(`npm answered ${response.statusCode} for ${name}.`) + ) + } + + let body = '' + response.setEncoding('utf8') + response.on('data', (chunk) => { + body += chunk + }) + response.on('end', () => { + const data = JSON.parse(body) + resolve({ + latest: data['dist-tags'].latest, + versions: Object.keys(data.versions), + }) + }) + }) + .on('error', reject) + }) +} + +export async function main(argv = process.argv.slice(2)) { + const flags = ['--offline', '--skip-files', '--unpublished'] + const options = argv.filter((arg) => arg.startsWith('--')) + const unknown = options.filter( + (arg) => !flags.includes(arg) && !arg.startsWith('--registry-file=') + ) + if (unknown.length) throw new Error(`Unknown option: ${unknown.join(', ')}`) + + const dir = path.resolve(argv.find((arg) => !arg.startsWith('--')) ?? ROOT) + const manifest = JSON.parse( + fs.readFileSync(path.join(dir, 'package.json'), 'utf8') + ) + if (manifest.private) { + console.log('release-check: the package is private, nothing to publish.') + return + } + + // A recorded registry answer, so the tests never depend on the network. + const recorded = options.find((arg) => arg.startsWith('--registry-file=')) + let record + if (recorded) { + record = JSON.parse(fs.readFileSync(recorded.split('=')[1], 'utf8')) + } else if (!options.includes('--offline')) { + record = await fetchRecord(manifest.name) + } + + if (options.includes('--unpublished')) { + if (record === undefined) { + throw new Error('--unpublished needs the registry, so not --offline.') + } + if (!record || !record.versions.includes(manifest.version)) { + console.log(`${manifest.name}@${manifest.version}`) + } + return + } + + const problems = checkManifest({ + manifest, + dir, + record, + files: !options.includes('--skip-files'), + }) + for (const problem of problems) console.error(`release-check: ${problem}`) + if (problems.length) process.exit(1) + console.log( + `release-check: ${manifest.name}@${manifest.version} can publish.` + ) +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + main().catch((error) => { + console.error(`release-check: ${error.message}`) + process.exit(1) + }) +} diff --git a/test/release-check.test.js b/test/release-check.test.js new file mode 100644 index 0000000..cc37801 --- /dev/null +++ b/test/release-check.test.js @@ -0,0 +1,152 @@ +const { spawnSync } = require('node:child_process') +const fs = require('node:fs') +const os = require('node:os') +const { join } = require('node:path') + +// The gate is an ES module and the package is CommonJS, so it runs in a real +// node process here, which is also how the release workflow runs it. +const SCRIPT = join(__dirname, '..', 'scripts', 'release-check.mjs') + +let dir + +const write = (manifest, record) => { + fs.writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) + if (record !== undefined) { + fs.writeFileSync(join(dir, 'registry.json'), JSON.stringify(record)) + } +} + +const run = (...flags) => { + const registry = fs.existsSync(join(dir, 'registry.json')) + ? [`--registry-file=${join(dir, 'registry.json')}`] + : ['--offline'] + const result = spawnSync( + process.execPath, + [SCRIPT, dir, ...registry, ...flags], + { encoding: 'utf8' } + ) + return { status: result.status, out: result.stdout + result.stderr } +} + +beforeEach(() => { + dir = fs.mkdtempSync(join(os.tmpdir(), 'release-check-')) +}) + +afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }) +}) + +describe('release-check', () => { + test('passes a publishable package', () => { + write({ name: 'x', version: '1.0.0', dependencies: { druxt: '^0.24.0' } }) + expect(run('--skip-files')).toEqual({ + status: 0, + out: 'release-check: x@1.0.0 can publish.\n', + }) + }) + + test.each(['workspace:*', 'link:../druxt', 'file:../druxt'])( + 'refuses the specifier %s', + (range) => { + write({ name: 'x', version: '1.0.0', peerDependencies: { druxt: range } }) + const result = run('--skip-files') + expect(result.status).toBe(1) + expect(result.out).toContain('does not resolve from npm') + } + ) + + test('refuses an invalid version', () => { + write({ name: 'x', version: '1.0' }) + expect(run('--skip-files').out).toContain('is not a valid version') + }) + + test('refuses a version below the published one', () => { + write( + { name: 'x', version: '1.1.9' }, + { latest: '1.2.0', versions: ['1.2.0'] } + ) + const result = run('--skip-files') + expect(result.status).toBe(1) + expect(result.out).toContain('does not rise above the published 1.2.0') + }) + + test('refuses a snapshot of an already published version', () => { + write( + { name: 'x', version: '1.2.0-dev.20260920004838' }, + { latest: '1.2.0', versions: ['1.2.0'] } + ) + expect(run('--skip-files').status).toBe(1) + }) + + test('passes a snapshot above the published version', () => { + write( + { name: 'x', version: '1.2.1-dev.20260920004838' }, + { latest: '1.2.0', versions: ['1.2.0'] } + ) + expect(run('--skip-files').status).toBe(0) + }) + + test('passes a stable release over its own prerelease', () => { + write( + { name: 'x', version: '2.0.0' }, + { latest: '2.0.0-beta.1', versions: ['2.0.0-beta.1'] } + ) + expect(run('--skip-files').status).toBe(0) + }) + + test('passes an already published version, because publish skips it', () => { + write( + { name: 'x', version: '1.2.0' }, + { latest: '1.2.0', versions: ['1.2.0'] } + ) + expect(run('--skip-files').status).toBe(0) + }) + + test('passes a package npm has never seen', () => { + write({ name: 'x', version: '0.0.1' }, null) + expect(run('--skip-files').status).toBe(0) + }) + + test('refuses a files entry that was not built, or is empty', () => { + write({ name: 'x', version: '1.0.0', files: ['dist', 'templates'] }) + fs.mkdirSync(join(dir, 'dist')) + const result = run() + expect(result.status).toBe(1) + expect(result.out).toContain('files entry "dist" is empty') + expect(result.out).toContain('files entry "templates" does not exist') + + fs.writeFileSync(join(dir, 'dist', 'index.js'), '') + fs.mkdirSync(join(dir, 'templates')) + fs.writeFileSync(join(dir, 'templates', 'plugin.js'), '') + expect(run().status).toBe(0) + }) + + test('skips a private package', () => { + write({ name: 'x', version: 'nope', private: true }) + expect(run()).toEqual({ + status: 0, + out: 'release-check: the package is private, nothing to publish.\n', + }) + }) + + test('--unpublished prints the version only when npm lacks it', () => { + write( + { name: 'x', version: '1.2.1' }, + { latest: '1.2.0', versions: ['1.2.0'] } + ) + expect(run('--unpublished').out).toBe('x@1.2.1\n') + + write( + { name: 'x', version: '1.2.0' }, + { latest: '1.2.0', versions: ['1.2.0'] } + ) + expect(run('--unpublished').out).toBe('') + }) + + test('refuses an unknown option', () => { + write({ name: 'x', version: '1.0.0' }) + const result = run('--nope') + expect(result.status).toBe(1) + expect(result.out).toContain('Unknown option: --nope') + }) +}) From d3fb0750595812afa421dc3970a71aa25f9f8d94 Mon Sep 17 00:00:00 2001 From: Stuart Clark Date: Sun, 20 Sep 2026 01:03:05 +0000 Subject: [PATCH 2/5] fix(release): encode every slash in a registry package name --- scripts/release-check.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/release-check.mjs b/scripts/release-check.mjs index eec86ac..e903394 100644 --- a/scripts/release-check.mjs +++ b/scripts/release-check.mjs @@ -111,7 +111,7 @@ export function checkManifest({ manifest, dir, record, files = true }) { * @returns {Promise} `{ latest, versions }`, or `null` when npm has no such package. */ export function fetchRecord(name) { - const url = `https://registry.npmjs.org/${name.replace('/', '%2f')}` + const url = `https://registry.npmjs.org/${name.replace(/\//g, '%2f')}` const headers = { accept: 'application/vnd.npm.install-v1+json' } return new Promise((resolve, reject) => { From cf7d306611ff9901fc46cae0fc70956855460a4f Mon Sep 17 00:00:00 2001 From: Stuart Clark Date: Sun, 20 Sep 2026 03:48:09 +0000 Subject: [PATCH 3/5] fix(release): keep repository code out of the publishing job --- .github/workflows/release.yml | 142 ++++++++++++++++++++++++---------- RELEASING.md | 13 +++- scripts/release-check.mjs | 19 ++++- 3 files changed, 127 insertions(+), 47 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0b768f9..165455e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,9 +13,14 @@ name: Release # 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 a dry run until the repository variable NPM_PUBLISH is -# `true`. +# no token. It stays off until the repository variable NPM_PUBLISH is `true`. +# Until then the packed tarball is the dry run. on: push: @@ -38,13 +43,14 @@ concurrency: cancel-in-progress: false jobs: - dev: + # 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 - id-token: write - env: - PUBLISH: ${{ github.event_name != 'pull_request' && github.ref == 'refs/heads/main' && vars.NPM_PUBLISH == 'true' }} + outputs: + count: ${{ steps.pending.outputs.count }} steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: @@ -82,39 +88,64 @@ jobs: if: steps.pending.outputs.count != '0' run: npm run release:check - # Trusted publishing needs npm 11.5.1 or later, which needs a newer Node - # than the one the package builds on. - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + - 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 - name: Update npm - if: steps.pending.outputs.count != '0' run: npm install --global npm@^11.5.1 - - name: Pack without publishing - if: steps.pending.outputs.count != '0' && env.PUBLISH != 'true' - run: | - npm pack --dry-run - echo "Dry run: nothing was published." >> "$GITHUB_STEP_SUMMARY" + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: dev-package + path: dev-package - name: Publish under the dev tag - if: steps.pending.outputs.count != '0' && env.PUBLISH == 'true' - run: node node_modules/@changesets/cli/bin.js publish --tag dev --no-git-tag - - - name: Report the version - if: steps.pending.outputs.count != '0' - run: node -p "const p = require('./package.json'); p.name + '@' + p.version" >> "$GITHUB_STEP_SUMMARY" + run: | + npm publish dev-package/*.tgz --tag dev --access public + echo "Published under the dev tag." >> "$GITHUB_STEP_SUMMARY" - release: + # 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 - id-token: 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. @@ -153,7 +184,10 @@ jobs: - name: Find a version npm does not have id: unpublished if: steps.changesets.outputs.has-changesets == 'false' - run: echo "tag=$(node scripts/release-check.mjs --unpublished)" >> "$GITHUB_OUTPUT" + 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 != '' @@ -163,32 +197,56 @@ jobs: if: steps.unpublished.outputs.tag != '' run: npm run release:check - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + - 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 - name: Update npm - if: steps.unpublished.outputs.tag != '' run: npm install --global npm@^11.5.1 - - name: Pack without publishing - if: steps.unpublished.outputs.tag != '' && vars.NPM_PUBLISH != 'true' - run: | - npm pack --dry-run - echo "Dry run: nothing was published." >> "$GITHUB_STEP_SUMMARY" - - - name: Publish - if: steps.unpublished.outputs.tag != '' && vars.NPM_PUBLISH == 'true' - run: node node_modules/@changesets/cli/bin.js publish + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: release-package + path: release-package - - name: Tag and create the GitHub Release - if: steps.unpublished.outputs.tag != '' && vars.NPM_PUBLISH == 'true' + - name: Publish, tag and create the GitHub Release env: - GH_TOKEN: ${{ steps.app.outputs.token || github.token }} + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + TAG: ${{ needs.version.outputs.tag }} + VERSION: ${{ needs.version.outputs.version }} run: | - git push origin --tags - version=$(node -p "require('./package.json').version") - gh release create "v$version" --generate-notes --verify-tag - echo "Published v$version." >> "$GITHUB_STEP_SUMMARY" + 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" diff --git a/RELEASING.md b/RELEASING.md index f952911..79b0c0c 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -32,6 +32,17 @@ To follow the channel on a site, let Renovate track the tag: 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` 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: @@ -42,7 +53,7 @@ To follow the channel on a site, let Renovate track the tag: ## 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 packs the package with `npm pack --dry-run` and publishes nothing. +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. diff --git a/scripts/release-check.mjs b/scripts/release-check.mjs index e903394..6a8ccc1 100644 --- a/scripts/release-check.mjs +++ b/scripts/release-check.mjs @@ -26,6 +26,7 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const FIELDS = ['dependencies', 'peerDependencies', 'optionalDependencies'] const UNPUBLISHABLE = /^(workspace|link|file|portal|git|git\+ssh|git\+https|github|https?):/ +const REGISTRY_TIMEOUT = 15000 const VERSION = /^(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?$/ /** @@ -115,8 +116,10 @@ export function fetchRecord(name) { const headers = { accept: 'application/vnd.npm.install-v1+json' } return new Promise((resolve, reject) => { - https - .get(url, { headers }, (response) => { + const request = https.get( + url, + { headers, timeout: REGISTRY_TIMEOUT }, + (response) => { if (response.statusCode === 404) { response.resume() return resolve(null) @@ -140,8 +143,16 @@ export function fetchRecord(name) { versions: Object.keys(data.versions), }) }) - }) - .on('error', reject) + } + ) + + // Without this a stalled connection holds the job until its own timeout. + request.on('timeout', () => + request.destroy( + new Error(`npm did not answer for ${name} within 15 seconds.`) + ) + ) + request.on('error', reject) }) } From 29825a8472bf95854d18bf1bb33ea5ee2afc9cb0 Mon Sep 17 00:00:00 2001 From: Stuart Clark Date: Sun, 20 Sep 2026 03:58:47 +0000 Subject: [PATCH 4/5] fix(release): harden the version job and the files check --- .github/workflows/release.yml | 4 +++- RELEASING.md | 2 +- scripts/release-check.mjs | 5 +++++ test/release-check.test.js | 8 ++++++++ 4 files changed, 17 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 165455e..82642bd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -161,7 +161,9 @@ jobs: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: fetch-depth: 0 - token: ${{ steps.app.outputs.token || github.token }} + # 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: diff --git a/RELEASING.md b/RELEASING.md index 79b0c0c..2aa9f03 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -56,6 +56,6 @@ The build job ends by packing a tarball, and the publish job hands that tarball 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. +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`. diff --git a/scripts/release-check.mjs b/scripts/release-check.mjs index 6a8ccc1..19758ce 100644 --- a/scripts/release-check.mjs +++ b/scripts/release-check.mjs @@ -89,6 +89,11 @@ export function checkManifest({ manifest, dir, record, files = true }) { } if (files) { + // Without a list npm packs the whole directory, and nothing here could + // say whether the build output is in it. + if (!Array.isArray(manifest.files) || manifest.files.length === 0) { + problems.push('package.json has no "files" list to check the build by.') + } for (const entry of manifest.files || []) { const target = path.join(dir, entry) if (!fs.existsSync(target)) { diff --git a/test/release-check.test.js b/test/release-check.test.js index cc37801..26a540d 100644 --- a/test/release-check.test.js +++ b/test/release-check.test.js @@ -121,6 +121,14 @@ describe('release-check', () => { expect(run().status).toBe(0) }) + test.each([undefined, []])('refuses a files list of %p', (list) => { + write({ name: 'x', version: '1.0.0', files: list }) + const result = run() + expect(result.status).toBe(1) + expect(result.out).toContain('has no "files" list') + expect(run('--skip-files').status).toBe(0) + }) + test('skips a private package', () => { write({ name: 'x', version: 'nope', private: true }) expect(run()).toEqual({ From 5755e345be7ed1245d587440514f950a05e4f4e7 Mon Sep 17 00:00:00 2001 From: Stuart Clark Date: Sun, 20 Sep 2026 04:05:54 +0000 Subject: [PATCH 5/5] fix(release): publish the tarball by path and pin npm in the publishing jobs --- .github/workflows/release.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 82642bd..8aa02fc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -122,17 +122,20 @@ jobs: 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 npm@^11.5.1 + 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 + 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 @@ -228,8 +231,9 @@ jobs: 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 npm@^11.5.1 + run: npm install --global --ignore-scripts npm@11.19.1 - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: @@ -246,7 +250,7 @@ jobs: if [ -n "$(npm view "$TAG" version 2>/dev/null)" ]; then echo "$TAG is already on npm." else - npm publish release-package/*.tgz --access public + 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