hecks 3.2.0 → 3.3.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 (187) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/bluebook/assembly/contracts.rb +2 -1
  3. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +1 -0
  4. data/lib/hecks/bluebook/dsl/command_builder.rb +2 -39
  5. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +3 -3
  6. data/lib/hecks/bluebook/dsl/need_word.rb +50 -0
  7. data/lib/hecks/bluebook/dsl/query_builder.rb +6 -1
  8. data/lib/hecks/bluebook/query.rb +8 -5
  9. data/lib/hecks/cli/check_era.rb +3 -39
  10. data/lib/hecks/cli/interview_draft.rb +86 -31
  11. data/lib/hecks/cli/interview_session.rb +77 -26
  12. data/lib/hecks/cli/interview_shape.rb +179 -0
  13. data/lib/hecks/doors/cli_runner.rb +14 -1
  14. data/lib/hecks/doors/launcher_options.rb +13 -3
  15. data/lib/hecks/doors/mcp_door.rb +16 -2
  16. data/lib/hecks/doors/mcp_door_scope.rb +45 -5
  17. data/lib/hecks/doors/mcp_guide_cache.rb +75 -0
  18. data/lib/hecks/doors.rb +1 -1
  19. data/lib/hecks/embryonaut_bluebook/manifest.rb +117 -0
  20. data/lib/hecks/embryonaut_bluebook/registry.rb +209 -0
  21. data/lib/hecks/embryonaut_bluebook/vendor.rb +1 -1
  22. data/lib/hecks/embryonaut_bluebook.rb +2 -0
  23. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +17 -1
  24. data/lib/hecks/fuzzing/qa_settings.rb +15 -34
  25. data/lib/hecks/gate/stages.yml +113 -8
  26. data/lib/hecks/hecks/adapters/codebase/regeneration.rb +10 -1
  27. data/lib/hecks/hecks/adapters/codebase/test_runner.rb +10 -2
  28. data/lib/hecks/hecks/adapters/first_admin.rb +108 -0
  29. data/lib/hecks/hecks/adapters/git.rb +59 -0
  30. data/lib/hecks/hecks/adapters/in_process_access.rb +25 -0
  31. data/lib/hecks/hecks/adapters/in_process_boot.rb +2 -0
  32. data/lib/hecks/hecks/adapters/rust_toolchain.rb +18 -3
  33. data/lib/hecks/hecks/adapters/rust_workspace.rb +1 -1
  34. data/lib/hecks/hecks/adapters/site_toolchain.rb +88 -11
  35. data/lib/hecks/hecks/codebase.behaviors +6 -0
  36. data/lib/hecks/hecks/codebase.bluebook +21 -0
  37. data/lib/hecks/hecks/custodian.behaviors +6 -0
  38. data/lib/hecks/hecks/custodian.bluebook +217 -0
  39. data/lib/hecks/hecks/hecks.hecksagon +24 -0
  40. data/lib/hecks/hecks/hecks.world +8 -3
  41. data/lib/hecks/hecks/site.world +5 -2
  42. data/lib/hecks/language/bluebook/query.bluebook +25 -0
  43. data/lib/hecks/language/bluebook/vocabulary.bluebook +220 -7
  44. data/lib/hecks/language/hecksagon/hecksagon.bluebook +1 -1
  45. data/lib/hecks/language/oidc.json +5 -0
  46. data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +3 -3
  47. data/lib/hecks/projections/deploy/box/hosting.rb +170 -0
  48. data/lib/hecks/projections/deploy/box/hosting_settings.rb +123 -0
  49. data/lib/hecks/projections/deploy/box/settings.rb +40 -6
  50. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +2 -0
  51. data/lib/hecks/projections/deploy/box/templates/deploy-service.services-roll.part +33 -0
  52. data/lib/hecks/projections/deploy/box/templates/deploy-service.sh.tmpl +79 -0
  53. data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef-roll.part +77 -0
  54. data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef.part +12 -0
  55. data/lib/hecks/projections/deploy/box/templates/hosting.mk.tmpl +24 -0
  56. data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +17 -0
  57. data/lib/hecks/projections/deploy/box/templates/smoke-after-deploy.sh.tmpl +271 -0
  58. data/lib/hecks/projections/deploy/box.rb +28 -4
  59. data/lib/hecks/projections/deploy/fargate.rb +3 -3
  60. data/lib/hecks/projections/deploy/lambda.rb +8 -8
  61. data/lib/hecks/projections/model/deviations.rb +1 -1
  62. data/lib/hecks/projections/site/edge/behaviours.rb +5 -3
  63. data/lib/hecks/projections/site/edge/checks.rb +31 -5
  64. data/lib/hecks/projections/site/edge.rb +25 -11
  65. data/lib/hecks/projections/site/live_edge.rb +162 -0
  66. data/lib/hecks/projections/site/routes_ts.rb +50 -27
  67. data/lib/hecks/projections/site/site_cdn.rb +7 -4
  68. data/lib/hecks/projections/site/table/checks.rb +53 -7
  69. data/lib/hecks/projections/site/table/link_builder.rb +64 -0
  70. data/lib/hecks/projections/site/table/row_builder.rb +21 -6
  71. data/lib/hecks/projections/site/table.rb +16 -5
  72. data/lib/hecks/quality_control/adapters/git_pr.rb +0 -14
  73. data/lib/hecks/quality_control/cli/qa_discover_external_domains.rb +1 -1
  74. data/lib/hecks/quality_control/cli/qa_open_pr.rb +32 -16
  75. data/lib/hecks/quality_control/cli/qa_seed_angles.rb +2 -2
  76. data/lib/hecks/quality_control/cli/qa_seed_targets.rb +1 -2
  77. data/lib/hecks/quality_control/quality_control.bluebook +76 -124
  78. data/lib/hecks/runtime/interpreting.rb +3 -22
  79. data/lib/hecks/runtime/needs.rb +48 -0
  80. data/lib/hecks/runtime/query_interpreter.rb +3 -0
  81. data/lib/hecks/runtime/registry/verification.rb +3 -3
  82. data/lib/hecks/rust_build/host.rb +147 -0
  83. data/lib/hecks/rust_build/kernel_input.rb +51 -17
  84. data/lib/hecks/rust_build/project_rust/codegen_run.rb +0 -18
  85. data/lib/hecks/rust_build/project_rust.rb +7 -140
  86. data/lib/hecks/rust_build.rb +1 -0
  87. data/lib/hecks/site/bluebook/site.bluebook +80 -14
  88. data/lib/hecks/site/bluebook/site.ports.hecksagon +6 -0
  89. data/lib/hecks/sme/bluebook/sme.bluebook +128 -1
  90. data/lib/hecks/storehouse.rb +35 -1
  91. data/lib/hecks/tools/comment_style.rb +4 -2
  92. data/lib/hecks/tools/gate.rb +1 -1
  93. data/lib/hecks/tools/rust_comment_style.rb +2 -4
  94. data/lib/hecks/tools/site_routes.rb +69 -28
  95. data/lib/hecks/tools/tools_doc.rb +204 -0
  96. data/lib/hecks/tools.rb +1 -0
  97. data/lib/hecks/version.rb +1 -1
  98. data/lib/hecks/vocabulary.rb +152 -1
  99. data/lib/hecks.rb +1 -0
  100. data/qa/settings.yml +1 -2
  101. data/rust/build/src/lineage_pass.rs +31 -16
  102. data/rust/build/src/main.rs +4 -2
  103. data/rust/build/src/pipeline.rs +9 -20
  104. data/rust/codegen/src/attr.rs +1 -1
  105. data/rust/codegen/src/bridging.rs +8 -8
  106. data/rust/codegen/src/commands.rs +1 -1
  107. data/rust/codegen/src/constraints.rs +1 -1
  108. data/rust/codegen/src/dependency_planning.rs +1 -1
  109. data/rust/codegen/src/domain_generator.rs +2 -2
  110. data/rust/codegen/src/exemplar.rs +1 -1
  111. data/rust/codegen/src/expr_emitter.rs +1 -1
  112. data/rust/codegen/src/fielded.rs +1 -1
  113. data/rust/codegen/src/json.rs +214 -23
  114. data/rust/codegen/src/json_codec.rs +1 -1
  115. data/rust/codegen/src/literal.rs +6 -3
  116. data/rust/codegen/src/main.rs +23 -13
  117. data/rust/codegen/src/mutations.rs +1 -1
  118. data/rust/codegen/src/naming.rs +5 -3
  119. data/rust/codegen/src/optional_pass.rs +226 -0
  120. data/rust/codegen/src/ports.rs +1 -1
  121. data/rust/codegen/src/queries.rs +3 -3
  122. data/rust/codegen/src/reactions.rs +1 -1
  123. data/rust/codegen/src/read_models.rs +2 -2
  124. data/rust/codegen/src/reference_specs.rs +1 -1
  125. data/rust/codegen/src/registry.rs +4 -4
  126. data/rust/codegen/src/shared.rs +1 -1
  127. data/rust/{build → codegen}/src/sidecars.rs +9 -5
  128. data/rust/codegen/src/skip_reason.rs +1 -1
  129. data/rust/codegen/src/types.rs +4 -4
  130. data/rust/host/HECKS_RELEASE +1 -1
  131. data/rust/host/src/api.rs +1 -1
  132. data/rust/host/src/dispatch.rs +5 -0
  133. data/rust/host/src/needs.rs +193 -29
  134. data/rust/host/src/resend.rs +4 -4
  135. data/rust/host/src/ui_schema.rs +1 -1
  136. data/rust/parser/src/emit.rs +12 -0
  137. data/rust/parser/src/ir.rs +2 -0
  138. data/rust/parser/src/keywords.rs +2 -0
  139. data/rust/parser/src/main.rs +1 -0
  140. data/rust/parser/src/parse/command.rs +2 -44
  141. data/rust/parser/src/parse/mod.rs +1 -0
  142. data/rust/parser/src/parse/needs.rs +60 -0
  143. data/rust/parser/src/parse/query.rs +3 -0
  144. data/rust/src/exemplar/commands.rs +1 -1
  145. data/rust/src/exemplar/constraints.rs +1 -1
  146. data/rust/src/exemplar/fielded.rs +1 -1
  147. data/rust/src/exemplar/json.rs +1 -1
  148. data/rust/src/exemplar/mod.rs +1 -1
  149. data/rust/src/exemplar/mutations.rs +1 -1
  150. data/rust/src/exemplar/queries.rs +1 -1
  151. data/rust/src/exemplar/reactions.rs +1 -1
  152. data/rust/src/exemplar/read_models.rs +1 -1
  153. data/rust/src/exemplar/registry.rs +1 -1
  154. data/rust/src/exemplar/types.rs +1 -1
  155. data/rust/src/kernel/cli.rs +18 -8
  156. data/rust/src/kernel/needs.rs +104 -9
  157. data/rust/src/kernel/read_model.rs +1 -1
  158. data/rust/src/lib.rs +1 -1
  159. metadata +24 -30
  160. data/lib/hecks/rust_build/append_optionals.rb +0 -79
  161. data/lib/hecks/rust_build/rust_literal.rb +0 -33
  162. data/lib/hecks/three_zero/forms.yml +0 -127
  163. data/lib/hecks/three_zero.rb +0 -12
  164. data/rust/build/src/json.rs +0 -421
  165. data/rust/build/src/optional_pass.rs +0 -150
  166. data/rust/project/bridging.rb +0 -257
  167. data/rust/project/commands.rb +0 -877
  168. data/rust/project/constraints.rb +0 -86
  169. data/rust/project/dependency_planning.rb +0 -178
  170. data/rust/project/domain_generator.rb +0 -983
  171. data/rust/project/exemplar.rb +0 -148
  172. data/rust/project/expr_emitter.rb +0 -72
  173. data/rust/project/fielded.rb +0 -141
  174. data/rust/project/json_codec.rb +0 -641
  175. data/rust/project/mutations.rb +0 -695
  176. data/rust/project/naming.rb +0 -223
  177. data/rust/project/ports.rb +0 -65
  178. data/rust/project/queries.rb +0 -493
  179. data/rust/project/reactions.rb +0 -317
  180. data/rust/project/read_models.rb +0 -690
  181. data/rust/project/reference_specs.rb +0 -27
  182. data/rust/project/registry.rb +0 -396
  183. data/rust/project/skip_reason.rb +0 -29
  184. data/rust/project/types.rb +0 -225
  185. data/rust/project/write_if_changed.rb +0 -7
  186. data/rust/project.rb +0 -30
  187. data/rust/project_rust_pipeline.rb +0 -391
@@ -1,3 +1,5 @@
1
+ require_relative "hosting_settings"
2
+
1
3
  module Hecks
2
4
  module Projections
3
5
  module Deploy
@@ -34,6 +36,8 @@ module Hecks
34
36
  "@sha256:226d1f059b75399fe19182893c7184591c07b97afc8dfcf44eeb80c9a77a530f".freeze
35
37
  TUNNEL_SHAPE = "tunnel: a hash needs `to` (the container it forwards to) and `token_secret` " \
36
38
  "(the secret holding the tunnel token)".freeze
39
+ ORIGIN_ENV_SHAPE = "origin_env: the container environment variable names that hold the origin secret " \
40
+ "need an origin_secret and a task_definition to compare against".freeze
37
41
  ORIGIN_PAIR = "origin_header and origin_secret go together: the header a CDN sends, " \
38
42
  "and the secret that holds its value".freeze
39
43
 
@@ -45,7 +49,9 @@ module Hecks
45
49
  # @!attribute [r] env [Hash{String => String}] plain environment variables
46
50
  # @!attribute [r] secrets [Hash{String => String}] environment variable => secret name,
47
51
  # resolved on the box at deploy time and never written into a template
48
- Container = Struct.new(:name, :repository, :port, :env, :secrets, keyword_init: true)
52
+ # @!attribute [r] tag_parameter [String] the stack parameter that holds this container's
53
+ # image tag, which a hosting `deploy-service.sh` sets
54
+ Container = Struct.new(:name, :repository, :port, :env, :secrets, :tag_parameter, keyword_init: true)
49
55
 
50
56
  # A set of URL paths one container serves.
51
57
  #
@@ -79,8 +85,8 @@ module Hecks
79
85
  :infra_name, :stack_prefix, :instance_type, :volume_gb, :swap_gb, :database_class,
80
86
  :storage_gb, :backup_days, :snapshots_keep, :database_name, :engine_version,
81
87
  :containers, :routes, :default_container, :origin_header, :origin_secret,
82
- :secret_prefixes, :writable_secrets, :tunnel, :tunnel_service, :proxy_image, :task_definition,
83
- :migration, :s3_buckets, keyword_init: true
88
+ :secret_prefixes, :writable_secrets, :origin_env, :tunnel, :tunnel_service, :proxy_image, :task_definition,
89
+ :migration, :s3_buckets, :hosting, keyword_init: true
84
90
  ) do
85
91
  # @return [String] the CloudFormation stack that holds the database
86
92
  def rds_stack = "#{stack_prefix}-#{infra_name}-rds"
@@ -110,6 +116,7 @@ module Hecks
110
116
  task_definition = read_task_definition(s[:task_definition], listed)
111
117
  containers = read_containers(listed, infra_name)
112
118
  header, secret = read_origin(s)
119
+ origin_env = read_origin_env(s, secret, task_definition)
113
120
  tunnel, tunnel_service = read_tunnel(s.fetch(:tunnel, false), containers)
114
121
 
115
122
  Plan.new(
@@ -118,12 +125,13 @@ module Hecks
118
125
  engine_version: check(:engine_version, s.fetch(:engine_version, "16").to_s, ENGINE),
119
126
  containers: containers, routes: read_routes(s.fetch(:routes, []), containers),
120
127
  default_container: read_default(s[:default_container], containers), origin_header: header,
121
- origin_secret: secret, secret_prefixes: read_prefixes(s, infra_name),
128
+ origin_secret: secret, origin_env: origin_env, secret_prefixes: read_prefixes(s, infra_name),
122
129
  writable_secrets: read_writable_secrets(s),
123
130
  tunnel: tunnel, tunnel_service: tunnel_service,
124
131
  proxy_image: check(:proxy_image, s.fetch(:proxy_image, PROXY_IMAGE), IMAGE),
125
132
  task_definition: task_definition, migration: read_migration(s[:migration], database_name),
126
- s3_buckets: read_s3_access(s.fetch(:s3_access, []))
133
+ s3_buckets: read_s3_access(s.fetch(:s3_access, [])),
134
+ hosting: HostingSettings.read(s, task_definition)
127
135
  )
128
136
  end
129
137
 
@@ -221,10 +229,18 @@ module Hecks
221
229
  name: name, repository: check(:repository, spec.fetch(:repository, "#{infra_name}-#{name}"), REPOSITORY),
222
230
  port: integer(:port, spec.fetch(:port) { raise ArgumentError, "containers: #{name} has no port" }, 1, 65_535),
223
231
  env: string_map(:env, spec.fetch(:env, {}), ENV_KEY, ENV_VALUE),
224
- secrets: string_map(:secrets, spec.fetch(:secrets, {}), ENV_KEY, SECRET_NAME)
232
+ secrets: string_map(:secrets, spec.fetch(:secrets, {}), ENV_KEY, SECRET_NAME),
233
+ tag_parameter: check(:tag_parameter, spec.fetch(:tag_parameter, default_tag_parameter(name)),
234
+ HostingSettings::PARAMETER)
225
235
  )
226
236
  end
227
237
 
238
+ # @param name [String] a container name such as `web-app`
239
+ # @return [String] the image-tag parameter a stack names it by, such as `WebAppImageTag`
240
+ def default_tag_parameter(name)
241
+ "#{name.split('-').map(&:capitalize).join}ImageTag"
242
+ end
243
+
228
244
  def read_routes(list, containers)
229
245
  raise ArgumentError, "routes: expected a list" unless list.is_a?(Array)
230
246
 
@@ -263,6 +279,24 @@ module Hecks
263
279
  [check(:origin_header, header, HEADER), check(:origin_secret, secret, SECRET_NAME)]
264
280
  end
265
281
 
282
+ # The container environment variables that hold the origin secret in the task
283
+ # definition. Only Caddy reads the named secret; the containers keep the task
284
+ # definition's copy, so a copy that differs makes every request through the CDN
285
+ # fail. Naming them lets deploy refuse a mismatch.
286
+ #
287
+ # @param settings [Hash{Symbol => Object}] the world's `AwsBox` settings
288
+ # @param secret [String, nil] the declared origin secret
289
+ # @param task_definition [String, nil] the declared task definition family
290
+ # @return [Array<String>] the variable names, empty when none are declared
291
+ # @raise [ArgumentError] when named without an origin secret and a task definition
292
+ def read_origin_env(settings, secret, task_definition)
293
+ names = Array(settings.fetch(:origin_env, []))
294
+ return [] if names.empty?
295
+ raise ArgumentError, ORIGIN_ENV_SHAPE if secret.nil? || task_definition.nil?
296
+
297
+ names.map { |name| check(:origin_env, name, ENV_KEY) }.uniq
298
+ end
299
+
266
300
  # `tunnel true` only opens the outbound port; a hash also runs cloudflared as a service.
267
301
  #
268
302
  # @param value [Boolean, Hash{Symbol => Object}] the world's `tunnel` setting
@@ -55,6 +55,7 @@ ROLL=$(jq -n --arg compose "$(B64 "$WORK/compose.json")" --arg secrets "$(B64 "$
55
55
  --arg registry "$ACCOUNT.dkr.ecr.$REGION.amazonaws.com" --arg dir "$DIR" '
56
56
  {commands: ["cloud-init status --wait >/dev/null 2>&1 || true",
57
57
  "set -e", "mkdir -p \($dir)/caddy-extra && cd \($dir)", "umask 077",
58
+ "CADDY_BEFORE=$(cat Caddyfile 2>/dev/null | sha256sum)",
58
59
  "echo \($compose) | base64 -d > compose.json; echo \($secrets) | base64 -d > secrets.json",
59
60
  "echo \($caddy) | base64 -d > Caddyfile; echo \($fetch) | base64 -d > fetch-secrets.sh",
60
61
  "bash fetch-secrets.sh",
@@ -62,6 +63,7 @@ ROLL=$(jq -n --arg compose "$(B64 "$WORK/compose.json")" --arg secrets "$(B64 "$
62
63
  "aws ecr get-login-password --region '"$REGION"' | docker login --username AWS --password-stdin \($registry) >/dev/null",
63
64
  "docker compose -f compose.json pull --quiet",
64
65
  "docker compose -f compose.json up -d --remove-orphans",
66
+ "[ \"$(sha256sum < Caddyfile)\" = \"$CADDY_BEFORE\" ] || docker compose -f compose.json restart caddy",
65
67
  "for i in $(seq 1 40); do docker compose -f compose.json ps --format \"{{.Status}}\" | grep -qE \"^Up (Less than a second|[0-4] seconds?)|Restarting|Created|Exited\" || break; sleep 1; done; docker compose -f compose.json ps --format \"table {{.Service}}\\t{{.Status}}\""],
66
68
  executionTimeout: ["900"]}')
67
69
 
@@ -0,0 +1,33 @@
1
+ # deploy-box.sh renders every container the roll does not name at "latest". Name the tag each other
2
+ # container runs now, read from the box's own Compose file, so rolling one service leaves the rest alone.
3
+ BOX_ID="$(aws cloudformation describe-stacks --stack-name "$BOX_STACK" --region "$REGION" \
4
+ --query "Stacks[0].Outputs[?OutputKey=='InstanceId'].OutputValue" --output text)"
5
+ [ -n "$BOX_ID" ] && [ "$BOX_ID" != None ] || { echo "no instance in stack ${BOX_STACK}" >&2; exit 1; }
6
+
7
+ echo "==> reading the tags the box runs now"
8
+ READ_JSON="$(jq -n --arg c "cat ${BOX_DIR}/compose.json 2>/dev/null || echo '{}'" '{commands: [$c]}')"
9
+ COMMAND_ID="$(aws ssm send-command --instance-ids "$BOX_ID" --document-name AWS-RunShellScript --region "$REGION" \
10
+ --parameters "$READ_JSON" --query 'Command.CommandId' --output text)"
11
+ for _ in $(seq 1 30); do
12
+ sleep 2
13
+ state="$(aws ssm get-command-invocation --command-id "$COMMAND_ID" --instance-id "$BOX_ID" --region "$REGION" \
14
+ --query Status --output text 2>/dev/null || echo Pending)"
15
+ [ "$state" != InProgress ] && [ "$state" != Pending ] && break
16
+ done
17
+ [ "$state" = Success ] || { echo "could not read the box's Compose file (${state}); nothing was rolled" >&2; exit 1; }
18
+ aws ssm get-command-invocation --command-id "$COMMAND_ID" --instance-id "$BOX_ID" --region "$REGION" \
19
+ --query StandardOutputContent --output text > "${SCRATCH_DIR}/compose.json"
20
+
21
+ TAGS=()
22
+ for name in $SERVICES; do
23
+ if [ "$name" = "$SERVICE_NAME" ]; then
24
+ TAGS+=("${name}=${TAG}")
25
+ continue
26
+ fi
27
+ image="$(jq -r --arg n "$name" '.services[$n].image // empty' "${SCRATCH_DIR}/compose.json")"
28
+ # A container the box does not run yet starts at its "latest" image.
29
+ if [ -n "$image" ]; then TAGS+=("${name}=${image##*:}"); fi
30
+ done
31
+ echo "==> rolling ${TAGS[*]}"
32
+
33
+ bash ./deploy-box.sh "${TAGS[@]}"
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env bash
2
+ # GENERATED by hecks deploy project -- re-run it to refresh this file rather than hand-editing.
3
+ # Settings come from the domain's deployed_to("AwsBox") block (hosting_scripts true).
4
+ #
5
+ # Deploys ONE container's freshly built local image to @@STACK@@'s app box, under a fresh, unique ECR tag.
6
+ #
7
+ # bash deploy-service.sh <service>
8
+ # EXISTING_TAG=<tag> bash deploy-service.sh <service> redeploy a tag already in ECR (no push)
9
+ #
10
+ # Why it exists: pushing a floating tag and restarting a container looks like a deploy, but nothing
11
+ # records which image the box was told to run, and a box that pulls a tag it already has keeps the old
12
+ # image. This script pushes the local image under a never-reused tag (<service>-<UTC timestamp>),
13
+ # refuses a tag that is already in ECR, and names that tag in the roll.
14
+ @@MODE_SUMMARY@@
15
+ #
16
+ # It ends with smoke-after-deploy.sh, whose own exit codes (20-23) are distinct from a failed deploy
17
+ # step's. SKIP_POST_DEPLOY_SMOKE=1 skips it.
18
+ #
19
+ # Environment:
20
+ # LOCAL_IMAGE local image to push, default <ecr repository>:latest
21
+ # EXISTING_TAG redeploy this tag from ECR instead of building and pushing
22
+ # SETTLE_TIMEOUT_SECS default 1200, how long to wait for a stack update
23
+ # SKIP_POST_DEPLOY_SMOKE=1 passed through to smoke-after-deploy.sh
24
+ set -euo pipefail
25
+
26
+ @@SETTINGS@@
27
+
28
+ @@SERVICES@@
29
+
30
+ SERVICE_NAME="${1:?usage: deploy-service.sh <service> (one of: ${SERVICES})}"
31
+ resolve_service "$SERVICE_NAME"
32
+
33
+ cd "$(dirname "${BASH_SOURCE[0]}")"
34
+ SETTLE_TIMEOUT_SECS="${SETTLE_TIMEOUT_SECS:-1200}"
35
+ ACCOUNT_ID="$(aws sts get-caller-identity --query Account --output text)"
36
+ ECR_HOST="${ACCOUNT_ID}.dkr.ecr.${REGION}.amazonaws.com"
37
+ SCRATCH_DIR="$(mktemp -d)"
38
+ trap 'rm -rf "$SCRATCH_DIR"' EXIT
39
+
40
+ # True once $1 (a value of $SECONDS) is further in the past than the settle timeout.
41
+ timed_out() { [ $((SECONDS - $1)) -gt "$SETTLE_TIMEOUT_SECS" ]; }
42
+
43
+ @@REPOSITORY_STEP@@
44
+ TAG="${EXISTING_TAG:-${SERVICE_NAME}-$(date -u +%Y%m%d%H%M%S)}"
45
+ IMAGE_URI="${ECR_HOST}/${ECR_REPOSITORY}:${TAG}"
46
+ LOCAL_IMAGE="${LOCAL_IMAGE:-${ECR_REPOSITORY}:latest}"
47
+
48
+ if [ -n "${EXISTING_TAG:-}" ]; then
49
+ echo "==> reusing ${IMAGE_URI} (no push)"
50
+ aws ecr describe-images --repository-name "$ECR_REPOSITORY" --region "$REGION" \
51
+ --image-ids "imageTag=${TAG}" --query 'imageDetails[0].imageTags' --output text > /dev/null \
52
+ || { echo "tag ${TAG} is not in ECR" >&2; exit 1; }
53
+ else
54
+ if aws ecr describe-images --repository-name "$ECR_REPOSITORY" --region "$REGION" \
55
+ --image-ids "imageTag=${TAG}" > /dev/null 2>&1; then
56
+ echo "tag ${TAG} is already in ECR; a tag is never reused. Run again." >&2
57
+ exit 1
58
+ fi
59
+ echo "==> tagging ${LOCAL_IMAGE} as ${IMAGE_URI}"
60
+ docker tag "$LOCAL_IMAGE" "$IMAGE_URI"
61
+
62
+ # A private docker config with no credential helper: a helper that blocks on a keychain prompt
63
+ # hangs forever in any unattended shell.
64
+ echo "==> ecr login"
65
+ DOCKER_CONFIG_DIR="${SCRATCH_DIR}/docker-config"
66
+ mkdir -p "$DOCKER_CONFIG_DIR"
67
+ echo '{}' > "${DOCKER_CONFIG_DIR}/config.json"
68
+ aws ecr get-login-password --region "$REGION" \
69
+ | docker --config "$DOCKER_CONFIG_DIR" login --username AWS --password-stdin "$ECR_HOST"
70
+
71
+ echo "==> pushing ${IMAGE_URI}"
72
+ docker --config "$DOCKER_CONFIG_DIR" push "$IMAGE_URI"
73
+ fi
74
+
75
+ @@ROLL_STEPS@@
76
+
77
+ # The deploy itself is done. End with the smoke, which reports separately.
78
+ echo "==> deploy succeeded"
79
+ bash ./smoke-after-deploy.sh
@@ -0,0 +1,77 @@
1
+ echo "==> setting the stack's ${CFN_PARAM_KEY} to ${TAG} (registers the new task definition revision)"
2
+ stack_parameters() {
3
+ aws cloudformation describe-stacks --stack-name "$IMAGE_STACK" --region "$REGION" \
4
+ --query 'Stacks[0].Parameters[].{k:ParameterKey,v:ParameterValue}' --output json
5
+ }
6
+ stack_parameters > "${SCRATCH_DIR}/params-before.json"
7
+ ALL_PARAM_KEYS="$(jq -r '.[].k' "${SCRATCH_DIR}/params-before.json")"
8
+ CFN_ARGS=()
9
+ found=0
10
+ for key in $ALL_PARAM_KEYS; do
11
+ if [ "$key" = "$CFN_PARAM_KEY" ]; then
12
+ CFN_ARGS+=("ParameterKey=${key},ParameterValue=${TAG}")
13
+ found=1
14
+ else
15
+ CFN_ARGS+=("ParameterKey=${key},UsePreviousValue=true")
16
+ fi
17
+ done
18
+ if [ "$found" != "1" ]; then
19
+ echo "stack ${IMAGE_STACK} has no parameter named ${CFN_PARAM_KEY}; the image was pushed but nothing was rolled" >&2
20
+ exit 1
21
+ fi
22
+
23
+ if UPDATE_OUT="$(aws cloudformation update-stack --stack-name "$IMAGE_STACK" --region "$REGION" \
24
+ --use-previous-template --capabilities CAPABILITY_IAM --parameters "${CFN_ARGS[@]}" \
25
+ --query 'StackId' --output text 2>&1)"; then
26
+ echo "==> waiting for the CloudFormation stack update to settle"
27
+ started=$SECONDS
28
+ while true; do
29
+ stack_status="$(aws cloudformation describe-stacks --stack-name "$IMAGE_STACK" --region "$REGION" \
30
+ --query 'Stacks[0].StackStatus' --output text)"
31
+ case "$stack_status" in
32
+ UPDATE_COMPLETE) echo " -> ${stack_status}"; break ;;
33
+ *ROLLBACK*|*FAILED*)
34
+ echo " -> ${stack_status}: the stack update failed, investigate before rolling anything" >&2
35
+ exit 1 ;;
36
+ esac
37
+ if timed_out "$started"; then
38
+ echo "==> the stack update did not settle within ${SETTLE_TIMEOUT_SECS}s (last: ${stack_status})" >&2
39
+ exit 1
40
+ fi
41
+ sleep 10
42
+ done
43
+ elif echo "$UPDATE_OUT" | grep -q "No updates are to be performed"; then
44
+ echo " -> the stack already has ${TAG}; nothing to update"
45
+ else
46
+ echo "$UPDATE_OUT" >&2
47
+ exit 1
48
+ fi
49
+
50
+ echo "==> checking that only ${CFN_PARAM_KEY} changed"
51
+ stack_parameters > "${SCRATCH_DIR}/params-after.json"
52
+ CHANGED="$(jq -rn --slurpfile before "${SCRATCH_DIR}/params-before.json" --slurpfile after "${SCRATCH_DIR}/params-after.json" '
53
+ ($before[0] | map({(.k): .v}) | add // {}) as $b
54
+ | ($after[0] | map({(.k): .v}) | add // {}) as $a
55
+ | [($b + $a) | keys[] | select($b[.] != $a[.])] | join(" ")')"
56
+ NOW="$(jq -r --arg k "$CFN_PARAM_KEY" '.[] | select(.k == $k) | .v' "${SCRATCH_DIR}/params-after.json")"
57
+ if [ "$NOW" != "$TAG" ] || { [ -n "$CHANGED" ] && [ "$CHANGED" != "$CFN_PARAM_KEY" ]; }; then
58
+ echo "expected only ${CFN_PARAM_KEY} to change to ${TAG}; it is ${NOW} and changed: ${CHANGED:-nothing}" >&2
59
+ exit 1
60
+ fi
61
+ echo " -> ${CFN_PARAM_KEY} is ${TAG}; no other parameter changed"
62
+
63
+ # The newest active revision of the family is what the stack just registered. Roll it only if it
64
+ # really carries the image pushed above: a stack update that registered something else would
65
+ # otherwise roll an old image and still look like a deploy.
66
+ TD="$(aws ecs describe-task-definition --task-definition "$FAMILY" --region "$REGION" \
67
+ --query 'taskDefinition.[family,revision]' --output text | tr '\t' ':')"
68
+ RUNNING_IMAGE="$(aws ecs describe-task-definition --task-definition "$TD" --region "$REGION" \
69
+ --query "taskDefinition.containerDefinitions[?name=='${SERVICE_NAME}'].image | [0]" --output text)"
70
+ if [ "$RUNNING_IMAGE" != "$IMAGE_URI" ]; then
71
+ echo "task definition ${TD} has ${RUNNING_IMAGE}, not ${IMAGE_URI}; refusing to roll" >&2
72
+ exit 1
73
+ fi
74
+ echo "==> task definition ${TD} carries ${SERVICE_NAME}=${TAG}"
75
+
76
+ bash ./deploy-box.sh "$TD"
77
+ export TASKDEF="$TD"
@@ -0,0 +1,12 @@
1
+ # The active task definition names the image repository of every container, so the push goes where
2
+ # the box already pulls from.
3
+ active_image() {
4
+ aws ecs describe-task-definition --task-definition "$FAMILY" --region "$REGION" \
5
+ --query "taskDefinition.containerDefinitions[?name=='${SERVICE_NAME}'].image | [0]" --output text
6
+ }
7
+ CURRENT_IMAGE="$(active_image)"
8
+ case "$CURRENT_IMAGE" in
9
+ None|"") echo "task definition family ${FAMILY} has no container named ${SERVICE_NAME}" >&2; exit 1 ;;
10
+ esac
11
+ ECR_REPOSITORY="${CURRENT_IMAGE#*/}"
12
+ ECR_REPOSITORY="${ECR_REPOSITORY%%[:@]*}"
@@ -0,0 +1,24 @@
1
+ # GENERATED by hecks deploy project -- re-run it to refresh this file rather than hand-editing.
2
+ # Included by the Makefile beside it; settings come from the domain's deployed_to("AwsBox") block
3
+ # (hosting_scripts true).
4
+
5
+ HECKS ?= hecks
6
+ SERVICE ?= @@SERVICE@@
7
+ URL ?= @@URL@@
8
+
9
+ .PHONY: deploy-service smoke-after-deploy check-era
10
+
11
+ # Pushes one container's already-built local image under a fresh tag and rolls it onto the box.
12
+ # Build the image first; see deploy-service.sh.
13
+ deploy-service:
14
+ bash ./deploy-service.sh $(SERVICE)
15
+
16
+ # Waits for the last roll to settle, then dispatches and follows the smoke.
17
+ # SKIP_POST_DEPLOY_SMOKE=1 opts out, DRY_RUN=1 dispatches nothing.
18
+ smoke-after-deploy:
19
+ bash ./smoke-after-deploy.sh
20
+
21
+ # Compares the era a live host reports at GET /version with expected-era.
22
+ check-era:
23
+ @test -n "$(URL)" || { echo "usage: make check-era URL=https://host.example"; exit 1; }
24
+ HECKS_ENVIRONMENT=memory $(HECKS) host.check_era host="$(URL)" expected=expected-era --wait
@@ -9,6 +9,10 @@
9
9
  # are replaced with the RDS stack's values where the task defines them, and every container runs on
10
10
  # the box's own network, so the proxy reaches each one on 127.0.0.1:<port>. Secrets (name + valueFrom)
11
11
  # are written to secrets.json for fetch-secrets.sh to resolve on the box.
12
+ #
13
+ # When the world names origin_env, the named origin secret is compared with those variables in the
14
+ # task definition and the render refuses on any difference (Caddy checks the CDN's header against
15
+ # the secret; the containers check it against their own copy). Values are never printed.
12
16
  set -euo pipefail
13
17
  HERE=$(cd "$(dirname "$0")" && pwd)
14
18
  DB_HOST=${1:?rds endpoint}
@@ -18,6 +22,19 @@ TD=${3:-@@FAMILY@@}
18
22
  DEF=$(aws ecs describe-task-definition --task-definition "$TD" --query 'taskDefinition.containerDefinitions' --output json)
19
23
  echo "rendering from task definition $TD" >&2
20
24
 
25
+ ORIGIN_ENV=$(jq -r '.origin.env // [] | .[]' "$HERE/services.json")
26
+ if [ -n "$ORIGIN_ENV" ]; then
27
+ ORIGIN_SECRET_ID=$(jq -r '.origin.secret' "$HERE/services.json")
28
+ ORIGIN_VALUE=$(aws secretsmanager get-secret-value --secret-id "$ORIGIN_SECRET_ID" --query SecretString --output text)
29
+ for NAME in $ORIGIN_ENV; do
30
+ HOLDERS=$(echo "$DEF" | jq -r --arg n "$NAME" '[.[] | select(any((.environment // [])[]; .name == $n)) | .name] | join(" ")')
31
+ [ -n "$HOLDERS" ] || { echo "refusing to render: origin_env names $NAME, which no container in $TD sets" >&2; exit 1; }
32
+ DIFFERENT=$(echo "$DEF" | jq -r --arg n "$NAME" --arg v "$ORIGIN_VALUE" \
33
+ '[.[] | select(any((.environment // [])[]; .name == $n and .value != $v)) | .name] | join(" ")')
34
+ [ -z "$DIFFERENT" ] || { echo "refusing to render: $NAME in $TD (container: $DIFFERENT) differs from the origin secret $ORIGIN_SECRET_ID; the proxy would refuse every request from the CDN. Make them equal." >&2; exit 1; }
35
+ done
36
+ fi
37
+
21
38
  jq --argjson def "$DEF" --arg host "$DB_HOST" --arg secret "$DB_SECRET" '
22
39
  def log: {driver: "json-file", options: {"max-size": "10m", "max-file": "3"}};
23
40
  def env(c): ((c.environment // []) | map({(.name): .value}) | add // {})
@@ -0,0 +1,271 @@
1
+ #!/usr/bin/env bash
2
+ # GENERATED by hecks deploy project -- re-run it to refresh this file rather than hand-editing.
3
+ # Settings come from the domain's deployed_to("AwsBox") block (hosting_scripts true).
4
+ #
5
+ # Runs the production smoke once a roll of @@STACK@@'s app box has settled, so a deploy ends with a real
6
+ # check of the live site instead of waiting for the next scheduled run. Called at the end of
7
+ # deploy-service.sh and of `make deploy`; also runnable by hand:
8
+ #
9
+ # bash smoke-after-deploy.sh
10
+ #
11
+ # What it does, in order:
12
+ # 1. Waits for the roll to settle: two consecutive checks, one interval apart, must agree that
13
+ @@SETTLE_CHECKS@@
14
+ # Gives up after the timeout. The box is read through SSM, the way deploy-box.sh rolls it.
15
+ # 2. Dispatches the smoke workflow on the configured ref, finds the run that dispatch created (a
16
+ # workflow_dispatch run that did not exist before the dispatch, never a scheduled one), waits for it,
17
+ # and prints its conclusion, the failed check names and its URL.
18
+ #
19
+ # It only ever READS AWS; the smoke itself runs on GitHub against the live site.
20
+ #
21
+ # Exit codes (chosen to stay clear of the ones a failed deploy step produces: 1 from bash, 2 from bash
22
+ # misuse, 254/255 from the aws CLI):
23
+ # 0 the smoke passed, was skipped on purpose (SKIP_POST_DEPLOY_SMOKE=1), or was left running
24
+ # (SMOKE_ASYNC=1)
25
+ # 20 the roll did not settle in time, or a stack ended in a failed state; the smoke was NOT run
26
+ # 21 `gh` is missing or not signed in, or no repository is configured; the smoke was NOT run (the
27
+ # manual command is printed)
28
+ # 22 the smoke ran and FAILED
29
+ # 23 the smoke could not be dispatched, found or finished; result unknown
30
+ #
31
+ # Environment:
32
+ # SKIP_POST_DEPLOY_SMOKE=1 skip everything and say so
33
+ # SMOKE_ASYNC=1 dispatch the smoke and return as soon as its run is found, printing the
34
+ # run URL, instead of waiting for it to finish (exit 0: the result is NOT
35
+ # known yet)
36
+ # DRY_RUN=1 wait for the roll as usual, then print what would be dispatched without
37
+ # dispatching anything
38
+ # SETTLE_TIMEOUT_SECS default 1200
39
+ # SETTLE_CHECK_INTERVAL_SECS default 5
40
+ # SMOKE_RUN_TIMEOUT_SECS default 1800, how long to wait for the run
41
+ # SSM_POLL_SECS default 2, how often to ask SSM whether a command on the box finished
42
+ # REPO GitHub repository (owner/name) holding the workflow
43
+ # WORKFLOW workflow file name
44
+ # SMOKE_REF ref to dispatch on
45
+ # TASKDEF the task definition the box should run (family or family:revision);
46
+ # default: the latest revision of the family. deploy-service.sh and
47
+ # `make deploy TASKDEF=...` pass the one they rolled.
48
+ set -uo pipefail
49
+
50
+ @@SETTINGS@@
51
+
52
+ SETTLE_TIMEOUT_SECS="${SETTLE_TIMEOUT_SECS:-1200}"
53
+ SETTLE_CHECK_INTERVAL_SECS="${SETTLE_CHECK_INTERVAL_SECS:-5}"
54
+ SMOKE_RUN_TIMEOUT_SECS="${SMOKE_RUN_TIMEOUT_SECS:-1800}"
55
+ SSM_POLL_SECS="${SSM_POLL_SECS:-2}"
56
+ MANUAL_COMMAND="bash smoke-after-deploy.sh"
57
+
58
+ if [ "${SKIP_POST_DEPLOY_SMOKE:-}" = "1" ]; then
59
+ echo "==> post-deploy smoke SKIPPED (SKIP_POST_DEPLOY_SMOKE=1)."
60
+ echo " Run it yourself when ready: ${MANUAL_COMMAND}"
61
+ exit 0
62
+ fi
63
+
64
+ if ! command -v gh >/dev/null 2>&1 || ! gh auth status >/dev/null 2>&1; then
65
+ echo "==> post-deploy smoke NOT RUN: the GitHub CLI (gh) is missing or not signed in." >&2
66
+ echo " Run it by hand: gh workflow run ${WORKFLOW} --ref ${SMOKE_REF}" >&2
67
+ echo " or, once gh works: ${MANUAL_COMMAND}" >&2
68
+ exit 21
69
+ fi
70
+
71
+ if [ -z "$REPO" ]; then
72
+ REPO="$(gh repo view --json nameWithOwner --jq .nameWithOwner 2>/dev/null || true)"
73
+ fi
74
+ if [ -z "$REPO" ]; then
75
+ echo "==> post-deploy smoke NOT RUN: no GitHub repository is configured. Set REPO=owner/name, or the" >&2
76
+ echo " smoke_repo setting, or run from a clone gh can read." >&2
77
+ exit 21
78
+ fi
79
+ MANUAL_DISPATCH="gh workflow run ${WORKFLOW} --repo ${REPO} --ref ${SMOKE_REF}"
80
+
81
+ # The status of a stack, or nothing when it cannot be read.
82
+ stack_status() {
83
+ aws cloudformation describe-stacks --stack-name "$1" --region "$REGION" \
84
+ --query 'Stacks[0].StackStatus' --output text 2>/dev/null
85
+ }
86
+
87
+ # Runs a script on the box over SSM and prints its output; fails when it does not succeed.
88
+ box_run() {
89
+ local payload command_id state
90
+ payload="$(jq -n --arg c "$1" '{commands: [$c]}')"
91
+ command_id="$(aws ssm send-command --instance-ids "$BOX_ID" --document-name AWS-RunShellScript \
92
+ --region "$REGION" --parameters "$payload" --query 'Command.CommandId' --output text 2>/dev/null)" || return 1
93
+ for _ in $(seq 1 60); do
94
+ sleep "$SSM_POLL_SECS"
95
+ state="$(aws ssm get-command-invocation --command-id "$command_id" --instance-id "$BOX_ID" \
96
+ --region "$REGION" --query Status --output text 2>/dev/null || echo Pending)"
97
+ [ "$state" != InProgress ] && [ "$state" != Pending ] && break
98
+ done
99
+ [ "$state" = Success ] || return 1
100
+ aws ssm get-command-invocation --command-id "$command_id" --instance-id "$BOX_ID" --region "$REGION" \
101
+ --query StandardOutputContent --output text
102
+ }
103
+
104
+ # Prints "ok <signature>" when the box looks settled, otherwise "wait <why>". The signature names
105
+ # every image the box runs, so two checks agree only when nothing changed between them.
106
+ box_state() {
107
+ local report compose ps name image wanted signature
108
+ report="$(box_run "cd ${BOX_DIR} && cat compose.json && echo ===PS=== && docker compose -f compose.json ps --format '{{.Service}} {{.Status}}'")" \
109
+ || { echo "wait could not read the box"; return; }
110
+ compose="${report%%===PS===*}"
111
+ ps="${report#*===PS===}"
112
+ for name in $EXPECTED_SERVICES; do
113
+ printf '%s\n' "$ps" | grep -E "^${name} Up " | grep -qvE " Up (Less than a second|[0-4] seconds?)|Restarting" \
114
+ || { echo "wait ${name} is not up and settled"; return; }
115
+ done
116
+ signature=""
117
+ for name in $EXPECTED_SERVICES; do
118
+ image="$(printf '%s' "$compose" | jq -r --arg n "$name" '.services[$n].image // empty' 2>/dev/null)"
119
+ [ "$name" = caddy ] || [ -n "$image" ] || { echo "wait ${name} is not in the box's Compose file"; return; }
120
+ if [ -n "$TASK_DEFINITION" ] && [ "$name" != caddy ]; then
121
+ wanted="$(aws ecs describe-task-definition --task-definition "$TASK_DEFINITION" --region "$REGION" \
122
+ --query "taskDefinition.containerDefinitions[?name=='${name}'].image | [0]" --output text 2>/dev/null)"
123
+ [ "$image" = "$wanted" ] || { echo "wait ${name} runs ${image}, ${TASK_DEFINITION} has ${wanted}"; return; }
124
+ fi
125
+ signature="${signature}${name}=${image} "
126
+ done
127
+ echo "ok ${signature}"
128
+ }
129
+
130
+ wait_for_settled_roll() {
131
+ echo "==> waiting for the roll to settle (box ${BOX_STACK}@@IMAGE_STACK_WAIT@@; timeout ${SETTLE_TIMEOUT_SECS}s)"
132
+ local start=$SECONDS previous="" status stacks state signature why s
133
+ BOX_ID="$(aws cloudformation describe-stacks --stack-name "$BOX_STACK" --region "$REGION" \
134
+ --query "Stacks[0].Outputs[?OutputKey=='InstanceId'].OutputValue" --output text 2>/dev/null)"
135
+ if [ -z "$BOX_ID" ] || [ "$BOX_ID" = None ]; then
136
+ echo "==> no instance in stack ${BOX_STACK}. The smoke was NOT run." >&2
137
+ return 20
138
+ fi
139
+ while true; do
140
+ stacks=""
141
+ for s in $WATCHED_STACKS; do
142
+ status="$(stack_status "$s")"
143
+ case "$status" in
144
+ *ROLLBACK*|*FAILED*)
145
+ echo "==> roll did not settle: stack ${s} is ${status}. The smoke was NOT run." >&2
146
+ return 20 ;;
147
+ esac
148
+ stacks="${stacks}${s}=${status:-unreadable} "
149
+ done
150
+ if printf '%s' "$stacks" | tr ' ' '\n' | grep -v '^$' | grep -qvE '=(CREATE|UPDATE)_COMPLETE$'; then
151
+ state="wait ${stacks}"
152
+ else
153
+ state="$(box_state)"
154
+ fi
155
+ if [ "${state%% *}" = "ok" ]; then
156
+ signature="${stacks}${state#ok }"
157
+ if [ "$signature" = "$previous" ]; then
158
+ echo " -> settled on ${state#ok } (two consecutive checks agree)"
159
+ return 0
160
+ fi
161
+ echo " -> looks settled (${state#ok }); confirming in ${SETTLE_CHECK_INTERVAL_SECS}s"
162
+ previous="$signature"
163
+ else
164
+ why="${state#wait }"
165
+ echo " -> not yet: ${why}"
166
+ previous=""
167
+ fi
168
+ if [ $((SECONDS - start + SETTLE_CHECK_INTERVAL_SECS)) -gt "$SETTLE_TIMEOUT_SECS" ]; then
169
+ echo "==> roll did not settle within ${SETTLE_TIMEOUT_SECS}s (last: ${state#wait }). The smoke was NOT run." >&2
170
+ return 20
171
+ fi
172
+ sleep "$SETTLE_CHECK_INTERVAL_SECS"
173
+ done
174
+ }
175
+
176
+ # Ids of the workflow_dispatch runs that exist now, one per line.
177
+ dispatch_run_ids() {
178
+ gh run list --repo "$REPO" --workflow "$WORKFLOW" --event workflow_dispatch --limit 50 \
179
+ --json databaseId --jq '.[].databaseId' 2>/dev/null
180
+ }
181
+
182
+ print_failed_checks() {
183
+ local run_id="$1" log names
184
+ log="$(gh run view "$run_id" --repo "$REPO" --log 2>/dev/null || true)"
185
+ # Log lines are "<job>\t<step>\t<timestamp> <text>"; a smoke that prints " <check>... FAILED: <why>"
186
+ # for each failing check is listed by name.
187
+ names="$(printf '%s\n' "$log" | awk -F'\t' 'NF >= 3 { print $3 }' | sed -E 's/^[0-9T:.-]+Z //' \
188
+ | grep -E '\.\.\. FAILED:' | sed -E 's/^ +//' || true)"
189
+ if [ -n "$names" ]; then
190
+ echo " failed checks:"
191
+ printf '%s\n' "$names" | sed 's/^/ - /'
192
+ else
193
+ echo " (no failed check names in the log; the run may have failed before the checks. Last log lines:)"
194
+ printf '%s\n' "$log" | tail -n 15 | sed 's/^/ /'
195
+ fi
196
+ }
197
+
198
+ run_smoke() {
199
+ local before run_id="" waited=0 url conclusion status
200
+ if ! before="$(dispatch_run_ids)"; then
201
+ echo "==> could not list the smoke workflow's runs, so it was NOT dispatched (it could not be told apart afterwards). Run it by hand: ${MANUAL_DISPATCH}" >&2
202
+ return 23
203
+ fi
204
+
205
+ echo "==> dispatching the production smoke: ${MANUAL_DISPATCH}"
206
+ if ! gh workflow run "$WORKFLOW" --repo "$REPO" --ref "$SMOKE_REF"; then
207
+ echo "==> could not dispatch the smoke workflow. Deploy succeeded; run it by hand: ${MANUAL_DISPATCH}" >&2
208
+ return 23
209
+ fi
210
+
211
+ # The dispatch call does not return the run, so find the workflow_dispatch run that was not there
212
+ # before it (scheduled runs are never candidates).
213
+ while [ -z "$run_id" ] && [ "$waited" -lt 180 ]; do
214
+ sleep 2
215
+ waited=$((waited + 2))
216
+ run_id="$(comm -13 <(printf '%s\n' "$before" | sort) <(dispatch_run_ids | sort) | sort -n | tail -n 1)"
217
+ done
218
+ if [ -z "$run_id" ]; then
219
+ echo "==> dispatched, but the new run never appeared. Deploy succeeded; check https://github.com/${REPO}/actions/workflows/${WORKFLOW}" >&2
220
+ return 23
221
+ fi
222
+ url="https://github.com/${REPO}/actions/runs/${run_id}"
223
+ echo "==> smoke run ${run_id}: ${url}"
224
+ if [ "${SMOKE_ASYNC:-}" = "1" ]; then
225
+ echo "==> deploy succeeded; the smoke is still running (SMOKE_ASYNC=1), so its result is NOT known yet."
226
+ echo " Watch it: gh run watch ${run_id} --repo ${REPO} or open ${url}"
227
+ return 0
228
+ fi
229
+
230
+ waited=0
231
+ while true; do
232
+ status="$(gh run view "$run_id" --repo "$REPO" --json status --jq .status 2>/dev/null || true)"
233
+ [ "$status" = "completed" ] && break
234
+ if [ "$waited" -ge "$SMOKE_RUN_TIMEOUT_SECS" ]; then
235
+ echo "==> deploy succeeded, but the smoke run did not finish within ${SMOKE_RUN_TIMEOUT_SECS}s: see ${url}" >&2
236
+ return 23
237
+ fi
238
+ sleep 5
239
+ waited=$((waited + 5))
240
+ done
241
+
242
+ conclusion="$(gh run view "$run_id" --repo "$REPO" --json conclusion --jq .conclusion || true)"
243
+ echo "==> smoke conclusion: ${conclusion} ${url}"
244
+ if [ "$conclusion" = "success" ]; then
245
+ echo "==> deploy succeeded, post-deploy smoke passed."
246
+ return 0
247
+ fi
248
+ print_failed_checks "$run_id"
249
+ echo "==> deploy succeeded, post-deploy smoke FAILED: see ${url}" >&2
250
+ return 22
251
+ }
252
+
253
+ rc=0
254
+ wait_for_settled_roll || rc=$?
255
+ [ "$rc" -eq 0 ] || exit "$rc"
256
+
257
+ if [ "${DRY_RUN:-}" = "1" ]; then
258
+ echo "==> DRY_RUN=1: would dispatch: ${MANUAL_DISPATCH}"
259
+ newest="$(dispatch_run_ids | sort -n | tail -n 1 || true)"
260
+ if [ -n "$newest" ]; then
261
+ echo " then pick the workflow_dispatch run that appears after it; the newest existing one is"
262
+ echo " https://github.com/${REPO}/actions/runs/${newest} (not the one it would wait for)"
263
+ else
264
+ echo " (no workflow_dispatch runs exist yet)"
265
+ fi
266
+ echo " then wait for it and report its conclusion and failed checks. Nothing dispatched."
267
+ exit 0
268
+ fi
269
+
270
+ run_smoke || rc=$?
271
+ exit "$rc"