1fe5b5df91
Validate / docs-check (push) Successful in 7s
Validate / base-change-warning (push) Successful in 6s
Validate / validate-with-pi (push) Successful in 4m11s
Validate / validate-omos (push) Successful in 4m31s
Validate / validate-base (push) Successful in 5m19s
Validate / validate-omos-with-pi (push) Successful in 11m38s
Belt-and-braces against transient registry-1.docker.io blips (rate limits, brief 5xx, CDN flap). Replaces all five push docker/build-push- action@v7 invocations (1 base + 4 variants) with shell: bash steps that run docker buildx build --push in a for-loop with backoff (15s, 30s). Smoke build steps (load: true, no push) are untouched. Does NOT mask deterministic failures: a true regression (e.g. the cache-export 400 we hit 2026-05-23..28) fails all 3 attempts identically and the job still fails by design. Orthogonal layer to both cache-export disablement and the ci-release-watcher skill's transient-rerun heuristic. - AGENTS.md: new Critical conventions bullet documenting the retry pattern, the consistency rule across push steps, and why it's duplicated rather than factored (Gitea Actions doesn't support reusable composite shell steps cleanly). - CHANGELOG.md: Unreleased section addendum, no image-side change. No image-side change.
801 lines
33 KiB
YAML
801 lines
33 KiB
YAML
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) — with retry
|
||
shell: bash
|
||
env:
|
||
BASE_TAG_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||
run: |
|
||
set -euo pipefail
|
||
# 3-attempt retry around `docker buildx build --push` for transient
|
||
# registry-1.docker.io blips. Does NOT mask deterministic failures:
|
||
# a true regression (e.g. cache-export 400 hit 2026-05-23..28) will
|
||
# fail all 3 attempts identically and the job still fails — by
|
||
# design.
|
||
# 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.
|
||
for attempt in 1 2 3; do
|
||
echo "==> Build+push attempt ${attempt}/3"
|
||
if docker buildx build \
|
||
--platform linux/amd64,linux/arm64 \
|
||
--file Dockerfile.base \
|
||
--push \
|
||
--tag "${BASE_TAG_FULL}" \
|
||
.; then
|
||
echo "==> Attempt ${attempt} succeeded"
|
||
exit 0
|
||
fi
|
||
if [[ "${attempt}" -lt 3 ]]; then
|
||
backoff=$(( attempt * 15 ))
|
||
echo "==> Attempt ${attempt} failed, sleeping ${backoff}s before retry"
|
||
sleep "${backoff}"
|
||
fi
|
||
done
|
||
echo "==> All 3 build+push attempts failed"
|
||
exit 1
|
||
|
||
# ── 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"
|
||
- name: Build and push variant (with retry)
|
||
shell: bash
|
||
env:
|
||
TAGS: ${{ steps.tags.outputs.tags }}
|
||
BASE_IMAGE_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||
run: |
|
||
set -euo pipefail
|
||
TAG_FLAGS=()
|
||
while IFS= read -r t; do [[ -n "$t" ]] && TAG_FLAGS+=( -t "$t" ); done <<< "${TAGS}"
|
||
# 3-attempt retry around `docker buildx build --push` (see build-base
|
||
# step for full rationale). Variant: base (opencode only).
|
||
for attempt in 1 2 3; do
|
||
echo "==> Build+push attempt ${attempt}/3"
|
||
if docker buildx build \
|
||
--platform linux/amd64,linux/arm64 \
|
||
--file Dockerfile.variant \
|
||
--push \
|
||
--build-arg "BASE_IMAGE=${BASE_IMAGE_FULL}" \
|
||
--build-arg "INSTALL_OPENCODE=true" \
|
||
--build-arg "INSTALL_OMOS=false" \
|
||
--build-arg "INSTALL_PI=false" \
|
||
"${TAG_FLAGS[@]}" \
|
||
.; then
|
||
echo "==> Attempt ${attempt} succeeded"
|
||
exit 0
|
||
fi
|
||
if [[ "${attempt}" -lt 3 ]]; then
|
||
backoff=$(( attempt * 15 ))
|
||
echo "==> Attempt ${attempt} failed, sleeping ${backoff}s before retry"
|
||
sleep "${backoff}"
|
||
fi
|
||
done
|
||
echo "==> All 3 build+push attempts failed"
|
||
exit 1
|
||
|
||
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"
|
||
- name: Build and push variant (with retry)
|
||
shell: bash
|
||
env:
|
||
TAGS: ${{ steps.tags.outputs.tags }}
|
||
BASE_IMAGE_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||
OMOS_VERSION: ${{ needs.resolve-versions.outputs.omos_version }}
|
||
run: |
|
||
set -euo pipefail
|
||
TAG_FLAGS=()
|
||
while IFS= read -r t; do [[ -n "$t" ]] && TAG_FLAGS+=( -t "$t" ); done <<< "${TAGS}"
|
||
# 3-attempt retry (see build-base step for rationale). Variant: omos.
|
||
for attempt in 1 2 3; do
|
||
echo "==> Build+push attempt ${attempt}/3"
|
||
if docker buildx build \
|
||
--platform linux/amd64,linux/arm64 \
|
||
--file Dockerfile.variant \
|
||
--push \
|
||
--build-arg "BASE_IMAGE=${BASE_IMAGE_FULL}" \
|
||
--build-arg "INSTALL_OPENCODE=true" \
|
||
--build-arg "INSTALL_OMOS=true" \
|
||
--build-arg "INSTALL_PI=false" \
|
||
--build-arg "OMOS_VERSION=${OMOS_VERSION}" \
|
||
"${TAG_FLAGS[@]}" \
|
||
.; then
|
||
echo "==> Attempt ${attempt} succeeded"
|
||
exit 0
|
||
fi
|
||
if [[ "${attempt}" -lt 3 ]]; then
|
||
backoff=$(( attempt * 15 ))
|
||
echo "==> Attempt ${attempt} failed, sleeping ${backoff}s before retry"
|
||
sleep "${backoff}"
|
||
fi
|
||
done
|
||
echo "==> All 3 build+push attempts failed"
|
||
exit 1
|
||
|
||
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"
|
||
- name: Build and push variant (with retry)
|
||
shell: bash
|
||
env:
|
||
TAGS: ${{ steps.tags.outputs.tags }}
|
||
BASE_IMAGE_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||
PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
|
||
run: |
|
||
set -euo pipefail
|
||
TAG_FLAGS=()
|
||
while IFS= read -r t; do [[ -n "$t" ]] && TAG_FLAGS+=( -t "$t" ); done <<< "${TAGS}"
|
||
# 3-attempt retry (see build-base step for rationale). Variant: with-pi.
|
||
for attempt in 1 2 3; do
|
||
echo "==> Build+push attempt ${attempt}/3"
|
||
if docker buildx build \
|
||
--platform linux/amd64,linux/arm64 \
|
||
--file Dockerfile.variant \
|
||
--push \
|
||
--build-arg "BASE_IMAGE=${BASE_IMAGE_FULL}" \
|
||
--build-arg "INSTALL_OPENCODE=true" \
|
||
--build-arg "INSTALL_OMOS=false" \
|
||
--build-arg "INSTALL_PI=true" \
|
||
--build-arg "PI_VERSION=${PI_VERSION}" \
|
||
"${TAG_FLAGS[@]}" \
|
||
.; then
|
||
echo "==> Attempt ${attempt} succeeded"
|
||
exit 0
|
||
fi
|
||
if [[ "${attempt}" -lt 3 ]]; then
|
||
backoff=$(( attempt * 15 ))
|
||
echo "==> Attempt ${attempt} failed, sleeping ${backoff}s before retry"
|
||
sleep "${backoff}"
|
||
fi
|
||
done
|
||
echo "==> All 3 build+push attempts failed"
|
||
exit 1
|
||
|
||
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"
|
||
- name: Build and push variant (with retry)
|
||
shell: bash
|
||
env:
|
||
TAGS: ${{ steps.tags.outputs.tags }}
|
||
BASE_IMAGE_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||
PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
|
||
OMOS_VERSION: ${{ needs.resolve-versions.outputs.omos_version }}
|
||
run: |
|
||
set -euo pipefail
|
||
TAG_FLAGS=()
|
||
while IFS= read -r t; do [[ -n "$t" ]] && TAG_FLAGS+=( -t "$t" ); done <<< "${TAGS}"
|
||
# 3-attempt retry (see build-base step for rationale). Variant: omos-with-pi.
|
||
for attempt in 1 2 3; do
|
||
echo "==> Build+push attempt ${attempt}/3"
|
||
if docker buildx build \
|
||
--platform linux/amd64,linux/arm64 \
|
||
--file Dockerfile.variant \
|
||
--push \
|
||
--build-arg "BASE_IMAGE=${BASE_IMAGE_FULL}" \
|
||
--build-arg "INSTALL_OPENCODE=true" \
|
||
--build-arg "INSTALL_OMOS=true" \
|
||
--build-arg "INSTALL_PI=true" \
|
||
--build-arg "PI_VERSION=${PI_VERSION}" \
|
||
--build-arg "OMOS_VERSION=${OMOS_VERSION}" \
|
||
"${TAG_FLAGS[@]}" \
|
||
.; then
|
||
echo "==> Attempt ${attempt} succeeded"
|
||
exit 0
|
||
fi
|
||
if [[ "${attempt}" -lt 3 ]]; then
|
||
backoff=$(( attempt * 15 ))
|
||
echo "==> Attempt ${attempt} failed, sleeping ${backoff}s before retry"
|
||
sleep "${backoff}"
|
||
fi
|
||
done
|
||
echo "==> All 3 build+push attempts failed"
|
||
exit 1
|
||
|
||
# ── 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
|