Bump version to 2.2.3 #111
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
| # Builds and publishes the multi-arch Docker image | |
| # | |
| # Triggered by: | |
| # - On git tag push, publishes to :X.Y.Z, :X.Y, :X.x and :latest | |
| # - On manual trigger, rebuilds the current branch as :latest, or a given tag | |
| # - On weekly cron, rebuilds the newest release as :latest, for base image patches | |
| # | |
| # The workflow will: | |
| # - Resolve and validate the version up front, so bad input fails in seconds | |
| # - Build multi-arch (amd64, arm64) in parallel on native runners (no QEMU) | |
| # - Trivy scans + reports security issues, and fails cron on CRITICAL CVEs | |
| # - Publishes to GHCR, then to Docker Hub if configured (never blocking GHCR) | |
| # - Attaches BuildKit per-arch SBOM + provenance to the image itself | |
| # - Signs provenance (index) and per-arch SBOMs (per-arch manifest) via Sigstore | |
| # - Writes a pretty job summary with tags, digest and attestation status | |
| name: 🐳 Build + Publish Docker Image | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| tag: | |
| description: 'Tag to build (empty = build current ref as :latest. tag must exist in git)' | |
| required: false | |
| default: '' | |
| push: | |
| # Loose on purpose, so bad tags fail loudly in prepare instead of silently not running | |
| tags: ['*.*.*'] | |
| schedule: | |
| - cron: '0 4 * * 0' | |
| concurrency: | |
| # Per-ref, so back-to-back releases each still get published | |
| group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.tag }} | |
| cancel-in-progress: false | |
| permissions: | |
| contents: read # least-privilege default; jobs elevate as needed | |
| env: | |
| GH_IMAGE: ghcr.io/${{ github.repository }} | |
| DH_IMAGE: docker.io/${{ vars.DOCKER_REPO || 'lissy93/web-check' }} | |
| jobs: | |
| prepare: | |
| name: 🔢 Resolve Version | |
| timeout-minutes: 5 | |
| runs-on: ubuntu-latest | |
| outputs: | |
| ref: ${{ steps.resolve.outputs.ref }} | |
| version: ${{ steps.resolve.outputs.version }} | |
| semver: ${{ steps.resolve.outputs.semver }} | |
| latest: ${{ steps.resolve.outputs.latest }} | |
| steps: | |
| - name: 🛎️ Checkout (with tags) | |
| uses: actions/checkout@v6 | |
| with: | |
| fetch-depth: 0 | |
| - name: 🔢 Resolve and validate version | |
| id: resolve | |
| env: | |
| INPUT_TAG: ${{ inputs.tag }} | |
| EVENT: ${{ github.event_name }} | |
| REF_NAME: ${{ github.ref_name }} | |
| REF_TYPE: ${{ github.ref_type }} | |
| run: | | |
| set -euo pipefail | |
| SEMVER='^[0-9]+\.[0-9]+\.[0-9]+$' | |
| if [ -n "$INPUT_TAG" ]; then | |
| # Manual rebuild of a specific release, never moves :latest | |
| if ! echo "$INPUT_TAG" | grep -qE "$SEMVER"; then | |
| echo "::error::Invalid tag '${INPUT_TAG}'. Must be semver (e.g. 2.2.0)." | |
| exit 1 | |
| fi | |
| if ! git rev-parse -q --verify "refs/tags/${INPUT_TAG}" >/dev/null; then | |
| echo "::error::Tag '${INPUT_TAG}' does not exist in this repository." | |
| exit 1 | |
| fi | |
| ref="refs/tags/${INPUT_TAG}"; version="$INPUT_TAG"; semver=true; latest=false | |
| elif [ "$REF_TYPE" = "tag" ]; then | |
| # Release tag pushed | |
| if ! echo "$REF_NAME" | grep -qE "$SEMVER"; then | |
| echo "::error::Tag '${REF_NAME}' is not semver; refusing to publish." | |
| exit 1 | |
| fi | |
| ref="refs/tags/${REF_NAME}"; version="$REF_NAME"; semver=true; latest=true | |
| elif [ "$EVENT" = "schedule" ]; then | |
| # Weekly refresh of :latest from the newest release, version tags stay immutable | |
| newest=$(git tag --list --sort=-v:refname | grep -E "$SEMVER" | head -n1 || true) | |
| if [ -z "$newest" ]; then | |
| echo "::error::No semver tag found to rebuild." | |
| exit 1 | |
| fi | |
| ref="refs/tags/${newest}"; version="$newest"; semver=false; latest=true | |
| else | |
| # Manual build of whatever ref was dispatched | |
| ref="$GITHUB_REF"; version="latest"; semver=false; latest=true | |
| fi | |
| echo "Building ${ref} as version=${version} (semver=${semver}, latest=${latest})" | |
| { | |
| echo "ref=$ref" | |
| echo "version=$version" | |
| echo "semver=$semver" | |
| echo "latest=$latest" | |
| } >> "$GITHUB_OUTPUT" | |
| build: | |
| name: 🔨 Build (${{ matrix.arch }}) | |
| needs: prepare | |
| timeout-minutes: 60 | |
| permissions: | |
| contents: read # for checkout | |
| packages: write # for push image by digest to GHCR | |
| security-events: write # for upload Trivy SARIF to code scanning | |
| env: | |
| DOCKER_BUILD_SUMMARY: 'false' | |
| DOCKER_BUILD_RECORD_UPLOAD: 'false' | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - platform: linux/amd64 | |
| runner: ubuntu-latest | |
| arch: amd64 | |
| - platform: linux/arm64 | |
| runner: ubuntu-24.04-arm | |
| arch: arm64 | |
| runs-on: ${{ matrix.runner }} | |
| steps: | |
| - name: 🛎️ Checkout | |
| uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ needs.prepare.outputs.ref }} | |
| - name: 🏷️ Build metadata | |
| id: meta | |
| run: | | |
| set -euo pipefail | |
| { | |
| echo "revision=$(git rev-parse HEAD)" | |
| echo "created=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: 🔧 Set up Buildx | |
| uses: docker/setup-buildx-action@v4 | |
| - name: 🔑 Login to GHCR | |
| uses: docker/login-action@v4 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.repository_owner }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| # The docker exporter can't carry attestations, so the push step re-adds them | |
| - name: 🔨 Build image (load for scan) | |
| uses: docker/build-push-action@v7 | |
| with: | |
| context: . | |
| file: ./Dockerfile | |
| platforms: ${{ matrix.platform }} | |
| load: true | |
| tags: web-check-scan:${{ matrix.arch }} | |
| provenance: false | |
| # Advisory everywhere except cron, so a Trivy outage can't block a release | |
| - name: 🛡️ Trivy vulnerability scan | |
| id: scan | |
| uses: aquasecurity/trivy-action@v0.36.0 | |
| continue-on-error: ${{ github.event_name != 'schedule' }} | |
| env: | |
| TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db:2 | |
| TRIVY_JAVA_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-java-db:1 | |
| with: | |
| image-ref: web-check-scan:${{ matrix.arch }} | |
| severity: CRITICAL | |
| ignore-unfixed: true | |
| exit-code: ${{ github.event_name == 'schedule' && '1' || '0' }} | |
| vuln-type: 'os,library' | |
| format: 'sarif' | |
| output: 'trivy-${{ matrix.arch }}.sarif' | |
| timeout: '10m' | |
| - name: 📋 List blocking CVEs (on scan failure) | |
| if: always() && steps.scan.outcome == 'failure' | |
| continue-on-error: true | |
| run: | | |
| jq -r '.runs[].results[]? | "\(.ruleId): \(.message.text)"' \ | |
| "trivy-${{ matrix.arch }}.sarif" | sort -u | |
| - name: 📤 Upload Trivy SARIF | |
| if: always() && hashFiles(format('trivy-{0}.sarif', matrix.arch)) != '' | |
| continue-on-error: true | |
| uses: github/codeql-action/upload-sarif@v4 | |
| with: | |
| sarif_file: trivy-${{ matrix.arch }}.sarif | |
| category: trivy-${{ matrix.arch }} | |
| - name: 🚀 Push by digest | |
| id: push | |
| uses: docker/build-push-action@v7 | |
| with: | |
| context: . | |
| file: ./Dockerfile | |
| platforms: ${{ matrix.platform }} | |
| provenance: mode=max | |
| sbom: true | |
| labels: | | |
| org.opencontainers.image.version=${{ needs.prepare.outputs.version }} | |
| org.opencontainers.image.revision=${{ steps.meta.outputs.revision }} | |
| org.opencontainers.image.created=${{ steps.meta.outputs.created }} | |
| outputs: type=image,name=${{ env.GH_IMAGE }},push-by-digest=true,name-canonical=true,push=true | |
| - name: 🧬 Write digest | |
| env: | |
| DIGEST: ${{ steps.push.outputs.digest }} | |
| DIGESTS_DIR: ${{ runner.temp }}/digests | |
| ARCH: ${{ matrix.arch }} | |
| run: | | |
| set -euo pipefail | |
| if [ -z "$DIGEST" ]; then | |
| echo "::error::Build produced no digest for ${ARCH}" | |
| exit 1 | |
| fi | |
| mkdir -p "$DIGESTS_DIR" | |
| echo "$DIGEST" > "$DIGESTS_DIR/$ARCH" | |
| - name: 📤 Upload digest | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: digest-${{ matrix.arch }} | |
| path: ${{ runner.temp }}/digests/${{ matrix.arch }} | |
| if-no-files-found: error | |
| retention-days: 1 | |
| merge: | |
| name: 🧩 Merge & Push Manifests | |
| needs: [prepare, build] | |
| timeout-minutes: 45 | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read # least-privilege baseline | |
| packages: write # push manifest + attestations to GHCR | |
| id-token: write # OIDC token for keyless attestation signing | |
| attestations: write # write provenance + SBOM attestations | |
| artifact-metadata: write # storage record for push-to-registry | |
| env: | |
| HAS_DH: ${{ secrets.DOCKERHUB_PASSWORD != '' }} | |
| steps: | |
| - name: 📥 Download digests | |
| uses: actions/download-artifact@v8 | |
| with: | |
| path: ${{ runner.temp }}/digests | |
| pattern: digest-* | |
| merge-multiple: true | |
| - name: 🔧 Set up Buildx | |
| uses: docker/setup-buildx-action@v4 | |
| - name: 🔑 Login to GHCR | |
| uses: docker/login-action@v4 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.repository_owner }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: 🔑 Login to Docker Hub | |
| id: dh_login | |
| if: env.HAS_DH == 'true' | |
| continue-on-error: true | |
| uses: docker/login-action@v4 | |
| with: | |
| username: ${{ vars.DOCKER_USERNAME || 'lissy93' }} | |
| password: ${{ secrets.DOCKERHUB_PASSWORD }} | |
| # An older build finishing second must not clobber a newer release | |
| - name: 🕓 Guard against :latest regression | |
| id: guard | |
| env: | |
| VERSION: ${{ needs.prepare.outputs.version }} | |
| WANT_LATEST: ${{ needs.prepare.outputs.latest }} | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| set -euo pipefail | |
| keep="$WANT_LATEST" | |
| if [ "$WANT_LATEST" = "true" ] && [ "$VERSION" != "latest" ]; then | |
| newest=$(gh api "repos/${GITHUB_REPOSITORY}/tags" --paginate -q '.[].name' 2>/dev/null \ | |
| | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -n1 || true) | |
| if [ -n "$newest" ] && [ "$newest" != "$VERSION" ] && | |
| [ "$(printf '%s\n%s\n' "$VERSION" "$newest" | sort -V | tail -n1)" = "$newest" ]; then | |
| echo "::warning::Release ${newest} is newer than ${VERSION}; not moving :latest" | |
| keep=false | |
| fi | |
| fi | |
| echo "latest=$keep" >> "$GITHUB_OUTPUT" | |
| - name: 🗂️ Generate tags | |
| id: meta | |
| uses: docker/metadata-action@v6 | |
| with: | |
| images: | | |
| ${{ env.GH_IMAGE }} | |
| ${{ steps.dh_login.outcome == 'success' && env.DH_IMAGE || '' }} | |
| tags: | | |
| type=raw,value=latest,enable=${{ steps.guard.outputs.latest }} | |
| type=semver,pattern={{version}},value=${{ needs.prepare.outputs.version }},enable=${{ needs.prepare.outputs.semver }} | |
| type=semver,pattern={{major}}.{{minor}},value=${{ needs.prepare.outputs.version }},enable=${{ needs.prepare.outputs.semver }} | |
| type=semver,pattern={{major}}.x,value=${{ needs.prepare.outputs.version }},enable=${{ needs.prepare.outputs.semver }} | |
| flavor: | | |
| latest=false | |
| # Registries are pushed separately, so Docker Hub can never block GHCR. | |
| # imagetools (unlike docker manifest) keeps the BuildKit attestation manifests | |
| - name: 🧩 Create and push manifest (GHCR) | |
| id: manifest | |
| working-directory: ${{ runner.temp }}/digests | |
| run: | | |
| set -euo pipefail | |
| shopt -s nullglob | |
| SOURCES=() | |
| for f in *; do SOURCES+=("${GH_IMAGE}@$(cat "$f")"); done | |
| if [ ${#SOURCES[@]} -eq 0 ]; then | |
| echo "::error::No per-arch digests found" | |
| exit 1 | |
| fi | |
| mapfile -t TAGS < <(jq -r --arg img "$GH_IMAGE" \ | |
| '.tags[] | select(startswith($img + ":"))' <<< "$DOCKER_METADATA_OUTPUT_JSON") | |
| if [ ${#TAGS[@]} -eq 0 ]; then | |
| echo "::error::No GHCR tags were generated" | |
| exit 1 | |
| fi | |
| ARGS=(); for t in "${TAGS[@]}"; do ARGS+=(-t "$t"); done | |
| docker buildx imagetools create "${ARGS[@]}" "${SOURCES[@]}" | |
| DIGEST=$(docker buildx imagetools inspect "${TAGS[0]}" --format '{{.Manifest.Digest}}') | |
| echo "digest=$DIGEST" >> "$GITHUB_OUTPUT" | |
| echo "Published ${#TAGS[@]} tag(s) to GHCR at ${DIGEST}" | |
| - name: 🧩 Create and push manifest (Docker Hub) | |
| id: dh_manifest | |
| if: steps.dh_login.outcome == 'success' | |
| continue-on-error: true | |
| working-directory: ${{ runner.temp }}/digests | |
| env: | |
| GHCR_DIGEST: ${{ steps.manifest.outputs.digest }} | |
| run: | | |
| set -euo pipefail | |
| shopt -s nullglob | |
| SOURCES=() | |
| for f in *; do SOURCES+=("${GH_IMAGE}@$(cat "$f")"); done | |
| if [ ${#SOURCES[@]} -eq 0 ]; then | |
| echo "::error::No per-arch digests found" | |
| exit 1 | |
| fi | |
| mapfile -t TAGS < <(jq -r --arg img "$DH_IMAGE" \ | |
| '.tags[] | select(startswith($img + ":"))' <<< "$DOCKER_METADATA_OUTPUT_JSON") | |
| if [ ${#TAGS[@]} -eq 0 ]; then | |
| echo "::error::No Docker Hub tags were generated" | |
| exit 1 | |
| fi | |
| ARGS=(); for t in "${TAGS[@]}"; do ARGS+=(-t "$t"); done | |
| docker buildx imagetools create "${ARGS[@]}" "${SOURCES[@]}" | |
| DIGEST=$(docker buildx imagetools inspect "${TAGS[0]}" --format '{{.Manifest.Digest}}') | |
| if [ "$DIGEST" != "$GHCR_DIGEST" ]; then | |
| echo "::error::Docker Hub digest ${DIGEST} != GHCR ${GHCR_DIGEST}; skipping its attestations" | |
| exit 1 | |
| fi | |
| echo "Published ${#TAGS[@]} tag(s) to Docker Hub at ${DIGEST}" | |
| # BuildKit writes one SBOM per architecture, against the per-arch manifest | |
| - name: 🧾 Extract per-arch SBOMs and subjects | |
| id: sbom | |
| env: | |
| DIGEST: ${{ steps.manifest.outputs.digest }} | |
| MAX_BYTES: '12582912' | |
| run: | | |
| set -euo pipefail | |
| RAW=$(docker buildx imagetools inspect "${GH_IMAGE}@${DIGEST}" --raw) | |
| for arch in amd64 arm64; do | |
| subject=$(jq -r --arg a "$arch" \ | |
| '[.manifests[] | select(.platform.os == "linux" and .platform.architecture == $a) | .digest] | first // empty' \ | |
| <<< "$RAW") | |
| fmt='{{ json (index .SBOM "linux/'"$arch"'").SPDX }}' | |
| docker buildx imagetools inspect "${GH_IMAGE}@${DIGEST}" \ | |
| --format "$fmt" > "full.$arch.json" 2>/dev/null || true | |
| jq -c 'del(.files) | |
| | .relationships |= map(select( | |
| (.spdxElementId | startswith("SPDXRef-File") | not) and | |
| (.relatedSpdxElement | startswith("SPDXRef-File") | not)))' \ | |
| "full.$arch.json" > "sbom.$arch.json" 2>/dev/null || true | |
| size=$(stat -c%s "sbom.$arch.json" 2>/dev/null || echo 0) | |
| if [ -n "$subject" ] && [ "$size" -le "$MAX_BYTES" ] && | |
| jq -e 'type == "object" and has("packages")' "sbom.$arch.json" >/dev/null 2>&1; then | |
| echo "${arch}=true" >> "$GITHUB_OUTPUT" | |
| echo "${arch}_subject=$subject" >> "$GITHUB_OUTPUT" | |
| echo "linux/${arch}: $(jq '.packages | length' "sbom.$arch.json") packages, ${size} bytes, subject ${subject}" | |
| else | |
| echo "::warning::Unusable SBOM for linux/${arch} (${size} bytes); skipping its attestation" | |
| echo "${arch}=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| done | |
| - name: 🛡️ Attest provenance (GHCR) | |
| id: prov_ghcr | |
| uses: actions/attest@v4 | |
| continue-on-error: true | |
| with: | |
| subject-name: ${{ env.GH_IMAGE }} | |
| subject-digest: ${{ steps.manifest.outputs.digest }} | |
| push-to-registry: true | |
| show-summary: false | |
| - name: 🪪 Attest SBOM, amd64 (GHCR) | |
| id: sbom_amd64_ghcr | |
| if: steps.sbom.outputs.amd64 == 'true' | |
| uses: actions/attest@v4 | |
| continue-on-error: true | |
| with: | |
| subject-name: ${{ env.GH_IMAGE }} | |
| subject-digest: ${{ steps.sbom.outputs.amd64_subject }} | |
| sbom-path: sbom.amd64.json | |
| push-to-registry: true | |
| show-summary: false | |
| - name: 🪪 Attest SBOM, arm64 (GHCR) | |
| id: sbom_arm64_ghcr | |
| if: steps.sbom.outputs.arm64 == 'true' | |
| uses: actions/attest@v4 | |
| continue-on-error: true | |
| with: | |
| subject-name: ${{ env.GH_IMAGE }} | |
| subject-digest: ${{ steps.sbom.outputs.arm64_subject }} | |
| sbom-path: sbom.arm64.json | |
| push-to-registry: true | |
| show-summary: false | |
| - name: 🛡️ Attest provenance (Docker Hub) | |
| id: prov_dh | |
| if: steps.dh_manifest.outcome == 'success' | |
| uses: actions/attest@v4 | |
| continue-on-error: true | |
| with: | |
| subject-name: ${{ env.DH_IMAGE }} | |
| subject-digest: ${{ steps.manifest.outputs.digest }} | |
| push-to-registry: true | |
| show-summary: false | |
| - name: 🪪 Attest SBOM, amd64 (Docker Hub) | |
| id: sbom_amd64_dh | |
| if: steps.dh_manifest.outcome == 'success' && steps.sbom.outputs.amd64 == 'true' | |
| uses: actions/attest@v4 | |
| continue-on-error: true | |
| with: | |
| subject-name: ${{ env.DH_IMAGE }} | |
| subject-digest: ${{ steps.sbom.outputs.amd64_subject }} | |
| sbom-path: sbom.amd64.json | |
| push-to-registry: true | |
| show-summary: false | |
| - name: 🪪 Attest SBOM, arm64 (Docker Hub) | |
| id: sbom_arm64_dh | |
| if: steps.dh_manifest.outcome == 'success' && steps.sbom.outputs.arm64 == 'true' | |
| uses: actions/attest@v4 | |
| continue-on-error: true | |
| with: | |
| subject-name: ${{ env.DH_IMAGE }} | |
| subject-digest: ${{ steps.sbom.outputs.arm64_subject }} | |
| sbom-path: sbom.arm64.json | |
| push-to-registry: true | |
| show-summary: false | |
| - name: 📋 Job summary | |
| if: always() | |
| continue-on-error: true | |
| env: | |
| DIGEST: ${{ steps.manifest.outputs.digest }} | |
| TAGS_JSON: ${{ steps.meta.outputs.json }} | |
| DH_MANIFEST: ${{ steps.dh_manifest.outcome }} | |
| AMD64_SUBJECT: ${{ steps.sbom.outputs.amd64_subject }} | |
| ARM64_SUBJECT: ${{ steps.sbom.outputs.arm64_subject }} | |
| RESULTS: | | |
| Provenance (GHCR)=${{ steps.prov_ghcr.outcome }} | |
| SBOM amd64 (GHCR)=${{ steps.sbom_amd64_ghcr.outcome }} | |
| SBOM arm64 (GHCR)=${{ steps.sbom_arm64_ghcr.outcome }} | |
| Provenance (Docker Hub)=${{ steps.prov_dh.outcome }} | |
| SBOM amd64 (Docker Hub)=${{ steps.sbom_amd64_dh.outcome }} | |
| SBOM arm64 (Docker Hub)=${{ steps.sbom_arm64_dh.outcome }} | |
| run: | | |
| set -euo pipefail | |
| icon() { | |
| case "$1" in | |
| success) echo "✅" ;; | |
| failure) echo "⚠️" ;; | |
| *) echo "⏭️" ;; | |
| esac | |
| } | |
| { | |
| echo "## 🐳 Docker Image" | |
| echo | |
| echo "**Manifest:** \`${DIGEST:-unknown}\`" | |
| if [ "${DH_MANIFEST:-skipped}" = "failure" ]; then | |
| echo | |
| echo "> ⚠️ Docker Hub publish failed. GHCR was published successfully." | |
| fi | |
| echo | |
| echo "The following tags have been updated and published:" | |
| echo | |
| echo '```' | |
| if [ -n "${TAGS_JSON:-}" ]; then jq -r '.tags[]?' <<< "$TAGS_JSON"; fi | |
| echo '```' | |
| echo | |
| echo "## 🪪 Attestations" | |
| echo | |
| while IFS='=' read -r name outcome; do | |
| if [ -n "$name" ]; then | |
| echo "- $(icon "${outcome:-skipped}") ${name}: ${outcome:-skipped}" | |
| fi | |
| done <<< "${RESULTS:-}" | |
| echo | |
| echo "Attestation failures are non-fatal; the image is published regardless." | |
| echo | |
| echo "Verify provenance (subject is the multi-arch index):" | |
| echo '```bash' | |
| echo "gh attestation verify oci://${GH_IMAGE}@${DIGEST:-} --repo ${GITHUB_REPOSITORY}" | |
| echo '```' | |
| echo | |
| echo "Verify an SBOM (subject is the per-arch manifest):" | |
| echo '```bash' | |
| echo "gh attestation verify oci://${GH_IMAGE}@${AMD64_SUBJECT:-<amd64-digest>} --repo ${GITHUB_REPOSITORY} # amd64" | |
| echo "gh attestation verify oci://${GH_IMAGE}@${ARM64_SUBJECT:-<arm64-digest>} --repo ${GITHUB_REPOSITORY} # arm64" | |
| echo '```' | |
| } >> "$GITHUB_STEP_SUMMARY" |