Skip to content
Draft
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
24 changes: 17 additions & 7 deletions .github/workflows/0-test-npm-publish-split.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,6 @@ jobs:
exit 1
fi

# The real-npm tests in publish.test.ts skip below npm 11; pin the
# version the publish-tarballs action installs.
- name: npm 11
run: |
npm install --global "npm@$(yq '.inputs.npm-version.default' ../publish-tarballs/action.yaml)"
npm --version

- name: Tests (pnpm from packageManager)
run: pnpm exec vitest run

Expand All @@ -73,6 +66,23 @@ jobs:
pnpm --version
./node_modules/.bin/vitest run test/pack.test.ts

- name: pnpm version of publish-tarballs
id: pnpm-upload
run: echo "version=$(yq '.inputs.pnpm-version.default' ../publish-tarballs/action.yaml)" >> "${GITHUB_OUTPUT}"

# Same setup as publish-tarballs; the real-pnpm tests need pnpm 12+.
- uses: ./actions/node/setup-pnpm
with:
version: ${{ steps.pnpm-upload.outputs.version }}
working-directory: ${{ runner.temp }}

- name: Tests (pnpm of publish-tarballs)
env:
REQUIRE_REAL_PNPM: '1'
run: |
(cd "${RUNNER_TEMP}" && pnpm --version)
./node_modules/.bin/vitest run

- name: Install bats
run: sudo apt-get update && sudo apt-get install -y bats

Expand Down
53 changes: 37 additions & 16 deletions actions/node/npm-split-publish/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ npm trusted publishing (OIDC) does not accept self-hosted runners, so
unpublished non-private package, in dependency order, it runs
`prepublishOnly`, `prepublish` and `pnpm pack`. Packages with `publish` or
`postpublish` scripts are refused: those would run after the upload. `publish` uploads the tarballs with
`npm publish <tarball> --ignore-scripts`. The two halves share
`manifest.jsonl`, typed as `ManifestEntry` in `src/manifest.ts`.
`pnpm publish <tarball>` (pnpm 11+ publishes natively, with OIDC and
provenance). Registry lookups use `pnpm config get` and `pnpm view`; only
pnpm 9 and 10 run npm for `pnpm view`, so the upload never needs npm. The two halves share `manifest.jsonl`, typed as `ManifestEntry`
in `src/manifest.ts`.

## Manifest Format (Public)

Expand Down Expand Up @@ -51,22 +53,37 @@ Validation: `src/manifest.ts` (`validateEntry`).
runs when a package declares a different repository (for example after a
rename). Private repositories and GitHub Packages targets are left alone, so
no private repository URL gets published.
- **Dist-tag.** Every upload passes `--tag`: `publishConfig.tag`, else `latest`.
npm 11 refuses a prerelease or a version below the highest published one
without an explicit tag; the previous path (npm 10 under pnpm) tagged those
`latest`, so that stays the behaviour. Trade-off: if two releases of one
- **Dist-tag.** An upload passes `--tag` only for `publishConfig.tag`; pnpm
ignores a packed `publishConfig.tag` and defaults to `latest`, also for a
prerelease or a version below the highest published one, as before. A
packed top-level `tag` wins over `--tag` in pnpm 12, so it must equal the
tag the upload uses. Trade-off: if two releases of one
repository overlap and their uploads finish out of order, `latest` can move
back to the older version without an error (the old path could too, with a
shorter window).
- **The upload job does not trust the artifact.** Every manifest value is
validated; each tarball's manifest is read the way npm reads it (extracted
with node-tar and pacote's options, `src/tarball.ts`) and must have the
entry's name and version; a packed `publishConfig`
may not set auth, proxy or scope-registry keys, nor a different `registry`
(npm applies those packed keys that are not given on the command line).
Values are passed as `--flag=value`, and npm runs from an empty directory so
no `.npmrc` from the artifact is read. `GITHUB_TOKEN` is sent only to
npm.pkg.github.com.
validated; each tarball's manifest is read the way pnpm reads it (the last
entry named `package/package.json`, `src/tarball.ts`), and archives where tar
readers can disagree (duplicate manifests, data after the end marker, a
second gzip member, pax `size`, global pax headers, more than 2 GiB
unpacked) are refused. This mirrors pnpm 12, so the upload fails before the
first package with an older pnpm. The manifest must have the entry's name
and version; a packed `publishConfig` may not set `@<scope>:registry` nor a
different `registry` (pnpm 12 prefers both to `--registry`; of the packed
`publishConfig` it also reads `access`, which a flag overrides). Values are
passed as `--flag=value`, and pnpm runs from an empty temporary directory,
so it reads no `.npmrc` from the artifact and skips its git checks.
`GITHUB_TOKEN` is sent only to npm.pkg.github.com.
- **pnpm on the upload runner.** `publish-tarballs` installs pnpm
`pnpm-version` with `setup-pnpm` (`pnpm/setup`) in `$RUNNER_TEMP`, without the caller's
`packageManager` or `devEngines`, and puts it first on `PATH` for the rest
of the job (`PATH`, `PNPM_HOME`). Its own script runs on the runner's
Node. `pnpm/setup` is a node24 action: a self-hosted runner needs a runner
version with node24.
- **Registry auth while packing.** The lookups use pnpm's config at
GITHUB_WORKSPACE, as `pnpm install` does. pnpm 11.5.3+ ignores `${VAR}`
tokens in a project `.npmrc`; such repositories keep registry auth in a
user-level `.npmrc`.
- **Fallback token.** With `npm-token-fallback`, the upload job takes
`NPMJS_TOKEN` out of the caller's `env` secret and masks it before any step
receives it; no other caller secret reaches that runner.
Expand Down Expand Up @@ -94,5 +111,9 @@ The actions run the committed `lib/`; CI (`0-test-npm-publish-split.yaml`)
fails when it does not match a fresh build.

`test/pack.test.ts` runs the real `pnpm` from PATH on `test/fixtures/workspace`
and fakes only `npm view`. CI runs it with pnpm 9.12 (what block repos pin) and
with the repo's pnpm. Other tests replace pnpm/npm with a fake `Runner`.
and fakes only `pnpm view`. CI runs it with pnpm 9.12 (what block repos pin),
the repo's pnpm, and the pnpm of `publish-tarballs`. `test/tarball.test.ts`
and the real-pnpm case in `test/publish.test.ts` compare with
`pnpm publish --dry-run` and skip below pnpm 12 (CI sets
`REQUIRE_REAL_PNPM=1`, which turns the skip into a failure). Other tests replace pnpm with
a fake `Runner`.
6 changes: 3 additions & 3 deletions actions/node/npm-split-publish/lib/pack/index.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion actions/node/npm-split-publish/lib/pack/index.js.map

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions actions/node/npm-split-publish/lib/publish/index.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion actions/node/npm-split-publish/lib/publish/index.js.map

Large diffs are not rendered by default.

5 changes: 2 additions & 3 deletions actions/node/npm-split-publish/package.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
{
"name": "@milaboratories/github-ci-npm-split-publish",
"private": true,
"description": "Split npm publish: pack on the build runner, upload from a GitHub-hosted runner with npm trusted publishing.",
"description": "Split npm publish: pack on the build runner, upload with pnpm from a GitHub-hosted runner with npm trusted publishing.",
"scripts": {
"build": "rm -rf lib dist && tsc && ncc build dist/bin/pack.js --minify --out lib/pack --source-map && ncc build dist/bin/publish.js --minify --out lib/publish --source-map && ncc build dist/bin/prepare.js --minify --out lib/prepare --source-map",
"test": "vitest run"
},
"dependencies": {
"@actions/core": "catalog:",
"tar": "7.5.22"
"@actions/core": "catalog:"
},
"devDependencies": {
"@types/node": "catalog:",
Expand Down
6 changes: 3 additions & 3 deletions actions/node/npm-split-publish/src/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ export interface ManifestEntry {
registry: string;
/**
* publishConfig.tag / publishConfig.access, '' when unset. The upload passes
* them as flags: npm applies the tarball's publishConfig only for keys not
* given on the command line, so the flags are what decides.
* them as flags: pnpm ignores a packed publishConfig.tag, and a flag wins
* over a packed publishConfig.access.
*/
tag: string;
access: string;
Expand All @@ -26,7 +26,7 @@ export interface ManifestEntry {
const FIELDS: (keyof ManifestEntry)[] = ['name', 'version', 'file', 'registry', 'tag', 'access'];

// The manifest crosses from the build job to the upload job as an artifact,
// so the upload job does not trust it: every value ends up in npm's argv and
// so the upload job does not trust it: every value ends up in pnpm's argv and
// must not be readable as an option.
const NAME = /^(@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/;
const VERSION = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/;
Expand Down
79 changes: 40 additions & 39 deletions actions/node/npm-split-publish/src/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import * as path from 'path';

import { ManifestEntry, readManifest } from './manifest';
import { isPublished } from './registry';
import { CommandError, Runner } from './run';
import { CommandError, Runner, runChecked } from './run';
import { readPackedManifest } from './tarball';

export const NPMJS_HOST = 'registry.npmjs.org';
Expand All @@ -18,8 +18,8 @@ export interface Credentials {
/** Token for npm.pkg.github.com (the job's GITHUB_TOKEN). Sent nowhere else. */
npmAuthToken?: string;
/**
* Legacy registry.npmjs.org token. npm tries OIDC first and uses it only if
* the exchange fails. Leave empty to require trusted publishing.
* Legacy registry.npmjs.org token. pnpm tries OIDC first and uses it only
* if the exchange fails. Leave empty to require trusted publishing.
*/
npmjsFallbackToken?: string;
}
Expand All @@ -29,7 +29,7 @@ export interface PublishOptions extends Credentials {
dir: string;
/** Adds `--access public` to every upload, as `pnpm -r publish --access public` did. */
accessPublic: boolean;
/** npm config file written for the uploads; nothing else is read. */
/** Auth file (.npmrc format) written for the uploads; nothing else is read. */
userconfig: string;
}

Expand All @@ -39,16 +39,19 @@ export interface PublishDeps {
group: <T>(title: string, fn: () => Promise<T>) => Promise<T>;
}

// Env var names the generated npm config refers to; npm expands them when it
// The tarball check reads the manifest the way pnpm 12 does.
const MIN_PNPM_MAJOR = 12;

// Env var names the generated auth file refers to; pnpm expands them when it
// reads the file, so the file itself holds no secret.
const NPM_AUTH_TOKEN_VAR = 'NPM_AUTH_TOKEN';
const NPMJS_FALLBACK_TOKEN_VAR = 'NPMJS_FALLBACK_TOKEN';

/**
* npm config (userconfig) for uploading `entries`. Every registry must be
* https on a supported host: npm attaches a token by host whatever the
* scheme, and the GitHub token must reach GitHub Packages only. Throws before
* anything is uploaded otherwise.
* Auth file for uploading `entries`. Every registry must be https on a
* supported host: pnpm attaches a token by host whatever the scheme, and the
* GitHub token must reach GitHub Packages only. Throws before anything is
* uploaded otherwise.
*/
export function npmrcFor(entries: ManifestEntry[], credentials: Credentials): string {
const lines = new Set<string>();
Expand Down Expand Up @@ -84,42 +87,37 @@ export function npmrcFor(entries: ManifestEntry[], credentials: Credentials): st
return [...lines].map((l) => l + '\n').join('');
}

/**
* npm applies a packed publishConfig for every key not given on the command
* line. Keys that could send the upload or its credentials elsewhere, or
* change how npm authenticates, are refused; `registry` must be the entry's.
*/
const FORBIDDEN_PUBLISH_CONFIG = new Set([
'_auth', '_authtoken', '_password', 'always-auth', 'auth-type', 'ca', 'cafile', 'cert', 'certfile',
'email', 'globalconfig', 'https-proxy', 'key', 'keyfile', 'noproxy', 'otp', 'password', 'proxy',
'strict-ssl', 'token', 'userconfig', 'username',
]);
/** Refuses packed manifest keys that pnpm 12 prefers to our flags: `tag`, `publishConfig` registries. */
export function checkPackedManifest(label: string, packed: { tag?: unknown; publishConfig?: unknown }, entry: ManifestEntry): void {
if (packed.tag !== undefined && packed.tag !== (entry.tag || 'latest')) {
throw new Error(`${label}: packed package.json sets tag ${JSON.stringify(packed.tag)}, not '${entry.tag || 'latest'}'`);
}
checkPackedPublishConfig(label, packed.publishConfig, entry.registry);
}

export function checkPackedPublishConfig(label: string, publishConfig: unknown, registry: string): void {
function checkPackedPublishConfig(label: string, publishConfig: unknown, registry: string): void {
if (publishConfig === undefined) return;
if (typeof publishConfig !== 'object' || publishConfig === null) {
throw new Error(`${label}: packed publishConfig is not an object`);
}
for (const [key, value] of Object.entries(publishConfig)) {
const lower = key.toLowerCase();
if (FORBIDDEN_PUBLISH_CONFIG.has(lower) || lower.startsWith('//') || /^@[^/]+:registry$/.test(lower)) {
if (/^@[^/]+:registry$/i.test(key)) {
throw new Error(`${label}: packed publishConfig sets '${key}', which the split publish does not allow`);
}
if (lower === 'registry' && String(value).replace(/\/+$/, '') !== registry.replace(/\/+$/, '')) {
if (key.toLowerCase() === 'registry' && String(value).replace(/\/+$/, '') !== registry.replace(/\/+$/, '')) {
throw new Error(`${label}: packed publishConfig.registry ${String(value)} is not ${registry}`);
}
}
}

/** `npm publish` arguments for one entry. Lifecycle scripts already ran during pack. */
/** `pnpm publish` arguments for one entry. */
export function publishArgs(entry: ManifestEntry, dir: string, accessPublic: boolean): string[] {
// `--flag=value` keeps a value from being read as an option, on top of the
// manifest validation.
const args = ['publish', path.resolve(dir, entry.file), '--ignore-scripts', `--registry=${entry.registry}`];
// Always explicit. With the default tag npm 11 refuses a prerelease or a
// version below the highest published one; the old path (npm 10 under
// pnpm) tagged those `latest`, so that stays the default.
args.push(`--tag=${entry.tag || 'latest'}`);
const args = ['publish', path.resolve(dir, entry.file), `--registry=${entry.registry}`, '--json'];
if (entry.tag) {
args.push(`--tag=${entry.tag}`);
}
if (accessPublic) {
args.push('--access=public');
} else if (entry.access) {
Expand All @@ -141,34 +139,37 @@ export async function publishTarballs(options: PublishOptions, deps: PublishDeps
log('Nothing to publish.');
return [];
}
// npm publishes whatever name/version the tarball holds, so the manifest
// pnpm publishes whatever name/version the tarball holds, so the manifest
// alone is not enough: check every tarball before the first upload.
for (const entry of entries) {
const label = `${entry.name}@${entry.version}`;
const tarball = path.join(dir, entry.file);
if (!fs.existsSync(tarball)) {
throw new Error(`${label}: ${entry.file} is missing from ${dir}`);
}
const packed = readPackedManifest(tarball) as { name?: unknown; version?: unknown; publishConfig?: unknown };
const packed = readPackedManifest(tarball) as { name?: unknown; version?: unknown; tag?: unknown; publishConfig?: unknown };
if (packed.name !== entry.name || packed.version !== entry.version) {
throw new Error(`${label}: ${entry.file} contains ${String(packed.name)}@${String(packed.version)}`);
}
checkPackedPublishConfig(label, packed.publishConfig, entry.registry);
checkPackedManifest(label, packed, entry);
}

fs.writeFileSync(userconfig, npmrcFor(entries, options));
// npm reads a project .npmrc from its working directory. The tarball
// directory comes from another job's artifact, so npm runs from an empty
// directory instead and gets the tarballs by absolute path.
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'npm-publish-'));
// An empty directory, so pnpm reads no .npmrc or pnpm-workspace.yaml from the artifact.
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'pnpm-publish-'));
const env: NodeJS.ProcessEnv = {
...process.env,
NPM_CONFIG_USERCONFIG: userconfig,
PNPM_CONFIG_NPMRC_AUTH_FILE: userconfig,
[NPM_AUTH_TOKEN_VAR]: options.npmAuthToken ?? '',
[NPMJS_FALLBACK_TOKEN_VAR]: options.npmjsFallbackToken ?? '',
};
const envRun: Runner = (command, args, runOptions) => run(command, args, { ...runOptions, env });

const pnpmVersion = (await runChecked(envRun, 'pnpm', ['--version'], { cwd })).stdout.trim();
if (Number(pnpmVersion.split('.')[0]) < MIN_PNPM_MAJOR) {
throw new Error(`pnpm ${pnpmVersion} is on PATH; the split publish needs pnpm ${MIN_PNPM_MAJOR} or later`);
}

const published: ManifestEntry[] = [];
for (const entry of entries) {
const label = `${entry.name}@${entry.version}`;
Expand All @@ -177,7 +178,7 @@ export async function publishTarballs(options: PublishOptions, deps: PublishDeps
continue;
}
const result = await group(`${label} -> ${entry.registry}`, () =>
envRun('npm', publishArgs(entry, dir, accessPublic), { cwd, echo: true }),
envRun('pnpm', publishArgs(entry, dir, accessPublic), { cwd, echo: true }),
);
if (result.code !== 0) {
// A version the lookup could not see, e.g. a restricted package queried
Expand All @@ -186,7 +187,7 @@ export async function publishTarballs(options: PublishOptions, deps: PublishDeps
log(`${label}: already in ${entry.registry} (refused as a duplicate), skipping`);
continue;
}
throw new CommandError(['npm', ...publishArgs(entry, dir, accessPublic)].join(' '), result);
throw new CommandError(['pnpm', ...publishArgs(entry, dir, accessPublic)].join(' '), result);
}
published.push(entry);
}
Expand Down
Loading
Loading