53b41cd76b
Lint / actionlint (push) Successful in 16s
Lint / hadolint (push) Successful in 13s
Publish Docker Image / resolve-versions (push) Successful in 10s
Publish Docker Image / base-decide (push) Successful in 16s
Publish Docker Image / build-base (push) Has been skipped
Publish Docker Image / smoke-studio (push) Successful in 5m20s
Publish Docker Image / smoke (push) Successful in 14m40s
Publish Docker Image / build-variant-studio (push) Successful in 21m51s
Publish Docker Image / build-variant (push) Successful in 15m59s
Publish Docker Image / update-description (push) Successful in 6s
Publish Docker Image / promote-base-latest (push) Successful in 17s
v1.8.0 never shipped: smoke (67/68) and smoke-studio (70/71) each failed the
same single assertion, so build-variant and everything downstream skipped and
latest stayed on v1.7.0.
The assertion was wrong, not the product. It grepped for a literal
stage=/home/developer/.mempalace/pi-stage/, but run() invokes
docker run --rm --entrypoint="" "$IMAGE" sh -c "$cmd"
and neither Dockerfile sets USER or ENV HOME — the published base image config
has no HOME at all; it is normally set by entrypoint-user.sh, which
--entrypoint="" skips on purpose. So the assertion executed as root with
HOME=/root, mempalace-pi-session correctly resolved
stage=/root/.mempalace/pi-stage/... (the stage is $HOME-relative by design), and
the literal grep could never match under any circumstances.
The tell was one line below in the log: the sibling assertion "pi stage follows
MEMPALACE_PALACE_PATH" PASSED, because it sets the variable explicitly and never
consults HOME. Default fails + explicit passes = wrong HOME, not broken staging.
Now asserts the invariant actually intended — the stage sits beside the resolved
palace, sharing its lifetime — which is user-independent:
case "$stage" in "stage=$HOME/.mempalace/pi-stage/"*) exit 0 ;; *) exit 1 ;; esac
$HOME is expanded by the container's own shell, so it holds as root, as
developer, or under any future user. Verified all four cases against the real
bin/mempalace-pi-session by extracting the committed assertion bodies and
running them under sh -c: virgin HOME -> exit 0; HOME=/home/developer -> exit 0;
MEMPALACE_PI_STAGE pinned to a .cache path -> exit 1 (the regression this
assertion exists to catch still fails it); developer-identity companion -> 0.
Added that companion assertion, "pi stage is palace-adjacent for the developer
user", which covers the deployment-specific path properly by SUPPLYING
HOME=/home/developer rather than assuming it.
Why this took a release to surface: docker-publish.yml triggers on push tags v*
only. The assertion was added on a push to main (7c00dd6), where only lint.yml
runs, so v1.8.0 was its first execution ever. Any smoke assertion written
outside a release was unvalidated until a release consumed it.
New workflow_dispatch input smoke_only probes/builds the base, runs both smoke
jobs against HEAD, and stops before publishing. Implemented as
`if: inputs.smoke_only != 'true'` on build-variant and build-variant-studio,
deliberately WITHOUT always() so the implicit needs-succeeded gate survives and
a red smoke still blocks a release. promote-base-latest and update-description
already require build-variant success, so they skip on their own. On a tag push
inputs is unset and null != 'true' is true, so releases are unaffected.
Finally, run() no longer discards output. A red ❌ carried zero diagnostic
weight: explaining this one-line failure needed a CI-log dig plus a registry
image-config inspection, when the container had already printed the answer.
Failures now show the last lines of output (guarded with `if`, not a trailing
`&&`, which would abort under set -e), and the stage assertions echo the
resolved stage and the HOME they saw to stderr — invisible while they pass.
819 lines
41 KiB
YAML
819 lines
41 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. Gitea API
|
|
# requires auth even for public-repo commit listing.
|
|
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})."
|