Files
opencode-devbox/.gitea/workflows/docker-publish-split.yml
T
joakimp 51ec4a88cf
Validate / base-change-warning (push) Successful in 6s
Validate / docs-check (push) Successful in 13s
Validate / validate-with-pi (push) Successful in 4m9s
Validate / validate-omos (push) Successful in 4m31s
Validate / validate-base (push) Successful in 5m40s
Validate / validate-omos-with-pi (push) Successful in 12m49s
CI: drop registry cache-export from build-base (Hub 400 root cause)
Diagnosed during manual v1.15.12 publish: buildkit's mode=max cache export
to registry-1.docker.io reproducibly returns HTTP 400 with HTML body on the
resumable-upload PUT. Image push (layers + manifest) works fine in parallel;
only --cache-to fails. Removing cache-from/cache-to lets the publish complete.

This explains all four prior CI failures (runs 332/333/334/336) which shared
the exact same failure shape. Action-pin hypothesis (setup-buildx-action
v4.1.0) was correctly disproven by run 336 with v4.0.0 pinned.

Trade-off: every Dockerfile.base change now pays the full ~3 min multi-arch
build. Unchanged bases short-circuit at the content-addressed probe step in
base-decide and never re-build, so day-to-day cost is zero.

Re-enable when moby/buildkit upstream resolves the cache-export protocol
mismatch with Hub CDN, or when we can switch to a non-registry cache backend.

CHANGELOG.md: full root-cause writeup in Unreleased section, including
status update on every prior suspect (all ruled out).
2026-05-28 10:40:08 +00:00

690 lines
28 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
name: Publish Docker Image
# Two-phase split-base build pipeline. Replaces the original
# docker-publish.yml single-Dockerfile pipeline.
#
# Pipeline shape:
# 1. base-decide compute base hash from Dockerfile.base + rootfs/
# + entrypoints; probe Docker Hub for existing tag.
# 2. build-base only if probe missed; multi-arch push of base-<hash>.
# 3. smoke-* (×4) amd64-only build of each variant FROMing the base
# tag; runs scripts/smoke-test.sh.
# 4. build-variant-* multi-arch push of each variant tag (the user-
# (×4) facing release tags, unchanged in shape).
# 5. promote-base-latest re-tag base-<hash> → base-latest with `crane copy`
# (manifest copy, no rebuild).
# 6. update-description patch Docker Hub description (unchanged).
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
release_tag:
description: 'Release tag to publish (e.g. v1.14.50). Used only for workflow_dispatch runs — tag-triggered runs derive the tag from github.ref.'
required: false
default: ''
promote_latest:
description: 'Update latest/* aliases (default true for tag-push, set false for manual test runs)'
required: false
default: 'false'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
env:
BUILDKIT_PROGRESS: plain
IMAGE: ${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox
RELEASE_TAG: ${{ github.ref_type == 'tag' && github.ref_name || inputs.release_tag }}
PROMOTE_LATEST: ${{ github.ref_type == 'tag' && 'true' || inputs.promote_latest }}
# ───────────────────────────────────────────────────────────────────
# Reusable disk-reclaim snippet — strips catthehacker toolchains and
# stale docker state. Identical to the production workflow's pattern.
# ───────────────────────────────────────────────────────────────────
jobs:
# ── Phase 1: decide whether base needs rebuilding ──────────────────
base-decide:
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
outputs:
base_tag: ${{ steps.compute.outputs.base_tag }}
need_build: ${{ steps.probe.outputs.need_build }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Compute base tag from Dockerfile.base + dependencies
id: compute
run: |
# Hash inputs that determine the base image's contents.
# Order is fixed via `find -print0 | sort -z` for reproducibility.
# Junk filters: __pycache__/*.pyc and macOS metadata (.DS_Store,
# ._AppleDouble) are gitignored locally but still picked up by
# `find rootfs -type f`, which would diverge the local hash from
# CI's clean checkout. Exclude them defensively here.
HASH=$(
{
cat Dockerfile.base
find rootfs -type f \
! -path '*/__pycache__/*' \
! -name '*.pyc' \
! -name '.DS_Store' \
! -name '._*' \
-print0 2>/dev/null | sort -z | xargs -0 cat 2>/dev/null
cat entrypoint.sh entrypoint-user.sh
} | sha256sum | cut -c1-12
)
BASE_TAG="base-${HASH}"
echo "base_tag=${BASE_TAG}" >> "$GITHUB_OUTPUT"
echo "Computed base tag: ${BASE_TAG}"
- name: Force IPv4 for Docker Hub
run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- name: Probe Docker Hub for existing base tag
id: probe
run: |
set +e
docker manifest inspect "${IMAGE}:${{ steps.compute.outputs.base_tag }}" \
> /dev/null 2>&1
PROBE_RC=$?
set -e
if [ "${PROBE_RC}" = "0" ]; then
echo "need_build=false" >> "$GITHUB_OUTPUT"
echo "Base tag ${IMAGE}:${{ steps.compute.outputs.base_tag }} exists — skipping rebuild."
else
echo "need_build=true" >> "$GITHUB_OUTPUT"
echo "Base tag ${IMAGE}:${{ steps.compute.outputs.base_tag }} missing — will build."
fi
# ── Phase 1b: resolve floating npm versions (pi, omos) to concrete
# versions so the variant build-args carry a different value when an
# upstream package bumps. Without this, when PI_VERSION / OMOS_VERSION
# default to 'latest', the docker/build-push-action build-arg string
# is byte-identical across builds, so the resulting layer-hash is
# identical, so the registry buildcache silently reuses the layer
# from whatever pi/omos version was current when the cache was first
# populated. Same class of bug as pi-devbox v0.74.0..v0.75.5 (fixed in
# v0.75.5b 2026-05-23). Currently masked here because OPENCODE_VERSION
# is hard-coded in Dockerfile.variant and bumps every release —
# invalidating the parent-chain cache key for the pi/omos layers — but
# that masking would fail the moment we cut a vN.N.Nb opencode-version-
# unchanged release that only bumps pi or omos. Fix is preventative.
resolve-versions:
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
outputs:
pi_version: ${{ steps.resolve.outputs.pi_version }}
omos_version: ${{ steps.resolve.outputs.omos_version }}
steps:
- name: Resolve pi + omos versions from npm registry
id: resolve
run: |
set -eu
# Query the npm registry directly via curl+jq rather than `npm view`.
# catthehacker/ubuntu:act-latest ships Node/npm under /opt/acttoolcache/
# and adds it to PATH only via /etc/environment — which act_runner never
# sources (it reads the Docker image's ENV instructions, not /etc/environment).
# curl and jq are both guaranteed present in every job in this workflow.
PI_VERSION=$(curl -sf "https://registry.npmjs.org/@earendil-works%2Fpi-coding-agent/latest" | jq -r '.version')
OMOS_VERSION=$(curl -sf "https://registry.npmjs.org/oh-my-opencode-slim/latest" | jq -r '.version')
echo "pi_version=${PI_VERSION}" >> "$GITHUB_OUTPUT"
echo "omos_version=${OMOS_VERSION}" >> "$GITHUB_OUTPUT"
echo "Resolved PI_VERSION=${PI_VERSION}, OMOS_VERSION=${OMOS_VERSION}"
# ── Phase 2: build & push base (multi-arch), only when needed ──────
build-base:
needs: [base-decide]
if: needs.base-decide.outputs.need_build == 'true'
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Force IPv4 for Docker Hub
run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- name: Reclaim runner disk
run: |
set -x
df -h / || true
rm -rf \
/opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
/usr/local/lib/android /usr/local/share/powershell \
/usr/local/share/chromium /usr/local/share/boost \
/usr/lib/jvm 2>/dev/null || true
apt-get clean || true
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* || true
docker system prune -af --volumes || true
docker builder prune -af || true
df -h / || true
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
with:
driver-opts: network=host
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push base (multi-arch)
uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.base
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
# Registry cache disabled: buildkit's cache-export (mode=max) hits a
# reproducible HTTP 400 from registry-1.docker.io on the resumable-
# upload PUT (state-token format mismatch on Hub CDN, suspected to
# have started ~2026-05-23). Image push itself works fine. We pay
# the full base build on every Dockerfile.base change, but the base
# tag itself is content-addressed (base-<hash>) so unchanged bases
# short-circuit at the probe step and never re-build anyway. Re-
# enable when upstream resolves; tracked in CHANGELOG v1.15.12.
# ── Phase 3: amd64 smoke per variant (gates the multi-arch publish) ─
# Each smoke job builds amd64-only against the base tag and runs
# scripts/smoke-test.sh. base-decide.outputs.base_tag is always set;
# build-base may have been skipped (cache hit) but the tag exists either way.
smoke-base:
needs: [base-decide, build-base]
if: |
always() &&
needs.base-decide.result == 'success' &&
(needs.build-base.result == 'success' || needs.build-base.result == 'skipped')
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- name: Force IPv4 for Docker Hub
run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- name: Reclaim runner disk
run: |
rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
/usr/local/lib/android /usr/local/share/powershell \
/usr/local/share/chromium /usr/local/share/boost \
/usr/lib/jvm 2>/dev/null || true
docker system prune -af --volumes || true
docker builder prune -af || true
- uses: docker/setup-buildx-action@v4
with: {driver-opts: network=host}
- uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build amd64 variant for smoke
uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.variant
platforms: linux/amd64
push: false
load: true
tags: opencode-devbox:smoke-base
build-args: |
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
INSTALL_OPENCODE=true
INSTALL_OMOS=false
INSTALL_PI=false
- name: Smoke test (amd64)
run: bash scripts/smoke-test.sh opencode-devbox:smoke-base --variant base
smoke-omos:
needs: [base-decide, build-base, resolve-versions]
if: |
always() &&
needs.base-decide.result == 'success' &&
needs.resolve-versions.result == 'success' &&
(needs.build-base.result == 'success' || needs.build-base.result == 'skipped')
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- run: |
rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
/usr/local/lib/android /usr/local/share/powershell \
/usr/local/share/chromium /usr/local/share/boost \
/usr/lib/jvm 2>/dev/null || true
docker system prune -af --volumes || true
docker builder prune -af || true
- uses: docker/setup-buildx-action@v4
with: {driver-opts: network=host}
- uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.variant
platforms: linux/amd64
push: false
load: true
tags: opencode-devbox:smoke-omos
build-args: |
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
INSTALL_OPENCODE=true
INSTALL_OMOS=true
INSTALL_PI=false
OMOS_VERSION=${{ needs.resolve-versions.outputs.omos_version }}
- env:
EXPECTED_OMOS_VERSION: ${{ needs.resolve-versions.outputs.omos_version }}
run: bash scripts/smoke-test.sh opencode-devbox:smoke-omos --variant omos
smoke-with-pi:
needs: [base-decide, build-base, resolve-versions]
if: |
always() &&
needs.base-decide.result == 'success' &&
needs.resolve-versions.result == 'success' &&
(needs.build-base.result == 'success' || needs.build-base.result == 'skipped')
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- run: |
rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
/usr/local/lib/android /usr/local/share/powershell \
/usr/local/share/chromium /usr/local/share/boost \
/usr/lib/jvm 2>/dev/null || true
docker system prune -af --volumes || true
docker builder prune -af || true
- uses: docker/setup-buildx-action@v4
with: {driver-opts: network=host}
- uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.variant
platforms: linux/amd64
push: false
load: true
tags: opencode-devbox:smoke-with-pi
build-args: |
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
INSTALL_OPENCODE=true
INSTALL_OMOS=false
INSTALL_PI=true
PI_VERSION=${{ needs.resolve-versions.outputs.pi_version }}
- env:
EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
run: bash scripts/smoke-test.sh opencode-devbox:smoke-with-pi --variant with-pi
smoke-omos-with-pi:
needs: [base-decide, build-base, resolve-versions]
if: |
always() &&
needs.base-decide.result == 'success' &&
needs.resolve-versions.result == 'success' &&
(needs.build-base.result == 'success' || needs.build-base.result == 'skipped')
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- run: |
rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
/usr/local/lib/android /usr/local/share/powershell \
/usr/local/share/chromium /usr/local/share/boost \
/usr/lib/jvm 2>/dev/null || true
docker system prune -af --volumes || true
docker builder prune -af || true
- uses: docker/setup-buildx-action@v4
with: {driver-opts: network=host}
- uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.variant
platforms: linux/amd64
push: false
load: true
tags: opencode-devbox:smoke-omos-with-pi
build-args: |
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
INSTALL_OPENCODE=true
INSTALL_OMOS=true
INSTALL_PI=true
PI_VERSION=${{ needs.resolve-versions.outputs.pi_version }}
OMOS_VERSION=${{ needs.resolve-versions.outputs.omos_version }}
- env:
EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
EXPECTED_OMOS_VERSION: ${{ needs.resolve-versions.outputs.omos_version }}
run: bash scripts/smoke-test.sh opencode-devbox:smoke-omos-with-pi --variant omos-with-pi
# ── Phase 4: multi-arch publish per variant ────────────────────────
build-variant-base:
needs: [base-decide, smoke-base]
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- run: |
rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
/usr/local/lib/android /usr/local/share/powershell \
/usr/local/share/chromium /usr/local/share/boost \
/usr/lib/jvm 2>/dev/null || true
docker system prune -af --volumes || true
docker builder prune -af || true
- uses: docker/setup-qemu-action@v3
with: {platforms: arm64}
- uses: docker/setup-buildx-action@v4
with: {driver-opts: network=host}
- uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Compute version-specific tags
id: tags
run: |
VERSION="${{ env.RELEASE_TAG }}"
{ echo "tags<<EOF"
echo "${IMAGE}:${VERSION}"
if [ "${{ env.PROMOTE_LATEST }}" = "true" ]; then
echo "${IMAGE}:latest"
fi
echo "EOF"
} >> "$GITHUB_OUTPUT"
- uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.variant
platforms: linux/amd64,linux/arm64
push: true
build-args: |
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
INSTALL_OPENCODE=true
INSTALL_OMOS=false
INSTALL_PI=false
tags: ${{ steps.tags.outputs.tags }}
build-variant-omos:
needs: [base-decide, smoke-omos, resolve-versions]
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- run: |
rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
/usr/local/lib/android /usr/local/share/powershell \
/usr/local/share/chromium /usr/local/share/boost \
/usr/lib/jvm 2>/dev/null || true
docker system prune -af --volumes || true
docker builder prune -af || true
- uses: docker/setup-qemu-action@v3
with: {platforms: arm64}
- uses: docker/setup-buildx-action@v4
with: {driver-opts: network=host}
- uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Compute version-specific tags
id: tags
run: |
VERSION="${{ env.RELEASE_TAG }}"
{ echo "tags<<EOF"
echo "${IMAGE}:${VERSION}-omos"
if [ "${{ env.PROMOTE_LATEST }}" = "true" ]; then
echo "${IMAGE}:latest-omos"
fi
echo "EOF"
} >> "$GITHUB_OUTPUT"
- uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.variant
platforms: linux/amd64,linux/arm64
push: true
build-args: |
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
INSTALL_OPENCODE=true
INSTALL_OMOS=true
INSTALL_PI=false
OMOS_VERSION=${{ needs.resolve-versions.outputs.omos_version }}
tags: ${{ steps.tags.outputs.tags }}
build-variant-with-pi:
needs: [base-decide, smoke-with-pi, resolve-versions]
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- run: |
rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
/usr/local/lib/android /usr/local/share/powershell \
/usr/local/share/chromium /usr/local/share/boost \
/usr/lib/jvm 2>/dev/null || true
docker system prune -af --volumes || true
docker builder prune -af || true
- uses: docker/setup-qemu-action@v3
with: {platforms: arm64}
- uses: docker/setup-buildx-action@v4
with: {driver-opts: network=host}
- uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Compute version-specific tags
id: tags
run: |
VERSION="${{ env.RELEASE_TAG }}"
{ echo "tags<<EOF"
echo "${IMAGE}:${VERSION}-with-pi"
if [ "${{ env.PROMOTE_LATEST }}" = "true" ]; then
echo "${IMAGE}:latest-with-pi"
fi
echo "EOF"
} >> "$GITHUB_OUTPUT"
- uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.variant
platforms: linux/amd64,linux/arm64
push: true
build-args: |
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
INSTALL_OPENCODE=true
INSTALL_OMOS=false
INSTALL_PI=true
PI_VERSION=${{ needs.resolve-versions.outputs.pi_version }}
tags: ${{ steps.tags.outputs.tags }}
build-variant-omos-with-pi:
needs: [base-decide, smoke-omos-with-pi, resolve-versions]
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- run: |
rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
/usr/local/lib/android /usr/local/share/powershell \
/usr/local/share/chromium /usr/local/share/boost \
/usr/lib/jvm 2>/dev/null || true
docker system prune -af --volumes || true
docker builder prune -af || true
- uses: docker/setup-qemu-action@v3
with: {platforms: arm64}
- uses: docker/setup-buildx-action@v4
with: {driver-opts: network=host}
- uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Compute version-specific tags
id: tags
run: |
VERSION="${{ env.RELEASE_TAG }}"
{ echo "tags<<EOF"
echo "${IMAGE}:${VERSION}-omos-with-pi"
if [ "${{ env.PROMOTE_LATEST }}" = "true" ]; then
echo "${IMAGE}:latest-omos-with-pi"
fi
echo "EOF"
} >> "$GITHUB_OUTPUT"
- uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.variant
platforms: linux/amd64,linux/arm64
push: true
build-args: |
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
INSTALL_OPENCODE=true
INSTALL_OMOS=true
INSTALL_PI=true
PI_VERSION=${{ needs.resolve-versions.outputs.pi_version }}
OMOS_VERSION=${{ needs.resolve-versions.outputs.omos_version }}
tags: ${{ steps.tags.outputs.tags }}
# ── Phase 5: promote base-<hash> → base-latest (manifest copy only) ─
promote-base-latest:
needs:
- base-decide
- build-variant-base
- build-variant-omos
- build-variant-with-pi
- build-variant-omos-with-pi
# Skip on cache-hit base builds: when need_build=false, base-latest
# already points at the same digest as base-<hash>, so the retag is
# a tautology and any transient failure of it is purely cosmetic.
# Manual workflow_dispatch with promote_latest=true overrides this
# gate as an escape hatch (e.g., if base-latest got hand-deleted).
#
# `always()` wrapper + explicit base-variant success check protects
# against the gitea-Actions default of "skipped need => skip dependent":
# a partial-publish run (e.g., omos-with-pi smoke fails) shouldn't
# prevent the base-latest alias from advancing on a real base rebuild.
if: |
always() &&
needs.build-variant-base.result == 'success' &&
(inputs.promote_latest == 'true' ||
(github.ref_type == 'tag' && needs.base-decide.outputs.need_build == 'true'))
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
# Direct pinned install instead of imjasonh/setup-crane@v0.4. The
# action's bootstrap script calls api.github.com/.../releases/latest
# to discover the crane version, which periodically rate-limits and
# produces tag=null → download from .../download/null/... → 404 →
# 'gzip: unexpected end of file' → exit 2. Pinning removes the
# runtime dependency on GitHub API entirely. Bump CRANE_VERSION
# deliberately when you want updates.
- name: Install crane (pinned)
env:
CRANE_VERSION: v0.21.6
run: |
set -eux
curl -fsSL "https://github.com/google/go-containerregistry/releases/download/${CRANE_VERSION}/go-containerregistry_Linux_x86_64.tar.gz" \
| tar -xz -C /usr/local/bin crane
crane version
- name: Login (crane)
run: |
crane auth login docker.io \
-u ${{ vars.DOCKERHUB_USERNAME }} \
-p "${{ secrets.DOCKERHUB_TOKEN }}"
- name: Re-tag base-<hash> as base-latest
run: |
crane copy \
${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }} \
${{ env.IMAGE }}:base-latest
# ── Phase 6: update Hub description (only on real release runs) ────
update-description:
needs:
- build-variant-base
- build-variant-omos
- build-variant-with-pi
- build-variant-omos-with-pi
# Run when at least the base variant published — don't let a single
# variant failure (e.g., omos-with-pi smoke threshold) prevent Hub
# description refresh for the other variants that did publish.
# Without this `always()` wrapper, gitea Actions' default behavior
# of "skipped need => skip dependent" cascades from any failed/
# skipped build-variant-* into update-description, and the Hub
# description goes stale on partial-publish releases.
if: |
always() &&
needs.build-variant-base.result == 'success' &&
(github.ref_type == 'tag' || inputs.promote_latest == 'true')
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- name: Update Docker Hub description
run: |
TOKEN=$(curl -s -X POST https://hub.docker.com/v2/auth/token \
-H "Content-Type: application/json" \
-d '{"identifier":"${{ vars.DOCKERHUB_USERNAME }}","secret":"${{ secrets.DOCKERHUB_TOKEN }}"}' \
| jq -r .access_token)
if [ "$TOKEN" = "null" ] || [ -z "$TOKEN" ]; then
echo "::error::Failed to authenticate with Docker Hub API"
exit 1
fi
HTTP_CODE=$(jq -n \
--rawfile full DOCKER_HUB.md \
--arg short "Portable AI dev environment for opencode. Debian-based with git, Node.js, AWS CLI, and SSH support." \
'{"full_description": $full, "description": $short}' | \
curl -s -o /tmp/hub-response.txt -w "%{http_code}" -X PATCH \
"https://hub.docker.com/v2/repositories/${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox/" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d @-)
if [ "$HTTP_CODE" != "200" ]; then
echo "Response body:"
cat /tmp/hub-response.txt
echo "::error::Docker Hub description update failed with HTTP $HTTP_CODE"
exit 1
fi