fix(build): pin image builds to a commit and tag by it — plus probe 1 of the resize finding #115

Merged
triform-admin merged 3 commits from fix/gateway-provenance into main 2026-09-10 19:06:53 +00:00

Closes the loose end from #114: the live gateway could not be pinned because
nobody could say what was in it. Fixes the mechanism that produced that, then
uses it.

The mechanism

Both image build Jobs cloned --depth 1 --branch main and pushed a fixed
opaque tag (standalone-v13, standalone-v8). So an image recorded neither
what it was built from nor when — "v13" is not an answer — and rerunning
replaced the tag silently. standalone-v13 never existed in the registry at
all, so the manifest had been edited to a tag that was never built while
something else was running.

Now both Jobs:

  • clone a full 40-char commit, detached. A build that reports on the wrong
    ref is worse than one that fails — the lesson fire-build.sh already
    learned, applied to the two images it does not cover.
  • tag by that sha (g-<sha12> / s-<sha12>), so the registry is
    self-describing and rebuilding the same commit is idempotent rather than a
    silent tag move.
  • stamp org.opencontainers.image.revision as a label. A tag can be
    moved; a label cannot. crane config <ref> then answers the provenance
    question for any image, tagged or not — exactly what could not be answered
    for the gateway this morning.

Used it

Built the gateway from c9e35d78 (current main) and rolled the cluster onto
it. Verified after the roll:

$ crane config .../chromeless-gateway:g-c9e35d78a4f0
org.opencontainers.image.revision = c9e35d78a4f0897e70fdbaf8ed8619ef36aa58cf
org.opencontainers.image.source   = https://forgejo.triform.dev/triform/chromeless

tag resolves to the pinned digest; login returns 303; a viewer reaches
ice=connected with 129 frames and 22466 audio bytes — so the audio re-arm
fix still holds on a client bundle that now includes batches B and C.

This supersedes sha256:63d6cfa7 rather than reproducing it. Reproducing
an unidentifiable image was never the goal; being able to say what is running
is. The DRIFT note in stack.yaml is replaced by a real pin.

Also: probe 1 of the resize/GPU finding, answered

No code change — the existing [diag] counter was enough, so this cost a CDP
session rather than a lane cycle.

Cb.setViewport 854x590 against a fresh worker with a live viewer:

framesReceived  600 -> 601 -> frozen at 601 for 30 s -> permanent death
[diag] spanning the resize:  issued=136 (27.2 fps) acked=135
[diag] six ticks before it:  147/147, 148/148, ... all balanced

The renderer submits exactly one frame after the resize, then nothing. One
BeginFrame issued and never acked. So the renderer is not stuck upstream of
viz — a frame gets through; what dies is that single in-flight ack, dropped
when the Display was reconfigured underneath it.

This invalidates all three "third attempt" steps the finding proposed,
which is the useful part:

  • its own probe-1 hypothesis ("if the renderer is not painting, look at
    RenderWidgetHostView::SetSize") — it paints;
  • waiting longer: awaiting_reconfigure_ack_ waits for "the ack viz still
    holds", and that ack no longer exists, so the wait cannot terminate. That is
    why attempt 2 traded a crash for a freeze;
  • Stop()/Start(): the header's LIFECYCLE CONSTRAINT permits it only once
    the in-flight frame is known-drained, and viz clears
    pending_frame_callback_ only when the Display finishes the frame. It
    never finishes — so Stop/Start lands in precisely the forbidden window and
    trips the same double-issue that aborted the GPU in attempt 1.

All three assumed the pre-resize frame completes eventually. It does not. The
finding now records where a fourth attempt should start instead — cheapest
being to invert attempt 2: hold the resize for the ack rather than holding
the ack-chain for the resize, which costs one frame of latency and needs no
viz internals.

Verification

make verify / make lint / lint-deploy-pin pass
build-images.yaml self-consistency checked programmatically — both Jobs' clone scripts reference only variables their env defines, both COMMITs are full shas
gateway image provenance tag → digest match, revision label present
stack after the roll login 303, ice=connected, 129 frames, 22466 audio bytes

The consistency check earned its keep: it caught this edit half-applied — env
set to COMMIT while the script still read $BRANCH, which would have failed
at runtime.

Closes the loose end from #114: the live gateway could not be pinned because nobody could say what was in it. Fixes the mechanism that produced that, then uses it. ## The mechanism Both image build Jobs cloned `--depth 1 --branch main` and pushed a fixed opaque tag (`standalone-v13`, `standalone-v8`). So an image recorded neither what it was built from nor when — "v13" is not an answer — and rerunning replaced the tag silently. `standalone-v13` never existed in the registry at all, so the manifest had been edited to a tag that was never built while something else was running. Now both Jobs: - clone a **full 40-char commit**, detached. A build that reports on the wrong ref is worse than one that fails — the lesson `fire-build.sh` already learned, applied to the two images it does not cover. - tag by that sha (`g-<sha12>` / `s-<sha12>`), so the registry is self-describing and rebuilding the same commit is idempotent rather than a silent tag move. - stamp `org.opencontainers.image.revision` as a **label**. A tag can be moved; a label cannot. `crane config <ref>` then answers the provenance question for any image, tagged or not — exactly what could not be answered for the gateway this morning. ## Used it Built the gateway from `c9e35d78` (current main) and rolled the cluster onto it. Verified after the roll: ``` $ crane config .../chromeless-gateway:g-c9e35d78a4f0 org.opencontainers.image.revision = c9e35d78a4f0897e70fdbaf8ed8619ef36aa58cf org.opencontainers.image.source = https://forgejo.triform.dev/triform/chromeless ``` tag resolves to the pinned digest; `login` returns 303; a viewer reaches `ice=connected` with 129 frames and 22466 audio bytes — so the audio re-arm fix still holds on a client bundle that now includes batches B and C. This **supersedes** `sha256:63d6cfa7` rather than reproducing it. Reproducing an unidentifiable image was never the goal; being able to say what is running is. The DRIFT note in `stack.yaml` is replaced by a real pin. ## Also: probe 1 of the resize/GPU finding, answered No code change — the existing `[diag]` counter was enough, so this cost a CDP session rather than a lane cycle. `Cb.setViewport 854x590` against a fresh worker with a live viewer: ``` framesReceived 600 -> 601 -> frozen at 601 for 30 s -> permanent death [diag] spanning the resize: issued=136 (27.2 fps) acked=135 [diag] six ticks before it: 147/147, 148/148, ... all balanced ``` The renderer submits **exactly one** frame after the resize, then nothing. One BeginFrame issued and never acked. So the renderer is not stuck upstream of viz — a frame gets through; what dies is that single in-flight ack, dropped when the Display was reconfigured underneath it. **This invalidates all three "third attempt" steps the finding proposed**, which is the useful part: - its own probe-1 hypothesis ("if the renderer is not painting, look at `RenderWidgetHostView::SetSize`") — it paints; - waiting longer: `awaiting_reconfigure_ack_` waits for "the ack viz still holds", and that ack no longer exists, so the wait cannot terminate. That is why attempt 2 traded a crash for a freeze; - `Stop()`/`Start()`: the header's LIFECYCLE CONSTRAINT permits it only once the in-flight frame is known-drained, and viz clears `pending_frame_callback_` only when the Display *finishes* the frame. It never finishes — so Stop/Start lands in precisely the forbidden window and trips the same double-issue that aborted the GPU in attempt 1. All three assumed the pre-resize frame completes eventually. It does not. The finding now records where a fourth attempt should start instead — cheapest being to invert attempt 2: hold the **resize** for the ack rather than holding the ack-chain for the resize, which costs one frame of latency and needs no viz internals. ## Verification | | | | --- | --- | | `make verify` / `make lint` / `lint-deploy-pin` | pass | | build-images.yaml self-consistency | checked programmatically — both Jobs' clone scripts reference only variables their env defines, both COMMITs are full shas | | gateway image provenance | tag → digest match, revision label present | | stack after the roll | login 303, ice=connected, 129 frames, 22466 audio bytes | The consistency check earned its keep: it caught this edit half-applied — env set to `COMMIT` while the script still read `$BRANCH`, which would have failed at runtime.
The live gateway on 2026-09-10 was sha256:63d6cfa7 with no tag pointing
at it and no commit naming it. It could not be pinned in stack.yaml
honestly, because a pin is a provenance claim and nobody could say what
was inside. This is the mechanism that produced it.

Both Jobs cloned `--depth 1 --branch main` and pushed a fixed opaque tag
(standalone-v13, standalone-v8). So the image recorded neither what it
was built from nor when: the tag says "v13", which is not an answer.
Rerunning replaced the tag silently.

Now:

- clone a full 40-char COMMIT, detached. A build that reports on the
  wrong ref is worse than one that fails — the same lesson fire-build.sh
  learned, applied to the two images it does not cover.
- tag by that sha (g-<sha12> / s-<sha12>), so the registry answers
  "what is this?" without anyone remembering, and rebuilding the same
  commit is idempotent rather than a silent tag move.
- carry org.opencontainers.image.revision as a LABEL. A tag can be
  moved; a label cannot. `crane config <ref>` then answers the
  provenance question for any image, tagged or not — exactly what could
  not be answered today.

standalone-v13 never existed in the registry, so the manifest had been
edited to a tag that was never built while something else was running.

Validated the manifest programmatically rather than by reading it: both
Jobs' clone scripts reference only variables their env defines, and both
COMMITs are full shas. That check caught this edit half-applied — env
set to COMMIT while the script still read $BRANCH, which would have
failed at runtime.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ran the finding's own cheapest next step against a fresh worker with a
live viewer, driving Cb.setViewport over CDP.

framesReceived: 600 -> 601 -> frozen at 601 for 30 s -> permanent death.
The renderer submits exactly ONE frame after the resize and then nothing.
The [diag] tick spanning the resize reads `issued=136 acked=135` where
every one of the six before it was balanced (147/147, 148/148).

So one BeginFrame was issued and never acked. The renderer is not stuck
upstream of viz — a frame gets through. What dies is that single
in-flight ack, dropped when the Display was reconfigured underneath it.

This invalidates all three next steps the file proposed, which is the
useful part:

- probe 1's own hypothesis ("if the renderer is not painting, look at
  RenderWidgetHostView::SetSize") — it paints;
- waiting longer: awaiting_reconfigure_ack_ waits for "the ack viz still
  holds", and that ack no longer exists, so the wait cannot terminate.
  That is why attempt 2 traded a crash for a freeze;
- Stop()/Start(): the header's LIFECYCLE CONSTRAINT allows it only once
  the in-flight frame is known-drained, and viz clears
  pending_frame_callback_ only when the Display FINISHES the frame. It
  never finishes, so Stop/Start lands in precisely the forbidden window
  and trips the same double-issue that aborted the GPU in attempt 1.

All three assumed the pre-resize frame completes eventually. It does not.

Recorded where a fourth attempt should start instead — cheapest being to
invert attempt 2: hold the RESIZE for the ack rather than holding the
ack-chain for the resize, which costs one frame of latency and needs no
viz internals.

No code change. The next person gets a measured constraint instead of
three plausible directions that are all closed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
chore(stack): pin the gateway to a commit that can be named
All checks were successful
CodeQL / Analyze go (pull_request) Has been skipped
CodeQL / Analyze javascript-typescript (pull_request) Has been skipped
Public security / secrets (pull_request) Has been skipped
CI / Docs link check (pull_request) Successful in 5s
CI / Container smoke test (pull_request) Successful in 1m6s
native-peer-gate / native-peer-gate-strict (M7 gate) (pull_request) Successful in 19s
Public security / ownership (pull_request) Successful in 5s
native-peer-gate / native-peer-gate-scaffold (permissive) (pull_request) Successful in 1m30s
CI / Lint (pull_request) Successful in 4m41s
E2E / docker-compose + Playwright (pull_request) Successful in 5m4s
6a5c4222b4
676186a3 = commit c9e35d78, tag g-c9e35d78a4f0, built by
build-images.yaml today. Replaces sha256:63d6cfa7, which ran here with
no tag pointing at it and no commit naming it — unpinnable, because a
pin is a provenance claim.

Supersedes rather than reproduces it: main carries batches B and C
(#109, #112), so this is newer than what was running. Reproducing an
unidentifiable image was never the goal; being able to say what is
running is.

Verified after rolling: `crane config` reports
org.opencontainers.image.revision = c9e35d78a4,
the tag resolves to the pinned digest, login returns 303, and a viewer
gets ice=connected with 129 frames and 22466 audio bytes — so the audio
re-arm fix still holds on the new client bundle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
triform/chromeless!115
No description provided.