Files
pi-devbox/.gitea/workflows/docker-publish.yml
T
Joakim Persson 56e3742a2d
Lint / hadolint (push) Successful in 10s
Lint / skill-floor (push) Failing after 13s
Lint / doc-drift (push) Successful in 18s
Lint / actionlint (push) Successful in 33s
Publish Docker Image / lint-gate (push) Successful in 32s
Publish Docker Image / resolve-versions (push) Successful in 17s
Publish Docker Image / base-decide (push) Successful in 13s
Publish Docker Image / build-base (push) Has been skipped
Publish Docker Image / smoke-studio (push) Successful in 5m38s
Publish Docker Image / smoke (push) Successful in 8m20s
Publish Docker Image / build-variant-studio (push) Successful in 20m34s
Publish Docker Image / build-variant (push) Successful in 21m5s
Publish Docker Image / update-description (push) Successful in 8s
Publish Docker Image / promote-base-latest (push) Successful in 9s
fix(ci): skip the BuildKit check whose warning embedded this Dockerfile in CI output
v1.10.1 (run 707) failed with ZERO failing steps: every step Success, `smoke`
printing "101 passed, 0 failed", `smoke-studio` "104 passed, 0 failed", both
jobs red. The clipboard fix from f3b3748 worked exactly as predicted; this is a
second, independent defect that run 704 had masked.

CHAIN, measured end to end rather than reasoned:

`ARG BASE_IMAGE` has no default on purpose (the two-phase build always supplies
it), which trips BuildKit's InvalidDefaultArgInFrom check. buildx attaches that
warning's source context to the build metadata as
buildx.build.warnings[].sourceInfo.data — the ENTIRE Dockerfile, base64, on ONE
line. docker/build-push-action writes that metadata to $GITHUB_OUTPUT as a
`name<<ghadelimiter_<uuid>` heredoc. Gitea's act_runner truncates any single
line at exactly 65536 chars, so the closing delimiter was cut off:

  invalid format delimiter 'ghadelimiter_...' not found before end of file

and the runner failed the job while attributing it to no step at all.

  v1.9.4  run 695 SUCCESS: longest metadata line 56355, 0 delimiter errors
  v1.10.0 run 704 failed : longest metadata line 65536, 1 delimiter error
  v1.10.1 run 707 failed : longest metadata line 65536, 1 delimiter error

65536 = 2^16: cut AT the cap, not merely long. Independent route via file size
rather than log parsing: 42251 B at v1.9.4 (base64 56335, matching the log) vs
50034 B now (base64 66712, truncated). The cap corresponds to a 49152 B
Dockerfile, so this cycle's comment growth crossed it by 882 B.

Located by asking which top-level metadata key CONTAINS the base64, instead of
assuming: it is buildx.build.warnings -> sourceInfo -> data in BOTH runs.

THIS IS THE SECOND FIX FOR THIS BUG. The first, `provenance: false` on the two
smoke build steps, was committed as 784fad7 and is REVERTED here: a real buildx
on another host showed provenance metadata present in both modes with a longest
string of 71 chars, i.e. the base64 was never in provenance. Shipping it would
have left the release broken a third time while looking like a fix.

Also rejected, each measured: floating action tags moving (act action-bundle
hashes byte-identical between runs 695 and 707, all four) and the build-check
annotation text changing (byte-identical).

FIX: `# check=skip=InvalidDefaultArgInFrom` as the FIRST line of
Dockerfile.variant — BuildKit parses `# check=` only before any other line, so
placement is load-bearing. No honest default exists for BASE_IMAGE: `scratch`
would satisfy the linter while being a lie, and would convert today's instant
"invalid reference format" into a failure deep in the build. Verified against
the actual edited file with `docker buildx build --check`: "Check complete, no
warnings found." Zero warnings means no sourceInfo, so the longest metadata line
drops 65536 -> ~1936 and the file's SIZE stops gating CI.

GUARD: scripts/check-dockerfile-directives.sh, wired into BOTH lint.yml and
docker-publish.yml's lint-gate, because a check that gates only `push` lets a
tag regress — the adoption slip that let doc-drift land 27 h after the v1.9.3
tag. It also fails if ARG BASE_IMAGE gains a default, so the directive cannot
rot into guarding a check that can no longer fire. Truth table, exit codes:
present 0, removed 1, demoted to line 2 → 1, ARG defaulted 1, file missing 2
(a gate that cannot run must not pass).

Release renamed v1.10.1 -> v1.10.2 with both failed tags left standing as
tombstones. Docs swept again (README "since" marker, Dockerfile decision
comments) because CI reads them from the TAG.

Gates: doc-drift 23 OK / 0 DRIFT; base-hash, workflow-shell, skill-floor,
lint-shell (17 files now), dockerfile-directives all rc=0.
2026-10-02 10:58:03 +02:00

1020 lines
55 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:
# `type:` is REQUIRED for Gitea to render these fields in the "Run
# workflow" dialog. Without it (Gitea 1.26.2) the dispatch form shows a
# branch selector and NO inputs at all, so a manual run silently uses
# every default — which for `release_tag: ''` means RELEASE_TAG resolves
# empty, the variant tag list becomes `<image>:`, and the run dies on an
# invalid reference AFTER paying the full base + smoke cost (~70 min).
# That made the documented `smoke_only` escape hatch below unreachable
# from the UI for its whole existence; found 2026-09-06 trying to use it.
#
# Deliberately `string` and not `boolean`, even though these two read as
# flags: every consumption is a STRING comparison against 'true'
# (`inputs.smoke_only != 'true'` at the build-variant gates,
# `inputs.promote_latest == 'true'` at the promote gates) plus string
# interpolation into env.PROMOTE_LATEST. A boolean-typed input yields a
# real boolean, so `!= 'true'` would compare across types and could
# invert a publish gate rather than fail loudly. Changing the type here
# would mean re-auditing all six call sites; keeping it string is a
# rendering fix with provably zero semantic change.
release_tag:
description: 'Release tag to publish (e.g. v1.0.0). Used only for workflow_dispatch runs.'
required: false
default: ''
type: string
promote_latest:
description: 'Update latest aliases (default true for tag-push, false for manual test runs)'
required: false
default: 'false'
type: string
smoke_only:
description: 'Build base + run both smoke jobs against HEAD, then stop. Publishes nothing. Use to validate smoke assertions without cutting a tag. Set to the literal string true.'
required: false
default: 'false'
type: string
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 | LC_ALL=C sort -z`. The LC_ALL=C is
# load-bearing: sort collates per locale, and a dictionary locale
# (sv_SE/en_US.UTF-8) orders rootfs differently from byte order, giving
# a different hash for identical content (measured 2026-09-22:
# base-f40c4b7b103d under C/C.UTF-8 vs base-d8df62216a81 under
# sv_SE.UTF-8). The runner ships LANG=C.UTF-8 today, so this pin
# changes nothing now; it stops the hash depending on that accident.
# Predicting base_tag locally MUST use the same prefix on this sort.
# 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 | LC_ALL=C 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).
# ── release gate ──────────────────────────────────────────────
# Refuse to spend a base build on a tree whose own shell scripts do not lint.
#
# v1.8.14's first attempt is why this exists. smoke and smoke-studio both failed
# at scripts/smoke-test.sh:770 AFTER build-base had already spent ~46 minutes,
# on a defect shellcheck had flagged as SC2289 (severity error) a day earlier:
# the lint workflow went red on the very push that introduced it (run 186) and
# stayed red for runs 187 and 188, unread.
#
# lint.yml deliberately does not run on tag pushes, and its reasoning is sound
# (the tagged tree was already linted on main; a tag-ref lint run sorts above
# the publish run and makes a release look finished before anything ships). The
# missing invariant was never "lint the tag" -- it was "do not RELEASE a tree
# whose lint failed", and only a job inside THIS workflow can enforce that.
#
# ~40 s, ahead of everything expensive, and it runs scripts/lint-shell.sh --
# the same file lint.yml calls, not a second copy that drifts.
#
# scripts/check-doc-drift.sh is here for the same reason and closes the same
# gap -- and for it the gap is strictly worse. Shellcheck judges the TREE:
# green on main is still green at the tag, because the bytes did not move.
# Check 9 judges the tree against UPSTREAM NOW, and the floating refs it
# watches (PI_OBSMEM_REF=master and friends, which resolve-versions below turns
# into SHAs) move with no commit in this repo at all -- so a green reading on
# main carries no information about tag time, and that window is exactly where
# releases live. Worked example: pi-observational-memory moved cba0334 ->
# e7d77dc the day AFTER v1.9.3 was tagged. Nothing went red; it surfaced only
# because someone ran the gate by hand. Without this step a tag can publish a
# component that no CHANGELOG entry names, and the floating ref means no other
# file in the repo would record it either.
#
# Adds ~8 s. No token and no built image: it takes the last published vX.Y.Z
# from the Hub tags API, that release's baked labels from the anonymous
# registry API, and `git ls-remote`s each upstream -- so a plain checkout is
# enough, with no tags or history to fetch. Offline it SKIPs loudly and
# counted rather than passing, so an outage degrades it to a visible skip
# instead of a false green. Residual, accepted: it resolves the refs seconds
# before resolve-versions resolves them again, so an upstream push landing
# inside that window still slips through -- and check 9 on the NEXT release
# would then name it.
lint-gate:
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- name: Install shellcheck
run: |
apt-get update
apt-get install -y --no-install-recommends shellcheck
- name: "Shellcheck + syntax-check repository scripts (severity: error)"
run: bash scripts/lint-shell.sh
- name: Components the next build would bake differently are named in the CHANGELOG
run: bash scripts/check-doc-drift.sh
# Runs HERE as well as in lint.yml deliberately. A guard that gates only
# `push` lets a tag regress — the adoption slip that let doc-drift land
# 27 h after the v1.9.3 tag. This one protects the tag build from the
# zero-failing-step failure mode that killed v1.10.0 and v1.10.1.
- name: Dockerfile.variant still opens with its BuildKit check directive
run: bash scripts/check-dockerfile-directives.sh Dockerfile.variant
resolve-versions:
# Gated: a defective tree must not reach a 46-minute base build.
needs: [lint-gate]
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
outputs:
pi_version: ${{ steps.resolve.outputs.pi_version }}
mempalace_version: ${{ steps.resolve.outputs.mempalace_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
}
# Read a commit SHA from Gitea, surviving a bad build token.
#
# These repos are public (see the note at the call sites), so auth is
# a convenience, not a requirement — but Gitea REJECTS an invalid
# token (401) rather than ignoring it, so a revoked or malformed
# GITEA_BUILD_TOKEN could fail an entire release on reads that work
# fine anonymously. An ABSENT secret was always safe (Gitea ignores an
# empty `token ` value and serves the request, 200); a STALE one was
# not. So: try authed, and on 401/403 retry anonymously.
#
# A non-200 after that emits nothing and returns 0 deliberately, so
# require_sha raises the loud explicit abort rather than this helper
# inventing a fallback ref.
#
# Messages go to STDERR, not as ::warning:: annotations: this
# function's stdout IS the SHA, so anything written there would be
# captured into the ref by the command substitution.
gitea_sha() { # $1=repo
local repo="$1" url resp code
url="https://gitea.jordbo.se/api/v1/repos/joakimp/${repo}/commits?limit=1&sha=main"
resp=$(curl -s -w '\n%{http_code}' -H "$AUTH_HEADER" "$url" || printf '\n000')
code=${resp##*$'\n'}
if [ "$code" = "401" ] || [ "$code" = "403" ]; then
printf 'WARNING: Gitea rejected the build token for %s (HTTP %s); retrying anonymously. The read should succeed (public repo), but GITEA_BUILD_TOKEN is stale or malformed and should be rotated.\n' "$repo" "$code" >&2
resp=$(curl -s -w '\n%{http_code}' "$url" || printf '\n000')
code=${resp##*$'\n'}
fi
if [ "$code" != "200" ]; then
printf 'WARNING: Gitea commit lookup for %s returned HTTP %s\n' "$repo" "$code" >&2
return 0
fi
printf '%s' "${resp%$'\n'*}" | jq -r '.[0].sha // empty' 2>/dev/null || true
}
# ── 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"
# ── mempalace core: same audit as pi, from Dockerfile.base ────
# Until now this pin had NO CI-side audit at all — a literal string
# in Dockerfile.base with zero references in this workflow, while
# PI_VERSION got a concreteness gate, a published-on-registry check
# and a drift warning. It is the same class of risk: the palace's MCP
# tool schema is the agent-facing contract, and a client/server skew
# against the shared central palace is a fleet-wide, not local,
# problem. Read from Dockerfile.base (not duplicated here) so a local
# `docker build` and CI install the same version by construction.
MEMPALACE_VERSION=$(sed -n 's/^ARG MEMPALACE_VERSION=\([^[:space:]]*\).*/\1/p' Dockerfile.base | head -n1)
if ! printf '%s' "${MEMPALACE_VERSION:-}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::ARG MEMPALACE_VERSION in Dockerfile.base is not a concrete version (got '${MEMPALACE_VERSION:-<empty>}'). CI refuses to build from a floating palace version — see the pin policy comment above that ARG."
exit 1
fi
# One fetch, two gates. `curl -sf` exits non-zero and prints nothing
# on 404 (PyPI's answer for an unpublished version), so an empty body
# lands in the "not published" branch with its own message.
MEMPALACE_PYPI=$(curl -sf "https://pypi.org/pypi/mempalace/${MEMPALACE_VERSION}/json" || true)
MEMPALACE_PUBLISHED=$(printf '%s' "$MEMPALACE_PYPI" | jq -r '.info.version // empty' 2>/dev/null || true)
if [ "${MEMPALACE_PUBLISHED:-}" != "${MEMPALACE_VERSION}" ]; then
echo "::error::Pinned mempalace version ${MEMPALACE_VERSION} is not published on PyPI (registry returned '${MEMPALACE_PUBLISHED:-<empty>}'). Fix ARG MEMPALACE_VERSION in Dockerfile.base."
exit 1
fi
# A yanked release still installs when pinned exactly (PEP 592), so
# `uv tool install mempalace==X` would succeed silently and ship a
# version upstream has withdrawn to the whole fleet. The escape hatch
# is the same one-line bump that got us here.
MEMPALACE_YANKED=$(printf '%s' "$MEMPALACE_PYPI" | jq -r '.info.yanked // false' 2>/dev/null || true)
if [ "${MEMPALACE_YANKED:-false}" = "true" ]; then
# Reason hoisted into its own variable rather than inlined as a
# $(...) inside the message: a jq program nested in a substitution
# inside a double-quoted string needs escaping that silently breaks
# the FILTER (jq compile error) while the surrounding `exit 1` still
# fires, so the gate looks correct and reports garbage. Caught by
# the mutation test, not by review.
MEMPALACE_YANK_REASON=$(printf '%s' "$MEMPALACE_PYPI" | jq -r '.info.yanked_reason // "no reason given"' 2>/dev/null || true)
echo "::error::Pinned mempalace version ${MEMPALACE_VERSION} is YANKED on PyPI (${MEMPALACE_YANK_REASON:-no reason given}). An exact pin installs a yanked release without complaint — bump ARG MEMPALACE_VERSION in Dockerfile.base."
exit 1
fi
# Informational only, exactly like pi's npm drift warning: a newer
# palace must never be adopted implicitly. `|| true` so a transient
# PyPI failure cannot fail a release whose pin is already verified.
MEMPALACE_PYPI_LATEST=$(curl -sf "https://pypi.org/pypi/mempalace/json" | jq -r '.info.version // empty' 2>/dev/null || true)
if [ -n "${MEMPALACE_PYPI_LATEST:-}" ] && [ "${MEMPALACE_PYPI_LATEST}" != "${MEMPALACE_VERSION}" ]; then
echo "::warning::mempalace ${MEMPALACE_PYPI_LATEST} is published; this build ships the audited pin ${MEMPALACE_VERSION}. To adopt it: read the upstream CHANGELOG for MCP tool-schema changes (the agent-facing contract) and for sync/delete semantics, check the skew it introduces against the central palace host's server version, then bump ARG MEMPALACE_VERSION in Dockerfile.base and note the audit in CHANGELOG.md."
fi
echo "mempalace_version=${MEMPALACE_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=$(gitea_sha pi-toolkit)
require_sha PI_TOOLKIT_REF "$TOOLKIT_REF"
EXTENSIONS_REF=$(gitea_sha pi-extensions)
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=$(gitea_sha mempalace-toolkit)
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 MEMPALACE_VERSION=${MEMPALACE_VERSION} (pinned in Dockerfile.base; PyPI latest is ${MEMPALACE_PYPI_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 }}
EXPECTED_MEMPALACE_VERSION: ${{ needs.resolve-versions.outputs.mempalace_version }}
run: |
# Single source of truth for the node major is Dockerfile.base's ARG.
# Asserting the BUILT image matches it also catches a stale cached layer.
EXPECTED_NODE_MAJOR=$(sed -n 's/^ARG NODE_VERSION=\([0-9][0-9]*\).*/\1/p' Dockerfile.base)
export EXPECTED_NODE_MAJOR
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 }}
EXPECTED_MEMPALACE_VERSION: ${{ needs.resolve-versions.outputs.mempalace_version }}
run: |
# Single source of truth for the node major is Dockerfile.base's ARG.
# Asserting the BUILT image matches it also catches a stale cached layer.
EXPECTED_NODE_MAJOR=$(sed -n 's/^ARG NODE_VERSION=\([0-9][0-9]*\).*/\1/p' Dockerfile.base)
export EXPECTED_NODE_MAJOR
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})."