Skip to content
Open
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
26 changes: 24 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@ on:
workflow_dispatch:
inputs:
branch:
description: 'Branch to release from'
description: 'Branch to release from (must be a protected release branch)'
required: true
default: 'next'
type: string
type: choice
options:
- next
- master
version_type:
description: 'Version bump type'
required: true
Expand Down Expand Up @@ -55,6 +58,25 @@ jobs:
id-token: write

steps:
# The `branch` choice is not enforced for API-triggered dispatches, so
# re-validate it here and require the workflow to be dispatched from the
# very branch it releases (dispatch ref == release branch). This is the
# load-bearing guard against releasing an arbitrary ref.
- name: Guard release branch
env:
INPUT_BRANCH: ${{ github.event.inputs.branch }}
DISPATCH_REF: ${{ github.ref_name }}
run: |
set -euo pipefail
case "$INPUT_BRANCH" in
next|master) ;;
*) echo "::error::Disallowed release branch '$INPUT_BRANCH' (allowed: next, master)"; exit 1 ;;
esac
if [ "$DISPATCH_REF" != "$INPUT_BRANCH" ]; then
echo "::error::Dispatched from '$DISPATCH_REF' but branch input is '$INPUT_BRANCH'. Re-run 'Use workflow from' set to '$INPUT_BRANCH'."
exit 1
fi

- name: Checkout
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:
Expand Down
41 changes: 37 additions & 4 deletions scripts/publish.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import path from 'node:path';

import {
applyPublishConfigOverrides,
assertValidNpmPackageName,
getCatalogs,
getPublishablePackages,
getWorkspaceVersionMap,
Expand All @@ -48,6 +49,28 @@ const versionMap = getWorkspaceVersionMap(baseDir);
const catalogs = getCatalogs(baseDir);
const npmBin = process.platform === 'win32' ? 'npm.cmd' : 'npm';

// Reject any malformed package name before it reaches `npm publish` (a typo'd
// name would otherwise publish a brand-new bogus package).
for (const pkg of packages) {
assertValidNpmPackageName(pkg.name);
}

// Never let a prerelease version land on the `latest` dist-tag (that would make
// it the default install for every consumer). The release workflow derives the
// tag, but this is the last line of defence regardless of how it was invoked.
if (npmTag === 'latest') {
const prereleases = packages.filter((pkg) => pkg.version.includes('-'));
if (prereleases.length > 0) {
const sample = prereleases
.slice(0, 5)
.map((pkg) => `${pkg.name}@${pkg.version}`)
.join(', ');
const more = prereleases.length > 5 ? ` (+${prereleases.length - 5} more)` : '';
console.error(`❌ Refusing to publish prerelease version(s) to the "latest" tag: ${sample}${more}`);
process.exit(1);
}
}

console.log(
`📦 Publishing ${packages.length} packages (tag: ${npmTag}${isDryRun ? ', dry-run' : ''}${useProvenance ? ', provenance' : ''})`,
);
Expand All @@ -63,9 +86,16 @@ function isPublished(name, version) {
timeout: 15000,
}).trim();
return result === version;
} catch {
// Could be 404 (not published) or network error.
// Either way, we should attempt to publish.
} catch (err) {
const stderr = String(err?.stderr ?? '');
// A genuine 404 means the version is not published yet — expected, stay quiet.
// Anything else (network, 5xx, auth) is indeterminate: we cannot confirm, so
// warn loudly. We still return false, but npm's version immutability prevents
// an accidental overwrite if a publish is then attempted.
if (!/E404|404 Not Found/i.test(stderr)) {
const detail = stderr.split('\n')[0] || (err instanceof Error ? err.message : String(err));
console.warn(` ⚠️ could not verify ${name}@${version} on npm: ${detail}`);
}
return false;
}
}
Expand Down Expand Up @@ -98,7 +128,10 @@ function publishOne(pkg) {
execFileSync(npmBin, publishArgs, {
cwd: packageDir,
stdio: 'inherit',
env: { ...process.env, NPM_CONFIG_LOGLEVEL: 'verbose' },
// Verbose logging only on dry-run. On a real publish, force a non-verbose
// level so an inherited NPM_CONFIG_LOGLEVEL=verbose can't surface auth
// headers in CI logs.
env: { ...process.env, NPM_CONFIG_LOGLEVEL: isDryRun ? 'verbose' : 'notice' },
timeout: 120000,
});
} finally {
Expand Down
27 changes: 27 additions & 0 deletions scripts/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,33 @@ const PUBLISH_CONFIG_OVERRIDE_FIELDS = [
'os',
];

// Valid npm package name (scoped or unscoped). Names that fail this are
// rejected before they reach a git command (release commit message) or an
// `npm publish` invocation — defence in depth against a malicious or
// malformed `name` field smuggling shell metacharacters or a typo'd package.
const NPM_NAME_RE = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;

/**
* Whether `name` is a syntactically valid npm package name (scoped or unscoped,
* 1-214 chars, lowercase, URL-safe).
* @param {unknown} name
* @returns {boolean}
*/
export function isValidNpmPackageName(name) {
return typeof name === 'string' && name.length > 0 && name.length <= 214 && NPM_NAME_RE.test(name);
}

/**
* Throw if `name` is not a valid npm package name. Used to reject a malicious or
* typo'd `name` before it reaches a git command or `npm publish`.
* @param {unknown} name
*/
export function assertValidNpmPackageName(name) {
if (!isValidNpmPackageName(name)) {
throw new Error(`Invalid npm package name: ${JSON.stringify(name)}`);
}
}

function readWorkspaceConfig(baseDir) {
const workspaceFile = path.join(baseDir, 'pnpm-workspace.yaml');

Expand Down
12 changes: 7 additions & 5 deletions scripts/version.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
#!/usr/bin/env node

import { execSync } from 'node:child_process';
import { execFileSync, execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

import semver from 'semver';

import { getPublishablePackages } from './utils.js';
import { assertValidNpmPackageName, getPublishablePackages } from './utils.js';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
Expand Down Expand Up @@ -67,6 +67,7 @@ packageFolders.forEach(({ folder, directory }) => {

if (fs.existsSync(packageJsonPath)) {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
assertValidNpmPackageName(packageJson.name);
const originalContent = JSON.stringify(packageJson, null, 2) + '\n';

if (!isDryRun) {
Expand Down Expand Up @@ -135,14 +136,15 @@ try {

${updatedVersions.map((pkg) => `- ${pkg.name}@${pkg.newVersion}`).join('\n')}`;

// Commit changes
// Commit changes. Pass the message as an argv entry (execFileSync), never as
// an interpolated shell string — the message embeds package names.
console.log('\n💾 Creating version commit...');
execSync(`git commit -m "${commitMessage}"`, { stdio: 'inherit' });
execFileSync('git', ['commit', '-m', commitMessage], { stdio: 'inherit' });

// Create tag using the main egg version
const tagName = `v${eggVersion}`;
console.log(`\n🏷️ Creating tag ${tagName}...`);
execSync(`git tag ${tagName}`, { stdio: 'inherit' });
execFileSync('git', ['tag', tagName], { stdio: 'inherit' });

console.log('\n✅ Version bump complete!');
console.log(`\nTo publish, push the changes and tag:`);
Expand Down
Loading
Loading