mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-13 04:58:50 +00:00
ci: release chain — shared workflows, semver wording, source-only goreleaser, version stamping
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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,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
@@ -0,0 +1,10 @@
|
||||
version: 1
|
||||
force:
|
||||
existing: true
|
||||
wording:
|
||||
patch:
|
||||
- patch-release
|
||||
minor:
|
||||
- minor-release
|
||||
major:
|
||||
- breaking
|
||||
+15
@@ -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"
|
||||
Executable
+65
@@ -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}"
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user