a2f0a4a441
resolve-versions claimed "Gitea API requires auth even for public-repo commit listing" above the pi-toolkit / pi-extensions curls. Measurably false for the repos it guards. Verified 2026-08-15, unauthenticated vs authenticated GET of /api/v1/repos/joakimp/<repo>/commits?limit=1&sha=main: pi-toolkit private=false unauth=200 auth=200 sha 0e1369e6b496 identical pi-extensions private=false unauth=200 auth=200 sha 98eb07bce60a identical mempalace-toolkit private=false unauth=200 auth=200 sha f60cf9c73205 identical Only /api/v1/repos/*/actions/* refuses anonymous reads with 401 — almost certainly what the claim was over-generalised from. (Same over-generalisation I nearly committed to opencode-devbox's AGENTS.md today; 69fc80a there narrowed it to the actions endpoints for the same reason.) Checked the three repos actually queried rather than reusing the pi-devbox result — if any had been private the comment would have been TRUE, and the correction wrong. Behaviour deliberately unchanged: the header still gets passed. It survives a repo being flipped private, and an unset secret degrades cleanly because Gitea ignores an empty `token ` value and serves anonymously: no header 200 empty token (secret unset) 200 garbage token 401 That last row is the fragility now documented: a REVOKED or malformed token returns 401 where anonymous returns 200, so a stale GITEA_BUILD_TOKEN converts a healthy public read into a require_sha failure that presents as an API or network fault. Encountered exactly that today with an expired PAT on the actions endpoints, so the note tells the next reader to suspect the token first. Comment-only: no non-comment line changed, YAML re-parsed.
835 lines
42 KiB
YAML
835 lines
42 KiB
YAML
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-<hash>.
|
|
# 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-<hash> → base-latest with `crane copy`.
|
|
# 7. update-description patch Docker Hub description.
|
|
#
|
|
# Note the trigger: `push: tags: v*` (plus workflow_dispatch). Nothing here runs
|
|
# on a push to main, so a smoke assertion added outside a release is UNVALIDATED
|
|
# until the next tag — which is exactly how v1.8.0 shipped a broken assertion
|
|
# written three days earlier (it asserted a literal /home/developer stage path,
|
|
# while `run` executes `docker run --entrypoint=""` as root with HOME=/root).
|
|
# The `smoke_only` dispatch input exists to close that gap: it runs steps 1-4
|
|
# against HEAD and stops before anything is published.
|
|
|
|
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'
|
|
smoke_only:
|
|
description: 'Build base + run both smoke jobs against HEAD, then stop. Publishes nothing. Use to validate smoke assertions without cutting a tag.'
|
|
required: false
|
|
default: 'false'
|
|
|
|
concurrency:
|
|
group: ${{ github.workflow }}-${{ github.ref }}
|
|
cancel-in-progress: false
|
|
|
|
# Gitea Actions' default step shell is `sh -e {0}` (dash), which rejects
|
|
# bash-only syntax like `set -o pipefail`, `[[ ]]`, and arrays. Setting the
|
|
# default to bash workflow-wide eliminates the whole class of "forgot
|
|
# `shell: bash` on this step" bugs (hit twice: ed49b8d resolve-versions,
|
|
# b7197e8/b33e9dc promote-base-latest). All existing dash steps use only
|
|
# POSIX syntax, so bash (a superset) runs them unchanged.
|
|
defaults:
|
|
run:
|
|
shell: bash
|
|
|
|
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:
|
|
needs: [resolve-versions]
|
|
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: Guard — base *_REF args must be folded into the base hash
|
|
run: bash scripts/check-base-hash.sh
|
|
|
|
- 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
|
|
# mempalace-toolkit is cloned in Dockerfile.base at a ref CI
|
|
# resolves to a SHA; fold it in so base_tag changes when the
|
|
# toolkit moves (otherwise a toolkit-only fix never lands).
|
|
echo "${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }}"
|
|
} | 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 }}
|
|
studio_tag: ${{ steps.resolve.outputs.studio_tag }}
|
|
atelier_ref: ${{ steps.resolve.outputs.atelier_ref }}
|
|
atelier_tag: ${{ steps.resolve.outputs.atelier_tag }}
|
|
mempalace_toolkit_ref: ${{ steps.resolve.outputs.mempalace_toolkit_ref }}
|
|
steps:
|
|
# Needed since v1.7.0: the pi version and the pi-atelier tag are now
|
|
# PINNED IN Dockerfile.variant and read from it here, so this job has to
|
|
# see the repo. Keeping the pins in the Dockerfile (rather than duplicated
|
|
# in this workflow) means a local `docker build` and CI ship the same
|
|
# versions by construction, and a bump is one reviewable line.
|
|
- uses: actions/checkout@v4
|
|
- name: Resolve pi version + companion refs
|
|
id: resolve
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
AUTH_HEADER="Authorization: token ${GITEA_BUILD_TOKEN:-${GITHUB_TOKEN:-}}"
|
|
|
|
# Fail loud rather than silently shipping a floating branch. A
|
|
# transient network/API failure must ABORT the release, not bake
|
|
# an unpinned ref that defeats both cache-busting AND after-the-
|
|
# fact reproducibility. (Previously each lookup fell back to
|
|
# `main`/`master` via `|| echo`.)
|
|
require_sha() { # $1=label $2=value
|
|
if ! printf '%s' "${2:-}" | grep -qiE '^[0-9a-f]{40}$'; then
|
|
echo "::error::Could not resolve $1 to a commit SHA (got '${2:-<empty>}'). Refusing to fall back to a floating ref — published images must stay reproducible. Check connectivity and GITEA_BUILD_TOKEN/GITHUB_TOKEN."
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
# ── pi version: from the PIN, not from npm `latest` ───────────
|
|
# Until v1.7.0 this followed npm `latest`, which meant every release
|
|
# silently adopted whatever pi had shipped that morning — unaudited —
|
|
# in the same build that then got tagged and published. A pi minor
|
|
# can move the TUI/renderer internals that pi-atelier wraps (0.84 vs
|
|
# atelier 0.6.0: startup hang, sustained CPU) or the session `.jsonl`
|
|
# format that pi-session-repair parses. The pin makes adoption an
|
|
# explicit, reviewable act; the drift warning below makes it a
|
|
# prompt rather than a surprise.
|
|
PI_VERSION=$(sed -n 's/^ARG PI_VERSION=\([^[:space:]]*\).*/\1/p' Dockerfile.variant | head -n1)
|
|
if ! printf '%s' "${PI_VERSION:-}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
|
echo "::error::ARG PI_VERSION in Dockerfile.variant is not a concrete version (got '${PI_VERSION:-<empty>}'). CI refuses to build from a floating pi version — see the pin policy comment above that ARG."
|
|
exit 1
|
|
fi
|
|
# The pin must actually exist on npm: catches a typo, an unpublished
|
|
# version, or one yanked after we audited it — at resolve time, with
|
|
# a clear message, instead of as an `npm install` failure mid-build.
|
|
PI_PUBLISHED=$(curl -sf "https://registry.npmjs.org/@earendil-works%2Fpi-coding-agent/${PI_VERSION}" | jq -r '.version // empty' 2>/dev/null || true)
|
|
if [ "${PI_PUBLISHED:-}" != "${PI_VERSION}" ]; then
|
|
echo "::error::Pinned pi version ${PI_VERSION} is not published on npm (registry returned '${PI_PUBLISHED:-<empty>}'). Fix ARG PI_VERSION in Dockerfile.variant."
|
|
exit 1
|
|
fi
|
|
# Informational only — a newer pi must never be adopted implicitly.
|
|
# `|| true`: a transient registry failure must not fail a release
|
|
# whose version is already pinned and verified above.
|
|
PI_NPM_LATEST=$(curl -sf "https://registry.npmjs.org/@earendil-works%2Fpi-coding-agent/latest" | jq -r '.version // empty' 2>/dev/null || true)
|
|
if [ -n "${PI_NPM_LATEST:-}" ] && [ "${PI_NPM_LATEST}" != "${PI_VERSION}" ]; then
|
|
echo "::warning::pi ${PI_NPM_LATEST} is published; this build ships the audited pin ${PI_VERSION}. To adopt it: read the upstream CHANGELOG for every version in between (TUI/theme API, session .jsonl format, extension loader, Node engine), re-check pi-atelier's floor, then bump ARG PI_VERSION in Dockerfile.variant and note the audit in CHANGELOG.md."
|
|
fi
|
|
echo "pi_version=${PI_VERSION}" >> "$GITHUB_OUTPUT"
|
|
|
|
# pi-fork / pi-observational-memory (GitHub) → commit SHAs.
|
|
FORK_REF=$(curl -sf -H "Accept: application/vnd.github.sha" \
|
|
"https://api.github.com/repos/elpapi42/pi-fork/commits/master" || true)
|
|
require_sha PI_FORK_REF "$FORK_REF"
|
|
OBSMEM_REF=$(curl -sf -H "Accept: application/vnd.github.sha" \
|
|
"https://api.github.com/repos/elpapi42/pi-observational-memory/commits/master" || true)
|
|
require_sha PI_OBSMEM_REF "$OBSMEM_REF"
|
|
echo "fork_ref=${FORK_REF}" >> "$GITHUB_OUTPUT"
|
|
echo "obsmem_ref=${OBSMEM_REF}" >> "$GITHUB_OUTPUT"
|
|
|
|
# pi-atelier → the PINNED TAG's commit SHA. Unlike fork/obsmem
|
|
# (which track a branch head) atelier wraps pi's private TUI
|
|
# renderer, so its version is pinned in Dockerfile.variant and read
|
|
# from there; we only resolve tag → SHA, for reproducibility and to
|
|
# defeat the cache-hit footgun. Never floats to a branch.
|
|
ATELIER_TAG=$(sed -n 's/^ARG PI_ATELIER_REF=\([^[:space:]]*\).*/\1/p' Dockerfile.variant | head -n1)
|
|
if ! printf '%s' "${ATELIER_TAG:-}" | grep -qE '^v?[0-9]+\.[0-9]+\.[0-9]+$'; then
|
|
echo "::error::ARG PI_ATELIER_REF in Dockerfile.variant is not a semver tag (got '${ATELIER_TAG:-<empty>}'). pi-atelier must stay pinned to a tag — see the floor note above that ARG."
|
|
exit 1
|
|
fi
|
|
ATELIER_LS=$(git ls-remote --tags "https://github.com/michaelmjhhhh/pi-atelier.git" || true)
|
|
# Peeled ^{} line first (annotated tags), then the direct ref.
|
|
ATELIER_REF=$(printf '%s\n' "$ATELIER_LS" | awk -v t="refs/tags/${ATELIER_TAG}^{}" '$2==t{print $1}')
|
|
if [ -z "$ATELIER_REF" ]; then
|
|
ATELIER_REF=$(printf '%s\n' "$ATELIER_LS" | awk -v t="refs/tags/${ATELIER_TAG}" '$2==t{print $1}')
|
|
fi
|
|
require_sha PI_ATELIER_REF "$ATELIER_REF"
|
|
echo "atelier_ref=${ATELIER_REF}" >> "$GITHUB_OUTPUT"
|
|
echo "atelier_tag=${ATELIER_TAG}" >> "$GITHUB_OUTPUT"
|
|
|
|
# pi-toolkit / pi-extensions (Gitea) → commit SHAs. All three Gitea
|
|
# repos read in this step are PUBLIC: an unauthenticated GET of these
|
|
# commit endpoints returns 200 with the IDENTICAL sha (verified
|
|
# 2026-08-15 for pi-toolkit, pi-extensions and mempalace-toolkit).
|
|
# The comment that used to sit here claimed the Gitea API "requires
|
|
# auth even for public-repo commit listing" — it does not. Only
|
|
# /api/v1/repos/*/actions/* refuses anonymous reads (401), which is
|
|
# what that claim was almost certainly generalised from.
|
|
#
|
|
# The header is still passed on purpose: it keeps working if a repo is
|
|
# ever flipped private, and an ABSENT secret degrades cleanly, because
|
|
# Gitea ignores an empty `token ` value and serves the request
|
|
# anonymously (200). The real hazard is the opposite one — a REVOKED or
|
|
# malformed token returns 401 where anonymous would have returned 200,
|
|
# so a stale GITEA_BUILD_TOKEN turns a healthy public read into a
|
|
# require_sha failure that reads like an API or network fault. If this
|
|
# step ever fails on a repo you can browse anonymously, suspect the
|
|
# token before you suspect Gitea.
|
|
TOOLKIT_REF=$(curl -sf -H "$AUTH_HEADER" \
|
|
"https://gitea.jordbo.se/api/v1/repos/joakimp/pi-toolkit/commits?limit=1&sha=main" \
|
|
| jq -r '.[0].sha // empty' 2>/dev/null || true)
|
|
require_sha PI_TOOLKIT_REF "$TOOLKIT_REF"
|
|
EXTENSIONS_REF=$(curl -sf -H "$AUTH_HEADER" \
|
|
"https://gitea.jordbo.se/api/v1/repos/joakimp/pi-extensions/commits?limit=1&sha=main" \
|
|
| jq -r '.[0].sha // empty' 2>/dev/null || true)
|
|
require_sha PI_EXTENSIONS_REF "$EXTENSIONS_REF"
|
|
echo "toolkit_ref=${TOOLKIT_REF}" >> "$GITHUB_OUTPUT"
|
|
echo "extensions_ref=${EXTENSIONS_REF}" >> "$GITHUB_OUTPUT"
|
|
|
|
# mempalace-toolkit (Gitea) → commit SHA. UNLIKE the others this
|
|
# is cloned in Dockerfile.base, so the SAME SHA is ALSO folded
|
|
# into the base-decide hash (see that job) to force a base rebuild
|
|
# when the toolkit moves — otherwise a toolkit-only fix silently
|
|
# fails to land unless Dockerfile.base itself changes.
|
|
MEMPALACE_TOOLKIT_REF=$(curl -sf -H "$AUTH_HEADER" \
|
|
"https://gitea.jordbo.se/api/v1/repos/joakimp/mempalace-toolkit/commits?limit=1&sha=main" \
|
|
| jq -r '.[0].sha // empty' 2>/dev/null || true)
|
|
require_sha MEMPALACE_TOOLKIT_REF "$MEMPALACE_TOOLKIT_REF"
|
|
echo "mempalace_toolkit_ref=${MEMPALACE_TOOLKIT_REF}" >> "$GITHUB_OUTPUT"
|
|
|
|
# pi-studio (omaclaren/pi-studio) → newest SEMVER TAG's commit SHA
|
|
# for the :*-studio images. Upstream stopped publishing GitHub
|
|
# *Releases* at v0.5.55 but keeps tagging every version (vX.Y.Z) and
|
|
# pushing to main, so pinning main HEAD risked baking half-finished
|
|
# commits that land after a tag. Take the newest stable tag instead.
|
|
# List ALL tags in one `git ls-remote` call — the REST tags API
|
|
# paginates at 100 and this repo already has >140 tags, so page 1 is
|
|
# NOT guaranteed to hold the newest — pick the highest X.Y.Z with
|
|
# `sort -V` (pre-releases like -rc1 excluded by the strict filter),
|
|
# then resolve its commit SHA (a SHA, not a moving tag, preserves
|
|
# cache-busting + reproducibility and is what require_sha demands).
|
|
STUDIO_TAGS=$(git ls-remote --tags "https://github.com/omaclaren/pi-studio.git" || true)
|
|
STUDIO_TAG=$(printf '%s\n' "$STUDIO_TAGS" | awk '{print $2}' \
|
|
| sed -n 's#^refs/tags/##p' \
|
|
| grep -E '^v?[0-9]+\.[0-9]+\.[0-9]+$' \
|
|
| sort -V | tail -n1 || true)
|
|
if [ -z "${STUDIO_TAG:-}" ]; then
|
|
echo "::error::Could not resolve a pi-studio semver tag (git ls-remote empty/unreachable). Refusing to fall back to a floating ref."
|
|
exit 1
|
|
fi
|
|
# Prefer the peeled ^{} line (annotated tags); fall back to the
|
|
# direct ref (lightweight tags, which pi-studio currently uses).
|
|
STUDIO_REF=$(printf '%s\n' "$STUDIO_TAGS" | awk -v t="refs/tags/${STUDIO_TAG}^{}" '$2==t{print $1}')
|
|
if [ -z "$STUDIO_REF" ]; then
|
|
STUDIO_REF=$(printf '%s\n' "$STUDIO_TAGS" | awk -v t="refs/tags/${STUDIO_TAG}" '$2==t{print $1}')
|
|
fi
|
|
require_sha PI_STUDIO_REF "$STUDIO_REF"
|
|
echo "studio_ref=${STUDIO_REF}" >> "$GITHUB_OUTPUT"
|
|
echo "studio_tag=${STUDIO_TAG}" >> "$GITHUB_OUTPUT"
|
|
|
|
echo "Resolved PI_VERSION=${PI_VERSION} (pinned in Dockerfile.variant; npm latest is ${PI_NPM_LATEST:-unknown})"
|
|
echo "Resolved PI_ATELIER_REF=${ATELIER_REF} (pi-atelier ${ATELIER_TAG}, pinned)"
|
|
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} (pi-studio ${STUDIO_TAG})"
|
|
echo "Resolved MEMPALACE_TOOLKIT_REF=${MEMPALACE_TOOLKIT_REF}"
|
|
|
|
# ── Phase 2: build & push base (multi-arch), only when needed ──────
|
|
build-base:
|
|
needs: [base-decide, resolve-versions]
|
|
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 }}
|
|
MEMPALACE_TOOLKIT_REF: ${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }}
|
|
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 \
|
|
--build-arg MEMPALACE_TOOLKIT_REF="${MEMPALACE_TOOLKIT_REF}" \
|
|
--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 }}
|
|
MEMPALACE_TOOLKIT_REF=${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }}
|
|
PI_ATELIER_REF=${{ needs.resolve-versions.outputs.atelier_ref }}
|
|
PI_ATELIER_VERSION=${{ needs.resolve-versions.outputs.atelier_tag }}
|
|
RELEASE_TAG=smoke
|
|
SOURCE_REVISION=${{ github.sha }}
|
|
- 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 }}
|
|
PI_STUDIO_VERSION=${{ needs.resolve-versions.outputs.studio_tag }}
|
|
MEMPALACE_TOOLKIT_REF=${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }}
|
|
PI_ATELIER_REF=${{ needs.resolve-versions.outputs.atelier_ref }}
|
|
PI_ATELIER_VERSION=${{ needs.resolve-versions.outputs.atelier_tag }}
|
|
RELEASE_TAG=smoke-studio
|
|
SOURCE_REVISION=${{ github.sha }}
|
|
- 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]
|
|
# A `smoke_only` dispatch stops the pipeline here: base is probed/built and
|
|
# both smoke jobs run, but nothing is published. Deliberately NOT wrapped in
|
|
# always() — specifying `if:` keeps the implicit "all needs succeeded" gate,
|
|
# so a failing smoke still blocks the release. On a tag push `inputs` is
|
|
# unset, and `null != 'true'` is true, so releases are unaffected.
|
|
# promote-base-latest and update-description need build-variant to have
|
|
# succeeded, so they skip on their own — no extra guard required.
|
|
if: inputs.smoke_only != 'true'
|
|
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 }}
|
|
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 }}
|
|
MEMPALACE_TOOLKIT_REF: ${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }}
|
|
ATELIER_REF: ${{ needs.resolve-versions.outputs.atelier_ref }}
|
|
ATELIER_TAG: ${{ needs.resolve-versions.outputs.atelier_tag }}
|
|
run: |
|
|
set -euo pipefail
|
|
TAG_FLAGS=()
|
|
while IFS= read -r t; do [[ -n "$t" ]] && TAG_FLAGS+=( -t "$t" ); done <<< "${TAGS}"
|
|
BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
# 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 "MEMPALACE_TOOLKIT_REF=${MEMPALACE_TOOLKIT_REF}" \
|
|
--build-arg "PI_ATELIER_REF=${ATELIER_REF}" \
|
|
--build-arg "PI_ATELIER_VERSION=${ATELIER_TAG}" \
|
|
--build-arg "IMAGE_TITLE=pi-devbox" \
|
|
--build-arg "IMAGE_DESCRIPTION=pi-devbox ${RELEASE_TAG} — core variant: pi coding agent CLI ${PI_VERSION}, pi-toolkit, extensions (fork + observational-memory + atelier ${ATELIER_TAG} TUI sidebar), MemPalace. No browser UI — see the -studio tags for that." \
|
|
--build-arg "RELEASE_TAG=${RELEASE_TAG}" \
|
|
--build-arg "BUILD_DATE=${BUILD_DATE}" \
|
|
--build-arg "SOURCE_REVISION=${GITHUB_SHA:-}" \
|
|
"${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]
|
|
if: inputs.smoke_only != 'true'
|
|
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<<EOF"
|
|
echo "${IMAGE}:${VERSION}-studio"
|
|
if [ "${{ env.PROMOTE_LATEST }}" = "true" ]; then
|
|
echo "${IMAGE}:latest-studio"
|
|
fi
|
|
echo "EOF"
|
|
} >> "$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 }}
|
|
STUDIO_TAG: ${{ needs.resolve-versions.outputs.studio_tag }}
|
|
MEMPALACE_TOOLKIT_REF: ${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }}
|
|
ATELIER_REF: ${{ needs.resolve-versions.outputs.atelier_ref }}
|
|
ATELIER_TAG: ${{ needs.resolve-versions.outputs.atelier_tag }}
|
|
run: |
|
|
set -euo pipefail
|
|
TAG_FLAGS=()
|
|
while IFS= read -r t; do [[ -n "$t" ]] && TAG_FLAGS+=( -t "$t" ); done <<< "${TAGS}"
|
|
BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
# 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 "MEMPALACE_TOOLKIT_REF=${MEMPALACE_TOOLKIT_REF}" \
|
|
--build-arg "INSTALL_STUDIO=true" \
|
|
--build-arg "IMAGE_TITLE=pi-devbox (studio)" \
|
|
--build-arg "PI_ATELIER_REF=${ATELIER_REF}" \
|
|
--build-arg "PI_ATELIER_VERSION=${ATELIER_TAG}" \
|
|
--build-arg "IMAGE_DESCRIPTION=pi-devbox ${RELEASE_TAG} — studio variant: everything in the core variant (pi ${PI_VERSION}, pi-toolkit, fork + observational-memory + atelier ${ATELIER_TAG}, MemPalace) plus the pi-studio browser UI ${STUDIO_TAG}." \
|
|
--build-arg "PI_STUDIO_REF=${STUDIO_REF}" \
|
|
--build-arg "PI_STUDIO_VERSION=${STUDIO_TAG}" \
|
|
--build-arg "RELEASE_TAG=${RELEASE_TAG}" \
|
|
--build-arg "BUILD_DATE=${BUILD_DATE}" \
|
|
--build-arg "SOURCE_REVISION=${GITHUB_SHA:-}" \
|
|
"${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
|
|
# Run on every tag release (and on promote_latest=true dispatches).
|
|
# The job-level gate deliberately does NOT key off need_build anymore:
|
|
# the actual no-op optimization moved INTO the step as a digest compare
|
|
# (see below). Keying the gate on need_build was wrong because a prior
|
|
# dry-run dispatch (promote_latest=false) can pre-build+push base-<hash>,
|
|
# making need_build=false on the subsequent tag run even though
|
|
# base-latest is still stale — the old gate then skipped promotion and
|
|
# left base-latest pointing at the PREVIOUS base. (Observed 2026-06-27,
|
|
# v1.2.3: dry-run-first release left base-latest one base behind.)
|
|
if: |
|
|
always() &&
|
|
needs.build-variant.result == 'success' &&
|
|
(inputs.promote_latest == 'true' || github.ref_type == 'tag')
|
|
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-<hash> as base-latest (only if stale)
|
|
# shell: bash is REQUIRED — Gitea Actions' default step shell is
|
|
# `sh -e {0}` (dash), which rejects `set -o pipefail` with
|
|
# "Illegal option -o pipefail" and aborts the step before the
|
|
# crane digest-compare runs, leaving base-latest un-promoted.
|
|
# Same footgun as ed49b8d (resolve-versions). Regression shipped
|
|
# in b7197e8, caught on the v1.2.4 release (run 418).
|
|
shell: bash
|
|
env:
|
|
BASE_HASH_REF: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
|
BASE_LATEST_REF: ${{ env.IMAGE }}:base-latest
|
|
run: |
|
|
set -euo pipefail
|
|
# Correctness invariant: after a release, base-latest must resolve to
|
|
# the SAME digest as the base-<hash> the just-built variants were
|
|
# FROM. Compare digests rather than trusting need_build — a prior
|
|
# dry-run dispatch can pre-build base-<hash>, so need_build=false on
|
|
# the tag run does NOT imply base-latest is already current. When the
|
|
# digests already match (genuine cache-hit release) this is a no-op,
|
|
# so we skip the crane copy entirely — preserving the original
|
|
# "don't do a tautological retag" intent and avoiding any cosmetic
|
|
# transient-failure exposure on releases that change nothing.
|
|
want=$(crane digest "${BASE_HASH_REF}")
|
|
have=$(crane digest "${BASE_LATEST_REF}" 2>/dev/null || echo "")
|
|
echo "base-<hash> digest: ${want}"
|
|
echo "base-latest digest: ${have:-<absent>}"
|
|
if [ "${want}" = "${have}" ]; then
|
|
echo "base-latest already current; nothing to promote."
|
|
else
|
|
echo "Promoting base-latest -> ${BASE_HASH_REF}"
|
|
crane copy "${BASE_HASH_REF}" "${BASE_LATEST_REF}"
|
|
fi
|
|
|
|
# ── 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})."
|