diff --git a/.github/scripts/check-breaking.cjs b/.github/scripts/check-breaking.cjs new file mode 100644 index 000000000..5d90b958f --- /dev/null +++ b/.github/scripts/check-breaking.cjs @@ -0,0 +1,326 @@ +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const COMMENT_MARKER = ""; +const LABEL = "breaking change"; +const MAX_REPORT_LENGTH = 50_000; +const REMOVED_PACKAGE_REPORT = "Package removed from the current workspace."; + +const CHECKED_PACKAGES = new Set(["compio", "compio-driver"]); + +function stableFeatureNames(features) { + const unstable = new Set(); + const visit = (name) => { + if (unstable.has(name) || !Object.hasOwn(features, name)) { + return; + } + unstable.add(name); + for (const member of features[name]) { + visit(member); + } + }; + visit("nightly"); + + let changed; + do { + changed = false; + for (const [name, members] of Object.entries(features)) { + if ( + !unstable.has(name) && + members.some((member) => unstable.has(member)) + ) { + unstable.add(name); + changed = true; + } + } + } while (changed); + + return Object.keys(features) + .filter((name) => !unstable.has(name)) + .sort(); +} + +function workspacePackages(metadata) { + const members = new Set(metadata.workspace_members); + return metadata.packages + .filter( + ({ id, name }) => members.has(id) && CHECKED_PACKAGES.has(name), + ) + .map(({ features = {}, name }) => ({ + name, + stableFeatures: stableFeatureNames(features), + })) + .sort(({ name: left }, { name: right }) => left.localeCompare(right)); +} + +function classifyPackages(currentPackages, baselinePackages) { + const current = new Map( + currentPackages.map((package) => [package.name, package]), + ); + const baseline = new Set(baselinePackages.map(({ name }) => name)); + return { + common: [...current.values()].filter(({ name }) => baseline.has(name)), + removed: [...baseline].filter((name) => !current.has(name)).sort(), + }; +} + +async function readWorkspacePackages(exec, manifestPath) { + const result = await exec.getExecOutput( + "cargo", + [ + "metadata", + "--manifest-path", + manifestPath, + "--no-deps", + "--format-version", + "1", + ], + { silent: true }, + ); + return workspacePackages(JSON.parse(result.stdout)); +} + +async function checkPackage({ + baselineRev, + core, + exec, + packageName, + stableFeatures, +}) { + core.startGroup(`Checking ${packageName}`); + const args = [ + "semver-checks", + "check-release", + "--package", + packageName, + "--baseline-rev", + baselineRev, + "--release-type", + "minor", + "--only-explicit-features", + ]; + if (stableFeatures.length > 0) { + args.push("--features", stableFeatures.join(",")); + } + + let result; + try { + result = await exec.getExecOutput("cargo", args, { + ignoreReturnCode: true, + }); + } finally { + core.endGroup(); + } + + if (result.exitCode === 0) { + return null; + } + if (result.exitCode === 100) { + const output = [result.stdout, result.stderr] + .map((value) => value.trim()) + .filter(Boolean) + .join("\n"); + return [ + `$ cargo ${args.join(" ")}`, + output || "cargo-semver-checks reported a breaking public API change.", + ].join("\n"); + } + throw new Error( + `cargo-semver-checks could not check ${packageName} (exit code ${result.exitCode}).`, + ); +} + +async function check({ + baselineRev = process.env.BASELINE_REV, + core, + cwd = process.cwd(), + exec, + reportPath = process.env.BREAKING_REPORT_PATH, +}) { + if (!baselineRev) { + throw new Error("BASELINE_REV is required."); + } + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "compio-semver-")); + const baselineDir = path.join(tempDir, "baseline"); + let worktreeAdded = false; + + try { + await exec.exec("git", ["worktree", "add", "--detach", baselineDir, baselineRev]); + worktreeAdded = true; + + const currentPackages = await readWorkspacePackages( + exec, + path.join(cwd, "Cargo.toml"), + ); + const baselinePackages = await readWorkspacePackages( + exec, + path.join(baselineDir, "Cargo.toml"), + ); + const { common, removed } = classifyPackages( + currentPackages, + baselinePackages, + ); + const breaking = [...removed]; + const reports = new Map( + removed.map((name) => [name, REMOVED_PACKAGE_REPORT]), + ); + + if (removed.length > 0) { + core.info(`Removed workspace crates: ${removed.join(", ")}`); + } + + for (const { name, stableFeatures } of common) { + const packageReport = await checkPackage({ + baselineRev, + core, + exec, + packageName: name, + stableFeatures, + }); + if (packageReport !== null) { + breaking.push(name); + reports.set(name, packageReport); + } + } + + breaking.sort(); + const report = breaking + .map((name) => `[${name}]\n${reports.get(name)}`) + .join("\n\n"); + core.setOutput("breaking", breaking.length > 0); + core.setOutput("crates", breaking.join("\n")); + if (reportPath) { + fs.mkdirSync(path.dirname(reportPath), { recursive: true }); + fs.writeFileSync( + reportPath, + `${JSON.stringify({ crates: breaking, report })}\n`, + ); + } + return breaking; + } finally { + if (worktreeAdded) { + await exec.exec("git", ["worktree", "remove", "--force", baselineDir], { + ignoreReturnCode: true, + silent: true, + }); + } + fs.rmSync(tempDir, { force: true, recursive: true }); + } +} + +function escapeHtml(value) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">"); +} + +function formatDiagnostics(diagnostics) { + const escaped = escapeHtml(diagnostics.trim()); + const suffix = "\n\n... report truncated to fit the GitHub comment limit."; + return escaped.length <= MAX_REPORT_LENGTH + ? escaped + : `${escaped.slice(0, MAX_REPORT_LENGTH - suffix.length)}${suffix}`; +} + +function buildReportBody({ crates, diagnostics }) { + const crateList = crates.map((name) => `- \`${name}\``).join("\n"); + return `${COMMENT_MARKER} +### Breaking API change detected + +\`cargo-semver-checks\` found breaking public API changes in these crates: + +${crateList} + +Add \`!\` before the colon in the PR title, for example \`feat!: ...\` or \`feat(runtime)!: ...\`. In the PR description, explain the affected API, the impact on users, and the migration path. + +
+cargo-semver-checks report + +
${formatDiagnostics(diagnostics)}
+
`; +} + +function readReport(reportPath) { + return JSON.parse(fs.readFileSync(reportPath, "utf8")); +} + +async function report({ + crates, + diagnostics, + github, + context, + issueNumber = context.issue?.number, +}) { + if (crates.length === 0) { + throw new Error("At least one breaking crate is required."); + } + if (!issueNumber) { + throw new Error("A pull request number is required."); + } + if (typeof diagnostics !== "string" || !diagnostics.trim()) { + throw new Error("Breaking change diagnostics are required."); + } + + const body = buildReportBody({ crates, diagnostics }); + + await github.rest.issues.addLabels({ + ...context.repo, + issue_number: issueNumber, + labels: [LABEL], + }); + + const comments = await github.paginate(github.rest.issues.listComments, { + ...context.repo, + issue_number: issueNumber, + per_page: 100, + }); + const existing = comments.find( + (comment) => + comment.user?.type === "Bot" && comment.body?.includes(COMMENT_MARKER), + ); + + if (existing) { + await github.rest.issues.updateComment({ + ...context.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + ...context.repo, + issue_number: issueNumber, + body, + }); + } +} + +async function reportFromFile({ core, reportPath, ...options }) { + const { crates, report: diagnostics } = readReport(reportPath); + if (!Array.isArray(crates)) { + throw new Error("The breaking change report has no crate list."); + } + if (crates.length === 0) { + core.info("No breaking public API changes detected."); + return false; + } + if (typeof diagnostics !== "string" || !diagnostics.trim()) { + throw new Error("The breaking change report has no diagnostics."); + } + + await report({ crates, diagnostics, ...options }); + return true; +} + +module.exports = { + buildReportBody, + check, + classifyPackages, + readReport, + report, + reportFromFile, + stableFeatureNames, + workspacePackages, +}; diff --git a/.github/scripts/check-breaking.test.cjs b/.github/scripts/check-breaking.test.cjs new file mode 100644 index 000000000..de3b7c8b2 --- /dev/null +++ b/.github/scripts/check-breaking.test.cjs @@ -0,0 +1,246 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const { check, reportFromFile } = require("./check-breaking.cjs"); + +function metadata(packages) { + const entries = packages.map((package) => + typeof package === "string" ? { name: package, features: {} } : package, + ); + const records = entries.map(({ features, name }) => ({ + features, + id: `${name} 0.1.0`, + name, + })); + return { + packages: records, + workspace_members: records.map(({ id }) => id), + }; +} + +function mockCore() { + return { + groups: [], + info() {}, + outputs: {}, + endGroup() {}, + setOutput(name, value) { + this.outputs[name] = value; + }, + startGroup(name) { + this.groups.push(name); + }, + }; +} + +function mockExec({ + baseline, + current, + outputs = {}, + semverArgs = [], + statuses = {}, +}) { + let metadataCall = 0; + return { + async exec() { + return 0; + }, + async getExecOutput(tool, args) { + assert.equal(tool, "cargo"); + if (args[0] === "metadata") { + const value = metadataCall++ === 0 ? current : baseline; + return { exitCode: 0, stderr: "", stdout: JSON.stringify(metadata(value)) }; + } + + semverArgs.push(args); + const packageName = args[args.indexOf("--package") + 1]; + return { + exitCode: statuses[packageName] ?? 0, + stderr: "", + stdout: outputs[packageName] ?? "", + }; + }, + }; +} + +test("reports incompatible and removed checked crates", async () => { + const core = mockCore(); + const exec = mockExec({ + baseline: ["compio", "compio-driver", "compio-fs"], + current: ["compio", "compio-net"], + statuses: { compio: 100 }, + outputs: { compio: "compio compatibility report" }, + }); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "compio-semver-test-")); + const reportPath = path.join(tempDir, "report.json"); + try { + const breaking = await check({ + baselineRev: "base", + core, + exec, + reportPath, + }); + + assert.deepEqual(breaking, ["compio", "compio-driver"]); + assert.deepEqual(core.outputs, { + breaking: true, + crates: "compio\ncompio-driver", + }); + const report = JSON.parse(fs.readFileSync(reportPath, "utf8")); + assert.deepEqual(report.crates, ["compio", "compio-driver"]); + assert.match(report.report, /\[compio\]/); + assert.match(report.report, /compio compatibility report/); + assert.match(report.report, /\[compio-driver\]/); + assert.match(report.report, /removed from the current workspace/); + } finally { + fs.rmSync(tempDir, { force: true, recursive: true }); + } +}); + +test("reports a compatible workspace", async () => { + const core = mockCore(); + const exec = mockExec({ + baseline: ["compio", "compio-driver", "compio-runtime"], + current: ["compio", "compio-driver", "compio-runtime"], + }); + + const breaking = await check({ baselineRev: "base", core, exec }); + + assert.deepEqual(breaking, []); + assert.deepEqual(core.outputs, { breaking: false, crates: "" }); +}); + +test("checks only compio and compio-driver", async () => { + const core = mockCore(); + const semverArgs = []; + const packages = ["compio", "compio-driver", "compio-macros", "compio-net"]; + const exec = mockExec({ + baseline: packages, + current: packages, + semverArgs, + }); + + await check({ baselineRev: "base", core, exec }); + + assert.deepEqual( + semverArgs.map((args) => args[args.indexOf("--package") + 1]), + ["compio", "compio-driver"], + ); +}); + +test("checks one package with stable features only", async () => { + const core = mockCore(); + const semverArgs = []; + const packages = [ + { + features: { + all: ["bytes"], + bytes: ["dep:bytes"], + default: ["bytes"], + nightly: ["read_buf"], + "nightly-all": ["nightly"], + read_buf: [], + }, + name: "compio-driver", + }, + ]; + const exec = mockExec({ + baseline: packages, + current: packages, + semverArgs, + }); + + await check({ baselineRev: "base", core, exec }); + + assert.deepEqual(semverArgs, [ + [ + "semver-checks", + "check-release", + "--package", + "compio-driver", + "--baseline-rev", + "base", + "--release-type", + "minor", + "--only-explicit-features", + "--features", + "all,bytes,default", + ], + ]); +}); + +test("fails when cargo-semver-checks cannot complete", async () => { + const core = mockCore(); + const exec = mockExec({ + baseline: ["compio"], + current: ["compio"], + statuses: { compio: 101 }, + }); + + await assert.rejects( + check({ baselineRev: "base", core, exec }), + /could not check compio \(exit code 101\)/, + ); +}); + +test("labels the PR and lists each breaking crate", async () => { + const calls = []; + const github = { + paginate: async () => [], + rest: { + issues: { + addLabels: async (args) => calls.push(["addLabels", args]), + createComment: async (args) => calls.push(["createComment", args]), + listComments: async () => {}, + }, + }, + }; + const context = { + issue: { number: 42 }, + repo: { owner: "compio-rs", repo: "compio" }, + runId: 123, + }; + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "compio-report-test-")); + const reportPath = path.join(tempDir, "report.json"); + fs.writeFileSync( + reportPath, + JSON.stringify({ + crates: ["compio-runtime", "compio-net"], + report: + "[compio-runtime]\n runtime API\n\n[compio-net]\nremoved API", + }), + ); + try { + assert.equal( + await reportFromFile({ + core: mockCore(), + github, + context, + issueNumber: 42, + reportPath, + }), + true, + ); + } finally { + fs.rmSync(tempDir, { force: true, recursive: true }); + } + + const comment = calls.find(([name]) => name === "createComment")[1]; + assert.match(comment.body, /- `compio-runtime`/); + assert.match(comment.body, /- `compio-net`/); + assert.match(comment.body, /`feat!: \.\.\.`/); + assert.match(comment.body, /migration path/); + assert.match(comment.body, /
/); + assert.match(comment.body, /cargo-semver-checks report<\/summary>/); + assert.match(comment.body, /
/);
+  assert.match(comment.body, /<breaking> runtime API/);
+  assert.match(comment.body, /<\/code><\/pre>/);
+  assert.doesNotMatch(comment.body, /workflow run|actions\/runs/);
+  assert.ok(calls.some(([name]) => name === "addLabels"));
+  assert.ok(!calls.some(([name]) => name === "getLabel"));
+  assert.ok(!calls.some(([name]) => name === "createLabel"));
+});
diff --git a/.github/workflows/ci_check_breaking.yml b/.github/workflows/ci_check_breaking.yml
new file mode 100644
index 000000000..8bdea42aa
--- /dev/null
+++ b/.github/workflows/ci_check_breaking.yml
@@ -0,0 +1,42 @@
+name: Check Breaking Changes
+
+on:
+  pull_request:
+    types: [opened, reopened, synchronize]
+
+concurrency:
+  group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
+  cancel-in-progress: true
+
+jobs:
+  check-breaking:
+    runs-on: ubuntu-latest
+    permissions:
+      contents: read
+    steps:
+      - uses: actions/checkout@v6
+        with:
+          fetch-depth: 0
+          persist-credentials: false
+      - name: Setup Rust Toolchain
+        run: rustup default stable
+      - uses: taiki-e/install-action@cargo-semver-checks
+      - name: Check Public API Compatibility
+        id: semver
+        uses: actions/github-script@v8
+        env:
+          BASELINE_REV: ${{ github.event.pull_request.base.sha }}
+          BREAKING_REPORT_PATH: ${{ runner.temp }}/breaking-change/report.json
+          CARGO_INCREMENTAL: 0
+        with:
+          script: |
+            const { check } = require("./.github/scripts/check-breaking.cjs");
+            await check({ core, exec });
+      - name: Save Breaking Change Report
+        uses: actions/upload-artifact@v7
+        with:
+          name: breaking-change-report
+          path: ${{ runner.temp }}/breaking-change/report.json
+          if-no-files-found: error
+          retention-days: 1
+
diff --git a/.github/workflows/ci_report_breaking.yml b/.github/workflows/ci_report_breaking.yml
new file mode 100644
index 000000000..f5c653293
--- /dev/null
+++ b/.github/workflows/ci_report_breaking.yml
@@ -0,0 +1,48 @@
+name: Report Breaking Changes
+
+on:
+  workflow_run:
+    workflows: [Check Breaking Changes]
+    types: [completed]
+
+concurrency:
+  group: ${{ github.workflow }}-${{ github.event.workflow_run.id }}
+  cancel-in-progress: true
+
+jobs:
+  report-breaking:
+    if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.pull_requests[0].number }}
+    runs-on: ubuntu-latest
+    permissions:
+      actions: read
+      contents: read
+      issues: write
+      pull-requests: write
+    steps:
+      - uses: actions/checkout@v6
+        with:
+          ref: ${{ github.event.repository.default_branch }}
+          persist-credentials: false
+      - name: Load Breaking Change Report
+        uses: actions/download-artifact@v8
+        with:
+          name: breaking-change-report
+          path: ${{ runner.temp }}/breaking-change
+          repository: ${{ github.repository }}
+          run-id: ${{ github.event.workflow_run.id }}
+          github-token: ${{ github.token }}
+      - name: Label PR and Report Breaking Changes
+        uses: actions/github-script@v8
+        env:
+          BREAKING_REPORT_PATH: ${{ runner.temp }}/breaking-change/report.json
+          PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }}
+        with:
+          script: |
+            const { reportFromFile } = require("./.github/scripts/check-breaking.cjs");
+            await reportFromFile({
+              core,
+              github,
+              context,
+              issueNumber: Number(process.env.PR_NUMBER),
+              reportPath: process.env.BREAKING_REPORT_PATH,
+            });