diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml new file mode 100644 index 0000000..c893496 --- /dev/null +++ b/.github/workflows/pr.yaml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0be7935 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..7b35096 --- /dev/null +++ b/.goreleaser.yaml @@ -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 diff --git a/admin.go b/admin.go index c8824ff..bb95039 100644 --- a/admin.go +++ b/admin.go @@ -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. diff --git a/semver.yaml b/semver.yaml new file mode 100644 index 0000000..0e21b52 --- /dev/null +++ b/semver.yaml @@ -0,0 +1,10 @@ +version: 1 +force: + existing: true +wording: + patch: + - patch-release + minor: + - minor-release + major: + - breaking diff --git a/version.go b/version.go new file mode 100644 index 0000000..c0e5842 --- /dev/null +++ b/version.go @@ -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" diff --git a/workflow-prepare.sh b/workflow-prepare.sh new file mode 100755 index 0000000..65a287a --- /dev/null +++ b/workflow-prepare.sh @@ -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}" diff --git a/workflow_prepare_test.go b/workflow_prepare_test.go new file mode 100644 index 0000000..1c9e61a --- /dev/null +++ b/workflow_prepare_test.go @@ -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) + } + }) +}