fix(ci): release fast-path re-tag :edge → :vX.Y.Z when SHA matches (Fixes #1677) (#1680)

## Summary

Adds `.github/workflows/release-fast-path.yml`: a metadata-only re-tag
workflow that fires on `push.tags: v[0-9]+.[0-9]+.[0-9]+` and, when
`:edge`'s `org.opencontainers.image.revision` label matches the tag SHA,
applies `:vX.Y.Z`, `:vX.Y`, `:vX`, `:latest` to the existing edge
manifest via `crane tag`. No rebuild, no test re-run — ~seconds vs ~30
min today. If the SHA doesn't match (tag points to an older commit, or
`:edge` wasn't built yet), it dispatches the existing `deploy.yml`
pipeline as a fallback so validated bytes always ship.

To prevent double-fire, `deploy.yml`'s top-level `on:` block drops
`tags: ['v*']` — `release-fast-path.yml` is now the sole consumer of
`push.tags`. Edge publishing on master push is untouched.

## TDD

Red commit adds `cmd/server/release_fast_path_workflow_test.go` (two
tests: one asserts the new workflow exists with the required
trigger/permissions/markers; the other asserts `deploy.yml`'s `on:`
block no longer mentions `tags:`). Both fail on assertions in the red
commit. Green commit adds the workflow file + edits `deploy.yml`; both
pass.

## Acceptance criteria (from #1677)

- Tag-CI completes in <2 min when tag SHA == `:edge` revision →
fast-path is metadata-only, single short job
- Falls back to full pipeline on SHA mismatch → `gh workflow run
deploy.yml --ref ${{ github.ref }}`
- `:vX.Y.Z` has same digest as `:edge` → `crane tag` copies the
manifest, bytes are byte-identical
- No regression on older-SHA tags → fallback path runs the unchanged
full validation

Fixes #1677

---------

Co-authored-by: Kpa-clawbot <bot@corescope.local>
This commit is contained in:
Kpa-clawbot
2026-06-12 05:52:06 -07:00
committed by GitHub
co-authored by Kpa-clawbot
parent 626900a22a
commit 2ef7d2437d
3 changed files with 193 additions and 1 deletions
-1
View File
@@ -3,7 +3,6 @@ name: CI/CD Pipeline
on:
push:
branches: [master]
tags: ['v*']
pull_request:
branches: [master]
workflow_dispatch:
+111
View File
@@ -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 }}"
@@ -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)
}
}