Repository navigation
Merge branch 'v5' into scanner/fix-17426 #42996
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |