Skip to content

fix(ci): cover every image-ref storage shape in promote, retag and mirror - #3404

Merged
myasnikovdaniil merged 9 commits into
mainfrom
fix/image-ref-tag-scope
Jul 22, 2026
Merged

fix(ci): cover every image-ref storage shape in promote, retag and mirror#3404
myasnikovdaniil merged 9 commits into
mainfrom
fix/image-ref-tag-scope

Conversation

@myasnikovdaniil

@myasnikovdaniil myasnikovdaniil commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

The bug

Promotion rewrites the rc version substring to the stable version across the vendored image references. That rewrite lived inline in promote-rc.yaml and globbed the depth-2 package values.yaml plus packages/apps/kubernetes/images/*.tag only, on the premise that the kubernetes app was the only one whose .tag files carry the cozystack version. Nine other .tag files do, and system/multus seds its reference straight into a vendored upstream daemonset manifest.

#3397 (promote v1.6.0-rc.4v1.6.0) is therefore staged with 33 of 55 first-party references still reading v1.6.0-rc.4.

hack/promote-retag.sh and hack/nightly-mirror.sh had the same blind spot from the other direction — both scanned the depth-2 values.yaml alone, so those images were never retagged to the stable version (30 references selected before, 42 after) and never mirrored to the public registry for a nightly. A retag miss leaves an image short of a tag, since the digest still resolves inside one registry. A mirror miss is worse: the host rewrite walks the same file list, so a reference that is neither mirrored nor rewritten leaves the published tree pointing at the private build registry.

v1.6.0 is the first release to go through promotion — every earlier release was a full rebuild from the tag, which stamped every file uniformly. The glob has always been wrong; the old path just made it unobservable.

Why it could not be caught before

The rewrite was workflow-inline, so there was nothing to unit test. The only way to observe the miss was to cut a release.

It is a script now — hack/promote-rewrite-tags.sh — and hack/promote-rewrite-tags_test.bats round-trips it against the real tree under make unit-tests, with no cluster, registry or release involved.

The fix

The three call sites each carried their own idea of where a reference can live, which is how they drifted apart. That knowledge moves into hack/lib/image-refs.sh as the single enumeration — image_ref_files() for the files, collect_image_refs() for the references inside them — and all three source it, so a new storage location is declared once and reaches every consumer.

The rewrite also asserts its own postcondition: it scans wider than it rewrites and fails the promotion when a reference survives in a location the enumeration does not know about, rather than shipping it quietly. The wide scan is shape-filtered to image-reference position, because X.Y.Z-rc.N is an ordinary version string that other things legitimately contain — kubevirt-csi-driver/go.sum pins github.com/golang/protobuf v1.4.0-rc.2, and v1.4.0-rc.2 is a cozystack rc that was actually cut. Narrowing it to ${RC}@sha256: instead would have been wrong: system/cilium/values.yaml keeps tag: and digest: under separate keys.

docs/agents/image-refs.md records the contract — three tag classes, three storage shapes, the invariants, and which consumer does what.

Not a bug: kamaji

cluster-api-control-plane-provider-kamaji stays at v0.19.0-cozystack.0 across the promotion. It is a first-party rebuild of an upstream component, versioned by that component rather than by the cozystack version line, so no rewrite is owed. A test pins this so a future fix here cannot over-reach.

Documented known gaps, deliberately not fixed here

Both are pre-existing and in the host dimension; the version dimension this PR owns is complete, because the version always lives in a tag key regardless of where the host sits.

  • The nightly host rewrite cannot reach a split host. It is a literal <src-registry>/ substring replace, so keycloak-operator (host in a sibling registry: key) and kubeovn (global.registry.address, no trailing slash) are mirrored but keep pointing at the build registry in the published tree. Fixing it needs structure-aware rewriting.
  • A reference inside a gzip is invisible. capi-providers-cpprovider ships files/components.gz, and the promote postcondition cannot catch this class at all since grep -rIl skips binaries. Nothing is owed today — kamaji is component-versioned and the image is still mirrored and retagged via its .tag — so the residual is that a nightly's kamaji ConfigMap keeps the build-registry host.

Testing

  • 17 tests in the new suite; make bats-unit-tests green (312 tests, exit 0); shellcheck -x clean; zizmor clean on the workflow.
  • Verified against four real rc versions (1.4.0-rc.2, 1.4.0-rc.4, 1.5.0-rc.2, 1.6.0-rc.4) in both directions — no false positives, no false negatives.
  • Every test is mutation-proven. Reverting the enumeration to the old glob, deleting any of the five YAML shapes, corrupting emitted digests, reverting extras to parse-only, dropping the unreadable-file guard, broadening shape 4 beyond global.images, or planting an unenumerated first-party reference in any storage shape each fail a named test.

Reviewer note, out of scope

platform-migrations is pinned at two different digests — core/platform/values.yaml at v1.5.0 and backupstrategy-controller/values.yaml at v1.4.0-rc.2 (an unowned pin; no Makefile writes that key, tracked in #3143). A promote-retag.sh dry-run emits two copies to the same destination tag, so the write-once guard will fail the promotion. This is independent of this PR and needs a decision about the backup-client image version, so it is left alone here — but it blocks the 1.6.0 promote and wants fixing before the re-cut.

Release note

Promotion, retagging and nightly mirroring now cover every image reference the build stamps, including those stored in `images/*.tag` files and in vendored manifests. Previously a promoted release could ship images still tagged with the release-candidate version, and those images received no stable tag in the registry.

Fixes the promote half of #3143.

Summary by CodeRabbit

  • New Features

    • Image promotion and nightly mirroring now detect digest-pinned image references across values files, standalone tag files, and supported templates.
    • Stable release preparation supports RC→stable tag rewriting via a dedicated promotion script, with broader verification to ensure no RC references remain.
  • Bug Fixes

    • Improved completeness of ref discovery and scanning, including previously missed template/tag locations.
  • Documentation

    • Added and linked guidance documenting image-reference invariants and how tooling enumerates supported storage shapes.

…rror

Promotion rewrote the rc version substring across the depth-2 package
values.yaml plus packages/apps/kubernetes/images/*.tag alone, on the
premise that the kubernetes app was the only one whose .tag files carry
the cozystack version. Nine other .tag files do, and system/multus seds
its ref straight into a vendored upstream manifest. v1.6.0 was therefore
staged with 33 of 55 first-party refs still reading v1.6.0-rc.4.

promote-retag.sh and nightly-mirror.sh had the same blind spot from the
other direction: both scanned only the depth-2 values.yaml, so those
images were never retagged to the stable version (30 refs selected
before, 42 after) and never mirrored to the public registry for a
nightly. A retag miss merely leaves an image short of a tag, since the
digest still resolves inside one registry; a mirror miss is worse,
because the host rewrite walks the same file list and a ref that is
neither mirrored nor rewritten leaves the published tree pointing at the
private build registry.

The three call sites each carried their own idea of where a ref can
live, which is how they drifted apart. Move that knowledge into
hack/lib/image-refs.sh as the single enumeration -- image_ref_files()
for the files, collect_image_refs() for the refs inside them -- and have
all three source it, so a new storage location is declared once and
reaches every consumer.

The rewrite itself moves out of promote-rc.yaml into
hack/promote-rewrite-tags.sh. Being workflow-inline is why this was only
observable by cutting a release: there was nothing to unit test. It now
also asserts its own postcondition, scanning wider than it rewrites and
failing the promotion when any ref survives in a location the
enumeration does not know about, rather than shipping it quietly.

Component-versioned images (kamaji's v0.19.0-cozystack.N) and
third-party pass-through images do not ride the cozystack version line
and are deliberately left alone; a test pins that so a future fix here
cannot over-reach. capi-providers-cpprovider is a documented known gap:
its ref is also embedded in a gzipped copy that the chart actually
ships, and rewriting only the readable copy would diverge the two.

docs/agents/image-refs.md records the contract -- three tag classes,
three storage shapes, the invariants, and which consumer does what.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
Review of the previous commit found the postcondition itself could abort
a release. It grepped the bare "X.Y.Z-rc.N" substring across the tree, and
three live files carry such strings without being image refs:
kubevirt-csi-driver's go.sum pins github.com/golang/protobuf v1.4.0-rc.2
-- a version cozystack actually cut, so promoting v1.4.0 would have
failed on a Go checksum line -- metallb_test.yaml names v1.5.0-rc.2 in a
comment, and the console pnpm-lock.yaml carries rolldown@1.0.0-rc.15. The
remediation the error message gave would have made things worse: declaring
go.sum as a ref source would sed a checksum file.

Match the version only in image-reference position now: after an
image/repository/tag key with no intervening '#', or immediately before an
@sha256 digest. Narrowing it to "${RC}@sha256:" alone would have been
wrong -- cilium keeps `tag: v1.5.0` and `digest:` under separate keys, so
the version is not always adjacent to the digest. Verified against four
real rc versions that previously aborted.

The suite also only tested the fail-closed direction, which is why that
defect survived, and the collector had no completeness guard at all: a
first-party ref added at an unenumerated path on a `latest@` tag was
mirrored by nothing and passed all eighteen tests. Since the mirror's
failure mode is the worst of the three -- the published nightly points at
the private build registry -- that gap mattered most where coverage was
thinnest.

Added: a fail-open test using the three real rc versions above; a
collector completeness diff that discovers refs by content and fails on
any the enumeration misses; coverage for the OCI-artifact shape, whose
deletion previously left every suite green; and an unreadable-file test,
since a read error was being treated as "no match" and silently skipped.

The component-versioned test was vacuous -- its rc version shared no
substring with the kamaji tag it claimed to protect, so it passed by
construction. It now uses an rc version sharing kamaji's 0.19.0 prefix,
which a loosely-anchored implementation would corrupt.

Round-trip fixture discovery matched a combined 'ghcr.io/cozystack/
cozystack/' prefix, which skips keycloak-operator (host in a sibling
registry: key) and kubeovn (host in global.registry.address). It now
matches 'cozystack/cozystack' and stamps split `tag:` keys too.

Also documented two pre-existing gaps rather than half-fixing them: the
nightly host rewrite is a contiguous-substring replace and cannot reach a
split host, and a ref inside a gzip is invisible to every tool including
the postcondition, which skips binaries. Both are host-dimension defects;
the version dimension this change owns is unaffected, because the version
always lives in a tag key regardless of where the host sits.

Assisted-By: Claude <noreply@anthropic.com>
Assisted-By: GPT-5 <noreply@openai.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
Both round-2 reviewers independently landed on the same two test-side
holes, and both are the guard being blind in exactly the way the bug it
guards was.

The completeness oracle derived its expected set from a grep for a
contiguous ghcr.io/cozystack/cozystack/...@sha256: string, so it could
not see any split-host shape: cilium, keycloak-operator, kubeovn, the OCI
artifact and seven shape-3 refs, 12 of 43 first-party refs in all. That
includes keycloak-operator, which the test's own rationale names as the
motivating profile. The fixture-discovery grep in the same file had
already been widened for this exact reason; the fix was never carried to
the oracle.

The oracle now applies the same shape knowledge to an independently
discovered file list, which is the axis that actually drifts -- what each
consumer disagreed about historically is which FILES it visits, not how a
ref is spelled inside one. To keep that sharing safe, shape coverage is
now pinned by its own test, so deleting a shape still fails something
even though both sides of the comparison would move together.

Sharing required splitting collect_refs_from_file out of
collect_image_refs. Behaviour is unchanged: same 67 refs collected before
and after, multus and the packages artifact both still present.

A YAML oracle cannot cover everything, though. A reviewer planted a
split-host ref in a Helm template, which yq cannot parse at all, and it
survived every test. A file-level marker check now backstops that: any
file carrying a first-party marker must be enumerated or explicitly
allowlisted. Two files are allowlisted today, each with its reason -- the
talos-csr-signer Dockerfile placeholder, and the documented capi gzip
gap. This also closes the secondary weakness that comparing on repo@digest
masks an unenumerated occurrence when the same image sits at an
enumerated path, which is live for that capi file.

Second hole: the postcondition's key-position branch had no coverage.
Replacing the whole pattern with the naive "${RC}@sha256:" -- the
narrowing rejected because cilium keeps tag: and digest: under separate
keys -- left all 22 tests green, so the branch that makes the split shape
visible was asserted by nothing. Added a cilium-shaped fixture at an
unenumerated path.

Both previously-surviving mutations are now caught. The oracle was also
pre-filtered by registry marker before parsing, cutting it from 32s to 5s.

Assisted-By: Claude <noreply@anthropic.com>
Assisted-By: GPT-5 <noreply@openai.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
Round-3 review, both reviewers again converging on the same defects.

The file-level marker check was keyed to the exact ref spellings present
in the tree rather than to the join semantics _collect_yaml_shapes
implements, and three realistic first-party refs evaded it: `registry:
ghcr.io/cozystack/cozystack` + `repository: <name>` (kubeovn's layout
under the key name keycloak-operator and ingress-nginx use, which the old
marker missed because it required a trailing slash a complete-value host
does not have), a host+org split with precedent at fluxcd's values.yaml,
and a single-quoted `repository:` where only double quotes were admitted.
All three carried a version-free latest@sha256: tag -- exactly the
profile the check exists for -- and passed the whole suite. The markers
now follow the collector's join semantics; this matches 43 files and
needs no new allowlist entries. The residual limit (a host split at an
arbitrary point inside cozystack/cozystack) is stated rather than
implied, along with why widening further is not worth it: a bare
cozystack/cozystack marker matches 65 files and would need 23 allowlist
entries for Makefiles and test fixtures.

More seriously, making the completeness oracle share shape logic
introduced a regression the previous independent oracle would have
caught: corrupting the last hex digit of every emitted digest left the
rewrite, retag and mirror suites entirely green, because both sides of
the comparison move together and the per-shape test matched only a
repository substring. The digest is the only part deciding which bytes
get retagged, so the per-shape test now asserts the exact canonical
repo@sha256:digest for every shape.

Declared extras were also narrowed by the refactor: parsing alone means a
ref inside a block scalar yields the enclosing block (which the ownership
filter then discards) and an extra that stops parsing after `make update`
yields nothing at all. Both are silent skips, in the one storage shape
defined as a manifest vendored verbatim from upstream. Extras get the
textual scrape again alongside parsing, with the callers' dedup absorbing
the overlap.

Finally, enumerating a path only proves the path is visited: a declared
extra whose ref the parser cannot reach stayed enumerated but uncollected
while both completeness checks passed. Added a test asserting a declared
extra yields its ref, using an unparseable Helm template with a
single-quoted split-host ref -- the shape that defeated both guards.

Also corrected the claim that the per-shape test compensates for the
shared oracle: it catches a shape being DELETED, not narrowed. A shape
losing its registry rejoin is caught by promote-retag and nightly-mirror,
which assert named packages, and the comment now says so.

Assisted-By: Claude <noreply@anthropic.com>
Assisted-By: GPT-5 <noreply@openai.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
Round-4 review. Both reviewers independently found the declared-extra
test was passing while the thing it guards was broken, and each found one
more.

That test asserted `grep -q "@sha256:<digest>"`, which any token
containing the digest satisfies. Its fixture was a split-host ref, which
a textual scrape can never reconstruct -- only the contiguous tag value
is recoverable, so ref_repo() reduces it to `latest` and every consumer
drops it at the ownership filter. The fixture was therefore
enumerated-but-uncollected, which is exactly the conflation the test was
added to catch, and gutting the scrape to emit a bare digest with no
repository left the whole suite green. The fixture is now a contiguous
ref in an unparseable template (single-quoted, so the scrape's new
quote-tolerance is exercised) and the assertion pins the exact canonical
ref. The limitation that a split-host ref in an unparseable file is
recoverable by neither route is recorded rather than implied away: the
file-level marker check is what covers that case.

The scrape also captured a leading quote, so any quoted ref in a .tag
file or declared extra yielded a token every consumer silently discards.
Nothing in the tree is affected today, but it meant the double-pass
rescued only unquoted refs.

Markers missed quoted YAML keys: an unenumerated template using
"registry": / "repository": passed the entire suite. They now tolerate
quoting on the key as well as the value, still matching 43 files with no
new allowlist entries.

Shape 4 could be broadened from .global.images[] to every map in the
document and pass everything, because asserting that the expected ref
appears cannot see an unexpected one. That scoping is load-bearing -- the
host is a document-level key, so a global binding staples kubeovn's
registry onto unrelated repositories and manufactures owned refs that
were never built, which promotion would then retag and mirror. The shape-4
fixture now carries a digest-pinned sibling outside global.images and the
test asserts its absence.

Two per-shape fixture gaps closed while there: shape 2 had no sibling
`registry:`, so it losing its rejoin was invisible here and caught only
by one nightly-mirror fixture; and shape 1 had no tagless case, so
narrowing it to require a :tag before the digest went undetected -- a
digest-only pin is an ordinary Helm spelling.

Also corrected the residual-limit comment on the markers. Coverage is
exhaustive, not merely broad: the collector joins registry + "/" +
repository, so a split can only fall on a path-separator boundary and
markers 1-3 cover all four possibilities. A split elsewhere does not need
covering because it does not produce a first-party ref.

Assisted-By: Claude <noreply@anthropic.com>
Assisted-By: GPT-5 <noreply@openai.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
Round-5 review. The shape-4 negative fixture placed its decoy outside
`global` entirely, so it only caught a document-wide broadening. Mutating
the rule to `.global | ..` instead passed the per-shape test and all three
suites while still stapling the document-level registry onto unrelated
maps inside `global` -- manufacturing first-party refs that promotion
would then retag and mirror to destinations nothing ever built. The
fixture now carries two decoys at different depths, one inside `global`
but outside `global.images` and one outside `global`, and the mutation is
caught.

Markers also required the key to be immediately followed by its colon, so
`registry : ghcr.io/...` was missed. No producer writes that spelling and
it is valid YAML rather than anything in the tree, so this is theoretical
-- but tolerating whitespace costs one character class and keeps the
comment's exhaustiveness claim true lexically as well as structurally.
Marker count is unchanged at 43 files with the same two allowlist entries.

Assisted-By: Claude <noreply@anthropic.com>
Assisted-By: GPT-5 <noreply@openai.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
Two review nits, both comment- or fixture-level.

The completeness oracle's comment still said the per-shape test "matches
on the repository substring, so a shape that loses its registry rejoin
still satisfies it". That stopped being true in the same commit that
introduced it -- the substring match was replaced with an exact
canonical-ref assertion, which kills both the shape-2 and shape-3 rejoin
mutations. The comment understated coverage.

The fail-open test copied the tree once and looped three rc versions over
it, so the second and third iterations ran against a tree the first
rewrite had already mutated. Each now gets a fresh copy, so each tests
the input it names.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
The shape-3 fixture held a single repository/digest pair, so a rule that
binds one digest per file and reuses it for every repository satisfied
it -- and passed all three suites. That is not a theoretical mutation:
against the real linstor values it hands piraeus-server's digest to
linstor-csi, which retags and mirrors the wrong image under the right
name. Silently shipping wrong bytes is worse than the missing-tag bug
this branch started from.

The fixture now carries two shape-3 maps with distinct digests in one
file and asserts both exact canonical refs, so the digest must be bound
per map rather than per file.

Assisted-By: Claude <noreply@anthropic.com>
Assisted-By: GPT-5 <noreply@openai.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request addresses a critical bug where image references in certain storage locations were being skipped during release promotion, retagging, and nightly mirroring. By centralizing the enumeration of these references into a shared library, the changes ensure that all first-party images are correctly processed, preventing the release of images still tagged with release-candidate versions. The update also introduces automated validation to catch potential future regressions in reference coverage.

Highlights

  • Centralized Image Reference Enumeration: Introduced hack/lib/image-refs.sh to provide a single source of truth for locating image references across the repository, replacing disparate and incomplete globs in various scripts.
  • Improved Promotion and Mirroring Reliability: Updated hack/promote-rewrite-tags.sh, hack/promote-retag.sh, and hack/nightly-mirror.sh to use the new shared library, ensuring all storage shapes (values.yaml, .tag files, and templates) are consistently processed.
  • Automated Postcondition Checks: Added a strict postcondition to the promotion rewrite that scans the entire tree and fails if any release-candidate version strings remain in image-reference positions, preventing silent failures.
  • Documentation and Testing: Added docs/agents/image-refs.md to document the image reference contract and implemented comprehensive unit tests in hack/promote-rewrite-tags_test.bats to ensure the rewrite logic remains robust.
New Features

🧠 You can now enable Memory (public preview) to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Ignored Files
  • Ignored by pattern: .github/workflows/** (1)
    • .github/workflows/promote-rc.yaml
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment Gemini (@gemini-code-assist) Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on Gemini (@gemini-code-assist) comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 9987bb1f-4479-4ce3-809d-19a0ea583683

📥 Commits

Reviewing files that changed from the base of the PR and between 91c0761 and 1f302d0.

📒 Files selected for processing (1)
  • docs/agents/image-refs.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • docs/agents/image-refs.md

📝 Walkthrough

Walkthrough

The change centralizes image-reference enumeration and extraction, updates nightly mirroring and promotion retagging to use it, and adds a validated RC-to-stable rewrite script. Release workflows, documentation, and Bats tests cover additional storage shapes, completeness checks, and digest preservation.

Changes

Image-reference promotion tooling

Layer / File(s) Summary
Image-reference contract and shared library
AGENTS.md, docs/agents/image-refs.md, docs/release.md, hack/lib/image-refs.sh
Documents supported reference shapes and adds shared file enumeration plus YAML/text extraction.
Nightly mirror enumeration
hack/nightly-mirror.sh, hack/nightly-mirror_test.bats
Nightly mirroring and host rewriting use the shared file set, with coverage for .tag and template references.
Promotion retag enumeration
hack/promote-retag.sh, hack/promote-retag_test.bats
Promotion retagging uses shared reference collection and tests references outside values.yaml.
RC-to-stable rewrite and verification
.github/workflows/promote-rc.yaml, hack/promote-rewrite-tags.sh, hack/promote-rewrite-tags_test.bats
The workflow invokes validated rewriting that scans enumerated references, detects leftovers, and preserves digests.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant GitHubActions
  participant promote-rewrite-tags.sh
  participant image_ref_files
  participant TargetTree
  GitHubActions->>promote-rewrite-tags.sh: pass RC_VERSION and STABLE_VERSION
  promote-rewrite-tags.sh->>image_ref_files: enumerate reference-bearing files
  image_ref_files->>TargetTree: return supported reference files
  promote-rewrite-tags.sh->>TargetTree: rewrite tags and scan for remaining RC references
  promote-rewrite-tags.sh-->>GitHubActions: return success or failure
Loading

Possibly related PRs

Suggested reviewers: ivanhunters

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: broader image-ref coverage across promote, retag, and nightly mirror workflows.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/image-ref-tag-scope

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request centralizes and refactors the image reference collection, retagging, mirroring, and tag-rewriting logic across Cozystack by introducing a shared library (hack/lib/image-refs.sh) and a dedicated script (hack/promote-rewrite-tags.sh), accompanied by comprehensive documentation and unit tests. The review feedback suggests optimizing performance in the shared library by combining multiple yq invocations into a single call, and improving robustness in the tag-rewriting script by using a trap to ensure temporary files are cleaned up on exit.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread hack/lib/image-refs.sh
Comment on lines +182 to +198
# shape 1
yq -r '.. | select(tag == "!!str") | select(test("@sha256:[0-9a-f]{64}"))' "$_ir_f" 2>/dev/null || true
# shape 2. The sub("^/"; "") is what makes `registry` optional: absent, it
# alternates to "" and leaves a leading slash on the join, stripped back to
# the bare repository the rule emitted before registry was handled.
yq -r '.. | select(tag == "!!map") | select(has("repository") and has("digest")) | select(.repository | tag == "!!str") | select(.digest | tag == "!!str") | select((.registry // "") | tag == "!!str") | (((.registry // "") + "/" + .repository) | sub("^/"; "")) + "@" + .digest' "$_ir_f" 2>/dev/null || true
# shape 3
yq -r '.. | select(tag == "!!map") | select(has("repository") and has("tag")) | select(.tag | tag == "!!str") | select(.tag | test("@sha256:[0-9a-f]{64}")) | select(.repository | tag == "!!str") | select((.registry // "") | tag == "!!str") | (((.registry // "") + "/" + .repository) | sub("^/"; "")) + "@" + (.tag | sub(".*@"; ""))' "$_ir_f" 2>/dev/null || true
# shape 4. Scoped to global.images rather than a recursive descent: the
# host is a document-level key, so binding it to a map found anywhere in
# the file would attach kube-ovn's registry to unrelated repositories.
# $reg is a yq binding, not a shell variable — the single quotes are
# required, so SC2016's "expressions don't expand" is inverted here.
# shellcheck disable=SC2016
yq -r '(.global.registry.address // "") as $reg | select($reg != "") | select($reg | tag == "!!str") | .global.images[] | select(tag == "!!map") | select(has("repository") and has("tag")) | select(.tag | tag == "!!str") | select(.tag | test("@sha256:[0-9a-f]{64}")) | select(.repository | tag == "!!str") | $reg + "/" + .repository + "@" + (.tag | sub(".*@"; ""))' "$_ir_f" 2>/dev/null || true
# shape 5
yq -r '.. | select(tag == "!!map") | select(has("platformSourceUrl") and has("platformSourceRef")) | (.platformSourceUrl | sub("^oci://"; "")) + "@" + (.platformSourceRef | sub("^digest="; ""))' "$_ir_f" 2>/dev/null || true

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Performance Optimization: Combine yq Invocations

Currently, _collect_yaml_shapes runs yq 5 separate times for every single YAML file. Since yq is a Go binary, spawning it repeatedly introduces significant process startup overhead, especially when scanning dozens of package files.

You can combine all 5 independent queries into a single yq invocation using the union/comma (,) operator. This reduces the process spawns from 5 per file to just 1, yielding a massive speedup during image reference collection.

Suggested change
# shape 1
yq -r '.. | select(tag == "!!str") | select(test("@sha256:[0-9a-f]{64}"))' "$_ir_f" 2>/dev/null || true
# shape 2. The sub("^/"; "") is what makes `registry` optional: absent, it
# alternates to "" and leaves a leading slash on the join, stripped back to
# the bare repository the rule emitted before registry was handled.
yq -r '.. | select(tag == "!!map") | select(has("repository") and has("digest")) | select(.repository | tag == "!!str") | select(.digest | tag == "!!str") | select((.registry // "") | tag == "!!str") | (((.registry // "") + "/" + .repository) | sub("^/"; "")) + "@" + .digest' "$_ir_f" 2>/dev/null || true
# shape 3
yq -r '.. | select(tag == "!!map") | select(has("repository") and has("tag")) | select(.tag | tag == "!!str") | select(.tag | test("@sha256:[0-9a-f]{64}")) | select(.repository | tag == "!!str") | select((.registry // "") | tag == "!!str") | (((.registry // "") + "/" + .repository) | sub("^/"; "")) + "@" + (.tag | sub(".*@"; ""))' "$_ir_f" 2>/dev/null || true
# shape 4. Scoped to global.images rather than a recursive descent: the
# host is a document-level key, so binding it to a map found anywhere in
# the file would attach kube-ovn's registry to unrelated repositories.
# $reg is a yq binding, not a shell variable — the single quotes are
# required, so SC2016's "expressions don't expand" is inverted here.
# shellcheck disable=SC2016
yq -r '(.global.registry.address // "") as $reg | select($reg != "") | select($reg | tag == "!!str") | .global.images[] | select(tag == "!!map") | select(has("repository") and has("tag")) | select(.tag | tag == "!!str") | select(.tag | test("@sha256:[0-9a-f]{64}")) | select(.repository | tag == "!!str") | $reg + "/" + .repository + "@" + (.tag | sub(".*@"; ""))' "$_ir_f" 2>/dev/null || true
# shape 5
yq -r '.. | select(tag == "!!map") | select(has("platformSourceUrl") and has("platformSourceRef")) | (.platformSourceUrl | sub("^oci://"; "")) + "@" + (.platformSourceRef | sub("^digest="; ""))' "$_ir_f" 2>/dev/null || true
# Shapes 1-5 combined into a single yq invocation to avoid process startup overhead.
yq -r '
(.. | select(tag == "!!str") | select(test("@sha256:[0-9a-f]{64}"))),
(.. | select(tag == "!!map") | select(has("repository") and has("digest")) | select(.repository | tag == "!!str") | select(.digest | tag == "!!str") | select((.registry // "") | tag == "!!str") | (((.registry // "") + "/" + .repository) | sub("^/"; "")) + "@" + .digest),
(.. | select(tag == "!!map") | select(has("repository") and has("tag")) | select(.tag | tag == "!!str") | select(.tag | test("@sha256:[0-9a-f]{64}")) | select(.repository | tag == "!!str") | select((.registry // "") | tag == "!!str") | (((.registry // "") + "/" + .repository) | sub("^/"; "")) + "@" + (.tag | sub(".*@"; ""))),
((.global.registry.address // "") as $reg | select($reg != "") | select($reg | tag == "!!str") | .global.images[] | select(tag == "!!map") | select(has("repository") and has("tag")) | select(.tag | tag == "!!str") | select(.tag | test("@sha256:[0-9a-f]{64}")) | select(.repository | tag == "!!str") | $reg + "/" + .repository + "@" + (.tag | sub(".*@"; ""))),
(.. | select(tag == "!!map") | select(has("platformSourceUrl") and has("platformSourceRef")) | (.platformSourceUrl | sub("^oci://"; "")) + "@" + (.platformSourceRef | sub("^digest="; "")))
' "$_ir_f" 2>/dev/null || true

Comment on lines +54 to +55
files_list=$(mktemp)
image_ref_files "$ROOT" > "$files_list"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

low

Robustness: Ensure Temporary File Cleanup via trap

If the script exits prematurely (for example, due to set -e triggering on an unexpected command failure or a user interrupt), the temporary file created by mktemp will be leaked in /tmp.

Adding a trap immediately after creating the temporary file ensures it is always cleaned up on exit.

Suggested change
files_list=$(mktemp)
image_ref_files "$ROOT" > "$files_list"
files_list=$(mktemp)
trap 'rm -f "$files_list"' EXIT
image_ref_files "$ROOT" > "$files_list"

@dosubot dosubot Bot added area/build Issues or PRs related to image build infrastructure, multi-arch support area/ci Issues or PRs related to CI workflows, GitHub Actions, automation area/release Issues or PRs related to release tooling (changelog, backport, release pipeline) area/testing Issues or PRs related to testing (e2e, bats, unit tests) kind/bug Categorizes issue or PR as related to a bug labels Jul 21, 2026
@github-actions github-actions Bot added the size/XXL This PR changes 1000+ lines, ignoring generated files label Jul 21, 2026
The build-list violations bullet pointed at #3143, which tracks the
adjacent gap (packages in the list going stale between releases) and
never covers packages absent from the list; they are now tracked in
the newly filed #3416. The nightly split-host paragraph claimed both
affected images are mirrored while the published tree points at the
build registry — today neither ref carries the source-registry host
(keycloak-operator is never rebuilt by CI, kubeovn is built externally
by cozystack/kubeovn-chart), so the ownership filter skips them and
the gap is latent, not live; reworded to say exactly that. The opening
example paired a v1.6.0 tag with the real v1.5.0 grafana digest — a
ref that will never exist — and grafana is slated to stop being built;
replaced with the literal committed cozystack-api ref.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>

@IvanHunters IvanHunters left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict

LGTM with non-blocking notes

CI/release-tooling only (no packages/ chart, migration, RBAC, or CRD touched); the fix consolidates image-ref enumeration into one shared library, adds a scan-wider-than-you-rewrite postcondition, and ships a mutation-proven test suite that I verified green with three confirmed RED mutations.

Claim mismatches

[UNVERIFIABLE] "zizmor clean on the workflow" — zizmor is not available in this static review environment, so I could not reproduce it. The workflow change (.github/workflows/promote-rc.yaml) only replaces the inline rewrite loop with a call to hack/promote-rewrite-tags.sh and does not add an action uses: or alter permissions:, so the two gated audits (unpinned-uses, excessive-permissions) are not implicated on inspection — but I did not execute the tool.

Caveats

  • Phase 5b (upgrade / fresh-install) is N/A by classification: no file under packages/, no migration, no chart values/templates, no CRD/RBAC. The change affects only the release pipeline that stamps/retags/mirrors images. Stated explicitly rather than skipped.
  • Phase 5c (config-combination matrix) is N/A: no chart values.yaml/template {{- if }}/dependsOn touched.
  • Phase 5d (test adequacy) verified by execution, not reasoning: baseline hack/cozytest.sh runs of all three suites (promote-rewrite-tags_test.bats 17 tests, promote-retag_test.bats 5, nightly-mirror_test.bats 4) passed green. Non-vacuity confirmed by mutation: reverting hack/lib/image-refs.sh image_ref_files() to the pre-fix depth-2 values.yaml-only glob turned RED "rewrite covers .tag files outside packages/apps/kubernetes", "rewrite covers a ref stamped into a declared template", and "image_ref_files enumerates all three storage shapes". File restored afterwards.
  • shellcheck -x over the four shell files returns clean (exit 0) locally; the envelope's shell_lint SC1091 info entries ("Not following: hack/lib/image-refs.sh") are the sourced-library notice and resolve under -x / the # shellcheck source= directives — not defects.
  • The rc→stable rewrite is a blind sed s/rc/stable/g over enumerated ref-bearing files, so a third-party or component-versioned tag carrying the exact cozystack rc string in the same file would also be rewritten. This is documented and asserted as an accepted limitation (promote-rewrite-tags_test.bats "component-versioned and third-party tags are left alone", using an rc that shares kamaji's 0.19.0 prefix to keep the test non-vacuous); no such collision exists in-tree.

Recommended follow-ups

  • hack/overlay-main-images.sh is a fourth image-ref consumer that intentionally does NOT source hack/lib/image-refs.sh — it walks the tree itself with find … -name values.yaml -o -name '*.tag' (hack/overlay-main-images.sh:91) and therefore does not pick up IMAGE_REF_EXTRA_FILES (the system/multus stamped template). This divergence is called out in docs/agents/image-refs.md and is pre-existing, so it is not a regression from this PR. Worth folding overlay onto the same enumeration in a later change so "declare a new storage location once" holds for all four consumers.
  • The two host-dimension gaps the PR documents (split-host substring rewrite for keycloak-operator/kubeovn; ref inside capi-providers-cpprovider's files/components.gz) remain open by design. The version dimension this PR owns is complete; they need the separate structure-aware / gzip-aware follow-up already noted.
  • Per the reviewer note in the PR body, platform-migrations being pinned at two different digests (core/platform/values.yaml vs backupstrategy-controller/values.yaml, tracked in #3143) will trip promote-retag.sh's write-once guard with two copies to the same destination tag; that is independent of this PR but blocks the 1.6.0 re-cut and should be resolved before promotion.

myasnikovdaniil added a commit that referenced this pull request Jul 22, 2026
…3405)

## This is a workaround

`backupstrategy-controller` should not be running the platform
**migrations** image. This PR does not fix that — it makes the existing
reuse *safe* so the v1.6.0 promote can proceed, and documents in the
tree exactly what is wrong and what should replace it. Treat it as a
stopgap with a TODO, not as an endorsement of the design.

## What is actually wrong

The Altinity strategy Pod drives clickhouse-backup's HTTP API. Its
script uses exactly four binaries — `curl`, `jq`, `sleep`, `date`. For
that it currently pulls `platform-migrations`:

| | |
| --- | --- |
| Size | **349 MB** (a dedicated `alpine + curl + jq + ca-certificates`
measures **23 MB**) |
| Also contains | `kubectl`, `helm`, `git`, `cozyhr`, the `etcd-migrate`
binary, every migration script, vendored etcd CRDs |
| `ENTRYPOINT` | `run-migrations.sh` |

Two consequences worth naming. A tenant-adjacent backup Pod carries
cluster tooling it has no use for. And because the image's own
entrypoint is the migration runner, the `command:` override in the
strategy template is load-bearing — drop that one line and the Pod runs
platform migrations with `kubectl` and `helm` already on the PATH.

It also creates the coupling that caused the bug this PR fixes: bumping
`ETCD_OPERATOR_VERSION` or editing any migration script re-digests the
image, forcing the backup client's pin to move for reasons that have
nothing to do with backups.

## The release blocker

`platform-migrations` was pinned at two different digests in the
committed tree:

| Location | Pin | Producer |
| --- | --- | --- |
| `packages/core/platform/values.yaml` `.migrations.image` |
`v1.5.0@sha256:8bf61f17…` | `packages/core/platform/Makefile` |
| `backupstrategy-controller/values.yaml` `.chBackupClientImage` |
`v1.4.0-rc.2@sha256:17390197…` | **none** |

Promotion retags **by digest**: `hack/promote-retag.sh` copies every
collected `<repo>@<digest>` to `<repo>:<stable-version>`. Two digests
under one repository produce two copies competing for the same
destination tag — the first wins, the second hits the write-once guard,
and the promotion fails. A dry run against `main` emits exactly that
pair:

```
▸ ghcr.io/cozystack/cozystack/platform-migrations  sha256:17390197…
  → docker://ghcr.io/cozystack/cozystack/platform-migrations:v9.9.9
▸ ghcr.io/cozystack/cozystack/platform-migrations  sha256:8bf61f17…
  → docker://ghcr.io/cozystack/cozystack/platform-migrations:v9.9.9
```

`v1.6.0` is the first release cut through promotion rather than a full
rebuild. Every earlier release ran `make build`, which restamped both
copies as a side effect and hid the missing producer.

## What this PR does

`packages/core/platform/Makefile` now stamps both keys with the same
ref, so they move in lockstep by construction rather than by
instruction, and the committed value is aligned to the digest
`core/platform` already carried. Moving the runner forward two minors is
safe: it is used only as a curl + jq container, and the image built at
`v1.5.0` carries them (`git show v1.5.0:…/migrations/Dockerfile` → `apk
add … jq ca-certificates bash curl`).

## What should replace it

A dedicated `ch-backup-client` image built by **this package's own
Makefile** — measured at 23 MB, 15× smaller, with none of the cluster
tooling and none of the entrypoint hazard. That also deletes the
cross-package stamp this PR adds, because the pin would be produced by
the package that owns it.

The objection that has kept this reuse in place — "a second tag release
CI would have to re-digest each cut" — does not survive contact with the
actual defect. Every other first-party image already pays that cost,
automatically. What broke here was not an extra tag; it was **a pin with
no producer**, which a package-owned image cannot reproduce.

I am deliberately not doing that here: it means a new image, a new build
target and a new digest introduced during a release cut. It belongs in a
follow-up.

## The guard

`hack/image-pin-consistency.bats` asserts the general invariant — no
repository pinned at more than one digest — driven through the real
promotion selector so it tracks whatever set promotion actually acts on,
rather than a reimplementation that could drift from it. Plus a specific
lockstep assertion so a recurrence names its cause. This check could not
have been added before now: it fails on the very duplicate this PR
removes.

## Testing

- `hack/image-pin-consistency.bats` — both tests pass; verified they
fail when the drifted pin is restored.
- `make bats-unit-tests` green (294 tests, exit 0).
- `make -C packages/system/backupstrategy-controller test` — 11 tests, 3
suites, green.
- `helm template … --set backupStorage.bucketNameOverride=test-bucket`
renders the strategy Pod with the new ref.
- The Makefile's `yq` step was simulated directly to confirm the
relative path and key path resolve and that comments survive the
in-place edit.
- Merged locally with #3404 onto `main` and re-run: the expanded ref
collector in that PR surfaces no additional duplicate pins (314 tests,
exit 0).

## Relationship to other work

Relates to #3143 — this is one instance of its unowned-pin class, the
one its discussion flagged as having no producer. The four never-built
packages there remain untouched.

Independent of #3404 (promote/retag/mirror storage-shape coverage); the
two touch no common files and can merge in either order. Both are needed
before the `v1.6.0` promote is re-cut.

## Release note

```release-note
Fixed a release-blocking duplicate pin: the ClickHouse backup client image is now stamped from the same source as the platform migrations image it reuses, so both carry one digest. Previously they could drift, which made promotion attempt to retag two digests to the same stable tag and fail. Reusing the migrations image here remains a documented workaround pending a dedicated backup-client image.
```


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Ensured the platform migrations image digest is pinned consistently
between the platform and backup strategy components.
* Updated the backup strategy component to reference the new pinned
`platform-migrations` digest.

* **Tests**
* Added promotion/retag consistency checks to fail on conflicting source
digests for the same stable tag.
* Added drift detection to ensure `platform-migrations` image references
match across consumer configuration files.

* **Documentation**
* Expanded in-chart comments and values documentation clarifying the
coupling and workaround behavior.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
@myasnikovdaniil
myasnikovdaniil merged commit d0c0f39 into main Jul 22, 2026
17 checks passed
@myasnikovdaniil
myasnikovdaniil deleted the fix/image-ref-tag-scope branch July 22, 2026 11:41
myasnikovdaniil added a commit that referenced this pull request Jul 22, 2026
Promoting this branch fails in two independent ways, both landing after
finalize has already created the write-once stable tag, published the
GitHub release and moved :latest.

chBackupClientImage had no producer and froze at v1.4.0-rc.2 while
.migrations.image advanced to v1.6.0-rc.4, leaving platform-migrations
pinned at two digests. promote-retag.sh copies every collected
<repo>@<digest> to <repo>:<stable>, so both compete for one destination
tag: the lower-sorting v1.4.0-rc.2 digest wins it and the second copy
trips the write-once guard, aborting the retag with five repositories
still untagged and the installer chart unpublished. Align it to the
digest this branch already carries for the migrations image. Both come
from the same tree, so the runner keeps the curl, jq and ca-certificates
the Altinity strategy Pod needs, and that Pod overrides the ENTRYPOINT
with its own command.

promote-rc.yaml on main now calls hack/promote-rewrite-tags.sh, which
does not exist here, so the dispatch would fail outright. Vendor it and
hack/lib/image-refs.sh verbatim from main (identical blobs). That also
fixes the rewrite's coverage: the old inline glob enumerated 155 files
and missed 10 rc-bearing ones, which is why the earlier promote left 11
references reading v1.6.0-rc.4. The shared enumeration covers all 168
and fails the promotion if any rc reference survives.

Both fixes are on main via #3405 and #3404; this carries them onto the
rc.4 staging tree so v1.6.0 ships the bytes rc.4 tested.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
myasnikovdaniil added a commit that referenced this pull request Jul 22, 2026
)

## What this PR does

The promote step guarded PR creation with `gh pr view <branch>`, which
resolves a pull request by head branch **in any state**. A closed one
satisfied the guard, so creation was skipped while the step still exited
0 — a promotion that reports success and leaves nothing to merge. Same
silent-skip shape as the enumeration bug fixed in #3404: the run is
green, the artifact is missing, and nothing says so.

`STABLE_BRANCH` is derived from the stable version
(`release-${version}`), not from the rc number, so every rc promoted to
that version shares it. Once any promote PR for a version has been
closed, both re-dispatch paths this workflow documents as supported are
wedged: re-dispatching the same rc, and promoting a newer rc to the same
version. That second one is named in the workflow's own header as the
reason a leftover draft is tolerated — the tolerance was implemented for
the release draft and missed for the PR.

Neither path recovers by reopening the old PR, because the step's own
`git checkout -B` plus force-push makes that PR's head unreachable, and
GitHub then refuses:

```
422 Validation Failed
  state cannot be changed. The release-1.6.0 branch was force-pushed or recreated.
```

The fix asks the question the guard meant to ask — is there an **open**
PR for this head — and creates one otherwise.

**Hit live.** Promoting `v1.6.0-rc.4` after #3397 had been closed: [run
29917852639](https://github.com/cozystack/cozystack/actions/runs/29917852639)
went green, logged `PR already open for release-1.6.0`, and opened no
PR. This currently blocks the v1.6.0 release.

**Verified in both directions** against live data, so the change is not
vacuous:

| head branch | PR state | old guard | new guard |
| --- | --- | --- | --- |
| `release-1.6.0` | #3397 closed | `TRUE` → skip (bug) | `FALSE` →
create ✅ |
| `chore/gitignore` | #3412 open | `TRUE` → skip | `TRUE` → skip ✅ |

`actionlint` exits 0 and `zizmor` reports no findings. The guard reads
`${STABLE_BRANCH}` as a shell variable from the step's `env:` block
rather than as a `${{ }}` expansion inside `run:`, so no
expression-injection surface is added.

### Screenshots

N/A — no UI change.

### Downstream repositories

Walked the trigger map in `docs/agents/contributing.md` against the
diff, which is one file: `.github/workflows/promote-rc.yaml`. The only
workflow-related trigger in the map is cozystack/ccp on "change
release-prep behaviour in `.github/workflows/tags.yaml`", which this
does not touch. This change also restores the documented behaviour of
`promote-rc.yaml` rather than altering its contract, so nothing
downstream sees a result different from what the docs already promise.

- [x] No downstream repository is affected by this change
- [ ] [cozystack/website](https://github.com/cozystack/website) -
follow-up:
- [ ]
[cozystack/terraform-provider-cozystack](https://github.com/cozystack/terraform-provider-cozystack)
- follow-up:
- [ ]
[cozystack/ansible-cozystack](https://github.com/cozystack/ansible-cozystack)
- follow-up:
- [ ] [cozystack/ccp](https://github.com/cozystack/ccp) - follow-up:
- [ ] [cozystack/talm](https://github.com/cozystack/talm) - follow-up:
- [ ] [cozystack/cozyhr](https://github.com/cozystack/cozyhr) -
follow-up:
- [ ] [cozystack/cozy-proxy](https://github.com/cozystack/cozy-proxy) -
follow-up:
- [ ]
[cozystack/cozystack-telemetry-server](https://github.com/cozystack/cozystack-telemetry-server)
- follow-up:
- [ ]
[cozystack/external-apps-example](https://github.com/cozystack/external-apps-example)
- follow-up:
- [ ] [cozystack/examples](https://github.com/cozystack/examples) -
follow-up:

### Release note

```release-note
fix(release): promoting a release candidate now opens the promotion pull request even when an earlier promotion attempt for the same version was abandoned. Previously the workflow mistook a closed pull request for an open one, skipped creating a new one, and reported success with nothing left to merge.
```


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved release PR creation checks so closed or previously merged
pull requests no longer prevent new release PRs from being opened.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Harshit Rawat (harshitrwt) pushed a commit to harshitrwt/cozystack that referenced this pull request Jul 22, 2026
The promote step guarded PR creation with `gh pr view <branch>`, which
resolves a pull request by head branch in any state. A closed one
satisfied the guard, so creation was skipped while the step still exited
0 — a promotion that reports success and leaves nothing to merge, the
same silent-skip shape as the enumeration bug fixed in cozystack#3404.

STABLE_BRANCH is derived from the stable version, so every rc promoted to
that version shares it. Both re-dispatch paths this workflow documents as
supported are therefore wedged once any promote PR for that version has
been closed: re-dispatching the same rc, and promoting a newer rc to the
same version. Neither recovers by reopening the old PR, because the
step's own force-push makes its head unreachable and GitHub refuses with
"state cannot be changed. The <branch> branch was force-pushed or
recreated."

Hit live while promoting v1.6.0-rc.4 after cozystack#3397 had been closed: the run
went green and opened no PR.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/build Issues or PRs related to image build infrastructure, multi-arch support area/ci Issues or PRs related to CI workflows, GitHub Actions, automation area/release Issues or PRs related to release tooling (changelog, backport, release pipeline) area/testing Issues or PRs related to testing (e2e, bats, unit tests) kind/bug Categorizes issue or PR as related to a bug size/XXL This PR changes 1000+ lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants