Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/staged-publishing.md
Original file line number Diff line number Diff line change
@@ -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 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
13 changes: 13 additions & 0 deletions github-release/README.md
Original file line number Diff line number Diff line change
@@ -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 changes: 23 additions & 0 deletions github-release/action.yml
Original file line number Diff line number Diff line change
@@ -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
23 changes: 22 additions & 1 deletion publish/README.md
Original file line number Diff line number Diff line change
@@ -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 changes: 8 additions & 0 deletions publish/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 change: 1 addition & 0 deletions rolldown.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
46 changes: 46 additions & 0 deletions src/github-release/index.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 9 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
15 changes: 15 additions & 0 deletions src/publish/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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. " +
Expand All @@ -48,13 +54,22 @@ async function main() {

const result = await runPublish({
script,
stage,
github,
createGithubReleases,
pushGitTags,
cwd,
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(
Expand Down
16 changes: 16 additions & 0 deletions src/publish/options.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 10 additions & 0 deletions src/publish/options.ts
Original file line number Diff line number Diff line change
@@ -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.",
);
}
}
98 changes: 97 additions & 1 deletion src/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading