Skip to content

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

Draft
objectstack-fleet[bot] wants to merge 3 commits into
mainfrom
claude/project-thread-g9042w
Draft

objectstack-fleet[bot] wants to merge 3 commits into
mainfrom
claude/project-thread-g9042w

Conversation

@objectstack-fleet

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. 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.

Six branches at dd539bd, 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

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.
  • 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 dd539bd: 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, 316.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

This branch has not been deployed

No deployments
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

1 participant