From 5ac06fe83589fd2aacdc084003997bf6cb137927 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Fri, 2 Oct 2026 12:32:11 +0100 Subject: [PATCH] feat: add nightly publishing (#75) --- README.md | 74 ++++++++++++++++ pack/action.yml | 23 ++++- publish/action.yml | 19 +++- scripts/nightly.ts | 150 ++++++++++++++++++++++++++++++++ scripts/pack.ts | 18 +++- scripts/publish.ts | 8 +- test/nightly.test.ts | 202 +++++++++++++++++++++++++++++++++++++++++++ test/pack.test.ts | 37 +++++++- test/publish.test.ts | 24 ++++- 9 files changed, 547 insertions(+), 8 deletions(-) create mode 100644 scripts/nightly.ts create mode 100644 test/nightly.test.ts diff --git a/README.md b/README.md index 5a7f357..cb02dd2 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,9 @@ All subactions take a `node-version` input (default `24`; uppt needs `--experime | `install` | `true` | Set to `false` to handle `actions/setup-node` and dependency installation yourself (pinned package manager, cached `node_modules`, hardened install policy). The caller must then put `node`, `npm`, and any package manager on PATH first. | | `packages` | _(unset)_ | Must match the value passed to `uppt/pr`. | | `releases` | _(unset)_ | Independent-mode publish payload, from the workflow's `releases` dispatch input. Never set by hand. | +| `nightly` | `false` | Pack nightly builds of the current branch. See [Nightly releases](#nightly-releases). | +| `nightly-suffix` | `-nightly` | Suffix appended to package names for nightly builds. | +| `nightly-aliases` | _(unset)_ | External dependencies to point at their own nightlies. | | Output | Description | | --- | --- | @@ -197,6 +200,7 @@ All subactions take a `node-version` input (default `24`; uppt needs `--experime | `npm-tag` | _(derived)_ | npm dist-tag override for every tarball. By default stable versions publish to `latest`, prereleases to their identifier (`5.0.0-beta.0` → `beta`), bare-numeric prereleases (`5.0.0-0`) to `next`, and maintenance releases to `x` (see [Maintenance releases](#maintenance-releases)). | | `files` | _(scan artifact)_ | JSON array of tarball filenames, as emitted by `uppt/pack`. When omitted, every `*.tgz` in the artifact is published. | | `releases` | _(unset)_ | Independent-mode publish payload. Never set by hand. | +| `nightly` | `false` | Publish nightly builds with `npm publish` instead of staging them. See [Nightly releases](#nightly-releases). | ## Lifecycle scripts @@ -235,6 +239,76 @@ If your line uses a different dist-tag (`legacy`, `v3-latest`), or the release s It overrides the derivation, applies to every tarball in the run, and any value other than `latest` also keeps the GitHub release out of the "Latest" slot. +## Nightly releases + +uppt can also publish a nightly build of every push to a branch, under a separate package name (`@nuxt/test-utils` → `@nuxt/test-utils-nightly`). Nightlies skip the release PR and staging: they publish straight to npm via OIDC, so add a trusted publisher for each nightly package pointing at the workflow below, with 'Environment name' set to `nightly` (no `npm stage publish` permission needed). + +Create a matching `nightly` [GitHub environment](https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments) and limit its deployment branches to the branch(es) you publish nightlies from (e.g. `main`). Without approvals or staging, this is what stops a workflow run on any other ref from publishing. + +
+Nightly workflow + +```yaml +name: nightly + +on: + push: + branches: [main] + +permissions: {} + +jobs: + pack: + if: '!github.event.repository.fork' + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + files: ${{ steps.pack.outputs.files }} + steps: + - id: pack + uses: danielroe/uppt/pack@65a86313a63b10a6793de6c4ff8614b18e127a71 # v0.6.10 + with: + nightly: true + + publish: + needs: pack + runs-on: ubuntu-latest + concurrency: + group: nightly-${{ github.ref }} + cancel-in-progress: false + permissions: + id-token: write + environment: nightly + steps: + - uses: danielroe/uppt/publish@65a86313a63b10a6793de6c4ff8614b18e127a71 # v0.6.10 + with: + nightly: true + files: ${{ needs.pack.outputs.files }} +``` + +
+ +`uppt/pack` rewrites each package before packing it: + +- **Name**: `-nightly`. Set `nightly-suffix` to use something else (`-edge`). +- **Version**: `--`, e.g. `3.20.1-2605140905-a1b2c3d`. `` is the `X.Y.Z` part of the version in `package.json`, the timestamp is the HEAD commit's date in UTC, and `` is its 7-character short hash. Newer commits always sort higher. +- **Workspace dependencies**: in a monorepo (pass the same `packages` input), dependencies between listed packages become `npm:-nightly@`, so each nightly installs the other nightlies from the same commit. +- **External nightlies**: `nightly-aliases` points dependencies on packages from other repos at their own nightlies. +- **Bins**: every command gains a `-nightly` copy, plus one named after the package, so `npx -nightly` works. + +```yaml +- uses: danielroe/uppt/pack@65a86313a63b10a6793de6c4ff8614b18e127a71 # v0.6.10 + with: + nightly: true + packages: packages/* + nightly-aliases: | + nuxi + @nuxt/cli: @nuxt/cli-nightly@5x +``` + +A bare name aliases to `npm:-nightly@latest`. Nightlies publish to the `latest` dist-tag of the nightly package; set `npm-tag` on `uppt/publish` to publish a branch to another tag (`5x`). + ## Monorepo support For a lockstep monorepo (where every publishable package shares a single version and one `vX.Y.Z` tag), pass the same `packages:` input to `uppt/pr`, `uppt/release`, and `uppt/pack`: diff --git a/pack/action.yml b/pack/action.yml index 56672bf..1f6f3b2 100644 --- a/pack/action.yml +++ b/pack/action.yml @@ -27,6 +27,18 @@ inputs: description: 'Independent-mode publish payload: the ordered JSON array of `{ name, version, dir }` entries emitted by `uppt/release` and delivered via the workflow''s `releases` dispatch input. Never set by hand. When present, exactly those workspaces are packed, in that order, and the run must be on the matching `release-YYYY-MM-DD` coordination tag.' required: false default: '' + nightly: + description: 'Set to `true` to pack nightly builds of the current branch instead of a release tag. Each package is renamed to `` and versioned `--` from the HEAD commit, and dependencies between listed packages are pinned to their nightlies. Pair with `nightly: true` on `uppt/publish`.' + required: false + default: 'false' + nightly-suffix: + description: 'Suffix appended to every package name for nightly builds.' + required: false + default: '-nightly' + nightly-aliases: + description: 'Newline-separated dependencies from outside the repo to point at their own nightly builds. A bare name (`nuxi`) becomes `npm:nuxi@latest`; `: [@]` sets the target explicitly (`@nuxt/cli: @nuxt/cli-nightly@5x`). Applies to `dependencies` and `optionalDependencies`.' + required: false + default: '' outputs: files: @@ -41,6 +53,7 @@ runs: env: EVENT_NAME: ${{ github.event_name }} RELEASES: ${{ inputs.releases }} + NIGHTLY: ${{ inputs.nightly }} run: | set -euo pipefail case "$EVENT_NAME" in @@ -50,7 +63,12 @@ runs: exit 1 ;; esac - if [ -n "${RELEASES:-}" ]; then + if [ "${NIGHTLY:-}" = "true" ]; then + if [ "${GITHUB_REF:-}" = "${GITHUB_REF#refs/heads/}" ]; then + echo "::error::danielroe/uppt/pack with 'nightly: true' expected a 'refs/heads/*' ref, got '${GITHUB_REF:-}'." + exit 1 + fi + elif [ -n "${RELEASES:-}" ]; then if ! printf '%s' "${GITHUB_REF:-}" | grep -Eq '^refs/tags/release-[0-9]{4}-[0-9]{2}-[0-9]{2}(\.[0-9]+)?$'; then echo "::error::danielroe/uppt/pack got a 'releases' payload but the ref is not a 'refs/tags/release-YYYY-MM-DD' coordination tag (got '${GITHUB_REF:-}')." exit 1 @@ -115,6 +133,9 @@ runs: PACK_OUT_DIR: ${{ runner.temp }}/uppt-pack PACKAGES: ${{ inputs.packages }} RELEASES: ${{ inputs.releases }} + NIGHTLY: ${{ inputs.nightly }} + NIGHTLY_SUFFIX: ${{ inputs.nightly-suffix }} + NIGHTLY_ALIASES: ${{ inputs.nightly-aliases }} run: node --experimental-strip-types ${{ github.action_path }}/../scripts/pack.ts - name: Upload tarball artifact diff --git a/publish/action.yml b/publish/action.yml index 86d16ef..1f1f244 100644 --- a/publish/action.yml +++ b/publish/action.yml @@ -1,5 +1,5 @@ name: 'uppt/publish' -description: 'Stage-publish a prebuilt tarball to npm using OIDC trusted publishing.' +description: 'Stage-publish a prebuilt tarball to npm using OIDC trusted publishing, or publish nightly builds directly.' author: 'Daniel Roe' branding: @@ -27,6 +27,10 @@ inputs: description: 'Independent-mode publish payload: the ordered JSON array of `{ name, version, dir }` entries emitted by `uppt/release` and delivered via the workflow''s `releases` dispatch input. Never set by hand. When present, the run must be on the matching `release-YYYY-MM-DD` coordination tag, and (when `files` is omitted) tarballs are staged in the payload''s topological order.' required: false default: '' + nightly: + description: 'Set to `true` to publish nightly builds packed by `uppt/pack` with `nightly: true`. Runs `npm publish` instead of `npm stage publish` (no approval step), expects a `refs/heads/*` ref, and defaults the dist-tag to `latest`.' + required: false + default: 'false' runs: using: composite @@ -36,6 +40,7 @@ runs: env: EVENT_NAME: ${{ github.event_name }} RELEASES: ${{ inputs.releases }} + NIGHTLY: ${{ inputs.nightly }} run: | set -euo pipefail case "$EVENT_NAME" in @@ -45,7 +50,16 @@ runs: exit 1 ;; esac - if [ -n "${RELEASES:-}" ]; then + if [ "${NIGHTLY:-}" = "true" ]; then + if [ -n "${RELEASES:-}" ]; then + echo "::error::danielroe/uppt/publish cannot combine 'nightly: true' with a 'releases' payload." + exit 1 + fi + if [ "${GITHUB_REF:-}" = "${GITHUB_REF#refs/heads/}" ]; then + echo "::error::danielroe/uppt/publish with 'nightly: true' expected a 'refs/heads/*' ref, got '${GITHUB_REF:-}'." + exit 1 + fi + elif [ -n "${RELEASES:-}" ]; then if ! printf '%s' "${GITHUB_REF:-}" | grep -Eq '^refs/tags/release-[0-9]{4}-[0-9]{2}-[0-9]{2}(\.[0-9]+)?$'; then echo "::error::danielroe/uppt/publish got a 'releases' payload but the ref is not a 'refs/tags/release-YYYY-MM-DD' coordination tag (got '${GITHUB_REF:-}')." exit 1 @@ -80,4 +94,5 @@ runs: TARBALL_DIR: ${{ runner.temp }}/uppt-tarballs TARBALL_FILES: ${{ inputs.files }} RELEASES: ${{ inputs.releases }} + NIGHTLY: ${{ inputs.nightly }} run: node --experimental-strip-types ${{ github.action_path }}/../scripts/publish.ts diff --git a/scripts/nightly.ts b/scripts/nightly.ts new file mode 100644 index 0000000..73510a7 --- /dev/null +++ b/scripts/nightly.ts @@ -0,0 +1,150 @@ +// Rewrite package.json(s) into nightly builds before `uppt/pack` packs +// them: `@--`, with dependencies +// between listed packages pinned to the nightlies from the same commit. + +import { execFileSync } from 'node:child_process' +import { readFileSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' + +import { isValidPackageName } from './_independent.ts' +import { resolveWorkspaces } from './_workspaces.ts' +import { makePkgFormatter } from './pkg-format.ts' + +const ALL_DEPENDENCY_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'] as const +const ALIAS_FIELDS = ['dependencies', 'optionalDependencies'] as const + +const DEFAULT_SUFFIX = '-nightly' + +export function nightlyTimestamp (date: Date): string { + return date.toISOString().replace(/\D/g, '').slice(2, 12) +} + +export function nightlyVersion (version: string, date: Date, sha: string): string { + const core = version.match(/^\d+\.\d+\.\d+/)?.[0] + if (!core) throw new Error(`Invalid version: ${JSON.stringify(version)}`) + if (!/^[0-9a-f]{7,}$/.test(sha)) { + throw new Error(`Invalid commit sha: ${JSON.stringify(sha)}`) + } + return `${core}-${nightlyTimestamp(date)}-${sha.slice(0, 7)}` +} + +export function validateSuffix (suffix: string): string { + if (!/^[a-z0-9._-]+$/.test(suffix)) { + throw new Error(`Invalid nightly suffix ${JSON.stringify(suffix)}: expected lowercase alphanumerics, ".", "_" or "-".`) + } + return suffix +} + +export function nightlyName (name: string, suffix: string): string { + const renamed = `${name}${suffix}` + if (!isValidPackageName(renamed)) { + throw new Error(`Nightly package name ${JSON.stringify(renamed)} is not a valid npm package name.`) + } + return renamed +} + +/** Lines of `` or `: [@]`, mapped to `npm:` specifiers. */ +export function parseAliases (raw: string, suffix: string): Map { + const aliases = new Map() + for (const line of raw.split(/\r?\n/).map(l => l.replace(/#.*$/, '').trim()).filter(Boolean)) { + const match = line.match(/^(@?[^@:\s]+)(?:\s*:\s*(@?[^@:\s]+)(?:@([^@:\s]+))?)?$/) + if (!match) throw new Error(`Invalid alias line: ${JSON.stringify(line)}`) + const [, name, target = nightlyName(name!, suffix), range = 'latest'] = match + if (!isValidPackageName(name!) || !isValidPackageName(target)) { + throw new Error(`Invalid package name in alias line: ${JSON.stringify(line)}`) + } + aliases.set(name!, `npm:${target}@${range}`) + } + return aliases +} + +function unscoped (name: string): string { + return name.replace(/^@[^/]+\//, '') +} + +// `npx ` only picks a bin matching the unscoped package name when there are several. +export function nightlyBin (bin: unknown, name: string, renamed: string, suffix: string): Record | undefined { + if (typeof bin === 'string') bin = { [unscoped(name)]: bin } + if (!bin || typeof bin !== 'object') return undefined + const entries = Object.entries(bin as Record) + if (!entries.length) return undefined + const out: Record = { ...(bin as Record) } + for (const [command, path] of entries) out[`${command}${suffix}`] ??= path + out[unscoped(renamed)] ??= entries[0]![1] + return out +} + +export interface NightlyTarget { + name: string + version: string +} + +export function rewriteManifest ( + pkg: Record, + opts: { suffix: string, targets: Map, aliases: Map }, +): void { + const name = pkg.name as string + const self = opts.targets.get(name)! + pkg.name = self.name + pkg.version = self.version + const bin = nightlyBin(pkg.bin, name, self.name, opts.suffix) + if (bin) pkg.bin = bin + + for (const field of ALL_DEPENDENCY_FIELDS) { + const deps = pkg[field] as Record | undefined + if (!deps || typeof deps !== 'object') continue + for (const dep of Object.keys(deps)) { + const target = opts.targets.get(dep) + if (target) { + deps[dep] = `npm:${target.name}@${target.version}` + } + else if ((ALIAS_FIELDS as readonly string[]).includes(field) && opts.aliases.has(dep)) { + deps[dep] = opts.aliases.get(dep)! + } + } + } +} + +function git (...args: string[]): string { + return execFileSync('git', args, { encoding: 'utf8' }).trim() +} + +export function applyNightly (rootDir: string, opts: { packagesInput: string, suffix?: string, aliases?: string }): NightlyTarget[] { + const suffix = validateSuffix(opts.suffix || DEFAULT_SUFFIX) + const aliases = parseAliases(opts.aliases ?? '', suffix) + + const dirs = opts.packagesInput + ? resolveWorkspaces(rootDir, opts.packagesInput).map(ws => ws.dir) + : [rootDir] + const manifests = dirs.map((dir) => { + const path = resolve(dir, 'package.json') + const source = readFileSync(path, 'utf8') + return { path, source, pkg: JSON.parse(source) as Record } + }) + + const [sha, epoch] = git('log', '-1', '--format=%H %ct', 'HEAD').split(' ') + const date = new Date(Number(epoch) * 1000) + + const targets = new Map() + for (const { pkg } of manifests) { + if (typeof pkg.name !== 'string' || typeof pkg.version !== 'string') { + throw new Error(`Nightly builds need a "name" and "version" in every package.json (got ${JSON.stringify(pkg.name)}@${JSON.stringify(pkg.version)}).`) + } + if (pkg.private === true) { + throw new Error(`Refusing to publish a nightly of private package ${pkg.name}.`) + } + targets.set(pkg.name, { + name: nightlyName(pkg.name, suffix), + version: nightlyVersion(pkg.version, date, sha!), + }) + } + + for (const { path, source, pkg } of manifests) { + rewriteManifest(pkg, { suffix, targets, aliases }) + writeFileSync(path, makePkgFormatter(source)(pkg)) + } + + const result = [...targets.values()] + for (const target of result) console.log(`Nightly: ${target.name}@${target.version}`) + return result +} diff --git a/scripts/pack.ts b/scripts/pack.ts index 29b7c28..e520919 100644 --- a/scripts/pack.ts +++ b/scripts/pack.ts @@ -24,6 +24,9 @@ // workspaces are packed, in that order, and the ref // must be a `release-YYYY-MM-DD` coordination // tag instead of `vX.Y.Z`. +// NIGHTLY `true` to pack nightly builds of a `refs/heads/*` ref +// NIGHTLY_SUFFIX package name suffix +// NIGHTLY_ALIASES external dependencies to alias to their nightlies import process from 'node:process' import { execFileSync } from 'node:child_process' @@ -33,6 +36,7 @@ import { resolve } from 'node:path' import { runMain } from './_cli.ts' import { parseFilenames } from './_pack-json.ts' import { resolveWorkspaces } from './_workspaces.ts' +import { applyNightly } from './nightly.ts' import { COORDINATION_TAG_RE, releasesFromEnv, type ReleaseEntry } from './_independent.ts' function runCapture (cmd: string, args: string[], cwd: string): string { @@ -67,8 +71,15 @@ export function main () { const ref = process.env.GITHUB_REF ?? '' const releases = releasesFromEnv(process.env.RELEASES) + const nightly = process.env.NIGHTLY === 'true' const tag = ref.startsWith('refs/tags/') ? ref.slice('refs/tags/'.length) : '' - if (releases) { + if (nightly) { + if (releases) throw new Error('RELEASES cannot be combined with NIGHTLY') + if (!ref.startsWith('refs/heads/')) { + throw new Error(`NIGHTLY is set, so GITHUB_REF must be a 'refs/heads/*' branch, got '${ref || ''}'`) + } + } + else if (releases) { if (!COORDINATION_TAG_RE.test(tag)) { throw new Error(`RELEASES is set, so GITHUB_REF must be a 'refs/tags/release-YYYY-MM-DD' coordination tag, got '${ref || ''}'`) } @@ -83,6 +94,9 @@ export function main () { const hasPnpmLock = existsSync(resolve(process.cwd(), 'pnpm-lock.yaml')) const packagesInput = process.env.PACKAGES?.trim() ?? '' + if (nightly) { + applyNightly(process.cwd(), { packagesInput, suffix: process.env.NIGHTLY_SUFFIX?.trim(), aliases: process.env.NIGHTLY_ALIASES }) + } const targets = releases ? releaseTargets(process.cwd(), releases) : packagesInput.length @@ -109,7 +123,7 @@ export function main () { throw new Error(`Pack tool reported '${filename}' but it is not present in ${outDir}`) } const size = statSync(tarballPath).size - console.log(`Packed ${filename} (${size} bytes) for ${tag}`) + console.log(`Packed ${filename} (${size} bytes) for ${tag || ref}`) } const githubOutput = process.env.GITHUB_OUTPUT diff --git a/scripts/publish.ts b/scripts/publish.ts index b9225a6..68ad49d 100644 --- a/scripts/publish.ts +++ b/scripts/publish.ts @@ -30,6 +30,8 @@ // independent mode: an array of // `{ name, version, dir }`. Names the tarballs to // stage when TARBALL_FILES is absent. +// NIGHTLY `true` to `npm publish` nightlies (dist-tag +// `latest` by default) instead of staging import process from 'node:process' import { execFileSync } from 'node:child_process' @@ -111,6 +113,10 @@ export function main () { if (!existsSync(dir)) throw new Error(`TARBALL_DIR does not exist: ${dir}`) const releases = releasesFromEnv(process.env.RELEASES) + const nightly = process.env.NIGHTLY === 'true' + if (nightly && releases) throw new Error('RELEASES cannot be combined with NIGHTLY') + const command = nightly ? ['publish'] : ['stage', 'publish'] + const defaultTag = nightly ? (tagOverride ?? 'latest') : tagOverride const filesEnv = process.env.TARBALL_FILES?.trim() let tarballs: string[] @@ -135,7 +141,7 @@ export function main () { if (!existsSync(tarballPath)) { throw new Error(`Tarball '${tarball}' is not present in ${dir}`) } - run('npm', ['stage', 'publish', tarballPath, '--provenance', '--ignore-scripts', `--access=${access}`, `--tag=${tagFor(tarball, tagOverride)}`]) + run('npm', [...command, tarballPath, '--provenance', '--ignore-scripts', `--access=${access}`, `--tag=${tagFor(tarball, defaultTag)}`]) } } diff --git a/test/nightly.test.ts b/test/nightly.test.ts new file mode 100644 index 0000000..deae53d --- /dev/null +++ b/test/nightly.test.ts @@ -0,0 +1,202 @@ +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { resolve } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const execFileSync = vi.hoisted(() => vi.fn()) +vi.mock('node:child_process', () => ({ execFileSync })) + +const { + applyNightly, + nightlyBin, + nightlyName, + nightlyTimestamp, + nightlyVersion, + parseAliases, + rewriteManifest, + validateSuffix, +} = await import('../scripts/nightly.ts') + +// https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string +const STRICT_SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/ + +const SHA = '0123456789abcdef0123456789abcdef01234567' +const EPOCH = Date.UTC(2026, 4, 14, 9, 5, 42) / 1000 + +function mockGit () { + execFileSync.mockReturnValue(`${SHA} ${EPOCH}`) +} + +let root: string + +function writePkg (dir: string, pkg: Record) { + mkdirSync(resolve(root, dir), { recursive: true }) + writeFileSync(resolve(root, dir, 'package.json'), `${JSON.stringify(pkg, null, 2)}\n`) +} + +const readPkg = (dir: string) => JSON.parse(readFileSync(resolve(root, dir, 'package.json'), 'utf8')) + +beforeEach(() => { + root = realpathSync(mkdtempSync(resolve(tmpdir(), 'uppt-nightly-'))) + vi.spyOn(console, 'log').mockImplementation(() => {}) +}) + +afterEach(() => { + execFileSync.mockReset() + vi.restoreAllMocks() +}) + +describe('nightlyTimestamp', () => { + it('formats the date as YYMMDDHHmm in UTC', () => { + expect(nightlyTimestamp(new Date(EPOCH * 1000))).toBe('2605140905') + }) +}) + +describe('nightlyVersion', () => { + const date = new Date(EPOCH * 1000) + + it('appends timestamp and short sha to the version core', () => { + expect(nightlyVersion('1.2.3', date, SHA)).toBe('1.2.3-2605140905-0123456') + expect(nightlyVersion('5.0.0-alpha.1', date, SHA)).toBe('5.0.0-2605140905-0123456') + }) + + it('produces valid semver for an all-digit sha with a leading zero', () => { + expect(nightlyVersion('1.2.3', date, '0123456aaaa')).toMatch(STRICT_SEMVER) + }) + + it('sorts chronologically as plain strings', () => { + const versions = [ + nightlyVersion('1.2.3', new Date(Date.UTC(2026, 0, 2)), 'fffffff'), + nightlyVersion('1.2.3', new Date(Date.UTC(2026, 0, 1, 23, 59)), '0000000'), + nightlyVersion('1.2.3', new Date(Date.UTC(2026, 9, 1)), 'aaaaaaa'), + ] + expect([...versions].sort()).toEqual([versions[1], versions[0], versions[2]]) + }) + + it('rejects a malformed sha', () => { + expect(() => nightlyVersion('1.2.3', date, 'nope')).toThrow(/Invalid commit sha/) + expect(() => nightlyVersion('latest', date, SHA)).toThrow(/Invalid version/) + }) +}) + +describe('validateSuffix / nightlyName', () => { + it('accepts and applies a suffix', () => { + expect(validateSuffix('-canary')).toBe('-canary') + expect(nightlyName('@nuxt/test-utils', '-nightly')).toBe('@nuxt/test-utils-nightly') + }) + + it('rejects unsafe suffixes and names', () => { + expect(() => validateSuffix('-Nightly')).toThrow(/Invalid nightly suffix/) + expect(() => validateSuffix('')).toThrow(/Invalid nightly suffix/) + expect(() => nightlyName('pkg', '~~/')).toThrow(/not a valid npm package name/) + }) +}) + +describe('parseAliases', () => { + it('parses bare names and explicit targets', () => { + expect(parseAliases(` + nuxi + # comment + @nuxt/cli: @nuxt/cli-nightly@5x + h3: h3-next + `, '-nightly')).toEqual(new Map([ + ['nuxi', 'npm:nuxi-nightly@latest'], + ['@nuxt/cli', 'npm:@nuxt/cli-nightly@5x'], + ['h3', 'npm:h3-next@latest'], + ])) + }) + + it('rejects malformed lines', () => { + expect(() => parseAliases('a b', '-nightly')).toThrow(/Invalid alias line/) + expect(() => parseAliases('Foo: bar', '-nightly')).toThrow(/Invalid package name/) + }) +}) + +describe('nightlyBin', () => { + it('adds suffixed commands and one matching the renamed package', () => { + expect(nightlyBin({ nuxi: './a.mjs', nuxt: './b.mjs' }, '@nuxt/cli', '@nuxt/cli-nightly', '-nightly')).toEqual({ + 'nuxi': './a.mjs', + 'nuxt': './b.mjs', + 'nuxi-nightly': './a.mjs', + 'nuxt-nightly': './b.mjs', + 'cli-nightly': './a.mjs', + }) + }) + + it('keeps the original command for a string bin', () => { + expect(nightlyBin('./cli.mjs', 'nuxi', 'nuxi-nightly', '-nightly')).toEqual({ + 'nuxi': './cli.mjs', + 'nuxi-nightly': './cli.mjs', + }) + }) + + it('ignores missing or empty bins', () => { + expect(nightlyBin(undefined, 'a', 'a-nightly', '-nightly')).toBeUndefined() + expect(nightlyBin({}, 'a', 'a-nightly', '-nightly')).toBeUndefined() + }) +}) + +describe('rewriteManifest', () => { + it('renames, versions, and pins internal and aliased dependencies', () => { + const pkg: Record = { + name: 'a', + version: '1.0.0', + dependencies: { 'b': 'workspace:*', 'nuxi': 'npm:nuxi@^3.0.0', 'c': 'npm:other@1', 'left-pad': '1' }, + devDependencies: { b: 'npm:b@^1.0.0', nuxi: '^3.0.0' }, + peerDependencies: { b: 'workspace:*' }, + optionalDependencies: { nuxi: '^3.0.0' }, + } + rewriteManifest(pkg, { + suffix: '-nightly', + targets: new Map([ + ['a', { name: 'a-nightly', version: '1.0.1-x' }], + ['b', { name: 'b-nightly', version: '1.0.1-x' }], + ]), + aliases: new Map([['nuxi', 'npm:nuxi-nightly@latest']]), + }) + expect(pkg).toEqual({ + name: 'a-nightly', + version: '1.0.1-x', + dependencies: { 'b': 'npm:b-nightly@1.0.1-x', 'nuxi': 'npm:nuxi-nightly@latest', 'c': 'npm:other@1', 'left-pad': '1' }, + devDependencies: { b: 'npm:b-nightly@1.0.1-x', nuxi: '^3.0.0' }, + peerDependencies: { b: 'npm:b-nightly@1.0.1-x' }, + optionalDependencies: { nuxi: 'npm:nuxi-nightly@latest' }, + }) + }) +}) + +describe('applyNightly', () => { + it('rewrites the root package', () => { + mockGit() + writePkg('.', { name: 'pkg', version: '1.2.3', bin: './cli.mjs' }) + expect(applyNightly(root, { packagesInput: '' })).toEqual([{ name: 'pkg-nightly', version: '1.2.3-2605140905-0123456' }]) + expect(readPkg('.')).toEqual({ + name: 'pkg-nightly', + version: '1.2.3-2605140905-0123456', + bin: { 'pkg': './cli.mjs', 'pkg-nightly': './cli.mjs' }, + }) + }) + + it('rewrites every listed workspace with a custom suffix and aliases', () => { + mockGit() + writePkg('packages/a', { name: '@scope/a', version: '1.2.3', dependencies: { '@scope/b': 'workspace:*', 'nuxi': '^3' } }) + writePkg('packages/b', { name: '@scope/b', version: '1.2.3' }) + writePkg('packages/c', { name: 'c', private: true, version: '0.0.0' }) + applyNightly(root, { packagesInput: 'packages/*', suffix: '-edge', aliases: 'nuxi' }) + expect(readPkg('packages/a')).toEqual({ + name: '@scope/a-edge', + version: '1.2.3-2605140905-0123456', + dependencies: { '@scope/b': 'npm:@scope/b-edge@1.2.3-2605140905-0123456', 'nuxi': 'npm:nuxi-edge@latest' }, + }) + expect(readPkg('packages/b').name).toBe('@scope/b-edge') + expect(readPkg('packages/c').name).toBe('c') + }) + + it('refuses a private root or a package without a version', () => { + mockGit() + writePkg('.', { name: 'pkg', version: '1.2.3', private: true }) + expect(() => applyNightly(root, { packagesInput: '' })).toThrow(/private package pkg/) + writePkg('.', { name: 'pkg' }) + expect(() => applyNightly(root, { packagesInput: '' })).toThrow(/need a "name" and "version"/) + }) +}) diff --git a/test/pack.test.ts b/test/pack.test.ts index 00b211b..0fcdce6 100644 --- a/test/pack.test.ts +++ b/test/pack.test.ts @@ -9,7 +9,7 @@ vi.mock('node:child_process', () => ({ execFileSync })) const { main } = await import('../scripts/pack.ts') -const PACK_ENV = ['GITHUB_REF', 'RELEASES', 'PACK_OUT_DIR', 'PACKAGES', 'GITHUB_OUTPUT'] as const +const PACK_ENV = ['GITHUB_REF', 'RELEASES', 'PACK_OUT_DIR', 'PACKAGES', 'GITHUB_OUTPUT', 'NIGHTLY', 'NIGHTLY_SUFFIX', 'NIGHTLY_ALIASES'] as const let env: NodeJS.ProcessEnv let cwd: string @@ -157,4 +157,39 @@ describe('pack', () => { expect(() => main()).toThrow(/does not match/) }) }) + + describe('nightly mode', () => { + beforeEach(() => { + process.env.NIGHTLY = 'true' + process.env.GITHUB_REF = 'refs/heads/main' + }) + + it('rewrites the package before packing it', () => { + process.env.NIGHTLY_SUFFIX = '-edge' + process.env.NIGHTLY_ALIASES = 'nuxi' + writePkg(root, { name: 'root-pkg', version: '1.2.3', dependencies: { nuxi: '^3' } }) + const packed = vi.fn() + execFileSync.mockImplementation((cmd: string) => { + if (cmd === 'git') return '0123456789abcdef 1778749542' + packed(JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8'))) + const filename = 'root-pkg-edge-1.2.3-2605140905-0123456.tgz' + writeFileSync(resolve(outDir, filename), 'tarball') + return `{"filename":"${filename}"}` + }) + main() + expect(packed).toHaveBeenCalledWith({ name: 'root-pkg-edge', version: '1.2.3-2605140905-0123456', dependencies: { nuxi: 'npm:nuxi-edge@latest' } }) + }) + + it('requires a branch ref', () => { + process.env.GITHUB_REF = 'refs/tags/v1.2.3' + expect(() => main()).toThrow(/must be a 'refs\/heads\/\*' branch/) + delete process.env.GITHUB_REF + expect(() => main()).toThrow(/got ''/) + }) + + it('cannot be combined with RELEASES', () => { + process.env.RELEASES = JSON.stringify([{ name: 'root-pkg', version: '1.2.3', dir: '.' }]) + expect(() => main()).toThrow(/RELEASES cannot be combined with NIGHTLY/) + }) + }) }) diff --git a/test/publish.test.ts b/test/publish.test.ts index e0988df..ccaccf2 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -23,7 +23,7 @@ let env: NodeJS.ProcessEnv beforeEach(() => { env = { ...process.env } - for (const key of ['NPM_ACCESS', 'NPM_TAG', 'TARBALL_DIR', 'TARBALL_FILES', 'RELEASES']) delete process.env[key] + for (const key of ['NPM_ACCESS', 'NPM_TAG', 'TARBALL_DIR', 'TARBALL_FILES', 'RELEASES', 'NIGHTLY']) delete process.env[key] vi.spyOn(console, 'log').mockImplementation(() => {}) }) @@ -120,6 +120,28 @@ describe('publish', () => { expect(() => main()).toThrow(/Tarball 'a-1\.0\.0\.tgz' is not present/) }) + it('publishes nightlies directly to latest', () => { + process.env.NIGHTLY = 'true' + process.env.TARBALL_DIR = fixture(['a-nightly-1.0.1-2605140905-0123456.tgz']) + main() + expect(npmArgs()[0]).toEqual(['publish', resolve(process.env.TARBALL_DIR, 'a-nightly-1.0.1-2605140905-0123456.tgz'), '--provenance', '--ignore-scripts', '--access=public', '--tag=latest']) + }) + + it('publishes nightlies to an overridden dist-tag', () => { + process.env.NIGHTLY = 'true' + process.env.NPM_TAG = '5x' + process.env.TARBALL_DIR = fixture(['a-nightly-1.0.1-2605140905-0123456.tgz']) + main() + expect(npmArgs()[0]).toContain('--tag=5x') + }) + + it('refuses to combine nightlies with RELEASES', () => { + process.env.NIGHTLY = 'true' + process.env.TARBALL_DIR = fixture([]) + process.env.RELEASES = JSON.stringify([{ name: 'a', version: '1.0.0', dir: '.' }]) + expect(() => main()).toThrow(/RELEASES cannot be combined with NIGHTLY/) + }) + it('stages prereleases under the prerelease identifier', () => { process.env.TARBALL_DIR = fixture(['a-5.0.0-beta.0.tgz']) main() -- 2.51.2