Skip to content

Commit a9903b1

Browse files
cpcloudCIclaude
authored
ci(release): move to scheduled weekly releases with relnotes tooling (#756)
## Summary - Stop releasing on every push to main; semantic-release now runs on a weekly Sunday cron (2pm UTC) or via manual `workflow_dispatch` - Add `ci/release/dry-run.bash`: worktree-based simulation that strips the github plugin, unsets `GITHUB_ACTIONS` to exercise the full pipeline - Add `ci/release/run.bash`: explicit `npx -p` wrapper for the actual release - Add `scheduled-release.yml` workflow with cron + manual trigger; dynamically fetches required checks from the branch ruleset API and verifies HEAD passed all of them before releasing - CI runs the dry-run on PRs to validate the release config - Add `conventionalcommits` preset to `.releaserc.json` for grouped release notes (Features, Bug Fixes, Performance, Refactors, Documentation) - Add `.conventionalcommits.js` bridge config (reads types from `.releaserc.json`) for `conventional-changelog-cli` - Add `relnotes` dev shell tool: preview unreleased changelog locally via glow - Add `nodejs`, `jq`, `glow` to the Nix dev shell - Add `*.js` to biome's file includes --------- Co-authored-by: CI <ci@localhost> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3bdce4f commit a9903b1

File tree

8 files changed

+216
-32
lines changed

8 files changed

+216
-32
lines changed

.conventionalcommits.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// Copyright 2026 Phillip Cloud
2+
// Licensed under the Apache License, Version 2.0
3+
4+
5+
6+
const { readFileSync } = require("node:fs");
7+
8+
const releaseConfig = JSON.parse(readFileSync("./.releaserc.json", "utf8"));
9+
10+
const types = releaseConfig.plugins
11+
.filter((p) => Array.isArray(p) && p[0] === "@semantic-release/release-notes-generator")
12+
.map((p) => p[1].presetConfig.types)[0];
13+
14+
module.exports = {
15+
options: {
16+
preset: {
17+
name: "conventionalcommits",
18+
types,
19+
},
20+
},
21+
writerOpts: {
22+
finalizeContext(ctx) {
23+
ctx.linkCompare = false;
24+
return ctx;
25+
},
26+
},
27+
};

.github/workflows/ci.yml

Lines changed: 8 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -221,47 +221,25 @@ jobs:
221221
run: nix run '.#docs'
222222

223223
# ---------------------------------------------------------------------------
224-
# Semantic Release (dry-run on PRs, publish on main push)
224+
# Semantic Release (dry-run on PRs to exercise the full release pipeline)
225225
# ---------------------------------------------------------------------------
226226

227-
semantic-release:
228-
name: Semantic Release
229-
needs: [changes, test, nix-build, docs]
230-
if: >-
231-
always()
232-
&& !contains(needs.*.result, 'failure')
233-
&& !contains(needs.*.result, 'cancelled')
234-
&& (needs.changes.outputs.go != 'true' || (needs.test.result == 'success' && needs.nix-build.result == 'success'))
227+
semantic-release-dry-run:
228+
name: Semantic Release (dry-run)
229+
if: github.event_name == 'pull_request'
235230
runs-on: ubuntu-latest
236-
concurrency:
237-
group: semantic-release-${{ github.ref }}
238-
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
239-
permissions:
240-
contents: write
241231
steps:
242-
- name: Generate app token
243-
id: app-token
244-
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
245-
with:
246-
app-id: ${{ secrets.APP_ID }}
247-
private-key: ${{ secrets.APP_PRIVATE_KEY }}
248-
249232
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
250233
with:
251234
fetch-depth: 0
252-
token: ${{ steps.app-token.outputs.token }}
235+
persist-credentials: false
253236

254237
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
255238
with:
256239
node-version: lts/*
257240

258-
- name: Install semantic-release
259-
run: npm install -g semantic-release@25.0.3 @semantic-release/exec@7.1.0 @semantic-release/git@10.0.1
260-
261-
- name: Run semantic-release
262-
run: npx semantic-release ${{ github.event_name == 'pull_request' && '--dry-run' || '' }}
263-
env:
264-
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
241+
- name: Simulate release (dry-run)
242+
run: bash ci/release/dry-run.bash
265243

266244
# ---------------------------------------------------------------------------
267245
# Gate: single required check that rolls up all jobs above.
@@ -271,7 +249,7 @@ jobs:
271249
result:
272250
name: CI Result
273251
if: always()
274-
needs: [changes, test, benchmarks, nix-build, docs, semantic-release]
252+
needs: [changes, test, benchmarks, nix-build, docs, semantic-release-dry-run]
275253
runs-on: ubuntu-latest
276254
steps:
277255
- run: exit 1
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# Copyright 2026 Phillip Cloud
2+
# Licensed under the Apache License, Version 2.0
3+
4+
name: Scheduled Release
5+
6+
on:
7+
schedule:
8+
- cron: "0 14 * * 0" # Sundays at 2pm UTC
9+
workflow_dispatch: {}
10+
11+
concurrency:
12+
group: scheduled-release
13+
cancel-in-progress: false
14+
15+
permissions:
16+
contents: read
17+
18+
jobs:
19+
semantic-release:
20+
name: Semantic Release
21+
runs-on: ubuntu-latest
22+
permissions:
23+
checks: read
24+
contents: write
25+
steps:
26+
- name: Generate app token
27+
id: app-token
28+
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
29+
with:
30+
app-id: ${{ secrets.APP_ID }}
31+
private-key: ${{ secrets.APP_PRIVATE_KEY }}
32+
33+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
34+
with:
35+
fetch-depth: 0
36+
token: ${{ steps.app-token.outputs.token }}
37+
38+
- name: Verify HEAD passed required checks
39+
env:
40+
GH_TOKEN: ${{ steps.app-token.outputs.token }}
41+
run: |
42+
sha="${{ github.sha }}"
43+
44+
# Fetch required check names from the branch ruleset
45+
required=$(gh api "repos/${{ github.repository }}/rules/branches/main" \
46+
--jq '[.[] | select(.type == "required_status_checks") | .parameters.required_status_checks[].context] |
47+
if length == 0 then "::error::No required status checks found for main. Aborting release." | halt_error(1)
48+
else . end')
49+
50+
# Fetch all check runs for HEAD once, then verify each required check
51+
gh api "repos/${{ github.repository }}/commits/$sha/check-runs" \
52+
--jq '.check_runs | group_by(.name) | map(sort_by(.completed_at) | last | {name, conclusion})' \
53+
| jq --argjson required "$required" -e '
54+
($required - [.[].name]) as $missing |
55+
if ($missing | length) > 0 then
56+
"::error::Missing checks for \($missing | join(", "))" | halt_error(1)
57+
else . end |
58+
map(select(.name as $n | $required | index($n))) |
59+
map(select(.conclusion != "success")) |
60+
if length > 0 then
61+
map("::error::\(.name) is \(.conclusion // "none"), expected success") | .[] | halt_error(1)
62+
else true end'
63+
64+
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
65+
with:
66+
node-version: lts/*
67+
68+
- name: Run semantic-release
69+
run: bash ci/release/run.bash
70+
env:
71+
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}

.releaserc.json

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,25 @@
11
{
22
"branches": ["main"],
33
"plugins": [
4-
"@semantic-release/commit-analyzer",
5-
"@semantic-release/release-notes-generator",
4+
["@semantic-release/commit-analyzer", {
5+
"preset": "conventionalcommits"
6+
}],
7+
["@semantic-release/release-notes-generator", {
8+
"preset": "conventionalcommits",
9+
"presetConfig": {
10+
"types": [
11+
{"type": "feat", "section": "Features"},
12+
{"type": "fix", "section": "Bug Fixes"},
13+
{"type": "perf", "section": "Performance"},
14+
{"type": "refactor", "section": "Refactors"},
15+
{"type": "docs", "section": "Documentation"},
16+
{"type": "chore", "hidden": true},
17+
{"type": "ci", "hidden": true},
18+
{"type": "style", "hidden": true},
19+
{"type": "test", "hidden": true}
20+
]
21+
}
22+
}],
623
["@semantic-release/exec", {
724
"prepareCmd": "echo ${nextRelease.version} > VERSION"
825
}],

biome.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"includes": [
44
"docs/static/css/*.css",
55
"docs/static/js/*.js",
6+
"*.js",
67
"*.json",
78
".github/winget-settings.json"
89
]

ci/release/dry-run.bash

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
#!/usr/bin/env bash
2+
# Copyright 2026 Phillip Cloud
3+
# Licensed under the Apache License, Version 2.0
4+
#
5+
# Simulate a semantic-release run in a disposable worktree.
6+
# Strips @semantic-release/github to avoid any GitHub API calls.
7+
8+
set -euo pipefail
9+
10+
curdir="$PWD"
11+
worktree="$(mktemp -d)"
12+
branch="semantic-release-dry-run-$(basename "$worktree")"
13+
14+
git worktree add -b "$branch" "$worktree" HEAD
15+
16+
cleanup() {
17+
cd "$curdir"
18+
git worktree remove --force "$worktree"
19+
git worktree prune
20+
if git show-ref --verify --quiet "refs/heads/$branch"; then
21+
git branch -D "$branch"
22+
fi
23+
}
24+
trap cleanup EXIT
25+
26+
cd "$worktree"
27+
28+
# Strip @semantic-release/github so the dry-run makes no API calls
29+
tmp=$(mktemp)
30+
jq '.plugins |= map(select(
31+
if type == "array" then .[0] != "@semantic-release/github"
32+
else . != "@semantic-release/github"
33+
end
34+
))' .releaserc.json > "$tmp"
35+
mv "$tmp" .releaserc.json
36+
37+
git add .releaserc.json
38+
git -c user.email=ci@localhost -c user.name=CI \
39+
commit -m "test: semantic-release dry run" --no-verify --no-gpg-sign
40+
41+
# Unset so semantic-release exercises the full pipeline instead of
42+
# short-circuiting on PR detection
43+
unset GITHUB_ACTIONS
44+
45+
npx --yes \
46+
-p "semantic-release@25.0.3" \
47+
-p "@semantic-release/exec@7.1.0" \
48+
-p "@semantic-release/git@10.0.1" \
49+
-p "conventional-changelog-conventionalcommits@8.0.0" \
50+
semantic-release \
51+
--ci \
52+
--dry-run \
53+
--branches "$branch" \
54+
--repository-url "file://$PWD"

ci/release/run.bash

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#!/usr/bin/env bash
2+
# Copyright 2026 Phillip Cloud
3+
# Licensed under the Apache License, Version 2.0
4+
#
5+
# Run semantic-release to publish a new release.
6+
7+
set -euo pipefail
8+
9+
npx --yes \
10+
-p "semantic-release@25.0.3" \
11+
-p "@semantic-release/exec@7.1.0" \
12+
-p "@semantic-release/git@10.0.1" \
13+
-p "conventional-changelog-conventionalcommits@8.0.0" \
14+
semantic-release --ci

flake.nix

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,24 @@
358358
'';
359359
};
360360

361+
relnotes = pkgs.writeShellApplication {
362+
name = "relnotes";
363+
runtimeInputs = [
364+
pkgs.nodejs
365+
pkgs.glow
366+
pkgs.ncurses
367+
pkgs.less
368+
];
369+
text = ''
370+
notes=$(npx -y -p conventional-changelog-cli -- conventional-changelog --config ./.conventionalcommits.js --tag-prefix v)
371+
if [[ -n "$notes" ]] && [[ -t 1 ]]; then
372+
echo "$notes" | glow --width "$(tput cols)" - | less -FRX
373+
else
374+
echo "$notes"
375+
fi
376+
'';
377+
};
378+
361379
in
362380
{
363381
checks = {
@@ -398,6 +416,10 @@
398416
pkgs.imagemagick
399417
pkgs.gopls
400418
pkgs.goreleaser
419+
pkgs.nodejs
420+
pkgs.jq
421+
pkgs.glow
422+
relnotes
401423
]
402424
++ enabledPackages;
403425
};

0 commit comments

Comments
 (0)