Compare commits

...
Author SHA1 Message Date
Mateusz Burzyński 79d401e0bd Staged publishing support 2026-07-23 13:21:42 +02:00
18 changed files with 916 additions and 25 deletions
+7
View File
@@ -0,0 +1,7 @@
---
"@changesets/action": minor
---
Add staged publishing support to the publish action and a GitHub release action
that validates the staged handoff, verifies approved packages, and creates tags
and releases idempotently.
+1
View File
@@ -30,6 +30,7 @@ There are also sub-actions hosted in this repository. Check out their respective
- published - A boolean value to indicate whether a publishing has happened or not
- published-packages - A JSON array to present the published packages. The format is `[{"name": "@xx/xx", "version": "1.2.0"}, {"name": "@xx/xy", "version": "0.8.9"}]`
- staged-release-artifact-id - The exact artifact id for staged packages emitted by a configured or custom publish command
### Example workflow
+2
View File
@@ -56,6 +56,8 @@ outputs:
published-packages:
description: >
A JSON array to present the published packages. The format is `[{"name": "@xx/xx", "version": "1.2.0"}, {"name": "@xx/xy", "version": "0.8.9"}]`
staged-release-artifact-id:
description: Artifact id for a staged release handoff emitted by the publish script
has-changesets:
description: A boolean about whether there were changesets. Useful if you want to create your own publishing functionality.
pr-number:
+13
View File
@@ -0,0 +1,13 @@
# changesets/action/github-release
Finalizes packages produced by staged publishing. Pass the exact
`staged-release-artifact-id` output from `changesets/action/publish`.
By default the action waits up to two minutes for every approved package
version to become visible before creating any Git tag or GitHub Release.
Private registries therefore need read authentication configured before this
step. Set `verify-published: false` to skip that check.
The handoff is bound to the repository, workflow run, and commit. Tags and
releases are created idempotently; a tag that already points at another commit
is rejected.
+23
View File
@@ -0,0 +1,23 @@
name: Changesets - GitHub Release
description: Create Git tags and GitHub Releases after approving a staged publish
runs:
using: node24
main: ../dist/github-release.js
inputs:
github-token:
description: "The GitHub token to use for authentication. Defaults to the GitHub-provided token."
required: false
default: ${{ github.token }}
staged-release-artifact-id:
description: "The exact artifact id emitted by the publish action"
required: true
verify-published:
description: "Verify that every approved package is visible in its configured registry before creating tags and releases"
required: false
default: true
outputs:
released-packages:
description: "A JSON array of packages finalized by this action"
branding:
icon: package
color: blue
+22 -1
View File
@@ -1,3 +1,24 @@
# changesets/action/publish
TODO
Publishes packages with Changesets.
Set the optional `stage` input to `true` or `false` to override
`stagedPublishing` from the Changesets config. The input is tri-state: omitting
it leaves the config in control. An explicit `stage` cannot be combined with a
custom `script`; configure the script itself instead.
When packages are staged, the action uploads a 30-day handoff and returns its
exact id as `staged-release-artifact-id`. It also prints a topologically
ordered approval command:
```sh
changeset stage approve <id...>
```
That command is only a convenience and uses the default registry. For custom
or multiple registries, split the IDs into correctly scoped commands and pass
`--registry` to each.
After approval, pass the artifact id to
`changesets/action/github-release` to verify publication and create Git tags
and GitHub Releases.
+8
View File
@@ -11,6 +11,12 @@ inputs:
script:
description: "The command to use to publish packages"
required: false
stage:
description: >
Whether to use staged publishing. Omit to use the Changesets config,
set to true for --stage, or false for --no-stage. Cannot be combined
with a custom script.
required: false
pack-dir-artifact-id:
description: "Artifact id for packed publish output generated by the pack subaction"
required: false
@@ -30,6 +36,8 @@ outputs:
published-packages:
description: >
A JSON array to present the published packages. The format is `[{"name": "@xx/xx", "version": "1.2.0"}, {"name": "@xx/xy", "version": "0.8.9"}]`
staged-release-artifact-id:
description: "Artifact id for the staged release handoff"
branding:
icon: package
color: blue
+1
View File
@@ -9,6 +9,7 @@ export default defineConfig({
"select-mode": "src/select-mode/index.ts",
version: "src/version/index.ts",
publish: "src/publish/index.ts",
"github-release": "src/github-release/index.ts",
},
output: {
dir: "dist",
+46
View File
@@ -0,0 +1,46 @@
import * as core from "@actions/core";
import { GitHub } from "../github.ts";
import {
downloadAndValidateStagedRelease,
finalizeStagedRelease,
} from "../staged-release.ts";
import { getOptionalBooleanInput, getRequiredInput } from "../utils.ts";
try {
await main();
} catch (error) {
core.setFailed((error as Error).message);
}
async function main() {
const cwd = process.cwd();
const github = new GitHub({
cwd,
githubToken: getRequiredInput("github-token"),
commitMode: "github-api",
});
const artifactId = Number(getRequiredInput("staged-release-artifact-id"));
const verify = getOptionalBooleanInput("verify-published") ?? true;
const { handoff, packagesByName } = await downloadAndValidateStagedRelease(
artifactId,
cwd,
);
await finalizeStagedRelease({
github,
handoff,
packagesByName,
cwd,
verify,
});
core.setOutput(
"released-packages",
JSON.stringify(
handoff.releases.map((release) => ({
name: release.packageName,
version: release.version,
})),
),
);
}
+9
View File
@@ -3,6 +3,7 @@ import * as core from "@actions/core";
import { GitHub } from "./github.ts";
import readChangesetState from "./readChangesetState.ts";
import { runPublish, runVersion } from "./run.ts";
import { uploadStagedRelease } from "./staged-release.ts";
import {
fileExists,
getOptionalInput,
@@ -148,6 +149,14 @@ import {
cwd,
});
if (result.stagedPackages?.length) {
const artifactId = await uploadStagedRelease(
result.stagedPackages,
result.exitCode === 0 ? "approve" : "reject",
);
core.setOutput("staged-release-artifact-id", String(artifactId));
}
if (result.published) {
core.setOutput("published", "true");
core.setOutput(
+15
View File
@@ -3,12 +3,15 @@ import os from "node:os";
import * as core from "@actions/core";
import { GitHub } from "../github.ts";
import { runPublish } from "../run.ts";
import { uploadStagedRelease } from "../staged-release.ts";
import {
downloadArtifact,
getOptionalBooleanInput,
getOptionalInput,
getRequiredInput,
validateChangesetsCliVersion,
} from "../utils.ts";
import { assertValidStageInput } from "./options.ts";
try {
await main();
@@ -23,10 +26,13 @@ async function main() {
const githubToken = getRequiredInput("github-token");
const script = getOptionalInput("script");
const stage = getOptionalBooleanInput("stage");
const packDirArtifactId = getOptionalInput("pack-dir-artifact-id");
const createGithubReleases = core.getBooleanInput("create-github-releases");
const pushGitTags = core.getBooleanInput("push-git-tags");
assertValidStageInput(stage, script);
if (createGithubReleases && !pushGitTags) {
throw new Error(
"The input 'create-github-releases' is set to true, but 'push-git-tags' is set to false. " +
@@ -48,6 +54,7 @@ async function main() {
const result = await runPublish({
script,
stage,
github,
createGithubReleases,
pushGitTags,
@@ -55,6 +62,14 @@ async function main() {
fromPackDir,
});
if (result.stagedPackages?.length) {
const artifactId = await uploadStagedRelease(
result.stagedPackages,
result.exitCode === 0 ? "approve" : "reject",
);
core.setOutput("staged-release-artifact-id", String(artifactId));
}
if (result.published) {
core.setOutput("published", "true");
core.setOutput(
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { assertValidStageInput } from "./options.ts";
describe("publish stage input", () => {
it("rejects an explicit stage override with a custom script", () => {
expect(() => assertValidStageInput(false, "pnpm release")).toThrow(
"cannot be combined with a custom 'script'",
);
});
it("allows an omitted stage input with a custom script", () => {
expect(() =>
assertValidStageInput(undefined, "pnpm release"),
).not.toThrow();
});
});
+10
View File
@@ -0,0 +1,10 @@
export function assertValidStageInput(
stage: boolean | undefined,
script: string | undefined,
) {
if (stage !== undefined && script) {
throw new Error(
"The 'stage' input cannot be combined with a custom 'script'. Configure staged publishing inside the script instead.",
);
}
}
+97 -1
View File
@@ -5,7 +5,7 @@ import { writeChangeset } from "@changesets/write";
import { createFixture } from "fs-fixture";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { GitHub } from "./github.ts";
import { runPublish, runVersion } from "./run.ts";
import { readChangesetsOutput, runPublish, runVersion } from "./run.ts";
vi.mock("@actions/github", () => ({
context: {
@@ -110,6 +110,102 @@ afterEach(() => {
});
describe("publish", () => {
it("parses staged publish events in emitted order", async () => {
await using fixture = await createFixture({
"changesets-output.ndjson": [
JSON.stringify({
type: "npm-stage",
packageName: "pkg-b",
version: "1.0.0",
tag: "latest",
gitTag: "pkg-b@1.0.0",
stageId: "stage-b",
}),
JSON.stringify({ type: "unknown" }),
JSON.stringify({
type: "npm-stage",
packageName: "pkg-a",
version: "1.0.0",
tag: "latest",
gitTag: "pkg-a@1.0.0",
stageId: "stage-a",
}),
].join("\n"),
});
await expect(
readChangesetsOutput(path.join(fixture.path, "changesets-output.ndjson")),
).resolves.toMatchObject([
{ type: "npm-stage", stageId: "stage-b" },
{ type: "npm-stage", stageId: "stage-a" },
]);
});
it("returns staged packages from a custom publish script", async () => {
await using fixture = await createSimpleProjectFixture();
const cwd = fixture.path;
vi.stubEnv("RUNNER_TEMP", cwd);
await fixture.writeFile(
"write-output.cjs",
`require("node:fs").writeFileSync(process.env.CHANGESETS_OUTPUT, JSON.stringify({
type: "npm-stage",
packageName: "changesets-dev-simple-project-pkg-b",
version: "1.0.0",
tag: "latest",
gitTag: "changesets-dev-simple-project-pkg-b@1.0.0",
stageId: "stage-b"
}) + "\\n")`,
);
const result = await runPublish({
script: "node write-output.cjs",
github: createGithub(cwd),
createGithubReleases: false,
pushGitTags: false,
cwd,
});
expect(result).toMatchObject({
published: false,
exitCode: 0,
stagedPackages: [{ type: "npm-stage", stageId: "stage-b" }],
});
expect(mockedGithubMethods.repos.createRelease).not.toHaveBeenCalled();
});
it("passes an explicit stage override to the built-in CLI", async () => {
await using fixture = await createFixture({
"node_modules/@changesets/cli/package.json": JSON.stringify({
name: "@changesets/cli",
type: "module",
}),
"node_modules/@changesets/cli/bin.js": `
import fs from "node:fs";
fs.writeFileSync("args.json", JSON.stringify(process.argv.slice(2)));
fs.writeFileSync(process.env.CHANGESETS_OUTPUT, "");
`,
"package.json": JSON.stringify({
name: "simple-project",
version: "1.0.0",
}),
"package-lock.json": "",
});
const cwd = fixture.path;
vi.stubEnv("RUNNER_TEMP", cwd);
await runPublish({
stage: false,
github: createGithub(cwd),
createGithubReleases: false,
pushGitTags: false,
cwd,
});
await expect(
fixture.readFile("args.json", "utf8").then(JSON.parse),
).resolves.toEqual(["publish", "--no-stage"]);
});
it("warns when a custom publish script does not create the output file", async () => {
await using fixture = await createSimpleProjectFixture();
const cwd = fixture.path;
+76 -23
View File
@@ -31,7 +31,7 @@ import {
// To avoid that, we ensure to cap the message to 60k chars.
const MAX_CHARACTERS_PER_MESSAGE = 60000;
const createRelease = async (
export const createRelease = async (
octokit: Octokit,
{ pkg, tagName }: { pkg: Package; tagName: string },
) => {
@@ -66,6 +66,7 @@ const createRelease = async (
type PublishOptions = {
script?: string;
fromPackDir?: string;
stage?: boolean;
createGithubReleases: boolean;
pushGitTags: boolean;
github: GitHub;
@@ -73,11 +74,25 @@ type PublishOptions = {
};
type PublishedPackage = { name: string; version: string };
type ChangesetsOutputEvent = {
type: "git-tag";
tag: string;
packageName: string;
};
export type ChangesetsOutputEvent =
| {
type: "git-tag";
tag: string;
packageName: string;
}
| {
type: "npm-stage";
packageName: string;
version: string;
tag: string;
gitTag: string;
stageId: string;
};
export type NpmStageEvent = Extract<
ChangesetsOutputEvent,
{ type: "npm-stage" }
>;
class ChangesetsOutputReadError extends Error {}
@@ -85,10 +100,12 @@ type PublishResult =
| {
published: true;
publishedPackages: PublishedPackage[];
stagedPackages?: NpmStageEvent[];
exitCode: number;
}
| {
published: false;
stagedPackages?: NpmStageEvent[];
exitCode: number;
};
@@ -99,18 +116,32 @@ function isObject(value: unknown) {
function isChangesetsOutputEvent(
value: unknown,
): value is ChangesetsOutputEvent {
return (
isObject(value) &&
"type" in value &&
if (!isObject(value) || !("type" in value)) return false;
if (
value.type === "git-tag" &&
"tag" in value &&
typeof value.tag === "string" &&
"packageName" in value &&
typeof value.packageName === "string"
) {
return true;
}
return (
value.type === "npm-stage" &&
"packageName" in value &&
typeof value.packageName === "string" &&
"version" in value &&
typeof value.version === "string" &&
"tag" in value &&
typeof value.tag === "string" &&
"gitTag" in value &&
typeof value.gitTag === "string" &&
"stageId" in value &&
typeof value.stageId === "string"
);
}
async function readChangesetsOutput(outputPath: string) {
export async function readChangesetsOutput(outputPath: string) {
let rawOutput: string;
try {
rawOutput = await fs.readFile(outputPath, "utf8");
@@ -158,6 +189,7 @@ async function readChangesetsOutput(outputPath: string) {
export async function runPublish({
script,
fromPackDir,
stage,
github,
createGithubReleases,
pushGitTags,
@@ -187,6 +219,9 @@ export async function runPublish({
);
} else {
const args = ["publish"];
if (stage !== undefined) {
args.push(stage ? "--stage" : "--no-stage");
}
if (fromPackDir) {
args.push("--from-pack-dir", fromPackDir);
}
@@ -196,8 +231,13 @@ export async function runPublish({
);
}
let { packages, tool } = await getPackages(cwd);
let packagesByName = new Map(packages.map((x) => [x.packageJson.name, x]));
let { packages, rootPackage, tool } = await getPackages(cwd);
let packagesByName = new Map(
[...packages, ...(rootPackage ? [rootPackage] : [])].map((x) => [
x.packageJson.name,
x,
]),
);
let output: ChangesetsOutputEvent[];
try {
output = await readChangesetsOutput(outputFile);
@@ -210,16 +250,24 @@ export async function runPublish({
);
output = [];
}
let releases = output.map((event) => {
let pkg = packagesByName.get(event.packageName);
if (pkg === undefined) {
throw new Error(
`Package "${event.packageName}" not found.` +
"This is probably a bug in the action, please open an issue",
);
}
return { pkg, tag: event.tag };
});
const stagedPackages = output.filter(
(event): event is NpmStageEvent => event.type === "npm-stage",
);
let releases = output
.filter(
(event): event is Extract<ChangesetsOutputEvent, { type: "git-tag" }> =>
event.type === "git-tag",
)
.map((event) => {
let pkg = packagesByName.get(event.packageName);
if (pkg === undefined) {
throw new Error(
`Package "${event.packageName}" not found.` +
"This is probably a bug in the action, please open an issue",
);
}
return { pkg, tag: event.tag };
});
if (tool.type === "root" && packages.length === 0) {
throw new Error(
@@ -248,11 +296,16 @@ export async function runPublish({
name: pkg.packageJson.name,
version: pkg.packageJson.version,
})),
...(stagedPackages.length > 0 ? { stagedPackages } : {}),
exitCode: changesetPublishOutput.exitCode,
};
}
return { published: false, exitCode: changesetPublishOutput.exitCode };
return {
published: false,
...(stagedPackages.length > 0 ? { stagedPackages } : {}),
exitCode: changesetPublishOutput.exitCode,
};
}
type GetMessageOptions = {
+218
View File
@@ -0,0 +1,218 @@
import * as path from "node:path";
import { getExecOutput } from "@actions/exec";
import type { Package } from "@manypkg/get-packages";
import { createFixture } from "fs-fixture";
import { describe, expect, it, vi } from "vitest";
import type { GitHub } from "./github.ts";
import {
createStagedReleaseHandoff,
finalizeStagedRelease,
formatStageCommand,
validateStagedReleaseHandoff,
verifyPublished,
type StagedReleaseEntry,
} from "./staged-release.ts";
vi.mock("@actions/github", () => ({
context: {
repo: { owner: "changesets", repo: "action" },
runId: 123,
sha: "abc123",
},
}));
vi.mock("@actions/exec", async (importOriginal) => ({
...(await importOriginal<typeof import("@actions/exec")>()),
getExecOutput: vi.fn(),
}));
const entry: StagedReleaseEntry = {
packageName: "pkg-a",
version: "1.0.0",
tag: "latest",
gitTag: "pkg-a@1.0.0",
stageId: "1de6f3db-2ed9-4d72-b3dd-8f0e2b474a2f",
};
async function createProject() {
return createFixture({
"package.json": JSON.stringify({
name: "repo",
private: true,
workspaces: ["packages/*"],
}),
"package-lock.json": "",
"packages/pkg-a/package.json": JSON.stringify({
name: "pkg-a",
version: "1.0.0",
}),
"packages/pkg-b/package.json": JSON.stringify({
name: "pkg-b",
version: "1.0.0",
}),
});
}
describe("staged release handoff", () => {
it("prints stage ids in their topological event order", () => {
expect(
formatStageCommand(
[
{ type: "npm-stage", ...entry, stageId: "stage-b" },
{ type: "npm-stage", ...entry, stageId: "stage-a" },
],
"approve",
),
).toBe("changeset stage approve stage-b stage-a");
});
it("prints rejection recovery for a partial publish failure", () => {
expect(
formatStageCommand(
[{ type: "npm-stage", ...entry, stageId: "stage-a" }],
"reject",
),
).toBe("changeset stage reject stage-a");
});
it("validates origin, manifests, tags, and preserves order", async () => {
await using fixture = await createProject();
const handoff = createStagedReleaseHandoff(
[
{ type: "npm-stage", ...entry },
{
type: "npm-stage",
...entry,
packageName: "pkg-b",
gitTag: "pkg-b@1.0.0",
stageId: "2de6f3db-2ed9-4d72-b3dd-8f0e2b474a2f",
},
],
{ repository: "changesets/action", runId: 123, sha: "abc123" },
);
const result = await validateStagedReleaseHandoff(handoff, fixture.path, {
repository: "changesets/action",
runId: 123,
sha: "abc123",
});
expect(result.handoff.releases.map((release) => release.stageId)).toEqual([
"1de6f3db-2ed9-4d72-b3dd-8f0e2b474a2f",
"2de6f3db-2ed9-4d72-b3dd-8f0e2b474a2f",
]);
});
it("rejects a handoff from another workflow run", async () => {
await using fixture = await createProject();
const handoff = createStagedReleaseHandoff(
[{ type: "npm-stage", ...entry }],
{ repository: "changesets/action", runId: 122, sha: "abc123" },
);
await expect(
validateStagedReleaseHandoff(handoff, fixture.path, {
repository: "changesets/action",
runId: 123,
sha: "abc123",
}),
).rejects.toThrow("do not match this workflow run");
});
it("verifies an exact version through the configured package manager", async () => {
await using fixture = await createProject();
vi.mocked(getExecOutput).mockResolvedValue({
exitCode: 0,
stdout: JSON.stringify("1.0.0"),
stderr: "",
} as never);
await verifyPublished([entry], fixture.path, { timeoutMs: 0 });
expect(getExecOutput).toHaveBeenCalledWith(
"npm",
["view", "pkg-a@1.0.0", "version", "--json"],
expect.objectContaining({ cwd: fixture.path }),
);
});
it("accepts existing correct tags and releases without writes", async () => {
const getRef = vi.fn().mockResolvedValue({
data: { object: { sha: "abc123" } },
});
const createRef = vi.fn();
const getReleaseByTag = vi.fn().mockResolvedValue({ data: { id: 1 } });
const createRelease = vi.fn();
const github = {
octokit: {
rest: {
git: { getRef, createRef },
repos: { getReleaseByTag, createRelease },
},
},
} as unknown as GitHub;
const pkg = {
dir: path.join("/repo", "packages/pkg-a"),
relativeDir: "packages/pkg-a",
packageJson: { name: "pkg-a", version: "1.0.0" },
} satisfies Package;
await finalizeStagedRelease({
github,
handoff: {
version: 1,
repository: "changesets/action",
runId: 123,
sha: "abc123",
releases: [entry],
},
packagesByName: new Map([["pkg-a", pkg]]),
cwd: "/repo",
verify: false,
});
expect(createRef).not.toHaveBeenCalled();
expect(createRelease).not.toHaveBeenCalled();
});
it("rejects an existing tag at another commit", async () => {
const github = {
octokit: {
rest: {
git: {
getRef: vi.fn().mockResolvedValue({
data: { object: { sha: "wrong" } },
}),
createRef: vi.fn(),
},
repos: {
getReleaseByTag: vi.fn(),
createRelease: vi.fn(),
},
},
},
} as unknown as GitHub;
await expect(
finalizeStagedRelease({
github,
handoff: {
version: 1,
repository: "changesets/action",
runId: 123,
sha: "abc123",
releases: [entry],
},
packagesByName: new Map([
[
"pkg-a",
{
dir: "/repo/pkg-a",
relativeDir: "pkg-a",
packageJson: { name: "pkg-a", version: "1.0.0" },
} satisfies Package,
],
]),
cwd: "/repo",
verify: false,
}),
).rejects.toThrow("expected abc123");
});
});
+342
View File
@@ -0,0 +1,342 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import artifact from "@actions/artifact";
import * as core from "@actions/core";
import { getExecOutput } from "@actions/exec";
import { context } from "@actions/github";
import { getPackages, type Package } from "@manypkg/get-packages";
import type { GitHub } from "./github.ts";
import { createRelease, type NpmStageEvent } from "./run.ts";
import { downloadArtifact } from "./utils.ts";
export const STAGED_RELEASE_VERSION = 1;
export const STAGED_RELEASE_FILENAME = "staged-release.json";
const STAGE_ID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export type StagedReleaseEntry = Omit<NpmStageEvent, "type">;
export type StagedReleaseHandoff = {
version: typeof STAGED_RELEASE_VERSION;
repository: string;
runId: number;
sha: string;
releases: StagedReleaseEntry[];
};
type HandoffIdentity = {
repository: string;
runId: number;
sha: string;
};
function currentIdentity(): HandoffIdentity {
return {
repository: `${context.repo.owner}/${context.repo.repo}`,
runId: context.runId,
sha: context.sha,
};
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isHttpNotFound(error: unknown) {
return (
isObject(error) &&
(error.status === 404 || error.code === 404 || error.code === "404")
);
}
function isStagedReleaseEntry(value: unknown): value is StagedReleaseEntry {
return (
isObject(value) &&
typeof value.packageName === "string" &&
value.packageName.length > 0 &&
typeof value.version === "string" &&
value.version.length > 0 &&
typeof value.tag === "string" &&
value.tag.length > 0 &&
typeof value.gitTag === "string" &&
value.gitTag.length > 0 &&
typeof value.stageId === "string" &&
STAGE_ID_PATTERN.test(value.stageId)
);
}
export function createStagedReleaseHandoff(
events: readonly NpmStageEvent[],
identity: HandoffIdentity = currentIdentity(),
): StagedReleaseHandoff {
return {
version: STAGED_RELEASE_VERSION,
...identity,
releases: events.map(({ type: _, ...event }) => event),
};
}
export function formatStageCommand(
events: readonly NpmStageEvent[],
operation: "approve" | "reject",
) {
return `changeset stage ${operation} ${events
.map((event) => event.stageId)
.join(" ")}`;
}
export async function uploadStagedRelease(
events: readonly NpmStageEvent[],
operation: "approve" | "reject",
): Promise<number> {
const verb = operation === "approve" ? "Approve" : "Reject";
core.info(`${verb} the staged packages in this order:
${formatStageCommand(events, operation)}`);
core.info(
"This command uses the default registry. For custom or multiple registries, split the IDs and pass --registry to each command.",
);
const tmpDir = process.env.RUNNER_TEMP ?? (await fs.realpath(os.tmpdir()));
const outDir = path.join(tmpDir, `changeset-staged-release-${Date.now()}`);
const handoffPath = path.join(outDir, STAGED_RELEASE_FILENAME);
await fs.mkdir(outDir, { recursive: true });
await fs.writeFile(
handoffPath,
`${JSON.stringify(createStagedReleaseHandoff(events), null, 2)}\n`,
);
const result = await artifact.uploadArtifact(
`changeset-staged-release-${context.runId}-${Date.now()}`,
[handoffPath],
outDir,
{ skipArchive: true, retentionDays: 30 },
);
if (result.id === undefined) {
throw new Error("Staged release artifact upload did not return an id");
}
return result.id;
}
function expectedGitTag(
tool: Awaited<ReturnType<typeof getPackages>>["tool"],
entry: StagedReleaseEntry,
) {
return tool.type === "root"
? `v${entry.version}`
: `${entry.packageName}@${entry.version}`;
}
export async function validateStagedReleaseHandoff(
value: unknown,
cwd: string,
identity: HandoffIdentity = currentIdentity(),
): Promise<{
handoff: StagedReleaseHandoff;
packagesByName: Map<string, Package>;
}> {
if (
!isObject(value) ||
value.version !== STAGED_RELEASE_VERSION ||
value.repository !== identity.repository ||
value.runId !== identity.runId ||
value.sha !== identity.sha ||
!Array.isArray(value.releases) ||
value.releases.length === 0 ||
!value.releases.every(isStagedReleaseEntry)
) {
throw new Error(
"Invalid staged release artifact: version, origin, or release entries do not match this workflow run",
);
}
const handoff = value as StagedReleaseHandoff;
const uniqueFields: Array<keyof StagedReleaseEntry> = [
"packageName",
"gitTag",
"stageId",
];
for (const field of uniqueFields) {
const values = handoff.releases.map((entry) => entry[field]);
if (new Set(values).size !== values.length) {
throw new Error(
`Invalid staged release artifact: duplicate ${field} value`,
);
}
}
const { packages, rootPackage, tool } = await getPackages(cwd);
const packagesByName = new Map(
[...packages, ...(rootPackage ? [rootPackage] : [])].map((pkg) => [
pkg.packageJson.name,
pkg,
]),
);
for (const entry of handoff.releases) {
const pkg = packagesByName.get(entry.packageName);
if (!pkg || pkg.packageJson.version !== entry.version) {
throw new Error(
`Invalid staged release artifact: ${entry.packageName}@${entry.version} does not match the checked-out manifest`,
);
}
if (entry.gitTag !== expectedGitTag(tool, entry)) {
throw new Error(
`Invalid staged release artifact: unexpected Git tag ${entry.gitTag}`,
);
}
}
return { handoff, packagesByName };
}
export async function downloadAndValidateStagedRelease(
artifactId: number,
cwd: string,
) {
const tmpDir = process.env.RUNNER_TEMP ?? (await fs.realpath(os.tmpdir()));
const artifactDir = await downloadArtifact(
tmpDir,
artifactId,
"changeset-staged-release",
);
const handoffPath = path.join(artifactDir, STAGED_RELEASE_FILENAME);
let value: unknown;
try {
value = JSON.parse(await fs.readFile(handoffPath, "utf8"));
} catch (error) {
throw new Error(`Failed to read staged release artifact ${artifactId}`, {
cause: error,
});
}
return validateStagedReleaseHandoff(value, cwd);
}
function outputContainsVersion(output: string, version: string) {
const values: unknown[] = [];
for (const line of output.split("\n")) {
if (!line.trim()) continue;
try {
values.push(JSON.parse(line));
} catch {
continue;
}
}
const contains = (value: unknown): boolean => {
if (value === version) return true;
if (Array.isArray(value)) return value.some(contains);
if (isObject(value)) return Object.values(value).some(contains);
return false;
};
return values.some(contains);
}
async function isPublished(
cwd: string,
tool: Awaited<ReturnType<typeof getPackages>>["tool"],
entry: StagedReleaseEntry,
) {
const spec = `${entry.packageName}@${entry.version}`;
const command =
tool.type === "pnpm" ? "pnpm" : tool.type === "yarn" ? "yarn" : "npm";
const args =
tool.type === "yarn"
? ["npm", "info", spec, "--fields", "version", "--json"]
: ["view", spec, "version", "--json"];
const result = await getExecOutput(command, args, {
cwd,
ignoreReturnCode: true,
silent: true,
env: process.env as Record<string, string>,
});
return (
result.exitCode === 0 && outputContainsVersion(result.stdout, entry.version)
);
}
export async function verifyPublished(
releases: readonly StagedReleaseEntry[],
cwd: string,
options: { timeoutMs?: number; intervalMs?: number } = {},
) {
const timeoutMs = options.timeoutMs ?? 120_000;
const intervalMs = options.intervalMs ?? 5_000;
const deadline = Date.now() + timeoutMs;
const { tool } = await getPackages(cwd);
while (true) {
const results = await Promise.all(
releases.map((release) => isPublished(cwd, tool, release)),
);
if (results.every(Boolean)) return;
if (Date.now() >= deadline) {
const missing = releases
.filter((_, index) => !results[index])
.map((release) => `${release.packageName}@${release.version}`);
throw new Error(
`Timed out waiting for approved packages to become visible: ${missing.join(", ")}`,
);
}
await new Promise((resolve) =>
setTimeout(resolve, Math.min(intervalMs, deadline - Date.now())),
);
}
}
async function ensureTag(github: GitHub, tag: string, sha: string) {
try {
const existing = await github.octokit.rest.git.getRef({
...context.repo,
ref: `tags/${tag}`,
});
if (existing.data.object.sha !== sha) {
throw new Error(
`Git tag ${tag} already exists at ${existing.data.object.sha}, expected ${sha}`,
);
}
return;
} catch (error) {
if (!isHttpNotFound(error)) {
throw error;
}
}
await github.octokit.rest.git.createRef({
...context.repo,
ref: `refs/tags/${tag}`,
sha,
});
}
async function ensureRelease(github: GitHub, pkg: Package, tagName: string) {
try {
await github.octokit.rest.repos.getReleaseByTag({
...context.repo,
tag: tagName,
});
return;
} catch (error) {
if (!isHttpNotFound(error)) {
throw error;
}
}
await createRelease(github.octokit, { pkg, tagName });
}
export async function finalizeStagedRelease(options: {
github: GitHub;
handoff: StagedReleaseHandoff;
packagesByName: Map<string, Package>;
cwd: string;
verify: boolean;
}) {
if (options.verify) {
await verifyPublished(options.handoff.releases, options.cwd);
}
for (const entry of options.handoff.releases) {
const pkg = options.packagesByName.get(entry.packageName)!;
await ensureTag(options.github, entry.gitTag, options.handoff.sha);
await ensureRelease(options.github, pkg, entry.gitTag);
}
}
+10
View File
@@ -137,6 +137,16 @@ export function getRequiredInput(name: string) {
return core.getInput(name, { required: true });
}
export function getOptionalBooleanInput(name: string) {
const value = getOptionalInput(name);
if (value === undefined) return undefined;
if (value.toLowerCase() === "true") return true;
if (value.toLowerCase() === "false") return false;
throw new Error(
`Invalid boolean input ${JSON.stringify(name)}: ${JSON.stringify(value)}`,
);
}
export function throwOnRenamedInputs(renames: Record<string, string>) {
const references: Record<string, string> = {};