56e3742a2d
Lint / hadolint (push) Successful in 10s
Lint / skill-floor (push) Failing after 13s
Lint / doc-drift (push) Successful in 18s
Lint / actionlint (push) Successful in 33s
Publish Docker Image / lint-gate (push) Successful in 32s
Publish Docker Image / resolve-versions (push) Successful in 17s
Publish Docker Image / base-decide (push) Successful in 13s
Publish Docker Image / build-base (push) Has been skipped
Publish Docker Image / smoke-studio (push) Successful in 5m38s
Publish Docker Image / smoke (push) Successful in 8m20s
Publish Docker Image / build-variant-studio (push) Successful in 20m34s
Publish Docker Image / build-variant (push) Successful in 21m5s
Publish Docker Image / update-description (push) Successful in 8s
Publish Docker Image / promote-base-latest (push) Successful in 9s
v1.10.1 (run 707) failed with ZERO failing steps: every step Success, `smoke` printing "101 passed, 0 failed", `smoke-studio` "104 passed, 0 failed", both jobs red. The clipboard fix fromf3b3748worked exactly as predicted; this is a second, independent defect that run 704 had masked. CHAIN, measured end to end rather than reasoned: `ARG BASE_IMAGE` has no default on purpose (the two-phase build always supplies it), which trips BuildKit's InvalidDefaultArgInFrom check. buildx attaches that warning's source context to the build metadata as buildx.build.warnings[].sourceInfo.data — the ENTIRE Dockerfile, base64, on ONE line. docker/build-push-action writes that metadata to $GITHUB_OUTPUT as a `name<<ghadelimiter_<uuid>` heredoc. Gitea's act_runner truncates any single line at exactly 65536 chars, so the closing delimiter was cut off: invalid format delimiter 'ghadelimiter_...' not found before end of file and the runner failed the job while attributing it to no step at all. v1.9.4 run 695 SUCCESS: longest metadata line 56355, 0 delimiter errors v1.10.0 run 704 failed : longest metadata line 65536, 1 delimiter error v1.10.1 run 707 failed : longest metadata line 65536, 1 delimiter error 65536 = 2^16: cut AT the cap, not merely long. Independent route via file size rather than log parsing: 42251 B at v1.9.4 (base64 56335, matching the log) vs 50034 B now (base64 66712, truncated). The cap corresponds to a 49152 B Dockerfile, so this cycle's comment growth crossed it by 882 B. Located by asking which top-level metadata key CONTAINS the base64, instead of assuming: it is buildx.build.warnings -> sourceInfo -> data in BOTH runs. THIS IS THE SECOND FIX FOR THIS BUG. The first, `provenance: false` on the two smoke build steps, was committed as784fad7and is REVERTED here: a real buildx on another host showed provenance metadata present in both modes with a longest string of 71 chars, i.e. the base64 was never in provenance. Shipping it would have left the release broken a third time while looking like a fix. Also rejected, each measured: floating action tags moving (act action-bundle hashes byte-identical between runs 695 and 707, all four) and the build-check annotation text changing (byte-identical). FIX: `# check=skip=InvalidDefaultArgInFrom` as the FIRST line of Dockerfile.variant — BuildKit parses `# check=` only before any other line, so placement is load-bearing. No honest default exists for BASE_IMAGE: `scratch` would satisfy the linter while being a lie, and would convert today's instant "invalid reference format" into a failure deep in the build. Verified against the actual edited file with `docker buildx build --check`: "Check complete, no warnings found." Zero warnings means no sourceInfo, so the longest metadata line drops 65536 -> ~1936 and the file's SIZE stops gating CI. GUARD: scripts/check-dockerfile-directives.sh, wired into BOTH lint.yml and docker-publish.yml's lint-gate, because a check that gates only `push` lets a tag regress — the adoption slip that let doc-drift land 27 h after the v1.9.3 tag. It also fails if ARG BASE_IMAGE gains a default, so the directive cannot rot into guarding a check that can no longer fire. Truth table, exit codes: present 0, removed 1, demoted to line 2 → 1, ARG defaulted 1, file missing 2 (a gate that cannot run must not pass). Release renamed v1.10.1 -> v1.10.2 with both failed tags left standing as tombstones. Docs swept again (README "since" marker, Dockerfile decision comments) because CI reads them from the TAG. Gates: doc-drift 23 OK / 0 DRIFT; base-hash, workflow-shell, skill-floor, lint-shell (17 files now), dockerfile-directives all rc=0.
231 lines
12 KiB
YAML
231 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: 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
|