Skip to content

Commit 7e23f72

Browse files
committed
ci: verify changelog before release, get release notes from it
1 parent de87c10 commit 7e23f72

5 files changed

Lines changed: 208 additions & 20 deletions

File tree

.github/workflows/ci.yml

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@ on:
1313
default: false
1414

1515
outputs:
16-
version:
17-
description: Meson project version
18-
value: ${{ jobs.bundle.outputs.version }}
16+
release-tag:
17+
description: Tag name if a new release should be published (otherwise empty)
18+
value: ${{ fromJSON(jobs.lint.outputs.release) && jobs.lint.outputs.tag || '' }}
1919

2020
defaults:
2121
run:
@@ -106,6 +106,11 @@ jobs:
106106
runs-on: ubuntu-24.04
107107
timeout-minutes: 5
108108

109+
outputs:
110+
version: ${{ steps.version.outputs.version }}
111+
tag: ${{ steps.version.outputs.tag }}
112+
release: ${{ steps.release-notes.outcome == 'success' }}
113+
109114
steps:
110115
- name: Checkout
111116
id: checkout
@@ -172,13 +177,48 @@ jobs:
172177
uses: ./.github/actions/actionlint
173178
if: ${{ !cancelled() && steps.checkout.outcome == 'success' }}
174179

180+
- name: Get current version
181+
id: version
182+
run: |
183+
version="$(jq -r .version package.json)"
184+
tag="v${version}"
185+
echo "version=$version" | tee -a "$GITHUB_OUTPUT"
186+
echo "tag=$tag" | tee -a "$GITHUB_OUTPUT"
187+
188+
- name: List existing releases
189+
id: releases
190+
run: |
191+
existing="$(gh release list --json tagName | jq -c '[.[].tagName]')"
192+
echo "existing=$existing" | tee -a "$GITHUB_OUTPUT"
193+
env:
194+
GH_TOKEN: ${{ github.token }}
195+
GH_REPO: ${{ github.repository }}
196+
197+
- name: Verify changelog
198+
run: >-
199+
npm run-script changelog --
200+
${{ contains(fromJSON(steps.releases.outputs.existing), steps.version.outputs.tag) && '-u' || '-d "$(date +%F)"' }}
201+
-v "$VERSION"
202+
-p "$VERSION"
203+
-o "$RUNNER_TEMP/release-notes.md"
204+
env:
205+
VERSION: ${{ steps.version.outputs.version }}
206+
207+
- name: Upload release notes
208+
id: release-notes
209+
if: ${{ !contains(fromJSON(steps.releases.outputs.existing), steps.version.outputs.tag) }}
210+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
211+
with:
212+
path: "${{ runner.temp }}/release-notes.md"
213+
if-no-files-found: error
214+
archive: false
215+
175216
bundle:
176217
runs-on: ubuntu-24.04
177218
timeout-minutes: 5
178219

179220
outputs:
180221
build-inputs: ${{ steps.ninja-inputs.outputs.inputs }}
181-
version: ${{ steps.version.outputs.version }}
182222

183223
steps:
184224
- name: Checkout
@@ -196,13 +236,6 @@ jobs:
196236
run: meson setup '-Dshebang_override=/usr/bin/env gjs' '-Dtests=disabled' build
197237
shell: pipetty bash -e {0}
198238

199-
- name: Get project version
200-
id: version
201-
working-directory: build
202-
run: |
203-
version="$(jq -r '.version' meson-info/intro-projectinfo.json)"
204-
echo "version=$version" | tee -a "$GITHUB_OUTPUT"
205-
206239
- name: Build extension bundle
207240
id: pack
208241
run: ninja -j1 bundle

.github/workflows/master.yml

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ jobs:
6767

6868
release:
6969
needs: ci
70-
if: ${{ !cancelled() }}
70+
if: ${{ !cancelled() && needs.ci.outputs.release-tag }}
7171
runs-on: ubuntu-slim
7272
timeout-minutes: 5
7373

@@ -77,18 +77,16 @@ jobs:
7777
env:
7878
GH_TOKEN: ${{ github.token }}
7979
GH_REPO: ${{ github.repository }}
80-
TAG: v${{ needs.ci.outputs.version }}
80+
TAG: ${{ needs.ci.outputs.release-tag }}
8181

8282
steps:
8383
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
8484
with:
8585
pattern: '*.shell-extension.zip'
8686
skip-decompress: true
8787

88-
- id: existing
89-
run: |
90-
existing="$(gh release list --json tagName | jq -c '[.[].tagName]')"
91-
echo "existing=$existing" | tee -a "$GITHUB_OUTPUT"
88+
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
89+
with:
90+
name: 'release-notes.md'
9291

93-
- run: gh release create "$TAG" --draft --generate-notes --target "$GITHUB_SHA" ./*.shell-extension.zip
94-
if: ${{ !contains(fromJSON(steps.existing.outputs.existing), env.TAG) }}
92+
- run: gh release create "$TAG" --draft --notes-file release-notes.md --target "$GITHUB_SHA" ./*.shell-extension.zip

package-lock.json

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@
1616
"devDependencies": {
1717
"@eslint/compat": "2.0.5",
1818
"@eslint/js": "9.39.4",
19+
"argparse": "2.0.1",
1920
"eslint": "9.39.4",
2021
"eslint-config-gnome": "git+https://gitlab.gnome.org/World/javascript/eslint-config-gnome.git#c479d059e8d9ea99c3b53c2ea43abf7fdb05eb51",
2122
"eslint-plugin-import": "2.32.0",
23+
"markdown-it": "14.1.1",
2224
"markdownlint-cli2": "0.22.0",
2325
"markdownlint-rule-relative-links": "5.1.0",
2426
"npm-run-all2": "8.0.4"
@@ -34,7 +36,8 @@
3436
"test": "tox -e meson-test",
3537
"version:meson": "tox -e meson-rewrite -- kwargs set project / version \"${npm_package_version//-/}\" && tox -e meson-format -- -i meson.build",
3638
"version:pkgbuild": "tox -e setconf -- pkgver \"${npm_package_version//-/}\"",
37-
"version": "run-s version:*"
39+
"version": "run-s version:*",
40+
"changelog": "tools/changelog.mjs"
3841
},
3942
"version": "62.0.2"
4043
}

tools/changelog.mjs

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
#!/usr/bin/env node
2+
3+
// SPDX-FileCopyrightText: 2026 Aleksandr Mezin <mezin.alexander@gmail.com>
4+
//
5+
// SPDX-License-Identifier: CC0-1.0
6+
7+
import { readFileSync, writeFileSync } from 'node:fs';
8+
9+
import { ArgumentParser } from 'argparse';
10+
import MarkdownIt from 'markdown-it';
11+
12+
class ChangelogError extends Error {
13+
constructor(message, line = 0, details = {}) {
14+
super(message);
15+
16+
this.name = 'ChangelogError';
17+
this.line = line;
18+
this.details = details;
19+
}
20+
}
21+
22+
function plainText(token) {
23+
if (token.children)
24+
return token.children.map(child => plainText(child)).join('');
25+
26+
return token.content ?? '';
27+
}
28+
29+
function* parse(changelog) {
30+
const lines = changelog.split('\n');
31+
let tokens = new MarkdownIt().parse(changelog, {});
32+
let index =
33+
tokens.findIndex(token => token.tag === 'h2' && token.type === 'heading_open');
34+
35+
while (index >= 0) {
36+
const openToken = tokens[index++];
37+
const inlineContainerToken = tokens[index++];
38+
const closeToken = tokens[index++];
39+
40+
console.assert(closeToken.type === 'heading_close');
41+
console.assert(closeToken.tag === openToken.tag);
42+
43+
const header = plainText(inlineContainerToken);
44+
const [, version, date] = /^\s*(\S+)\s*-?\s*(\S+)?/.exec(header);
45+
46+
tokens = tokens.slice(index);
47+
index =
48+
tokens.findIndex(token => token.tag === 'h2' && token.type === 'heading_open');
49+
50+
const start = openToken.map[0];
51+
const end = tokens[index]?.map[0] ?? lines.length;
52+
const content = lines.slice(start, end);
53+
54+
yield { start, end, header, version, date, content: content.join('\n') };
55+
}
56+
}
57+
58+
function run({ input, check_version, check_date, ignore_unreleased, print_entry, output }) {
59+
const changelog = readFileSync(input, 'utf8');
60+
const parser = parse(changelog);
61+
62+
let { value: entry, done } = parser.next();
63+
64+
if (done)
65+
throw new ChangelogError('Found no version entries');
66+
67+
if (ignore_unreleased && entry.date === undefined && /\bunreleased\b/i.test(entry.version))
68+
({ value: entry, done } = parser.next());
69+
70+
if (done)
71+
throw new ChangelogError('Found no released versions');
72+
73+
if (check_version !== undefined && entry.version !== check_version) {
74+
throw new ChangelogError(
75+
`Latest changelog entry does not match the expected version "${check_version}"`,
76+
entry.start,
77+
entry
78+
);
79+
}
80+
81+
if (check_date !== undefined && entry.date !== check_date) {
82+
throw new ChangelogError(
83+
`Latest changelog entry does not match the expected date "${check_date}"`,
84+
entry.start,
85+
entry
86+
);
87+
}
88+
89+
if (print_entry === undefined)
90+
return;
91+
92+
while (!done) {
93+
if (entry.version === print_entry)
94+
break;
95+
96+
({ value: entry, done } = parser.next());
97+
}
98+
99+
if (done)
100+
throw new ChangelogError(`Version "${print_entry}" not found`);
101+
102+
if (!output || output === '-')
103+
process.stdout.write(entry.content);
104+
else
105+
writeFileSync(output, entry.content, 'utf8');
106+
}
107+
108+
function normalizeVersion(version) {
109+
version = version.replace(/^[vV]/, '');
110+
111+
if (!version)
112+
throw new Error('Failed to parse version');
113+
114+
return version;
115+
}
116+
117+
function main() {
118+
const parser = new ArgumentParser();
119+
120+
parser.add_argument('-i', '--input', {
121+
default: 'CHANGELOG.md',
122+
help: 'Input file',
123+
});
124+
125+
parser.add_argument('-v', '--check-version', {
126+
type: normalizeVersion,
127+
help: 'Check that latest changelog entry matches the specified version',
128+
});
129+
130+
parser.add_argument('-d', '--check-date', {
131+
help: 'Check that latest changelog entry has the specified date',
132+
});
133+
134+
parser.add_argument('-u', '--ignore-unreleased', {
135+
action: 'store_true',
136+
help: 'If the latest entry is "Unreleased", ignore it and check the next entry',
137+
});
138+
139+
parser.add_argument('-p', '--print-entry', {
140+
type: normalizeVersion,
141+
help: 'Print changelog entry for the specified version',
142+
});
143+
144+
parser.add_argument('-o', '--output', {
145+
default: '-',
146+
help: 'Output file',
147+
});
148+
149+
run(parser.parse_args());
150+
}
151+
152+
main();

0 commit comments

Comments
 (0)