mirror of
https://github.com/changesets/action
synced 2026-10-26 16:39:56 +08:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79d401e0bd |
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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 = {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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> = {};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user