release: build images via workflow dispatch
Plugin versions resolve through the Go module proxy before the build so the
GHA layer cache cannot reuse a stale xcaddy layer.

Assisted-by: Claude Opus 5 via Claude Code
change mzoxukkykotrnvonoltxsmvqttztoqqv
commit 2d93e02c5da86054283135f8c21f84950c0e0d51
author Alpha Chen <alpha@kejadlen.dev>
date
parent smyrxkss
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
deleted file mode 100644
index d067bd4..0000000
--- a/.github/workflows/ci.yml
+++ /dev/null
@@ -1,52 +0,0 @@
-name: ci
-
-on:
-  push:
-    tags:
-      - 'v*'
-
-jobs:
-  ci:
-    runs-on: ubuntu-latest
-    permissions:
-      contents: read
-      packages: write
-
-    steps:
-      - name: Checkout repository
-        uses: actions/checkout@v4
-
-      - name: Set up Docker Buildx
-        uses: docker/setup-buildx-action@v3
-
-      - name: Extract metadata (tags, labels) for Docker
-        id: meta
-        uses: docker/metadata-action@v5
-        with:
-          images: ghcr.io/kejadlen/caddy-tailscale
-          tags: |
-            type=semver,pattern={{version}}
-
-      - name: Log in to the Container registry
-        uses: docker/login-action@v3
-        with:
-          registry: ghcr.io
-          username: ${{ github.actor }}
-          password: ${{ secrets.GITHUB_TOKEN }}
-
-      - name: Strip the v prefix from the tag
-        run: |
-          TAG=${{ github.ref_name }}
-          echo "VERSION=${TAG#v}" >> $GITHUB_ENV
-
-      - name: Build and push Docker image
-        uses: docker/build-push-action@v6
-        with:
-          context: .
-          push: true
-          build-args: |
-            CADDY_VERSION=${{ env.VERSION }}
-          tags: ${{ steps.meta.outputs.tags }}
-          labels: ${{ steps.meta.outputs.labels }}
-          cache-from: type=gha
-          cache-to: type=gha,mode=max
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..aa6c359
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,137 @@
+name: release
+
+on:
+  workflow_dispatch:
+    inputs:
+      caddy-version:
+        description: Caddy version, like 2.11.4 (defaults to the latest release)
+        required: false
+        type: string
+      caddy-tailscale-version:
+        description: caddy-tailscale version, commit, or branch (defaults to the latest)
+        required: false
+        type: string
+      caddy-ratelimit-version:
+        description: caddy-ratelimit version, commit, or branch (defaults to the latest)
+        required: false
+        type: string
+
+permissions: {}
+
+concurrency:
+  group: release
+  cancel-in-progress: false
+
+jobs:
+  release:
+    runs-on: ubuntu-latest
+    permissions:
+      contents: read  # checkout.
+      packages: write # push image to GHCR.
+
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+        with:
+          persist-credentials: false
+
+      - name: Resolve versions
+        id: versions
+        env:
+          GH_TOKEN: ${{ github.token }}
+          INPUTS_CADDY_VERSION: ${{ inputs.caddy-version }}
+          INPUTS_CADDY_TAILSCALE_VERSION: ${{ inputs.caddy-tailscale-version }}
+          INPUTS_CADDY_RATELIMIT_VERSION: ${{ inputs.caddy-ratelimit-version }}
+        run: |
+          set -euo pipefail
+
+          latest=$(gh api repos/caddyserver/caddy/releases/latest --jq .tag_name)
+          latest=${latest#v}
+          caddy=${INPUTS_CADDY_VERSION#v}
+          caddy=${caddy:-$latest}
+          if [[ ! $caddy =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+            echo "::error::Caddy version '$caddy' isn't in X.Y.Z form."
+            exit 1
+          fi
+          if ! docker manifest inspect "caddy:$caddy-builder" > /dev/null; then
+            echo "::error::caddy:$caddy-builder isn't on Docker Hub. Check the version, or wait for the official image to publish it."
+            exit 1
+          fi
+
+          # Pin plugins to concrete module versions so the build cache can't serve stale ones.
+          resolve() {
+            local module=$1 query=$2 url info
+            if [[ ! $query =~ ^[a-z0-9._-]*$ ]]; then
+              echo "::error::$module version '$query' may only contain lowercase letters, digits, '.', '_', and '-'." >&2
+              return 1
+            fi
+            if [[ -z $query ]]; then
+              url="https://proxy.golang.org/$module/@latest"
+            else
+              url="https://proxy.golang.org/$module/@v/$query.info"
+            fi
+            if ! info=$(curl -fsSL "$url"); then
+              echo "::error::The Go module proxy couldn't resolve $module@${query:-latest}." >&2
+              return 1
+            fi
+            jq -r .Version <<< "$info"
+          }
+          tailscale=$(resolve github.com/tailscale/caddy-tailscale "$INPUTS_CADDY_TAILSCALE_VERSION")
+          ratelimit=$(resolve github.com/mholt/caddy-ratelimit "$INPUTS_CADDY_RATELIMIT_VERSION")
+
+          is_latest=false
+          [[ $caddy == "$latest" ]] && is_latest=true
+
+          {
+            echo "caddy=$caddy"
+            echo "calver=$(date -u +%Y.%m.%d)"
+            echo "latest=$is_latest"
+            echo "tailscale=$tailscale"
+            echo "ratelimit=$ratelimit"
+          } >> "$GITHUB_OUTPUT"
+
+          {
+            echo "| Component | Version |"
+            echo "| --- | --- |"
+            echo "| Caddy | $caddy |"
+            echo "| caddy-tailscale | $tailscale |"
+            echo "| caddy-ratelimit | $ratelimit |"
+          } >> "$GITHUB_STEP_SUMMARY"
+
+      - name: Set up Docker Buildx
+        uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
+
+      - name: Extract metadata (tags, labels) for Docker
+        id: meta
+        uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
+        with:
+          images: ghcr.io/kejadlen/caddy-tailscale
+          flavor: latest=false
+          tags: |
+            type=raw,value=${{ steps.versions.outputs.caddy }}-${{ steps.versions.outputs.calver }}
+            type=raw,value=${{ steps.versions.outputs.caddy }}
+            type=raw,value=latest,enable=${{ steps.versions.outputs.latest }}
+          labels: |
+            dev.kejadlen.caddy-tailscale.version=${{ steps.versions.outputs.tailscale }}
+            dev.kejadlen.caddy-ratelimit.version=${{ steps.versions.outputs.ratelimit }}
+
+      - name: Log in to the Container registry
+        uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
+        with:
+          registry: ghcr.io
+          username: ${{ github.actor }}
+          password: ${{ secrets.GITHUB_TOKEN }}
+
+      - name: Build and push Docker image
+        uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
+        with:
+          context: .
+          push: true
+          build-args: |
+            CADDY_VERSION=${{ steps.versions.outputs.caddy }}
+            CADDY_TAILSCALE_VERSION=${{ steps.versions.outputs.tailscale }}
+            CADDY_RATELIMIT_VERSION=${{ steps.versions.outputs.ratelimit }}
+          tags: ${{ steps.meta.outputs.tags }}
+          labels: ${{ steps.meta.outputs.labels }}
+          cache-from: type=gha
+          cache-to: type=gha,mode=max
diff --git a/Dockerfile b/Dockerfile
index 316a47e..9931636 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -2,9 +2,13 @@ ARG CADDY_VERSION
 
 FROM caddy:${CADDY_VERSION}-builder AS builder
 
+ARG CADDY_RATELIMIT_VERSION
+ARG CADDY_TAILSCALE_VERSION
+
+# An empty plugin version lets xcaddy pick the latest.
 RUN xcaddy build \
-    --with github.com/mholt/caddy-ratelimit \
-    --with github.com/tailscale/caddy-tailscale
+    --with "github.com/mholt/caddy-ratelimit${CADDY_RATELIMIT_VERSION:+@$CADDY_RATELIMIT_VERSION}" \
+    --with "github.com/tailscale/caddy-tailscale${CADDY_TAILSCALE_VERSION:+@$CADDY_TAILSCALE_VERSION}"
 
 FROM caddy:${CADDY_VERSION}
 
diff --git a/justfile b/justfile
index 1ecec6f..730d295 100644
--- a/justfile
+++ b/justfile
@@ -1,4 +1,6 @@
-release version:
-    docker manifest inspect caddy:{{version}} > /dev/null
-    jj tag set v{{version}} -r @-
-    git push origin tag v{{version}}
+# Empty versions default to the latest.
+release caddy="" tailscale="" ratelimit="":
+    gh workflow run release.yml \
+        -f caddy-version={{caddy}} \
+        -f caddy-tailscale-version={{tailscale}} \
+        -f caddy-ratelimit-version={{ratelimit}}