e30e75b5d4
Code Quality / Oxlint + Oxfmt (push) Waiting to run
Code Quality / Template Sync (push) Waiting to run
Code Quality / Build Changed Packages (push) Waiting to run
Code Quality / Test Changed Packages (push) Waiting to run
Deploy Expo Example / Deploy Production (push) Waiting to run
Deploy Ink Example / Deploy Production (push) Waiting to run
Python Tests / pytest (assistant-stream, 3.10) (push) Waiting to run
Python Tests / pytest (assistant-stream, 3.12) (push) Waiting to run
Python Tests / pytest (assistant-ui-sync-server-api, 3.10) (push) Waiting to run
Python Tests / pytest (assistant-ui-sync-server-api, 3.12) (push) Waiting to run
Deploy Shadcn Registry / Deploy Production (push) Waiting to run
Template Metrics / LOC + Bundle Size (push) Waiting to run
Changesets / Create Version PR (push) Has been cancelled
303 lines
12 KiB
YAML
303 lines
12 KiB
YAML
name: Changeset Semver Check
|
|
|
|
on:
|
|
pull_request:
|
|
branches: [main]
|
|
types: [opened, synchronize, reopened, labeled]
|
|
|
|
permissions:
|
|
contents: read
|
|
pull-requests: read
|
|
|
|
concurrency:
|
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
|
cancel-in-progress: true
|
|
|
|
jobs:
|
|
analyze:
|
|
name: Semver Check
|
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
|
timeout-minutes: 2
|
|
steps:
|
|
- name: Checkout PR head
|
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # ratchet:actions/checkout@v7
|
|
with:
|
|
ref: ${{ github.event.pull_request.head.sha }}
|
|
fetch-depth: 0
|
|
|
|
- name: Check changeset bump types against package versions
|
|
id: check
|
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
|
env:
|
|
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
|
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
|
with:
|
|
script: |
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const { execFileSync } = require("child_process");
|
|
|
|
// --- Helpers ---
|
|
|
|
function bumpVersion(version, bumpType) {
|
|
const [major, minor, patch] = version.split(".").map(Number);
|
|
if (bumpType === "major") return `${major + 1}.0.0`;
|
|
if (bumpType === "minor") return `${major}.${minor + 1}.0`;
|
|
return `${major}.${minor}.${patch + 1}`;
|
|
}
|
|
|
|
function isOutsideCaretRange(rangeVersion, newVersion) {
|
|
const [rMajor, rMinor, rPatch] = rangeVersion.split(".").map(Number);
|
|
const [nMajor, nMinor, nPatch] = newVersion.split(".").map(Number);
|
|
// ^0.0.Z — exact match only
|
|
if (rMajor === 0 && rMinor === 0) {
|
|
return nMajor !== 0 || nMinor !== 0 || nPatch !== rPatch;
|
|
}
|
|
// ^0.X.Z (X > 0) — same major and minor
|
|
if (rMajor === 0) {
|
|
return nMajor !== 0 || nMinor !== rMinor;
|
|
}
|
|
// ^X.Y.Z (X > 0) — same major
|
|
return nMajor !== rMajor;
|
|
}
|
|
|
|
function computeCascade(bumps, pkgMap, revDeps) {
|
|
const cascade = [];
|
|
const visited = new Set(bumps.map((v) => v.pkg));
|
|
const queue = bumps.map((v) => ({
|
|
pkg: v.pkg,
|
|
newVersion: bumpVersion(v.version, v.bumpType),
|
|
}));
|
|
|
|
let qi = 0;
|
|
while (qi < queue.length) {
|
|
const { pkg, newVersion } = queue[qi++];
|
|
const dependents = revDeps.get(pkg) || [];
|
|
for (const dep of dependents) {
|
|
if (visited.has(dep.name)) continue;
|
|
// Extract version from the ^ range (e.g., "^0.12.15" → "0.12.15")
|
|
const rangeVersion = dep.range.replace(/^\^/, "");
|
|
if (!isOutsideCaretRange(rangeVersion, newVersion)) continue;
|
|
visited.add(dep.name);
|
|
// This dependent gets a cascade patch
|
|
const depInfo = pkgMap.get(dep.name);
|
|
const depVersion = depInfo?.version ?? dep.version;
|
|
const cascadeNewVersion = bumpVersion(depVersion, "patch");
|
|
const isBreaking = isOutsideCaretRange(depVersion, cascadeNewVersion);
|
|
cascade.push({
|
|
name: dep.name,
|
|
version: depVersion,
|
|
newVersion: cascadeNewVersion,
|
|
isBreaking,
|
|
});
|
|
// If the cascade patch is itself range-breaking (0.0.x),
|
|
// recurse to check further downstream
|
|
if (isBreaking) {
|
|
queue.push({ pkg: dep.name, newVersion: cascadeNewVersion });
|
|
}
|
|
}
|
|
}
|
|
return cascade;
|
|
}
|
|
|
|
// --- Scope to PR-changed changeset files ---
|
|
|
|
const { BASE_SHA, HEAD_SHA } = process.env;
|
|
let prChangedFiles;
|
|
try {
|
|
const diff = execFileSync(
|
|
"git",
|
|
["diff", "--name-only", "--diff-filter=ACM", BASE_SHA, HEAD_SHA, "--", ".changeset/*.md"],
|
|
{ encoding: "utf8" },
|
|
).trim();
|
|
prChangedFiles = new Set(
|
|
diff ? diff.split("\n").map((f) => path.basename(f)) : [],
|
|
);
|
|
} catch {
|
|
// If git diff fails (e.g., shallow clone), fall back to all files
|
|
core.warning("Could not diff against base — checking all changeset files");
|
|
prChangedFiles = null;
|
|
}
|
|
|
|
const changesetDir = ".changeset";
|
|
const allFiles = fs.readdirSync(changesetDir).filter(
|
|
(f) => f.endsWith(".md") && f !== "README.md",
|
|
);
|
|
const files = prChangedFiles
|
|
? allFiles.filter((f) => prChangedFiles.has(f))
|
|
: allFiles;
|
|
|
|
if (files.length === 0) {
|
|
core.info("No changeset files changed in this PR.");
|
|
return;
|
|
}
|
|
|
|
// --- Build package map and reverse dependency graph ---
|
|
|
|
const pkgMap = new Map();
|
|
const revDeps = new Map();
|
|
const packageManifests = [];
|
|
const packagesDir = "packages";
|
|
for (const entry of fs.readdirSync(packagesDir, { withFileTypes: true })) {
|
|
if (!entry.isDirectory()) continue;
|
|
const dir = entry.name;
|
|
const pkgJsonPath = path.join(packagesDir, dir, "package.json");
|
|
let pkgJson;
|
|
try {
|
|
pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8"));
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (pkgJson.private) continue;
|
|
packageManifests.push(pkgJson);
|
|
const major = parseInt(pkgJson.version.split(".")[0], 10);
|
|
pkgMap.set(pkgJson.name, { major, version: pkgJson.version });
|
|
}
|
|
|
|
for (const pkgJson of packageManifests) {
|
|
const allDeps = {
|
|
...pkgJson.dependencies,
|
|
...pkgJson.peerDependencies,
|
|
};
|
|
for (const [depName, range] of Object.entries(allDeps)) {
|
|
if (typeof range !== "string" || !range.startsWith("^")) continue;
|
|
if (!pkgMap.has(depName)) continue;
|
|
if (!revDeps.has(depName)) revDeps.set(depName, []);
|
|
revDeps.get(depName).push({
|
|
name: pkgJson.name,
|
|
version: pkgJson.version,
|
|
range,
|
|
});
|
|
}
|
|
}
|
|
|
|
// --- Parse changeset files ---
|
|
|
|
const allBumps = [];
|
|
const violations = [];
|
|
|
|
for (const file of files) {
|
|
const content = fs.readFileSync(
|
|
path.join(changesetDir, file),
|
|
"utf8",
|
|
);
|
|
|
|
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
if (!match) continue;
|
|
|
|
const frontmatter = match[1];
|
|
for (const line of frontmatter.split("\n")) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed) continue;
|
|
|
|
const bumpMatch = trimmed.match(
|
|
/^"([^"]+)":\s*(patch|minor|major)$/,
|
|
);
|
|
if (!bumpMatch) continue;
|
|
|
|
const [, pkg, bumpType] = bumpMatch;
|
|
const pkgInfo = pkgMap.get(pkg);
|
|
|
|
// Skip unknown or private packages (not in pkgMap)
|
|
if (!pkgInfo) continue;
|
|
|
|
const { major, version } = pkgInfo;
|
|
|
|
allBumps.push({ file, pkg, bumpType, version });
|
|
|
|
// Rules:
|
|
// 0.0.x → patch technically breaks ^0.0.x (exact match), but not
|
|
// flagged because these packages are pre-stable with no
|
|
// internal dependents. Only minor/major are flagged.
|
|
// 0.x → only patch is safe (minor/major break ^)
|
|
// 1.x+ → patch and minor are safe (only major breaks ^)
|
|
const needsConfirmation =
|
|
(major === 0 && bumpType !== "patch") ||
|
|
(major >= 1 && bumpType === "major");
|
|
|
|
if (needsConfirmation) {
|
|
const reason =
|
|
major === 0
|
|
? `0.x package — ${bumpType} bump breaks \`^\` caret range`
|
|
: `major bump breaks \`^\` caret range`;
|
|
violations.push({ file, pkg, bumpType, version, reason });
|
|
}
|
|
}
|
|
}
|
|
|
|
if (allBumps.length === 0) {
|
|
core.info("No package bumps found in changesets.");
|
|
return;
|
|
}
|
|
|
|
// --- Compute cascade impact for ALL bumps ---
|
|
|
|
const cascade = computeCascade(allBumps, pkgMap, revDeps);
|
|
const breakingCascade = cascade.filter((c) => c.isBreaking);
|
|
|
|
// --- Build cascade table (shared between both summary paths) ---
|
|
|
|
function renderCascadeSection() {
|
|
if (cascade.length === 0) return "";
|
|
let section = `### Cascade Impact (${cascade.length} packages)\n\n`;
|
|
section += "| Package | Version | Cascade Bump | Breaking |\n";
|
|
section += "| --- | --- | --- | --- |\n";
|
|
for (const c of cascade) {
|
|
const indicator = c.isBreaking
|
|
? `⛔ YES — \`^${c.version}\` breaks`
|
|
: "✅ no";
|
|
section += `| \`${c.name}\` | ${c.version} → ${c.newVersion} | patch | ${indicator} |\n`;
|
|
}
|
|
section += "\n";
|
|
if (breakingCascade.length > 0) {
|
|
section += `> **${breakingCascade.length} downstream package(s)** will also break their consumers' \`^\` ranges.\n\n`;
|
|
}
|
|
return section;
|
|
}
|
|
|
|
// --- Build summary ---
|
|
|
|
let summary;
|
|
|
|
if (violations.length === 0) {
|
|
// Informational summary for safe PRs
|
|
summary = "## Changeset Impact Summary\n\n";
|
|
summary += "| File | Package | Version | Bump |\n";
|
|
summary += "| --- | --- | --- | --- |\n";
|
|
for (const b of allBumps) {
|
|
summary += `| \`${b.file}\` | \`${b.pkg}\` | ${b.version} | ${b.bumpType} |\n`;
|
|
}
|
|
summary += "\n";
|
|
summary += renderCascadeSection();
|
|
|
|
await core.summary.addRaw(summary).write();
|
|
core.info("All changeset bump types are safe for current package versions. ✓");
|
|
} else {
|
|
// Violation summary
|
|
summary = "## ⚠️ Semver-Breaking Changeset Detected\n\n";
|
|
summary += "| File | Package | Version | Bump | Why |\n";
|
|
summary += "| --- | --- | --- | --- | --- |\n";
|
|
for (const v of violations) {
|
|
summary += `| \`${v.file}\` | \`${v.pkg}\` | ${v.version} | **${v.bumpType}** | ${v.reason} |\n`;
|
|
}
|
|
summary += "\n";
|
|
summary += renderCascadeSection();
|
|
summary += "### What this means\n\n";
|
|
summary += "- **0.x packages**: `^0.12.15` only matches `>=0.12.15 <0.13.0` — ";
|
|
summary += "a minor bump is effectively a breaking change for all consumers.\n";
|
|
summary += "- **1.x+ packages**: `^1.3.12` matches `>=1.3.12 <2.0.0` — ";
|
|
summary += "minor/patch are safe, but a major bump breaks all consumers.\n\n";
|
|
summary += "This check is informational — the PR can still be merged if this is intentional.\n";
|
|
|
|
await core.summary.addRaw(summary).write();
|
|
|
|
// Emit warnings for breaking cascade entries (visible in PR checks tab)
|
|
for (const c of breakingCascade) {
|
|
core.warning(
|
|
`Cascade breaks ${c.name} (${c.version} → ${c.newVersion}, ^${c.version} consumers affected)`,
|
|
);
|
|
}
|
|
|
|
core.setFailed(`Semver-breaking bumps detected: ${violations.map((v) => `${v.pkg}@${v.bumpType}`).join(", ")}`);
|
|
}
|