diff --git a/.github/workflows/docker-manual.yml b/.github/workflows/docker-manual.yml new file mode 100644 index 0000000000..5d7a40d415 --- /dev/null +++ b/.github/workflows/docker-manual.yml @@ -0,0 +1,248 @@ +name: Manual relay image +run-name: Manual relay image ยท ${{ inputs.source_ref }} + +# Operator-only publishing. This deliberately does not qualify the source via CI. +# Publish manual-* tags only; normal release publishing remains in docker.yml. +on: + workflow_dispatch: + inputs: + source_ref: + description: block/buzz branch, tag, or full SHA reachable from a repository branch + required: true + type: string + +permissions: {} + +env: + IMAGE_NAME: ${{ vars.GHCR_IMAGE != '' && vars.GHCR_IMAGE || 'ghcr.io/block/buzz' }} + +jobs: + resolve: + name: Resolve the selected source + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + outputs: + sha: ${{ steps.source.outputs.sha }} + tag: ${{ steps.source.outputs.tag }} + steps: + - name: Require block/buzz repository + env: + REPOSITORY: ${{ github.repository }} + shell: bash + run: | + set -euo pipefail + [[ "$REPOSITORY" == block/buzz ]] || { + echo '::error::Run this workflow in block/buzz; use source_ref to select repository code.' + exit 1 + } + - name: Resolve a source reachable from a repository branch + id: source + env: + SOURCE_REF: ${{ inputs.source_ref }} + shell: bash + run: | + set -euo pipefail + if [[ -z "$SOURCE_REF" || ${#SOURCE_REF} -gt 255 || ! "$SOURCE_REF" =~ ^[A-Za-z0-9._/@-]+$ ]]; then + echo '::error::Supply a repository branch, tag, or full commit SHA.' + exit 1 + fi + case "$SOURCE_REF" in + -*|*..*|*.lock|refs/pull/*|pull/*|*/pull/*) + echo '::error::PR refs and malformed refs are not allowed.' + exit 1 + ;; + esac + workdir=$(mktemp -d) + trap 'rm -rf "$workdir"' EXIT + git -C "$workdir" init --quiet + git -C "$workdir" remote add origin https://github.com/block/buzz.git + # Fetch only canonical branch histories, never refs/pull or arbitrary SHAs. + # Full history is needed to prove ancestry; blobs are fetched only by build jobs. + git -C "$workdir" fetch --filter=blob:none --no-tags origin \ + '+refs/heads/*:refs/remotes/origin/*' + if [[ "$SOURCE_REF" =~ ^[0-9a-fA-F]{40}$ ]]; then + sha=$(git -C "$workdir" rev-parse --verify "${SOURCE_REF}^{commit}") + elif [[ "$SOURCE_REF" == refs/heads/* ]]; then + sha=$(git -C "$workdir" rev-parse --verify "refs/remotes/origin/${SOURCE_REF#refs/heads/}^{commit}") + else + tag_ref="$SOURCE_REF" + if [[ "$SOURCE_REF" != refs/* ]] && \ + sha=$(git -C "$workdir" rev-parse --verify "refs/remotes/origin/${SOURCE_REF}^{commit}" 2>/dev/null); then + : + else + if [[ "$SOURCE_REF" != refs/* ]]; then + tag_ref="refs/tags/$SOURCE_REF" + fi + [[ "$tag_ref" == refs/tags/* ]] || { + echo '::error::Only repository branches and tags are allowed.' + exit 1 + } + git -C "$workdir" fetch --no-tags origin "$tag_ref:refs/tags/manual-source" + sha=$(git -C "$workdir" rev-parse --verify 'refs/tags/manual-source^{commit}') + fi + fi + [[ "$sha" =~ ^[0-9a-f]{40}$ ]] + branches=$(git -C "$workdir" for-each-ref --format='%(refname)' --contains="$sha" refs/remotes/origin/) + [[ -n "$branches" ]] || { + echo '::error::Selected commit must be reachable from a block/buzz branch; PR-only commits are not allowed.' + exit 1 + } + echo "sha=$sha" >> "$GITHUB_OUTPUT" + echo "tag=manual-${sha:0:12}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT" + echo "Selected source: $sha. CI qualification is skipped." + + build: + name: Build (${{ matrix.platform }}) + needs: resolve + runs-on: ${{ matrix.runner }} + timeout-minutes: 90 + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-24.04 + arch: amd64 + - platform: linux/arm64 + runner: ubuntu-24.04-arm + arch: arm64 + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ needs.resolve.outputs.sha }} + persist-credentials: false + - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + with: + buildkitd-config-inline: | + [worker.oci] + max-parallelism = 2 + - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push the runtime by digest + id: build + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + with: + context: . + file: ./Dockerfile + target: runtime + platforms: ${{ matrix.platform }} + build-args: | + BUZZ_SOURCE_SHA=${{ needs.resolve.outputs.sha }} + BUZZ_BUILD_ID=manual-actions:${{ github.run_id }}:${{ github.run_attempt }} + BUZZ_BUILD_URL=https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }} + labels: | + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.revision=${{ needs.resolve.outputs.sha }} + xyz.buzz.manual-build=true + outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true + cache-from: | + type=registry,ref=${{ env.IMAGE_NAME }}-buildcache:${{ matrix.arch }} + type=registry,ref=${{ env.IMAGE_NAME }}-buildcache:manual-${{ matrix.arch }} + cache-to: type=registry,ref=${{ env.IMAGE_NAME }}-buildcache:manual-${{ matrix.arch }},mode=max,compression=zstd + - name: Record this architecture's published digest + env: + IMAGE_DIGEST: ${{ steps.build.outputs.digest }} + shell: bash + run: | + set -euo pipefail + [[ "$IMAGE_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]] + mkdir -p "$RUNNER_TEMP/manual-digest" + printf '%s\n' "$IMAGE_DIGEST" > "$RUNNER_TEMP/manual-digest/digest" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: manual-digest-${{ matrix.arch }} + path: ${{ runner.temp }}/manual-digest/digest + if-no-files-found: error + overwrite: true + retention-days: 7 + + publish: + name: Publish and verify the combined image + needs: [resolve, build] + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + packages: write + steps: + - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: manual-digest-* + path: ${{ runner.temp }}/manual-digests + - name: Assemble and verify the published image + env: + SOURCE_SHA: ${{ needs.resolve.outputs.sha }} + MANUAL_TAG: ${{ needs.resolve.outputs.tag }} + shell: bash + run: | + set -euo pipefail + digests=() + for arch in amd64 arm64; do + digest=$(cat "$RUNNER_TEMP/manual-digests/manual-digest-$arch/digest") + [[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]] + digests+=("$IMAGE_NAME@$digest") + done + image="$IMAGE_NAME:$MANUAL_TAG" + docker buildx imagetools create --tag "$image" \ + --annotation "index:org.opencontainers.image.revision=$SOURCE_SHA" \ + --annotation "index:org.opencontainers.image.source=https://github.com/$GITHUB_REPOSITORY" \ + --annotation "index:xyz.buzz.manual-build=true" "${digests[@]}" + # Verify the published bytes using this workflow, not scripts from the selected source. + merged_digest=$(docker buildx imagetools inspect "$image" --format '{{json .Manifest}}' | + jq -er '.digest | select(test("^sha256:[0-9a-f]{64}$"))') + pinned="$IMAGE_NAME@$merged_digest" + source_url="https://github.com/$GITHUB_REPOSITORY" + docker buildx imagetools inspect "$pinned" --raw > "$RUNNER_TEMP/index.json" + jq -e '.manifests | + map(select((.platform.os == "unknown" and .platform.architecture == "unknown") | not)) | + map(.platform.os + "/" + .platform.architecture) | sort == ["linux/amd64", "linux/arm64"]' \ + "$RUNNER_TEMP/index.json" >/dev/null + runtime_digests='{}' + for arch in amd64 arm64; do + child=$(jq -er --arg arch "$arch" '.manifests[] | + select(.platform.os == "linux" and .platform.architecture == $arch) | + .digest | select(test("^sha256:[0-9a-f]{64}$"))' "$RUNNER_TEMP/index.json") + docker buildx imagetools inspect "$IMAGE_NAME@$child" --format '{{json .Image}}' | + jq -e --arg arch "$arch" --arg sha "$SOURCE_SHA" --arg source "$source_url" ' + .os == "linux" and .architecture == $arch and + .config.Labels["org.opencontainers.image.revision"] == $sha and + .config.Labels["org.opencontainers.image.source"] == $source' >/dev/null + runtime_digests=$(jq -cn --argjson current "$runtime_digests" \ + --arg platform "linux/$arch" --arg digest "$child" '$current + {($platform): $digest}') + done + jq -n --arg image "$pinned" --arg tag "$image" --arg digest "$merged_digest" \ + --arg sha "$SOURCE_SHA" --arg source "$source_url" --argjson platforms "$runtime_digests" \ + '{image:$image, tag:$tag, digest:$digest, source_sha:$sha, source_url:$source, + platforms:$platforms, + ci_qualification:"skipped", status:"registry-verified"}' \ + > "$RUNNER_TEMP/manual-image.json" + { + echo '## Manual relay image published to GHCR' + echo + echo "Source commit: $SOURCE_SHA" + echo "Registry tag: $image" + echo "Digest: $(jq -r .digest "$RUNNER_TEMP/manual-image.json")" + echo 'Platforms: linux/amd64, linux/arm64' + echo 'CI qualification was skipped. No deployment was started.' + echo 'Any downstream registry import must be verified separately.' + } >> "$GITHUB_STEP_SUMMARY" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: manual-image-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/manual-image.json + if-no-files-found: error + retention-days: 30