|
| 1 | +// ============================================================================= |
| 2 | +// Self-contained changelog config. |
| 3 | +// |
| 4 | +// WHY THE TEMPLATES ARE INLINED HERE: |
| 5 | +// @semantic-release/release-notes-generator renders with the Handlebars-based conventional-changelog-writer. |
| 6 | +// conventional-changelog-conventionalcommits >= 9 dropped its Handlebars writerOpts in favour of @conventional-changelog/ template, which release-notes-generator does NOT use — so with preset >= 9 the section grouping silently disappears (flat list, no "### Features"). |
| 7 | +// To stay compatible with ANY preset version (8, 9, 10, …) we supply the full Handlebars writerOpts (mainTemplate / headerPartial / commitPartial) and the grouping ourselves; the preset is then only used for its commit PARSER. |
| 8 | +// ============================================================================= |
| 9 | + |
| 10 | +const SECTIONS = [ |
| 11 | + { type: 'feat', section: 'Features' }, |
| 12 | + { type: 'fix', section: 'Bug Fixes' }, |
| 13 | + { type: 'refactor', section: 'Code Refactoring' }, |
| 14 | + { type: 'style', section: 'Styles' }, |
| 15 | + { type: 'chore', section: 'Others' }, |
| 16 | + { type: 'docs', section: 'Documentation' }, |
| 17 | + { type: 'perf', section: 'Performance' }, |
| 18 | + { type: 'test', section: 'Tests' }, |
| 19 | + { type: 'ci', section: 'CI/CD' } |
| 20 | +]; |
| 21 | +const TYPE_TO_SECTION = Object.fromEntries(SECTIONS.map(s => [s.type, s.section])); |
| 22 | +const SECTION_ORDER = SECTIONS.map(s => s.section); |
| 23 | +const isSignoff = (line) => /^\s*Signed-off-by:/i.test(line); |
| 24 | +const stripSignoff = (text) => (text || '').split('\n').filter(l => !isSignoff(l)).join('\n').trim(); |
| 25 | + |
| 26 | +// ── Handlebars templates (writer-8 compatible; vendored from the conventional-commits preset so they work regardless of the installed preset major) ── |
| 27 | +const mainTemplate = `{{> header}} |
| 28 | +{{#if noteGroups}} |
| 29 | +{{#each noteGroups}} |
| 30 | +
|
| 31 | +### ⚠ {{title}} |
| 32 | +
|
| 33 | +{{#each notes}} |
| 34 | +* {{#if commit.scope}}**{{commit.scope}}:** {{/if}}{{text}} |
| 35 | +{{/each}} |
| 36 | +{{/each}} |
| 37 | +{{/if}} |
| 38 | +{{#each commitGroups}} |
| 39 | +
|
| 40 | +{{#if title}} |
| 41 | +### {{title}} |
| 42 | +
|
| 43 | +{{/if}} |
| 44 | +{{#each commits}} |
| 45 | +{{> commit root=@root}} |
| 46 | +{{/each}} |
| 47 | +{{/each}} |
| 48 | +`; |
| 49 | + |
| 50 | +const headerPartial = `## {{#if @root.linkCompare~}} |
| 51 | + [{{version}}]({{~@root.host}}/{{#if this.owner}}{{~this.owner}}{{else}}{{~@root.owner}}{{/if}}/{{#if this.repository}}{{~this.repository}}{{else}}{{~@root.repository}}{{/if}}/compare/{{previousTag}}...{{currentTag}}) |
| 52 | +{{~else}} |
| 53 | + {{~version}} |
| 54 | +{{~/if}} |
| 55 | +{{~#if date}} ({{date}}) |
| 56 | +{{/if}} |
| 57 | +`; |
| 58 | + |
| 59 | +// commit line: bold each comma-split scope as **scope:** (built as scopeFmt in transform) and keep the subject. PR commits carry an inline "(#N)" that GitHub auto-links, so leave them link-free; only direct pushes (no "(#N)") get an appended ([shortHash](…/commit/<hash>)) link. body/footer follow (built in transform) |
| 60 | +const commitPartial = |
| 61 | + '*{{#if scopeFmt}} {{scopeFmt}}:{{/if}} {{#if subject}}{{subject}}{{else}}{{header}}{{/if}}' + |
| 62 | + '{{#unless hasIssueRef}}{{#if @root.linkReferences}} ([{{shortHash}}]({{@root.host}}/{{@root.owner}}/{{@root.repository}}/commit/{{hash}})){{/if}}{{/unless}}' + |
| 63 | + '\n{{#if body}}\n{{body}}\n{{/if}}\n{{#if footer}}\n\n{{footer}}\n{{/if}}\n'; |
| 64 | + |
| 65 | +// ── dynamic changelog title ── |
| 66 | +// reuse an existing level-1 header (`# ...`) at the very top of CHANGELOG.md so new releases are inserted BELOW it; if there is none, leave changelogTitle unset so semantic-release just prepends (no title is forced onto title-less changelogs) |
| 67 | +const fs = require('fs'); |
| 68 | +const path = require('path'); |
| 69 | +const CHANGELOG_FILE = 'CHANGELOG.md'; |
| 70 | +function detectChangelogTitle(file) { |
| 71 | + try { |
| 72 | + const text = fs.readFileSync(path.resolve(process.cwd(), file), 'utf8'); |
| 73 | + const first = text.split('\n').find(l => l.trim() !== ''); |
| 74 | + // a single '#' ATX header (rejects '##', '###', …) with actual text |
| 75 | + if (first && /^#\s+\S/.test(first)) { return first.trim(); } |
| 76 | + } catch (e) { /* missing or unreadable → treat as no title */ } |
| 77 | + return null; |
| 78 | +} |
| 79 | +const CHANGELOG_TITLE = detectChangelogTitle(CHANGELOG_FILE); |
| 80 | + |
| 81 | +module.exports = { |
| 82 | + "branches": ["main"], |
| 83 | + "tagFormat": "v${version}", |
| 84 | + "plugins": [ |
| 85 | + ["@semantic-release/commit-analyzer", { |
| 86 | + "preset": "conventionalcommits", |
| 87 | + "releaseRules": [ |
| 88 | + // { "breaking": true, "release": "minor" }, |
| 89 | + { "breaking": true, "release": "major" }, |
| 90 | + // { "type": "feat", "release": "patch" }, |
| 91 | + { "type": "chore", "release": "patch" }, |
| 92 | + { "type": "refactor", "release": "patch" }, |
| 93 | + { "type": "style", "release": "patch" }, |
| 94 | + { "type": "docs", "release": "patch" }, |
| 95 | + { "type": "ci", "release": "patch" } |
| 96 | + ] |
| 97 | + }], |
| 98 | + ["@semantic-release/release-notes-generator", { |
| 99 | + "preset": "conventionalcommits", |
| 100 | + "presetConfig": { "types": SECTIONS }, |
| 101 | + "writerOpts": { |
| 102 | + "groupBy": "type", |
| 103 | + // order sections as listed in SECTIONS (not alphabetically); unknown types go last |
| 104 | + "commitGroupsSort": (a, b) => { |
| 105 | + const rank = (t) => { const i = SECTION_ORDER.indexOf(t); return i === -1 ? SECTION_ORDER.length : i; }; |
| 106 | + return rank(a.title) - rank(b.title); |
| 107 | + }, |
| 108 | + "commitsSort": ["header", "subject"], |
| 109 | + "noteGroupsSort": "title", |
| 110 | + "mainTemplate": mainTemplate, |
| 111 | + "headerPartial": headerPartial, |
| 112 | + "commitPartial": commitPartial, |
| 113 | + "footerPartial": "", |
| 114 | + "transform": (commit) => { |
| 115 | + const c = { ...commit }; |
| 116 | + |
| 117 | + // type -> section label (drives the "### <section>" grouping) |
| 118 | + if (TYPE_TO_SECTION[c.type]) { |
| 119 | + c.type = TYPE_TO_SECTION[c.type]; |
| 120 | + } |
| 121 | + |
| 122 | + // preset's default transform (overridden here) normally sets shortHash; restore it so the commit link text isn't empty ("[](…/commit/<hash>)") |
| 123 | + if (typeof c.hash === 'string') { |
| 124 | + c.shortHash = c.hash.substring(0, 7); |
| 125 | + } |
| 126 | + |
| 127 | + // PR commits carry an inline "(#N)" that GitHub auto-links; keep it verbatim and skip the sha link. direct pushes (no "(#N)") get the commit-sha link (see commitPartial) |
| 128 | + c.hasIssueRef = /\(#\d+\)/.test(c.subject || ''); |
| 129 | + |
| 130 | + // comma-split the scope and bold each part -> "**a**, **b**" (rendered via scopeFmt) |
| 131 | + if (c.scope) { |
| 132 | + c.scopeFmt = c.scope.split(',').map(s => '**' + s.trim() + '**').join(', '); |
| 133 | + } |
| 134 | + |
| 135 | + // the parser may split trailing body lines into `footer` (e.g. a bullet containing an issue-like "#N"); fold body + footer back together and drop Signed-off-by |
| 136 | + const detail = [c.body, c.footer] |
| 137 | + .filter(Boolean) |
| 138 | + .join('\n') |
| 139 | + .split('\n') |
| 140 | + .filter(l => !isSignoff(l)); |
| 141 | + if (detail.some(l => l.trim() !== '')) { |
| 142 | + // bullet body (`- ...`) -> 2-space sub-list right under the subject; free-form body (no bullets) -> blank line + 4-space verbatim block |
| 143 | + const hasBullets = detail.some(l => /^\s*-\s+\S/.test(l)); |
| 144 | + const indent = hasBullets ? ' ' : ' '; |
| 145 | + const block = detail.map(l => l.trim() === '' ? '' : indent + l).join('\n'); |
| 146 | + c.body = hasBullets ? block : '\n' + block; |
| 147 | + } else { |
| 148 | + c.body = null; |
| 149 | + } |
| 150 | + c.footer = null; |
| 151 | + |
| 152 | + // drop Signed-off-by trailers from notes |
| 153 | + if (Array.isArray(c.notes)) { |
| 154 | + c.notes = c.notes |
| 155 | + .map(n => ({ ...n, text: stripSignoff(n.text) })) |
| 156 | + .filter(n => n.text); |
| 157 | + } |
| 158 | + |
| 159 | + return c; |
| 160 | + } |
| 161 | + } |
| 162 | + }], |
| 163 | + ["@semantic-release/changelog", Object.assign( |
| 164 | + { "changelogFile": CHANGELOG_FILE }, |
| 165 | + CHANGELOG_TITLE ? { "changelogTitle": CHANGELOG_TITLE } : {} |
| 166 | + )], |
| 167 | + ["@semantic-release/exec", { |
| 168 | + "prepareCmd": "pre-commit run --files CHANGELOG.md || true" |
| 169 | + }], |
| 170 | + ["@semantic-release/git", { |
| 171 | + "assets": ["CHANGELOG.md"], |
| 172 | + "message": "chore(release): v${nextRelease.version}" |
| 173 | + }], |
| 174 | + ["@semantic-release/github", { |
| 175 | + // the two font zips are built by the release workflow before semantic-release runs |
| 176 | + "assets": [ |
| 177 | + { "path": "Lekton-Optimized.zip", "label": "Lekton-Optimized — desktop faces (dotted 0, big bullet, + Bold Italic)" }, |
| 178 | + { "path": "Lekton-NerdFont.zip", "label": "Lekton-NerdFont — Nerd Font Mono faces (otf + ttf)" } |
| 179 | + ] |
| 180 | + }] |
| 181 | + ] |
| 182 | +}; |
0 commit comments