Skip to content

ci(framework-build-base): tag every published digest immutably, refuse a silent re-point - #31784

Merged
os-zhuang merged 5 commits into
mainfrom
claude/project-thread-g9042w
Nov 9, 2026
Merged

os-zhuang merged 5 commits into
mainfrom
claude/project-thread-g9042w

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Steedos · project thread

Fixes #31756

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:framework-build-base 发布工作流现在给每个发布出去的镜像 digest 打一个专属标签 SHA-DIGEST12(提交号加 digest 前 12 位),永不删除、永不改指向;已发布但没有这个标签的旧 digest,下次被请求时自动补上;rebuild: true 覆盖已发布标签时直接拒绝,除非同时给 replace_published: true。
  • 为什么改:cloud 按 digest 钉住这个基础镜像。GHCR 清理无标签版本,或同一提交重建把 SHA 标签挪走,都会让钉住的 digest 不再可拉,cloud 的每个镜像就退回约 20 分钟的源码构建。只有发布方能让 digest 常驻。
  • 风险与回滚:只动发布 job 的标签步骤和一个默认关闭的新输入;PR 的 verify 干跑不推送,发布路径只在 main 上手动或 repository_dispatch 时运行。回滚即还原本 PR;已写出的 digest 标签是额外标签,留着无害。
  • 先看:.github/workflows/framework-build-base.yml 的 Is this commit already packaged 步骤(新的拒绝分支),然后两个打标签步骤(kept、digest_tag,脚本逐字相同,含 --prefer-index=false)。
  • 两分钟验证:看下方 Verification 的本地运行记录:六个分支逐一给出预期退出码和 registry 状态;合并后一次 workflow_dispatch 已发布的 pin,应看到 Summary 打印 digest 标签,且匿名可读 $IMAGE:SHA-DIGEST12。
  • 需要你做的:审阅并手动合并(Tier H by path);合并后由席位征得你同意,再对一个已发布 pin 做一次 dispatch 补标签。

What changes

.github/workflows/framework-build-base.yml, publish job only:

  1. A digest tag of its own, never deleted or re-pointed. After the build, a new step Tag the pushed digest under its own name writes $IMAGE:$SHA-${DIGEST:7:12} onto the pushed digest with docker buildx imagetools create --prefer-index=false, then reads the tag back and refuses (::error::, exit 1) unless it holds exactly that digest. A digest that does not match ^sha256:[0-9a-f]{64}$ is refused before anything is written. DIGEST and SHA reach the script through env: only.
  2. Backfill. A step Keep the published digest served under its own tag runs whenever the SHA tag already exists, with the same script against the existing digest. On the skip path it still builds nothing; a pin published before this change gains its digest tag the next time it is requested. On the replace path it runs before the build, so the old digest is held by its own tag before the SHA tag moves.
  3. No silent re-point. New boolean workflow_dispatch input replace_published (default false). In Is this commit already packaged, rebuild: true over a published tag without replace_published: true prints ::error:: naming the existing digest and the downstream step (in the cloud repository, framework-base-image.sh write, then commit .objectstack-base-image) and exits 1. repository_dispatch carries neither input, so it is unaffected; the header says so.
  4. Header and Summary. Next to "A tag is written once", the header states that the digest tag is never deleted or re-pointed and why. The Summary prints the digest tag (and, on a replace, the tag that keeps the replaced digest served). The "Pin it by digest" line is kept.
  5. Only a real not-found reads as unpublished (contract review should-fix). Is this commit already packaged used to read every failed imagetools inspect as "not published", so a registry, token or network error rebuilt the commit and moved its SHA tag past both guards, before the backfill. The tag is now absent only when buildx answers ERROR: REF: not found (containerd's resolver wraps a 404 for the tag as REF: plus errdefs.ErrNotFound, the text not found; the step comment cites both sources). Any other failure prints ::error:: naming the ref and the error and exits 1; a read that names no digest fails the same way.
  6. scripts/pm/dispatch-gates.self-test.mjs: the census row framework-build-base.yml:32 no-check-families re-anchored to :44 (the declaration moved down 12 header lines). The declaration's own reason now also names the digest-tag writes, so it stays true.

Tests

No test file is added: the publish path cannot run from a PR (the verify dry run pushes nothing), so the proof is a local run of the workflow's own run: bodies, extracted verbatim from the YAML at head, against a fake docker that keeps a registry in a text file (echo instead of a registry write). The harness first asserts the two tag steps run byte-identical scripts.

Eight branches at 65fc33f, each with its expected exit
kept and digest_tag run byte-identical scripts
===== case fresh      (no SHA tag yet)
  existing: skip=false
  digest_tag: imagetools create --prefer-index=false --tag IMAGE:1111...1111-aaaaaaaaaaaa IMAGE@sha256:aaaaaaaaaaaa...0001
  digest_tag: IMAGE:1111...1111-aaaaaaaaaaaa holds sha256:aaaaaaaaaaaa...0001
  job exit 0; registry: SHA and SHA-aaaaaaaaaaaa both hold D1
===== case backfill   (SHA at D1, published before this change, no digest tag)
  existing: already exists at D1; nothing to build.  digest=D1 skip=true
  kept: imagetools create ... -aaaaaaaaaaaa ... ; holds D1          job exit 0
  requested again: kept: holds D1 (no create, idempotent)            job exit 0
===== case refuse     (SHA at D1, rebuild: true, replace_published: false)
  ::error::IMAGE:SHA is published at sha256:aaaaaaaaaaaa...0001, and rebuild: true would move it to a new digest. To replace it on purpose, dispatch again with rebuild: true and replace_published: true; then rewrite every downstream pin (in the cloud repository: run framework-base-image.sh write from its scripts directory, then commit .objectstack-base-image).
  job exit 1; registry unchanged
===== case replace    (SHA at D1, rebuild: true, replace_published: true)
  kept (before the build): SHA-aaaaaaaaaaaa holds D1
  build moves SHA to D2; digest_tag: SHA-bbbbbbbbbbbb holds D2
  Summary: digest tag SHA-bbbbbbbbbbbb; the replaced digest stays served under SHA-aaaaaaaaaaaa
  job exit 0
===== case shape      (existing digest sha256:AAAA)
  ::error::refusing to tag 'sha256:AAAA': a digest is sha256: followed by 64 lowercase hex
  job exit 1; nothing written
===== case repoint    (SHA-aaaaaaaaaaaa already holds another digest D3)
  ::error::IMAGE:SHA-aaaaaaaaaaaa already holds sha256:cccccccccccc...0003, not sha256:aaaaaaaaaaaa...0001. A digest tag is never re-pointed, so nothing was changed; ...
  job exit 1; registry unchanged
===== case notfound   (registry empty: inspect answers ERROR: REF: not found)
  existing: skip=false; build; digest_tag: SHA-aaaaaaaaaaaa holds D1    job exit 0
===== case transient  (SHA at D1, but the read fails: token service 503)
  ::error::cannot tell whether IMAGE:SHA is published, so nothing is built or moved. The registry read failed with: ERROR: failed to authorize: failed to fetch oauth token: unexpected status from GET request to https://ghcr.io/token: 503 Service Unavailable
  job exit 1; registry unchanged

IMAGE is ghcr.io/objectstack-ai/framework-build-base; SHA is forty 1s; long digests are shortened here with ..., the run printed them whole.

Red on main, and one mutation leg
  • The harm on main (c2bb879): main's Is this commit already packaged script, same fake registry with SHA at D1, REBUILD=true: exit 0, outputs skip=false. The build then pushes and moves the SHA tag, and D1 is left untagged. On this branch the same input is the refuse case above, exit 1.
  • The transient-read harm on the previous head (dd539bd): the transient case exits 0. existing reads the failed inspect as unpublished (skip=false), the build moves the SHA tag from D1 to D2, and D1 is left untagged. At 65fc33f the same case exits 1 with the registry unchanged.
  • Why --prefer-index=false: with the flag removed from a temp copy (2 lines changed, 0 left carrying it), the fake docker mimics buildx's default for a lone image manifest, wrapping it in a new index with its own digest. The fresh case then goes red: ::error::wrote IMAGE:SHA-aaaaaaaaaaaa, but it reads back as sha256:f895effb..., not sha256:aaaaaaaaaaaa...0001, so sha256:aaaaaaaaaaaa...0001 is not yet held by a tag of its own. (exit 1). The buildx behaviour it mimics is read from buildx's util/imagetools/create.go (combine: a single non-index source is carbon-copied only when preferIndex is false) and the flag's help text in commands/imagetools/create.go (default true). The publish job pushes a single-platform image with provenance: false and sbom: false, so its digest names a plain image manifest.

Acceptance notes

  • The brief's command was imagetools create --tag ... IMAGE@DIGEST. Without --prefer-index=false, buildx wraps a lone image manifest in a new index, so the tag would hold a different digest and the pinned one would stay untagged. The flag is added, and the read-back makes any such drift loud.
  • The brief placed the backfill on the skip path. The same step also runs on the replace_published path, before the build, so a pin rebuilt on purpose never leaves its old digest untagged, even when a later step fails.
  • The digest-tag step on the fresh path sits after Note the build wall, so the build-wall reading still measures the build only.
  • No error text names an issue number.

Verification

  • Dispatch gates at 65fc33f (main merged in): node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 46 commands. All 46 exit 0 (check:pm-dispatch-gates: 2205 fast-tier cases pass, 328.5 s). --ran reconciliation: 46 derived, 46 run, 0 NOT-MEASURED, 0 UNRUN.
  • node scripts/check-self-test-wired.mjs --self-test went red on this diff's first spelling of the refusal (it named cloud's script as a scripts/ path, which the live-corpus battery reads as an invocation of a missing file in this tree). It is green on the respelled text.
  • verify (the PR's dry run) is CI's to report on this head.
  • Owed after landing, not this PR: one workflow_dispatch on an already-published pin (it should backfill the digest tag), then an anonymous manifest read of $IMAGE:SHA-DIGEST12. The seat asks the maintainer first, because it writes to the registry.

Generated by Claude Code

…e a silent re-point

Every digest the publish job pushes, and the digest already published
under a requested sha, now carries its own tag SHA-DIGEST12 that is
never deleted or re-pointed, so a GHCR cleanup of untagged versions or a
rebuild of the same pin never takes a pinned digest away. A rebuild over
a published tag is refused unless replace_published is also true.

Re-anchors the dispatch-gates self-test row for the moved
no-check-families declaration (line 32 to 44).

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsR2jSrcejjpWHd8D1fpmZ
…om a held tag

A write whose read-back differs now says the tag was written and the
digest is not yet held, instead of claiming nothing changed.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsR2jSrcejjpWHd8D1fpmZ
…ipts/ path

The replace refusal spelled the cloud repository's script as a scripts/
path, which check-self-test-wired reads as an invocation of a file in
this tree.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsR2jSrcejjpWHd8D1fpmZ
@objectstack-fleet objectstack-fleet Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Nov 9, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:devx#2 · zhuangjianguo · session_01QsR2jTo666XXx9e1Gm21C6

Contract-review-claim: session_01QsR2jTo666XXx9e1Gm21C6 · head dd539bd · 2026-11-09T04:14Z

Rules face (.github/workflows/framework-build-base.yml), by an isolated Fable reviewer of this seat. The record follows.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:devx#2 · zhuangjianguo · session_01QsR2jTo666XXx9e1Gm21C6

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: dd539bd54e21c4622cf7ed872aed7feefa86b9f8
Local-runs: none
Scope: .github/workflows/framework-build-base.yml + scripts/pm/dispatch-gates.self-test.mjs

Inputs read: card #31756 (body, triage 6731150162, claim 6731438753, os-dev-report 6731764376), PR #31784 (body, 2-file list, net diff origin/main...dd539bd5, +112/-8), the 65 check-runs on the head (every non-skipped run success; Build framework build base (dry run, no push) success, Lint · PM dispatch-gates success, Governed Surface Queue Guard success, Check Changeset success, Build & push ghcr.io/objectstack-ai/framework-build-base skipped as the publish if: demands on a PR). The cited code outside the tree, docker/buildx util/imagetools/create.go, commands/imagetools/create.go and util/imagetools/printers.go, was read (fetched read-only) to judge deviation (1); the dev's harness under project files issue-31756 was skimmed as the dev's claim, not as ground truth. Nothing was built, run or re-run.

① Derived judgments

  • Accept set (dispatch-gates families): unchanged. The workflow still carries one whole # dispatch-gates: no-check-families -- reason line, now at line 44 (12 header lines added above it); the self-test census row moves 32 to 44 and the census compares the exact file:line string (self-test lines 8046-8081), so the pin is exact. Lint · PM dispatch-gates success is the gate verdict. Right.
  • Triggers and the PR dry run: on.pull_request.paths, the verify job and the publish job's if: are untouched; the PR's own dry run is the skipped publish plus a green verify, as before. Right.
  • workflow_dispatch surface: replace_published is added (boolean, default false, additive); rebuild: true over a published tag changes from "move the tag" to "refuse, naming the digest" — a narrowing the card itself orders (its fix item 2). Right.
  • repository_dispatch surface: REBUILD and REPLACE are inputs.x && 'true' || 'false', and inputs is empty on that event, so both read false; the hook can only build a missing tag or report an existing one, and now also backfills the digest tag on the existing one (a registry write the skip path did not make before, and a possible red from the new step). The header says it never replaces anything. Right.
  • Registry surface: a new tag family SHA-DIGEST12 in the framework-build-base package; the SHA tag keeps its meaning (the card asks for exactly this). Right.
  • Permissions: top-level contents: read; packages: write on publish only (lines 166-168); verify has no packages grant. Unchanged. Right.
  • Expression hygiene: every ${{ }} in the head file sits in env:, with:, if:, concurrency or an input default; a mechanical scan of every run: body finds none inlined. $IMAGE is the workflow-level env: export. Right.
  • Clause-②: no on the PR body: the diff publishes no package. Right.

② Semver level

No changeset, and Check Changeset is success: the diff touches .github/workflows/** and scripts/pm/** only, nothing a package publishes. Clause-②: no on the PR body agrees; Metadata-protocol: none agrees (no metadata schema touched). Right.

③ Boundary flags

Dev deviations (os-dev-report 6731764376), each weighed:

  1. imagetools create --prefer-index=false plus a read-back. Accepted, and necessary. buildx util/imagetools/create.go combine: with one source and no annotations, an index or manifest list is returned as its original bytes regardless of the flag, and a plain image manifest is returned as its original bytes only if !preferIndex (lines 104-120 on master); otherwise it is wrapped in a new OCI index with its own digest. commands/imagetools/create.go declares --prefer-index with default true. The publish job pushes one platform with provenance: false and sbom: false, so the pushed digest names a plain manifest: without the flag the tag would have held a different digest and the pinned one would stay untagged. The brief's bare command would therefore not have met the card. The flag needs buildx 0.13 or later; setup-buildx-action v4.4.1 installs the latest release.
    The read-back compares the right quantities: imagetools inspect TAG --format '{{json .Manifest}}' | jq .digest is the tag's top-level digest (printers.go: .Manifest is the manifest descriptor for an image manifest and a struct carrying p.manifest.Digest for an index), steps.build.outputs.digest is the pushed top-level digest, and existing reads the same field for the backfill. One quantity on every path.
  2. Backfill also runs on the replace_published path, before the build. Accepted, and necessary: without it a replace that moved the SHA tag and then failed before digest_tag would orphan the old digest. With it the old digest is held by its own tag before anything can move SHA.
  3. Fresh-path tag step after Note the build wall. Accepted: the wall reading stays comparable with main's one cold reading; in the seconds between push and tag the digest is held by IMAGE:SHA, and the next request backfills if the job dies there.
  4. no-check-families reason now names digest-tag writes. Accepted: the reason is still one whole line in the -- reason grammar (the census's "ENDS on its own marker line" case holds), and it is now true.

open_questions: [], security_findings: none, out_of_scope_findings: []: nothing to escalate. The owed post-landing workflow_dispatch on a published pin (the first real backfill and an anonymous read of the digest tag) is correctly left out of this PR; it writes to the registry and the seat asks the maintainer first. The dev's local proof is a fake docker whose "wrap" is a hash of the digest, a model of buildx, not buildx; the claim it models is confirmed above from buildx's own source, so the proof's reading is not load-bearing for this verdict.

④ Design judgment

.github/workflows/framework-build-base.yml (hits), judged against the card's two scenarios and the "never deleted or re-pointed" rule.

Scenario A, GHCR cleanup of untagged versions. Fresh path: Build and push writes IMAGE:SHA at D, digest_tag writes IMAGE:SHA-D12 onto IMAGE@D and refuses unless the tag reads back as D. Replace path: kept tags the old digest first, the build moves SHA, digest_tag tags the new digest. Skip path: kept backfills the digest already under SHA, idempotent when the tag already holds it. A failure between push and tag (runner death, the 60-minute wall) leaves the digest held by IMAGE:SHA, never untagged, and the next request for that SHA backfills it. A concurrent request for the same SHA waits on concurrency.group: framework-build-base-SHA (not cancelled; cancel-in-progress is PR-only) and then takes the skip path; a request for another SHA cannot produce this tag name. Holds.

Scenario B, rebuild: true of the same pin. existing: tag absent → build; present and REBUILD != true → skip; present, rebuild, REPLACE != true → ::error:: naming the digest and the cloud rewrite step, exit 1, nothing written; present, rebuild, replace → skip=false with digest handed on, so kept runs before the build. The refusal text names no issue number, and the respelling (no scripts/ path) is what check-self-test-wired accepted. Holds.

Re-point rule. Both tag steps call imagetools create only when the tag reads as absent, refuse with exit 1 when it holds another digest, and refuse after the write when it does not read back as DIGEST. The shape guard (^sha256:[0-9a-f]{64}$) runs before any write, so ${DIGEST:7:12} is always the first 12 hex. The two scripts are byte-identical in the diff. Holds, with the one finding below.

Header and summary: "never deleted or re-pointed" stands in the paragraph directly after "A tag is written once" with the reason (card's done-when); the rebuild input says it needs replace_published over a published tag; Summary prints the digest tag on every path and, on a replace, the tag that keeps the replaced digest served; "Pin it by digest" is kept.

Findings:

  • should-fix — Is this commit already packaged (line 213) reads DIGEST with stderr discarded and a trailing || true, so any inspect failure that is not "not found" (a GHCR 5xx, a token hiccup, a network fault) is read as "not published": skip=false, no digest output, kept does not run, and the job rebuilds and moves IMAGE:SHA to a new digest with neither rebuild nor replace_published, bypassing the refusal this PR adds and the backfill it relies on. For a digest published before this PR and not yet backfilled, that is exactly the loss the card closes; the new comment at lines 214-215 ("A published digest is handed on whatever happens next") is not true on that path. The swallow is inherited from main, but the refusal and the backfill are both built on it. Fix in the same step: keep stderr, treat only an explicit not-found as absent, and exit 1 naming the error otherwise. The same conflation in held() (lines 248, 336) is benign: a failed first read leads to a create that can only write the digest whose own 12-hex prefix names the tag (a real re-point needs a 48-bit prefix collision), and a failed second read is a loud exit 1.
  • nit — The SHA-DIGEST12 name space: two digests of the same SHA sharing their first 12 hex collide into one tag; the second is refused as a re-point and that SHA can no longer be replaced until a human reads the tag's history. At 2^-48 per pair this is an accepted deviation, recorded here so it is not rediscovered; no change asked.
  • nit — Summary on a refusal path (rebuild without replace, or a malformed digest) prints digest tag ...: not written and build wall: not reached s without saying why; the ::error:: above carries the reason. Prose only; leave as is or fold into a follow-up.

scripts/pm/dispatch-gates.self-test.mjs (implementing: the census pin that enforces the marker's line). The row '.github/workflows/framework-build-base.yml:44 no-check-families' matches the head's marker line exactly (32 + 12 header lines); the census is an exact string compare and the "whole reason" and "non-empty reason" cases hold on the re-worded line. Lint · PM dispatch-gates success. No finding.

Verdict basis: no must-fix. The should-fix earns a delta review of its fix; the two nits end with this round.

Implemented-by: claude/project-thread-g9042w
Reviewed-by: session_cse_01QsR2jTo666XXx9e1Gm21C6

VERDICT: PASS

…lished

A failed tag read (registry, token or network) used to read as
unpublished, so the job rebuilt and moved the sha tag past the replace
guard and before the backfill. Now only buildx's not-found answer
means unpublished; any other failure stops the job naming the ref and
the error.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsR2jSrcejjpWHd8D1fpmZ
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:devx#2 · zhuangjianguo · session_01QsR2jTo666XXx9e1Gm21C6

Contract-review-claim: session_01QsR2jTo666XXx9e1Gm21C6 · head 65fc33f · 2026-11-09T04:41Z

Delta review of the should-fix in 6732088839, by the same reviewer.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:devx#2 · zhuangjianguo · session_01QsR2jTo666XXx9e1Gm21C6

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 65fc33f44b5095b6c27de9309e438f0bcc8b05a4
Local-runs: none
Scope: .github/workflows/framework-build-base.yml + scripts/pm/dispatch-gates.self-test.mjs

Delta round of record 6732088839 (head dd539bd5, PASS with one should-fix). This record judges the fix for that should-fix and re-states the verdict on the new head; every judgment of 6732088839 not named below stands unchanged.

Inputs read: card #31756 (body and every comment, nothing new since the os-dev-report 6731764376), PR #31784 (updated body, 2-file list, net diff origin/main...65fc33f4, +132/-9), the check-runs on the head (48 success, 16 skipped, no failure; Build framework build base (dry run, no push) success, Lint · PM dispatch-gates success, Governed Surface Queue Guard success, Lint & Repo Gates success, Build & push ghcr.io/objectstack-ai/framework-build-base skipped as the publish if: demands on a PR), and the delta git diff dd539bd5 65fc33f4 -- .github/workflows/framework-build-base.yml. The cited code outside the tree was read (fetched read-only): containerd core/remotes/docker/resolver.go, containerd/errdefs errors.go, buildx util/imagetools/inspect.go, util/imagetools/location.go, util/imagetools/printers.go and cmd/buildx/main.go. The dev's proof-head.txt and proof-prev-transient.txt were read as the dev's claims. Nothing was built, run or re-run.

What the head holds: fix commit 9907636b (20 insertions, 1 deletion, the Is this commit already packaged step only) plus a merge of main at 8dbb4a12. The merge-base of the head with origin/main is 8dbb4a12 itself, and .github/workflows/framework-build-base.yml and scripts/pm/dispatch-gates.self-test.mjs are byte-identical between the previous merge-base and main, so the merge brought nothing into this PR's two files; the net diff is still those two files alone. The fix adds lines below the no-check-families marker, which stays on line 44, so the self-test census row still matches exactly (Lint · PM dispatch-gates success).

① Derived judgments

  • No accept-set or public-surface change beyond 6732088839: the fix is inside one run: body. Inputs, triggers, permissions (packages: write on publish only), the repository_dispatch reading (REBUILD/REPLACE both false) and the expression hygiene (no ${{ }} in any run: body; the new lines use $REF, $READ, $ERR, $DIGEST only) are as judged before. Right.
  • One behaviour change, the one asked for: a failed registry read no longer reads as "unpublished". Only buildx's not-found answer does; a successful read that names no digest, and every other failure, stop the job with ::error:: naming the ref and the registry's own text. The PR body's item 5 describes exactly the tree. Right.

② Semver level

Unchanged from 6732088839: no package published, Clause-②: no, no changeset (Check Changeset skipped on this head because no package path changed; it was success on the previous head for the same reason). Right.

③ Boundary flags

The dev raised no new deviation and no open question for the fix. The PR body's new proof cases (notfound exit 0 with the build; transient exit 1 with the registry unchanged; the previous head's transient exit 0 with D1 left untagged) are the dev's fake-docker claims; the fake prints ERROR: REF: not found because the dev wrote it to, so the wording claim is judged from the cited sources below, not from the proof. The four deviations accepted in 6732088839 stand.

④ Design judgment

.github/workflows/framework-build-base.yml (hits): the fix, sentence by sentence against the code it cites.

The wording. buildx imagetools inspect builds its Printer with ParseLocation(name) then resolver.Get(ctx, ref.String()); Get calls Resolve, which for a registry ref calls parseRef(in) (reference.ParseNormalizedNamed plus TagNameOnly) and hands ref.String() to containerd's resolver. For ghcr.io/objectstack-ai/framework-build-base:SHA (a domain-qualified name that already carries a tag) that string is byte-identical to $REF. In containerd's Resolve, the only host is ghcr.io; a 404 on the manifests/SHA request sets firstErr = fmt.Errorf("%s: %w", ref, errdefs.ErrNotFound) (resolver.go lines 352-356), ref being that same input string, and errdefs.ErrNotFound.Error() is "not found". buildx returns the error unwrapped (Get, Resolve, NewPrinter all return err) and cmd/buildx/main.go prints ERROR: %v to stderr. So the missing-tag answer is exactly ERROR: ghcr.io/objectstack-ai/framework-build-base:SHA: not found, and grep -Fq "$REF: not found" matches it as a fixed substring. The comment in the step is accurate. Holds.

Other 404s and other denials. A missing package takes the same 404 path and reads as unpublished, which is right: the push then creates it and there is nothing to protect. A private package or a token without pull scope does not answer 404 on GHCR: an authorization failure is wrapped as pull access denied, repository does not exist or may require authorization: ... (priority 1) and a 401/403 status as unexpected status ... 403 Forbidden (priority 3), neither of which carries REF: not found, so both fail closed and print the registry's text. In this job the read is made after docker/login-action with the repository GITHUB_TOKEN carrying packages: write, and the package is public (the anonymous-pull step depends on it), so neither case is reachable in normal operation and both are loud if they ever are. The resolver's other not-found texts (no resolve hosts: not found, content at URL not found from a later fetch) lack the REF: prefix and fail closed, correctly: a resolved manifest that cannot be fetched is not "unpublished". Holds.

Shell. set -euo pipefail and if READ="$(cmd)"; then (with the command's stderr redirected into $ERR) compose correctly: the status of an assignment from a command substitution is the command's status, and errexit is suspended inside an if condition, so a failing inspect reaches the elif/else instead of killing the step; there is no pipe, so pipefail plays no part. grep -Fq is a fixed-string match ($REF holds no pattern characters anyway) and exits 1 on an empty file, so a silent death of docker lands in the fail-closed else. DIGEST="$(printf '%s' "$READ" | jq -r '.digest // empty')" can only fail if buildx printed non-JSON on success, which --format '{{json .Manifest}}' does not; if it ever did, set -e would stop the step with jq's error (fail closed, less well named). The success branch with an empty digest also fails closed. The mktemp file is never removed and sits in /tmp rather than $RUNNER_TEMP as the file's other temp paths do (lines 390, 398); one small file on an ephemeral hosted runner for the life of the job has no effect on correctness or on the build's disk budget. Holds.

Likelihood of a false fail-closed. The grep target is text owned by containerd (the %s: %w with ErrNotFound, unchanged in that resolver since its first version) and by buildx's ERROR: prefix, both reached through the buildx release that setup-buildx-action installs. If either ever changed, every first publish of a new SHA would stop at existing with ::error::cannot tell whether REF is published ... followed by the registry's real stderr, so the cause is on the first red run's face and no digest can be lost by it. Low likelihood, loud and lossless failure mode: an acceptable coupling, recorded as a nit below.

Findings:

  • nit — The unpublished test is a text match on containerd's and buildx's error wording rather than on the HTTP status. Any wording change fails every first publish closed, loudly and without loss (the ::error:: prints the registry's own text). Accepted deviation; the alternative (a raw curl of the manifest with a token exchange) would re-implement the auth path for a check buildx already makes. No change asked.
  • nit — containerd also spells rejecting N byte manifest for REF: not found when a manifest exceeds its 4 MiB MaxManifestSize; that text would match the grep and read as unpublished. A plain single-platform image manifest is a few kilobytes, so the path is unreachable for this image; noted so a future multi-arch or attested index is not surprised by it. No change asked.
  • nit — ERR="$(mktemp)" is not removed and does not use $RUNNER_TEMP as the rest of the file does. No effect on an ephemeral runner; a consistency touch for a prose-face pass or a follow-up, not this head.

scripts/pm/dispatch-gates.self-test.mjs (implementing): untouched by the fix; the :44 row still matches the live marker line. No finding.

The should-fix of 6732088839 is closed by the tree: a transient registry, token or network error at existing now stops the job before anything is built or moved, so the replace_published refusal and the pre-build backfill can no longer be bypassed by a swallowed read. No must-fix, no should-fix; nits only, so this round ends here.

Implemented-by: claude/project-thread-g9042w
Reviewed-by: session_cse_01QsR2jTo666XXx9e1Gm21C6

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:devx#2 · zhuangjianguo · session_01QsR2jTo666XXx9e1Gm21C6

维护者速读

  • 改了什么:framework-build-base.yml 推送的每个镜像 digest 都多打一个只属于它的标签 SHA-<digest 前 12 位>,写一次,永不删除、永不改指。已发布的旧 digest 在下一次被请求时补打这个标签。rebuild: true 覆盖已发布的版本,现在必须同时给 replace_published: true,否则拒绝并说明下游要改什么。"是否已发布"这一步只把 registry 明确的 not found 当作未发布,其他读取失败一律报错停下。
  • 为什么改:ci(framework-build-base): tag every published base digest immutably, so a GHCR untagged cleanup or a rebuild: true never removes the digest cloud pins #31756。cloud 按 digest 固定基础镜像。GHCR 清理无标签版本,或同一 pin 重建,都会让那个 digest 取不到,cloud 每次构建就退回约 20 分钟的源码构建。
  • 风险与回滚:只改这个发布 workflow 和自测里的一处行号。packages: write 仍只在 publish job。回滚 = revert 本 PR。
  • 先看:Is this commit already packaged 和两个 …served under its own tag 步骤。
  • 两分钟验证:本 PR 的 CI 全绿(48 成功、16 跳过),dry run 构建成功。发布路径要合并后跑一次 dispatch 才看得到。
  • 需要你做的:亲手合并本 PR(Tier H,发布 workflow)。契约复核两轮 PASS 在案:6732088839,以及 delta 轮 6732418896(Head-sha: 65fc33f44b5095b6c27de9309e438f0bcc8b05a4)。合并后,本席请你允许对一个已发布的 pin 跑一次 dispatch,验证补打标签。
  • 席位意见:建议合。

席位核对 · ACCEPT · 2026-11-09T04:42Z · head 65fc33f44b5095b6c27de9309e438f0bcc8b05a4

Seat domain:devx#2 reviewed the diff of its own dispatch (claim 6731438753 on #31756, card thread session_01QsR2jSrcejjpWHd8D1fpmZ, report 6731764376). Checked against GitHub, not against the report.

  1. Shape: net diff against main: 2 files, +132 / -9. framework-build-base.yml: the replace_published input, the reworked existing step, two steps that hold each digest under its SHA-DIGEST12 tag (the backfill before any build, and the fresh push after it), the header and the summary. dispatch-gates.self-test.mjs: the no-check-families line pin moves from 32 to 44. Every file is inside the claim. Body: Fixes #31756, skip-changeset, Clause-②: no. The seat applied the dev's v2 body for the new head.
  2. Premise: on main, publish tags only $IMAGE:$SHA. rebuild: true re-points that tag, and an untagged digest is what a GHCR cleanup removes. The diff gives every digest a tag of its own and never re-points one. The dev's deviation --prefer-index=false is necessary: buildx's default wraps a lone manifest in a new index with another digest. The reviewer confirmed that from the buildx source.
  3. Gates / check-runs: this head has 64 completed runs: 48 success, 16 skipped, 0 red. The dry run Build framework build base and Lint · PM dispatch-gates are green. Build & push is skipped, as the publish if: demands on a PR. check-expected-skips --pr 31784 from main: OK, 16 skipped, every one in main's roster, exit 0. mergeable_state: clean. Dev: 46 derived families, 46 exit 0. A fake-docker proof covers eight branches, including the prior-head harm leg (a transient read error moved the tag on dd539bd5).
  4. Contract review: two rounds by the same isolated Fable reviewer. Round 1 was 6732088839 on dd539bd5 (claim 6732083732): PASS with one should-fix. The existing step read every inspect failure as "not published", so a transient registry error could rebuild and move $IMAGE:$SHA past both new guards. Commit 9907636b closes it: only the registry's REF: not found answer means unpublished, and any other failure exits 1. Round 2 is 6732418896 on this head (claim 6732414373): PASS, nits only. The not-found wording was verified against the containerd and buildx sources.
  5. Accepted deviations: the records' nits. N1: the unpublished test matches error text, not an HTTP status, so a wording change would fail a first publish closed and loudly, never lose a digest. N2: a 12-hex prefix collision (about 2^-48) would refuse a replace until a human acts. N3: the summary on refusal paths omits the reason, which the ::error carries. N4: containerd's oversize-manifest message also ends in REF: not found, which cannot happen for a few-KB single-platform manifest. N5: the mktemp file is not removed. None of these moves the head.
  6. Landing: check-governed-merges classes this governed, Tier H, because framework-build-base.yml is on the pinned H-by-path list. The maintainer merges by hand. One check is owed after landing, and it writes to the registry, so the seat asks the maintainer first: one workflow_dispatch on an already-published pin, which should backfill its SHA-DIGEST12 tag, followed by an anonymous manifest read of that tag. Then this seat writes the landing note.

@objectstack-fleet objectstack-fleet Bot added the pm:awaiting-merge PR only: landing waits on the maintainer's own review and merge (SKILL.md 复核 ②); never on an issue label Nov 9, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review November 9, 2026 05:22
@os-zhuang
os-zhuang enabled auto-merge November 9, 2026 05:22
@os-zhuang
os-zhuang added this pull request to the merge queue Nov 9, 2026
Merged via the queue into main with commit 38ce26f Nov 9, 2026
70 checks passed
@os-zhuang
os-zhuang deleted the claude/project-thread-g9042w branch November 9, 2026 05:53
@objectstack-fleet objectstack-fleet Bot removed the pm:awaiting-merge PR only: landing waits on the maintainer's own review and merge (SKILL.md 复核 ②); never on an issue label Nov 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ci(framework-build-base): tag every published base digest immutably, so a GHCR untagged cleanup or a rebuild: true never removes the digest cloud pins

2 participants