From 441d8d2d7cdd0db1f27478dfdd0d3baa07efb534 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Fri, 24 Jul 2026 11:42:39 +0900 Subject: [PATCH 1/5] Fix release runner scheduling resource request --- .controlplane/controlplane.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.controlplane/controlplane.yml b/.controlplane/controlplane.yml index d321df04..78612cc2 100644 --- a/.controlplane/controlplane.yml +++ b/.controlplane/controlplane.yml @@ -83,5 +83,10 @@ apps: <<: *common # match_if_app_name_starts_with is used to identify these "qa" apps. match_if_app_name_starts_with: true + # These review-app one-off defaults request app-sized resources and set a + # 15-minute server-side active deadline. + runner_job_default_cpu: "300m" + runner_job_default_memory: "1Gi" + runner_job_timeout: 900 image_retention_days: 5 stale_app_image_deployed_days: 5 # If the app is older than 5 days, the nightly automations will clean stale apps. From 1fc3f390bea00655b0846d83b2831a38b3589e34 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Mon, 31 Aug 2026 01:28:59 -1000 Subject: [PATCH 2/5] Allow isolated review-app cpflow canaries --- .agents/agent-workflow.yml | 2 + .../workflows/cpflow-deploy-review-app.yml | 2 +- bin/test-cpflow-github-flow | 52 +++++++++++++++---- 3 files changed, 45 insertions(+), 11 deletions(-) diff --git a/.agents/agent-workflow.yml b/.agents/agent-workflow.yml index e781e476..5558c843 100644 --- a/.agents/agent-workflow.yml +++ b/.agents/agent-workflow.yml @@ -2,6 +2,8 @@ base_branch: master merge_submission: mode: direct +trusted_actions: + - shakacode/control-plane-flow follow_up_prefix: 'Follow-up:' review_gate: n/a approval_exempt: n/a diff --git a/.github/workflows/cpflow-deploy-review-app.yml b/.github/workflows/cpflow-deploy-review-app.yml index 056ed624..eefa2b28 100644 --- a/.github/workflows/cpflow-deploy-review-app.yml +++ b/.github/workflows/cpflow-deploy-review-app.yml @@ -30,7 +30,7 @@ jobs: github.event.issue.pull_request && contains(fromJson('["+review-app-deploy","+review-app-deploy\n","+review-app-deploy\r\n"]'), github.event.comment.body) && contains(fromJson('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)) - uses: shakacode/control-plane-flow/.github/workflows/cpflow-deploy-review-app.yml@v5.2.0 + uses: shakacode/control-plane-flow/.github/workflows/cpflow-deploy-review-app.yml@b8676066d3344098bef8e35ee4abce1d0ca9d8a3 # unreleased control-plane-flow PR #435 revision secrets: CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }} DOCKER_BUILD_SSH_KEY: ${{ secrets.DOCKER_BUILD_SSH_KEY }} diff --git a/bin/test-cpflow-github-flow b/bin/test-cpflow-github-flow index 8ea97e0b..3750e51c 100755 --- a/bin/test-cpflow-github-flow +++ b/bin/test-cpflow-github-flow @@ -132,8 +132,13 @@ PROMOTE_WORKFLOW = %r{\Ashakacode/control-plane-flow/\.github/workflows/cpflow-p EXPECTED_PROMOTE_WORKFLOW_REF_FORMAT = "shakacode/control-plane-flow/.github/workflows/cpflow-promote-staging-to-production.yml@vX.Y.Z" EXPECTED_CPFLOW_CHECKOUT_ACTION = "actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd" EXPECTED_CPFLOW_CHECKOUT_REPOSITORY = "shakacode/control-plane-flow" +REVIEW_APP_CANARY_PATH = ".github/workflows/cpflow-deploy-review-app.yml" +REVIEW_APP_CANARY_JOB = "deploy" +REVIEW_APP_CANARY_WORKFLOW = "shakacode/control-plane-flow/.github/workflows/cpflow-deploy-review-app.yml" +FULL_COMMIT_SHA = /\A[0-9a-f]{40}\z/i -refs = Hash.new { |hash, key| hash[key] = [] } +stable_refs = Hash.new { |hash, key| hash[key] = [] } +review_app_canary_entries = [] Dir[".github/workflows/cpflow-*.yml"].sort.each do |path| doc = YAML.load_file(path, aliases: true) @@ -157,7 +162,17 @@ Dir[".github/workflows/cpflow-*.yml"].sort.each do |path| end uses_ref = uses_match[1] - refs[uses_ref] << "#{path}:#{job_name}" + entry = "#{path}:#{job_name}" + if path == REVIEW_APP_CANARY_PATH && job_name == REVIEW_APP_CANARY_JOB + canary_workflow = job["uses"].to_s.delete_suffix("@#{uses_ref}") + unless canary_workflow == REVIEW_APP_CANARY_WORKFLOW + abort "#{entry} must call #{REVIEW_APP_CANARY_WORKFLOW}, got #{canary_workflow.inspect}" + end + + review_app_canary_entries << { ref: uses_ref, entry: entry } + else + stable_refs[uses_ref] << entry + end if job["uses"].to_s.match?(PROMOTE_WORKFLOW) abort "#{path}:#{job_name} must not call the cross-repo production reusable workflow; use a normal caller-repo job with environment: production" @@ -204,7 +219,7 @@ if checkout_ref.to_s.strip.empty? abort "#{promote_path}:promote-to-production must pin the Checkout control-plane-flow actions step" end -refs[checkout_ref] << "#{promote_path}:promote-to-production" +stable_refs[checkout_ref] << "#{promote_path}:promote-to-production" setup_step = Array(promote_job["steps"]).find { |step| step["name"] == "Setup production environment" } @@ -220,17 +235,34 @@ unless setup_match "for example #{EXPECTED_PROMOTE_WORKFLOW_REF_FORMAT}" end -refs[setup_match[1]] << "#{promote_path}:promote-to-production setup" +stable_refs[setup_match[1]] << "#{promote_path}:promote-to-production setup" + +unless review_app_canary_entries.length == 1 + abort "expected exactly one review-app canary entry at #{REVIEW_APP_CANARY_PATH}:#{REVIEW_APP_CANARY_JOB}, " \ + "found #{review_app_canary_entries.length}" +end -if refs.empty? - puts "no upstream cpflow reusable workflow refs found" -elsif refs.length > 1 - refs.each do |ref, paths| +if stable_refs.empty? + abort "no stable upstream cpflow refs found outside the review-app canary" +elsif stable_refs.length > 1 + stable_refs.each do |ref, paths| puts "#{ref}: #{paths.uniq.sort.join(', ')}" end - abort "cpflow workflow wrappers use multiple upstream refs: #{refs.keys.sort.join(', ')}" + abort "non-canary cpflow workflow wrappers use multiple upstream refs: #{stable_refs.keys.sort.join(', ')}" +end + +stable_ref = stable_refs.keys.fetch(0) +review_app_canary = review_app_canary_entries.fetch(0) +review_app_ref = review_app_canary.fetch(:ref) + +if review_app_ref == stable_ref + puts "cpflow refs: #{stable_ref}" +elsif review_app_ref.match?(FULL_COMMIT_SHA) + puts "cpflow stable ref: #{stable_ref}" + puts "review-app canary ref: #{review_app_ref} (#{review_app_canary.fetch(:entry)})" else - puts "cpflow refs: #{refs.keys.sort.join(', ')}" + abort "review-app canary may diverge from #{stable_ref} only with a full 40-character commit SHA, " \ + "got #{review_app_ref.inspect}" end RUBY From 3662f672e01a18b6b7ea3f59e8681f0f59b29c53 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Fri, 4 Sep 2026 16:50:17 -1000 Subject: [PATCH 3/5] Keep review-app operations on the released cpflow contract --- .../docs/testing-cpflow-github-actions.md | 66 +++++++++--- .../workflows/cpflow-delete-review-app.yml | 12 ++- .../workflows/cpflow-deploy-review-app.yml | 11 +- Gemfile | 2 +- Gemfile.lock | 4 +- bin/check-cpflow-review-app-contract | 83 +++++++++++++++ bin/test-cpflow-github-flow | 62 +++++------ spec/cpflow_review_app_contract_spec.rb | 100 ++++++++++++++++++ 8 files changed, 283 insertions(+), 57 deletions(-) create mode 100755 bin/check-cpflow-review-app-contract create mode 100644 spec/cpflow_review_app_contract_spec.rb diff --git a/.controlplane/docs/testing-cpflow-github-actions.md b/.controlplane/docs/testing-cpflow-github-actions.md index c39a7244..259e078f 100644 --- a/.controlplane/docs/testing-cpflow-github-actions.md +++ b/.controlplane/docs/testing-cpflow-github-actions.md @@ -7,35 +7,71 @@ Use this repo note only as the canary checklist for ## Local Checks -After regenerating the generated `cpflow-*` wrappers, run: +Install the pinned dependencies, then validate the released review-app pair and +all existing generated-workflow checks without network access: ```sh -bin/conductor-exec bin/test-cpflow-github-flow +bin/conductor-exec bundle check +bin/conductor-exec ruby bin/check-cpflow-review-app-contract +bin/conductor-exec bundle exec rspec spec/cpflow_review_app_contract_spec.rb +bin/conductor-exec bin/test-cpflow-github-flow --offline ``` -When testing an unreleased upstream `control-plane-flow` checkout, pass that -checkout's `bin/cpflow`: +`--offline` explicitly skips public package-registry readiness checks. It still +checks the release contract, renderer resources/probes, YAML, existing wrapper +consistency, and actionlint. It does not contact the deployment platform, prove +an image builds, or prove deployment readiness. For registry readiness as well: ```sh -bin/conductor-exec bin/test-cpflow-github-flow ruby /path/to/control-plane-flow/bin/cpflow +bin/conductor-exec bin/test-cpflow-github-flow bundle exec cpflow ``` -## Testing An Upstream PR Downstream +The full helper requires `actionlint` on `PATH` in addition to the installed +Ruby bundle. Ordinary RSpec contract tests need only Ruby and the bundle; they +exercise the pure-Ruby validator, not the external lint executable. Run the full +helper separately when validating workflow changes. -Use an immutable upstream commit SHA, not a branch: +## Released Review-App Pair -```sh -bin/pin-cpflow-github-ref <40-character-control-plane-flow-commit-sha> -bin/conductor-exec bin/test-cpflow-github-flow ruby /path/to/control-plane-flow/bin/cpflow -``` +The deploy/delete callers and local CLI use cpflow **5.3.0**. Both callers pin +`b1e5ff4a04adfccfd8b59996e8abdbb5defb3fd6` with a readable `v5.3.0` +comment. Other generated callers intentionally remain on their existing +**5.2.0** cohort; their migration is separate scope. + +The pair is inseparable: both need the generated authenticated redispatch +input, exact run/job names, and matching permissions. A pin-only upgrade or a +single divergent canary is rejected. The offline validator reads the installed +5.3.0 gem's caller templates as its source of truth, without loading cpflow code. -Leave `CPFLOW_VERSION` unset while testing a commit SHA. After the upstream PR -ships in a release tag, repin wrappers to that tag. Use `v5.2.0` for the -promotion-hardening and release-runner timeout fixes; use immutable commit SHAs -only for future unreleased upstream PR tests. +Leave `CPFLOW_VERSION` unset for these SHA-pinned callers: upstream builds the +CLI from the same pinned source. The variable-based RubyGems override requires +a release-tag ref and cannot be combined with a commit SHA. + +Do not run the all-wrapper pin helper or blindly regenerate over the customized +validation script for this split migration. A future upgrade must update both +callers, the local dependency/lockfile, validator release constants, and fixture +tests together. Preserve the renderer assertions and separately review any +changes to other caller cohorts. ## Review App Canary +Deployment requires explicit authorization. A push is not unconditionally safe: +the released workflow deploys existing apps, and its intent reconciliation can +honor a newer accepted manual request even if the app was previously absent. +Before any publication under a no-deployment constraint, verify fresh app +absence and the absence of competing accepted intents or queued/running work; +otherwise hold publication. PR body edits alone are not a deploy trigger. + +An absent app with a reconciled `pull_request` intent skips creation/build/deploy +and reports `image_built=false`; this is a skip, not a successful canary. The +cross-operation handoff targets default-branch wrappers, so validating a branch +alone does not prove end-to-end deploy/delete reconciliation before merge. + +The 5.3.0 integration repair does not establish the cause of the previously +observed active runner with no replica. The existing resource/deadline settings +are preserved; that platform failure remains unresolved until separately +authorized evidence establishes its cause. + 1. Open or reuse a same-repository PR. 2. Comment exactly `+review-app-deploy`. 3. Confirm the deploy job checks out the expected upstream Control Plane Flow diff --git a/.github/workflows/cpflow-delete-review-app.yml b/.github/workflows/cpflow-delete-review-app.yml index 53f87243..9c127f38 100644 --- a/.github/workflows/cpflow-delete-review-app.yml +++ b/.github/workflows/cpflow-delete-review-app.yml @@ -1,5 +1,7 @@ name: Delete Review App +run-name: "Delete Review App - PR #${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}" + on: pull_request_target: types: [closed] @@ -11,9 +13,15 @@ on: description: Pull request number targeted for deletion required: true type: number + reconcile_intent_run_id: + description: Authenticated internal handoff; manual values are rejected + required: false + type: string permissions: + actions: write contents: read + deployments: write issues: write pull-requests: write @@ -22,6 +30,8 @@ jobs: # pull_request_target is intentional: fork PR-close events need access to # staging secrets to delete review apps and update PR comments. The upstream # reusable workflow checks out trusted base-branch action code, not fork code. + # author_association is a cheap caller-side cost filter. The reusable workflow + # still checks the commenter's current repository permission before privileged work. if: | (github.event_name == 'issue_comment' && github.event.issue.pull_request && @@ -31,6 +41,6 @@ jobs: github.event_name == 'workflow_dispatch' # This `if:` mirrors the upstream job guard to avoid a billable workflow_call # when the event does not match. Keep both conditions in sync. - uses: shakacode/control-plane-flow/.github/workflows/cpflow-delete-review-app.yml@v5.2.0 + uses: shakacode/control-plane-flow/.github/workflows/cpflow-delete-review-app.yml@b1e5ff4a04adfccfd8b59996e8abdbb5defb3fd6 # v5.3.0 secrets: CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }} diff --git a/.github/workflows/cpflow-deploy-review-app.yml b/.github/workflows/cpflow-deploy-review-app.yml index eefa2b28..a9ae8a62 100644 --- a/.github/workflows/cpflow-deploy-review-app.yml +++ b/.github/workflows/cpflow-deploy-review-app.yml @@ -13,15 +13,24 @@ on: description: Pull request number to deploy required: true type: number + reconcile_intent_run_id: + description: Authenticated internal handoff; manual values are rejected + required: false + type: string permissions: + actions: write contents: read deployments: write issues: write pull-requests: write jobs: + # The reusable job exposes `image_built`; downstream jobs can read + # `needs.deploy.outputs.image_built`. A value of `false` means this check did not validate the Docker image. deploy: + # author_association is a cheap caller-side cost filter. The reusable workflow + # still checks the commenter's current repository permission before privileged work. if: | (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || @@ -30,7 +39,7 @@ jobs: github.event.issue.pull_request && contains(fromJson('["+review-app-deploy","+review-app-deploy\n","+review-app-deploy\r\n"]'), github.event.comment.body) && contains(fromJson('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)) - uses: shakacode/control-plane-flow/.github/workflows/cpflow-deploy-review-app.yml@b8676066d3344098bef8e35ee4abce1d0ca9d8a3 # unreleased control-plane-flow PR #435 revision + uses: shakacode/control-plane-flow/.github/workflows/cpflow-deploy-review-app.yml@b1e5ff4a04adfccfd8b59996e8abdbb5defb3fd6 # v5.3.0 secrets: CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }} DOCKER_BUILD_SSH_KEY: ${{ secrets.DOCKER_BUILD_SSH_KEY }} diff --git a/Gemfile b/Gemfile index 48166fea..664c097f 100644 --- a/Gemfile +++ b/Gemfile @@ -5,7 +5,7 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" } ruby "3.4.6" -gem "cpflow", "5.2.0", require: false +gem "cpflow", "5.3.0", require: false gem "react_on_rails_pro", "17.0.0" gem "shakapacker", "10.2.0" diff --git a/Gemfile.lock b/Gemfile.lock index 8e3d128c..ddb15b9f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -143,7 +143,7 @@ GEM term-ansicolor (~> 1.6) thor (>= 0.20.3, < 2.0) tins (~> 1.16) - cpflow (5.2.0) + cpflow (5.3.0) dotenv (~> 3.1) jwt (~> 3.1) psych (~> 5.2) @@ -530,7 +530,7 @@ DEPENDENCIES capybara-screenshot coffee-rails coveralls_reborn (~> 0.25.0) - cpflow (= 5.2.0) + cpflow (= 5.3.0) database_cleaner debug (>= 1.0.0) factory_bot_rails diff --git a/bin/check-cpflow-review-app-contract b/bin/check-cpflow-review-app-contract new file mode 100755 index 00000000..1bdad5ea --- /dev/null +++ b/bin/check-cpflow-review-app-contract @@ -0,0 +1,83 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "yaml" + +# These callers advance as a pair independently of the other generated workflows. +# Compare their behavior with the installed release's authoritative templates. +# This reads files only: it neither loads cpflow application code nor contacts APIs. +module CpflowReviewAppContract + VERSION = "5.3.0" + SHA = "b1e5ff4a04adfccfd8b59996e8abdbb5defb3fd6" + CALLERS = { "deploy" => "deploy", "delete" => "delete-review-app" }.freeze + + def self.check(root) + template_root = File.join(Gem::Specification.find_by_name("cpflow", VERSION).full_gem_path, + "lib/github_flow_templates") + errors = dependency_errors(root) + CALLERS.each do |operation, job_name| + path = ".github/workflows/cpflow-#{operation}-review-app.yml" + source = File.read(File.join(root, path)) + template = File.read(File.join(template_root, path)).sub("__CPFLOW_GITHUB_ACTIONS_REF__", SHA) + errors.concat(caller_errors(path, source, template, job_name)) + end + errors + rescue Psych::Exception, KeyError, TypeError, NoMethodError, Errno::ENOENT, Gem::LoadError => error + # Never echo configuration values or parser excerpts in validation output. + ["Missing or malformed review-app contract input (#{error.class})"] + end + + def self.dependency_errors(root) + errors = [] + gemfile = File.read(File.join(root, "Gemfile")) + lockfile = File.read(File.join(root, "Gemfile.lock")) + gem_pin = /^gem ["']cpflow["'], ["']#{Regexp.escape(VERSION)}["'], require: false$/ + errors << "Gemfile must pin local cpflow to #{VERSION}" unless gemfile.match?(gem_pin) + unless lockfile.scan(/^ cpflow \(([^)]+)\)$/).flatten == [VERSION] && + lockfile.scan(/^ cpflow \(= ([^)]+)\)$/).flatten == [VERSION] + errors << "Gemfile.lock must resolve and require cpflow #{VERSION}" + end + errors + end + + def self.caller_errors(path, source, template, job_name) + actual = YAML.safe_load(source) + expected = YAML.safe_load(template) + errors = [] + errors << "#{path}: top-level contract keys changed" unless actual.keys.sort_by(&:to_s) == + expected.keys.sort_by(&:to_s) + %w[name run-name permissions].each do |key| + errors << "#{path}: #{key} differs from release" unless actual[key] == expected[key] + end + + triggers = actual["on"] || actual[true] + expected_triggers = expected["on"] || expected[true] + unless triggers.dig("workflow_dispatch", "inputs") == expected_triggers.dig("workflow_dispatch", "inputs") + errors << "#{path}: dispatch inputs differ from release" + end + unless triggers.reject { |key, _| key == "workflow_dispatch" } == + expected_triggers.reject { |key, _| key == "workflow_dispatch" } + errors << "#{path}: event admission differs from release" + end + + jobs = actual.fetch("jobs") + expected_job = expected.fetch("jobs").fetch(job_name) + job = jobs[job_name] || {} + unless jobs.keys == [job_name] && job.keys.sort == expected_job.keys.sort + errors << "#{path}: job contract differs from release" + end + errors << "#{path}: job guard differs from release" unless job["if"] == expected_job["if"] + errors << "#{path}: expected released workflow ref" unless job["uses"] == expected_job["uses"] + errors << "#{path}: credential mapping differs from release" unless job["secrets"] == expected_job["secrets"] + pin_line = /^ uses: #{Regexp.escape(expected_job.fetch("uses"))} # v#{Regexp.escape(VERSION)}$/ + errors << "#{path}: expected SHA pin with release comment" unless source.match?(pin_line) + errors + end +end + +if $PROGRAM_NAME == __FILE__ + abort "Usage: ruby bin/check-cpflow-review-app-contract [repository-root]" if ARGV.length > 1 + errors = CpflowReviewAppContract.check(ARGV.fetch(0, File.expand_path("..", __dir__))) + abort errors.join("\n") unless errors.empty? + puts "review-app release contract: v#{CpflowReviewAppContract::VERSION}" +end diff --git a/bin/test-cpflow-github-flow b/bin/test-cpflow-github-flow index 3750e51c..acc495f3 100755 --- a/bin/test-cpflow-github-flow +++ b/bin/test-cpflow-github-flow @@ -4,13 +4,26 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT" +offline=false +if [[ "${1:-}" == "--offline" ]]; then + offline=true + shift +fi + cpflow_cmd=(cpflow) if [[ $# -gt 0 ]]; then cpflow_cmd=("$@") fi -echo "==> cpflow github-flow-readiness" -"${cpflow_cmd[@]}" github-flow-readiness +if [[ "$offline" == true ]]; then + echo "==> SKIP registry readiness (--offline)" +else + echo "==> cpflow github-flow-readiness" + "${cpflow_cmd[@]}" github-flow-readiness +fi + +echo "==> check released review-app caller contract" +ruby bin/check-cpflow-review-app-contract echo "==> check node renderer probe template" ruby <<'RUBY' @@ -132,13 +145,12 @@ PROMOTE_WORKFLOW = %r{\Ashakacode/control-plane-flow/\.github/workflows/cpflow-p EXPECTED_PROMOTE_WORKFLOW_REF_FORMAT = "shakacode/control-plane-flow/.github/workflows/cpflow-promote-staging-to-production.yml@vX.Y.Z" EXPECTED_CPFLOW_CHECKOUT_ACTION = "actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd" EXPECTED_CPFLOW_CHECKOUT_REPOSITORY = "shakacode/control-plane-flow" -REVIEW_APP_CANARY_PATH = ".github/workflows/cpflow-deploy-review-app.yml" -REVIEW_APP_CANARY_JOB = "deploy" -REVIEW_APP_CANARY_WORKFLOW = "shakacode/control-plane-flow/.github/workflows/cpflow-deploy-review-app.yml" -FULL_COMMIT_SHA = /\A[0-9a-f]{40}\z/i +REVIEW_APP_RELEASE_JOBS = { + ".github/workflows/cpflow-deploy-review-app.yml" => "deploy", + ".github/workflows/cpflow-delete-review-app.yml" => "delete-review-app", +}.freeze stable_refs = Hash.new { |hash, key| hash[key] = [] } -review_app_canary_entries = [] Dir[".github/workflows/cpflow-*.yml"].sort.each do |path| doc = YAML.load_file(path, aliases: true) @@ -163,16 +175,9 @@ Dir[".github/workflows/cpflow-*.yml"].sort.each do |path| uses_ref = uses_match[1] entry = "#{path}:#{job_name}" - if path == REVIEW_APP_CANARY_PATH && job_name == REVIEW_APP_CANARY_JOB - canary_workflow = job["uses"].to_s.delete_suffix("@#{uses_ref}") - unless canary_workflow == REVIEW_APP_CANARY_WORKFLOW - abort "#{entry} must call #{REVIEW_APP_CANARY_WORKFLOW}, got #{canary_workflow.inspect}" - end - - review_app_canary_entries << { ref: uses_ref, entry: entry } - else - stable_refs[uses_ref] << entry - end + # The released pair is checked independently above; other wrappers retain + # their existing stable cohort until a separately scoped migration. + stable_refs[uses_ref] << entry unless REVIEW_APP_RELEASE_JOBS[path] == job_name if job["uses"].to_s.match?(PROMOTE_WORKFLOW) abort "#{path}:#{job_name} must not call the cross-repo production reusable workflow; use a normal caller-repo job with environment: production" @@ -237,33 +242,16 @@ end stable_refs[setup_match[1]] << "#{promote_path}:promote-to-production setup" -unless review_app_canary_entries.length == 1 - abort "expected exactly one review-app canary entry at #{REVIEW_APP_CANARY_PATH}:#{REVIEW_APP_CANARY_JOB}, " \ - "found #{review_app_canary_entries.length}" -end - if stable_refs.empty? - abort "no stable upstream cpflow refs found outside the review-app canary" + abort "no stable upstream cpflow refs found outside the released review-app pair" elsif stable_refs.length > 1 stable_refs.each do |ref, paths| puts "#{ref}: #{paths.uniq.sort.join(', ')}" end - abort "non-canary cpflow workflow wrappers use multiple upstream refs: #{stable_refs.keys.sort.join(', ')}" + abort "non-review-app cpflow workflow wrappers use multiple upstream refs: #{stable_refs.keys.sort.join(', ')}" end -stable_ref = stable_refs.keys.fetch(0) -review_app_canary = review_app_canary_entries.fetch(0) -review_app_ref = review_app_canary.fetch(:ref) - -if review_app_ref == stable_ref - puts "cpflow refs: #{stable_ref}" -elsif review_app_ref.match?(FULL_COMMIT_SHA) - puts "cpflow stable ref: #{stable_ref}" - puts "review-app canary ref: #{review_app_ref} (#{review_app_canary.fetch(:entry)})" -else - abort "review-app canary may diverge from #{stable_ref} only with a full 40-character commit SHA, " \ - "got #{review_app_ref.inspect}" -end +puts "cpflow stable refs outside released review-app pair: #{stable_refs.keys.fetch(0)}" RUBY echo "==> actionlint" diff --git a/spec/cpflow_review_app_contract_spec.rb b/spec/cpflow_review_app_contract_spec.rb new file mode 100644 index 00000000..328cf508 --- /dev/null +++ b/spec/cpflow_review_app_contract_spec.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require "yaml" +require "open3" +require "rbconfig" +require "tmpdir" +require "fileutils" + +load File.expand_path("../bin/check-cpflow-review-app-contract", __dir__) + +RSpec.describe "Released cpflow review-app callers" do + let(:root) { File.expand_path("..", __dir__) } + let(:release_sha) { "b1e5ff4a04adfccfd8b59996e8abdbb5defb3fd6" } + + it "moves deploy and delete together to the released source" do + %w[deploy delete].each do |operation| + path = File.join(root, ".github/workflows/cpflow-#{operation}-review-app.yml") + workflow = YAML.safe_load(File.read(path)) + job = operation == "deploy" ? "deploy" : "delete-review-app" + + expect(workflow.fetch("jobs").fetch(job).fetch("uses")).to end_with("@#{release_sha}") + end + end + + it "validates the release from Ruby alone without external tools" do + output, status = Open3.capture2e({ "PATH" => "" }, RbConfig.ruby, + File.join(root, "bin/check-cpflow-review-app-contract")) + + expect(status.success?).to be(true), output + expect(output).to include("review-app release contract: v5.3.0") + end + + context "with a copied caller fixture" do + let(:fixture) { Dir.mktmpdir("cpflow-review-contract") } + + around do |example| + FileUtils.mkdir_p(File.join(fixture, ".github/workflows")) + %w[deploy delete].each do |operation| + path = ".github/workflows/cpflow-#{operation}-review-app.yml" + FileUtils.cp(File.join(root, path), File.join(fixture, path)) + end + %w[Gemfile Gemfile.lock].each { |path| FileUtils.cp(File.join(root, path), fixture) } + example.run + ensure + FileUtils.remove_entry_secure(fixture) + end + + it "rejects a missing authenticated redispatch input" do + change_fixture("deploy", " reconcile_intent_run_id:", " unrelated_input:") + + expect(CpflowReviewAppContract.check(fixture)).to include(a_string_including("dispatch inputs")) + end + + [ + ["a moving ref", "deploy", "b1e5ff4a04adfccfd8b59996e8abdbb5defb3fd6", "main", "released workflow ref"], + ["a mismatched pair", "delete", "b1e5ff4a04adfccfd8b59996e8abdbb5defb3fd6", "a" * 40, "released workflow ref"], + ["a stale release comment", "delete", "# v5.3.0", "# v5.2.0", "release comment"], + ["missing dispatch permission", "deploy", " actions: write\n", "", "permissions"], + ["missing deletion permission", "delete", " deployments: write\n", "", "permissions"], + ["excessive permissions", "deploy", " contents: read", " contents: write", "permissions"], + ["an incorrect run name", "delete", "Delete Review App - PR #", "Delete - PR #", "run-name"], + ["an incorrect job name", "delete", " delete-review-app:", " renamed:", "job contract"], + ["extra push admission", "deploy", "on:\n", "on:\n push:\n", "event admission"], + ["PR edited admission", "deploy", "[opened, synchronize, reopened]", "[opened, synchronize, reopened, edited]", + "event admission"], + ["a weakened source guard", "deploy", "head.repo.full_name == github.repository", + "head.repo.full_name != github.repository", "job guard"], + ["a broadened command guard", "delete", '"OWNER","MEMBER","COLLABORATOR"', + '"OWNER","MEMBER","COLLABORATOR","NONE"', "job guard"] + ].each do |description, operation, before, after, message| + it "rejects #{description}" do + change_fixture(operation, before, after) + + expect(CpflowReviewAppContract.check(fixture)).to include(a_string_including(message)) + end + end + + it "rejects an out-of-date local CLI dependency" do + path = File.join(fixture, "Gemfile") + File.write(path, File.read(path).sub('gem "cpflow", "5.3.0"', 'gem "cpflow", "5.2.0"')) + + expect(CpflowReviewAppContract.check(fixture)).to include(a_string_including("Gemfile")) + end + + it "rejects an out-of-date lockfile resolution" do + path = File.join(fixture, "Gemfile.lock") + File.write(path, File.read(path).sub(" cpflow (5.3.0)", " cpflow (5.2.0)")) + + expect(CpflowReviewAppContract.check(fixture)).to include(a_string_including("Gemfile.lock")) + end + + def change_fixture(operation, before, after) + path = File.join(fixture, ".github/workflows/cpflow-#{operation}-review-app.yml") + source = File.read(path) + raise "Fixture substitution did not match" unless source.include?(before) + + File.write(path, source.sub(before, after)) + end + end +end From 235563ec8cd5211e895753c5fc9e0c8582266dc0 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Fri, 4 Sep 2026 22:34:47 -1000 Subject: [PATCH 4/5] Pin workflow execution sources while preserving release contracts --- .agents/agent-workflow.yml | 9 + .../action.yml | 3 +- .../cpflow-setup-environment/action.yml | 15 +- .github/workflows/claude-code-review.yml | 6 +- .github/workflows/claude.yml | 6 +- .../cpflow-cleanup-stale-review-apps.yml | 2 +- .github/workflows/cpflow-deploy-staging.yml | 2 +- .github/workflows/cpflow-help-command.yml | 2 +- .../cpflow-promote-staging-to-production.yml | 21 ++- .github/workflows/cpflow-review-app-help.yml | 2 +- .github/workflows/demo-fleet-smoke.yml | 2 +- .github/workflows/js_test.yml | 11 +- .github/workflows/lint_test.yml | 11 +- .github/workflows/rspec_test.yml | 17 +- bin/test-cpflow-github-flow | 43 ++++- spec/cpflow_review_app_contract_spec.rb | 174 +++++++++++++++++- 16 files changed, 286 insertions(+), 40 deletions(-) diff --git a/.agents/agent-workflow.yml b/.agents/agent-workflow.yml index 5558c843..18ccd0e0 100644 --- a/.agents/agent-workflow.yml +++ b/.agents/agent-workflow.yml @@ -3,7 +3,16 @@ base_branch: master merge_submission: mode: direct trusted_actions: + - actions/checkout + - actions/github-script + - actions/setup-node + - anthropics/claude-code-action + - browser-actions/setup-chrome + - coactions/setup-xvfb + - docker/setup-buildx-action + - ruby/setup-ruby - shakacode/control-plane-flow + - shakacode/react_on_rails follow_up_prefix: 'Follow-up:' review_gate: n/a approval_exempt: n/a diff --git a/.github/actions/cpflow-delete-control-plane-app/action.yml b/.github/actions/cpflow-delete-control-plane-app/action.yml index 63981dd5..f011907f 100644 --- a/.github/actions/cpflow-delete-control-plane-app/action.yml +++ b/.github/actions/cpflow-delete-control-plane-app/action.yml @@ -17,8 +17,9 @@ runs: steps: - name: Delete application shell: bash - run: ${{ github.action_path }}/delete-app.sh + run: '"$ACTION_PATH/delete-app.sh"' env: + ACTION_PATH: ${{ github.action_path }} APP_NAME: ${{ inputs.app_name }} CPLN_ORG: ${{ inputs.cpln_org }} REVIEW_APP_PREFIX: ${{ inputs.review_app_prefix }} diff --git a/.github/actions/cpflow-setup-environment/action.yml b/.github/actions/cpflow-setup-environment/action.yml index 0ab65963..24a2f7b2 100644 --- a/.github/actions/cpflow-setup-environment/action.yml +++ b/.github/actions/cpflow-setup-environment/action.yml @@ -25,29 +25,32 @@ runs: using: composite steps: - name: Set up Ruby - uses: ruby/setup-ruby@v1 + uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0 with: ruby-version: ${{ inputs.ruby_version }} - name: Install Control Plane CLI and cpflow gem shell: bash + env: + CPLN_CLI_VERSION: ${{ inputs.cpln_cli_version }} + CPFLOW_VERSION: ${{ inputs.cpflow_version }} run: | set -euo pipefail - sudo npm install -g @controlplane/cli@${{ inputs.cpln_cli_version }} + sudo npm install -g "@controlplane/cli@$CPLN_CLI_VERSION" cpln --version - gem install cpflow -v ${{ inputs.cpflow_version }} + gem install cpflow -v "$CPFLOW_VERSION" cpflow --version - name: Setup Control Plane profile and registry login shell: bash + env: + TOKEN: ${{ inputs.token }} + ORG: ${{ inputs.org }} run: | set -euo pipefail - TOKEN="${{ inputs.token }}" - ORG="${{ inputs.org }}" - if [[ -z "$TOKEN" ]]; then echo "Error: Control Plane token not provided" >&2 exit 1 diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 3ea56cbb..701f9302 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -6,6 +6,7 @@ on: jobs: claude-review: + if: github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest permissions: contents: read @@ -15,13 +16,14 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: fetch-depth: 1 + persist-credentials: false - name: Run Claude Code Review id: claude-review - uses: anthropics/claude-code-action@v1 + uses: anthropics/claude-code-action@d75b94d5ad426cb8546e6628b6f5f19b84e5cce1 # v1.0.216 env: CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} with: diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 468fa108..1ad9fe8e 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -119,13 +119,14 @@ jobs: statuses: read steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: fetch-depth: 1 + persist-credentials: false - name: Run Claude Code id: claude - uses: anthropics/claude-code-action@v1 + uses: anthropics/claude-code-action@d75b94d5ad426cb8546e6628b6f5f19b84e5cce1 # v1.0.216 env: CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} with: @@ -145,4 +146,3 @@ jobs: # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://code.claude.com/docs/en/cli-reference for available options # claude_args: '--allowed-tools Bash(gh pr:*)' - diff --git a/.github/workflows/cpflow-cleanup-stale-review-apps.yml b/.github/workflows/cpflow-cleanup-stale-review-apps.yml index f4d9504a..01a966d1 100644 --- a/.github/workflows/cpflow-cleanup-stale-review-apps.yml +++ b/.github/workflows/cpflow-cleanup-stale-review-apps.yml @@ -12,6 +12,6 @@ jobs: cleanup: # Cleanup targets the current inferred review-app prefix. If you changed # naming conventions, manually delete review apps under the old prefix. - uses: shakacode/control-plane-flow/.github/workflows/cpflow-cleanup-stale-review-apps.yml@v5.2.0 + uses: shakacode/control-plane-flow/.github/workflows/cpflow-cleanup-stale-review-apps.yml@1d1ec7f7af181c5c6cf07f512ce336dbdb367246 # v5.2.0 secrets: CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }} diff --git a/.github/workflows/cpflow-deploy-staging.yml b/.github/workflows/cpflow-deploy-staging.yml index 13e2c644..bb34457f 100644 --- a/.github/workflows/cpflow-deploy-staging.yml +++ b/.github/workflows/cpflow-deploy-staging.yml @@ -16,7 +16,7 @@ permissions: jobs: deploy-staging: - uses: shakacode/control-plane-flow/.github/workflows/cpflow-deploy-staging.yml@v5.2.0 + uses: shakacode/control-plane-flow/.github/workflows/cpflow-deploy-staging.yml@1d1ec7f7af181c5c6cf07f512ce336dbdb367246 # v5.2.0 with: staging_app_branch_default: "master" secrets: diff --git a/.github/workflows/cpflow-help-command.yml b/.github/workflows/cpflow-help-command.yml index a1004606..16e2955a 100644 --- a/.github/workflows/cpflow-help-command.yml +++ b/.github/workflows/cpflow-help-command.yml @@ -23,4 +23,4 @@ jobs: contains(fromJson('["+review-app-help","+review-app-help\n","+review-app-help\r\n"]'), github.event.comment.body) && contains(fromJson('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)) || github.event_name == 'workflow_dispatch' - uses: shakacode/control-plane-flow/.github/workflows/cpflow-help-command.yml@v5.2.0 + uses: shakacode/control-plane-flow/.github/workflows/cpflow-help-command.yml@1d1ec7f7af181c5c6cf07f512ce336dbdb367246 # v5.2.0 diff --git a/.github/workflows/cpflow-promote-staging-to-production.yml b/.github/workflows/cpflow-promote-staging-to-production.yml index 1929f0a1..0698b4dc 100644 --- a/.github/workflows/cpflow-promote-staging-to-production.yml +++ b/.github/workflows/cpflow-promote-staging-to-production.yml @@ -61,15 +61,15 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Checkout control-plane-flow actions - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: shakacode/control-plane-flow - ref: v5.2.0 + ref: 1d1ec7f7af181c5c6cf07f512ce336dbdb367246 # v5.2.0 path: .cpflow persist-credentials: false @@ -90,7 +90,7 @@ jobs: fi - name: Validate required secrets and variables - uses: ./.cpflow/.github/actions/cpflow-validate-config + uses: shakacode/control-plane-flow/.github/actions/cpflow-validate-config@1d1ec7f7af181c5c6cf07f512ce336dbdb367246 # v5.2.0 # Pass secrets via env so the composite action checks indirect shell # variables instead of interpolating secret values into a run script. env: @@ -171,7 +171,10 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Setup production environment - uses: ./.cpflow/.github/actions/cpflow-setup-environment + uses: shakacode/control-plane-flow/.github/actions/cpflow-setup-environment@1d1ec7f7af181c5c6cf07f512ce336dbdb367246 # v5.2.0 + env: + # The v5.2 gemspec needs matching checkout metadata to package its action archive. + GIT_DIR: ${{ github.workspace }}/.cpflow/.git with: token: ${{ secrets.CPLN_TOKEN_PRODUCTION }} org: ${{ steps.cpln-orgs.outputs.production }} @@ -179,7 +182,7 @@ jobs: cpln_cli_version: ${{ vars.CPLN_CLI_VERSION }} cpflow_version: ${{ vars.CPFLOW_VERSION }} # The setup action validates CPFLOW_VERSION against this full workflow ref. - control_plane_flow_ref: shakacode/control-plane-flow/.github/workflows/cpflow-promote-staging-to-production.yml@v5.2.0 + control_plane_flow_ref: shakacode/control-plane-flow/.github/workflows/cpflow-promote-staging-to-production.yml@1d1ec7f7af181c5c6cf07f512ce336dbdb367246 # Runs after Setup production environment so the pinned Ruby (>= 3.1) is on PATH. # YAML.load_file(..., aliases: true) is not supported on Ruby 3.0 (system Ruby on ubuntu-22.04). @@ -237,7 +240,7 @@ jobs: - name: Detect release phase support id: release-phase - uses: ./.cpflow/.github/actions/cpflow-detect-release-phase + uses: shakacode/control-plane-flow/.github/actions/cpflow-detect-release-phase@1d1ec7f7af181c5c6cf07f512ce336dbdb367246 # v5.2.0 with: app_name: ${{ vars.PRODUCTION_APP_NAME }} @@ -441,7 +444,7 @@ jobs: echo "image=${staging_image}" >> "$GITHUB_OUTPUT" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - name: Copy image from staging id: copy-image @@ -589,7 +592,7 @@ jobs: - name: Wait for deployment health id: health-check - uses: ./.cpflow/.github/actions/cpflow-wait-for-health + uses: shakacode/control-plane-flow/.github/actions/cpflow-wait-for-health@1d1ec7f7af181c5c6cf07f512ce336dbdb367246 # v5.2.0 with: workload_name: ${{ steps.workloads.outputs.primary }} app_name: ${{ vars.PRODUCTION_APP_NAME }} diff --git a/.github/workflows/cpflow-review-app-help.yml b/.github/workflows/cpflow-review-app-help.yml index c844dad4..e8bec4a8 100644 --- a/.github/workflows/cpflow-review-app-help.yml +++ b/.github/workflows/cpflow-review-app-help.yml @@ -18,4 +18,4 @@ jobs: # to PR-open help. Remove it, or uncomment and adapt this guard, if forks or # clones should stay quiet until Control Plane is configured: # if: vars.REVIEW_APP_PREFIX != '' || vars.CPLN_ORG_STAGING != '' - uses: shakacode/control-plane-flow/.github/workflows/cpflow-review-app-help.yml@v5.2.0 + uses: shakacode/control-plane-flow/.github/workflows/cpflow-review-app-help.yml@1d1ec7f7af181c5c6cf07f512ce336dbdb367246 # v5.2.0 diff --git a/.github/workflows/demo-fleet-smoke.yml b/.github/workflows/demo-fleet-smoke.yml index 8174ec7f..3c40298c 100644 --- a/.github/workflows/demo-fleet-smoke.yml +++ b/.github/workflows/demo-fleet-smoke.yml @@ -11,7 +11,7 @@ on: jobs: smoke: - uses: shakacode/react_on_rails/.github/workflows/demo-fleet-smoke.yml@613c6c2a251a782328b26d68e7f23daa9b677497 + uses: shakacode/react_on_rails/.github/workflows/demo-fleet-smoke.yml@613c6c2a251a782328b26d68e7f23daa9b677497 # Source snapshot 2026-07-18 with: ruby-version: "3.4.6" node-version: "22" diff --git a/.github/workflows/js_test.yml b/.github/workflows/js_test.yml index 46b6267c..df15837d 100644 --- a/.github/workflows/js_test.yml +++ b/.github/workflows/js_test.yml @@ -6,6 +6,9 @@ on: pull_request: branches: [ "master" ] +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest @@ -23,15 +26,17 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@a37ce9120846195fa4ece8f58b268e6043cb2f26 # v3.7.0 + with: + persist-credentials: false - - uses: ruby/setup-ruby@v1 + - uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0 with: ruby-version: ${{ matrix.ruby }} bundler-cache: true - name: Use Node.js ${{ matrix.node }} - uses: actions/setup-node@v3 + uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 # v3.9.1 with: node-version: ${{ matrix.node }} cache: yarn diff --git a/.github/workflows/lint_test.yml b/.github/workflows/lint_test.yml index 9623e38e..e2aefab3 100644 --- a/.github/workflows/lint_test.yml +++ b/.github/workflows/lint_test.yml @@ -6,6 +6,9 @@ on: pull_request: branches: [ "master" ] +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest @@ -22,15 +25,17 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@a37ce9120846195fa4ece8f58b268e6043cb2f26 # v3.7.0 + with: + persist-credentials: false - - uses: ruby/setup-ruby@v1 + - uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0 with: ruby-version: ${{ matrix.ruby }} bundler-cache: true - name: Use Node.js ${{ matrix.node }} - uses: actions/setup-node@v3 + uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 # v3.9.1 with: node-version: ${{ matrix.node }} cache: yarn diff --git a/.github/workflows/rspec_test.yml b/.github/workflows/rspec_test.yml index 83a67698..e744d284 100644 --- a/.github/workflows/rspec_test.yml +++ b/.github/workflows/rspec_test.yml @@ -6,6 +6,9 @@ on: pull_request: branches: [ "master" ] +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest @@ -39,7 +42,7 @@ jobs: steps: - name: Install Chrome - uses: browser-actions/setup-chrome@latest + uses: browser-actions/setup-chrome@48ad923757ca74d66703209fe939badbdf80f2f4 # v2.2.0 - name: Check Chrome version run: chrome --version @@ -51,16 +54,18 @@ jobs: run: "export DISPLAY=:99" - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@a37ce9120846195fa4ece8f58b268e6043cb2f26 # v3.7.0 + with: + persist-credentials: false - name: Use Ruby ${{ matrix.ruby }} - uses: ruby/setup-ruby@v1 + uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0 with: ruby-version: ${{ matrix.ruby }} bundler-cache: true - name: Use Node.js ${{ matrix.node }} - uses: actions/setup-node@v3 + uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 # v3.9.1 with: node-version: ${{ matrix.node }} cache: yarn @@ -90,7 +95,7 @@ jobs: node renderer/node-renderer.js & RENDERER_PID=$! echo "Waiting for Node renderer (PID $RENDERER_PID) on port 3800..." - for i in $(seq 1 30); do + for _attempt in $(seq 1 30); do if ! kill -0 $RENDERER_PID 2>/dev/null; then echo "Node renderer process exited unexpectedly (see output above)." exit 1 @@ -105,7 +110,7 @@ jobs: exit 1 - name: Run rspec with xvfb - uses: coactions/setup-xvfb@v1 + uses: coactions/setup-xvfb@b6b4fcfb9f5a895edadc3bc76318fae0ac17c8b3 # v1.0.1 with: run: bundle exec rake ci:rspec working-directory: ./ #optional diff --git a/bin/test-cpflow-github-flow b/bin/test-cpflow-github-flow index acc495f3..37f8357b 100755 --- a/bin/test-cpflow-github-flow +++ b/bin/test-cpflow-github-flow @@ -142,9 +142,16 @@ require "yaml" CONTROL_PLANE_FLOW_WORKFLOW = %r{\Ashakacode/control-plane-flow/\.github/workflows/[^@\s]+@([^\s]+)\z} PROMOTE_WORKFLOW = %r{\Ashakacode/control-plane-flow/\.github/workflows/cpflow-promote-staging-to-production\.yml@([^\s]+)\z} -EXPECTED_PROMOTE_WORKFLOW_REF_FORMAT = "shakacode/control-plane-flow/.github/workflows/cpflow-promote-staging-to-production.yml@vX.Y.Z" +EXPECTED_STABLE_REF = "1d1ec7f7af181c5c6cf07f512ce336dbdb367246" # v5.2.0 +EXPECTED_PROMOTE_WORKFLOW_REF_FORMAT = "shakacode/control-plane-flow/.github/workflows/cpflow-promote-staging-to-production.yml@#{EXPECTED_STABLE_REF}" EXPECTED_CPFLOW_CHECKOUT_ACTION = "actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd" EXPECTED_CPFLOW_CHECKOUT_REPOSITORY = "shakacode/control-plane-flow" +EXPECTED_PROMOTE_ACTIONS = %w[ + cpflow-validate-config + cpflow-setup-environment + cpflow-detect-release-phase + cpflow-wait-for-health +].freeze REVIEW_APP_RELEASE_JOBS = { ".github/workflows/cpflow-deploy-review-app.yml" => "deploy", ".github/workflows/cpflow-delete-review-app.yml" => "delete-review-app", @@ -224,8 +231,23 @@ if checkout_ref.to_s.strip.empty? abort "#{promote_path}:promote-to-production must pin the Checkout control-plane-flow actions step" end +unless checkout_with["path"] == ".cpflow" + abort "#{promote_path}:promote-to-production must preserve the .cpflow source checkout" +end + stable_refs[checkout_ref] << "#{promote_path}:promote-to-production" +expected_action_refs = EXPECTED_PROMOTE_ACTIONS.map do |name| + "#{EXPECTED_CPFLOW_CHECKOUT_REPOSITORY}/.github/actions/#{name}@#{EXPECTED_STABLE_REF}" +end +actual_action_refs = Array(promote_job["steps"]).filter_map do |step| + ref = step["uses"].to_s + ref if ref.include?("/cpflow-") +end +unless actual_action_refs == expected_action_refs + abort "#{promote_path}:promote-to-production must use the four immutable v5.2.0 actions in order" +end + setup_step = Array(promote_job["steps"]).find { |step| step["name"] == "Setup production environment" } unless setup_step @@ -235,6 +257,23 @@ end setup_ref = setup_step.fetch("with", {})["control_plane_flow_ref"] setup_match = setup_ref.to_s.match(PROMOTE_WORKFLOW) +unless setup_step.fetch("with", {})["working_directory"] == ".cpflow" + abort "#{promote_path}:promote-to-production must install from the .cpflow source checkout" +end + +expected_git_dir = "${{ github.workspace }}/.cpflow/.git" +unless setup_step.fetch("env", {})["GIT_DIR"] == expected_git_dir + abort "#{promote_path}:promote-to-production setup must use matching checkout metadata for archive packaging" +end + +if promote_doc.fetch("env", {}).key?("GIT_DIR") || + promote_doc.fetch("jobs", {}).values.any? { |job| job.fetch("env", {}).key?("GIT_DIR") } || + promote_doc.fetch("jobs", {}).values.flat_map { |job| Array(job["steps"]) }.any? do |step| + !step.equal?(setup_step) && step.fetch("env", {}).key?("GIT_DIR") + end + abort "#{promote_path}:GIT_DIR must be scoped only to the setup step" +end + unless setup_match abort "#{promote_path}:promote-to-production must pass a pinned production control_plane_flow_ref to setup, " \ "for example #{EXPECTED_PROMOTE_WORKFLOW_REF_FORMAT}" @@ -249,6 +288,8 @@ elsif stable_refs.length > 1 puts "#{ref}: #{paths.uniq.sort.join(', ')}" end abort "non-review-app cpflow workflow wrappers use multiple upstream refs: #{stable_refs.keys.sort.join(', ')}" +elsif stable_refs.keys.fetch(0) != EXPECTED_STABLE_REF + abort "non-review-app cpflow sources must retain the immutable v5.2.0 release" end puts "cpflow stable refs outside released review-app pair: #{stable_refs.keys.fetch(0)}" diff --git a/spec/cpflow_review_app_contract_spec.rb b/spec/cpflow_review_app_contract_spec.rb index 328cf508..665748ba 100644 --- a/spec/cpflow_review_app_contract_spec.rb +++ b/spec/cpflow_review_app_contract_spec.rb @@ -8,7 +8,7 @@ load File.expand_path("../bin/check-cpflow-review-app-contract", __dir__) -RSpec.describe "Released cpflow review-app callers" do +RSpec.describe "Workflow release and security contracts" do let(:root) { File.expand_path("..", __dir__) } let(:release_sha) { "b1e5ff4a04adfccfd8b59996e8abdbb5defb3fd6" } @@ -97,4 +97,176 @@ def change_fixture(operation, before, after) File.write(path, source.sub(before, after)) end end + + context "with pinned workflow security boundaries" do + let(:stable_sha) { "1d1ec7f7af181c5c6cf07f512ce336dbdb367246" } + let(:upstream) { "shakacode/control-plane-flow" } + + def workflow(name) + YAML.safe_load(File.read(File.join(root, ".github/workflows/#{name}.yml"))) + end + + it "keeps the older reusable workflow cohort on immutable v5.2.0" do + %w[cleanup-stale-review-apps deploy-staging help-command review-app-help].each do |name| + jobs = workflow("cpflow-#{name}").fetch("jobs") + + expect(jobs.values.map { |job| job.fetch("uses") }).to eq( + ["#{upstream}/.github/workflows/cpflow-#{name}.yml@#{stable_sha}"] + ) + end + end + + it "keeps promotion actions and their source checkout on the same immutable release" do + job = workflow("cpflow-promote-staging-to-production").fetch("jobs").fetch("promote-to-production") + steps = job.fetch("steps") + actions = steps.filter_map { |step| step["uses"] if step["uses"].to_s.include?("/cpflow-") } + names = %w[cpflow-validate-config cpflow-setup-environment cpflow-detect-release-phase cpflow-wait-for-health] + + expect(job.fetch("environment")).to eq("production") + expect(actions).to eq(names.map { |name| "#{upstream}/.github/actions/#{name}@#{stable_sha}" }) + checkout = steps.find { |step| step.fetch("with", {})["repository"] == upstream } + expect(checkout.fetch("with")).to include("ref" => stable_sha, "path" => ".cpflow") + setup = steps.find { |step| step["uses"].to_s.include?("/cpflow-setup-environment@") } + setup_ref = "#{upstream}/.github/workflows/cpflow-promote-staging-to-production.yml@#{stable_sha}" + expect(setup.fetch("with")).to include( + "working_directory" => ".cpflow", + "control_plane_flow_ref" => setup_ref + ) + end + + it "keeps fork test jobs read-only" do + %w[js_test lint_test rspec_test].each do |name| + doc = workflow(name) + + expect(doc.fetch("permissions")).to eq("contents" => "read") + expect(doc.fetch("jobs").values).to all(satisfy { |job| !job.key?("permissions") }) + end + end + + it "scopes matching checkout metadata to the archive-packaging step only" do + doc = workflow("cpflow-promote-staging-to-production") + jobs = doc.fetch("jobs").values + steps = jobs.flat_map { |job| job.fetch("steps", []) } + setup = steps.find { |step| step["uses"].to_s.include?("/cpflow-setup-environment@") } + + expect(setup.fetch("env")).to eq("GIT_DIR" => "${{ github.workspace }}/.cpflow/.git") + expect(doc.fetch("env", {})).not_to have_key("GIT_DIR") + expect(jobs).to all(satisfy { |job| !job.fetch("env", {}).key?("GIT_DIR") }) + expect(steps.reject { |step| step.equal?(setup) }) + .to all(satisfy { |step| !step.fetch("env", {}).key?("GIT_DIR") }) + end + + it "does not persist checkout credentials in no-push jobs" do + %w[js_test lint_test rspec_test claude claude-code-review cpflow-promote-staging-to-production].each do |name| + steps = workflow(name).fetch("jobs").values.flat_map { |job| job.fetch("steps", []) } + checkouts = steps.select { |step| step["uses"].to_s.start_with?("actions/checkout@") } + + expect(checkouts).not_to be_empty + expect(checkouts).to all(satisfy { |step| step.fetch("with").fetch("persist-credentials") == false }) + end + end + + it "admits only same-repository PRs to the credentialed review job" do + job = workflow("claude-code-review").fetch("jobs").fetch("claude-review") + + expect(job.fetch("if")).to eq("github.event.pull_request.head.repo.full_name == github.repository") + end + + context "with isolated composite action command stubs" do + let(:fixture) { Dir.mktmpdir("workflow-shell-inputs") } + let(:setup_steps) do + YAML.safe_load(File.read(File.join(root, ".github/actions/cpflow-setup-environment/action.yml"))) + .fetch("runs").fetch("steps") + end + let(:hostile_input) { 'value with spaces; $(printf injected > "$FIXTURE_ROOT/injected") " end' } + + around do |example| + %w[sudo npm gem cpln cpflow].each do |command| + path = File.join(fixture, command) + script = <<~'SHELL' + #!/bin/bash + set -eu + printf '%s\0' "$@" >> "$FIXTURE_ROOT/COMMAND_NAME.args" + if [[ "COMMAND_NAME" == sudo ]]; then + exec "$@" + fi + SHELL + File.write(path, script.gsub("COMMAND_NAME", command)) + FileUtils.chmod(0o700, path) + end + example.run + ensure + FileUtils.remove_entry_secure(fixture) + end + + def run_step(step, inputs = {}) + bindings = step.fetch("env", {}).transform_values do |expression| + match = /\A\$\{\{ inputs\.([a-z_]+) \}\}\z/.match(expression) + raise "Unexpected test input binding" unless match + + inputs.fetch(match[1]) + end + env = { "PATH" => fixture, "FIXTURE_ROOT" => fixture }.merge(bindings) + _output, status = Open3.capture2e(env, "/bin/bash", "-c", step.fetch("run"), + chdir: fixture, unsetenv_others: true) + expect(File).not_to exist(File.join(fixture, "injected")) + status + end + + def arguments_for(command) + File.binread(File.join(fixture, "#{command}.args")).split("\0") + end + + it "passes version inputs as literal single arguments without shell evaluation" do + step = setup_steps.fetch(1) + + status = run_step(step, "cpln_cli_version" => hostile_input, "cpflow_version" => hostile_input) + + expect(status.success?).to be(true) + expect(arguments_for("npm")).to eq(["install", "-g", "@controlplane/cli@#{hostile_input}"]) + expect(arguments_for("gem")).to eq(["install", "cpflow", "-v", hostile_input]) + expect(arguments_for("cpln")).to eq(["--version"]) + expect(arguments_for("cpflow")).to eq(["--version"]) + end + + it "passes profile inputs literally without shell evaluation" do + expect(run_step(setup_steps.fetch(2), "token" => hostile_input, "org" => hostile_input).success?).to be(true) + expected_arguments = [ + "profile", "create", "default", "--token", hostile_input, "--org", hostile_input, + "profile", "update", "default", "--org", hostile_input, "--token", hostile_input, + "image", "docker-login", "--org", hostile_input + ] + expect(arguments_for("cpln")).to eq(expected_arguments) + end + + %w[token org].each do |missing_input| + it "rejects an empty #{missing_input} input before invoking a command" do + inputs = { "token" => "fixture-value", "org" => "fixture-value", missing_input => "" } + + expect(run_step(setup_steps.fetch(2), inputs).success?).to be(false) + expect(Dir[File.join(fixture, "*.args")]).to be_empty + end + end + + it "quotes the action path before executing the delete script" do + action = YAML.safe_load(File.read(File.join(root, + ".github/actions/cpflow-delete-control-plane-app/action.yml"))) + step = action.fetch("runs").fetch("steps").fetch(0) + action_path = File.join(fixture, "action $(printf injected)") + FileUtils.mkdir_p(action_path) + script = File.join(action_path, "delete-app.sh") + File.write(script, "#!/bin/bash\nprintf called > \"$FIXTURE_ROOT/called\"\n") + FileUtils.chmod(0o700, script) + + expect(step.fetch("env").fetch("ACTION_PATH")).to eq("${{ github.action_path }}") + env = { "PATH" => fixture, "FIXTURE_ROOT" => fixture, "ACTION_PATH" => action_path } + _output, status = Open3.capture2e(env, "/bin/bash", "-c", step.fetch("run"), + chdir: fixture, unsetenv_others: true) + + expect(status.success?).to be(true) + expect(File.read(File.join(fixture, "called"))).to eq("called") + expect(File).not_to exist(File.join(fixture, "injected")) + end + end + end end From 6377676c74ba314296fb5eeef1eb39422a6ffce5 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Fri, 4 Sep 2026 23:04:23 -1000 Subject: [PATCH 5/5] Document the intentional split between workflow release cohorts --- .controlplane/readme.md | 66 +++++++++++++++++++++++++---------------- .github/cpflow-help.md | 62 +++++++++++++++++++------------------- 2 files changed, 70 insertions(+), 58 deletions(-) diff --git a/.controlplane/readme.md b/.controlplane/readme.md index 9586eec9..99679694 100644 --- a/.controlplane/readme.md +++ b/.controlplane/readme.md @@ -630,33 +630,47 @@ React on Rails docs reference: Keep the reusable-workflow mechanics in the upstream [`control-plane-flow` CI automation guide](https://github.com/shakacode/control-plane-flow/blob/v5.2.0/docs/ci-automation.md). -For this repo, the update loop is: - -1. Update the bundled `cpflow` gem to the desired release. -2. Refresh generated wrappers from that release with `--staging-branch master`. -3. Keep generated refs on the same release tag as the bundled `cpflow` gem. - This branch pins refs to `v5.2.0`, which includes upstream promotion - hardening and the release-runner timeout fix. Use a full commit SHA only for - short-lived upstream testing and leave `CPFLOW_VERSION` unset in that case. -4. Keep app names and GitHub settings aligned with `.controlplane/controlplane.yml`. -5. Validate locally: - -```bash -bin/conductor-exec bundle update cpflow -bin/conductor-exec bundle exec cpflow update-github-actions --staging-branch master -bin/conductor-exec bin/test-cpflow-github-flow bundle exec cpflow -``` - -Then open a normal PR, wait for GitHub Actions, and test a real review-app -deploy. Comment-triggered workflows run from `master`; for PR-branch workflow -edits, dispatch the workflow explicitly: +This repo deliberately keeps two release cohorts: + +- The review-app deploy/delete callers pin cpflow `v5.3.0` at + `b1e5ff4a04adfccfd8b59996e8abdbb5defb3fd6`. The bundled `cpflow` gem and + lockfile use `5.3.0`. +- Staging, cleanup, help, and promotion retain `v5.2.0` at + `1d1ec7f7af181c5c6cf07f512ce336dbdb367246`. Their migration is separate + scope, not an automatic consequence of updating the local gem. + +Full commit SHAs are the normal release contract, not just a testing option. +Leave `CPFLOW_VERSION` unset: SHA-pinned workflows build the CLI from their +pinned source, and the runtime version override requires a release-tag ref. +Promotion must keep its action refs, `.cpflow` checkout, and setup provenance +on the same `v5.2.0` SHA. Its setup-only `GIT_DIR` binding supplies that +checkout's metadata when packaging the downloaded action source. + +Do not run blanket `cpflow update-github-actions` regeneration or +`bin/pin-cpflow-github-ref` for this split. They can overwrite the separate +cohorts and caller customizations. For a future review-app upgrade, update both +callers, the local gem and lockfile, validator release constants, and fixture +tests together in a reviewed PR. Preserve the renderer assertions and review +changes to other cohorts separately. Keep the staging branch `master` and +the settings in `.controlplane/controlplane.yml` intact. + +After installing the pinned dependencies, run these local checks: ```bash -gh workflow run cpflow-deploy-review-app.yml --ref -f pr_number= +bin/conductor-exec bundle check +bin/conductor-exec ruby bin/check-cpflow-review-app-contract +bin/conductor-exec bundle exec rspec spec/cpflow_review_app_contract_spec.rb +bin/conductor-exec bin/test-cpflow-github-flow --offline ``` -This loads the workflow file from ``, but trusted local composite -actions still come from the default branch before secrets are used. Treat it as -a partial smoke test, then verify a real deploy after the workflow changes land -on `master`. See the short -[testing checklist](docs/testing-cpflow-github-actions.md) for the canary steps. +These checks do not deploy or prove deployment readiness. The full helper needs +`actionlint`; ordinary contract specs need only Ruby and the bundle. See the +[testing checklist and split-migration exception](docs/testing-cpflow-github-actions.md#released-review-app-pair). + +Deployment needs separate, explicit authorization. Do not treat publication or +a green local check as permission to dispatch a workflow or retry a deployment. +A push can itself deploy an existing app or honor an accepted deployment +intent. Follow the checklist's publication guard before pushing under a +no-deployment constraint. Comment-triggered runs and cross-operation handoffs +use default-branch wrappers, so a branch-only smoke test does not prove the +complete deploy/delete flow. diff --git a/.github/cpflow-help.md b/.github/cpflow-help.md index a66b58ef..dceebf06 100644 --- a/.github/cpflow-help.md +++ b/.github/cpflow-help.md @@ -129,42 +129,40 @@ production org, using production-only secrets and values. ## Version Locking -Generated wrappers pin Control Plane Flow with a release tag, for example -`v5.2.0`. Reusable review-app, staging, cleanup, and -helper workflows pin the tag in their `uses:` ref. Production promotion pins -the same tag in the `Checkout control-plane-flow actions` step so the -caller-owned job can keep `environment: production` and receive production -environment secrets directly. - -Leave `CPFLOW_VERSION` unset so the workflow builds cpflow from the same -checked-out upstream source. If you set `CPFLOW_VERSION`, it must match the -release tag your wrappers are pinned to: a `CPFLOW_VERSION=5.2.x` runtime -override goes with a wrapper pinned to `uses: ...@v5.2.x` (substitute the -release you pinned above). - -After updating the `cpflow` gem in this repo, update the generated wrappers in -the same PR: +This repo uses immutable release SHAs and deliberately separates two cohorts: + +- Review-app deploy/delete callers: `v5.3.0` at + `b1e5ff4a04adfccfd8b59996e8abdbb5defb3fd6`, with the local `cpflow` gem + and lockfile on `5.3.0`. Upgrade the two callers together. +- Staging, cleanup, help, and promotion: `v5.2.0` at + `1d1ec7f7af181c5c6cf07f512ce336dbdb367246`. Keep this cohort unchanged + unless its migration is separately reviewed. + +Leave `CPFLOW_VERSION` unset. Each SHA-pinned workflow builds cpflow from its +own pinned upstream source; a runtime version override requires a release-tag +ref and is incompatible with these SHAs. Promotion keeps its action refs, +`.cpflow` source checkout, and setup provenance on the same `v5.2.0` SHA. +The setup-only `GIT_DIR` binding provides matching checkout metadata for +packaging the action archive. Preserve the caller-owned +`environment: production` job. + +Do not run blanket `cpflow update-github-actions` regeneration or +`bin/pin-cpflow-github-ref` over this split. A review-app upgrade must update +both callers, the local dependency and lockfile, validator release constants, +and fixture tests together. See the +[split-migration exception and local checks](../.controlplane/docs/testing-cpflow-github-actions.md#released-review-app-pair). + +After installing the pinned dependencies, validate locally without deploying: ```sh -cpflow update-github-actions -bin/test-cpflow-github-flow +bin/conductor-exec ruby bin/check-cpflow-review-app-contract +bin/conductor-exec bundle exec rspec spec/cpflow_review_app_contract_spec.rb +bin/conductor-exec bin/test-cpflow-github-flow --offline ``` -If `cpflow` is bundled by the app, use: - -```sh -bundle exec cpflow update-github-actions -bin/test-cpflow-github-flow bundle exec cpflow -``` - -Do not leave downstream apps pinned to a moving branch such as `main`. For a -short-lived test of an unreleased upstream PR, pin to a full 40-character commit -SHA and leave `CPFLOW_VERSION` unset: - -```sh -bin/pin-cpflow-github-ref <40-character-control-plane-flow-commit-sha> -bin/test-cpflow-github-flow ruby /path/to/control-plane-flow/bin/cpflow -``` +Deployment, manual dispatch, and deployment retries need explicit authorization. +A push can also deploy; follow the testing checklist's publication guard under +any no-deployment constraint. Local validation does not authorize a canary. ## Advanced Variables