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-. # 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- → 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.0.0 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.0.0 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.0.0 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.0.0 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.0.0 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.0.0 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<> "$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.0.0 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<> "$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.0.0 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<> "$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.0.0 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<> "$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- → 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-, 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- 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