f7c34091b1
Mirrors the pi-devbox v0.75.5b fix (2026-05-23) onto the four-variant pipeline here. The with-pi, omos, and omos-with-pi variants install upstream npm packages whose *_VERSION build-args defaulted to 'latest'. When the build-arg string is byte-identical across builds, the layer hash is identical and the registry buildcache silently reuses the layer from whatever upstream version was current when the cache was first populated — same mechanism that shipped pi-devbox v0.74.0..v0.75.5 with identical image bytes. Currently masked here because OPENCODE_VERSION is a hard-coded ARG that bumps every release; parent-chain cache invalidation flushes the downstream pi/omos layers. Masking would fail on any vN.N.Nb opencode- version-unchanged release that only bumps pi or omos. Filed last night as parked followup; fixing preventatively now that #5 (AWS SSO inside tor-ms22 container) cleared. CHANGES .gitea/workflows/docker-publish-split.yml — new resolve-versions job running 'npm view @earendil-works/pi-coding-agent version' and 'npm view oh-my-opencode-slim version', exposing concrete strings as job outputs. All six affected jobs (smoke-omos, smoke-with-pi, smoke-omos-with-pi, build-variant-omos, build-variant-with-pi, build-variant-omos-with-pi) now consume them as PI_VERSION / OMOS_VERSION build-args. smoke-base / build-variant-base unaffected. scripts/smoke-test.sh — new run_expect helper asserting an expected substring in command output. The pi check uses EXPECTED_PI_VERSION; the omos check uses EXPECTED_OMOS_VERSION against npm ls -g. Both env vars are wired from resolve-versions outputs in the smoke jobs. Catches this regression class on the next release, not four releases later. Dockerfile.variant — comment blocks above OPENCODE_VERSION (source- pinned, not subject to the bug), PI_VERSION (CI-resolved), and OMOS_VERSION (CI-resolved) explaining the cache-hit footgun. AGENTS.md — new convention bullet under 'Critical conventions' naming the resolve-versions job + EXPECTED_*_VERSION wiring as the contract to keep in lockstep when modifying variant build-args. .gitea/README.md — Step 1 expanded to cover the parallel resolve- versions job alongside base-decide; pipeline diagram updated. CHANGELOG.md — Unreleased entry describing the fix, masking mechanism, and audit footprint. No image-content change expected on the next release vs what 'latest' would have resolved to anyway. Purely makes the cache invalidate correctly going forward.
680 lines
28 KiB
YAML
680 lines
28 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
|
||
id: resolve
|
||
run: |
|
||
set -eu
|
||
PI_VERSION=$(npm view @earendil-works/pi-coding-agent version)
|
||
OMOS_VERSION=$(npm view oh-my-opencode-slim 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 for faster repeat base rebuilds (e.g. Node bump).
|
||
cache-from: type=registry,ref=${{ env.IMAGE }}:base-buildcache
|
||
cache-to: type=registry,ref=${{ env.IMAGE }}:base-buildcache,mode=max
|
||
|
||
# ── 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
|