diff --git a/.changeset/staged-publishing.md b/.changeset/staged-publishing.md new file mode 100644 index 00000000..c20225c8 --- /dev/null +++ b/.changeset/staged-publishing.md @@ -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. diff --git a/README.md b/README.md index f726e6cc..22b329f0 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/action.yml b/action.yml index 8f0d342e..d91a573d 100644 --- a/action.yml +++ b/action.yml @@ -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: diff --git a/github-release/README.md b/github-release/README.md new file mode 100644 index 00000000..7eee5987 --- /dev/null +++ b/github-release/README.md @@ -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. diff --git a/github-release/action.yml b/github-release/action.yml new file mode 100644 index 00000000..d62b2462 --- /dev/null +++ b/github-release/action.yml @@ -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 diff --git a/publish/README.md b/publish/README.md index 2850488a..a47fa607 100644 --- a/publish/README.md +++ b/publish/README.md @@ -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 +``` + +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. diff --git a/publish/action.yml b/publish/action.yml index 809c5509..2f2ac4fd 100644 --- a/publish/action.yml +++ b/publish/action.yml @@ -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 diff --git a/rolldown.config.js b/rolldown.config.js index 07ed44c4..6e156aca 100644 --- a/rolldown.config.js +++ b/rolldown.config.js @@ -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", diff --git a/src/github-release/index.ts b/src/github-release/index.ts new file mode 100644 index 00000000..ed57e181 --- /dev/null +++ b/src/github-release/index.ts @@ -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, + })), + ), + ); +} diff --git a/src/index.ts b/src/index.ts index 83b3dab1..8af7e75d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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( diff --git a/src/publish/index.ts b/src/publish/index.ts index 925489a2..e1cd01b4 100644 --- a/src/publish/index.ts +++ b/src/publish/index.ts @@ -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( diff --git a/src/publish/options.test.ts b/src/publish/options.test.ts new file mode 100644 index 00000000..9b23a30a --- /dev/null +++ b/src/publish/options.test.ts @@ -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(); + }); +}); diff --git a/src/publish/options.ts b/src/publish/options.ts new file mode 100644 index 00000000..83c5bcc5 --- /dev/null +++ b/src/publish/options.ts @@ -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.", + ); + } +} diff --git a/src/run.test.ts b/src/run.test.ts index 1dce7a0b..c739d4c8 100644 --- a/src/run.test.ts +++ b/src/run.test.ts @@ -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; diff --git a/src/run.ts b/src/run.ts index 67783083..95e8ff68 100644 --- a/src/run.ts +++ b/src/run.ts @@ -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 => + 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 = { diff --git a/src/staged-release.test.ts b/src/staged-release.test.ts new file mode 100644 index 00000000..bbd39376 --- /dev/null +++ b/src/staged-release.test.ts @@ -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()), + 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"); + }); +}); diff --git a/src/staged-release.ts b/src/staged-release.ts new file mode 100644 index 00000000..48a7cc47 --- /dev/null +++ b/src/staged-release.ts @@ -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; + +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 { + 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 { + 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>["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; +}> { + 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 = [ + "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>["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, + }); + 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; + 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); + } +} diff --git a/src/utils.ts b/src/utils.ts index 9ff842c7..7bbc17c6 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -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) { const references: Record = {};