11e512e497
pi reads custom providers from ~/.pi/agent/models.json, and ~/.pi is the devbox-pi-config named volume. A file baked into the image would only seed a fresh volume, once. A hand-made link is one more step on every new volume: on 2026-10-12 that was the same `ln -s /opt/fleet-pi/models.json …` once per device, for one fleet-shared file. New rootfs/usr/local/lib/pi-devbox/link-pi-models.sh, called from entrypoint-user.sh after the pi-toolkit and pi-extensions installs. When /opt/fleet-pi/models.json exists (PI_MODELS_JSON overrides the path, =off disables it), it links ~/.pi/agent/models.json to it, honouring PI_CODING_AGENT_DIR. It only ever creates a link where nothing exists: a real file or a link of the user's own is left alone, with one NOTE line. With nothing mounted, it does nothing and prints nothing. A link whose target later vanishes is harmless; measured with pi 1.0.0, a dangling models.json behaves like a missing one (rc 0, no error, no custom models). Tests: - scripts/test-link-pi-models.sh: 13 two-sided cases against a throwaway HOME. Wired into lint.yml, so it runs on every push and not only at release. - Of 11 mutations, 10 are caught. The 11th (ln -s -> ln -sfn on the final link) is unreachable behind the guards. - smoke-test.sh: with nothing mounted no link appears, and the script links a mounted file. Both commands were run under dash here. - recreate-sanity-check.sh: pass, warn or fail on the link, but only when a shared file is mounted. Here it passes. Docs: - README "Custom model providers (models.json)": mount the DIRECTORY (git pull renames, and a single-file mount then shows "No such file" until recreate), keep keys in .env as "$NAME". - .env.example: PI_MODELS_JSON. Local gates green: lint-shell, check-workflow-shell, check-dockerfile-directives, check-base-hash, check-doc-drift.
237 lines
12 KiB
YAML
237 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: 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
|