From 5959b8aa8986e5bd72f2d84e286c1be028c5ae31 Mon Sep 17 00:00:00 2001 From: Soren Date: Mon, 12 Oct 2026 21:08:33 +0200 Subject: [PATCH 1/5] ci(release): add arm64 image build timing workflow Measure QEMU-on-ubuntu-latest vs native ubuntu-24.04-arm before committing release.yml to multi-arch; numbers decide the path. Co-Authored-By: Soren Co-Authored-By: Cursor Composer 2.5 --- .github/workflows/measure-arm64-image.yml | 78 +++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 .github/workflows/measure-arm64-image.yml diff --git a/.github/workflows/measure-arm64-image.yml b/.github/workflows/measure-arm64-image.yml new file mode 100644 index 0000000..09c71af --- /dev/null +++ b/.github/workflows/measure-arm64-image.yml @@ -0,0 +1,78 @@ +# One-shot timing for multi-arch release options (FLO-1043). +# Does not push images. Safe to run on a PR; remove after the decision lands. +name: Measure arm64 image build + +on: + pull_request: + paths: + - ".github/workflows/measure-arm64-image.yml" + - "Dockerfile" + - "go.mod" + - "go.sum" + - "cmd/**" + - "internal/**" + - "frontend/**" + - "scripts/entrypoint.sh" + workflow_dispatch: + +jobs: + qemu-arm64: + name: QEMU arm64 on ubuntu-latest + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - uses: actions/checkout@v5 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Time arm64 build under QEMU + run: | + set -euo pipefail + START=$(date +%s) + echo "measure-arm64: qemu start $(date -u +%Y-%m-%dT%H:%M:%SZ)" + docker buildx build \ + --platform linux/arm64 \ + --progress=plain \ + --load \ + -t cotel:measure-qemu-arm64 \ + . + END=$(date +%s) + ELAPSED=$((END - START)) + echo "measure-arm64: qemu end $(date -u +%Y-%m-%dT%H:%M:%SZ) elapsed=${ELAPSED}s" + echo "elapsed_seconds=${ELAPSED}" >> "$GITHUB_STEP_SUMMARY" + echo "## QEMU arm64 on ubuntu-latest" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "- elapsed: **${ELAPSED}s** ($((ELAPSED / 60))m $((ELAPSED % 60))s)" >> "$GITHUB_STEP_SUMMARY" + + native-arm64: + name: Native arm64 on ubuntu-24.04-arm + runs-on: ubuntu-24.04-arm + timeout-minutes: 60 + steps: + - uses: actions/checkout@v5 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Time native arm64 build + run: | + set -euo pipefail + START=$(date +%s) + echo "measure-arm64: native start $(date -u +%Y-%m-%dT%H:%M:%SZ)" + docker buildx build \ + --platform linux/arm64 \ + --progress=plain \ + --load \ + -t cotel:measure-native-arm64 \ + . + END=$(date +%s) + ELAPSED=$((END - START)) + echo "measure-arm64: native end $(date -u +%Y-%m-%dT%H:%M:%SZ) elapsed=${ELAPSED}s" + echo "elapsed_seconds=${ELAPSED}" >> "$GITHUB_STEP_SUMMARY" + echo "## Native arm64 on ubuntu-24.04-arm" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "- elapsed: **${ELAPSED}s** ($((ELAPSED / 60))m $((ELAPSED % 60))s)" >> "$GITHUB_STEP_SUMMARY" From 32fc843338b5cae1dbdaa8650b92aa492f7d9c34 Mon Sep 17 00:00:00 2001 From: Soren Date: Mon, 12 Oct 2026 21:14:05 +0200 Subject: [PATCH 2/5] ci(release): publish multi-arch images from native runners Native ubuntu-24.04-arm built the image in ~47s; QEMU on ubuntu-latest was already several times slower and still running, so release.yml splits amd64/arm64 across native runners and merges the manifest. PR builds publish a throwaway pr- tag to prove pull works before a release is cut. Co-Authored-By: Soren Co-Authored-By: Cursor Composer 2.5 --- .github/workflows/multiarch-image.yml | 131 ++++++++++++++++++++++++++ .github/workflows/release.yml | 123 ++++++++++++++++++++---- CHANGELOG.md | 1 + docs/decisions/0003-release-policy.md | 10 +- docs/operations/duckdb-snapshots.md | 8 ++ 5 files changed, 254 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/multiarch-image.yml diff --git a/.github/workflows/multiarch-image.yml b/.github/workflows/multiarch-image.yml new file mode 100644 index 0000000..0379712 --- /dev/null +++ b/.github/workflows/multiarch-image.yml @@ -0,0 +1,131 @@ +# PR gate for the release image build path: native amd64 + native arm64, +# push-by-digest, then a throwaway multi-arch tag `pr-` 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 }} + 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 + 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" + 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}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 587bb10..9fa7c3c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,15 +15,19 @@ 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 }} steps: - name: Ensure running on main run: | @@ -32,6 +36,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 @@ -105,6 +114,89 @@ jobs: git push origin HEAD:main git push origin "${{ steps.version.outputs.tag }}" + - 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=${{ github.sha }} + 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 +211,23 @@ 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 }} 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 }} 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 }}" \ --notes-file release-notes.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 00532b7..6e343ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A failing `GET /api/v1/bash-commands` no longer renders as the "no command detail in this data" explainer. The Bash section branched on row count alone, so a request that errored looked identical to one that legitimately returned nothing, blaming Claude Code's telemetry for what was actually a server fault. Fetch failures now show the error ### Changed +- Release images are multi-arch (`linux/amd64` + `linux/arm64`). `release.yml` builds each platform on a native hosted runner and merges a manifest list, so `docker pull ghcr.io/flopsstuff/cotel:latest` works on arm64 hosts (Pi, Apple Silicon) without a local `docker compose build` before restore. QEMU-emulated arm64 on a single amd64 runner was measured and rejected; native `ubuntu-24.04-arm` finished the same Dockerfile in ~47s ([ADR-0003](docs/decisions/0003-release-policy.md), [docs/operations/duckdb-snapshots.md](docs/operations/duckdb-snapshots.md)) - The bundled `cloudflared` is **2026.9.3** in place of 2024.11.1, still an explicit pin rather than `latest` so rebuilding an old commit produces the binary it originally shipped. The two years in between hold one change that touches cotel: 2026.4.0 flipped the `--edge-ip-version` default from `4` to `auto`, which connects over whichever address family the system resolver answers with first and falls back to the other only after a connection has already failed - so a container whose resolver answers `AAAA` first but has no working IPv6 egress spends its first attempts failing, and then sticks with the fallback for ten minutes at a time. Token mode therefore sets `TUNNEL_EDGE_IP_VERSION=4` unless the operator sets it, holding the behaviour the old version had; local-config mode is deliberately left alone, because that env var silently outranks an `edge-ip-version` in the operator's own `config.yml`. The other new arrival is visible rather than behavioural: cloudflared now probes DNS, QUIC, HTTP/2 and the Cloudflare API at startup and logs a `CONNECTIVITY PRE-CHECKS` table, which is kept on - it runs concurrently with startup and does not gate it, a `FAIL` row does not stop the tunnel, and it is the fastest way to separate a blocked UDP path from a bad token, which is exactly the diagnosis the deploy health gate cannot make because it probes cotel's `/healthz` and not the tunnel. The bump does **not** address the token-in-logs leak and was kept out of that fix for the same reason the fix was kept out of this bump: measured on 2026.9.3, a token left in the environment is still printed in full by the startup env dump. `--token-file`, which the new version adds, is not adopted either - the token stays readable through `docker inspect` and `/proc/1/environ` regardless, so it would close no access path in exchange for a tmpfs mount and a secret on disk ([docs/operations/cloudflare-tunnel-remote.md](docs/operations/cloudflare-tunnel-remote.md)) - The DuckDB driver is `github.com/duckdb/duckdb-go/v2`, which carries engine **1.5.6** in place of 1.1.3. The reason is not the version: `github.com/marcboeker/go-duckdb` is deprecated by its own author — the first line of its `go.mod` reads *"This module has moved to github.com/duckdb/duckdb-go"* and its last tag is a year old — so cotel was pinned to an address that is no longer the dependency and has no upstream to receive a bug report. This is **not** a fix for the ART-metadata corruption that took production down: upstream [duckdb#25360](https://github.com/duckdb/duckdb/issues/25360) reports the same class of fault still reproducing on 1.5.5, and what the newer engine changes is that it raises a Go error instead of `abort()`ing inside cgo. The on-disk format was the thing that needed measuring, since a newer engine writing a file the previous image cannot read would make the upgrade unrollbackable with production's only copy of the data inside it — and it does not: an existing file is never converted by ordinary open-and-write, not even when `storage_compatibility_version=v1.5.6` is asked for explicitly, and 1.1.3 reads a file 1.5.6 has written to, replays a WAL 1.5.6 left, and was confirmed doing so as the actual `ghcr.io/flopsstuff/cotel:latest` image. The door closes only for a *newly created* file at an explicit opt-in, so `storage.Open` now pins `storage_compatibility_version=v0.10.2` in the DSN and a test reads the format number straight out of the file header to hold it at 64 — the pin is a no-op against today's engine, which is the point: a default is behaviour, not a contract, and a future minor could raise it and leave no diff of ours to point at. Two further consequences: Go **1.24.0** is now the build floor (`go.mod`, the builder image and CI move together), and the runtime image grows 213 MB → 294 MB for the bigger static engine library — against which ICU is now linked in rather than fetched over the network on every cold start. A DuckDB `JSON` column no longer scans into a Go `*string` (v2 hands back a decoded `map[string]interface{}`), so `ExportSpans` and the session-detail query cast in SQL; any new query reading `attributes` or `resource_attrs` into a string needs the same `::VARCHAR` ([ADR-0018](docs/decisions/0018-duckdb-go-v2-driver.md)) - The chart palette is re-derived — eight of its ten values change, `--color-chart-1` is unchanged in both schemes because it is `--color-accent`. The old palette broke two things at once. Its dark values were the light values lightened, which reverses loudness when the surface flips: the quietest series in light was the loudest in dark, so the Overview's cost line shouted over the spans area it annotates for any reader on a dark theme, and did not for anyone on a light one. And it was not the colour-independent palette it was documented to be — `chart-1` and `chart-2` measured ΔE 0.4 light / 0.3 dark under deuteranopia, the same colour to roughly one man in twelve, and the palette is handed out in order, so that was the pair every two-series chart in the product drew. The new values hold both rules with margin: identical salience rank in both schemes, and every one of the ten pairs ≥ 8.6 ΔE under protanopia, deuteranopia and tritanopia in both schemes, measured with Machado 2009 at severity 1.0 in OKLab. Each token keeps its hue to within 3° — the palette still reads blue, violet, green, amber, pink — and only lightness and chroma moved; the contrast floor against the surface rises from 3.19:1 to 4.08:1, above the 3:1 WCAG 1.4.11 asks of a 2 px stroke. `--color-chart-4` also stops being byte-identical to `--color-warning`, which it was in both schemes: the cost line was literally the warning colour. The rules, the ruler and the full pairwise measurement are in [tokens.md](docs/design/tokens.md) diff --git a/docs/decisions/0003-release-policy.md b/docs/decisions/0003-release-policy.md index 8080fe1..daad512 100644 --- a/docs/decisions/0003-release-policy.md +++ b/docs/decisions/0003-release-policy.md @@ -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. + ### 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 job was still building well past that. Prefer native runners; do not add `platforms: linux/amd64,linux/arm64` on a single amd64 runner. ## Decision lenses applied diff --git a/docs/operations/duckdb-snapshots.md b/docs/operations/duckdb-snapshots.md index 164970f..9e48d50 100644 --- a/docs/operations/duckdb-snapshots.md +++ b/docs/operations/duckdb-snapshots.md @@ -150,6 +150,14 @@ needs nothing but the image already on the host: no DuckDB CLI, no version matching by hand (which is the step of the recovery procedure most able to destroy a file). +Published releases push a multi-arch image (`linux/amd64` and `linux/arm64`) — +see [ADR-0003](../decisions/0003-release-policy). On an arm64 host (Pi, Apple +Silicon) `docker pull ghcr.io/flopsstuff/cotel:latest` is enough; you do not +need `docker compose build` just to get a binary that can import. If pull still +reports `no matching manifest for linux/arm64`, you are on a release older than +that change — build once with `docker compose build` and proceed with the local +tag. + Two rules the flag enforces rather than documents: the target database file must be **empty or absent** (the snapshot's `schema.sql` issues plain `CREATE TABLE`, so importing over populated tables would fail halfway), and the snapshot must From 82285c92f854e618ad847677a8ae5ff54df96062 Mon Sep 17 00:00:00 2001 From: Soren Date: Mon, 12 Oct 2026 21:20:41 +0200 Subject: [PATCH 3/5] ci(release): drop one-shot arm64 timing workflow Native vs QEMU numbers are in ADR-0003; multiarch-image.yml is the ongoing PR gate. QEMU was cancelled after ~11 minutes still building. Co-Authored-By: Soren Co-Authored-By: Cursor Composer 2.5 --- .github/workflows/measure-arm64-image.yml | 78 ----------------------- docs/decisions/0003-release-policy.md | 2 +- 2 files changed, 1 insertion(+), 79 deletions(-) delete mode 100644 .github/workflows/measure-arm64-image.yml diff --git a/.github/workflows/measure-arm64-image.yml b/.github/workflows/measure-arm64-image.yml deleted file mode 100644 index 09c71af..0000000 --- a/.github/workflows/measure-arm64-image.yml +++ /dev/null @@ -1,78 +0,0 @@ -# One-shot timing for multi-arch release options (FLO-1043). -# Does not push images. Safe to run on a PR; remove after the decision lands. -name: Measure arm64 image build - -on: - pull_request: - paths: - - ".github/workflows/measure-arm64-image.yml" - - "Dockerfile" - - "go.mod" - - "go.sum" - - "cmd/**" - - "internal/**" - - "frontend/**" - - "scripts/entrypoint.sh" - workflow_dispatch: - -jobs: - qemu-arm64: - name: QEMU arm64 on ubuntu-latest - runs-on: ubuntu-latest - timeout-minutes: 90 - steps: - - uses: actions/checkout@v5 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 - - - name: Time arm64 build under QEMU - run: | - set -euo pipefail - START=$(date +%s) - echo "measure-arm64: qemu start $(date -u +%Y-%m-%dT%H:%M:%SZ)" - docker buildx build \ - --platform linux/arm64 \ - --progress=plain \ - --load \ - -t cotel:measure-qemu-arm64 \ - . - END=$(date +%s) - ELAPSED=$((END - START)) - echo "measure-arm64: qemu end $(date -u +%Y-%m-%dT%H:%M:%SZ) elapsed=${ELAPSED}s" - echo "elapsed_seconds=${ELAPSED}" >> "$GITHUB_STEP_SUMMARY" - echo "## QEMU arm64 on ubuntu-latest" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - echo "- elapsed: **${ELAPSED}s** ($((ELAPSED / 60))m $((ELAPSED % 60))s)" >> "$GITHUB_STEP_SUMMARY" - - native-arm64: - name: Native arm64 on ubuntu-24.04-arm - runs-on: ubuntu-24.04-arm - timeout-minutes: 60 - steps: - - uses: actions/checkout@v5 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 - - - name: Time native arm64 build - run: | - set -euo pipefail - START=$(date +%s) - echo "measure-arm64: native start $(date -u +%Y-%m-%dT%H:%M:%SZ)" - docker buildx build \ - --platform linux/arm64 \ - --progress=plain \ - --load \ - -t cotel:measure-native-arm64 \ - . - END=$(date +%s) - ELAPSED=$((END - START)) - echo "measure-arm64: native end $(date -u +%Y-%m-%dT%H:%M:%SZ) elapsed=${ELAPSED}s" - echo "elapsed_seconds=${ELAPSED}" >> "$GITHUB_STEP_SUMMARY" - echo "## Native arm64 on ubuntu-24.04-arm" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - echo "- elapsed: **${ELAPSED}s** ($((ELAPSED / 60))m $((ELAPSED % 60))s)" >> "$GITHUB_STEP_SUMMARY" diff --git a/docs/decisions/0003-release-policy.md b/docs/decisions/0003-release-policy.md index daad512..76e3083 100644 --- a/docs/decisions/0003-release-policy.md +++ b/docs/decisions/0003-release-policy.md @@ -57,7 +57,7 @@ The published image must be pullable on both amd64 hosts and arm64 hosts (Apple - `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 job was still building well past that. Prefer native runners; do not add `platforms: linux/amd64,linux/arm64` on a single amd64 runner. +- 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 From baa20218018db8ee80e77c201276c4ed79a267ad Mon Sep 17 00:00:00 2001 From: Soren Date: Tue, 13 Oct 2026 09:52:17 +0200 Subject: [PATCH 4/5] fix(ci): skip fork GHCR push; label release from tagged sha Fork PRs cannot write packages with GITHUB_TOKEN, so the multi-arch gate skips rather than red-X outside contributors. Release images now label org.opencontainers.image.revision with the prepare commit that was actually built, not workflow_dispatch github.sha. Co-Authored-By: Soren Co-Authored-By: Cursor Composer 2.5 --- .github/workflows/multiarch-image.yml | 3 +++ .github/workflows/release.yml | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/multiarch-image.yml b/.github/workflows/multiarch-image.yml index 0379712..dbce93a 100644 --- a/.github/workflows/multiarch-image.yml +++ b/.github/workflows/multiarch-image.yml @@ -24,6 +24,8 @@ env: 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 runs-on: ${{ matrix.runner }} timeout-minutes: 30 permissions: @@ -83,6 +85,7 @@ jobs: 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9fa7c3c..21087e1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,6 +28,7 @@ jobs: 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: | @@ -106,6 +107,7 @@ 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 }}" @@ -113,6 +115,7 @@ jobs: -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: @@ -159,7 +162,7 @@ jobs: platforms: ${{ matrix.platform }} labels: | org.opencontainers.image.version=${{ needs.prepare.outputs.version }} - org.opencontainers.image.revision=${{ github.sha }} + 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 From d79d70c8baca902c7c3a7eeda19149591afaff06 Mon Sep 17 00:00:00 2001 From: Daedalus Date: Tue, 13 Oct 2026 09:56:20 +0200 Subject: [PATCH 5/5] fix(release): give gh a repository in the manifest job The release flow now ends in a `merge` job that has no `actions/checkout`, and gh resolves the repository from a git remote - it does not read GITHUB_REPOSITORY. Without this the tag, the changelog commit and all image tags land and only the GitHub Release step fails. Co-Authored-By: Daedalus Co-Authored-By: Claude Opus 4.8 Co-Authored-By: Soren --- .github/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 21087e1..7b47467 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -230,6 +230,9 @@ jobs: - 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 "${{ needs.prepare.outputs.tag }}" \ --title "cotel ${{ needs.prepare.outputs.tag }}" \