cloudflare/kumo
Publicmirrored from https://github.com/cloudflare/kumoAvailable
ci/scripts/validate-kumo-changeset.ts
250lines · modecode
| 1 | #!/usr/bin/env tsx |
| 2 | |
| 3 | import { readFileSync } from "fs"; |
| 4 | import { join } from "path"; |
| 5 | import { |
| 6 | hasChangesInPath, |
| 7 | getNewlyAddedFiles, |
| 8 | isPullRequestContext, |
| 9 | logPullRequestContext, |
| 10 | } from "../utils/git-operations"; |
| 11 | |
| 12 | /** |
| 13 | * Validates that a changeset exists for the @cloudflare/kumo package |
| 14 | * when files in packages/kumo/ are modified in a pull request. |
| 15 | */ |
| 16 | |
| 17 | const KUMO_PACKAGE_NAME = "@cloudflare/kumo"; |
| 18 | const KUMO_PATH = "packages/kumo"; |
| 19 | const CHANGESET_DIR = ".changeset"; |
| 20 | |
| 21 | interface ChangesetFile { |
| 22 | name: string; |
| 23 | content: string; |
| 24 | packages: string[]; |
| 25 | } |
| 26 | |
| 27 | function main() { |
| 28 | console.log(`🔍 Validating changeset for kumo package: ${KUMO_PACKAGE_NAME}`); |
| 29 | |
| 30 | // Check if we're in a validation context (CI PR or local pre-push) |
| 31 | const shouldValidate = isPullRequestContext() || isLocalContext(); |
| 32 | |
| 33 | // Log detection method for transparency |
| 34 | if (isPullRequestContext()) { |
| 35 | logPullRequestContext(); |
| 36 | } else if (isLocalContext()) { |
| 37 | console.log("Detected context: Local pre-push hook"); |
| 38 | } |
| 39 | |
| 40 | if (!shouldValidate) { |
| 41 | console.log("Not a validation context, skipping changeset validation"); |
| 42 | return; |
| 43 | } |
| 44 | |
| 45 | // Skip validation on Changesets release PRs. The `changesets/action` bot |
| 46 | // opens these PRs from a `changeset-release/<target>` branch and, by |
| 47 | // design, their diff modifies `packages/kumo/` (version bump + CHANGELOG) |
| 48 | // while removing — not adding — `.changeset/*.md` files. Running the |
| 49 | // "must add a new changeset" rule here would always fail. See |
| 50 | // https://github.com/changesets/action for the branch-name convention. |
| 51 | // |
| 52 | // GITHUB_HEAD_REF is the PR's source branch name and is always set on |
| 53 | // pull_request workflow runs — the only CI path that reaches this code, |
| 54 | // per the isPullRequestContext() guard above. |
| 55 | const headRef = process.env.GITHUB_HEAD_REF ?? ""; |
| 56 | if (headRef.startsWith("changeset-release/")) { |
| 57 | console.log( |
| 58 | `Detected Changesets release PR (branch: ${headRef}); skipping validation.`, |
| 59 | ); |
| 60 | return; |
| 61 | } |
| 62 | |
| 63 | console.log("Validating changesets..."); |
| 64 | |
| 65 | // Check if kumo files have been modified |
| 66 | const hasKumoChanges = checkForKumoChanges(); |
| 67 | if (!hasKumoChanges) { |
| 68 | console.log( |
| 69 | "No changes detected in packages/kumo/, skipping changeset validation", |
| 70 | ); |
| 71 | return; |
| 72 | } |
| 73 | |
| 74 | console.log("Changes detected in packages/kumo/"); |
| 75 | |
| 76 | // Check for newly added changesets in this MR |
| 77 | const newChangesets = getNewlyAddedChangesets(); |
| 78 | const newKumoChangesets = newChangesets.filter((cs) => |
| 79 | cs.packages.includes(KUMO_PACKAGE_NAME), |
| 80 | ); |
| 81 | |
| 82 | if (newKumoChangesets.length === 0) { |
| 83 | // Use CI collapsible section for better visibility (CI only) |
| 84 | if (process.env.CI) { |
| 85 | console.error( |
| 86 | "\x1b[0Ksection_start:" + |
| 87 | Date.now() + |
| 88 | ":changeset_error\r\x1b[0K\x1b[31;1m❌ CHANGESET VALIDATION FAILED\x1b[0m", |
| 89 | ); |
| 90 | } else { |
| 91 | console.error("\x1b[31;1m❌ CHANGESET VALIDATION FAILED\x1b[0m"); |
| 92 | } |
| 93 | console.error(""); |
| 94 | |
| 95 | // Check if there are any new changesets at all |
| 96 | if (newChangesets.length === 0) { |
| 97 | console.error( |
| 98 | "\x1b[31;1m❌ ERROR: Changes detected in packages/kumo/ but no NEW changeset files found\x1b[0m", |
| 99 | ); |
| 100 | } else { |
| 101 | console.error( |
| 102 | "\x1b[31;1m❌ ERROR: Found NEW changeset files, but none target @cloudflare/kumo\x1b[0m", |
| 103 | ); |
| 104 | console.error(""); |
| 105 | console.error("New changesets found:"); |
| 106 | newChangesets.forEach((cs) => { |
| 107 | console.error(` - ${cs.name} (targets: ${cs.packages.join(", ")})`); |
| 108 | }); |
| 109 | } |
| 110 | |
| 111 | console.error(""); |
| 112 | console.error("\x1b[33;1m📋 To fix this issue:\x1b[0m"); |
| 113 | console.error(" 1. Run: \x1b[36mpnpm changeset\x1b[0m"); |
| 114 | console.error( |
| 115 | ' 2. Select "\x1b[36m@cloudflare/kumo\x1b[0m" when prompted', |
| 116 | ); |
| 117 | console.error( |
| 118 | " 3. Choose the appropriate change type (patch/minor/major)", |
| 119 | ); |
| 120 | console.error(" 4. Write a clear description of your changes"); |
| 121 | console.error(" 5. Commit the generated changeset file"); |
| 122 | console.error(""); |
| 123 | console.error( |
| 124 | "This ensures proper versioning and changelog generation for the kumo package.", |
| 125 | ); |
| 126 | console.error(""); |
| 127 | if (process.env.CI) { |
| 128 | console.error( |
| 129 | "\x1b[0Ksection_end:" + Date.now() + ":changeset_error\r\x1b[0K", |
| 130 | ); |
| 131 | } |
| 132 | |
| 133 | process.exit(1); |
| 134 | } |
| 135 | |
| 136 | console.log( |
| 137 | `✅ Found ${newKumoChangesets.length} NEW changeset(s) for @cloudflare/kumo:`, |
| 138 | ); |
| 139 | newKumoChangesets.forEach((cs) => { |
| 140 | console.log(` - ${cs.name}`); |
| 141 | }); |
| 142 | |
| 143 | console.log("Changeset validation passed!"); |
| 144 | } |
| 145 | |
| 146 | function checkForKumoChanges(): boolean { |
| 147 | const result = hasChangesInPath(KUMO_PATH); |
| 148 | |
| 149 | if (result === null) { |
| 150 | console.warn( |
| 151 | "⚠️ Warning: Could not determine if kumo changes exist, assuming they do", |
| 152 | ); |
| 153 | return true; |
| 154 | } |
| 155 | |
| 156 | return result; |
| 157 | } |
| 158 | |
| 159 | function getNewlyAddedChangesets(): ChangesetFile[] { |
| 160 | // Determine working directory (handle both repo root and packages/kumo contexts) |
| 161 | const cwd = process.cwd().includes("packages/kumo") ? "../.." : "."; |
| 162 | |
| 163 | // Get newly added files in .changeset directory |
| 164 | const newFiles = getNewlyAddedFiles(CHANGESET_DIR, { cwd }); |
| 165 | |
| 166 | // Note: empty array means no new changesets were added in this MR |
| 167 | // Do NOT fall back to getChangesets() as that would include existing changesets |
| 168 | |
| 169 | const changesets: ChangesetFile[] = []; |
| 170 | |
| 171 | for (const { status, path: filePath } of newFiles) { |
| 172 | // Only consider newly added files (A = Added) |
| 173 | if (status !== "A") { |
| 174 | continue; |
| 175 | } |
| 176 | |
| 177 | const fileName = filePath.split("/").pop(); |
| 178 | |
| 179 | // Skip config files and README |
| 180 | if ( |
| 181 | !fileName || |
| 182 | fileName === "config.json" || |
| 183 | fileName === "README.md" || |
| 184 | fileName === "USAGE.md" || |
| 185 | !fileName.endsWith(".md") |
| 186 | ) { |
| 187 | continue; |
| 188 | } |
| 189 | |
| 190 | try { |
| 191 | // Resolve file path relative to repo root |
| 192 | const repoRoot = process.cwd().includes("packages/kumo") ? "../.." : "."; |
| 193 | const fullFilePath = join(repoRoot, filePath); |
| 194 | const content = readFileSync(fullFilePath, "utf8"); |
| 195 | const packages = parseChangesetPackages(content); |
| 196 | |
| 197 | changesets.push({ |
| 198 | name: fileName, |
| 199 | content, |
| 200 | packages, |
| 201 | }); |
| 202 | } catch (error) { |
| 203 | console.warn( |
| 204 | `Warning: Could not parse changeset file ${fileName}: ${error}`, |
| 205 | ); |
| 206 | } |
| 207 | } |
| 208 | |
| 209 | return changesets; |
| 210 | } |
| 211 | |
| 212 | function parseChangesetPackages(content: string): string[] { |
| 213 | const lines = content.split("\n"); |
| 214 | const packages: string[] = []; |
| 215 | |
| 216 | // Track whether we're inside the YAML frontmatter section (between --- markers) |
| 217 | // where package declarations are defined |
| 218 | let inFrontmatter = false; |
| 219 | for (const line of lines) { |
| 220 | if (line.trim() === "---") { |
| 221 | inFrontmatter = !inFrontmatter; |
| 222 | continue; |
| 223 | } |
| 224 | |
| 225 | if (inFrontmatter) { |
| 226 | // Parse YAML-style package declarations |
| 227 | // Format: "@package/name": patch|minor|major |
| 228 | const match = line.match( |
| 229 | /^["']?([^"':]+)["']?\s*:\s*(patch|minor|major)/, |
| 230 | ); |
| 231 | if (match) { |
| 232 | packages.push(match[1]); |
| 233 | } |
| 234 | } |
| 235 | } |
| 236 | |
| 237 | return packages; |
| 238 | } |
| 239 | |
| 240 | /** |
| 241 | * Checks if we're running in a local development context (not CI) |
| 242 | */ |
| 243 | function isLocalContext(): boolean { |
| 244 | return !process.env.CI; |
| 245 | } |
| 246 | |
| 247 | // Run if this is the main module (ES module compatible check) |
| 248 | if (import.meta.url === `file://${process.argv[1]}`) { |
| 249 | main(); |
| 250 | } |
| 251 | |