From 85f72491317ec3970b447fd889e2372f4593895b Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Wed, 6 May 2026 13:15:02 +0200 Subject: [PATCH 1/3] ci: detect breaking-change commits in PRs Adds a workflow that scans every commit in a pull request for Conventional Commits breaking-change markers (`type!:` or `BREAKING CHANGE:` footer) limited to the packages tracked by .release-it.json (appkit, appkit-ui, shared). When a breaking commit is found, the job posts a sticky PR comment explaining the major-version impact and fails the check, unless the PR carries an `allow-breaking-change` label. This prevents accidental major bumps from landing on main once the new check is added to branch-protection required checks. Signed-off-by: MarioCadenas --- .github/scripts/detect-breaking-commits.sh | 40 +++++++++++ .../upsert-breaking-change-comment.cjs | 71 +++++++++++++++++++ .github/workflows/pr-breaking-change.yml | 51 +++++++++++++ knip.json | 3 +- 4 files changed, 164 insertions(+), 1 deletion(-) create mode 100755 .github/scripts/detect-breaking-commits.sh create mode 100644 .github/scripts/upsert-breaking-change-comment.cjs create mode 100644 .github/workflows/pr-breaking-change.yml diff --git a/.github/scripts/detect-breaking-commits.sh b/.github/scripts/detect-breaking-commits.sh new file mode 100755 index 000000000..6eb6517d5 --- /dev/null +++ b/.github/scripts/detect-breaking-commits.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# +# Scans commits between $BASE_SHA and $HEAD_SHA for Conventional Commits +# breaking-change markers, restricted to the packages tracked by +# .release-it.json. Writes `found` and (on match) `list` to $GITHUB_OUTPUT. +# +# Required env: BASE_SHA, HEAD_SHA, GITHUB_OUTPUT + +set -euo pipefail + +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 +PATTERN='^(feat|fix|chore|refactor|perf|build|ci|docs|style|test|revert)(\([^)]+\))?!:|^BREAKING[ -]CHANGE:' + +breaking="" +while IFS= read -r sha; do + [ -z "$sha" ] && continue + msg=$(git log -1 --format=%B "$sha") + if printf '%s\n' "$msg" | grep -Eq "$PATTERN"; then + subject=$(git log -1 --format=%s "$sha") + breaking+="- \`${sha:0:7}\` ${subject}"$'\n' + fi +done < <(git rev-list "$BASE_SHA".."$HEAD_SHA" -- "${PATHS[@]}") + +if [ -n "$breaking" ]; then + { + echo "found=true" + echo "list<> "$GITHUB_OUTPUT" + echo "Breaking commits found:" + printf '%s' "$breaking" +else + echo "found=false" >> "$GITHUB_OUTPUT" + echo "No breaking commits found." +fi diff --git a/.github/scripts/upsert-breaking-change-comment.cjs b/.github/scripts/upsert-breaking-change-comment.cjs new file mode 100644 index 000000000..aca59e381 --- /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", + "", + "The following commits in this PR contain Conventional Commits breaking-change markers (`type!:` or `BREAKING CHANGE:` footer) and touch packages tracked by `.release-it.json`:", + "", + 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-breaking-change.yml b/.github/workflows/pr-breaking-change.yml new file mode 100644 index 000000000..2adc2f5e3 --- /dev/null +++ b/.github/workflows/pr-breaking-change.yml @@ -0,0 +1,51 @@ +name: PR Breaking Change Check + +on: + pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] + branches: + - main + +permissions: + contents: read + pull-requests: write + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + 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 }} + + - name: Find breaking commits + id: scan + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: bash .github/scripts/detect-breaking-commits.sh + + - 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/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"] From 350098ee5bd6cce17aad564a399ed97cc0d4f9e3 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Wed, 6 May 2026 18:40:04 +0200 Subject: [PATCH 2/3] ci: consolidate PR metadata workflow and rewrite breaking-change detector in TS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback on #350: - Merge `pr-title.yml` and `pr-breaking-change.yml` into a single `pr-metadata.yml` workflow ("PR Metadata Verification") with two jobs: `check-title` and `detect-breaking`. - Move the breaking-commit scan from a bash script to `tools/detect-breaking-commits.ts`, matching the rest of `tools/*.ts`. - Run the TS script directly with `node` (Node 24 native type stripping) so the job stays lightweight — no pnpm install or tsx transpile step. --- .github/scripts/detect-breaking-commits.sh | 40 --------- ...pr-breaking-change.yml => pr-metadata.yml} | 36 +++++++- .github/workflows/pr-title.yml | 36 -------- tools/detect-breaking-commits.ts | 89 +++++++++++++++++++ 4 files changed, 122 insertions(+), 79 deletions(-) delete mode 100755 .github/scripts/detect-breaking-commits.sh rename .github/workflows/{pr-breaking-change.yml => pr-metadata.yml} (62%) delete mode 100644 .github/workflows/pr-title.yml create mode 100644 tools/detect-breaking-commits.ts diff --git a/.github/scripts/detect-breaking-commits.sh b/.github/scripts/detect-breaking-commits.sh deleted file mode 100755 index 6eb6517d5..000000000 --- a/.github/scripts/detect-breaking-commits.sh +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env bash -# -# Scans commits between $BASE_SHA and $HEAD_SHA for Conventional Commits -# breaking-change markers, restricted to the packages tracked by -# .release-it.json. Writes `found` and (on match) `list` to $GITHUB_OUTPUT. -# -# Required env: BASE_SHA, HEAD_SHA, GITHUB_OUTPUT - -set -euo pipefail - -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 -PATTERN='^(feat|fix|chore|refactor|perf|build|ci|docs|style|test|revert)(\([^)]+\))?!:|^BREAKING[ -]CHANGE:' - -breaking="" -while IFS= read -r sha; do - [ -z "$sha" ] && continue - msg=$(git log -1 --format=%B "$sha") - if printf '%s\n' "$msg" | grep -Eq "$PATTERN"; then - subject=$(git log -1 --format=%s "$sha") - breaking+="- \`${sha:0:7}\` ${subject}"$'\n' - fi -done < <(git rev-list "$BASE_SHA".."$HEAD_SHA" -- "${PATHS[@]}") - -if [ -n "$breaking" ]; then - { - echo "found=true" - echo "list<> "$GITHUB_OUTPUT" - echo "Breaking commits found:" - printf '%s' "$breaking" -else - echo "found=false" >> "$GITHUB_OUTPUT" - echo "No breaking commits found." -fi diff --git a/.github/workflows/pr-breaking-change.yml b/.github/workflows/pr-metadata.yml similarity index 62% rename from .github/workflows/pr-breaking-change.yml rename to .github/workflows/pr-metadata.yml index 2adc2f5e3..9dfe3dc2d 100644 --- a/.github/workflows/pr-breaking-change.yml +++ b/.github/workflows/pr-metadata.yml @@ -1,8 +1,8 @@ -name: PR Breaking Change Check +name: PR Metadata Verification on: pull_request: - types: [opened, synchronize, reopened, labeled, unlabeled] + types: [opened, synchronize, reopened, edited, labeled, unlabeled] branches: - main @@ -15,6 +15,31 @@ concurrency: 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: @@ -26,12 +51,17 @@ jobs: 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 commits id: scan env: BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: bash .github/scripts/detect-breaking-commits.sh + # 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 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/tools/detect-breaking-commits.ts b/tools/detect-breaking-commits.ts new file mode 100644 index 000000000..4b3cd0aae --- /dev/null +++ b/tools/detect-breaking-commits.ts @@ -0,0 +1,89 @@ +#!/usr/bin/env tsx + +/** + * Scans commits between $BASE_SHA and $HEAD_SHA for Conventional Commits + * breaking-change markers, restricted to the packages tracked by + * .release-it.json. Writes `found` and (on match) `list` to $GITHUB_OUTPUT. + * + * Required env: BASE_SHA, HEAD_SHA, GITHUB_OUTPUT + */ + +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 writeOutput(found: boolean, list: string): void { + const outputPath = requireEnv("GITHUB_OUTPUT"); + if (!found) { + appendFileSync(outputPath, "found=false\n"); + return; + } + appendFileSync( + outputPath, + `found=true\nlist< Date: Wed, 6 May 2026 18:48:22 +0200 Subject: [PATCH 3/3] ci: also scan PR title and description for breaking-change markers The previous detector only walked per-commit messages, so a PR with a title like `feat!: foo` or with `BREAKING CHANGE:` in the description would slip through even though both surfaces feed `release-it` after a squash merge (title becomes the squash subject, description can land in the squash body). Pass `PR_TITLE` and `PR_BODY` into the detector and group hits by source (title / description / commits) in the sticky PR comment. --- .../upsert-breaking-change-comment.cjs | 2 +- .github/workflows/pr-metadata.yml | 4 +- tools/detect-breaking-commits.ts | 62 ++++++++++++++----- 3 files changed, 52 insertions(+), 16 deletions(-) diff --git a/.github/scripts/upsert-breaking-change-comment.cjs b/.github/scripts/upsert-breaking-change-comment.cjs index aca59e381..cebc53bb3 100644 --- a/.github/scripts/upsert-breaking-change-comment.cjs +++ b/.github/scripts/upsert-breaking-change-comment.cjs @@ -44,7 +44,7 @@ module.exports = async ({ github, context }) => { MARKER, "### Breaking change detected", "", - "The following commits in this PR contain Conventional Commits breaking-change markers (`type!:` or `BREAKING CHANGE:` footer) and touch packages tracked by `.release-it.json`:", + "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(), "", diff --git a/.github/workflows/pr-metadata.yml b/.github/workflows/pr-metadata.yml index 9dfe3dc2d..0fa41941e 100644 --- a/.github/workflows/pr-metadata.yml +++ b/.github/workflows/pr-metadata.yml @@ -55,11 +55,13 @@ jobs: with: node-version-file: .nvmrc - - name: Find breaking commits + - 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 diff --git a/tools/detect-breaking-commits.ts b/tools/detect-breaking-commits.ts index 4b3cd0aae..a5896c2ab 100644 --- a/tools/detect-breaking-commits.ts +++ b/tools/detect-breaking-commits.ts @@ -1,11 +1,21 @@ #!/usr/bin/env tsx /** - * Scans commits between $BASE_SHA and $HEAD_SHA for Conventional Commits - * breaking-change markers, restricted to the packages tracked by - * .release-it.json. Writes `found` and (on match) `list` to $GITHUB_OUTPUT. + * 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"; @@ -51,6 +61,20 @@ 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) { @@ -59,30 +83,40 @@ function writeOutput(found: boolean, list: string): void { } appendFileSync( outputPath, - `found=true\nlist< 0) { + sections.push(["- **Commits**:", ...commitHits].join("\n")); } - if (breaking.length === 0) { - console.log("No breaking commits found."); + if (sections.length === 0) { + console.log("No breaking-change markers found."); writeOutput(false, ""); return; } - const list = breaking.join(""); - console.log("Breaking commits found:"); - process.stdout.write(list); + const list = sections.join("\n"); + console.log("Breaking-change markers found:"); + console.log(list); writeOutput(true, list); }