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
6 changes: 5 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
6 changes: 4 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions scripts/check-release.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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) {
Expand Down
37 changes: 37 additions & 0 deletions scripts/release-notes.mjs
Original file line number Diff line number Diff line change
@@ -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(/<!--[\s\S]*?-->/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 <version> <output-file>');
const notes = extractReleaseNotes(await readFile('CHANGELOG.md', 'utf8'), version);
await writeFile(output, notes);
}
30 changes: 30 additions & 0 deletions tests/release-notes.test.ts
Original file line number Diff line number Diff line change
@@ -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(['', '<!-- Add release descriptions here. -->'])('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');
});
});
5 changes: 5 additions & 0 deletions tests/release.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '';
Expand Down Expand Up @@ -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');
Expand Down
Loading