Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
27 changes: 27 additions & 0 deletions .conventionalcommits.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Copyright 2026 Phillip Cloud
// Licensed under the Apache License, Version 2.0



const { readFileSync } = require("node:fs");

const releaseConfig = JSON.parse(readFileSync("./.releaserc.json", "utf8"));

const types = releaseConfig.plugins
.filter((p) => Array.isArray(p) && p[0] === "@semantic-release/release-notes-generator")
.map((p) => p[1].presetConfig.types)[0];

Comment thread
cpcloud marked this conversation as resolved.
module.exports = {
options: {
preset: {
name: "conventionalcommits",
types,
},
},
writerOpts: {
finalizeContext(ctx) {
ctx.linkCompare = false;
return ctx;
},
},
};
38 changes: 8 additions & 30 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -221,47 +221,25 @@ jobs:
run: nix run '.#docs'

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

semantic-release:
name: Semantic Release
needs: [changes, test, nix-build, docs]
if: >-
always()
&& !contains(needs.*.result, 'failure')
&& !contains(needs.*.result, 'cancelled')
&& (needs.changes.outputs.go != 'true' || (needs.test.result == 'success' && needs.nix-build.result == 'success'))
semantic-release-dry-run:
name: Semantic Release (dry-run)
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
concurrency:
group: semantic-release-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: write
steps:
- name: Generate app token
id: app-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}

- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
token: ${{ steps.app-token.outputs.token }}
persist-credentials: false

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

- name: Install semantic-release
run: npm install -g semantic-release@25.0.3 @semantic-release/exec@7.1.0 @semantic-release/git@10.0.1

- name: Run semantic-release
run: npx semantic-release ${{ github.event_name == 'pull_request' && '--dry-run' || '' }}
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
- name: Simulate release (dry-run)
run: bash ci/release/dry-run.bash

# ---------------------------------------------------------------------------
# Gate: single required check that rolls up all jobs above.
Expand All @@ -271,7 +249,7 @@ jobs:
result:
name: CI Result
if: always()
needs: [changes, test, benchmarks, nix-build, docs, semantic-release]
needs: [changes, test, benchmarks, nix-build, docs, semantic-release-dry-run]
runs-on: ubuntu-latest
steps:
- run: exit 1
Expand Down
71 changes: 71 additions & 0 deletions .github/workflows/scheduled-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Copyright 2026 Phillip Cloud
# Licensed under the Apache License, Version 2.0

name: Scheduled Release

on:
schedule:
- cron: "0 14 * * 0" # Sundays at 2pm UTC
workflow_dispatch: {}

concurrency:
group: scheduled-release
cancel-in-progress: false

permissions:
contents: read

Comment thread
cpcloud marked this conversation as resolved.
jobs:
semantic-release:
name: Semantic Release
runs-on: ubuntu-latest
permissions:
checks: read
contents: write
steps:
- name: Generate app token
id: app-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}

- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
token: ${{ steps.app-token.outputs.token }}

- name: Verify HEAD passed required checks
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
sha=$(git rev-parse HEAD)

# Fetch required check names from the branch ruleset
mapfile -t checks < <(
gh api "repos/${{ github.repository }}/rules/branches/main" \
--jq '[.[] | select(.type == "required_status_checks") | .parameters.required_status_checks[].context] | .[]'
)

if [ ${#checks[@]} -eq 0 ]; then
echo "::error::No required status checks found for main. Aborting release."
exit 1
fi

for check_name in "${checks[@]}"; do
conclusion=$(gh api "repos/${{ github.repository }}/commits/$sha/check-runs" \
--jq "[.check_runs[] | select(.name == \"$check_name\")] | sort_by(.completed_at) | last | .conclusion")
if [ "$conclusion" != "success" ]; then
echo "::error::HEAD ($sha) $check_name is '${conclusion:-none}', expected 'success'. Aborting release."
exit 1
fi
done

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

Comment thread
cpcloud marked this conversation as resolved.
- name: Run semantic-release
run: bash ci/release/run.bash
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
21 changes: 19 additions & 2 deletions .releaserc.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,25 @@
{
"branches": ["main"],
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
["@semantic-release/commit-analyzer", {
"preset": "conventionalcommits"
}],
["@semantic-release/release-notes-generator", {
"preset": "conventionalcommits",
"presetConfig": {
"types": [
{"type": "feat", "section": "Features"},
{"type": "fix", "section": "Bug Fixes"},
{"type": "perf", "section": "Performance"},
{"type": "refactor", "section": "Refactors"},
{"type": "docs", "section": "Documentation"},
{"type": "chore", "hidden": true},
{"type": "ci", "hidden": true},
{"type": "style", "hidden": true},
{"type": "test", "hidden": true}
]
}
}],
["@semantic-release/exec", {
"prepareCmd": "echo ${nextRelease.version} > VERSION"
}],
Expand Down
1 change: 1 addition & 0 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"includes": [
"docs/static/css/*.css",
"docs/static/js/*.js",
"*.js",
Comment thread
cpcloud marked this conversation as resolved.
"*.json",
".github/winget-settings.json"
]
Expand Down
54 changes: 54 additions & 0 deletions ci/release/dry-run.bash
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# Copyright 2026 Phillip Cloud
# Licensed under the Apache License, Version 2.0
#
# Simulate a semantic-release run in a disposable worktree.
# Strips @semantic-release/github to avoid any GitHub API calls.

set -euo pipefail

curdir="$PWD"
worktree="$(mktemp -d)"
branch="semantic-release-dry-run-$(basename "$worktree")"

git worktree add -b "$branch" "$worktree" HEAD

cleanup() {
cd "$curdir"
git worktree remove --force "$worktree"
git worktree prune
if git show-ref --verify --quiet "refs/heads/$branch"; then
git branch -D "$branch"
fi
}
trap cleanup EXIT

cd "$worktree"

# Strip @semantic-release/github so the dry-run makes no API calls
tmp=$(mktemp)
jq '.plugins |= map(select(
if type == "array" then .[0] != "@semantic-release/github"
else . != "@semantic-release/github"
end
))' .releaserc.json > "$tmp"
mv "$tmp" .releaserc.json
Comment thread
cpcloud marked this conversation as resolved.

git add .releaserc.json
git -c user.email=ci@localhost -c user.name=CI \
commit -m "test: semantic-release dry run" --no-verify --no-gpg-sign

# Unset so semantic-release exercises the full pipeline instead of
# short-circuiting on PR detection
unset GITHUB_ACTIONS

npx --yes \
-p "semantic-release@25.0.3" \
-p "@semantic-release/exec@7.1.0" \
-p "@semantic-release/git@10.0.1" \
-p "conventional-changelog-conventionalcommits@8.0.0" \
semantic-release \
--ci \
--dry-run \
--branches "$branch" \
--repository-url "file://$PWD"
14 changes: 14 additions & 0 deletions ci/release/run.bash
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# Copyright 2026 Phillip Cloud
# Licensed under the Apache License, Version 2.0
#
# Run semantic-release to publish a new release.

set -euo pipefail

npx --yes \
-p "semantic-release@25.0.3" \
-p "@semantic-release/exec@7.1.0" \
-p "@semantic-release/git@10.0.1" \
-p "conventional-changelog-conventionalcommits@8.0.0" \
semantic-release --ci
22 changes: 22 additions & 0 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,24 @@
'';
};

relnotes = pkgs.writeShellApplication {
name = "relnotes";
runtimeInputs = [
pkgs.nodejs
pkgs.glow
Comment thread
cpcloud marked this conversation as resolved.
pkgs.ncurses
pkgs.less
];
text = ''
notes=$(npx -y -p conventional-changelog-cli -- conventional-changelog --config ./.conventionalcommits.js --tag-prefix v)
Comment thread
cpcloud marked this conversation as resolved.
Comment thread
cpcloud marked this conversation as resolved.
if [[ -n "$notes" ]] && [[ -t 1 ]]; then
echo "$notes" | glow --width "$(tput cols)" - | less -FRX
else
echo "$notes"
fi
'';
};

in
{
checks = {
Expand Down Expand Up @@ -398,6 +416,10 @@
pkgs.imagemagick
pkgs.gopls
pkgs.goreleaser
pkgs.nodejs
pkgs.jq
pkgs.glow
relnotes
Comment thread
cpcloud marked this conversation as resolved.
]
++ enabledPackages;
};
Expand Down
Loading