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
31 changes: 20 additions & 11 deletions .github/workflows/node-docker-simple-fast-pnpm.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -506,20 +506,26 @@ jobs:
with:
version: ${{ inputs.gcp-sdk-version }}

- name: Create Release Pull Request and Publish npm package (Public)
if: steps.context.outputs.is-release == 'true' && inputs.publish-to-public == 'true'
# changesets/action opens the "chore: update versions" PR while
# changesets are pending; with none left, the packages are published
# with npm trusted publishing (OIDC): pack, then upload, in this
# GitHub-hosted job (id-token: write). Each package needs a trusted
# publisher naming the caller's repository and workflow file.
- name: Create Release Pull Request
id: changesets
if: steps.context.outputs.is-release == 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPMJS_TOKEN: ${{ env.NPMJS_TOKEN }}
uses: changesets/action@v1
with:
commit: "chore: update versions"
title: "chore: update versions"
publish: pnpm -r publish --no-git-checks --access public

- name: Create Release Pull Request and Publish npm package
if: steps.context.outputs.is-release == 'true' && inputs.publish-to-public == 'false'
- name: Pack unpublished npm packages
id: npm-pack
if: steps.context.outputs.is-release == 'true'
&& steps.changesets.outputs.hasChangesets == 'false'
uses: milaboratory/github-ci/actions/node/pack-unpublished@v4
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Expand All @@ -529,11 +535,14 @@ jobs:
GCP_KMS_KEYRING: ${{ secrets.GCP_KMS_KEYRING }}
GCP_KMS_LOCATION: ${{ secrets.GCP_KMS_LOCATION }}
GCP_KMS_DIGEST_ALGORITHM: ${{ inputs.gcp-kms-digest-algorithm }}
uses: changesets/action@v1

- name: Publish npm packages
if: steps.npm-pack.outputs.count != '' && steps.npm-pack.outputs.count != '0'
uses: milaboratory/github-ci/actions/node/publish-tarballs@v4
with:
commit: "chore: update versions"
title: "chore: update versions"
publish: pnpm -r publish --no-git-checks
dir: ${{ steps.npm-pack.outputs.dir }}
access-public: ${{ inputs.publish-to-public }}
npm-auth-token: ${{ secrets.GITHUB_TOKEN }}

outputs:
# Even constant outputs become initialized only after job start.
Expand Down
31 changes: 14 additions & 17 deletions .github/workflows/node-matrix-pnpm.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -927,31 +927,28 @@ jobs:
git commit -m "Auto-generated changes"
git push

- name: Publish npm package
id: publish-to-private
# npm trusted publishing (OIDC). This job runs on a GitHub-hosted runner
# with id-token: write, so pack and upload run back to back: pack runs
# prepublishOnly/prepublish and `pnpm pack` for each unpublished
# package, upload publishes the tarballs. Each package needs a trusted
# publisher naming the caller's repository and workflow file.
- name: Pack unpublished npm packages
id: npm-pack
if: github.ref_name == 'main'
&& steps.check-changes.outputs.has-changes == '0'
&& inputs.publish-to-public == 'false'
uses: milaboratory/github-ci/actions/node/pack-unpublished@v4
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPMJS_TOKEN: ${{ env.NPMJS_TOKEN }}
uses: changesets/action@v1
with:
publish: pnpm -r publish --no-git-checks

- name: Publish npm package (public)
id: publish-to-public
if: github.ref_name == 'main'
&& steps.check-changes.outputs.has-changes == '0'
&& inputs.publish-to-public == 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPMJS_TOKEN: ${{ env.NPMJS_TOKEN }}
uses: changesets/action@v1
- name: Publish npm packages
if: steps.npm-pack.outputs.count != '' && steps.npm-pack.outputs.count != '0'
uses: milaboratory/github-ci/actions/node/publish-tarballs@v4
with:
publish: pnpm -r publish --no-git-checks --access public
dir: ${{ steps.npm-pack.outputs.dir }}
access-public: ${{ inputs.publish-to-public }}
npm-auth-token: ${{ secrets.GITHUB_TOKEN }}
Comment on lines +945 to +951

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Custom registries stop working

Replacing pnpm -r publish with publish-tarballs removes custom-registry support here and in node-docker-simple-fast-pnpm.yaml. If a caller supplies a custom registry through npmrc-config, packing still accepts it, but npmrcFor rejects every host except npmjs and GitHub Packages. The release fails after its prepublish scripts have already run.

Preserve that upload path, or reject the unsupported configuration before packing and document the changed input contract.

Prompt To Fix With AI
This is a comment left during a code review.
Path: .github/workflows/node-matrix-pnpm.yaml
Line: 945-951

Comment:
**Custom registries stop working**

Replacing `pnpm -r publish` with `publish-tarballs` removes custom-registry support here and in `node-docker-simple-fast-pnpm.yaml`. If a caller supplies a custom registry through `npmrc-config`, packing still accepts it, but `npmrcFor` rejects every host except npmjs and GitHub Packages. The release fails after its prepublish scripts have already run.

Preserve that upload path, or reject the unsupported configuration before packing and document the changed input contract.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code


- name: Create release with tag
if: github.ref_name == 'main'
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/node-matrix.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -801,6 +801,15 @@ jobs:
role-to-assume: ${{ secrets.AWS_ASSUME_ROLE }}
aws-region: ${{ secrets.AWS_ASSUME_REGION }}

# npm trusted publishing (OIDC): npm 11.5.1+ and, in public repositories,
# a package.json `repository` naming this repository (npm adds
# provenance and the registry checks it). NPMJS_TOKEN is used only if
# the OIDC exchange fails.
- name: Prepare npm trusted publishing
uses: milaboratory/github-ci/actions/node/prepare-trusted-publishing@v4
with:
registry-url: ${{ inputs.registry-url }}

- name: Publish npm package
uses: milaboratory/github-ci/actions/shell@v4
env:
Expand Down
12 changes: 12 additions & 0 deletions .github/workflows/node-simple.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,18 @@ jobs:
if: inputs.gcp-login-enable
uses: milaboratory/github-ci/actions/milab-shell-utils@v4

# npm trusted publishing (OIDC): npm 11.5.1+ and, in public repositories,
# a package.json `repository` naming this repository (npm adds
# provenance and the registry checks it). NPMJS_TOKEN is used only if
# the OIDC exchange fails.
- name: Prepare npm trusted publishing
if: steps.npm-pkg-status.outputs.exist == '0'
&& steps.context.outputs.is-release == 'true'
uses: milaboratory/github-ci/actions/node/prepare-trusted-publishing@v4
with:
package-path: ${{ inputs.node-execution-path }}
registry-url: ${{ inputs.registry-url }}

- name: Publish npm package
if: steps.npm-pkg-status.outputs.exist == '0'
&& steps.context.outputs.is-release == 'true'
Expand Down
28 changes: 28 additions & 0 deletions actions/node/npm-split-publish/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Implementation of the split npm publish behind two composite actions:
|---|---|---|
| `actions/node/pack-unpublished` | `lib/pack` (`src/bin/pack.ts` → `src/pack.ts`) | the build runner, often self-hosted |
| `actions/node/publish-tarballs` | `lib/publish` (`src/bin/publish.ts` → `src/publish.ts`) | GitHub-hosted, `id-token: write` |
| `actions/node/prepare-trusted-publishing` | `lib/prepare` (`src/bin/prepare.ts` → `src/repository.ts`) | GitHub-hosted jobs that run `npm publish` themselves |

npm trusted publishing (OIDC) does not accept self-hosted runners, so
`pack` does everything `pnpm -r publish` does except the upload: for every
Expand All @@ -15,6 +16,29 @@ unpublished non-private package, in dependency order, it runs
`npm publish <tarball> --ignore-scripts`. The two halves share
`manifest.jsonl`, typed as `ManifestEntry` in `src/manifest.ts`.

## Manifest Format (Public)

`publish-tarballs` reads `<dir>/manifest.jsonl` plus the tarballs next to it.
`pack-unpublished` writes it, and repositories that pack themselves (e.g.
milaboratory/pframes-rs) write it by hand, so this format is a contract:
change it only compatibly. One JSON object per line, uploaded in file order:

```json
{"name": "@scope/pkg", "version": "1.2.3", "file": "scope-pkg-1.2.3.tgz",
"registry": "https://registry.npmjs.org/", "tag": "", "access": "public"}
```

| Field | Rule |
|---|---|
| `name` | npm package name, lowercase |
| `version` | semver |
| `file` | tarball basename in the same directory, `.tgz` |
| `registry` | https URL; registry.npmjs.org (OIDC) or npm.pkg.github.com |
| `tag` | dist-tag, or `""` for `latest` |
| `access` | `""`, `public` or `restricted` |

Validation: `src/manifest.ts` (`validateEntry`).

## Behaviour worth knowing

- **`repository` for provenance.** Under trusted publishing npm turns
Expand Down Expand Up @@ -46,6 +70,10 @@ unpublished non-private package, in dependency order, it runs
- **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.
- **New packages.** npm accepts a trusted publisher only for a package that
exists, and no token can publish any more, so `pack` refuses a package that
registry.npmjs.org does not have yet, before any script runs: publish its
first version by hand, add its trusted publisher, re-run the job.
- **Partial releases.** `prepublishOnly` (`block-tools publish` to the block
registry, `pl-pkg prepublish`) runs in the build job, before the upload job
starts. If the upload fails, those side effects have happened and npm lacks
Expand Down
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.

4 changes: 4 additions & 0 deletions actions/node/npm-split-publish/lib/prepare/index.js

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions actions/node/npm-split-publish/lib/prepare/index.js.map

Large diffs are not rendered by default.

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion 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.

2 changes: 1 addition & 1 deletion actions/node/npm-split-publish/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"private": true,
"description": "Split npm publish: pack on the build runner, upload 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",
"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": {
Expand Down
16 changes: 1 addition & 15 deletions actions/node/npm-split-publish/src/bin/pack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
// root is the current directory. Outputs: dir, count.

import * as core from '@actions/core';
import * as fs from 'fs';
import * as path from 'path';

import { publicSourceRepository } from '../github';
import { packWorkspace } from '../pack';
import { spawnRunner } from '../run';

Expand All @@ -31,20 +31,6 @@ async function main(): Promise<void> {
core.setOutput('count', String(entries.length));
}

/**
* https://github.com/<owner>/<repo> when the repository being built is public
* on github.com, the only case where npm adds provenance on its own. The
* event payload says whether the repository is private.
*/
function publicSourceRepository(): string | undefined {
const { GITHUB_SERVER_URL: server, GITHUB_REPOSITORY: repository, GITHUB_EVENT_PATH: eventPath } = process.env;
if (server !== 'https://github.com' || !repository || !eventPath || !fs.existsSync(eventPath)) {
return undefined;
}
const event = JSON.parse(fs.readFileSync(eventPath, 'utf8')) as { repository?: { private?: boolean } };
return event.repository?.private === false ? `${server}/${repository}` : undefined;
}

function requireEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`${name} must be set`);
Expand Down
33 changes: 33 additions & 0 deletions actions/node/npm-split-publish/src/bin/prepare.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Entry point of actions/node/prepare-trusted-publishing, for workflows that
// run `npm publish` themselves on a GitHub-hosted runner.
//
// Env: PACKAGE_DIR (default: the current directory), REGISTRY (the registry
// the job publishes to; package.json publishConfig.registry wins; default
// npmjs), GITHUB_WORKSPACE (the repository root, for `repository.directory`),
// GITHUB_SERVER_URL, GITHUB_REPOSITORY, GITHUB_EVENT_PATH.

import * as core from '@actions/core';
import * as path from 'path';

import { publicSourceRepository } from '../github';
import { prepareRepository } from '../repository';

const MESSAGES = {
'other-registry': 'Not publishing to registry.npmjs.org: npm adds no provenance there, package.json left as is.',
'private-repository': 'Private repository: npm adds no provenance, package.json left as is.',
added: 'Added `repository` to package.json for npm provenance.',
present: 'package.json `repository` already names this repository.',
};

try {
const pkgDir = path.resolve(process.env.PACKAGE_DIR || '.');
const result = prepareRepository({
pkgDir,
workspaceRoot: process.env.GITHUB_WORKSPACE || process.cwd(),
registry: process.env.REGISTRY,
sourceRepository: publicSourceRepository(),
});
core.info(`${path.join(pkgDir, 'package.json')}: ${MESSAGES[result]}`);
} catch (error) {
core.setFailed((error as Error).message);
}
15 changes: 15 additions & 0 deletions actions/node/npm-split-publish/src/github.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import * as fs from 'fs';

/**
* https://github.com/<owner>/<repo> when the repository being built is public
* on github.com, the only case where npm adds provenance on its own. The
* event payload says whether the repository is private.
*/
export function publicSourceRepository(env: NodeJS.ProcessEnv = process.env): string | undefined {
const { GITHUB_SERVER_URL: server, GITHUB_REPOSITORY: repository, GITHUB_EVENT_PATH: eventPath } = env;
if (server !== 'https://github.com' || !repository || !eventPath || !fs.existsSync(eventPath)) {
return undefined;
}
const event = JSON.parse(fs.readFileSync(eventPath, 'utf8')) as { repository?: { private?: boolean } };
return event.repository?.private === false ? `${server}/${repository}` : undefined;
}
18 changes: 16 additions & 2 deletions actions/node/npm-split-publish/src/pack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import * as fs from 'fs';
import * as path from 'path';

import { ManifestEntry, writeManifest } from './manifest';
import { PackageJson, isPublished, resolveRegistry } from './registry';
import { PackageJson, resolveRegistry, versionState } from './registry';
import { repositoryState, withRepository } from './repository';
import { Runner, runChecked } from './run';
import { readPackedManifest } from './tarball';
Expand Down Expand Up @@ -79,23 +79,37 @@ export async function packWorkspace(options: PackOptions, deps: PackDeps): Promi
// their registry yet. Only these are checked; already-published packages
// keep releasing as before.
const planned: Planned[] = [];
const newOnNpmjs: string[] = [];
for (const pkg of packages) {
const { name, version } = pkg.manifest;
if (pkg.manifest.private) {
continue;
}
const registry = await resolveRegistry(pkg.manifest, npmrcDir, run);
if (await isPublished(name, version, registry, npmrcDir, run)) {
const state = await versionState(name, version, registry, npmrcDir, run);
if (state === 'published') {
log(`${name}@${version}: already in ${registry}, skipping`);
continue;
}
if (state === 'new-package' && isNpmjs(registry)) {
newOnNpmjs.push(name);
}
// Provenance applies to npmjs uploads from a public repository only.
const provenanceFrom = sourceRepository && isNpmjs(registry) ? sourceRepository : undefined;
planned.push({ pkg, registry, provenanceFrom });
}

// Every check before the first prepublishOnly, so a bad package fails the
// run without side effects.
if (newOnNpmjs.length > 0) {
// npm accepts a trusted publisher only for a package that exists, and no
// token can publish any more, so the first version needs a person.
throw new Error(
`not on npmjs yet: ${newOnNpmjs.join(', ')}. Publish the first version of each by hand ` +
`(npm publish with 2FA), add its trusted publisher (repository + workflow file, "npm publish" allowed), ` +
`then re-run this job. Nothing was published or uploaded.`,
);
}
for (const { pkg, provenanceFrom } of planned) {
const { name } = pkg.manifest;
for (const script of UNSUPPORTED_SCRIPTS) {
Expand Down
20 changes: 14 additions & 6 deletions actions/node/npm-split-publish/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,26 +35,34 @@ export async function resolveRegistry(pkg: PackageJson & { name: string }, npmrc
return configGet('registry');
}

export type VersionState = 'published' | 'new-version' | 'new-package';

/**
* Whether name@version is in the registry. Throws on any lookup failure other
* than "package not found".
* Where name@version stands in the registry: published, a new version of an
* existing package, or a package the registry does not have at all. Throws on
* any lookup failure other than "not found".
*/
export async function isPublished(
export async function versionState(
name: string,
version: string,
registry: string,
cwd: string,
run: Runner,
): Promise<boolean> {
): Promise<VersionState> {
// npm view: exit 0 + the version when it exists; exit 0 + no output when the
// package exists without that version; E404 when the package does not exist.
const args = ['view', `${name}@${version}`, 'version', `--registry=${registry}`];
const result = await run('npm', args, { cwd });
if (result.code === 0) {
return result.stdout.trim() !== '';
return result.stdout.trim() !== '' ? 'published' : 'new-version';
}
if (/\bE404\b/.test(result.stderr + result.stdout)) {
return false;
return 'new-package';
Comment on lines 59 to +60

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Normal version bumps get rejected

versionState treats every E404 from npm view name@version version as a missing package. npm also returns E404 when the package exists but that exact version does not. The new gate in packWorkspace therefore rejects normal npmjs releases with instructions to publish the first version by hand.

After a version lookup returns E404, query the package without the version before returning new-package. Add a test that distinguishes these two failures.

Prompt To Fix With AI
This is a comment left during a code review.
Path: actions/node/npm-split-publish/src/registry.ts
Line: 59-60

Comment:
**Normal version bumps get rejected**

`versionState` treats every `E404` from `npm view name@version version` as a missing package. npm also returns `E404` when the package exists but that exact version does not. The new gate in `packWorkspace` therefore rejects normal npmjs releases with instructions to publish the first version by hand.

After a version lookup returns `E404`, query the package without the version before returning `new-package`. Add a test that distinguishes these two failures.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code

}
throw new CommandError(['npm', ...args].join(' '), result);
}

/** Whether name@version is in the registry (see versionState). */
export async function isPublished(name: string, version: string, registry: string, cwd: string, run: Runner): Promise<boolean> {
return (await versionState(name, version, registry, cwd, run)) === 'published';
}
Loading
Loading