From 3db5dded7fb0b921a93deb77fe545e94d247138e Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Mon, 12 Oct 2026 22:03:09 +1100 Subject: [PATCH 1/3] chore(ci): automate releases after version PRs --- .github/workflows/release.yml | 77 ++++++++++++++++++++++++++++------ scripts/prepare-release.ts | 37 ++++++++++++++++ scripts/release-plan.ts | 25 +++++++++++ test/unit/release-plan.test.ts | 51 ++++++++++++++++++++++ 4 files changed, 177 insertions(+), 13 deletions(-) create mode 100644 scripts/prepare-release.ts create mode 100644 scripts/release-plan.ts create mode 100644 test/unit/release-plan.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 15a814d80..525119d18 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,18 +4,62 @@ on: push: tags: - 'v*' + workflow_run: + workflows: [CI] + types: [completed] + branches: [main] permissions: contents: write id-token: write jobs: + prepare: + if: >- + github.event_name == 'push' || + (github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_repository.full_name == github.repository) + runs-on: ubuntu-24.04-arm + permissions: + contents: read + outputs: + release: ${{ steps.plan.outputs.release }} + tag: ${{ steps.plan.outputs.tag }} + dist-tag: ${{ steps.plan.outputs.dist-tag }} + steps: + - name: Checkout tested commit + uses: actions/checkout@v7 + with: + ref: ${{ github.event.workflow_run.head_sha || github.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@v7 + with: + node-version: 24.21.0 + package-manager-cache: false + + - name: Plan release + id: plan + run: node scripts/prepare-release.ts + release: + needs: prepare + if: needs.prepare.outputs.release == 'true' runs-on: ubuntu-24.04-arm + concurrency: + group: release-${{ needs.prepare.outputs.tag }} + cancel-in-progress: false + env: + RELEASE_TAG: ${{ needs.prepare.outputs.tag }} + RELEASE_DIST_TAG: ${{ needs.prepare.outputs.dist-tag }} steps: - name: Checkout uses: actions/checkout@v7 with: + ref: ${{ github.event.workflow_run.head_sha || github.sha }} fetch-depth: 0 persist-credentials: false @@ -29,8 +73,26 @@ jobs: cache: pnpm registry-url: https://registry.npmjs.org + - name: Create release tag + run: | + RELEASE_SHA=$(git rev-parse HEAD) + if git show-ref --verify --quiet "refs/tags/$RELEASE_TAG"; then + if [ "$(git rev-parse "$RELEASE_TAG^{commit}")" != "$RELEASE_SHA" ]; then + echo '::error::The release tag points to another commit.' + exit 1 + fi + else + gh api "repos/$GITHUB_REPOSITORY/git/refs" \ + -f "ref=refs/tags/$RELEASE_TAG" -f "sha=$RELEASE_SHA" + fi + git fetch origin "refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # GITHUB_TOKEN tag creation does not trigger another workflow. + # Publish in this run so release PRs need no extra token or manual push. - name: Generate changelog - run: npx changelogithub + run: pnpm dlx changelogithub --to "$RELEASE_TAG" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -43,16 +105,5 @@ jobs: - name: Build run: pnpm build - - name: Detect prerelease tag - id: prerelease - run: | - TAG="${GITHUB_REF#refs/tags/}" - if [[ "$TAG" =~ -(beta|rc|alpha) ]]; then - DIST_TAG=$(echo "$TAG" | sed 's/.*-\(beta\|rc\|alpha\).*/\1/') - echo "tag=--tag $DIST_TAG" >> "$GITHUB_OUTPUT" - else - echo "tag=" >> "$GITHUB_OUTPUT" - fi - - name: Publish - run: pnpm publish -r --access public --no-git-checks --provenance ${{ steps.prerelease.outputs.tag }} + run: pnpm publish -r --access public --no-git-checks --provenance --tag "$RELEASE_DIST_TAG" diff --git a/scripts/prepare-release.ts b/scripts/prepare-release.ts new file mode 100644 index 000000000..d3cdf120f --- /dev/null +++ b/scripts/prepare-release.ts @@ -0,0 +1,37 @@ +import { execFileSync } from 'node:child_process' +import { appendFileSync, readFileSync } from 'node:fs' +import process from 'node:process' +import { planRelease } from './release-plan.ts' + +function readVersion(source: string): unknown { + const manifest: unknown = JSON.parse(source) + if (!manifest || typeof manifest !== 'object' || !('version' in manifest)) + throw new Error('The package manifest must contain a version.') + return manifest.version +} + +const versions = [ + 'package.json', + 'packages/script/package.json', + 'packages/cli/package.json', + 'packages/devtools-app/package.json', +].map(path => readVersion(readFileSync(path, 'utf8'))) + +const mode = process.env.GITHUB_EVENT_NAME +if (mode !== 'push' && mode !== 'workflow_run') + throw new Error('Release requires a tag push or completed main CI.') + +const plan = planRelease(versions, mode === 'push' + ? { _tag: 'Tag', tag: process.env.GITHUB_REF_NAME || '' } + : { _tag: 'Merge', previousVersion: readVersion(execFileSync('git', ['show', 'HEAD^:package.json'], { encoding: 'utf8' })) }) + +if (plan._tag === 'Err') + throw new Error(plan.message) + +const output = process.env.GITHUB_OUTPUT +if (!output) + throw new Error('GitHub Actions must provide the output file.') + +appendFileSync(output, plan._tag === 'Skip' + ? 'release=false\n' + : `release=true\ntag=${plan.tag}\ndist-tag=${plan.distTag}\n`) diff --git a/scripts/release-plan.ts b/scripts/release-plan.ts new file mode 100644 index 000000000..8f6b61d7e --- /dev/null +++ b/scripts/release-plan.ts @@ -0,0 +1,25 @@ +type ReleaseTrigger + = { _tag: 'Merge', previousVersion: unknown } + | { _tag: 'Tag', tag: string } + +type ReleasePlan + = { _tag: 'Skip' } + | { _tag: 'Release', tag: string, distTag: string } + | { _tag: 'Err', message: string } + +export function planRelease(versions: readonly unknown[], trigger: ReleaseTrigger): ReleasePlan { + const version = versions[0] + const match = typeof version === 'string' + ? /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(alpha|beta|rc)\.(?:0|[1-9]\d*))?$/.exec(version) + : null + if (!match || match[0] !== version) + return { _tag: 'Err', message: 'Use a stable, alpha, beta, or rc package version.' } + if (versions.length !== 4 || versions.some(value => value !== version)) + return { _tag: 'Err', message: 'All package versions must match before release.' } + if (trigger._tag === 'Merge' && trigger.previousVersion === version) + return { _tag: 'Skip' } + const tag = `v${version}` + if (trigger._tag === 'Tag' && trigger.tag !== tag) + return { _tag: 'Err', message: 'The release tag must match the package version.' } + return { _tag: 'Release', tag, distTag: match[1] || 'latest' } +} diff --git a/test/unit/release-plan.test.ts b/test/unit/release-plan.test.ts new file mode 100644 index 000000000..e7b77a3c0 --- /dev/null +++ b/test/unit/release-plan.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { planRelease } from '../../scripts/release-plan' + +const versions = ['2.0.0-beta.16', '2.0.0-beta.16', '2.0.0-beta.16', '2.0.0-beta.16'] + +describe('release planning', () => { + it('releases an aligned version change after main CI', () => { + expect(planRelease(versions, { _tag: 'Merge', previousVersion: '2.0.0-beta.15' })).toEqual({ + _tag: 'Release', + tag: 'v2.0.0-beta.16', + distTag: 'beta', + }) + }) + + it('skips a merge without a version change', () => { + expect(planRelease(versions, { _tag: 'Merge', previousVersion: versions[0] })).toEqual({ _tag: 'Skip' }) + }) + + it('rejects mismatched workspace versions', () => { + expect(planRelease([...versions.slice(0, 3), '2.0.0-beta.15'], { _tag: 'Merge', previousVersion: '2.0.0-beta.15' })).toEqual({ + _tag: 'Err', + message: 'All package versions must match before release.', + }) + }) + + it('rejects a tag that differs from the package version', () => { + expect(planRelease(versions, { _tag: 'Tag', tag: 'v2.0.0-beta.15' })).toEqual({ + _tag: 'Err', + message: 'The release tag must match the package version.', + }) + }) + + it.each(['bad', '2.0.0-beta.1\n', '2.0.0-other.1', null])('rejects unsafe version %s', (version) => { + expect(planRelease([version, version, version, version], { _tag: 'Tag', tag: 'vbad' })).toEqual({ + _tag: 'Err', + message: 'Use a stable, alpha, beta, or rc package version.', + }) + }) + + it.each([ + ['2.1.0', 'latest'], + ['2.1.0-alpha.1', 'alpha'], + ['2.1.0-rc.1', 'rc'], + ])('publishes %s to %s', (version, distTag) => { + expect(planRelease([version, version, version, version], { _tag: 'Tag', tag: `v${version}` })).toEqual({ + _tag: 'Release', + tag: `v${version}`, + distTag, + }) + }) +}) From 822c8d4bb17b09820cc2105e4ef0e1272f37f8ba Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Mon, 12 Oct 2026 23:08:08 +1100 Subject: [PATCH 2/3] test(e2e): bound idle scheduling in browser fixtures --- test/e2e/_usercentrics-suite.ts | 2 ++ test/e2e/basic.test.ts | 22 ++++++++++++++++++++-- test/utils/setup-fixture.ts | 11 +++++++++++ 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/test/e2e/_usercentrics-suite.ts b/test/e2e/_usercentrics-suite.ts index 1894c0baf..c37745ba9 100644 --- a/test/e2e/_usercentrics-suite.ts +++ b/test/e2e/_usercentrics-suite.ts @@ -1,5 +1,6 @@ import { getBrowser, url } from '@nuxt/test-utils/e2e' import { expect, it } from 'vitest' +import { installFixtureIdleDeadline } from '../utils/setup-fixture' // The real Usercentrics CMP v3 loader validates `data-ruleset-id` against // registered domains and silently no-ops on unknown origins. Tests stub the @@ -9,6 +10,7 @@ import { expect, it } from 'vitest' async function newPage() { const browser = await getBrowser() const page = await browser.newPage() + await page.addInitScript(installFixtureIdleDeadline) await page.route('**/web.cmp.usercentrics.eu/**', route => route.fulfill({ status: 200, contentType: 'application/javascript', diff --git a/test/e2e/basic.test.ts b/test/e2e/basic.test.ts index 117ddbbe0..2cda755de 100644 --- a/test/e2e/basic.test.ts +++ b/test/e2e/basic.test.ts @@ -4,7 +4,7 @@ import { createResolver } from '@nuxt/kit' import { getBrowser, url, waitForHydration } from '@nuxt/test-utils/e2e' import { parseURL } from 'ufo' import { afterEach, describe, expect, it } from 'vitest' -import { setupFixture } from '../utils/setup-fixture' +import { installFixtureIdleDeadline, setupFixture } from '../utils/setup-fixture' const { resolve } = createResolver(import.meta.url) @@ -20,11 +20,21 @@ afterEach(async () => { await Promise.all(pages.splice(0).map(page => page.close())) }) -async function createPage(path: string, options?: any) { +async function createPage(path: string, options?: any, starveIdleCallbacks = false) { const logs: { text: string, location: string }[] = [] const browser = await getBrowser() const page = await browser.newPage(options) pages.push(page) + if (starveIdleCallbacks) { + // Model a busy Chromium renderer: only callbacks with deadlines can run. + await page.addInitScript(() => { + const requestIdleCallback = window.requestIdleCallback.bind(window) + window.requestIdleCallback = (callback, options) => options?.timeout === undefined + ? 0 + : requestIdleCallback(callback, options) + }) + } + await page.addInitScript(installFixtureIdleDeadline) page.addListener('console', (msg) => { const location = `${parseURL(msg.location().url).pathname}:${msg.location().lineNumber}` if (!location.startsWith('/_nuxt')) { @@ -73,6 +83,14 @@ async function waitForLogCount( } describe('basic', () => { + it('loads onNuxtReady scripts when the renderer has no idle time', { timeout: 30000 }, async () => { + const { page, logs } = await createPage('/reload-trigger', undefined, true) + await waitForLogCount(logs, 'Script -- Loaded', 1, 3000) + expect(await page.$eval('#status', el => el.textContent?.trim())).toBe('loaded') + await page.click('#reload-script') + await waitForLogCount(logs, 'Script -- Loaded', 2, 3000) + expect(logs().filter(log => log.text === 'Script -- Loaded')).toHaveLength(2) + }) it('relative onNuxtReady', async () => { const { page, logs } = await createPage('/') // visit and collect all logs, we need to do a snapshot on them diff --git a/test/utils/setup-fixture.ts b/test/utils/setup-fixture.ts index 3f290b09a..ff83d88bd 100644 --- a/test/utils/setup-fixture.ts +++ b/test/utils/setup-fixture.ts @@ -2,6 +2,17 @@ import type { TestOptions } from '@nuxt/test-utils/e2e' import { setup } from '@nuxt/test-utils/e2e' import { defu } from 'defu' +/** Keep native idle scheduling, with a deadline for busy CI renderers. */ +export function installFixtureIdleDeadline(target: Pick = window) { + if (typeof target.requestIdleCallback !== 'function') + return + const requestIdleCallback = target.requestIdleCallback.bind(target) + target.requestIdleCallback = (callback, options) => requestIdleCallback(callback, { + timeout: 1000, + ...options, + }) +} + /** * Compile settings that a production build applies and a fixture build does not. * From 4235f0f0195208e445eb20588324d6b30aa3d2db Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Mon, 12 Oct 2026 23:22:13 +1100 Subject: [PATCH 3/3] fix(ci): order idle setup and prevent channel rollback --- .github/workflows/release.yml | 7 ++++++- scripts/check-release-channel.ts | 35 ++++++++++++++++++++++++++++++++ scripts/release-plan.ts | 29 ++++++++++++++++++++++++++ test/e2e/basic.test.ts | 11 +--------- test/unit/release-plan.test.ts | 32 ++++++++++++++++++++++++++++- test/utils/setup-fixture.ts | 9 ++++++-- 6 files changed, 109 insertions(+), 14 deletions(-) create mode 100644 scripts/check-release-channel.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 525119d18..3ce59041b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,7 +50,7 @@ jobs: if: needs.prepare.outputs.release == 'true' runs-on: ubuntu-24.04-arm concurrency: - group: release-${{ needs.prepare.outputs.tag }} + group: release-${{ needs.prepare.outputs.dist-tag }} cancel-in-progress: false env: RELEASE_TAG: ${{ needs.prepare.outputs.tag }} @@ -105,5 +105,10 @@ jobs: - name: Build run: pnpm build + - name: Check release channel + id: channel + run: node scripts/check-release-channel.ts + - name: Publish + if: steps.channel.outputs.promote == 'true' run: pnpm publish -r --access public --no-git-checks --provenance --tag "$RELEASE_DIST_TAG" diff --git a/scripts/check-release-channel.ts b/scripts/check-release-channel.ts new file mode 100644 index 000000000..b222882bd --- /dev/null +++ b/scripts/check-release-channel.ts @@ -0,0 +1,35 @@ +import { appendFileSync, readFileSync } from 'node:fs' +import process from 'node:process' +import { planPromotion } from './release-plan.ts' + +const distTag = process.env.RELEASE_DIST_TAG +const output = process.env.GITHUB_OUTPUT +if (!distTag || !output) + throw new Error('Release requires a distribution tag and an Actions output file.') + +const names = ['@nuxt/scripts', '@nuxt/scripts-cli', '@nuxt/scripts-devtools'] +const currentVersions = await Promise.all(names.map(async (name) => { + const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}`, { + headers: { accept: 'application/vnd.npm.install-v1+json', 'cache-control': 'no-cache' }, + signal: AbortSignal.timeout(30000), + }) + if (!response.ok) + throw new Error(`Registry lookup failed for ${name}: ${response.status}.`) + const metadata: unknown = await response.json() + if (!metadata || typeof metadata !== 'object' || !('dist-tags' in metadata)) + throw new Error(`Registry metadata lacks distribution tags for ${name}.`) + const tags = metadata['dist-tags'] + if (!tags || typeof tags !== 'object') + throw new Error(`Registry distribution tags are invalid for ${name}.`) + return Object.hasOwn(tags, distTag) ? Reflect.get(tags, distTag) : undefined +})) + +const manifest: unknown = JSON.parse(readFileSync('package.json', 'utf8')) +if (!manifest || typeof manifest !== 'object' || !('version' in manifest)) + throw new Error('The package manifest must contain a version.') +const plan = planPromotion(manifest.version, currentVersions.filter(version => version !== undefined)) +if (plan._tag === 'Err') + throw new Error(plan.message) +if (plan._tag === 'Skip') + console.log('Skip publication because a newer version owns the distribution tag.') +appendFileSync(output, `promote=${plan._tag === 'Promote'}\n`) diff --git a/scripts/release-plan.ts b/scripts/release-plan.ts index 8f6b61d7e..6963dbabd 100644 --- a/scripts/release-plan.ts +++ b/scripts/release-plan.ts @@ -7,6 +7,35 @@ type ReleasePlan | { _tag: 'Release', tag: string, distTag: string } | { _tag: 'Err', message: string } +function parseVersion(version: unknown): readonly bigint[] | null { + if (typeof version !== 'string') + return null + const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(alpha|beta|rc)\.(0|[1-9]\d*))?$/.exec(version) + if (!match || match[0] !== version) + return null + const channelRank = match[4] === 'alpha' ? 0n : match[4] === 'beta' ? 1n : match[4] === 'rc' ? 2n : 3n + return [BigInt(match[1]!), BigInt(match[2]!), BigInt(match[3]!), channelRank, BigInt(match[5] || '0')] +} + +export function planPromotion(candidate: unknown, currentVersions: readonly unknown[]): + { _tag: 'Promote' } | { _tag: 'Skip' } | { _tag: 'Err', message: string } { + const version = parseVersion(candidate) + if (!version) + return { _tag: 'Err', message: 'The release version must use a supported release version.' } + for (const current of currentVersions) { + const published = parseVersion(current) + if (!published) + return { _tag: 'Err', message: 'The channel version must use a supported release version.' } + for (let index = 0; index < version.length; index++) { + if (published[index]! > version[index]!) + return { _tag: 'Skip' } + if (published[index]! < version[index]!) + break + } + } + return { _tag: 'Promote' } +} + export function planRelease(versions: readonly unknown[], trigger: ReleaseTrigger): ReleasePlan { const version = versions[0] const match = typeof version === 'string' diff --git a/test/e2e/basic.test.ts b/test/e2e/basic.test.ts index 2cda755de..aa9fa2780 100644 --- a/test/e2e/basic.test.ts +++ b/test/e2e/basic.test.ts @@ -25,16 +25,7 @@ async function createPage(path: string, options?: any, starveIdleCallbacks = fal const browser = await getBrowser() const page = await browser.newPage(options) pages.push(page) - if (starveIdleCallbacks) { - // Model a busy Chromium renderer: only callbacks with deadlines can run. - await page.addInitScript(() => { - const requestIdleCallback = window.requestIdleCallback.bind(window) - window.requestIdleCallback = (callback, options) => options?.timeout === undefined - ? 0 - : requestIdleCallback(callback, options) - }) - } - await page.addInitScript(installFixtureIdleDeadline) + await page.addInitScript(installFixtureIdleDeadline, { starveIdleCallbacks }) page.addListener('console', (msg) => { const location = `${parseURL(msg.location().url).pathname}:${msg.location().lineNumber}` if (!location.startsWith('/_nuxt')) { diff --git a/test/unit/release-plan.test.ts b/test/unit/release-plan.test.ts index e7b77a3c0..1da4e9ee7 100644 --- a/test/unit/release-plan.test.ts +++ b/test/unit/release-plan.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { planRelease } from '../../scripts/release-plan' +import { planPromotion, planRelease } from '../../scripts/release-plan' const versions = ['2.0.0-beta.16', '2.0.0-beta.16', '2.0.0-beta.16', '2.0.0-beta.16'] @@ -49,3 +49,33 @@ describe('release planning', () => { }) }) }) + +describe('release channel promotion', () => { + it.each(['2.0.0-beta.17', '2.0.0-beta.100', '2.1.0-beta.1', '3.0.0-beta.1', '2.0.0'])('skips a delayed release behind %s', (currentVersion) => { + expect(planPromotion('2.0.0-beta.16', [currentVersion])).toEqual({ _tag: 'Skip' }) + }) + + it.each(['2.0.0-beta.15', '2.0.0-beta.16', '1.9.9', '2.0.0-alpha.20', '2.0.0-beta.9'])('allows a release ahead of or equal to %s', (currentVersion) => { + expect(planPromotion('2.0.0-beta.16', [currentVersion])).toEqual({ _tag: 'Promote' }) + }) + + it('skips if any workspace channel is newer', () => { + expect(planPromotion('2.0.0-beta.16', ['2.0.0-beta.15', '2.0.0-beta.17', '2.0.0-beta.15'])).toEqual({ _tag: 'Skip' }) + }) + + it('rejects an unrecognised registry version', () => { + expect(planPromotion('2.0.0-beta.16', ['bad'])).toEqual({ _tag: 'Err', message: 'The channel version must use a supported release version.' }) + }) + + it('allows the first version in a new channel', () => { + expect(planPromotion('2.0.0-rc.1', [])).toEqual({ _tag: 'Promote' }) + }) + + it('promotes a stable release ahead of its release candidate', () => { + expect(planPromotion('2.0.0', ['2.0.0-rc.10'])).toEqual({ _tag: 'Promote' }) + }) + + it('rejects an invalid candidate', () => { + expect(planPromotion('bad', [])).toEqual({ _tag: 'Err', message: 'The release version must use a supported release version.' }) + }) +}) diff --git a/test/utils/setup-fixture.ts b/test/utils/setup-fixture.ts index ff83d88bd..b70c5e958 100644 --- a/test/utils/setup-fixture.ts +++ b/test/utils/setup-fixture.ts @@ -3,10 +3,15 @@ import { setup } from '@nuxt/test-utils/e2e' import { defu } from 'defu' /** Keep native idle scheduling, with a deadline for busy CI renderers. */ -export function installFixtureIdleDeadline(target: Pick = window) { +export function installFixtureIdleDeadline(options: { starveIdleCallbacks?: boolean } = {}) { + const target = window if (typeof target.requestIdleCallback !== 'function') return - const requestIdleCallback = target.requestIdleCallback.bind(target) + const nativeRequestIdleCallback = target.requestIdleCallback.bind(target) + // Install the busy-renderer model and deadline together. Init script order is undefined. + const requestIdleCallback: Window['requestIdleCallback'] = options.starveIdleCallbacks + ? (callback, options) => options?.timeout === undefined ? 0 : nativeRequestIdleCallback(callback, options) + : nativeRequestIdleCallback target.requestIdleCallback = (callback, options) => requestIdleCallback(callback, { timeout: 1000, ...options,