Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 68 additions & 12 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.dist-tag }}
cancel-in-progress: false
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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

Expand All @@ -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 }}

Expand All @@ -43,16 +105,10 @@ 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: Check release channel
id: channel
run: node scripts/check-release-channel.ts

- name: Publish
run: pnpm publish -r --access public --no-git-checks --provenance ${{ steps.prerelease.outputs.tag }}
if: steps.channel.outputs.promote == 'true'
run: pnpm publish -r --access public --no-git-checks --provenance --tag "$RELEASE_DIST_TAG"
35 changes: 35 additions & 0 deletions scripts/check-release-channel.ts
Original file line number Diff line number Diff line change
@@ -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' },

Check failure on line 13 in scripts/check-release-channel.ts

View workflow job for this annotation

GitHub Actions / lint

Inconsistently quoted property 'accept' found
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`)
37 changes: 37 additions & 0 deletions scripts/prepare-release.ts
Original file line number Diff line number Diff line change
@@ -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`)
54 changes: 54 additions & 0 deletions scripts/release-plan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
type ReleaseTrigger
= { _tag: 'Merge', previousVersion: unknown }
| { _tag: 'Tag', tag: string }

type ReleasePlan
= { _tag: 'Skip' }
| { _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'
? /^(?: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' }
}
2 changes: 2 additions & 0 deletions test/e2e/_usercentrics-suite.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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',
Expand Down
13 changes: 11 additions & 2 deletions test/e2e/basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -20,11 +20,12 @@ 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)
await page.addInitScript(installFixtureIdleDeadline, { starveIdleCallbacks })
page.addListener('console', (msg) => {
const location = `${parseURL(msg.location().url).pathname}:${msg.location().lineNumber}`
if (!location.startsWith('/_nuxt')) {
Expand Down Expand Up @@ -73,6 +74,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
Expand Down
81 changes: 81 additions & 0 deletions test/unit/release-plan.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest'
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']

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,
})
})
})

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.' })
})
})
16 changes: 16 additions & 0 deletions test/utils/setup-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,22 @@ 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(options: { starveIdleCallbacks?: boolean } = {}) {
const target = window
if (typeof target.requestIdleCallback !== 'function')
return
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,
})
}

/**
* Compile settings that a production build applies and a fixture build does not.
*
Expand Down
Loading