# Release, Go library. Runs on a version tag (v1.2.3) pushed to main. # # The pipeline does one thing: it publishes the release from the tag, notes and # all. No gate runs here. The tagged tree was tested on every push to # development, the deep suite is the suite workflow's business, and race never # runs in CI at all. A library ships no assets, so there is no build and # nothing to upload. # # Every step is one command, and the scripted steps are Perl with builtins # only. Perl drives curl through a list, so no argument is ever word-split, # globbed or quoted wrong. name: Release on: push: tags: ["v*"] jobs: release: runs-on: fedora 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: 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: 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 steps. 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 v$ENV{VERSION} is live (id $1)\n}; '