diff --git a/.changeset/coverage-check-simulation.md b/.changeset/coverage-check-simulation.md new file mode 100644 index 000000000..a9b2b17ad --- /dev/null +++ b/.changeset/coverage-check-simulation.md @@ -0,0 +1,5 @@ +--- +"posthog-android": patch +--- + +[SIMULATION — DO NOT MERGE] Intentional package mismatch to verify the new Changeset coverage workflow. The code change is in `posthog-android-gradle-plugin/`, but this changeset declares `posthog-android`. The workflow comment should call this out. diff --git a/.github/workflows/changeset-coverage.yml b/.github/workflows/changeset-coverage.yml new file mode 100644 index 000000000..1290dd49d --- /dev/null +++ b/.github/workflows/changeset-coverage.yml @@ -0,0 +1,49 @@ +name: 'Changeset coverage' + +on: + pull_request: + branches: [main] + +permissions: + contents: read + pull-requests: write + +concurrency: + group: changeset-coverage-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + check: + name: Check changeset coverage + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + + - name: Setup environment + uses: ./.github/actions/setup + with: + install: false + + - name: Compute coverage report + id: coverage + env: + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: node scripts/check-changeset-coverage.mjs >> "$GITHUB_OUTPUT" + + - name: Post sticky PR comment + if: steps.coverage.outputs.body != '' + uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4 + with: + header: changeset-coverage + message: ${{ steps.coverage.outputs.body }} + + - name: Delete stale sticky PR comment + if: steps.coverage.outputs.body == '' + uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4 + with: + header: changeset-coverage + delete: true diff --git a/posthog-android-gradle-plugin/SIMULATION_DELETE_BEFORE_MERGE.md b/posthog-android-gradle-plugin/SIMULATION_DELETE_BEFORE_MERGE.md new file mode 100644 index 000000000..e153ea9be --- /dev/null +++ b/posthog-android-gradle-plugin/SIMULATION_DELETE_BEFORE_MERGE.md @@ -0,0 +1,8 @@ +# Changeset coverage simulation marker + +This file exists only to verify the new `Changeset coverage` workflow. +It is a no-op file inside `posthog-android-gradle-plugin/` paired with a +changeset that declares a different package — the workflow should post a +PR comment flagging the mismatch. + +Delete this file (and the matching changeset) before merging. diff --git a/scripts/check-changeset-coverage.mjs b/scripts/check-changeset-coverage.mjs new file mode 100644 index 000000000..a328133c2 --- /dev/null +++ b/scripts/check-changeset-coverage.mjs @@ -0,0 +1,142 @@ +#!/usr/bin/env node +// Compares packages modified in this PR against packages declared in any +// changeset added/modified in this PR. Writes a markdown comment body to +// stdout in GITHUB_OUTPUT format. Never exits non-zero — the workflow only +// uses this to surface a warning, not to gate the PR. + +import { execSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; + +const baseRef = process.env.BASE_REF || 'main'; + +const sh = (cmd) => execSync(cmd, { encoding: 'utf8' }).trim(); + +// 1. Workspace package directories → package names. Use pnpm itself as the +// source of truth so we handle globs, exclusions, and the catalog correctly. +const cwd = process.cwd(); +const workspaceListing = JSON.parse(sh('pnpm m ls --json --depth=-1')); +const dirToName = {}; +for (const entry of workspaceListing) { + if (!entry.name) continue; // workspace root has no name field + if (entry.path === cwd) continue; + const relPath = entry.path.startsWith(cwd + '/') + ? entry.path.slice(cwd.length + 1) + : entry.path; + dirToName[relPath] = entry.name; +} +const knownNames = new Set(Object.values(dirToName)); + +// 2. Diff vs base. +const mergeBase = sh(`git merge-base origin/${baseRef} HEAD`); +const changedFiles = sh(`git diff --name-only ${mergeBase}...HEAD`).split('\n').filter(Boolean); + +// 3. Map changed files → affected packages. +const ignoreSuffixes = ['/CHANGELOG.md', '/package.json']; +const affected = new Set(); +for (const file of changedFiles) { + if (file.startsWith('.changeset/')) continue; + if (ignoreSuffixes.some((s) => file.endsWith(s))) continue; + for (const [dir, name] of Object.entries(dirToName)) { + if (file === dir || file.startsWith(dir + '/')) { + affected.add(name); + break; + } + } +} + +// 4. Find changeset files added or modified in this PR. +const changesetFiles = sh( + `git diff --name-only --diff-filter=AM ${mergeBase}...HEAD -- .changeset/`, +) + .split('\n') + .filter((f) => f.endsWith('.md') && !f.endsWith('README.md')); + +const writeOutput = (body) => { + if (!body) { + process.stdout.write('body=\n'); + } else { + process.stdout.write(`body< !declared.has(n)).sort(); +const extra = [...declared.keys()].filter((n) => !affected.has(n) && knownNames.has(n)).sort(); + +const declaredList = [...declared] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([n, b]) => `- \`${n}\` — ${b}`) + .join('\n'); + +if (missing.length === 0 && extra.length === 0) { + // Coverage matches — no comment needed. Workflow will delete any stale one. + writeOutput(''); + process.exit(0); +} + +const summary = (() => { + if (missing.length && extra.length) { + return 'Possible changeset mismatch — modified and declared packages differ'; + } + if (missing.length === 1) { + return `\`${missing[0]}\` is modified but not declared in any changeset`; + } + if (missing.length > 1) { + return `${missing.length} packages modified but not declared in any changeset`; + } + if (extra.length === 1) { + return `Changeset declares \`${extra[0]}\` but no source files in that package changed`; + } + return 'Changesets declare packages with no source changes in this PR'; +})(); + +const inner = []; +inner.push( + 'This is informational — the PR is not blocked. Click the triangle above to collapse, or push a fix and this comment will auto-delete.', +); +inner.push(''); +if (missing.length > 0) { + inner.push('**Modified in this PR but not in any changeset:**'); + for (const n of missing) inner.push(`- \`${n}\``); + inner.push(''); + inner.push('If this package should ship the change, add it to the changeset frontmatter:'); + inner.push(''); + inner.push('```'); + inner.push('---'); + for (const n of missing) inner.push(`"${n}": patch`); + inner.push('---'); + inner.push('```'); + inner.push(''); +} +if (extra.length > 0) { + inner.push('**Declared in a changeset but no source files modified:**'); + for (const n of extra) inner.push(`- \`${n}\``); + inner.push(''); + inner.push( + 'Double-check this is intentional — for example, releasing a previously-merged change.', + ); + inner.push(''); +} +inner.push('**Changesets in this PR:**'); +inner.push(declaredList || '_(none)_'); + +const body = `
\n⚠️ ${summary}\n\n${inner.join('\n')}\n\n
`; +writeOutput(body);