From 40fb41bb86c5e36e058b1ae165ee0d5bf263b17a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 15:52:48 +0000 Subject: [PATCH] fix(ci): pass the tarball to npm publish as an explicit file path `npm publish "pkg/pharn-dev-pharn-${VERSION}.tgz"` matched npm's GitHub `owner/repo` shorthand, so npm tried `git ls-remote ssh://git@github.com/pkg/...` and the 0.6.0 publish job failed with `Permission denied (publickey)`. Nothing reached the registry. Prefix the path with `./` and pin every tarball argument in publish.yml as an explicit file path in tests/publish-workflow.test.ts, since no PR check ever executes that workflow. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YGD9yKFvXkXpZd3DdvdZmn --- .dev/features/publish-tarball-path/PLAN.md | 13 +++++++ .github/workflows/publish.yml | 4 +- tests/publish-workflow.test.ts | 45 ++++++++++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 .dev/features/publish-tarball-path/PLAN.md create mode 100644 tests/publish-workflow.test.ts diff --git a/.dev/features/publish-tarball-path/PLAN.md b/.dev/features/publish-tarball-path/PLAN.md new file mode 100644 index 0000000..72bf7ab --- /dev/null +++ b/.dev/features/publish-tarball-path/PLAN.md @@ -0,0 +1,13 @@ +# PLAN — publish-tarball-path + +The 0.6.0 release run failed in the `publish` job: `npm publish "pkg/pharn-dev-pharn-0.6.0.tgz"`. +npm treats an argument as a local file only when it starts with `./`, `../`, `/` or `~/`; a bare +`pkg/x.tgz` matches the GitHub `owner/repo` shorthand, so npm ran +`git ls-remote ssh://git@github.com/pkg/pharn-dev-pharn-0.6.0.tgz.git` and died on +`Permission denied (publickey)`. Nothing reached the registry (npm still lists 0.5.0 as latest). + +## Files + +- `.github/workflows/publish.yml` — publish `./pkg/pharn-dev-pharn-${VERSION}.tgz` (explicit file path) +- `tests/publish-workflow.test.ts` — pins that every `.tgz` passed to `npm publish`/`npm install` in + publish.yml is an explicit file path (publish.yml runs only on a Release, so no PR check executes it) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 598455c..ee9b745 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -140,7 +140,9 @@ jobs: # Exactly the tarball of the verified version, by name; --ignore-scripts so # nothing from the package runs here. `--provenance` emits a signed # attestation via the same OIDC id-token used for Trusted Publishing. + # The leading `./` is load-bearing: a bare `pkg/x.tgz` matches npm's GitHub + # `owner/repo` shorthand and is resolved as a git repo, not the file. - name: Publish env: VERSION: ${{ needs.build.outputs.version }} - run: npm publish "pkg/pharn-dev-pharn-${VERSION}.tgz" --provenance --access public --ignore-scripts + run: npm publish "./pkg/pharn-dev-pharn-${VERSION}.tgz" --provenance --access public --ignore-scripts diff --git a/tests/publish-workflow.test.ts b/tests/publish-workflow.test.ts new file mode 100644 index 0000000..07cec37 --- /dev/null +++ b/tests/publish-workflow.test.ts @@ -0,0 +1,45 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +// npm reads a package argument as a LOCAL FILE only when it starts with `./`, +// `../`, `/` or `~/` (npm-package-arg's file-spec test). A bare relative path +// such as `pkg/pharn-dev-pharn-0.6.0.tgz` has the shape of the GitHub +// `owner/repo` shorthand, so npm resolves it as a git dependency and runs +// `git ls-remote ssh://git@github.com/pkg/pharn-dev-pharn-0.6.0.tgz.git`. That +// is exactly how the 0.6.0 release failed: the publish job died on +// `Permission denied (publickey)` and nothing reached the registry. The build +// job's smoke install never hit it only because its path began with `../`. +// +// Pinned statically because publish.yml runs solely on a published Release — +// no PR check ever executes it, so the first run of a broken line is a release. +const PUBLISH_WORKFLOW = '.github/workflows/publish.yml'; + +/** Every `.tgz` argument handed to `npm publish` / `npm install` in the workflow. */ +function tarballArgs(source: string): { command: string; arg: string }[] { + const found: { command: string; arg: string }[] = []; + for (const line of source.split('\n')) { + const m = /\bnpm (publish|install|i)\b(.*)$/.exec(line); + if (!m) continue; + for (const token of m[2]!.trim().split(/\s+/)) { + const arg = token.replace(/^["']|["']$/g, ''); + if (arg.endsWith('.tgz')) found.push({ command: m[1]!, arg }); + } + } + return found; +} + +describe('publish.yml tarball arguments', () => { + const args = tarballArgs(readFileSync(PUBLISH_WORKFLOW, 'utf8')); + + it('publishes a tarball (so the path check below is not vacuous)', () => { + expect(args.some((a) => a.command === 'publish')).toBe(true); + }); + + it('passes every tarball as an explicit file path, never an owner/repo lookalike', () => { + for (const { arg } of args) { + expect(arg, `npm would resolve "${arg}" as a GitHub repo`).toMatch( + /^(?:\.{1,2}|~)?\//, + ); + } + }); +});