From 461b6ab117df3fcbabed2aabb0e245fe7c1b81da Mon Sep 17 00:00:00 2001 From: larry foulkrod Date: Sun, 16 Aug 2026 15:28:05 -0500 Subject: [PATCH 1/2] ci(release): enforce release-note policy --- .github/pull_request_template.md | 13 ++ .github/workflows/ci.yml | 16 ++ .github/workflows/release.yml | 23 ++- CONTRIBUTING.md | 18 ++ RELEASING.md | 32 +++- docs/release-notes.md | 111 +++++++++++ docs/releases/README.md | 14 ++ release-notes.d/README.md | 20 ++ scripts/release-notes.mjs | 317 +++++++++++++++++++++++++++++++ tests/release-notes.test.mjs | 133 +++++++++++++ 10 files changed, 687 insertions(+), 10 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 docs/release-notes.md create mode 100644 docs/releases/README.md create mode 100644 release-notes.d/README.md create mode 100644 scripts/release-notes.mjs create mode 100644 tests/release-notes.test.mjs diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 005ae44..c11c4eb 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -6,6 +6,19 @@ +## Release note + + + ## Release and security check - [ ] I considered whether this changes permissions, downloaded software, executed commands, dependencies, configuration files, or release artifacts. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd4f1b4..93d4e07 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ on: branches: ["main"] pull_request: branches: ["main"] + merge_group: schedule: - cron: "17 8 * * 1" workflow_dispatch: @@ -35,8 +36,23 @@ jobs: steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: + fetch-depth: 0 persist-credentials: false + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version: 24 + + - name: Test release-note tooling + run: node --test tests/release-notes.test.mjs + + - name: Validate stored fragments + run: node scripts/release-notes.mjs validate-fragments + + - name: Validate pull request release-note metadata + if: github.event_name == 'pull_request' + run: node scripts/release-notes.mjs validate-pr + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: go.mod diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9bbb273..8658281 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,6 +44,21 @@ jobs: exit 1 fi + - name: Require reviewed release notes + shell: bash + run: | + notes="docs/releases/${GITHUB_REF_NAME}.md" + test -s "${notes}" || { + echo "Missing reviewed release notes: ${notes}" + exit 1 + } + remaining=$(find release-notes.d -maxdepth 1 -type f -name '*.md' ! -iname 'README.md' -print) + test -z "${remaining}" || { + echo "Release-note fragments must be incorporated before tagging:" + echo "${remaining}" + exit 1 + } + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: go.mod @@ -280,6 +295,11 @@ jobs: id-token: write attestations: write steps: + - name: Checkout release source + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + persist-credentials: false + - name: Download archives uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -304,6 +324,7 @@ jobs: with: files: dist/* fail_on_unmatched_files: true - generate_release_notes: true + body_path: docs/releases/${{ github.ref_name }}.md + generate_release_notes: false prerelease: ${{ contains(github.ref_name, '-') }} make_latest: ${{ !contains(github.ref_name, '-') }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a8cbe01 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,18 @@ +# Contributing to Omnideck CLI + +Keep pull requests focused, explain the outcome, and include the relevant +automated and manual verification. + +## Pull request requirements + +- Use a [Conventional Commit](https://www.conventionalcommits.org/en/v1.0.0/) + title such as `feat(desktop): add native zoom` or + `fix(setup): recover from an occupied port`. +- Add a user-facing file under `release-notes.d/`, or apply + `release-note:none` and explain `None: ` under the pull request's + `## Release note` heading. +- Update documentation and tests when behavior changes. +- Run the repository's documented quality checks before requesting review. + +Read [the release-note policy](docs/release-notes.md) for fragment categories, +examples, validation, and release generation. diff --git a/RELEASING.md b/RELEASING.md index 18710db..4b41c77 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -23,12 +23,26 @@ the default release. ## Publish a preview -1. Run `make verify` locally. -2. Merge the intended changes to `main` and ensure every required CI and CodeQL +1. Choose the next version, then generate its checked-in release draft from + the outstanding fragments: + + ```sh + VERSION=v0.8.0-alpha.1 + node scripts/release-notes.mjs generate \ + --version "${VERSION}" \ + --output "docs/releases/${VERSION}.md" + ``` + + Curate the generated file for user-visible outcomes, upgrade guidance, known + limitations, and preview feedback. Remove the fragments incorporated into + that file. The release pull request uses `release-note:none` with a reason + explaining that it only aggregates previously reviewed fragments. +2. Run `make verify` locally. +3. Merge the release change to `main` and ensure every required CI and CodeQL check is green. -3. Choose the next prerelease identifier. Increment the final number for every - new build; never move or replace a published tag. -4. Create and push an annotated tag: +4. Increment the final prerelease number for every new build; never move or + replace a published tag. +5. Create and push an annotated tag: ```sh git switch main @@ -36,7 +50,7 @@ the default release. git tag -a v0.8.0-alpha.1 -m "Omnideck CLI v0.8.0-alpha.1" git push origin v0.8.0-alpha.1 ``` -5. Open the release workflow. Confirm that the source checks, vulnerability +6. Open the release workflow. Confirm that the source checks, vulnerability scan, builds, SBOM generation, and provenance attestations passed. Approve the protected `release` environment only after reviewing those results. @@ -95,6 +109,6 @@ provenance, embedded version, and portable contract. If any RC check fails after publication, fix forward on `main` and publish the next RC number. Do not replace the failed RC. -GitHub-generated notes are a useful baseline. Curate the release description for -user-visible changes, upgrade notes, known limitations, and a short request for -preview feedback. +The tag workflow publishes the exact checked-in +`docs/releases/.md` body. It does not generate a release description +from raw commit or pull request titles. diff --git a/docs/release-notes.md b/docs/release-notes.md new file mode 100644 index 0000000..2aad30c --- /dev/null +++ b/docs/release-notes.md @@ -0,0 +1,111 @@ +# Release-note policy + +This repository uses [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) +for change classification, [Semantic Versioning](https://semver.org/) where the +repository publishes versions, and the change categories from +[Keep a Changelog](https://keepachangelog.com/en/2.0.0/). Release notes are +written for people installing or using the omnideck command-line interface. + +Every pull request must make an explicit release-note decision. The required CI +check accepts exactly one of: + +1. one or more new, valid files under `release-notes.d/`; or +2. the `release-note:none` label plus `None: ` under the pull + request's `## Release note` heading. + +Features and breaking changes cannot use `release-note:none`. + +## Pull request titles + +The pull request title is the canonical machine-readable description and must +use this form: + +```text +[optional scope][optional !]: +``` + +Allowed types are `build`, `chore`, `ci`, `docs`, `feat`, `fix`, +`perf`, `refactor`, `revert`, `style`, and `test`. Examples: + +```text +feat(desktop): add native application zoom +fix(cli): preserve the selected runtime port +ci(release): verify published checksums +feat(api)!: remove the legacy profile schema +``` + +Use a concise technical title. Put polished user-facing copy in the fragment. + +## Release-note fragments + +Add a short, unique Markdown file such as +`release-notes.d/native-desktop-zoom.md`: + +```markdown +--- +type: added +area: desktop +--- + +Zoom the entire application with Ctrl/Cmd and +, -, or 0. Tabs, menus, and +previews remain aligned at every zoom level. +``` + +The required fields are: + +- `type`: `added`, `changed`, `deprecated`, `removed`, `fixed`, or + `security` +- `area`: a lowercase kebab-case product or repository area +- body: plain, user-facing prose describing the outcome + +Write what changed for the reader and why it matters. Avoid build systems, +test environments, commit hashes, internal refactors, and qualification detail +unless the repository's users must act on them. Include upgrade or migration +guidance when behavior is incompatible. + +A pull request may add multiple fragments when it contains distinct notable +changes. Do not combine a fragment with `release-note:none`. + +## Changes without release notes + +For maintenance that has no externally visible outcome: + +1. apply the `release-note:none` label; and +2. write a specific reason in the pull request body: + +```markdown +## Release note + +None: Expands release qualification only; shipped behavior is unchanged. +``` + +The explicit reason makes omissions reviewable. `feat` titles and titles with +`!` must provide fragments instead. + +## Validation and generation + +Run the shared local checks: + +```sh +node --test tests/release-notes.test.mjs +node scripts/release-notes.mjs validate-fragments +``` + +Generate a release draft from all outstanding fragments: + +```sh +node scripts/release-notes.mjs generate --version v1.2.3 +node scripts/release-notes.mjs generate \ + --version v1.2.3 \ + --output docs/releases/v1.2.3.md +``` + +Generation groups fragments into the Keep a Changelog categories. The result is +a draft: before publication, add a short release theme when useful, remove +duplication, confirm upgrade guidance and known limitations, and keep the +language focused on the shipped product. + +The release change consumes its fragments after their text is incorporated into +the checked-in release notes or changelog. That release pull request uses +`release-note:none` with a reason explaining that it only aggregates already +reviewed fragments. diff --git a/docs/releases/README.md b/docs/releases/README.md new file mode 100644 index 0000000..f075946 --- /dev/null +++ b/docs/releases/README.md @@ -0,0 +1,14 @@ +# Checked-in CLI release notes + +Each release stores its reviewed GitHub release body here as +`v.md`. Generate the first draft from outstanding fragments: + +```sh +node scripts/release-notes.mjs generate \ + --version v1.2.3 \ + --output docs/releases/v1.2.3.md +``` + +Curate that draft, commit it with the release change, and remove the fragments +whose text it incorporates. The tag workflow requires the exact file and +publishes it as the GitHub release body. diff --git a/release-notes.d/README.md b/release-notes.d/README.md new file mode 100644 index 0000000..998641b --- /dev/null +++ b/release-notes.d/README.md @@ -0,0 +1,20 @@ +# Release-note fragments + +Every pull request must add a fragment here or explicitly use the +`release-note:none` label with a reason. See +[the release-note policy](../docs/release-notes.md) for the complete contract. + +Use a unique lowercase kebab-case filename and this format: + +```markdown +--- +type: changed +area: setup +--- + +Setup now selects another available local port automatically when the saved +port is already in use. +``` + +Valid types are `added`, `changed`, `deprecated`, `removed`, `fixed`, +and `security`. Do not edit this README as a substitute for a fragment. diff --git a/scripts/release-notes.mjs b/scripts/release-notes.mjs new file mode 100644 index 0000000..776a5f5 --- /dev/null +++ b/scripts/release-notes.mjs @@ -0,0 +1,317 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process'; +import { + existsSync, + mkdirSync, + readFileSync, + readdirSync, + writeFileSync, +} from 'node:fs'; +import { basename, dirname, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const RELEASE_NOTE_TYPES = [ + 'added', + 'changed', + 'deprecated', + 'removed', + 'fixed', + 'security', +]; + +const CONVENTIONAL_TITLE = + /^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-z0-9][a-z0-9._/-]*\))?!?: \S.*$/; + +const scriptPath = fileURLToPath(import.meta.url); +const repositoryRoot = resolve(dirname(scriptPath), '..'); +const defaultNotesDirectory = join(repositoryRoot, 'release-notes.d'); + +export function isConventionalTitle(title) { + return CONVENTIONAL_TITLE.test(String(title || '').trim()); +} + +export function parseFragment(text, source = '') { + const normalized = String(text).replace(/\r\n/g, '\n'); + const match = normalized.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); + if (!match) { + throw new Error(`${source}: expected YAML front matter between --- lines`); + } + + const metadata = {}; + for (const line of match[1].split('\n')) { + const field = line.match(/^([a-z][a-z0-9-]*):\s*(\S.*)$/); + if (!field) { + throw new Error(`${source}: invalid front matter line: ${line}`); + } + if (field[1] in metadata) { + throw new Error(`${source}: duplicate ${field[1]} field`); + } + metadata[field[1]] = field[2].trim(); + } + + const unknown = Object.keys(metadata).filter( + (key) => !['type', 'area'].includes(key), + ); + if (unknown.length) { + throw new Error(`${source}: unsupported field(s): ${unknown.join(', ')}`); + } + if (!RELEASE_NOTE_TYPES.includes(metadata.type)) { + throw new Error( + `${source}: type must be one of ${RELEASE_NOTE_TYPES.join(', ')}`, + ); + } + if (!/^[a-z0-9][a-z0-9-]*$/.test(metadata.area || '')) { + throw new Error(`${source}: area must be a lowercase kebab-case name`); + } + + const body = match[2].trim(); + if (!body) { + throw new Error(`${source}: release-note text must not be empty`); + } + if (/^#{1,6}\s/m.test(body)) { + throw new Error(`${source}: use prose, not headings, inside a fragment`); + } + + return { + type: metadata.type, + area: metadata.area, + body, + source, + }; +} + +export function parseChangedFiles(text) { + return String(text || '') + .split(/\r?\n/) + .filter(Boolean) + .map((line) => { + const columns = line.split('\t'); + return { + status: columns[0], + path: columns.at(-1), + }; + }); +} + +function releaseNoteSection(body) { + const withoutComments = String(body || '').replace(//g, ''); + const lines = withoutComments.split(/\r?\n/); + const heading = lines.findIndex((line) => + /^##\s+Release note\s*$/i.test(line), + ); + if (heading === -1) return ''; + + const section = []; + for (const line of lines.slice(heading + 1)) { + if (/^##\s+/.test(line)) break; + section.push(line); + } + return section.join('\n').trim(); +} + +function isFragmentPath(path) { + return ( + path.startsWith('release-notes.d/') && + path.endsWith('.md') && + basename(path).toLowerCase() !== 'readme.md' + ); +} + +export function validatePullRequest( + { title, body = '', labels = [], changedFiles = [] }, + { readFragment = (path) => readFileSync(join(repositoryRoot, path), 'utf8') } = {}, +) { + const errors = []; + + if (!isConventionalTitle(title)) { + errors.push( + 'PR title must use Conventional Commits, for example feat(desktop): add native zoom', + ); + } + + const fragments = changedFiles.filter( + ({ status, path }) => status[0] !== 'D' && isFragmentPath(path), + ); + const addedFragments = fragments.filter(({ status }) => + ['A', 'C', 'R'].includes(status[0]), + ); + const hasNoNoteLabel = labels.includes('release-note:none'); + + if (hasNoNoteLabel && addedFragments.length) { + errors.push( + 'Choose either release-note fragments or the release-note:none label, not both', + ); + } else if (hasNoNoteLabel) { + const section = releaseNoteSection(body); + if (!/^None:\s+\S/im.test(section)) { + errors.push( + 'release-note:none requires "None: " under the PR body Release note heading', + ); + } + + const titleType = String(title || '').match(/^([a-z]+)/)?.[1]; + if (titleType === 'feat' || /!\s*:/.test(String(title || ''))) { + errors.push('Features and breaking changes must include a release-note fragment'); + } + } else if (!addedFragments.length) { + errors.push( + 'Add a release-notes.d/*.md fragment or apply release-note:none with a reason', + ); + } + + for (const { path } of fragments) { + try { + parseFragment(readFragment(path), path); + } catch (error) { + errors.push(error.message); + } + } + + return errors; +} + +function changedFilesForEvent(pullRequest) { + if (process.env.RELEASE_NOTES_CHANGED_FILES !== undefined) { + return parseChangedFiles(process.env.RELEASE_NOTES_CHANGED_FILES); + } + + const range = `${pullRequest.base.sha}...${pullRequest.head.sha}`; + const output = execFileSync('git', ['diff', '--name-status', range], { + cwd: repositoryRoot, + encoding: 'utf8', + }); + return parseChangedFiles(output); +} + +function fragmentFiles(directory = defaultNotesDirectory) { + if (!existsSync(directory)) { + return []; + } + return readdirSync(directory, { withFileTypes: true }) + .filter( + (entry) => + entry.isFile() && + entry.name.endsWith('.md') && + entry.name.toLowerCase() !== 'readme.md', + ) + .map((entry) => join(directory, entry.name)) + .sort(); +} + +export function renderReleaseNotes(fragments, version = '') { + if (!fragments.length) { + throw new Error('No release-note fragments are available'); + } + + const title = version ? `# Release notes for ${version}` : '# Release notes'; + const lines = [title, '']; + + for (const type of RELEASE_NOTE_TYPES) { + const matches = fragments.filter((fragment) => fragment.type === type); + if (!matches.length) continue; + + lines.push(`## ${type[0].toUpperCase()}${type.slice(1)}`, ''); + for (const fragment of matches) { + const area = fragment.area + .split('-') + .map((part) => part[0].toUpperCase() + part.slice(1)) + .join(' '); + const prose = fragment.body.replace(/\s+/g, ' ').trim(); + lines.push(`- **${area}:** ${prose}`); + } + lines.push(''); + } + + return `${lines.join('\n').trimEnd()}\n`; +} + +function loadFragments() { + return fragmentFiles().map((path) => + parseFragment(readFileSync(path, 'utf8'), relative(repositoryRoot, path)), + ); +} + +function optionValue(args, name) { + const index = args.indexOf(name); + if (index === -1) return ''; + if (!args[index + 1]) throw new Error(`${name} requires a value`); + return args[index + 1]; +} + +function validatePrCommand() { + const eventPath = process.env.GITHUB_EVENT_PATH; + if (!eventPath) { + console.log('GITHUB_EVENT_PATH is not set; PR metadata validation skipped.'); + return; + } + + const event = JSON.parse(readFileSync(eventPath, 'utf8')); + if (!event.pull_request) { + console.log('This event has no pull request; PR metadata validation skipped.'); + return; + } + + const errors = validatePullRequest({ + title: event.pull_request.title, + body: event.pull_request.body || '', + labels: event.pull_request.labels.map((label) => label.name), + changedFiles: changedFilesForEvent(event.pull_request), + }); + + if (errors.length) { + for (const error of errors) console.error(`::error::${error}`); + process.exitCode = 1; + return; + } + console.log('Release-note policy passed.'); +} + +function main() { + const [command, ...args] = process.argv.slice(2); + + if (command === 'validate-pr') { + validatePrCommand(); + return; + } + + if (command === 'validate-fragments') { + const fragments = loadFragments(); + console.log(`Validated ${fragments.length} release-note fragment(s).`); + return; + } + + if (command === 'generate') { + const output = renderReleaseNotes(loadFragments(), optionValue(args, '--version')); + const outputPath = optionValue(args, '--output'); + if (!outputPath) { + process.stdout.write(output); + return; + } + + const resolvedOutput = resolve(repositoryRoot, outputPath); + if ( + resolvedOutput !== repositoryRoot && + !resolvedOutput.startsWith(`${repositoryRoot}${sep}`) + ) { + throw new Error('--output must stay inside the repository'); + } + mkdirSync(dirname(resolvedOutput), { recursive: true }); + writeFileSync(resolvedOutput, output, 'utf8'); + console.log(`Wrote ${relative(repositoryRoot, resolvedOutput)}`); + return; + } + + throw new Error( + 'Usage: node scripts/release-notes.mjs [--version VERSION] [--output PATH]', + ); +} + +if (process.argv[1] && resolve(process.argv[1]) === scriptPath) { + try { + main(); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/tests/release-notes.test.mjs b/tests/release-notes.test.mjs new file mode 100644 index 0000000..8e9247d --- /dev/null +++ b/tests/release-notes.test.mjs @@ -0,0 +1,133 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + isConventionalTitle, + parseChangedFiles, + parseFragment, + renderReleaseNotes, + validatePullRequest, +} from '../scripts/release-notes.mjs'; + +const validFragment = `--- +type: added +area: desktop +--- + +Zoom the whole application while keeping tabs, menus, and previews aligned. +`; + +test('accepts the organization Conventional Commit title format', () => { + assert.equal(isConventionalTitle('feat(desktop): add native zoom'), true); + assert.equal(isConventionalTitle('fix!: remove an unsafe fallback'), true); + assert.equal(isConventionalTitle('Add native zoom'), false); + assert.equal(isConventionalTitle('Feat(desktop): add native zoom'), false); +}); + +test('parses a valid Keep a Changelog fragment', () => { + assert.deepEqual(parseFragment(validFragment, 'zoom.md'), { + type: 'added', + area: 'desktop', + body: 'Zoom the whole application while keeping tabs, menus, and previews aligned.', + source: 'zoom.md', + }); +}); + +test('rejects invalid fragment metadata', () => { + assert.throws( + () => + parseFragment( + `--- +type: feature +area: Desktop UI +--- + +Add zoom. +`, + 'bad.md', + ), + /type must be one of/, + ); +}); + +test('parses added, renamed, and deleted paths from git diff output', () => { + assert.deepEqual( + parseChangedFiles( + 'A\trelease-notes.d/zoom.md\nR100\told.md\trelease-notes.d/new.md\nD\tgone.md\n', + ), + [ + { status: 'A', path: 'release-notes.d/zoom.md' }, + { status: 'R100', path: 'release-notes.d/new.md' }, + { status: 'D', path: 'gone.md' }, + ], + ); +}); + +test('requires a fragment or an explicit no-note decision', () => { + const errors = validatePullRequest({ + title: 'fix(cli): preserve configuration', + changedFiles: [{ status: 'M', path: 'main.go' }], + }); + assert.match(errors.join('\n'), /Add a release-notes/); +}); + +test('accepts and validates a new fragment', () => { + const errors = validatePullRequest( + { + title: 'feat(desktop): add native zoom', + changedFiles: [{ status: 'A', path: 'release-notes.d/native-zoom.md' }], + }, + { readFragment: () => validFragment }, + ); + assert.deepEqual(errors, []); +}); + +test('requires a reason for release-note:none', () => { + const errors = validatePullRequest({ + title: 'ci(desktop): expand package checks', + body: '## Release note\n\nNone: Build qualification only; no user-visible behavior changed.\n', + labels: ['release-note:none'], + changedFiles: [{ status: 'M', path: '.github/workflows/desktop.yml' }], + }); + assert.deepEqual(errors, []); +}); + +test('features and breaking changes cannot opt out of release notes', () => { + const featureErrors = validatePullRequest({ + title: 'feat(chat): add folders', + body: '## Release note\n\nNone: Internal only.\n', + labels: ['release-note:none'], + }); + assert.match(featureErrors.join('\n'), /Features and breaking changes/); + + const breakingErrors = validatePullRequest({ + title: 'fix(api)!: remove legacy fields', + body: '## Release note\n\nNone: Internal only.\n', + labels: ['release-note:none'], + }); + assert.match(breakingErrors.join('\n'), /Features and breaking changes/); +}); + +test('renders grouped human-facing release notes', () => { + const output = renderReleaseNotes( + [ + parseFragment(validFragment, 'zoom.md'), + parseFragment( + `--- +type: fixed +area: setup +--- + +Recover automatically when the saved port is already in use. +`, + 'port.md', + ), + ], + 'v1.2.0', + ); + + assert.match(output, /^# Release notes for v1\.2\.0/m); + assert.match(output, /^## Added$/m); + assert.match(output, /^- \*\*Desktop:\*\* Zoom the whole application/m); + assert.match(output, /^## Fixed$/m); +}); From 76ea64c876be2c160541f44b0427dd9627e5c8c0 Mon Sep 17 00:00:00 2001 From: larry foulkrod Date: Sun, 16 Aug 2026 15:30:58 -0500 Subject: [PATCH 2/2] ci(release): rerun policy on metadata changes --- .github/workflows/ci.yml | 15 --------- .github/workflows/release-note-policy.yml | 39 +++++++++++++++++++++++ 2 files changed, 39 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/release-note-policy.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 93d4e07..3f09317 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,23 +36,8 @@ jobs: steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: - fetch-depth: 0 persist-credentials: false - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 - with: - node-version: 24 - - - name: Test release-note tooling - run: node --test tests/release-notes.test.mjs - - - name: Validate stored fragments - run: node scripts/release-notes.mjs validate-fragments - - - name: Validate pull request release-note metadata - if: github.event_name == 'pull_request' - run: node scripts/release-notes.mjs validate-pr - - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: go.mod diff --git a/.github/workflows/release-note-policy.yml b/.github/workflows/release-note-policy.yml new file mode 100644 index 0000000..a3faf6d --- /dev/null +++ b/.github/workflows/release-note-policy.yml @@ -0,0 +1,39 @@ +name: Release-note policy + +on: + pull_request: + branches: [main] + types: [opened, synchronize, reopened, edited, labeled, unlabeled] + merge_group: + push: + branches: [main] + +permissions: + contents: read + +jobs: + release-note-policy: + name: release-note-policy + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Set up Node + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version: 24 + + - name: Test release-note tooling + run: node --test tests/release-notes.test.mjs + + - name: Validate stored fragments + run: node scripts/release-notes.mjs validate-fragments + + - name: Validate pull request metadata + if: github.event_name == 'pull_request' + run: node scripts/release-notes.mjs validate-pr