Add select-mode, version, pack and publish sub-actions (#656)

* Add select-mode, version, and publish sub-actions

* Rename

* No github-token needed for select-mode for now

* extend select-mode with publish-plan check and upload

* Rename base-branch to pr-base-branch

* add `pack` subaction

* `--from-pack` support in the publish subaction

* rename CLI arg

* update rolldown config

* rename pack dir input/output

* tweak retendion days

---------

Co-authored-by: Mateusz Burzyński <mateuszburzynski@gmail.com>
This commit is contained in:
Bjorn Lu
2026-06-23 06:42:37 +00:00
committed by GitHub
co-authored by Mateusz Burzyński
parent 5649ff41b8
commit a12d90de83
18 changed files with 1487 additions and 24 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@changesets/action": minor
---
Add new `/select-mode`, `/version`, and `/publish` sub-actions to better control version and publish steps
+3
View File
@@ -0,0 +1,3 @@
# changesets/action/pack
TODO
+15
View File
@@ -0,0 +1,15 @@
name: Changesets - Pack
description: Pack publishable packages into tarballs
runs:
using: node24
main: ../dist/pack.js
inputs:
publish-plan-artifact-id:
description: "Artifact id for a publish plan generated by the select-mode subaction"
required: false
outputs:
pack-dir-artifact-id:
description: "Artifact id for the packed output directory"
branding:
icon: package
color: blue
+1
View File
@@ -17,6 +17,7 @@
"release:pr": "node --experimental-strip-types ./scripts/release-pr.ts" "release:pr": "node --experimental-strip-types ./scripts/release-pr.ts"
}, },
"dependencies": { "dependencies": {
"@actions/artifact": "^6.2.1",
"@actions/core": "^3.0.1", "@actions/core": "^3.0.1",
"@actions/exec": "^3.0.0", "@actions/exec": "^3.0.0",
"@actions/github": "^9.1.1", "@actions/github": "^9.1.1",
+943 -2
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
# changesets/action/publish
TODO
+29
View File
@@ -0,0 +1,29 @@
name: Changesets - Publish
description: Publish packages to npm
runs:
using: node24
main: ../dist/publish.js
inputs:
github-token:
description: "The GitHub token to use for authentication. Defaults to the GitHub-provided token."
required: false
default: ${{ github.token }}
script:
description: "The command to use to publish packages"
required: false
pack-dir-artifact-id:
description: "Artifact id for packed publish output generated by the pack subaction"
required: false
create-github-releases:
description: "Whether to create Github releases after publish"
required: false
default: true
outputs:
published:
description: "A boolean value to indicate whether a publishing has happened or not"
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"}]`
branding:
icon: package
color: blue
+6 -2
View File
@@ -3,8 +3,12 @@ import { defineConfig } from "rolldown";
export default defineConfig({ export default defineConfig({
input: { input: {
index: "src/index.ts", index: "src/index.ts",
["pr-status"]: "src/pr-status/index.ts", pack: "src/pack/index.ts",
["pr-comment"]: "src/pr-comment/index.ts", "pr-status": "src/pr-status/index.ts",
"pr-comment": "src/pr-comment/index.ts",
"select-mode": "src/select-mode/index.ts",
version: "src/version/index.ts",
publish: "src/publish/index.ts",
}, },
output: { output: {
dir: "dist", dir: "dist",
+3
View File
@@ -0,0 +1,3 @@
# changesets/action/select-mode
TODO
+14
View File
@@ -0,0 +1,14 @@
name: Changesets - Select Mode
description: Whether to version or publish in the current repo state
runs:
using: node24
main: ../dist/select-mode.js
inputs: {}
outputs:
mode:
description: "The mode to use for the current repo state: 'version', 'publish', or 'none'."
publish-plan-artifact-id:
description: "Artifact id for the generated publish plan when mode is `publish`"
branding:
icon: package
color: blue
+82
View File
@@ -0,0 +1,82 @@
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 { downloadArtifact, execChangesetsCli } from "../utils.ts";
try {
await main();
} catch (err) {
core.setFailed((err as Error).message);
}
async function main() {
const publishPlanArtifactId = core.getInput("publish-plan-artifact-id");
// If the user needs to change the cwd, set `working-directory` in the step instead
const cwd = process.cwd();
const tmpDir = process.env.RUNNER_TEMP ?? (await fs.realpath(os.tmpdir()));
const outDir = path.join(tmpDir, `changeset-pack-${Date.now()}`);
await pack(cwd, {
outDir,
publishPlanPath: publishPlanArtifactId
? await downloadPublishPlanArtifact(tmpDir, Number(publishPlanArtifactId))
: undefined,
});
const packDirArtifact = await artifact.uploadArtifact(
`changeset-pack-${Date.now()}`,
await getFiles(outDir),
outDir,
{
retentionDays: 30,
},
);
if (packDirArtifact.id === undefined) {
throw new Error("Packed artifact upload did not return an artifact id");
}
core.setOutput("pack-dir-artifact-id", String(packDirArtifact.id));
}
async function pack(
cwd: string,
args: {
outDir: string;
publishPlanPath?: string;
},
) {
const cliArgs = ["pack", "--out-dir", args.outDir];
if (args.publishPlanPath) {
cliArgs.push("--from-plan", args.publishPlanPath);
}
await execChangesetsCli(cliArgs, {
cwd,
env: process.env,
});
}
async function downloadPublishPlanArtifact(tmpDir: string, artifactId: number) {
const downloadPath = await downloadArtifact(
tmpDir,
artifactId,
"changeset-publish-plan",
);
return path.join(downloadPath, "publish-plan.json");
}
async function getFiles(dir: string): Promise<string[]> {
const entries = await fs.readdir(dir, { withFileTypes: true });
const files = await Promise.all(
entries.map(async (entry) => {
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
return getFiles(entryPath);
}
return [entryPath];
}),
);
return files.flat();
}
+67
View File
@@ -0,0 +1,67 @@
import fs from "node:fs/promises";
import os from "node:os";
import * as core from "@actions/core";
import { Git } from "../git.ts";
import { setupOctokit } from "../octokit.ts";
import { runPublish } from "../run.ts";
import { downloadArtifact } from "../utils.ts";
try {
await main();
} catch (err) {
core.setFailed((err as Error).message);
}
async function main() {
const githubToken = core.getInput("github-token", { required: true });
const script = core.getInput("script");
const packDirArtifactId = core.getInput("pack-dir-artifact-id");
const createGithubReleases = core.getBooleanInput("create-github-releases");
// If the user needs to change the cwd, set `working-directory` in the step instead
const cwd = process.cwd();
const octokit = setupOctokit(githubToken);
// NOTE: Always pass octokit here as publish does not need a commit-mode
const git = new Git({ octokit, cwd });
const fromPackDir = packDirArtifactId
? await downloadArtifact(
process.env.RUNNER_TEMP ?? (await fs.realpath(os.tmpdir())),
Number(packDirArtifactId),
"changeset-pack",
)
: undefined;
const result = await runPublish({
script,
githubToken,
git,
octokit,
createGithubReleases,
cwd,
fromPackDir,
});
if (result.published) {
core.setOutput("published", "true");
core.setOutput(
"published-packages",
JSON.stringify(result.publishedPackages),
);
} else {
core.setOutput("published", "false");
}
if (result.exitCode !== 0) {
throw new Error(
`Publish command exited with code ${result.exitCode}${
result.published
? `, but some packages were published: ${result.publishedPackages
.map((p) => `${p.name}@${p.version}`)
.join(", ")}`
: ""
}`,
);
}
}
+32 -20
View File
@@ -1,8 +1,12 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import { createRequire } from "node:module";
import path from "node:path"; import path from "node:path";
import * as core from "@actions/core"; import * as core from "@actions/core";
import { exec, getExecOutput } from "@actions/exec"; import {
exec,
getExecOutput,
type ExecOptions,
type ExecOutput,
} from "@actions/exec";
import * as github from "@actions/github"; import * as github from "@actions/github";
import type { PreState } from "@changesets/types"; import type { PreState } from "@changesets/types";
import { type Package, getPackages } from "@manypkg/get-packages"; import { type Package, getPackages } from "@manypkg/get-packages";
@@ -10,15 +14,15 @@ import { Git } from "./git.ts";
import type { Octokit } from "./octokit.ts"; import type { Octokit } from "./octokit.ts";
import readChangesetState from "./readChangesetState.ts"; import readChangesetState from "./readChangesetState.ts";
import { import {
execChangesetsCli,
getChangedPackages, getChangedPackages,
getChangelogEntry, getChangelogEntry,
getExecOutputChangesetsCli,
getVersionsByDirectory, getVersionsByDirectory,
isErrorWithCode, isErrorWithCode,
sortTheThings, sortTheThings,
} from "./utils.ts"; } from "./utils.ts";
const require = createRequire(import.meta.url);
// GitHub Issues/PRs messages have a max size limit on the // GitHub Issues/PRs messages have a max size limit on the
// message body payload. // message body payload.
// `body is too long (maximum is 65536 characters)`. // `body is too long (maximum is 65536 characters)`.
@@ -58,7 +62,8 @@ const createRelease = async (
}; };
type PublishOptions = { type PublishOptions = {
script: string; script?: string;
fromPackDir?: string;
githubToken: string; githubToken: string;
octokit: Octokit; octokit: Octokit;
createGithubReleases: boolean; createGithubReleases: boolean;
@@ -81,17 +86,36 @@ type PublishResult =
export async function runPublish({ export async function runPublish({
script, script,
fromPackDir,
githubToken, githubToken,
git, git,
octokit, octokit,
createGithubReleases, createGithubReleases,
cwd, cwd,
}: PublishOptions): Promise<PublishResult> { }: PublishOptions): Promise<PublishResult> {
let changesetPublishOutput = await getExecOutput(script, undefined, { let changesetPublishOutput: ExecOutput;
const execOptions: ExecOptions = {
cwd, cwd,
ignoreReturnCode: true, ignoreReturnCode: true,
env: { ...process.env, GITHUB_TOKEN: githubToken }, env: { ...process.env, GITHUB_TOKEN: githubToken },
}); };
if (script) {
changesetPublishOutput = await getExecOutput(
script,
undefined,
execOptions,
);
} else {
const args = ["publish"];
if (fromPackDir) {
args.push("--from-pack-dir", fromPackDir);
}
changesetPublishOutput = await getExecOutputChangesetsCli(
args,
execOptions,
);
}
let { packages, tool } = await getPackages(cwd); let { packages, tool } = await getPackages(cwd);
let releasedPackages: Package[] = []; let releasedPackages: Package[] = [];
@@ -277,19 +301,7 @@ export async function runVersion({
if (script) { if (script) {
await exec(script, undefined, { cwd, env }); await exec(script, undefined, { cwd, env });
} else { } else {
await exec( await execChangesetsCli(["version"], { cwd, env });
"node",
[
require.resolve("@changesets/cli/bin.js", {
paths: [cwd],
}),
"version",
],
{
cwd,
env,
},
);
} }
let changedPackages = await getChangedPackages(cwd, versionsByDirectory); let changedPackages = await getChangedPackages(cwd, versionsByDirectory);
+117
View File
@@ -0,0 +1,117 @@
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 readChangesetState from "../readChangesetState.ts";
import { execChangesetsCli } from "../utils.ts";
type ModeResult =
| {
mode: "none";
}
| {
mode: "version";
}
| {
mode: "publish";
publishPlanPath: string;
};
type PublishPlan = unknown[];
try {
await main();
} catch (err) {
core.setFailed((err as Error).message);
}
async function main() {
const result = await getMode();
core.setOutput("mode", result.mode);
if (result.mode === "publish") {
const publishPlanArtifact = await artifact.uploadArtifact(
path.basename(result.publishPlanPath, ".json"),
[result.publishPlanPath],
path.dirname(result.publishPlanPath),
{
skipArchive: true,
retentionDays: 30,
},
);
if (publishPlanArtifact.id === undefined) {
throw new Error(
"Publish plan artifact upload did not return an artifact id",
);
}
core.setOutput("publish-plan-artifact-id", String(publishPlanArtifact.id));
}
}
async function getMode(): Promise<ModeResult> {
const { changesets } = await readChangesetState();
if (changesets.length > 0) {
const hasNonEmptyChangesets = changesets.some(
(changeset) => changeset.releases.length > 0,
);
if (hasNonEmptyChangesets) {
return { mode: "version" };
}
return { mode: "none" };
}
const cwd = process.cwd();
const publishPlanPath = path.join(
process.env.RUNNER_TEMP ?? (await fs.realpath(os.tmpdir())),
`changeset-publish-plan-${Date.now()}.json`,
);
await execChangesetsCli(["publish-plan", "--output", publishPlanPath], {
cwd,
env: process.env,
});
const publishPlan = await readPublishPlan(publishPlanPath);
if (publishPlan.length === 0) {
return { mode: "none" };
}
return {
mode: "publish",
publishPlanPath,
};
}
async function readPublishPlan(publishPlanPath: string): Promise<PublishPlan> {
let rawPlan: string;
try {
rawPlan = await fs.readFile(publishPlanPath, "utf8");
} catch (err) {
throw new Error(`Failed to read publish plan at ${publishPlanPath}`, {
cause: err,
});
}
let plan: unknown;
try {
plan = JSON.parse(rawPlan);
} catch (err) {
throw new Error(`Failed to parse publish plan at ${publishPlanPath}`, {
cause: err,
});
}
if (
typeof plan !== "object" ||
plan === null ||
!("version" in plan) ||
typeof plan.version !== "number" ||
!("plan" in plan) ||
!Array.isArray(plan.plan)
) {
throw new Error(
`Invalid publish plan at ${publishPlanPath}: expected { version: number; plan: unknown[] }`,
);
}
return plan.plan;
}
+65
View File
@@ -1,6 +1,17 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import { createRequire } from "node:module";
import path from "node:path";
import artifact from "@actions/artifact";
import {
exec,
getExecOutput,
type ExecOptions as ActionsExecOptions,
type ExecOutput,
} from "@actions/exec";
import { getPackages, type Package } from "@manypkg/get-packages"; import { getPackages, type Package } from "@manypkg/get-packages";
const require = createRequire(import.meta.url);
export const BumpLevels = { export const BumpLevels = {
dep: 0, dep: 0,
patch: 1, patch: 1,
@@ -111,3 +122,57 @@ export function fileExists(filePath: string) {
() => false, () => false,
); );
} }
function resolveChangesetsCli(cwd: string) {
return require.resolve("@changesets/cli/bin.js", {
paths: [cwd],
});
}
interface ExecOptions extends Omit<ActionsExecOptions, "env"> {
env?: Record<string, string | undefined>;
}
export function execChangesetsCli(args: string[], options?: ExecOptions) {
return exec(
"node",
[resolveChangesetsCli(options?.cwd ?? process.cwd()), ...args],
options as ActionsExecOptions,
);
}
export function getExecOutputChangesetsCli(
args: string[],
options?: ExecOptions,
): Promise<ExecOutput> {
return getExecOutput(
"node",
[resolveChangesetsCli(options?.cwd ?? process.cwd()), ...args],
options as ActionsExecOptions,
);
}
export async function downloadArtifact(
tmpDir: string,
artifactId: number,
name: string,
) {
if (!Number.isInteger(artifactId) || artifactId <= 0) {
throw new Error(
`Invalid ${JSON.stringify(name)} artifact id: ${artifactId}`,
);
}
const downloadPath = path.join(tmpDir, `${name}-${artifactId}-${Date.now()}`);
const result = await artifact.downloadArtifact(artifactId, {
path: downloadPath,
});
if (!result.downloadPath) {
throw new Error(
`${JSON.stringify(name)} artifact download did not return a path for artifact ${artifactId}`,
);
}
return result.downloadPath;
}
+56
View File
@@ -0,0 +1,56 @@
import * as core from "@actions/core";
import { Git } from "../git.ts";
import { setupOctokit } from "../octokit.ts";
import { runVersion } from "../run.ts";
try {
await main();
} catch (err) {
core.setFailed((err as Error).message);
}
async function main() {
const githubToken = core.getInput("github-token", { required: true });
const script = core.getInput("script");
const commitMessage = core.getInput("commit-message", { required: true });
const prTitle = core.getInput("pr-title", { required: true });
const prDraft = core.getInput("pr-draft") || undefined;
const prBaseBranch = core.getInput("pr-base-branch");
const commitMode = core.getInput("commit-mode") || "git-cli";
const setupGitUser = core.getBooleanInput("setup-git-user");
// Validations
if (prDraft !== undefined && prDraft !== "always" && prDraft !== "create") {
throw new Error(`Invalid pr-draft input: ${prDraft}`);
}
// If the user needs to change the cwd, set `working-directory` in the step instead
const cwd = process.cwd();
const octokit = setupOctokit(githubToken);
const git = new Git({
octokit: commitMode === "github-api" ? octokit : undefined,
cwd,
});
if (setupGitUser) {
core.info("setting git user");
await git.setupUser();
}
const { pullRequestNumber } = await runVersion({
script,
githubToken,
git,
octokit,
cwd,
prTitle,
commitMessage,
// TODO: Use neutral message for PR description
hasPublishScript: true,
prDraft,
branch: prBaseBranch,
});
core.setOutput("pr-number", String(pullRequestNumber));
}
+3
View File
@@ -0,0 +1,3 @@
# changesets/action/version
TODO
+43
View File
@@ -0,0 +1,43 @@
name: Changesets - Version
description: Version packages
runs:
using: node24
main: ../dist/version.js
inputs:
github-token:
description: "The GitHub token to use for authentication. Defaults to the GitHub-provided token."
required: false
default: ${{ github.token }}
script:
description: "The command to use to publish packages"
required: false
commit-message:
description: "The commit message. Default to `Version Packages`"
required: false
default: "Version Packages"
pr-title:
description: "The pull request title. Default to `Version Packages`"
required: false
default: "Version Packages"
pr-draft:
description: "Controls draft PR behavior. Use 'create' to create new version PRs as draft, or 'always' to also convert existing version PRs back to draft when updating them."
required: false
pr-base-branch:
description: "Sets the base branch of the PR. Defaults to `github.ref_name`."
required: false
commit-mode:
description: >
An enum to specify the commit mode. Use "git-cli" to push changes using the Git CLI,
or "github-api" to push changes via the GitHub API. When using "github-api",
all commits and tags are signed using GitHub's GPG key and attributed to the user
or app who owns the GITHUB_TOKEN.
required: false
default: "git-cli"
setup-git-user:
description: Sets up the git user for commits as `"github-actions[bot]"`. Default to `true`
required: false
default: true
outputs: {}
branding:
icon: package
color: blue