diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fcb4dc2..4ce3e9b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -48,8 +48,9 @@ jobs: - 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' + # 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 +64,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' + run: node scripts/sync-npm-tags.mjs - name: Create and push the release tag if: steps.plan.outputs.release == 'true' env: 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/.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/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 60eef62..8f8491d 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -13,10 +13,15 @@ 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 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. @@ -36,6 +41,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`. @@ -50,7 +60,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 +68,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 @@ -72,12 +84,12 @@ 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. | -| 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. | @@ -107,10 +119,15 @@ 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 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`; 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 +143,13 @@ 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. +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/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 d941c24..c5b4aa1 100644 --- a/packages/slides/tests/release.test.mjs +++ b/packages/slides/tests/release.test.mjs @@ -1,17 +1,47 @@ 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'; 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 })); @@ -107,3 +137,42 @@ 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 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, + }), /current published beta/); + await assert.rejects(syncNpmTags({ name, version }, { + fetchFn: async () => new Response(null, { status: 503 }), run: noWrite, + }), /HTTP 503/); +}); 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/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 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`); +} 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..9f837bf --- /dev/null +++ b/scripts/sync-npm-tags.mjs @@ -0,0 +1,45 @@ +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, +} = {}) { + 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}.`; + } + 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)); +}