Skip to content

Commit 199e729

Browse files
theoephraimCI
andauthored
fix: harden publish for trusted publishing + stranded tags (#101)
## Summary Two related fixes for publish failures hit when releasing brand-new packages from GitHub Actions with npm trusted publishing (OIDC). Reported by a downstream project (varlock) where publishing `@varlock/kubernetes-plugin@0.1.0` failed because the package didn't exist on npm yet, leaving a stranded GH draft release + remote tag that then broke the retry's `git push --tags`. ### 1. Detect new-package + OIDC-only auth before any side effects Trusted publishing can't bootstrap a package that doesn't exist on npm — the trusted publisher config has to be created on npmjs.com first, which requires the package to already exist. We now check the registry up front and emit a specific, actionable error directing the user to publish a `0.0.0` placeholder first. The check is gated on `willUseOidcExclusively` (OIDC env detected AND no `NPM_TOKEN`/`NODE_AUTH_TOKEN` AND no `.npmrc` auth) to avoid false positives for users who enable `id-token: write` for provenance attestations alongside token auth. Runs before `gh release create` and `npm publish`, so a failing check leaves no stranded state. On `--dry-run` it warns instead of erroring. ### 2. Per-tag force push replaces blanket `git push --tags` `gh release create --draft --target SHA` creates the tag on the remote at draft-creation time. If a prior publish failed and HEAD has since moved, the remote tag is stale and `git push --tags` rejects with "already exists". The fix iterates `releasePlan.releases` minus `result.failed` and force-pushes each tag individually. Preserves the existing anySucceeded-aware semantics used for local tag movement: packages whose targets all succeeded in a prior run are stripped upstream by the `alreadyPublished` filter, so their tags stay at the SHA the artifact was actually published from — the GitHub release continues to point at the right commit. Mixed-success scenarios (e.g. package A published from SHA1 in run 1, package B retried from SHA2 in run 2) end up correct: A's tag is left at SHA1, only B's tag is force-pushed to SHA2. ## Test plan - [x] `bun run check` passes (lint + format + typecheck) - [x] `bun run test` — 258/258 pass - [ ] Verify in downstream project (varlock) that the new error fires on the OIDC + new-package case - [ ] Verify a clean retry after publishing a `0.0.0` placeholder succeeds and pushes tags without rejection --------- Co-authored-by: CI <ci@example.com>
1 parent e6b16a1 commit 199e729

4 files changed

Lines changed: 126 additions & 11 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@varlock/bumpy': patch
3+
---
4+
5+
Harden the publish flow for two failure modes hit when releasing brand-new packages via GitHub Actions + npm trusted publishing (OIDC).
6+
7+
- Detect the new-package case before any side effects. When OIDC is the only available auth path (no `NPM_TOKEN`/`NODE_AUTH_TOKEN`, no `.npmrc` auth), bumpy now checks the npm registry up front and emits a clear error directing the user to publish a `0.0.0` placeholder before merging — instead of failing partway through with stranded GitHub draft releases and remote tags. The check is skipped when a token fallback is present, so users who enable `id-token: write` for provenance attestations alongside token auth are unaffected.
8+
- Replace blanket `git push --tags` after publish with per-tag force push. `gh release create --draft --target SHA` creates the tag on the remote at draft-creation time; if a prior publish failed and HEAD has since moved, the remote tag is stale and `git push --tags` rejects with "already exists". The new logic iterates `releasePlan.releases` minus failed packages and force-pushes each tag individually, preserving the anySucceeded-aware semantics already used for local tag movement — packages whose targets all succeeded in a prior run are stripped upstream and their tags stay at the SHA the artifact was actually published from.

packages/bumpy/src/commands/publish.ts

Lines changed: 85 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import { log, colorize } from '../utils/logger.ts';
22
import { loadConfig } from '../core/config.ts';
33
import { discoverWorkspace } from '../core/workspace.ts';
44
import { DependencyGraph } from '../core/dep-graph.ts';
5-
import { pushWithTags, hasUncommittedChanges } from '../core/git.ts';
6-
import { publishPackages } from '../core/publish-pipeline.ts';
5+
import { forcePushTag, hasUncommittedChanges, tagExists } from '../core/git.ts';
6+
import { publishPackages, willUseOidcExclusively } from '../core/publish-pipeline.ts';
77
import {
88
createIndividualReleases,
99
findReleaseByTag,
@@ -24,7 +24,7 @@ import {
2424
import { loadFormatter } from '../core/changelog.ts';
2525
import { detectWorkspaces } from '../utils/package-manager.ts';
2626
import { CI_PLAN_CACHE_PATH } from './ci.ts';
27-
import { tryRunArgs } from '../utils/shell.ts';
27+
import { runArgsAsync, tryRunArgs } from '../utils/shell.ts';
2828
import type { BumpyConfig, PackageConfig, ReleasePlan, PlannedRelease, WorkspacePackage } from '../types.ts';
2929

3030
interface PublishCommandOptions {
@@ -108,6 +108,22 @@ export async function publishCommand(rootDir: string, opts: PublishCommandOption
108108
}
109109
console.log();
110110

111+
// Trusted publishing (OIDC) cannot bootstrap a new package — fail early if any
112+
// package being published doesn't exist on npm yet, before we create draft releases.
113+
// Only checks when OIDC is the only available auth (no token fallback), to avoid
114+
// false positives for users with id-token: write enabled solely for provenance.
115+
if (willUseOidcExclusively(rootDir)) {
116+
const newPackages = await findPackagesMissingFromNpm(toPublish, packages);
117+
if (newPackages.length > 0) {
118+
const logFn = opts.dryRun ? log.warn : log.error;
119+
logFn(`Trusted publishing (OIDC) cannot create a new package. The following don't exist on npm yet:`);
120+
for (const name of newPackages) logFn(` • ${name}`);
121+
logFn(`Publish a 0.0.0 placeholder version manually to claim the name, then configure`);
122+
logFn(`trusted publishing on npmjs.com. Bumpy will then publish the real version via OIDC.`);
123+
if (!opts.dryRun) process.exit(1);
124+
}
125+
}
126+
111127
// Load the changelog formatter for release note generation
112128
const formatter = config.changelog !== false ? await loadFormatter(config.changelog, rootDir) : undefined;
113129
const ghAvailable = isGhAvailable();
@@ -316,15 +332,37 @@ export async function publishCommand(rootDir: string, opts: PublishCommandOption
316332
process.exit(1);
317333
}
318334

319-
// Push tags
335+
// Push tags — per-tag force push only for releases handled this run.
336+
//
337+
// We use `releasePlan.releases` (not result.published) so that packages with
338+
// skipNpmPublish or private packages with `privatePackages.tag` enabled are
339+
// covered too — their local tags are created in publish-pipeline regardless of
340+
// whether npm publish ran. Failed packages are skipped (their local tag was
341+
// not created). The `alreadyPublished` filter above has already stripped
342+
// packages whose targets all succeeded in prior runs, so we never touch tags
343+
// tied to a previously-published SHA.
344+
//
345+
// Force-push is necessary because `gh release create --draft --target SHA`
346+
// creates the tag on the remote at draft-creation time. If a previous attempt
347+
// failed and HEAD has since moved, the remote tag is at a stale SHA and a
348+
// plain `git push --tags` would reject. Force is safe here because the local
349+
// tag was just created at the SHA we successfully published from.
320350
if (!opts.dryRun && !opts.noPush && result.published.length > 0) {
321-
try {
322-
log.step('Pushing tags...');
323-
pushWithTags({ cwd: rootDir });
324-
log.success('Pushed tags to remote');
325-
} catch (err) {
326-
log.warn(`Failed to push tags: ${err instanceof Error ? err.message : err}`);
351+
const failed = new Set(result.failed.map((f) => f.name));
352+
const pushed: string[] = [];
353+
log.step('Pushing tags...');
354+
for (const release of releasePlan.releases) {
355+
if (failed.has(release.name)) continue;
356+
const tag = `${release.name}@${release.newVersion}`;
357+
if (!tagExists(tag, { cwd: rootDir })) continue;
358+
try {
359+
forcePushTag(tag, { cwd: rootDir });
360+
pushed.push(tag);
361+
} catch (err) {
362+
log.warn(` Failed to push tag ${tag}: ${err instanceof Error ? err.message : err}`);
363+
}
327364
}
365+
if (pushed.length > 0) log.success(`Pushed ${pushed.length} tag(s) to remote`);
328366
}
329367

330368
// Fallback: if gh isn't available, we can't use draft releases — use legacy individual releases
@@ -478,3 +516,40 @@ async function checkIfPublished(name: string, version: string, pkgConfig?: Packa
478516
return false;
479517
}
480518
}
519+
520+
/**
521+
* Check whether a package exists on npm at all (any version).
522+
* Returns true if the package is registered, false if it doesn't exist or the query fails.
523+
*/
524+
async function packageExistsOnNpm(name: string, registry?: string): Promise<boolean> {
525+
const args = ['npm', 'info', name, 'name'];
526+
if (registry) args.push('--registry', registry);
527+
try {
528+
const result = await runArgsAsync(args);
529+
return result.trim() === name;
530+
} catch {
531+
return false;
532+
}
533+
}
534+
535+
/**
536+
* Filter `toPublish` to package names that don't exist on npm yet.
537+
* Skips packages not going through the standard npm publish flow.
538+
*/
539+
async function findPackagesMissingFromNpm(
540+
toPublish: PlannedRelease[],
541+
packages: Map<string, WorkspacePackage>,
542+
): Promise<string[]> {
543+
const missing: string[] = [];
544+
await Promise.all(
545+
toPublish.map(async (release) => {
546+
const pkg = packages.get(release.name)!;
547+
const pkgConfig = pkg.bumpy || {};
548+
if (pkgConfig.publishCommand || pkgConfig.skipNpmPublish) return;
549+
if (pkg.private && !pkgConfig.publishCommand) return;
550+
const exists = await packageExistsOnNpm(release.name, pkgConfig.registry);
551+
if (!exists) missing.push(release.name);
552+
}),
553+
);
554+
return missing;
555+
}

packages/bumpy/src/core/git.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,21 @@ export function pushWithTags(opts?: { cwd?: string }): void {
1616
});
1717
}
1818

19+
/**
20+
* Force-push a single tag to origin, using BUMPY_GH_TOKEN if available.
21+
*
22+
* Force is required because `gh release create --draft --target SHA` creates
23+
* the tag on the remote at draft-creation time. If a previous publish attempt
24+
* failed and HEAD has since moved, the remote tag points at the stale SHA —
25+
* `git push --tags` would reject. The caller is responsible for ensuring the
26+
* local tag is at the correct SHA (i.e. only call after a successful publish).
27+
*/
28+
export function forcePushTag(tag: string, opts?: { cwd?: string }): void {
29+
withGitToken(opts?.cwd, () => {
30+
runArgs(['git', 'push', 'origin', `refs/tags/${tag}`, '--force'], opts);
31+
});
32+
}
33+
1934
/**
2035
* Temporarily configure git credentials using BUMPY_GH_TOKEN (or GH_TOKEN),
2136
* execute a callback, then restore the original config.

packages/bumpy/src/core/publish-pipeline.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,30 @@ export interface PublishResult {
3030
* - GitLab CI: `GITLAB_CI` + `NPM_ID_TOKEN`
3131
* - CircleCI: `CIRCLECI` + `NPM_ID_TOKEN`
3232
*/
33-
function detectOidcProvider(): 'github-actions' | 'gitlab' | 'circleci' | null {
33+
export function detectOidcProvider(): 'github-actions' | 'gitlab' | 'circleci' | null {
3434
if (process.env.ACTIONS_ID_TOKEN_REQUEST_URL) return 'github-actions';
3535
if (process.env.GITLAB_CI && process.env.NPM_ID_TOKEN) return 'gitlab';
3636
if (process.env.CIRCLECI && process.env.NPM_ID_TOKEN) return 'circleci';
3737
return null;
3838
}
3939

40+
/**
41+
* Returns true when OIDC trusted publishing is the only available npm auth path:
42+
* an OIDC provider is detected AND no token env vars or .npmrc auth are present.
43+
*
44+
* Used to gate checks that only matter when OIDC will definitely be used — e.g.
45+
* erroring when a brand-new package can't be bootstrapped via trusted publishing.
46+
* Detection alone is leaky (id-token: write is also set for provenance), so this
47+
* helper avoids false positives when a token fallback exists.
48+
*/
49+
export function willUseOidcExclusively(rootDir: string): boolean {
50+
if (!detectOidcProvider()) return false;
51+
if (process.env.NPM_TOKEN || process.env.NODE_AUTH_TOKEN) return false;
52+
const npmrcPath = resolve(rootDir, '.npmrc');
53+
const existingNpmrc = existsSync(npmrcPath) ? readFileSync(npmrcPath, 'utf-8') : '';
54+
return !existingNpmrc.includes(':_authToken=');
55+
}
56+
4057
const OIDC_NPM_UPGRADE_HINTS: Record<string, string> = {
4158
'github-actions': 'Add `actions/setup-node@v6` with `node-version: lts/*` to your workflow',
4259
gitlab: 'Use a Node.js image with npm >= 11.5.1 or run `npm install -g npm@latest`',

0 commit comments

Comments
 (0)