bitfab 0.62.5 → 0.62.7

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.
package/cloudReplay.py CHANGED
@@ -11,6 +11,7 @@ import signal
11
11
  import subprocess
12
12
  import sys
13
13
  import tempfile
14
+ import threading
14
15
  import time
15
16
  import uuid
16
17
  from pathlib import Path
@@ -20,6 +21,20 @@ CONFIG = ".bitfab/cloud.json"
20
21
  PREFIX = "bitfab-replay/"
21
22
  DEFAULT_SECRET_PREFIX = "BITFAB_CLOUD_"
22
23
  API_VERSION = "2026-03-10"
24
+ # The SDK finds runs by this title and feeds them through these inputs. Never change them.
25
+ RUN_NAME = "Bitfab replay ${{ inputs.execution_id }}"
26
+ RUNNER_ENV = {
27
+ "BITFAB_CLOUD_REQUEST": "${{ inputs.request }}",
28
+ "BITFAB_EXECUTION_ID": "${{ inputs.execution_id }}",
29
+ "BITFAB_COMMIT_SHA": "${{ github.sha }}",
30
+ }
31
+ # The runner reports its result as a job annotation with this title, so the workflow
32
+ # needs no upload step. Older workflows still upload RESULT_FILE as an artifact.
33
+ RESULT_TITLE = "Bitfab replay result"
34
+ RESULT_FILE = "bitfab-cloud-result.json"
35
+ # Steps older setups generated that the SDK now owns.
36
+ OLD_UPLOAD_STEP = "Save replay identity"
37
+ OLD_JOB_TIMEOUT = 35
23
38
  ITEM_ERROR_FIELDS = (
24
39
  "error",
25
40
  "traceError",
@@ -30,13 +45,61 @@ ITEM_ERROR_FIELDS = (
30
45
  UUID = re.compile(r"^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$")
31
46
  SHA = re.compile(r"^[0-9a-f]{40}$")
32
47
  PIPELINE = re.compile(r"^[\w.][\w.-]*$")
48
+ # Every SDK's replay prints this to stderr as soon as the server creates the experiment.
49
+ EXPERIMENT_LINE = re.compile(rb"^\[replay\] Experiment ([0-9a-f-]{36}):")
50
+ ITEM_ID_FIELDS = ("originalTraceId", "original_trace_id", "traceId", "trace_id")
51
+ ITEM_ERROR_LINES = 20
52
+ ITEM_ERROR_LENGTH = 500
53
+ OUTPUT_TAIL_LENGTH = 4000
54
+ ENV_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
55
+ CLOUD_VALUE_FLAGS = (
56
+ "--registry",
57
+ "--trace-ids",
58
+ "--max-concurrency",
59
+ "--cloud-status",
60
+ "--cloud-watch",
61
+ "--cloud-cancel",
62
+ "--cloud-cleanup",
63
+ "--cloud-request-id",
64
+ "--cloud-include",
65
+ "--cloud-timeout",
66
+ )
67
+ CLOUD_SWITCHES = (
68
+ "--cloud",
69
+ "--cloud-dry-run",
70
+ "--cloud-detach",
71
+ "--cloud-check",
72
+ "--fail-on-error",
73
+ "-h",
74
+ "--help",
75
+ )
76
+ RUNNER_OWNED_FLAGS = {
77
+ "--dry-run": "use --cloud-dry-run to review the snapshot or --cloud-check to resolve traces on the runner",
78
+ "--seed": "seed locally",
79
+ "--cases": "seed locally",
80
+ "--from-trace": "seed locally",
81
+ "--run": "seed locally",
82
+ }
83
+ PATH_FLAGS = ("--params", "--code-change")
84
+ SELECTION_FLAGS = ("--dataset-ids", "--dataset-id", "--resume")
33
85
  HELP = """GitHub cloud replay (requires git, gh login, and Python 3.10+).
34
86
  --cloud PIPELINE --trace-ids UUID[,UUID] [--registry PATH]
35
87
  [--max-concurrency 1..32] [--cloud-request-id UUID]
36
88
  [--cloud-include PATH ...] [--cloud-dry-run] [--cloud-detach]
89
+ [--cloud-timeout MINUTES] [--cloud-check] [--fail-on-error] [REPLAY OPTIONS]
90
+ Every other replay option after the pipeline, such as --name, --dataset-ids,
91
+ --attempts, --mock, or --resume, is passed to the replay on the runner as
92
+ given. --params and --code-change files must be in the snapshot.
93
+ --registry comes from .bitfab/cloud.json; --dry-run and seeding stay local.
94
+ Select traces with --trace-ids (1..100 UUIDs), --dataset-ids, or --resume.
95
+ --fail-on-error exits 1 locally when any replayed item errored.
96
+ --cloud-check runs on GitHub without replaying anything: it checks that every
97
+ configured secret has a value, runs cloud.json's checkCommand when set, and
98
+ resolves the traces with the replay's --dry-run to load the registry.
37
99
  --cloud-status UUID | --cloud-watch UUID | --cloud-cancel UUID
38
100
  --cloud-cleanup UUID
39
- --cloud-init --config SPEC | --cloud-secrets --env-file FILE [NAME ...]
101
+ --cloud-init [--config SPEC] (creates a setup, or brings an existing one up to date)
102
+ --cloud-secrets --env-file FILE [NAME ...]
40
103
  PIPELINE may be any pipeline in the registry, unless .bitfab/cloud.json names one.
41
104
  Snapshot tracked working files without changing HEAD, the index, or local files.
42
105
  New files require explicit --cloud-include. Credentials and ignored files are refused.
@@ -45,6 +108,12 @@ continue on GitHub; watch/status/cleanup can recover them using the printed UUID
45
108
  """
46
109
 
47
110
 
111
+ class CommandError(RuntimeError):
112
+ def __init__(self, message, http_status=None):
113
+ super().__init__(message)
114
+ self.http_status = http_status
115
+
116
+
48
117
  def command(args, *, cwd=None, env=None, timeout=60, input=None):
49
118
  result = subprocess.run(
50
119
  args,
@@ -58,8 +127,13 @@ def command(args, *, cwd=None, env=None, timeout=60, input=None):
58
127
  )
59
128
  if result.returncode:
60
129
  # Child errors can contain credential-bearing URLs or application output.
61
- raise RuntimeError(
62
- f"{args[0]} {args[1]} failed (exit {result.returncode}); check authentication and permissions"
130
+ status = re.search(r"\bHTTP (\d{3})\b", result.stderr or "")
131
+ http_status = int(status[1]) if status else None
132
+ raise CommandError(
133
+ f"{args[0]} {args[1]} failed (exit {result.returncode}"
134
+ + (f", HTTP {http_status}" if http_status else "")
135
+ + "); check authentication and permissions",
136
+ http_status,
63
137
  )
64
138
  return result.stdout.strip()
65
139
 
@@ -194,10 +268,10 @@ def configure_secrets(argv):
194
268
  args = parser.parse_args(argv)
195
269
  root = root_directory()
196
270
  config = configuration(root)
197
- prefix = config.get("secretPrefix", "")
198
- names = args.names or config.get("secrets") or ["BITFAB_API_KEY"]
199
- if not all(re.fullmatch(r"[A-Z_][A-Z0-9_]*", name) for name in names):
200
- raise ValueError("Secret names must be uppercase environment variable names")
271
+ targets = secret_targets(config)
272
+ names = args.names or list(targets) or ["BITFAB_API_KEY"]
273
+ if not all(ENV_NAME.fullmatch(name) for name in names):
274
+ raise ValueError("Secret names must be environment variable names")
201
275
  values = {}
202
276
  for path in args.env_file:
203
277
  for key, value in parse_environment_file(
@@ -209,14 +283,15 @@ def configure_secrets(argv):
209
283
  missing = []
210
284
  empty = []
211
285
  for name in names:
212
- value = values.get(name)
286
+ target = targets.get(name, config.get("secretPrefix", "") + name)
287
+ renamed = "secret" in config.get("env", {}).get(name, {})
288
+ value = values.get(target, values.get(name)) if renamed else values.get(name)
213
289
  if value is None:
214
290
  missing.append(name)
215
291
  continue
216
292
  if not value:
217
293
  empty.append(name)
218
294
  continue
219
- target = prefix + name
220
295
  if not args.dry_run:
221
296
  command(
222
297
  [
@@ -242,6 +317,48 @@ def configure_secrets(argv):
242
317
  }
243
318
 
244
319
 
320
+ def secret_targets(config):
321
+ prefix = config.get("secretPrefix", "")
322
+ targets = {name: prefix + name for name in config.get("secrets", [])}
323
+ for name, source in config.get("env", {}).items():
324
+ if "secret" in source:
325
+ targets[name] = source["secret"]
326
+ return targets
327
+
328
+
329
+ def runner_env(config):
330
+ env = {
331
+ name: "${{ secrets." + target + " }}"
332
+ for name, target in secret_targets(config).items()
333
+ }
334
+ for name, source in config.get("env", {}).items():
335
+ if "variable" in source:
336
+ env[name] = "${{ vars." + source["variable"] + " }}"
337
+ return env
338
+
339
+
340
+ def validate_env(mapping, secrets):
341
+ if not isinstance(mapping, dict):
342
+ raise ValueError(
343
+ 'env must map environment variable names to {"secret": NAME} or {"variable": NAME}'
344
+ )
345
+ for name, source in mapping.items():
346
+ if not ENV_NAME.fullmatch(name) or name in RUNNER_ENV:
347
+ raise ValueError(f"env cannot set {name}")
348
+ if name in secrets:
349
+ raise ValueError(f"{name} is in both secrets and env; list it once")
350
+ if (
351
+ not isinstance(source, dict)
352
+ or len(source) != 1
353
+ or next(iter(source)) not in ("secret", "variable")
354
+ or not isinstance(next(iter(source.values())), str)
355
+ or not ENV_NAME.fullmatch(next(iter(source.values())))
356
+ ):
357
+ raise ValueError(
358
+ f'env.{name} must be {{"secret": "GITHUB_SECRET_NAME"}} or {{"variable": "GITHUB_VARIABLE_NAME"}}'
359
+ )
360
+
361
+
245
362
  def validate_config(config):
246
363
  if config.get("version") != 1 or config.get("provider") != "github":
247
364
  raise ValueError("Run bitfab:setup cloud to configure the GitHub provider")
@@ -266,6 +383,8 @@ def validate_config(config):
266
383
  raise ValueError(
267
384
  "The runner command must execute locally, not recursively dispatch"
268
385
  )
386
+ if "cliCommand" in config:
387
+ validate_cli_command(config["cliCommand"])
269
388
  if config.get("pushTriggersReviewed") is not True:
270
389
  raise ValueError(
271
390
  "Setup must review push-triggered CI/deployments and set pushTriggersReviewed=true"
@@ -283,6 +402,30 @@ def validate_config(config):
283
402
  for name in names
284
403
  ):
285
404
  raise ValueError("secrets must be uppercase environment variable names")
405
+ validate_env(config.get("env", {}), names)
406
+ if "checkCommand" in config:
407
+ check = config["checkCommand"]
408
+ if (
409
+ not isinstance(check, list)
410
+ or not check
411
+ or not all(isinstance(v, str) and v and "\x00" not in v for v in check)
412
+ or any(v.startswith("--cloud") for v in check)
413
+ ):
414
+ raise ValueError(
415
+ "checkCommand must be a nonempty JSON argument array that runs locally"
416
+ )
417
+
418
+
419
+ def validate_cli_command(cli_command):
420
+ if (
421
+ not isinstance(cli_command, list)
422
+ or not cli_command
423
+ or not all(isinstance(v, str) and v and "\x00" not in v for v in cli_command)
424
+ or any(v.startswith("--cloud") for v in cli_command)
425
+ ):
426
+ raise ValueError(
427
+ "cliCommand must be the argument array that starts the SDK's bitfab-replay command from workingDirectory"
428
+ )
286
429
 
287
430
 
288
431
  def check_pipeline(config, pipeline):
@@ -322,7 +465,53 @@ def api(repo, suffix, *, method="GET", payload=None):
322
465
  return json.loads(result) if result else None
323
466
 
324
467
 
468
+ def split_replay_options(argv):
469
+ cloud, options = [], []
470
+ index = 0
471
+ while index < len(argv):
472
+ token = argv[index]
473
+ name = token.split("=", 1)[0]
474
+ if name in CLOUD_VALUE_FLAGS:
475
+ step = 1 if "=" in token else 2
476
+ cloud += argv[index : index + step]
477
+ index += step
478
+ elif name in CLOUD_SWITCHES:
479
+ cloud.append(token)
480
+ index += 1
481
+ elif token.startswith("-"):
482
+ if name in RUNNER_OWNED_FLAGS:
483
+ raise ValueError(
484
+ f"{name} is not sent to the runner; {RUNNER_OWNED_FLAGS[name]}"
485
+ )
486
+ options += token.split("=", 1) if token.startswith("--") else [token]
487
+ index += 1
488
+ following = argv[index] if index < len(argv) else None
489
+ if (
490
+ "=" not in token
491
+ and following is not None
492
+ and not following.startswith("-")
493
+ ):
494
+ options.append(following)
495
+ index += 1
496
+ elif options:
497
+ raise ValueError(
498
+ "Put the pipeline right after --cloud, before replay options"
499
+ )
500
+ else:
501
+ cloud.append(token)
502
+ index += 1
503
+ for value in options:
504
+ if not value or "\x00" in value or "\n" in value or len(value) > 1000:
505
+ raise ValueError(
506
+ "Replay options must be single-line values of at most 1000 characters"
507
+ )
508
+ if sum(len(value) for value in options) > 16000:
509
+ raise ValueError("Replay options exceed 16000 characters")
510
+ return cloud, options
511
+
512
+
325
513
  def parse(argv):
514
+ argv, options = split_replay_options(argv)
326
515
  flags = [value.split("=", 1)[0] for value in argv if value.startswith("--")]
327
516
  if any(flags.count(flag) > 1 for flag in flags if flag != "--cloud-include"):
328
517
  raise ValueError("Duplicate cloud option")
@@ -342,32 +531,68 @@ def parse(argv):
342
531
  parser.add_argument("--cloud-include", action="append", default=[])
343
532
  parser.add_argument("--cloud-dry-run", action="store_true")
344
533
  parser.add_argument("--cloud-detach", action="store_true")
534
+ parser.add_argument("--cloud-timeout", type=int)
535
+ parser.add_argument("--cloud-check", action="store_true")
536
+ parser.add_argument("--fail-on-error", action="store_true")
345
537
  args = parser.parse_args(argv)
538
+ args.options = options
346
539
  operations = [
347
540
  name
348
541
  for name in ("status", "watch", "cancel", "cleanup")
349
542
  if getattr(args, "cloud_" + name)
350
543
  ]
351
544
  if operations:
352
- if len(operations) != 1 or len(argv) != 2:
545
+ if len(operations) != 1 or len(argv) != 2 or options:
353
546
  raise ValueError("Cloud lifecycle commands take only their execution UUID")
354
547
  operation = operations[0]
355
548
  execution_id = getattr(args, "cloud_" + operation)
356
549
  else:
357
- if not args.cloud or not args.pipeline or not args.trace_ids:
550
+ if not args.cloud or not args.pipeline:
358
551
  raise ValueError(HELP)
552
+ if not args.trace_ids and not any(flag in options for flag in SELECTION_FLAGS):
553
+ raise ValueError(
554
+ "Select traces with --trace-ids, --dataset-ids, or --resume"
555
+ )
359
556
  operation = "submit"
360
557
  execution_id = args.cloud_request_id or str(uuid.uuid4())
361
- traces = args.trace_ids.split(",")
362
- if not 1 <= len(traces) <= 100 or not all(UUID.fullmatch(t) for t in traces):
363
- raise ValueError("Supply 1..100 explicit trace UUIDs")
558
+ if args.trace_ids is not None:
559
+ traces = args.trace_ids.split(",")
560
+ if not 1 <= len(traces) <= 100 or not all(
561
+ UUID.fullmatch(t) for t in traces
562
+ ):
563
+ raise ValueError("Supply 1..100 explicit trace UUIDs")
364
564
  if not 1 <= args.max_concurrency <= 32:
365
565
  raise ValueError("--max-concurrency must be 1..32")
566
+ # Self-hosted runners allow jobs of up to 5 days; GitHub-hosted ones stop at 6 hours.
567
+ if args.cloud_timeout is not None and not 1 <= args.cloud_timeout <= 7200:
568
+ raise ValueError("--cloud-timeout must be 1..7200 minutes")
366
569
  if not UUID.fullmatch(execution_id):
367
570
  raise ValueError("Execution ID must be a UUID")
368
571
  return args, operation, execution_id
369
572
 
370
573
 
574
+ def option_paths(options):
575
+ return [value for flag, value in zip(options, options[1:]) if flag in PATH_FLAGS]
576
+
577
+
578
+ def map_option_paths(options, convert):
579
+ mapped = list(options)
580
+ for index, flag in enumerate(options[:-1]):
581
+ if flag in PATH_FLAGS:
582
+ mapped[index + 1] = convert(options[index + 1])
583
+ return mapped
584
+
585
+
586
+ def repository_path(root, value):
587
+ try:
588
+ path = Path(value).resolve().relative_to(root).as_posix()
589
+ except ValueError as error:
590
+ raise ValueError(f"{value} must be inside the repository") from error
591
+ if sensitive(path):
592
+ raise ValueError(f"Refusing credential-like file: {value}")
593
+ return path
594
+
595
+
371
596
  def sensitive(path):
372
597
  parts = Path(path).parts
373
598
  name = Path(path).name.lower()
@@ -391,7 +616,7 @@ def sensitive(path):
391
616
  )
392
617
 
393
618
 
394
- def snapshot(root, config, args, execution_id):
619
+ def snapshot(root, config, args, execution_id, files=()):
395
620
  if git(root, "ls-files", "-u"):
396
621
  raise ValueError("Resolve merge conflicts before snapshotting")
397
622
  head = git(root, "rev-parse", "HEAD")
@@ -428,7 +653,7 @@ def snapshot(root, config, args, execution_id):
428
653
  raise ValueError(
429
654
  f"Refusing credential-like tracked file in snapshot: {path}"
430
655
  )
431
- required = [CONFIG, f".github/workflows/{config['workflow']}"]
656
+ required = [CONFIG, f".github/workflows/{config['workflow']}", *files]
432
657
  if config.get("registry") is not None:
433
658
  required.append(config["registry"])
434
659
  tracked = set(
@@ -547,41 +772,94 @@ def status(record, *, fetch_result=True):
547
772
  if (
548
773
  fetch_result
549
774
  and record["state"] == "completed"
550
- and record["conclusion"] == "success"
551
775
  and not record.get("testRunId")
776
+ and not record.get("check")
777
+ and not record.get("resultChecked")
552
778
  ):
553
- with tempfile.TemporaryDirectory(prefix="bitfab-cloud-result-") as directory:
554
- command(
555
- [
556
- "gh",
557
- "run",
558
- "download",
559
- str(record["runId"]),
560
- "--repo",
561
- record["repository"],
562
- "--name",
563
- "bitfab-replay-" + record["id"],
564
- "--dir",
565
- directory,
566
- ]
567
- )
568
- artifact = Path(directory) / "bitfab-cloud-result.json"
569
- if artifact.is_symlink() or artifact.stat().st_size > 4096:
570
- raise ValueError("Invalid replay result artifact")
571
- result = json.loads(artifact.read_text())
572
- if (
573
- result.get("executionId") != record["id"]
574
- or result.get("commitSha") != record["sha"]
575
- or not UUID.fullmatch(result.get("testRunId", ""))
779
+ success = record["conclusion"] == "success"
780
+ try:
781
+ result = read_result(record)
782
+ except RuntimeError:
783
+ # A run that failed before its replay started reports no result.
784
+ if success:
785
+ raise
786
+ record["resultChecked"] = True
787
+ return record
788
+ if (
789
+ result.get("executionId") != record["id"]
790
+ or result.get("commitSha") != record["sha"]
791
+ ):
792
+ raise ValueError("Replay result does not match this execution")
793
+ if record["request"].get("check"):
794
+ if result.get("check") != "passed" or not isinstance(
795
+ result.get("resolved"), int
576
796
  ):
577
- raise ValueError("Replay result artifact does not match this execution")
578
- counts = replay_counts(result)
579
- record["testRunId"] = result["testRunId"]
580
- if counts is not None:
581
- record["replayed"], record["errored"] = counts
797
+ raise ValueError("Cloud check result does not match this execution")
798
+ record["check"] = "passed"
799
+ record["resolved"] = result["resolved"]
800
+ return record
801
+ if not UUID.fullmatch(result.get("testRunId", "")):
802
+ raise ValueError("Replay result does not match this execution")
803
+ counts = replay_counts(result)
804
+ record["testRunId"] = result["testRunId"]
805
+ if isinstance(result.get("stoppedEarly"), str):
806
+ record["stoppedEarly"] = result["stoppedEarly"]
807
+ if counts is not None:
808
+ record["replayed"], record["errored"] = counts
582
809
  return record
583
810
 
584
811
 
812
+ def read_result(record):
813
+ try:
814
+ result = read_result_annotation(record)
815
+ except RuntimeError:
816
+ # Older workflows still upload the result, so a failed Checks API read
817
+ # must not hide it.
818
+ result = None
819
+ return result or read_result_artifact(record)
820
+
821
+
822
+ def read_result_annotation(record):
823
+ repo = record["repository"]
824
+ jobs = api(repo, f"actions/runs/{record['runId']}/jobs?per_page=100") or {}
825
+ for job in jobs.get("jobs", []):
826
+ check_run = str(job.get("check_run_url", "")).rsplit("/", 1)[-1]
827
+ if not check_run.isdigit():
828
+ continue
829
+ for note in api(repo, f"check-runs/{check_run}/annotations?per_page=100") or []:
830
+ if (
831
+ note.get("title") == RESULT_TITLE
832
+ and len(note.get("message", "")) <= 4096
833
+ ):
834
+ try:
835
+ return json.loads(base64.b64decode(note["message"], validate=True))
836
+ except ValueError as error:
837
+ raise ValueError("Invalid replay result annotation") from error
838
+ return None
839
+
840
+
841
+ def read_result_artifact(record):
842
+ with tempfile.TemporaryDirectory(prefix="bitfab-cloud-result-") as directory:
843
+ command(
844
+ [
845
+ "gh",
846
+ "run",
847
+ "download",
848
+ str(record["runId"]),
849
+ "--repo",
850
+ record["repository"],
851
+ "--name",
852
+ "bitfab-replay-" + record["id"],
853
+ "--dir",
854
+ directory,
855
+ ]
856
+ )
857
+ artifact = Path(directory) / RESULT_FILE
858
+ if artifact.is_symlink() or artifact.stat().st_size > 4096:
859
+ raise ValueError("Invalid replay result artifact")
860
+ return json.loads(artifact.read_text())
861
+
862
+
585
863
  def cleanup(root, record):
586
864
  if record.get("cleaned"):
587
865
  return
@@ -608,16 +886,35 @@ def cleanup(root, record):
608
886
  record["cleaned"] = True
609
887
 
610
888
 
889
+ def http_status(error):
890
+ status = getattr(error, "http_status", None)
891
+ return f" (HTTP {status})" if status else ""
892
+
893
+
611
894
  def preflight(repo, config):
612
- info = api(repo, "")
613
- workflow = api(repo, f"actions/workflows/{config['workflow']}")
895
+ try:
896
+ command(["gh", "auth", "status", "--hostname", "github.com"])
897
+ except RuntimeError as error:
898
+ raise ValueError(
899
+ "gh is not logged in to github.com; run gh auth login"
900
+ ) from error
901
+ try:
902
+ api(repo, "")
903
+ except RuntimeError as error:
904
+ raise ValueError(
905
+ f"The gh account cannot read {repo}{http_status(error)}; log in with an account that has write access to it"
906
+ ) from error
907
+ path = f".github/workflows/{config['workflow']}"
908
+ try:
909
+ workflow = api(repo, f"actions/workflows/{config['workflow']}")
910
+ except RuntimeError as error:
911
+ raise ValueError(
912
+ f"GitHub Actions has not registered {path}{http_status(error)}; merging it to the default branch once registers it, and after that each replay runs the copy in its own snapshot"
913
+ ) from error
614
914
  if workflow.get("state") != "active":
615
- raise ValueError("Replay workflow must be active")
616
- api(
617
- repo,
618
- f"contents/.github/workflows/{config['workflow']}?"
619
- + urlencode({"ref": info["default_branch"]}),
620
- )
915
+ raise ValueError(
916
+ f"{path} is {workflow.get('state')}; enable it in the repository's Actions tab"
917
+ )
621
918
 
622
919
 
623
920
  def run_cli(argv):
@@ -637,17 +934,27 @@ def run_cli(argv):
637
934
  registry = Path(args.registry).resolve().relative_to(root).as_posix()
638
935
  if registry != config.get("registry"):
639
936
  raise ValueError("Registry does not match .bitfab/cloud.json")
937
+ options = map_option_paths(
938
+ args.options, lambda value: repository_path(root, value)
939
+ )
640
940
  request = {
641
941
  "id": execution_id,
642
942
  "pipeline": args.pipeline,
643
- "traceIds": args.trace_ids.split(","),
644
943
  "maxConcurrency": args.max_concurrency,
645
944
  }
945
+ if args.trace_ids is not None:
946
+ request["traceIds"] = args.trace_ids.split(",")
947
+ if options:
948
+ request["options"] = options
949
+ if args.cloud_check:
950
+ request["check"] = True
951
+ if args.cloud_timeout is not None:
952
+ request["timeoutMinutes"] = args.cloud_timeout
646
953
  if args.cloud_dry_run:
647
954
  return {
648
955
  "dryRun": True,
649
956
  "repository": repo,
650
- **snapshot(root, config, args, execution_id),
957
+ **snapshot(root, config, args, execution_id, option_paths(options)),
651
958
  }
652
959
  if path.exists():
653
960
  record = json.loads(path.read_text())
@@ -665,9 +972,10 @@ def run_cli(argv):
665
972
  "Submission stopped before dispatch. Use --cloud-cleanup, then submit a new execution UUID"
666
973
  )
667
974
  else:
668
- command(["gh", "auth", "status", "--hostname", "github.com"])
669
975
  preflight(repo, config)
670
- source = snapshot(root, config, args, execution_id)
976
+ source = snapshot(
977
+ root, config, args, execution_id, option_paths(options)
978
+ )
671
979
  record = {
672
980
  "id": execution_id,
673
981
  "repository": repo,
@@ -677,6 +985,8 @@ def run_cli(argv):
677
985
  "state": "prepared",
678
986
  **source,
679
987
  }
988
+ if args.fail_on_error:
989
+ record["failOnError"] = True
680
990
  save(path, record)
681
991
  print(
682
992
  f"Cloud execution {execution_id}. Recover with --cloud-status {execution_id}",
@@ -684,26 +994,39 @@ def run_cli(argv):
684
994
  flush=True,
685
995
  )
686
996
  ref = "refs/heads/" + record["branch"]
687
- # Empty lease asserts that the temporary remote branch does not exist.
688
- git(
689
- root,
690
- "push",
691
- f"--force-with-lease={ref}:",
692
- "origin",
693
- f"{record['sha']}:{ref}",
694
- )
997
+ try:
998
+ # Empty lease asserts that the temporary remote branch does not exist.
999
+ git(
1000
+ root,
1001
+ "push",
1002
+ f"--force-with-lease={ref}:",
1003
+ "origin",
1004
+ f"{record['sha']}:{ref}",
1005
+ )
1006
+ except RuntimeError as error:
1007
+ raise ValueError(
1008
+ f"Pushing the snapshot branch {record['branch']} to origin failed; check that git can push to {repo}"
1009
+ ) from error
695
1010
  record["state"] = "dispatch_unknown"
696
1011
  save(path, record)
697
1012
  encoded = base64.b64encode(json.dumps(request).encode()).decode()
698
- response = api(
699
- repo,
700
- f"actions/workflows/{config['workflow']}/dispatches",
701
- method="POST",
702
- payload={
703
- "ref": record["branch"],
704
- "inputs": {"execution_id": execution_id, "request": encoded},
705
- },
706
- )
1013
+ try:
1014
+ response = api(
1015
+ repo,
1016
+ f"actions/workflows/{config['workflow']}/dispatches",
1017
+ method="POST",
1018
+ payload={
1019
+ "ref": record["branch"],
1020
+ "inputs": {
1021
+ "execution_id": execution_id,
1022
+ "request": encoded,
1023
+ },
1024
+ },
1025
+ )
1026
+ except RuntimeError as error:
1027
+ raise ValueError(
1028
+ f"Dispatching {config['workflow']} on {record['branch']} failed{http_status(error)}; the gh account needs write access to Actions, and the registered workflow must accept workflow_dispatch. Check the Actions tab, then resume with --cloud-status {execution_id}"
1029
+ ) from error
707
1030
  if isinstance(response, dict) and response.get("workflow_run_id"):
708
1031
  record["runId"] = response["workflow_run_id"]
709
1032
  record["url"] = response.get("html_url")
@@ -729,17 +1052,24 @@ def run_cli(argv):
729
1052
  save(path, record)
730
1053
  return record
731
1054
  if operation == "watch" or (operation == "submit" and not args.cloud_detach):
732
- deadline = time.monotonic() + 40 * 60
733
- while record["state"] != "completed" and time.monotonic() < deadline:
1055
+ # Once GitHub shows the run, its job timeout bounds the wait and Ctrl-C detaches.
1056
+ # A dispatch GitHub never shows as a run would otherwise be waited on forever.
1057
+ started = time.monotonic()
1058
+ while record["state"] != "completed":
734
1059
  status(record)
735
1060
  save(path, record)
1061
+ if (
1062
+ record["state"] == "dispatch_unknown"
1063
+ and time.monotonic() - started >= 10 * 60
1064
+ ):
1065
+ raise ValueError(
1066
+ "GitHub has shown no run for this dispatch after 10 minutes. Check Actions, then resume with --cloud-watch "
1067
+ + execution_id
1068
+ + " or remove the snapshot branch with --cloud-cleanup "
1069
+ + execution_id
1070
+ )
736
1071
  if record["state"] != "completed":
737
1072
  time.sleep(5)
738
- if record["state"] != "completed":
739
- raise ValueError(
740
- "Watch timed out; the job may still run. Resume with --cloud-watch "
741
- + execution_id
742
- )
743
1073
  if record["state"] == "completed" or (
744
1074
  operation == "cleanup" and record["state"] == "prepared"
745
1075
  ):
@@ -763,61 +1093,279 @@ def execute():
763
1093
  check_pipeline(config, request["pipeline"])
764
1094
  if request["id"] != os.environ["BITFAB_EXECUTION_ID"]:
765
1095
  raise ValueError("Replay request does not match the configured execution")
766
- parse(
1096
+ timeout = request.get("timeoutMinutes")
1097
+ traces = (
1098
+ ["--trace-ids", ",".join(request["traceIds"])] if "traceIds" in request else []
1099
+ )
1100
+ options = request.get("options", [])
1101
+ parsed, _, _ = parse(
767
1102
  [
768
1103
  "--cloud",
769
1104
  request["pipeline"],
770
- "--trace-ids",
771
- ",".join(request["traceIds"]),
1105
+ *traces,
772
1106
  "--max-concurrency",
773
1107
  str(request["maxConcurrency"]),
774
1108
  "--cloud-request-id",
775
1109
  request["id"],
1110
+ *([] if timeout is None else ["--cloud-timeout", str(timeout)]),
1111
+ *(["--cloud-check"] if request.get("check") else []),
1112
+ *options,
776
1113
  ]
777
1114
  )
778
- if not os.environ.get("BITFAB_API_KEY"):
779
- raise ValueError("Configure the BITFAB_API_KEY GitHub secret")
1115
+ if parsed.options != options:
1116
+ raise ValueError("Replay options were not in the form the SDK sends")
1117
+ check_secrets(config)
780
1118
  args = [
781
1119
  *config["command"],
782
1120
  request["pipeline"],
783
- "--trace-ids",
784
- ",".join(request["traceIds"]),
1121
+ *traces,
785
1122
  "--max-concurrency",
786
1123
  str(request["maxConcurrency"]),
787
- "--no-code-change",
1124
+ *map_option_paths(options, lambda value: snapshot_file(root, value)),
1125
+ *(
1126
+ []
1127
+ if "--code-change" in options or "--no-code-change" in options
1128
+ else ["--no-code-change"]
1129
+ ),
788
1130
  ]
1131
+ if request.get("check"):
1132
+ summary = run_check(root, config, request, args)
1133
+ write_result(summary)
1134
+ return summary
1135
+ experiment = {}
1136
+ # GitHub cancels and times out a job with SIGINT then SIGTERM; turn SIGTERM into the
1137
+ # same interrupt so the replay is stopped cleanly and its experiment is still reported.
1138
+ previous = signal.signal(signal.SIGTERM, raise_interrupt)
1139
+ try:
1140
+ summary = run_replay(root, config, request, args, timeout, experiment)
1141
+ except BaseException as error:
1142
+ if UUID.fullmatch(experiment.get("id", "")):
1143
+ write_result(
1144
+ {
1145
+ "executionId": request["id"],
1146
+ "commitSha": os.environ["GITHUB_SHA"],
1147
+ "testRunId": experiment["id"],
1148
+ "stoppedEarly": (str(error) or type(error).__name__)[:300],
1149
+ }
1150
+ )
1151
+ raise
1152
+ finally:
1153
+ signal.signal(signal.SIGTERM, previous)
1154
+ write_result(summary)
1155
+ return summary
1156
+
1157
+
1158
+ def snapshot_file(root, value):
1159
+ path = within(root, value)
1160
+ if sensitive(value) or not path.is_file():
1161
+ raise ValueError(f"{value} is not a file in the replay snapshot")
1162
+ return str(path)
1163
+
1164
+
1165
+ def check_secrets(config):
1166
+ targets = {"BITFAB_API_KEY": "BITFAB_API_KEY", **secret_targets(config)}
1167
+ empty = [name for name in targets if not os.environ.get(name)]
1168
+ if empty:
1169
+ raise ValueError(
1170
+ "These replay environment variables are empty on the runner: "
1171
+ + ", ".join(f"{name} (secret {targets[name]})" for name in empty)
1172
+ + ". GitHub passes a secret that does not exist as an empty string. Create each one under Settings, Secrets and variables, Actions, in the repository or in the job's Environment"
1173
+ )
1174
+
1175
+
1176
+ def run_check(root, config, request, args):
1177
+ directory = within(root, config["workingDirectory"])
1178
+ if config.get("checkCommand"):
1179
+ print(f"Running checkCommand: {shlex.join(config['checkCommand'])}", flush=True)
1180
+ code = subprocess.run(
1181
+ config["checkCommand"], cwd=directory, stdin=subprocess.DEVNULL, check=False
1182
+ ).returncode
1183
+ if code:
1184
+ raise ValueError(f"checkCommand exited {code}; its output is above")
1185
+ result = run_command(root, config, [*args, "--dry-run"], None, {})
1186
+ items = result.get("items")
1187
+ if not isinstance(items, list):
1188
+ raise ValueError("The replay dry run did not return its resolved items")
1189
+ errors = item_errors(items)
1190
+ report_item_errors(errors)
1191
+ if errors:
1192
+ raise ValueError(
1193
+ f"{len(errors)} of {len(items)} traces failed to resolve; the errors are above"
1194
+ )
1195
+ return {
1196
+ "executionId": request["id"],
1197
+ "commitSha": os.environ["GITHUB_SHA"],
1198
+ "check": "passed",
1199
+ "resolved": len(items),
1200
+ }
1201
+
1202
+
1203
+ def item_errors(items):
1204
+ errors = []
1205
+ for item in items:
1206
+ if not item_errored(item):
1207
+ continue
1208
+ error = next(
1209
+ item[field] for field in ITEM_ERROR_FIELDS if item.get(field) is not None
1210
+ )
1211
+ if isinstance(error, dict):
1212
+ error = error.get("message") or json.dumps(error)
1213
+ trace = next(
1214
+ (
1215
+ item[field]
1216
+ for field in ITEM_ID_FIELDS
1217
+ if isinstance(item.get(field), str)
1218
+ ),
1219
+ "unknown trace",
1220
+ )
1221
+ text = " ".join(str(error).split())
1222
+ if len(text) > ITEM_ERROR_LENGTH:
1223
+ text = text[:ITEM_ERROR_LENGTH] + "..."
1224
+ errors.append((trace, text))
1225
+ return errors
1226
+
1227
+
1228
+ def report_item_errors(errors):
1229
+ if not errors:
1230
+ return
1231
+ shown = errors[:ITEM_ERROR_LINES]
1232
+ lines = [f"trace {trace}: {text}" for trace, text in shown]
1233
+ if len(errors) > len(shown):
1234
+ lines.append(f"and {len(errors) - len(shown)} more errored items")
1235
+ print("Errored items:", file=sys.stderr)
1236
+ for line in lines:
1237
+ print(" " + line, file=sys.stderr)
1238
+ sys.stderr.flush()
1239
+ summary = os.environ.get("GITHUB_STEP_SUMMARY")
1240
+ if summary:
1241
+ with Path(summary).open("a") as file:
1242
+ file.write(
1243
+ "\n#### Errored items\n\n"
1244
+ + "".join(f"- `{line.replace('`', chr(39))}`\n" for line in lines)
1245
+ )
1246
+
1247
+
1248
+ def raise_interrupt(signum, frame):
1249
+ raise KeyboardInterrupt
1250
+
1251
+
1252
+ def forward_stderr(stream, experiment):
1253
+ for line in iter(stream.readline, b""):
1254
+ sys.stderr.buffer.write(line)
1255
+ sys.stderr.flush()
1256
+ if "id" not in experiment:
1257
+ match = EXPERIMENT_LINE.match(line)
1258
+ if match:
1259
+ experiment["id"] = match[1].decode()
1260
+
1261
+
1262
+ def write_result(summary):
1263
+ # Base64, because GitHub masks every line of a multi-line secret in logs and
1264
+ # annotations, so a JSON credential alone turns each { and } into ***.
1265
+ message = base64.b64encode(json.dumps(summary).encode()).decode()
1266
+ print(f"::notice title={RESULT_TITLE}::{message}", flush=True)
1267
+ if "check" in summary:
1268
+ with Path(os.environ["GITHUB_STEP_SUMMARY"]).open("a") as file:
1269
+ file.write(
1270
+ f"### Bitfab cloud check\n\nPassed: every secret has a value and {summary['resolved']} traces resolved. Commit: `{summary['commitSha']}`\n"
1271
+ )
1272
+ return
1273
+ lines = [
1274
+ f"Test run: `{summary['testRunId']}`",
1275
+ f"Commit: `{summary['commitSha']}`",
1276
+ ]
1277
+ if "stoppedEarly" in summary:
1278
+ lines.append(
1279
+ f"Stopped early: {summary['stoppedEarly']}. Traces that finished are saved in this test run."
1280
+ )
1281
+ else:
1282
+ lines.append(f"Replayed: {summary['replayed']}, errored: {summary['errored']}")
1283
+ with Path(os.environ["GITHUB_STEP_SUMMARY"]).open("a") as file:
1284
+ file.write("### Bitfab replay\n\n" + "\n\n".join(lines) + "\n")
1285
+
1286
+
1287
+ def run_replay(root, config, request, args, timeout, experiment):
1288
+ result = run_command(root, config, args, timeout, experiment)
1289
+ test_run = result.get("testRunId", result.get("test_run_id"))
1290
+ if not isinstance(test_run, str) or not UUID.fullmatch(test_run):
1291
+ raise ValueError("Replay did not return a valid persisted test run UUID")
1292
+ items = result.get("items")
1293
+ if not isinstance(items, list):
1294
+ raise ValueError("Replay did not return its replayed items")
1295
+ replayed = [item for item in items if not carried_over(item)]
1296
+ errors = item_errors(replayed)
1297
+ report_item_errors(errors)
1298
+ return {
1299
+ "executionId": request["id"],
1300
+ "commitSha": os.environ["GITHUB_SHA"],
1301
+ "testRunId": test_run,
1302
+ "replayed": len(replayed),
1303
+ "errored": len(errors),
1304
+ }
1305
+
1306
+
1307
+ def carried_over(item):
1308
+ return isinstance(item, dict) and (
1309
+ item.get("carriedOver") is True or item.get("carried_over") is True
1310
+ )
1311
+
1312
+
1313
+ def print_output_tail(text):
1314
+ tail = text[-OUTPUT_TAIL_LENGTH:].strip()
1315
+ if tail:
1316
+ print(
1317
+ "Last replay output:\n"
1318
+ + "\n".join(" " + line for line in tail.splitlines()),
1319
+ file=sys.stderr,
1320
+ flush=True,
1321
+ )
1322
+
1323
+
1324
+ def run_command(root, config, args, timeout, experiment):
789
1325
  with tempfile.TemporaryFile() as output:
790
1326
  with subprocess.Popen(
791
1327
  args,
792
1328
  cwd=within(root, config["workingDirectory"]),
793
1329
  stdout=output,
1330
+ stderr=subprocess.PIPE,
794
1331
  stdin=subprocess.DEVNULL,
795
1332
  start_new_session=True,
796
1333
  ) as child:
1334
+ reader = threading.Thread(
1335
+ target=forward_stderr, args=(child.stderr, experiment), daemon=True
1336
+ )
1337
+ reader.start()
797
1338
  try:
798
- deadline = time.monotonic() + 25 * 60
1339
+ # Without --cloud-timeout the job's own timeout is the only limit.
1340
+ deadline = None if timeout is None else time.monotonic() + timeout * 60
799
1341
  while child.poll() is None:
800
1342
  if os.fstat(output.fileno()).st_size > 16 * 1024 * 1024:
801
1343
  raise ValueError("Replay output exceeded 16 MiB")
802
- if time.monotonic() >= deadline:
803
- raise subprocess.TimeoutExpired(args, 25 * 60)
1344
+ if deadline is not None and time.monotonic() >= deadline:
1345
+ raise ValueError(
1346
+ f"Replay stopped at the {timeout}-minute --cloud-timeout; traces that finished are saved in Bitfab as an interrupted experiment"
1347
+ )
804
1348
  time.sleep(0.1)
805
1349
  code = child.returncode
806
1350
  except BaseException:
807
1351
  with contextlib.suppress(ProcessLookupError):
808
1352
  os.killpg(child.pid, signal.SIGTERM)
1353
+ # Time for the SDK to mark the experiment interrupted before the hard kill.
809
1354
  with contextlib.suppress(subprocess.TimeoutExpired):
810
- child.wait(timeout=10)
1355
+ child.wait(timeout=30)
811
1356
  with contextlib.suppress(ProcessLookupError):
812
1357
  os.killpg(child.pid, signal.SIGKILL)
813
1358
  child.wait()
814
1359
  raise
1360
+ finally:
1361
+ reader.join(timeout=5)
815
1362
  if output.tell() > 16 * 1024 * 1024:
816
1363
  raise ValueError("Replay output exceeded 16 MiB")
817
1364
  output.seek(0)
818
- text = output.read().decode()
1365
+ text = output.read().decode(errors="replace")
819
1366
  if code:
820
- raise ValueError(f"Replay command exited {code}; inspect the job logs")
1367
+ print_output_tail(text)
1368
+ raise ValueError(f"Replay command exited {code}; its output is above")
821
1369
  decoder = json.JSONDecoder()
822
1370
  result = None
823
1371
  for offset in [0, *[i + 1 for i, value in enumerate(text) if value == "\n"]]:
@@ -828,30 +1376,10 @@ def execute():
828
1376
  result = value
829
1377
  except json.JSONDecodeError:
830
1378
  pass
831
- test_run = (
832
- None if result is None else result.get("testRunId", result.get("test_run_id"))
833
- )
834
- if not isinstance(test_run, str) or not UUID.fullmatch(test_run):
835
- raise ValueError("Replay did not return a valid persisted test run UUID")
836
- items = result.get("items")
837
- if not isinstance(items, list):
838
- raise ValueError("Replay did not return its replayed items")
839
- summary = {
840
- "executionId": request["id"],
841
- "commitSha": os.environ["GITHUB_SHA"],
842
- "testRunId": test_run,
843
- "replayed": len(items),
844
- "errored": sum(1 for item in items if item_errored(item)),
845
- }
846
- (Path(os.environ["RUNNER_TEMP"]) / "bitfab-cloud-result.json").write_text(
847
- json.dumps(summary) + "\n"
848
- )
849
- with Path(os.environ["GITHUB_STEP_SUMMARY"]).open("a") as file:
850
- file.write(
851
- f"### Bitfab replay\n\nTest run: `{test_run}`\n\nCommit: `{os.environ['GITHUB_SHA']}`\n\n"
852
- f"Replayed: {summary['replayed']}, errored: {summary['errored']}\n"
853
- )
854
- return summary
1379
+ if result is None:
1380
+ print_output_tail(text)
1381
+ raise ValueError("Replay did not print a JSON result; its output is above")
1382
+ return result
855
1383
 
856
1384
 
857
1385
  def item_errored(item):
@@ -892,18 +1420,77 @@ def report_errored_items(record):
892
1420
  return 0
893
1421
 
894
1422
 
1423
+ def replay_step(config, cli_command, env):
1424
+ return {
1425
+ "name": "Replay",
1426
+ "working-directory": config["workingDirectory"],
1427
+ "run": shlex.join([*cli_command, "--cloud-execute"]),
1428
+ "env": {**env, **RUNNER_ENV},
1429
+ }
1430
+
1431
+
1432
+ def workflow_document(job_options, setup_steps, replay):
1433
+ """Only settings belong here; everything Bitfab may need to change runs inside the SDK."""
1434
+ job = {
1435
+ "runs-on": job_options.get("runsOn", "ubuntu-24.04"),
1436
+ "steps": [
1437
+ {
1438
+ "name": "Check out replay snapshot",
1439
+ "uses": "actions/checkout@11d5960a326750d5838078e36cf38b85af677262",
1440
+ "with": {"ref": "${{ github.sha }}", "persist-credentials": False},
1441
+ },
1442
+ *setup_steps,
1443
+ replay,
1444
+ ],
1445
+ }
1446
+ for key in ("environment", "services"):
1447
+ if key in job_options:
1448
+ job[key] = job_options[key]
1449
+ return {
1450
+ "name": "Bitfab cloud replay",
1451
+ "run-name": RUN_NAME,
1452
+ "on": {
1453
+ "workflow_dispatch": {
1454
+ "inputs": {
1455
+ "execution_id": {"required": True, "type": "string"},
1456
+ "request": {"required": True, "type": "string"},
1457
+ }
1458
+ }
1459
+ },
1460
+ "permissions": {"contents": "read"},
1461
+ "jobs": {"replay": job},
1462
+ }
1463
+
1464
+
1465
+ def write_new_files(root, outputs):
1466
+ for name in outputs:
1467
+ if within(root, name).exists():
1468
+ raise ValueError(
1469
+ f"Refusing to overwrite {name}; review and edit the existing setup"
1470
+ )
1471
+ for name, content in outputs.items():
1472
+ target = within(root, name)
1473
+ target.parent.mkdir(parents=True, exist_ok=True)
1474
+ with target.open("x") as file:
1475
+ file.write(content)
1476
+
1477
+
895
1478
  def initialize(argv):
896
1479
  parser = argparse.ArgumentParser(
897
1480
  prog="bitfab-replay --cloud-init",
898
- description="Install direct GitHub cloud replay from a reviewed JSON setup specification. Existing files are never overwritten.",
1481
+ description="Install direct GitHub cloud replay from a reviewed JSON setup specification, or bring an existing setup up to date in place. Safe to run again.",
899
1482
  )
900
1483
  parser.add_argument(
901
1484
  "--config",
902
- required=True,
903
- help="JSON file with cloud config, cliCommand, setupSteps, optional pipeline (omit or null to allow every registry pipeline), secrets, variables, environment, services, runsOn",
1485
+ help='JSON file with cloud config, cliCommand, setupSteps, optional pipeline (omit or null to allow every registry pipeline), secrets, variables, env (runner variable names mapped to {"secret": NAME} or {"variable": NAME} for renamed secrets and repository variables), checkCommand, environment, services, runsOn. Required for a new setup; for an existing one only cliCommand is read, and only when the workflow does not already show it',
904
1486
  )
905
1487
  args = parser.parse_args(argv)
906
- spec = json.loads(Path(args.config).read_text())
1488
+ spec = json.loads(Path(args.config).read_text()) if args.config else None
1489
+ root = root_directory()
1490
+ if within(root, CONFIG).exists():
1491
+ return update_existing(root, spec)
1492
+ if spec is None:
1493
+ raise ValueError(f"No {CONFIG} yet; pass --config with a setup specification")
907
1494
  config = {
908
1495
  key: spec[key]
909
1496
  for key in (
@@ -924,7 +1511,17 @@ def initialize(argv):
924
1511
  secret_prefix = DEFAULT_SECRET_PREFIX
925
1512
  config["secrets"] = secrets
926
1513
  config["secretPrefix"] = secret_prefix
1514
+ if not isinstance(variables, list):
1515
+ raise ValueError("variables must be a list of names")
1516
+ env = dict(spec.get("env") or {})
1517
+ for name in variables:
1518
+ env.setdefault(name, {"variable": name})
1519
+ if env:
1520
+ config["env"] = env
1521
+ if spec.get("checkCommand") is not None:
1522
+ config["checkCommand"] = spec["checkCommand"]
927
1523
  validate_config(config)
1524
+ validate_cli_command(spec.get("cliCommand"))
928
1525
  if set(secrets) & set(variables):
929
1526
  raise ValueError("Secret names and variable names must not overlap")
930
1527
  if "BITFAB_API_KEY" not in secrets or not all(
@@ -933,16 +1530,6 @@ def initialize(argv):
933
1530
  raise ValueError(
934
1531
  "Supply uppercase secret/variable names, including BITFAB_API_KEY; never values"
935
1532
  )
936
- cli_command = spec.get("cliCommand")
937
- if (
938
- not isinstance(cli_command, list)
939
- or not cli_command
940
- or not all(isinstance(v, str) and v and "\x00" not in v for v in cli_command)
941
- or any(v.startswith("--cloud") for v in cli_command)
942
- ):
943
- raise ValueError(
944
- "cliCommand must be the argument array that starts the SDK's bitfab-replay command from workingDirectory"
945
- )
946
1533
  steps = spec.get("setupSteps")
947
1534
  if (
948
1535
  not isinstance(steps, list)
@@ -950,81 +1537,145 @@ def initialize(argv):
950
1537
  or not all(isinstance(step, dict) for step in steps)
951
1538
  ):
952
1539
  raise ValueError("setupSteps must contain reviewed GitHub Actions setup steps")
953
- env = {key: "${{ secrets." + secret_prefix + key + " }}" for key in secrets}
954
- env.update({key: "${{ vars." + key + " }}" for key in variables})
955
- env.update(
956
- BITFAB_CLOUD_REQUEST="${{ inputs.request }}",
957
- BITFAB_EXECUTION_ID="${{ inputs.execution_id }}",
958
- BITFAB_COMMIT_SHA="${{ github.sha }}",
1540
+ workflow = workflow_document(
1541
+ spec, steps, replay_step(config, spec["cliCommand"], runner_env(config))
959
1542
  )
960
- job = {
961
- "runs-on": spec.get("runsOn", "ubuntu-24.04"),
962
- "timeout-minutes": 35,
963
- "steps": [
964
- {
965
- "name": "Check out replay snapshot",
966
- "uses": "actions/checkout@11d5960a326750d5838078e36cf38b85af677262",
967
- "with": {"ref": "${{ github.sha }}", "persist-credentials": False},
968
- },
969
- *steps,
970
- {
971
- "name": "Replay",
972
- "working-directory": config["workingDirectory"],
973
- "run": shlex.join([*cli_command, "--cloud-execute"]),
974
- "env": env,
975
- },
976
- {
977
- "name": "Save replay identity",
978
- "if": "always()",
979
- "uses": "actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02",
980
- "with": {
981
- "name": "bitfab-replay-${{ inputs.execution_id }}",
982
- "path": "${{ runner.temp }}/bitfab-cloud-result.json",
983
- "if-no-files-found": "ignore",
984
- "retention-days": 7,
985
- },
986
- },
987
- ],
988
- }
989
- for key in ("environment", "services"):
990
- if key in spec:
991
- job[key] = spec[key]
992
- workflow = {
993
- "name": "Bitfab cloud replay",
994
- "run-name": "Bitfab replay ${{ inputs.execution_id }}",
995
- "on": {
996
- "workflow_dispatch": {
997
- "inputs": {
998
- "execution_id": {"required": True, "type": "string"},
999
- "request": {"required": True, "type": "string"},
1000
- }
1001
- }
1002
- },
1003
- "permissions": {"contents": "read"},
1004
- "jobs": {"replay": job},
1005
- }
1006
- root = root_directory()
1007
1543
  outputs = {
1008
1544
  CONFIG: json.dumps(config, indent=2) + "\n",
1009
1545
  f".github/workflows/{config['workflow']}": json.dumps(workflow, indent=2)
1010
1546
  + "\n",
1011
1547
  }
1012
- for name in outputs:
1013
- target = within(root, name)
1014
- if target.exists():
1015
- raise ValueError(
1016
- f"Refusing to overwrite {name}; review and edit the existing setup"
1017
- )
1018
- for name, content in outputs.items():
1019
- target = within(root, name)
1020
- target.parent.mkdir(parents=True, exist_ok=True)
1021
- with target.open("x") as file:
1022
- file.write(content)
1548
+ write_new_files(root, outputs)
1023
1549
  return {
1024
1550
  "files": list(outputs),
1025
- "requiredSecrets": [secret_prefix + name for name in secrets],
1026
- "requiredVariables": variables,
1027
- "next": "Configure secrets securely, review push triggers, merge the workflow to the default branch, then run --cloud-dry-run",
1551
+ "requiredSecrets": sorted(set(secret_targets(config).values())),
1552
+ "requiredVariables": sorted(
1553
+ source["variable"]
1554
+ for source in config.get("env", {}).values()
1555
+ if "variable" in source
1556
+ ),
1557
+ "next": "Configure secrets securely, review push triggers, get the workflow registered with GitHub Actions (merging it to the default branch once does that), run --cloud-dry-run, then --cloud-check",
1558
+ }
1559
+
1560
+
1561
+ def replace_file(target, content):
1562
+ with tempfile.NamedTemporaryFile(mode="w", dir=target.parent, delete=False) as file:
1563
+ file.write(content)
1564
+ temporary = file.name
1565
+ os.replace(temporary, target)
1566
+
1567
+
1568
+ def update_existing(root, spec):
1569
+ """Existing files win: only the Replay step and steps older setups generated change."""
1570
+ config = json.loads(within(root, CONFIG).read_text())
1571
+ validate_config(config)
1572
+ name = f".github/workflows/{config['workflow']}"
1573
+ workflow_path = within(root, name)
1574
+ text = workflow_path.read_text() if workflow_path.is_file() else ""
1575
+ try:
1576
+ workflow = json.loads(text)
1577
+ job = workflow["jobs"]["replay"]
1578
+ steps = job["steps"]
1579
+ except (ValueError, KeyError, TypeError) as error:
1580
+ # A workflow the customer rewrote as YAML is current when nothing older remains.
1581
+ if (
1582
+ "--cloud-execute" in text
1583
+ and OLD_UPLOAD_STEP not in text
1584
+ and ".bitfab/cloudReplay.py" not in text
1585
+ and not re.search(rf"timeout-minutes:\s*{OLD_JOB_TIMEOUT}\b", text)
1586
+ ):
1587
+ return {"files": [], "updated": False, "next": "Already up to date"}
1588
+ raise ValueError(
1589
+ f"{name} is missing or was not generated by --cloud-init; edit its Replay step by hand to run the SDK's bitfab-replay command with --cloud-execute"
1590
+ ) from error
1591
+ replay = [
1592
+ index
1593
+ for index, step in enumerate(steps)
1594
+ if isinstance(step, dict) and step.get("name") == "Replay"
1595
+ ]
1596
+ if len(replay) != 1:
1597
+ raise ValueError(f"{name} must contain exactly one step named Replay")
1598
+ step = steps[replay[0]]
1599
+ words = shlex.split(step.get("run", ""))
1600
+ if spec is not None and spec.get("cliCommand") is not None:
1601
+ cli_command = spec["cliCommand"]
1602
+ elif words[-1:] == ["--cloud-execute"]:
1603
+ cli_command = words[:-1]
1604
+ elif config.get("cliCommand") is not None:
1605
+ cli_command = config["cliCommand"]
1606
+ else:
1607
+ raise ValueError(
1608
+ "The Replay step does not run the SDK command; pass --config with a file whose cliCommand starts bitfab-replay from workingDirectory"
1609
+ )
1610
+ validate_cli_command(cli_command)
1611
+ env = {
1612
+ key: value
1613
+ for key, value in (step.get("env") or {}).items()
1614
+ if key not in RUNNER_ENV
1615
+ }
1616
+ declared = runner_env(config)
1617
+ conflicts = sorted(
1618
+ name for name in declared if name in env and env[name] != declared[name]
1619
+ )
1620
+ undeclared = sorted(name for name in env if name not in declared)
1621
+ env = {**declared, **env}
1622
+ extra = {
1623
+ key: value
1624
+ for key, value in step.items()
1625
+ if key not in ("name", "uses", "with", "run", "working-directory", "env")
1626
+ }
1627
+ before = json.dumps([config, workflow], sort_keys=True)
1628
+ # Edit in place so the customer's checkout options, extra steps, and job
1629
+ # settings survive; only the Replay step and our old result upload change.
1630
+ job["steps"] = [
1631
+ {**replay_step(config, cli_command, env), **extra}
1632
+ if index == replay[0]
1633
+ else entry
1634
+ for index, entry in enumerate(steps)
1635
+ if not (
1636
+ isinstance(entry, dict)
1637
+ and entry.get("name") == OLD_UPLOAD_STEP
1638
+ and str(entry.get("uses", "")).startswith("actions/upload-artifact@")
1639
+ )
1640
+ ]
1641
+ # The limit older setups generated; a value the customer chose stays.
1642
+ if job.get("timeout-minutes") == OLD_JOB_TIMEOUT:
1643
+ del job["timeout-minutes"]
1644
+ config.pop("cliCommand", None)
1645
+ # Setups before the SDK carried the script left a copy that nothing reads now.
1646
+ old_script = ".bitfab/cloudReplay.py"
1647
+ removable = [old_script] if within(root, old_script).exists() else []
1648
+ mismatches = {}
1649
+ if conflicts:
1650
+ mismatches["conflicts"] = conflicts
1651
+ if undeclared:
1652
+ mismatches["undeclared"] = undeclared
1653
+ if mismatches:
1654
+ mismatches["mismatchNext"] = (
1655
+ "The Replay step sets these differently from, or in addition to, .bitfab/cloud.json. "
1656
+ 'Describe each one in cloud.json "env" as {"secret": NAME} or {"variable": NAME} '
1657
+ "so --cloud-secrets and the runner's empty-secret check see it; the step was left as it is"
1658
+ )
1659
+ if json.dumps([config, workflow], sort_keys=True) == before:
1660
+ return {
1661
+ "files": [],
1662
+ "updated": False,
1663
+ "removable": removable,
1664
+ **mismatches,
1665
+ "next": "Already up to date",
1666
+ }
1667
+ outputs = {
1668
+ CONFIG: json.dumps(config, indent=2) + "\n",
1669
+ name: json.dumps(workflow, indent=2) + "\n",
1670
+ }
1671
+ for output, content in outputs.items():
1672
+ replace_file(within(root, output), content)
1673
+ return {
1674
+ "files": list(outputs),
1675
+ "updated": True,
1676
+ "removable": removable,
1677
+ **mismatches,
1678
+ "next": "Review the diff, then run --cloud-dry-run; replays use the workflow in their own snapshot, so the change applies without merging",
1028
1679
  }
1029
1680
 
1030
1681
 
@@ -1040,9 +1691,26 @@ def main():
1040
1691
  result = run_cli(sys.argv[1:])
1041
1692
  print(json.dumps(result, indent=2))
1042
1693
  if result.get("state") == "completed" and result.get("conclusion") != "success":
1694
+ if result.get("request", {}).get("check"):
1695
+ print(
1696
+ f"Cloud check failed. The Replay step log names what is missing: {result.get('url')}",
1697
+ file=sys.stderr,
1698
+ )
1699
+ elif result.get("testRunId"):
1700
+ print(
1701
+ f"Cloud replay stopped early ({result.get('stoppedEarly', result.get('conclusion'))}). Traces that finished are saved in test run {result['testRunId']}.",
1702
+ file=sys.stderr,
1703
+ )
1043
1704
  return 1
1044
1705
  if result.get("state") == "completed":
1045
- return report_errored_items(result)
1706
+ code = report_errored_items(result)
1707
+ if result.get("failOnError") and result.get("errored"):
1708
+ print(
1709
+ "Cloud replay: exiting 1 because of --fail-on-error",
1710
+ file=sys.stderr,
1711
+ )
1712
+ return 1
1713
+ return code
1046
1714
  return 0
1047
1715
  except KeyboardInterrupt:
1048
1716
  print(