diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e04629ea..85628dd7 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -3,7 +3,6 @@ name: CI/CD Pipeline on: push: branches: [master] - tags: ['v*'] pull_request: branches: [master] workflow_dispatch: diff --git a/.github/workflows/release-fast-path.yml b/.github/workflows/release-fast-path.yml new file mode 100644 index 00000000..dea88a4e --- /dev/null +++ b/.github/workflows/release-fast-path.yml @@ -0,0 +1,111 @@ +name: Release Fast-Path + +# Issue #1677: re-tag :edge as :vX.Y.Z when the tag SHA matches :edge's +# org.opencontainers.image.revision label. Skips ~30 min of Go test + +# Playwright + Docker rebuild because the bytes are identical — only the +# manifest name changes. Falls back to deploy.yml when SHAs differ so +# tags on older commits still go through full validation. +# +# This workflow is the SOLE consumer of push.tags. deploy.yml's tag +# trigger has been removed to prevent double-fire. + +on: + push: + tags: ['v[0-9]+.[0-9]+.[0-9]+'] + +permissions: + contents: read + packages: write + +concurrency: + group: release-fast-path-${{ github.ref }} + cancel-in-progress: false + +jobs: + retag-or-fallback: + name: "🏷️ Re-tag :edge → :vX.Y.Z (fast) or dispatch deploy.yml (fallback)" + runs-on: ubuntu-latest + steps: + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Install crane + uses: imjasonh/setup-crane@v0.4 + + - name: Parse semver from tag + id: semver + run: | + set -euo pipefail + TAG="${GITHUB_REF#refs/tags/}" + # Expect vMAJOR.MINOR.PATCH (workflow trigger already enforces this). + if [[ ! "$TAG" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + echo "Tag $TAG does not match vMAJOR.MINOR.PATCH" >&2 + exit 1 + fi + MAJOR="${BASH_REMATCH[1]}" + MINOR="${BASH_REMATCH[2]}" + { + echo "tag=$TAG" + echo "vMajor=v$MAJOR" + echo "vMajorMinor=v$MAJOR.$MINOR" + } >> "$GITHUB_OUTPUT" + echo "Parsed: $TAG → v$MAJOR / v$MAJOR.$MINOR / $TAG" + + - name: Inspect :edge revision label + id: edge + run: | + set -euo pipefail + IMAGE="ghcr.io/kpa-clawbot/corescope" + EDGE_REF="${IMAGE}:edge" + # crane config returns the OCI image config JSON; the revision label + # is set by docker/metadata-action on the master-edge build. + # If :edge doesn't exist yet (first run on a fresh registry), fall + # through to the slow path. + if ! CONFIG="$(crane config "$EDGE_REF" 2>/dev/null)"; then + echo "edge_revision=" >> "$GITHUB_OUTPUT" + echo "no_edge=true" >> "$GITHUB_OUTPUT" + echo ":edge not found in registry — will use fallback path" + exit 0 + fi + REV="$(echo "$CONFIG" | jq -r '.config.Labels["org.opencontainers.image.revision"] // ""')" + echo "edge_revision=$REV" >> "$GITHUB_OUTPUT" + echo "no_edge=false" >> "$GITHUB_OUTPUT" + echo ":edge org.opencontainers.image.revision = $REV" + echo "tag SHA (github.sha) = ${{ github.sha }}" + + # ─────────── FAST PATH: SHAs match, metadata-only retag ─────────── + - name: Re-tag :edge → :vX.Y.Z + :vX.Y + :vX + :latest (fast path) + if: steps.edge.outputs.no_edge == 'false' && steps.edge.outputs.edge_revision == github.sha + run: | + set -euo pipefail + IMAGE="ghcr.io/kpa-clawbot/corescope" + SRC="${IMAGE}:edge" + echo "SHA match — fast-path re-tag from $SRC" + for NEW_TAG in \ + "${{ steps.semver.outputs.tag }}" \ + "${{ steps.semver.outputs.vMajorMinor }}" \ + "${{ steps.semver.outputs.vMajor }}" \ + "latest"; do + echo " crane tag $SRC $NEW_TAG" + crane tag "$SRC" "$NEW_TAG" + done + echo "Fast-path complete — all tags point at the :edge manifest digest." + + # ─────────── FALLBACK: SHAs differ, run the full pipeline ─────────── + - name: Dispatch full deploy.yml pipeline (fallback) + if: steps.edge.outputs.no_edge == 'true' || steps.edge.outputs.edge_revision != github.sha + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + echo "SHA mismatch (or no :edge) — falling back to full pipeline" + echo " :edge revision = '${{ steps.edge.outputs.edge_revision }}'" + echo " tag SHA = '${{ github.sha }}'" + gh workflow run deploy.yml \ + --repo "${{ github.repository }}" \ + --ref "${{ github.ref }}" + echo "Dispatched deploy.yml against ${{ github.ref }}" diff --git a/cmd/server/release_fast_path_workflow_test.go b/cmd/server/release_fast_path_workflow_test.go new file mode 100644 index 00000000..42cc34f7 --- /dev/null +++ b/cmd/server/release_fast_path_workflow_test.go @@ -0,0 +1,82 @@ +// Tests for issue #1677: release fast-path workflow. +// +// These tests gate the workflow config (not Go code) by parsing the YAML +// files as text and asserting structural invariants. They follow the same +// "config gate" pattern as openapi_completeness_test.go. +// +// 1. .github/workflows/release-fast-path.yml MUST exist and own the +// push.tags trigger for v-tags, with the two execution branches +// (re-tag-via-crane on SHA match, fallback to deploy.yml otherwise). +// 2. .github/workflows/deploy.yml MUST NOT trigger on push.tags any +// more — the fast-path workflow owns tag pushes to avoid double-fire. +package main + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +const ( + fastPathWorkflowRel = "../../.github/workflows/release-fast-path.yml" + deployWorkflowRel = "../../.github/workflows/deploy.yml" +) + +func TestReleaseFastPathWorkflowExists(t *testing.T) { + abs, _ := filepath.Abs(fastPathWorkflowRel) + raw, err := os.ReadFile(fastPathWorkflowRel) + if err != nil { + t.Fatalf("issue #1677: release-fast-path.yml missing at %s: %v", abs, err) + } + src := string(raw) + + // Trigger: push.tags matching semver v-tags. + triggerRe := regexp.MustCompile(`(?m)^\s*tags:\s*\[\s*['"]v\[0-9\]\+\.\[0-9\]\+\.\[0-9\]\+['"]\s*\]`) + if !triggerRe.MatchString(src) { + t.Errorf("release-fast-path.yml: missing required push.tags trigger 'v[0-9]+.[0-9]+.[0-9]+'") + } + + // Permissions: needs packages:write to re-tag in GHCR, contents:read for checkout. + for _, perm := range []string{"packages: write", "contents: read"} { + if !strings.Contains(src, perm) { + t.Errorf("release-fast-path.yml: missing required permission %q", perm) + } + } + + // Required markers covering both execution branches: + // - re-tag path: install crane, read :edge revision label, apply new tags + // - fallback path: dispatch the existing deploy.yml pipeline + required := []string{ + "imjasonh/setup-crane", // crane install action + "org.opencontainers.image.revision", // label inspected on :edge + "ghcr.io/kpa-clawbot/corescope", // image ref + ":edge", // source tag we copy from + "crane tag", // metadata-only retag + "workflow run deploy.yml", // fallback dispatch + } + for _, need := range required { + if !strings.Contains(src, need) { + t.Errorf("release-fast-path.yml: missing required marker %q (issue #1677 fix-path)", need) + } + } +} + +func TestDeployWorkflowNoLongerTriggersOnTags(t *testing.T) { + raw, err := os.ReadFile(deployWorkflowRel) + if err != nil { + t.Fatalf("deploy.yml: %v", err) + } + // Extract the top-level `on:` block: from `^on:` up to the next + // top-level YAML key (line that starts in column 0 with a letter). + blockRe := regexp.MustCompile(`(?ms)^on:\s*\n(.*?)\n([a-zA-Z][a-zA-Z0-9_-]*:)`) + m := blockRe.FindStringSubmatch(string(raw)) + if m == nil { + t.Fatalf("deploy.yml: could not locate top-level on: block") + } + onBlock := m[1] + if regexp.MustCompile(`(?m)^\s*tags:\s*\[`).MatchString(onBlock) { + t.Errorf("deploy.yml: on: block still triggers on push.tags; the fast-path workflow (release-fast-path.yml) must own tag pushes to avoid double-fire (issue #1677).\non-block was:\n%s", onBlock) + } +}