Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions .github/workflows/changelog-check.yml
Original file line number Diff line number Diff line change
@@ -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 = '<!-- changelog-check -->'
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
17 changes: 10 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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/<id>.json`
fragment directly; `pnpm --filter site validate:changelog` enforces the shape.
Expand Down
44 changes: 44 additions & 0 deletions scripts/changelog/check.mjs
Original file line number Diff line number Diff line change
@@ -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)
46 changes: 3 additions & 43 deletions scripts/changelog/from-pr.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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(/<!--[\s\S]*?-->/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
Expand Down Expand Up @@ -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')
Expand Down
62 changes: 62 additions & 0 deletions scripts/changelog/lib.mjs
Original file line number Diff line number Diff line change
@@ -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(/<!--[\s\S]*?-->/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
}
Loading