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/monorepo-preview-release/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ For each PR:
- Each package gets its version bumped to `<current>-git-<short-sha>` (prerelease).
- Each package is published to npm under the dist-tag `pr-<pr-number>`.
- Private packages (`"private": true`) are excluded.
- For a package's **first ever publish**, npm force-assigns the `latest` dist-tag to that version even with a custom `--tag`. The action detects these first-time publishes 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.

## Usage
Expand Down Expand Up @@ -52,7 +53,27 @@ The `oidc-with-token-fallback` mode exists to handle **first-time publishes** of

## 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 packages to `vland-bot` (`POST <vland_bot_url>/v1/github/preview-release`). Each 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 first-time packages (`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

Expand All @@ -61,9 +82,12 @@ The action shells out to the real `pnpm` CLI for the heavy lifting:
- `pnpm list -r --json --depth 0` to enumerate the workspace.
- `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 first-time publishes.

`pnpm publish` takes care of `workspace:*` and `catalog:` resolution, `publishConfig` overrides, lifecycle scripts (`prepublishOnly`, `prepack`, `prepare`), and the npm Trusted Publisher OIDC exchange. This action just orchestrates which packages get bumped 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 commands (`pnpm list`, `pnpm view`) stay silent — their 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
10 changes: 5 additions & 5 deletions actions/monorepo-preview-release/dist/index.js

Large diffs are not rendered by default.

42 changes: 35 additions & 7 deletions actions/monorepo-preview-release/src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ 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 type { Octokit } from "./types.ts";
import {
formatError,
Expand All @@ -11,12 +10,14 @@ import {
getPrChangedFiles,
getWorkspacesPackages,
isUnpublished,
runLogged,
type WorkspacePackage,
} from "./utils.ts";

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

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

async function bumpPackage(pkg: WorkspacePackage, 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 @@ -54,9 +56,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: WorkspacePackage, tag: string, mode: PublishMode): Promise<void> {
Expand All @@ -83,6 +86,20 @@ async function publishPackage(pkg: WorkspacePackage, tag: string, mode: PublishM
if (!r2.ok) throw new Error(`Failed to publish ${pkg.name} (fallback): ${r2.stderr}`);
}

async function demoteAutoLatest(pkg: WorkspacePackage, 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 @@ -141,6 +158,11 @@ export async function publishPackages(options: Options): Promise<PublishResults>
const packagesToPublish = await getPackagesToPublish(changed, allPackages);
const nextVersions = new Map<string, string>();

const firstTime = new Set<string>();
for (const pkg of packagesToPublish) {
if (await isUnpublished(pkg.name)) firstTime.add(pkg.name);
}

try {
const preid = `git-${latestCommitSha.substring(0, 7)}`;
for (const pkg of packagesToPublish) {
Expand All @@ -150,18 +172,24 @@ export async function publishPackages(options: Options): Promise<PublishResults>
throw new Error(`Failed to bump packages: ${formatError(cause).message}`);
}

const tag = getPublishTag(prNumber);

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

for (const pkg of packagesToPublish) {
if (firstTime.has(pkg.name)) await demoteAutoLatest(pkg, tag);
}

return Array.from(nextVersions.entries()).map(([packageName, nextVersion]) => ({
packageName,
nextVersion,
firstTime: firstTime.has(packageName),
}));
});
}
2 changes: 2 additions & 0 deletions actions/monorepo-preview-release/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ try {
const npmToken = core.getInput("npm_token") || undefined;
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
19 changes: 19 additions & 0 deletions actions/monorepo-preview-release/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,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 };
}

// pnpm list -r --json --depth 0
export async function getWorkspacesPackages(cwd: string = process.cwd()): Promise<WorkspacePackage[]> {
const result = await x("pnpm", ["list", "-r", "--json", "--depth", "0"], { nodeOptions: { cwd } });
Expand Down