mirror of
https://github.com/changesets/action
synced 2026-10-26 16:39:56 +08:00
Staged publishing support
This commit is contained in:
@@ -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 - 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"}]`
|
- 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
|
### Example workflow
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ outputs:
|
|||||||
published-packages:
|
published-packages:
|
||||||
description: >
|
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"}]`
|
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:
|
has-changesets:
|
||||||
description: A boolean about whether there were changesets. Useful if you want to create your own publishing functionality.
|
description: A boolean about whether there were changesets. Useful if you want to create your own publishing functionality.
|
||||||
pr-number:
|
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
|
# 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:
|
script:
|
||||||
description: "The command to use to publish packages"
|
description: "The command to use to publish packages"
|
||||||
required: false
|
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:
|
pack-dir-artifact-id:
|
||||||
description: "Artifact id for packed publish output generated by the pack subaction"
|
description: "Artifact id for packed publish output generated by the pack subaction"
|
||||||
required: false
|
required: false
|
||||||
@@ -30,6 +36,8 @@ outputs:
|
|||||||
published-packages:
|
published-packages:
|
||||||
description: >
|
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"}]`
|
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:
|
branding:
|
||||||
icon: package
|
icon: package
|
||||||
color: blue
|
color: blue
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export default defineConfig({
|
|||||||
"select-mode": "src/select-mode/index.ts",
|
"select-mode": "src/select-mode/index.ts",
|
||||||
version: "src/version/index.ts",
|
version: "src/version/index.ts",
|
||||||
publish: "src/publish/index.ts",
|
publish: "src/publish/index.ts",
|
||||||
|
"github-release": "src/github-release/index.ts",
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
dir: "dist",
|
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 { GitHub } from "./github.ts";
|
||||||
import readChangesetState from "./readChangesetState.ts";
|
import readChangesetState from "./readChangesetState.ts";
|
||||||
import { runPublish, runVersion } from "./run.ts";
|
import { runPublish, runVersion } from "./run.ts";
|
||||||
|
import { uploadStagedRelease } from "./staged-release.ts";
|
||||||
import {
|
import {
|
||||||
fileExists,
|
fileExists,
|
||||||
getOptionalInput,
|
getOptionalInput,
|
||||||
@@ -148,6 +149,14 @@ import {
|
|||||||
cwd,
|
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) {
|
if (result.published) {
|
||||||
core.setOutput("published", "true");
|
core.setOutput("published", "true");
|
||||||
core.setOutput(
|
core.setOutput(
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ import os from "node:os";
|
|||||||
import * as core from "@actions/core";
|
import * as core from "@actions/core";
|
||||||
import { GitHub } from "../github.ts";
|
import { GitHub } from "../github.ts";
|
||||||
import { runPublish } from "../run.ts";
|
import { runPublish } from "../run.ts";
|
||||||
|
import { uploadStagedRelease } from "../staged-release.ts";
|
||||||
import {
|
import {
|
||||||
downloadArtifact,
|
downloadArtifact,
|
||||||
|
getOptionalBooleanInput,
|
||||||
getOptionalInput,
|
getOptionalInput,
|
||||||
getRequiredInput,
|
getRequiredInput,
|
||||||
validateChangesetsCliVersion,
|
validateChangesetsCliVersion,
|
||||||
} from "../utils.ts";
|
} from "../utils.ts";
|
||||||
|
import { assertValidStageInput } from "./options.ts";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await main();
|
await main();
|
||||||
@@ -23,10 +26,13 @@ async function main() {
|
|||||||
|
|
||||||
const githubToken = getRequiredInput("github-token");
|
const githubToken = getRequiredInput("github-token");
|
||||||
const script = getOptionalInput("script");
|
const script = getOptionalInput("script");
|
||||||
|
const stage = getOptionalBooleanInput("stage");
|
||||||
const packDirArtifactId = getOptionalInput("pack-dir-artifact-id");
|
const packDirArtifactId = getOptionalInput("pack-dir-artifact-id");
|
||||||
const createGithubReleases = core.getBooleanInput("create-github-releases");
|
const createGithubReleases = core.getBooleanInput("create-github-releases");
|
||||||
const pushGitTags = core.getBooleanInput("push-git-tags");
|
const pushGitTags = core.getBooleanInput("push-git-tags");
|
||||||
|
|
||||||
|
assertValidStageInput(stage, script);
|
||||||
|
|
||||||
if (createGithubReleases && !pushGitTags) {
|
if (createGithubReleases && !pushGitTags) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"The input 'create-github-releases' is set to true, but 'push-git-tags' is set to false. " +
|
"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({
|
const result = await runPublish({
|
||||||
script,
|
script,
|
||||||
|
stage,
|
||||||
github,
|
github,
|
||||||
createGithubReleases,
|
createGithubReleases,
|
||||||
pushGitTags,
|
pushGitTags,
|
||||||
@@ -55,6 +62,14 @@ async function main() {
|
|||||||
fromPackDir,
|
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) {
|
if (result.published) {
|
||||||
core.setOutput("published", "true");
|
core.setOutput("published", "true");
|
||||||
core.setOutput(
|
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 { createFixture } from "fs-fixture";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { GitHub } from "./github.ts";
|
import { GitHub } from "./github.ts";
|
||||||
import { runPublish, runVersion } from "./run.ts";
|
import { readChangesetsOutput, runPublish, runVersion } from "./run.ts";
|
||||||
|
|
||||||
vi.mock("@actions/github", () => ({
|
vi.mock("@actions/github", () => ({
|
||||||
context: {
|
context: {
|
||||||
@@ -110,6 +110,102 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("publish", () => {
|
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 () => {
|
it("warns when a custom publish script does not create the output file", async () => {
|
||||||
await using fixture = await createSimpleProjectFixture();
|
await using fixture = await createSimpleProjectFixture();
|
||||||
const cwd = fixture.path;
|
const cwd = fixture.path;
|
||||||
|
|||||||
+76
-23
@@ -31,7 +31,7 @@ import {
|
|||||||
// To avoid that, we ensure to cap the message to 60k chars.
|
// To avoid that, we ensure to cap the message to 60k chars.
|
||||||
const MAX_CHARACTERS_PER_MESSAGE = 60000;
|
const MAX_CHARACTERS_PER_MESSAGE = 60000;
|
||||||
|
|
||||||
const createRelease = async (
|
export const createRelease = async (
|
||||||
octokit: Octokit,
|
octokit: Octokit,
|
||||||
{ pkg, tagName }: { pkg: Package; tagName: string },
|
{ pkg, tagName }: { pkg: Package; tagName: string },
|
||||||
) => {
|
) => {
|
||||||
@@ -66,6 +66,7 @@ const createRelease = async (
|
|||||||
type PublishOptions = {
|
type PublishOptions = {
|
||||||
script?: string;
|
script?: string;
|
||||||
fromPackDir?: string;
|
fromPackDir?: string;
|
||||||
|
stage?: boolean;
|
||||||
createGithubReleases: boolean;
|
createGithubReleases: boolean;
|
||||||
pushGitTags: boolean;
|
pushGitTags: boolean;
|
||||||
github: GitHub;
|
github: GitHub;
|
||||||
@@ -73,11 +74,25 @@ type PublishOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type PublishedPackage = { name: string; version: string };
|
type PublishedPackage = { name: string; version: string };
|
||||||
type ChangesetsOutputEvent = {
|
export type ChangesetsOutputEvent =
|
||||||
type: "git-tag";
|
| {
|
||||||
tag: string;
|
type: "git-tag";
|
||||||
packageName: string;
|
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 {}
|
class ChangesetsOutputReadError extends Error {}
|
||||||
|
|
||||||
@@ -85,10 +100,12 @@ type PublishResult =
|
|||||||
| {
|
| {
|
||||||
published: true;
|
published: true;
|
||||||
publishedPackages: PublishedPackage[];
|
publishedPackages: PublishedPackage[];
|
||||||
|
stagedPackages?: NpmStageEvent[];
|
||||||
exitCode: number;
|
exitCode: number;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
published: false;
|
published: false;
|
||||||
|
stagedPackages?: NpmStageEvent[];
|
||||||
exitCode: number;
|
exitCode: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -99,18 +116,32 @@ function isObject(value: unknown) {
|
|||||||
function isChangesetsOutputEvent(
|
function isChangesetsOutputEvent(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
): value is ChangesetsOutputEvent {
|
): value is ChangesetsOutputEvent {
|
||||||
return (
|
if (!isObject(value) || !("type" in value)) return false;
|
||||||
isObject(value) &&
|
if (
|
||||||
"type" in value &&
|
|
||||||
value.type === "git-tag" &&
|
value.type === "git-tag" &&
|
||||||
"tag" in value &&
|
"tag" in value &&
|
||||||
typeof value.tag === "string" &&
|
typeof value.tag === "string" &&
|
||||||
"packageName" in value &&
|
"packageName" in value &&
|
||||||
typeof value.packageName === "string"
|
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;
|
let rawOutput: string;
|
||||||
try {
|
try {
|
||||||
rawOutput = await fs.readFile(outputPath, "utf8");
|
rawOutput = await fs.readFile(outputPath, "utf8");
|
||||||
@@ -158,6 +189,7 @@ async function readChangesetsOutput(outputPath: string) {
|
|||||||
export async function runPublish({
|
export async function runPublish({
|
||||||
script,
|
script,
|
||||||
fromPackDir,
|
fromPackDir,
|
||||||
|
stage,
|
||||||
github,
|
github,
|
||||||
createGithubReleases,
|
createGithubReleases,
|
||||||
pushGitTags,
|
pushGitTags,
|
||||||
@@ -187,6 +219,9 @@ export async function runPublish({
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const args = ["publish"];
|
const args = ["publish"];
|
||||||
|
if (stage !== undefined) {
|
||||||
|
args.push(stage ? "--stage" : "--no-stage");
|
||||||
|
}
|
||||||
if (fromPackDir) {
|
if (fromPackDir) {
|
||||||
args.push("--from-pack-dir", fromPackDir);
|
args.push("--from-pack-dir", fromPackDir);
|
||||||
}
|
}
|
||||||
@@ -196,8 +231,13 @@ export async function runPublish({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let { packages, tool } = await getPackages(cwd);
|
let { packages, rootPackage, tool } = await getPackages(cwd);
|
||||||
let packagesByName = new Map(packages.map((x) => [x.packageJson.name, x]));
|
let packagesByName = new Map(
|
||||||
|
[...packages, ...(rootPackage ? [rootPackage] : [])].map((x) => [
|
||||||
|
x.packageJson.name,
|
||||||
|
x,
|
||||||
|
]),
|
||||||
|
);
|
||||||
let output: ChangesetsOutputEvent[];
|
let output: ChangesetsOutputEvent[];
|
||||||
try {
|
try {
|
||||||
output = await readChangesetsOutput(outputFile);
|
output = await readChangesetsOutput(outputFile);
|
||||||
@@ -210,16 +250,24 @@ export async function runPublish({
|
|||||||
);
|
);
|
||||||
output = [];
|
output = [];
|
||||||
}
|
}
|
||||||
let releases = output.map((event) => {
|
const stagedPackages = output.filter(
|
||||||
let pkg = packagesByName.get(event.packageName);
|
(event): event is NpmStageEvent => event.type === "npm-stage",
|
||||||
if (pkg === undefined) {
|
);
|
||||||
throw new Error(
|
let releases = output
|
||||||
`Package "${event.packageName}" not found.` +
|
.filter(
|
||||||
"This is probably a bug in the action, please open an issue",
|
(event): event is Extract<ChangesetsOutputEvent, { type: "git-tag" }> =>
|
||||||
);
|
event.type === "git-tag",
|
||||||
}
|
)
|
||||||
return { pkg, tag: event.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) {
|
if (tool.type === "root" && packages.length === 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -248,11 +296,16 @@ export async function runPublish({
|
|||||||
name: pkg.packageJson.name,
|
name: pkg.packageJson.name,
|
||||||
version: pkg.packageJson.version,
|
version: pkg.packageJson.version,
|
||||||
})),
|
})),
|
||||||
|
...(stagedPackages.length > 0 ? { stagedPackages } : {}),
|
||||||
exitCode: changesetPublishOutput.exitCode,
|
exitCode: changesetPublishOutput.exitCode,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { published: false, exitCode: changesetPublishOutput.exitCode };
|
return {
|
||||||
|
published: false,
|
||||||
|
...(stagedPackages.length > 0 ? { stagedPackages } : {}),
|
||||||
|
exitCode: changesetPublishOutput.exitCode,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
type GetMessageOptions = {
|
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 });
|
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>) {
|
export function throwOnRenamedInputs(renames: Record<string, string>) {
|
||||||
const references: Record<string, string> = {};
|
const references: Record<string, string> = {};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user