name: Publish Docker Image # Two-phase split-base build pipeline for pi-devbox. # Adapted from opencode-devbox/.gitea/workflows/docker-publish-split.yml # (commit before v1.16.2). pi-devbox v1.0.0 introduces a self-contained # build chain — base + variant Dockerfiles in this repo — so this # workflow no longer depends on opencode-devbox CI. # # Pipeline shape: # 1. base-decide compute base hash from Dockerfile.base + rootfs/ # + entrypoints; probe Docker Hub for existing tag. # 2. resolve-versions resolve pi @ npm 'latest', pi-fork/pi-obsmem refs # to commit SHAs (defeats registry-buildcache # cache-hit footgun on byte-identical build args). # 3. build-base only if probe missed; multi-arch push of base-. # 4. smoke amd64-only build of the variant FROMing the base # tag; runs scripts/smoke-test.sh. # 5. build-variant multi-arch push of latest + vX.Y.Z tags. # 6. promote-base-latest re-tag base- → base-latest with `crane copy`. # 7. update-description patch Docker Hub description. on: push: tags: - 'v*' workflow_dispatch: inputs: release_tag: description: 'Release tag to publish (e.g. v1.0.0). Used only for workflow_dispatch runs.' required: false default: '' promote_latest: description: 'Update latest aliases (default true for tag-push, 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 }}/pi-devbox RELEASE_TAG: ${{ github.ref_type == 'tag' && github.ref_name || inputs.release_tag }} PROMOTE_LATEST: ${{ github.ref_type == 'tag' && 'true' || inputs.promote_latest }} 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 are gitignored # locally but still picked up by `find rootfs -type f` on a clean CI # checkout. Exclude them defensively. 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 versions to concrete refs ──────────── # Without this, when PI_VERSION defaults to 'latest', the build-arg string # is byte-identical across builds → identical layer hash → registry # buildcache silently reuses the layer from whatever pi 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). resolve-versions: runs-on: ubuntu-latest container: image: catthehacker/ubuntu:act-latest outputs: pi_version: ${{ steps.resolve.outputs.pi_version }} fork_ref: ${{ steps.resolve.outputs.fork_ref }} obsmem_ref: ${{ steps.resolve.outputs.obsmem_ref }} toolkit_ref: ${{ steps.resolve.outputs.toolkit_ref }} extensions_ref: ${{ steps.resolve.outputs.extensions_ref }} studio_ref: ${{ steps.resolve.outputs.studio_ref }} steps: - name: Resolve pi version + companion refs id: resolve run: | set -eu # Query npm registry directly; catthehacker/ubuntu:act-latest's npm # is not reliably on PATH in act_runner job containers. PI_VERSION=$(curl -sf "https://registry.npmjs.org/@earendil-works%2Fpi-coding-agent/latest" | jq -r '.version') echo "pi_version=${PI_VERSION}" >> "$GITHUB_OUTPUT" # Resolve pi-fork / pi-observational-memory git refs to commit # SHAs so the build-arg string changes whenever upstream moves. FORK_REF=$(curl -sf -H "Accept: application/vnd.github.sha" \ "https://api.github.com/repos/elpapi42/pi-fork/commits/master" || echo "master") OBSMEM_REF=$(curl -sf -H "Accept: application/vnd.github.sha" \ "https://api.github.com/repos/elpapi42/pi-observational-memory/commits/master" || echo "master") [ -n "$FORK_REF" ] || FORK_REF=master [ -n "$OBSMEM_REF" ] || OBSMEM_REF=master echo "fork_ref=${FORK_REF}" >> "$GITHUB_OUTPUT" echo "obsmem_ref=${OBSMEM_REF}" >> "$GITHUB_OUTPUT" # Also resolve pi-toolkit / pi-extensions main HEADs to SHAs so a # workflow_dispatch re-run produces byte-identical images when # those repos haven't moved (and a clean diff in build-arg strings # when they have, defeating the registry buildcache footgun). # Gitea API requires auth even for public-repo commit listing. TOOLKIT_REF=$(curl -sf -H "Authorization: token ${GITEA_BUILD_TOKEN:-${GITHUB_TOKEN:-}}" \ "https://gitea.jordbo.se/api/v1/repos/joakimp/pi-toolkit/commits?limit=1&sha=main" \ | jq -r '.[0].sha // "main"' 2>/dev/null || echo "main") EXTENSIONS_REF=$(curl -sf -H "Authorization: token ${GITEA_BUILD_TOKEN:-${GITHUB_TOKEN:-}}" \ "https://gitea.jordbo.se/api/v1/repos/joakimp/pi-extensions/commits?limit=1&sha=main" \ | jq -r '.[0].sha // "main"' 2>/dev/null || echo "main") [ -n "$TOOLKIT_REF" ] || TOOLKIT_REF=main [ -n "$EXTENSIONS_REF" ] || EXTENSIONS_REF=main echo "toolkit_ref=${TOOLKIT_REF}" >> "$GITHUB_OUTPUT" echo "extensions_ref=${EXTENSIONS_REF}" >> "$GITHUB_OUTPUT" # Resolve pi-studio (omaclaren/pi-studio) main HEAD to a SHA for # the :latest-studio variant — same cache-busting rationale. STUDIO_REF=$(curl -sf -H "Accept: application/vnd.github.sha" \ "https://api.github.com/repos/omaclaren/pi-studio/commits/main" || echo "main") [ -n "$STUDIO_REF" ] || STUDIO_REF=main echo "studio_ref=${STUDIO_REF}" >> "$GITHUB_OUTPUT" echo "Resolved PI_VERSION=${PI_VERSION}" echo "Resolved PI_FORK_REF=${FORK_REF}, PI_OBSMEM_REF=${OBSMEM_REF}" echo "Resolved PI_TOOLKIT_REF=${TOOLKIT_REF}, PI_EXTENSIONS_REF=${EXTENSIONS_REF}" echo "Resolved PI_STUDIO_REF=${STUDIO_REF}" # ── 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. # Registry cache disabled: buildkit cache-export hits HTTP 400 from # Hub CDN since ~2026-05-23. Image push itself works; we pay full # base build on Dockerfile.base change, but the base tag is content- # addressed so unchanged bases short-circuit at the probe step. 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 (gates the multi-arch publish) ───────────── smoke: 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 - 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: pi-devbox:smoke build-args: | BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }} PI_VERSION=${{ needs.resolve-versions.outputs.pi_version }} PI_FORK_REF=${{ needs.resolve-versions.outputs.fork_ref }} PI_OBSMEM_REF=${{ needs.resolve-versions.outputs.obsmem_ref }} PI_TOOLKIT_REF=${{ needs.resolve-versions.outputs.toolkit_ref }} PI_EXTENSIONS_REF=${{ needs.resolve-versions.outputs.extensions_ref }} - name: Smoke test (amd64) env: EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }} run: bash scripts/smoke-test.sh pi-devbox:smoke # ── Phase 3b: amd64 smoke for the studio variant ──────────────────── # Additive + independent of the core `smoke` job: gates ONLY # build-variant-studio, never the core build-variant. A studio build or # smoke failure therefore cannot block the :latest / :vX.Y.Z release. smoke-studio: 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 - 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 studio variant for smoke uses: docker/build-push-action@v7 with: context: . file: Dockerfile.variant platforms: linux/amd64 push: false load: true tags: pi-devbox:smoke-studio build-args: | BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }} PI_VERSION=${{ needs.resolve-versions.outputs.pi_version }} PI_FORK_REF=${{ needs.resolve-versions.outputs.fork_ref }} PI_OBSMEM_REF=${{ needs.resolve-versions.outputs.obsmem_ref }} PI_TOOLKIT_REF=${{ needs.resolve-versions.outputs.toolkit_ref }} PI_EXTENSIONS_REF=${{ needs.resolve-versions.outputs.extensions_ref }} INSTALL_STUDIO=true PI_STUDIO_REF=${{ needs.resolve-versions.outputs.studio_ref }} - name: Smoke test studio (amd64) env: EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }} run: bash scripts/smoke-test.sh pi-devbox:smoke-studio # ── Phase 4: multi-arch publish ───────────────────────────────────── build-variant: needs: [base-decide, smoke, 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<> "$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 }} FORK_REF: ${{ needs.resolve-versions.outputs.fork_ref }} OBSMEM_REF: ${{ needs.resolve-versions.outputs.obsmem_ref }} TOOLKIT_REF: ${{ needs.resolve-versions.outputs.toolkit_ref }} EXTENSIONS_REF: ${{ needs.resolve-versions.outputs.extensions_ref }} 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). 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 "PI_VERSION=${PI_VERSION}" \ --build-arg "PI_FORK_REF=${FORK_REF}" \ --build-arg "PI_OBSMEM_REF=${OBSMEM_REF}" \ --build-arg "PI_TOOLKIT_REF=${TOOLKIT_REF}" \ --build-arg "PI_EXTENSIONS_REF=${EXTENSIONS_REF}" \ "${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 4b: multi-arch publish of the studio variant ─────────────── # Additive: publishes :vX.Y.Z-studio (+ :latest-studio on release). Gated # on its own smoke-studio, NOT on the core build-variant, so it can ship # or fail independently of the core release. build-variant-studio: needs: [base-decide, smoke-studio, 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 studio version-specific tags id: tags run: | VERSION="${{ env.RELEASE_TAG }}" { echo "tags<> "$GITHUB_OUTPUT" - name: Build and push studio 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 }} FORK_REF: ${{ needs.resolve-versions.outputs.fork_ref }} OBSMEM_REF: ${{ needs.resolve-versions.outputs.obsmem_ref }} TOOLKIT_REF: ${{ needs.resolve-versions.outputs.toolkit_ref }} EXTENSIONS_REF: ${{ needs.resolve-versions.outputs.extensions_ref }} STUDIO_REF: ${{ needs.resolve-versions.outputs.studio_ref }} 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). 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 "PI_VERSION=${PI_VERSION}" \ --build-arg "PI_FORK_REF=${FORK_REF}" \ --build-arg "PI_OBSMEM_REF=${OBSMEM_REF}" \ --build-arg "PI_TOOLKIT_REF=${TOOLKIT_REF}" \ --build-arg "PI_EXTENSIONS_REF=${EXTENSIONS_REF}" \ --build-arg "INSTALL_STUDIO=true" \ --build-arg "PI_STUDIO_REF=${STUDIO_REF}" \ "${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- → base-latest (manifest copy only) ─ promote-base-latest: needs: - base-decide - build-variant # 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). if: | always() && needs.build-variant.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 periodically rate-limits on # api.github.com/.../releases/latest. Pinning removes the runtime # dependency on GitHub API entirely. - 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, resolve-versions] if: | always() && needs.build-variant.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 env: PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }} run: | # Substitute {{PI_VERSION}} placeholders in DOCKER_HUB.md so the # Hub page always shows which pi version is in :latest. The # placeholder lives in DOCKER_HUB.md (committed); CI fills it # at publish time using the same resolved version that was # baked into the variant image. No drift between page and image. if [ -z "${PI_VERSION}" ]; then echo "::error::PI_VERSION env var is empty. Likely cause: the" echo "::error::update-description job is missing 'resolve-versions'" echo "::error::in its needs: list, so needs.resolve-versions.outputs.pi_version" echo "::error::resolves to an empty string instead of the actual version." exit 1 fi cp DOCKER_HUB.md /tmp/hub-full.md sed -i "s/{{PI_VERSION}}/${PI_VERSION}/g" /tmp/hub-full.md if grep -q '{{PI_VERSION}}' /tmp/hub-full.md; then echo "::error::DOCKER_HUB.md still contains unsubstituted {{PI_VERSION}} markers" exit 1 fi 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 /tmp/hub-full.md \ --arg short "Linux container with the pi coding-agent, MemPalace, and curated dev tooling." \ '{"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 }}/pi-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 echo "Description updated (pi version: ${PI_VERSION})."