Repository navigation
ci(release): publish multi-arch amd64+arm64 images from native runners #144
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5959b8a
32fc843
25df12b
82285c9
baa2021
d79d70c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,134 @@ | ||
| # PR gate for the release image build path: native amd64 + native arm64, | ||
| # push-by-digest, then a throwaway multi-arch tag `pr-<n>` so arm64 hosts can | ||
| # `docker pull` the merged manifest without cutting a release. QEMU is | ||
| # intentionally not used (see ADR-0003). | ||
| name: Multi-arch image build | ||
|
|
||
| on: | ||
| pull_request: | ||
| paths: | ||
| - ".github/workflows/multiarch-image.yml" | ||
| - ".github/workflows/release.yml" | ||
| - "Dockerfile" | ||
| - "go.mod" | ||
| - "go.sum" | ||
| - "cmd/**" | ||
| - "internal/**" | ||
| - "frontend/**" | ||
| - "scripts/entrypoint.sh" | ||
| workflow_dispatch: | ||
|
|
||
| env: | ||
| REGISTRY: ghcr.io | ||
|
|
||
| jobs: | ||
| build: | ||
| name: Build ${{ matrix.platform }} | ||
| # Fork PRs get a read-only GITHUB_TOKEN; packages:write cannot elevate it. | ||
| if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Build fork pull requests without publishing them. For a fork pull request, this condition skips both architecture builds. The fork therefore receives no image-build check. Keep GHCR login, digest push, and manifest publication restricted to trusted runs, but run a no-push build for forks with their read-only token. GitHub documents that fork PR tokens have restricted write access. (docs.github.com) 🤖 Prompt for AI AgentsSource: Learnings |
||
| runs-on: ${{ matrix.runner }} | ||
| timeout-minutes: 30 | ||
| permissions: | ||
| contents: read | ||
| packages: write | ||
| strategy: | ||
| fail-fast: true | ||
| matrix: | ||
| include: | ||
| - platform: linux/amd64 | ||
| runner: ubuntu-latest | ||
| digest_name: linux-amd64 | ||
| - platform: linux/arm64 | ||
| runner: ubuntu-24.04-arm | ||
| digest_name: linux-arm64 | ||
| steps: | ||
| - uses: actions/checkout@v5 | ||
|
|
||
| - name: Lowercase image name | ||
| id: image | ||
| run: | | ||
| echo "name=${{ env.REGISTRY }}/$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" | ||
|
|
||
| - name: Set up Docker Buildx | ||
| uses: docker/setup-buildx-action@v4 | ||
|
|
||
| - name: Log in to GHCR | ||
| uses: docker/login-action@v4 | ||
| with: | ||
| registry: ${{ env.REGISTRY }} | ||
| username: ${{ github.actor }} | ||
| password: ${{ secrets.GITHUB_TOKEN }} | ||
|
|
||
| - name: Build and push by digest | ||
| id: build | ||
| uses: docker/build-push-action@v7 | ||
| with: | ||
| context: . | ||
| platforms: ${{ matrix.platform }} | ||
| outputs: type=image,name=${{ steps.image.outputs.name }},push-by-digest=true,name-canonical=true,push=true | ||
| cache-from: type=gha,scope=multiarch-${{ matrix.digest_name }} | ||
| cache-to: type=gha,scope=multiarch-${{ matrix.digest_name }},mode=max | ||
|
|
||
| - name: Export digest | ||
| run: | | ||
| mkdir -p "${{ runner.temp }}/digests" | ||
| digest="${{ steps.build.outputs.digest }}" | ||
| touch "${{ runner.temp }}/digests/${digest#sha256:}" | ||
|
|
||
| - uses: actions/upload-artifact@v4 | ||
| with: | ||
| name: digests-${{ matrix.digest_name }} | ||
| path: ${{ runner.temp }}/digests/* | ||
| if-no-files-found: error | ||
| retention-days: 1 | ||
|
|
||
| merge: | ||
| name: Publish multi-arch tag | ||
| needs: build | ||
| if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository | ||
| runs-on: ubuntu-latest | ||
| permissions: | ||
| contents: read | ||
| packages: write | ||
| steps: | ||
| - name: Lowercase image name | ||
| id: image | ||
| run: | | ||
| echo "name=${{ env.REGISTRY }}/$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" | ||
|
|
||
| - name: Tag name | ||
| id: tag | ||
| run: | | ||
| if [ "${{ github.event_name }}" = "pull_request" ]; then | ||
| echo "name=pr-${{ github.event.number }}" >> "$GITHUB_OUTPUT" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win Prevent older runs from replacing a newer PR image. If two runs for the same PR overlap, both publish 🤖 Prompt for AI Agents |
||
| else | ||
| echo "name=multiarch-test" >> "$GITHUB_OUTPUT" | ||
| fi | ||
|
|
||
| - name: Download digests | ||
| uses: actions/download-artifact@v4 | ||
| with: | ||
| path: ${{ runner.temp }}/digests | ||
| pattern: digests-* | ||
| merge-multiple: true | ||
|
|
||
| - name: Set up Docker Buildx | ||
| uses: docker/setup-buildx-action@v4 | ||
|
|
||
| - name: Log in to GHCR | ||
| uses: docker/login-action@v4 | ||
| with: | ||
| registry: ${{ env.REGISTRY }} | ||
| username: ${{ github.actor }} | ||
| password: ${{ secrets.GITHUB_TOKEN }} | ||
|
|
||
| - name: Create manifest list and push | ||
| working-directory: ${{ runner.temp }}/digests | ||
| run: | | ||
| IMAGE="${{ steps.image.outputs.name }}" | ||
| TAG="${{ steps.tag.outputs.name }}" | ||
| docker buildx imagetools create \ | ||
| -t "${IMAGE}:${TAG}" \ | ||
| $(printf "${IMAGE}@sha256:%s " *) | ||
| echo "Published ${IMAGE}:${TAG}" | ||
| docker buildx imagetools inspect "${IMAGE}:${TAG}" | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -15,15 +15,20 @@ on: | |||||||||||||
|
|
||||||||||||||
| env: | ||||||||||||||
| REGISTRY: ghcr.io | ||||||||||||||
| # GHCR digests require a lowercase repository path. | ||||||||||||||
| IMAGE_NAME: ${{ github.repository }} | ||||||||||||||
|
|
||||||||||||||
| jobs: | ||||||||||||||
| release: | ||||||||||||||
| name: Create release | ||||||||||||||
| prepare: | ||||||||||||||
| name: Tag release | ||||||||||||||
| runs-on: ubuntu-latest | ||||||||||||||
| permissions: | ||||||||||||||
| contents: write | ||||||||||||||
| packages: write | ||||||||||||||
| outputs: | ||||||||||||||
| tag: ${{ steps.version.outputs.tag }} | ||||||||||||||
| version: ${{ steps.version.outputs.version }} | ||||||||||||||
| image: ${{ steps.image.outputs.name }} | ||||||||||||||
| revision: ${{ steps.commit.outputs.sha }} | ||||||||||||||
| steps: | ||||||||||||||
| - name: Ensure running on main | ||||||||||||||
| run: | | ||||||||||||||
|
|
@@ -32,6 +37,11 @@ jobs: | |||||||||||||
| exit 1 | ||||||||||||||
| fi | ||||||||||||||
|
|
||||||||||||||
| - name: Lowercase image name | ||||||||||||||
| id: image | ||||||||||||||
| run: | | ||||||||||||||
| echo "name=${{ env.REGISTRY }}/$(echo '${{ env.IMAGE_NAME }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" | ||||||||||||||
|
|
||||||||||||||
| - uses: actions/checkout@v5 | ||||||||||||||
| with: | ||||||||||||||
| fetch-depth: 0 | ||||||||||||||
|
|
@@ -97,13 +107,98 @@ jobs: | |||||||||||||
| git config user.email "github-actions[bot]@users.noreply.github.com" | ||||||||||||||
|
|
||||||||||||||
| - name: Commit and tag | ||||||||||||||
| id: commit | ||||||||||||||
| run: | | ||||||||||||||
| git add CHANGELOG.md | ||||||||||||||
| git commit -m "chore: release ${{ steps.version.outputs.tag }}" | ||||||||||||||
| git tag -a "${{ steps.version.outputs.tag }}" \ | ||||||||||||||
| -m "Release ${{ steps.version.outputs.tag }}" | ||||||||||||||
| git push origin HEAD:main | ||||||||||||||
| git push origin "${{ steps.version.outputs.tag }}" | ||||||||||||||
| echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" | ||||||||||||||
|
|
||||||||||||||
| - uses: actions/upload-artifact@v4 | ||||||||||||||
| with: | ||||||||||||||
| name: release-notes | ||||||||||||||
| path: release-notes.md | ||||||||||||||
|
|
||||||||||||||
| build: | ||||||||||||||
| name: Build ${{ matrix.platform }} | ||||||||||||||
| needs: prepare | ||||||||||||||
| runs-on: ${{ matrix.runner }} | ||||||||||||||
| permissions: | ||||||||||||||
| contents: read | ||||||||||||||
| packages: write | ||||||||||||||
| strategy: | ||||||||||||||
| fail-fast: true | ||||||||||||||
| matrix: | ||||||||||||||
| include: | ||||||||||||||
| - platform: linux/amd64 | ||||||||||||||
| runner: ubuntu-latest | ||||||||||||||
| digest_name: linux-amd64 | ||||||||||||||
| - platform: linux/arm64 | ||||||||||||||
| runner: ubuntu-24.04-arm | ||||||||||||||
| digest_name: linux-arm64 | ||||||||||||||
| steps: | ||||||||||||||
| - uses: actions/checkout@v5 | ||||||||||||||
| with: | ||||||||||||||
| ref: ${{ needs.prepare.outputs.tag }} | ||||||||||||||
|
|
||||||||||||||
| - name: Set up Docker Buildx | ||||||||||||||
| uses: docker/setup-buildx-action@v4 | ||||||||||||||
|
|
||||||||||||||
| - name: Log in to GHCR | ||||||||||||||
| uses: docker/login-action@v4 | ||||||||||||||
| with: | ||||||||||||||
| registry: ${{ env.REGISTRY }} | ||||||||||||||
| username: ${{ github.actor }} | ||||||||||||||
| password: ${{ secrets.GITHUB_TOKEN }} | ||||||||||||||
|
|
||||||||||||||
| - name: Build and push by digest | ||||||||||||||
| id: build | ||||||||||||||
| uses: docker/build-push-action@v7 | ||||||||||||||
| with: | ||||||||||||||
| context: . | ||||||||||||||
| platforms: ${{ matrix.platform }} | ||||||||||||||
| labels: | | ||||||||||||||
| org.opencontainers.image.version=${{ needs.prepare.outputs.version }} | ||||||||||||||
| org.opencontainers.image.revision=${{ needs.prepare.outputs.revision }} | ||||||||||||||
| outputs: type=image,name=${{ needs.prepare.outputs.image }},push-by-digest=true,name-canonical=true,push=true | ||||||||||||||
| cache-from: type=gha,scope=${{ matrix.digest_name }} | ||||||||||||||
| cache-to: type=gha,scope=${{ matrix.digest_name }},mode=max | ||||||||||||||
|
|
||||||||||||||
| - name: Export digest | ||||||||||||||
| run: | | ||||||||||||||
| mkdir -p "${{ runner.temp }}/digests" | ||||||||||||||
| digest="${{ steps.build.outputs.digest }}" | ||||||||||||||
| touch "${{ runner.temp }}/digests/${digest#sha256:}" | ||||||||||||||
|
|
||||||||||||||
| - uses: actions/upload-artifact@v4 | ||||||||||||||
| with: | ||||||||||||||
| name: digests-${{ matrix.digest_name }} | ||||||||||||||
| path: ${{ runner.temp }}/digests/* | ||||||||||||||
| if-no-files-found: error | ||||||||||||||
| retention-days: 1 | ||||||||||||||
|
|
||||||||||||||
| merge: | ||||||||||||||
| name: Manifest and GitHub Release | ||||||||||||||
| needs: [prepare, build] | ||||||||||||||
| runs-on: ubuntu-latest | ||||||||||||||
| permissions: | ||||||||||||||
| contents: write | ||||||||||||||
| packages: write | ||||||||||||||
| steps: | ||||||||||||||
| - name: Download digests | ||||||||||||||
| uses: actions/download-artifact@v4 | ||||||||||||||
| with: | ||||||||||||||
| path: ${{ runner.temp }}/digests | ||||||||||||||
| pattern: digests-* | ||||||||||||||
| merge-multiple: true | ||||||||||||||
|
|
||||||||||||||
| - name: Download release notes | ||||||||||||||
| uses: actions/download-artifact@v4 | ||||||||||||||
| with: | ||||||||||||||
| name: release-notes | ||||||||||||||
|
|
||||||||||||||
| - name: Set up Docker Buildx | ||||||||||||||
| uses: docker/setup-buildx-action@v4 | ||||||||||||||
|
|
@@ -119,26 +214,26 @@ jobs: | |||||||||||||
| id: meta | ||||||||||||||
| uses: docker/metadata-action@v6 | ||||||||||||||
| with: | ||||||||||||||
| images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} | ||||||||||||||
| images: ${{ needs.prepare.outputs.image }} | ||||||||||||||
| tags: | | ||||||||||||||
| type=semver,pattern={{version}},value=${{ steps.version.outputs.tag }} | ||||||||||||||
| type=semver,pattern={{major}}.{{minor}},value=${{ steps.version.outputs.tag }} | ||||||||||||||
| type=semver,pattern={{version}},value=${{ needs.prepare.outputs.tag }} | ||||||||||||||
| type=semver,pattern={{major}}.{{minor}},value=${{ needs.prepare.outputs.tag }} | ||||||||||||||
|
Comment on lines
+219
to
+220
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Publish the documented For a prepared tag such as Proposed tag patterns--- "a/.github/workflows/release.yml"
+++ "b/.github/workflows/release.yml"
@@ -215,9 +215,11 @@
uses: docker/metadata-action@v6
with:
images: ${{ needs.prepare.outputs.image }}
tags: |
+ type=semver,pattern=v{{version}},value=${{ needs.prepare.outputs.tag }}
+ type=semver,pattern=v{{major}}.{{minor}},value=${{ needs.prepare.outputs.tag }}
type=semver,pattern={{version}},value=${{ needs.prepare.outputs.tag }}
type=semver,pattern={{major}}.{{minor}},value=${{ needs.prepare.outputs.tag }}
type=raw,value=latest
- name: Create manifest list and push📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||
| type=raw,value=latest | ||||||||||||||
|
|
||||||||||||||
| - name: Build and push Docker image | ||||||||||||||
| uses: docker/build-push-action@v7 | ||||||||||||||
| with: | ||||||||||||||
| context: . | ||||||||||||||
| push: true | ||||||||||||||
| tags: ${{ steps.meta.outputs.tags }} | ||||||||||||||
| labels: ${{ steps.meta.outputs.labels }} | ||||||||||||||
| cache-from: type=gha | ||||||||||||||
| cache-to: type=gha,mode=max | ||||||||||||||
| - name: Create manifest list and push | ||||||||||||||
| working-directory: ${{ runner.temp }}/digests | ||||||||||||||
| run: | | ||||||||||||||
| docker buildx imagetools create \ | ||||||||||||||
| $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ | ||||||||||||||
| $(printf '${{ needs.prepare.outputs.image }}@sha256:%s ' *) | ||||||||||||||
|
|
||||||||||||||
| - name: Create GitHub Release | ||||||||||||||
| env: | ||||||||||||||
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||||||||||||||
| # This job has no checkout, so gh has no git remote to resolve the | ||||||||||||||
| # repository from; it does not read GITHUB_REPOSITORY. | ||||||||||||||
| GH_REPO: ${{ github.repository }} | ||||||||||||||
| run: | | ||||||||||||||
| gh release create "${{ steps.version.outputs.tag }}" \ | ||||||||||||||
| --title "cotel ${{ steps.version.outputs.tag }}" \ | ||||||||||||||
| gh release create "${{ needs.prepare.outputs.tag }}" \ | ||||||||||||||
| --title "cotel ${{ needs.prepare.outputs.tag }}" \ | ||||||||||||||
|
Comment on lines
+234
to
+235
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Give The new Proposed fix env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GH_REPO: ${{ github.repository }}🧰 Tools🪛 zizmor (1.30.1)[info] 234-234: code injection via template expansion (template-injection): may expand into attacker-controllable code (template-injection) [info] 235-235: code injection via template expansion (template-injection): may expand into attacker-controllable code (template-injection) 🤖 Prompt for AI Agents |
||||||||||||||
| --notes-file release-notes.md | ||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -31,12 +31,16 @@ Releases are created **only** by manually triggering `.github/workflows/release. | |
| 2. Accepts a `bump` input (`minor` default, `patch`, `major`). | ||
| 3. Updates `CHANGELOG.md` and commits it. | ||
| 4. Creates an annotated git tag and pushes both the commit and the tag. | ||
| 5. Builds and pushes the Docker image to GHCR with `vMAJOR.MINOR.PATCH`, `vMAJOR.MINOR`, and `latest` tags. | ||
| 5. Builds and pushes a **multi-arch** Docker image to GHCR (`linux/amd64` and `linux/arm64`) with `vMAJOR.MINOR.PATCH`, `vMAJOR.MINOR`, and `latest` tags. | ||
| 6. Creates a GitHub Release with the extracted changelog section as release notes. | ||
|
|
||
| ### Image platforms | ||
|
|
||
| The published image must be pullable on both amd64 hosts and arm64 hosts (Apple Silicon production, Raspberry Pi restore drills). A single `platforms: linux/amd64,linux/arm64` build on `ubuntu-latest` would compile DuckDB through cgo under QEMU; measured on 2026-10-12 that path is the expensive one (see Consequences). Instead `release.yml` builds each architecture on a **native** hosted runner (`ubuntu-latest` + `ubuntu-24.04-arm`), pushes by digest, and merges a manifest list. Native arm64 on the hosted runner was ~47s for a coldish image build; that is cheap enough to keep on every release. `multiarch-image.yml` exercises the same two native builds on PRs that touch the image path, without pushing. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Correct the PR publication policy.
🤖 Prompt for AI Agents |
||
|
|
||
| ### CI changes | ||
|
|
||
| `ci.yml` now runs only on pushes to `main` and pull requests. It no longer publishes Docker images — that responsibility belongs entirely to `release.yml`. The smoke test continues to validate Docker builds on every CI run. | ||
| `ci.yml` now runs only on pushes to `main` and pull requests. It no longer publishes Docker images — that responsibility belongs entirely to `release.yml`. The smoke test continues to validate an amd64 Docker build on every CI run; `multiarch-image.yml` covers the arm64 half of the release path. | ||
|
|
||
| ## Options considered | ||
|
|
||
|
|
@@ -50,8 +54,10 @@ Releases are created **only** by manually triggering `.github/workflows/release. | |
|
|
||
| - No Docker image is published until someone presses the button — deliberate gate. | ||
| - GHCR always has a `latest` tag pointing to the newest release, and versioned tags for pinning. | ||
| - `latest` (and each version tag) is a multi-arch manifest: `docker pull` on arm64 no longer requires a local `docker compose build` before `cotel --db-import`. | ||
| - CHANGELOG must be kept up-to-date by the developer; the release workflow will produce an empty section if it isn't. | ||
| - Branch protection on `main` would block the release workflow's push-back commit. If protection is added later, the workflow token must be granted bypass or a deploy key used instead. | ||
| - QEMU-emulated arm64 on `ubuntu-latest` was measured and rejected for the release path: native `ubuntu-24.04-arm` finished the same Dockerfile in **47s**, while the QEMU build step was still running at **~11 minutes** when cancelled. Prefer native runners; do not add `platforms: linux/amd64,linux/arm64` on a single amd64 runner. | ||
|
|
||
| ## Decision lenses applied | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Trigger the build when
.dockerignorechanges.If a PR adds or changes
.dockerignorewithout changing a listed path, this workflow does not run..dockerignorecontrols which files reach the Docker build context, so that PR can change the release image build without this gate. Add.dockerignoretopull_request.paths. (docs.docker.com)🤖 Prompt for AI Agents