From 8c7a641a65eaa0dbf6bfe35fded1c290f45e2116 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Sat, 23 May 2026 16:37:36 +0200 Subject: [PATCH 1/3] ci(build): native amd64+arm64 runners + merge-job pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splits the multi-arch build from one QEMU-emulated job per (track, composer) into two native-arch parallel jobs that build by digest, plus a merge job per cell that assembles the final manifest list, attests, and signs it. Before: ubuntu-latest with `setup-qemu-action` and `platforms: linux/amd64,linux/arm64` → arm64 emulated → ~40 min wall per cell. With 5 active matrix cells (3 tracks × 2 composer modes minus tag/rolling exclude), the full daily build took ~40 min. After: each (track, composer, platform) builds on its native runner — amd64 on `ubuntu-latest`, arm64 on `ubuntu-24.04-arm` — both ~12 min in parallel. Then a per-(track, composer) merge job joins the two digests with `docker buildx imagetools create` and pushes the manifest list under the canonical tags. Daily build now ~15 min wall. Implementation: - Build job (now per-arch): - `runs-on: ${{ matrix.platform.runner }}` — `ubuntu-latest` / `ubuntu-24.04-arm` - QEMU step removed (native) - `docker/build-push-action` outputs `type=image,push-by-digest=true, name-canonical=true` (no tags pushed yet) - Per-arch digest exported and uploaded as a workflow artifact - Merge job (new): - `needs: build`, runs after both per-arch builds complete - Per (track, composer) matrix matching the build matrix - Downloads the per-arch digest artifacts - Re-resolves the ref and re-computes the tag list (same logic as the build job) - `docker buildx imagetools create -t -t ... @sha256: @sha256:` - Captures the manifest-list digest, then runs `attest-build-provenance` and `cosign sign` on THAT digest - Sanity check: aborts if fewer than 2 per-arch digests arrived (catches silent build-job arch failures that would otherwise publish a degenerate single-arch manifest) What stays the same: - The `tag/rolling` matrix exclude (symfony/dom-crawler audit advisory) - `continue-on-error` for the rolling variants - The PR-event gate that runs only the `tag` track for PR builds (PR builds now produce 2 parallel per-arch builds — both push-by- digest=false, so no registry writes happen on PR) - All attestation + cosign outputs land under the manifest-list digest, identical observable behaviour for consumers Signed-off-by: Sebastian Mendel --- .github/workflows/build.yml | 290 +++++++++++++++++++++++++++++++++--- 1 file changed, 270 insertions(+), 20 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 77949a3..0327d7c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -47,8 +47,8 @@ concurrency: jobs: build: - name: build (${{ matrix.track.name }}/${{ matrix.composer }}) - runs-on: ubuntu-latest + name: build (${{ matrix.track.name }}/${{ matrix.composer }}/${{ matrix.platform.arch }}) + runs-on: ${{ matrix.platform.runner }} # Rolling variants resolve composer deps fresh against composer.json # ranges, so they fail whenever upstream Snipe-IT's constraints # reference a major that's now under a composer audit advisory @@ -90,6 +90,17 @@ jobs: # `rolling` — composer.lock deleted before install (catches transitive CVEs) - pinned - rolling + platform: + # Native runners per arch — no QEMU emulation. amd64 builds + # on ubuntu-latest (~12 min), arm64 on ubuntu-24.04-arm (~12 + # min, in parallel). Previous arrangement was QEMU-emulated + # arm64 on ubuntu-latest, adding ~25 min per cell. The merge + # job below joins the two per-arch digests into a single + # multi-arch manifest list per (track, composer) cell. + - arch: amd64 + runner: ubuntu-latest + - arch: arm64 + runner: ubuntu-24.04-arm exclude: # tag/rolling is structurally impossible to be green: snipe-it's # tagged composer.json carries a `symfony/dom-crawler ^4.4` @@ -177,9 +188,9 @@ jobs: } >> "$GITHUB_OUTPUT" echo "Resolved: track=$TRACK_NAME ref=$REF version=$VERSION" - - name: Set up QEMU - if: steps.gate.outputs.skip != 'true' - uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 + # QEMU intentionally NOT installed: native amd64 + arm64 runners + # build their own platform. The previous emulated arm64 build on + # ubuntu-latest added ~25 min to wall-time per cell. - name: Set up Docker Buildx if: steps.gate.outputs.skip != 'true' @@ -273,16 +284,20 @@ jobs: org.opencontainers.image.revision=${{ github.sha }} org.opencontainers.image.licenses=AGPL-3.0-or-later - - name: Build (and push if not PR) + - name: Build (by digest, no tags yet — tags pushed by merge job) if: steps.gate.outputs.skip != 'true' id: build uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: . - platforms: ${{ github.event_name == 'pull_request' && 'linux/amd64' || 'linux/amd64,linux/arm64' }} + platforms: linux/${{ matrix.platform.arch }} target: runtime - push: ${{ github.event_name != 'pull_request' }} - tags: ${{ steps.meta.outputs.tags }} + # Push by digest only: no human-readable tag is attached to + # this per-platform manifest. The merge job downloads all + # per-arch digests and creates a single multi-arch manifest + # list with the actual tags. Skipped entirely on PR events + # (push: false) so PRs never write to the registry. + outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }} labels: ${{ steps.meta.outputs.labels }} build-args: | SNIPE_IT_VERSION=${{ steps.ref.outputs.ref }} @@ -313,17 +328,253 @@ jobs: cache-from: type=gha,scope=${{ matrix.track.name }}-${{ matrix.composer }} cache-to: type=gha,scope=${{ matrix.track.name }}-${{ matrix.composer }},mode=max - - name: Attest build provenance + # Per-arch digest export. The merge job downloads every per-arch + # artifact for a given (track, composer) and joins them into one + # multi-arch manifest. Attestation + cosign signing happen on + # that final manifest in the merge job — signing per-arch + # manifests would produce N signatures the consumer would have + # to verify in lockstep with the platform they're pulling. + - name: Export per-arch digest + if: steps.gate.outputs.skip != 'true' && github.event_name != 'pull_request' + env: + DIGEST: ${{ steps.build.outputs.digest }} + run: | + mkdir -p /tmp/digests + # File name is the digest minus the algorithm prefix, content + # is empty — the file's existence + its name is the payload. + touch "/tmp/digests/${DIGEST#sha256:}" + + - name: Upload per-arch digest artifact if: steps.gate.outputs.skip != 'true' && github.event_name != 'pull_request' + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + name: digests-${{ matrix.track.name }}-${{ matrix.composer }}-${{ matrix.platform.arch }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + # ─ Merge per-arch digests into a single multi-arch manifest list ─────── + # Runs once per (track, composer) cell after all per-arch builds in + # that cell complete. Downloads all digest artifacts for the cell, + # creates the manifest list with the canonical tags, and pushes it. + # Then runs attestation + cosign signing on the merged manifest. + merge: + name: merge (${{ matrix.track.name }}/${{ matrix.composer }}) + if: github.event_name != 'pull_request' + needs: build + runs-on: ubuntu-latest + continue-on-error: ${{ matrix.composer == 'rolling' }} + permissions: + contents: read + packages: write + id-token: write + attestations: write + strategy: + fail-fast: false + matrix: + track: + - &tag-track-merge + name: tag + ref_source: file + tags_extra: '' + - name: master + ref_source: master + tags_extra: 'master' + - name: develop + ref_source: develop + tags_extra: 'develop,nightly' + composer: + - pinned + - rolling + exclude: + # Same as the build matrix — tag/rolling is structurally + # impossible to be green (symfony/dom-crawler ^4.4 + audit). + - track: *tag-track-merge + composer: rolling + steps: + - name: Filter — should this track run? + id: gate + env: + EVENT: ${{ github.event_name }} + TRACK: ${{ matrix.track.name }} + INPUT_TRACK: ${{ inputs.track }} + run: | + if [ "$EVENT" = "workflow_dispatch" ] && [ -n "$INPUT_TRACK" ] \ + && [ "$INPUT_TRACK" != "all" ] && [ "$INPUT_TRACK" != "$TRACK" ]; then + echo "skip=true" >> "$GITHUB_OUTPUT"; exit 0 + fi + echo "skip=false" >> "$GITHUB_OUTPUT" + + - name: Checkout + if: steps.gate.outputs.skip != 'true' + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Resolve build ref for this track + if: steps.gate.outputs.skip != 'true' + id: ref + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REF_SOURCE: ${{ matrix.track.ref_source }} + INPUT_VERSION: ${{ inputs.snipe_it_version }} + TRACK_NAME: ${{ matrix.track.name }} + run: | + # Same logic as the build job's Resolve step — re-run so the + # merge job's tag computation uses the identical ref the build + # job did. (Inlined rather than factored to keep the workflow + # readable; ~20 lines.) + set -euo pipefail + if [ "$REF_SOURCE" = "file" ]; then + REF="${INPUT_VERSION:-$(cat .snipe-it-version)}" + else + REF=$(gh api "repos/grokability/snipe-it/branches/$REF_SOURCE" --jq '.commit.sha') + fi + VERSION="${REF#v}" + MAJOR_MINOR=$(echo "$VERSION" | awk -F. '{print $1 "." $2}') + MAJOR=$(echo "$VERSION" | awk -F. '{print $1}') + IS_TAG="false" + [ "$REF_SOURCE" = "file" ] && IS_TAG="true" + { + echo "ref=$REF" + echo "version=$VERSION" + echo "major=$MAJOR" + echo "major_minor=$MAJOR_MINOR" + echo "is_tag=$IS_TAG" + echo "build_date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" + echo "date_tag=$(date -u +'%Y%m%d')" + } >> "$GITHUB_OUTPUT" + + - name: Download per-arch digests + if: steps.gate.outputs.skip != 'true' + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + with: + path: /tmp/digests + pattern: digests-${{ matrix.track.name }}-${{ matrix.composer }}-* + merge-multiple: true + + - name: Set up Docker Buildx + if: steps.gate.outputs.skip != 'true' + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Log in to ghcr.io + if: steps.gate.outputs.skip != 'true' + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Compute image tags (tag-stream) + if: steps.gate.outputs.skip != 'true' && matrix.track.ref_source == 'file' + id: tags_tag + env: + IS_RELEASE_FROM_FILE: ${{ inputs.snipe_it_version == '' }} + COMPOSER_MODE: ${{ matrix.composer }} + REF_VERSION: ${{ steps.ref.outputs.version }} + REF_DATE_TAG: ${{ steps.ref.outputs.date_tag }} + REF_MAJOR_MINOR: ${{ steps.ref.outputs.major_minor }} + REF_MAJOR: ${{ steps.ref.outputs.major }} + IS_MAIN_REF: ${{ github.ref == 'refs/heads/main' }} + run: | + SUF="" + LATEST="latest" + if [ "$COMPOSER_MODE" = "rolling" ]; then + SUF="-rolling" + LATEST="rolling" + fi + ENABLE_LATEST="false" + if [ "$IS_RELEASE_FROM_FILE" = "true" ] && [ "$IS_MAIN_REF" = "true" ]; then + ENABLE_LATEST="true" + fi + { + echo "tags<<__EOF__" + echo "type=raw,value=${REF_VERSION}${SUF}" + echo "type=raw,value=${REF_VERSION}${SUF}-${REF_DATE_TAG}" + echo "type=raw,value=${REF_MAJOR_MINOR}${SUF}" + echo "type=raw,value=${REF_MAJOR}${SUF}" + echo "type=raw,value=${LATEST},enable=${ENABLE_LATEST}" + echo "type=sha,prefix=sha-${COMPOSER_MODE}-" + echo "__EOF__" + } >> "$GITHUB_OUTPUT" + + - name: Compute image tags (branch-stream) + if: steps.gate.outputs.skip != 'true' && matrix.track.ref_source != 'file' + id: tags_branch + env: + COMPOSER_MODE: ${{ matrix.composer }} + TAGS_EXTRA: ${{ matrix.track.tags_extra }} + REF_VERSION: ${{ steps.ref.outputs.version }} + run: | + SUF="" + if [ "$COMPOSER_MODE" = "rolling" ]; then + SUF="-rolling" + fi + { + echo "tags<<__EOF__" + # shellcheck disable=SC2086 # word-split intentional on comma list + for t in $(echo "$TAGS_EXTRA" | tr ',' ' '); do + echo "type=raw,value=${t}${SUF}" + done + echo "type=raw,value=${REF_VERSION}${SUF}" + echo "type=sha,prefix=sha-${COMPOSER_MODE}-" + echo "__EOF__" + } >> "$GITHUB_OUTPUT" + + - name: Extract metadata + if: steps.gate.outputs.skip != 'true' + id: meta + uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: ${{ steps.tags_tag.outputs.tags || steps.tags_branch.outputs.tags }} + + - name: Create + push multi-arch manifest list + if: steps.gate.outputs.skip != 'true' + id: manifest + env: + IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + METADATA_JSON: ${{ steps.meta.outputs.json }} + run: | + set -euo pipefail + # Build the -t flag list from metadata-action's tag output + # (JSON array under .tags). Build the per-arch source-image + # arguments from the digest files dropped by the build jobs. + mapfile -t TAG_FLAGS < <(jq -r '.tags[] | "-t \(.)"' <<<"$METADATA_JSON") + mapfile -t SRC_REFS < <( + find /tmp/digests -maxdepth 1 -type f -printf "${IMAGE}@sha256:%f\n" + ) + # Sanity: we expect exactly 2 per-arch digests (amd64+arm64). + # If a build-job arch failed silently — or a pattern-mismatch + # on the artifact download dropped a digest — we'd otherwise + # publish a degenerate single-arch manifest under the multi- + # arch tag. Fail loudly instead. + if [ "${#SRC_REFS[@]}" -ne 2 ]; then + echo "ERROR: expected 2 per-arch digests, got ${#SRC_REFS[@]}" >&2 + printf ' %s\n' "${SRC_REFS[@]}" >&2 + exit 1 + fi + echo "tags:" + printf ' %s\n' "${TAG_FLAGS[@]}" + echo "sources:" + printf ' %s\n' "${SRC_REFS[@]}" + docker buildx imagetools create "${TAG_FLAGS[@]}" "${SRC_REFS[@]}" + # Inspect the first tag to capture the manifest digest for + # attestation + cosign below. + FIRST_TAG=$(jq -r '.tags[0]' <<<"$METADATA_JSON") + MANIFEST_DIGEST=$(docker buildx imagetools inspect "$FIRST_TAG" --format '{{json .Manifest}}' | jq -r '.digest') + echo "digest=$MANIFEST_DIGEST" >> "$GITHUB_OUTPUT" + + - name: Attest build provenance (on multi-arch manifest) + if: steps.gate.outputs.skip != 'true' uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 with: subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - subject-digest: ${{ steps.build.outputs.digest }} + subject-digest: ${{ steps.manifest.outputs.digest }} push-to-registry: true - # ─ Keyless cosign signing ─────────────────────────────────────────── - # Complements the SLSA provenance attestation above with a cosign - # signature on the image manifest. The two are NOT duplicates: + # ─ Keyless cosign signing on the multi-arch manifest digest ───────── + # `attest-build-provenance` and `cosign sign` are NOT duplicates: # * `attest-build-provenance` attaches an in-toto SLSA predicate # describing HOW the artefact was built (verifiable via # `gh attestation verify` or `cosign verify-attestation`). @@ -331,16 +582,15 @@ jobs: # workflow signed it (verifiable via `cosign verify` with the # workflow identity / certificate-identity-regexp). # Both land in the Rekor transparency log; consumers may pin to - # either or both depending on their policy engine (Sigstore policy - # controller, Kyverno, OPA, …). + # either or both depending on their policy engine. - name: Install cosign - if: steps.gate.outputs.skip != 'true' && github.event_name != 'pull_request' + if: steps.gate.outputs.skip != 'true' uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 - - name: Sign image with cosign (keyless / OIDC) - if: steps.gate.outputs.skip != 'true' && github.event_name != 'pull_request' + - name: Sign manifest with cosign (keyless / OIDC) + if: steps.gate.outputs.skip != 'true' env: COSIGN_EXPERIMENTAL: '1' - IMAGE_REF: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }} + IMAGE_REF: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.manifest.outputs.digest }} run: | cosign sign --yes "$IMAGE_REF" From 99e4cc068a8e633a28603e76e8d71b304a65a78f Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Mon, 25 May 2026 01:39:29 +0200 Subject: [PATCH 2/3] ci(build): address PR #27 reviewer findings (cosign --recursive, per-arch attest, merge needs guard, manifest digest capture) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent reviewer passes (devops-architect + security-auditor) flagged 1 medium consumer-facing regression + several real CI bugs in the multi-arch restructure. Bundled fixes: Consumer-surface parity (security-auditor MEDIUM): - `cosign sign --recursive` in merge job so per-arch child manifests keep their own signature. `cosign verify @` would otherwise break for consumers doing arch-pinned admission control (resolve tag → pull per-arch digest → verify). - Restored per-arch `attest-build-provenance` in the build job (parallel to the existing merge-job attestation of the index digest). Keeps `gh attestation verify` working for arch-pinned consumers. Cost: 3 attestations per release (2 per-arch + 1 index) — trivial. Workflow correctness (devops-architect): - Merge-job ref-resolve was producing WRONG `VERSION` for branch tracks (`master`/`develop`/`nightly`) — it set VERSION= instead of `master-YYYYMMDD-` like the build job does. Tags on branch-track multi-arch manifests would have been malformed. Replaced with the build job's verbatim Resolve logic (sync warning comments added on both copies). Full refactor into a single upstream `resolve` job tracked as a follow-up. - Manifest digest now captured via `docker buildx imagetools create --metadata-file` instead of a post-hoc `imagetools inspect` of the first tag. Removes an extra registry round-trip + the TOCTOU window where a hostile-or-buggy registry response could substitute the digest the workflow signs. - Merge job's `needs: build` now combined with `if: ... && !cancelled()` so a failed `tag/pinned` cell doesn't accidentally cancel the merge for unrelated successful `master/rolling` or `develop/rolling` cells. The existing 2-digest guard catches the per-cell missing-digest case loudly. - Attestation + cosign steps now gated on `steps.manifest.outputs.digest != ''` so a soft-failed rolling merge doesn't emit misleading red annotations on attest/sign steps that would have run with an empty subject-digest. Cleanup: - Removed the `COSIGN_EXPERIMENTAL: '1'` env (no-op since cosign 2.0 — keyless is the default). Out of scope (filed as follow-up issues): - Full `resolve` job refactor that builds + merge both consume. - Document the new attestation/signing surface in README. Verified: yaml parses clean, actionlint clean (alias warning is pre-existing on the matrix anchor). Signed-off-by: Sebastian Mendel --- .github/workflows/build.yml | 109 +++++++++++++++++++++++++++--------- 1 file changed, 81 insertions(+), 28 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0327d7c..426e267 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -153,6 +153,11 @@ jobs: # Inputs are passed via env vars (NOT shell interpolation) so that # workflow_dispatch values containing shell metacharacters can't # become command injection. SonarCloud rule githubactions:S7630. + # + # MUST stay byte-identical to the merge job's "Resolve build + # ref" step (around line 420) — divergence between the two + # produces wrong tag names. Tracked for refactor into a single + # upstream `resolve` job that both consume. GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} REF_SOURCE: ${{ matrix.track.ref_source }} INPUT_VERSION: ${{ inputs.snipe_it_version }} @@ -328,12 +333,24 @@ jobs: cache-from: type=gha,scope=${{ matrix.track.name }}-${{ matrix.composer }} cache-to: type=gha,scope=${{ matrix.track.name }}-${{ matrix.composer }},mode=max + # Attest the per-arch image too — keeps `gh attestation verify` + # working when consumers do arch-pinned verification (resolve tag + # → pull per-arch digest → verify that digest). The merge job + # ALSO attests the multi-arch index digest, so both verification + # surfaces stay green. Mirrors the cosign --recursive call in + # merge that signs both the index and its children. + - name: Attest per-arch image + if: steps.gate.outputs.skip != 'true' && github.event_name != 'pull_request' + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: true + # Per-arch digest export. The merge job downloads every per-arch # artifact for a given (track, composer) and joins them into one - # multi-arch manifest. Attestation + cosign signing happen on - # that final manifest in the merge job — signing per-arch - # manifests would produce N signatures the consumer would have - # to verify in lockstep with the platform they're pulling. + # multi-arch manifest with `docker buildx imagetools create`, then + # signs the index digest (and per-arch children via --recursive). - name: Export per-arch digest if: steps.gate.outputs.skip != 'true' && github.event_name != 'pull_request' env: @@ -360,7 +377,13 @@ jobs: # Then runs attestation + cosign signing on the merged manifest. merge: name: merge (${{ matrix.track.name }}/${{ matrix.composer }}) - if: github.event_name != 'pull_request' + # `!cancelled()` (instead of default success-required) lets each + # (track, composer) merge cell run independently of the build job's + # overall pass/fail. The 2-digest guard below catches the per-cell + # case where the build for THIS cell didn't produce both digests — + # so a failed pinned cell can't block a successful rolling cell from + # merging into its manifest. + if: github.event_name != 'pull_request' && !cancelled() needs: build runs-on: ubuntu-latest continue-on-error: ${{ matrix.composer == 'rolling' }} @@ -415,26 +438,39 @@ jobs: if: steps.gate.outputs.skip != 'true' id: ref env: + # MUST stay byte-identical to the build job's "Resolve build + # ref" step (around line 149) — divergence between the two + # produces wrong tag names. The follow-up to extract this + # into a single upstream `resolve` job is tracked separately. + # TOCTOU note: branch tracks re-resolve the upstream SHA here + # ~12 min after the build job did, so if grokability/master + # advances between build start and merge start the manifest + # gets tagged with the NEW SHA but the digest came from the + # OLD one. Tag-track builds aren't affected (they read the + # same .snipe-it-version file content both times). GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} REF_SOURCE: ${{ matrix.track.ref_source }} INPUT_VERSION: ${{ inputs.snipe_it_version }} TRACK_NAME: ${{ matrix.track.name }} run: | - # Same logic as the build job's Resolve step — re-run so the - # merge job's tag computation uses the identical ref the build - # job did. (Inlined rather than factored to keep the workflow - # readable; ~20 lines.) - set -euo pipefail if [ "$REF_SOURCE" = "file" ]; then - REF="${INPUT_VERSION:-$(cat .snipe-it-version)}" + if [ -n "$INPUT_VERSION" ]; then + REF="$INPUT_VERSION" + else + REF=$(tr -d '[:space:]' < .snipe-it-version) + fi + VERSION="${REF#v}" + MAJOR=$(echo "$VERSION" | cut -d. -f1) + MAJOR_MINOR=$(echo "$VERSION" | cut -d. -f1-2) + IS_TAG=true else - REF=$(gh api "repos/grokability/snipe-it/branches/$REF_SOURCE" --jq '.commit.sha') + REF="$REF_SOURCE" + SHA=$(gh api "repos/grokability/snipe-it/commits/${REF_SOURCE}" --jq '.sha') + VERSION="${REF_SOURCE}-$(date -u +%Y%m%d)-${SHA:0:7}" + MAJOR="" + MAJOR_MINOR="" + IS_TAG=false fi - VERSION="${REF#v}" - MAJOR_MINOR=$(echo "$VERSION" | awk -F. '{print $1 "." $2}') - MAJOR=$(echo "$VERSION" | awk -F. '{print $1}') - IS_TAG="false" - [ "$REF_SOURCE" = "file" ] && IS_TAG="true" { echo "ref=$REF" echo "version=$VERSION" @@ -558,15 +594,27 @@ jobs: printf ' %s\n' "${TAG_FLAGS[@]}" echo "sources:" printf ' %s\n' "${SRC_REFS[@]}" - docker buildx imagetools create "${TAG_FLAGS[@]}" "${SRC_REFS[@]}" - # Inspect the first tag to capture the manifest digest for - # attestation + cosign below. - FIRST_TAG=$(jq -r '.tags[0]' <<<"$METADATA_JSON") - MANIFEST_DIGEST=$(docker buildx imagetools inspect "$FIRST_TAG" --format '{{json .Manifest}}' | jq -r '.digest') + # Capture the manifest digest via --metadata-file directly from + # buildx instead of a post-hoc `imagetools inspect` of a tag — + # avoids an extra registry round-trip + the eventual-consistency + # race where the just-pushed manifest may not yet be readable. + # https://docs.docker.com/build/ci/github-actions/multi-platform/#distribute-build-across-multiple-runners + docker buildx imagetools create \ + --metadata-file /tmp/manifest-metadata.json \ + "${TAG_FLAGS[@]}" "${SRC_REFS[@]}" + MANIFEST_DIGEST=$(jq -r '."containerimage.digest"' /tmp/manifest-metadata.json) + if [ -z "$MANIFEST_DIGEST" ] || [ "$MANIFEST_DIGEST" = "null" ]; then + echo "ERROR: failed to capture manifest digest from buildx --metadata-file" >&2 + cat /tmp/manifest-metadata.json >&2 + exit 1 + fi echo "digest=$MANIFEST_DIGEST" >> "$GITHUB_OUTPUT" - name: Attest build provenance (on multi-arch manifest) - if: steps.gate.outputs.skip != 'true' + # Skip attestation when manifest step couldn't produce a digest — + # otherwise rolling-merge soft-failures emit a misleading second + # red annotation here. + if: steps.gate.outputs.skip != 'true' && steps.manifest.outputs.digest != '' uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 with: subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} @@ -584,13 +632,18 @@ jobs: # Both land in the Rekor transparency log; consumers may pin to # either or both depending on their policy engine. - name: Install cosign - if: steps.gate.outputs.skip != 'true' + if: steps.gate.outputs.skip != 'true' && steps.manifest.outputs.digest != '' uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 - - name: Sign manifest with cosign (keyless / OIDC) - if: steps.gate.outputs.skip != 'true' + - name: Sign manifest + per-platform children with cosign (keyless / OIDC) + if: steps.gate.outputs.skip != 'true' && steps.manifest.outputs.digest != '' env: - COSIGN_EXPERIMENTAL: '1' IMAGE_REF: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.manifest.outputs.digest }} run: | - cosign sign --yes "$IMAGE_REF" + # --recursive: also sign every child manifest of the index, so + # `cosign verify ghcr.io/.../snipe-it-php-fpm@` + # keeps working for consumers that do arch-pinned verification + # (resolve tag → pull per-arch digest → verify). Index-only + # signing would silently break that admission-control pattern. + # Cost: 3 signatures total (1 index + 2 per-arch) — trivial. + cosign sign --recursive --yes "$IMAGE_REF" From 27271fefe1e5e9b6fc16e2e5d8dd12e88d411105 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Mon, 25 May 2026 01:40:57 +0200 Subject: [PATCH 3/3] fix(ci): use --tag= form for buildx imagetools create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review on PR #27 caught: `mapfile -t TAG_FLAGS < <(jq -r '.tags[] | "-t \(.)"')` produces array elements like `"-t ghcr.io/...:8.5.0"` — single space-containing string. `"${TAG_FLAGS[@]}"` then passes each as ONE argv token, so `docker buildx imagetools create` sees one arg `-t ghcr.io/...:8.5.0` instead of two args `-t` and the value, and fails flag parsing. Switched to `--tag=` form which is a single argv token by design. Signed-off-by: Sebastian Mendel --- .github/workflows/build.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 426e267..31e0555 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -576,7 +576,13 @@ jobs: # Build the -t flag list from metadata-action's tag output # (JSON array under .tags). Build the per-arch source-image # arguments from the digest files dropped by the build jobs. - mapfile -t TAG_FLAGS < <(jq -r '.tags[] | "-t \(.)"' <<<"$METADATA_JSON") + # Use --tag= (single argv token) instead of `-t ` + # (two tokens). The previous mapfile-with-"-t X" produced + # array elements that were one space-containing string, which + # `${TAG_FLAGS[@]}` then passed as a single argv to buildx — + # docker would see one arg `-t ghcr.io/...:8.5.0` and fail + # flag parsing. The =-form sidesteps that entirely. + mapfile -t TAG_FLAGS < <(jq -r '.tags[] | "--tag=\(.)"' <<<"$METADATA_JSON") mapfile -t SRC_REFS < <( find /tmp/digests -maxdepth 1 -type f -printf "${IMAGE}@sha256:%f\n" )