diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c65ca9a..8005ad5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,9 +30,13 @@ jobs: env: RELEASE_VERSION: ${{ github.ref_name }} run: npm run check:release -- --version "$RELEASE_VERSION" + - name: Prepare changelog content + env: + RELEASE_VERSION: ${{ github.ref_name }} + run: node scripts/release-notes.mjs "$RELEASE_VERSION" "$RUNNER_TEMP/release-notes.md" - name: Publish release with installation files env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} RELEASE_VERSION: ${{ github.ref_name }} - run: gh release create "$RELEASE_VERSION" main.js manifest.json styles.css --verify-tag --title "$RELEASE_VERSION" --generate-notes + run: gh release create "$RELEASE_VERSION" main.js manifest.json styles.css --verify-tag --title "$RELEASE_VERSION" --notes-file "$RUNNER_TEMP/release-notes.md" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 767b38b..508c667 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,14 +38,16 @@ For editor changes, report the exact Obsidian version, OS, editing mode, and man Use a descriptive branch name such as `feat/selection-toolbar` or `docs/color-behavior`; do not use a `codex` prefix. Use [Conventional Commits](https://www.conventionalcommits.org/) when submitting your own commits, for example `docs: define selection toolbar behavior`. +Changes to `main` must go through a pull request, including changes by maintainers. Do not push directly to `main`. + Pull requests should explain the user-visible problem, resulting behavior, verification, and remaining limits. Update usage documentation when behavior changes. The maintainer controls commits to the main branch, version tags, and marketplace submission; automation must not commit or push without explicit authorization. ## Releases -Update `manifest.json`, `package.json`, and both root version fields in `package-lock.json` to the same version. Add the corresponding minimum Obsidian version to `versions.json`, and update `CHANGELOG.md`. Run `npm run check` before tagging the committed release. +Update `manifest.json`, `package.json`, and both root version fields in `package-lock.json` to the same version. Add the corresponding minimum Obsidian version to `versions.json`. Write the release descriptions directly under a matching `## x.y.z` section in `CHANGELOG.md`, using `###` for subsections. Each version must have one non-empty section. Run `npm run check` before tagging the committed release. Pushing a version tag such as `0.1.0` to GitHub triggers the release workflow. Use the exact `manifest.json` version without a `v` prefix. The tagged commit must contain the workflow and all release sources; creating a local tag alone does not trigger GitHub Actions. -The workflow installs locked dependencies, runs all checks, builds the tagged code, validates version alignment, and publishes a GitHub Release with `main.js`, `manifest.json`, and `styles.css` attached separately. GitHub supplies the additional source ZIP and tar.gz downloads. The workflow does not create commits or push tags. A failed check prevents publication, and existing releases are not overwritten. +The workflow installs locked dependencies, runs all checks, builds the tagged code, validates version alignment, and publishes a GitHub Release with `main.js`, `manifest.json`, and `styles.css` attached separately. Its description contains the matching CHANGELOG section, without an automatically generated compare or commit-history link. GitHub supplies the additional source ZIP and tar.gz downloads. The workflow does not create commits or push tags. A failed check prevents publication, and existing releases are not overwritten. Contributions are licensed under the repository's [MIT license](LICENSE). Do not include material you do not have permission to contribute. diff --git a/scripts/check-release.mjs b/scripts/check-release.mjs index d0490d4..5c31890 100644 --- a/scripts/check-release.mjs +++ b/scripts/check-release.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import { readFile, readdir } from 'node:fs/promises'; import { execFileSync } from 'node:child_process'; import path from 'node:path'; +import { extractReleaseNotes } from './release-notes.mjs'; const readJSON = async file => JSON.parse(await readFile(file, 'utf8')); const manifest = await readJSON('manifest.json'); @@ -16,6 +17,7 @@ assert.equal(versions[manifest.version], manifest.minAppVersion); assert.equal(manifest.id, 'hover-bar'); assert.equal(manifest.isDesktopOnly, true); assert.ok(manifest.description.length <= 250 && manifest.description.endsWith('.')); +extractReleaseNotes(await readFile('CHANGELOG.md', 'utf8'), manifest.version); const requested = process.argv.indexOf('--version'); let tree; if (requested !== -1) { diff --git a/scripts/release-notes.mjs b/scripts/release-notes.mjs new file mode 100644 index 0000000..f2dde58 --- /dev/null +++ b/scripts/release-notes.mjs @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import { readFile, writeFile } from 'node:fs/promises'; +import { pathToFileURL } from 'node:url'; + +export function extractReleaseNotes(changelog, version) { + assert.match(version, /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/, 'Release version must use x.y.z without a prefix'); + const lines = changelog.replace(/\r\n?/g, '\n').split('\n'); + const headings = []; + let fence = null; + for (const [index, line] of lines.entries()) { + const marker = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line); + if (marker) { + if (!fence) fence = marker[1]; + else if (marker[1][0] === fence[0] && marker[1].length >= fence.length && !marker[2].trim()) fence = null; + continue; + } + if (fence) continue; + const heading = /^##[ \t]+(.+?)\s*$/.exec(line); + if (heading) headings.push({ index, title: heading[1] }); + } + const literal = version.replace(/\./g, '\\.'); + const title = new RegExp(`^(?:${literal}|\\[${literal}\\])(?:\\s|$)`); + const matches = headings.filter(heading => title.test(heading.title)); + assert.equal(matches.length, 1, `CHANGELOG.md must contain exactly one section for ${version}`); + const start = matches[0]; + const end = headings.find(heading => heading.index > start.index)?.index ?? lines.length; + const notes = lines.slice(start.index + 1, end).join('\n').trim(); + assert.ok(notes.replace(//g, '').trim(), `Release notes for ${version} must not be empty`); + return `${notes}\n`; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const [version, output] = process.argv.slice(2); + assert.ok(output, 'Usage: node scripts/release-notes.mjs '); + const notes = extractReleaseNotes(await readFile('CHANGELOG.md', 'utf8'), version); + await writeFile(output, notes); +} diff --git a/tests/release-notes.test.ts b/tests/release-notes.test.ts new file mode 100644 index 0000000..24db74f --- /dev/null +++ b/tests/release-notes.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +const script = '../scripts/release-notes.mjs'; +const { extractReleaseNotes } = await import(script); + +describe('release changelog content', () => { + it('uses only the matching version content, including Added and Fixed subsections', () => { + const source = '# Changelog\n\n## Unreleased\n\n- Future work.\n\n## 0.2.0 — 2026-10-25\n\n### Added\n\n- New action.\n\n### Fixed\n\n- Keep selection.\n\n## 0.1.0 — 2026-10-24\n\n- Initial release.\n'; + expect(extractReleaseNotes(source, '0.2.0')).toBe('### Added\n\n- New action.\n\n### Fixed\n\n- Keep selection.\n'); + }); + it('accepts bracketed version headings and Windows line endings', () => { + expect(extractReleaseNotes('## [0.1.0] - 2026-10-24\r\n\r\n- Initial release.\r\n', '0.1.0')).toBe('- Initial release.\n'); + }); + it.each(['0.1.00', '0.1.0-preview', '0.1.1'])('does not mistake %s for the requested version', heading => { + expect(() => extractReleaseNotes(`## ${heading}\n\n- Other release.\n`, '0.1.0')).toThrow('exactly one section'); + }); + it('ignores apparent version headings inside fenced code blocks', () => { + const source = '## 0.1.0\n\n- Example:\n\n```markdown\n## 0.1.0\n## 0.0.9\n```\n\n- Actual change.\n\n## 0.0.9\n\n- Older change.\n'; + expect(extractReleaseNotes(source, '0.1.0')).toBe('- Example:\n\n```markdown\n## 0.1.0\n## 0.0.9\n```\n\n- Actual change.\n'); + }); + it('rejects duplicate matching sections', () => { + expect(() => extractReleaseNotes('## 0.1.0\n\n- First.\n\n## [0.1.0]\n\n- Duplicate.\n', '0.1.0')).toThrow('exactly one section'); + }); + it.each(['', ''])('rejects empty release content: %s', body => { + expect(() => extractReleaseNotes(`## 0.1.0\n\n${body}\n\n## 0.0.9\n\n- Older change.\n`, '0.1.0')).toThrow('must not be empty'); + }); + it('rejects prefixed tag versions', () => { + expect(() => extractReleaseNotes('## 0.1.0\n\n- Initial release.\n', 'v0.1.0')).toThrow('without a prefix'); + }); +}); diff --git a/tests/release.test.ts b/tests/release.test.ts index bc18530..4ed953f 100644 --- a/tests/release.test.ts +++ b/tests/release.test.ts @@ -42,6 +42,7 @@ beforeEach(() => { json('package.json', { version: '0.1.0' }); json('package-lock.json', { version: '0.1.0', packages: { '': { version: '0.1.0' } } }); json('versions.json', { '0.1.0': '1.14.4' }); + fixture.files.set('CHANGELOG.md', '## 0.1.0\n\n- Initial release.\n'); fixture.tree = [...fixture.files.keys()].filter(file => file !== 'main.js'); fixture.head = fixture.tag = '1'.repeat(40); fixture.trackedDocs = ''; @@ -75,6 +76,10 @@ describe('release validation', () => { fixture.files.delete('styles.css'); await expect(check()).rejects.toThrow('Missing file: styles.css'); }); + it('rejects publication without changelog content for the current version', async () => { + fixture.files.set('CHANGELOG.md', '## 0.0.9\n\n- Older release.\n'); + await expect(check()).rejects.toThrow('exactly one section for 0.1.0'); + }); it('rejects local development documents in a release tag', async () => { fixture.tree.push('docs/DEVELOPMENT.md'); await expect(check()).rejects.toThrow('Local documentation must never enter a release tag');