From 60a026bfd448a6335493e20956b5b0b00ac4557b Mon Sep 17 00:00:00 2001 From: "pip-the-concierge[bot]" Date: Mon, 10 Aug 2026 05:22:37 +0000 Subject: [PATCH 1/7] feat: add changeset gate for package changes and fix publish notifications - Add new check-changeset workflow that runs on PRs to detect when package source files change without an accompanying changeset. Non-package changes (like renovate config, docs, CI files) no longer require empty changesets. - Split the changeset.yaml notify-slack job into two targeted notifications: - notify-slack-success: fires only when packages are actually published - notify-slack-failure: fires only when the version job fails (real publish failure), not when there are simply no changesets to publish - Add check:changeset script to package.json for local changeset status checks - Add .devbox/ to prettierignore and eslint ignores to prevent environment artifacts from failing lint checks Co-Authored-By: Claude Opus 4.6 Co-Authored-By: Chris Chan --- .github/workflows/changeset.yaml | 28 ++++++++----- .github/workflows/check-changeset.yml | 57 +++++++++++++++++++++++++++ .prettierignore | 1 + eslint.config.js | 1 + package.json | 1 + 5 files changed, 79 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/check-changeset.yml diff --git a/.github/workflows/changeset.yaml b/.github/workflows/changeset.yaml index 254bde1a8a2..1bc5ea5282e 100644 --- a/.github/workflows/changeset.yaml +++ b/.github/workflows/changeset.yaml @@ -38,20 +38,30 @@ jobs: published_packages: ${{ steps.changesets.outputs.publishedPackages }} buildkite_webhook_url: ${{ secrets.ENCHIRIDION_BUILDKITE_WEBHOOK_URL }} - notify-slack: + notify-slack-success: runs-on: ubuntu-latest timeout-minutes: 5 - needs: - version - # We only trigger this flow if the publish is happening, inferred by not having a changeset. - if: needs.version.outputs.hasChangesets == 'false' - env: - PUBLISHED: ${{ needs.version.outputs.hasPublished }} + needs: version + if: needs.version.outputs.hasPublished == 'true' + steps: + - name: Notify successful publish + uses: slackapi/slack-github-action@v1.24.0 + with: + channel-id: 'C02NUQ27G56' + slack-message: 'Packages have been published' + env: + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + + notify-slack-failure: + runs-on: ubuntu-latest + timeout-minutes: 5 + needs: version + if: needs.version.result == 'failure' steps: - - name: Send + - name: Notify publish failure uses: slackapi/slack-github-action@v1.24.0 with: channel-id: 'C02NUQ27G56' - slack-message: ${{ env.PUBLISHED == 'true' && 'Packages have been published' || 'Publishing failed' }} + slack-message: 'Publishing failed' env: SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} diff --git a/.github/workflows/check-changeset.yml b/.github/workflows/check-changeset.yml new file mode 100644 index 00000000000..879c8961a57 --- /dev/null +++ b/.github/workflows/check-changeset.yml @@ -0,0 +1,57 @@ +name: Check changeset + +on: + push: + branches-ignore: + - 'main' + +jobs: + check-changeset: + if: github.ref != 'refs/heads/changeset-release/main' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check for package output changes without changeset + run: | + # Get changed files compared to origin/main + CHANGED_FILES=$(git diff --name-only origin/main...HEAD) + + # Check if any changeset files exist (markdown files in .changeset, excluding README) + HAS_CHANGESET=false + for file in $CHANGED_FILES; do + if [[ "$file" == .changeset/*.md ]] && [[ "$file" != ".changeset/README.md" ]]; then + HAS_CHANGESET=true + break + fi + done + + # Check if any package source files that affect output have changed + HAS_PACKAGE_CHANGES=false + for file in $CHANGED_FILES; do + if [[ "$file" == packages/*/src/** ]] || \ + [[ "$file" == packages/*/package.json ]] || \ + [[ "$file" == packages/*/tsconfig*.json ]] || \ + [[ "$file" == packages/*/rollup.config* ]] || \ + [[ "$file" == packages/*/vite.config* ]] || \ + [[ "$file" == packages/*/styles/** ]] || \ + [[ "$file" == packages/*/css/** ]] || \ + [[ "$file" == packages/*/sass/** ]] || \ + [[ "$file" == packages/*/less/** ]]; then + HAS_PACKAGE_CHANGES=true + break + fi + done + + echo "Has changeset: $HAS_CHANGESET" + echo "Has package output changes: $HAS_PACKAGE_CHANGES" + + if [[ "$HAS_PACKAGE_CHANGES" == "true" ]] && [[ "$HAS_CHANGESET" == "false" ]]; then + echo "::error::Package source files have changed but no changeset was found. Please run 'pnpm changeset' to add a changeset describing the changes." + exit 1 + fi + + echo "✓ Changeset check passed" diff --git a/.prettierignore b/.prettierignore index 2db2d063332..a6cc3e19f57 100644 --- a/.prettierignore +++ b/.prettierignore @@ -17,6 +17,7 @@ !*.scss # Explicitly ignore... +.devbox/ devbox.lock pnpm-lock.yaml **/CHANGELOG.md diff --git a/eslint.config.js b/eslint.config.js index e424270ad92..d2d3191e2e0 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -14,6 +14,7 @@ import prettierRules from './.prettierrc.js' export default tseslint.config( { ignores: [ + '.devbox/**', '**/CHANGELOG.md', '**/tmp/**', '**/*.d.ts', diff --git a/package.json b/package.json index 3254cb4e664..a03cae57034 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "reset": "pnpm clean && pnpm install --force", "chromatic": "chromatic", "changeset": "changeset", + "check:changeset": "changeset status --since=origin/main", "plop": "plop", "pkg:aio": "pnpm -F @kaizen/components", "pkg:tokens": "pnpm -F @kaizen/design-tokens", From ceff0aafbdf59d0de548330b65e37cefa0a55916 Mon Sep 17 00:00:00 2001 From: "pip-the-concierge[bot]" Date: Mon, 10 Aug 2026 06:33:54 +0000 Subject: [PATCH 2/7] feat: replace changeset gate with advisory tarball drift check [agent-managed] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the path-based check-changeset.yml (which missed cross-package propagation like design-token changes affecting components' built CSS) with a tarball-fingerprint check that compares pnpm-packed output against the published npm registry version. Changes: - Add scripts/check-package-output.ts: per-package sha256 manifest fingerprinting against the published tarball on npm - Add .github/workflows/check-package-output.yml: advisory PR check (not a merge gate) that reports drift without a changeset - Delete .github/workflows/check-changeset.yml: superseded by the tarball check which catches all cases including cross-package deps - Fix notify-slack-failure: add always() so the job actually runs when the version job fails (GitHub Actions skips dependents by default) - Improve failure Slack notification: use Block Kit with commit SHA, actor, subject line, and a direct link to the failed run No hard changeset gate remains — by design. A package change without a changeset will merge and simply not publish. Co-Authored-By: Claude Opus 4.6 Co-Authored-By: Chris Chan --- .github/workflows/changeset.yaml | 53 ++- .github/workflows/check-changeset.yml | 57 ---- .github/workflows/check-package-output.yml | 26 ++ package.json | 1 + scripts/check-package-output.ts | 358 +++++++++++++++++++++ 5 files changed, 436 insertions(+), 59 deletions(-) delete mode 100644 .github/workflows/check-changeset.yml create mode 100644 .github/workflows/check-package-output.yml create mode 100644 scripts/check-package-output.ts diff --git a/.github/workflows/changeset.yaml b/.github/workflows/changeset.yaml index 1bc5ea5282e..dff137f8243 100644 --- a/.github/workflows/changeset.yaml +++ b/.github/workflows/changeset.yaml @@ -56,12 +56,61 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 needs: version - if: needs.version.result == 'failure' + if: ${{ always() && needs.version.result == 'failure' }} steps: + - uses: actions/checkout@v4 + + - name: Get failure context + id: context + run: | + SHA="${{ github.sha }}" + SHORT_SHA="${SHA:0:7}" + SUBJECT=$(git log -1 --format='%s' "$SHA") + echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT" + echo "subject=$SUBJECT" >> "$GITHUB_OUTPUT" + echo "run_url=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" >> "$GITHUB_OUTPUT" + - name: Notify publish failure uses: slackapi/slack-github-action@v1.24.0 with: channel-id: 'C02NUQ27G56' - slack-message: 'Publishing failed' + payload: | + { + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":x: *Publishing failed*" + } + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "*Commit:* <${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|`${{ steps.context.outputs.short_sha }}`> ${{ steps.context.outputs.subject }}" + }, + { + "type": "mrkdwn", + "text": "*Pushed by:* ${{ github.actor }}" + } + ] + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": { + "type": "plain_text", + "text": "View failed run" + }, + "url": "${{ steps.context.outputs.run_url }}" + } + ] + } + ] + } env: SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} diff --git a/.github/workflows/check-changeset.yml b/.github/workflows/check-changeset.yml deleted file mode 100644 index 879c8961a57..00000000000 --- a/.github/workflows/check-changeset.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: Check changeset - -on: - push: - branches-ignore: - - 'main' - -jobs: - check-changeset: - if: github.ref != 'refs/heads/changeset-release/main' - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Check for package output changes without changeset - run: | - # Get changed files compared to origin/main - CHANGED_FILES=$(git diff --name-only origin/main...HEAD) - - # Check if any changeset files exist (markdown files in .changeset, excluding README) - HAS_CHANGESET=false - for file in $CHANGED_FILES; do - if [[ "$file" == .changeset/*.md ]] && [[ "$file" != ".changeset/README.md" ]]; then - HAS_CHANGESET=true - break - fi - done - - # Check if any package source files that affect output have changed - HAS_PACKAGE_CHANGES=false - for file in $CHANGED_FILES; do - if [[ "$file" == packages/*/src/** ]] || \ - [[ "$file" == packages/*/package.json ]] || \ - [[ "$file" == packages/*/tsconfig*.json ]] || \ - [[ "$file" == packages/*/rollup.config* ]] || \ - [[ "$file" == packages/*/vite.config* ]] || \ - [[ "$file" == packages/*/styles/** ]] || \ - [[ "$file" == packages/*/css/** ]] || \ - [[ "$file" == packages/*/sass/** ]] || \ - [[ "$file" == packages/*/less/** ]]; then - HAS_PACKAGE_CHANGES=true - break - fi - done - - echo "Has changeset: $HAS_CHANGESET" - echo "Has package output changes: $HAS_PACKAGE_CHANGES" - - if [[ "$HAS_PACKAGE_CHANGES" == "true" ]] && [[ "$HAS_CHANGESET" == "false" ]]; then - echo "::error::Package source files have changed but no changeset was found. Please run 'pnpm changeset' to add a changeset describing the changes." - exit 1 - fi - - echo "✓ Changeset check passed" diff --git a/.github/workflows/check-package-output.yml b/.github/workflows/check-package-output.yml new file mode 100644 index 00000000000..412a3d5a869 --- /dev/null +++ b/.github/workflows/check-package-output.yml @@ -0,0 +1,26 @@ +name: Check package output + +on: + pull_request: + branches: + - 'main' + - 'release/**' + +jobs: + check-package-output: + # Skip on changeset release branches (automated version bumps) + if: github.head_ref != 'changeset-release/main' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: ./.github/actions/setup + + - name: Build workspace + run: pnpm turbo build + + - name: Check package output drift + run: npx tsx scripts/check-package-output.ts diff --git a/package.json b/package.json index a03cae57034..b79b4bfa7b7 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "chromatic": "chromatic", "changeset": "changeset", "check:changeset": "changeset status --since=origin/main", + "check:package-output": "npx tsx scripts/check-package-output.ts", "plop": "plop", "pkg:aio": "pnpm -F @kaizen/components", "pkg:tokens": "pnpm -F @kaizen/design-tokens", diff --git a/scripts/check-package-output.ts b/scripts/check-package-output.ts new file mode 100644 index 00000000000..9456cd6c45a --- /dev/null +++ b/scripts/check-package-output.ts @@ -0,0 +1,358 @@ +/** + * check-package-output.ts + * + * Advisory check: does the tarball we'd publish differ from the one already on npm, + * for packages with no changeset? + * + * Usage: npx tsx scripts/check-package-output.ts + * + * Exit codes: + * 0 - all packages match or are covered by changesets + * 1 - reportable packages found (drift without changeset) + * 2 - infrastructure error + */ + +/* eslint-disable no-console */ + +import { execSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { + existsSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join, relative } from 'node:path' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function exec(cmd: string, opts?: { cwd?: string }): string { + return execSync(cmd, { + encoding: 'utf-8', + cwd: opts?.cwd, + maxBuffer: 50 * 1024 * 1024, + }).trim() +} + +function execSafe(cmd: string, opts?: { cwd?: string }): { ok: boolean; stdout: string } { + try { + return { ok: true, stdout: exec(cmd, opts) } + } catch { + return { ok: false, stdout: '' } + } +} + +function sha256(data: Buffer | string): string { + return createHash('sha256').update(data).digest('hex') +} + +/** Recursively list all files under dir, returning relative paths sorted. */ +function listFiles(dir: string, base = dir): string[] { + const results: string[] = [] + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name) + if (entry.isDirectory()) { + results.push(...listFiles(full, base)) + } else if (entry.isFile()) { + results.push(relative(base, full)) + } + } + return results.sort() +} + +/** Build a fingerprint manifest: "sha256 relative/path" per file, then hash the manifest. */ +function fingerprint(dir: string): { hash: string; manifest: string[] } { + const files = listFiles(dir) + const manifest = files.map((f) => { + const content = readFileSync(join(dir, f)) + return `${sha256(content)} ${f}` + }) + const hash = sha256(manifest.join('\n')) + return { hash, manifest } +} + +/** Compare two manifests and return diff info. */ +function diffManifests( + localManifest: string[], + publishedManifest: string[], +): { added: string[]; removed: string[]; modified: string[] } { + const localMap = new Map( + localManifest.map((line) => { + const [hash, ...rest] = line.split(' ') + return [rest.join(' '), hash] as [string, string] + }), + ) + const publishedMap = new Map( + publishedManifest.map((line) => { + const [hash, ...rest] = line.split(' ') + return [rest.join(' '), hash] as [string, string] + }), + ) + + const added: string[] = [] + const removed: string[] = [] + const modified: string[] = [] + + for (const [path] of localMap) { + if (!publishedMap.has(path)) { + added.push(path) + } else if (localMap.get(path) !== publishedMap.get(path)) { + modified.push(path) + } + } + for (const [path] of publishedMap) { + if (!localMap.has(path)) { + removed.push(path) + } + } + + return { added, removed, modified } +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +interface PackageResult { + name: string + status: 'identical' | 'changed' | 'skipped' + reason?: string + diff?: { added: string[]; removed: string[]; modified: string[] } +} + +async function main(): Promise { + const repoRoot = exec('git rev-parse --show-toplevel') + const packagesDir = join(repoRoot, 'packages') + + // Step 1 is expected to have been run already (pnpm turbo build) - we don't build here + // The workflow runs the build step separately before invoking this script + + // Find public packages + const packageDirs = readdirSync(packagesDir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => join(packagesDir, d.name)) + .filter((dir) => { + const pkgPath = join(dir, 'package.json') + if (!existsSync(pkgPath)) return false + const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) + return pkg.private !== true + }) + + console.log(`Found ${packageDirs.length} public packages`) + + const results: PackageResult[] = [] + const tmpBase = mkdtempSync(join(tmpdir(), 'pkg-output-')) + + try { + for (const pkgDir of packageDirs) { + const pkgJson = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf-8')) + const { name, version } = pkgJson + console.log(`\nChecking ${name}@${version}...`) + + // Local side: pnpm pack + const localTmp = join(tmpBase, `local-${name.replace(/\//g, '__')}`) + const localPackDest = join(tmpBase, `pack-${name.replace(/\//g, '__')}`) + execSync(`mkdir -p "${localPackDest}"`) + + let tarball: string + try { + tarball = exec(`pnpm pack --pack-destination "${localPackDest}"`, { + cwd: pkgDir, + }) + } catch { + console.log(` SKIP: pnpm pack failed for ${name}`) + results.push({ name, status: 'skipped', reason: 'pnpm pack failed' }) + continue + } + + // Extract local tarball + const tarballPath = join(localPackDest, tarball.split('\n').pop()!) + execSync(`mkdir -p "${localTmp}"`) + execSync(`tar -xf "${tarballPath}" -C "${localTmp}" --strip-components=1`) + + // Published side: get tarball URL from registry + const viewResult = execSafe(`npm view "${name}@${version}" dist.tarball 2>/dev/null`) + if (!viewResult.ok || !viewResult.stdout) { + console.log( + ` SKIP: ${name}@${version} not on registry (first release or version bump in flight)`, + ) + results.push({ + name, + status: 'skipped', + reason: `${version} not on registry`, + }) + continue + } + + const tarballUrl = viewResult.stdout + const publishedTmp = join(tmpBase, `published-${name.replace(/\//g, '__')}`) + const publishedTarball = join(tmpBase, `published-${name.replace(/\//g, '__')}.tgz`) + + try { + execSync(`curl -sL "${tarballUrl}" -o "${publishedTarball}"`) + execSync(`mkdir -p "${publishedTmp}"`) + execSync(`tar -xf "${publishedTarball}" -C "${publishedTmp}" --strip-components=1`) + } catch { + console.log(` SKIP: failed to download published tarball for ${name}`) + results.push({ + name, + status: 'skipped', + reason: 'failed to download published tarball', + }) + continue + } + + // Fingerprint both + const localFp = fingerprint(localTmp) + const publishedFp = fingerprint(publishedTmp) + + if (localFp.hash === publishedFp.hash) { + console.log(` IDENTICAL`) + results.push({ name, status: 'identical' }) + } else { + const diff = diffManifests(localFp.manifest, publishedFp.manifest) + console.log( + ` CHANGED: +${diff.added.length} -${diff.removed.length} ~${diff.modified.length}`, + ) + results.push({ name, status: 'changed', diff }) + } + } + + // Step 3: read declared releases from changeset status + const changesetOutputFile = join(tmpBase, 'changeset-status.json') + const changesetResult = execSafe( + `pnpm changeset status --since=origin/main --output="${changesetOutputFile}"`, + ) + + let declaredReleases: string[] = [] + if ( + changesetResult.ok && + existsSync(changesetOutputFile) && + statSync(changesetOutputFile).size > 0 + ) { + try { + const status = JSON.parse(readFileSync(changesetOutputFile, 'utf-8')) + declaredReleases = ((status.releases ?? []) as { name: string }[]).map((r) => r.name) + } catch { + // changeset status output may be empty or invalid - that's ok + } + } + + console.log(`\nDeclared releases: ${declaredReleases.join(', ') || '(none)'}`) + + // Determine reportable packages + const reportable = results.filter( + (r) => r.status === 'changed' && !declaredReleases.includes(r.name), + ) + + // Generate report + const report = generateReport(results, reportable, declaredReleases) + console.log('\n' + report) + + // Write to GITHUB_STEP_SUMMARY if available + const summaryPath = process.env.GITHUB_STEP_SUMMARY + if (summaryPath) { + writeFileSync(summaryPath, report, { flag: 'a' }) + } + + if (reportable.length > 0) { + console.log(`\n${reportable.length} package(s) have tarball drift without a changeset.`) + process.exit(1) + } + + console.log('\nAll packages are either identical or covered by changesets.') + process.exit(0) + } finally { + rmSync(tmpBase, { recursive: true, force: true }) + } +} + +function generateReport( + results: PackageResult[], + reportable: PackageResult[], + declaredReleases: string[], +): string { + const lines: string[] = [] + + lines.push('## Package Output Check\n') + + if (reportable.length === 0) { + lines.push('All packages either match their published version or are covered by a changeset.\n') + } else { + lines.push( + `**${reportable.length} package(s) have published tarball drift without a changeset:**\n`, + ) + + for (const pkg of reportable) { + lines.push(`### \`${pkg.name}\`\n`) + if (pkg.diff) { + const examples: string[] = [] + const allDiffs = [ + ...pkg.diff.modified.map((f) => `modified: ${f}`), + ...pkg.diff.added.map((f) => `added: ${f}`), + ...pkg.diff.removed.map((f) => `removed: ${f}`), + ] + + // Special case: only package.json changed + if ( + pkg.diff.modified.length === 1 && + pkg.diff.modified[0] === 'package.json' && + pkg.diff.added.length === 0 && + pkg.diff.removed.length === 0 + ) { + lines.push( + 'Difference is only in `package.json` — likely an internal dependency version change.\n', + ) + } + + // Special case: dist/styles.css or CSS changes + const cssChanges = [...pkg.diff.modified, ...pkg.diff.added].filter( + (f) => f.endsWith('.css') && (f.startsWith('dist/') || f.includes('/dist/')), + ) + if (cssChanges.length > 0) { + lines.push( + 'Built CSS files differ — likely cause is a design-token value change propagating from `@kaizen/design-tokens`.\n', + ) + } + + // Show up to 5 examples + for (const diff of allDiffs.slice(0, 5)) { + examples.push(`- \`${diff}\``) + } + if (allDiffs.length > 5) { + examples.push(`- ... and ${allDiffs.length - 5} more`) + } + lines.push(examples.join('\n') + '\n') + } + } + } + + // Summary table + lines.push('\n### Summary\n') + lines.push('| Package | Status |') + lines.push('|---------|--------|') + for (const r of results) { + const statusEmoji = + r.status === 'identical' + ? 'identical' + : r.status === 'skipped' + ? `skipped (${r.reason})` + : declaredReleases.includes(r.name) + ? 'changed (has changeset)' + : '**DRIFT - no changeset**' + lines.push(`| \`${r.name}\` | ${statusEmoji} |`) + } + + return lines.join('\n') +} + +main().catch((err) => { + console.error('Fatal error:', err) + process.exit(2) +}) From 4f793f0170249651d37c76442c29e042e594ae21 Mon Sep 17 00:00:00 2001 From: Chris Chan Date: Mon, 10 Aug 2026 16:50:37 +1000 Subject: [PATCH 3/7] refactor: reduce tarball drift check to a pass/fail signal - Kept the changeset cross-reference so PRs that already have one stay green Co-Authored-By: Claude --- .github/workflows/check-package-output.yml | 6 +- package.json | 3 +- pnpm-lock.yaml | 3 + scripts/check-package-output.ts | 411 +++++---------------- 4 files changed, 92 insertions(+), 331 deletions(-) diff --git a/.github/workflows/check-package-output.yml b/.github/workflows/check-package-output.yml index 412a3d5a869..b6e47a5789e 100644 --- a/.github/workflows/check-package-output.yml +++ b/.github/workflows/check-package-output.yml @@ -8,8 +8,8 @@ on: jobs: check-package-output: - # Skip on changeset release branches (automated version bumps) - if: github.head_ref != 'changeset-release/main' + # Automated version bumps always differ from what's published — that's the point of them. + if: ${{ !startsWith(github.head_ref, 'changeset-release/') }} runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -23,4 +23,4 @@ jobs: run: pnpm turbo build - name: Check package output drift - run: npx tsx scripts/check-package-output.ts + run: pnpm check:package-output diff --git a/package.json b/package.json index b79b4bfa7b7..f4a8dbcd0ee 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "chromatic": "chromatic", "changeset": "changeset", "check:changeset": "changeset status --since=origin/main", - "check:package-output": "npx tsx scripts/check-package-output.ts", + "check:package-output": "tsx scripts/check-package-output.ts", "plop": "plop", "pkg:aio": "pnpm -F @kaizen/components", "pkg:tokens": "pnpm -F @kaizen/design-tokens", @@ -84,6 +84,7 @@ "stylelint": "^17.14.1", "stylelint-config-standard": "^40.0.0", "stylelint-config-standard-scss": "^17.0.0", + "tsx": "^4.23.11", "turbo": "^2.10.9", "typescript": "^5.9.3", "typescript-eslint": "^8.66.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f97fa319b18..88211509b69 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -154,6 +154,9 @@ importers: stylelint-config-standard-scss: specifier: ^17.0.0 version: 17.0.0(postcss@8.5.26)(stylelint@17.14.1(typescript@5.9.3)) + tsx: + specifier: ^4.23.11 + version: 4.23.11 turbo: specifier: ^2.10.9 version: 2.10.9 diff --git a/scripts/check-package-output.ts b/scripts/check-package-output.ts index 9456cd6c45a..ae166896dfb 100644 --- a/scripts/check-package-output.ts +++ b/scripts/check-package-output.ts @@ -1,358 +1,115 @@ /** - * check-package-output.ts + * Advisory: does the tarball we'd publish differ from the one already on npm? * - * Advisory check: does the tarball we'd publish differ from the one already on npm, - * for packages with no changeset? - * - * Usage: npx tsx scripts/check-package-output.ts - * - * Exit codes: - * 0 - all packages match or are covered by changesets - * 1 - reportable packages found (drift without changeset) - * 2 - infrastructure error + * Assumes `pnpm turbo build` has already run. Exits 1 if any package's output + * differs with no changeset covering it — a human decides whether that warrants + * a release. */ /* eslint-disable no-console */ -import { execSync } from 'node:child_process' +import { execFileSync } from 'node:child_process' import { createHash } from 'node:crypto' -import { - existsSync, - mkdtempSync, - readFileSync, - readdirSync, - rmSync, - statSync, - writeFileSync, -} from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, readdirSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join, relative } from 'node:path' +import { join } from 'node:path' -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- +const run = (cmd: string, args: string[], cwd?: string): string => + execFileSync(cmd, args, { encoding: 'utf-8', cwd, maxBuffer: 64 * 1024 * 1024 }).trim() -function exec(cmd: string, opts?: { cwd?: string }): string { - return execSync(cmd, { - encoding: 'utf-8', - cwd: opts?.cwd, - maxBuffer: 50 * 1024 * 1024, - }).trim() -} +const sha = (data: Buffer | string): string => createHash('sha256').update(data).digest('hex') -function execSafe(cmd: string, opts?: { cwd?: string }): { ok: boolean; stdout: string } { - try { - return { ok: true, stdout: exec(cmd, opts) } - } catch { - return { ok: false, stdout: '' } - } +const fingerprint = (dir: string): string => { + const files = readdirSync(dir, { recursive: true, withFileTypes: true }) + .filter(entry => entry.isFile()) + .map(entry => join(entry.parentPath, entry.name).slice(dir.length + 1)) + .sort() + return sha(files.map(f => `${sha(readFileSync(join(dir, f)))} ${f}`).join('\n')) } -function sha256(data: Buffer | string): string { - return createHash('sha256').update(data).digest('hex') +/** Extract into a directory of its own, so the tarball file never lands in the fingerprint. */ +const extractedFingerprint = (tarball: string, dest: string): string => { + const contents = join(dest, 'contents') + mkdirSync(contents) + run('tar', ['-xf', tarball, '-C', contents, '--strip-components=1']) + return fingerprint(contents) } -/** Recursively list all files under dir, returning relative paths sorted. */ -function listFiles(dir: string, base = dir): string[] { - const results: string[] = [] - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const full = join(dir, entry.name) - if (entry.isDirectory()) { - results.push(...listFiles(full, base)) - } else if (entry.isFile()) { - results.push(relative(base, full)) - } - } - return results.sort() +const packedFingerprint = (pkgDir: string, tmp: string): string => { + const dest = mkdtempSync(join(tmp, 'local-')) + const tarball = run('pnpm', ['pack', '--pack-destination', dest], pkgDir).split('\n').pop()! + return extractedFingerprint(tarball, dest) } -/** Build a fingerprint manifest: "sha256 relative/path" per file, then hash the manifest. */ -function fingerprint(dir: string): { hash: string; manifest: string[] } { - const files = listFiles(dir) - const manifest = files.map((f) => { - const content = readFileSync(join(dir, f)) - return `${sha256(content)} ${f}` - }) - const hash = sha256(manifest.join('\n')) - return { hash, manifest } -} - -/** Compare two manifests and return diff info. */ -function diffManifests( - localManifest: string[], - publishedManifest: string[], -): { added: string[]; removed: string[]; modified: string[] } { - const localMap = new Map( - localManifest.map((line) => { - const [hash, ...rest] = line.split(' ') - return [rest.join(' '), hash] as [string, string] - }), - ) - const publishedMap = new Map( - publishedManifest.map((line) => { - const [hash, ...rest] = line.split(' ') - return [rest.join(' '), hash] as [string, string] - }), - ) - - const added: string[] = [] - const removed: string[] = [] - const modified: string[] = [] - - for (const [path] of localMap) { - if (!publishedMap.has(path)) { - added.push(path) - } else if (localMap.get(path) !== publishedMap.get(path)) { - modified.push(path) - } - } - for (const [path] of publishedMap) { - if (!localMap.has(path)) { - removed.push(path) - } +const publishedFingerprint = (name: string, version: string, tmp: string): string | null => { + let url: string + try { + url = run('npm', ['view', `${name}@${version}`, 'dist.tarball']) + } catch { + return null } - - return { added, removed, modified } + if (!url) return null + const dest = mkdtempSync(join(tmp, 'published-')) + const tarball = join(dest, 'published.tgz') + run('curl', ['-sSL', url, '-o', tarball]) + return extractedFingerprint(tarball, dest) } -// --------------------------------------------------------------------------- -// Main -// --------------------------------------------------------------------------- - -interface PackageResult { - name: string - status: 'identical' | 'changed' | 'skipped' - reason?: string - diff?: { added: string[]; removed: string[]; modified: string[] } -} - -async function main(): Promise { - const repoRoot = exec('git rev-parse --show-toplevel') - const packagesDir = join(repoRoot, 'packages') - - // Step 1 is expected to have been run already (pnpm turbo build) - we don't build here - // The workflow runs the build step separately before invoking this script - - // Find public packages - const packageDirs = readdirSync(packagesDir, { withFileTypes: true }) - .filter((d) => d.isDirectory()) - .map((d) => join(packagesDir, d.name)) - .filter((dir) => { - const pkgPath = join(dir, 'package.json') - if (!existsSync(pkgPath)) return false - const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) - return pkg.private !== true +const releasedPackages = (): Set => { + const out = join(mkdtempSync(join(tmpdir(), 'changeset-')), 'status.json') + try { + execFileSync('pnpm', ['changeset', 'status', '--since=origin/main', `--output=${out}`], { + stdio: 'ignore', }) - - console.log(`Found ${packageDirs.length} public packages`) - - const results: PackageResult[] = [] - const tmpBase = mkdtempSync(join(tmpdir(), 'pkg-output-')) - + } catch { + // Non-zero simply means uncovered changes; the status file is still written. + } try { - for (const pkgDir of packageDirs) { - const pkgJson = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf-8')) - const { name, version } = pkgJson - console.log(`\nChecking ${name}@${version}...`) - - // Local side: pnpm pack - const localTmp = join(tmpBase, `local-${name.replace(/\//g, '__')}`) - const localPackDest = join(tmpBase, `pack-${name.replace(/\//g, '__')}`) - execSync(`mkdir -p "${localPackDest}"`) - - let tarball: string - try { - tarball = exec(`pnpm pack --pack-destination "${localPackDest}"`, { - cwd: pkgDir, - }) - } catch { - console.log(` SKIP: pnpm pack failed for ${name}`) - results.push({ name, status: 'skipped', reason: 'pnpm pack failed' }) - continue - } - - // Extract local tarball - const tarballPath = join(localPackDest, tarball.split('\n').pop()!) - execSync(`mkdir -p "${localTmp}"`) - execSync(`tar -xf "${tarballPath}" -C "${localTmp}" --strip-components=1`) - - // Published side: get tarball URL from registry - const viewResult = execSafe(`npm view "${name}@${version}" dist.tarball 2>/dev/null`) - if (!viewResult.ok || !viewResult.stdout) { - console.log( - ` SKIP: ${name}@${version} not on registry (first release or version bump in flight)`, - ) - results.push({ - name, - status: 'skipped', - reason: `${version} not on registry`, - }) - continue - } - - const tarballUrl = viewResult.stdout - const publishedTmp = join(tmpBase, `published-${name.replace(/\//g, '__')}`) - const publishedTarball = join(tmpBase, `published-${name.replace(/\//g, '__')}.tgz`) - - try { - execSync(`curl -sL "${tarballUrl}" -o "${publishedTarball}"`) - execSync(`mkdir -p "${publishedTmp}"`) - execSync(`tar -xf "${publishedTarball}" -C "${publishedTmp}" --strip-components=1`) - } catch { - console.log(` SKIP: failed to download published tarball for ${name}`) - results.push({ - name, - status: 'skipped', - reason: 'failed to download published tarball', - }) - continue - } - - // Fingerprint both - const localFp = fingerprint(localTmp) - const publishedFp = fingerprint(publishedTmp) - - if (localFp.hash === publishedFp.hash) { - console.log(` IDENTICAL`) - results.push({ name, status: 'identical' }) - } else { - const diff = diffManifests(localFp.manifest, publishedFp.manifest) - console.log( - ` CHANGED: +${diff.added.length} -${diff.removed.length} ~${diff.modified.length}`, - ) - results.push({ name, status: 'changed', diff }) - } - } - - // Step 3: read declared releases from changeset status - const changesetOutputFile = join(tmpBase, 'changeset-status.json') - const changesetResult = execSafe( - `pnpm changeset status --since=origin/main --output="${changesetOutputFile}"`, - ) - - let declaredReleases: string[] = [] - if ( - changesetResult.ok && - existsSync(changesetOutputFile) && - statSync(changesetOutputFile).size > 0 - ) { - try { - const status = JSON.parse(readFileSync(changesetOutputFile, 'utf-8')) - declaredReleases = ((status.releases ?? []) as { name: string }[]).map((r) => r.name) - } catch { - // changeset status output may be empty or invalid - that's ok - } - } - - console.log(`\nDeclared releases: ${declaredReleases.join(', ') || '(none)'}`) - - // Determine reportable packages - const reportable = results.filter( - (r) => r.status === 'changed' && !declaredReleases.includes(r.name), - ) - - // Generate report - const report = generateReport(results, reportable, declaredReleases) - console.log('\n' + report) - - // Write to GITHUB_STEP_SUMMARY if available - const summaryPath = process.env.GITHUB_STEP_SUMMARY - if (summaryPath) { - writeFileSync(summaryPath, report, { flag: 'a' }) - } - - if (reportable.length > 0) { - console.log(`\n${reportable.length} package(s) have tarball drift without a changeset.`) - process.exit(1) - } - - console.log('\nAll packages are either identical or covered by changesets.') - process.exit(0) - } finally { - rmSync(tmpBase, { recursive: true, force: true }) + const status = JSON.parse(readFileSync(out, 'utf-8')) + return new Set(status.releases.map((r: { name: string }) => r.name)) + } catch { + return new Set() } } -function generateReport( - results: PackageResult[], - reportable: PackageResult[], - declaredReleases: string[], -): string { - const lines: string[] = [] - - lines.push('## Package Output Check\n') - - if (reportable.length === 0) { - lines.push('All packages either match their published version or are covered by a changeset.\n') - } else { - lines.push( - `**${reportable.length} package(s) have published tarball drift without a changeset:**\n`, - ) - - for (const pkg of reportable) { - lines.push(`### \`${pkg.name}\`\n`) - if (pkg.diff) { - const examples: string[] = [] - const allDiffs = [ - ...pkg.diff.modified.map((f) => `modified: ${f}`), - ...pkg.diff.added.map((f) => `added: ${f}`), - ...pkg.diff.removed.map((f) => `removed: ${f}`), - ] - - // Special case: only package.json changed - if ( - pkg.diff.modified.length === 1 && - pkg.diff.modified[0] === 'package.json' && - pkg.diff.added.length === 0 && - pkg.diff.removed.length === 0 - ) { - lines.push( - 'Difference is only in `package.json` — likely an internal dependency version change.\n', - ) - } +const root = run('git', ['rev-parse', '--show-toplevel']) +const tmp = mkdtempSync(join(tmpdir(), 'package-output-')) +const released = releasedPackages() + +const packages = readdirSync(join(root, 'packages'), { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => join(root, 'packages', entry.name)) + .flatMap(dir => { + try { + const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf-8')) + return pkg.private === true ? [] : [{ dir, name: pkg.name, version: pkg.version }] + } catch { + return [] + } + }) - // Special case: dist/styles.css or CSS changes - const cssChanges = [...pkg.diff.modified, ...pkg.diff.added].filter( - (f) => f.endsWith('.css') && (f.startsWith('dist/') || f.includes('/dist/')), - ) - if (cssChanges.length > 0) { - lines.push( - 'Built CSS files differ — likely cause is a design-token value change propagating from `@kaizen/design-tokens`.\n', - ) - } +const drifted: string[] = [] - // Show up to 5 examples - for (const diff of allDiffs.slice(0, 5)) { - examples.push(`- \`${diff}\``) - } - if (allDiffs.length > 5) { - examples.push(`- ... and ${allDiffs.length - 5} more`) - } - lines.push(examples.join('\n') + '\n') - } - } +for (const { dir, name, version } of packages) { + const published = publishedFingerprint(name, version, tmp) + if (published === null) { + console.log(`? ${name} — ${version} not on registry, skipped`) + continue } - - // Summary table - lines.push('\n### Summary\n') - lines.push('| Package | Status |') - lines.push('|---------|--------|') - for (const r of results) { - const statusEmoji = - r.status === 'identical' - ? 'identical' - : r.status === 'skipped' - ? `skipped (${r.reason})` - : declaredReleases.includes(r.name) - ? 'changed (has changeset)' - : '**DRIFT - no changeset**' - lines.push(`| \`${r.name}\` | ${statusEmoji} |`) + if (packedFingerprint(dir, tmp) === published) { + console.log(`= ${name} — matches ${version}`) + } else if (released.has(name)) { + console.log(`~ ${name} — differs, changeset present`) + } else { + console.log(`! ${name} — differs from ${version}, no changeset`) + drifted.push(name) } - - return lines.join('\n') } -main().catch((err) => { - console.error('Fatal error:', err) - process.exit(2) -}) +if (drifted.length > 0) { + console.log( + `\nOutput differs from the published version with no changeset: ${drifted.join(', ')}.\n` + + `Advisory only — add a changeset if these changes should ship.`, + ) + process.exit(1) +} From b15e581548ad1061035d3b78b06e8321cb2e53ea Mon Sep 17 00:00:00 2001 From: Chris Chan Date: Mon, 10 Aug 2026 16:51:34 +1000 Subject: [PATCH 4/7] fix: JSON-escape commit subject in publish-failure Slack payload Co-Authored-By: Claude --- .github/workflows/changeset.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/changeset.yaml b/.github/workflows/changeset.yaml index dff137f8243..09b6945404e 100644 --- a/.github/workflows/changeset.yaml +++ b/.github/workflows/changeset.yaml @@ -65,7 +65,8 @@ jobs: run: | SHA="${{ github.sha }}" SHORT_SHA="${SHA:0:7}" - SUBJECT=$(git log -1 --format='%s' "$SHA") + # JSON-escape: an unescaped quote or backslash would break the Block Kit payload. + SUBJECT=$(git log -1 --format='%s' "$SHA" | jq -Rj 'tojson | .[1:-1]') echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT" echo "subject=$SUBJECT" >> "$GITHUB_OUTPUT" echo "run_url=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" >> "$GITHUB_OUTPUT" From 3b30f32cb0f6f0fe1c48a3778691e7d2e8d36b9d Mon Sep 17 00:00:00 2001 From: Chris Chan Date: Mon, 10 Aug 2026 17:02:58 +1000 Subject: [PATCH 5/7] fix: run drift check on feature branches only, and satisfy prettier - Trigger inverted to push/branches-ignore so the guard works without a PR context Co-Authored-By: Claude --- .github/workflows/check-package-output.yml | 9 +++++---- scripts/check-package-output.ts | 12 ++++++------ 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/.github/workflows/check-package-output.yml b/.github/workflows/check-package-output.yml index b6e47a5789e..f32a6373d6d 100644 --- a/.github/workflows/check-package-output.yml +++ b/.github/workflows/check-package-output.yml @@ -1,15 +1,16 @@ name: Check package output +# The inverse of changeset.yaml: that publishes from main and release branches, +# this advises on feature branches, where a changeset can still be added. on: - pull_request: - branches: + push: + branches-ignore: - 'main' - 'release/**' + - 'changeset-release/**' jobs: check-package-output: - # Automated version bumps always differ from what's published — that's the point of them. - if: ${{ !startsWith(github.head_ref, 'changeset-release/') }} runs-on: ubuntu-latest timeout-minutes: 20 steps: diff --git a/scripts/check-package-output.ts b/scripts/check-package-output.ts index ae166896dfb..49f9cc5de79 100644 --- a/scripts/check-package-output.ts +++ b/scripts/check-package-output.ts @@ -21,10 +21,10 @@ const sha = (data: Buffer | string): string => createHash('sha256').update(data) const fingerprint = (dir: string): string => { const files = readdirSync(dir, { recursive: true, withFileTypes: true }) - .filter(entry => entry.isFile()) - .map(entry => join(entry.parentPath, entry.name).slice(dir.length + 1)) + .filter((entry) => entry.isFile()) + .map((entry) => join(entry.parentPath, entry.name).slice(dir.length + 1)) .sort() - return sha(files.map(f => `${sha(readFileSync(join(dir, f)))} ${f}`).join('\n')) + return sha(files.map((f) => `${sha(readFileSync(join(dir, f)))} ${f}`).join('\n')) } /** Extract into a directory of its own, so the tarball file never lands in the fingerprint. */ @@ -77,9 +77,9 @@ const tmp = mkdtempSync(join(tmpdir(), 'package-output-')) const released = releasedPackages() const packages = readdirSync(join(root, 'packages'), { withFileTypes: true }) - .filter(entry => entry.isDirectory()) - .map(entry => join(root, 'packages', entry.name)) - .flatMap(dir => { + .filter((entry) => entry.isDirectory()) + .map((entry) => join(root, 'packages', entry.name)) + .flatMap((dir) => { try { const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf-8')) return pkg.private === true ? [] : [{ dir, name: pkg.name, version: pkg.version }] From fbcf9118bbf16e1fa750dfba43af448d8536ecd2 Mon Sep 17 00:00:00 2001 From: Chris Chan Date: Mon, 10 Aug 2026 17:13:48 +1000 Subject: [PATCH 6/7] fix: fetch published tarballs directly instead of shelling out to npm - npm view inherits pnpm's env config and buries the result in warnings - A 404 is now distinguishable from a real registry failure Co-Authored-By: Claude --- scripts/check-package-output.ts | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/scripts/check-package-output.ts b/scripts/check-package-output.ts index 49f9cc5de79..3f944128641 100644 --- a/scripts/check-package-output.ts +++ b/scripts/check-package-output.ts @@ -10,7 +10,7 @@ import { execFileSync } from 'node:child_process' import { createHash } from 'node:crypto' -import { mkdirSync, mkdtempSync, readFileSync, readdirSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -41,17 +41,23 @@ const packedFingerprint = (pkgDir: string, tmp: string): string => { return extractedFingerprint(tarball, dest) } -const publishedFingerprint = (name: string, version: string, tmp: string): string | null => { - let url: string - try { - url = run('npm', ['view', `${name}@${version}`, 'dist.tarball']) - } catch { - return null - } - if (!url) return null +/** Null means this version was never published, which is a skip rather than a failure. */ +const publishedFingerprint = async ( + name: string, + version: string, + tmp: string, +): Promise => { + const metadata = await fetch(`https://registry.npmjs.org/${name}/${version}`) + if (metadata.status === 404) return null + if (!metadata.ok) throw new Error(`registry returned ${metadata.status} for ${name}@${version}`) + + const { dist } = (await metadata.json()) as { dist: { tarball: string } } + const download = await fetch(dist.tarball) + if (!download.ok) throw new Error(`tarball download failed for ${name}@${version}`) + const dest = mkdtempSync(join(tmp, 'published-')) const tarball = join(dest, 'published.tgz') - run('curl', ['-sSL', url, '-o', tarball]) + writeFileSync(tarball, Buffer.from(await download.arrayBuffer())) return extractedFingerprint(tarball, dest) } @@ -91,7 +97,7 @@ const packages = readdirSync(join(root, 'packages'), { withFileTypes: true }) const drifted: string[] = [] for (const { dir, name, version } of packages) { - const published = publishedFingerprint(name, version, tmp) + const published = await publishedFingerprint(name, version, tmp) if (published === null) { console.log(`? ${name} — ${version} not on registry, skipped`) continue From 94667479f3bc106cdd34cf4d088398a16beb44e5 Mon Sep 17 00:00:00 2001 From: Chris Chan Date: Mon, 10 Aug 2026 17:13:55 +1000 Subject: [PATCH 7/7] chore: drop check:changeset script Co-Authored-By: Claude --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index f4a8dbcd0ee..6dab965575c 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,6 @@ "reset": "pnpm clean && pnpm install --force", "chromatic": "chromatic", "changeset": "changeset", - "check:changeset": "changeset status --since=origin/main", "check:package-output": "tsx scripts/check-package-output.ts", "plop": "plop", "pkg:aio": "pnpm -F @kaizen/components",