detached 98d8255

ci(release): tag main every morning on ermite and publish in the same run

A schedule at 07:00 Europe/Paris (ermite's cron timezone) runs next-release-tag.sh on main and pushes the tag with the job token. That push starts no run, so the release job publishes the tag in the same run. The tag job never runs on the GitHub mirror, which only publishes the tags it receives. Manual signed tags still publish, and the policy test is back in CI.
commit
98d8255ddc39ad6ff61104fed1c352b3e16e78e6
author
brice · · 2026-10-22 09:33 UTC
parent
6baf0bb
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
checks
0/1
6 files · +183 -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 +60 -0
@@ -0,0 +1,60 @@
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+while IFS= read -r commit; do
23+ subject=$(git log -1 --format=%s "$commit")
24+ body=$(git log -1 --format=%b "$commit")
25+
26+ # A breaking change is a v0.x minor release.
27+ if grep -Eq '^[[:alpha:]]+(\([^)]+\))?!:' <<<"$subject" || grep -Eiq '^BREAKING[ -]CHANGE:' <<<"$body"; then
28+ minor=true
29+ continue
30+ fi
31+
32+ type=${subject%%(*}
33+ type=${type%%:*}
34+ type=${type%%!*}
35+ type=$(tr '[:upper:]' '[:lower:]' <<<"$type")
36+ case "$type" in
37+ feat) minor=true ;;
38+ fix|perf|refactor|build|chore|ci) patch=true ;;
39+ docs|test|style) ;;
40+ *) ;;
41+ esac
42+done < <(git rev-list "$range")
43+
44+if [ "$minor" != true ] && [ "$patch" != true ]; then
45+ exit 0
46+fi
47+
48+# The renamed project starts a new public release line at v0.1.0.
49+if [ -z "$latest" ]; then
50+ printf '%s\n' 'v0.1.0'
51+ exit 0
52+fi
53+
54+version=${latest#v}
55+IFS=. read -r major minor_version patch_version <<<"$version"
56+if [ "$minor" = true ]; then
57+ printf 'v%s.%s.0\n' "$major" "$((minor_version + 1))"
58+else
59+ printf 'v%s.%s.%s\n' "$major" "$minor_version" "$((patch_version + 1))"
60+fi
@@ -0,0 +1,60 @@
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+while IFS= read -r commit; do
23+ subject=$(git log -1 --format=%s "$commit")
24+ body=$(git log -1 --format=%b "$commit")
25+
26+ # A breaking change is a v0.x minor release.
27+ if grep -Eq '^[[:alpha:]]+(\([^)]+\))?!:' <<<"$subject" || grep -Eiq '^BREAKING[ -]CHANGE:' <<<"$body"; then
28+ minor=true
29+ continue
30+ fi
31+
32+ type=${subject%%(*}
33+ type=${type%%:*}
34+ type=${type%%!*}
35+ type=$(tr '[:upper:]' '[:lower:]' <<<"$type")
36+ case "$type" in
37+ feat) minor=true ;;
38+ fix|perf|refactor|build|chore|ci) patch=true ;;
39+ docs|test|style) ;;
40+ *) ;;
41+ esac
42+done < <(git rev-list "$range")
43+
44+if [ "$minor" != true ] && [ "$patch" != true ]; then
45+ exit 0
46+fi
47+
48+# The renamed project starts a new public release line at v0.1.0.
49+if [ -z "$latest" ]; then
50+ printf '%s\n' 'v0.1.0'
51+ exit 0
52+fi
53+
54+version=${latest#v}
55+IFS=. read -r major minor_version patch_version <<<"$version"
56+if [ "$minor" = true ]; then
57+ printf 'v%s.%s.0\n' "$major" "$((minor_version + 1))"
58+else
59+ printf 'v%s.%s.%s\n' "$major" "$minor_version" "$((patch_version + 1))"
60+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