diff --git a/.github/scripts/upsert-breaking-change-comment.cjs b/.github/scripts/upsert-breaking-change-comment.cjs new file mode 100644 index 000000000..cebc53bb3 --- /dev/null +++ b/.github/scripts/upsert-breaking-change-comment.cjs @@ -0,0 +1,71 @@ +/** + * Upserts (or removes) a sticky PR comment summarizing breaking commits + * detected by `detect-breaking-commits.sh`. + * + * Invoked via `actions/github-script`. Inputs come from environment vars: + * FOUND - "true" if the scan found breaking commits + * BREAKING_LIST - markdown bullet list of breaking commits + * ALLOWED - "true" if the PR carries the allow-breaking-change label + */ + +const MARKER = ""; + +module.exports = async ({ github, context }) => { + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + const found = process.env.FOUND === "true"; + const allowed = process.env.ALLOWED === "true"; + const list = process.env.BREAKING_LIST || ""; + + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number, + per_page: 100, + }); + const existing = comments.find((c) => c.body?.includes(MARKER)); + + if (!found) { + if (existing) { + await github.rest.issues.deleteComment({ + owner, + repo, + comment_id: existing.id, + }); + } + return; + } + + const status = allowed + ? "> This PR has the `allow-breaking-change` label, so this check will pass. Make sure the next release is intentionally bumped to a major version." + : "> Add the **`allow-breaking-change`** label to this PR if the breaking change is intentional, or rewrite the offending commits to remove the `!` / `BREAKING CHANGE:` footer."; + + const body = [ + MARKER, + "### Breaking change detected", + "", + "This PR contains Conventional Commits breaking-change markers (`type!:` or `BREAKING CHANGE:` footer) in one or more of the following surfaces, all of which feed `release-it` after a squash merge:", + "", + list.trim(), + "", + "Merging this PR will force a **major** version bump on the next release (`bumpStrict: true` in `.release-it.json`).", + "", + status, + ].join("\n"); + + if (existing) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body, + }); + } +}; diff --git a/.github/workflows/pr-metadata.yml b/.github/workflows/pr-metadata.yml new file mode 100644 index 000000000..0fa41941e --- /dev/null +++ b/.github/workflows/pr-metadata.yml @@ -0,0 +1,83 @@ +name: PR Metadata Verification + +on: + pull_request: + types: [opened, synchronize, reopened, edited, labeled, unlabeled] + branches: + - main + +permissions: + contents: read + pull-requests: write + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + check-title: + runs-on: + group: databricks-protected-runner-group + labels: linux-ubuntu-latest + + name: Conventional Commit Title + steps: + - name: Validate PR title + uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + types: | + feat + fix + docs + test + ci + refactor + perf + chore + revert + style + build + + detect-breaking: + name: Detect Breaking Commits + runs-on: + group: databricks-protected-runner-group + labels: linux-ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: .nvmrc + + - name: Find breaking-change markers + id: scan + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} + # Node 24 strips TS type annotations natively, so no tsx/transpile step needed. + run: node tools/detect-breaking-commits.ts + + - name: Upsert sticky PR comment + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + env: + FOUND: ${{ steps.scan.outputs.found }} + BREAKING_LIST: ${{ steps.scan.outputs.list }} + ALLOWED: ${{ contains(github.event.pull_request.labels.*.name, 'allow-breaking-change') }} + with: + script: | + const upsert = require('./.github/scripts/upsert-breaking-change-comment.cjs'); + await upsert({ github, context }); + + - name: Fail unless explicitly allowed + if: steps.scan.outputs.found == 'true' && !contains(github.event.pull_request.labels.*.name, 'allow-breaking-change') + run: | + echo "::error::Breaking-change commits detected in tracked packages. Add the 'allow-breaking-change' label to bypass, or rewrite the offending commits." + exit 1 diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml deleted file mode 100644 index f1876004a..000000000 --- a/.github/workflows/pr-title.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: PR Title - -on: - pull_request: - types: [opened, synchronize, reopened, edited] - branches: - - main - -permissions: - pull-requests: read - -jobs: - check-title: - runs-on: - group: databricks-protected-runner-group - labels: linux-ubuntu-latest - - name: Conventional Commit Title - steps: - - name: Validate PR title - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - types: | - feat - fix - docs - test - ci - refactor - perf - chore - revert - style - build diff --git a/knip.json b/knip.json index 036404ee4..2d234091e 100644 --- a/knip.json +++ b/knip.json @@ -23,7 +23,8 @@ "packages/appkit/src/plugins/agents/load-agents.ts", "template/**", "tools/**", - "docs/**" + "docs/**", + ".github/scripts/**" ], "ignoreDependencies": ["json-schema-to-typescript"], "ignoreBinaries": ["tarball"] diff --git a/tools/detect-breaking-commits.ts b/tools/detect-breaking-commits.ts new file mode 100644 index 000000000..a5896c2ab --- /dev/null +++ b/tools/detect-breaking-commits.ts @@ -0,0 +1,123 @@ +#!/usr/bin/env tsx + +/** + * Scans for Conventional Commits breaking-change markers in a PR. Three + * surfaces are checked, because all three feed `release-it` once the PR is + * squash-merged: + * + * 1. Each commit between $BASE_SHA and $HEAD_SHA, restricted to the + * packages tracked by .release-it.json (avoids docs/tooling-only noise). + * 2. The PR title ($PR_TITLE), which becomes the squash commit subject. + * 3. The PR description ($PR_BODY), which can land in the squash commit + * body depending on repo settings. + * + * Writes `found` and (on match) `list` to $GITHUB_OUTPUT. The `list` is a + * markdown bullet list, grouping hits by source surface. + * + * Required env: BASE_SHA, HEAD_SHA, GITHUB_OUTPUT + * Optional env: PR_TITLE, PR_BODY + */ + +import { execFileSync } from "node:child_process"; +import { appendFileSync } from "node:fs"; + +const TRACKED_PATHS = [ + "packages/appkit", + "packages/appkit-ui", + "packages/shared", +]; + +// Conventional Commits breaking-change markers: +// 1. `type!:` or `type(scope)!:` in the subject line +// 2. `BREAKING CHANGE:` or `BREAKING-CHANGE:` footer line +const BREAKING_PATTERN = + /^(feat|fix|chore|refactor|perf|build|ci|docs|style|test|revert)(\([^)]+\))?!:|^BREAKING[ -]CHANGE:/m; + +function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) { + console.error(`Missing required env var: ${name}`); + process.exit(1); + } + return value; +} + +function git(...args: string[]): string { + return execFileSync("git", args, { encoding: "utf8" }); +} + +function listCommits(base: string, head: string): string[] { + return git("rev-list", `${base}..${head}`, "--", ...TRACKED_PATHS) + .split("\n") + .map((sha) => sha.trim()) + .filter(Boolean); +} + +function commitMessage(sha: string): string { + return git("log", "-1", "--format=%B", sha); +} + +function commitSubject(sha: string): string { + return git("log", "-1", "--format=%s", sha).trim(); +} + +function scanCommits(base: string, head: string): string[] { + const hits: string[] = []; + for (const sha of listCommits(base, head)) { + if (BREAKING_PATTERN.test(commitMessage(sha))) { + hits.push(` - \`${sha.slice(0, 7)}\` ${commitSubject(sha)}`); + } + } + return hits; +} + +function scanText(text: string | undefined): boolean { + return Boolean(text && BREAKING_PATTERN.test(text)); +} + +function writeOutput(found: boolean, list: string): void { + const outputPath = requireEnv("GITHUB_OUTPUT"); + if (!found) { + appendFileSync(outputPath, "found=false\n"); + return; + } + appendFileSync( + outputPath, + `found=true\nlist< 0) { + sections.push(["- **Commits**:", ...commitHits].join("\n")); + } + + if (sections.length === 0) { + console.log("No breaking-change markers found."); + writeOutput(false, ""); + return; + } + + const list = sections.join("\n"); + console.log("Breaking-change markers found:"); + console.log(list); + writeOutput(true, list); +} + +main();