From 4815548b8efbc7b6f025acb4d1a761ff032b6d3b Mon Sep 17 00:00:00 2001 From: Jules Schoonman Date: Sat, 3 Oct 2026 16:29:54 +0200 Subject: [PATCH 1/5] Keep npm latest current during initial beta releases --- .github/workflows/release.yml | 13 +++++-- docs/releases.md | 37 ++++++++++++++------ package.json | 1 + packages/slides/tests/release.test.mjs | 43 +++++++++++++++++++++++ scripts/publish-slides.mjs | 5 +-- scripts/sync-npm-tags.mjs | 47 ++++++++++++++++++++++++++ 6 files changed, 131 insertions(+), 15 deletions(-) create mode 100644 scripts/sync-npm-tags.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fcb4dc2..4a6eb11 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,6 +11,10 @@ on: description: 'Optional existing tag: repair GitHub release only. Leave empty to publish the current version on main.' required: false type: string + sync_tags: + description: 'Only repair npm tags for the already-published version on main; leave tag empty.' + type: boolean + default: false concurrency: group: publish-slides @@ -42,14 +46,16 @@ jobs: package-manager-cache: false - name: Check version and previous publication id: plan + if: inputs.sync_tags != true env: RELEASE_BEFORE: ${{ github.event.before }} run: node scripts/prepare-npm-release.mjs - uses: pnpm/action-setup@v4 if: steps.plan.outputs.release == 'true' && steps.plan.outputs.published == 'false' - name: Install npm with trusted publishing support - if: steps.plan.outputs.release == 'true' && steps.plan.outputs.published == 'false' - run: npm install --global npm@11 + if: steps.plan.outputs.release == 'true' || inputs.sync_tags == true + # OIDC dist-tag authentication requires npm 11.21.0 or later. + run: npm install --global 'npm@^11.21.0' - name: Install packaging dependencies if: steps.plan.outputs.release == 'true' && steps.plan.outputs.published == 'false' # Packaging does not run the native renderer or need content repositories. @@ -63,6 +69,9 @@ jobs: env: NPM_TAG: ${{ steps.plan.outputs.npmTag }} run: npm publish "$RUNNER_TEMP/slides.tgz" --access public --tag "$NPM_TAG" --provenance + - name: Update npm distribution tags + if: steps.plan.outputs.release == 'true' || inputs.sync_tags == true + run: node scripts/sync-npm-tags.mjs - name: Create and push the release tag if: steps.plan.outputs.release == 'true' env: diff --git a/docs/releases.md b/docs/releases.md index 60eef62..9d6b662 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -15,8 +15,10 @@ Content repositories choose when to upgrade and redeploy their websites. changelog and lockfile. During beta, the next version after `0.1.0-beta.1` is `0.1.0-beta.2`. Review the PR, let its checks pass, and merge it. 3. **Publish release** detects the version change on `main`, checks the release, - builds and packs the software, and publishes it to npm. Beta versions use - npm's `beta` channel; stable versions use `latest`. It then creates and pushes + builds and packs the software, and publishes it to npm. Beta versions update + `beta` and also `latest` while the default version is still a beta. Once + `latest` points to a stable release, later betas leave it unchanged. Stable + versions update `latest`. It then creates and pushes an annotated tag such as `@allmaps/slides@0.1.0-beta.2` and creates the matching GitHub release from the changelog. Beta versions are GitHub prereleases. No local publish command or manual tagging is needed. @@ -50,7 +52,7 @@ The package's npm settings must authorize this GitHub trusted publisher: | Repository | `slides` | | Workflow filename | `release.yml` | | Environment name | `npm` | -| Allowed actions | Enable **npm publish** for direct publication. | +| Allowed actions | Enable **npm publish** and **npm dist-tag**. | The publishing job uses the GitHub environment named `npm`. Under repository **Settings → Environments → npm**, restrict deployment branches to `main`. @@ -58,7 +60,9 @@ The job already runs only on `main`, including manual publication. Tag-triggered GitHub release repairs do not publish to npm or use this environment. The environment name in npm's trusted-publisher settings must match `npm`. -The publishing job has `id-token: write` permission and uses npm 11 with OIDC. +The publishing job has `id-token: write` permission and uses npm 11.21 or later +with OIDC. Tag management requires the separate **Allow npm dist-tag** permission +in the trusted-publisher settings; permission to publish alone is insufficient. No npm token secret or interactive login is needed in Actions. pnpm prepares the tarball so `publishConfig.exports` and bundled workspace packages are handled correctly; npm publishes that tarball with provenance. Packaging does @@ -75,6 +79,7 @@ silently switch to staged publishing. | Prepare release (`version.yml`) | Changesets on `main`, or manual run on `main` | Opens/updates the version and changelog PR. | | Publish release (`release.yml`) | Package manifest changes on `main` | Publishes only when the version changes, then creates the tag and GitHub release. | | Publish release (`release.yml`) | Manual run on `main` with an empty tag | Attempts the current prepared release, including retrying an interrupted publication. | +| Publish release (`release.yml`) | Manual run on `main` with `sync_tags` enabled and an empty tag | Repairs `latest` for the already-published beta without rebuilding or republishing. | | Publish release (`release.yml`) | Push of `@allmaps/slides@*`, or manual run with an existing tag | Creates the GitHub release only; does not publish to npm. | | Slides architecture (`slides.yml`) | Every pull request, or manual run | Tests the CLI, app, renderer, development server and installed package; skips tests for documentation-only PRs. | | Static renderer (`static-render.yml`) | Every pull request, or manual run | Tests the Linux renderer container; skips tests for documentation-only PRs. | @@ -107,10 +112,16 @@ public Slides template. - A manual run with an empty tag uses the selected `main` commit. Use it only when that commit is the prepared release. If more work has landed on `main`, rerun the original failed run instead. +- If `beta` is current but `latest` still points to an older beta, enable + **Allow npm dist-tag** for the trusted publisher, then run **Publish release** + on `main` with `sync_tags` enabled and `tag` empty. This uses the package version + on `main`, requires it to match npm's current `beta`, and never replaces a + stable or newer `latest`. It needs no new package version or Git tag. GitHub supplies source ZIP/tar downloads for tagged releases. The first npm -publication also received `latest`; use an explicit version or `@beta` for beta -updates. A version already accepted by npm cannot be overwritten. +publication also received `latest`; the release workflow now keeps that default +current throughout beta. The `-beta.N` version suffix and GitHub prerelease status +remain unchanged. A version already accepted by npm cannot be overwritten. ## Local fallback @@ -126,13 +137,17 @@ git push origin '@allmaps/slides@0.1.0-beta.2' ``` Replace the example tag with the version just published. `release:publish` -rebuilds the package, selects `beta` or `latest`, publishes and creates a local -annotated tag. Complete npm's publishing authentication when prompted. Pushing -the tag creates the GitHub release. Coordinate this fallback with any running +rebuilds the package, publishes, synchronizes npm tags using the same policy, and +creates a local annotated tag. Complete npm's publishing authentication when +prompted. Pushing the tag creates the GitHub release. Coordinate this fallback with any running Actions publication so they do not attempt the same version concurrently. -Do not use `changeset publish` here: it can select `latest` for a package whose -first stable release has not yet been published. +To repair only npm tags from a checkout of the published version, run +`pnpm release:tags` after logging in. Add `--dry-run` to inspect the proposed +change without modifying npm. + +Use these release commands instead of `changeset publish` to keep source +validation, npm tags and GitHub releases on the same workflow. ## Finish beta diff --git a/package.json b/package.json index ad5c6fa..ce6e6ba 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "changeset": "changeset", "release:version": "changeset version && pnpm install --lockfile-only --ignore-scripts", "release:check": "node scripts/check-release.mjs", + "release:tags": "node scripts/sync-npm-tags.mjs", "release:publish": "node scripts/publish-slides.mjs" }, "devDependencies": { diff --git a/packages/slides/tests/release.test.mjs b/packages/slides/tests/release.test.mjs index d941c24..a668349 100644 --- a/packages/slides/tests/release.test.mjs +++ b/packages/slides/tests/release.test.mjs @@ -7,6 +7,7 @@ import path from 'node:path'; import { test } from 'node:test'; import { checkRelease } from '../../../scripts/check-release.mjs'; import { prepareNpmRelease, publishedBuildInfo } from '../../../scripts/prepare-npm-release.mjs'; +import { syncNpmTags } from '../../../scripts/sync-npm-tags.mjs'; const name = '@allmaps/slides'; const version = '0.1.0-beta.2'; @@ -107,3 +108,45 @@ test('registry errors, missing tarballs, and corrupt packages cannot be mistaken await assert.rejects(publishedBuildInfo(name, version, await publishedFixture(t, info, { corrupt: true })), /integrity mismatch/); await assert.rejects(publishedBuildInfo(name, version, await publishedFixture(t, info, { tarStatus: 404 })), /tarball returned HTTP 404/); }); + +test('beta publication advances latest without changing the beta alias', async () => { + for (const latest of [undefined, '0.1.0-beta.1', '0.0.9-beta.99']) { + const commands = []; + const result = await syncNpmTags({ name, version }, { + fetchFn: async url => { + assert.equal(url, `https://registry.npmjs.org/-/package/${encodeURIComponent(name)}/dist-tags`); + return Response.json({ beta: version, ...(latest ? { latest } : {}) }); + }, + run: (...args) => commands.push(args), + }); + assert.equal(commands.length, 1); + assert.deepEqual(commands[0][1], ['dist-tag', 'add', `${name}@${version}`, 'latest', '--registry=https://registry.npmjs.org']); + assert.match(result, /latest and beta now point/); + } +}); + +test('tag repair preserves stable, current and newer defaults', async () => { + const noWrite = () => assert.fail('Must not change npm tags'); + for (const latest of ['0.1.0', '0.0.1', version, '0.1.0-beta.10', '0.2.0-beta.1', '1.0.0-rc.1']) { + await syncNpmTags({ name, version }, { + fetchFn: async () => Response.json({ beta: version, latest }), run: noWrite, + }); + } + await syncNpmTags({ name, version: '0.1.0' }, { fetchFn: noNetwork, run: noWrite }); + await assert.rejects(syncNpmTags({ name, version }, { + fetchFn: async () => Response.json({ beta: '0.1.0-beta.3', latest: '0.1.0-beta.1' }), run: noWrite, + }), /current published beta/); +}); + +test('tag repair refuses unpublished betas and registry failures; dry run never writes', async () => { + const noWrite = () => assert.fail('Must not change npm tags'); + await assert.rejects(syncNpmTags({ name, version }, { + fetchFn: async () => Response.json({ latest: '0.1.0-beta.1' }), run: noWrite, + }), /current published beta/); + await assert.rejects(syncNpmTags({ name, version }, { + fetchFn: async () => new Response(null, { status: 503 }), run: noWrite, + }), /HTTP 503/); + assert.match(await syncNpmTags({ name, version }, { + fetchFn: async () => Response.json({ beta: version, latest: '0.1.0-beta.1' }), run: noWrite, dryRun: true, + }), /Would set latest/); +}); diff --git a/scripts/publish-slides.mjs b/scripts/publish-slides.mjs index 8c86d55..706e29c 100644 --- a/scripts/publish-slides.mjs +++ b/scripts/publish-slides.mjs @@ -1,11 +1,12 @@ import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { checkRelease } from './check-release.mjs'; +import { syncNpmTags } from './sync-npm-tags.mjs'; const cwd = fileURLToPath(new URL('../', import.meta.url)); const { info, npmTag } = await checkRelease(cwd); const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; -// Changesets can prefer `latest` before a package has any stable releases. -// Specify the channel ourselves; checkRelease verifies the software tree. +// Publish to the release channel first; syncNpmTags also advances latest during beta. execFileSync(pnpm, ['--filter', info.name, 'publish', '--access', 'public', '--tag', npmTag, '--no-git-checks'], { cwd, stdio: 'inherit' }); +await syncNpmTags(info); execFileSync(pnpm, ['exec', 'changeset', 'git-tag'], { cwd, stdio: 'inherit' }); diff --git a/scripts/sync-npm-tags.mjs b/scripts/sync-npm-tags.mjs new file mode 100644 index 0000000..19b1a3b --- /dev/null +++ b/scripts/sync-npm-tags.mjs @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const betaParts = version => /^(\d+)\.(\d+)\.(\d+)-beta\.(\d+)$/.exec(version)?.slice(1).map(Number); + +/** Keep the default install current while the package has only beta releases. */ +export async function syncNpmTags({ name, version }, { + fetchFn = fetch, + run = execFileSync, + dryRun = false, +} = {}) { + assert.equal(name, '@allmaps/slides'); + const target = betaParts(version); + if (!target) { + assert.match(version, /^\d+\.\d+\.\d+$/); + return 'Stable publications already update latest.'; + } + const response = await fetchFn(`https://registry.npmjs.org/-/package/${encodeURIComponent(name)}/dist-tags`, { + signal: AbortSignal.timeout(30_000), + }); + if (!response.ok) throw new Error(`npm dist-tags returned HTTP ${response.status}`); + const tags = await response.json(); + assert.equal(tags.beta, version, 'Only the current published beta can be promoted to latest.'); + if (tags.latest === version) return `latest already points to ${version}.`; + if (tags.latest) { + const current = betaParts(tags.latest); + // Once latest points to a stable release (or another prerelease channel), + // beta publication must leave that default alone. + if (!current) return `Keeping latest at ${tags.latest}; beta remains opt-in.`; + const difference = target.findIndex((part, index) => part !== current[index]); + if (difference < 0 || target[difference] < current[difference]) + return `Keeping newer latest ${tags.latest}.`; + } + if (dryRun) return `Would set latest to ${version}, retaining beta.`; + run(process.platform === 'win32' ? 'npm.cmd' : 'npm', + ['dist-tag', 'add', `${name}@${version}`, 'latest', '--registry=https://registry.npmjs.org'], + { stdio: 'inherit' }); + return `latest and beta now point to ${version}.`; +} + +if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) { + const pkg = JSON.parse(await readFile(new URL('../packages/slides/package.json', import.meta.url), 'utf8')); + console.log(await syncNpmTags(pkg, { dryRun: process.argv.includes('--dry-run') })); +} From 18ddbcaf2d799d140d7fb0c932ac241f02a0d73a Mon Sep 17 00:00:00 2001 From: Jules Schoonman Date: Sat, 3 Oct 2026 16:35:11 +0200 Subject: [PATCH 2/5] Simplify npm tag updates and fix publishing test fixture --- .github/workflows/release.yml | 9 ++------ docs/releases.md | 14 ++++-------- package.json | 1 - packages/slides/tests/build-info.test.mjs | 26 +++++++++++++++++------ packages/slides/tests/release.test.mjs | 5 +---- scripts/sync-npm-tags.mjs | 4 +--- 6 files changed, 28 insertions(+), 31 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4a6eb11..4ce3e9b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,10 +11,6 @@ on: description: 'Optional existing tag: repair GitHub release only. Leave empty to publish the current version on main.' required: false type: string - sync_tags: - description: 'Only repair npm tags for the already-published version on main; leave tag empty.' - type: boolean - default: false concurrency: group: publish-slides @@ -46,14 +42,13 @@ jobs: package-manager-cache: false - name: Check version and previous publication id: plan - if: inputs.sync_tags != true env: RELEASE_BEFORE: ${{ github.event.before }} run: node scripts/prepare-npm-release.mjs - uses: pnpm/action-setup@v4 if: steps.plan.outputs.release == 'true' && steps.plan.outputs.published == 'false' - name: Install npm with trusted publishing support - if: steps.plan.outputs.release == 'true' || inputs.sync_tags == true + if: steps.plan.outputs.release == 'true' # OIDC dist-tag authentication requires npm 11.21.0 or later. run: npm install --global 'npm@^11.21.0' - name: Install packaging dependencies @@ -70,7 +65,7 @@ jobs: NPM_TAG: ${{ steps.plan.outputs.npmTag }} run: npm publish "$RUNNER_TEMP/slides.tgz" --access public --tag "$NPM_TAG" --provenance - name: Update npm distribution tags - if: steps.plan.outputs.release == 'true' || inputs.sync_tags == true + if: steps.plan.outputs.release == 'true' run: node scripts/sync-npm-tags.mjs - name: Create and push the release tag if: steps.plan.outputs.release == 'true' diff --git a/docs/releases.md b/docs/releases.md index 9d6b662..c33f9e7 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -79,7 +79,6 @@ silently switch to staged publishing. | Prepare release (`version.yml`) | Changesets on `main`, or manual run on `main` | Opens/updates the version and changelog PR. | | Publish release (`release.yml`) | Package manifest changes on `main` | Publishes only when the version changes, then creates the tag and GitHub release. | | Publish release (`release.yml`) | Manual run on `main` with an empty tag | Attempts the current prepared release, including retrying an interrupted publication. | -| Publish release (`release.yml`) | Manual run on `main` with `sync_tags` enabled and an empty tag | Repairs `latest` for the already-published beta without rebuilding or republishing. | | Publish release (`release.yml`) | Push of `@allmaps/slides@*`, or manual run with an existing tag | Creates the GitHub release only; does not publish to npm. | | Slides architecture (`slides.yml`) | Every pull request, or manual run | Tests the CLI, app, renderer, development server and installed package; skips tests for documentation-only PRs. | | Static renderer (`static-render.yml`) | Every pull request, or manual run | Tests the Linux renderer container; skips tests for documentation-only PRs. | @@ -112,11 +111,10 @@ public Slides template. - A manual run with an empty tag uses the selected `main` commit. Use it only when that commit is the prepared release. If more work has landed on `main`, rerun the original failed run instead. -- If `beta` is current but `latest` still points to an older beta, enable - **Allow npm dist-tag** for the trusted publisher, then run **Publish release** - on `main` with `sync_tags` enabled and `tag` empty. This uses the package version - on `main`, requires it to match npm's current `beta`, and never replaces a - stable or newer `latest`. It needs no new package version or Git tag. +- If an earlier beta publication left `latest` behind, correct it once from a + logged-in terminal: `npm dist-tag add @allmaps/slides@0.1.0-beta.2 latest` + (replace the example with the current beta version). This updates the tag + without republishing. Subsequent releases update it automatically. GitHub supplies source ZIP/tar downloads for tagged releases. The first npm publication also received `latest`; the release workflow now keeps that default @@ -142,10 +140,6 @@ creates a local annotated tag. Complete npm's publishing authentication when prompted. Pushing the tag creates the GitHub release. Coordinate this fallback with any running Actions publication so they do not attempt the same version concurrently. -To repair only npm tags from a checkout of the published version, run -`pnpm release:tags` after logging in. Add `--dry-run` to inspect the proposed -change without modifying npm. - Use these release commands instead of `changeset publish` to keep source validation, npm tags and GitHub releases on the same workflow. diff --git a/package.json b/package.json index ce6e6ba..ad5c6fa 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,6 @@ "changeset": "changeset", "release:version": "changeset version && pnpm install --lockfile-only --ignore-scripts", "release:check": "node scripts/check-release.mjs", - "release:tags": "node scripts/sync-npm-tags.mjs", "release:publish": "node scripts/publish-slides.mjs" }, "devDependencies": { diff --git a/packages/slides/tests/build-info.test.mjs b/packages/slides/tests/build-info.test.mjs index 6cb6e0f..b4ccee6 100644 --- a/packages/slides/tests/build-info.test.mjs +++ b/packages/slides/tests/build-info.test.mjs @@ -101,17 +101,27 @@ test('Changesets advances the existing beta and graduates to the intended regula test('release commands select the npm channel explicitly and tag only after successful publication', async t => { const { root, dir } = await fixture(t); await mkdir(path.join(root, 'scripts')); - for (const name of ['check-release.mjs', 'publish-slides.mjs']) + for (const name of ['check-release.mjs', 'publish-slides.mjs', 'sync-npm-tags.mjs']) await writeFile(path.join(root, 'scripts', name), await readFile(new URL(`../../../scripts/${name}`, import.meta.url))); await symlink(new URL('../src/build-info.ts', import.meta.url), path.join(dir, 'src/build-info.ts')); await mkdir(path.join(root, '.changeset')); await writeFile(path.join(root, '.changeset/pre.json'), '{"mode":"pre","tag":"beta"}'); await writeFile(path.join(dir, 'CHANGELOG.md'), `# Slides\n\n## ${pkg.version}\n\nInitial beta.\n`); await mkdir(path.join(root, 'mock-bin')); - const mock = path.join(root, 'mock-bin/pnpm'); + const mockBin = path.join(root, 'mock-bin'); // No registry operation or tag creation: record subprocess arguments instead. - await writeFile(mock, `#!${process.execPath}\nrequire('node:fs').appendFileSync(process.env.RELEASE_TEST_LOG, JSON.stringify(process.argv.slice(2)) + '\\n');\nprocess.exit(process.env.RELEASE_TEST_FAIL === '1' ? 1 : 0);\n`); - await chmod(mock, 0o755); + for (const command of ['pnpm', 'npm']) { + const mock = path.join(mockBin, command); + await writeFile(mock, `#!${process.execPath}\nrequire('node:fs').appendFileSync(process.env.RELEASE_TEST_LOG, JSON.stringify(process.argv.slice(2)) + '\\n');\nprocess.exit(process.env.RELEASE_TEST_FAIL === '1' || (process.env.RELEASE_TEST_FAIL === 'tags' && process.argv[2] === 'dist-tag') ? 1 : 0);\n`); + await chmod(mock, 0o755); + } + const registry = 'data:text/javascript,' + encodeURIComponent(` + import assert from 'node:assert/strict'; + globalThis.fetch = async url => { + assert.equal(url, 'https://registry.npmjs.org/-/package/%40allmaps%2Fslides/dist-tags'); + return Response.json({ beta: '${pkg.version}', latest: '0.1.0-beta.0' }); + }; + `); const commit = () => { git(root, 'add', '.'); git(root, '-c', 'user.name=Slides test', '-c', 'user.email=slides@example.invalid', 'commit', '-qm', 'release fixture', '--no-gpg-sign'); @@ -119,13 +129,14 @@ test('release commands select the npm channel explicitly and tag only after succ commit(); await mkdir(path.join(root, 'content')); const log = path.join(root, 'content/publish-log'); - const run = (env = {}) => execFileSync(process.execPath, [path.join(root, 'scripts/publish-slides.mjs')], { + const run = (env = {}) => execFileSync(process.execPath, ['--import', registry, path.join(root, 'scripts/publish-slides.mjs')], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], - env: { ...process.env, PATH: `${path.dirname(mock)}${path.delimiter}${process.env.PATH}`, RELEASE_TEST_LOG: log, ...env }, + env: { ...process.env, PATH: `${mockBin}${path.delimiter}${process.env.PATH}`, RELEASE_TEST_LOG: log, ...env }, }); const calls = async () => (await readFile(log, 'utf8')).trim().split('\n').filter(Boolean).map(JSON.parse); const expected = tag => [ ['--filter', '@allmaps/slides', 'publish', '--access', 'public', '--tag', tag, '--no-git-checks'], + ...(tag === 'beta' ? [['dist-tag', 'add', `${pkg.name}@${pkg.version}`, 'latest', '--registry=https://registry.npmjs.org']] : []), ['exec', 'changeset', 'git-tag'], ]; run(); @@ -133,6 +144,9 @@ test('release commands select the npm channel explicitly and tag only after succ await writeFile(log, ''); assert.throws(() => run({ RELEASE_TEST_FAIL: '1' })); assert.equal((await calls()).length, 1, 'Failed publication must not create a release tag'); + await writeFile(log, ''); + assert.throws(() => run({ RELEASE_TEST_FAIL: 'tags' })); + assert.deepEqual(await calls(), expected('beta').slice(0, 2), 'Failed npm tag update must not create a Git release tag'); await writeFile(path.join(dir, 'package.json'), JSON.stringify({ ...pkg, version: '0.1.0' })); await writeFile(path.join(dir, 'CHANGELOG.md'), '# Slides\n\n## 0.1.0\n\nFirst regular release.\n'); diff --git a/packages/slides/tests/release.test.mjs b/packages/slides/tests/release.test.mjs index a668349..0121957 100644 --- a/packages/slides/tests/release.test.mjs +++ b/packages/slides/tests/release.test.mjs @@ -138,7 +138,7 @@ test('tag repair preserves stable, current and newer defaults', async () => { }), /current published beta/); }); -test('tag repair refuses unpublished betas and registry failures; dry run never writes', async () => { +test('tag updates refuse unpublished betas and registry failures', async () => { const noWrite = () => assert.fail('Must not change npm tags'); await assert.rejects(syncNpmTags({ name, version }, { fetchFn: async () => Response.json({ latest: '0.1.0-beta.1' }), run: noWrite, @@ -146,7 +146,4 @@ test('tag repair refuses unpublished betas and registry failures; dry run never await assert.rejects(syncNpmTags({ name, version }, { fetchFn: async () => new Response(null, { status: 503 }), run: noWrite, }), /HTTP 503/); - assert.match(await syncNpmTags({ name, version }, { - fetchFn: async () => Response.json({ beta: version, latest: '0.1.0-beta.1' }), run: noWrite, dryRun: true, - }), /Would set latest/); }); diff --git a/scripts/sync-npm-tags.mjs b/scripts/sync-npm-tags.mjs index 19b1a3b..9f837bf 100644 --- a/scripts/sync-npm-tags.mjs +++ b/scripts/sync-npm-tags.mjs @@ -10,7 +10,6 @@ const betaParts = version => /^(\d+)\.(\d+)\.(\d+)-beta\.(\d+)$/.exec(version)?. export async function syncNpmTags({ name, version }, { fetchFn = fetch, run = execFileSync, - dryRun = false, } = {}) { assert.equal(name, '@allmaps/slides'); const target = betaParts(version); @@ -34,7 +33,6 @@ export async function syncNpmTags({ name, version }, { if (difference < 0 || target[difference] < current[difference]) return `Keeping newer latest ${tags.latest}.`; } - if (dryRun) return `Would set latest to ${version}, retaining beta.`; run(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['dist-tag', 'add', `${name}@${version}`, 'latest', '--registry=https://registry.npmjs.org'], { stdio: 'inherit' }); @@ -43,5 +41,5 @@ export async function syncNpmTags({ name, version }, { if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) { const pkg = JSON.parse(await readFile(new URL('../packages/slides/package.json', import.meta.url), 'utf8')); - console.log(await syncNpmTags(pkg, { dryRun: process.argv.includes('--dry-run') })); + console.log(await syncNpmTags(pkg)); } From 27c8da25b393b290659b2d984116a4cef0beb343 Mon Sep 17 00:00:00 2001 From: Jules Schoonman Date: Sat, 3 Oct 2026 16:48:02 +0200 Subject: [PATCH 3/5] Prerelease improvements --- .github/workflows/version.yml | 24 +++++++++++++++++++- docs/releases.md | 7 +++++- packages/slides/tests/release.test.mjs | 31 +++++++++++++++++++++++++- 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/.github/workflows/version.yml b/.github/workflows/version.yml index c62b9cd..a5696ab 100644 --- a/.github/workflows/version.yml +++ b/.github/workflows/version.yml @@ -25,12 +25,34 @@ jobs: with: fetch-depth: 0 persist-credentials: false - - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 with: node-version: 24 + package-manager-cache: false + - name: Check for pending release changes + id: changes + run: | + node --input-type=module <<'NODE' + import { appendFile, readFile, readdir } from 'node:fs/promises'; + // Consumed beta notes live in .changeset/pre/ and must not start another PR. + const pending = (await readdir('.changeset', { withFileTypes: true })).some( + entry => entry.isFile() && entry.name.endsWith('.md') && entry.name !== 'README.md', + ); + const pre = await readFile('.changeset/pre.json', 'utf8').then(JSON.parse).catch(error => { + if (error.code === 'ENOENT') return undefined; + throw error; + }); + const needed = pending || pre?.mode === 'exit'; + await appendFile(process.env.GITHUB_OUTPUT, `needed=${needed}\n`); + if (!needed) await appendFile(process.env.GITHUB_STEP_SUMMARY, + 'No pending changesets or beta exit; no version PR needed.\n'); + NODE + - uses: pnpm/action-setup@v4 + if: steps.changes.outputs.needed == 'true' - run: pnpm install --frozen-lockfile --ignore-scripts + if: steps.changes.outputs.needed == 'true' - uses: changesets/action/version@v2 + if: steps.changes.outputs.needed == 'true' with: script: pnpm release:version github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/docs/releases.md b/docs/releases.md index c33f9e7..7390b40 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -38,6 +38,11 @@ publish anything. Pending changesets alone do not publish anything either: they prepare the version PR. A release requires a clean software commit, consumed changesets and a changelog entry matching the version. +**Prepare release** skips version preparation when there are no new changesets. +Archived beta notes in `.changeset/pre/` do not count, so merging a version PR +does not open another empty PR. Explicitly leaving beta with `changeset pre exit` +still allows a version PR without new notes. The same checks apply to manual runs. + [Software checks](development.md#checks) cover the package and application. Content changes belong in their own repositories and need no Slides changeset. Local content edits do not block `pnpm release:check`. @@ -76,7 +81,7 @@ silently switch to staged publishing. | Workflow | Trigger | Result | | --- | --- | --- | -| Prepare release (`version.yml`) | Changesets on `main`, or manual run on `main` | Opens/updates the version and changelog PR. | +| Prepare release (`version.yml`) | Changesets on `main`, or manual run on `main` | Opens/updates a version PR only for new changesets or an explicit beta exit. | | Publish release (`release.yml`) | Package manifest changes on `main` | Publishes only when the version changes, then creates the tag and GitHub release. | | Publish release (`release.yml`) | Manual run on `main` with an empty tag | Attempts the current prepared release, including retrying an interrupted publication. | | Publish release (`release.yml`) | Push of `@allmaps/slides@*`, or manual run with an existing tag | Creates the GitHub release only; does not publish to npm. | diff --git a/packages/slides/tests/release.test.mjs b/packages/slides/tests/release.test.mjs index 0121957..c5b4aa1 100644 --- a/packages/slides/tests/release.test.mjs +++ b/packages/slides/tests/release.test.mjs @@ -1,10 +1,11 @@ import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; import { createHash } from 'node:crypto'; -import { mkdtemp, mkdir, writeFile, rm, realpath } from 'node:fs/promises'; +import { mkdtemp, mkdir, readFile, writeFile, rm, realpath } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { test } from 'node:test'; +import { parse } from 'yaml'; import { checkRelease } from '../../../scripts/check-release.mjs'; import { prepareNpmRelease, publishedBuildInfo } from '../../../scripts/prepare-npm-release.mjs'; import { syncNpmTags } from '../../../scripts/sync-npm-tags.mjs'; @@ -13,6 +14,34 @@ const name = '@allmaps/slides'; const version = '0.1.0-beta.2'; const noNetwork = () => { throw new Error('Unexpected registry request'); }; +test('version workflow ignores consumed beta notes but allows new changesets and beta exit', async t => { + const workflow = parse(await readFile(new URL('../../../.github/workflows/version.yml', import.meta.url), 'utf8')); + const check = workflow.jobs.version.steps.find(step => step.id === 'changes').run; + for (const { mode, pending, expected } of [ + { mode: 'pre', pending: false, expected: false }, + { mode: 'pre', pending: true, expected: true }, + { mode: 'exit', pending: false, expected: true }, + { mode: undefined, pending: false, expected: false }, + { mode: undefined, pending: true, expected: true }, + ]) { + const root = await mkdtemp(path.join(tmpdir(), 'slides-version-workflow-')); + t.after(() => rm(root, { recursive: true, force: true })); + await mkdir(path.join(root, '.changeset/pre'), { recursive: true }); + await writeFile(path.join(root, '.changeset/README.md'), 'Release instructions'); + await writeFile(path.join(root, '.changeset/config.json'), '{}'); + await writeFile(path.join(root, '.changeset/pre/shipped.md'), '---\n"@allmaps/slides": patch\n---\nAlready released.\n'); + if (mode) await writeFile(path.join(root, '.changeset/pre.json'), JSON.stringify({ mode, tag: 'beta' })); + if (pending) await writeFile(path.join(root, '.changeset/new.md'), '---\n"@allmaps/slides": patch\n---\nNew fix.\n'); + const output = path.join(root, 'output'); + const summary = path.join(root, 'summary'); + execFileSync('bash', ['-e', '-c', check], { + cwd: root, stdio: 'pipe', env: { ...process.env, GITHUB_OUTPUT: output, GITHUB_STEP_SUMMARY: summary }, + }); + assert.equal(await readFile(output, 'utf8'), `needed=${expected}\n`, JSON.stringify({ mode, pending })); + if (!expected) assert.match(await readFile(summary, 'utf8'), /no version PR needed/); + } +}); + async function fixture(t, nextVersion = version) { const root = await realpath(await mkdtemp(path.join(tmpdir(), 'slides-release-'))); t.after(() => rm(root, { recursive: true, force: true })); From 1aa6915a5b873f942071fc88053160048ff77d37 Mon Sep 17 00:00:00 2001 From: Jules Schoonman Date: Thu, 8 Oct 2026 16:14:26 +0200 Subject: [PATCH 4/5] Update Dockerfile --- packages/static-render/Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/static-render/Dockerfile b/packages/static-render/Dockerfile index ac0c9f4..b4352c1 100644 --- a/packages/static-render/Dockerfile +++ b/packages/static-render/Dockerfile @@ -30,6 +30,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends git \ COPY apps ./apps COPY scripts ./scripts COPY .changeset ./.changeset +# The release regression test exercises the version workflow's changeset guard. +COPY .github/workflows/version.yml ./.github/workflows/version.yml RUN mkdir -p content && pnpm install --frozen-lockfile \ && pnpm --filter @allmaps/slides --filter @allmaps/static-render --filter @allmaps/iiif check \ && pnpm --filter @allmaps/slides --filter @allmaps/slides-app --filter @allmaps/svelte-canvas-panel test From 9e697291ae40ec1ad3e07d212a911c86efd90f0d Mon Sep 17 00:00:00 2001 From: Jules Schoonman Date: Wed, 14 Oct 2026 14:12:44 +0200 Subject: [PATCH 5/5] Skip expensive checks for version PRs --- .github/workflows/slides.yml | 35 +++-- .github/workflows/static-render.yml | 23 +++- docs/development.md | 16 ++- docs/releases.md | 9 +- packages/slides/tests/version-only.test.mjs | 141 ++++++++++++++++++++ scripts/check-version-only.mjs | 61 +++++++++ 6 files changed, 264 insertions(+), 21 deletions(-) create mode 100644 packages/slides/tests/version-only.test.mjs create mode 100644 scripts/check-version-only.mjs diff --git a/.github/workflows/slides.yml b/.github/workflows/slides.yml index 64f69ae..59939bb 100644 --- a/.github/workflows/slides.yml +++ b/.github/workflows/slides.yml @@ -1,6 +1,6 @@ name: Slides architecture on: - # Always report the required check; skip expensive steps for documentation-only PRs. + # Always report the required check, including documentation and version-only PRs. pull_request: workflow_dispatch: jobs: @@ -12,6 +12,9 @@ jobs: pull-requests: read steps: - uses: actions/checkout@v6 + with: + # Include the PR merge parents for the version-only comparison. + fetch-depth: 2 - name: Detect changes that need testing id: changes # Manual runs and PRs beyond the API's 3,000-file limit run all tests. @@ -20,38 +23,50 @@ jobs: with: predicate-quantifier: every filters: .github/test-paths.yml + - name: Check for version-only changes + id: version + if: >- + github.event_name == 'pull_request' && + github.event.pull_request.changed_files <= 3000 && + github.event.pull_request.base.ref == 'main' && + github.event.pull_request.head.repo.full_name == github.repository && + github.head_ref == 'changeset-release/main' && + steps.changes.outputs.tests != 'false' + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: node scripts/check-version-only.mjs "$BASE_SHA" - name: Documentation-only changes if: steps.changes.outputs.tests == 'false' run: echo "Documentation-only changes; integration tests were not needed." >> "$GITHUB_STEP_SUMMARY" - name: Check out the integration presentation - if: steps.changes.outputs.tests != 'false' + if: steps.changes.outputs.tests != 'false' && steps.version.outputs.only != 'true' run: git submodule update --init content/slides-template - uses: pnpm/action-setup@v4 - if: steps.changes.outputs.tests != 'false' + if: steps.changes.outputs.tests != 'false' && steps.version.outputs.only != 'true' with: version: 10.22.0 - uses: actions/setup-node@v6 - if: steps.changes.outputs.tests != 'false' + if: steps.changes.outputs.tests != 'false' && steps.version.outputs.only != 'true' with: node-version: 24 cache: pnpm - name: Set up native renderer - if: steps.changes.outputs.tests != 'false' + if: steps.changes.outputs.tests != 'false' && steps.version.outputs.only != 'true' run: sudo sh packages/static-render/scripts/install-system-deps.sh - run: pnpm install --frozen-lockfile - if: steps.changes.outputs.tests != 'false' + if: steps.changes.outputs.tests != 'false' && steps.version.outputs.only != 'true' - name: Unit and component checks - if: steps.changes.outputs.tests != 'false' + if: steps.changes.outputs.tests != 'false' && steps.version.outputs.only != 'true' run: | pnpm -r test pnpm --filter @allmaps/slides --filter @allmaps/iiif --filter @allmaps/static-render check pnpm check ./content/slides-template - name: Native pixel alignment - if: steps.changes.outputs.tests != 'false' + if: steps.changes.outputs.tests != 'false' && steps.version.outputs.only != 'true' run: xvfb-run -a pnpm --filter @allmaps/static-render test:native - name: Independent development sites - if: steps.changes.outputs.tests != 'false' + if: steps.changes.outputs.tests != 'false' && steps.version.outputs.only != 'true' run: pnpm --filter @allmaps/slides test:dev - name: Fresh installed consumer - if: steps.changes.outputs.tests != 'false' + if: steps.changes.outputs.tests != 'false' && steps.version.outputs.only != 'true' run: xvfb-run -a pnpm --filter @allmaps/slides test:package diff --git a/.github/workflows/static-render.yml b/.github/workflows/static-render.yml index 2d98c3d..9a2f5b9 100644 --- a/.github/workflows/static-render.yml +++ b/.github/workflows/static-render.yml @@ -1,6 +1,6 @@ name: Static renderer on: - # Always report the required check; skip expensive steps for documentation-only PRs. + # Always report the required check, including documentation and version-only PRs. pull_request: workflow_dispatch: jobs: @@ -11,6 +11,9 @@ jobs: pull-requests: read steps: - uses: actions/checkout@v6 + with: + # Include the PR merge parents for the version-only comparison. + fetch-depth: 2 - name: Detect changes that need testing id: changes # Manual runs and PRs beyond the API's 3,000-file limit run all tests. @@ -19,13 +22,25 @@ jobs: with: predicate-quantifier: every filters: .github/test-paths.yml + - name: Check for version-only changes + id: version + if: >- + github.event_name == 'pull_request' && + github.event.pull_request.changed_files <= 3000 && + github.event.pull_request.base.ref == 'main' && + github.event.pull_request.head.repo.full_name == github.repository && + github.head_ref == 'changeset-release/main' && + steps.changes.outputs.tests != 'false' + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: node scripts/check-version-only.mjs "$BASE_SHA" - name: Documentation-only changes if: steps.changes.outputs.tests == 'false' run: echo "Documentation-only changes; container tests were not needed." >> "$GITHUB_STEP_SUMMARY" - uses: docker/setup-buildx-action@v3 - if: steps.changes.outputs.tests != 'false' + if: steps.changes.outputs.tests != 'false' && steps.version.outputs.only != 'true' - name: Build image and run native smoke test - if: steps.changes.outputs.tests != 'false' + if: steps.changes.outputs.tests != 'false' && steps.version.outputs.only != 'true' uses: docker/build-push-action@v6 with: context: . @@ -37,6 +52,6 @@ jobs: cache-from: type=gha,scope=slides-renderer cache-to: type=gha,mode=max,scope=slides-renderer - name: Check container entrypoint - if: steps.changes.outputs.tests != 'false' + if: steps.changes.outputs.tests != 'false' && steps.version.outputs.only != 'true' timeout-minutes: 1 run: docker run --rm slides-renderer:check node packages/static-render/bin/render.mjs --help diff --git a/docs/development.md b/docs/development.md index ffcdfdf..b28e5c4 100644 --- a/docs/development.md +++ b/docs/development.md @@ -134,10 +134,18 @@ Every pull request reports the required `integration` and `container` checks. Documentation-only changes pass after a quick file check, without installing dependencies or building the container. The shared rules in [`.github/test-paths.yml`](../.github/test-paths.yml) exclude guides, software -READMEs, changelogs and changeset notes. All other paths run the full checks, -including configuration, licenses, workflows and content submodules. Manual -workflow runs always run the full checks; a failed change-detection step fails -the required check. +READMEs, changelogs and changeset notes. + +Automatic version PRs from this repository's `changeset-release/main` branch to +`main` also skip expensive tests when a comparison against the PR base confirms +that only the Slides package's `version` field, changelog and Changesets release +records changed. Any other manifest field, lockfile, code, configuration, +license, workflow or content change runs the full checks. The required checks +still report success with a summary explaining the shortcut. Publication keeps +its release validation and package build. + +Manual workflow runs and PRs exceeding 3,000 changed files always run the full +checks; a failed change-detection step fails the required check. ```sh pnpm -r test diff --git a/docs/releases.md b/docs/releases.md index 7390b40..8f8491d 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -13,7 +13,10 @@ Content repositories choose when to upgrade and redeploy their websites. changesets are collected into one release. 2. **Prepare release** opens or updates a version PR with the new version, changelog and lockfile. During beta, the next version after `0.1.0-beta.1` is - `0.1.0-beta.2`. Review the PR, let its checks pass, and merge it. + `0.1.0-beta.2`. Review the PR, let its checks pass, and merge it. For a + version-only PR, these checks verify the changed files and manifest instead + of repeating the expensive software tests. Changes to code, dependencies or + the lockfile still run the full checks. 3. **Publish release** detects the version change on `main`, checks the release, builds and packs the software, and publishes it to npm. Beta versions update `beta` and also `latest` while the default version is still a beta. Once @@ -85,8 +88,8 @@ silently switch to staged publishing. | Publish release (`release.yml`) | Package manifest changes on `main` | Publishes only when the version changes, then creates the tag and GitHub release. | | Publish release (`release.yml`) | Manual run on `main` with an empty tag | Attempts the current prepared release, including retrying an interrupted publication. | | Publish release (`release.yml`) | Push of `@allmaps/slides@*`, or manual run with an existing tag | Creates the GitHub release only; does not publish to npm. | -| Slides architecture (`slides.yml`) | Every pull request, or manual run | Tests the CLI, app, renderer, development server and installed package; skips tests for documentation-only PRs. | -| Static renderer (`static-render.yml`) | Every pull request, or manual run | Tests the Linux renderer container; skips tests for documentation-only PRs. | +| Slides architecture (`slides.yml`) | Every pull request, or manual run | Tests the CLI, app, renderer, development server and installed package; skips expensive tests for documentation-only and verified version-only PRs. | +| Static renderer (`static-render.yml`) | Every pull request, or manual run | Tests the Linux renderer container; uses the same documentation and version-only shortcuts. | | Deploy to GitHub Pages (content repos) | Push to `main`, or manual run | Installs the pinned npm package, builds content and deploys the site. | | Build and Publish Docker Image (Kattenburg) | Push to `main`, version tag, or manual run | Builds the site and publishes an Nginx image to GHCR. | diff --git a/packages/slides/tests/version-only.test.mjs b/packages/slides/tests/version-only.test.mjs new file mode 100644 index 0000000..821b7c5 --- /dev/null +++ b/packages/slides/tests/version-only.test.mjs @@ -0,0 +1,141 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { chmod, mkdir, mkdtemp, readFile, rename, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { test } from 'node:test'; +import { isVersionOnlyChange } from '../../../scripts/check-version-only.mjs'; + +const manifest = 'packages/slides/package.json'; +const pkg = { + name: '@allmaps/slides', version: '0.1.0-beta.2', + dependencies: { sharp: '0.35.4' }, scripts: { build: 'node build.mjs' }, + exports: { '.': './dist/index.js' }, engines: { node: '>=24' }, +}; + +async function fixture(t) { + const cwd = await mkdtemp(path.join(tmpdir(), 'slides-version-only-')); + t.after(() => rm(cwd, { recursive: true, force: true })); + const git = (...args) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); + const write = async (file, text) => { + await mkdir(path.dirname(path.join(cwd, file)), { recursive: true }); + await writeFile(path.join(cwd, file), text); + }; + const writePackage = (fields = {}) => write(manifest, JSON.stringify({ ...pkg, version: '0.1.0-beta.3', ...fields })); + const commit = () => { git('add', '.'); git('commit', '-qm', 'Fixture'); return git('rev-parse', 'HEAD'); }; + git('init', '-b', 'main'); + git('config', 'user.name', 'Release test'); + git('config', 'user.email', 'release@example.org'); + await writePackage({ version: pkg.version }); + await write('packages/slides/CHANGELOG.md', `# Slides\n\n## ${pkg.version}\n`); + await write('.changeset/pre.json', '{"mode":"pre","tag":"beta"}'); + await write('.changeset/new-feature.md', '---\n"@allmaps/slides": patch\n---\nFeature.\n'); + const base = commit(); + await writePackage(); + return { cwd, base, git, write, writePackage, commit }; +} + +test('version-only changes include a beta bump, changelog and archived notes across multiple commits', async t => { + const f = await fixture(t); + f.commit(); + await f.write('packages/slides/CHANGELOG.md', '# Slides\n\n## 0.1.0-beta.3\n'); + await mkdir(path.join(f.cwd, '.changeset/pre')); + await rename(path.join(f.cwd, '.changeset/new-feature.md'), path.join(f.cwd, '.changeset/pre/new-feature.md')); + f.commit(); + assert.equal(isVersionOnlyChange(f.base, f), true); +}); + +test('version-only changes include a stable release that removes prerelease records', async t => { + const f = await fixture(t); + await f.writePackage({ version: '0.1.0' }); + await rm(path.join(f.cwd, '.changeset/pre.json')); + await rm(path.join(f.cwd, '.changeset/new-feature.md')); + f.commit(); + assert.equal(isVersionOnlyChange(f.base, f), true); +}); + +test('a depth-two PR merge checkout compares against main even when main advanced', async t => { + const f = await fixture(t); + f.git('checkout', '-b', 'release'); + f.commit(); + f.git('checkout', 'main'); + await f.write('apps/slides/src/app.ts', 'Already on main\n'); + const base = f.commit(); + f.git('merge', '--no-edit', 'release'); + const checkout = await mkdtemp(path.join(tmpdir(), 'slides-shallow-merge-')); + t.after(() => rm(checkout, { recursive: true, force: true })); + f.git('clone', '--depth', '2', pathToFileURL(f.cwd).href, checkout); + assert.equal(isVersionOnlyChange(base, { cwd: checkout }), true); +}); + +test('code, configuration, dependencies and unknown paths still require full checks', async t => { + for (const file of [ + 'apps/slides/src/app.ts', 'pnpm-lock.yaml', 'package.json', + '.changeset/config.json', '.changeset/pre/config.json', + '.github/workflows/slides.yml', 'scripts/check-version-only.mjs', + 'content/slides-template/slides.md', 'LICENSE.md', 'unknown\nfile.md', + ]) { + await t.test(file, async t => { + const f = await fixture(t); + await f.write(file, 'Changed\n'); + f.commit(); + assert.equal(isVersionOnlyChange(f.base, f), false); + }); + } +}); + +test('a version bump cannot conceal other manifest edits', async t => { + for (const fields of [ + { name: 'other-package' }, { dependencies: { sharp: '0.36.0' } }, + { scripts: { build: 'node different.mjs' } }, { exports: {} }, { engines: { node: '>=26' } }, + { version: pkg.version }, { version: 'invalid' }, { version: 3 }, + ]) { + await t.test(JSON.stringify(fields), async t => { + const f = await fixture(t); + await f.writePackage(fields); + await f.write('packages/slides/CHANGELOG.md', '# Updated\n'); + f.commit(); + assert.equal(isVersionOnlyChange(f.base, f), false); + }); + } +}); + +test('file deletions, symlinks, mode changes and malformed manifests cannot use the shortcut', async t => { + for (const kind of ['deleted manifest', 'deleted changelog', 'symlink', 'executable', 'invalid JSON']) { + await t.test(kind, async t => { + const f = await fixture(t); + if (kind === 'deleted manifest') await rm(path.join(f.cwd, manifest)); + if (kind === 'deleted changelog') await rm(path.join(f.cwd, 'packages/slides/CHANGELOG.md')); + if (kind === 'symlink') await symlink('../package.json', path.join(f.cwd, '.changeset/linked.md')); + if (kind === 'executable') await chmod(path.join(f.cwd, manifest), 0o755); + if (kind === 'invalid JSON') await f.write(manifest, '{'); + f.commit(); + assert.equal(isVersionOnlyChange(f.base, f), false); + }); + } +}); + +test('a later version commit does not hide earlier code changes in the PR', async t => { + const f = await fixture(t); + await f.write('apps/slides/src/app.ts', 'Changed\n'); + f.commit(); + await f.writePackage({ version: '0.1.0-beta.4' }); + f.commit(); + assert.equal(isVersionOnlyChange(f.base, f), false); + assert.throws(() => isVersionOnlyChange('missing', f), /base commit SHA/); + assert.throws(() => isVersionOnlyChange('a'.repeat(40), f)); +}); + +test('the command reports the decision to GitHub and its job summary', async t => { + const f = await fixture(t); + f.commit(); + const output = path.join(f.cwd, 'output'); + const summary = path.join(f.cwd, 'summary'); + const script = fileURLToPath(new URL('../../../scripts/check-version-only.mjs', import.meta.url)); + execFileSync(process.execPath, [script, f.base], { + cwd: f.cwd, stdio: 'pipe', env: { ...process.env, GITHUB_OUTPUT: output, GITHUB_STEP_SUMMARY: summary }, + }); + assert.equal(await readFile(output, 'utf8'), 'only=true\n'); + assert.match(await readFile(summary, 'utf8'), /Verified version-only changes/); +}); diff --git a/scripts/check-version-only.mjs b/scripts/check-version-only.mjs new file mode 100644 index 0000000..696af06 --- /dev/null +++ b/scripts/check-version-only.mjs @@ -0,0 +1,61 @@ +import { execFileSync } from 'node:child_process'; +import { appendFile } from 'node:fs/promises'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { isDeepStrictEqual } from 'node:util'; + +const manifest = 'packages/slides/package.json'; +const versionPattern = /^\d+\.\d+\.\d+(?:-beta\.\d+)?$/; + +// Compare the checked-out PR merge with its base, not just its branch name or +// last commit. Dependency, lockfile, code and workflow edits must run the tests. +export function isVersionOnlyChange(base, { cwd = process.cwd() } = {}) { + if (!/^[a-f0-9]{40,64}$/.test(base ?? '')) throw new Error('Expected the PR base commit SHA'); + const git = (...args) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + const entries = git('diff', '--raw', '--no-renames', '--ignore-submodules=none', '-z', base, 'HEAD', '--').split('\0'); + let versionChanged = false; + for (let i = 0; i < entries.length - 1; i += 2) { + const [beforeMode, afterMode, , , status] = entries[i].slice(1).split(' '); + const file = entries[i + 1]; + // Release metadata consists of ordinary files, never symlinks or submodules. + if (![beforeMode, afterMode].every(mode => mode === '100644' || mode === '000000')) return false; + if (!['A', 'M', 'D'].includes(status)) return false; + if (file === manifest) { + if (status !== 'M') return false; + versionChanged = true; + } else if (file === 'packages/slides/CHANGELOG.md') { + if (status === 'D') return false; + } else if (file !== '.changeset/pre.json' && !/^\.changeset\/(?:pre\/)?[^/]+\.md$/.test(file)) { + return false; + } + } + if (!versionChanged) return false; + + let before, after; + // A malformed manifest needs the normal checks, not a release shortcut. + const beforeText = git('show', `${base}:${manifest}`); + const afterText = git('show', `HEAD:${manifest}`); + try { + before = JSON.parse(beforeText); + after = JSON.parse(afterText); + } catch { + return false; + } + if (!before || !after || typeof before.version !== 'string' || typeof after.version !== 'string') return false; + const { version: beforeVersion, ...beforePackage } = before; + const { version: afterVersion, ...afterPackage } = after; + return beforePackage.name === '@allmaps/slides' + && versionPattern.test(afterVersion) + && beforeVersion !== afterVersion + && isDeepStrictEqual(beforePackage, afterPackage); +} + +if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) { + const only = isVersionOnlyChange(process.argv[2]); + if (process.env.GITHUB_OUTPUT) await appendFile(process.env.GITHUB_OUTPUT, `only=${only}\n`); + const message = only + ? 'Verified version-only changes; the expensive tests were not needed.' + : 'Changes are not version-only; running the normal checks.'; + console.log(message); + if (process.env.GITHUB_STEP_SUMMARY) await appendFile(process.env.GITHUB_STEP_SUMMARY, `${message}\n`); +}