diff --git a/README.md b/README.md index b465bcc0..5f41559a 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ This action for [Changesets](https://github.com/changesets/changesets) creates a pull request with all of the package versions updated and changelogs updated and when there are new changesets on [your configured `baseBranch`](https://github.com/changesets/changesets/blob/main/docs/config-file-options.md#basebranch-git-branch-name), the PR will be updated. When you're ready, you can merge the pull request and you can either publish the packages to npm manually or setup the action to do it for you. +There are also sub-actions hosted in this repository. Check out their respective READMEs for more details: + +- [pr-status](./pr-status/README.md): Generate changeset status in PRs. + ## Usage ### Inputs diff --git a/package.json b/package.json index 51a42aff..754bad92 100644 --- a/package.json +++ b/package.json @@ -20,17 +20,22 @@ "@actions/core": "^3.0.1", "@actions/exec": "^3.0.0", "@actions/github": "^9.1.1", + "@changesets/get-release-plan": "^4.0.16", "@changesets/ghcommit": "^2.0.1", + "@changesets/git": "^3.0.4", "@changesets/pre": "^2.0.2", "@changesets/read": "^0.6.5", "@manypkg/get-packages": "^1.1.3", "@octokit/core": "^7.0.6", "@octokit/plugin-throttling": "^11.0.3", "@types/mdast": "^3.0.0", + "human-id": "^4.1.3", + "markdown-table": "^3.0.4", "mdast-util-to-string": "^1.0.6", "remark-parse": "^7.0.1", "remark-stringify": "^7.0.3", "semver": "^7.5.3", + "tinyexec": "^1.1.2", "unified": "^8.3.2" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c11cd8d3..61825dd6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,9 +22,15 @@ importers: '@actions/github': specifier: ^9.1.1 version: 9.1.1 + '@changesets/get-release-plan': + specifier: ^4.0.16 + version: 4.0.16 '@changesets/ghcommit': specifier: ^2.0.1 version: 2.0.1 + '@changesets/git': + specifier: ^3.0.4 + version: 3.0.4 '@changesets/pre': specifier: ^2.0.2 version: 2.0.2 @@ -43,6 +49,12 @@ importers: '@types/mdast': specifier: ^3.0.0 version: 3.0.15 + human-id: + specifier: ^4.1.3 + version: 4.1.3 + markdown-table: + specifier: ^3.0.4 + version: 3.0.4 mdast-util-to-string: specifier: ^1.0.6 version: 1.1.0 @@ -55,6 +67,9 @@ importers: semver: specifier: ^7.5.3 version: 7.7.4 + tinyexec: + specifier: ^1.1.2 + version: 1.1.2 unified: specifier: ^8.3.2 version: 8.4.2 @@ -948,6 +963,9 @@ packages: markdown-table@1.1.3: resolution: {integrity: sha512-1RUZVgQlpJSPWYbFSpmudq5nHY1doEIv89gBtF0s4gW1GF2XorxcA/70M5vq7rLv0a6mhOUccRsqkwhwLCIQ2Q==} + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -2250,6 +2268,8 @@ snapshots: markdown-table@1.1.3: {} + markdown-table@3.0.4: {} + math-intrinsics@1.1.0: {} mdast-util-compact@1.0.4: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3c05cc47..f6ecbded 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,15 +3,15 @@ dedupePeers: true dedupePeerDependents: true minimumReleaseAge: 10080 minimumReleaseAgeExclude: - - '@oxc-project/*' - - '@rolldown/*' + - "@oxc-project/*" + - "@rolldown/*" - rolldown@1.0.1 - vite@8.0.13 - vitest@4.1.6 overrides: - lightningcss: '-' # we do not bundle any css - postcss: '-' # we do not bundle any css + lightningcss: "-" # we do not bundle any css + postcss: "-" # we do not bundle any css shellEmulator: true trustPolicy: no-downgrade diff --git a/pr-status/README.md b/pr-status/README.md new file mode 100644 index 00000000..c3055a23 --- /dev/null +++ b/pr-status/README.md @@ -0,0 +1,61 @@ +# changesets/action/pr-status + +This action generates the changeset status in PRs, e.g. whether it has changeset files and which packages will be released if the PR is merged. + +It requires the repo to be checked out, and automatically fetches the PR head ref into a temporary detached worktree in order to infer the changed files and packages. It also requires the [`pull_request_target`](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#pull_request_target) event to be triggered in order to have permissions to comment on the PR and to work in PRs from forks. + +You can also use the [`pull_request`](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#pull_request) event if you prefer to lock permissions down and not run for PRs from forks. Make sure to add an if check to prevent the action from failing in fork PRs: + +```yaml +jobs: + pr-status: + if: github.event.pull_request.head.repo.full_name == github.repository + # ... +``` + +See the [action metadata](action.yml) for details on the inputs and outputs. + +> [!WARNING] +> **Do not run untrusted code** when using the `pull_request_target` event. The example below only checks out code and does not run any code from the PR. Read more about the `pull_request_target` event in the [GitHub documentation](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#pull_request_target). + +## Example setup + +```yaml +# .github/workflows/comment-changeset-pr-status.yml +name: Comment changeset status in PRs + +on: + pull_request_target: + types: [opened, synchronize, reopened] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + pr-status: + runs-on: ubuntu-slim + permissions: + contents: read # to check out files in the repo + outputs: + commentBody: ${{ steps.pr-status.outputs.commentBody }} + steps: + - name: Check out repo + uses: actions/checkout@v6 + + - name: Generate status + id: pr-status + uses: changesets/action/pr-status@v1 + + comment: + needs: pr-status + runs-on: ubuntu-slim + permissions: + pull-requests: write # to create and update comments on PRs + steps: + - name: Comment on PR + uses: mshick/add-pr-comment@v3 + with: + message-id: changeset-pr-status + message: ${{ needs.pr-status.outputs.commentBody }} +``` diff --git a/pr-status/action.yml b/pr-status/action.yml new file mode 100644 index 00000000..f58232b2 --- /dev/null +++ b/pr-status/action.yml @@ -0,0 +1,12 @@ +name: Changesets - PR Status +description: Generate the changeset status in PRs +runs: + using: node24 + main: ../dist/pr-status.js +inputs: {} +outputs: + commentBody: + description: The generated comment body to present the changeset status in PRs. +branding: + icon: package + color: blue diff --git a/rolldown.config.js b/rolldown.config.js index 73ce840d..b3359c73 100644 --- a/rolldown.config.js +++ b/rolldown.config.js @@ -1,13 +1,16 @@ -import { defineConfig } from 'rolldown' +import { defineConfig } from "rolldown"; export default defineConfig({ - input: 'src/index.ts', + input: { + index: "src/index.ts", + ["pr-status"]: "src/pr-status/index.ts", + }, output: { - dir: 'dist', - format: 'esm', + dir: "dist", + format: "esm", cleanDir: true, minify: true, comments: false, }, - platform: 'node', -}) + platform: "node", +}); diff --git a/src/pr-status/index.ts b/src/pr-status/index.ts new file mode 100644 index 00000000..816ae095 --- /dev/null +++ b/src/pr-status/index.ts @@ -0,0 +1,21 @@ +import * as core from "@actions/core"; +import * as github from "@actions/github"; +import { getCommentMessage } from "./message.ts"; + +(async () => { + const context = github.context.payload.pull_request; + if (!context) { + core.error( + "This action should only be run on `pull_request_target` or `pull_request` events", + ); + return; + } + + core.info("Creating comment message..."); + const commentBody = await getCommentMessage(context); + core.setOutput("commentBody", commentBody); + core.info("Done!"); +})().catch((err) => { + core.error(err); + core.setFailed(err.message); +}); diff --git a/src/pr-status/message.ts b/src/pr-status/message.ts new file mode 100644 index 00000000..a446ec73 --- /dev/null +++ b/src/pr-status/message.ts @@ -0,0 +1,128 @@ +import * as github from "@actions/github"; +import getReleasePlan from "@changesets/get-release-plan"; +import type { + ComprehensiveRelease, + ReleasePlan, + VersionType, +} from "@changesets/types"; +import { markdownTable } from "markdown-table"; +import { + getNewChangesetTemplateContent, + getNewChangesetUrl, +} from "./template.ts"; +import { withPullRequestWorktree } from "./worktree.ts"; + +type PullRequestContext = NonNullable< + typeof github.context.payload.pull_request +>; + +export async function getCommentMessage(context: PullRequestContext) { + const { releasePlan, templateContent } = await withPullRequestWorktree( + context, + async ({ cwd, baseRef }) => { + const releasePlan = await getReleasePlan(cwd, baseRef); + const templateContent = await getNewChangesetTemplateContent( + cwd, + baseRef, + context.title, + ); + + return { + releasePlan, + templateContent, + }; + }, + ); + + const newChangesetUrl = getNewChangesetUrl( + context.head.repo.html_url, + context.head.ref, + templateContent, + ); + + if (releasePlan.changesets.length > 0) { + return getApproveMessage(context.head.sha, newChangesetUrl, releasePlan); + } else { + return getAbsentMessage(context.head.sha, newChangesetUrl, releasePlan); + } +} + +function getApproveMessage( + commitSha: string, + newChangesetUrl: string, + releasePlan: ReleasePlan, +) { + return `\ +### 🦋 Changeset detected + +Latest commit: ${commitSha} + +**The changes in this PR will be included in the next version bump.** + +${getReleasePlanMessage(releasePlan)} + +Not sure what this means? [Click here to learn what changesets are](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md). + +[Click here if you're a maintainer who wants to add another changeset to this PR](${newChangesetUrl})`; +} + +function getAbsentMessage( + commitSha: string, + newChangesetUrl: string, + releasePlan: ReleasePlan, +) { + return `\ +### ⚠️ No Changeset found + +Latest commit: ${commitSha} + +Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.** + +${getReleasePlanMessage(releasePlan)} + +[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md). + +[Click here if you're a maintainer who wants to add a changeset to this PR](${newChangesetUrl})`; +} + +function getReleasePlanMessage(releasePlan: ReleasePlan) { + const publishableReleases = releasePlan.releases.filter( + (r) => r.type !== "none", + ) as (ComprehensiveRelease & { type: Exclude })[]; + + const table = markdownTable([ + ["Name", "Type"], + ...publishableReleases.map((release) => { + return [ + release.name, + { + major: "Major", + minor: "Minor", + patch: "Patch", + }[release.type], + ]; + }), + ]); + + let summary = "This PR includes "; + if (releasePlan.changesets.length === 0) { + summary += "no changesets"; + } else { + summary += `changesets to release ${publishableReleases.length} package`; + if (publishableReleases.length !== 1) { + summary += "s"; + } + } + + return `\ +
+${summary} + +${ + publishableReleases.length > 0 + ? table + : "When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types" +} + +
`; +} diff --git a/src/pr-status/template.ts b/src/pr-status/template.ts new file mode 100644 index 00000000..03f9bc29 --- /dev/null +++ b/src/pr-status/template.ts @@ -0,0 +1,30 @@ +import { getChangedPackagesSinceRef } from "@changesets/git"; +import { humanId } from "human-id"; + +export function getNewChangesetUrl( + headRepoUrl: string, + headRef: string, + templateContent: string, +) { + const fileName = humanId({ separator: "-", capitalize: false }); + return `${headRepoUrl}/new/${headRef}?filename=.changeset/${fileName}.md&value=${encodeURIComponent(templateContent)}`; +} + +export async function getNewChangesetTemplateContent( + cwd: string, + baseRef: string, + prTitle: string, +) { + const changedPackages = await getChangedPackagesSinceRef({ + cwd, + ref: baseRef, + }); + + return `\ +--- +${changedPackages.map((p) => `"${p.packageJson.name}": patch`).join("\n")} +--- + +${prTitle} +`; +} diff --git a/src/pr-status/worktree.test.ts b/src/pr-status/worktree.test.ts new file mode 100644 index 00000000..21fe6f78 --- /dev/null +++ b/src/pr-status/worktree.test.ts @@ -0,0 +1,148 @@ +import { pathToFileURL } from "node:url"; +import getReleasePlan from "@changesets/get-release-plan"; +import { createFixture } from "fs-fixture"; +import { exec } from "tinyexec"; +import { describe, expect, it } from "vitest"; +import { withPullRequestWorktree } from "./worktree.ts"; + +async function git(cwd: string, args: string[]) { + const output = await exec("git", args, { + nodeOptions: { cwd }, + throwOnError: true, + }); + return output.stdout.trim(); +} + +describe("withPullRequestWorktree", () => { + it("fetches a PR branch into a detached worktree and keeps the main checkout untouched", async () => { + // Local source repo + await using sourceRepoFixture = await createFixture({ + ".changeset/config.json": JSON.stringify({}), + "package.json": JSON.stringify({ + name: "repo", + private: true, + workspaces: ["packages/*"], + }), + "packages/pkg-a/package.json": JSON.stringify({ + name: "pkg-a", + version: "1.0.0", + }), + }); + const sourceRepo = sourceRepoFixture.path; + await git(sourceRepo, ["init", "-b", "main"]); + await git(sourceRepo, ["config", "user.name", "Test User"]); + await git(sourceRepo, ["config", "user.email", "test@example.com"]); + await git(sourceRepo, ["add", "."]); + await git(sourceRepo, ["commit", "-m", "base"]); + + // Simulate remote bare git server + await using originBareFixture = await createFixture(); + const originBare = originBareFixture.path; + await git(originBare, ["clone", "--bare", sourceRepo, originBare]); + + // Simulate checkout PR in github action + await using checkoutRepoFixture = await createFixture(); + const checkoutRepo = checkoutRepoFixture.path; + await git(checkoutRepo, [ + "clone", + "--depth", + "1", + "--branch", + "main", + pathToFileURL(originBare).toString(), + checkoutRepo, + ]); + + // Simulate remote fork bare git server + await using forkBareFixture = await createFixture(); + const forkBare = forkBareFixture.path; + await git(forkBare, ["clone", "--bare", originBare, forkBare]); + + await using forkRepoFixture = await createFixture(); + const forkRepo = forkRepoFixture.path; + await git(forkRepo, [ + "clone", + "--depth", + "1", + "--branch", + "main", + pathToFileURL(forkBare).toString(), + forkRepo, + ]); + await git(forkRepo, ["config", "user.name", "Test User"]); + await git(forkRepo, ["config", "user.email", "test@example.com"]); + await git(forkRepo, ["checkout", "-b", "feature"]); + + // log all files in forkRepo with dot files + console.log( + "Files in forkRepo:\n", + await exec("ls", ["-Ra"], { nodeOptions: { cwd: forkRepo } }).then( + (res) => res.stdout.trim(), + ), + ); + + await forkRepoFixture.mkdir("packages/pkg-a/src"); + await forkRepoFixture.writeFile( + "packages/pkg-a/src/index.ts", + "export const value = 1;\n", + ); + await forkRepoFixture.writeFile( + ".changeset/add-pkg-a.md", + `\ +--- +"pkg-a": patch +--- + +Add pkg-a +`, + ); + + await git(forkRepo, ["add", "."]); + await git(forkRepo, ["commit", "-m", "feature"]); + await git(forkRepo, ["push", "origin", "feature"]); + + // Run tests + const originalHead = await git(checkoutRepo, ["rev-parse", "HEAD"]); + const context = { + number: 123, + base: { + ref: "main", + }, + head: { + ref: "feature", + repo: { + clone_url: pathToFileURL(forkBare).toString(), + }, + }, + } as any; + + const result = await withPullRequestWorktree( + context, + async ({ cwd, baseRef }) => { + const releasePlan = await getReleasePlan(cwd, baseRef); + return { + currentHead: await git(cwd, ["rev-parse", "HEAD"]), + currentBranch: await git(cwd, ["branch", "--show-current"]), + releases: releasePlan.releases.map((release) => ({ + name: release.name, + type: release.type, + newVersion: release.newVersion, + })), + }; + }, + checkoutRepo, + ); + + expect(result.currentHead).not.toBe(originalHead); + expect(result.currentBranch).toBe(""); + expect(result.releases).toEqual([ + { + name: "pkg-a", + type: "patch", + newVersion: "1.0.1", + }, + ]); + expect(await git(checkoutRepo, ["rev-parse", "HEAD"])).toBe(originalHead); + expect(await git(checkoutRepo, ["branch", "--show-current"])).toBe("main"); + }); +}); diff --git a/src/pr-status/worktree.ts b/src/pr-status/worktree.ts new file mode 100644 index 00000000..5c049cd7 --- /dev/null +++ b/src/pr-status/worktree.ts @@ -0,0 +1,136 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type * as github from "@actions/github"; +import { isRepoShallow } from "@changesets/git"; +import { exec } from "tinyexec"; + +type PullRequestContext = NonNullable< + typeof github.context.payload.pull_request +>; + +type WorktreeInfo = { + baseRef: string; + cwd: string; +}; + +type TinyexecOptions = Parameters[2]; + +function git(cwd: string, args: string[], opts: TinyexecOptions = {}) { + return exec("git", args, { + nodeOptions: { cwd, ...opts.nodeOptions }, + throwOnError: true, + ...opts, + }); +} + +async function deleteRef(cwd: string, ref: string) { + await git(cwd, ["update-ref", "-d", ref], { throwOnError: false }); +} + +function getRefNames(context: PullRequestContext) { + const suffix = `${context.number}-${randomUUID()}`; + return { + baseLocalRef: `refs/changesets-action-pr-status/base/${suffix}`, + baseRemoteRef: `refs/heads/${context.base.ref}`, + headLocalRef: `refs/changesets-action-pr-status/head/${suffix}`, + headRemoteRef: `refs/heads/${context.head.ref}`, + }; +} + +async function ensureMergeBase(args: { + cwd: string; + refs: ReturnType; + headRemoteUrl: string; + deepenBy?: number; +}) { + const { cwd, refs, headRemoteUrl, deepenBy = 50 } = args; + + while (true) { + const mergeBase = await git( + cwd, + ["merge-base", refs.baseLocalRef, "HEAD"], + { throwOnError: false }, + ); + + if (mergeBase.exitCode === 0) { + return mergeBase.stdout.trim(); + } + + if (!(await isRepoShallow({ cwd }))) { + throw new Error( + `Failed to find merge base between "${refs.baseLocalRef}" and HEAD, and the repository is no longer shallow.`, + ); + } + + await git(cwd, [ + "fetch", + "--no-tags", + `--deepen=${deepenBy}`, + "origin", + `${refs.baseRemoteRef}:${refs.baseLocalRef}`, + ]); + await git(cwd, [ + "fetch", + "--no-tags", + `--deepen=${deepenBy}`, + headRemoteUrl, + `${refs.headRemoteRef}:${refs.headLocalRef}`, + ]); + } +} + +export async function withPullRequestWorktree( + context: PullRequestContext, + fn: (worktree: WorktreeInfo) => Promise, + repoCwd: string = process.cwd(), +) { + const worktreeDir = await fs.mkdtemp( + path.join(os.tmpdir(), "changesets-action-pr-status-"), + ); + const refs = getRefNames(context); + + try { + await git(repoCwd, [ + "fetch", + "--no-tags", + "--depth=1", + "origin", + `${refs.baseRemoteRef}:${refs.baseLocalRef}`, + ]); + await git(repoCwd, [ + "fetch", + "--no-tags", + "--depth=1", + context.head.repo.clone_url, + `${refs.headRemoteRef}:${refs.headLocalRef}`, + ]); + await git(repoCwd, [ + "worktree", + "add", + "--detach", + worktreeDir, + refs.headLocalRef, + ]); + await ensureMergeBase({ + cwd: worktreeDir, + refs, + headRemoteUrl: context.head.repo.clone_url, + }); + + return await fn({ + baseRef: refs.baseLocalRef, + cwd: worktreeDir, + }); + } finally { + await git(repoCwd, ["worktree", "remove", "--force", worktreeDir], { + throwOnError: false, + }); + await Promise.all([ + deleteRef(repoCwd, refs.baseLocalRef), + deleteRef(repoCwd, refs.headLocalRef), + ]); + await fs.rm(worktreeDir, { recursive: true, force: true }); + } +}