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
7 changes: 5 additions & 2 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,19 @@ updates:
- "@types/*"
commit-message:
prefix: "chore(deps)"
# Tooling updates are listed under the release's Commits; they need no hand-written entry.
labels: ["dependencies", "javascript", "skip-changelog"]

- package-ecosystem: github-actions
directory: /
schedule:
interval: monthly
commit-message:
prefix: "ci"
labels: ["dependencies", "github_actions", "skip-changelog"]

# The digest-pinned base image the scaffold emits. These PRs fail the bump check
# until `shared:docker`'s version in src/features/docker.ts is bumped by hand.
# The digest-pinned base image the scaffold emits. These PRs fail the bump and changelog checks
# until `shared:docker`'s version in src/features/docker.ts is bumped and an entry added by hand.
- package-ecosystem: docker
directory: /packages/preset-nuxt4/templates/docker
schedule:
Expand Down
13 changes: 13 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,19 @@ existing project, which is the most confusing possible outcome.

CI gates this, so an unbumped feature fails the run before anything else.

## Changelog

Skip this section if nothing under `packages/` changes, other than a package's own
`test/` or `README.md`.

Prepare release turns `## Unreleased` in `CHANGELOG.md` into the release notes,
so an entry missing there is missing from the release.

- [ ] I added a line under `## Unreleased` saying what changes for someone using battlestack
- [ ] Not applicable

CI gates this. For a change with nothing to tell users, add the `skip-changelog` label and say why here.

## Testing

<!--
Expand Down
1 change: 1 addition & 0 deletions .github/rulesets/require-ci-to-pass.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"do_not_enforce_on_create": false,
"required_status_checks": [
{ "context": "Feature version bump check", "integration_id": 15368 },
{ "context": "Changelog entry", "integration_id": 15368 },
{ "context": "Type-check and test (Node 24)", "integration_id": 15368 },
{ "context": "Type-check and test (Node 26)", "integration_id": 15368 },
{ "context": "Type-check, test, build, CLI smoke (Windows, Node 24)", "integration_id": 15368 },
Expand Down
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,29 @@ jobs:
- name: Lockstep package version check
run: pnpm version:check

# Prepare release turns `## Unreleased` into the release notes, so a shipped change without an entry
# is missing from them. Runs on push too, where it has no base and passes.
changelog-check:
name: Changelog entry
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0

- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: '24'

- name: Changelog check
env:
CHANGELOG_CHECK_BASE: ${{ github.base_ref && format('origin/{0}', github.base_ref) || '' }}
CHANGELOG_CHECK_HEAD_REF: ${{ github.head_ref }}
SKIP_CHANGELOG_CHECK: ${{ contains(github.event.pull_request.labels.*.name, 'skip-changelog') && '1' || '' }}
run: node scripts/changelog-check.mjs

verify:
name: Type-check and test (Node ${{ matrix.node }})
runs-on: ubuntu-latest
Expand Down
12 changes: 12 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,18 @@ a file, not the one that contributed the text. Changing your feature's
`collectDocs` output and bumping only your own version therefore reaches new
scaffolds and no existing project. Bump the writing feature too.

## Changes that ship need a changelog entry

Add a line under `## Unreleased` in `CHANGELOG.md` for any change under
`packages/`, apart from a package's own `test/` and `README.md`. Write it for
someone using battlestack: what changes for them, and what they have to do, if
anything. Prepare release turns the section into the release's notes and lists
the commit subjects below them.

The **Changelog entry** check in CI enforces this. A change with nothing to tell
users can take the `skip-changelog` label; say why in the pull request.
Dependabot's npm and Actions updates get the label automatically.

## Testing: a test must be able to fail

Every assertion should survive one question: **if I delete the behaviour this
Expand Down
5 changes: 5 additions & 0 deletions docs/repository-settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ is the definition. It requires every job in [`ci.yml`](../.github/workflows/ci.y

```
Feature version bump check
Changelog entry
Type-check and test (Node 24)
Type-check and test (Node 26)
Type-check, test, build, CLI smoke (Windows, Node 24)
Expand Down Expand Up @@ -99,6 +100,10 @@ other job still runs. Adding or removing the label reruns CI, so no push is
needed. Use it for a change that doesn't alter what a feature emits for existing
projects, and say why in the pull request.

**`skip-changelog` does the same for the Changelog entry check**, for a change
under `packages/` with nothing to tell users. Dependabot applies it to its npm
and Actions updates.

## Auto-merge

Auto-merge is currently **off** (`allow_auto_merge: false`), so the button does
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"tsc": "tsc --noEmit && tsc --noEmit -p scripts",
"bump": "tsx scripts/bump-check.ts",
"bump:check": "tsx scripts/bump-check.ts --check",
"changelog:check": "node scripts/changelog-check.mjs",
"check:contrast": "pnpm --filter @battlestack/theme check:contrast",
"check:contract": "pnpm --filter @battlestack/theme check:contract",
"check:ds": "pnpm check:contrast && pnpm check:contract",
Expand Down
73 changes: 73 additions & 0 deletions scripts/changelog-check.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
#!/usr/bin/env node
/**
* PR gate: a change to what ships (anything under `packages/`, except each package's own `test/` and README)
* must add an entry under `## Unreleased` in CHANGELOG.md, which Prepare release turns into the release notes.
*
* CHANGELOG_CHECK_BASE=origin/main node scripts/changelog-check.mjs
*
* Bypass with a truthy $SKIP_CHANGELOG_CHECK, which CI sets from the `skip-changelog` PR label.
*/
import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { pathToFileURL } from 'node:url'

/** True for a path that reaches users: published package code, templates, or a package manifest. */
export function isShipped(file) {
if (!file.startsWith('packages/')) return false
return !/^packages\/[^/]+\/(test\/|README\.md$)/.test(file)
}

/** First line of every bullet in the `## Unreleased` section, or `[]` when there is none. */
export function unreleasedEntries(markdown) {
const section = /^## Unreleased[^\n]*\n([\s\S]*?)(?=^## |(?![\s\S]))/m.exec(markdown)
if (!section) return []
return section[1].split('\n').filter((line) => line.startsWith('- ')).map((line) => line.trim())
}

function git(args) {
return execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
}

function main() {
if (/^(1|true|yes)$/i.test(process.env.SKIP_CHANGELOG_CHECK ?? '')) {
console.log('changelog check bypassed via SKIP_CHANGELOG_CHECK')
return
}
// Prepare release folds `## Unreleased` into the version stanza, so its PR removes entries by design.
if ((process.env.CHANGELOG_CHECK_HEAD_REF ?? '').startsWith('release/v')) {
console.log('changelog check skipped for a release branch')
return
}
const base = process.env.CHANGELOG_CHECK_BASE?.trim()
if (!base) {
console.log('changelog check: no base ref (not a pull request), nothing to compare')
return
}

const shipped = git(['diff', '--name-only', '--no-renames', `${base}...HEAD`]).split('\n').filter(isShipped)
if (shipped.length === 0) {
console.log('OK: nothing under packages/ ships from this change, no entry needed')
return
}

let before = ''
try {
before = git(['show', `${base}:CHANGELOG.md`])
} catch {
// No CHANGELOG.md at the base: every entry on HEAD is new.
}
const known = new Set(unreleasedEntries(before))
const added = unreleasedEntries(readFileSync('CHANGELOG.md', 'utf8')).filter((e) => !known.has(e))
if (added.length > 0) {
console.log(`OK: ${added.length} new Unreleased entr${added.length === 1 ? 'y' : 'ies'}`)
return
}

console.log('FAIL: this change ships, but CHANGELOG.md gained no entry under `## Unreleased`.')
console.log(` shipped files: ${shipped.slice(0, 5).join(', ')}${shipped.length > 5 ? `, +${shipped.length - 5} more` : ''}`)
console.log(' fix: add a line under `## Unreleased` saying what changes for someone using battlestack')
console.log('\nbypass (sparingly): SKIP_CHANGELOG_CHECK=1, or the `skip-changelog` label on the PR')
process.exit(1)
}

if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) main()
35 changes: 35 additions & 0 deletions scripts/test/changelog-check.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
// @ts-expect-error plain .mjs release tooling, no declaration file
import { isShipped, unreleasedEntries } from '../changelog-check.mjs'

describe('isShipped', () => {
it.each([
['packages/core/src/utils/templates.ts', true],
['packages/preset-nuxt4/templates/auth/test/e2e/api/auth.test.ts', true],
['packages/preset-nuxt4/templates/ai-tool-config/claude-code/.claude/rules/vue.md', true],
['packages/theme/package.json', true],
['packages/cli/test/pull.test.ts', false],
['packages/cli/README.md', false],
['scripts/bump-check.ts', false],
['.github/workflows/ci.yml', false],
['CHANGELOG.md', false],
['pnpm-lock.yaml', false],
])('%s -> %s', (file, expected) => {
expect(isShipped(file)).toBe(expected)
})
})

describe('unreleasedEntries', () => {
it('lists the first line of each Unreleased bullet', () => {
const md = '# Changelog\n\n## Unreleased\n\n- One,\n continued.\n- Two.\n\n## v1.3.0 (2026-09-24)\n\n- old\n'
expect(unreleasedEntries(md)).toEqual(['- One,', '- Two.'])
})

it('is empty without an Unreleased section', () => {
expect(unreleasedEntries('# Changelog\n\n## v1.3.0 (2026-09-24)\n\n- old\n')).toEqual([])
})

it('reads an Unreleased section that ends the file', () => {
expect(unreleasedEntries('# Changelog\n\n## Unreleased\n\n- Only.\n')).toEqual(['- Only.'])
})
})
Loading