Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6002c6299d | |||
| d73bf2e9d3 | |||
| 3a59e15563 | |||
| d1db595f17 | |||
| 26384fe9f1 | |||
| b33e9dc592 | |||
| 3cdc2069db | |||
| cc53877328 | |||
| c42b237d30 | |||
| b7197e88b0 | |||
| 2985d9ade8 | |||
| bff810c1eb | |||
| 904fe85249 | |||
| cda488c565 | |||
| 9ab9a28458 |
@@ -9,6 +9,19 @@ WORKSPACE_PATH=~/projects
|
||||
# Path to SSH keys on host
|
||||
SSH_KEY_PATH=~/.ssh
|
||||
|
||||
# ── MemPalace memory (local by default) ───────────────────────────
|
||||
# By default the mempalace.ts extension spawns a LOCAL mempalace-mcp stdio
|
||||
# server (palace at ~/.mempalace). Uncomment the devbox-palace volume in
|
||||
# docker-compose.yml to persist it across container recreation.
|
||||
#
|
||||
# To instead share ONE MemPalace across containers/harnesses (pi + opencode
|
||||
# + native), set the URL below. When set, the extension connects over HTTP
|
||||
# and NO local mempalace-mcp is spawned; the devbox-palace volume is then
|
||||
# irrelevant. MEMPALACE_REMOTE_TOKEN, if set, is sent as a bearer token.
|
||||
# Serve it with: mempalace-mcp --transport http --host 0.0.0.0 --port 8765
|
||||
# MEMPALACE_REMOTE_URL=http://mempalace.lan:8765/mcp
|
||||
# MEMPALACE_REMOTE_TOKEN=
|
||||
|
||||
# ── LAN access from the container (host-OS-agnostic) ─────────────────
|
||||
# On VM-backed hosts (macOS OrbStack / Docker Desktop) the container can't
|
||||
# reach the host's directly-attached LAN peers by default. The entrypoint
|
||||
|
||||
@@ -38,6 +38,16 @@ 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
|
||||
@@ -565,16 +575,19 @@ jobs:
|
||||
needs:
|
||||
- base-decide
|
||||
- build-variant
|
||||
# Skip on cache-hit base builds: when need_build=false, base-latest
|
||||
# already points at the same digest as base-<hash>, so the retag is
|
||||
# a tautology and any transient failure of it is purely cosmetic.
|
||||
# Manual workflow_dispatch with promote_latest=true overrides this
|
||||
# gate as an escape hatch (e.g., if base-latest got hand-deleted).
|
||||
# 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' && needs.base-decide.outputs.need_build == 'true'))
|
||||
(inputs.promote_latest == 'true' || github.ref_type == 'tag')
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
@@ -596,11 +609,38 @@ jobs:
|
||||
crane auth login docker.io \
|
||||
-u ${{ vars.DOCKERHUB_USERNAME }} \
|
||||
-p "${{ secrets.DOCKERHUB_TOKEN }}"
|
||||
- name: Re-tag base-<hash> as base-latest
|
||||
- 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: |
|
||||
crane copy \
|
||||
${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }} \
|
||||
${{ env.IMAGE }}:base-latest
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
name: Lint workflows
|
||||
|
||||
# Durable guard against CI-workflow bugs — most importantly the recurring
|
||||
# "bash-only syntax under the default `sh`/dash shell" footgun that broke
|
||||
# resolve-versions (ed49b8d) and promote-base-latest (b7197e8 → run 418).
|
||||
# actionlint runs shellcheck against each `run:` step using its *effective*
|
||||
# shell, so `set -o pipefail` under dash is flagged as SC3040 before any
|
||||
# expensive build runs. This is cheap (~10s) and independent of the build
|
||||
# pipeline, so it fires on every push/PR — not just on release tags, which
|
||||
# is where the build workflow (docker-publish.yml) is otherwise only
|
||||
# triggered.
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: lint-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
actionlint:
|
||||
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 python3-yaml
|
||||
|
||||
- name: Gitea shell guard (catches the actionlint blind spot)
|
||||
# actionlint models GitHub Actions, where the default run shell is
|
||||
# bash, so it does NOT flag bash syntax in a step that merely OMITS
|
||||
# `shell:` — which is exactly how ed49b8d and b7197e8 manifested on
|
||||
# Gitea (default sh/dash). This guard enforces that every run: step
|
||||
# resolves to bash under Gitea's real defaults. Run it BEFORE
|
||||
# actionlint so the more precise diagnostic surfaces first.
|
||||
run: bash scripts/check-workflow-shell.sh .gitea/workflows
|
||||
|
||||
- name: Install actionlint (pinned)
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.7
|
||||
run: |
|
||||
curl -fsSL \
|
||||
"https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
|
||||
| tar -xz -C /usr/local/bin actionlint
|
||||
actionlint --version
|
||||
|
||||
- name: Run actionlint
|
||||
# SHELLCHECK_OPTS excludes pure-style codes (quoting/style opinions)
|
||||
# so the guard stays focused on correctness bugs — crucially the
|
||||
# SC3xxx "not POSIX / wrong shell" family that catches the pipefail
|
||||
# footgun. Do NOT exclude SC3040 (set -o pipefail under sh) or any
|
||||
# other SC3xxx code.
|
||||
env:
|
||||
SHELLCHECK_OPTS: "-e SC2086 -e SC2016 -e SC2129 -e SC2001 -e SC2312"
|
||||
# Pass explicit paths: actionlint's no-arg mode auto-detects a
|
||||
# project by looking for `.github/workflows`, which doesn't exist in
|
||||
# this `.gitea/workflows` repo and hard-fails with exit 3
|
||||
# ("no project was found"). Globbing the workflow files is the
|
||||
# supported way to lint a non-GitHub layout.
|
||||
run: actionlint -color .gitea/workflows/*.yml
|
||||
+196
@@ -11,6 +11,202 @@ Pre-v1.0.0 tags followed the pi npm version (`v{pi_version}[letter]`).
|
||||
|
||||
---
|
||||
|
||||
## v1.3.0 — 2026-07-02
|
||||
|
||||
Minor release. Headline: **shared/external MemPalace** — the `mempalace.ts`
|
||||
bridge can now point at one MemPalace HTTP server (`MEMPALACE_REMOTE_URL`,
|
||||
optional `MEMPALACE_REMOTE_TOKEN`) shared across containers/harnesses instead of
|
||||
a per-container local palace; ships `docker-compose.mempalace.yml` for the
|
||||
server. Also ships the **`nano` + `micro`** non-modal editors and a **CI
|
||||
workflow-lint layer** (Gitea-accurate sh-vs-bash guard + actionlint/shellcheck),
|
||||
with the `docker-publish.yml` bash-defaults and `promote-base-latest` shell
|
||||
fixes. pi stays `0.80.3`; the base image rebuilds (the mempalace-toolkit ref
|
||||
advanced and `Dockerfile.base` gained nano/micro), so the new bridge and editors
|
||||
land in `base-<hash>`.
|
||||
|
||||
### Added
|
||||
|
||||
- **Share one MemPalace across containers via `MEMPALACE_REMOTE_URL`.** The
|
||||
`mempalace.ts` bridge (from `mempalace-toolkit`) can now connect to a shared
|
||||
MemPalace over HTTP instead of spawning a per-container local server: set
|
||||
`MEMPALACE_REMOTE_URL=http://<host>:8765/mcp` (optionally
|
||||
`MEMPALACE_REMOTE_TOKEN`) in `.env` and no local `mempalace-mcp` is spawned.
|
||||
A new `docker-compose.mempalace.yml` stands up such a shared server
|
||||
(`mempalace-mcp --transport http`). Leaving the URL unset keeps the default
|
||||
local-per-container palace. See `.env.example`. (The HTTP transport is
|
||||
unauthenticated — keep it on a trusted network or behind a reverse proxy.)
|
||||
|
||||
- **Two non-modal terminal editors alongside `nvim`: `nano` and `micro`.**
|
||||
The image previously shipped only `nvim` (with `EDITOR=nvim`), a modal
|
||||
vi-style editor. Not everyone is comfortable with vi keybindings, so both
|
||||
a classic and a modern non-modal option now ship:
|
||||
- **`nano`** (apt) — ~2.8 MB installed. Its dependencies (`libc6`,
|
||||
`libncursesw6`, `libtinfo6`) are already present via `nvim`/`less`/`htop`/
|
||||
`tmux`, so it pulls in **no extra packages**. On-screen shortcut hints
|
||||
(`^O` write, `^X` exit) make it the lowest-friction fallback.
|
||||
- **`micro`** — ~12 MB, a single static Go binary installed from GitHub
|
||||
releases (same pattern as `bat`/`eza`/`zoxide`). Desktop-style keybindings
|
||||
(`Ctrl+S` save, `Ctrl+Q` quit, `Ctrl+C/V/X`, `Ctrl+Z` undo), mouse
|
||||
support, and syntax highlighting out of the box. Pin with
|
||||
`--build-arg MICRO_VERSION=vX.Y.Z`; defaults to `latest`.
|
||||
|
||||
Combined footprint is ~15 MB (<0.5% of the ~3.2 GB image). **`EDITOR`
|
||||
stays `nvim`** — the new editors are opt-in via `export EDITOR=micro`
|
||||
(or `nano`) and/or `git config --global core.editor micro`.
|
||||
|
||||
Note: micro's upstream repo moved `zyedidia/micro` → `micro-editor/micro`;
|
||||
the Dockerfile uses the canonical URL because the old org's
|
||||
`/releases/latest` redirect lands on another `/latest` URL (the org
|
||||
rename), which would defeat the tag-parsing `latest`-resolution idiom.
|
||||
These are base-image additions, so they only land once the `base-<hash>`
|
||||
rebuilds (this file changed, so the next build picks them up).
|
||||
|
||||
### Added (CI)
|
||||
|
||||
- **Workflow lint (`.gitea/workflows/lint.yml`) running on every push and PR.**
|
||||
Two complementary checks, so CI-workflow bugs are caught before an expensive
|
||||
build runs:
|
||||
- **`scripts/check-workflow-shell.sh`** — a Gitea-accurate guard that fails
|
||||
if any `run:` step doesn't resolve to `bash` under Gitea's real defaults.
|
||||
This catches the exact recurrence class (omit `shell:`, use bash syntax),
|
||||
which **actionlint alone does not** — actionlint models GitHub Actions
|
||||
(default shell = bash) and so assumes a shell-less step is bash, whereas
|
||||
Gitea's default is `sh`/dash.
|
||||
- **`actionlint` + `shellcheck`** — catches explicit `shell: sh` + bash
|
||||
syntax (SC3040 etc.), expression errors, and general workflow mistakes.
|
||||
Style-only shellcheck codes are excluded; the SC3xxx "wrong shell" family
|
||||
is kept.
|
||||
|
||||
### Changed (CI)
|
||||
|
||||
- **Workflow-level `defaults: run: shell: bash` in `docker-publish.yml`.**
|
||||
Gitea Actions defaults each `run:` step to `sh` (dash), so every bash-syntax
|
||||
step had to individually remember `shell: bash` — a discipline requirement
|
||||
that failed twice (ed49b8d, b7197e8). Setting the default workflow-wide
|
||||
eliminates the whole class. All pre-existing dash steps use only POSIX
|
||||
syntax, so bash (a superset) runs them unchanged.
|
||||
|
||||
### Fixed (CI)
|
||||
|
||||
- **`promote-base-latest` now sets `shell: bash` on the base-latest re-tag
|
||||
step.** The `b7197e8` fix (v1.2.4) moved the digest-compare into that step
|
||||
with `set -euo pipefail`, but Gitea Actions' default step shell is `sh`
|
||||
(dash), which rejects `-o pipefail` (`Illegal option -o pipefail`) and aborts
|
||||
the step before the `crane copy` runs. On the v1.2.4 release (run 418) this
|
||||
left `base-latest` un-promoted, still pointing at the v1.2.3 base — the four
|
||||
consumer tags (`v1.2.4`, `latest`, `v1.2.4-studio`, `latest-studio`) were
|
||||
unaffected because they `FROM` the exact `base-<hash>`, not `base-latest`.
|
||||
Same footgun as `ed49b8d` (`resolve-versions needs shell: bash`).
|
||||
|
||||
---
|
||||
|
||||
## v1.2.4 — 2026-06-29
|
||||
|
||||
Patch release. Headline: **pi `0.80.2` → `0.80.3`** (npm `latest`). Also ships a
|
||||
global gitignore baked into the image, secrets-via-`env_file`-only compose
|
||||
hardening, and a CI fix so `promote-base-latest` re-points `base-latest`
|
||||
reliably after a dry-run-first release. The mempalace pin stays `3.5.0`. The
|
||||
base image rebuilds because `Dockerfile.base` changed (the gitignore seed +
|
||||
`entrypoint-user.sh` wiring).
|
||||
|
||||
### Added
|
||||
|
||||
- **Global gitignore baked into the image.** A `~/.gitignore_global`
|
||||
(`*.bak`, `*.bak.*`, `*~`, `*.orig`, `*.swp`, `*.tmp`) is seeded into the home
|
||||
dir from `/etc/skel-devbox/` on first boot (seed-if-absent, like
|
||||
`.bash_aliases`/`.inputrc`, so user edits survive recreate) and wired via
|
||||
`git config --global core.excludesFile`. Personal/tooling backup artifacts are
|
||||
now ignored across all repos in the container without per-repo `.gitignore`
|
||||
entries. The `core.excludesFile` wiring is skipped if the user already set one.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Secrets are now delivered to the container via `env_file: .env` only; the
|
||||
`environment:` block no longer re-declares `GITEA_ACCESS_TOKEN`,
|
||||
`GITEA_HOST`, or `GITHUB_PERSONAL_ACCESS_TOKEN`.** An `environment:` entry
|
||||
both overrides `env_file:` and is interpolated from the host shell, so a
|
||||
stale shell export (e.g. one auto-loaded by an opencode/dotenv hook) would
|
||||
silently shadow the value in your `.env` — an updated token in `.env` never
|
||||
reached the container. Delivering secrets via `env_file` only decouples the
|
||||
container from whatever the host shell happens to export. No action needed:
|
||||
`.env.example` already documents every supported variable. Affects
|
||||
`docker-compose.yml` and the README “basic shape” snippet.
|
||||
|
||||
### Fixed (CI)
|
||||
|
||||
- **`promote-base-latest` now re-points `base-latest` reliably after a
|
||||
dry-run-first release.** The job's gate previously required
|
||||
`need_build == 'true'`, on the assumption that `need_build == false`
|
||||
implied `base-latest` was already current. That assumption breaks when a
|
||||
`workflow_dispatch` dry-run (`promote_latest=false`) pre-builds and pushes
|
||||
`base-<hash>` first: the subsequent tag run then sees `need_build == false`
|
||||
(probe hit) and **skipped** promotion, leaving `base-latest` pointing at the
|
||||
*previous* base. (Observed 2026-06-27 releasing v1.2.3 via dry-run-then-tag
|
||||
— `base-latest` ended up one base behind, lacking the mempalace self-heal.)
|
||||
Now the gate runs on every tag release (or `promote_latest=true` dispatch),
|
||||
and the no-op optimization moved **into** the step as a `crane digest`
|
||||
compare: it re-tags only when `base-latest` actually differs from the
|
||||
released `base-<hash>`, so genuine cache-hit releases stay a no-op while
|
||||
stale aliases get corrected. No image-content change; base hash unaffected.
|
||||
|
||||
---
|
||||
|
||||
## v1.2.3 — 2026-06-27
|
||||
|
||||
Patch release. Headline: **mempalace-mcp now self-heals** instead of latching
|
||||
`available=false` permanently after a slow cold-open. Also folds in the `yq`
|
||||
and mempalace-skill changes that were sitting unreleased. **No pi/mempalace
|
||||
version change** — pi npm `latest` is still `0.80.2` (= v1.2.2) and the
|
||||
mempalace pin stays `3.5.0`; the base image rebuilds purely because the
|
||||
`mempalace-toolkit` ref advances to pick up the self-heal extension.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **mempalace-mcp self-heal — no more permanent `available=false` latch.**
|
||||
The `mempalace.ts` pi extension (from `mempalace-toolkit`, bumped to
|
||||
[`e12b624`](https://gitea.jordbo.se/joakimp/mempalace-toolkit/commit/e12b624))
|
||||
previously tripped its per-request timeout on a slow virtiofs cold-open of
|
||||
the palace, killed the child, and set `available=false` **forever** (no
|
||||
respawn) — a pi restart was the only recovery.
|
||||
- **Bounded respawn with capped exponential backoff** via `ensureAlive()`
|
||||
(`MEMPALACE_MCP_MAX_RESPAWNS=2`, `MEMPALACE_MCP_RESPAWN_BACKOFF_MS=1000`;
|
||||
set max to `0` to disable). Both `execute()` and initial startup route
|
||||
through it. The respawn budget **resets on any successful JSON-RPC
|
||||
response** (`onStdout`), so a healthy session can't slowly exhaust it.
|
||||
- **Scoped init timeout** raised `120000 → 300000` ms (`MEMPALACE_MCP_INIT_TIMEOUT_MS`),
|
||||
affecting **init only** — the per-call timeout stays `60000`
|
||||
(`MEMPALACE_MCP_TIMEOUT_MS`) — so a genuine cold HNSW deserialize isn't
|
||||
killed mid-open.
|
||||
- **Concurrency hardening:** a generation counter prevents a late-exiting
|
||||
killed process from clobbering a fresh respawn, and an explicit `healthy`
|
||||
flag replaces the racy `proc != null` check.
|
||||
- Note: the build-time `smoke-test.sh` verifies the extension is present and
|
||||
deployed but does **not** exercise respawn behaviour — first live
|
||||
validation is on a running container.
|
||||
- **`yq` is now mikefarah's Go yq, not Debian's Python `yq`.** The base image
|
||||
previously apt-installed `yq`, which on Debian/Ubuntu is the unrelated
|
||||
kislyuk/`yq` (a jq wrapper, v3.x) — incompatible with the mikefarah v4 syntax
|
||||
the `cloud-init` repo's `provision.sh`/`deploy.sh` expect. Dropped the apt
|
||||
package and install the mikefarah binary instead (multi-arch amd64/arm64,
|
||||
following the repo's `latest` convention like `tealdeer`/`uv`; pin a tag
|
||||
with `--build-arg YQ_VERSION=vX.Y.Z`). The build-time `smoke-test.sh` gate
|
||||
asserts `yq --version` reports `mikefarah` **and** major **v4**, so both a
|
||||
regression to the Python package and a surprise future yq v5 fail CI.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Baked `mempalace` skill now teaches temporal grounding.** Added a
|
||||
*Temporal grounding* rule to the image-baked
|
||||
`skills/mempalace/SKILL.md` (Phase 1 wake-up + a matching anti-pattern):
|
||||
before using relative time terms ("yesterday", "last week"), establish the
|
||||
current date/time and compute the delta against the actual diary/drawer
|
||||
timestamp. Explicitly calls out that a **container recreate or fresh session
|
||||
is not a day boundary** — pi-devbox restarts several times a day, so two
|
||||
entries minutes apart can straddle a recreate. Fixes agents mislabelling
|
||||
same-day sessions as "yesterday".
|
||||
|
||||
---
|
||||
|
||||
## v1.2.2 — 2026-06-24
|
||||
|
||||
Patch release: pick up **pi `0.80.2`** (npm `latest`) and **mempalace `3.5.0`**,
|
||||
|
||||
+73
-12
@@ -51,11 +51,18 @@ ENV DEBIAN_FRONTEND=noninteractive
|
||||
# See the bundled `dot-watch` helper for live .dot -> PNG
|
||||
# re-render (handy with pi-studio's image preview).
|
||||
# imagemagick — image conversion / resizing for thumbnails, etc. ~50 MB.
|
||||
# yq — YAML-aware companion to jq.
|
||||
# (yq is NOT apt-installed: Debian's `yq` is the unrelated Python tool;
|
||||
# mikefarah's Go yq is installed as a pinned binary further down.)
|
||||
# socat — TCP relay. Powers `studio-expose`, which bridges
|
||||
# pi-studio's container-loopback server to the container's
|
||||
# external interface so a published port can reach it.
|
||||
# ~1 MB; generally useful for any port-forwarding need.
|
||||
# nano — small, non-modal terminal editor for users who don't want
|
||||
# a vi-based editor. ~2.8 MB installed; its deps (libc6,
|
||||
# libncursesw6, libtinfo6) are already pulled in by nvim/less/
|
||||
# htop/tmux, so it adds no extra packages. Companion to nvim
|
||||
# and the `micro` binary installed further down. EDITOR stays
|
||||
# nvim; users opt in via `export EDITOR=nano`.
|
||||
RUN apt-get update && \
|
||||
apt-get upgrade -y --no-install-recommends && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
@@ -66,7 +73,6 @@ RUN apt-get update && \
|
||||
openssh-client \
|
||||
gnupg \
|
||||
jq \
|
||||
yq \
|
||||
ripgrep \
|
||||
fd-find \
|
||||
tree \
|
||||
@@ -92,6 +98,7 @@ RUN apt-get update && \
|
||||
graphviz \
|
||||
imagemagick \
|
||||
socat \
|
||||
nano \
|
||||
&& ln -s /usr/bin/fdfind /usr/local/bin/fd \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
@@ -231,6 +238,33 @@ RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "arm64" ;
|
||||
ln -s /opt/nvim-linux-${ARCH}/bin/nvim /usr/local/bin/nvim && \
|
||||
nvim --version | head -1
|
||||
|
||||
# micro — modern, non-modal terminal editor. Ships alongside nvim so users
|
||||
# who aren't comfortable with vi-style modal editing have a friendly option:
|
||||
# desktop-style keybindings (Ctrl+S save, Ctrl+Q quit, Ctrl+C/V/X, Ctrl+Z
|
||||
# undo), mouse support, and syntax highlighting out of the box. A single
|
||||
# static Go binary (~12 MB) installed from GitHub releases, exactly like
|
||||
# bat/eza/zoxide below. EDITOR stays nvim (see below); users opt in with
|
||||
# `export EDITOR=micro` or `git config --global core.editor micro`.
|
||||
#
|
||||
# NOTE: upstream moved zyedidia/micro -> micro-editor/micro. The old org URL
|
||||
# still 302s, but its /releases/latest redirect lands on ANOTHER /latest URL
|
||||
# (the org rename), so the tag-parsing idiom below would resolve "latest"
|
||||
# instead of a version. Use the canonical micro-editor/micro URL.
|
||||
# Arch asset naming differs from the others: amd64 -> linux64, arm64 ->
|
||||
# linux-arm64. The tarball extracts to micro-<version>/micro.
|
||||
ARG MICRO_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "linux64" ;; arm64) echo "linux-arm64" ;; *) echo "linux64" ;; esac) && \
|
||||
V="${MICRO_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/micro-editor/micro/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && [ -n "$V" ] && \
|
||||
echo "Installing micro ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/micro-editor/micro/releases/download/v${V}/micro-${V}-${ARCH}.tar.gz" | tar -xz -C /tmp && \
|
||||
install /tmp/micro-${V}/micro /usr/local/bin/micro && \
|
||||
rm -rf /tmp/micro-${V} && \
|
||||
micro --version
|
||||
|
||||
# bat
|
||||
ARG BAT_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
||||
@@ -289,15 +323,21 @@ RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64"
|
||||
# Always installed in the base. Set INSTALL_MEMPALACE=false at base-build
|
||||
# time to shave ~300 MB.
|
||||
#
|
||||
# Stall protection (fixed 2026-06-13): mempalace-mcp is launched by the
|
||||
# `mempalace.ts` pi extension from mempalace-toolkit (cloned below). That
|
||||
# extension now applies a per-REQUEST timeout in its JSON-RPC client and
|
||||
# kills the child on stall, so a virtiofs cold-open of chroma.sqlite3 /
|
||||
# HNSW load can no longer hang the pi TUI uninterruptibly. Tunables:
|
||||
# Stall protection (fixed 2026-06-13; self-heal added 2026-06-25):
|
||||
# mempalace-mcp is launched by the `mempalace.ts` pi extension from
|
||||
# mempalace-toolkit (cloned below). That extension applies a per-REQUEST
|
||||
# timeout in its JSON-RPC client and kills the child on stall, so a virtiofs
|
||||
# cold-open of chroma.sqlite3 / HNSW load can no longer hang the pi TUI
|
||||
# uninterruptibly. A stall-kill is no longer a permanent latch either: the
|
||||
# next tool call respawns the server with capped exponential backoff (the
|
||||
# budget resets on any successful response). Tunables:
|
||||
# MEMPALACE_MCP_TIMEOUT_MS (default 60000), MEMPALACE_MCP_INIT_TIMEOUT_MS
|
||||
# (default 120000); 0 disables. A standalone stdio-watchdog shim is NOT
|
||||
# needed — the extension already owns request/response correlation. See
|
||||
# CHANGELOG.md "Unreleased > Fixed".
|
||||
# (default 300000 — generous so a genuine first cold-open isn't killed),
|
||||
# MEMPALACE_MCP_MAX_RESPAWNS (default 2; 0 disables self-heal),
|
||||
# MEMPALACE_MCP_RESPAWN_BACKOFF_MS (default 1000); timeouts of 0 disable.
|
||||
# Defaults live in the extension, so no ENV is needed here. A standalone
|
||||
# stdio-watchdog shim is NOT needed — the extension already owns
|
||||
# request/response correlation. See CHANGELOG.md "Unreleased > Fixed".
|
||||
ARG INSTALL_MEMPALACE=true
|
||||
# Pin to a known-good version. Bump deliberately, not implicitly: an
|
||||
# unpinned install silently swept in mempalace 3.3.x/3.4.0 with a broken
|
||||
@@ -404,6 +444,26 @@ RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64"
|
||||
chmod +x /usr/local/bin/tldr && \
|
||||
tldr --version
|
||||
|
||||
# ── yq (mikefarah) — YAML processor, jq's companion for YAML ─────────
|
||||
# Installed as the mikefarah Go binary — NOT Debian's `yq` apt package, which
|
||||
# is the unrelated Python kislyuk/yq (a jq wrapper with different syntax and
|
||||
# version line, e.g. 3.x). The cloud-init repo's deploy.sh/provision.sh
|
||||
# require mikefarah yq v4 (the unrelated Debian python yq is v3.x). Follows
|
||||
# the repo's `latest` convention (like tealdeer/uv/etc.); the smoke test pins
|
||||
# the contract to major v4, so a future yq v5 fails CI instead of silently
|
||||
# breaking provision.sh. Pin a specific tag with --build-arg YQ_VERSION=vX.Y.Z.
|
||||
ARG YQ_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
|
||||
V="${YQ_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/mikefarah/yq/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
[ -n "$V" ] && \
|
||||
echo "Installing mikefarah yq ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/mikefarah/yq/releases/download/${V}/yq_linux_${ARCH}" -o /usr/local/bin/yq && \
|
||||
chmod +x /usr/local/bin/yq && \
|
||||
yq --version
|
||||
|
||||
# ── AWS CLI v2 (for SSO/Bedrock authentication) ─────────────────────
|
||||
RUN ARCH=$(case "${TARGETARCH}" in \
|
||||
amd64) echo "x86_64" ;; \
|
||||
@@ -451,8 +511,9 @@ ENV PATH="/home/${USER_NAME}/.pi/npm-global/bin:${PATH}"
|
||||
|
||||
# ── Shell defaults (bash history, aliases, readline) ─────────────────
|
||||
RUN mkdir -p /etc/skel-devbox
|
||||
COPY rootfs/home/developer/.bash_aliases /etc/skel-devbox/.bash_aliases
|
||||
COPY rootfs/home/developer/.inputrc /etc/skel-devbox/.inputrc
|
||||
COPY rootfs/home/developer/.bash_aliases /etc/skel-devbox/.bash_aliases
|
||||
COPY rootfs/home/developer/.inputrc /etc/skel-devbox/.inputrc
|
||||
COPY rootfs/home/developer/.gitignore_global /etc/skel-devbox/.gitignore_global
|
||||
|
||||
# ── Entrypoint ────────────────────────────────────────────────────────
|
||||
COPY rootfs/usr/local/lib/pi-devbox/ /usr/local/lib/pi-devbox/
|
||||
|
||||
@@ -29,13 +29,18 @@ on the host.
|
||||
- ChromaDB embedding model pre-warmed at build time (`all-MiniLM-L6-v2`)
|
||||
|
||||
The host-mounted palace at `~/.mempalace` is shared across the host and
|
||||
this container so all your agents share one brain.
|
||||
this container so all your agents share one brain. To instead share a palace
|
||||
across *several* containers/harnesses, set `MEMPALACE_REMOTE_URL` to a shared
|
||||
MemPalace HTTP endpoint (see `.env.example` and `docker-compose.mempalace.yml`);
|
||||
the bridge then connects over HTTP and spawns no local server.
|
||||
|
||||
### Modern CLI tooling
|
||||
|
||||
| Tool | Purpose |
|
||||
|---|---|
|
||||
| `nvim` | Neovim text editor |
|
||||
| `nvim` | Neovim text editor (modal / vi-style) |
|
||||
| `nano` | Small non-modal editor (on-screen shortcut hints) |
|
||||
| `micro` | Modern non-modal editor (Ctrl+S/Ctrl+Q keys, mouse, syntax highlighting) |
|
||||
| `tmux` | Terminal multiplexer (configured for 0-indexed sessions) |
|
||||
| `ripgrep`, `fd` | Fast file content / filename search |
|
||||
| `fzf` | Fuzzy finder |
|
||||
@@ -49,6 +54,11 @@ this container so all your agents share one brain.
|
||||
| `gosu` | Privilege de-escalation in entrypoint |
|
||||
| `htop`, `tree`, `less` | Inspection utilities |
|
||||
|
||||
The default `$EDITOR` is `nvim`. Three editors ship so you can pick your
|
||||
comfort level — if you'd rather not use a vi-style editor, `nano` and `micro`
|
||||
are both non-modal. Set your preference with `export EDITOR=micro` (or `nano`)
|
||||
in your shell profile, and/or `git config --global core.editor micro`.
|
||||
|
||||
### Document and image tooling
|
||||
|
||||
- `pandoc` — universal Markdown↔HTML/Org/RST/etc. converter
|
||||
@@ -319,9 +329,10 @@ services:
|
||||
environment:
|
||||
- TERM=xterm-256color
|
||||
# - STUDIO_EXPOSE=1 # -studio only: auto-start the socat bridge on boot
|
||||
- GITEA_ACCESS_TOKEN=${GITEA_ACCESS_TOKEN:-}
|
||||
- GITEA_HOST=${GITEA_HOST:-}
|
||||
- GITHUB_PERSONAL_ACCESS_TOKEN=${GITHUB_PERSONAL_ACCESS_TOKEN:-}
|
||||
# Secrets (GITEA_*, GITHUB_*, …) come from env_file: .env above — not
|
||||
# duplicated here. An environment: entry overrides env_file and is
|
||||
# interpolated from the host shell, so a stale shell export would
|
||||
# silently shadow your .env. See .env.example for the full list.
|
||||
volumes:
|
||||
# Workspace: your host source tree
|
||||
- ${WORKSPACE_PATH:-.}:/workspace
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Shared MemPalace server (optional) — one palace for many clients.
|
||||
#
|
||||
# Runs `mempalace-mcp` over HTTP so several containers/harnesses (pi +
|
||||
# opencode + native) can share ONE palace instead of each keeping its own.
|
||||
# Point every client at it by setting, in that client's .env:
|
||||
#
|
||||
# MEMPALACE_REMOTE_URL=http://<reachable-host>:8765/mcp
|
||||
#
|
||||
# (see .env.example). When set, the client connects over HTTP and does NOT
|
||||
# spawn its own local mempalace-mcp.
|
||||
#
|
||||
# Start: docker compose -f docker-compose.mempalace.yml up -d
|
||||
# Stop: docker compose -f docker-compose.mempalace.yml down
|
||||
# Logs: docker compose -f docker-compose.mempalace.yml logs -f
|
||||
#
|
||||
# Why reuse the devbox image? mempalace-mcp is already installed in it, and
|
||||
# reusing it GUARANTEES the server's mempalace version matches the clients'
|
||||
# (both are pinned by the same image build). Override with a slimmer image via
|
||||
# MEMPALACE_SERVER_IMAGE if you prefer (it must provide `mempalace-mcp`).
|
||||
#
|
||||
# ⚠ SECURITY: mempalace-mcp's HTTP transport has NO authentication of its own.
|
||||
# Do NOT expose port 8765 to an untrusted network. The default below binds to
|
||||
# 127.0.0.1 (host loopback) only. To let sibling containers reach it, either
|
||||
# attach them to the shared `mempalace-net` network (container-to-container, no
|
||||
# host port needed — use http://mempalace-server:8765/mcp), or front it with a
|
||||
# reverse proxy that enforces MEMPALACE_REMOTE_TOKEN as `Authorization: Bearer`.
|
||||
|
||||
name: mempalace-server
|
||||
|
||||
services:
|
||||
mempalace:
|
||||
image: ${MEMPALACE_SERVER_IMAGE:-joakimp/pi-devbox:latest}
|
||||
container_name: mempalace-server
|
||||
# Bypass the devbox entrypoint (dev-shell/LAN/config setup) and run the
|
||||
# HTTP MCP server directly. HOME + explicit --palace pin the data path so
|
||||
# it does not depend on the image's default user/HOME. Runs as root so it
|
||||
# can initialise the fresh named volume; the volume is dedicated to this
|
||||
# server (clients reach it over HTTP, never by mounting it).
|
||||
entrypoint: []
|
||||
user: "0:0"
|
||||
environment:
|
||||
- HOME=/data
|
||||
command:
|
||||
- mempalace-mcp
|
||||
- --transport
|
||||
- http
|
||||
- --host
|
||||
- "0.0.0.0"
|
||||
- --port
|
||||
- "8765"
|
||||
- --palace
|
||||
- /data/.mempalace
|
||||
restart: unless-stopped
|
||||
# Loopback-only by default (see SECURITY note). Use "8765:8765" to expose on
|
||||
# all host interfaces, or drop `ports:` entirely and rely on mempalace-net.
|
||||
ports:
|
||||
- "127.0.0.1:8765:8765"
|
||||
volumes:
|
||||
# The shared palace data — precious; back this up.
|
||||
- mempalace-shared:/data/.mempalace
|
||||
# Embedding-model cache (~79 MB, disposable) so search does not re-download.
|
||||
- mempalace-shared-chroma:/data/.cache/chroma
|
||||
networks:
|
||||
- mempalace-net
|
||||
healthcheck:
|
||||
# A tools/list round-trip proves the server is answering MCP (python3 is
|
||||
# always present — mempalace itself is a python tool in the image).
|
||||
test:
|
||||
- CMD
|
||||
- python3
|
||||
- -c
|
||||
- "import urllib.request,json; d=json.dumps({'jsonrpc':'2.0','id':1,'method':'tools/list','params':{}}).encode(); r=urllib.request.Request('http://127.0.0.1:8765/mcp',data=d,headers={'Content-Type':'application/json','Accept':'application/json'}); urllib.request.urlopen(r,timeout=5).read()"
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
|
||||
volumes:
|
||||
mempalace-shared:
|
||||
mempalace-shared-chroma:
|
||||
|
||||
networks:
|
||||
mempalace-net:
|
||||
name: mempalace-net
|
||||
+11
-4
@@ -31,9 +31,13 @@ services:
|
||||
- .env
|
||||
environment:
|
||||
- TERM=xterm-256color
|
||||
- GITEA_ACCESS_TOKEN=${GITEA_ACCESS_TOKEN:-}
|
||||
- GITEA_HOST=${GITEA_HOST:-}
|
||||
- GITHUB_PERSONAL_ACCESS_TOKEN=${GITHUB_PERSONAL_ACCESS_TOKEN:-}
|
||||
# Secrets (GITEA_*, GITHUB_*, and any others) are delivered to the
|
||||
# container via `env_file: .env` above — do NOT duplicate them here.
|
||||
# An `environment:` entry overrides env_file AND is interpolated from
|
||||
# the host shell, so a stale shell export (e.g. one auto-loaded by a
|
||||
# dotenv hook) would silently shadow the value in your .env. Keeping
|
||||
# secrets env_file-only decouples the container from the host shell.
|
||||
# See .env.example for the full list of supported variables.
|
||||
volumes:
|
||||
# Host workspace — mount your project here
|
||||
- ${WORKSPACE_PATH:-.}:/workspace
|
||||
@@ -72,7 +76,10 @@ services:
|
||||
# Persist uv data (Python installs, tool installs)
|
||||
- devbox-uv:/home/developer/.local/share/uv
|
||||
|
||||
# Optional: persist MemPalace data (conversation memory, knowledge graph)
|
||||
# Optional: persist MemPalace data (conversation memory, knowledge graph).
|
||||
# Applies to the LOCAL palace only (the default). In EXTERNAL mode
|
||||
# (MEMPALACE_REMOTE_URL set in .env) the shared server owns the data, so
|
||||
# this volume is irrelevant.
|
||||
# - devbox-palace:/home/developer/.mempalace
|
||||
|
||||
# Optional: persist ChromaDB embedding model cache (~79 MB)
|
||||
|
||||
+7
-1
@@ -33,7 +33,7 @@ fi
|
||||
# directly.
|
||||
SKEL_DIR="/etc/skel-devbox"
|
||||
if [ -d "$SKEL_DIR" ]; then
|
||||
for f in .bash_aliases .inputrc; do
|
||||
for f in .bash_aliases .inputrc .gitignore_global; do
|
||||
if [ -f "$SKEL_DIR/$f" ] && [ ! -e "$HOME/$f" ]; then
|
||||
cp "$SKEL_DIR/$f" "$HOME/$f"
|
||||
fi
|
||||
@@ -91,6 +91,12 @@ fi
|
||||
if [ -n "${GIT_USER_EMAIL:-}" ] && ! git config --global user.email &>/dev/null; then
|
||||
git config --global user.email "$GIT_USER_EMAIL"
|
||||
fi
|
||||
# Global gitignore for personal/tooling artifacts (*.bak, *~, *.orig, ...).
|
||||
# Seeded above into $HOME/.gitignore_global from /etc/skel-devbox. Point git at
|
||||
# it only if the user has not already set their own core.excludesFile.
|
||||
if [ -f "$HOME/.gitignore_global" ] && ! git config --global core.excludesFile &>/dev/null; then
|
||||
git config --global core.excludesFile "$HOME/.gitignore_global"
|
||||
fi
|
||||
|
||||
# ── pi: deploy toolkit + extensions + mempalace bridge ─────────────
|
||||
# pi is always installed in pi-devbox; no INSTALL_PI guard needed.
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Global gitignore — personal/tooling artifacts (applies to all repos in the container)
|
||||
# Seeded into $HOME/.gitignore_global by entrypoint-user.sh and wired via
|
||||
# `git config --global core.excludesFile`. Edit freely; it is yours after first boot.
|
||||
|
||||
# backup / editor / merge artifacts
|
||||
*.bak
|
||||
*.bak.*
|
||||
*~
|
||||
*.orig
|
||||
*.swp
|
||||
*.tmp
|
||||
@@ -43,6 +43,29 @@ Run these immediately when a session begins, before responding to the user:
|
||||
|
||||
Do NOT announce this to the user. Just do it silently to orient yourself.
|
||||
|
||||
### Temporal grounding — compute time deltas, don't guess
|
||||
|
||||
Diary entries and drawers carry real timestamps (`timestamp`, `created_at`).
|
||||
Before describing *when* something happened — "yesterday", "earlier today",
|
||||
"last week", "a while back" — **establish the current date/time first and
|
||||
compute the delta against the actual timestamp.** Get "now" from the injected
|
||||
session date or by running `date` in a shell; never infer it.
|
||||
|
||||
**A container recreate or a fresh session is NOT a day boundary.** A pi-devbox
|
||||
container is frequently restarted — often several times within the *same* day —
|
||||
and each restart begins a new session with a fresh wake-up. Do not reason "new
|
||||
session ⇒ last session was yesterday": two diary entries 90 minutes apart can
|
||||
straddle a container recreate. The only authoritative clock is the timestamp on
|
||||
the memory, not the session/container boundary.
|
||||
|
||||
**Practical rule:** prefer explicit, checkable phrasing — e.g. "earlier today,
|
||||
~8h ago (both 2026-06-25)" — over a vague relative term. If you catch yourself
|
||||
about to write "yesterday" / "last week", subtract `now − entry.timestamp` and
|
||||
state the computed result. (Remember timestamps may be UTC while the wall clock
|
||||
is local — reconcile the offset before computing the delta.) Note too that
|
||||
session feeders can lag up to a week (see *Multi-harness palace*), so a recent
|
||||
absence in `wing_conversations` is not proof nothing happened.
|
||||
|
||||
### Phase 2: Active Session (during work)
|
||||
|
||||
#### Search Before You Speak
|
||||
@@ -294,6 +317,7 @@ Entity-relationship triples with temporal validity. Query with `mempalace_kg_que
|
||||
## Anti-Patterns
|
||||
|
||||
- **Don't guess when you can search.** If a question touches past work, search first.
|
||||
- **Don't infer elapsed time from session or container boundaries.** A restart isn't a new day. Compare the actual timestamp (`timestamp` / `created_at`) against the current date/time before saying "yesterday", "last week", etc.
|
||||
- **Don't skip the diary.** A session without a diary entry is a session forgotten.
|
||||
- **Don't summarize drawer content.** File verbatim — the embedding model needs the original words.
|
||||
- **Don't mine .git directories or node_modules.** The CLI miner respects .gitignore by default.
|
||||
|
||||
Executable
+65
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env bash
|
||||
# Gitea-accurate guard against the recurring "bash syntax under the default
|
||||
# sh/dash shell" footgun (ed49b8d resolve-versions; b7197e8 promote-base-latest,
|
||||
# run 418).
|
||||
#
|
||||
# WHY A CUSTOM CHECK AND NOT JUST actionlint:
|
||||
# actionlint models *GitHub* Actions, whose default `run` shell is bash. It
|
||||
# therefore assumes a step that omits `shell:` runs under bash, and does NOT
|
||||
# flag `set -o pipefail` there. Gitea Actions' default is `sh` (dash), so the
|
||||
# exact bug we hit (omit `shell:`, use bash syntax) is invisible to actionlint.
|
||||
# actionlint only fires when a step *explicitly* declares `shell: sh`.
|
||||
#
|
||||
# THE INVARIANT THIS ENFORCES:
|
||||
# Every `run:` step in every .gitea/workflows/*.yml must resolve to an
|
||||
# effective shell of `bash` — via the step's own `shell:`, a job-level
|
||||
# `defaults.run.shell`, or a workflow-level `defaults.run.shell`. Any step
|
||||
# that would fall through to Gitea's `sh` default is a FAILURE, because a
|
||||
# future author adding bash syntax to it fails silently in CI.
|
||||
#
|
||||
# Pair this with actionlint (which catches explicit `shell: sh` + bash syntax,
|
||||
# expression errors, and much else). Together they cover the class on Gitea.
|
||||
set -euo pipefail
|
||||
|
||||
WF_DIR="${1:-.gitea/workflows}"
|
||||
|
||||
python3 - "$WF_DIR" <<'PY'
|
||||
import sys, glob, os
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
sys.stderr.write("ERROR: python3 yaml module missing (apt install python3-yaml)\n")
|
||||
sys.exit(2)
|
||||
|
||||
wf_dir = sys.argv[1]
|
||||
files = sorted(glob.glob(os.path.join(wf_dir, "*.yml")) + glob.glob(os.path.join(wf_dir, "*.yaml")))
|
||||
if not files:
|
||||
sys.stderr.write(f"ERROR: no workflow files under {wf_dir}\n")
|
||||
sys.exit(2)
|
||||
|
||||
problems = []
|
||||
for f in files:
|
||||
with open(f) as fh:
|
||||
doc = yaml.safe_load(fh) or {}
|
||||
wf_shell = (((doc.get("defaults") or {}).get("run") or {}).get("shell"))
|
||||
jobs = doc.get("jobs") or {}
|
||||
for jname, job in jobs.items():
|
||||
job = job or {}
|
||||
job_shell = (((job.get("defaults") or {}).get("run") or {}).get("shell"))
|
||||
steps = job.get("steps") or []
|
||||
for i, step in enumerate(steps):
|
||||
step = step or {}
|
||||
if "run" not in step:
|
||||
continue # `uses:` steps have no shell
|
||||
eff = step.get("shell") or job_shell or wf_shell or "sh" # Gitea default = sh
|
||||
if eff != "bash":
|
||||
name = step.get("name") or f"step[{i}]"
|
||||
problems.append(f"{f}: job '{jname}' / '{name}': effective shell = '{eff}' (Gitea default is sh; declare shell: bash or a bash default)")
|
||||
|
||||
if problems:
|
||||
sys.stderr.write("Workflow shell guard FAILED — bash default not guaranteed:\n")
|
||||
for p in problems:
|
||||
sys.stderr.write(f" - {p}\n")
|
||||
sys.exit(1)
|
||||
print(f"Workflow shell guard OK — all run: steps in {len(files)} workflow file(s) resolve to bash.")
|
||||
PY
|
||||
@@ -6,6 +6,7 @@
|
||||
# Verifies:
|
||||
# - pi binary present and (if EXPECTED_PI_VERSION set) matches CI's resolved version
|
||||
# - new v1.0.0 base additions (pandoc, graphviz, imagemagick, yq, tealdeer)
|
||||
# - non-modal editors nano + micro (alongside nvim)
|
||||
# - tmux 0-indexing baked in /etc/tmux.conf (required for pi-studio variants)
|
||||
# - pi-toolkit cloned at /opt/pi-toolkit
|
||||
# - pi-extensions cloned at /opt/pi-extensions
|
||||
@@ -24,7 +25,8 @@ set -euo pipefail
|
||||
IMAGE="${1:?usage: $0 <image>}"
|
||||
PASS=0; FAIL=0
|
||||
# pi-devbox v1.0.0 (decoupled from opencode-devbox) added pandoc, graphviz,
|
||||
# imagemagick, yq, tealdeer, and a baked /etc/tmux.conf. Local arm64 build
|
||||
# imagemagick, yq, tealdeer, a baked /etc/tmux.conf, and the non-modal
|
||||
# editors nano + micro (~15 MB combined). Local arm64 build
|
||||
# observed 3.20 GB. CI amd64 builds may differ slightly; threshold below
|
||||
# carries +300 MB margin to absorb arch differences without false reds.
|
||||
# Tighten in a follow-up release once amd64 actuals are observed in CI logs.
|
||||
@@ -71,12 +73,14 @@ run "git" "git --version"
|
||||
run "aws" "aws --version"
|
||||
run "uv" "uv --version"
|
||||
run "nvim" "nvim --version"
|
||||
run "nano" "nano --version"
|
||||
run "micro" "micro --version"
|
||||
run "mempalace-mcp" "mempalace-mcp --help"
|
||||
# v1.0.0 base additions — verify presence and basic functionality.
|
||||
run "pandoc" "pandoc --version"
|
||||
run "graphviz (dot)" "dot -V"
|
||||
run "imagemagick" "magick --version"
|
||||
run "yq" "yq --version"
|
||||
run "yq (mikefarah v4)" "yq --version | grep -qE 'mikefarah.*version v4'"
|
||||
run "tldr (tealdeer)" "tldr --version"
|
||||
run "socat" "socat -V"
|
||||
run "studio-expose helper" "test -x /usr/local/bin/studio-expose"
|
||||
|
||||
Reference in New Issue
Block a user