From a7440741ca3efb7c431aaf68ab9241810c2ef0df Mon Sep 17 00:00:00 2001 From: "Nikita Melnychenko (QenTerra)" Date: Wed, 9 Sep 2026 13:55:20 +0200 Subject: [PATCH 1/2] docs(release): standardize release and contributor writing --- .github/PULL_REQUEST_TEMPLATE.md | 4 +- .github/release.yml | 17 +- .github/workflows/release.yml | 8 +- CHANGELOG.md | 10 +- CONTRIBUTING.md | 9 + docs/RELEASING.md | 10 ++ docs/release-writing.md | 290 +++++++++++++++++++++++++++++++ scripts/release-notes.mjs | 193 +++++++++++++++----- scripts/release-notes.test.mjs | 267 ++++++++++++++++++++++++++++ scripts/verify-release.mjs | 2 +- 10 files changed, 748 insertions(+), 62 deletions(-) create mode 100644 docs/release-writing.md create mode 100644 scripts/release-notes.test.mjs diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 5e41c2e..6b3bada 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,6 +1,8 @@ + + ## Purpose -Describe the problem and the observable outcome. +Describe the problem, trigger, and observable outcome in plain English unless the project selects another language. Use sentence case, concrete facts, and no decorative headings. Rewrite this description to match the final diff before review. ## Scope diff --git a/.github/release.yml b/.github/release.yml index d823bdb..7bd0475 100644 --- a/.github/release.yml +++ b/.github/release.yml @@ -1,17 +1,20 @@ +# Generated notes are a draft; edit against CHANGELOG.md before publication. changelog: exclude: labels: - 'release: skip-notes' - authors: - - dependabot categories: - title: Added labels: ['type: feature'] - - title: Fixed - labels: ['type: bug', 'type: security'] - title: Changed - labels: ['type: performance', 'type: refactor', 'type: dependency'] - - title: Documentation - labels: ['type: documentation'] + labels: ['type: performance', 'type: refactor', 'type: dependency', 'type: documentation'] + - title: Deprecated + labels: ['type: deprecation'] + - title: Removed + labels: ['type: removal'] + - title: Fixed + labels: ['type: bug'] + - title: Security + labels: ['type: security'] - title: Other changes labels: ['*'] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a2b3320..2a7dc74 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,6 +33,9 @@ jobs: - name: Install dependencies run: npm ci + - name: Test release-note formatting + run: node --test scripts/release-notes.test.mjs + - name: Verify release tag env: TAG_NAME: ${{ github.event_name == 'push' && github.ref_name || inputs.tag }} @@ -76,13 +79,12 @@ jobs: TAG_NAME: ${{ github.event_name == 'push' && github.ref_name || inputs.tag }} run: | VERSION="${TAG_NAME#v}" - DISPLAY_VERSION=$(node --input-type=module -e "const parts=process.argv[1].split('.'); console.log(parts[2] === '0' ? parts.slice(0, 2).join('.') : parts.join('.'))" "$VERSION") CHROME_ARCHIVE=".output/elements-$VERSION-chrome.zip" if gh release view "$TAG_NAME" >/dev/null 2>&1; then gh release edit "$TAG_NAME" \ --target "${{ github.sha }}" \ - --title "Elements $DISPLAY_VERSION" \ + --title "Elements $VERSION" \ --notes-file ".output/release-notes.md" \ --draft=false gh release upload "$TAG_NAME" \ @@ -91,7 +93,7 @@ jobs: else gh release create "$TAG_NAME" \ --target "${{ github.sha }}" \ - --title "Elements $DISPLAY_VERSION" \ + --title "Elements $VERSION" \ --notes-file ".output/release-notes.md" \ "$CHROME_ARCHIVE" fi diff --git a/CHANGELOG.md b/CHANGELOG.md index a27719e..0c288c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable changes to Elements are documented in this file. ### Changed +- Standardised commit messages, pull request reviews, changelogs, and release notes with shared contributor templates. + - Adopted QenTerra repository standard 1.3.0 with the canonical Contact block and funding-surface prohibition. - Adopted the QenTerra public repository standard, including complete contribution, maintenance, release, Wiki, and contact documentation. @@ -14,15 +16,15 @@ All notable changes to Elements are documented in this file. - Required pull requests, successful project and governance checks, resolved review conversations, linear history, and immutable release tags at GitHub. +### Removed + +- Removed external donation configuration, badges, and README prose. + ### Security - Updated the transitive `brace-expansion`, `nanoid`, and `postcss` resolutions to versions that clear the current high-severity npm audit gate. -### Removed - -- Removed external donation configuration, badges, and README prose. - ## [1.0.0] — 2026-07-29 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3d37ab3..e210db5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,6 +26,15 @@ there, then removes them after the gate. Set `ELEMENTS_KEEP_VERIFY_WORKSPACE=1` only when you need to inspect that external workspace. +## Commit and review format + +Follow [Release writing](docs/release-writing.md) for commit grammar, PR and review +comment templates, changelog entries, release notes, and publication updates. +Write in English, use a specific Conventional Commit subject of at most 72 +characters, and keep one coherent outcome per change. Breaking changes include +a migration footer. PR descriptions use the repository template's section order; +review findings explain the condition, consequence, and requested change. + ## Pull requests - Keep one change per pull request. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index dbb2218..8caf854 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -1,5 +1,14 @@ # Releasing +## Release text and publication updates + +Follow [Release writing](release-writing.md) for the release title and body, +changelog categories, contributor attribution, and publication result format. +Use the complete version in the GitHub Release title; keep any native display +label separate. Prepare and review the body before publication, then read back +the title, body, target, flags, and assets from each affected provider. +A hosted draft still requires publication authority. + ## Contract - Release owner: Nikita Melnychenko (QenTerra) @@ -31,6 +40,7 @@ Run the complete gate against the intended release tree: ```sh node scripts/verify-repository.mjs +node --test scripts/release-notes.test.mjs ``` It validates source, dependencies, tests, the production extension, the Pages diff --git a/docs/release-writing.md b/docs/release-writing.md new file mode 100644 index 0000000..ea1524a --- /dev/null +++ b/docs/release-writing.md @@ -0,0 +1,290 @@ +# Release writing + +Use these conventions for commits, pull requests, review comments, changelogs, +release notes, and publication updates. The project's [release procedure](RELEASING.md) +defines its actual verification and distribution requirements. + +## Language and style + +- Write repository messages in English unless the project explicitly selects + another language. Localised notes retain the same facts and section order. +- Use sentence case, plain Markdown, `-` bullets, and blank lines around blocks. + End complete prose sentences with periods; commit subjects have no trailing period. +- Name the affected feature, triggering condition, and observable result. Explain + implementation details only when they help review or migration. +- Avoid emoji headings, generic praise, raw commit dumps, task narration, and + unsupported claims such as “various improvements.” +- Keep one coherent change per bullet. Remove optional empty sections and all + instructional placeholders. Retain required checks or explain inapplicability. +- Preserve released history and published artifacts. Formatting cleanup does not + authorise rewriting shared commits, moving tags, or replacing release assets. + +## Commit grammar + +Follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/): +`type(scope): imperative description`. The scope is optional, lowercase kebab-case, +and names a stable component. Keep the complete subject within 72 characters, +start the description lowercase unless an identifier requires case, and omit a +trailing period. Explain why and consequences in an optional body. + +Allowed types: `feat`, `fix`, `docs`, `refactor`, `perf`, `test`, `build`, `ci`, +`chore`, `security`, `release`, and `revert`. Use `perf` for measured improvement +and `release` for version/release metadata only. Incompatible changes use `!` +or `BREAKING CHANGE:` and always include actionable migration guidance in the +footer. Include issue and authorship trailers only when truthful and applicable. + +PR titles use the same grammar. Keep one reviewable outcome per commit and PR; +remove WIP, checkpoint, fixup, and squash subjects before durable integration. +Review comments state the location, condition, consequence, and requested change. +Use a priority only for a substantiated finding, `Suggestion:` for nonblocking +feedback, and `Question:` for an unresolved question. + +## Changelog and release structure + +Follow [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Keep `Unreleased` +first and released versions newest first, using `## [version] - YYYY-MM-DD`. +Use the actual publication date. The version has no tag prefix. + +Keep nonempty categories in this order: `Added`, `Changed`, `Deprecated`, +`Removed`, `Fixed`, `Security`. Describe user, consumer, or operator impact, +not individual commits. Deprecations identify the replacement and removal window; +if no removal is scheduled, say so. Mark incompatible entries `**Breaking:**` +and put required migration guidance before the categories. Publish security +detail only through the project's authorised disclosure process. + +Use ` ` as the GitHub Release title, retaining the full version +and prerelease suffix. A native application's display/build label remains governed +by its product manifest; do not substitute it for the GitHub Release title. +Start the body with one factual sentence. Then use applicable sections in this +order: `Upgrade notes`, the changelog categories, `Downloads`, `Known issues`, +`Verification`, `Acknowledgements`, `Full changelog`. Changelog entries retain +their meaning in release notes. Omit optional empty sections. + +Downloads identify the actual asset, platform, architecture, checksum, and verified +signing status. Source-only releases say so and do not present source archives as +installers. Prereleases state their channel and limits and require the provider's +prerelease flag. Always include a real comparison link, or the first release's tag +link when there is no predecessor. `Unreleased` links the latest tag to `main`; +before any release, link to the branch history instead of inventing a tag. + +GitHub's generated notes are a draft: reconcile them with the changelog, remove +internal noise, and check category placement and attribution. Use `release: skip-notes` +only for changes with no reader impact; do not hide relevant security or dependency +changes because a bot authored them. The catch-all generated category is for review +and must be resolved into the canonical categories before publication. + +## Publishing and reporting + +Prepare the final body, target commit, version, tag, and assets before requesting +missing publication approval. Local commits, pushes, tags, hosted drafts, published +releases, package uploads, deployments, update feeds, and external comments are +separate actions. Reuse existing approval only within its stated scope. + +Preview the final Markdown and check links. Pass multiline text as a structured +argument or UTF-8 body file, not shell-interpolated prose. After writing, read back +each affected provider surface: target commit, peeled tag, title, body, draft and +prerelease flags, assets, checksums, and any registry or deployment version. + +Report verified publication, draft state, and incomplete surfaces separately. +After an uncertain request, inspect whether it succeeded before retrying. Never +silently replace an immutable artifact or move a published tag to repair a mistake. + +## Templates + +Replace every angle-bracket field. All sample product changes below are fictional +formatting examples, not release history. Keep the project's actual PR checklist. + +## Commit + +```text +(): + + + +Refs: +``` + +Omit the scope and trailer when not applicable. These are fictional formatting examples, not product history: + +```text +fix(player): preserve the queue when playback resumes + +Resume from the saved queue instead of rebuilding it from the library. +This retains tracks added during the previous playback session. +``` + +```text +feat(config)!: require an explicit library path + +BREAKING CHANGE: set libraryPath before starting the player; automatic +discovery of the current directory has been removed. +``` + +```text +release: prepare 1.4.0 +``` + +Use the release type only when the actual diff contains version and release metadata. A revert explains why and identifies the real reverted SHA in its body or `Refs:` trailer. + +## Pull Request + +Title: `(): `. + +```markdown +## Purpose + + + +## Scope + +- + +## Verification + +- `` — . +- Unverified: . + +## Evidence + + + +## Risk and recovery + + + +## Documentation and release + + + +## Checklist + +- [ ] The title and diff describe one coherent change. +- [ ] Verification and documentation match the reviewed revision. +- [ ] Material manual and external gaps are explicit. +- [ ] Content is safe to share with this repository's audience. +``` + +Use the adopted repository checklist when it has additional required fields. Check boxes only after performing the check; delete instructional placeholders, not required checks. + +## Review Comment and Resolution + +```text +[P1] Preserve the queue when playback resumes + +When a saved session contains manually queued tracks, rebuilding it from +the library drops those tracks. Restore the saved queue before starting +playback and verify a resume with tracks absent from the library order. +``` + +```text +Suggestion: name the unit in the timeout parameter + +Rename timeout to timeoutMs so callers can see the expected unit. +``` + +```text +Resolved in : . Verified with . + +``` + +Attach findings to the smallest relevant code location. These examples do not authorise posting comments. + +## Changelog Entry + +```markdown +## [Unreleased] + +## [] - + + + +### Added + +- + +### Changed + +- **Breaking:** + +### Deprecated + +- + +### Removed + +- + +### Fixed + +- + +### Security + +- + +[Unreleased]: /compare/... +[]: /compare/... +``` + +Keep only nonempty categories. For the first release, replace the last comparison with the tag or release URL. Example of useful wording: “Fixed a crash when importing an empty playlist.” Avoid “fix(import): handle edge cases.” + +## Release Body + +Title: ` `; set tag and provider prerelease status separately. + +```markdown + + +## Upgrade notes + + + +## Added + +- + +## Fixed + +- + +## Downloads + +| Artifact | Platform | Integrity | +| ------------------------------------------------- | --------------------- | ------------------------------------------------------ | +| [](https://example.invalid/asset) | | [SHA-256 checksums](https://example.invalid/checksums) | + + + +## Known issues + +- + +## Verification + + + +## Acknowledgements + + + +## Full changelog + +[...](https://example.invalid/compare) +``` + +The body shows two example categories; insert other nonempty categories in the order specified above. Omit optional sections, use the first-release link when needed, and substitute package installation details for a package release. Source-only releases omit `Downloads`. Do not claim an installer, signing result, or workaround from the example alone. + +## Publication Receipt + +Use this for a maintainer status update after publication, not as an automatic appendix to release notes. + +```markdown +: . + +- Target: ``; tag: ``. +- : . +- Verification: . +- Remaining: . +``` + +Report each intended surface separately when outcomes differ. Example: a verified GitHub Release with an unverified registry publication is `Partially published`; it is not “release complete.” diff --git a/scripts/release-notes.mjs b/scripts/release-notes.mjs index 305565d..82e57f8 100644 --- a/scripts/release-notes.mjs +++ b/scripts/release-notes.mjs @@ -2,58 +2,159 @@ import { createHash } from 'node:crypto' import { mkdir, readFile, writeFile } from 'node:fs/promises' -import { dirname, join } from 'node:path' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' -const projectRoot = join(import.meta.dirname, '..') -const packageJson = JSON.parse(await readFile(join(projectRoot, 'package.json'), 'utf8')) -const changelog = await readFile(join(projectRoot, 'CHANGELOG.md'), 'utf8') -const tag = process.argv[2] -const outputPath = process.argv[3] -const expectedTag = `v${packageJson.version}` +const repositoryUrl = 'https://github.com/QenTerra/elements' +const categories = ['Added', 'Changed', 'Deprecated', 'Removed', 'Fixed', 'Security'] -if (tag !== expectedTag) { - throw new Error(`Release tag ${tag || '(missing)'} does not match ${expectedTag}`) -} -if (!outputPath) throw new Error('Release notes output path is required') - -const headingPrefix = `## [${packageJson.version}] — ` -const sectionStart = changelog.indexOf(headingPrefix) -if (sectionStart === -1) { - throw new Error(`CHANGELOG.md has no ${packageJson.version} section`) -} -const headingEnd = changelog.indexOf('\n', sectionStart) -const heading = changelog.slice(sectionStart, headingEnd) -if (!/^## \[[^\]]+\] — \d{4}-\d{2}-\d{2}$/.test(heading)) { - throw new Error(`CHANGELOG.md section ${packageJson.version} needs a release date`) -} -const nextSection = changelog.indexOf('\n## [', headingEnd + 1) -const releaseChanges = changelog - .slice(headingEnd + 1, nextSection === -1 ? changelog.length : nextSection) - .trim() +export async function writeReleaseNotes(projectRoot, tag, outputPath) { + const packageJson = JSON.parse(await readFile(join(projectRoot, 'package.json'), 'utf8')) + const changelog = await readFile(join(projectRoot, 'CHANGELOG.md'), 'utf8') + const version = packageJson.version + const expectedTag = `v${version}` -const archive = `elements-${packageJson.version}-chrome.zip` -const checksums = [] -const bytes = await readFile(join(projectRoot, '.output', archive)) -checksums.push(`${createHash('sha256').update(bytes).digest('hex')} ${archive}`) + if (tag !== expectedTag) { + throw new Error(`Release tag ${tag || '(missing)'} does not match ${expectedTag}`) + } + if (!outputPath) throw new Error('Release notes output path is required') -const notes = `${releaseChanges} + const lines = changelog.split(/\r?\n/) + const fencedLines = new Set() + let fence + for (const [index, line] of lines.entries()) { + const marker = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/) + if (fence) { + fencedLines.add(index) + if ( + marker && + marker[1][0] === fence[0] && + marker[1].length >= fence.length && + !marker[2].trim() + ) + fence = undefined + } else if (marker && (marker[1][0] === '~' || !marker[2].includes('`'))) { + fence = marker[1] + fencedLines.add(index) + } + } + const sectionStart = lines.findIndex( + (line, index) => !fencedLines.has(index) && line.startsWith(`## [${version}]`), + ) + if (sectionStart === -1) throw new Error(`CHANGELOG.md has no ${version} section`) + const heading = lines[sectionStart].match(/^## \[([^\]]+)\] (?:—|-) (\d{4}-\d{2}-\d{2})$/) + const date = heading?.[2] + const parsedDate = date && new Date(`${date}T00:00:00Z`) + if ( + !date || + Number.isNaN(parsedDate.getTime()) || + parsedDate.toISOString().slice(0, 10) !== date + ) { + throw new Error(`CHANGELOG.md section ${version} needs a valid release date`) + } -## Downloads + const relativeEnd = lines + .slice(sectionStart + 1) + .findIndex((line, index) => !fencedLines.has(sectionStart + 1 + index) && /^## \[/.test(line)) + const sectionEnd = relativeEnd === -1 ? lines.length : sectionStart + 1 + relativeEnd + const normalizeReference = (label) => label.trim().replace(/\s+/g, ' ').toLowerCase() + const references = new Map() + const orderedDefinitions = [] + // Reference-looking lines inside multiline code spans are literal text. + const codeSpanLines = new Set() + for (let start = 0; start < lines.length; start += 1) { + if (fencedLines.has(start)) continue + let end = start + while (end + 1 < lines.length && !fencedLines.has(end + 1) && lines[end + 1].trim()) end += 1 + const block = lines.slice(start, end + 1).join('\n') + const runs = [...block.matchAll(/`+/g)] + for (let i = 0; i < runs.length; i += 1) { + const close = runs.findIndex((run, index) => index > i && run[0].length === runs[i][0].length) + if (close === -1) continue + const firstLine = start + block.slice(0, runs[i].index).split('\n').length - 1 + const lastLine = start + block.slice(0, runs[close].index).split('\n').length - 1 + for (let line = firstLine + 1; line <= lastLine; line += 1) codeSpanLines.add(line) + i = close + } + start = end + } + for (const [index, line] of lines.entries()) { + if (fencedLines.has(index) || codeSpanLines.has(index)) continue + const definition = line.match( + /^ {0,3}\[([^\]]+)\]:\s*(<[^<>]*>|\S+)(?:[ \t]+("[^"\n]*"|'[^'\n]*'|\([^\n]*\)))?[ \t]*$/, + ) + if (!definition) continue + const [, label, destination, inlineTitle] = definition + const nextLineTitle = + !inlineTitle && lines[index + 1]?.match(/^ {0,3}("[^"\n]*"|'[^'\n]*'|\([^\n]*\))[ \t]*$/) + const reference = normalizeReference(label) + // Markdown resolves duplicate definitions to the first occurrence. + if (!references.has(reference)) { + references.set(reference, { + destination: destination.replace(/^<|>$/g, ''), + }) + } + orderedDefinitions.push(line) + if (nextLineTitle) orderedDefinitions.push(lines[index + 1]) + } + const previousHeading = lines + .slice(sectionEnd) + .find( + (line, index) => !fencedLines.has(sectionEnd + index) && /^## \[(?!Unreleased\])/.test(line), + ) + const previousVersion = previousHeading?.match(/^## \[([^\]]+)\]/)?.[1] + const comparison = previousVersion ? `v${previousVersion}...${tag}` : tag + const changelogUrl = previousVersion + ? `${repositoryUrl}/compare/${comparison}` + : `${repositoryUrl}/releases/tag/${tag}` + if (references.get(normalizeReference(version))?.destination !== changelogUrl) { + throw new Error(`CHANGELOG.md link for ${version} must match ${changelogUrl}`) + } -- \`${archive}\`: Chrome and compatible Chromium browsers. + const sections = new Map() + const upgradeNotes = [] + let current = upgradeNotes + for (let index = sectionStart + 1; index < sectionEnd; index += 1) { + const line = lines[index] + const categoryHeading = !fencedLines.has(index) && line.match(/^### (.+)$/) + if (categoryHeading) { + const category = + categoryHeading[1] === 'Privacy and security' ? 'Security' : categoryHeading[1] + if (!categories.includes(category)) + throw new Error(`Unsupported changelog category: ${category}`) + if (!sections.has(category)) sections.set(category, []) + current = sections.get(category) + } else { + current.push(line) + } + } + const releaseChanges = categories.flatMap((category) => { + const content = sections.get(category)?.join('\n').trim() + return content ? [`## ${category}\n\n${content}`] : [] + }) + const upgradeContent = upgradeNotes.join('\n').trim() + if (upgradeContent) releaseChanges.unshift(`## Upgrade notes\n\n${upgradeContent}`) -The archive is unsigned. Chrome Web Store distribution requires its signing and -review process. + const archive = `elements-${version}-chrome.zip` + const bytes = await readFile(join(projectRoot, '.output', archive)) + const checksum = createHash('sha256').update(bytes).digest('hex') + const opening = version.includes('-') + ? `Elements ${version} is a prerelease distributed as an unsigned Chrome extension archive for testing.` + : `Elements ${version} is distributed as an unsigned Chrome extension archive.` + const notes = [ + opening, + // Keep original definition precedence ahead of categories that may be reordered. + ...(orderedDefinitions.length ? [orderedDefinitions.join('\n')] : []), + ...releaseChanges, + `## Downloads\n\n- [${archive}](${repositoryUrl}/releases/download/${tag}/${archive}): Chrome and compatible Chromium browsers.\n\nThe archive is unsigned. Chrome Web Store distribution requires its signing and\nreview process.\n\nSHA-256:\n\n\`\`\`text\n${checksum} ${archive}\n\`\`\``, + `## Full changelog\n\n[${comparison}](${changelogUrl})`, + ].join('\n\n') -## SHA-256 - -\`\`\`text -${checksums.join('\n')} -\`\`\` - -See the [full changelog](https://github.com/QenTerra/elements/blob/main/CHANGELOG.md). -` + await mkdir(dirname(outputPath), { recursive: true }) + await writeFile(outputPath, `${notes}\n`) +} -await mkdir(dirname(outputPath), { recursive: true }) -await writeFile(outputPath, notes) -console.log(`Wrote release notes for ${tag} to ${outputPath}`) +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + await writeReleaseNotes(join(import.meta.dirname, '..'), process.argv[2], process.argv[3]) + console.log(`Wrote release notes for ${process.argv[2]} to ${process.argv[3]}`) +} diff --git a/scripts/release-notes.test.mjs b/scripts/release-notes.test.mjs new file mode 100644 index 0000000..a6fa5b4 --- /dev/null +++ b/scripts/release-notes.test.mjs @@ -0,0 +1,267 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { execFile } from 'node:child_process' +import { mkdtemp, mkdir, readFile, rm, writeFile, copyFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' +import test from 'node:test' +import { writeReleaseNotes } from './release-notes.mjs' + +const repositoryUrl = 'https://github.com/QenTerra/elements' +const archiveBytes = Buffer.from('synthetic release archive') +const firstRelease = (separator = '-', date = '2026-07-29') => `# Changelog + +## [Unreleased] + +### Added + +- Do not include unreleased changes. + +## [1.0.0] ${separator} ${date} + +### Fixed + +- Preserve saved rules. + +### Added + +- Inspect elements. + +### Privacy and security + +- Keep saved rules local. + +[1.0.0]: ${repositoryUrl}/releases/tag/v1.0.0 +[Unreleased]: ${repositoryUrl}/compare/v1.0.0...HEAD +` + +async function fixture(t, changelog = firstRelease(), version = '1.0.0', archive = true) { + const root = await mkdtemp(join(tmpdir(), 'elements-release-notes-')) + t.after(() => rm(root, { recursive: true, force: true })) + await mkdir(join(root, '.output')) + await writeFile(join(root, 'package.json'), JSON.stringify({ version, type: 'module' })) + await writeFile(join(root, 'CHANGELOG.md'), changelog) + if (archive) + await writeFile(join(root, '.output', `elements-${version}-chrome.zip`), archiveBytes) + const output = join(root, 'notes', 'release.md') + return { root, output } +} + +for (const separator of ['-', '—']) { + test(`generates canonical notes from ${separator} headings with non-visible reference metadata`, async (t) => { + const { root, output } = await fixture(t, firstRelease(separator)) + await writeReleaseNotes(root, 'v1.0.0', output) + const notes = await readFile(output, 'utf8') + assert.match( + notes, + /^Elements 1\.0\.0 is distributed as an unsigned Chrome extension archive\.\n/, + ) + assert.deepEqual(notes.match(/^## .+$/gm), [ + '## Added', + '## Fixed', + '## Security', + '## Downloads', + '## Full changelog', + ]) + assert.match(notes, /The archive is unsigned\./) + assert.ok( + notes.includes( + `${createHash('sha256').update(archiveBytes).digest('hex')} elements-1.0.0-chrome.zip`, + ), + ) + assert.ok(notes.includes(`${repositoryUrl}/releases/download/v1.0.0/elements-1.0.0-chrome.zip`)) + assert.ok(notes.includes(`[v1.0.0](${repositoryUrl}/releases/tag/v1.0.0)\n`)) + assert.doesNotMatch(notes, /Do not include|^### |## SHA-256/gm) + }) +} + +test('preserves upgrade prose, canonical category order and previous-release comparison', async (t) => { + const changelog = `## [1.1.0] - 2026-08-01 + +Restart the browser after upgrading. + +### Security + +- Sanitize stored rules. + +### Removed + +- Remove the old import format. + +### Deprecated + +- Use the new import format. + +### Changed + +- Update the options page; see [details][guide]. + +${firstRelease().replace('## [Unreleased]', '## [older-unreleased-placeholder]')} +[1.1.0]: ${repositoryUrl}/compare/v1.0.0...v1.1.0 +[guide]: ${repositoryUrl}/blob/main/README.md +` + // Keep the fixture's only preceding release boundary at 1.0.0. + const cleanChangelog = changelog.replace(/# Changelog[\s\S]*?(?=## \[1\.0\.0\])/, '') + const { root, output } = await fixture(t, cleanChangelog, '1.1.0') + await writeReleaseNotes(root, 'v1.1.0', output) + const notes = await readFile(output, 'utf8') + assert.deepEqual(notes.match(/^## .+$/gm), [ + '## Upgrade notes', + '## Changed', + '## Deprecated', + '## Removed', + '## Security', + '## Downloads', + '## Full changelog', + ]) + assert.match(notes, /Restart the browser after upgrading\./) + assert.ok(notes.includes('[details][guide]')) + assert.ok(notes.includes(`[guide]: ${repositoryUrl}/blob/main/README.md`)) + assert.ok(notes.includes(`[v1.0.0...v1.1.0](${repositoryUrl}/compare/v1.0.0...v1.1.0)`)) + assert.doesNotMatch(notes, /Preserve saved rules/) +}) + +for (const date of ['2026-02-30', '2026-13-01', '2026-07', 'draft']) { + test(`rejects invalid release date ${date}`, async (t) => { + const { root, output } = await fixture(t, firstRelease('-', date)) + await assert.rejects(writeReleaseNotes(root, 'v1.0.0', output), /valid release date/) + await assert.rejects(readFile(output), { code: 'ENOENT' }) + }) +} + +test('rejects missing version, wrong tag, missing archive and invalid comparison', async (t) => { + for (const [changelog, tag, archive, error] of [ + ['## [Unreleased]\n', 'v1.0.0', true, /has no 1.0.0 section/], + [firstRelease(), 'v1.1.0', true, /does not match/], + [firstRelease(), 'v1.0.0', false, /ENOENT/], + [ + firstRelease().replace('/releases/tag/v1.0.0', '/compare/v0.9.0...v1.0.0'), + 'v1.0.0', + true, + /link for 1.0.0 must match/, + ], + ]) { + const { root, output } = await fixture(t, changelog, '1.0.0', archive) + await assert.rejects(writeReleaseNotes(root, tag, output), error) + await assert.rejects(readFile(output), { code: 'ENOENT' }) + } +}) + +for (const separator of ['-', '—']) { + test(`release verifier accepts ${separator} heading`, async (t) => { + const { root } = await fixture(t, firstRelease(separator)) + await mkdir(join(root, 'scripts')) + await copyFile( + new URL('./verify-release.mjs', import.meta.url), + join(root, 'scripts', 'verify-release.mjs'), + ) + await writeFile(join(root, 'README.md'), 'elements-1.0.0-chrome.zip') + const { stdout } = await promisify(execFile)(process.execPath, [ + join(root, 'scripts', 'verify-release.mjs'), + 'v1.0.0', + ]) + assert.match( + stdout, + /Release v1.0.0 matches package version 1.0.0 and changelog date 2026-07-29/, + ) + }) +} + +for (const title of [ + '', + ' "Migration guide"', + " 'Migration guide'", + ' (Migration guide)', + '\n "Migration guide"', +]) { + test(`preserves collapsed, shortcut and case-insensitive references with title ${JSON.stringify(title)}`, async (t) => { + const changelog = + firstRelease().replace( + '- Inspect elements.', + '- Read [migration][] before upgrading; also see [migration] and [steps][MIGRATION].\n- Keep [migration](https://example.invalid/inline) unchanged.', + ) + `\n[migration]: https://example.invalid/migration${title}\n` + const { root, output } = await fixture(t, changelog) + await writeReleaseNotes(root, 'v1.0.0', output) + const notes = await readFile(output, 'utf8') + assert.ok( + notes.includes( + '- Read [migration][] before upgrading; also see [migration] and [steps][MIGRATION].', + ), + ) + assert.ok(notes.includes(`[migration]: https://example.invalid/migration${title}`)) + assert.ok(notes.includes('- Keep [migration](https://example.invalid/inline) unchanged.')) + }) +} + +test('normalizes reference whitespace and preserves an angle-bracket destination and first definition', async (t) => { + const changelog = + firstRelease().replace('- Inspect elements.', '- See [steps][Migration guide].') + + '\n[Migration guide]: "Upgrade"\n[migration GUIDE]: https://example.invalid/wrong\n' + const { root, output } = await fixture(t, changelog) + await writeReleaseNotes(root, 'v1.0.0', output) + const notes = await readFile(output, 'utf8') + assert.ok(notes.includes('- See [steps][Migration guide].')) + assert.ok(notes.includes('[Migration guide]: "Upgrade"')) + assert.ok(notes.indexOf('[Migration guide]:') < notes.indexOf('[migration GUIDE]:')) +}) + +for (const fence of ['```', '~~~~']) { + test(`preserves inline code spans and ${fence} fenced code verbatim`, async (t) => { + const sample = [ + '- The configuration key is `[migration]`; read [migration].', + '- Literal backticks: `` `[migration]` ``.', + '- A multiline span: `first line', + '[migration]: https://example.invalid/code-span', + 'last line`.', + '', + `${fence}markdown`, + '[migration]: https://example.invalid/fenced', + '## [9.9.9] - 2026-01-01', + '### Fenced heading', + '[migration][] and [steps][MIGRATION]', + fence, + ].join('\n') + const changelog = + firstRelease().replace('- Inspect elements.', sample) + + '\n[migration]: https://example.invalid/migration "Guide"\n' + const { root, output } = await fixture(t, changelog) + await writeReleaseNotes(root, 'v1.0.0', output) + const notes = await readFile(output, 'utf8') + assert.ok(notes.includes(sample)) + assert.ok(notes.includes('[migration]: https://example.invalid/migration "Guide"')) + assert.doesNotMatch(notes, /`\[migration\]\(https:/) + assert.equal(notes.split('https://example.invalid/fenced').length - 1, 1) + }) +} + +for (const placement of ['before-release', 'earlier-category']) { + test(`preserves first reference definition from ${placement}`, async (t) => { + const canonical = '[guide]: https://example.invalid/canonical "Original"' + let changelog = firstRelease().replace( + '- Inspect elements.', + '- Read [guide].\n\n[guide]: https://example.invalid/shadow', + ) + changelog = + placement === 'before-release' + ? `${canonical}\n\n${changelog}` + : changelog.replace('- Preserve saved rules.', `- Preserve saved rules.\n\n${canonical}`) + const { root, output } = await fixture(t, changelog) + await writeReleaseNotes(root, 'v1.0.0', output) + const notes = await readFile(output, 'utf8') + assert.equal(notes.match(/^\[guide\]: .+$/m)?.[0], canonical) + assert.ok(notes.includes('- Read [guide].')) + }) +} + +test('does not promote a multiline code span into reference metadata', async (t) => { + const literal = '- Literal: `first\n[guide]: https://example.invalid/literal\nlast`.' + const changelog = + firstRelease().replace('- Inspect elements.', `${literal}\n\n- Read [guide].`) + + '\n[guide]: https://example.invalid/canonical\n' + const { root, output } = await fixture(t, changelog) + await writeReleaseNotes(root, 'v1.0.0', output) + const notes = await readFile(output, 'utf8') + assert.ok(notes.includes(literal)) + assert.equal(notes.match(/^\[guide\]: .+$/m)?.[0], '[guide]: https://example.invalid/canonical') +}) diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs index a6f3327..17b283e 100644 --- a/scripts/verify-release.mjs +++ b/scripts/verify-release.mjs @@ -18,7 +18,7 @@ if (tag !== expected) throw new Error(`Release tag ${tag} does not match package version ${expected}`) const escapedVersion = packageJson.version.replaceAll('.', '\\.') -const releaseHeader = new RegExp(`^## \\[${escapedVersion}\\] — (\\d{4}-\\d{2}-\\d{2})$`, 'm') +const releaseHeader = new RegExp(`^## \\[${escapedVersion}\\] (?:—|-) (\\d{4}-\\d{2}-\\d{2})$`, 'm') const match = changelog.match(releaseHeader) if (!match) { throw new Error( From 3eb2b1afa6d6fa0d903d88aa51cfaf8361aee139 Mon Sep 17 00:00:00 2001 From: "Nikita Melnychenko (QenTerra)" Date: Wed, 9 Sep 2026 14:05:57 +0200 Subject: [PATCH 2/2] docs(release): align guide filenames with repository rules --- .github/PULL_REQUEST_TEMPLATE.md | 2 +- CONTRIBUTING.md | 2 +- docs/{release-writing.md => RELEASE_WRITING.md} | 0 docs/RELEASING.md | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename docs/{release-writing.md => RELEASE_WRITING.md} (100%) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 6b3bada..394682a 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,4 +1,4 @@ - + ## Purpose diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e210db5..a244d99 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,7 +28,7 @@ workspace. ## Commit and review format -Follow [Release writing](docs/release-writing.md) for commit grammar, PR and review +Follow [Release writing](docs/RELEASE_WRITING.md) for commit grammar, PR and review comment templates, changelog entries, release notes, and publication updates. Write in English, use a specific Conventional Commit subject of at most 72 characters, and keep one coherent outcome per change. Breaking changes include diff --git a/docs/release-writing.md b/docs/RELEASE_WRITING.md similarity index 100% rename from docs/release-writing.md rename to docs/RELEASE_WRITING.md diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 8caf854..c140a6e 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -2,7 +2,7 @@ ## Release text and publication updates -Follow [Release writing](release-writing.md) for the release title and body, +Follow [Release writing](RELEASE_WRITING.md) for the release title and body, changelog categories, contributor attribution, and publication result format. Use the complete version in the GitHub Release title; keep any native display label separate. Prepare and review the body before publication, then read back