diff --git a/.github/workflows/changelog-check.yml b/.github/workflows/changelog-check.yml new file mode 100644 index 000000000..b4fae54b2 --- /dev/null +++ b/.github/workflows/changelog-check.yml @@ -0,0 +1,88 @@ +name: Changelog Check + +# Exploration 0197: require every PR to either describe its user-facing change in +# a `## Changelog` section or carry the `skip-changelog` label. Runs on body and +# label edits so the check clears as soon as the PR is fixed. Add the job name +# `changelog-section` to the branch ruleset to make it block merges. +on: + pull_request: + types: [opened, edited, reopened, synchronize, labeled, unlabeled] + branches: [main] + +permissions: + contents: read + pull-requests: write + +concurrency: + group: changelog-check-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + changelog-section: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '24' + + - name: Require a Changelog section (or skip-changelog label) + id: check + continue-on-error: true + env: + PR_BODY: ${{ github.event.pull_request.body }} + PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} + run: node scripts/changelog/check.mjs + + - name: Post / resolve the helper comment + if: always() + continue-on-error: true + uses: actions/github-script@v7 + with: + script: | + const passed = '${{ steps.check.outcome }}' === 'success' + const marker = '' + const { owner, repo } = context.repo + const issue_number = context.payload.pull_request.number + const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number }) + const existing = comments.find( + (c) => c.user?.login === 'github-actions[bot]' && c.body?.includes(marker) + ) + if (passed) { + if (existing) { + await github.rest.issues.updateComment({ + owner, repo, comment_id: existing.id, + body: `${marker}\n✓ Changelog section found — thanks!` + }) + } + return + } + const body = [ + marker, + '**This PR needs a `## Changelog` section.**', + '', + 'Add one to the PR description so the change reaches the public', + 'changelog (CI publishes it automatically on merge):', + '', + '```markdown', + '## Changelog', + '', + 'Short, benefit-first headline', + 'One sentence on what the user can now do.', + '- A specific user-visible point', + 'tags: app, ai', + '```', + '', + 'No user-facing change? Add the **`skip-changelog`** label instead.' + ].join('\n') + if (existing) { + await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }) + } else { + await github.rest.issues.createComment({ owner, repo, issue_number, body }) + } + + - name: Enforce the result + if: steps.check.outcome != 'success' + run: | + echo "Changelog section missing — see the PR comment above." + exit 1 diff --git a/AGENTS.md b/AGENTS.md index 4f54bddc8..aac673f21 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -309,11 +309,13 @@ xNet keeps a user-facing changelog (explorations 0195–0197). It surfaces on th website (`/changelog`), as JSON/RSS feeds, and inside the app's "What's New" panel. -**You don't edit the changelog data by hand.** When your change ships something -a user will notice — a new feature, a fixed bug, a visible UX or performance -improvement — fill the **`## Changelog` section of the PR description**. On merge, -CI (`.github/workflows/changelog.yml`) turns that block into a fragment file and -commits it, stamping the date, PR number, and author automatically: +**Required — every PR must do one of two things, or it cannot merge.** A +required CI check (`changelog-section`) fails the PR unless it either has a +`## Changelog` section **or** carries the `skip-changelog` label. Don't edit the +changelog data by hand — fill the **`## Changelog` section of the PR +description**. On merge, CI (`.github/workflows/changelog.yml`) turns that block +into a fragment file and commits it, stamping the date, PR number, and author +automatically: ```markdown ## Changelog @@ -327,8 +329,9 @@ tags: app, ai Write for end users, not engineers: "Deals now sync after import," not `fix(schema): correct relation validation`. For internal-only PRs (refactors, -chores, CI), leave the block empty and add the **`skip-changelog`** label — a PR -merged without either gets a `needs-changelog` label so it isn't lost. +chores, CI), add the **`skip-changelog`** label instead — that satisfies the +check. (If a PR is ever admin-merged past the check without either, it gets a +`needs-changelog` label so it isn't lost.) To hand-author or correct an entry, drop/edit a `site/src/data/changelog/.json` fragment directly; `pnpm --filter site validate:changelog` enforces the shape. diff --git a/scripts/changelog/check.mjs b/scripts/changelog/check.mjs new file mode 100644 index 000000000..ef20e3741 --- /dev/null +++ b/scripts/changelog/check.mjs @@ -0,0 +1,44 @@ +#!/usr/bin/env node +/** + * PR gate (exploration 0197): fail unless the PR body has a `## Changelog` + * section with real content, or the PR carries the `skip-changelog` label. + * Wired as a required check (changelog-section) so a PR can't merge without + * either. Uses the same parsing as the on-merge writer (from-pr.mjs) via lib.mjs. + */ +import { hasChangelogContent } from './lib.mjs' + +const body = process.env.PR_BODY || '' +const labels = (process.env.PR_LABELS || '') + .split(',') + .map((l) => l.trim()) + .filter(Boolean) + +if (labels.includes('skip-changelog')) { + console.log('✓ skip-changelog label present — changelog not required for this PR.') + process.exit(0) +} + +if (hasChangelogContent(body)) { + console.log('✓ This PR has a Changelog section.') + process.exit(0) +} + +console.error( + [ + '✗ This PR is missing a "## Changelog" section.', + '', + 'Add one to the PR description so the change reaches the public changelog', + '(CI turns it into an entry automatically on merge):', + '', + ' ## Changelog', + '', + ' Short, benefit-first headline', + ' One sentence on what the user can now do.', + ' - A specific user-visible point', + ' tags: app, ai', + '', + 'If this PR has no user-facing impact (internal refactor, chore, CI),', + 'add the "skip-changelog" label instead.' + ].join('\n') +) +process.exit(1) diff --git a/scripts/changelog/from-pr.mjs b/scripts/changelog/from-pr.mjs index 3c222aea8..266b392f6 100644 --- a/scripts/changelog/from-pr.mjs +++ b/scripts/changelog/from-pr.mjs @@ -12,12 +12,9 @@ */ import { appendFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs' import { join } from 'node:path' +import { KNOWN_TAGS, cleanTags, extractChangelogBlock, parseChangelogBlock } from './lib.mjs' const DIR = 'site/src/data/changelog' -const KNOWN_TAGS = new Set([ - 'app', 'crm', 'finance', 'tasks', 'ai', 'plugins', 'editor', - 'sync', 'identity', 'platform', 'performance', 'devtools', 'ci' -]) const MODEL = process.env.CHANGELOG_MODEL || 'claude-haiku-4-5' function out(key, value) { @@ -30,43 +27,6 @@ function monthLabel(iso) { return d.toLocaleString('en-US', { month: 'long', year: 'numeric', timeZone: 'UTC' }) } -/** Pull the text under a `## Changelog` heading, up to the next heading. */ -function extractBlock(body) { - if (!body) return '' - const lines = body.split(/\r?\n/) - const start = lines.findIndex((l) => /^#{1,4}\s*changelog\s*$/i.test(l.trim())) - if (start === -1) return '' - const rest = [] - for (let i = start + 1; i < lines.length; i++) { - if (/^#{1,4}\s+\S/.test(lines[i])) break - rest.push(lines[i]) - } - return rest.join('\n').replace(//g, '').trim() -} - -function cleanTags(raw) { - const tags = (raw || []).map((t) => String(t).trim().toLowerCase()).filter((t) => KNOWN_TAGS.has(t)) - return tags.length ? [...new Set(tags)] : ['app'] -} - -/** Parse the block: first line = title, `-`/`*` lines = highlights, `tags:` line = tags, rest = summary. */ -function parseProse(block, fallbackTitle) { - const lines = block.split(/\r?\n/).map((l) => l.trim()).filter(Boolean) - let title = '' - const summary = [] - const highlights = [] - let tags = [] - for (const line of lines) { - const tagsMatch = line.match(/^tags?:\s*(.+)$/i) - if (tagsMatch) { tags = tagsMatch[1].split(','); continue } - if (/^[-*]\s+/.test(line)) { highlights.push(line.replace(/^[-*]\s+/, '')); continue } - if (!title) { title = line; continue } - summary.push(line) - } - title = title || fallbackTitle || '' - return { title, summary: summary.join(' ') || title, highlights, tags: cleanTags(tags) } -} - /** Ask Claude for a structured entry. Fail-open: returns null on any error. */ async function aiDraft(prTitle, prBody) { const key = process.env.ANTHROPIC_API_KEY @@ -115,8 +75,8 @@ async function main() { return out('written', 'false') } - const block = extractBlock(process.env.PR_BODY) - let parsed = block ? parseProse(block, process.env.PR_TITLE) : null + const block = extractChangelogBlock(process.env.PR_BODY) + let parsed = block ? parseChangelogBlock(block, process.env.PR_TITLE) : null if (!parsed || !parsed.summary) parsed = await aiDraft(process.env.PR_TITLE, process.env.PR_BODY) if (!parsed || !parsed.title || !parsed.summary) { console.log('no changelog block and no AI draft — nothing written') diff --git a/scripts/changelog/lib.mjs b/scripts/changelog/lib.mjs new file mode 100644 index 000000000..55edb5b12 --- /dev/null +++ b/scripts/changelog/lib.mjs @@ -0,0 +1,62 @@ +/** + * Shared changelog-from-PR parsing (exploration 0197). Used by both the writer + * (from-pr.mjs, on merge) and the gate (check.mjs, on every PR) so "does this PR + * have a changelog?" is decided by exactly the same logic that produces the entry. + */ + +export const KNOWN_TAGS = new Set([ + 'app', 'crm', 'finance', 'tasks', 'ai', 'plugins', 'editor', + 'sync', 'identity', 'platform', 'performance', 'devtools', 'ci' +]) + +export function cleanTags(raw) { + const tags = (raw || []).map((t) => String(t).trim().toLowerCase()).filter((t) => KNOWN_TAGS.has(t)) + return tags.length ? [...new Set(tags)] : ['app'] +} + +/** Pull the text under a `## Changelog` heading, up to the next heading, comments stripped. */ +export function extractChangelogBlock(body) { + if (!body) return '' + const lines = body.split(/\r?\n/) + const start = lines.findIndex((l) => /^#{1,4}\s*changelog\s*$/i.test(l.trim())) + if (start === -1) return '' + const rest = [] + for (let i = start + 1; i < lines.length; i++) { + if (/^#{1,4}\s+\S/.test(lines[i])) break + rest.push(lines[i]) + } + return rest.join('\n').replace(//g, '').trim() +} + +/** Parse the block: first line = title, `-`/`*` lines = highlights, `tags:` line = tags, rest = summary. */ +export function parseChangelogBlock(block, fallbackTitle) { + const lines = block.split(/\r?\n/).map((l) => l.trim()).filter(Boolean) + let title = '' + const summary = [] + const highlights = [] + let tags = [] + for (const line of lines) { + const tagsMatch = line.match(/^tags?:\s*(.+)$/i) + if (tagsMatch) { tags = tagsMatch[1].split(','); continue } + if (/^[-*]\s+/.test(line)) { highlights.push(line.replace(/^[-*]\s+/, '')); continue } + if (!title) { title = line; continue } + summary.push(line) + } + title = title || fallbackTitle || '' + return { title, summary: summary.join(' ') || title, highlights, tags: cleanTags(tags) } +} + +/** + * True if the PR body has a `## Changelog` section with real, user-authored + * content (not just the template comment or a lone `tags:` line). This is the + * merge gate's pass condition. + */ +export function hasChangelogContent(body) { + const block = extractChangelogBlock(body) + const substantive = block + .split(/\r?\n/) + .map((l) => l.trim()) + .filter((l) => l && !/^tags?:/i.test(l)) + .join('') + return substantive.length >= 8 +}