Skip to content

πŸ› fix(hub): blank the forge-unreachable label once a hive goes offline #42994

πŸ› fix(hub): blank the forge-unreachable label once a hive goes offline

πŸ› fix(hub): blank the forge-unreachable label once a hive goes offline #42994

Workflow file for this run

name: Build and Push Docker Image
on:
push:
# Run on every branch so the required `gate` context attaches to every
# push, but only long-lived branches BUILD and PUSH to GHCR β€” see the
# "Push policy" step below. Feature branches used to build all six images
# here as a compile gate; that duplicated v2-ci.yml's `docker` job (which
# builds every Dockerfile on pull_request) and cost ~20 hosted-runner
# minutes per push, starving the shared ubuntu-latest queue so the LAST
# check on a PR landed 25-30 minutes after the tests finished. Exclude bot
# branches so dependency PRs don't consume image CI; they build on the base
# branch when merged.
branches:
- '**'
- '!dependabot/**'
- '!copilot/**'
# `gate` is the ONLY status context v4 branch protection requires, and a
# push-only workflow cannot produce it for a pull request from a FORK: the
# push event fires in the contributor's repo, not here, so `gate` never
# attaches to the head SHA this repo evaluates and protection blocks the
# merge waiting for a check that cannot arrive (#4965). Six green fork PRs
# were structurally unmergeable; maintainers never saw it because
# enforce_admins is false, so an admin merge silently bypasses the same
# missing check.
#
# This trigger exists to produce that one context. It deliberately runs
# NOTHING else on a pull request β€” every build job below is skipped for this
# event β€” so it adds no image CI. The heavyweight builds stay push-only,
# exactly as the comment above describes.
#
# No `paths:` or `branches:` filter, on purpose. A required context must
# attach to EVERY pull request; a filtered one silently never runs on the
# changes it does not match, and protection then waits forever. That is also
# why the already PR-triggered `build-and-test` is not a drop-in replacement
# for `gate` in the required list β€” v2-ci.yml path-filters it.
#
# Leaving the filter off also keeps this workflow's `unpinned: '**'` contract
# in .github/release-lines.yml intact: that guard requires every `branches:`
# filter here to stay a wildcard, so a fixed base-branch list would fail it.
pull_request:
workflow_dispatch:
inputs:
release_sha:
description: >-
Exact release commit to build. Tagged Release supplies this after its
GITHUB_TOKEN-authenticated merge because that merge cannot fire the
push trigger (#6380). Leave empty for an ordinary manual branch build.
required: false
type: string
release_version:
description: >-
Exact git tag being published for release_sha (for example v5.35.2).
Tagged Release supplies this so release images self-report the tag
even though the tag is pushed after the image build is dispatched.
required: false
type: string
# Concurrency. PR builds share a group per PR with cancel-in-progress, so a
# force-push supersedes the previous build. PUSH builds (release lines and the
# other long-lived branches) get a group of their own per run, i.e. they are
# never cancelled and never queued behind each other. The earlier shape β€” one
# group per branch with cancel-in-progress:false β€” was meant to make them
# "queue and each run to completion", but GitHub's concurrency queue holds at
# most ONE pending run per group: when a third push arrives, the pending
# second run is CANCELLED (not queued) in favour of the newest. On a branch
# merging every few minutes that cancelled most builds (runs 42400016149,
# 42401056583, 42403292505 on 2026-11-07), so most commits never published
# images and Tagged Release had nothing to tag. Parallel push builds are safe:
# each completed build publishes an immutable SHA tag, and
# publish-image-tags.sh uses a per-tag workflow generation (run number) to
# advance moving tags without regressing when builds finish out of order.
concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.event_name == 'push' && github.run_id || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
packages: write
jobs:
# Decide once whether this run pushes images to GHCR, and expose it as an
# output every other job consumes (needs.gate.outputs.push). Only long-lived
# branches that a hive can be assigned to via the hub's branch switcher push:
# the release lines and standing experimental branches. Every OTHER branch
# still BUILDS (the image is a per-PR CI gate) but does not push β€” otherwise a
# temporary PR branch leaves an orphaned <branch>-latest tag on GHCR when it
# merges and is deleted. workflow_dispatch always pushes so a throwaway branch
# can be published for a hive on demand before merge.
#
# EXCEPTION: `release-gate/*` (#5072). tagged-release.yml pushes its
# release commit to a throwaway `release-gate/v<version>` branch to earn a
# `gate` check on
# that exact SHA before pushing to protected `v4` (see docs/releases.md,
# "Satisfying branch protection"). That push uses the job's default
# GITHUB_TOKEN, and GitHub's documented recursive-workflow guard means a
# GITHUB_TOKEN push never fires another workflow's `push` trigger β€” so
# tagged-release.yml has to fall back to explicitly dispatching this
# workflow via `gh workflow run docker.yml --ref release-gate/v<version>`
# instead. Without
# this exception, that dispatch would hit the `workflow_dispatch` branch
# below and force push=true on a branch nothing should ever publish images
# for: a full two-platform GHCR push under a one-off scratch branch name,
# solely to obtain a status check that only needs `gate` (a five-second job)
# to run. `release-gate/*` branches are deliberately never in LONG_LIVED and
# are deleted immediately after use (tagged-release.yml's `trap ... EXIT`),
# so this
# exception cannot leave a stray moving tag behind β€” it only ever prevents
# one from being created in the first place.
#
# The long-lived branch set is defined ONCE below (LONG_LIVED). To add or
# remove one, edit only that list β€” the whole workflow derives from this job's
# output, so nothing else changes.
#
# It is not a trigger filter, so it hand-writes the release lines a second
# time, and going stale here fails GREEN: the image would keep building on a
# new release line and never publish its <branch>-latest tag (#4462). The
# release-line guard asserts this list against .github/release-lines.yml β€”
# see the `env_lists` entry there and src/docs/release-line-guard.md.
#
# NOTE (#5339/#5356): do not special-case `release-gate/*` pull requests in
# this job. GITHUB_TOKEN-opened release PR runs may be recursion-blocked
# before any job starts, and workflow_dispatch check-runs are not associated
# with a PR even when dispatched after it exists. tagged-release.yml handles
# that release-only gap by mirroring its verified check-run as a SHA-scoped
# commit status. Ordinary pull requests still need this job unchanged so
# their head SHA receives the required `gate` check-run (#4965).
gate:
# Required on v5: keep on the self-hosted pool so a GitHub-hosted runner
# outage does not make otherwise-mergeable PRs impossible to merge.
runs-on: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && fromJSON(vars.HIVE_RUNNER_LABELS || '["ubuntu-latest"]') || 'ubuntu-latest' }}
timeout-minutes: 10
outputs:
push: ${{ steps.decide.outputs.push }}
sha: ${{ steps.decide.outputs.sha }}
release_version: ${{ steps.decide.outputs.release_version }}
ref_name: ${{ steps.decide.outputs.ref_name }}
cross_org: ${{ steps.registries.outputs.cross_org }}
cross_secret: ${{ steps.registries.outputs.cross_secret }}
steps:
- name: Decide push policy
id: decide
env:
# v5 added (RFC #4000/#4001/#4002 implementation line, branched from v4
# HEAD): without it here the '**' trigger above still BUILDS v5 as a CI
# gate, but never PUSHES, so v5-latest would never exist on GHCR. This
# Channel ownership (#7721 Phase 1): v5 publishes candidate + :latest
# (stable moves only via promote-stable.yml after soak), v6 publishes
# edge, v4 is maintenance and publishes no channel β€” see the
# RELEASE_BRANCH/CHANNELS args on each publish step below.
LONG_LIVED: "v4 v5 mk dd v6"
EVENT: ${{ github.event_name }}
TARGET_SHA: ${{ inputs.release_sha || github.sha }}
RELEASE_VERSION: ${{ inputs.release_version || '' }}
run: |
set -euo pipefail
: "${GITHUB_REF_NAME:?GITHUB_REF_NAME is required}"
REF_NAME=$GITHUB_REF_NAME
if ! [[ "$TARGET_SHA" =~ ^[0-9a-f]{40}$ ]]; then
echo "::error::release_sha must be a full 40-character lowercase commit SHA, got '${TARGET_SHA}'" >&2
exit 1
fi
if [ -n "$RELEASE_VERSION" ] && ! [[ "$RELEASE_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$ ]]; then
echo "::error::release_version must be a v-prefixed semver tag, got '${RELEASE_VERSION}'" >&2
exit 1
fi
push=false
case "$REF_NAME" in
release-gate/*)
# See the EXCEPTION comment above this job: this scratch branch
# only ever exists to earn a `gate` check for
# tagged-release.yml, never to publish an image, regardless of
# trigger event.
push=false
;;
*)
if [ "$EVENT" = "workflow_dispatch" ]; then
push=true
else
for b in $LONG_LIVED; do
if [ "$b" = "$REF_NAME" ]; then push=true; break; fi
done
fi
;;
esac
echo "push=$push" >> "$GITHUB_OUTPUT"
echo "sha=$TARGET_SHA" >> "$GITHUB_OUTPUT"
echo "release_version=$RELEASE_VERSION" >> "$GITHUB_OUTPUT"
echo "ref_name=$REF_NAME" >> "$GITHUB_OUTPUT"
echo "Push to GHCR: $push (branch=$REF_NAME sha=$TARGET_SHA event=$EVENT release_version=${RELEASE_VERSION:-<git describe>})"
# #6419: well-formed 40-hex is NOT "reachable from this branch". Without
# this, any principal with `actions: write` could dispatch
# `release_sha=<any commit object in the repo>` β€” including an
# unreviewed PR head β€” and every job below would build and publish it
# under the dispatched branch's moving tags (`<branch>-latest`,
# `candidate`, ...), laundered through the normal pipeline with
# legitimate provenance labels. tagged-release.yml, the only intended
# caller, already runs this exact `git merge-base --is-ancestor` check
# before it ever tags a commit (see its "push_v4" step) β€” the gate job
# re-verified nothing on the receiving end. Only fetch full history when
# a release_sha was actually supplied; the ordinary push/PR path (99% of
# runs) pays nothing extra.
- name: Checkout dispatched ref for release_sha ancestry check (#6419)
if: inputs.release_sha != ''
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.ref }}
fetch-depth: 0
- name: Verify release_sha is an ancestor of the dispatched branch (#6419)
if: inputs.release_sha != ''
env:
TARGET_SHA: ${{ inputs.release_sha }}
run: bash src/scripts/check-release-sha-ancestry.sh "$TARGET_SHA" HEAD .
# DUAL-PUBLISH (kubestellar -> hivecommons org transfer). During the
# transfer window every image publish is mirrored into the OTHER org's
# GHCR namespace, so a hive pinned to either registry path keeps
# resolving fresh tags no matter which side of the transfer the repo is
# on. The NATIVE org is wherever this repo currently lives
# (github.repository_owner β€” the only org this job's GITHUB_TOKEN can
# write packages to); the CROSS org and the classic-PAT secret that can
# write to it are derived here once, direction-agnostically, so the
# workflow needs no edit when the transfer actually happens:
# owner=kubestellar -> cross=hivecommons, secret GHCR_HIVECOMMONS_PAT
# owner=hivecommons -> cross=kubestellar, secret GHCR_KUBESTELLAR_PAT
# The merge jobs consume these outputs. Whether a MISSING secret is a
# warning or a hard failure is decided there β€” see the asymmetry
# comment on each "Check cross-registry credential" step.
- name: Decide cross-registry mirror target
id: registries
env:
OWNER: ${{ github.repository_owner }}
run: |
set -euo pipefail
if [ "$OWNER" = "kubestellar" ]; then
cross_org=hivecommons
cross_secret=GHCR_HIVECOMMONS_PAT
else
cross_org=kubestellar
cross_secret=GHCR_KUBESTELLAR_PAT
fi
echo "cross_org=${cross_org}" >> "$GITHUB_OUTPUT"
echo "cross_secret=${cross_secret}" >> "$GITHUB_OUTPUT"
echo "Native org: ${OWNER}; mirroring publishes to ghcr.io/${cross_org} using secret ${cross_secret}."
build:
needs: gate
# Runs when `gate` decided push=true, i.e. a long-lived branch or a
# workflow_dispatch release, and on same-repo PRs so Dockerfile/platform
# changes prove both release architectures before merge. Fork PRs still
# get only the required `gate` context here (#4965); they cannot safely use
# the self-hosted fleet. Feature-branch pushes are skipped because the PR
# event supplies the image compile gate.
# `release-gate/*` is excluded for the same reason `merge*` already skips
# it (gate forces push=false there): these builds publish NOTHING for the
# scratch branch. They are not merely wasted β€” branch protection evaluates
# the whole check SUITE, so while any job in it runs the `gate` context is
# not treated as satisfied even though its check-run is already `success`.
# The release job's merge waits 120s and a multi-arch build takes ~10min,
# so the merge could never win that race (#5339, 8th recurrence).
#
# No coverage is lost. A release commit modifies CHANGELOG.md and nothing
# else β€” no source, no Dockerfile β€” so its tree is byte-identical to the v4
# tip whose images this workflow already built, published and freshness-
# checked minutes earlier. The `gate` job itself still runs on the scratch
# branch; that is the whole reason the branch exists.
# push=true already implies not release-gate/* (gate forces push=false
# there); the explicit clause is kept because test-release-push-retry.sh
# asserts it (#5339) and it makes the intent readable.
if: >-
(needs.gate.outputs.push == 'true' ||
(github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository)) &&
!startsWith(github.ref, 'refs/heads/release-gate/')
strategy:
matrix:
include:
- platform: linux/amd64
self_hosted: true
qemu: false
timeout_minutes: 60
- platform: linux/arm64
self_hosted: true
qemu: true
timeout_minutes: 75
# Public PRs use the same free native ARM64 VM as the Podman arm64 lane.
# Explicit ARM64 overrides and all release/push runner selection stay intact.
runs-on: ${{ matrix.qemu && fromJSON(vars.HIVE_ARM64_RUNNER || (github.event_name == 'pull_request' && github.event.repository.private == false && '["ubuntu-24.04-arm"]' || vars.HIVE_RUNNER_LABELS || '["ubuntu-latest"]')) || ((github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && fromJSON(vars.HIVE_RUNNER_LABELS || '["ubuntu-latest"]') || 'ubuntu-latest') }}
timeout-minutes: ${{ matrix.timeout_minutes }}
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.gate.outputs.sha }}
# fetch-depth: 0 brings the full tag history into the .git this job
# copies into the image build, so src/Dockerfile's
# `git describe --tags` can stamp a real semver (#7092).
fetch-depth: 0
fetch-tags: true
- name: Set up QEMU for arm64 emulation
if: matrix.qemu && runner.arch != 'ARM64'
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
with:
platforms: arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
with:
driver-opts: network=host
- name: Log in to GHCR
if: needs.gate.outputs.push == 'true'
uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Platform slug
id: slug
run: echo "slug=${PLATFORM//\//-}" >> "$GITHUB_OUTPUT"
env:
PLATFORM: ${{ matrix.platform }}
# NOTE on Go compile caching (removed actions/cache transport, #4114):
# measurement showed the Go compile in this image is only ~25-50s, and
# the inject/extract transport never worked β€” every extract found the
# cache mounts empty (each actions/cache save was 273 bytes), so every
# "warm" run was actually cold while paying the transport's fixed cost.
# src/Dockerfile keeps its BuildKit cache mounts (free, and a no-op when
# empty) so any future runner-local BuildKit state still benefits, but we
# no longer round-trip them through actions/cache.
#
# LAYER CACHE STRATEGY: `no-cache-filters: builder,runtime` (the #3760
# stale-binary defense, see below) forces every layer of the builder and
# runtime stages to re-execute on every build β€” so a whole-image
# `cache-to: type=gha,mode=max` export bought almost nothing while
# costing 2-4 minutes per job to upload multi-GB layer sets. The ONLY
# stage that can legitimately be served from cache is `tmux-builder`
# (pinned base image + pinned tmux tarball; not listed in
# no-cache-filters). Warm exactly that stage: a targeted `--target
# tmux-builder` build seeds the builder's local layer cache from a small
# dedicated gha scope (and refreshes it with mode=max β€” cheap, tmux-stage
# layers only). The main build then hits those layers locally and does
# NO cache export at all. continue-on-error: pure optimization β€” if the
# gha cache service hiccups, the main build just recompiles tmux.
- name: Resolve base image registry (mirror.gcr.io with docker.io fallback)
uses: ./.github/actions/docker-base-registry
- name: Warm tmux-builder layer cache
continue-on-error: true
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
file: src/Dockerfile
platforms: ${{ matrix.platform }}
target: tmux-builder
build-args: |
BASE_REGISTRY=${{ env.BASE_REGISTRY }}
provenance: false
sbom: false
cache-from: type=gha,scope=tmux-${{ steps.slug.outputs.slug }}
cache-to: type=gha,scope=tmux-${{ steps.slug.outputs.slug }},mode=max
- name: Build and push by digest
id: build
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
file: src/Dockerfile
platforms: ${{ matrix.platform }}
build-args: |
BASE_REGISTRY=${{ env.BASE_REGISTRY }}
GIT_HASH=${{ needs.gate.outputs.sha }}
GIT_BRANCH=${{ needs.gate.outputs.ref_name }}
VERSION=${{ needs.gate.outputs.release_version }}
# Monotonic moving-tag guard metadata (#4804). This config label is
# inherited by each platform manifest without changing its media type.
labels: |
io.kubestellar.hive.github-actions-run-number=${{ github.run_number }}
org.opencontainers.image.revision=${{ needs.gate.outputs.sha }}
# STALE-BINARY FIX (unblocks the #3760 fix in the SHIPPED image).
# `no-cache-filters: builder` alone rebuilt
# a fresh /hive in the `builder` stage but let BuildKit serve the FINAL
# (`runtime`) stage's `COPY --from=builder /hive` layer AND the
# subsequent fresh-inode rewrite RUN from that persistent cache β€” so the
# rebuilt binary was thrown away and the OLD binary (e.g. commit
# 4d9c11c) shipped even though the manifest digest advanced. Listing the
# `runtime` stage too forces those binary-bearing layers to re-run every
# build, so the published /usr/local/bin/hive always matches HEAD.
no-cache-filters: builder,runtime
# #3760: DO NOT attach provenance/SBOM attestations. build-push-action
# adds them by DEFAULT, and an attestation can only be carried by an
# OCI image *index* β€” so the per-arch digest we push becomes
# application/vnd.oci.image.index.v1+json instead of a plain image
# manifest. That is exactly the OCI-index form the reporter measured on
# the crash-looping image (his working fork build, same v4 code, is a
# plain gzip-layer manifest and execs everywhere). The attestation/OCI-
# index build path is what lets a COPY --from layer ship an overlayfs
# metacopy redirect for /usr/local/bin/hive, which containerd/k3s and
# rootless podman present as non-executable at the image path until
# copy-up (a plain `cp` to any other path clears it). Forcing plain
# image manifests removes that trigger. See the in-final-stage rewrite
# in src/Dockerfile for the belt-and-suspenders defense.
provenance: false
sbom: false
# Push the per-arch digest only for long-lived branches; on a PR
# branch build without pushing so the image is still validated (CI
# gate) but nothing lands on GHCR.
# The image name is owner-derived (dual-publish, see the gate job):
# GITHUB_TOKEN can only push packages in the org that hosts the
# repo, so the build always targets the NATIVE org and the merge
# job mirrors the finished manifest list into the cross org.
outputs: type=image,name=ghcr.io/${{ github.repository_owner }}/hive,push-by-digest=true,name-canonical=true,push=${{ needs.gate.outputs.push }},oci-mediatypes=false
# tmux-builder layers come from the local cache warmed above; every
# other stage is forced to re-execute by no-cache-filters, so there
# is nothing worth exporting β€” deliberately NO cache-to here (the
# whole-image mode=max export measured 130-225s per job for ~0 reuse)
cache-from: type=gha,scope=tmux-${{ steps.slug.outputs.slug }}
# FRESHNESS ASSERTION (#3760 regression guard): run the image this job
# just produced and assert the ldflags-embedded commit equals the commit
# being built. `hive --version` prints "hive 3.0.0 (commit <short7>,
# branch <b>)" where <short7> is baked from /repo/.git at compile time.
#
# - push builds: probe the EXACT pushed artifact by digest β€” the most
# direct possible evidence that what landed on GHCR is fresh.
# - PR builds (push=false): nothing is pushed and push-by-digest output
# doesn't load into the local daemon, so re-run the build with --load.
# On this same builder every layer of the forced-fresh build above is
# in local cache, so the rebuild is seconds and yields those exact
# layers. If BuildKit ever resolved a STALE binary layer instead, the
# embedded hash would differ from the validated target SHA and this step fails loudly
# β€” a stale layer can make this check fail, never silently pass.
- name: Assert embedded commit matches build (freshness guard)
env:
EXPECTED_SHA: ${{ needs.gate.outputs.sha }}
REF_NAME: ${{ needs.gate.outputs.ref_name }}
run: |
set -euo pipefail
if [ "${{ needs.gate.outputs.push }}" = "true" ]; then
IMG="ghcr.io/${{ github.repository_owner }}/hive@${{ steps.build.outputs.digest }}"
docker pull "$IMG"
else
IMG=hive-freshness-probe:local
docker buildx build --load \
--platform "${{ matrix.platform }}" \
-f src/Dockerfile \
--build-arg BASE_REGISTRY="$BASE_REGISTRY" \
--build-arg GIT_HASH="$EXPECTED_SHA" \
--build-arg GIT_BRANCH="$REF_NAME" \
--build-arg VERSION="${{ needs.gate.outputs.release_version }}" \
-t "$IMG" .
fi
ACTUAL=$(docker run --rm --entrypoint /usr/local/bin/hive "$IMG" --version)
echo "image reports: $ACTUAL"
echo "expected commit: ${EXPECTED_SHA::7} (from $EXPECTED_SHA)"
case "$ACTUAL" in
*"commit ${EXPECTED_SHA::7}"*)
echo "Freshness OK: embedded commit matches the built SHA"
;;
*)
echo "::error::STALE BINARY: image reports '$ACTUAL' but this build is $EXPECTED_SHA. The published/validated image does not contain the code of this commit."
exit 1
;;
esac
# SECURITY (#3866, CWE-250/732): assert the REAL built image ships exactly
# one setuid/setgid binary, the su-exec launcher helper.
#
# src/deploy/test_image_suid_inventory.sh runs this same assertion against
# a fixture derived from src/Dockerfile on every PR that touches it (the
# image-suid-inventory job in suid-contract.yml). That fixture covers the
# base + apt layers, which is where the eleven world-executable
# setuid/setgid binaries came from. This copy is the one that sees the
# WHOLE image β€” every npm, pip and COPY layer included β€” so nothing can
# slip a setuid bit in below the layers the fixture reproduces.
#
# The image is already local here: pushed builds pulled it by digest in
# the step above, PR builds loaded it as hive-freshness-probe:local.
- name: Assert image SUID/SGID inventory
run: |
set -euo pipefail
if [ "${{ needs.gate.outputs.push }}" = "true" ]; then
IMG="ghcr.io/${{ github.repository_owner }}/hive@${{ steps.build.outputs.digest }}"
else
IMG=hive-freshness-probe:local
fi
bash src/deploy/test_image_suid_inventory.sh --image "$IMG"
# The runtime stage distrusts two withdrawn Mozilla roots (see "Layer 1b"
# in src/Dockerfile). Prove the built image actually rejects chains that
# end in them, for every static-bundle client it ships: curl (system
# bundle), node (NODE_OPTIONS=--use-openssl-ca β†’ system bundle) and the
# control that the bundle still trusts a normal public site.
- name: Assert distrusted CA roots are rejected
run: |
set -euo pipefail
if [ "${{ needs.gate.outputs.push }}" = "true" ]; then
IMG="ghcr.io/${{ github.repository_owner }}/hive@${{ steps.build.outputs.digest }}"
else
IMG=hive-freshness-probe:local
fi
bash src/deploy/test_image_ca_distrust.sh --image "$IMG"
- name: Export digest
if: needs.gate.outputs.push == 'true'
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
if: needs.gate.outputs.push == 'true'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: digests-${{ steps.slug.outputs.slug }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
# The merge job exists only to create and push the <branch>-latest / <sha>
# manifest tags, which only happens for long-lived branches. On a PR branch
# the build job above still runs (validating the image builds) and this job
# is skipped entirely.
merge:
if: needs.gate.outputs.push == 'true'
runs-on: ${{ fromJSON(vars.HIVE_RUNNER_LABELS || '["ubuntu-latest"]') }}
timeout-minutes: 20
needs: [gate, build]
steps:
- name: Checkout tag publisher
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.gate.outputs.sha }}
- name: Download digests
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
with:
driver-opts: network=host
- name: Log in to GHCR
uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create manifest list and push
env:
REF_NAME: ${{ needs.gate.outputs.ref_name }}
GATE_SHA: ${{ needs.gate.outputs.sha }}
RUN_NUMBER: ${{ github.run_number }}
run: |
src/scripts/publish-image-tags.sh \
ghcr.io/${{ github.repository_owner }}/hive /tmp/digests \
"$REF_NAME" "$GATE_SHA" "$RUN_NUMBER" \
v5 true candidate
# ---- Cross-org mirror (kubestellar <-> hivecommons dual-publish) ----
# Copy the tags just published above into the other org's GHCR
# namespace BY DIGEST (`imagetools create -t <cross>:<tag>
# <native>@<digest>`): both registries then serve the byte-identical,
# digest-identical manifest list β€” no second build, no drift. The gate
# job computed which org is "cross" and which secret can write to it.
#
# ASYMMETRY of the missing-secret handling, on purpose:
# - owner=hivecommons (post-transfer): a missing PAT would mean the
# OLD kubestellar registry silently stops receiving images while
# every spoke still pinned there keeps pulling stale tags β€” the
# dangerous one-sided publish β€” so it is a hard ::error failure.
# - owner=kubestellar (pre-transfer staging): the PAT may simply not
# exist yet; the mirror is purely additive and nothing that exists
# today degrades, so it is only a ::warning and the run stays green.
- name: Check cross-registry credential
id: crosspat
env:
# Env indirection: `if:` cannot read the secrets context, and the
# secret NAME itself is computed by the gate job.
CROSS_PAT: ${{ secrets[needs.gate.outputs.cross_secret] }}
CROSS_ORG: ${{ needs.gate.outputs.cross_org }}
CROSS_SECRET_NAME: ${{ needs.gate.outputs.cross_secret }}
OWNER: ${{ github.repository_owner }}
run: |
set -euo pipefail
if [ -n "$CROSS_PAT" ]; then
echo "available=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "available=false" >> "$GITHUB_OUTPUT"
if [ "$OWNER" = "hivecommons" ]; then
echo "::error::secret ${CROSS_SECRET_NAME} is not set, so this run cannot mirror to ghcr.io/${CROSS_ORG}. Post-transfer that is a silent one-sided publish (spokes pinned to the old org keep pulling stale tags), so this fails hard. Create a classic PAT with write:packages from a ${CROSS_ORG} member and store it as ${CROSS_SECRET_NAME}."
exit 1
fi
echo "::warning::secret ${CROSS_SECRET_NAME} is not set β€” skipping the ghcr.io/${CROSS_ORG} mirror. Pre-transfer this is expected until that PAT is created; nothing that exists today degrades."
# ghcr.io is ONE registry host and docker keeps one credential per
# host, so this login REPLACES the GITHUB_TOKEN login above. Every
# native-org write is already done (the publish step above was the
# last one). Reading the native image back for the digest copy still
# works: these packages are public, and the PAT is a valid ghcr.io
# credential for public reads regardless of org.
- name: Log in to GHCR as the cross-org publisher
if: steps.crosspat.outputs.available == 'true'
uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets[needs.gate.outputs.cross_secret] }}
- name: Mirror published tags into the cross-org registry (by digest)
if: steps.crosspat.outputs.available == 'true'
env:
NATIVE_IMAGE: ghcr.io/${{ github.repository_owner }}/hive
CROSS_IMAGE: ghcr.io/${{ needs.gate.outputs.cross_org }}/hive
BRANCH: ${{ needs.gate.outputs.ref_name }}
GIT_SHA: ${{ needs.gate.outputs.sha }}
# These three mirror publish-image-tags.sh's positional args in the
# publish step above β€” keep them in lockstep with that call.
INCLUDE_LATEST: "true"
RELEASE_BRANCH: "v5"
CHANNELS: "candidate"
run: |
set -euo pipefail
# Same tag set publish-image-tags.sh manages for this image. Each
# tag is copied from whatever the NATIVE tag currently points at,
# so the monotonic moving-tag guard's decisions carry over: the
# cross org can never regress a tag the native org did not.
branch_tag="${BRANCH//\//-}"
tags=("${GIT_SHA:0:7}" "${branch_tag}-latest")
if [ "$INCLUDE_LATEST" = "true" ]; then tags+=("latest"); fi
if [ "$BRANCH" = "$RELEASE_BRANCH" ] && [ -n "$CHANNELS" ]; then
IFS=, read -ra channel_tags <<<"$CHANNELS"
for ch in "${channel_tags[@]}"; do [ -n "$ch" ] && tags+=("$ch"); done
fi
for tag in "${tags[@]}"; do
if ! digest=$(docker buildx imagetools inspect \
"${NATIVE_IMAGE}:${tag}" --format '{{.Manifest.Digest}}' 2>&1); then
# Distinguish "tag does not exist" (fine: e.g. the monotonic
# guard published nothing this run) from a transport/auth
# failure (red, same policy as publish-image-tags.sh).
if grep -Eqi 'manifest unknown|manifest.*not found|not found.*manifest|no such manifest|(^|[[:space:]:])not found$' <<<"$digest"; then
echo "native tag ${NATIVE_IMAGE}:${tag} does not exist β€” nothing to mirror for it"
continue
fi
echo "::error::could not inspect ${NATIVE_IMAGE}:${tag}; refusing to silently skip a mirror on a transport/auth failure" >&2
echo "$digest" >&2
exit 1
fi
echo "Mirroring ${CROSS_IMAGE}:${tag} <- ${NATIVE_IMAGE}@${digest}"
docker buildx imagetools create -t "${CROSS_IMAGE}:${tag}" "${NATIVE_IMAGE}@${digest}"
done
build-contributor:
needs: gate
# Only when gate says push=true, same as `build` above (#4965).
# `release-gate/*` is excluded for the same reason `merge*` already skips
# it (gate forces push=false there): these builds publish NOTHING for the
# scratch branch. They are not merely wasted β€” branch protection evaluates
# the whole check SUITE, so while any job in it runs the `gate` context is
# not treated as satisfied even though its check-run is already `success`.
# The release job's merge waits 120s and a multi-arch build takes ~10min,
# so the merge could never win that race (#5339, 8th recurrence).
# push=true already implies not release-gate/* (gate forces push=false
# there); the explicit clause is kept because test-release-push-retry.sh
# asserts it (#5339) and it makes the intent readable.
if: >-
(needs.gate.outputs.push == 'true' ||
(github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository)) &&
!startsWith(github.ref, 'refs/heads/release-gate/')
strategy:
matrix:
include:
- platform: linux/amd64
self_hosted: true
qemu: false
timeout_minutes: 60
- platform: linux/arm64
self_hosted: true
qemu: true
timeout_minutes: 75
runs-on: ${{ matrix.qemu && fromJSON(vars.HIVE_ARM64_RUNNER || (github.event_name == 'pull_request' && github.event.repository.private == false && '["ubuntu-24.04-arm"]' || vars.HIVE_RUNNER_LABELS || '["ubuntu-latest"]')) || ((github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && fromJSON(vars.HIVE_RUNNER_LABELS || '["ubuntu-latest"]') || 'ubuntu-latest') }}
timeout-minutes: ${{ matrix.timeout_minutes }}
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.gate.outputs.sha }}
- name: Set up QEMU for arm64 emulation
if: matrix.qemu && runner.arch != 'ARM64'
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
with:
platforms: arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
with:
driver-opts: network=host
- name: Log in to GHCR
if: needs.gate.outputs.push == 'true'
uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Platform slug
id: slug
run: echo "slug=${PLATFORM//\//-}" >> "$GITHUB_OUTPUT"
env:
PLATFORM: ${{ matrix.platform }}
- name: Resolve base image registry (mirror.gcr.io with docker.io fallback)
uses: ./.github/actions/docker-base-registry
- name: Build and push contributor image by digest
id: build
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
file: src/Dockerfile.contributor
platforms: ${{ matrix.platform }}
build-args: |
BASE_REGISTRY=${{ env.BASE_REGISTRY }}
labels: |
io.kubestellar.hive.github-actions-run-number=${{ github.run_number }}
org.opencontainers.image.revision=${{ needs.gate.outputs.sha }}
# #3760: no default provenance/SBOM attestations β†’ plain image
# manifests, not an OCI index. See the note on the main build above.
provenance: false
sbom: false
# Owner-derived image name (dual-publish) β€” see the main build step.
outputs: type=image,name=ghcr.io/${{ github.repository_owner }}/hive-contributor,push-by-digest=true,name-canonical=true,push=${{ needs.gate.outputs.push }},oci-mediatypes=false
cache-from: type=gha,scope=contributor-${{ steps.slug.outputs.slug }}
cache-to: type=gha,scope=contributor-${{ steps.slug.outputs.slug }},mode=max
- name: Export digest
if: needs.gate.outputs.push == 'true'
run: |
mkdir -p /tmp/contrib-digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/contrib-digests/${digest#sha256:}"
- name: Upload digest
if: needs.gate.outputs.push == 'true'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: contrib-digests-${{ steps.slug.outputs.slug }}
path: /tmp/contrib-digests/*
if-no-files-found: error
retention-days: 1
merge-contributor:
if: needs.gate.outputs.push == 'true'
runs-on: ${{ fromJSON(vars.HIVE_RUNNER_LABELS || '["ubuntu-latest"]') }}
timeout-minutes: 20
needs: [gate, build-contributor]
steps:
- name: Checkout tag publisher
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.gate.outputs.sha }}
- name: Download digests
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
path: /tmp/contrib-digests
pattern: contrib-digests-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
with:
driver-opts: network=host
- name: Log in to GHCR
uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create contributor manifest list and push
env:
REF_NAME: ${{ needs.gate.outputs.ref_name }}
GATE_SHA: ${{ needs.gate.outputs.sha }}
RUN_NUMBER: ${{ github.run_number }}
run: |
src/scripts/publish-image-tags.sh \
ghcr.io/${{ github.repository_owner }}/hive-contributor /tmp/contrib-digests \
"$REF_NAME" "$GATE_SHA" "$RUN_NUMBER" \
v5 true candidate
# Cross-org mirror β€” same mechanics, same missing-secret asymmetry, and
# same "one credential per registry host" ordering constraint as the
# fully commented block in the `merge` job above.
- name: Check cross-registry credential
id: crosspat
env:
CROSS_PAT: ${{ secrets[needs.gate.outputs.cross_secret] }}
CROSS_ORG: ${{ needs.gate.outputs.cross_org }}
CROSS_SECRET_NAME: ${{ needs.gate.outputs.cross_secret }}
OWNER: ${{ github.repository_owner }}
run: |
set -euo pipefail
if [ -n "$CROSS_PAT" ]; then
echo "available=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "available=false" >> "$GITHUB_OUTPUT"
if [ "$OWNER" = "hivecommons" ]; then
echo "::error::secret ${CROSS_SECRET_NAME} is not set, so this run cannot mirror to ghcr.io/${CROSS_ORG}. Post-transfer that is a silent one-sided publish (spokes pinned to the old org keep pulling stale tags), so this fails hard. Create a classic PAT with write:packages from a ${CROSS_ORG} member and store it as ${CROSS_SECRET_NAME}."
exit 1
fi
echo "::warning::secret ${CROSS_SECRET_NAME} is not set β€” skipping the ghcr.io/${CROSS_ORG} mirror. Pre-transfer this is expected until that PAT is created; nothing that exists today degrades."
- name: Log in to GHCR as the cross-org publisher
if: steps.crosspat.outputs.available == 'true'
uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets[needs.gate.outputs.cross_secret] }}
- name: Mirror published tags into the cross-org registry (by digest)
if: steps.crosspat.outputs.available == 'true'
env:
NATIVE_IMAGE: ghcr.io/${{ github.repository_owner }}/hive-contributor
CROSS_IMAGE: ghcr.io/${{ needs.gate.outputs.cross_org }}/hive-contributor
BRANCH: ${{ needs.gate.outputs.ref_name }}
GIT_SHA: ${{ needs.gate.outputs.sha }}
# Lockstep with the publish-image-tags.sh args above.
INCLUDE_LATEST: "true"
RELEASE_BRANCH: "v5"
CHANNELS: "candidate"
run: |
set -euo pipefail
branch_tag="${BRANCH//\//-}"
tags=("${GIT_SHA:0:7}" "${branch_tag}-latest")
if [ "$INCLUDE_LATEST" = "true" ]; then tags+=("latest"); fi
if [ "$BRANCH" = "$RELEASE_BRANCH" ] && [ -n "$CHANNELS" ]; then
IFS=, read -ra channel_tags <<<"$CHANNELS"
for ch in "${channel_tags[@]}"; do [ -n "$ch" ] && tags+=("$ch"); done
fi
for tag in "${tags[@]}"; do
if ! digest=$(docker buildx imagetools inspect \
"${NATIVE_IMAGE}:${tag}" --format '{{.Manifest.Digest}}' 2>&1); then
if grep -Eqi 'manifest unknown|manifest.*not found|not found.*manifest|no such manifest|(^|[[:space:]:])not found$' <<<"$digest"; then
echo "native tag ${NATIVE_IMAGE}:${tag} does not exist β€” nothing to mirror for it"
continue
fi
echo "::error::could not inspect ${NATIVE_IMAGE}:${tag}; refusing to silently skip a mirror on a transport/auth failure" >&2
echo "$digest" >&2
exit 1
fi
echo "Mirroring ${CROSS_IMAGE}:${tag} <- ${NATIVE_IMAGE}@${digest}"
docker buildx imagetools create -t "${CROSS_IMAGE}:${tag}" "${NATIVE_IMAGE}@${digest}"
done
build-hub:
needs: gate
# Only when gate says push=true, same as `build` above (#4965).
# `release-gate/*` is excluded for the same reason `merge*` already skips
# it (gate forces push=false there): these builds publish NOTHING for the
# scratch branch. They are not merely wasted β€” branch protection evaluates
# the whole check SUITE, so while any job in it runs the `gate` context is
# not treated as satisfied even though its check-run is already `success`.
# The release job's merge waits 120s and a multi-arch build takes ~10min,
# so the merge could never win that race (#5339, 8th recurrence).
# push=true already implies not release-gate/* (gate forces push=false
# there); the explicit clause is kept because test-release-push-retry.sh
# asserts it (#5339) and it makes the intent readable.
if: >-
(needs.gate.outputs.push == 'true' ||
(github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository)) &&
!startsWith(github.ref, 'refs/heads/release-gate/')
strategy:
matrix:
include:
- platform: linux/amd64
self_hosted: true
qemu: false
timeout_minutes: 60
- platform: linux/arm64
self_hosted: true
qemu: true
timeout_minutes: 75
runs-on: ${{ matrix.qemu && fromJSON(vars.HIVE_ARM64_RUNNER || (github.event_name == 'pull_request' && github.event.repository.private == false && '["ubuntu-24.04-arm"]' || vars.HIVE_RUNNER_LABELS || '["ubuntu-latest"]')) || ((github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && fromJSON(vars.HIVE_RUNNER_LABELS || '["ubuntu-latest"]') || 'ubuntu-latest') }}
timeout-minutes: ${{ matrix.timeout_minutes }}
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.gate.outputs.sha }}
# fetch-depth: 0 brings the full tag history into the .git this job
# copies into the image build, so src/Dockerfile.hub's
# `git describe --tags` can stamp a real semver (#7092).
fetch-depth: 0
fetch-tags: true
- name: Set up QEMU for arm64 emulation
if: matrix.qemu && runner.arch != 'ARM64'
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
with:
platforms: arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
with:
driver-opts: network=host
- name: Log in to GHCR
if: needs.gate.outputs.push == 'true'
uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Platform slug
id: slug
run: echo "slug=${PLATFORM//\//-}" >> "$GITHUB_OUTPUT"
env:
PLATFORM: ${{ matrix.platform }}
- name: Resolve base image registry (mirror.gcr.io with docker.io fallback)
uses: ./.github/actions/docker-base-registry
- name: Build and push hub image by digest
id: build
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
file: src/Dockerfile.hub
platforms: ${{ matrix.platform }}
build-args: |
BASE_REGISTRY=${{ env.BASE_REGISTRY }}
GIT_HASH=${{ needs.gate.outputs.sha }}
GIT_BRANCH=${{ needs.gate.outputs.ref_name }}
VERSION=${{ needs.gate.outputs.release_version }}
labels: |
io.kubestellar.hive.github-actions-run-number=${{ github.run_number }}
org.opencontainers.image.revision=${{ needs.gate.outputs.sha }}
# STALE-BINARY FIX (see the main build step). The hub image also does
# `COPY --from=builder /hive` into a `runtime` final stage, and its GHA
# cache scope (hub-<slug>) is likewise branch-persistent β€” so without
# busting `runtime` the hub could ship an old hive that self-upgrades
# against the wrong SHA. Bust both binary-bearing stages.
no-cache-filters: builder,runtime
# #3760: no default provenance/SBOM attestations β†’ plain image
# manifests, not an OCI index. See the note on the main build above.
provenance: false
sbom: false
# Owner-derived image name (dual-publish) β€” see the main build step.
outputs: type=image,name=ghcr.io/${{ github.repository_owner }}/hive-hub,push-by-digest=true,name-canonical=true,push=${{ needs.gate.outputs.push }},oci-mediatypes=false
cache-from: type=gha,scope=hub-${{ steps.slug.outputs.slug }}
cache-to: type=gha,scope=hub-${{ steps.slug.outputs.slug }},mode=max
- name: Export digest
if: needs.gate.outputs.push == 'true'
run: |
mkdir -p /tmp/hub-digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/hub-digests/${digest#sha256:}"
- name: Upload digest
if: needs.gate.outputs.push == 'true'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: hub-digests-${{ steps.slug.outputs.slug }}
path: /tmp/hub-digests/*
if-no-files-found: error
retention-days: 1
merge-hub:
if: needs.gate.outputs.push == 'true'
runs-on: ${{ fromJSON(vars.HIVE_RUNNER_LABELS || '["ubuntu-latest"]') }}
timeout-minutes: 20
needs: [gate, build-hub]
steps:
- name: Checkout tag publisher
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.gate.outputs.sha }}
- name: Download digests
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
path: /tmp/hub-digests
pattern: hub-digests-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
with:
driver-opts: network=host
- name: Log in to GHCR
uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create hub manifest list and push
env:
REF_NAME: ${{ needs.gate.outputs.ref_name }}
GATE_SHA: ${{ needs.gate.outputs.sha }}
RUN_NUMBER: ${{ github.run_number }}
run: |
src/scripts/publish-image-tags.sh \
ghcr.io/${{ github.repository_owner }}/hive-hub /tmp/hub-digests \
"$REF_NAME" "$GATE_SHA" "$RUN_NUMBER" \
v5 true candidate
# Cross-org mirror β€” same mechanics, same missing-secret asymmetry, and
# same "one credential per registry host" ordering constraint as the
# fully commented block in the `merge` job above.
- name: Check cross-registry credential
id: crosspat
env:
CROSS_PAT: ${{ secrets[needs.gate.outputs.cross_secret] }}
CROSS_ORG: ${{ needs.gate.outputs.cross_org }}
CROSS_SECRET_NAME: ${{ needs.gate.outputs.cross_secret }}
OWNER: ${{ github.repository_owner }}
run: |
set -euo pipefail
if [ -n "$CROSS_PAT" ]; then
echo "available=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "available=false" >> "$GITHUB_OUTPUT"
if [ "$OWNER" = "hivecommons" ]; then
echo "::error::secret ${CROSS_SECRET_NAME} is not set, so this run cannot mirror to ghcr.io/${CROSS_ORG}. Post-transfer that is a silent one-sided publish (spokes pinned to the old org keep pulling stale tags), so this fails hard. Create a classic PAT with write:packages from a ${CROSS_ORG} member and store it as ${CROSS_SECRET_NAME}."
exit 1
fi
echo "::warning::secret ${CROSS_SECRET_NAME} is not set β€” skipping the ghcr.io/${CROSS_ORG} mirror. Pre-transfer this is expected until that PAT is created; nothing that exists today degrades."
- name: Log in to GHCR as the cross-org publisher
if: steps.crosspat.outputs.available == 'true'
uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets[needs.gate.outputs.cross_secret] }}
- name: Mirror published tags into the cross-org registry (by digest)
if: steps.crosspat.outputs.available == 'true'
env:
NATIVE_IMAGE: ghcr.io/${{ github.repository_owner }}/hive-hub
CROSS_IMAGE: ghcr.io/${{ needs.gate.outputs.cross_org }}/hive-hub
BRANCH: ${{ needs.gate.outputs.ref_name }}
GIT_SHA: ${{ needs.gate.outputs.sha }}
# Lockstep with the publish-image-tags.sh args above.
INCLUDE_LATEST: "true"
RELEASE_BRANCH: "v5"
CHANNELS: "candidate"
run: |
set -euo pipefail
branch_tag="${BRANCH//\//-}"
tags=("${GIT_SHA:0:7}" "${branch_tag}-latest")
if [ "$INCLUDE_LATEST" = "true" ]; then tags+=("latest"); fi
if [ "$BRANCH" = "$RELEASE_BRANCH" ] && [ -n "$CHANNELS" ]; then
IFS=, read -ra channel_tags <<<"$CHANNELS"
for ch in "${channel_tags[@]}"; do [ -n "$ch" ] && tags+=("$ch"); done
fi
for tag in "${tags[@]}"; do
if ! digest=$(docker buildx imagetools inspect \
"${NATIVE_IMAGE}:${tag}" --format '{{.Manifest.Digest}}' 2>&1); then
if grep -Eqi 'manifest unknown|manifest.*not found|not found.*manifest|no such manifest|(^|[[:space:]:])not found$' <<<"$digest"; then
echo "native tag ${NATIVE_IMAGE}:${tag} does not exist β€” nothing to mirror for it"
continue
fi
echo "::error::could not inspect ${NATIVE_IMAGE}:${tag}; refusing to silently skip a mirror on a transport/auth failure" >&2
echo "$digest" >&2
exit 1
fi
echo "Mirroring ${CROSS_IMAGE}:${tag} <- ${NATIVE_IMAGE}@${digest}"
docker buildx imagetools create -t "${CROSS_IMAGE}:${tag}" "${NATIVE_IMAGE}@${digest}"
done