diff --git a/.gitea/workflows/docker-publish.yml b/.gitea/workflows/docker-publish.yml index 6257531..e7fbf2f 100644 --- a/.gitea/workflows/docker-publish.yml +++ b/.gitea/workflows/docker-publish.yml @@ -222,6 +222,13 @@ jobs: - name: Components the next build would bake differently are named in the CHANGELOG run: bash scripts/check-doc-drift.sh + # Runs HERE as well as in lint.yml deliberately. A guard that gates only + # `push` lets a tag regress — the adoption slip that let doc-drift land + # 27 h after the v1.9.3 tag. This one protects the tag build from the + # zero-failing-step failure mode that killed v1.10.0 and v1.10.1. + - name: Dockerfile.variant still opens with its BuildKit check directive + run: bash scripts/check-dockerfile-directives.sh Dockerfile.variant + resolve-versions: # Gated: a defective tree must not reach a 46-minute base build. needs: [lint-gate] @@ -593,28 +600,6 @@ jobs: platforms: linux/amd64 push: false load: true - # provenance: false is LOAD-BEARING, not hygiene. buildx writes a - # provenance attestation into the metadata it hands back, and that - # metadata embeds the ENTIRE Dockerfile as one base64 "data" field on a - # single line. build-push-action writes the metadata to $GITHUB_OUTPUT - # as a `name<` heredoc, and Gitea's act_runner - # truncates any single line at exactly 65536 chars — so once - # base64(Dockerfile.variant) crosses 64 KiB the closing delimiter is - # cut off and the runner fails the job with - # invalid format delimiter 'ghadelimiter_...' not found before end of file - # and NO failing step: every step reports Success, the smoke suite - # reports "0 failed", and the job is red anyway. - # Measured: Dockerfile.variant was 42251 B at v1.9.4 (base64 56355, - # fine) and 50034 B at v1.10.1 (base64 66712, truncated to 65536) — - # the cap corresponds to a 49152 B Dockerfile, so the v1.10.0/v1.10.1 - # comment growth crossed it by 882 B. v1.10.0 run 704 and v1.10.1 - # run 707 both died here; in 704 it hid behind a stale clipboard - # assertion. Trimming comments would "fix" it until the next comment. - # These smoke images are built with load: true and thrown away, so the - # attestation has no consumer. The PUBLISHED images are built by raw - # `docker buildx build --push` in run: blocks, which never writes - # $GITHUB_OUTPUT metadata — so this changes nothing about what ships. - provenance: false tags: pi-devbox:smoke build-args: | BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }} @@ -680,28 +665,6 @@ jobs: platforms: linux/amd64 push: false load: true - # provenance: false is LOAD-BEARING, not hygiene. buildx writes a - # provenance attestation into the metadata it hands back, and that - # metadata embeds the ENTIRE Dockerfile as one base64 "data" field on a - # single line. build-push-action writes the metadata to $GITHUB_OUTPUT - # as a `name<` heredoc, and Gitea's act_runner - # truncates any single line at exactly 65536 chars — so once - # base64(Dockerfile.variant) crosses 64 KiB the closing delimiter is - # cut off and the runner fails the job with - # invalid format delimiter 'ghadelimiter_...' not found before end of file - # and NO failing step: every step reports Success, the smoke suite - # reports "0 failed", and the job is red anyway. - # Measured: Dockerfile.variant was 42251 B at v1.9.4 (base64 56355, - # fine) and 50034 B at v1.10.1 (base64 66712, truncated to 65536) — - # the cap corresponds to a 49152 B Dockerfile, so the v1.10.0/v1.10.1 - # comment growth crossed it by 882 B. v1.10.0 run 704 and v1.10.1 - # run 707 both died here; in 704 it hid behind a stale clipboard - # assertion. Trimming comments would "fix" it until the next comment. - # These smoke images are built with load: true and thrown away, so the - # attestation has no consumer. The PUBLISHED images are built by raw - # `docker buildx build --push` in run: blocks, which never writes - # $GITHUB_OUTPUT metadata — so this changes nothing about what ships. - provenance: false tags: pi-devbox:smoke-studio build-args: | BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }} diff --git a/.gitea/workflows/lint.yml b/.gitea/workflows/lint.yml index e76905e..d01a834 100644 --- a/.gitea/workflows/lint.yml +++ b/.gitea/workflows/lint.yml @@ -90,6 +90,12 @@ jobs: # 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 088da6d..2dd91f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,21 +11,73 @@ Pre-v1.0.0 tags followed the pi npm version (`v{pi_version}[letter]`). --- -## v1.10.1 — 2026-10-02 +## v1.10.2 — 2026-10-02 -**v1.10.0 was tagged and never published.** Its tag build (run 704) failed one -assertion out of 104 — a smoke check that required a pi dependency upstream had -deliberately deleted — so every publish job skipped and no image, no `latest`, -no Hub description was ever written. v1.10.1 is that same release plus the fix -to the assertion: **everything described under v1.10.0 below ships here**, and -that section remains the content record for this release. Nothing in it changed. +**v1.10.0 and v1.10.1 were both tagged and never published.** Neither failure +was in the image — both were in the test and CI harness, and each hid the other: -The tag number moves rather than being re-pointed because CI had already -consumed v1.10.0; a version that failed its build should stay failed and -readable, not be quietly overwritten with different bytes. +| tag | run | symptom | cause | +|---|---|---|---| +| v1.10.0 | 704 | `smoke` 100 passed / **1 failed**; `smoke-studio` 103 / **1** | a smoke assertion required a pi dependency upstream had deleted | +| v1.10.1 | 707 | `smoke` **101 passed / 0 failed**, `smoke-studio` **104 / 0**, and both jobs red anyway with **no failing step** | base64(`Dockerfile.variant`) in buildx's warning metadata crossed act_runner's 64 KiB line cap | + +In both runs every publish job skipped, so no image, no `latest`, and no Hub +description was ever written — the gates did their job twice. **Everything +described under v1.10.0 below ships here**; that section remains the content +record and nothing in it changed. + +The tag number moves each time rather than being re-pointed, because CI had +already consumed the previous one. A version that failed its build should stay +failed and readable, not be quietly overwritten with different bytes. ### Fixed +- **A job can fail with every step green, a smoke suite reporting `0 failed`, + and no failing step anywhere — and it cost two tags** — `Dockerfile.variant` + now opens with `# check=skip=InvalidDefaultArgInFrom`, and + `scripts/check-dockerfile-directives.sh` keeps it there. + + The chain, measured rather than reasoned: `ARG BASE_IMAGE` deliberately has no + default (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<` heredoc, + and Gitea's act_runner truncates any single line at exactly 65536 chars. Once + base64 of the file passed 64 KiB the closing delimiter was cut off and the + runner reported `invalid format delimiter 'ghadelimiter_...' not found before + end of file`, then failed the job while attributing it to nothing. + + Numbers: the file was 42251 B at v1.9.4 → base64 56355 chars, fine; it is + 50034 B now → base64 66712, truncated to exactly 65536 (2^16, i.e. cut *at* + the cap, not merely long). The cap corresponds to a 49152 B Dockerfile, so + this release's comment growth — much of it comments added while documenting + the v1.10.0 round — crossed it by **882 bytes**. Confirmed two ways: by + parsing the metadata out of both job logs, and independently by arithmetic on + the file size at each tag. Verified fixed by running `docker buildx build + --check` against the actual edited file: *"Check complete, no warnings + found."* With zero warnings there is no `sourceInfo`, so the longest metadata + line drops from 65536 to ~1936 chars and the Dockerfile's **size stops being + coupled to whether CI passes**. + + Hypotheses measured and rejected on the way, each of which would have produced + a wrong fix: floating action tags moving (the act action-bundle hashes are + byte-identical between the green v1.9.4 run and the red one — all four); the + build-check annotation text changing (byte-identical); and `provenance: false`, + which was written, tested against a real buildx, and **reverted** when the + metadata turned out to carry the base64 under `buildx.build.warnings`, not + under provenance. The published images are unaffected either way: they are + built by raw `docker buildx build --push`, which never writes + `$GITHUB_OUTPUT` metadata. + + The new guard runs in **both** `lint.yml` and the publish workflow's + `lint-gate`, because a check that gates only `push` lets a tag regress — the + same adoption slip that let doc-drift land 27 h after the v1.9.3 tag. It + also fails if `ARG BASE_IMAGE` ever gains a default, so the skip directive + cannot rot into pointing at a check that can no longer fire. Truth table: + directive present → rc=0; removed → rc=1; demoted to line 2 → rc=1; ARG given + a default → rc=1; file missing → rc=2 (cannot-run must not pass). + - **The smoke suite asserted a pi dependency that upstream deleted, and it cost this release its first tag build** — `scripts/smoke-test.sh` required `@mariozechner/clipboard` to be installed and `require()`-able at every install @@ -60,13 +112,15 @@ readable, not be quietly overwritten with different bytes. --- -## v1.10.0 — 2026-10-02 (tagged, never published — superseded by v1.10.1) +## v1.10.0 — 2026-10-02 (tagged, never published — content shipped as v1.10.2) > Tagged 2026-10-02 at commit `12f99c4`. Its build (run 704) went red on the > stale clipboard assertion described under v1.10.1, so `build-variant`, > `build-variant-studio`, `promote-base-latest` and `update-description` all > skipped and nothing reached the Hub. No image carries this tag. The content -> below is accurate and shipped as **v1.10.1**. +> below is accurate and shipped as **v1.10.2**. v1.10.1 (commit `c2c42c3`, +> run 707) was the first attempt at republishing it and died on the separate +> act_runner line-cap bug described under v1.10.2. Three small fixes found by the v1.9.4 first-boot acceptance and the CI base hash prediction, plus one boot-time wiring fix that stops a recurring `git push` diff --git a/Dockerfile.variant b/Dockerfile.variant index dc133a7..5305fd5 100644 --- a/Dockerfile.variant +++ b/Dockerfile.variant @@ -1,3 +1,37 @@ +# check=skip=InvalidDefaultArgInFrom +# +# ^ MUST stay the FIRST line of this file, and it is load-bearing for CI, not +# style. BuildKit parses `# check=` only before any other line, so moving it +# below the title comment silently disables it. +# +# Why it exists: `ARG BASE_IMAGE` (below) deliberately has NO default — this +# file is only ever built by the two-phase CI with --build-arg BASE_IMAGE= +# :base-. BuildKit's InvalidDefaultArgInFrom check flags that as +# "default value for global ARG results in an empty or invalid base image +# name". There is no honest default to give it: `scratch` would satisfy the +# linter while being a lie (nothing here can build FROM scratch), and it would +# convert today's instant "invalid reference format" into a failure deep in the +# build. So the check is skipped by name, with the reason written down. +# +# What the warning actually COST, which is why this is not cosmetic: buildx +# attaches the 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<` heredoc, and Gitea's act_runner truncates any +# single line at exactly 65536 chars. Once base64(this file) crossed 64 KiB the +# closing delimiter was cut off, and the runner failed the job with +# invalid format delimiter 'ghadelimiter_...' not found before end of file +# and NO failing step: every step green, smoke suite "0 failed", job red. +# Measured: 42251 B at v1.9.4 -> base64 56355 (fine); 50034 B at v1.10.1 -> +# base64 66712, truncated to exactly 65536. The cap corresponds to a 49152 B +# Dockerfile, so this release's comment growth crossed it by 882 B. It killed +# v1.10.0 (run 704, where a stale clipboard assertion hid it) and v1.10.1 +# (run 707). With zero warnings there is no sourceInfo, so the longest metadata +# line drops from 65536 to ~1936 chars and the Dockerfile's SIZE stops being +# coupled to CI passing at all. +# +# If this ever recurs — job red, zero failing steps, suite reporting 0 failed — +# grep the job log for `ghadelimiter` first. # pi-devbox — variant image # # FROMs a base- image produced by Dockerfile.base and adds only @@ -154,11 +188,11 @@ ARG USER_NAME=developer # (e7d77dc -> 1529e14 -> 731c3d4 in the nine days to 2026-10-01), so waiting for # a tag means holding pi indefinitely. A full SHA keeps the one property # `master` does not have: rebuilding this tag later produces the SAME image. -# v1.9.5 SKIPPED 0.99.x and 1.0.0 for lack of evidence. v1.10.1 went and got the +# v1.9.5 SKIPPED 0.99.x and 1.0.0 for lack of evidence. v1.10.2 went and got the # evidence instead of waiting for obsmem to mention a version, because "no issue # names 1.0" is an absence of a statement, not a measurement. # -# v1.10.1: 0.87.1 -> 1.0.0. MEASURED 2026-10-02 against the published npm +# v1.10.2: 0.87.1 -> 1.0.0. MEASURED 2026-10-02 against the published npm # tarballs for 0.87.1 and 1.0.0, unpacked side by side: # 1. NO `### Breaking Changes` SECTION IN 1.0.0 AT ALL. The changelog's # breaking sections belong to 0.87.0, 0.86.0, 0.84.3, 0.84.0, 0.83.0, @@ -273,7 +307,7 @@ ARG PI_ATELIER_REPO=https://github.com/michaelmjhhhh/pi-atelier.git # pi >=0.84.0, so it still spans the pinned 0.85.1. 13 commits v0.10.1..v0.10.3, # all under src/ tests/ docs/ scripts/ plus metadata; no entry-point move. # -# v1.10.1: v0.10.3 -> v0.13.0, closing the two-minor gap v1.9.5 flagged as the +# v1.10.2: v0.10.3 -> v0.13.0, closing the two-minor gap v1.9.5 flagged as the # residual risk of the pi bump. peerDependencies are UNCHANGED at pi >=0.84.0 # across v0.10.3, v0.12.1 and v0.13.0 — still a FLOOR, so still not evidence of # anything; the floor is satisfied by 1.0.0 either way. What was actually diff --git a/README.md b/README.md index 68f4dae..aad1599 100644 --- a/README.md +++ b/README.md @@ -358,7 +358,7 @@ DOT syntax errors instead of crashing. Then in Studio: open the PNG (or a `.md` that embeds it) and hit **refresh-from-disk** after each edit. Note: SVG is **not** in Studio's local-image-link allowlist — use PNG. -## Terminal UI mode: fullscreen is the default (since v1.10.1) +## Terminal UI mode: fullscreen is the default (since v1.10.2) pi **1.0.0** changed the default terminal UI mode to **fullscreen**, and this image adopts upstream's default rather than overriding it. Fullscreen draws into diff --git a/scripts/check-dockerfile-directives.sh b/scripts/check-dockerfile-directives.sh new file mode 100755 index 0000000..f9fd621 --- /dev/null +++ b/scripts/check-dockerfile-directives.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Guard: Dockerfile.variant must OPEN with its BuildKit `# check=` directive. +# +# Why this is a gate and not a comment. BuildKit parses `# check=` ONLY before +# any other line in the file, so moving it below the title comment — or dropping +# it during an unrelated header edit — silently re-enables the +# InvalidDefaultArgInFrom warning on `ARG BASE_IMAGE` / `FROM ${BASE_IMAGE}`. +# +# A re-enabled warning is not cosmetic. buildx attaches the 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<` heredoc, and +# Gitea's act_runner truncates any single line at exactly 65536 chars. Since +# base64(Dockerfile.variant) passed 64 KiB (50034 B source -> 66712 chars, cut +# to 65536), the closing delimiter is lost and act_runner fails the job with +# invalid format delimiter 'ghadelimiter_...' not found before end of file +# while attributing it to NO step: every step reports Success, the smoke suite +# prints "0 failed", and the job is red regardless. That cost two tags — +# v1.10.0 (run 704, where a stale clipboard assertion masked it) and v1.10.1 +# (run 707) — and most of a session to localise. +# +# So: one deterministic grep, run both on push AND in the publish workflow's +# lint-gate. A check that gates only `push` lets a tag regress — the same +# adoption slip that let doc-drift land 27 h after the v1.9.3 tag. +# +# Exit codes: 0 OK, 1 violation, 2 cannot-run (missing file) — matching +# lint-shell.sh / check-skill-floor.sh / check-doc-drift.sh, because a gate that +# cannot run must not pass. +set -euo pipefail + +DF="${1:-Dockerfile.variant}" +EXPECTED='# check=skip=InvalidDefaultArgInFrom' + +if [ ! -f "$DF" ]; then + echo "::error::check-dockerfile-directives: '$DF' not found (cannot-run)" >&2 + exit 2 +fi + +first="$(head -n 1 "$DF")" +if [ "$first" != "$EXPECTED" ]; then + { + echo "::error::$DF line 1 must be exactly: $EXPECTED" + echo "::error::found instead: ${first:-}" + echo "::error::" + echo "::error::BuildKit only honours '# check=' before any other line. Without it the" + echo "::error::InvalidDefaultArgInFrom warning returns, buildx embeds this whole file as" + echo "::error::base64 in buildx.build.warnings[].sourceInfo.data, that single line exceeds" + echo "::error::act_runner's 65536-char cap, and the smoke jobs fail with" + echo "::error:: invalid format delimiter 'ghadelimiter_...' not found before end of file" + echo "::error::and NO failing step. See the header of $DF for the full measurement." + } >&2 + exit 1 +fi + +# Second, independent assertion: the condition the directive exists FOR. If a +# later edit gives ARG BASE_IMAGE a default, the directive becomes dead weight +# and should be removed deliberately rather than left to rot — and if the ARG is +# renamed, this guard would otherwise keep passing while guarding nothing. +if ! grep -qE '^ARG BASE_IMAGE$' "$DF"; then + { + echo "::error::$DF no longer contains a bare 'ARG BASE_IMAGE' (no default)." + echo "::error::That is the only thing '$EXPECTED' suppresses. Either restore the bare ARG" + echo "::error::or drop the directive and this guard together — do not leave a skip" + echo "::error::directive pointing at a check that can no longer fire." + } >&2 + exit 1 +fi + +echo "Dockerfile directive guard OK — $DF opens with the check-skip directive and still declares a bare ARG BASE_IMAGE."