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
26 changes: 25 additions & 1 deletion actions/preview-release/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ For each PR:
- The package in `working_directory` (default the repo root) gets its version bumped to `<current>-git-<short-sha>` (prerelease).
- The package is published to npm under the dist-tag `pr-<pr-number>`.
- Private packages (`"private": true`) are skipped.
- For the package's **first ever publish**, npm force-assigns the `latest` dist-tag to that version even with a custom `--tag`. The action detects a first-time publish and removes the auto-assigned `latest` afterwards (best-effort), so a preview build never becomes the default `pnpm add <pkg>` target. See [First-time publishes & `latest`](#first-time-publishes--latest).
- Once publishing finishes, the action calls the `vland-bot` server (authenticated with a GitHub OIDC token scoped to the `vland-bot` audience) so it can comment on the PR.

> Working in a pnpm monorepo? Use [`monorepo-preview-release`](../monorepo-preview-release/README.md) instead — it detects changed packages and their workspace dependents.
Expand Down Expand Up @@ -52,17 +53,40 @@ The `oidc-with-token-fallback` mode exists to handle the **first-time publish**

## Outputs

This action has no outputs. The PR comment is produced by `vland-bot`, not by the action itself.
This action has no GitHub Action outputs. The PR comment is produced by `vland-bot`, not by the action itself.

The action POSTs the published package to `vland-bot` (`POST <vland_bot_url>/v1/github/preview-release`). The entry is:

| Field | Type | Description |
| --- | --- | --- |
| `packageName` | `string` | The npm package name. |
| `nextVersion` | `string` | The published preview version (`<current>-git-<short-sha>`). |
| `firstTime` | `boolean` | `true` when this run published the package's very first version. `vland-bot` can use it to flag the preview in the comment (its `latest` tag was just removed, so the package is only installable via `@pr-<n>` or the exact version until a stable release). |

## First-time publishes & `latest`

npm requires every package to have a `latest` dist-tag, so the **first** version ever published becomes `latest` — even when `pnpm publish --tag pr-<n>` is used. Without intervention, a brand-new package's preview build would silently become the default that `pnpm add <pkg>` (no tag) resolves to.

After publishing, the action removes that auto-assigned `latest` for a first-time package (`pnpm dist-tag rm <pkg> latest`). The result:

- Only the `pr-<n>` tag remains, so `pnpm add <pkg>@pr-<n>` and installs by exact version keep working.
- `pnpm add <pkg>` (no tag) **fails loudly** instead of silently installing a PR build.
- The first real release (via the normal release pipeline) re-establishes a proper stable `latest`.

This step is best-effort: if the registry refuses the removal, the action logs a warning rather than failing the run, and the package is still reported to `vland-bot` with `firstTime: true`.

## How publishing works

The action shells out to the real `pnpm` CLI for the heavy lifting:

- `pnpm version prerelease --preid git-<sha> --no-git-tag-version` to bump.
- `pnpm publish --tag pr-<n> --no-git-checks [--provenance]` to publish.
- `pnpm dist-tag rm <pkg> latest` to undo the auto-assigned `latest` on a first-time publish.

`pnpm publish` takes care of `publishConfig` overrides, lifecycle scripts (`prepublishOnly`, `prepack`, `prepare`), and the npm Trusted Publisher OIDC exchange. This action just orchestrates the bump and the auth-mode selection.

The mutating commands (`pnpm version`, `pnpm publish`, `pnpm dist-tag`) stream their combined output to the Actions log inside collapsible groups, so a run's progress is visible. The JSON query command (`pnpm view`) stays silent — its output is parsed, not displayed.

## Requirements on the calling workflow

Because `pnpm publish` is invoked from inside the action, the caller's job needs:
Expand Down
2 changes: 1 addition & 1 deletion actions/preview-release/dist/index.js

Large diffs are not rendered by default.

39 changes: 30 additions & 9 deletions actions/preview-release/src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ import { existsSync } from "node:fs";
import { readFile, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import * as core from "@actions/core";
import { x } from "tinyexec";
import { formatError, getPackage, isPublishable, isUnpublished, type Package } from "./utils.ts";
import { formatError, getPackage, isPublishable, isUnpublished, type Package, runLogged } from "./utils.ts";

export type PublishResults = Array<{
packageName: string;
nextVersion: string;
firstTime: boolean;
}>;

type Options = {
Expand All @@ -25,11 +25,12 @@ const PublishMode = {
type PublishMode = (typeof PublishMode)[keyof typeof PublishMode];

async function bumpPackage(pkg: Package, preid: string): Promise<string> {
const result = await x("pnpm", ["version", "prerelease", "--preid", preid, "--no-git-tag-version"], {
nodeOptions: { cwd: pkg.path },
const result = await runLogged("pnpm", ["version", "prerelease", "--preid", preid, "--no-git-tag-version"], {
cwd: pkg.path,
group: `pnpm version: ${pkg.name}`,
});
if (result.exitCode !== 0) {
throw new Error(`pnpm version failed for ${pkg.name} (exit ${result.exitCode}): ${result.stderr || result.stdout}`);
throw new Error(`pnpm version failed for ${pkg.name} (exit ${result.exitCode}): ${result.output}`);
}
const manifest = JSON.parse(await readFile(path.join(pkg.path, "package.json"), "utf8")) as { version: string };
return manifest.version;
Expand All @@ -42,9 +43,10 @@ async function publishOnce(
): Promise<{ ok: true } | { ok: false; stderr: string }> {
const args = ["publish", "--tag", tag, "--access", "public", "--no-git-checks"];
if (opts.provenance) args.push("--provenance");
const result = await x("pnpm", args, { nodeOptions: { cwd: pkg.path } });
const label = opts.provenance ? `${pkg.name} (provenance)` : pkg.name;
const result = await runLogged("pnpm", args, { cwd: pkg.path, group: `pnpm publish: ${label}` });
if (result.exitCode === 0) return { ok: true };
return { ok: false, stderr: result.stderr || result.stdout };
return { ok: false, stderr: result.output };
}

async function publishPackage(pkg: Package, tag: string, mode: PublishMode): Promise<void> {
Expand All @@ -71,6 +73,20 @@ async function publishPackage(pkg: Package, tag: string, mode: PublishMode): Pro
if (!r2.ok) throw new Error(`Failed to publish ${pkg.name} (fallback): ${r2.stderr}`);
}

async function demoteAutoLatest(pkg: Package, tag: string): Promise<void> {
const result = await runLogged("pnpm", ["dist-tag", "rm", pkg.name, "latest"], {
group: `pnpm dist-tag rm latest: ${pkg.name}`,
});
if (result.exitCode === 0) {
core.info(`Removed npm's auto-assigned "latest" from first-time package ${pkg.name}; only the "${tag}" tag remains.`);
return;
}
core.warning(
`${pkg.name}: npm set "latest" to the preview version on first publish and removing it failed ` +
`(${result.output.trim()}). "latest" now points to a PR build until a stable release re-points it.`,
);
}

function detectMode(workspaceDir: string, hasNpmToken: boolean): PublishMode {
const npmrcPath = path.join(workspaceDir, ".npmrc");
if (existsSync(npmrcPath)) return PublishMode.TOKEN_ONLY;
Expand Down Expand Up @@ -123,6 +139,8 @@ export async function release(options: Options): Promise<PublishResults> {
return [];
}

const firstTime = await isUnpublished(pkg.name);

let nextVersion: string;
try {
const preid = `git-${latestCommitSha.substring(0, 7)}`;
Expand All @@ -131,13 +149,16 @@ export async function release(options: Options): Promise<PublishResults> {
throw new Error(`Failed to bump package: ${formatError(cause).message}`);
}

const tag = getPublishTag(prNumber);

try {
const tag = getPublishTag(prNumber);
await publishPackage(pkg, tag, mode);
} catch (cause) {
throw new Error(`Failed to publish package: ${formatError(cause).message}`);
}

return [{ packageName: pkg.name, nextVersion }];
if (firstTime) await demoteAutoLatest(pkg, tag);

return [{ packageName: pkg.name, nextVersion, firstTime }];
});
}
2 changes: 2 additions & 0 deletions actions/preview-release/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ try {
const workingDirectory = core.getInput("working_directory") || ".";
const vlandBotUrl = core.getInput("vland_bot_url") || "https://bot.variable.land";

if (npmToken) core.setSecret(npmToken);

const prNumber = github.context.payload.pull_request?.number;
const latestCommitSha = github.context.payload.pull_request?.head?.sha;

Expand Down
20 changes: 20 additions & 0 deletions actions/preview-release/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import * as core from "@actions/core";
import { x } from "tinyexec";

export type Package = {
Expand All @@ -13,6 +14,25 @@ export function formatError(cause: unknown): Error {
return cause instanceof Error ? cause : new Error("Unknown error");
}

export async function runLogged(
cmd: string,
args: string[],
opts: { cwd?: string; group: string },
): Promise<{ exitCode: number; output: string }> {
const proc = x(cmd, args, { nodeOptions: opts.cwd ? { cwd: opts.cwd } : {} });
core.startGroup(opts.group);
let output = "";
try {
for await (const line of proc) {
output += `${line}\n`;
core.info(line);
}
} finally {
core.endGroup();
}
return { exitCode: proc.exitCode ?? 1, output };
}

export async function getPackage(cwd: string): Promise<Package> {
const manifestPath = path.join(cwd, "package.json");
const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as {
Expand Down