Skip to content

Harden production promotion image copy and readiness#356

Merged
justin808 merged 9 commits into
mainfrom
jg-codex/use-native-production-image-copy-template
Jun 3, 2026
Merged

Harden production promotion image copy and readiness#356
justin808 merged 9 commits into
mainfrom
jg-codex/use-native-production-image-copy-template

Conversation

@justin808

@justin808 justin808 commented Jun 2, 2026

Copy link
Copy Markdown
Member

Summary

  • replace the old staging-to-production image copy path with direct Docker registry auth using the staging and production Control Plane tokens
  • normalize Control Plane org variables before setup, copy, deploy, health-check, and rollback steps consume them
  • copy production images with Docker Buildx imagetools so digest-pinned refs, plain numeric tags, commit-suffixed tags, and multi-arch manifests are handled clearly
  • fail promotion before image copy when production is missing GVC or app workload container env vars that exist in staging
  • require Control Plane status.ready and status.readyLatest before endpoint health checks and rollback readiness polling
  • update docs and generator specs for the hardened promotion workflow/template

Why

The downstream tutorial promotion exposed several production-only failure modes:
environment secrets do not cross reusable workflow boundaries, Control Plane
profile selection does not carry through Docker credential helpers, pasted
CR/LF values can break raw registry hosts, and a promotion can look healthy when
an older ready replica still answers while the latest workload version cannot
boot. This PR updates the reusable workflow/template to catch those failures
before production is promoted or before a stale endpoint can mask a failed
deployment.

Successful downstream promotion after the fixes:
https://github.com/shakacode/react-webpack-rails-tutorial/actions/runs/26854234803

Validation

  • git diff --check
  • bundle exec rake check_command_docs
  • CPLN_ORG=dummy-test bundle exec rspec spec/command/generate_github_actions_spec.rb
  • bundle exec rubocop spec/command/generate_github_actions_spec.rb

Note

High Risk
Changes the production promotion path (registry auth, image tagging/copy, deploy/rollback gates) and production health semantics; mistakes could block or mis-handle live promotions.

Overview
Production promotion is hardened end-to-end: org names are trimmed and validated before any Control Plane or registry step; staging→production image copy no longer uses cpflow copy-image-from-upstream and instead logs into both registries with tokens, derives the next production tag (optionally keeping a _commit suffix), and copies via docker buildx imagetools create (including digest-pinned sources) with a collision check on the target tag.

Pre-deploy gates now compare env var names from staging to production at both the GVC and each configured app workload container, failing the run before copy when production is missing names staging has.

Readiness for promote health checks and rollback polling requires status.ready and status.readyLatest before HTTP endpoint checks, so an older ready replica cannot mask a failed latest revision.

Docs and generator specs are updated to match the new workflow and wait-for-health behavior; the promotion summary reports the copied production image on success.

Reviewed by Cursor Bugbot for commit 6cd8225. Bugbot is set up for automated code reviews on this repo. Configure here.

Summary by CodeRabbit

Release Notes

  • New Features

    • Enhanced org name validation with whitespace trimming and strict format enforcement
    • Improved environment variable parity checks between staging and production environments at GVC and workload levels
    • Strengthened image promotion with manifest verification and better tag handling
    • Stricter workload readiness checks requiring both status conditions to be met before deployment
  • Documentation

    • Updated requirements for GitHub repository variable formatting (single-line enforcement)
    • Enhanced guidance on production promotion safety and health check behavior

@coderabbitai

coderabbitai Bot commented Jun 2, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds Control Plane org normalization; verifies production env-var parity; replaces the prior copy flow with a Docker Buildx imagetools-based promotion step that computes and exports the next production image tag; and updates health/rollback checks, docs, and specs.

Changes

Production Image Copy Promotion Workflow

Layer / File(s) Summary
Normalize Control Plane org names
.github/workflows/cpflow-promote-staging-to-production.yml, lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml, docs/ci-automation.md, spec/command/generate_github_actions_spec.rb
Sanitize and validate CPLN_ORG_STAGING/CPLN_ORG_PRODUCTION (trim whitespace, reject embedded CR/LF, enforce lowercase alphanumeric+hyphen) and export steps.cpln-orgs.outputs.staging/production; docs and spec updated to assert this behavior.
Verify production environment variables
.github/workflows/cpflow-promote-staging-to-production.yml, lib/github_flow_templates/...
List env var names for the production GVC and each workload using normalized org outputs; fail promotion if staging variables are missing in production and warn about production-only variables.
Copy-image step (Buildx imagetools)
.github/workflows/cpflow-promote-staging-to-production.yml, lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml, spec/command/generate_github_actions_spec.rb
Add id: copy-image (Buildx imagetools): validate retry settings, strip digests, verify staging image exists, derive staging_commit and compute next production_image by incrementing latest numeric suffix and optionally appending commit fragment, create temporary DOCKER_CONFIG, login to registries, verify source manifest with buildx imagetools inspect, create/publish production tag via buildx imagetools create in a retry loop, abort if target exists, and export the computed image to GITHUB_OUTPUT. Specs updated to assert these behaviors and removal of older upstream/CPLN copy mechanisms.
Rewire workflow steps to use sanitized org outputs
.github/workflows/cpflow-promote-staging-to-production.yml, lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml
Update capture, setup, deploy comments, health action inputs, rollback, and rollback readiness to use steps.cpln-orgs.outputs.staging / ...production instead of raw repo variables; export COPIED_IMAGE into the deploy path.
Rollback readiness & promotion summary
.github/workflows/cpflow-promote-staging-to-production.yml, lib/...
Require both .status.ready and .status.readyLatest for rollback readiness; compute deployed_image from steps.copy-image.outputs.image when health-check passes, otherwise use previous image; add "Deployed image" to the GitHub step summary.
Wait-for-health composite action
.github/actions/cpflow-wait-for-health/action.yml
Wait until both .status.ready and .status.readyLatest are "true" (and capture .health.readiness) before curling the workload endpoint; otherwise log readiness and continue waiting.
Docs and examples
docs/ci-automation.md, docs/commands.md
Document single-line GitHub variable constraints for CPLN org names, promotion image-copy semantics and env-var parity checks, bootstrap guidance, status.readyLatest expectations, and update example release tag to v5.1.0.
Spec updates
spec/command/generate_github_actions_spec.rb
Generator specs extended to assert org normalization wiring, new copy-image Buildx contract and outputs, readyLatest coverage, concurrency expectations, and removal of prior copy mechanisms.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I trimmed the orgs, no stray newlines found,
hopped to registries where manifests are crowned,
buildx inspected, created the new tag with care,
exported the image, summary shows it there,
a rabbit's small hop keeps promotions fair.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Harden production promotion image copy and readiness' directly and accurately describes the primary changes: replacing the image copy mechanism with Docker Buildx, normalizing org inputs, and enforcing stricter readiness checks before health verification and rollback.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch jg-codex/use-native-production-image-copy-template

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 and usage tips.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 48f8adecde

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +351 to +354
staging_commit="${STAGING_IMAGE##*_}"
if [[ "${staging_commit}" == "${STAGING_IMAGE}" || -z "${staging_commit}" ]]; then
echo "::error::Staging image '${STAGING_IMAGE}' does not include the expected '_<commit>' suffix."
exit 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Allow promoting images without commit suffixes

When the staging workload points at a valid cpflow image that was built without --commit (for example my-app:7), this new guard aborts the entire production promotion even though cpflow still supports such tags: latest_image_next only appends _<commit> when a commit is present (lib/core/controlplane.rb:53-62), and the previous copy-image-from-upstream path passed a nil commit through to create the next plain production tag (lib/command/copy_image_from_upstream.rb:80-90). This regresses promotions for existing/manual staging images that do not have the optional commit suffix; generate the production name without a suffix in that case instead of exiting.

Useful? React with 👍 / 👎.

@greptile-apps

greptile-apps Bot commented Jun 2, 2026

Copy link
Copy Markdown

Greptile Summary

This PR replaces cpflow copy-image-from-upstream with native cpln image copy, creating a temporary staging profile from CPLN_TOKEN_STAGING for cross-org authentication and computing a sequentially-numbered production image name before the copy. The summary step is updated to report the actual copied image name on success, or the previous image on failure.

  • Copy step now queries the production registry for the highest existing image number, constructs a <app>:<n+1>_<commit> name, creates a temporary profile from the staging token, docker-logins to the staging registry, verifies the source image via docker manifest inspect, then runs cpln image copy with --to-profile default.
  • Summary step now correctly shows the production-side image name (COPIED_IMAGE) on a healthy deployment and falls back to PREVIOUS_IMAGE on failure.
  • Spec is updated to assert presence of the new profile-management and copy commands and the absence of CPLN_UPSTREAM_TOKEN and cpflow copy-image-from-upstream.

Confidence Score: 4/5

Safe to merge — the new copy path is well-structured with proper credential isolation, cleanup traps, and retry logic.

The core logic is sound: sequential image numbering, temporary profile creation with CPLN_TOKEN_STAGING, Docker credential setup, and a trap-based cleanup all hang together correctly. The two findings are cosmetic: CPLN_PROFILE on docker manifest inspect has no effect, and the retry warning does not distinguish manifest-inspect failures from copy failures — neither affects correctness.

No files require special attention beyond the style notes on the docker manifest inspect line in both workflow files.

Important Files Changed

Filename Overview
.github/workflows/cpflow-promote-staging-to-production.yml Replaces cpflow copy-image-from-upstream with cpln image copy using a temporary staging profile; adds production image name derivation and a COPIED_IMAGE output for the summary step.
lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml Template kept in sync with the reusable workflow; same native image-copy logic with temporary staging profile and updated summary step.
spec/command/generate_github_actions_spec.rb Spec updated to assert new workflow content (cpln image copy, profile management, COPIED_IMAGE) and assert absence of old cpflow commands and CPLN_UPSTREAM_TOKEN.

Sequence Diagram

sequenceDiagram
    participant WF as Workflow
    participant CplnProd as cpln (production default profile)
    participant CplnStage as cpln (upstream-staging profile)
    participant DockerStore as Docker credential store
    participant StageReg as Staging Registry
    participant ProdReg as Production Registry

    WF->>CplnProd: cpln image query --org PRODUCTION (get latest image number)
    CplnProd-->>WF: latest_number
    WF->>WF: "production_image = APP:latest_number+1_commit"

    WF->>CplnStage: "CPLN_TOKEN=STAGING cpln profile create upstream-profile"
    CplnStage-->>WF: profile created

    WF->>CplnStage: "CPLN_PROFILE=upstream cpln image docker-login --org STAGING"
    CplnStage->>DockerStore: write staging registry credentials
    DockerStore-->>WF: credentials stored

    loop up to copy_image_attempts
        WF->>StageReg: docker manifest inspect STAGING_IMAGE
        StageReg-->>WF: manifest OK
        WF->>CplnStage: cpln image copy STAGING_IMAGE --profile upstream --to-profile default --to-org PRODUCTION --to-name production_image
        CplnStage->>ProdReg: copy image
        ProdReg-->>WF: copy complete
    end

    WF->>WF: output production_image
    WF->>CplnProd: cpflow deploy-image -a PRODUCTION_APP
    WF->>WF: trap cleanup: cpln profile delete upstream-profile
Loading

Comments Outside Diff (1)

  1. .github/workflows/cpflow-promote-staging-to-production.yml, line 377-394 (link)

    P2 Retry warning misidentifies manifest-inspect failures as copy failures

    When docker manifest inspect fails (e.g., transient connectivity to the staging registry), the && short-circuits so cpln image copy never runs. copy_status=$? then captures the inspect's exit code, and the warning says "Image copy attempt X failed" — but the actual failure was the manifest pre-check, not the copy itself. This makes it harder to distinguish "staging registry unreachable" from "copy command failed" when debugging a failed promotion.

Reviews (1): Last reviewed commit: "Use native Control Plane image copy in p..." | Re-trigger Greptile

copy_status=1
for attempt in $(seq 1 "${copy_image_attempts}"); do
if cpflow copy-image-from-upstream -a "${PRODUCTION_APP_NAME}" --org "${CPLN_ORG_PRODUCTION}" --image "${STAGING_IMAGE}"; then
if CPLN_PROFILE="${upstream_profile}" docker manifest inspect "${source_image_ref}" >/dev/null &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 The CPLN_PROFILE variable prefix on docker manifest inspect is a no-op — Docker ignores it entirely. Credentials for the staging registry were already written to Docker's credential store by the cpln image docker-login call above. Leaving it here implies Docker respects CPLN_PROFILE, which could mislead future maintainers into thinking removing it would break authentication.

Suggested change
if CPLN_PROFILE="${upstream_profile}" docker manifest inspect "${source_image_ref}" >/dev/null &&
if docker manifest inspect "${source_image_ref}" >/dev/null &&

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

copy_status=1
for attempt in $(seq 1 "${copy_image_attempts}"); do
if cpflow copy-image-from-upstream -a "${PRODUCTION_APP_NAME}" --org "${CPLN_ORG_PRODUCTION}" --image "${STAGING_IMAGE}"; then
if CPLN_PROFILE="${upstream_profile}" docker manifest inspect "${source_image_ref}" >/dev/null &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Same misleading CPLN_PROFILE prefix on docker manifest inspect as in the reusable workflow — Docker doesn't consume this variable. The credential was already written to Docker's store by cpln image docker-login.

Suggested change
if CPLN_PROFILE="${upstream_profile}" docker manifest inspect "${source_image_ref}" >/dev/null &&
if docker manifest inspect "${source_image_ref}" >/dev/null &&

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

jq -r --arg prefix "${PRODUCTION_APP_NAME}:" \
'[.items[].name | select(startswith($prefix)) | (try capture("^[^:]+:(?<number>[0-9]+)") catch empty) | .number | tonumber] | max // 0'
)"
production_image="${PRODUCTION_APP_NAME}:$((latest_number + 1))_${staging_commit}"

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.

Race condition on sequential promotions: latest_number is computed from a live API query, so two promotions running close together (e.g. a re-run while the first is still copying) can both read the same max number and produce the same production_image tag. The second cpln image copy would overwrite the first silently.

If the Control Plane API supports it, passing --to-name without the numeric prefix and letting the registry assign it, or using a commit-hash-only tag like ${PRODUCTION_APP_NAME}:${staging_commit}, would be race-free. If the incrementing number is intentional for ordering, consider adding a uniqueness guard (e.g. abort if the computed tag already exists in the production registry before copying).

copy_status=1
for attempt in $(seq 1 "${copy_image_attempts}"); do
if cpflow copy-image-from-upstream -a "${PRODUCTION_APP_NAME}" --org "${CPLN_ORG_PRODUCTION}" --image "${STAGING_IMAGE}"; then
if CPLN_PROFILE="${upstream_profile}" docker manifest inspect "${source_image_ref}" >/dev/null &&

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.

CPLN_PROFILE has no effect on docker manifest inspect — Docker reads credentials from its own config (set up by cpln image docker-login above), not from this env var. The prefix is harmless but misleading; it could imply the manifest check uses a different identity than it does.

Suggested change
if CPLN_PROFILE="${upstream_profile}" docker manifest inspect "${source_image_ref}" >/dev/null &&
if docker manifest inspect "${source_image_ref}" >/dev/null &&

@claude

claude Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Code Review

Overview

This PR replaces cpflow copy-image-from-upstream with native cpln image copy for cross-org staging→production image promotion. It also introduces a temporary cpln profile scoped to the staging credentials, validates the _<commit> suffix on the staging image, deterministically names the production image with an auto-incremented number, and exposes the copied tag as a step output to improve the promotion summary.

The change is well-motivated: the previous workflow surfaced opaque failures when staging registry credentials weren't reachable, and cpln image copy --to-profile is the documented first-party mechanism for cross-org copies.


Strengths

  • Profile scoped cleanup (trap cleanup_upstream_profile EXIT) ensures the temporary staging profile is always removed, even on failure. The pre-creation call to cleanup_upstream_profile defensively removes stale profiles from previous crashed runs.
  • Validation of commit suffix before attempting the copy catches bad image names early with a clear error.
  • Retry loop preserved: all existing retry/interval logic is retained.
  • Promotion summary improvement: the COPIED_IMAGE output now shows the exact production tag that was pushed, with a correct fallback to PREVIOUS_IMAGE on deployment failure.
  • CPLN_UPSTREAM_TOKEN removal: narrowing credentials to CPLN_TOKEN_STAGING is a nice reduction in secret surface.
  • Template + spec updated in sync: the generator template and RSpec expectations are updated together so regenerated workflows won't regress.

Issues / Suggestions

Medium — deploy-image implicitly selects the image

The Deploy image to production step calls cpflow deploy-image without passing an explicit image tag, relying on cpflow to pick the latest image in the production registry. Now that this PR names the production image deterministically (latest_number + 1), the copy and the deploy are slightly decoupled: if a concurrent copy (e.g. a re-run) or a manual push lands between these two steps, the wrong image could be deployed silently. Either:

  • Document this assumption clearly in a comment on the deploy step, or
  • Pass ${{ steps.copy-image.outputs.image }} explicitly if cpflow deploy-image accepts an --image flag.

Medium — latest_number is not atomic (see inline comment on line 362)

Two promotions running close together can both query the same max production image number and compute the same production_image tag. The second cpln image copy would silently overwrite the first. A guard such as checking whether the computed tag already exists before copying would close this window.

Low — CPLN_PROFILE on docker manifest inspect is a no-op (see inline comment on line 377)

Docker reads credentials from its own config store (populated by cpln image docker-login), not from the CPLN_PROFILE env var. The prefix is harmless but may mislead future readers into thinking the manifest check uses a distinct identity.

Low — jq silently tolerates API errors

If cpln image query returns a non-JSON response or an API error, the pipeline fails due to set -euo pipefail — but the error message will be the raw API output, not a clear workflow annotation. Adding || { echo "::error::Failed to query production images"; exit 1; } after the subshell would surface this more cleanly.


Test Coverage

The RSpec expectations are comprehensive and cover the key new strings. One gap: there's no negative assertion that CPLN_TOKEN_STAGING is absent from the env block in contexts where it shouldn't appear, though the removal of CPLN_UPSTREAM_TOKEN: is correctly verified.


Summary

The approach is sound and the implementation is clean. The two medium items above are worth addressing before merging since they both affect correctness under concurrent runs or CI re-triggers, which are realistic scenarios in a promotion workflow.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 81c3f99b36

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

exit 1
fi

staging_commit="${STAGING_IMAGE##*_}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Strip digests before deriving promoted tag

When the staging workload was deployed with use_digest_image_ref, cpflow deploy-image writes a tagged digest reference (see lib/command/deploy_image.rb:72-75), so this workflow captures a STAGING_IMAGE like app:7_deadbeef@sha256:<digest>. This extraction keeps the digest in staging_commit, and line 381 then builds a --to-name such as prod:8_deadbeef@sha256:<digest>, which is not a valid destination image name/tag for cpln image copy; promotions from digest-pinned staging workloads will fail before deployment. Strip any @... digest from STAGING_IMAGE before extracting the commit and constructing production_image.

Useful? React with 👍 / 👎.

Comment thread .github/workflows/cpflow-promote-staging-to-production.yml Outdated
Comment on lines +357 to +362
latest_number="$(
cpln image query --org "${CPLN_ORG_PRODUCTION}" --prop "name~${PRODUCTION_APP_NAME}:" -o json |
jq -r --arg prefix "${PRODUCTION_APP_NAME}:" \
'[.items[].name | select(startswith($prefix)) | (try capture("^[^:]+:(?<number>[0-9]+)") catch empty) | .number | tonumber] | max // 0'
)"
production_image="${PRODUCTION_APP_NAME}:$((latest_number + 1))_${staging_commit}"

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.

Silent-failure risk in latest_number subshell assignment

In bash, var="$(cmd)" always exits 0 — even with set -o pipefail — so if cpln image query fails (network hiccup, API error), the pipeline produces no output, jq exits 0 on empty stdin, and latest_number is silently set to "". Bash then evaluates $(("" + 1)) as 1, meaning production_image would be tagged :1_<commit> and potentially overwrite an existing image.

Suggested fix: validate the result before using it in arithmetic:

Suggested change
latest_number="$(
cpln image query --org "${CPLN_ORG_PRODUCTION}" --prop "name~${PRODUCTION_APP_NAME}:" -o json |
jq -r --arg prefix "${PRODUCTION_APP_NAME}:" \
'[.items[].name | select(startswith($prefix)) | (try capture("^[^:]+:(?<number>[0-9]+)") catch empty) | .number | tonumber] | max // 0'
)"
production_image="${PRODUCTION_APP_NAME}:$((latest_number + 1))_${staging_commit}"
latest_number="$(
cpln image query --org "${CPLN_ORG_PRODUCTION}" --prop "name~${PRODUCTION_APP_NAME}:" -o json |
jq -r --arg prefix "${PRODUCTION_APP_NAME}:" \
'[.items[].name | select(startswith($prefix)) | (try capture("^[^:]+:(?<number>[0-9]+)") catch empty) | .number | tonumber] | max // 0'
)"
if ! [[ "${latest_number}" =~ ^[0-9]+$ ]]; then
echo "::error::Failed to determine the latest production image number for '${PRODUCTION_APP_NAME}'; cpln image query may have failed."
exit 1
fi
production_image="${PRODUCTION_APP_NAME}:$((latest_number + 1))_${staging_commit}"

Comment on lines +376 to +381
latest_number="$(
cpln image query --org "${CPLN_ORG_PRODUCTION}" --prop "name~${PRODUCTION_APP_NAME}:" -o json |
jq -r --arg prefix "${PRODUCTION_APP_NAME}:" \
'[.items[].name | select(startswith($prefix)) | (try capture("^[^:]+:(?<number>[0-9]+)") catch empty) | .number | tonumber] | max // 0'
)"
production_image="${PRODUCTION_APP_NAME}:$((latest_number + 1))_${staging_commit}"

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.

Same silent-failure risk as the companion comment in the reusable workflow: latest_number="$(subshell)" masks pipeline failures in bash, so a cpln image query error silently produces production_image="…:1_<commit>".

Apply the same guard here:

Suggested change
latest_number="$(
cpln image query --org "${CPLN_ORG_PRODUCTION}" --prop "name~${PRODUCTION_APP_NAME}:" -o json |
jq -r --arg prefix "${PRODUCTION_APP_NAME}:" \
'[.items[].name | select(startswith($prefix)) | (try capture("^[^:]+:(?<number>[0-9]+)") catch empty) | .number | tonumber] | max // 0'
)"
production_image="${PRODUCTION_APP_NAME}:$((latest_number + 1))_${staging_commit}"
latest_number="$(
cpln image query --org "${CPLN_ORG_PRODUCTION}" --prop "name~${PRODUCTION_APP_NAME}:" -o json |
jq -r --arg prefix "${PRODUCTION_APP_NAME}:" \
'[.items[].name | select(startswith($prefix)) | (try capture("^[^:]+:(?<number>[0-9]+)") catch empty) | .number | tonumber] | max // 0'
)"
if ! [[ "${latest_number}" =~ ^[0-9]+$ ]]; then
echo "::error::Failed to determine the latest production image number for '${PRODUCTION_APP_NAME}'; cpln image query may have failed."
exit 1
fi
production_image="${PRODUCTION_APP_NAME}:$((latest_number + 1))_${staging_commit}"

@claude

claude Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Code Review

Overview

Replaces cpflow copy-image-from-upstream with native cpln image copy for staging→production image promotion. The new implementation: validates the staging image's _<commit> suffix, derives a sequential production tag by querying existing production images, isolates Docker credentials in a temp DOCKER_CONFIG, runs the copy in a retry loop, and outputs the resulting production image tag. Both the reusable workflow and the generator template are updated in lockstep, with matching spec coverage.


What works well

  • Credential isolation: using a mktemp -d DOCKER_CONFIG with trap cleanup_copy_credentials EXIT is a solid pattern — credentials are scoped to this step and cleaned up even on failure.
  • Removed CPLN_UPSTREAM_TOKEN: less secret surface area, and no more temporary upstream profile creation/deletion.
  • Fixed DEPLOYED_IMAGE bug: the old template used steps.staging-image.outputs.image (the staging image ref) as the "deployed image" in the summary. The new steps.copy-image.outputs.image correctly reports the production image that was actually written.
  • Commit suffix validation before tag arithmetic prevents silent nonsense tags.
  • Step id + output propagation wired up cleanly through to the summary.
  • Spec coverage: the new expect(contents).not_to include assertions explicitly guard against regression to the old helper.

One issue to fix (both files)

Silent failure in latest_number assignment — see inline comments on both files.

In bash, var="$(cmd1 | cmd2)" always exits 0 even with set -o pipefail. If cpln image query encounters a transient API error and produces no output, jq exits 0 on empty stdin, latest_number is set to "", and $(("" + 1)) silently evaluates to 1. The result is production_image="app:1_<commit>" — which could silently overwrite an existing production image.

The inline comments include a suggested fix: add a [[ "${latest_number}" =~ ^[0-9]+$ ]] guard immediately after the assignment. Also worth adding the same assertion to the spec to lock in the behavior.


Minor observations (non-blocking)

  • docker manifest inspect on every retry: the manifest is re-verified on each copy attempt. Harmless in practice, but if only the cpln image copy call failed it adds latency per retry with no benefit.
  • docker-login on every retry: re-logging in each iteration refreshes the credential, which is actually fine for long retry windows.
  • Concurrent promotion race: latest_number is captured once before the retry loop (correct), but two simultaneous promotions could derive the same tag. Likely acceptable for a promotion flow not expected to run concurrently.

Summary

The direction is right and the implementation is clean. The one concrete issue worth fixing before merge is the silent latest_number fallback — everything else is minor.

@justin808
justin808 force-pushed the jg-codex/use-native-production-image-copy-template branch from b8952a2 to 59e1dfc Compare June 2, 2026 22:51
@claude

claude Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Review: Use native Control Plane image copy in promotion workflow

What this does

Replaces cpflow copy-image-from-upstream with an explicit docker logindocker pulldocker tagdocker push pipeline, and adds a normalization/validation step for the org name GitHub variables. The summary now reports the actual production image tag rather than the staging image tag.

Strengths

  • Credential isolation: using a mktemp -d dir for DOCKER_CONFIG with a trap-based cleanup avoids credential leakage between steps.
  • --password-stdin pattern: avoids tokens appearing in /proc/<pid>/cmdline.
  • Org name validation: the sanitize + validate two-step catches common misconfiguration (trailing whitespace, embedded newlines, invalid characters) early, before any registry URLs are constructed.
  • Deployment summary: reporting the actual pushed production image on success / previous image on failure is a useful improvement over the old approach that always showed the staging image tag.
  • Spec coverage: the expectation additions lock in the new behaviour so unintentional regressions are visible.

Issues

🔴 Race condition in production image numbering (both workflow files)

cpln image query → compute latest_number + 1 → push is not atomic. Two concurrent promotion runs (e.g., a re-run triggered while another is still in-flight) can both read the same latest_number and both attempt to push to :N+1_<sha>. The later push silently overwrites the first; both subsequent deploy-image calls then reference the same tag with no error.

Possible mitigations:

  • Use the staging commit SHA as the sole identifier: ${PRODUCTION_APP_NAME}:${staging_commit} (no counter). Idempotent and avoids the query entirely.
  • Add a post-push check: after pushing, verify the image digest in the production registry matches what you just pushed, and fail if it does not.
  • Serialize promotions via a GitHub environment with a single concurrent job limit.

🟡 docker pull without --platform — silent multi-arch downgrade (both workflow files)

Running docker pull "${source_image_ref}" without an explicit --platform flag pulls only the manifest for the runner's native architecture (typically linux/amd64). If the staging image is a multi-arch manifest list, the production registry receives only a single-arch image on every promotion — silently stripping arm64 or other variants with no warning.

If single-arch is intentional, add --platform linux/amd64 explicitly so the intent is documented. If multi-arch must be preserved, docker buildx imagetools create or skopeo copy --all would be a better tool.

🟡 Full pull/push through runner vs. server-side copy — performance concern

The new approach routes all image layers through the GitHub Actions runner. For a realistic production image (500 MB–2 GB compressed) this adds several minutes of download + upload time, saturates runner bandwidth, and requires several GB of ephemeral disk space per attempt (and per retry).

cpln image copy or skopeo copy --src-creds ... --dest-creds ... performs a server-side registry-to-registry blob transfer that never hits the runner. Was the server-side path ruled out because of credential plumbing? skopeo copy is worth evaluating as a lighter-weight alternative.

🟡 Stale comment in env block

The comment on line 369 of the deployed workflow (same line in the template):

# Pass the upstream token via env rather than `-t` so it doesn't appear in /proc/<pid>/cmdline.

This was written for the old cpflow call that accepted a -t flag. The new code uses --password-stdin throughout, making the comment misleading. Consider removing it or updating it to describe the --password-stdin approach.

jq -r --arg prefix "${PRODUCTION_APP_NAME}:" \
'[.items[].name | select(startswith($prefix)) | (try capture("^[^:]+:(?<number>[0-9]+)") catch empty) | .number | tonumber] | max // 0'
)"
if ! [[ "${latest_number}" =~ ^[0-9]+$ ]]; then

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.

Race condition: the cpln image query → increment → push sequence is not atomic. Two concurrent promotion runs can both read latest_number=N and both push :N+1_<sha>, with the second push silently overwriting the first. Both deploy-image steps then succeed against the same tag with no error.

The simplest fix is to drop the numeric counter entirely and use the commit SHA as the sole differentiator, which makes the push idempotent:

Suggested change
if ! [[ "${latest_number}" =~ ^[0-9]+$ ]]; then
production_image="${PRODUCTION_APP_NAME}:${staging_commit}"

If the counter is required for ordering, add a uniqueness guard after the push (e.g., verify the pushed digest matches the remote digest) or serialize promotions via a GitHub environment concurrency limit.

jq -r --arg prefix "${PRODUCTION_APP_NAME}:" \
'[.items[].name | select(startswith($prefix)) | (try capture("^[^:]+:(?<number>[0-9]+)") catch empty) | .number | tonumber] | max // 0'
)"
if ! [[ "${latest_number}" =~ ^[0-9]+$ ]]; then

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.

Race condition (same issue as the deployed workflow): the cpln image query → increment → push sequence is not atomic. Two concurrent promotion runs can both read latest_number=N and both push :N+1_<sha>, with the second silently overwriting the first.

Suggested change
if ! [[ "${latest_number}" =~ ^[0-9]+$ ]]; then
production_image="${PRODUCTION_APP_NAME}:${staging_commit}"

Using the commit SHA alone is idempotent and eliminates the race. If the numeric counter is required, serialize via GitHub environment concurrency limits or add a post-push digest check.

copy_status=1
for attempt in $(seq 1 "${copy_image_attempts}"); do
if cpflow copy-image-from-upstream -a "${PRODUCTION_APP_NAME}" --org "${CPLN_ORG_PRODUCTION}" --image "${STAGING_IMAGE}"; then
if printf '%s' "${CPLN_TOKEN_STAGING}" |

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.

Silent multi-arch downgrade: docker pull without --platform fetches only the manifest for the runner's native architecture (linux/amd64 on GitHub-hosted runners). If the staging image is a multi-arch manifest list, the production registry ends up with a single-arch image on every promotion — arm64 and other variants are silently dropped.

If single-arch is intentional, document it explicitly:

Suggested change
if printf '%s' "${CPLN_TOKEN_STAGING}" |
docker pull --platform linux/amd64 "${source_image_ref}" &&

If multi-arch needs to be preserved, use docker buildx imagetools create --tag "${production_image_ref}" "${source_image_ref}" instead of the pull/tag/push sequence.

copy_status=1
for attempt in $(seq 1 "${copy_image_attempts}"); do
if cpflow copy-image-from-upstream -a "${PRODUCTION_APP_NAME}" --org "${CPLN_ORG_PRODUCTION}" --image "${STAGING_IMAGE}"; then
if printf '%s' "${CPLN_TOKEN_STAGING}" |

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.

Silent multi-arch downgrade (same issue as the deployed workflow): docker pull without --platform fetches only the runner's native architecture. If the staging image is a multi-arch manifest list, only that one arch is pushed to production.

Suggested change
if printf '%s' "${CPLN_TOKEN_STAGING}" |
docker pull --platform linux/amd64 "${source_image_ref}" &&

For multi-arch preservation, prefer docker buildx imagetools create --tag "${production_image_ref}" "${source_image_ref}" which copies the full manifest list without pulling layers to the runner.

@claude

claude Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Code Review posted - see inline comments below for specific findings.

@claude

claude Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Code Review: Use native Control Plane image copy in promotion workflow

Overview

This PR replaces cpflow copy-image-from-upstream with a native Docker pull/retag/push approach for staging-to-production image promotion, and adds org-name validation/normalization before any step that uses a registry URL. The change is well-motivated -- native Docker commands make the copy path transparent, testable, and independent of cpflow internals.


Strengths

  • Validation-first design: org names are sanitized and regex-validated before reaching any downstream step. Failing early with a clear error is much better than getting an opaque registry URL error mid-copy.
  • Credential isolation: DOCKER_CONFIG is pointed at a temp dir with a trap EXIT cleanup. Correct approach -- avoids polluting the default Docker config and ensures cleanup even on early exit.
  • Good negative tests: The spec asserts .not_to include("cpflow copy-image-from-upstream") and removes old artifacts, verifying the migration is complete.
  • Summary improvement: On success the summary now shows the actual production image pushed; on failure it shows the previous image (what rollback restores). More useful than always showing the staging image.

Issues

1. Missing stderr redirect in validate_control_plane_org

sanitize_control_plane_name correctly routes its error to stderr (>&2), but validate_control_plane_org sends its ::error:: annotation to stdout. GitHub Actions processes the annotation regardless of stream, so it is functionally harmless, but the inconsistency is worth fixing. Affects both workflow files (.github/workflows/…:144 and lib/github_flow_templates/…:143).

2. Docker logins run on every retry iteration

Inside the retry loop, each attempt re-runs docker login for both registries before pulling. If the failure is a transient network blip on docker pull, the re-login is wasted work. If docker login itself is flaky (e.g. auth endpoint rate limit), it adds an extra failure surface on every retry.

Suggested fix: perform both logins once before the loop, and only retry from docker manifest inspect onward. The DOCKER_CONFIG isolation is already in place so credentials persist across iterations.

3. Image number race window (depends on the concurrency group)

There is a TOCTOU gap between the cpln image query that derives latest_number and the subsequent docker push. A simultaneous workflow run would derive the same latest_number and push to the same tag, silently overwriting. The workflow-level concurrency group closes this in practice, but the comment explaining the dependency is attached to the deploy step. Consider adding a comment near the latest_number query noting it relies on the concurrency group to serialize access.

4. Full image pulled to runner disk on every promotion

Switching from cpln image copy (server-side) to docker pull + docker push (client-side) means the entire container image transits the runner disk on each promotion. Depending on image size this could be hundreds of MB, slow the step noticeably, and fail on runners with tight disk quotas. This trade-off may be acceptable given the auth benefits -- worth documenting in the CI docs.


Minor / Non-blocking

  • staging_commit="${STAGING_IMAGE##*_}" correctly picks the last underscore-delimited segment; the subsequent docker manifest inspect acts as the real gate if naming drifts.
  • The jq expression uses try capture(...) catch empty so non-matching names are dropped rather than fatal. The max // 0 fallback for an empty result set is also correct.
  • Both the reusable workflow and the generator template are kept in sync, which is the right pattern.

Test Coverage

The spec additions are thorough -- positive assertions for all new shell fragments and negative assertions confirming complete removal of the old approach. No gaps identified.

if ! [[ "${value}" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]]; then
local display_value
display_value="$(printf '%q' "${value}")"
echo "::error::${label} (${display_value}) must be a valid Control Plane org name; use lowercase alphanumeric characters and hyphens only, with no leading or trailing hyphen."

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.

Missing >&2 redirect — sanitize_control_plane_name uses >&2 for its error, but this one goes to stdout. GitHub Actions processes ::error:: annotations from either stream, so the annotation itself is fine, but for consistency and so that error output is on stderr when this script is run locally or piped, add >&2 here.

Suggested change
echo "::error::${label} (${display_value}) must be a valid Control Plane org name; use lowercase alphanumeric characters and hyphens only, with no leading or trailing hyphen."
echo "::error::${label} (${display_value}) must be a valid Control Plane org name; use lowercase alphanumeric characters and hyphens only, with no leading or trailing hyphen." >&2

if ! [[ "${value}" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]]; then
local display_value
display_value="$(printf '%q' "${value}")"
echo "::error::${label} (${display_value}) must be a valid Control Plane org name; use lowercase alphanumeric characters and hyphens only, with no leading or trailing hyphen."

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.

Same missing >&2 as the deployed workflow — error annotation goes to stdout instead of stderr. Should be kept in sync with the fix in the other file.

Suggested change
echo "::error::${label} (${display_value}) must be a valid Control Plane org name; use lowercase alphanumeric characters and hyphens only, with no leading or trailing hyphen."
echo "::error::${label} (${display_value}) must be a valid Control Plane org name; use lowercase alphanumeric characters and hyphens only, with no leading or trailing hyphen." >&2

exit 1
fi

latest_number="$(

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.

There is a TOCTOU window between this query and the docker push below: if two promotion runs somehow bypassed the concurrency group (or the group was misconfigured), both would read the same latest_number and push to the same production tag, silently overwriting each other. The concurrency group closes this in practice, but the comment explaining the dependency is currently on the Deploy image step. Worth adding a note here so the invariant is visible at the point where it actually matters:

Suggested change
latest_number="$(
# The workflow-level concurrency group serializes access here: without it,
# two concurrent runs could derive the same latest_number and overwrite each other.
latest_number="$(
cpln image query --org "${CPLN_ORG_PRODUCTION}" --prop "name~${PRODUCTION_APP_NAME}:" -o json |
jq -r --arg prefix "${PRODUCTION_APP_NAME}:" \
'[.items[].name | select(startswith($prefix)) | (try capture("^[^:]+:(?<number>[0-9]+)") catch empty) | .number | tonumber] | max // 0'
)"

copy_status=1
for attempt in $(seq 1 "${copy_image_attempts}"); do
if cpflow copy-image-from-upstream -a "${PRODUCTION_APP_NAME}" --org "${CPLN_ORG_PRODUCTION}" --image "${STAGING_IMAGE}"; then
if printf '%s' "${CPLN_TOKEN_STAGING}" |

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.

Both docker login calls run on every retry iteration. If the root cause of a failure is a transient docker pull blip, re-logging in is harmless but wasteful. If docker login itself is the flaky step (e.g. registry auth endpoint rate-limit), including it in the retry body means it becomes an extra failure surface on every attempt.

Consider moving both docker login calls to just before the loop so the loop only retries the actual image operations (manifest inspect → pull → tag → push). The DOCKER_CONFIG temp dir is already set up before the loop, so credentials will persist across iterations.

…roduction-image-copy-template

* origin/main:
  Bump version to 5.1.0
  Update CHANGELOG.md for 5.1.0 (#357)
# The workflow-level concurrency group serializes this sequence so two
# production promotions cannot derive and publish the same next tag.
latest_number="$(
cpln image query --org "${CPLN_ORG_PRODUCTION}" --prop "name~${PRODUCTION_APP_NAME}:" --max 0 -o json |

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.

Potential bug: --max 0 may return zero results.

Many CLIs and APIs treat a page-size of 0 as "return nothing" rather than "return everything". If cpln image query follows that convention, .items will always be empty, max // 0 will always produce 0, and every promotion will attempt to create tag :1. The pre-copy collision guard would then permanently block the second promotion.

Please verify via cpln image query --help (or the Control Plane docs) that --max 0 means unlimited. If it does not, replace with an explicit large ceiling:

Suggested change
cpln image query --org "${CPLN_ORG_PRODUCTION}" --prop "name~${PRODUCTION_APP_NAME}:" --max 0 -o json |
cpln image query --org "${CPLN_ORG_PRODUCTION}" --prop "name~${PRODUCTION_APP_NAME}:" --max 9999 -o json |

and add a comment explaining the intent. This same concern applies to the parallel block in .github/workflows/cpflow-promote-staging-to-production.yml.

Comment on lines +295 to +296
missing_vars="$(comm -23 <(printf '%s\n' "${staging_vars}") <(printf '%s\n' "${production_vars}"))"
production_only_vars="$(comm -13 <(printf '%s\n' "${staging_vars}") <(printf '%s\n' "${production_vars}"))"

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.

Minor bug: printf '%s\n' "" emits a blank line, not empty stdin.

When production_vars is an empty string (production has no env vars), printf '%s\n' "${production_vars}" outputs one blank line. comm -13 then sees that blank line as a production-only variable and emits a misleading warning — "Production GVC has environment variables not present in staging:" followed by an empty line — even though production is correctly empty.

The staging side has an early-return guard ([[ -z "${staging_vars}" ]]), but the production side has none. Suggested fix: strip blank lines from both inputs before feeding comm:

Suggested change
missing_vars="$(comm -23 <(printf '%s\n' "${staging_vars}") <(printf '%s\n' "${production_vars}"))"
production_only_vars="$(comm -13 <(printf '%s\n' "${staging_vars}") <(printf '%s\n' "${production_vars}"))"
missing_vars="$(comm -23 <(printf '%s\n' "${staging_vars}") <(printf '%s\n' "${production_vars}" | grep -v '^$' || true))"
production_only_vars="$(comm -13 <(printf '%s\n' "${staging_vars}") <(printf '%s\n' "${production_vars}" | grep -v '^$' || true))"

The same change is needed in .github/workflows/cpflow-promote-staging-to-production.yml.

@justin808 justin808 changed the title Use native Control Plane image copy in promotion workflow Harden production promotion image copy and readiness Jun 3, 2026

workload_ready="$(echo "${workload_json}" | jq -r '.status.ready // false')"
latest_ready="$(echo "${workload_json}" | jq -r '.status.readyLatest // false')"
readiness_status="$(echo "${workload_json}" | jq -r '.health.readiness // "unknown"')"

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.

Likely wrong JSON path — will always show "unknown"

The Control Plane workload object returned by cpln workload get -o json does not have a top-level .health key. Health/readiness state lives under .status (e.g. .status.ready, .status.readyLatest). The .health.readiness path will resolve to null on every poll, so the // "unknown" fallback always fires.

This only affects the log line (not the readiness gate), but it makes the "waiting for latest deployment" message permanently say readiness=unknown and removes a useful diagnostic signal. Consider dropping this field until the correct path is confirmed from a real cpln workload response, or verify the path against the API and correct it.

Suggested change
readiness_status="$(echo "${workload_json}" | jq -r '.health.readiness // "unknown"')"
readiness_status="$(echo "${workload_json}" | jq -r '.status.readyLatest // "unknown"')"

# The workflow-level concurrency group serializes this sequence so two
# production promotions cannot derive and publish the same next tag.
latest_number="$(
cpln image query --org "${CPLN_ORG_PRODUCTION}" --prop "name~${PRODUCTION_APP_NAME}:" --max 0 -o json |

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.

Verify --max 0 means "no limit", not "return 0 results"

The tag-numbering logic relies on this query returning all production images so it can find the highest numeric tag. If cpln image query --max 0 means "return up to 0 items" (rather than "unlimited"), then .items would always be empty, max // 0 would always return 0, and every promotion would derive tag :1. The second promotion would then hit the collision guard and abort.

The same pattern exists in the template file. If --max 0 means "no limit", this is correct — please add a short comment to that effect so the intent is clear:

Suggested change
cpln image query --org "${CPLN_ORG_PRODUCTION}" --prop "name~${PRODUCTION_APP_NAME}:" --max 0 -o json |
cpln image query --org "${CPLN_ORG_PRODUCTION}" --prop "name~${PRODUCTION_APP_NAME}:" --max 0 -o json | # --max 0 = no limit; returns all matching images

Comment on lines +307 to +308
staging_workload_vars="$(list_workload_env_names "${CPLN_TOKEN_STAGING}" "${CPLN_ORG_STAGING}" "${STAGING_APP_NAME}" "${workload_name}")"
production_workload_vars="$(list_workload_env_names "${CPLN_TOKEN_PRODUCTION}" "${CPLN_ORG_PRODUCTION}" "${PRODUCTION_APP_NAME}" "${workload_name}")"

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.

Unclear failure when a production workload doesn't exist

If workload_name exists in staging but the production GVC hasn't been bootstrapped yet, cpln workload get for production will fail with a raw cpln error (e.g. "workload not found") and set -euo pipefail will abort the step immediately — bypassing check_required_vars and env_check_failed=1. The operator sees a cpln CLI error rather than the actionable message like "Production workload 'rails' is missing environment variables...".

Consider wrapping the production fetch with an explicit not-found check:

production_workload_vars="$(list_workload_env_names ... 2>/dev/null)" || {
  echo "::error::Production workload '${workload_name}' was not found in GVC '${PRODUCTION_APP_NAME}'; bootstrap it before promotion."
  env_check_failed=1
  continue
}

Same issue in the template file.


staging_registry="${CPLN_ORG_STAGING}.registry.cpln.io"
production_registry="${CPLN_ORG_PRODUCTION}.registry.cpln.io"
source_image_ref="${staging_registry}/${STAGING_IMAGE}"

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.

source_image_ref intentionally uses the original (possibly digest-suffixed) STAGING_IMAGE

This is correct for digest-pinned copies — docker buildx imagetools create will use registry/app:tag@sha256:abc as the source, pulling by digest instead of by tag. Just noting this is a deliberate asymmetry vs staging_image (which strips @sha256:... for the cpln image get validation). A brief comment here would help future readers:

Suggested change
source_image_ref="${staging_registry}/${STAGING_IMAGE}"
source_image_ref="${staging_registry}/${STAGING_IMAGE}" # use original ref (may include @sha256 digest) for digest-preserving copy

Comment on lines +517 to +518
if docker buildx imagetools inspect "${source_image_ref}" >/dev/null &&
docker buildx imagetools create --prefer-index=false --tag "${production_image_ref}" "${source_image_ref}"; then

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.

Source re-inspect on every retry is redundant (and can mask transient create failures)

Each retry re-runs imagetools inspect "${source_image_ref}" before imagetools create. The source image doesn't change between retries, so repeated inspection adds latency without value. More importantly: if inspect passes but create fails transiently, the retry will show a combined failure exit code from the && compound — which is correct, but it's easier to diagnose if the two commands are separated:

Suggested change
if docker buildx imagetools inspect "${source_image_ref}" >/dev/null &&
docker buildx imagetools create --prefer-index=false --tag "${production_image_ref}" "${source_image_ref}"; then
inspect_ok=0
if ! docker buildx imagetools inspect "${source_image_ref}" >/dev/null; then
inspect_ok=1
fi
if [[ "${inspect_ok}" -eq 0 ]] && docker buildx imagetools create --prefer-index=false --tag "${production_image_ref}" "${source_image_ref}"; then

Alternatively, move the imagetools inspect "${source_image_ref}" above the retry loop (it only needs to run once) and let the loop contain only imagetools create.

@claude

claude Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

PR Review: Harden production promotion image copy and readiness

Overview

This PR addresses real, production-confirmed failure modes — particularly stale-ready replica masking a broken latest deployment, CR/LF-corrupted org names, and missing env vars not caught before image copy. The overall approach is sound and the security practices (isolated Docker config dir, credential cleanup via trap, printf '%s' token | docker login --password-stdin) are good.


Potential Bug

cpln image query --max 0 semantics need verification
(see inline comment on line 471)

The entire tag-numbering sequence — querying all production images to find the highest :N tag — depends on --max 0 meaning "unlimited". If the cpln CLI interprets it as "return zero items", max // 0 always returns 0, every promotion tries to push :1, and the second promotion fails the collision guard permanently. This is the single highest-risk item in the PR and worth a quick confirmation or a comment in the script.


Code Quality

  • .health.readiness JSON path (action.yml line 74): This path almost certainly doesn't exist in the cpln workload response — the field will always resolve to "unknown". Not a correctness issue since it's only used in a log line, but it removes useful diagnostic context. See inline comment.

  • Production workload not found → cryptic error (workflow lines 307-308): If a workload exists in staging but the production GVC hasn't been bootstrapped, cpln workload get in list_workload_env_names fails with a raw cpln error and the step aborts immediately via set -euo pipefail, bypassing env_check_failed=1. The operator sees a CLI trace instead of an actionable message. See inline comment for a suggested guard.

  • source_image_ref uses original STAGING_IMAGE (with digest): This is the right behaviour for digest-preserving copies but is a deliberate asymmetry from staging_image (stripped). A one-line comment prevents future confusion. See inline comment.

  • Source re-inspect inside retry loop: imagetools inspect "${source_image_ref}" runs on every copy attempt even though the source doesn't change. Moving it above the loop reduces noise and makes per-attempt failure attribution clearer. See inline comment.


Security

The credential isolation (mktemp -d + export DOCKER_CONFIG + trap ... EXIT) is the correct approach — tokens never touch the default Docker config, and the temp dir is cleaned up even on non-zero exit. Good.

Pinning docker/setup-buildx-action by commit SHA rather than a tag is also correct.


Tests / Docs

  • The spec changes cover the new code paths (org normalization, workload-level parity, readyLatest, imagetools) and the not_to include assertions that verify old code paths are gone are a nice addition.
  • Docs and the numbered bootstrap checklist are clear and complete.

Summary

Strong PR overall — catches real promotion failures and the image-copy approach is more robust than cpflow copy-image-from-upstream. Three issues worth resolving before merge: confirming --max 0 semantics, fixing the .health.readiness path, and adding a workload-not-found guard in the env parity loop.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml (1)

470-485: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Digest-only staging refs still fail before the Buildx copy runs.

If the workload is pinned as app@sha256:..., ${STAGING_IMAGE%%@*} becomes just app, so cpln image get "${staging_image}" aborts the promotion even though docker buildx imagetools can copy that digest ref. This breaks the digest-pinned path the new workflow is trying to support.

Suggested fix
-          staging_image="${STAGING_IMAGE%%@*}"
-          if [[ -z "${staging_image}" ]]; then
+          staging_image="${STAGING_IMAGE%%@*}"
+          if [[ -z "${staging_image}" ]]; then
             echo "::error::Staging image '${STAGING_IMAGE}' did not contain a usable image tag."
             exit 1
           fi

-          if ! CPLN_TOKEN="${CPLN_TOKEN_STAGING}" cpln image get "${staging_image}" --org "${CPLN_ORG_STAGING}" -o json >/dev/null; then
+          has_tag_ref=false
+          if [[ "${staging_image}" == *:* ]]; then
+            has_tag_ref=true
+          fi
+
+          if [[ "${has_tag_ref}" == "true" ]] &&
+            ! CPLN_TOKEN="${CPLN_TOKEN_STAGING}" cpln image get "${staging_image}" --org "${CPLN_ORG_STAGING}" -o json >/dev/null; then
             echo "::error::Staging image '${STAGING_IMAGE}' was not found in org '${CPLN_ORG_STAGING}'; aborting promotion."
             exit 1
           fi

-          staging_tag="${staging_image##*:}"
+          staging_tag=""
+          if [[ "${has_tag_ref}" == "true" ]]; then
+            staging_tag="${staging_image##*:}"
+          fi
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml`
around lines 470 - 485, The script currently strips the entire "@..." suffix
from STAGING_IMAGE into staging_image which turns a digest ref like
"app@sha256:..." into just "app" and causes the cpln image get check to fail;
change the logic so staging_image preserves digest refs (i.e., if STAGING_IMAGE
contains '@' set staging_image="${STAGING_IMAGE}" else keep the existing
stripping behavior), and update staging_tag extraction to handle both tag and
digest forms (if staging_image contains '@' set staging_tag to the substring
after '@', elseif it contains ':' set staging_tag to the substring after ':';
otherwise leave empty); keep using the same symbols staging_image,
STAGING_IMAGE, staging_tag and the cpln image get check so the correct ref is
validated.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In
`@lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml`:
- Around line 470-485: The script currently strips the entire "@..." suffix from
STAGING_IMAGE into staging_image which turns a digest ref like "app@sha256:..."
into just "app" and causes the cpln image get check to fail; change the logic so
staging_image preserves digest refs (i.e., if STAGING_IMAGE contains '@' set
staging_image="${STAGING_IMAGE}" else keep the existing stripping behavior), and
update staging_tag extraction to handle both tag and digest forms (if
staging_image contains '@' set staging_tag to the substring after '@', elseif it
contains ':' set staging_tag to the substring after ':'; otherwise leave empty);
keep using the same symbols staging_image, STAGING_IMAGE, staging_tag and the
cpln image get check so the correct ref is validated.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8abbc9f4-694c-404a-bab9-4e08a1aa7c52

📥 Commits

Reviewing files that changed from the base of the PR and between ee11d62 and f50e284.

📒 Files selected for processing (3)
  • .github/workflows/cpflow-promote-staging-to-production.yml
  • lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml
  • spec/command/generate_github_actions_spec.rb
🚧 Files skipped from review as they are similar to previous changes (2)
  • .github/workflows/cpflow-promote-staging-to-production.yml
  • spec/command/generate_github_actions_spec.rb

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

There are 3 total unresolved issues (including 1 from previous review).

Fix All in Cursor

Bugbot Autofix is ON. A cloud agent has been kicked off to fix the reported issues.

Reviewed by Cursor Bugbot for commit 6cd8225. Configure here.

staging_commit=""
if [[ "${staging_tag}" == *_* ]]; then
staging_commit="${staging_tag##*_}"
fi

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Digest pin drops commit suffix

Medium Severity

When the staging image reference includes a digest (@sha256:…), commit suffix extraction uses the digest segment instead of the tag before @, so _&lt;commit&gt; is not copied onto the new production tag despite documented behavior.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6cd8225. Configure here.

echo
echo "Previous image: \`${PREVIOUS_IMAGE}\`"
echo "Previous version: ${PREVIOUS_VERSION}"
echo "Deployed image: \`${deployed_image}\`"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Summary mislabels failed promotion image

Medium Severity

On a failed health check, the promotion summary always prints Deployed image as the pre-promotion reference, even when rollback did not run or failed and production may still be running the newly copied image.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6cd8225. Configure here.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6cd8225efa

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +451 to +455
if [[ "${STAGING_IMAGE}" == *@* ]]; then
staging_image="${STAGING_IMAGE}"
else
staging_image="${STAGING_IMAGE%%@*}"
fi

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Strip digests before image lookup

When the staging workload is digest-pinned, STAGING_IMAGE is shaped like app:7_deadbeef@sha256:...; this branch keeps the @... suffix instead of removing it, so cpln image get is asked for a tagged-digest string rather than the Control Plane image name and the later tag parsing reads sha256:... instead of 7_deadbeef. Fresh evidence in this revision is that the new normalization branch preserves @ references on lines 451-455, so digest-pinned staging promotions still abort or lose the commit suffix instead of promoting the tag portion documented for this workflow.

Useful? React with 👍 / 👎.

@claude

claude Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Code Review — PR #356: Harden production promotion image copy and readiness

Overview

This PR addresses real production failure modes discovered in a downstream tutorial: stale-replica health masking, registry auth not crossing workflow boundaries, CR/LF-corrupted org names, and the old cpflow copy-image-from-upstream path not handling digest-pinned or multi-arch images. The direction is sound and the overall implementation is solid. A few bugs and minor code quality issues are noted inline below.


Bug: Commit suffix not preserved for digest-pinned images

For a digest-pinned staging image like myapp:123_abc@sha256:deadbeef, the tag extraction block (lines 466-470 in the reusable workflow, 485-489 in the template) sets staging_tag to the digest (sha256:deadbeef) rather than the tag portion (123_abc). Since a digest hash never contains _, staging_commit is always empty for digest-pinned images — the _commit suffix is silently dropped, contradicting the documented behaviour: "Tags with a _<commit> suffix keep that suffix in production."

Suggested replacement:

staging_tag=""
if [[ "${staging_image}" == *:*@* ]]; then
  staging_tag="${staging_image##*:}"  # e.g. 123_abc@sha256:...
  staging_tag="${staging_tag%%@*}"    # strip digest -> 123_abc
elif [[ "${staging_image}" == *@* ]]; then
  staging_tag=""  # digest-only ref, no tag to extract
elif [[ "${staging_image}" == *:* ]]; then
  staging_tag="${staging_image##*:}"
fi

Same fix is needed in both the reusable workflow and the template.


Minor / Code Quality

Dead else branch in staging image normalization (lines 451-455 / 470-474 in template)

The else branch staging_image="${STAGING_IMAGE%%@*}" only runs when STAGING_IMAGE contains no @. In that case %%@* strips nothing — staging_image == STAGING_IMAGE. The whole block simplifies to staging_image="${STAGING_IMAGE}".

source_image_ref uses the raw env var instead of the normalized local (line 496 / 515 in template)

Validation uses ${staging_image} (normalized), but the copy reference uses ${STAGING_IMAGE} (raw env var). They are always equal today, but the inconsistency is a maintenance trap if the normalization logic ever diverges.

No version annotation on the pinned docker/setup-buildx-action hash (line 422 / 441 in template)

Pin-by-hash is the right security call, but without a version comment reviewers cannot tell which release this hash corresponds to. A trailing # vX.Y.Z is the standard convention.


Operational note: --max 0 semantics in cpln image query

The concurrency comment says tag collisions are prevented by serialization, which implies the query must return all images so jq can compute the true maximum. If --max 0 means "return zero items" rather than "no limit" on the cpln CLI, latest_number would always be 0 and every promotion would try to publish :1, then immediately hit the collision guard. A brief comment confirming the semantics would prevent future confusion.


What's Good

  • Isolated DOCKER_CONFIG dir with an EXIT trap is the correct approach — no secrets touch disk outside the temp dir or appear in process arguments.
  • sanitize_control_plane_name + validate_control_plane_org correctly distinguishes leading/trailing whitespace (trim) from embedded newlines (hard-fail).
  • Fetching the workload JSON once and piping to two jq calls in the rollback readiness check is more efficient than two separate API calls.
  • Requiring both status.ready and status.readyLatest before endpoint checks cleanly closes the stale-replica masking hole.
  • Collision check before the retry loop prevents a partial retry from accidentally overwriting an already-published production tag.

Comment on lines +466 to +475
staging_tag=""
if [[ "${staging_image}" == *@* ]]; then
staging_tag="${staging_image##*@}"
elif [[ "${staging_image}" == *:* ]]; then
staging_tag="${staging_image##*:}"
fi
staging_commit=""
if [[ "${staging_tag}" == *_* ]]; then
staging_commit="${staging_tag##*_}"
fi

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.

Bug: commit suffix is never extracted for digest-pinned images.

When staging_image is myapp:123_abc@sha256:deadbeef, the *@* branch sets staging_tag = "sha256:deadbeef" (the digest). Because a digest hash never contains _, staging_commit stays empty and the _abc suffix is silently dropped from the production tag — contradicting the documented behaviour.

Suggested change
staging_tag=""
if [[ "${staging_image}" == *@* ]]; then
staging_tag="${staging_image##*@}"
elif [[ "${staging_image}" == *:* ]]; then
staging_tag="${staging_image##*:}"
fi
staging_commit=""
if [[ "${staging_tag}" == *_* ]]; then
staging_commit="${staging_tag##*_}"
fi
staging_tag=""
if [[ "${staging_image}" == *:*@* ]]; then
staging_tag="${staging_image##*:}" # e.g. 123_abc@sha256:...
staging_tag="${staging_tag%%@*}" # strip digest -> 123_abc
elif [[ "${staging_image}" == *@* ]]; then
staging_tag="" # digest-only ref, no tag to extract
elif [[ "${staging_image}" == *:* ]]; then
staging_tag="${staging_image##*:}"
fi
staging_commit=""
if [[ "${staging_tag}" == *_* ]]; then
staging_commit="${staging_tag##*_}"
fi

Comment on lines +485 to +494
staging_tag=""
if [[ "${staging_image}" == *@* ]]; then
staging_tag="${staging_image##*@}"
elif [[ "${staging_image}" == *:* ]]; then
staging_tag="${staging_image##*:}"
fi
staging_commit=""
if [[ "${staging_tag}" == *_* ]]; then
staging_commit="${staging_tag##*_}"
fi

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.

Bug: same commit suffix extraction issue as in the reusable workflow — for myapp:123_abc@sha256:deadbeef, staging_tag receives the digest (sha256:deadbeef), not the tag, so staging_commit is always empty for digest-pinned images.

Suggested change
staging_tag=""
if [[ "${staging_image}" == *@* ]]; then
staging_tag="${staging_image##*@}"
elif [[ "${staging_image}" == *:* ]]; then
staging_tag="${staging_image##*:}"
fi
staging_commit=""
if [[ "${staging_tag}" == *_* ]]; then
staging_commit="${staging_tag##*_}"
fi
staging_tag=""
if [[ "${staging_image}" == *:*@* ]]; then
staging_tag="${staging_image##*:}" # e.g. 123_abc@sha256:...
staging_tag="${staging_tag%%@*}" # strip digest -> 123_abc
elif [[ "${staging_image}" == *@* ]]; then
staging_tag="" # digest-only ref, no tag to extract
elif [[ "${staging_image}" == *:* ]]; then
staging_tag="${staging_image##*:}"
fi
staging_commit=""
if [[ "${staging_tag}" == *_* ]]; then
staging_commit="${staging_tag##*_}"
fi

Comment on lines +451 to +455
if [[ "${STAGING_IMAGE}" == *@* ]]; then
staging_image="${STAGING_IMAGE}"
else
staging_image="${STAGING_IMAGE%%@*}"
fi

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.

Dead code: the else branch only runs when STAGING_IMAGE contains no @. In that case %%@* strips nothing and staging_image == STAGING_IMAGE. The whole block is equivalent to staging_image="${STAGING_IMAGE}".

Suggested change
if [[ "${STAGING_IMAGE}" == *@* ]]; then
staging_image="${STAGING_IMAGE}"
else
staging_image="${STAGING_IMAGE%%@*}"
fi
staging_image="${STAGING_IMAGE}"

Comment on lines 469 to +474

if ! CPLN_TOKEN="${CPLN_TOKEN_STAGING}" cpln image get "${STAGING_IMAGE}" --org "${CPLN_ORG_STAGING}" -o json >/dev/null; then
if [[ "${STAGING_IMAGE}" == *@* ]]; then
staging_image="${STAGING_IMAGE}"
else
staging_image="${STAGING_IMAGE%%@*}"
fi

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.

Same dead-code issue as in the reusable workflow — %%@* is a no-op when there is no @.

Suggested change
if ! CPLN_TOKEN="${CPLN_TOKEN_STAGING}" cpln image get "${STAGING_IMAGE}" --org "${CPLN_ORG_STAGING}" -o json >/dev/null; then
if [[ "${STAGING_IMAGE}" == *@* ]]; then
staging_image="${STAGING_IMAGE}"
else
staging_image="${STAGING_IMAGE%%@*}"
fi
staging_image="${STAGING_IMAGE}"


staging_registry="${CPLN_ORG_STAGING}.registry.cpln.io"
production_registry="${CPLN_ORG_PRODUCTION}.registry.cpln.io"
source_image_ref="${staging_registry}/${STAGING_IMAGE}"

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.

Minor: uses ${STAGING_IMAGE} (raw env var) while all validation above used ${staging_image} (normalized local). They are always equal today, but using the local variable here would be consistent and remove a maintenance trap.

Suggested change
source_image_ref="${staging_registry}/${STAGING_IMAGE}"
source_image_ref="${staging_registry}/${staging_image}"


staging_registry="${CPLN_ORG_STAGING}.registry.cpln.io"
production_registry="${CPLN_ORG_PRODUCTION}.registry.cpln.io"
source_image_ref="${staging_registry}/${STAGING_IMAGE}"

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.

Same inconsistency as in the reusable workflow — prefer ${staging_image} for consistency with the validation above.

Suggested change
source_image_ref="${staging_registry}/${STAGING_IMAGE}"
source_image_ref="${staging_registry}/${staging_image}"

echo "image=${staging_image}" >> "$GITHUB_OUTPUT"

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5

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.

Pin-by-hash is correct for security, but please add a version comment so reviewers can identify the release without having to look up the hash.

Suggested change
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # vX.Y.Z

echo "image=${staging_image}" >> "$GITHUB_OUTPUT"

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5

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.

Same as the reusable workflow — add a version comment alongside the pinned hash.

Suggested change
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # vX.Y.Z

@justin808
justin808 merged commit 9ef104c into main Jun 3, 2026
29 checks passed
@justin808
justin808 deleted the jg-codex/use-native-production-image-copy-template branch June 3, 2026 03:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant