From a99f2e461e8f334894f2d33299f5de13319af9f5 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Wed, 23 Sep 2026 11:44:37 +0200 Subject: [PATCH] feat(ci): release pipeline on merge to main After tests + image publish, a release job bumps the tag via conventional commits (github-tag-action, default_bump=false so docs/chore merges skip releasing), retags :main images in GHCR as semver+latest (tag pushes via GITHUB_TOKEN do not retrigger CI), cross-compiles the CLI for 5 platforms, and cuts a GitHub Release with generated notes and checksums. Manual v* tag pushes get the same release treatment. CLI --version now reports the tag via the previously dead -X ldflags. Authored By: TDvorak Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 90 ++++++++++++++++++++++++++++++++ app/backend/cmd/cli/main.go | 8 +++ app/backend/internal/cli/root.go | 6 +++ 3 files changed, 104 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f89d31b..4245aa9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -222,3 +222,93 @@ jobs: ignore-unfixed: true vuln-type: os,library severity: CRITICAL,HIGH + + release: + name: Release + runs-on: ubuntu-latest + needs: [docker] + if: github.event_name != 'pull_request' + permissions: + contents: write + packages: write + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # Conventional-commit bump. Tags pushed with GITHUB_TOKEN do not + # retrigger this workflow, so images are retagged below instead. + - name: Bump version and push tag + if: github.ref == 'refs/heads/main' + id: tag_version + uses: mathieudutour/github-tag-action@v6.2 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + default_bump: false + + - name: Resolve release tag + id: release + run: | + if [ "${{ github.ref_type }}" = "tag" ]; then + tag="${GITHUB_REF_NAME}" + else + tag="${{ steps.tag_version.outputs.new_tag }}" + fi + if [ -z "$tag" ]; then + echo "No releasable commits since last tag - skipping release." + fi + echo "tag=$tag" >> "$GITHUB_OUTPUT" + + - name: Setup Go + if: steps.release.outputs.tag != '' + uses: actions/setup-go@v5 + with: + go-version-file: app/backend/go.mod + cache: true + + - name: Build CLI binaries + if: steps.release.outputs.tag != '' + working-directory: app/backend + env: + VERSION: ${{ steps.release.outputs.tag }} + run: bash ../../scripts/build-cli.sh + + - name: Checksums + if: steps.release.outputs.tag != '' + working-directory: app/backend/bin + run: sha256sum containr-* > checksums.txt + + - name: Login to GHCR + if: steps.release.outputs.tag != '' && github.ref == 'refs/heads/main' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Tag pushes from github-tag-action do not trigger workflows, so the + # release job retags the just-published :main images in-registry. + - name: Tag release images in GHCR + if: steps.release.outputs.tag != '' && github.ref == 'refs/heads/main' + env: + TAG: ${{ steps.release.outputs.tag }} + run: | + repo="${GITHUB_REPOSITORY,,}" + for img in backend frontend; do + docker buildx imagetools create \ + --tag "ghcr.io/${repo}-${img}:${TAG}" \ + --tag "ghcr.io/${repo}-${img}:latest" \ + "ghcr.io/${repo}-${img}:main" + done + + - name: Create GitHub Release + if: steps.release.outputs.tag != '' + working-directory: app/backend/bin + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.release.outputs.tag }} + run: | + gh release create "$TAG" --title "$TAG" --generate-notes \ + containr-* checksums.txt \ + || gh release upload "$TAG" containr-* checksums.txt --clobber diff --git a/app/backend/cmd/cli/main.go b/app/backend/cmd/cli/main.go index 2ba531b..854452b 100644 --- a/app/backend/cmd/cli/main.go +++ b/app/backend/cmd/cli/main.go @@ -6,7 +6,15 @@ import ( "os" ) +// Injected at build time via -ldflags (see scripts/build-cli.sh). +var ( + Version = "dev" + BuildTime = "unknown" + GitCommit = "unknown" +) + func main() { + cli.SetVersion(fmt.Sprintf("%s (%s, %s)", Version, GitCommit, BuildTime)) if err := cli.Run(); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) diff --git a/app/backend/internal/cli/root.go b/app/backend/internal/cli/root.go index 731d5d9..ee9c8ab 100644 --- a/app/backend/internal/cli/root.go +++ b/app/backend/internal/cli/root.go @@ -27,6 +27,12 @@ func Execute() { cobra.CheckErr(rootCmd.Execute()) } +// SetVersion overrides the version reported by `containr --version`. +// Called by main with the value injected via -ldflags at build time. +func SetVersion(v string) { + rootCmd.Version = v +} + func init() { cobra.OnInitialize(initConfig)