From 635c72db031e30e5761d27e8209168c283ad15fc Mon Sep 17 00:00:00 2001 From: Ignacio Van Droogenbroeck Date: Wed, 27 May 2026 14:59:08 -0600 Subject: [PATCH 1/4] ci(release): push multi-arch image to Docker Hub alongside GHCR Every release now publishes the multi-arch manifest (version, short_version, latest) to both ghcr.io/basekick-labs/arc and docker.io/basekicklabs/arc. - docker-build: logs into Docker Hub + pushes the per-platform image by digest to both registries (second build-push is a buildkit cache hit). Asserts the manifest digest matches across registries. - docker-merge: creates the multi-arch manifest in both registries from the shared digests. Adds a `test_mode` workflow_dispatch input (default false). When true, the `latest` tag is suppressed end-to-end so a CI verification run can't clobber the real latest on either registry. Branch-push releases are never test mode. Requires repo secrets DOCKERHUB_USERNAME + DOCKERHUB_TOKEN (token is a Docker Hub access token with Read & Write, not the account password). --- .github/workflows/release-build.yml | 131 ++++++++++++++++++++++++---- 1 file changed, 115 insertions(+), 16 deletions(-) diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 0e5365ee..b510ed22 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -10,6 +10,11 @@ on: description: 'Release version (e.g., 25.11.1)' required: true type: string + test_mode: + description: 'Test mode: push version/short tags only, NOT latest (use for CI verification so a test run cannot clobber the real latest tag)' + required: false + type: boolean + default: false permissions: contents: write @@ -17,6 +22,11 @@ permissions: env: REGISTRY_GHCR: ghcr.io IMAGE_NAME: basekick-labs/arc + # Docker Hub mirror — published alongside GHCR on every release. + # DOCKERHUB_USERNAME / DOCKERHUB_TOKEN are repo secrets (the token is a + # Docker Hub access token, not the account password). + REGISTRY_DOCKERHUB: docker.io + IMAGE_NAME_DOCKERHUB: basekicklabs/arc jobs: # Extract version from branch name or input @@ -26,6 +36,7 @@ jobs: outputs: version: ${{ steps.version.outputs.version }} short_version: ${{ steps.version.outputs.short_version }} + test_mode: ${{ steps.version.outputs.test_mode }} steps: - name: Extract version id: version @@ -46,9 +57,19 @@ jobs: # Extract short version (25.11) SHORT_VERSION="${VERSION%.*}" + # test_mode (workflow_dispatch only) suppresses the `latest` + # tag so a CI verification run cannot overwrite the real + # latest on either registry. Branch-push releases are never + # test mode. + TEST_MODE="${{ inputs.test_mode }}" + if [ "${{ github.event_name }}" != "workflow_dispatch" ]; then + TEST_MODE="false" + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT echo "short_version=$SHORT_VERSION" >> $GITHUB_OUTPUT - echo "📦 Building version: $VERSION" >> $GITHUB_STEP_SUMMARY + echo "test_mode=$TEST_MODE" >> $GITHUB_OUTPUT + echo "📦 Building version: $VERSION (test_mode=$TEST_MODE)" >> $GITHUB_STEP_SUMMARY # Build and push Docker multi-arch images docker-build: @@ -80,19 +101,33 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY_DOCKERHUB }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Extract metadata id: meta uses: docker/metadata-action@v5 with: images: | ${{ env.REGISTRY_GHCR }}/${{ env.IMAGE_NAME }} + ${{ env.REGISTRY_DOCKERHUB }}/${{ env.IMAGE_NAME_DOCKERHUB }} tags: | type=raw,value=${{ needs.prepare.outputs.version }} type=raw,value=${{ needs.prepare.outputs.short_version }} - type=raw,value=latest - - - name: Build and push by digest - id: build + type=raw,value=latest,enable=${{ needs.prepare.outputs.test_mode != 'true' }} + + # Push the per-platform image by digest to BOTH registries. The + # digest-and-merge pattern needs one digest per registry per + # platform; build-push-action can only target a single image name + # per invocation when using push-by-digest, so we run it twice + # (the second is a cache hit — same context, same build args — so + # it only re-pushes the already-built layers, no rebuild). + - name: Build and push by digest (GHCR) + id: build_ghcr uses: docker/build-push-action@v5 with: context: . @@ -104,11 +139,34 @@ jobs: build-args: | VERSION=${{ needs.prepare.outputs.version }} - - name: Export digest + - name: Build and push by digest (Docker Hub) + id: build_dockerhub + uses: docker/build-push-action@v5 + with: + context: . + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + outputs: type=image,name=${{ env.REGISTRY_DOCKERHUB }}/${{ env.IMAGE_NAME_DOCKERHUB }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + VERSION=${{ needs.prepare.outputs.version }} + + - name: Export digests run: | + # The digest is identical across both registries (same image + # content) but we write it to per-registry dirs so the merge + # job can build each manifest independently. They should match; + # we assert that so a divergence (e.g. a non-reproducible build) + # fails loudly rather than producing mismatched manifests. + ghcr_digest="${{ steps.build_ghcr.outputs.digest }}" + dockerhub_digest="${{ steps.build_dockerhub.outputs.digest }}" + if [[ "$ghcr_digest" != "$dockerhub_digest" ]]; then + echo "::error::digest mismatch between registries: ghcr=$ghcr_digest dockerhub=$dockerhub_digest" + exit 1 + fi mkdir -p /tmp/digests - digest="${{ steps.build.outputs.digest }}" - touch "/tmp/digests/${digest#sha256:}" + touch "/tmp/digests/${ghcr_digest#sha256:}" - name: Upload digest uses: actions/upload-artifact@v4 @@ -465,18 +523,59 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Create manifest list and push + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY_DOCKERHUB }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Create manifest list and push (GHCR) working-directory: /tmp/digests + env: + IMAGE_REF: ${{ env.REGISTRY_GHCR }}/${{ env.IMAGE_NAME }} + VERSION: ${{ needs.prepare.outputs.version }} + SHORT_VERSION: ${{ needs.prepare.outputs.short_version }} + TEST_MODE: ${{ needs.prepare.outputs.test_mode }} run: | - # Create multi-arch manifest with version, short_version, and latest tags + # Multi-arch manifest with version + short_version, plus latest + # unless this is a test_mode run (which must not clobber latest). + # The digests were pushed by-digest to GHCR in docker-build, so + # imagetools can reference them by @sha256 here. + TAGS=( -t "${IMAGE_REF}:${VERSION}" -t "${IMAGE_REF}:${SHORT_VERSION}" ) + if [ "${TEST_MODE}" != "true" ]; then + TAGS+=( -t "${IMAGE_REF}:latest" ) + fi docker buildx imagetools create \ - -t ${{ env.REGISTRY_GHCR }}/${{ env.IMAGE_NAME }}:${{ needs.prepare.outputs.version }} \ - -t ${{ env.REGISTRY_GHCR }}/${{ env.IMAGE_NAME }}:${{ needs.prepare.outputs.short_version }} \ - -t ${{ env.REGISTRY_GHCR }}/${{ env.IMAGE_NAME }}:latest \ - $(printf '${{ env.REGISTRY_GHCR }}/${{ env.IMAGE_NAME }}@sha256:%s ' *) + "${TAGS[@]}" \ + $(printf "${IMAGE_REF}@sha256:%s " *) + + echo "✅ GHCR multi-arch manifest pushed" >> $GITHUB_STEP_SUMMARY - echo "✅ Multi-arch manifest created and pushed" >> $GITHUB_STEP_SUMMARY - echo "Tags: ${{ needs.prepare.outputs.version }}, ${{ needs.prepare.outputs.short_version }}, latest" >> $GITHUB_STEP_SUMMARY + - name: Create manifest list and push (Docker Hub) + working-directory: /tmp/digests + env: + IMAGE_REF: ${{ env.REGISTRY_DOCKERHUB }}/${{ env.IMAGE_NAME_DOCKERHUB }} + VERSION: ${{ needs.prepare.outputs.version }} + SHORT_VERSION: ${{ needs.prepare.outputs.short_version }} + TEST_MODE: ${{ needs.prepare.outputs.test_mode }} + run: | + # Same digests were also pushed by-digest to Docker Hub in + # docker-build, so the @sha256 references resolve there too. + # latest is suppressed in test_mode, identical to the GHCR step. + TAGS=( -t "${IMAGE_REF}:${VERSION}" -t "${IMAGE_REF}:${SHORT_VERSION}" ) + if [ "${TEST_MODE}" != "true" ]; then + TAGS+=( -t "${IMAGE_REF}:latest" ) + fi + docker buildx imagetools create \ + "${TAGS[@]}" \ + $(printf "${IMAGE_REF}@sha256:%s " *) + + LATEST_NOTE="latest" + [ "${TEST_MODE}" = "true" ] && LATEST_NOTE="(latest suppressed — test_mode)" + echo "✅ Docker Hub multi-arch manifest pushed" >> $GITHUB_STEP_SUMMARY + echo "Tags: ${VERSION}, ${SHORT_VERSION}, ${LATEST_NOTE}" >> $GITHUB_STEP_SUMMARY + echo "Registries: ${{ env.REGISTRY_GHCR }}/${{ env.IMAGE_NAME }}, ${IMAGE_REF}" >> $GITHUB_STEP_SUMMARY # Test Docker image health test-docker: From f63df8506ff4b78d001fee8563ae1d8b6338986c Mon Sep 17 00:00:00 2001 From: Ignacio Van Droogenbroeck Date: Wed, 27 May 2026 15:05:24 -0600 Subject: [PATCH 2/4] ci(release): use per-registry digests in docker merge (fix test run) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first test run failed: my digest-equality assertion was wrong. push-by-digest serialises the manifest per registry, so GHCR and Docker Hub legitimately return DIFFERENT manifest digests for the same image content — not a non-reproducible build. Fix: drop the equality assertion. Write each registry's digest to its own subdir (/tmp/digests/{ghcr,dockerhub}/) and have each merge step reference that registry's digest set when building its manifest. --- .github/workflows/release-build.yml | 35 +++++++++++++---------------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index b510ed22..12dae855 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -154,25 +154,22 @@ jobs: - name: Export digests run: | - # The digest is identical across both registries (same image - # content) but we write it to per-registry dirs so the merge - # job can build each manifest independently. They should match; - # we assert that so a divergence (e.g. a non-reproducible build) - # fails loudly rather than producing mismatched manifests. + # Each registry returns its OWN manifest digest for the same + # image content — push-by-digest serialises the manifest per + # registry, so the two digests legitimately differ. Keep them + # in separate per-registry dirs; the merge job references each + # registry's digest when building that registry's manifest. ghcr_digest="${{ steps.build_ghcr.outputs.digest }}" dockerhub_digest="${{ steps.build_dockerhub.outputs.digest }}" - if [[ "$ghcr_digest" != "$dockerhub_digest" ]]; then - echo "::error::digest mismatch between registries: ghcr=$ghcr_digest dockerhub=$dockerhub_digest" - exit 1 - fi - mkdir -p /tmp/digests - touch "/tmp/digests/${ghcr_digest#sha256:}" + mkdir -p /tmp/digests/ghcr /tmp/digests/dockerhub + touch "/tmp/digests/ghcr/${ghcr_digest#sha256:}" + touch "/tmp/digests/dockerhub/${dockerhub_digest#sha256:}" - - name: Upload digest + - name: Upload digests uses: actions/upload-artifact@v4 with: name: digests-${{ strategy.job-index }} - path: /tmp/digests/* + path: /tmp/digests/** if-no-files-found: error retention-days: 1 @@ -531,7 +528,7 @@ jobs: password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Create manifest list and push (GHCR) - working-directory: /tmp/digests + working-directory: /tmp/digests/ghcr env: IMAGE_REF: ${{ env.REGISTRY_GHCR }}/${{ env.IMAGE_NAME }} VERSION: ${{ needs.prepare.outputs.version }} @@ -540,8 +537,8 @@ jobs: run: | # Multi-arch manifest with version + short_version, plus latest # unless this is a test_mode run (which must not clobber latest). - # The digests were pushed by-digest to GHCR in docker-build, so - # imagetools can reference them by @sha256 here. + # /tmp/digests/ghcr/* are the per-platform GHCR manifest digests + # (each registry returns its own digest for the same content). TAGS=( -t "${IMAGE_REF}:${VERSION}" -t "${IMAGE_REF}:${SHORT_VERSION}" ) if [ "${TEST_MODE}" != "true" ]; then TAGS+=( -t "${IMAGE_REF}:latest" ) @@ -553,15 +550,15 @@ jobs: echo "✅ GHCR multi-arch manifest pushed" >> $GITHUB_STEP_SUMMARY - name: Create manifest list and push (Docker Hub) - working-directory: /tmp/digests + working-directory: /tmp/digests/dockerhub env: IMAGE_REF: ${{ env.REGISTRY_DOCKERHUB }}/${{ env.IMAGE_NAME_DOCKERHUB }} VERSION: ${{ needs.prepare.outputs.version }} SHORT_VERSION: ${{ needs.prepare.outputs.short_version }} TEST_MODE: ${{ needs.prepare.outputs.test_mode }} run: | - # Same digests were also pushed by-digest to Docker Hub in - # docker-build, so the @sha256 references resolve there too. + # /tmp/digests/dockerhub/* are the per-platform Docker Hub + # manifest digests — distinct from the GHCR digests above. # latest is suppressed in test_mode, identical to the GHCR step. TAGS=( -t "${IMAGE_REF}:${VERSION}" -t "${IMAGE_REF}:${SHORT_VERSION}" ) if [ "${TEST_MODE}" != "true" ]; then From 8bc3bfc72e506b5a90b76d167174971c0698abd7 Mon Sep 17 00:00:00 2001 From: Ignacio Van Droogenbroeck Date: Wed, 27 May 2026 15:13:21 -0600 Subject: [PATCH 3/4] ci: bump all actions to Node-24 majors (GitHub Node-20 deprecation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub forces Node-24 for JS actions starting 2026-06-02 and removes Node-20 from runners 2026-09-16. Bumping every JS action to its lowest/current Node-24 major so the release pipeline doesn't break mid-release. Verified each action's Node runtime from its release notes / action.yml rather than guessing: actions/checkout v4 → v5 (v5 = first node24) actions/setup-go v5 → v6 (v5 was node20; v6 = node24) actions/upload-artifact v4 → v6 (v6 = first true node24 default) actions/download-artifact v4 → v7 (v7 = first true node24; pairs w/ upload v6) azure/setup-helm v4 → v5 (named in the deprecation warning) azure/setup-kubectl v4 → v5 (node24) docker/setup-qemu-action v3 → v4 (node24) docker/setup-buildx-action v3 → v4 (node24) docker/login-action v3 → v4 (node24) docker/metadata-action v5 → v6 (node24) docker/build-push-action v5,v6 → v7 (v7 = node24 default; unifies the two new dual-registry pushes with the existing helm-smoke build) softprops/action-gh-release v1 → v3 (v3 = node24) Left as-is (verified not affected): helm/kind-action@v1 — action.yml already declares using: node24 anthropics/claude-code-action@v1 — composite action, no JS runtime Chose the artifact pair upload@v6 + download@v7 (the first true node24 defaults) over v7/v8 to clear the deprecation without inheriting v7/v8's ESM + direct-upload + digest-error behaviour changes mid-release-stabilisation. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/claude-review.yml | 2 +- .github/workflows/release-build.yml | 70 ++++++++++++++--------------- 2 files changed, 36 insertions(+), 36 deletions(-) diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index 3d4cf325..50f310cb 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -24,7 +24,7 @@ jobs: issues: write id-token: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 1 diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 12dae855..052e33b1 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -86,23 +86,23 @@ jobs: - linux/arm64 steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY_GHCR }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Log in to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY_DOCKERHUB }} username: ${{ secrets.DOCKERHUB_USERNAME }} @@ -110,7 +110,7 @@ jobs: - name: Extract metadata id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: | ${{ env.REGISTRY_GHCR }}/${{ env.IMAGE_NAME }} @@ -128,7 +128,7 @@ jobs: # it only re-pushes the already-built layers, no rebuild). - name: Build and push by digest (GHCR) id: build_ghcr - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v7 with: context: . platforms: ${{ matrix.platform }} @@ -141,7 +141,7 @@ jobs: - name: Build and push by digest (Docker Hub) id: build_dockerhub - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v7 with: context: . platforms: ${{ matrix.platform }} @@ -166,7 +166,7 @@ jobs: touch "/tmp/digests/dockerhub/${dockerhub_digest#sha256:}" - name: Upload digests - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: digests-${{ strategy.job-index }} path: /tmp/digests/** @@ -187,10 +187,10 @@ jobs: runs-on: ${{ matrix.runner }} steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version: '1.26' cache: true @@ -213,7 +213,7 @@ jobs: ls -lh arc-${{ matrix.suffix }} - name: Upload binary - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: arc-binary-${{ matrix.suffix }} path: arc-${{ matrix.suffix }} @@ -233,10 +233,10 @@ jobs: binary_suffix: linux-arm64 steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Download binary - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: name: arc-binary-${{ matrix.binary_suffix }} @@ -345,7 +345,7 @@ jobs: sha256sum ${PKG_DIR}.deb > ${PKG_DIR}.deb.sha256 - name: Upload Debian package - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: arc-debian-${{ matrix.arch }}-${{ needs.prepare.outputs.version }} path: | @@ -367,7 +367,7 @@ jobs: binary_suffix: linux-arm64 steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Install RPM tools run: | @@ -375,7 +375,7 @@ jobs: sudo apt-get install -y rpm - name: Download binary - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: name: arc-binary-${{ matrix.binary_suffix }} @@ -486,7 +486,7 @@ jobs: sha256sum arc-${VERSION}-1.*.rpm > arc-${VERSION}-1.${ARCH}.rpm.sha256 - name: Upload RPM package - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: arc-rpm-${{ matrix.arch }}-${{ needs.prepare.outputs.version }} path: | @@ -504,24 +504,24 @@ jobs: packages: write steps: - name: Download digests - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: pattern: digests-* merge-multiple: true path: /tmp/digests - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY_GHCR }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Log in to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY_DOCKERHUB }} username: ${{ secrets.DOCKERHUB_USERNAME }} @@ -638,7 +638,7 @@ jobs: needs: [prepare, build-binaries] steps: - name: Download amd64 binary - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: name: arc-binary-linux-amd64 @@ -680,10 +680,10 @@ jobs: needs: prepare steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Install Helm - uses: azure/setup-helm@v4 + uses: azure/setup-helm@v5 with: version: '3.13.0' @@ -703,7 +703,7 @@ jobs: sha256sum arc-${VERSION}.tgz > arc-${VERSION}.tgz.sha256 - name: Upload Helm chart - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: arc-helm-${{ needs.prepare.outputs.version }} path: | @@ -718,19 +718,19 @@ jobs: needs: [prepare, helm-package] steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Download Helm chart - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: name: arc-helm-${{ needs.prepare.outputs.version }} path: dist/ - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Build Docker image for testing - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . push: false @@ -740,10 +740,10 @@ jobs: cache-to: type=gha,mode=max - name: Install kubectl - uses: azure/setup-kubectl@v4 + uses: azure/setup-kubectl@v5 - name: Install Helm - uses: azure/setup-helm@v4 + uses: azure/setup-helm@v5 with: version: '3.13.0' @@ -803,12 +803,12 @@ jobs: contents: write steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 - name: Download all release artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: pattern: arc-* merge-multiple: true @@ -931,7 +931,7 @@ jobs: cat release-notes.md - name: Create draft release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v3 with: draft: true prerelease: false From 763fd9d20d467a9f7d64596009b192299a558831 Mon Sep 17 00:00:00 2001 From: Ignacio Van Droogenbroeck Date: Wed, 27 May 2026 15:34:43 -0600 Subject: [PATCH 4/4] ci(release): publish Docker Hub via direct buildx --push (fix empty Tags page) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The digest-and-merge pattern (imagetools create) pushes via the registry manifest API, which Docker Hub's Tags page does NOT index — the image was pullable (verified anonymously) but hub.docker.com/r/ basekicklabs/arc/tags showed count:0. GHCR indexes registry-API pushes fine; Docker Hub only indexes the standard buildx --push path. Fix: split Docker Hub into its own job (docker-build-dockerhub) that does ONE multi-platform `docker buildx build --push` straight to docker.io/basekicklabs/arc with all tags. Layers come from the same gha cache the GHCR build populates, so it's mostly cache-hit + push. This goes through the path Hub's UI indexes, so published tags show up on the Tags page. GHCR stays on the fast per-platform digest-and-merge (docker-build + docker-merge) — its package UI indexes those correctly. test_mode latest-suppression preserved on both registries. Draft release now also `needs` docker-build-dockerhub so a Hub push failure blocks the release. --- .github/workflows/release-build.yml | 145 ++++++++++++++-------------- 1 file changed, 74 insertions(+), 71 deletions(-) diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 052e33b1..53ef9c0d 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -101,31 +101,23 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Log in to Docker Hub - uses: docker/login-action@v4 - with: - registry: ${{ env.REGISTRY_DOCKERHUB }} - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Extract metadata id: meta uses: docker/metadata-action@v6 with: images: | ${{ env.REGISTRY_GHCR }}/${{ env.IMAGE_NAME }} - ${{ env.REGISTRY_DOCKERHUB }}/${{ env.IMAGE_NAME_DOCKERHUB }} tags: | type=raw,value=${{ needs.prepare.outputs.version }} type=raw,value=${{ needs.prepare.outputs.short_version }} type=raw,value=latest,enable=${{ needs.prepare.outputs.test_mode != 'true' }} - # Push the per-platform image by digest to BOTH registries. The - # digest-and-merge pattern needs one digest per registry per - # platform; build-push-action can only target a single image name - # per invocation when using push-by-digest, so we run it twice - # (the second is a cache hit — same context, same build args — so - # it only re-pushes the already-built layers, no rebuild). + # GHCR uses the digest-and-merge pattern: each platform pushes its + # image by digest, and docker-merge combines them into a multi-arch + # manifest. GHCR's package UI indexes these registry-API pushes + # correctly. (Docker Hub does NOT — its Tags page only indexes the + # standard buildx --push path — so Docker Hub is handled separately + # in the docker-build-dockerhub job below, not here.) - name: Build and push by digest (GHCR) id: build_ghcr uses: docker/build-push-action@v7 @@ -139,39 +131,79 @@ jobs: build-args: | VERSION=${{ needs.prepare.outputs.version }} - - name: Build and push by digest (Docker Hub) - id: build_dockerhub + - name: Export digest + run: | + mkdir -p /tmp/digests + digest="${{ steps.build_ghcr.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v6 + with: + name: digests-${{ strategy.job-index }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + # Docker Hub gets a single multi-platform `buildx --push` (not the + # digest-and-merge pattern). imagetools/digest-merge pushes via the + # registry manifest API, which Docker Hub's Tags page does NOT index — + # the image is pullable but the Tags listing stays empty. A direct + # multi-platform build-push goes through the path Hub's UI indexes, so + # the published tags actually show up. Layers come from the same gha + # cache the GHCR build populated, so this is mostly cache-hit + push. + docker-build-dockerhub: + name: Build & Push Docker Hub + runs-on: ubuntu-latest + needs: prepare + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to Docker Hub + uses: docker/login-action@v4 + with: + registry: ${{ env.REGISTRY_DOCKERHUB }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v6 + with: + images: | + ${{ env.REGISTRY_DOCKERHUB }}/${{ env.IMAGE_NAME_DOCKERHUB }} + tags: | + type=raw,value=${{ needs.prepare.outputs.version }} + type=raw,value=${{ needs.prepare.outputs.short_version }} + type=raw,value=latest,enable=${{ needs.prepare.outputs.test_mode != 'true' }} + + - name: Build and push (multi-platform, direct) uses: docker/build-push-action@v7 with: context: . - platforms: ${{ matrix.platform }} + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - outputs: type=image,name=${{ env.REGISTRY_DOCKERHUB }}/${{ env.IMAGE_NAME_DOCKERHUB }},push-by-digest=true,name-canonical=true,push=true cache-from: type=gha cache-to: type=gha,mode=max build-args: | VERSION=${{ needs.prepare.outputs.version }} - - name: Export digests + - name: Summary run: | - # Each registry returns its OWN manifest digest for the same - # image content — push-by-digest serialises the manifest per - # registry, so the two digests legitimately differ. Keep them - # in separate per-registry dirs; the merge job references each - # registry's digest when building that registry's manifest. - ghcr_digest="${{ steps.build_ghcr.outputs.digest }}" - dockerhub_digest="${{ steps.build_dockerhub.outputs.digest }}" - mkdir -p /tmp/digests/ghcr /tmp/digests/dockerhub - touch "/tmp/digests/ghcr/${ghcr_digest#sha256:}" - touch "/tmp/digests/dockerhub/${dockerhub_digest#sha256:}" - - - name: Upload digests - uses: actions/upload-artifact@v6 - with: - name: digests-${{ strategy.job-index }} - path: /tmp/digests/** - if-no-files-found: error - retention-days: 1 + echo "✅ Docker Hub multi-arch image pushed via direct buildx --push" >> $GITHUB_STEP_SUMMARY + echo "Tags:" >> $GITHUB_STEP_SUMMARY + echo '${{ steps.meta.outputs.tags }}' | sed 's/^/ - /' >> $GITHUB_STEP_SUMMARY # Build Go binaries using native runners (CGO required for SQLite) build-binaries: @@ -520,15 +552,8 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Log in to Docker Hub - uses: docker/login-action@v4 - with: - registry: ${{ env.REGISTRY_DOCKERHUB }} - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Create manifest list and push (GHCR) - working-directory: /tmp/digests/ghcr + working-directory: /tmp/digests env: IMAGE_REF: ${{ env.REGISTRY_GHCR }}/${{ env.IMAGE_NAME }} VERSION: ${{ needs.prepare.outputs.version }} @@ -537,29 +562,8 @@ jobs: run: | # Multi-arch manifest with version + short_version, plus latest # unless this is a test_mode run (which must not clobber latest). - # /tmp/digests/ghcr/* are the per-platform GHCR manifest digests - # (each registry returns its own digest for the same content). - TAGS=( -t "${IMAGE_REF}:${VERSION}" -t "${IMAGE_REF}:${SHORT_VERSION}" ) - if [ "${TEST_MODE}" != "true" ]; then - TAGS+=( -t "${IMAGE_REF}:latest" ) - fi - docker buildx imagetools create \ - "${TAGS[@]}" \ - $(printf "${IMAGE_REF}@sha256:%s " *) - - echo "✅ GHCR multi-arch manifest pushed" >> $GITHUB_STEP_SUMMARY - - - name: Create manifest list and push (Docker Hub) - working-directory: /tmp/digests/dockerhub - env: - IMAGE_REF: ${{ env.REGISTRY_DOCKERHUB }}/${{ env.IMAGE_NAME_DOCKERHUB }} - VERSION: ${{ needs.prepare.outputs.version }} - SHORT_VERSION: ${{ needs.prepare.outputs.short_version }} - TEST_MODE: ${{ needs.prepare.outputs.test_mode }} - run: | - # /tmp/digests/dockerhub/* are the per-platform Docker Hub - # manifest digests — distinct from the GHCR digests above. - # latest is suppressed in test_mode, identical to the GHCR step. + # Docker Hub is published separately (docker-build-dockerhub) via + # a direct buildx --push so its Tags UI indexes the release. TAGS=( -t "${IMAGE_REF}:${VERSION}" -t "${IMAGE_REF}:${SHORT_VERSION}" ) if [ "${TEST_MODE}" != "true" ]; then TAGS+=( -t "${IMAGE_REF}:latest" ) @@ -570,9 +574,8 @@ jobs: LATEST_NOTE="latest" [ "${TEST_MODE}" = "true" ] && LATEST_NOTE="(latest suppressed — test_mode)" - echo "✅ Docker Hub multi-arch manifest pushed" >> $GITHUB_STEP_SUMMARY + echo "✅ GHCR multi-arch manifest pushed" >> $GITHUB_STEP_SUMMARY echo "Tags: ${VERSION}, ${SHORT_VERSION}, ${LATEST_NOTE}" >> $GITHUB_STEP_SUMMARY - echo "Registries: ${{ env.REGISTRY_GHCR }}/${{ env.IMAGE_NAME }}, ${IMAGE_REF}" >> $GITHUB_STEP_SUMMARY # Test Docker image health test-docker: @@ -798,7 +801,7 @@ jobs: create-draft-release: name: Create Draft Release runs-on: ubuntu-latest - needs: [prepare, docker-merge, test-docker, test-binary, helm-package, test-helm, debian-build, rpm-build] + needs: [prepare, docker-merge, docker-build-dockerhub, test-docker, test-binary, helm-package, test-helm, debian-build, rpm-build] permissions: contents: write steps: