@existdb/repo-xml-changelog-generator 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -42,7 +42,12 @@ Add to your `.releaserc` plugin chain. Place it after the commit-analyzer / rele
42
42
  }
43
43
  ```
44
44
 
45
- **Do not** include `@semantic-release/git` in the plugin chain. The mutation this plugin performs is in-memory on the CI runner; the on-disk `repo.xml.tmpl` in your default branch stays at its development-placeholder state, and the per-release changelog history lives in git tags + GitHub Releases. See [Design rationale](#design-rationale) below.
45
+ The plugin works with or without `@semantic-release/git` — the choice decides where your changelog history lives:
46
+
47
+ - **Without it** (in-memory mode), the mutation happens only on the CI runner; the on-disk `repo.xml.tmpl` in your default branch stays at its development-placeholder state, and the per-release changelog history lives in git tags + GitHub Releases. Note that each built XAR then contains only the entries curated in the template plus the *current* release's generated entry — the entries generated for previous releases are not carried forward.
48
+ - **With it** (commit-back mode), add `@semantic-release/git` *after* this plugin with `repo.xml.tmpl` (and typically `package.json` + `package-lock.json`) as assets. Each release then commits the new `<change>` entry back to the default branch, so the changelog accumulates and every released XAR carries the full history. This is the model `eXist-db/monex` adopted by community vote ([monex#453](https://github.com/eXist-db/monex/pull/453)); it requires branch protection to allow the GitHub Actions app to push.
49
+
50
+ See [Design rationale](#design-rationale) below.
46
51
 
47
52
  ## Configuration
48
53
 
@@ -76,6 +81,8 @@ For each conventional commit since `context.lastRelease.gitTag`:
76
81
  - `Revert` (for `revert:`)
77
82
  - or the literal type for other categories.
78
83
  - Render breaking changes as `Breaking change: <text>` from `BREAKING CHANGE:` footers.
84
+ - Render issue/PR references (e.g. `#398`) as XHTML `<a>` links, with URLs derived from semantic-release's `repositoryUrl`; on non-GitHub remotes they are kept as plain text.
85
+ - Hard-wrapped commit subjects are folded into a single line, and hard-wrapped `BREAKING CHANGE:` footers are collapsed to single-line items.
79
86
 
80
87
  The rendered items are wrapped in a `<change version="X.Y.Z"><ul xmlns="http://www.w3.org/1999/xhtml">…</ul></change>` and inserted at the **top** of the existing `<changelog>` element (most recent first).
81
88
 
@@ -95,9 +102,11 @@ The rendered items are wrapped in a `<change version="X.Y.Z"><ul xmlns="http://w
95
102
 
96
103
  The default approach for many semantic-release setups is to use `@semantic-release/git` to commit the mutated files (`package.json`, `CHANGELOG.md`, etc.) back to the default branch. For eXist-db org repos with branch protection that requires PRs, this means the GitHub Actions token can't push the release commit — you need a PAT or a GitHub App to bypass protection. That's operational overhead (PAT rotation, App installation per repo, secret management).
97
104
 
98
- The alternative — used by `@existdb/xst`, `@existdb/node-exist`, `@existdb/gulp-exist`, and (since 2026-05-19) `eXist-db/monex` and `eXist-db/function-documentation` — is to **pin the default-branch version to `0.0.0-development` permanently**, do all the version + changelog work in-memory on the CI runner, and let the built XAR carry the real version. No push-back, no token plumbing, branch protection works as intended.
105
+ The alternative — used by `@existdb/xst`, `@existdb/node-exist`, `@existdb/gulp-exist`, and `eXist-db/function-documentation` — is to **pin the default-branch version to `0.0.0-development` permanently**, do all the version + changelog work in-memory on the CI runner, and let the built XAR carry the real version. No push-back, no token plumbing, branch protection works as intended.
106
+
107
+ The trade-off: for npm packages the version history lives in the npm registry anyway, but for XAR packages the `<changelog>` in the shipped descriptor is itself the history — and in-memory mode drops each generated entry after its release. `eXist-db/monex` used in-memory mode from 2026-05-19 until a community vote switched it to commit-back ([monex#453](https://github.com/eXist-db/monex/pull/453)) after its 5.1.0 XAR shipped without the generated 5.0.0 entry. Branch protection there allows the GitHub Actions app to push, so no PAT or custom App is needed.
99
108
 
100
- This plugin is designed for that workflow. (It would also work alongside `@semantic-release/git`, but the apps in the org don't need that path.)
109
+ This plugin supports both workflows; pick per repository.
101
110
 
102
111
  ### Why not just publish the existing script as-is?
103
112
 
@@ -114,7 +123,7 @@ semantic-release itself is a Node tool and runs in Node. The plugin API is Node-
114
123
 
115
124
  ## Releasing this package
116
125
 
117
- This plugin releases itself with semantic-release, eating its own dog food. Merges to `main` run the [CI workflow](.github/workflows/release.yml): the test matrix runs, then `semantic-release` analyzes the Conventional Commits, computes the next version, publishes to npm (`@existdb` scope), and creates the GitHub Release. The in-tree `version` stays pinned at `0.0.0-development`; the real version is set in-memory at release time (the same no-push pattern this plugin advocates for adopters).
126
+ This plugin releases itself with semantic-release, eating its own dog food. Merges to `main` run the [CI workflow](.github/workflows/release.yml): the test matrix runs, then `semantic-release` analyzes the Conventional Commits, computes the next version, publishes to npm (`@existdb` scope), and creates the GitHub Release. The in-tree `version` stays pinned at `0.0.0-development`; the real version is set in-memory at release time (the in-memory workflow described above — a natural fit here, since an npm package's version history lives in the registry).
118
127
 
119
128
  Commit messages are linted against [Conventional Commits](https://www.conventionalcommits.org/) via commitlint — locally through a husky `commit-msg` hook, and in CI on every pull request.
120
129
 
package/index.js CHANGED
@@ -16,10 +16,11 @@
16
16
  * Reads conventional commits since `context.lastRelease.gitTag`, builds
17
17
  * a list of human-readable change items, and inserts a new `<change
18
18
  * version="X.Y.Z">` element with those items at the top of `<changelog>`.
19
- * Writes the descriptor back to disk. The mutation is in-memory on the
20
- * CI runner — pair with @semantic-release/github (REST-based release
21
- * creation) and DO NOT use @semantic-release/git, so the on-disk file
22
- * in master stays at its dev-placeholder state. See README for details.
19
+ * Writes the descriptor back to disk. Whether that mutation stays
20
+ * in-memory on the CI runner or is committed back to the default branch
21
+ * (so the changelog accumulates across releases) depends on whether
22
+ * @semantic-release/git is in the plugin chain — both workflows are
23
+ * supported. See README for the trade-offs.
23
24
  *
24
25
  * Config options (all optional):
25
26
  *
@@ -82,7 +83,8 @@ export async function prepare (pluginConfig, context) {
82
83
  const items = await buildChangeItems({
83
84
  prevTag: lastRelease.gitTag,
84
85
  version: nextRelease.version,
85
- cwd: context.cwd
86
+ cwd: context.cwd,
87
+ repositoryUrl: context.options?.repositoryUrl
86
88
  })
87
89
 
88
90
  if (items.length === 0) {
package/lib/changelog.js CHANGED
@@ -16,6 +16,7 @@ import conventionalChangelogConventionalCommits from 'conventional-changelog-con
16
16
 
17
17
  const REPO_NS = 'http://exist-db.org/xquery/repo'
18
18
  const HTML_NS = 'http://www.w3.org/1999/xhtml'
19
+ const MD_LINK = /\[([^\]]+)\]\(([^)\s]+)\)/g
19
20
 
20
21
  const SECTION_PREFIX = {
21
22
  Features: 'New',
@@ -24,17 +25,40 @@ const SECTION_PREFIX = {
24
25
  Reverts: 'Revert'
25
26
  }
26
27
 
27
- const XML_MAIN_TEMPLATE =
28
- '{{#each noteGroups}}' +
29
- '{{#each notes}}' +
30
- '<li>Breaking change: {{text}}</li>\n' +
31
- '{{/each}}' +
32
- '{{/each}}' +
33
- '{{#each commitGroups}}' +
34
- '{{#each commits}}' +
35
- '{{#unless isBreaking}}<li>{{prefix}}: {{#if scope}}{{scope}}: {{/if}}{{subject}}</li>\n{{/unless}}' +
36
- '{{/each}}' +
37
- '{{/each}}'
28
+ function escapeXml (text) {
29
+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
30
+ }
31
+
32
+ // Replaces the writer's default markdown template (conventional-changelog-writer
33
+ // v9+ takes a template function, no longer a handlebars string).
34
+ function xmlTemplate (context) {
35
+ const lines = []
36
+ for (const group of context.noteGroups ?? []) {
37
+ for (const note of group.notes) {
38
+ lines.push(`<li>Breaking change: ${escapeXml(note.text)}</li>`)
39
+ }
40
+ }
41
+ for (const group of context.commitGroups ?? []) {
42
+ for (const commit of group.commits) {
43
+ if (commit.isBreaking) continue
44
+ const scope = commit.scope ? `${commit.scope}: ` : ''
45
+ lines.push(`<li>${escapeXml(`${commit.prefix}: ${scope}${commit.subject}`)}</li>`)
46
+ }
47
+ }
48
+ return lines.join('\n')
49
+ }
50
+
51
+ /**
52
+ * Derive the {host, owner, repository} link context from a repository URL
53
+ * (semantic-release's `context.options.repositoryUrl`). Returns null when
54
+ * the URL is not a recognizable GitHub remote; issue references are then
55
+ * left as plain text instead of rendered with broken URLs.
56
+ */
57
+ export function githubContext (repositoryUrl) {
58
+ const m = (repositoryUrl ?? '').match(/github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?$/)
59
+ if (!m) return null
60
+ return { host: 'https://github.com', owner: m[1], repository: m[2] }
61
+ }
38
62
 
39
63
  function tagExists (tag, cwd) {
40
64
  try {
@@ -54,12 +78,14 @@ function getRawCommits ({ prevTag, cwd }) {
54
78
  const hashes = execSync(`git log ${ref}..HEAD --format=%H`, { encoding: 'utf8', cwd })
55
79
  .trim().split('\n').filter(Boolean)
56
80
 
81
+ // %s folds a hard-wrapped subject paragraph into a single line; %B would
82
+ // keep the line break and the parser would truncate the subject there.
57
83
  return hashes.map(hash =>
58
- execSync(`git log -1 --format=%B ${hash}`, { encoding: 'utf8', cwd }).trim()
84
+ execSync(`git log -1 --format=%s%n%n%b ${hash}`, { encoding: 'utf8', cwd }).trim()
59
85
  )
60
86
  }
61
87
 
62
- export async function buildChangeItems ({ prevTag, version, cwd }) {
88
+ export async function buildChangeItems ({ prevTag, version, cwd, repositoryUrl }) {
63
89
  const rawCommits = getRawCommits({ prevTag, cwd })
64
90
  if (rawCommits.length === 0) return []
65
91
 
@@ -72,10 +98,7 @@ export async function buildChangeItems ({ prevTag, version, cwd }) {
72
98
  const presetTransform = writerOpts.transform
73
99
  const writerOptions = {
74
100
  ...writerOpts,
75
- mainTemplate: XML_MAIN_TEMPLATE,
76
- headerPartial: '',
77
- commitPartial: '',
78
- footerPartial: '',
101
+ template: xmlTemplate,
79
102
  transform (commit, context) {
80
103
  const transformed = presetTransform(commit, context)
81
104
  if (!transformed) return null
@@ -88,7 +111,8 @@ export async function buildChangeItems ({ prevTag, version, cwd }) {
88
111
  }
89
112
  }
90
113
 
91
- const output = await writeChangelogString(parsed, { version }, writerOptions)
114
+ const linkContext = githubContext(repositoryUrl)
115
+ const output = await writeChangelogString(parsed, { version, ...linkContext }, writerOptions)
92
116
 
93
117
  const doc = new DOMParser().parseFromString(
94
118
  `<ul xmlns="${HTML_NS}">${output}</ul>`,
@@ -97,12 +121,37 @@ export async function buildChangeItems ({ prevTag, version, cwd }) {
97
121
  const liNodes = doc.getElementsByTagNameNS(HTML_NS, 'li')
98
122
  const items = []
99
123
  for (let i = 0; i < liNodes.length; i++) {
100
- const text = liNodes.item(i).textContent
124
+ // Collapse line breaks left over from hard-wrapped BREAKING CHANGE footers.
125
+ let text = liNodes.item(i).textContent.replace(/\s+/g, ' ').trim()
126
+ if (!linkContext) {
127
+ // Without a link context the preset renders references as markdown
128
+ // links with `undefined` URLs — reduce them to their label text.
129
+ text = text.replace(MD_LINK, '$1')
130
+ }
101
131
  if (text) items.push(text)
102
132
  }
103
133
  return items
104
134
  }
105
135
 
136
+ // Item text may contain markdown links produced by the conventional-changelog
137
+ // preset (issue refs, mentions); render them as XHTML anchors.
138
+ function appendItemContent (doc, li, text) {
139
+ let last = 0
140
+ for (const match of text.matchAll(MD_LINK)) {
141
+ if (match.index > last) {
142
+ li.appendChild(doc.createTextNode(text.slice(last, match.index)))
143
+ }
144
+ const a = doc.createElementNS(HTML_NS, 'a')
145
+ a.setAttribute('href', match[2])
146
+ a.appendChild(doc.createTextNode(match[1]))
147
+ li.appendChild(a)
148
+ last = match.index + match[0].length
149
+ }
150
+ if (last < text.length) {
151
+ li.appendChild(doc.createTextNode(text.slice(last)))
152
+ }
153
+ }
154
+
106
155
  export function insertChangeEntry ({ tmplPath, version, items }) {
107
156
  const tmpl = readFileSync(tmplPath, 'utf8')
108
157
  const doc = new DOMParser().parseFromString(tmpl, 'text/xml')
@@ -118,7 +167,7 @@ export function insertChangeEntry ({ tmplPath, version, items }) {
118
167
  for (const item of items) {
119
168
  ul.appendChild(doc.createTextNode('\n '))
120
169
  const li = doc.createElementNS(HTML_NS, 'li')
121
- li.textContent = item
170
+ appendItemContent(doc, li, item)
122
171
  ul.appendChild(li)
123
172
  }
124
173
  ul.appendChild(doc.createTextNode('\n '))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@existdb/repo-xml-changelog-generator",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "semantic-release plugin: inject conventional-commit changelog entries into EXPath package repo.xml descriptors (eXist-db / BaseX / MarkLogic XAR packages)",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -46,8 +46,8 @@
46
46
  },
47
47
  "dependencies": {
48
48
  "@xmldom/xmldom": "^0.9.12",
49
- "conventional-changelog-conventionalcommits": "^9.0.0",
50
- "conventional-changelog-writer": "^8.0.0",
49
+ "conventional-changelog-conventionalcommits": "^9.3.1",
50
+ "conventional-changelog-writer": "^9.0.0",
51
51
  "conventional-commits-parser": "^6.0.0"
52
52
  },
53
53
  "devDependencies": {