mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-25 20:03:36 +00:00
## 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>
263 lines
12 KiB
YAML
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 }}"
|