From 04b988794db5b4290c6a1804f85322acc6792705 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Wed, 26 Aug 2026 12:23:34 -0300 Subject: [PATCH 1/5] Guard backfill against mislabeling a partial publish. --- scripts/backfill_iteration_tags.py | 73 ++++++++++++++++++---- tests/unit/test_backfill_iteration_tags.py | 45 +++++++++++++ 2 files changed, 106 insertions(+), 12 deletions(-) diff --git a/scripts/backfill_iteration_tags.py b/scripts/backfill_iteration_tags.py index a192c13..3788000 100755 --- a/scripts/backfill_iteration_tags.py +++ b/scripts/backfill_iteration_tags.py @@ -26,17 +26,23 @@ digests are recoverable here. For the given cli: - 1. Resolve iteration `N` — the highest `v[-N]` release tag. The mutable - per-arch tags reflect that newest iteration's content, which is all the - registry still exposes (superseded iterations were orphaned when overwritten - and cannot be recovered). + 1. Resolve iteration `N` — the highest `v[-N]` release tag, or an explicit + `--iteration`. The mutable per-arch tags reflect that newest iteration's + content, which is all the registry still exposes (superseded iterations were + orphaned when overwritten and cannot be recovered). Auto-resolving assumes + the newest release's publish reached the build+push step; if it failed + *before* pushing images the live tags still hold an earlier iteration's + content, so pass `--iteration ` to label it correctly instead of + mislabeling it as the newest N. 2. Read the index digest each current `:-rust-` tag exposes (the tag's own top-level digest — the same `bldimg` anchor the publish workflow records, not the child per-platform submanifest). 3. `docker buildx imagetools create` an immutable `:-rust--` tag for each digest, re-referencing it so it can no longer become untagged. -Per-arch tags that already exist are skipped, so the script is safe to re-run. +A snapshot tag that already pins the same digest is skipped, so the script is +safe to re-run; one that exists pinning a *different* digest fails loudly rather +than being silently clobbered — that would be an immutability violation. """ import argparse @@ -140,6 +146,17 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--stellar-cli-version", required=True, metavar="V") parser.add_argument("--registry", default="docker.io/stellar/stellar-cli", metavar="REF") parser.add_argument("--repo", default="stellar/stellar-cli-docker", metavar="SLUG") + parser.add_argument( + "--iteration", + type=int, + metavar="N", + help=( + "Iteration index to label the recovered snapshots with. Defaults to " + "the highest v[-N] release tag. Override when the newest " + "release's publish failed before pushing images, so the live per-arch " + "tags still hold an earlier iteration's content." + ), + ) parser.add_argument( "--dry-run", action="store_true", @@ -148,16 +165,40 @@ def build_parser() -> argparse.ArgumentParser: return parser +def resolve_iteration(args, cli: str) -> int: + """The iteration index to label recovered snapshots with. + + An explicit `--iteration` wins. Otherwise it's the newest `v[-N]` + release, which assumes that release's publish reached build+push so the live + per-arch tags hold its content — a loud warning flags the assumption so an + operator recovering from a publish that failed before pushing knows to pass + `--iteration ` instead of mislabeling an earlier iteration as the newest. + """ + if args.iteration is not None: + return args.iteration + iteration = latest_iteration(gh_cli.list_release_tags(args.repo), cli) + if iteration is None: + common.die(f"no published releases found for stellar-cli {cli}") + common.log( + f"labeling recovered snapshots as iteration {iteration} (newest " + f"v{cli}[-N] release); this assumes that release's publish pushed its " + f"per-arch images. If it failed before the build/push step, the live " + f"tags still hold an earlier iteration — re-run with --iteration to " + f"pin the correct one." + ) + return iteration + + def main(argv: list[str] | None = None) -> int: args = build_parser().parse_args(argv) + if args.iteration is not None and args.iteration < 0: + common.die(f"--iteration must be non-negative, got {args.iteration}") common.preflight_checks(["buildx", "gh"]) cli = args.stellar_cli_version registry = args.registry - iteration = latest_iteration(gh_cli.list_release_tags(args.repo), cli) - if iteration is None: - common.die(f"no published releases found for stellar-cli {cli}") + iteration = resolve_iteration(args, cli) repo_path = dockerhub.repo_path(registry) pairs = current_pairs(dockerhub.list_tags(repo_path), cli) @@ -168,11 +209,19 @@ def main(argv: list[str] | None = None) -> int: skipped = 0 for (key, arch), digest in sorted(pairs.items()): target = f"{registry}:{cli}-rust{key}-{arch}-{iteration}" - if docker_inspect.exists(target): - common.log(f"skip {target}: already tagged") - skipped += 1 - continue source = f"{registry}@{digest}" + if docker_inspect.exists(target): + existing = docker_inspect.index_digest(target) + if existing == digest: + common.log(f"skip {target}: already pins {digest}") + skipped += 1 + continue + common.die( + f"{target} already exists pinning {existing}, but the live " + f"per-arch tag now exposes {digest}; refusing to re-point an " + f"immutable tag. If a newer iteration has since published, pass " + f"--iteration for the correct index." + ) common.log(f"::group::backfill {target} -> {source}") if args.dry_run: common.log(f"docker buildx imagetools create --tag {target} {source}") diff --git a/tests/unit/test_backfill_iteration_tags.py b/tests/unit/test_backfill_iteration_tags.py index 2422aa7..aa82577 100644 --- a/tests/unit/test_backfill_iteration_tags.py +++ b/tests/unit/test_backfill_iteration_tags.py @@ -125,6 +125,14 @@ def _wire_main(monkeypatch: pytest.MonkeyPatch, *, existing: set[str], releases= monkeypatch.setattr(backfill.gh_cli, "list_release_tags", lambda repo: releases or ["v25.1.0"]) monkeypatch.setattr(backfill.dockerhub, "list_tags", lambda repo_path: _hub_tags()) monkeypatch.setattr(backfill.docker_inspect, "exists", lambda ref: ref in existing) + + # An already-existing snapshot pins the same index digest its live per-arch + # tag exposes — the safe, re-runnable case, so `main` skips it. Tests that + # want a re-point conflict patch index_digest to return something else. + def _index_digest(ref: str) -> str: + return ARM64_INDEX if ref.rsplit("-", 1)[0].endswith("arm64") else AMD64_INDEX + + monkeypatch.setattr(backfill.docker_inspect, "index_digest", _index_digest) created = MagicMock() monkeypatch.setattr(backfill.docker_inspect, "create_manifest", created) return created @@ -160,6 +168,8 @@ def test_main_uses_highest_release_iteration(monkeypatch: pytest.MonkeyPatch) -> def test_main_skips_already_tagged_arches(monkeypatch: pytest.MonkeyPatch) -> None: + # amd64's snapshot already exists pinning the same digest → skip; arm64's is + # created. created = _wire_main(monkeypatch, existing={_arch_tag("amd64")}) rc = backfill.main(["--stellar-cli-version", "25.1.0", "--registry", "reg/img"]) @@ -170,6 +180,41 @@ def test_main_skips_already_tagged_arches(monkeypatch: pytest.MonkeyPatch) -> No assert _arch_tag("arm64") in tags +def test_main_refuses_to_repoint_existing_snapshot(monkeypatch: pytest.MonkeyPatch) -> None: + # A snapshot that already exists pinning a *different* digest than the live + # per-arch tag is an immutability violation — fail loudly, don't clobber. + _wire_main(monkeypatch, existing={_arch_tag("amd64")}) + monkeypatch.setattr(backfill.docker_inspect, "index_digest", lambda ref: "sha256:" + "0" * 64) + + with pytest.raises(SystemExit): + backfill.main(["--stellar-cli-version", "25.1.0", "--registry", "reg/img"]) + + +def test_main_accepts_explicit_iteration(monkeypatch: pytest.MonkeyPatch) -> None: + # Newest release is -1, but --iteration pins the live content to 0 (e.g. the + # -1 publish failed before pushing, so the live tags still hold -0's images). + created = _wire_main(monkeypatch, existing=set(), releases=["v25.1.0", "v25.1.0-1"]) + + rc = backfill.main( + ["--stellar-cli-version", "25.1.0", "--registry", "reg/img", "--iteration", "0"] + ) + + assert rc == 0 + tags = [call.args[0] for call in created.call_args_list] + assert _arch_tag("amd64", 0) in tags + assert _arch_tag("arm64", 0) in tags + assert _arch_tag("amd64", 1) not in tags + + +def test_main_rejects_negative_iteration(monkeypatch: pytest.MonkeyPatch) -> None: + _wire_main(monkeypatch, existing=set()) + + with pytest.raises(SystemExit): + backfill.main( + ["--stellar-cli-version", "25.1.0", "--registry", "reg/img", "--iteration", "-1"] + ) + + def test_main_dry_run_creates_nothing(monkeypatch: pytest.MonkeyPatch) -> None: created = _wire_main(monkeypatch, existing=set()) From 90e6e416f450d0fd638b6cf723d42d0024e504bb Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Wed, 26 Aug 2026 12:23:39 -0300 Subject: [PATCH 2/5] Fix stale tag reference in publish workflow comment. --- .github/workflows/publish.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c1eb7c5..58ac758 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -44,8 +44,8 @@ jobs: # auto-picked by the release workflow. Strip leading "v" and the # trailing "-" to derive the stellar-cli version; the refresh # index N (0 when there's no suffix, i.e. the first release) names - # the immutable :- Docker tag published by the aliases - # job (see issue #38). + # the immutable :-rust-- Docker tags minted by the + # manifest job (see issue #38). no_prefix="${RELEASE_TAG#v}" version="${no_prefix%%-*}" test -n "$version" || { echo "::error::could not determine stellar_cli_version from release tag '$RELEASE_TAG'"; exit 1; } From 1c7bb30840c49ddc199c60b67f0e17a6194d933e Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Wed, 26 Aug 2026 12:23:39 -0300 Subject: [PATCH 3/5] Correct release-branch and re-run tag docs. --- RELEASE.md | 8 +++++--- scripts/lib/gh_cli.py | 11 +++++++---- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 976b4ab..abeadd1 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -56,7 +56,7 @@ Every release gets a unique tag. Tags are never reused or updated in place. - **First release of a stellar-cli version**: `v-0` (e.g. `v26.0.0-0`). - **Refresh of the same stellar-cli version**: `v-` with `N` incrementing per refresh (e.g. `v26.0.0-1`, `v26.0.0-2`). -The `-N` index lines up one-to-one with the immutable `:-rust--` Docker tags, starting at `-0`. The `release` workflow picks the next available `-N` automatically from **both** existing releases and existing `release/*` branches — so an iteration that's been prepared (branch/PR merged) but whose GitHub Release hasn't been published yet never gets its number reused. Reuse would republish those immutable tags over different digests and defeat their immutability. Each release page is the snapshot of `builds.json` at that iteration; the historical `v26.0.0-0` page stays intact when `v26.0.0-1` is later published. +The `-N` index lines up one-to-one with the immutable `:-rust--` Docker tags, starting at `-0`. The `release` workflow picks the next available `-N` automatically from **both** existing releases and open `release/*` branches — so an iteration that's been prepared (branch/PR open) but not yet released never gets its number reused while it's in review. Reuse would republish those immutable tags over different digests and defeat their immutability. (The branch is auto-deleted on merge; publishing the GitHub Release follows merge immediately, so there's no practical window to reuse a merged-but-unpublished iteration's number.) Each release page is the snapshot of `builds.json` at that iteration; the historical `v26.0.0-0` page stays intact when `v26.0.0-1` is later published. > A handful of early releases predate this scheme and use a suffixless `v` tag (e.g. `v25.1.0`); those count as iteration 0, so the next refresh of such a version is `-1`. @@ -143,9 +143,11 @@ Triggered exclusively by the `release: published` event — when a maintainer cl Per-architecture tags (`:-rust-`) and multi-arch manifest lists (`:-rust`) on Docker Hub are **mutable** — re-publishing a `(cli, rust base)` pair overwrites the tag in place. Reproducibility is anchored by the per-arch image content digest and by the `builds.json` pins, not by tag stability. -Moving aliases (`:`, `:latest`) re-point each release. The immutable `:-rust--` snapshots are the exception — they're keyed by the release's refresh index, so a re-run recreates the same tags at the same digests rather than moving them. +Moving aliases (`:`, `:latest`) re-point each release. The immutable `:-rust--` snapshots are the exception — they're keyed by the release's refresh index and, by design, never move: the `manifest` job leaves an existing `:…-` tag alone when it already pins the same digest and **fails loudly** if a re-run built a different digest, rather than clobbering an on-chain `bldimg` anchor. -To recover from a failed run, use **Re-run failed jobs** from the GitHub Actions UI; re-runs simply rebuild and overwrite. Recovering from a corrupt push is the same — just re-run, no manual tag deletion needed. +To recover from a failed run, use **Re-run failed jobs** from the GitHub Actions UI. This re-runs against the same release event, so the tag and its refresh index `N` are unchanged — no new GitHub Release is created. Re-running only the failed downstream jobs (`manifest`, `aliases`, `release`) reuses the per-arch images already pushed by `build` and just overwrites the mutable tags; no manual tag deletion is needed. + +Re-running the `build` job itself is different: builds are not byte-reproducible (`BUILD_DATE` is the run's wall-clock time), so a rebuild generally produces a **new** per-arch digest. The mutable tags overwrite fine, but the `manifest` job will then refuse to re-point the already-created immutable `:…-` snapshot and fail. That guard is intentional — it protects the digest a contract may already pin. If you truly need to replace a published iteration's content, cut a **new** refresh iteration (`v-`) instead of rebuilding an existing one. ## Backfilling immutable per-arch tags for older releases diff --git a/scripts/lib/gh_cli.py b/scripts/lib/gh_cli.py index 845504a..18f8969 100644 --- a/scripts/lib/gh_cli.py +++ b/scripts/lib/gh_cli.py @@ -30,11 +30,14 @@ def list_release_tags(repo: str) -> list[str]: def list_release_branch_tags(repo: str) -> list[str]: """Release tags of the `release/` branches that exist on the repo. - A release branch is created at prepare time and persists across the - merge -> publish gap (merging the PR doesn't publish the GitHub - Release). Consulting it stops the tag picker from reusing an iteration - that's already been prepared but not yet published — which would let a + A release branch is created at prepare time and lives while its release PR + is open. Consulting it stops the tag picker from reusing an iteration that's + been prepared (branch/PR open) but not yet released — which would let a later publish overwrite the immutable `:-rust--` tags. + + The repo auto-deletes the branch on merge, so this covers the review window + (prepare -> merge); the normal flow publishes the GitHub Release right after + merge, so the brief merge -> publish gap isn't separately guarded here. """ out = runner.capture( [ From 9f771e7fb2c57b9e5af9f528c1fba9b019a0c92d Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Wed, 26 Aug 2026 12:52:19 -0300 Subject: [PATCH 4/5] Type-hint the resolve_iteration args parameter. --- scripts/backfill_iteration_tags.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/backfill_iteration_tags.py b/scripts/backfill_iteration_tags.py index 3788000..c6673ce 100755 --- a/scripts/backfill_iteration_tags.py +++ b/scripts/backfill_iteration_tags.py @@ -165,7 +165,7 @@ def build_parser() -> argparse.ArgumentParser: return parser -def resolve_iteration(args, cli: str) -> int: +def resolve_iteration(args: argparse.Namespace, cli: str) -> int: """The iteration index to label recovered snapshots with. An explicit `--iteration` wins. Otherwise it's the newest `v[-N]` From 7165231640a2321e22c9ace749fc2d34dcf8dee4 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Wed, 26 Aug 2026 12:52:19 -0300 Subject: [PATCH 5/5] Reword release-branch docstring to avoid PR-state implication. --- scripts/lib/gh_cli.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/scripts/lib/gh_cli.py b/scripts/lib/gh_cli.py index 18f8969..498c772 100644 --- a/scripts/lib/gh_cli.py +++ b/scripts/lib/gh_cli.py @@ -30,10 +30,11 @@ def list_release_tags(repo: str) -> list[str]: def list_release_branch_tags(repo: str) -> list[str]: """Release tags of the `release/` branches that exist on the repo. - A release branch is created at prepare time and lives while its release PR - is open. Consulting it stops the tag picker from reusing an iteration that's - been prepared (branch/PR open) but not yet released — which would let a - later publish overwrite the immutable `:-rust--` tags. + A release branch is created at prepare time and exists until its release PR + is merged. Consulting it stops the tag picker from reusing an iteration + that's been prepared (branch pushed, PR not yet merged) but not yet released + — which would let a later publish overwrite the immutable + `:-rust--` tags. The repo auto-deletes the branch on merge, so this covers the review window (prepare -> merge); the normal flow publishes the GitHub Release right after