Repository navigation
chore(ci): automate releases and stabilize browser tests #956
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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' }, | ||
| 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`) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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`) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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' } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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.' }) | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.