ci: release chain — shared workflows, semver wording, source-only goreleaser, version stamping

This commit is contained in:
2026-08-20 21:48:11 +01:00
parent 7621733c88
commit 64eef947a7
8 changed files with 339 additions and 8 deletions
+23
View File
@@ -0,0 +1,23 @@
name: Pull Request
on:
pull_request:
branches:
- main
push:
branches:
- "**"
- "!main"
permissions:
contents: read
pull-requests: write
security-events: write
jobs:
pr-checks:
uses: lukaszraczylo/shared-actions/.github/workflows/go-pr.yaml@main
with:
go-version: "1.25.x"
coverage-threshold: 75
secrets: inherit
+23
View File
@@ -0,0 +1,23 @@
name: Release
on:
push:
branches:
- main
paths:
- "**.go"
- "go.mod"
- "go.sum"
workflow_dispatch:
permissions:
id-token: write
contents: write
packages: write
jobs:
release:
uses: lukaszraczylo/shared-actions/.github/workflows/go-release.yaml@main
with:
go-version: "1.25.x"
secrets: inherit
+61
View File
@@ -0,0 +1,61 @@
version: 2
# Traefik plugins are source-only - no binary builds
# Traefik loads plugins via Yaegi interpreter at runtime
builds:
- skip: true
# Create source archive for GitHub releases
archives:
- formats: [tar.gz]
name_template: "{{ .ProjectName }}_v{{ .Version }}_source"
files:
- "*.go"
- "**/*.go"
- go.mod
- go.sum
- .traefik.yml
- LICENSE*
- README*
# Exclude test files and non-shipped dirs from release archive
- "!**/*_test.go"
- "!testdata/**"
- "!tools/**"
- "!integration/**"
- "!docs/**"
- "!examples/**"
- "!vendor/**"
- "!.superpowers/**"
checksum:
name_template: "{{ .ProjectName }}_v{{ .Version }}_checksums.txt"
algorithm: sha256
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
- "^Merge"
- "^WIP"
- "^chore:"
release:
github:
owner: lukaszraczylo
name: traefik-llmgateway
name_template: "v{{ .Version }}"
draft: false
prerelease: auto
signs:
- cmd: cosign
signature: "${artifact}.sigstore.json"
args:
- sign-blob
- "--bundle=${signature}"
- "${artifact}"
- "--yes"
artifacts: checksum
output: true
-8
View File
@@ -8,14 +8,6 @@ import (
"time"
)
// pluginVersion is the gateway's own build/plugin version string, surfaced
// read-only in GET /admin/api/overview (spec §4, v0.2). Bumped alongside
// the module's own release process — not read from anywhere else in the
// package, since Yaegi interpretation has no build-info mechanism to pull
// it from automatically. Keep in sync with the release tag: bump this
// alongside `git tag`, not before or after it.
const pluginVersion = "0.2.0"
// The three routes the read-only admin dashboard registers (spec §4,
// v0.2), matched only when adminEnabled(g.cfg) — see llmgateway.go's
// ServeHTTP dispatch.
+10
View File
@@ -0,0 +1,10 @@
version: 1
force:
existing: true
wording:
patch:
- patch-release
minor:
- minor-release
major:
- breaking
+15
View File
@@ -0,0 +1,15 @@
package traefikllmgateway
// pluginVersion is the released version of this plugin. It is stamped at
// release time by ./workflow-prepare.sh (invoked by the shared go-release
// workflow before GoReleaser builds and tags), which rewrites the string
// below to the computed semver.
//
// Traefik runs this plugin under Yaegi, where the version cannot be
// resolved from build info at runtime (debug.ReadBuildInfo sees Traefik's
// build graph, not the interpreted plugin). This build-stamped constant is
// therefore the single source of truth for the version surfaced read-only
// in GET /admin/api/overview (spec §4, v0.2). Local/dev/test builds carry
// the "0.0.0-dev" sentinel below, since nothing has stamped them. Not read
// from anywhere else in the package.
const pluginVersion = "0.0.0-dev"
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
#
# workflow-prepare.sh — stamp the release version into version.go at build time.
#
# The shared go-release workflow (lukaszraczylo/shared-actions go-release.yaml)
# runs this script, if present, from the repository root BEFORE GoReleaser
# builds and tags. Traefik runs this plugin under Yaegi, where the version
# cannot be resolved from build info at runtime, so the released semver must be
# baked into source here.
#
# Version source — first non-empty wins:
# $VERSION $VERSION_TAG $SEMVER $NEW_VERSION $RELEASE_VERSION
# A leading "v"/"V" is stripped.
#
# NOTE: go-release.yaml @main does not yet pass the computed version into this
# step's environment. Add it to the "Run workflow prepare script" step, e.g.:
# env:
# VERSION: ${{ needs.version.outputs.version }} # bare, no leading v
#
# The shared workflow runs this script in its test, version AND release jobs,
# but only the release job has a computed version. So a missing version is a
# no-op (leave the dev sentinel) — NOT a hard failure, otherwise the test/version
# jobs would break. A malformed version that IS provided is a hard error. Wire
# the env only on the release job's prepare step (see header note above).
set -euo pipefail
FILE="version.go"
CONST="pluginVersion"
VER="${VERSION:-${VERSION_TAG:-${SEMVER:-${NEW_VERSION:-${RELEASE_VERSION:-}}}}}"
VER="${VER#v}"
VER="${VER#V}"
if [ -z "$VER" ]; then
if [ "${GITHUB_ACTIONS:-}" = "true" ]; then
echo "workflow-prepare: WARNING no version provided; leaving ${FILE} at the dev placeholder. If this is the release build, set 'env: VERSION: \${{ needs.version.outputs.version }}' on the release job's prepare step — otherwise the release ships 0.0.0-dev and the admin overview reports it." >&2
else
echo "workflow-prepare: no version provided; leaving dev placeholder in ${FILE} (local build)"
fi
exit 0
fi
# Accept MAJOR[.MINOR[.PATCH]] with optional -prerelease / +build (semver-ish).
if ! printf '%s' "$VER" | grep -Eq '^[0-9]+(\.[0-9]+){0,2}(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$'; then
echo "workflow-prepare: ERROR version '${VER}' is not semver-shaped" >&2
exit 1
fi
if [ ! -f "$FILE" ]; then
echo "workflow-prepare: ERROR ${FILE} not found (run from repository root)" >&2
exit 1
fi
# Rewrite only the value of ${CONST}, anchored on the constant name.
tmp="$(mktemp)"
sed -E "s/(${CONST}[[:space:]]*=[[:space:]]*\")[^\"]*(\")/\1${VER}\2/" "$FILE" > "$tmp"
mv "$tmp" "$FILE"
if ! grep -Eq "${CONST}[[:space:]]*=[[:space:]]*\"${VER}\"" "$FILE"; then
echo "workflow-prepare: ERROR failed to stamp version into ${FILE}" >&2
exit 1
fi
command -v gofmt >/dev/null 2>&1 && gofmt -w "$FILE"
echo "workflow-prepare: stamped ${CONST} = \"${VER}\" in ${FILE}"
+142
View File
@@ -0,0 +1,142 @@
package traefikllmgateway
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// copyWorkflowPrepareFixture copies the repository's workflow-prepare.sh and
// version.go into a fresh temp directory and returns that directory. The
// script hardcodes "version.go" relative to its own working directory (see
// workflow-prepare.sh's FILE var), so the test runs it against a throwaway
// copy — never against the tracked version.go, which must keep carrying the
// "0.0.0-dev" sentinel in this package's own source.
func copyWorkflowPrepareFixture(t *testing.T) string {
t.Helper()
dir := t.TempDir()
for _, name := range []string{"workflow-prepare.sh", "version.go"} {
src, err := os.ReadFile(name)
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
if err := os.WriteFile(filepath.Join(dir, name), src, 0o600); err != nil { //nolint:gosec // G703: name ranges over a fixed literal slice above, not external input; dir is t.TempDir()
t.Fatalf("write %s: %v", name, err)
}
}
return dir
}
// runWorkflowPrepare runs `bash workflow-prepare.sh` with dir as the working
// directory (the fixture from copyWorkflowPrepareFixture) and env appended
// as extra VAR=value entries. Go's exec.Cmd documents that a later duplicate
// key in Env wins, so entries here override any same-named var inherited
// from the test process.
func runWorkflowPrepare(t *testing.T, dir string, env ...string) (stdout, stderr string, exitCode int) {
t.Helper()
cmd := exec.Command("bash", "workflow-prepare.sh")
cmd.Dir = dir
cmd.Env = append(os.Environ(), env...)
var out, errOut strings.Builder
cmd.Stdout = &out
cmd.Stderr = &errOut
err := cmd.Run()
if err != nil {
exitErr, ok := err.(*exec.ExitError)
if !ok {
t.Fatalf("run workflow-prepare.sh: %v", err)
}
return out.String(), errOut.String(), exitErr.ExitCode()
}
return out.String(), errOut.String(), 0
}
func readFixtureVersion(t *testing.T, dir string) string {
t.Helper()
got, err := os.ReadFile(filepath.Join(dir, "version.go"))
if err != nil {
t.Fatalf("read stamped version.go: %v", err)
}
return string(got)
}
func TestWorkflowPrepare(t *testing.T) {
t.Parallel()
t.Run("good: stamps VERSION into the const", func(t *testing.T) {
t.Parallel()
dir := copyWorkflowPrepareFixture(t)
stdout, stderr, code := runWorkflowPrepare(t, dir, "VERSION=9.9.9")
if code != 0 {
t.Fatalf("exit = %d, want 0; stdout=%s stderr=%s", code, stdout, stderr)
}
got := readFixtureVersion(t, dir)
if !strings.Contains(got, `const pluginVersion = "9.9.9"`) {
t.Errorf("version.go does not contain the stamped const, got:\n%s", got)
}
if strings.Contains(got, `const pluginVersion = "0.0.0-dev"`) {
t.Errorf("version.go const still carries the dev sentinel after stamping:\n%s", got)
}
})
t.Run("good: strips a leading v from VERSION", func(t *testing.T) {
t.Parallel()
dir := copyWorkflowPrepareFixture(t)
_, stderr, code := runWorkflowPrepare(t, dir, "VERSION=v1.2.3")
if code != 0 {
t.Fatalf("exit = %d, want 0; stderr=%s", code, stderr)
}
got := readFixtureVersion(t, dir)
if !strings.Contains(got, `pluginVersion = "1.2.3"`) {
t.Errorf("leading 'v' not stripped, got:\n%s", got)
}
})
t.Run("edge: no version env leaves the sentinel untouched", func(t *testing.T) {
t.Parallel()
dir := copyWorkflowPrepareFixture(t)
_, _, code := runWorkflowPrepare(t, dir,
"VERSION=", "VERSION_TAG=", "SEMVER=", "NEW_VERSION=", "RELEASE_VERSION=", "GITHUB_ACTIONS=")
if code != 0 {
t.Fatalf("exit = %d, want 0 (no-op) when no version env is set", code)
}
got := readFixtureVersion(t, dir)
if !strings.Contains(got, `pluginVersion = "0.0.0-dev"`) {
t.Errorf("version.go mutated on a no-op run, got:\n%s", got)
}
})
t.Run("bad: malformed VERSION exits non-zero and leaves the file untouched", func(t *testing.T) {
t.Parallel()
dir := copyWorkflowPrepareFixture(t)
_, stderr, code := runWorkflowPrepare(t, dir, "VERSION=not-a-version")
if code == 0 {
t.Fatalf("exit = 0, want non-zero for malformed VERSION")
}
if !strings.Contains(stderr, "not semver-shaped") {
t.Errorf("stderr missing the semver error, got: %s", stderr)
}
got := readFixtureVersion(t, dir)
if !strings.Contains(got, `pluginVersion = "0.0.0-dev"`) {
t.Errorf("version.go was mutated despite the malformed VERSION, got:\n%s", got)
}
})
}