Skip to content

Commit 9268798

Browse files
committed
Publish multi-arch Docker image to GHCR on release
Adds: * Dockerfile — single-stage, FROM distroless/static-debian12:nonroot, COPYs the prebuilt musl binary at /qn. No in-image compilation; the SLSA-attested binary that ships in the GitHub release IS what ships in the image. Distroless gives us a CA bundle (for any future HTTPS-calling qn feature) without a shell or package manager. * publish-docker.yml — reusable workflow invoked by cargo-dist as a user_publish_job. Downloads both musl tarballs from the GitHub release, stages per-arch build contexts, pushes single-arch images, then stitches them into a multi-arch manifest at the version tag. Promotes to :latest only for non-prerelease releases. * dist-workspace.toml — wires ./publish-docker into publish-jobs. The image is published private — the package's visibility needs to be flipped to private once after the first push (GHCR defaults to inherit- from-repo, which is public for a public repo). Pulls then require `docker login ghcr.io` with a PAT scoped to read:packages. End-to-end-tested the Dockerfile locally with a static musl-style binary: builds clean, runs under the nonroot user, exits 0.
1 parent 947db2a commit 9268798

4 files changed

Lines changed: 149 additions & 2 deletions

File tree

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
name: Publish Docker image to GHCR
2+
3+
# Reusable workflow invoked by cargo-dist's release pipeline as a
4+
# user_publish_job (see dist-workspace.toml `publish-jobs`).
5+
#
6+
# Builds a multi-arch (linux/amd64 + linux/arm64) image from the
7+
# pre-built musl binaries attached to the GitHub release, pushes
8+
# per-arch tags to GHCR, and stitches them into a multi-arch manifest
9+
# at the canonical tag. The image is published private — see Phase 2
10+
# of the packaging plan for the visibility flip.
11+
on:
12+
workflow_call:
13+
inputs:
14+
plan:
15+
description: dist-manifest JSON for this announcement
16+
required: true
17+
type: string
18+
19+
permissions:
20+
contents: read
21+
packages: write
22+
id-token: write
23+
attestations: write
24+
25+
env:
26+
REGISTRY: ghcr.io
27+
IMAGE_NAME: quicknode/qn
28+
29+
jobs:
30+
publish:
31+
runs-on: ubuntu-22.04
32+
steps:
33+
- uses: actions/checkout@v4
34+
35+
- name: Extract release tag from plan
36+
id: meta
37+
env:
38+
PLAN: ${{ inputs.plan }}
39+
run: |
40+
tag=$(echo "$PLAN" | jq -r '.announcement_tag')
41+
version=$(echo "$PLAN" | jq -r '.releases[0].app_version')
42+
is_prerelease=$(echo "$PLAN" | jq -r '.announcement_is_prerelease')
43+
echo "tag=$tag" >> "$GITHUB_OUTPUT"
44+
echo "version=$version" >> "$GITHUB_OUTPUT"
45+
echo "is_prerelease=$is_prerelease" >> "$GITHUB_OUTPUT"
46+
47+
- name: Download musl artifacts from the GitHub release
48+
env:
49+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
50+
run: |
51+
mkdir -p artifacts
52+
gh release download "${{ steps.meta.outputs.tag }}" \
53+
--pattern '*linux-musl*.tar.xz' \
54+
--dir artifacts/
55+
56+
- name: Stage per-arch binaries
57+
run: |
58+
mkdir -p build/amd64 build/arm64
59+
tar -xf artifacts/quicknode-cli-x86_64-unknown-linux-musl.tar.xz \
60+
--strip-components=1 -C build/amd64 \
61+
--wildcards '*/qn'
62+
tar -xf artifacts/quicknode-cli-aarch64-unknown-linux-musl.tar.xz \
63+
--strip-components=1 -C build/arm64 \
64+
--wildcards '*/qn'
65+
chmod +x build/amd64/qn build/arm64/qn
66+
file build/amd64/qn build/arm64/qn
67+
68+
- uses: docker/setup-qemu-action@v3
69+
- uses: docker/setup-buildx-action@v3
70+
71+
- name: Log in to GHCR
72+
uses: docker/login-action@v3
73+
with:
74+
registry: ${{ env.REGISTRY }}
75+
username: ${{ github.actor }}
76+
password: ${{ secrets.GITHUB_TOKEN }}
77+
78+
- name: Build and push linux/amd64
79+
id: amd64
80+
uses: docker/build-push-action@v6
81+
with:
82+
context: build/amd64
83+
file: Dockerfile
84+
platforms: linux/amd64
85+
push: true
86+
provenance: true
87+
sbom: true
88+
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}-amd64
89+
90+
- name: Build and push linux/arm64
91+
id: arm64
92+
uses: docker/build-push-action@v6
93+
with:
94+
context: build/arm64
95+
file: Dockerfile
96+
platforms: linux/arm64
97+
push: true
98+
provenance: true
99+
sbom: true
100+
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}-arm64
101+
102+
- name: Create and push multi-arch manifest for v${{ steps.meta.outputs.version }}
103+
run: |
104+
docker buildx imagetools create \
105+
-t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }} \
106+
-t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:v${{ steps.meta.outputs.version }} \
107+
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}-amd64 \
108+
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}-arm64
109+
110+
- name: Promote to :latest (skip for prereleases)
111+
if: ${{ steps.meta.outputs.is_prerelease == 'false' }}
112+
run: |
113+
docker buildx imagetools create \
114+
-t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest \
115+
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}

.github/workflows/release.yml

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -346,16 +346,31 @@ jobs:
346346
"id-token": "write"
347347
"packages": "write"
348348

349+
custom-publish-docker:
350+
needs:
351+
- plan
352+
- host
353+
if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }}
354+
uses: ./.github/workflows/publish-docker.yml
355+
with:
356+
plan: ${{ needs.plan.outputs.val }}
357+
secrets: inherit
358+
# publish jobs get escalated permissions
359+
permissions:
360+
"id-token": "write"
361+
"packages": "write"
362+
349363
announce:
350364
needs:
351365
- plan
352366
- host
353367
- publish-homebrew-formula
354368
- custom-publish-crates
369+
- custom-publish-docker
355370
# use "always() && ..." to allow us to wait for all publish jobs while
356371
# still allowing individual publish jobs to skip themselves (for prereleases).
357372
# "host" however must run to completion, no skipping allowed!
358-
if: ${{ always() && needs.host.result == 'success' && (needs.publish-homebrew-formula.result == 'skipped' || needs.publish-homebrew-formula.result == 'success') && (needs.custom-publish-crates.result == 'skipped' || needs.custom-publish-crates.result == 'success') }}
373+
if: ${{ always() && needs.host.result == 'success' && (needs.publish-homebrew-formula.result == 'skipped' || needs.publish-homebrew-formula.result == 'success') && (needs.custom-publish-crates.result == 'skipped' || needs.custom-publish-crates.result == 'success') && (needs.custom-publish-docker.result == 'skipped' || needs.custom-publish-docker.result == 'success') }}
359374
runs-on: "ubuntu-22.04"
360375
env:
361376
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Dockerfile

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# syntax=docker/dockerfile:1.7
2+
3+
# We do NOT compile here. The publish-docker workflow pulls the
4+
# cross-compiled musl binary from the GitHub release artifacts and
5+
# stages it at ./qn before invoking `docker buildx build`. That keeps
6+
# the multi-arch image build a no-op COPY rather than a cargo rebuild
7+
# (the SLSA-attested binary in the release IS what ships in the image).
8+
ARG TARGETARCH
9+
10+
FROM gcr.io/distroless/static-debian12:nonroot
11+
LABEL org.opencontainers.image.source="https://github.com/quicknode/cli"
12+
LABEL org.opencontainers.image.description="qn — Quicknode CLI"
13+
LABEL org.opencontainers.image.licenses="MIT"
14+
15+
COPY --chown=nonroot:nonroot qn /qn
16+
USER nonroot
17+
ENTRYPOINT ["/qn"]

dist-workspace.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,6 @@ tap = "quicknode/homebrew-tap"
2222
# Override the Homebrew formula name so `brew install quicknode/tap/qn` works
2323
# (default would derive it from the package name `quicknode-cli`).
2424
formula = "qn"
25-
publish-jobs = ["homebrew", "./publish-crates"]
25+
publish-jobs = ["homebrew", "./publish-crates", "./publish-docker"]
2626
# Emit SLSA build attestations for every binary archive. Critical for a credential-handling tool.
2727
github-attestations = true

0 commit comments

Comments
 (0)