detached fd8b26e

Merge 4509ec7f010bcee68855bf97f60a183467851813 into 6baf0bbb67a874c59bc9ea1831b5c8fc41d5f764

commit
fd8b26e32e04959b5be0826b6f840b01b55a53af
author
ermite · · 2026-10-22 09:34 UTC
merge of
6baf0bb + 4509ec7 · diff against the first parent
signature
unsigned
6 files · +186 -5
M .forgejo/workflows/ci.yml +3 -0
@@ -41,6 +41,9 @@ jobs:
4141 - name: go test
4242 run: go test -race ./...
4343
44+ - name: release-version policy
45+ run: bash .github/scripts/next-release-tag_test.sh
46+
4447 - name: golangci-lint
4548 uses: golangci/golangci-lint-action@v9.3.0
4649 with:
@@ -41,6 +41,9 @@ jobs:
41 - name: go test41 - name: go test
42 run: go test -race ./...42 run: go test -race ./...
43 43
44+ - name: release-version policy
45+ run: bash .github/scripts/next-release-tag_test.sh
46+
44 - name: golangci-lint47 - name: golangci-lint
45 uses: golangci/golangci-lint-action@v9.3.048 uses: golangci/golangci-lint-action@v9.3.0
46 with:49 with:
A .github/scripts/next-release-tag.sh +63 -0
@@ -0,0 +1,63 @@
1+#!/usr/bin/env bash
2+# Print the release tag for HEAD, or nothing when its unreleased commits do not
3+# require a release. A tag already pointing to HEAD is emitted again so a
4+# failed GoReleaser job can be retried without minting a second tag.
5+set -euo pipefail
6+
7+latest=$(git tag --list 'v*.*.*' --sort=-v:refname | head -n1)
8+head=$(git rev-parse HEAD)
9+if [ -n "$latest" ] && [ "$(git rev-parse "$latest^{}")" = "$head" ]; then
10+ printf '%s\n' "$latest"
11+ exit 0
12+fi
13+
14+if [ -n "$latest" ]; then
15+ range="${latest}..HEAD"
16+else
17+ range="HEAD"
18+fi
19+
20+minor=false
21+patch=false
22+# A here-string, not a process substitution: the ermite runner has no /dev/fd.
23+commits=$(git rev-list "$range")
24+while IFS= read -r commit; do
25+ [ -n "$commit" ] || continue
26+ subject=$(git log -1 --format=%s "$commit")
27+ body=$(git log -1 --format=%b "$commit")
28+
29+ # A breaking change is a v0.x minor release.
30+ if grep -Eq '^[[:alpha:]]+(\([^)]+\))?!:' <<<"$subject" || grep -Eiq '^BREAKING[ -]CHANGE:' <<<"$body"; then
31+ minor=true
32+ continue
33+ fi
34+
35+ type=${subject%%(*}
36+ type=${type%%:*}
37+ type=${type%%!*}
38+ type=$(tr '[:upper:]' '[:lower:]' <<<"$type")
39+ case "$type" in
40+ feat) minor=true ;;
41+ fix|perf|refactor|build|chore|ci) patch=true ;;
42+ docs|test|style) ;;
43+ *) ;;
44+ esac
45+done <<<"$commits"
46+
47+if [ "$minor" != true ] && [ "$patch" != true ]; then
48+ exit 0
49+fi
50+
51+# The renamed project starts a new public release line at v0.1.0.
52+if [ -z "$latest" ]; then
53+ printf '%s\n' 'v0.1.0'
54+ exit 0
55+fi
56+
57+version=${latest#v}
58+IFS=. read -r major minor_version patch_version <<<"$version"
59+if [ "$minor" = true ]; then
60+ printf 'v%s.%s.0\n' "$major" "$((minor_version + 1))"
61+else
62+ printf 'v%s.%s.%s\n' "$major" "$minor_version" "$((patch_version + 1))"
63+fi
@@ -0,0 +1,63 @@
1+#!/usr/bin/env bash
2+# Print the release tag for HEAD, or nothing when its unreleased commits do not
3+# require a release. A tag already pointing to HEAD is emitted again so a
4+# failed GoReleaser job can be retried without minting a second tag.
5+set -euo pipefail
6+
7+latest=$(git tag --list 'v*.*.*' --sort=-v:refname | head -n1)
8+head=$(git rev-parse HEAD)
9+if [ -n "$latest" ] && [ "$(git rev-parse "$latest^{}")" = "$head" ]; then
10+ printf '%s\n' "$latest"
11+ exit 0
12+fi
13+
14+if [ -n "$latest" ]; then
15+ range="${latest}..HEAD"
16+else
17+ range="HEAD"
18+fi
19+
20+minor=false
21+patch=false
22+# A here-string, not a process substitution: the ermite runner has no /dev/fd.
23+commits=$(git rev-list "$range")
24+while IFS= read -r commit; do
25+ [ -n "$commit" ] || continue
26+ subject=$(git log -1 --format=%s "$commit")
27+ body=$(git log -1 --format=%b "$commit")
28+
29+ # A breaking change is a v0.x minor release.
30+ if grep -Eq '^[[:alpha:]]+(\([^)]+\))?!:' <<<"$subject" || grep -Eiq '^BREAKING[ -]CHANGE:' <<<"$body"; then
31+ minor=true
32+ continue
33+ fi
34+
35+ type=${subject%%(*}
36+ type=${type%%:*}
37+ type=${type%%!*}
38+ type=$(tr '[:upper:]' '[:lower:]' <<<"$type")
39+ case "$type" in
40+ feat) minor=true ;;
41+ fix|perf|refactor|build|chore|ci) patch=true ;;
42+ docs|test|style) ;;
43+ *) ;;
44+ esac
45+done <<<"$commits"
46+
47+if [ "$minor" != true ] && [ "$patch" != true ]; then
48+ exit 0
49+fi
50+
51+# The renamed project starts a new public release line at v0.1.0.
52+if [ -z "$latest" ]; then
53+ printf '%s\n' 'v0.1.0'
54+ exit 0
55+fi
56+
57+version=${latest#v}
58+IFS=. read -r major minor_version patch_version <<<"$version"
59+if [ "$minor" = true ]; then
60+ printf 'v%s.%s.0\n' "$major" "$((minor_version + 1))"
61+else
62+ printf 'v%s.%s.%s\n' "$major" "$minor_version" "$((patch_version + 1))"
63+fi
A .github/scripts/next-release-tag_test.sh +48 -0
@@ -0,0 +1,48 @@
1+#!/usr/bin/env bash
2+set -euo pipefail
3+
4+repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
5+script="$repo_root/.github/scripts/next-release-tag.sh"
6+tmp=$(mktemp -d)
7+trap 'rm -rf "$tmp"' EXIT
8+
9+cd "$tmp"
10+git init --quiet
11+git config user.name 'Release Test'
12+git config user.email 'release-test@example.invalid'
13+
14+git commit --allow-empty --quiet -m 'feat: first release'
15+expect() {
16+ local want=$1
17+ local got
18+ got=$($script)
19+ if [ "$got" != "$want" ]; then
20+ printf 'next-release-tag = %q, want %q\n' "$got" "$want" >&2
21+ exit 1
22+ fi
23+}
24+
25+expect 'v0.1.0'
26+git tag -a v0.1.0 -m 'release v0.1.0'
27+
28+# A rerun after tag creation must release the existing tag, not skip it.
29+expect 'v0.1.0'
30+
31+git commit --allow-empty --quiet -m 'docs: explain releases'
32+expect ''
33+
34+git commit --allow-empty --quiet -m 'fix: repair installer'
35+expect 'v0.1.1'
36+git tag -a v0.1.1 -m 'release v0.1.1'
37+
38+git commit --allow-empty --quiet -m 'refactor!: replace extension handshake'
39+expect 'v0.2.0'
40+
41+# GoReleaser passes tag_sort directly to `git tag --sort`, so keep the
42+# configured semantic-version sort valid before a release run depends on it.
43+tag_sort=$(sed -n 's/^[[:space:]]*tag_sort:[[:space:]]*//p' "$repo_root/.goreleaser.yaml")
44+if [ "$tag_sort" != '-version:refname' ]; then
45+ printf 'goreleaser tag_sort = %q, want %q\n' "$tag_sort" '-version:refname' >&2
46+ exit 1
47+fi
48+git tag --sort "$tag_sort" --list 'v*.*.*' >/dev/null
@@ -0,0 +1,48 @@
1+#!/usr/bin/env bash
2+set -euo pipefail
3+
4+repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
5+script="$repo_root/.github/scripts/next-release-tag.sh"
6+tmp=$(mktemp -d)
7+trap 'rm -rf "$tmp"' EXIT
8+
9+cd "$tmp"
10+git init --quiet
11+git config user.name 'Release Test'
12+git config user.email 'release-test@example.invalid'
13+
14+git commit --allow-empty --quiet -m 'feat: first release'
15+expect() {
16+ local want=$1
17+ local got
18+ got=$($script)
19+ if [ "$got" != "$want" ]; then
20+ printf 'next-release-tag = %q, want %q\n' "$got" "$want" >&2
21+ exit 1
22+ fi
23+}
24+
25+expect 'v0.1.0'
26+git tag -a v0.1.0 -m 'release v0.1.0'
27+
28+# A rerun after tag creation must release the existing tag, not skip it.
29+expect 'v0.1.0'
30+
31+git commit --allow-empty --quiet -m 'docs: explain releases'
32+expect ''
33+
34+git commit --allow-empty --quiet -m 'fix: repair installer'
35+expect 'v0.1.1'
36+git tag -a v0.1.1 -m 'release v0.1.1'
37+
38+git commit --allow-empty --quiet -m 'refactor!: replace extension handshake'
39+expect 'v0.2.0'
40+
41+# GoReleaser passes tag_sort directly to `git tag --sort`, so keep the
42+# configured semantic-version sort valid before a release run depends on it.
43+tag_sort=$(sed -n 's/^[[:space:]]*tag_sort:[[:space:]]*//p' "$repo_root/.goreleaser.yaml")
44+if [ "$tag_sort" != '-version:refname' ]; then
45+ printf 'goreleaser tag_sort = %q, want %q\n' "$tag_sort" '-version:refname' >&2
46+ exit 1
47+fi
48+git tag --sort "$tag_sort" --list 'v*.*.*' >/dev/null
M .github/workflows/ci.yml +4 -0
@@ -50,6 +50,10 @@ jobs:
5050
5151 # Keep Linux-only quality checks in the Ubuntu test job so CI needs
5252 # only the three platform runners.
53+ - name: release-version policy
54+ if: matrix.os == 'ubuntu-latest'
55+ run: bash .github/scripts/next-release-tag_test.sh
56+
5357 - name: golangci-lint
5458 if: matrix.os == 'ubuntu-latest'
5559 uses: golangci/golangci-lint-action@v9.3.0
@@ -50,6 +50,10 @@ jobs:
50 50
51 # Keep Linux-only quality checks in the Ubuntu test job so CI needs51 # Keep Linux-only quality checks in the Ubuntu test job so CI needs
52 # only the three platform runners.52 # only the three platform runners.
53+ - name: release-version policy
54+ if: matrix.os == 'ubuntu-latest'
55+ run: bash .github/scripts/next-release-tag_test.sh
56+
53 - name: golangci-lint57 - name: golangci-lint
54 if: matrix.os == 'ubuntu-latest'58 if: matrix.os == 'ubuntu-latest'
55 uses: golangci/golangci-lint-action@v9.3.059 uses: golangci/golangci-lint-action@v9.3.0
M .github/workflows/release.yml +64 -3
@@ -1,23 +1,84 @@
11 name: release
22
3-# Pushing a v* tag publishes the release. This workflow intentionally runs on
4-# both ermite (publishes to git.bnema.dev) and the GitHub mirror (publishes a
5-# copy on github.com, because installed `zut update` only reads GitHub).
3+# Every morning at 07:00 Europe/Paris, ermite tags main when its unreleased
4+# conventional commits call for a release, then publishes it in the same run (a
5+# tag pushed with the job token starts no run). A v* tag pushed by hand also
6+# publishes. The GitHub mirror never tags: it receives the forge's tags and
7+# publishes a copy, because installed `zut update` only reads GitHub.
68 on:
9+ schedule:
10+ - cron: "0 7 * * *"
11+ timezone: Europe/Paris
12+ workflow_dispatch:
713 push:
814 tags: ["v*"]
915
1016 permissions:
1117 contents: write
1218
19+concurrency:
20+ group: release
21+ cancel-in-progress: false
22+
1323 jobs:
24+ tag:
25+ if: github.event_name != 'push' && github.server_url != 'https://github.com'
26+ runs-on: ubuntu-latest
27+ outputs:
28+ tag: ${{ steps.version.outputs.tag }}
29+ steps:
30+ - name: Check out main
31+ uses: actions/checkout@v4
32+ with:
33+ fetch-depth: 0
34+ fetch-tags: true
35+ ref: main
36+
37+ - name: Compute the next release tag
38+ id: version
39+ shell: bash
40+ run: |
41+ set -euo pipefail
42+ if git log -1 --format=%B | grep -Fq '[release=skip]'; then
43+ echo "The latest commit requested that the release be skipped."
44+ exit 0
45+ fi
46+ tag=$(bash .github/scripts/next-release-tag.sh)
47+ if [ -z "$tag" ]; then
48+ echo "No releasable conventional commits for this run."
49+ exit 0
50+ fi
51+ echo "Releasing $tag from $(git rev-parse --short HEAD)"
52+ echo "tag=$tag" >> "$GITHUB_OUTPUT"
53+
54+ - name: Create and push the tag
55+ if: steps.version.outputs.tag != ''
56+ env:
57+ TAG: ${{ steps.version.outputs.tag }}
58+ run: |
59+ # A retry finds the tag already on HEAD: it is published again as is.
60+ if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
61+ exit 0
62+ fi
63+ git config user.name "github-actions[bot]"
64+ git config user.email "github-actions[bot]@users.noreply.github.com"
65+ git tag -a "$TAG" -m "release $TAG"
66+ git push origin "refs/tags/$TAG"
67+
1468 release:
69+ needs: tag
70+ # A pushed tag publishes on ermite and on the mirror (the tag job is
71+ # skipped there); a scheduled or dispatched run publishes what it tagged.
72+ if: >-
73+ !cancelled() && needs.tag.result != 'failure' &&
74+ (github.event_name == 'push' || needs.tag.outputs.tag != '')
1575 runs-on: ubuntu-latest
1676 steps:
1777 - name: Check out the tag
1878 uses: actions/checkout@v4
1979 with:
2080 fetch-depth: 0
81+ ref: ${{ needs.tag.outputs.tag && format('refs/tags/{0}', needs.tag.outputs.tag) || github.ref }}
2182
2283 - name: Set up Go
2384 uses: actions/setup-go@v7
@@ -1,23 +1,84 @@
1 name: release1 name: release
2 2
3-# Pushing a v* tag publishes the release. This workflow intentionally runs on3+# Every morning at 07:00 Europe/Paris, ermite tags main when its unreleased
4-# both ermite (publishes to git.bnema.dev) and the GitHub mirror (publishes a4+# conventional commits call for a release, then publishes it in the same run (a
5-# copy on github.com, because installed `zut update` only reads GitHub).5+# tag pushed with the job token starts no run). A v* tag pushed by hand also
6+# publishes. The GitHub mirror never tags: it receives the forge's tags and
7+# publishes a copy, because installed `zut update` only reads GitHub.
6 on:8 on:
9+ schedule:
10+ - cron: "0 7 * * *"
11+ timezone: Europe/Paris
12+ workflow_dispatch:
7 push:13 push:
8 tags: ["v*"]14 tags: ["v*"]
9 15
10 permissions:16 permissions:
11 contents: write17 contents: write
12 18
19+concurrency:
20+ group: release
21+ cancel-in-progress: false
22+
13 jobs:23 jobs:
24+ tag:
25+ if: github.event_name != 'push' && github.server_url != 'https://github.com'
26+ runs-on: ubuntu-latest
27+ outputs:
28+ tag: ${{ steps.version.outputs.tag }}
29+ steps:
30+ - name: Check out main
31+ uses: actions/checkout@v4
32+ with:
33+ fetch-depth: 0
34+ fetch-tags: true
35+ ref: main
36+
37+ - name: Compute the next release tag
38+ id: version
39+ shell: bash
40+ run: |
41+ set -euo pipefail
42+ if git log -1 --format=%B | grep -Fq '[release=skip]'; then
43+ echo "The latest commit requested that the release be skipped."
44+ exit 0
45+ fi
46+ tag=$(bash .github/scripts/next-release-tag.sh)
47+ if [ -z "$tag" ]; then
48+ echo "No releasable conventional commits for this run."
49+ exit 0
50+ fi
51+ echo "Releasing $tag from $(git rev-parse --short HEAD)"
52+ echo "tag=$tag" >> "$GITHUB_OUTPUT"
53+
54+ - name: Create and push the tag
55+ if: steps.version.outputs.tag != ''
56+ env:
57+ TAG: ${{ steps.version.outputs.tag }}
58+ run: |
59+ # A retry finds the tag already on HEAD: it is published again as is.
60+ if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
61+ exit 0
62+ fi
63+ git config user.name "github-actions[bot]"
64+ git config user.email "github-actions[bot]@users.noreply.github.com"
65+ git tag -a "$TAG" -m "release $TAG"
66+ git push origin "refs/tags/$TAG"
67+
14 release:68 release:
69+ needs: tag
70+ # A pushed tag publishes on ermite and on the mirror (the tag job is
71+ # skipped there); a scheduled or dispatched run publishes what it tagged.
72+ if: >-
73+ !cancelled() && needs.tag.result != 'failure' &&
74+ (github.event_name == 'push' || needs.tag.outputs.tag != '')
15 runs-on: ubuntu-latest75 runs-on: ubuntu-latest
16 steps:76 steps:
17 - name: Check out the tag77 - name: Check out the tag
18 uses: actions/checkout@v478 uses: actions/checkout@v4
19 with:79 with:
20 fetch-depth: 080 fetch-depth: 0
81+ ref: ${{ needs.tag.outputs.tag && format('refs/tags/{0}', needs.tag.outputs.tag) || github.ref }}
21 82
22 - name: Set up Go83 - name: Set up Go
23 uses: actions/setup-go@v784 uses: actions/setup-go@v7
M CONTRIBUTING.md +4 -2
@@ -147,13 +147,15 @@ If a validation command cannot pass because of an unrelated repository or enviro
147147
148148 ## Releases
149149
150-The maintainer publishes a release by pushing a signed version tag to the forge:
150+Every morning at 07:00 Europe/Paris, the release workflow on git.bnema.dev tags `main` when its unreleased conventional commits call for a release (`.github/scripts/next-release-tag.sh`: `feat` or a breaking change bumps the minor version, `fix`, `perf`, `refactor`, `build`, `chore` and `ci` the patch). It then runs GoReleaser and publishes the archives and `checksums.txt`. Put `[release=skip]` in the latest commit message to skip a morning. Run it now with `ermite workflow run zut release.yml`.
151+
152+The maintainer can also publish by pushing a signed version tag:
151153
152154 ```sh
153155 git tag -s vX.Y.Z -m vX.Y.Z && git push origin vX.Y.Z
154156 ```
155157
156-The tag triggers the release workflow, which runs GoReleaser and publishes the archives and `checksums.txt` on git.bnema.dev. The GitHub mirror receives the same tag and publishes a copy, because installed `zut update` builds read releases from GitHub only.
158+The GitHub mirror receives the tags and publishes a copy, because installed `zut update` builds read releases from GitHub only.
157159
158160 ## Pull request shape
159161
@@ -147,13 +147,15 @@ If a validation command cannot pass because of an unrelated repository or enviro
147 147
148 ## Releases148 ## Releases
149 149
150-The maintainer publishes a release by pushing a signed version tag to the forge:150+Every morning at 07:00 Europe/Paris, the release workflow on git.bnema.dev tags `main` when its unreleased conventional commits call for a release (`.github/scripts/next-release-tag.sh`: `feat` or a breaking change bumps the minor version, `fix`, `perf`, `refactor`, `build`, `chore` and `ci` the patch). It then runs GoReleaser and publishes the archives and `checksums.txt`. Put `[release=skip]` in the latest commit message to skip a morning. Run it now with `ermite workflow run zut release.yml`.
151+
152+The maintainer can also publish by pushing a signed version tag:
151 153
152 ```sh154 ```sh
153 git tag -s vX.Y.Z -m vX.Y.Z && git push origin vX.Y.Z155 git tag -s vX.Y.Z -m vX.Y.Z && git push origin vX.Y.Z
154 ```156 ```
155 157
156-The tag triggers the release workflow, which runs GoReleaser and publishes the archives and `checksums.txt` on git.bnema.dev. The GitHub mirror receives the same tag and publishes a copy, because installed `zut update` builds read releases from GitHub only.158+The GitHub mirror receives the tags and publishes a copy, because installed `zut update` builds read releases from GitHub only.
157 159
158 ## Pull request shape160 ## Pull request shape
159 161

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close