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
13 changes: 13 additions & 0 deletions .dev/features/publish-tarball-path/PLAN.md
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 3 additions & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
45 changes: 45 additions & 0 deletions tests/publish-workflow.test.ts
Original file line number Diff line number Diff line change
@@ -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}|~)?\//,
);
}
});
});
Loading