From 20e4960b8e4a5d2c7b8c60e0ff22280cef6bd63d Mon Sep 17 00:00:00 2001 From: Sam Calder-Mason Date: Fri, 17 Jul 2026 17:08:27 +1000 Subject: [PATCH] feat(ci): auto-release on lab frontend dispatch with exact version pin The release workflow now also triggers on a frontend-release dispatch from ethpandaops/lab (and on workflow_dispatch), computing the next tag and pushing it itself, so backend releases no longer need a manual tag. The embedded frontend release is resolved once in the workflow and pinned exactly through the Makefile, goreleaser ldflags, Docker build args, an image label, and the release notes header, fixing the empty frontend_version in production images. Claude-Session: https://claude.ai/code/session_01MW57Wwhb2PA4rjwP7PBv1b --- .github/workflows/goreleaser.yaml | 112 --------------------- .github/workflows/release.yaml | 156 ++++++++++++++++++++++++++++++ .gitignore | 1 - .goreleaser.yaml | 12 +++ Dockerfile | 4 +- Makefile | 6 +- README.md | 5 + RELEASING.md | 12 +++ internal/version/version.go | 12 ++- internal/version/version_test.go | 17 ++++ 10 files changed, 220 insertions(+), 117 deletions(-) delete mode 100644 .github/workflows/goreleaser.yaml create mode 100644 .github/workflows/release.yaml create mode 100644 RELEASING.md diff --git a/.github/workflows/goreleaser.yaml b/.github/workflows/goreleaser.yaml deleted file mode 100644 index 66116b7..0000000 --- a/.github/workflows/goreleaser.yaml +++ /dev/null @@ -1,112 +0,0 @@ -name: goreleaser - -on: - push: - tags: - - '*' - workflow_dispatch: - inputs: - frontend_branch: - description: 'Frontend branch to use (leave empty for latest stable release)' - required: false - default: '' - -jobs: - goreleaser: - permissions: - contents: write - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 0 - ref: ${{ github.ref }} - - - name: Derive release suffix from tag (if it exists) - run: | - # Strip the 'refs/tags/' prefix - TAG_NAME=${GITHUB_REF#refs/tags/} - - # Extract suffix from tag name after the last '-' (e.g., 'dencun' from 'v1.0.0-dencun') - RELEASE_SUFFIX=${TAG_NAME##*-} - - # Check if the suffix is still a version pattern (e.g., 'v0.0.44'), in which case there's no suffix - if [[ $RELEASE_SUFFIX =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - RELEASE_SUFFIX="" - fi - - echo "RELEASE_SUFFIX=$RELEASE_SUFFIX" >> $GITHUB_ENV - - echo "Release suffix: $RELEASE_SUFFIX" - - - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 - with: - go-version: '1.25.1' - - - name: Run apt-get update - run: sudo apt-get update - - - name: Install cross-compiler for linux/arm64 - run: sudo apt-get -y install gcc-aarch64-linux-gnu - - - name: Install make - run: sudo apt-get -y install make - - - name: Set frontend branch - run: | - # Use workflow input if provided, otherwise use latest stable release - if [ "${{ github.event.inputs.frontend_branch }}" != "" ]; then - echo "FRONTEND_BRANCH=${{ github.event.inputs.frontend_branch }}" >> $GITHUB_ENV - fi - # If FRONTEND_BRANCH is empty, Makefile will use latest stable release - - - name: Download and setup frontend - run: | - echo "Using frontend branch: $FRONTEND_BRANCH" - FRONTEND_BRANCH=$FRONTEND_BRANCH make setup-frontend - - - name: Set up QEMU - uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0 - - - name: Set up Docker Context for Buildx - shell: bash - id: buildx-context - run: | - docker context create builders - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - with: - endpoint: builders - - - name: Login to DockerHub - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Update GoReleaser config - run: | - cp .goreleaser.yaml .goreleaser.yaml.new - - # If we have a RELEASE_SUFFIX, update the goreleaser config to not set - # the release as the latest - if [[ -n "$RELEASE_SUFFIX" ]]; then - echo "release:" >> .goreleaser.yaml.new - echo " prerelease: true" >> .goreleaser.yaml.new - echo " make_latest: false" >> .goreleaser.yaml.new - fi - - - name: Run GoReleaser in Docker - run: | - docker run --rm \ - -v ${{ github.workspace }}:/workspace \ - -w /workspace \ - -e GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }} \ - -e DOCKER_USERNAME=${{ secrets.DOCKERHUB_USERNAME }} \ - -e DOCKER_PASSWORD=${{ secrets.DOCKERHUB_TOKEN }} \ - -v /var/run/docker.sock:/var/run/docker.sock \ - -e RELEASE_SUFFIX=${{ env.RELEASE_SUFFIX }} \ - goreleaser/goreleaser-cross:v1.26.2 release --clean --config .goreleaser.yaml.new diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..9071ef2 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,156 @@ +name: release + +# Builds and publishes a release with an exact frontend release embedded. +# +# Triggered three ways: +# - repository_dispatch from ethpandaops/lab when it publishes a stable +# release: bumps the patch version, tags, and releases automatically. +# - workflow_dispatch: same auto-tagging, with an optional frontend_tag +# override (alpha frontend tags produce a suffixed prerelease). +# - tag push: the manual flow, embedding the latest stable frontend. + +on: + push: + tags: + - '*' + repository_dispatch: + types: [frontend-release] + workflow_dispatch: + inputs: + frontend_tag: + description: 'Frontend release tag to embed (empty = latest stable lab release)' + required: false + default: '' + +# Serialized so concurrent runs cannot compute the same next tag. +concurrency: + group: release + +jobs: + release: + permissions: + contents: write + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + ref: ${{ github.ref }} + + - name: Resolve frontend release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + FRONTEND_TAG="" + if [ "${{ github.event_name }}" = "repository_dispatch" ]; then + FRONTEND_TAG="${{ github.event.client_payload.frontend_tag }}" + elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + FRONTEND_TAG="${{ inputs.frontend_tag }}" + fi + + if [ -z "$FRONTEND_TAG" ]; then + FRONTEND_TAG=$(gh api repos/ethpandaops/lab/releases/latest --jq .tag_name) + fi + + if [ -z "$FRONTEND_TAG" ]; then + echo "Could not resolve a frontend release tag" >&2 + exit 1 + fi + + echo "Frontend release: $FRONTEND_TAG" + echo "FRONTEND_TAG=$FRONTEND_TAG" >> $GITHUB_ENV + + - name: Resolve release tag + run: | + if [ "${{ github.event_name }}" = "push" ]; then + TAG_NAME=${GITHUB_REF#refs/tags/} + + # Anything after the semver is the suffix (e.g. 'fusaka' from + # 'v1.3.45-fusaka'); a plain semver tag has none. + RELEASE_SUFFIX=$(echo "$TAG_NAME" | sed -E 's/^v[0-9]+\.[0-9]+\.[0-9]+-?//') + else + # Alpha frontend tags ('fusaka-v0.0.3') carry their name over as + # the release suffix; stable frontend tags ('v1.0.70') have none. + RELEASE_SUFFIX="" + if [[ ! $FRONTEND_TAG =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + RELEASE_SUFFIX=$(echo "$FRONTEND_TAG" | sed -E 's/-v[0-9]+\.[0-9]+\.[0-9]+$//') + fi + + LATEST_TAG=$(git tag -l 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -n 1) + if [ -z "$LATEST_TAG" ]; then + TAG_NAME="v0.0.1" + else + VERSION="${LATEST_TAG#v}" + IFS='.' read -r major minor patch <<< "$VERSION" + TAG_NAME="v${major}.${minor}.$((patch + 1))" + fi + + if [ -n "$RELEASE_SUFFIX" ]; then + TAG_NAME="${TAG_NAME}-${RELEASE_SUFFIX}" + fi + fi + + echo "Release tag: $TAG_NAME (suffix: ${RELEASE_SUFFIX:-none})" + echo "TAG_NAME=$TAG_NAME" >> $GITHUB_ENV + echo "RELEASE_SUFFIX=$RELEASE_SUFFIX" >> $GITHUB_ENV + + # Tags pushed with GITHUB_TOKEN do not trigger the tag-push path of + # this workflow, so the dispatch paths never double-build. + - name: Create and push tag + if: github.event_name != 'push' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag "$TAG_NAME" + git push origin "$TAG_NAME" + + - name: Set up Go + uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 + with: + go-version: '1.25.1' + + - name: Run apt-get update + run: sudo apt-get update + + - name: Install cross-compiler for linux/arm64 + run: sudo apt-get -y install gcc-aarch64-linux-gnu + + - name: Install make + run: sudo apt-get -y install make + + - name: Download and setup frontend + run: FRONTEND_TAG=$FRONTEND_TAG make setup-frontend + + - name: Set up QEMU + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 + + - name: Set up Docker Context for Buildx + shell: bash + id: buildx-context + run: | + docker context create builders + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + with: + endpoint: builders + + - name: Login to DockerHub + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Run GoReleaser in Docker + run: | + docker run --rm \ + -v ${{ github.workspace }}:/workspace \ + -w /workspace \ + -e GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }} \ + -e DOCKER_USERNAME=${{ secrets.DOCKERHUB_USERNAME }} \ + -e DOCKER_PASSWORD=${{ secrets.DOCKERHUB_TOKEN }} \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -e RELEASE_SUFFIX=${{ env.RELEASE_SUFFIX }} \ + -e FRONTEND_TAG=${{ env.FRONTEND_TAG }} \ + goreleaser/goreleaser-cross:v1.26.2 release --clean diff --git a/.gitignore b/.gitignore index 118623e..09a9c86 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,6 @@ dist/ *.dll *.so *.dylib -.goreleaser.yaml.new # Test coverage *.out diff --git a/.goreleaser.yaml b/.goreleaser.yaml index bd4e203..a21aebe 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -20,6 +20,7 @@ builds: - -X github.com/ethpandaops/lab-backend/internal/version.Version={{.Tag}} - -X github.com/ethpandaops/lab-backend/internal/version.GitCommit={{.FullCommit}} - -X github.com/ethpandaops/lab-backend/internal/version.BuildDate={{.Date}} + - -X github.com/ethpandaops/lab-backend/internal/version.FrontendVersion={{ envOrDefault "FRONTEND_TAG" "" }} mod_timestamp: "{{ .CommitTimestamp }}" - id: linux-arm64 @@ -36,7 +37,14 @@ builds: - -X github.com/ethpandaops/lab-backend/internal/version.Version={{.Tag}} - -X github.com/ethpandaops/lab-backend/internal/version.GitCommit={{.FullCommit}} - -X github.com/ethpandaops/lab-backend/internal/version.BuildDate={{.Date}} + - -X github.com/ethpandaops/lab-backend/internal/version.FrontendVersion={{ envOrDefault "FRONTEND_TAG" "" }} mod_timestamp: "{{ .CommitTimestamp }}" +release: + prerelease: auto + make_latest: '{{ not .Prerelease }}' + header: | + Embedded frontend: [ethpandaops/lab {{ envOrDefault "FRONTEND_TAG" "unknown" }}](https://github.com/ethpandaops/lab/releases/tag/{{ envOrDefault "FRONTEND_TAG" "" }}) + checksum: name_template: 'checksums.txt' snapshot: @@ -71,6 +79,8 @@ dockers: - "--build-arg=VERSION={{.Tag}}" - "--build-arg=GIT_COMMIT={{.FullCommit}}" - "--build-arg=BUILD_DATE={{.Date}}" + - '--build-arg=FRONTEND_VERSION={{ envOrDefault "FRONTEND_TAG" "" }}' + - '--label=io.ethpandaops.frontend.version={{ envOrDefault "FRONTEND_TAG" "" }}' - "--label=org.opencontainers.image.created={{.Date}}" - "--label=org.opencontainers.image.title={{.ProjectName}}" - "--label=org.opencontainers.image.revision={{.FullCommit}}" @@ -97,6 +107,8 @@ dockers: - "--build-arg=VERSION={{.Tag}}" - "--build-arg=GIT_COMMIT={{.FullCommit}}" - "--build-arg=BUILD_DATE={{.Date}}" + - '--build-arg=FRONTEND_VERSION={{ envOrDefault "FRONTEND_TAG" "" }}' + - '--label=io.ethpandaops.frontend.version={{ envOrDefault "FRONTEND_TAG" "" }}' - "--label=org.opencontainers.image.created={{.Date}}" - "--label=org.opencontainers.image.title={{.ProjectName}}" - "--label=org.opencontainers.image.revision={{.FullCommit}}" diff --git a/Dockerfile b/Dockerfile index aab7aa2..ef2b6f5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,13 +11,15 @@ COPY . . ARG VERSION=dev ARG GIT_COMMIT=dev ARG BUILD_DATE=unknown +ARG FRONTEND_VERSION= # Build the binary directly (frontend already included via goreleaser extra_files) RUN mkdir -p bin && \ go build -ldflags="-w -s \ -X github.com/ethpandaops/lab-backend/internal/version.Version=${VERSION} \ -X github.com/ethpandaops/lab-backend/internal/version.GitCommit=${GIT_COMMIT} \ - -X github.com/ethpandaops/lab-backend/internal/version.BuildDate=${BUILD_DATE}" \ + -X github.com/ethpandaops/lab-backend/internal/version.BuildDate=${BUILD_DATE} \ + -X github.com/ethpandaops/lab-backend/internal/version.FrontendVersion=${FRONTEND_VERSION}" \ -o bin/lab-backend ./cmd/server # Runtime stage diff --git a/Makefile b/Makefile index 9b8b0f1..c32fc9e 100644 --- a/Makefile +++ b/Makefile @@ -26,6 +26,7 @@ all: build # Frontend configuration FRONTEND_SOURCE ?= +FRONTEND_TAG ?= FRONTEND_BRANCH ?= FRONTEND_TARGET ?= web/frontend FRONTEND_VERSION_FILE ?= .tmp/frontend-version.txt @@ -57,7 +58,10 @@ setup-frontend: echo "dev" > $(FRONTEND_VERSION_FILE); \ printf "$(GREEN)✓ Copied $(FRONTEND_SOURCE)/dist -> $(FRONTEND_TARGET)$(RESET)\n"; \ else \ - if [ -n "$(FRONTEND_BRANCH)" ]; then \ + if [ -n "$(FRONTEND_TAG)" ]; then \ + printf "$(YELLOW)Frontend tag: $(FRONTEND_TAG)$(RESET)\n"; \ + RELEASE_TAG="$(FRONTEND_TAG)"; \ + elif [ -n "$(FRONTEND_BRANCH)" ]; then \ printf "$(YELLOW)Frontend branch: $(FRONTEND_BRANCH)$(RESET)\n"; \ RELEASE_TAG=$$(curl -s "https://api.github.com/repos/$(GITHUB_REPO)/releases" | \ grep -o '"tag_name": *"[^"]*"' | \ diff --git a/README.md b/README.md index 620655f..8ed89a8 100644 --- a/README.md +++ b/README.md @@ -56,9 +56,14 @@ FRONTEND_BRANCH=develop make run **Environment Variables:** - `FRONTEND_SOURCE` - Path to local frontend source (uses `dist/` directory) +- `FRONTEND_TAG` - Download an exact frontend release (e.g., `v1.0.70`) - `FRONTEND_BRANCH` - Download from specific branch's latest release (e.g., `develop`) - `GITHUB_REPO` - GitHub repository for frontend releases (default: `ethpandaops/lab`) +## Releasing + +See [RELEASING.md](RELEASING.md). Stable releases are cut automatically when [lab](https://github.com/ethpandaops/lab) publishes a release. + ## Configuration Copy `config.example.yaml` to `config.yaml` and configure: diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..c1c6828 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,12 @@ +# Releasing + +Releases are normally cut automatically: when [lab](https://github.com/ethpandaops/lab) publishes a stable release, it sends a `frontend-release` dispatch to this repo. The release workflow then bumps the patch version, tags it, and runs goreleaser with that exact frontend release pinned. + +Manual options: + +- **Run the release workflow** (`gh workflow run release.yaml`): cuts the next patch release. Leave `frontend_tag` empty to embed the latest stable lab release, or set it to any lab release tag — including alpha tags like `fusaka-v0.0.3`, which produce a suffixed backend tag (`v1.3.45-fusaka`) published as a prerelease and tagged `fusaka-latest` on Docker Hub. +- **Push a tag** (`git tag v1.3.45 && git push origin v1.3.45`): the old flow still works and embeds the latest stable lab release. + +Every release records the embedded frontend version in its release notes, in the `io.ethpandaops.frontend.version` Docker label, and in the binary itself (`frontend_version` in the version endpoint output). + +Deploying the image is a separate manual bump of `image.tag` in the [platform](https://github.com/ethpandaops/platform) lab application values. diff --git a/internal/version/version.go b/internal/version/version.go index 7aa43bc..b739875 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -12,6 +12,9 @@ var ( Version = "dev" GitCommit = "unknown" BuildDate = "unknown" + // FrontendVersion is the embedded frontend release tag. Set via ldflags + // for release builds; dev builds fall back to .tmp/frontend-version.txt. + FrontendVersion = "" ) // Info contains version information. @@ -32,10 +35,15 @@ func Get() Info { } // GetWithFrontend returns version information including frontend version. -// It reads the frontend version from .tmp/frontend-version.txt if it exists. +// It prefers the build-time FrontendVersion and falls back to reading +// .tmp/frontend-version.txt for dev builds. func GetWithFrontend() Info { info := Get() - info.FrontendVersion = readFrontendVersion() + + info.FrontendVersion = FrontendVersion + if info.FrontendVersion == "" { + info.FrontendVersion = readFrontendVersion() + } return info } diff --git a/internal/version/version_test.go b/internal/version/version_test.go index 1c72a00..6089558 100644 --- a/internal/version/version_test.go +++ b/internal/version/version_test.go @@ -52,6 +52,23 @@ func TestGetWithFrontend(t *testing.T) { require.NoError(t, os.RemoveAll(".tmp")) }) + t.Run("prefers build-time frontend version over file", func(t *testing.T) { + original := FrontendVersion + FrontendVersion = "v9.9.9" + + defer func() { FrontendVersion = original }() + + require.NoError(t, os.MkdirAll(".tmp", 0o755)) + require.NoError(t, os.WriteFile(".tmp/frontend-version.txt", []byte("frontend-v2.5.0-test"), 0o644)) + + info := GetWithFrontend() + + assert.Equal(t, "v9.9.9", info.FrontendVersion) + + // Cleanup + require.NoError(t, os.RemoveAll(".tmp")) + }) + t.Run("handles missing frontend version file", func(t *testing.T) { // Ensure .tmp directory doesn't exist os.RemoveAll(".tmp")