# Release, Go library. Runs on version tags (v1.2.3) pushed to main. # # A library ships no binaries, so there is no build matrix and no smoke test: the release # carries the CHANGELOG section as its body and nothing else. The gates still run first, # in their own job and once, minus the race detector: race never runs on a push path or a # tag, and the local gate raced this tree before the tag was cut. The write permission # sits on the release job alone, and the version contract these steps implement is in the # `release` skill. # # Every step is one command, so the step that fails is the gate that failed, and no shell # option has to be trusted for the run to stop. The scripted steps are Perl, not shell and # not Python: Perl behaves the same on both runner images, there is no bashism to trip over # on ash, and it is one language instead of two. The Perl uses builtins only, because # Fedora packages the Perl modules separately and nothing beyond `perl` itself may be # assumed present, which is why the release body is escaped by hand and curl is the # transport. name: Release on: push: tags: ["v*"] env: # The box is shared with the forge, so parallelism is bounded on purpose. The gates job # needs it most, since it runs the suite. GOFLAGS: -p=1 GOMAXPROCS: "2" jobs: gates: runs-on: fedora timeout-minutes: 10 steps: - uses: actions/checkout@v7 - uses: actions/setup-go@v6 with: # The module is the source of truth for the version, so it cannot drift. go-version-file: go.mod cache: true - name: Install Perl # Perl for the steps below. The install is a no-op where the package # is already present. run: dnf install -y perl - name: Validate the tag env: VERSION: ${{ gitea.ref_name }} run: | perl -e ' my $v = $ENV{VERSION} // q{}; $v =~ m{^v[0-9]+(\.[0-9]+){0,2}([-+].*)?$} or die qq{ERROR: expected a semver tag like v1.2.3, got: $v\n}; print qq{tag $v\n}; ' - name: Format run: | perl -e ' open(my $g, q{-|}, q{gofmt}, q{-l}, q{.}) or die qq{gofmt: $!}; my @bad = <$g>; close($g); print @bad; exit(@bad ? 1 : 0); ' - name: Vet run: go vet ./... - name: Modernise # Exits non-zero when it has something to rewrite, so it needs no output capture. run: go fix -diff ./... - name: Build run: go build ./... - name: Tests # Keep the pattern equal to `packages` in the project's justfile. run: go test -count=1 -timeout 30m -coverprofile=coverage.out ./... - name: Coverage floor run: | perl -e ' open(my $c, q{-|}, q{go}, q{tool}, q{cover}, q{-func=coverage.out}) or die qq{cover: $!}; my $total; while (my $l = <$c>) { $total = $1 if $l =~ m{^total:\s+\S+\s+([0-9.]+)%} } close($c); die qq{no total line in coverage.out\n} unless defined $total; printf qq{Total coverage: %s%%\n}, $total; exit($total < 80 ? 1 : 0); ' release: runs-on: fedora timeout-minutes: 15 needs: gates permissions: # contents: read is required for the checkout: a job that declares any # permissions gets a token scoped to exactly those, and releases: write # alone leaves the fetch with no read access, which Gitea answers with # a 404 "Repository not found". Verified on the instance 2026-09-16. contents: read releases: write steps: - uses: actions/checkout@v7 - name: Install Perl # The runner images are minimal and Perl is not guaranteed. The install is a # no-op where it is already present; drop this step once verified on the box. run: dnf install -y perl - name: Extract the CHANGELOG section env: VERSION: ${{ gitea.ref_name }} run: | # Each step derives what it needs from the tag, so no value has to travel between # jobs. The separator after the version is never read, so the heading format's # separator stays free. perl -e ' my $v = $ENV{VERSION} // q{}; $v =~ s{^v}{}; open(my $vout, q{>}, q{version-no-v.txt}) or die qq{version-no-v.txt: $!}; print $vout $v; close($vout); open(my $in, q{<}, q{CHANGELOG.md}) or die qq{CHANGELOG.md: $!}; my @lines = <$in>; close($in); my ($start, $end) = (-1, scalar @lines); for my $i (0 .. $#lines) { if ($start < 0) { $start = $i if $lines[$i] =~ m{^##\s+\[\Q$v\E\]} } elsif ($lines[$i] =~ m{^##\s+\[}) { $end = $i; last } } $start >= 0 or die qq{ERROR: no CHANGELOG section for $v, expected a heading like: ## [$v] - YYYY-MM-DD\n}; my @body = grep { m{\S} } @lines[$start + 1 .. $end - 1]; @body or die qq{ERROR: the CHANGELOG section for $v is empty\n}; open(my $out, q{>}, q{release-body.md}) or die qq{release-body.md: $!}; print $out @body; close($out); printf qq{notes for %s: %d lines\n}, $v, scalar @body; ' - name: Build the release request run: | perl -e ' open(my $vin, q{<}, q{version-no-v.txt}) or die qq{version-no-v.txt: $!}; my $v = <$vin>; close($vin); chomp $v; open(my $in, q{<:raw}, q{release-body.md}) or die qq{release-body.md: $!}; my $body = do { local $/; <$in> }; close($in); # Byte-oriented escaping: JSON is UTF-8, so non-ASCII passes through and only the # characters JSON forbids are rewritten. $body =~ s/([\\"])/\\$1/g; $body =~ s/\t/\\t/g; $body =~ s/\r//g; $body =~ s/\n/\\n/g; $body =~ s/([\x00-\x08\x0b\x0c\x0e-\x1f])/sprintf(q{\u%04x}, ord($1))/ge; my $json = sprintf(qq{{"tag_name":"v%s","name":"v%s","body":"%s","draft":false,"prerelease":false}}, $v, $v, $body); open(my $out, q{>}, q{release.json}) or die qq{release.json: $!}; print $out $json; close($out); print qq{release.json written for v$v\n}; ' - name: Create the release env: GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} GITEA_SERVER_URL: ${{ gitea.server_url }} GITEA_REPOSITORY: ${{ gitea.repository }} run: | perl -e ' my @cmd = (q{curl}, q{-sS}, q{-o}, q{response.json}, q{-w}, q{%{http_code}}, q{-H}, qq{Authorization: token $ENV{GITEA_TOKEN}}, q{-H}, q{Content-Type: application/json}, q{-X}, q{POST}, qq{$ENV{GITEA_SERVER_URL}/api/v1/repos/$ENV{GITEA_REPOSITORY}/releases}, q{--data-binary}, q{@release.json}); open(my $curl, q{-|}, @cmd) or die qq{curl: $!}; my $code = <$curl>; my $ok = close($curl); my $exit = $? >> 8; $code = defined $code ? $code : q{}; $ok or die qq{ERROR: curl failed (exit $exit) calling $ENV{GITEA_SERVER_URL}\n}; open(my $r, q{<:raw}, q{response.json}) or die qq{response.json: $!}; my $body = do { local $/; <$r> }; close($r); $code eq q{201} or die qq{ERROR: the release was not created, HTTP $code: $body\n}; $body =~ m{"id"\s*:\s*([0-9]+)} or die qq{ERROR: no release id in the response: $body\n}; print qq{release id $1\n}; '