206fd1ec35
Found while adding pi-devbox-firstboot, whose description was drafted at 1056 characters against pi's MAX_DESCRIPTION_LENGTH of 1024. pi does not reject it: dist/core/skills.js pushes a diagnostic and then loads the skill anyway -- "Still load the skill even with warnings, unless description is missing or empty". So the skill works, the violation is real, and the only thing that would ever report it is a diagnostic inside a live pi session. Nothing in CI starts one, so the warning is invisible here by construction. A spec violation that still works is the most durable kind of rot: nothing forces the fix. Unlike check-skill-floor.sh, this was NOT free to add. Measured headroom on the five baked skills: credential-incident-response 1022 (2 chars spare), pi-extensions 1003 (21), pi-devbox-firstboot 952, pi-devbox-environment 923, mempalace 254. Two of five are within one sentence of the limit, so "nobody would write a description that long" is already false here. It also catches the sibling failure that is a silent DROP rather than a warning: a missing or empty description returns skill: null, so the file ships, the symlink resolves, smoke-test's `test -f` passes, pi-devbox-version lists it -- and no agent can ever see it. Name rules are checked too, including against the directory, because the loader falls back to parentDirName and a mismatch silently renames the skill while every path-based pointer (the managed AGENTS block included) keeps naming the directory. Truth table, each mutation verified to differ materially from the shipped tree: shipped 0; over-long description 1; missing description 1 (reported as fatal, not a mere violation); name != directory 1; no frontmatter 1; directory with no SKILL.md 1; absent skills tree 2. Exit codes match lint-shell.sh, check-skill-floor.sh and check-doc-drift.sh -- a gate that cannot run must not pass. python3-yaml rather than grep/awk because `description: >-` is a folded block scalar whose value spans lines and joins with spaces, so a line-oriented length check measures the wrong string. Same package the actionlint job already installs, same parser check-doc-drift.sh already uses. The vendored copies (pi-extensions, mempalace) are deliberately in scope: an upstream edit over the limit turns this red on a file this repo does not author, which is precedented by check-skill-floor.sh's coupling and correct because the image ships those bytes. The fix in that case is to refresh the snapshot, not to exempt the file.
298 lines
15 KiB
YAML
298 lines
15 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: No raw docker prune in workflows (shared runner daemon)
|
|
# Everything on one runner host shares its Docker daemon (runner-a1
|
|
# runs several jobs at once; runner-2 also serves gitea.egl.lan), and
|
|
# a bare `docker system prune -a` in one job deletes the smoke image
|
|
# another job is testing (v1.12.2 run 740 shipped without its -studio
|
|
# tags that way). Reclaim goes through scripts/ci-reclaim-docker.sh,
|
|
# which spares labelled smoke images; this keeps it that way.
|
|
run: bash scripts/ci-reclaim-docker.sh lint .gitea/workflows/*.yml
|
|
|
|
- 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
|
|
|
|
skill-frontmatter:
|
|
# Gate the image-baked skills' frontmatter against the spec pi's own loader
|
|
# measures. Its own job, for the same reason as skill-floor and doc-drift:
|
|
# "a shipped skill violates the spec" should be a distinct red name, not a
|
|
# line buried in a job about workflow syntax.
|
|
#
|
|
# The gap it closes, measured 2026-10-23 while adding pi-devbox-firstboot:
|
|
# that skill's description was drafted at 1056 chars against the loader's
|
|
# MAX_DESCRIPTION_LENGTH of 1024. pi does NOT reject it — dist/core/skills.js
|
|
# pushes a diagnostic and then loads the skill anyway ("Still load the skill
|
|
# even with warnings, unless description is missing or empty"). So the only
|
|
# thing that would ever report the violation is a diagnostic inside a live pi
|
|
# session, and nothing in CI starts one. A spec violation that still works is
|
|
# the most durable kind of rot: nothing forces the fix.
|
|
#
|
|
# Not free in the way skill-floor was free: two of the five baked skills are
|
|
# already within ~20 chars of the limit (credential-incident-response 1022,
|
|
# pi-extensions 1003), so this fires on a plausible next edit rather than on
|
|
# a hypothetical one.
|
|
#
|
|
# The sibling failure it also catches is a silent DROP rather than a warning:
|
|
# a missing/empty description returns skill: null, so the file ships, the
|
|
# symlink resolves, smoke-test's `test -f` passes, pi-devbox-version lists
|
|
# it — and no agent can ever see it.
|
|
#
|
|
# Exit codes 0 valid / 1 violation / 2 cannot-run, matching lint-shell.sh,
|
|
# check-skill-floor.sh and check-doc-drift.sh. A missing skills tree or a
|
|
# python3 without the yaml module is a red 2, not a green tick.
|
|
runs-on: ubuntu-latest
|
|
container:
|
|
image: catthehacker/ubuntu:act-latest
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
# python3-yaml because `description: >-` is a folded block scalar: the
|
|
# value spans lines and joins with spaces, so a line-oriented length
|
|
# check measures the wrong string. Same package the actionlint job
|
|
# installs, same parser check-doc-drift.sh already relies on.
|
|
- name: Install python3-yaml
|
|
run: |
|
|
apt-get update
|
|
apt-get install -y --no-install-recommends python3-yaml
|
|
|
|
- name: Baked skill frontmatter is valid
|
|
run: bash scripts/check-skill-frontmatter.sh
|