cac5e00a31
Followsecfd2fc, which refreshed the stale floor by hand. A one-off refresh fixes the symptom; this makes the drift impossible to reintroduce silently. scripts/check-skill-floor.sh compares the repo floor (rootfs/usr/local/share/pi-devbox/skills/pi-extensions/) against the package repo it is a snapshot of, wired in as a new `skill-floor` job in lint.yml. DIRECTORY hash, not `sha256sum SKILL.md`, using the same tree_sha256 pipeline Dockerfile.variant uses for skillset_snapshot_tree_sha256 and for the reason already documented there: a file-only compare answers "did this one file change", not "is this the same skill". Verified by NEGATIVE CONTROL rather than asserted -- with SKILL.md left byte-identical and only evaluate-extension-usage.py edited, the directory check fails (rc=1) where a file-only compare would have passed. Seven behaviour tests, each with its expected rc written down before running: in-sync via local dir (0), in-sync via anonymous remote clone (0), missing --package-dir (2), bad argument (2), content drift (1), the sibling-file case (1), and --warn-only over drift (0). Exit codes 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 and never a green tick. A ref with no skill/ is NOT drift -- that is the documented fallback -- but it emits ::warning:: because it is precisely the condition under which the floor ships. Gating on another repo is normally a smell. It is proportionate here because the check can only fire when skill/ itself changed, which is exactly when the floor has gone stale; pi-extensions commits that leave skill/ alone cannot turn this red. It also needs no secret: pi-extensions is anonymously clonable (verified with `git ls-remote` and no credentials), so it cannot start failing when a token expires. Also bakes python3-yaml (552 KB, zero extra deps) into Dockerfile.base. This is the shellcheck story repeating exactly: scripts/check-workflow-shell.sh -- the guard against the Gitea sh/dash footgun that broke resolve-versions (ed49b8d) and promote-base-latest (b7197e8) -- hard-exits with "python3 yaml module missing", so a gate this repo already owns could not be run locally by anyone. lint.yml installing it explicitly in CI was the evidence. Found while wiring the job above: the guard could not be run before pushing. CHANGELOG Unreleased updated for both this andecfd2fc, including an explicit note on what is NOT fixed -- the silent-fallback half still has no manifest flag recording which copy was served. Verified locally with every gate this repo owns, all green: lint-shell.sh (15 files clean), check-workflow-shell.sh, check-base-hash.sh, actionlint 1.7.7 (pinned, same version as CI), hadolint 2.14.0, and the new check itself.
175 lines
8.5 KiB
YAML
175 lines
8.5 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: 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
|
|
|
|
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.14.0
|
|
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
|