cpflow 5.3.0 → 6.0.0.rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/agent-workflow.yml +13 -0
  3. data/.agents/bin/README.md +12 -1
  4. data/.agents/bin/docs +1 -1
  5. data/.agents/bin/lint +1 -1
  6. data/.agents/bin/setup +1 -1
  7. data/.agents/bin/test +1 -1
  8. data/.agents/bin/validate +2 -1
  9. data/.coderabbit.yaml +13 -0
  10. data/.github/actions/cpflow-delete-control-plane-app/action.yml +2 -1
  11. data/.github/actions/cpflow-setup-environment/action.yml +8 -6
  12. data/.github/dependabot.yml +10 -0
  13. data/.github/workflows/check_cpln_links.yml +6 -1
  14. data/.github/workflows/claude-code-review.yml +5 -2
  15. data/.github/workflows/claude.yml +6 -4
  16. data/.github/workflows/coderabbit-lifecycle-review.yml +29 -0
  17. data/.github/workflows/command_docs.yml +7 -2
  18. data/.github/workflows/cpflow-cleanup-stale-review-apps.yml +12 -5
  19. data/.github/workflows/cpflow-delete-review-app.yml +18 -11
  20. data/.github/workflows/cpflow-deploy-review-app.yml +25 -16
  21. data/.github/workflows/cpflow-deploy-staging.yml +16 -10
  22. data/.github/workflows/cpflow-help-command.yml +3 -3
  23. data/.github/workflows/cpflow-promote-staging-to-production.yml +7 -7
  24. data/.github/workflows/cpflow-review-app-help.yml +1 -1
  25. data/.github/workflows/rspec-shared.yml +34 -26
  26. data/.github/workflows/rspec-specific.yml +10 -2
  27. data/.github/workflows/rspec.yml +71 -4
  28. data/.github/workflows/rubocop.yml +9 -2
  29. data/.github/workflows/trigger-docs-site.yml +2 -0
  30. data/.rubocop.yml +6 -1
  31. data/CHANGELOG.md +24 -1
  32. data/CONTRIBUTING.md +39 -2
  33. data/Gemfile.lock +1 -1
  34. data/README.md +6 -1
  35. data/cpflow.gemspec +3 -15
  36. data/docs/ai-github-flow-prompt.md +2 -2
  37. data/docs/ci-automation.md +112 -60
  38. data/docs/commands.md +18 -8
  39. data/docs/rds-private-networking.md +12 -12
  40. data/docs/releasing.md +15 -4
  41. data/examples/controlplane.yml +5 -0
  42. data/lib/command/ai_github_flow_prompt.rb +1 -1
  43. data/lib/command/cleanup_stale_apps.rb +28 -4
  44. data/lib/command/copy_image_from_upstream.rb +3 -0
  45. data/lib/command/deploy_image.rb +38 -1
  46. data/lib/command/generate_github_actions.rb +36 -12
  47. data/lib/command/run.rb +224 -34
  48. data/lib/command/update_github_actions.rb +7 -6
  49. data/lib/core/controlplane.rb +145 -78
  50. data/lib/core/shell.rb +35 -10
  51. data/lib/core/timed_command.rb +111 -0
  52. data/lib/cpflow/version.rb +1 -1
  53. data/lib/cpflow.rb +1 -1
  54. data/lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml +7 -7
  55. data/lib/github_flow_templates/bin/test-cpflow-github-flow +63 -3
  56. data/lib/patches/hash.rb +2 -2
  57. data/rakelib/create_release.rake +14 -14
  58. data/script/check_shell_scripts +66 -0
  59. metadata +11 -18
@@ -4,6 +4,12 @@ require "resolv"
4
4
 
5
5
  module Command
6
6
  class DeployImage < Base # rubocop:disable Metrics/ClassLength
7
+ WORKLOAD_IMAGE_UPDATE_MAX_ATTEMPTS = 30
8
+ # Preserve the general failure cap; only optimistic-concurrency conflicts get the longer convergence window.
9
+ WORKLOAD_IMAGE_UPDATE_CONFLICT_MAX_ATTEMPTS = 120
10
+ WORKLOAD_IMAGE_UPDATE_CONFLICT_PATTERN =
11
+ /(?:\b409\b|(?:\A|\n)[ \t]*(?:error:[ \t]*)?conflict[ \t]*(?:\r?\n|\z))/i
12
+
7
13
  NAME = "deploy-image"
8
14
  OPTIONS = [
9
15
  app_option(required: true),
@@ -122,7 +128,8 @@ module Command
122
128
 
123
129
  container_name = container["name"]
124
130
  step("Deploying image '#{image}' for workload '#{workload}'") do
125
- cp.workload_set_image_ref(workload, container: container_name, image: image)
131
+ update_workload_image_ref(workload, container_name, image)
132
+
126
133
  deployed_endpoints[workload] = endpoint_for_workload(workload_data)
127
134
  # A missing public endpoint is valid; the image update still completed successfully.
128
135
  true
@@ -136,6 +143,36 @@ module Command
136
143
  deployed_endpoints
137
144
  end
138
145
 
146
+ def update_workload_image_ref(workload, container, image)
147
+ attempts = 0
148
+
149
+ loop do
150
+ attempts += 1
151
+ result = cp.workload_set_image_ref(workload, container: container, image: image)
152
+ return true if result.fetch(:success)
153
+
154
+ output = result.fetch(:output).to_s
155
+ raise workload_image_update_error(output) if attempts >= workload_image_update_limit(output)
156
+
157
+ wait_before_workload_image_update_retry
158
+ end
159
+ end
160
+
161
+ def workload_image_update_limit(output)
162
+ return WORKLOAD_IMAGE_UPDATE_CONFLICT_MAX_ATTEMPTS if output.match?(WORKLOAD_IMAGE_UPDATE_CONFLICT_PATTERN)
163
+
164
+ WORKLOAD_IMAGE_UPDATE_MAX_ATTEMPTS
165
+ end
166
+
167
+ def workload_image_update_error(output)
168
+ output.strip.empty? ? "Command exited with non-zero status." : output
169
+ end
170
+
171
+ def wait_before_workload_image_update_retry
172
+ progress.print(".")
173
+ Kernel.sleep(1)
174
+ end
175
+
139
176
  def print_deployed_endpoints(deployed_endpoints)
140
177
  progress.puts("\nDeployed endpoints:")
141
178
  deployed_endpoints.each do |workload, endpoint|
@@ -16,7 +16,7 @@ module Command
16
16
  def copy_files
17
17
  relative_paths = generated_files
18
18
  replacements = template_variables
19
- copy_template_files(relative_paths)
19
+ copy_generated_files(relative_paths)
20
20
  substitute_template_variables(relative_paths, replacements)
21
21
  make_shell_scripts_executable(relative_paths)
22
22
  end
@@ -27,11 +27,11 @@ module Command
27
27
 
28
28
  private
29
29
 
30
- def copy_template_files(relative_paths)
30
+ def copy_generated_files(relative_paths)
31
31
  relative_paths.each do |relative_path|
32
32
  empty_directory(File.dirname(relative_path), verbose: false)
33
33
  copy_file(
34
- File.join("github_flow_templates", relative_path),
34
+ GenerateGithubActions.source_file(relative_path),
35
35
  relative_path,
36
36
  force: true,
37
37
  verbose: ENV.fetch("HIDE_COMMAND_OUTPUT", nil) != "true"
@@ -99,6 +99,9 @@ module Command
99
99
  - manual promotion from staging to production
100
100
  - nightly cleanup and PR help workflows
101
101
 
102
+ It also copies cpflow's composite actions into `.github/actions/cpflow-*`
103
+ so every local `uses:` target is checked in and can be audited directly.
104
+
102
105
  Pass `--staging-branch BRANCH` when staging should auto-deploy from a branch
103
106
  other than `main` or `master`; the generator will bake that branch into the
104
107
  GitHub Actions push trigger and use it as the default STAGING_APP_BRANCH.
@@ -108,13 +111,13 @@ module Command
108
111
  DESC
109
112
  EXAMPLES = <<~EX
110
113
  ```sh
111
- # Creates thin .github/workflows wrappers for the Control Plane flow
114
+ # Creates workflow wrappers, local composite actions, and validation helpers
112
115
  cpflow generate-github-actions
113
116
 
114
117
  # Creates the flow with staging deploys triggered from develop
115
118
  cpflow generate-github-actions --staging-branch develop
116
119
 
117
- # Overwrites existing generated wrappers from the installed cpflow gem
120
+ # Overwrites existing generated GitHub Actions files from the installed cpflow gem
118
121
  cpflow generate-github-actions --force
119
122
  ```
120
123
  EX
@@ -122,22 +125,43 @@ module Command
122
125
  VALIDATIONS = [].freeze
123
126
  REQUIRES_STARTUP_CHECKS = false
124
127
 
125
- # Resolve template root from __dir__ rather than Cpflow.root_path because this file is
128
+ # Resolve source roots from __dir__ rather than Cpflow.root_path because this file is
126
129
  # loaded before `module Cpflow` finishes defining its class methods.
130
+ REPOSITORY_ROOT = Pathname.new(File.expand_path("../..", __dir__))
127
131
  TEMPLATE_ROOT = Pathname.new(File.expand_path("../github_flow_templates", __dir__))
132
+ ACTIONS_ROOT = REPOSITORY_ROOT.join(".github/actions")
128
133
 
129
- def self.generated_files
134
+ def self.generated_file_sources
130
135
  ensure_template_root!
131
136
 
132
- Dir.glob(TEMPLATE_ROOT.join("**", "*").to_s, File::FNM_DOTMATCH)
133
- .select { |path| File.file?(path) }
134
- .map { |path| Pathname.new(path).relative_path_from(TEMPLATE_ROOT).to_s }
135
- .sort
136
- .freeze
137
+ template_sources = files_beneath(TEMPLATE_ROOT).to_h do |source|
138
+ [source.relative_path_from(TEMPLATE_ROOT).to_s, source]
139
+ end
140
+ action_sources = files_beneath(ACTIONS_ROOT, "cpflow-*/**/*").to_h do |source|
141
+ [source.relative_path_from(REPOSITORY_ROOT).to_s, source]
142
+ end
143
+
144
+ template_sources.merge(action_sources).sort.to_h.freeze
145
+ end
146
+
147
+ def self.generated_files
148
+ generated_file_sources.keys.freeze
149
+ end
150
+
151
+ def self.source_file(relative_path)
152
+ generated_file_sources.fetch(relative_path).to_s
137
153
  end
138
154
 
139
155
  def self.ensure_template_root!
140
156
  raise "cpflow template directory not found: #{TEMPLATE_ROOT}" unless TEMPLATE_ROOT.directory?
157
+ raise "cpflow action directory not found: #{ACTIONS_ROOT}" unless ACTIONS_ROOT.directory?
158
+ end
159
+
160
+ def self.files_beneath(root, pattern = "**/*")
161
+ Dir.glob(root.join(pattern).to_s, File::FNM_DOTMATCH)
162
+ .select { |path| File.file?(path) }
163
+ .map { |path| Pathname.new(path) }
164
+ .sort
141
165
  end
142
166
 
143
167
  def call
data/lib/command/run.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "timeout"
4
+
3
5
  module Command
4
6
  class Run < Base # rubocop:disable Metrics/ClassLength
5
7
  INTERACTIVE_COMMANDS = [
@@ -49,6 +51,12 @@ module Command
49
51
  (can be configured though `runner_job_timeout` in `controlplane.yml`)
50
52
  - Waiting for a runner replica is limited to the smaller of `runner_job_timeout` and 1000 seconds.
51
53
  A terminal cron status fails immediately, and reaching the observation deadline reports the last safe status
54
+ - With non-interactive log methods 2 and 3, after a command prints its completion marker, Control Plane
55
+ has up to 20 minutes to reconcile the cron job to a terminal status. This can be configured through
56
+ `runner_job_status_reconciliation_timeout` in `controlplane.yml`; timing out exits nonzero and reports
57
+ the job, replica, and last observed status
58
+ - Log method 1 does not emit a completion marker, so its job-status polling is not covered by the
59
+ post-command reconciliation timeout
52
60
  - Non-interactive jobs return the Control Plane cron job status even when the job finishes before
53
61
  Control Plane exposes a runner replica to attach logs to
54
62
  - Injects `CPFLOW_GVC_ID` and `CPFLOW_GVC_CREATED` into the job, exposing the app's immutable GVC
@@ -108,15 +116,24 @@ module Command
108
116
  DEFAULT_JOB_CPU = "1"
109
117
  DEFAULT_JOB_MEMORY = "2Gi"
110
118
  DEFAULT_JOB_TIMEOUT = 21_600 # 6 hours
119
+ DEFAULT_JOB_STATUS_RECONCILIATION_TIMEOUT = 1_200 # 20 minutes
111
120
  DEFAULT_JOB_HISTORY_LIMIT = 10
112
121
  MAX_REPLICA_OBSERVATION_SECONDS = 1_000
113
122
  REPLICA_OBSERVATION_POLL_INTERVAL_SECONDS = 1
123
+ LOG_QUERY_LOOKBACK_SECONDS = 60
124
+ POST_TERMINAL_LOG_DRAIN_SECONDS = 120
125
+ POST_TERMINAL_LOG_POLL_INTERVAL_SECONDS = 1
126
+ LOG_REQUEST_TIMEOUT_SECONDS = 30
127
+ JOB_STATUS_UNAVAILABLE_RETRY_LIMIT = 5
128
+ JOB_STATUS_POLL_INTERVAL_SECONDS = 1
129
+ JOB_STATUS_REQUEST_TIMEOUT_SECONDS = 30
114
130
  NORMALIZED_JOB_STATUS_PATTERN = /\A[a-z][a-z0-9_-]{0,31}\z/
115
131
  MAGIC_END = "---cpflow run command finished---"
116
132
 
117
133
  attr_reader :interactive, :detached, :location, :original_workload, :runner_workload,
118
134
  :default_image, :default_cpu, :default_memory, :job_timeout, :job_history_limit,
119
- :container, :job, :replica, :command, :job_completed_before_replica_exit_status
135
+ :job_status_reconciliation_timeout, :container, :job, :replica, :command,
136
+ :job_completed_before_replica_exit_status
120
137
 
121
138
  def call # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
122
139
  @interactive = config.options[:interactive] || interactive_command?
@@ -130,6 +147,8 @@ module Command
130
147
  @default_cpu = config.current[:runner_job_default_cpu] || DEFAULT_JOB_CPU
131
148
  @default_memory = config.current[:runner_job_default_memory] || DEFAULT_JOB_MEMORY
132
149
  @job_timeout = config.current[:runner_job_timeout] || DEFAULT_JOB_TIMEOUT
150
+ @job_status_reconciliation_timeout =
151
+ config.current[:runner_job_status_reconciliation_timeout] || DEFAULT_JOB_STATUS_RECONCILIATION_TIMEOUT
133
152
  @job_history_limit = DEFAULT_JOB_HISTORY_LIMIT
134
153
 
135
154
  unless interactive
@@ -596,10 +615,12 @@ module Command
596
615
  if @log_method == 1 || @interactive
597
616
  args_join(config.args)
598
617
  else
618
+ # MAGIC_END must be a same-stream barrier after all payload output. Merge stderr into stdout before
619
+ # capturing the payload exit status and emitting the marker.
599
620
  <<~SCRIPT
600
- ( #{args_join(config.args)} )
621
+ ( #{args_join(config.args)} ) 2>&1
601
622
  CPFLOW_EXIT_CODE=$?
602
- echo '#{MAGIC_END}'
623
+ printf '\\n%s\\n' '#{MAGIC_END}'
603
624
  exit $CPFLOW_EXIT_CODE
604
625
  SCRIPT
605
626
  end
@@ -614,6 +635,7 @@ module Command
614
635
 
615
636
  def wait_for_job_status_and_log(logs_pipe) # rubocop:disable Metrics/MethodLength
616
637
  no_logs_counter = 0
638
+ command_finished = false
617
639
 
618
640
  loop do
619
641
  no_logs_counter += 1
@@ -623,12 +645,16 @@ module Command
623
645
 
624
646
  no_logs_counter = 0
625
647
  line = logs_pipe.gets
626
- break if line.chomp == MAGIC_END
648
+ if line.chomp == MAGIC_END
649
+ command_finished = true
650
+ break
651
+ end
627
652
 
628
653
  puts(line)
629
654
  end
630
655
 
631
- resolve_job_status
656
+ status_deadline = job_status_reconciliation_deadline if command_finished
657
+ resolve_job_status(status_deadline: status_deadline)
632
658
  end
633
659
 
634
660
  def print_detached_commands
@@ -642,51 +668,198 @@ module Command
642
668
  )
643
669
  end
644
670
 
645
- def resolve_job_status # rubocop:disable Metrics/MethodLength
671
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
672
+ def resolve_job_status(status_deadline: nil)
673
+ last_status = @last_job_status
646
674
  loop do
647
- status = current_job_status
648
-
649
- Shell.debug("JOB STATUS", status)
650
-
651
- case status
652
- when "active", "pending"
653
- sleep 1
654
- when "successful"
655
- break ExitCode::SUCCESS
656
- else
675
+ remaining_seconds = status_deadline && (status_deadline - monotonic_time)
676
+ if remaining_seconds && remaining_seconds <= 0
677
+ progress.puts(Shell.color(job_status_reconciliation_timeout_message(last_status), :red))
657
678
  break ExitCode::ERROR_DEFAULT
658
679
  end
680
+
681
+ request_timeout = if remaining_seconds
682
+ [JOB_STATUS_REQUEST_TIMEOUT_SECONDS, remaining_seconds].min
683
+ else
684
+ JOB_STATUS_REQUEST_TIMEOUT_SECONDS
685
+ end
686
+ begin
687
+ status = current_job_status(timeout_seconds: request_timeout)
688
+ rescue Shell::CommandTimeout
689
+ next
690
+ end
691
+ last_status = status
692
+ @last_job_status = status
693
+
694
+ exit_status = job_exit_status(status, unavailable_is_pending: false)
695
+ break exit_status if exit_status
696
+
697
+ sleep_seconds = JOB_STATUS_POLL_INTERVAL_SECONDS
698
+ sleep_seconds = [sleep_seconds, remaining_seconds].min if remaining_seconds
699
+ Kernel.sleep(sleep_seconds) if sleep_seconds.positive?
659
700
  end
660
701
  end
661
702
 
662
- def current_job_status
663
- result = cp.fetch_cron_workload(runner_workload, location: location)
703
+ def reconciled_job_status(unavailable_status_streak, reconciliation_deadline, reconciliation_deadline_origin,
704
+ request_timeout:)
705
+ status = begin
706
+ current_job_status(timeout_seconds: request_timeout)
707
+ rescue Shell::CommandTimeout
708
+ nil
709
+ end
710
+ next_unavailable_status_streak = status.nil? ? unavailable_status_streak + 1 : 0
711
+ exit_status = job_exit_status(status, unavailable_is_pending: status.nil?)
712
+
713
+ if next_unavailable_status_streak > JOB_STATUS_UNAVAILABLE_RETRY_LIMIT && reconciliation_deadline.nil?
714
+ reconciliation_deadline = start_reconciliation_deadline(
715
+ reconciliation_deadline,
716
+ duration: POST_TERMINAL_LOG_DRAIN_SECONDS
717
+ )
718
+ reconciliation_deadline_origin = :status_outage
719
+ elsif reconciliation_deadline_origin == :status_outage && %w[active pending].include?(status)
720
+ reconciliation_deadline = reconciliation_deadline_origin = @post_terminal_log_from = nil
721
+ end
722
+
723
+ [exit_status, next_unavailable_status_streak, status, reconciliation_deadline, reconciliation_deadline_origin]
724
+ end
725
+
726
+ def job_exit_status(status, unavailable_is_pending:)
727
+ Shell.debug("JOB STATUS", normalized_job_status(status))
728
+
729
+ case status
730
+ when nil then unavailable_is_pending ? nil : ExitCode::ERROR_DEFAULT
731
+ when "active", "pending" then nil
732
+ when "successful" then ExitCode::SUCCESS
733
+ else ExitCode::ERROR_DEFAULT
734
+ end
735
+ end
736
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
737
+
738
+ def current_job_status(timeout_seconds: nil)
739
+ result = if timeout_seconds
740
+ cp.fetch_cron_workload(runner_workload, location: location, timeout_seconds: timeout_seconds)
741
+ else
742
+ cp.fetch_cron_workload(runner_workload, location: location)
743
+ end
664
744
  job_details = result&.dig("items")&.find { |item| item["id"] == job }
665
745
  job_details&.dig("status")
666
746
  end
667
747
 
748
+ def job_status_reconciliation_deadline
749
+ monotonic_time + job_status_reconciliation_timeout
750
+ end
751
+
752
+ def job_status_reconciliation_timeout_message(status)
753
+ replica_args = app_workload_replica_args.join(" ")
754
+
755
+ <<~MESSAGE.chomp
756
+ ERROR: Control Plane job status did not reconcile within #{job_status_reconciliation_timeout} seconds after the command finished.
757
+ job: #{job}
758
+ replica: #{replica}
759
+ status: #{status || 'unknown'}
760
+ Inspect logs: cpflow logs #{replica_args}
761
+ Stop the stale runner: cpflow ps:stop #{replica_args}
762
+ MESSAGE
763
+ end
764
+
668
765
  ###########################################
669
766
  ### temporary extaction from run:detached
670
767
  ###########################################
671
- def show_logs_waiting # rubocop:disable Metrics/MethodLength
768
+ # Tracks log completion, authoritative job status, provisional status outages, and bounded deadlines.
769
+ def show_logs_waiting # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
672
770
  retries = 0
771
+ exit_status = nil
772
+ reconciliation_deadline = nil
773
+ reconciliation_deadline_origin = nil
774
+ unavailable_status_streak = 0
775
+ finish_marker_seen = false
776
+ last_job_status = nil
777
+
673
778
  begin
674
- job_finished_count = 0
779
+ # rubocop:disable Metrics/BlockLength
675
780
  loop do
676
- case print_uniq_logs
677
- when :finished
678
- break
679
- when :changed
680
- next
681
- else
682
- job_finished_count += 1 if resolve_job_status
683
- break if job_finished_count > 5
684
-
685
- sleep(1)
781
+ remaining = reconciliation_deadline && (reconciliation_deadline - monotonic_time)
782
+ break if remaining && !remaining.positive?
783
+
784
+ unless finish_marker_seen
785
+ log_request_timeout = remaining ? [LOG_REQUEST_TIMEOUT_SECONDS, remaining].min : LOG_REQUEST_TIMEOUT_SECONDS
786
+ log_status = begin
787
+ print_uniq_logs(timeout_seconds: log_request_timeout)
788
+ rescue Shell::CommandTimeout
789
+ :unchanged
790
+ end
791
+ finish_marker_seen = log_status == :finished
792
+ if finish_marker_seen && reconciliation_deadline.nil?
793
+ reconciliation_deadline = start_reconciliation_deadline(
794
+ reconciliation_deadline,
795
+ duration: job_status_reconciliation_timeout || POST_TERMINAL_LOG_DRAIN_SECONDS
796
+ )
797
+ reconciliation_deadline_origin = :finish_marker
798
+ elsif finish_marker_seen && reconciliation_deadline_origin == :status_outage
799
+ reconciliation_deadline = monotonic_time +
800
+ (job_status_reconciliation_timeout || POST_TERMINAL_LOG_DRAIN_SECONDS)
801
+ reconciliation_deadline_origin = :finish_marker
802
+ end
686
803
  end
687
- end
804
+ remaining = reconciliation_deadline && (reconciliation_deadline - monotonic_time)
805
+ break if remaining && !remaining.positive?
806
+
807
+ request_timeout = if remaining
808
+ [JOB_STATUS_REQUEST_TIMEOUT_SECONDS, remaining].min
809
+ else
810
+ JOB_STATUS_REQUEST_TIMEOUT_SECONDS
811
+ end
812
+
813
+ exit_status ||= begin
814
+ job_status_state = reconciled_job_status(
815
+ unavailable_status_streak, reconciliation_deadline, reconciliation_deadline_origin,
816
+ request_timeout: request_timeout
817
+ )
818
+ current_exit_status, unavailable_status_streak, observed_status,
819
+ reconciliation_deadline, reconciliation_deadline_origin = job_status_state
820
+ last_job_status = observed_status unless observed_status.nil?
821
+ current_exit_status
822
+ end
823
+ if exit_status && reconciliation_deadline_origin == :status_outage
824
+ reconciliation_deadline = monotonic_time + POST_TERMINAL_LOG_DRAIN_SECONDS
825
+ reconciliation_deadline_origin = :terminal
826
+ elsif exit_status && reconciliation_deadline.nil?
827
+ reconciliation_deadline = start_reconciliation_deadline(
828
+ reconciliation_deadline,
829
+ duration: POST_TERMINAL_LOG_DRAIN_SECONDS
830
+ )
831
+ reconciliation_deadline_origin = :terminal
832
+ end
833
+ break if finish_marker_seen && exit_status
834
+
835
+ remaining = reconciliation_deadline && (reconciliation_deadline - monotonic_time)
836
+ break if remaining && !remaining.positive?
688
837
 
689
- resolve_job_status
838
+ poll_interval = POST_TERMINAL_LOG_POLL_INTERVAL_SECONDS
839
+ poll_interval = [poll_interval, remaining].min if remaining
840
+ Kernel.sleep(poll_interval)
841
+ end
842
+ # rubocop:enable Metrics/BlockLength
843
+
844
+ if exit_status && finish_marker_seen
845
+ exit_status
846
+ elsif exit_status
847
+ Shell.warn(
848
+ "Runner job reached terminal status #{normalized_job_status(last_job_status)} but the command " \
849
+ "completion marker was unavailable after #{POST_TERMINAL_LOG_DRAIN_SECONDS} seconds; " \
850
+ "returning authoritative job exit status #{exit_status} with incomplete output."
851
+ )
852
+ exit_status
853
+ elsif reconciliation_deadline_origin == :finish_marker && job_status_reconciliation_timeout
854
+ progress.puts(Shell.color(job_status_reconciliation_timeout_message(last_job_status), :red))
855
+ ExitCode::ERROR_DEFAULT
856
+ else
857
+ Shell.warn(
858
+ "Runner job status reconciliation reached the #{POST_TERMINAL_LOG_DRAIN_SECONDS}-second deadline " \
859
+ "(last_status: #{normalized_job_status(last_job_status)}); returning exit status #{ExitCode::ERROR_DEFAULT}."
860
+ )
861
+ ExitCode::ERROR_DEFAULT
862
+ end
690
863
  rescue RuntimeError => e
691
864
  raise "#{e} Exiting..." unless retries < 10 # MAX_RETRIES
692
865
 
@@ -696,12 +869,20 @@ module Command
696
869
  end
697
870
  end
698
871
 
699
- def print_uniq_logs
872
+ def start_reconciliation_deadline(current_deadline, duration:)
873
+ return current_deadline if current_deadline
874
+
875
+ # Freeze the pre-terminal overlap so late-ingested entries do not age out of the query window.
876
+ @post_terminal_log_from = Time.now.to_i - LOG_QUERY_LOOKBACK_SECONDS
877
+ monotonic_time + duration
878
+ end
879
+
880
+ def print_uniq_logs(timeout_seconds: nil)
700
881
  status = nil
701
882
 
702
883
  @printed_log_entries ||= []
703
884
  ts = Time.now.to_i
704
- entries = normalized_log_entries(from: ts - 60, to: ts)
885
+ entries = fetch_log_entries(ts, timeout_seconds)
705
886
 
706
887
  (entries - @printed_log_entries).sort.each do |(_ts, val)|
707
888
  status ||= :changed
@@ -713,6 +894,15 @@ module Command
713
894
  status || :unchanged
714
895
  end
715
896
 
897
+ def fetch_log_entries(to, timeout_seconds)
898
+ from = @post_terminal_log_from || (to - LOG_QUERY_LOOKBACK_SECONDS)
899
+ return normalized_log_entries(from: from, to: to) unless timeout_seconds
900
+
901
+ Timeout.timeout(timeout_seconds, Shell::CommandTimeout) do
902
+ normalized_log_entries(from: from, to: to)
903
+ end
904
+ end
905
+
716
906
  def normalized_log_entries(from:, to:)
717
907
  log = cp.log_get(workload: runner_workload, from: from, to: to, replica: replica)
718
908
 
@@ -10,12 +10,13 @@ module Command
10
10
 
11
11
  NAME = "update-github-actions"
12
12
  OPTIONS = [staging_branch_option].freeze
13
- DESCRIPTION = "Regenerates generated GitHub Actions wrappers for the installed cpflow version"
13
+ DESCRIPTION = "Regenerates GitHub Actions files for the installed cpflow version"
14
14
  LONG_DESCRIPTION = <<~DESC.freeze
15
- Regenerates the generated cpflow GitHub Actions wrappers and helper files
16
- from the currently installed cpflow gem. Use this after updating the
17
- cpflow gem so checked-in workflow wrappers move to the matching upstream
18
- release tag, for example `v#{Cpflow::VERSION}`.
15
+ Regenerates the cpflow workflow wrappers, local composite actions, and
16
+ helper files from the currently installed cpflow gem. Use this after
17
+ updating the cpflow gem so checked-in workflow wrappers move to the
18
+ matching upstream release tag, for example `v#{Cpflow::VERSION}`, and the
19
+ generated action implementations move with them.
19
20
 
20
21
  If the existing generated staging workflow uses a custom single staging
21
22
  branch, the command preserves it. Pass `--staging-branch BRANCH` to set or
@@ -23,7 +24,7 @@ module Command
23
24
  DESC
24
25
  EXAMPLES = <<~EX
25
26
  ```sh
26
- # After updating the cpflow gem, refresh generated GitHub Actions wrappers
27
+ # After updating the cpflow gem, refresh every generated GitHub Actions file
27
28
  cpflow update-github-actions
28
29
 
29
30
  # When running cpflow through Bundler