a6abe17edc
pi auto-loads every .ts in ~/.pi/agent/extensions/, which lives in the devbox-pi-config volume. A baked extension only seeds a fresh volume once, and a hand-copied one is a per-device step that drifts — the same two problems link-pi-models.sh was added for on 2026-10-12, one level down. This is also the only path that reaches every host: four of them mount common/pi from docker-compose-repo, and three cannot be reached from a devbox to create a symlink by hand (measured: no ssh config entry, no name resolution, and this instance's ~/.ssh-local/config carries no jump alias — only gitea and the mempalace feed host are reachable). link-pi-extensions.sh links each TOP-LEVEL *.ts from the mounted fleet directory (default /opt/fleet-pi, the same directory that already carries models.json; PI_EXTENSIONS_SRC overrides, =off disables). A symlink, not a single-file bind mount, for the measured reason already documented for models.json: git pull replaces a file by rename, and a file mount then serves stale content. Never destructive — a real file of that name in ~/.pi/agent/extensions/ wins over the fleet copy. Nested *.ts are deliberately not linked: the directory is fleet pi config, not an extension tree. - scripts/test-link-pi-extensions.sh: 16 two-sided cases (the negative half is the contract), all passing, wired into lint.yml beside the models one. - smoke-test.sh and recreate-sanity-check.sh gained the matching in-image and on-device checks, each gated on the fleet directory actually holding *.ts so a device without a shared directory is not judged by them. - README: the shared directory can carry extensions, and the rules that apply. Also repairs a pre-existing release blocker, found while checking this change. check-doc-drift.sh failed on pristine HEAD (verified in a clean worktree): pi-extensions main moved 0d8e417 -> 4c1d28d without the SHA being named above the v1.11.2 heading, and docker-publish.yml's resolve-versions has needs: [lint-gate] — so the next tag could not have published. The CHANGELOG now names 4c1d28d and describes the two confirm-destructive commits from their diff (they stop the gate prompting on harmless redirects, and add tee/dd of= as write forms); node --test test/confirm-destructive.test.mjs at 4c1d28d: 2 pass, 0 fail. Local verification: lint-shell.sh (21 files, severity error), check-workflow-shell.sh, check-skill-floor.sh, check-dockerfile-directives.sh, check-doc-drift.sh, and both link-pi-*.sh contract suites all pass. actionlint and hadolint are not installed in a devbox, so those CI steps are unvalidated here; the smoke-test addition runs only inside the image at release time.
243 lines
12 KiB
YAML
243 lines
12 KiB
YAML
name: Lint
|
|
|
|
# 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 branch push/PR — not just on release tags,
|
|
# which is where the build workflow (docker-publish.yml) is otherwise only
|
|
# triggered.
|
|
#
|
|
# `branches: ['**']` (rather than a bare `push:`) deliberately EXCLUDES tag
|
|
# pushes. A bare `push:` also fires on `refs/tags/v*`, which was duplicate work —
|
|
# the tagged tree was already linted when the same commit was pushed to main
|
|
# (v1.6.4: lint id=529 on refs/heads/main, then id=531 again on
|
|
# refs/tags/v1.6.4, same sha e86e5df). The wasted compute is small (measured:
|
|
# lint here runs 0.3-0.9 min, against a 77.6 min release build for v1.6.4 — so
|
|
# runner contention is NOT a real argument in this repo, unlike opencode-devbox
|
|
# where actionlint installs shellcheck and takes 6-15 min). The substantive
|
|
# reason is discovery ambiguity: the runs listing is newest-first, so the
|
|
# tag-ref lint run sorts ABOVE the publish run, and "first run matching
|
|
# refs/tags/<tag>" picks lint — which goes green in under a minute while the
|
|
# image is still building, making a release look finished before anything is
|
|
# published. See AGENTS.md "Gitea API access" for the head_sha-filtered
|
|
# discovery pattern.
|
|
on:
|
|
push:
|
|
branches:
|
|
- '**'
|
|
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: "Shellcheck + syntax-check repository scripts (severity: error)"
|
|
# Gap being closed: everything else in this job shellchecks workflow
|
|
# `run:` steps ONLY, via actionlint. The repo's own shell scripts —
|
|
# entrypoint.sh, scripts/*.sh, and the extensionless tools under
|
|
# rootfs/usr/local/bin/ — have never been shellchecked. That exact gap
|
|
# (a sibling repo with no shell-script lint at all) is how a defect
|
|
# shipped invisibly for two months: `echo "$json" | python3 <<'EOF'
|
|
# ... json.load(sys.stdin)` cannot work — with no script argument
|
|
# python reads its SCRIPT from stdin, so the heredoc IS stdin and the
|
|
# json.load call hits EOF. shellcheck flags exactly this at severity
|
|
# ERROR (SC2259, "This redirection overrides piped input"); nothing
|
|
# ever ran it. Measured before adding this gate: `-S error` is 0
|
|
# findings across every shell file in THIS repo today, so it is free
|
|
# to add. `-S warning` is NOT free here (19x SC2088 tilde-in-quotes in
|
|
# scripts/recreate-sanity-check.sh, plus assorted SC2016 — both
|
|
# intentional), so warning-level would train people to ignore the job;
|
|
# hence error-only, matching the SHELLCHECK_OPTS philosophy below.
|
|
#
|
|
# Discovery is *.sh UNION a shebang scan, because rootfs/usr/local/
|
|
# bin/{pi-devbox-version,devbox-skill-reconcile,dot-watch,studio-expose}
|
|
# are shell scripts with no extension. -print0/mapfile -d '' so a path
|
|
# with a space cannot silently split, and the file count is asserted
|
|
# non-zero — a green tick over an empty file set is not a check.
|
|
#
|
|
# The implementation moved to scripts/lint-shell.sh on 2026-09-08 so the
|
|
# release gate in docker-publish.yml runs the SAME code rather than a
|
|
# second copy that drifts. Edit the script, not a copy of it.
|
|
run: bash scripts/lint-shell.sh
|
|
|
|
- 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: link-pi-models.sh contract (two-sided, no docker needed)
|
|
# The entrypoint links ~/.pi/agent/models.json to a mounted shared file
|
|
# and must never replace a real file or a user's own link. Tested here
|
|
# on every push, because the smoke test only runs at release time.
|
|
run: bash scripts/test-link-pi-models.sh
|
|
|
|
- name: link-pi-extensions.sh contract (two-sided, no docker needed)
|
|
# Same reasoning as the models step above: the entrypoint links top-level
|
|
# *.ts from the mounted fleet directory into ~/.pi/agent/extensions, and
|
|
# must never replace a user's own extension of the same name.
|
|
run: bash scripts/test-link-pi-extensions.sh
|
|
|
|
- name: Dockerfile.variant still opens with its BuildKit check directive
|
|
# See scripts/check-dockerfile-directives.sh: without that first line the
|
|
# smoke jobs fail with `invalid format delimiter 'ghadelimiter_...'` and
|
|
# NO failing step. Also wired into docker-publish.yml's lint-gate.
|
|
run: bash scripts/check-dockerfile-directives.sh Dockerfile.variant
|
|
|
|
- name: Install actionlint (pinned)
|
|
env:
|
|
ACTIONLINT_VERSION: 1.7.12
|
|
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
|
|
|
|
hadolint:
|
|
# Lint the two Dockerfiles that ARE the project (the shell/actions linting
|
|
# above never looked at them). Config — ignored rules + failure threshold
|
|
# — lives in .hadolint.yaml, which hadolint reads automatically, so a local
|
|
# `hadolint Dockerfile.base` reproduces CI exactly.
|
|
runs-on: ubuntu-latest
|
|
container:
|
|
image: catthehacker/ubuntu:act-latest
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
- name: Install hadolint (pinned)
|
|
env:
|
|
HADOLINT_VERSION: 2.15.1
|
|
run: |
|
|
curl -fsSL \
|
|
"https://github.com/hadolint/hadolint/releases/download/v${HADOLINT_VERSION}/hadolint-Linux-x86_64" \
|
|
-o /usr/local/bin/hadolint
|
|
chmod +x /usr/local/bin/hadolint
|
|
hadolint --version
|
|
|
|
- name: Run hadolint
|
|
run: hadolint Dockerfile.base Dockerfile.variant
|
|
|
|
skill-floor:
|
|
# Gate the VENDORED pi-extensions skill snapshot in rootfs/ against the
|
|
# package repo it is a snapshot of. Its own job rather than a step in
|
|
# `actionlint`, so "the floor is stale" is a distinct red name in the runs
|
|
# list instead of being buried in a lint job that is about something else.
|
|
#
|
|
# The gap it closes, measured 2026-09-10: the floor sat at 34284 B, untouched
|
|
# since fa04d20 (2026-07-30), while the package copy was 38973 B.
|
|
# Dockerfile.variant copies the fresh package copy over the SERVED path but
|
|
# never writes back to the floor, so nothing in the repo ever noticed. That
|
|
# matters because the floor is a FALLBACK: the copy is guarded by
|
|
# `if [ -f /opt/pi-extensions/skill/SKILL.md ]`, so a build whose clone
|
|
# yields no skill/ ships the vendored snapshot and still goes green, with no
|
|
# manifest flag or label saying which copy was served.
|
|
#
|
|
# Gating on another repo is normally a smell; it is proportionate here
|
|
# because the check compares the skill DIRECTORY hash, so it can only fire
|
|
# when that directory actually changed — which is exactly when the floor has
|
|
# gone stale. pi-extensions commits that leave skill/ alone cannot turn this
|
|
# red. No secret is needed either: the repo is anonymously clonable (verified
|
|
# 2026-09-10 with `git ls-remote` and no credentials), so this cannot start
|
|
# failing when a token expires.
|
|
#
|
|
# Exit codes are 0 in sync / 1 drift / 2 cannot-run, matching
|
|
# scripts/lint-shell.sh: a gate that cannot run must not pass, so an
|
|
# unreachable package repo is a red 2 rather than a green tick.
|
|
runs-on: ubuntu-latest
|
|
container:
|
|
image: catthehacker/ubuntu:act-latest
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
- name: Vendored pi-extensions skill floor matches the package
|
|
run: bash scripts/check-skill-floor.sh
|
|
|
|
doc-drift:
|
|
# Gate hand-maintained doc claims against the build files they describe.
|
|
# Its own job for the same reason as skill-floor: "the docs lie" should be a
|
|
# distinct red name, not a line buried in a job about workflow syntax.
|
|
#
|
|
# The gap it closes, measured 2026-09-10 while preparing v1.9.0 — five
|
|
# claims had rotted, every one of them a fact written by hand in a file
|
|
# nothing verified:
|
|
# * README.md's "Version pins" table was wrong on ALL THREE rows (pi
|
|
# 0.84.4 vs 0.85.1, pi-atelier v0.10.0 vs v0.10.1, mempalace 3.8.0 vs
|
|
# 3.9.0) — and that table exists specifically to be the reviewable
|
|
# record of what the repo freezes on purpose, so a wrong row destroys
|
|
# the only thing it is for.
|
|
# * README.md listed already-shipped typst PDF export under "Planned for
|
|
# an upcoming minor release", marked "(shipped in Unreleased/base)".
|
|
# * DOCKER_HUB.md claimed "Node.js v22" while v1.9.0 ships Node 24.
|
|
#
|
|
# DOCKER_HUB.md is why this is a gate and not a habit. It is PUBLISHED —
|
|
# update-description POSTs it to Docker Hub as full_description on every tag
|
|
# — and it had gone eight releases (v1.8.6 -> v1.9.0) untouched. Nothing
|
|
# generates it and nothing checked it, so the only thing keeping it true was
|
|
# someone remembering. It is also read from the TAG, so a fix pushed to main
|
|
# after tagging never reaches the published page.
|
|
#
|
|
# Two classes of check. 1-7 are hermetic: each compares a doc string against
|
|
# a value that exists in this repo — no network, no token, no built image.
|
|
# 8-9 compare against what is PUBLISHED, because those claims have no
|
|
# in-repo anchor and rotted for exactly that reason: 8 reads Docker Hub's
|
|
# measured sizes; 9 reads the ref labels baked into the last released image
|
|
# (anonymous registry API, no docker/crane) and `git ls-remote`s each
|
|
# floating upstream, then requires every component the next build would
|
|
# bake differently to be NAMED in the CHANGELOG above that release's
|
|
# heading. Both SKIP loudly and counted when offline — a skip is neither OK
|
|
# nor a failure. Claims that genuinely need a running container (the "N
|
|
# mempalace_* tools" count, uncompressed sizes) are still left out — a gate
|
|
# that cannot evaluate a claim honestly would have to guess, and a guessing
|
|
# gate is worse than none. Assert those in scripts/smoke-test.sh instead.
|
|
#
|
|
# Exit codes 0 in sync / 1 drift / 2 cannot-run, matching lint-shell.sh and
|
|
# check-skill-floor.sh. A renamed ARG makes the gate blind, so that is a red
|
|
# 2, not a green tick.
|
|
runs-on: ubuntu-latest
|
|
container:
|
|
image: catthehacker/ubuntu:act-latest
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
- name: Doc claims match the build files
|
|
run: bash scripts/check-doc-drift.sh
|