diff --git a/.gitea/workflows/docker-publish.yml b/.gitea/workflows/docker-publish.yml index 968a267..c17d938 100644 --- a/.gitea/workflows/docker-publish.yml +++ b/.gitea/workflows/docker-publish.yml @@ -58,6 +58,9 @@ jobs: - 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: | @@ -127,52 +130,70 @@ jobs: - name: Resolve pi version + companion refs id: resolve run: | - set -eu - # Query npm registry directly; catthehacker/ubuntu:act-latest's npm - # is not reliably on PATH in act_runner job containers. - PI_VERSION=$(curl -sf "https://registry.npmjs.org/@earendil-works%2Fpi-coding-agent/latest" | jq -r '.version') + 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:-}'). 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 npm (catthehacker/ubuntu:act-latest's npm is not + # reliably on PATH in act_runner job containers, so query directly). + PI_VERSION=$(curl -sf "https://registry.npmjs.org/@earendil-works%2Fpi-coding-agent/latest" | jq -r '.version' 2>/dev/null || true) + if ! printf '%s' "${PI_VERSION:-}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+'; then + echo "::error::Could not resolve pi version from npm (got '${PI_VERSION:-}')." + exit 1 + fi echo "pi_version=${PI_VERSION}" >> "$GITHUB_OUTPUT" - # Resolve pi-fork / pi-observational-memory git refs to commit - # SHAs so the build-arg string changes whenever upstream moves. + + # 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" || echo "master") + "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" || echo "master") - [ -n "$FORK_REF" ] || FORK_REF=master - [ -n "$OBSMEM_REF" ] || OBSMEM_REF=master + "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" - # Also resolve pi-toolkit / pi-extensions main HEADs to SHAs so a - # workflow_dispatch re-run produces byte-identical images when - # those repos haven't moved (and a clean diff in build-arg strings - # when they have, defeating the registry buildcache footgun). - # Gitea API requires auth even for public-repo commit listing. - TOOLKIT_REF=$(curl -sf -H "Authorization: token ${GITEA_BUILD_TOKEN:-${GITHUB_TOKEN:-}}" \ + + # 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 // "main"' 2>/dev/null || echo "main") - EXTENSIONS_REF=$(curl -sf -H "Authorization: token ${GITEA_BUILD_TOKEN:-${GITHUB_TOKEN:-}}" \ + | 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 // "main"' 2>/dev/null || echo "main") - [ -n "$TOOLKIT_REF" ] || TOOLKIT_REF=main - [ -n "$EXTENSIONS_REF" ] || EXTENSIONS_REF=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" - # Resolve mempalace-toolkit main HEAD to a SHA. UNLIKE the others, - # mempalace-toolkit is cloned in Dockerfile.base, so this SHA is - # ALSO folded into the base-decide hash to force a base rebuild - # when the toolkit moves (without it, a toolkit-only fix silently - # fails to land unless Dockerfile.base itself changes). - MEMPALACE_TOOLKIT_REF=$(curl -sf -H "Authorization: token ${GITEA_BUILD_TOKEN:-${GITHUB_TOKEN:-}}" \ + + # 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 // "main"' 2>/dev/null || echo "main") - [ -n "$MEMPALACE_TOOLKIT_REF" ] || MEMPALACE_TOOLKIT_REF=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" - # Resolve pi-studio (omaclaren/pi-studio) main HEAD to a SHA for - # the :latest-studio variant — same cache-busting rationale. + + # pi-studio (omaclaren/pi-studio) → commit SHA for :latest-studio. STUDIO_REF=$(curl -sf -H "Accept: application/vnd.github.sha" \ - "https://api.github.com/repos/omaclaren/pi-studio/commits/main" || echo "main") - [ -n "$STUDIO_REF" ] || STUDIO_REF=main + "https://api.github.com/repos/omaclaren/pi-studio/commits/main" || true) + require_sha PI_STUDIO_REF "$STUDIO_REF" echo "studio_ref=${STUDIO_REF}" >> "$GITHUB_OUTPUT" + echo "Resolved PI_VERSION=${PI_VERSION}" echo "Resolved PI_FORK_REF=${FORK_REF}, PI_OBSMEM_REF=${OBSMEM_REF}" echo "Resolved PI_TOOLKIT_REF=${TOOLKIT_REF}, PI_EXTENSIONS_REF=${EXTENSIONS_REF}" @@ -299,6 +320,9 @@ jobs: 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 }} + RELEASE_TAG=smoke + SOURCE_REVISION=${{ github.sha }} - name: Smoke test (amd64) env: EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }} @@ -355,6 +379,9 @@ jobs: PI_EXTENSIONS_REF=${{ needs.resolve-versions.outputs.extensions_ref }} INSTALL_STUDIO=true PI_STUDIO_REF=${{ needs.resolve-versions.outputs.studio_ref }} + MEMPALACE_TOOLKIT_REF=${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }} + RELEASE_TAG=smoke-studio + SOURCE_REVISION=${{ github.sha }} - name: Smoke test studio (amd64) env: EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }} @@ -406,10 +433,12 @@ jobs: 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 }} 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" @@ -423,6 +452,10 @@ jobs: --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 "RELEASE_TAG=${RELEASE_TAG}" \ + --build-arg "BUILD_DATE=${BUILD_DATE}" \ + --build-arg "SOURCE_REVISION=${GITHUB_SHA:-}" \ "${TAG_FLAGS[@]}" \ .; then echo "==> Attempt ${attempt} succeeded" @@ -487,10 +520,12 @@ jobs: TOOLKIT_REF: ${{ needs.resolve-versions.outputs.toolkit_ref }} EXTENSIONS_REF: ${{ needs.resolve-versions.outputs.extensions_ref }} STUDIO_REF: ${{ needs.resolve-versions.outputs.studio_ref }} + MEMPALACE_TOOLKIT_REF: ${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }} 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" @@ -504,8 +539,12 @@ jobs: --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 "PI_STUDIO_REF=${STUDIO_REF}" \ + --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" diff --git a/CHANGELOG.md b/CHANGELOG.md index aa529ac..ab7f6e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,72 @@ Pre-v1.0.0 tags followed the pi npm version (`v{pi_version}[letter]`). ## Unreleased +## v1.1.6 — 2026-06-19 + +Build provenance + reproducibility hardening, plus pi `0.79.7` → `0.79.8` +(auto-resolved at build). Companion refs are auto-resolved to SHAs at build +as before. + +### Bumped: pi 0.79.7 → 0.79.8 + +Notable upstream changes (from [pi releases](https://github.com/earendil-works/pi/releases/tag/v0.79.8)): + +- **Selective provider base entry points** — SDK users can pair + `@earendil-works/pi-ai/base` and `@earendil-works/pi-agent-core/base` with + explicit provider registration to keep bundled apps from including unused + provider transports. +- **Mistral prompt caching** — Mistral sessions use provider-side prompt + caching keyed on the pi session ID, with cached-token usage/cost + accounting. +- **Post-compaction token estimates** — compact results and compaction + events now include estimated post-compaction token counts. +- **OpenRouter Fusion alias** — `openrouter/fusion` available as a built-in + OpenRouter model alias. + +### Added + +- **Self-describing images: OCI labels + on-disk build manifest.** The + variant build now records exactly which pi version and companion-repo + commits were baked into each image. Previously the SHAs resolved by CI + only ever reached the build log (which rotates), so a published tag was + not reconstructable after the fact — confirming what shipped meant + triangulating from `git`, `pi --version`, and extension source. + - OCI labels: `org.opencontainers.image.{version,revision,created}` plus + `se.jordbo.pi-devbox.{pi,pi-toolkit,pi-extensions,pi-fork,pi-obsmem,mempalace-toolkit,pi-studio}-*ref` — + inspect with `docker inspect`. + - `/etc/pi-devbox/build-manifest.json` written from **ground truth** (the + actual checked-out `HEAD` of each `/opt` clone + live `pi --version`), + not just the intended build-args, so it also exposes a clone that + silently resolved to the wrong ref. The provenance ARGs are declared + last so a changing `BUILD_DATE` never invalidates the expensive + install/clone layers. +- **`scripts/check-base-hash.sh` — base-rebuild invariant guard.** Every + floating `ARG *_REF` consumed by `Dockerfile.base` must be folded into the + `base_tag` hash, or a ref-only change won't trigger a base rebuild (the + v1.1.2 mempalace-toolkit staleness footgun). The guard fails CI the moment + someone adds an `ARG *_REF` to `Dockerfile.base` without folding it in; it + runs in the `base-decide` job and locally. Smoke-test gained assertions for + the manifest (present, no `"unknown"` components) and the OCI labels. +- **Overridable companion repo URLs.** The three gitea-hosted companions + (`pi-toolkit`, `pi-extensions`, `mempalace-toolkit`) gained `*_REPO` + build-args defaulting to their canonical `gitea.jordbo.se` origin — + matching the existing `PI_FORK_REPO` / `PI_OBSMEM_REPO` / `PI_STUDIO_REPO` + pattern. A relocated or forked build can now repoint a companion at a + mirror, another host, or a local path (`--build-arg PI_EXTENSIONS_REPO=...`) + without editing the Dockerfiles. Defaults are unchanged, so the canonical + CI build is byte-identical. + +### Changed + +- **`resolve-versions` now fails loud instead of falling back to a floating + branch.** Each pi-version / companion-ref lookup previously degraded to + `main`/`master` on a transient API/network failure (`|| echo "main"`), + silently shipping an unpinned ref that defeats both cache-busting and + reproducibility. Resolution now validates each result is a 40-hex commit + SHA (and pi a real semver) and aborts the release otherwise. + +--- + ## v1.1.5 — 2026-06-18 Patch release: SSH ControlMaster read-only-socket fix + pi `0.79.6` → `0.79.7` diff --git a/Dockerfile.base b/Dockerfile.base index cb9e8ef..eb94a27 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -351,6 +351,11 @@ RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \ # ── mempalace-toolkit — bash wrappers for session/docs mining ──────── ARG INSTALL_MEMPALACE_TOOLKIT=true ARG MEMPALACE_TOOLKIT_REF=main +# MEMPALACE_TOOLKIT_REPO defaults to the canonical gitea origin but is +# overridable so a relocated/forked build can clone from a mirror or a +# different host without editing this Dockerfile (mirrors the +# PI_FORK_REPO / PI_OBSMEM_REPO / PI_STUDIO_REPO pattern in the variant). +ARG MEMPALACE_TOOLKIT_REPO=https://gitea.jordbo.se/joakimp/mempalace-toolkit.git # MEMPALACE_TOOLKIT_REF accepts EITHER a branch name OR a commit SHA. CI # resolves it to a SHA (resolve-versions job) and folds that SHA into the # base-decide hash so the base rebuilds when the toolkit moves. `git clone @@ -360,7 +365,7 @@ ARG MEMPALACE_TOOLKIT_REF=main RUN if [ "${INSTALL_MEMPALACE}" = "true" ] && [ "${INSTALL_MEMPALACE_TOOLKIT}" = "true" ]; then \ rm -rf /opt/mempalace-toolkit && mkdir -p /opt/mempalace-toolkit && \ git -C /opt/mempalace-toolkit init -q && \ - git -C /opt/mempalace-toolkit remote add origin https://gitea.jordbo.se/joakimp/mempalace-toolkit.git && \ + git -C /opt/mempalace-toolkit remote add origin "${MEMPALACE_TOOLKIT_REPO}" && \ ok=0; for i in 1 2 3 4 5; do \ if git -C /opt/mempalace-toolkit fetch --depth 1 origin "${MEMPALACE_TOOLKIT_REF}" && \ git -C /opt/mempalace-toolkit checkout -q FETCH_HEAD; then ok=1; break; fi; \ diff --git a/Dockerfile.variant b/Dockerfile.variant index e7c0132..3d3db31 100644 --- a/Dockerfile.variant +++ b/Dockerfile.variant @@ -41,6 +41,12 @@ ARG USER_NAME=developer ARG PI_VERSION=latest ARG PI_TOOLKIT_REF=main ARG PI_EXTENSIONS_REF=main +# Repo URLs default to the canonical gitea origin but are overridable so a +# relocated/forked build can clone from a mirror or a different host +# without editing this Dockerfile — same pattern as PI_FORK_REPO / +# PI_OBSMEM_REPO / PI_STUDIO_REPO below. +ARG PI_TOOLKIT_REPO=https://gitea.jordbo.se/joakimp/pi-toolkit.git +ARG PI_EXTENSIONS_REPO=https://gitea.jordbo.se/joakimp/pi-extensions.git # pi-fork (fork tool) + pi-observational-memory (recall tool) live on GitHub # under elpapi42. CI resolves these to commit SHAs to defeat the same # cache-hit footgun that affects PI_VERSION. @@ -77,8 +83,8 @@ RUN set -e && \ NPM_CONFIG_PREFIX=/usr npm install -g @earendil-works/pi-coding-agent@${PI_VERSION} ; \ fi && \ pi --version && \ - git_fetch_ref "https://gitea.jordbo.se/joakimp/pi-toolkit.git" "${PI_TOOLKIT_REF}" /opt/pi-toolkit && \ - git_fetch_ref "https://gitea.jordbo.se/joakimp/pi-extensions.git" "${PI_EXTENSIONS_REF}" /opt/pi-extensions && \ + git_fetch_ref "${PI_TOOLKIT_REPO}" "${PI_TOOLKIT_REF}" /opt/pi-toolkit && \ + git_fetch_ref "${PI_EXTENSIONS_REPO}" "${PI_EXTENSIONS_REF}" /opt/pi-extensions && \ git_fetch_ref "${PI_FORK_REPO}" "${PI_FORK_REF}" /opt/pi-fork && \ git_fetch_ref "${PI_OBSMEM_REPO}" "${PI_OBSMEM_REF}" /opt/pi-observational-memory && \ (cd /opt/pi-fork && npm install --omit=dev --no-audit --no-fund) && \ @@ -154,4 +160,60 @@ RUN if [ "${INSTALL_GO}" = "true" ]; then \ ln -s /usr/local/go/bin/gofmt /usr/local/bin/gofmt; \ fi +# ── Build provenance: OCI labels + on-disk build manifest ──────────── +# Records exactly which pi version and companion-repo commits were baked +# into THIS image, so a published tag is self-describing and reproducible +# after the fact (CI logs rotate; a released image must not depend on +# them). Previously the resolved SHAs only ever reached the CI build log. +# +# These ARGs are declared LAST, immediately before the layer that uses +# them, so a changing BUILD_DATE / RELEASE_TAG / SOURCE_REVISION never +# invalidates the expensive pi-install / clone layers above. +ARG RELEASE_TAG=dev +ARG BUILD_DATE= +ARG SOURCE_REVISION= +# MEMPALACE_TOOLKIT_REF is consumed in Dockerfile.base; re-declared here +# only so its intended ref lands in the label set alongside the others. +ARG MEMPALACE_TOOLKIT_REF=main + +LABEL org.opencontainers.image.version="${RELEASE_TAG}" \ + org.opencontainers.image.revision="${SOURCE_REVISION}" \ + org.opencontainers.image.created="${BUILD_DATE}" \ + se.jordbo.pi-devbox.pi-version="${PI_VERSION}" \ + se.jordbo.pi-devbox.pi-toolkit-ref="${PI_TOOLKIT_REF}" \ + se.jordbo.pi-devbox.pi-extensions-ref="${PI_EXTENSIONS_REF}" \ + se.jordbo.pi-devbox.pi-fork-ref="${PI_FORK_REF}" \ + se.jordbo.pi-devbox.pi-obsmem-ref="${PI_OBSMEM_REF}" \ + se.jordbo.pi-devbox.mempalace-toolkit-ref="${MEMPALACE_TOOLKIT_REF}" \ + se.jordbo.pi-devbox.pi-studio-ref="${PI_STUDIO_REF}" + +# The manifest is written from GROUND TRUTH — the actual checked-out HEAD +# of each /opt clone and the live `pi --version` — not merely the intended +# build-args. That way it also exposes a clone that silently resolved to +# something other than the requested ref. pi-studio is present only in the +# studio variant (JSON null otherwise). +RUN set -e; \ + mkdir -p /etc/pi-devbox; \ + rev() { git -C "$1" rev-parse HEAD 2>/dev/null || echo "unknown"; }; \ + PI_V="$(pi --version 2>/dev/null | head -n1 | tr -d '\r\n')"; \ + STUDIO_REV='null'; \ + if [ -d /opt/pi-studio/.git ]; then STUDIO_REV="\"$(rev /opt/pi-studio)\""; fi; \ + { \ + echo '{'; \ + echo " \"release_tag\": \"${RELEASE_TAG}\","; \ + echo " \"build_date\": \"${BUILD_DATE}\","; \ + echo " \"source_revision\": \"${SOURCE_REVISION}\","; \ + echo " \"pi_version\": \"${PI_V}\","; \ + echo " \"components\": {"; \ + echo " \"pi-toolkit\": \"$(rev /opt/pi-toolkit)\","; \ + echo " \"pi-extensions\": \"$(rev /opt/pi-extensions)\","; \ + echo " \"pi-fork\": \"$(rev /opt/pi-fork)\","; \ + echo " \"pi-observational-memory\": \"$(rev /opt/pi-observational-memory)\","; \ + echo " \"mempalace-toolkit\": \"$(rev /opt/mempalace-toolkit)\","; \ + echo " \"pi-studio\": ${STUDIO_REV}"; \ + echo " }"; \ + echo '}'; \ + } > /etc/pi-devbox/build-manifest.json; \ + echo "── build manifest ──"; cat /etc/pi-devbox/build-manifest.json + # WORKDIR / ENTRYPOINT / CMD inherited from base. diff --git a/README.md b/README.md index bfc2787..74545c1 100644 --- a/README.md +++ b/README.md @@ -537,6 +537,68 @@ pi-coding-agent@latest` (the build-arg string would otherwise be byte-identical across releases and the layer would silently reuse the previous version's bytes). +### Building a fork / relocated build + +The canonical build clones its companions from `gitea.jordbo.se`. Every +companion repo URL is an overridable build-arg (defaulting to the canonical +origin), so a fork or a build on a host that can't reach that gitea can +repoint each one at a mirror, another host, or a local `file://` path +**without editing the Dockerfiles**: + +| Build-arg | Default | Dockerfile | +|---|---|---| +| `PI_TOOLKIT_REPO` | `https://gitea.jordbo.se/joakimp/pi-toolkit.git` | variant | +| `PI_EXTENSIONS_REPO` | `https://gitea.jordbo.se/joakimp/pi-extensions.git` | variant | +| `MEMPALACE_TOOLKIT_REPO` | `https://gitea.jordbo.se/joakimp/mempalace-toolkit.git` | base | +| `PI_FORK_REPO` | `https://github.com/elpapi42/pi-fork.git` | variant | +| `PI_OBSMEM_REPO` | `https://github.com/elpapi42/pi-observational-memory.git` | variant | +| `PI_STUDIO_REPO` | `https://github.com/omaclaren/pi-studio.git` | variant | + +Each has a matching `*_REF` arg (branch name or commit SHA). Example — build +the variant against forked toolkit/extensions and a pinned pi: + +```bash +# base first (mempalace-toolkit lives here) +docker build -f Dockerfile.base -t myorg/pi-devbox:base-dev \ + --build-arg MEMPALACE_TOOLKIT_REPO=https://github.com/myorg/mempalace-toolkit.git . + +# then the variant FROM that base +docker build -f Dockerfile.variant -t myorg/pi-devbox:dev \ + --build-arg BASE_IMAGE=myorg/pi-devbox:base-dev \ + --build-arg PI_VERSION=0.79.7 \ + --build-arg PI_TOOLKIT_REPO=https://github.com/myorg/pi-toolkit.git \ + --build-arg PI_EXTENSIONS_REPO=https://github.com/myorg/pi-extensions.git . +``` + +Note: the gitea companions clone anonymously (no token needed); only the +`resolve-versions` CI job calls the gitea *API* (which needs a token even +for public repos). A plain `docker build` like the above skips that job +entirely, so no credentials are required for a local/forked build. + +Provenance build-args (all optional; populate the OCI labels and +`/etc/pi-devbox/build-manifest.json` — see below): `RELEASE_TAG`, +`BUILD_DATE`, `SOURCE_REVISION`. CI sets these automatically; a manual build +leaves them at harmless defaults. + +### Build provenance (labels + manifest) + +Every published image is self-describing. Inspect the OCI labels without +pulling the filesystem: + +```bash +docker inspect --format '{{json .Config.Labels}}' joakimp/pi-devbox:latest | jq . +``` + +`org.opencontainers.image.{version,revision,created}` plus +`se.jordbo.pi-devbox.*-ref` record the intended pi version and companion +refs. The on-disk `/etc/pi-devbox/build-manifest.json` records **ground +truth** — the actual checked-out commit of each `/opt` clone and the live +`pi --version` — so a tag is reconstructable after CI logs rotate: + +```bash +docker run --rm --entrypoint= joakimp/pi-devbox:latest cat /etc/pi-devbox/build-manifest.json +``` + ## Troubleshooting ### Image grew unexpectedly diff --git a/scripts/check-base-hash.sh b/scripts/check-base-hash.sh new file mode 100755 index 0000000..74080c6 --- /dev/null +++ b/scripts/check-base-hash.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# check-base-hash.sh — guard the base-rebuild invariant. +# +# Every floating `ARG *_REF` consumed by Dockerfile.base MUST be folded +# into the base_tag hash in the docker-publish workflow. Otherwise a +# ref-only change to that dependency does not change the base hash, the +# Docker Hub probe finds the old base tag, and the base is NOT rebuilt — +# the dependency fix silently fails to land. This is the v1.1.2-class +# staleness footgun (then it was mempalace-toolkit; this guard stops the +# next one before it ships). +# +# Runs in CI (base-decide job) and locally: bash scripts/check-base-hash.sh +set -euo pipefail +cd "$(dirname "$0")/.." + +WF=".gitea/workflows/docker-publish.yml" +DF="Dockerfile.base" + +# Extract the hash-compute block: the `HASH=$( … ) | sha256sum | cut` +# brace-group in the "Compute base tag" step. This lives in a separate +# file from the workflow, so scanning $WF here is free of the self-match +# hazard an inline workflow step would have. +block=$(awk '/HASH=\$\(/{f=1} f{print} f && /cut -c1-12/{exit}' "$WF") +if [ -z "$block" ]; then + echo "::error::could not locate the HASH=\$( … ) | sha256sum block in $WF" + exit 1 +fi + +refs=$(grep -oE '^ARG [A-Z0-9_]+_REF' "$DF" | awk '{print $2}' | sort -u) +fail=0 +for r in $refs; do + lc=$(printf '%s' "$r" | tr '[:upper:]' '[:lower:]') + if ! printf '%s' "$block" | grep -q "outputs.$lc"; then + echo "::error::Dockerfile.base declares '$r' but it is NOT folded into the base_tag hash in $WF." + echo "::error::Add echo \"\${{ needs.resolve-versions.outputs.$lc }}\" inside the HASH=\$( … ) | sha256sum block, or a $r-only change will silently fail to rebuild the base." + fail=1 + fi +done + +if [ "$fail" = 0 ]; then + echo "OK: all Dockerfile.base *_REF args are folded into base_tag (${refs:-none})." +fi +exit $fail diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 507b468..0ff4465 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -113,6 +113,28 @@ else echo " ℹ️ pi-studio not present (non-studio variant) — skipping studio clone checks" fi +# ── Build provenance (manifest + OCI labels) ───────────────────────── +echo "" +echo "── Build provenance ──" +run "/etc/pi-devbox/build-manifest.json present" \ + "test -f /etc/pi-devbox/build-manifest.json" +run_expect "manifest records pi-extensions component" \ + "cat /etc/pi-devbox/build-manifest.json" '"pi-extensions"' +run_expect "manifest records pi_version" \ + "cat /etc/pi-devbox/build-manifest.json" '"pi_version"' +# Every component must be a resolved commit (or null for pi-studio in the +# non-studio variant) — 'unknown' means a clone silently failed to resolve. +run "manifest has no unresolved ('unknown') components" \ + "! grep -q '\"unknown\"' /etc/pi-devbox/build-manifest.json" +# OCI labels live in the image config, not the container fs — inspect them +# from the host docker rather than via `docker run`. +LBL=$(docker inspect --format '{{ index .Config.Labels "se.jordbo.pi-devbox.pi-extensions-ref" }}' "$IMAGE" 2>/dev/null || true) +if [ -n "$LBL" ] && [ "$LBL" != "" ]; then + printf " ✅ OCI label se.jordbo.pi-devbox.pi-extensions-ref=%s\n" "$LBL"; PASS=$((PASS+1)) +else + printf " ❌ OCI label se.jordbo.pi-devbox.pi-extensions-ref missing or empty\n"; FAIL=$((FAIL+1)) +fi + # ── Runtime deployment (needs entrypoint to run) ────────────────────── echo "" echo "── Runtime deployment ──"