Files
meshcore-analyzer/.github/workflows/release-fast-path.yml
T
efitenandClaude Opus 5 b8c8d98e61 fix(release): keep every platform when re-tagging :edge as a release (#2031)
## Problem

`crane mutate` works on one image, not on an index. Pointed at the
multi-arch `:edge` tag it silently resolves the default platform, so the
fast path published v3.11.0 as a single amd64 OCI manifest, and `crane
tag` then pointed `v3.11`, `v3` and `latest` at that same manifest.
`docker pull` on arm64 against any of those four tags fails.

Verified in the registry:

| tag | shape | arch |
|---|---|---|
| `v3.9.2`, `v3.10`, `v3.10.1`, `edge` | index, 4 children | multi-arch
|
| `v3.11.0`, `v3.11`, `v3`, `latest` | `oci.image.manifest.v1`, 17
layers | amd64 only, revision `a2ea18f7` |

Earlier releases are indexes, so this only hit v3.11.0. The GitHub
release and both `corescope-decrypt` binaries are unaffected.

## Change

- The fast path now reads the `:edge` manifest, mutates each runnable
platform child by digest (`/app/.image-version` plus the version label,
as #1807 intended) and reassembles an index with `crane index append`. A
single-platform `:edge` still takes the old single mutate path.
- Attestation manifests (`platform.architecture == "unknown"`) are not
carried over: they reference the pre-mutation digests, so copying them
would attest the wrong images.
- A new verification step compares the platform set of `vX.Y.Z`, `vX.Y`,
`vX` and `latest` against `:edge` and fails the run if any of them
differs. A release tag that resolves to one platform is worse than a
slow release, so this should break the build rather than ship.
- Scratch tags (`tmp-vX.Y.Z-linux-amd64`, ...) are deleted best-effort
afterwards; the index references the manifests by digest, so leaving
them behind is only untidy.
- Added `workflow_dispatch` with a `tag` input to republish the images
for an existing release. A dispatched run resolves the tagged commit
itself, because `github.sha` is then the ref the workflow file came
from, and it skips the `deploy.yml` dispatch: that release already
exists and releases here are immutable (the trap from #1955/#1956).

## Tests

None: this repository has no harness that executes workflow files, and
the CI jobs cannot reach a step that pushes to GHCR. What the change is
verified against instead:

- `crane index append` accepts `-m/--manifest` repeated plus `-t/--tag`,
with the base index optional, so building an index from scratch is
supported (crane docs for `index append`).
- The YAML parses and every `run:` block passes `bash -n`.
- The platform comparison was run by hand against the live registry:
`:edge` reports `linux/amd64,linux/arm64` and `v3.11.0` reports
`single`, which is exactly the case the new step must fail on.
- The real test is the dispatch on `v3.11.0` right after merge, which is
also the repair. If the verification step fails there, nothing is
published and the tags stay as they are.

## Not verified

- The scratch-tag delete needs `delete:packages`; `GITHUB_TOKEN` may not
have it. It cannot fail the run.
- Whether GHCR keeps the attestation manifests attached to `:edge`
reachable after the index is rebuilt for a release tag (they stay on
`:edge` itself, which is untouched).

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 11:07:18 +02:00

263 lines
12 KiB
YAML

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 Playwright + Docker rebuild
# because the application bytes are identical. Both paths dispatch deploy.yml
# for Go validation and release binaries; mismatched SHAs also run the full
# image pipeline 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.
#
# It can also be dispatched by hand with an existing tag, to republish the
# images for a release whose tags are wrong (v3.11.0 shipped an amd64-only
# manifest, see below). A manual run never dispatches deploy.yml: the release
# for that tag already exists and this repository has immutable releases.
on:
push:
tags: ['v[0-9]+.[0-9]+.[0-9]+']
workflow_dispatch:
inputs:
tag:
description: 'Existing vX.Y.Z tag to republish container tags for'
required: true
type: string
permissions:
contents: read
packages: write
actions: write # issue #1702: required for the `gh workflow run deploy.yml` dispatch
concurrency:
group: release-fast-path-${{ inputs.tag || github.ref }}
cancel-in-progress: false
jobs:
retag-or-fallback:
name: "🏷️ Re-tag :edge and dispatch release artifacts or full 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
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
if [[ -n "${{ inputs.tag || '' }}" ]]; then
TAG="${{ inputs.tag || '' }}"
else
TAG="${GITHUB_REF#refs/tags/}"
fi
# Expect vMAJOR.MINOR.PATCH (the push trigger already enforces this;
# a dispatched tag has to be checked here).
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]}"
# On a dispatched run github.sha is the ref the workflow file came
# from, not the tagged commit, so resolve the tag itself.
if [[ -n "${{ inputs.tag || '' }}" ]]; then
TARGET_SHA="$(gh api "repos/${{ github.repository }}/git/refs/tags/$TAG" --jq '.object.sha')"
OBJ_TYPE="$(gh api "repos/${{ github.repository }}/git/refs/tags/$TAG" --jq '.object.type')"
if [[ "$OBJ_TYPE" == "tag" ]]; then
TARGET_SHA="$(gh api "repos/${{ github.repository }}/git/tags/$TARGET_SHA" --jq '.object.sha')"
fi
else
TARGET_SHA="${{ github.sha }}"
fi
{
echo "tag=$TAG"
echo "vMajor=v$MAJOR"
echo "vMajorMinor=v$MAJOR.$MINOR"
echo "targetSha=$TARGET_SHA"
} >> "$GITHUB_OUTPUT"
echo "Parsed: $TAG → v$MAJOR / v$MAJOR.$MINOR / $TAG at $TARGET_SHA"
- 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 "tagged commit = ${{ steps.semver.outputs.targetSha }}"
# ─────────── FAST PATH: SHAs match, metadata-only retag ───────────
# Issue #1807: a plain `crane tag` keeps the ldflags-baked
# Version="edge" inside the binary, so /api/stats reports "edge" on
# tagged releases. Instead of a full rebuild we append a single-file
# layer (/app/.image-version containing the tag) and set the OCI
# version label. The server reads .image-version at startup (see
# resolveVersion()).
#
# `crane mutate` works on ONE image, not on an index: pointed at a
# multi-arch tag it silently returns the default platform. v3.11.0
# shipped amd64-only that way. So mutate each platform child of the
# :edge index and reassemble an index from the results.
- 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 == steps.semver.outputs.targetSha
run: |
set -euo pipefail
IMAGE="ghcr.io/kpa-clawbot/corescope"
SRC="${IMAGE}:edge"
TAG="${{ steps.semver.outputs.tag }}"
echo "SHA match — fast-path re-tag from $SRC"
# Build a one-file layer: /app/.image-version = vX.Y.Z
LAYER_DIR="$(mktemp -d)"
mkdir -p "${LAYER_DIR}/app"
printf '%s' "$TAG" > "${LAYER_DIR}/app/.image-version"
tar --owner=0 --group=0 --mtime='UTC 2020-01-01' \
-C "$LAYER_DIR" -cf /tmp/image-version-layer.tar app
crane manifest "$SRC" > /tmp/edge-manifest.json
MEDIA="$(jq -r '.mediaType // ""' /tmp/edge-manifest.json)"
if [[ "$MEDIA" == *".index."* || "$MEDIA" == *".manifest.list."* ]]; then
# Attestation manifests carry platform architecture "unknown" and
# are rebuilt by buildx, not by us; carrying them over would point
# them at the pre-mutation digests, so the release index holds the
# runnable platforms only.
mapfile -t PLATFORMS < <(jq -r '
.manifests[]
| select((.platform.architecture // "unknown") != "unknown")
| "\(.platform.os)/\(.platform.architecture)\(if .platform.variant then "/" + .platform.variant else "" end)\t\(.digest)"
' /tmp/edge-manifest.json)
if [[ "${#PLATFORMS[@]}" -eq 0 ]]; then
echo "No runnable platforms in $SRC" >&2
exit 1
fi
MANIFEST_ARGS=()
for entry in "${PLATFORMS[@]}"; do
PLATFORM="${entry%%$'\t'*}"
DIGEST="${entry##*$'\t'}"
SAFE="${PLATFORM//\//-}"
TMP_TAG="${IMAGE}:tmp-${TAG}-${SAFE}"
echo " crane mutate ${SRC}@${DIGEST} ($PLATFORM) → $TMP_TAG"
crane mutate "${IMAGE}@${DIGEST}" \
--append /tmp/image-version-layer.tar \
--label "org.opencontainers.image.version=${TAG}" \
--tag "$TMP_TAG"
MANIFEST_ARGS+=(-m "$TMP_TAG")
done
echo " crane index append ${MANIFEST_ARGS[*]} → ${IMAGE}:${TAG}"
crane index append "${MANIFEST_ARGS[@]}" -t "${IMAGE}:${TAG}"
else
# Single-platform :edge (nothing to reassemble).
echo " crane mutate $SRC (single platform) → ${IMAGE}:${TAG}"
crane mutate "$SRC" \
--append /tmp/image-version-layer.tar \
--label "org.opencontainers.image.version=${TAG}" \
--tag "${IMAGE}:${TAG}"
fi
for NEW_TAG in \
"${{ steps.semver.outputs.vMajorMinor }}" \
"${{ steps.semver.outputs.vMajor }}" \
"latest"; do
echo " crane tag ${IMAGE}:${TAG} $NEW_TAG"
crane tag "${IMAGE}:${TAG}" "$NEW_TAG"
done
echo "Fast-path complete — release tags point at :edge plus a one-file version layer."
# A release tag that resolves to one platform is worse than a slow
# release: `docker pull` on the other architecture fails outright.
- name: Verify the published tags carry every :edge platform
if: steps.edge.outputs.no_edge == 'false' && steps.edge.outputs.edge_revision == steps.semver.outputs.targetSha
run: |
set -euo pipefail
IMAGE="ghcr.io/kpa-clawbot/corescope"
TAG="${{ steps.semver.outputs.tag }}"
platforms_of() {
crane manifest "$1" | jq -r '
if (.mediaType // "") | test("index|manifest.list") then
[ .manifests[]
| select((.platform.architecture // "unknown") != "unknown")
| "\(.platform.os)/\(.platform.architecture)\(if .platform.variant then "/" + .platform.variant else "" end)" ]
| sort | join(",")
else
"single"
end'
}
WANT="$(platforms_of "${IMAGE}:edge")"
echo " :edge platforms = $WANT"
for CHECK in \
"$TAG" \
"${{ steps.semver.outputs.vMajorMinor }}" \
"${{ steps.semver.outputs.vMajor }}" \
"latest"; do
GOT="$(platforms_of "${IMAGE}:${CHECK}")"
echo " :${CHECK} platforms = $GOT"
if [[ "$GOT" != "$WANT" ]]; then
echo "::error::${IMAGE}:${CHECK} has platforms [$GOT], expected [$WANT]"
exit 1
fi
done
echo "All release tags carry the same platforms as :edge."
- name: Remove the per-platform scratch tags
if: steps.edge.outputs.no_edge == 'false' && steps.edge.outputs.edge_revision == steps.semver.outputs.targetSha
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -uo pipefail
IMAGE="ghcr.io/kpa-clawbot/corescope"
TAG="${{ steps.semver.outputs.tag }}"
# Best effort: the index already references the manifests by digest,
# so deleting the scratch tags does not affect it. Leaving them is
# only untidy, hence no failure here. This step is skipped when an
# earlier one failed, which keeps the scratch tags around to inspect.
for T in $(crane ls "$IMAGE" | grep "^tmp-${TAG}-" || true); do
echo " crane delete ${IMAGE}:${T}"
crane delete "${IMAGE}:${T}" || echo " (could not delete ${T}, leaving it)"
done
# Both image routes need the single release-artifacts writer in
# deploy.yml. A manual republish skips it: the release exists already and
# releases here are immutable, so action-gh-release would fail.
- name: Dispatch release artifacts or full deploy.yml pipeline
if: ${{ !inputs.tag }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
DISPATCH_ARGS=()
if [[ "${{ steps.edge.outputs.no_edge == 'false' && steps.edge.outputs.edge_revision == steps.semver.outputs.targetSha }}" == 'true' ]]; then
echo "Images published — dispatching Go validation and release artifacts"
DISPATCH_ARGS+=(--field images_published=true)
else
# No new inputs: an older tag may have an older deploy.yml schema.
echo "SHA mismatch (or no :edge) — falling back to full pipeline"
fi
gh workflow run deploy.yml \
--repo "${{ github.repository }}" \
--ref "${{ github.ref }}" \
"${DISPATCH_ARGS[@]}"
echo "Dispatched deploy.yml against ${{ github.ref }}"