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/" 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: 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