bitfab 0.62.4 → 0.62.5

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 17cb4215f2940d7b31473b2de0b4e5c1a0f87cc9eaed67050cb78f866ea1a24d
4
- data.tar.gz: cab44aa11d1bb5f8a30cbfc38f2507dfc53e230821d0851279ace1ed24a19dff
3
+ metadata.gz: f7055ee2b869bb3acd9740221ebe869d67564ec79faa41ec34c3de2d1e5c309c
4
+ data.tar.gz: 1e1fa48e73ebe238b50a1d88804512026dfa0e1faa833422c1646afce461d25c
5
5
  SHA512:
6
- metadata.gz: 7f2c5073f314c9afe5b9dccb6c101476803a00602467089d618fe0131be631c97f2768bd240ae31728875953605f508517bfcb041339b43795dacba6bd50f695
7
- data.tar.gz: cb1933586e1b8aeaf0233b7c163abb10cbe6805b156d26489c596cbbdba972984614ac5245aab38d0e35ad0333ae09fd7edc27f42b1ae9d826fef05aaa44c09b
6
+ metadata.gz: 79492cc60cb6ef608357d1bde581d632fdbd8140cf89b1a622e4a5b9efbbd265ac558d0342efa0d165252ab43233aa458bd0eb2a2f3e59770c02244742889eea
7
+ data.tar.gz: 213a1c8ce50982a81b59fdfce1ecc6e1f724e6e75833a08793da16307037a2c4e5b10d631365c2b76468b0596bfcc524426bd986a39539a9339357b58ef7ee06
@@ -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,17 @@ 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}):")
33
50
  HELP = """GitHub cloud replay (requires git, gh login, and Python 3.10+).
34
51
  --cloud PIPELINE --trace-ids UUID[,UUID] [--registry PATH]
35
52
  [--max-concurrency 1..32] [--cloud-request-id UUID]
36
53
  [--cloud-include PATH ...] [--cloud-dry-run] [--cloud-detach]
54
+ [--cloud-timeout MINUTES]
37
55
  --cloud-status UUID | --cloud-watch UUID | --cloud-cancel UUID
38
56
  --cloud-cleanup UUID
39
- --cloud-init --config SPEC | --cloud-secrets --env-file FILE [NAME ...]
57
+ --cloud-init [--config SPEC] (creates a setup, or brings an existing one up to date)
58
+ --cloud-secrets --env-file FILE [NAME ...]
40
59
  PIPELINE may be any pipeline in the registry, unless .bitfab/cloud.json names one.
41
60
  Snapshot tracked working files without changing HEAD, the index, or local files.
42
61
  New files require explicit --cloud-include. Credentials and ignored files are refused.
@@ -266,6 +285,8 @@ def validate_config(config):
266
285
  raise ValueError(
267
286
  "The runner command must execute locally, not recursively dispatch"
268
287
  )
288
+ if "cliCommand" in config:
289
+ validate_cli_command(config["cliCommand"])
269
290
  if config.get("pushTriggersReviewed") is not True:
270
291
  raise ValueError(
271
292
  "Setup must review push-triggered CI/deployments and set pushTriggersReviewed=true"
@@ -285,6 +306,18 @@ def validate_config(config):
285
306
  raise ValueError("secrets must be uppercase environment variable names")
286
307
 
287
308
 
309
+ def validate_cli_command(cli_command):
310
+ if (
311
+ not isinstance(cli_command, list)
312
+ or not cli_command
313
+ or not all(isinstance(v, str) and v and "\x00" not in v for v in cli_command)
314
+ or any(v.startswith("--cloud") for v in cli_command)
315
+ ):
316
+ raise ValueError(
317
+ "cliCommand must be the argument array that starts the SDK's bitfab-replay command from workingDirectory"
318
+ )
319
+
320
+
288
321
  def check_pipeline(config, pipeline):
289
322
  if not isinstance(pipeline, str) or not PIPELINE.fullmatch(pipeline):
290
323
  raise ValueError("Invalid pipeline name")
@@ -342,6 +375,7 @@ def parse(argv):
342
375
  parser.add_argument("--cloud-include", action="append", default=[])
343
376
  parser.add_argument("--cloud-dry-run", action="store_true")
344
377
  parser.add_argument("--cloud-detach", action="store_true")
378
+ parser.add_argument("--cloud-timeout", type=int)
345
379
  args = parser.parse_args(argv)
346
380
  operations = [
347
381
  name
@@ -363,6 +397,9 @@ def parse(argv):
363
397
  raise ValueError("Supply 1..100 explicit trace UUIDs")
364
398
  if not 1 <= args.max_concurrency <= 32:
365
399
  raise ValueError("--max-concurrency must be 1..32")
400
+ # Self-hosted runners allow jobs of up to 5 days; GitHub-hosted ones stop at 6 hours.
401
+ if args.cloud_timeout is not None and not 1 <= args.cloud_timeout <= 7200:
402
+ raise ValueError("--cloud-timeout must be 1..7200 minutes")
366
403
  if not UUID.fullmatch(execution_id):
367
404
  raise ValueError("Execution ID must be a UUID")
368
405
  return args, operation, execution_id
@@ -547,39 +584,82 @@ def status(record, *, fetch_result=True):
547
584
  if (
548
585
  fetch_result
549
586
  and record["state"] == "completed"
550
- and record["conclusion"] == "success"
551
587
  and not record.get("testRunId")
588
+ and not record.get("resultChecked")
552
589
  ):
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())
590
+ success = record["conclusion"] == "success"
591
+ try:
592
+ result = read_result(record)
593
+ except RuntimeError:
594
+ # A run that failed before its replay started reports no result.
595
+ if success:
596
+ raise
597
+ record["resultChecked"] = True
598
+ return record
599
+ if (
600
+ result.get("executionId") != record["id"]
601
+ or result.get("commitSha") != record["sha"]
602
+ or not UUID.fullmatch(result.get("testRunId", ""))
603
+ ):
604
+ raise ValueError("Replay result does not match this execution")
605
+ counts = replay_counts(result)
606
+ record["testRunId"] = result["testRunId"]
607
+ if isinstance(result.get("stoppedEarly"), str):
608
+ record["stoppedEarly"] = result["stoppedEarly"]
609
+ if counts is not None:
610
+ record["replayed"], record["errored"] = counts
611
+ return record
612
+
613
+
614
+ def read_result(record):
615
+ try:
616
+ result = read_result_annotation(record)
617
+ except RuntimeError:
618
+ # Older workflows still upload the result, so a failed Checks API read
619
+ # must not hide it.
620
+ result = None
621
+ return result or read_result_artifact(record)
622
+
623
+
624
+ def read_result_annotation(record):
625
+ repo = record["repository"]
626
+ jobs = api(repo, f"actions/runs/{record['runId']}/jobs?per_page=100") or {}
627
+ for job in jobs.get("jobs", []):
628
+ check_run = str(job.get("check_run_url", "")).rsplit("/", 1)[-1]
629
+ if not check_run.isdigit():
630
+ continue
631
+ for note in api(repo, f"check-runs/{check_run}/annotations?per_page=100") or []:
572
632
  if (
573
- result.get("executionId") != record["id"]
574
- or result.get("commitSha") != record["sha"]
575
- or not UUID.fullmatch(result.get("testRunId", ""))
633
+ note.get("title") == RESULT_TITLE
634
+ and len(note.get("message", "")) <= 4096
576
635
  ):
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
582
- return record
636
+ try:
637
+ return json.loads(base64.b64decode(note["message"], validate=True))
638
+ except ValueError as error:
639
+ raise ValueError("Invalid replay result annotation") from error
640
+ return None
641
+
642
+
643
+ def read_result_artifact(record):
644
+ with tempfile.TemporaryDirectory(prefix="bitfab-cloud-result-") as directory:
645
+ command(
646
+ [
647
+ "gh",
648
+ "run",
649
+ "download",
650
+ str(record["runId"]),
651
+ "--repo",
652
+ record["repository"],
653
+ "--name",
654
+ "bitfab-replay-" + record["id"],
655
+ "--dir",
656
+ directory,
657
+ ]
658
+ )
659
+ artifact = Path(directory) / RESULT_FILE
660
+ if artifact.is_symlink() or artifact.stat().st_size > 4096:
661
+ raise ValueError("Invalid replay result artifact")
662
+ return json.loads(artifact.read_text())
583
663
 
584
664
 
585
665
  def cleanup(root, record):
@@ -643,6 +723,8 @@ def run_cli(argv):
643
723
  "traceIds": args.trace_ids.split(","),
644
724
  "maxConcurrency": args.max_concurrency,
645
725
  }
726
+ if args.cloud_timeout is not None:
727
+ request["timeoutMinutes"] = args.cloud_timeout
646
728
  if args.cloud_dry_run:
647
729
  return {
648
730
  "dryRun": True,
@@ -729,17 +811,24 @@ def run_cli(argv):
729
811
  save(path, record)
730
812
  return record
731
813
  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:
814
+ # Once GitHub shows the run, its job timeout bounds the wait and Ctrl-C detaches.
815
+ # A dispatch GitHub never shows as a run would otherwise be waited on forever.
816
+ started = time.monotonic()
817
+ while record["state"] != "completed":
734
818
  status(record)
735
819
  save(path, record)
820
+ if (
821
+ record["state"] == "dispatch_unknown"
822
+ and time.monotonic() - started >= 10 * 60
823
+ ):
824
+ raise ValueError(
825
+ "GitHub has shown no run for this dispatch after 10 minutes. Check Actions, then resume with --cloud-watch "
826
+ + execution_id
827
+ + " or remove the snapshot branch with --cloud-cleanup "
828
+ + execution_id
829
+ )
736
830
  if record["state"] != "completed":
737
831
  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
832
  if record["state"] == "completed" or (
744
833
  operation == "cleanup" and record["state"] == "prepared"
745
834
  ):
@@ -763,6 +852,7 @@ def execute():
763
852
  check_pipeline(config, request["pipeline"])
764
853
  if request["id"] != os.environ["BITFAB_EXECUTION_ID"]:
765
854
  raise ValueError("Replay request does not match the configured execution")
855
+ timeout = request.get("timeoutMinutes")
766
856
  parse(
767
857
  [
768
858
  "--cloud",
@@ -773,6 +863,7 @@ def execute():
773
863
  str(request["maxConcurrency"]),
774
864
  "--cloud-request-id",
775
865
  request["id"],
866
+ *([] if timeout is None else ["--cloud-timeout", str(timeout)]),
776
867
  ]
777
868
  )
778
869
  if not os.environ.get("BITFAB_API_KEY"):
@@ -786,32 +877,100 @@ def execute():
786
877
  str(request["maxConcurrency"]),
787
878
  "--no-code-change",
788
879
  ]
880
+ experiment = {}
881
+ # GitHub cancels and times out a job with SIGINT then SIGTERM; turn SIGTERM into the
882
+ # same interrupt so the replay is stopped cleanly and its experiment is still reported.
883
+ previous = signal.signal(signal.SIGTERM, raise_interrupt)
884
+ try:
885
+ summary = run_replay(root, config, request, args, timeout, experiment)
886
+ except BaseException as error:
887
+ if UUID.fullmatch(experiment.get("id", "")):
888
+ write_result(
889
+ {
890
+ "executionId": request["id"],
891
+ "commitSha": os.environ["GITHUB_SHA"],
892
+ "testRunId": experiment["id"],
893
+ "stoppedEarly": (str(error) or type(error).__name__)[:300],
894
+ }
895
+ )
896
+ raise
897
+ finally:
898
+ signal.signal(signal.SIGTERM, previous)
899
+ write_result(summary)
900
+ return summary
901
+
902
+
903
+ def raise_interrupt(signum, frame):
904
+ raise KeyboardInterrupt
905
+
906
+
907
+ def forward_stderr(stream, experiment):
908
+ for line in iter(stream.readline, b""):
909
+ sys.stderr.buffer.write(line)
910
+ sys.stderr.flush()
911
+ if "id" not in experiment:
912
+ match = EXPERIMENT_LINE.match(line)
913
+ if match:
914
+ experiment["id"] = match[1].decode()
915
+
916
+
917
+ def write_result(summary):
918
+ # Base64, because GitHub masks every line of a multi-line secret in logs and
919
+ # annotations, so a JSON credential alone turns each { and } into ***.
920
+ message = base64.b64encode(json.dumps(summary).encode()).decode()
921
+ print(f"::notice title={RESULT_TITLE}::{message}", flush=True)
922
+ lines = [
923
+ f"Test run: `{summary['testRunId']}`",
924
+ f"Commit: `{summary['commitSha']}`",
925
+ ]
926
+ if "stoppedEarly" in summary:
927
+ lines.append(
928
+ f"Stopped early: {summary['stoppedEarly']}. Traces that finished are saved in this test run."
929
+ )
930
+ else:
931
+ lines.append(f"Replayed: {summary['replayed']}, errored: {summary['errored']}")
932
+ with Path(os.environ["GITHUB_STEP_SUMMARY"]).open("a") as file:
933
+ file.write("### Bitfab replay\n\n" + "\n\n".join(lines) + "\n")
934
+
935
+
936
+ def run_replay(root, config, request, args, timeout, experiment):
789
937
  with tempfile.TemporaryFile() as output:
790
938
  with subprocess.Popen(
791
939
  args,
792
940
  cwd=within(root, config["workingDirectory"]),
793
941
  stdout=output,
942
+ stderr=subprocess.PIPE,
794
943
  stdin=subprocess.DEVNULL,
795
944
  start_new_session=True,
796
945
  ) as child:
946
+ reader = threading.Thread(
947
+ target=forward_stderr, args=(child.stderr, experiment), daemon=True
948
+ )
949
+ reader.start()
797
950
  try:
798
- deadline = time.monotonic() + 25 * 60
951
+ # Without --cloud-timeout the job's own timeout is the only limit.
952
+ deadline = None if timeout is None else time.monotonic() + timeout * 60
799
953
  while child.poll() is None:
800
954
  if os.fstat(output.fileno()).st_size > 16 * 1024 * 1024:
801
955
  raise ValueError("Replay output exceeded 16 MiB")
802
- if time.monotonic() >= deadline:
803
- raise subprocess.TimeoutExpired(args, 25 * 60)
956
+ if deadline is not None and time.monotonic() >= deadline:
957
+ raise ValueError(
958
+ f"Replay stopped at the {timeout}-minute --cloud-timeout; traces that finished are saved in Bitfab as an interrupted experiment"
959
+ )
804
960
  time.sleep(0.1)
805
961
  code = child.returncode
806
962
  except BaseException:
807
963
  with contextlib.suppress(ProcessLookupError):
808
964
  os.killpg(child.pid, signal.SIGTERM)
965
+ # Time for the SDK to mark the experiment interrupted before the hard kill.
809
966
  with contextlib.suppress(subprocess.TimeoutExpired):
810
- child.wait(timeout=10)
967
+ child.wait(timeout=30)
811
968
  with contextlib.suppress(ProcessLookupError):
812
969
  os.killpg(child.pid, signal.SIGKILL)
813
970
  child.wait()
814
971
  raise
972
+ finally:
973
+ reader.join(timeout=5)
815
974
  if output.tell() > 16 * 1024 * 1024:
816
975
  raise ValueError("Replay output exceeded 16 MiB")
817
976
  output.seek(0)
@@ -843,14 +1002,6 @@ def execute():
843
1002
  "replayed": len(items),
844
1003
  "errored": sum(1 for item in items if item_errored(item)),
845
1004
  }
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
1005
  return summary
855
1006
 
856
1007
 
@@ -892,18 +1043,77 @@ def report_errored_items(record):
892
1043
  return 0
893
1044
 
894
1045
 
1046
+ def replay_step(config, cli_command, env):
1047
+ return {
1048
+ "name": "Replay",
1049
+ "working-directory": config["workingDirectory"],
1050
+ "run": shlex.join([*cli_command, "--cloud-execute"]),
1051
+ "env": {**env, **RUNNER_ENV},
1052
+ }
1053
+
1054
+
1055
+ def workflow_document(job_options, setup_steps, replay):
1056
+ """Only settings belong here; everything Bitfab may need to change runs inside the SDK."""
1057
+ job = {
1058
+ "runs-on": job_options.get("runsOn", "ubuntu-24.04"),
1059
+ "steps": [
1060
+ {
1061
+ "name": "Check out replay snapshot",
1062
+ "uses": "actions/checkout@11d5960a326750d5838078e36cf38b85af677262",
1063
+ "with": {"ref": "${{ github.sha }}", "persist-credentials": False},
1064
+ },
1065
+ *setup_steps,
1066
+ replay,
1067
+ ],
1068
+ }
1069
+ for key in ("environment", "services"):
1070
+ if key in job_options:
1071
+ job[key] = job_options[key]
1072
+ return {
1073
+ "name": "Bitfab cloud replay",
1074
+ "run-name": RUN_NAME,
1075
+ "on": {
1076
+ "workflow_dispatch": {
1077
+ "inputs": {
1078
+ "execution_id": {"required": True, "type": "string"},
1079
+ "request": {"required": True, "type": "string"},
1080
+ }
1081
+ }
1082
+ },
1083
+ "permissions": {"contents": "read"},
1084
+ "jobs": {"replay": job},
1085
+ }
1086
+
1087
+
1088
+ def write_new_files(root, outputs):
1089
+ for name in outputs:
1090
+ if within(root, name).exists():
1091
+ raise ValueError(
1092
+ f"Refusing to overwrite {name}; review and edit the existing setup"
1093
+ )
1094
+ for name, content in outputs.items():
1095
+ target = within(root, name)
1096
+ target.parent.mkdir(parents=True, exist_ok=True)
1097
+ with target.open("x") as file:
1098
+ file.write(content)
1099
+
1100
+
895
1101
  def initialize(argv):
896
1102
  parser = argparse.ArgumentParser(
897
1103
  prog="bitfab-replay --cloud-init",
898
- description="Install direct GitHub cloud replay from a reviewed JSON setup specification. Existing files are never overwritten.",
1104
+ 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
1105
  )
900
1106
  parser.add_argument(
901
1107
  "--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",
1108
+ help="JSON file with cloud config, cliCommand, setupSteps, optional pipeline (omit or null to allow every registry pipeline), secrets, variables, 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
1109
  )
905
1110
  args = parser.parse_args(argv)
906
- spec = json.loads(Path(args.config).read_text())
1111
+ spec = json.loads(Path(args.config).read_text()) if args.config else None
1112
+ root = root_directory()
1113
+ if within(root, CONFIG).exists():
1114
+ return update_existing(root, spec)
1115
+ if spec is None:
1116
+ raise ValueError(f"No {CONFIG} yet; pass --config with a setup specification")
907
1117
  config = {
908
1118
  key: spec[key]
909
1119
  for key in (
@@ -925,6 +1135,7 @@ def initialize(argv):
925
1135
  config["secrets"] = secrets
926
1136
  config["secretPrefix"] = secret_prefix
927
1137
  validate_config(config)
1138
+ validate_cli_command(spec.get("cliCommand"))
928
1139
  if set(secrets) & set(variables):
929
1140
  raise ValueError("Secret names and variable names must not overlap")
930
1141
  if "BITFAB_API_KEY" not in secrets or not all(
@@ -933,16 +1144,6 @@ def initialize(argv):
933
1144
  raise ValueError(
934
1145
  "Supply uppercase secret/variable names, including BITFAB_API_KEY; never values"
935
1146
  )
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
1147
  steps = spec.get("setupSteps")
947
1148
  if (
948
1149
  not isinstance(steps, list)
@@ -952,74 +1153,15 @@ def initialize(argv):
952
1153
  raise ValueError("setupSteps must contain reviewed GitHub Actions setup steps")
953
1154
  env = {key: "${{ secrets." + secret_prefix + key + " }}" for key in secrets}
954
1155
  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 }}",
1156
+ workflow = workflow_document(
1157
+ spec, steps, replay_step(config, spec["cliCommand"], env)
959
1158
  )
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
1159
  outputs = {
1008
1160
  CONFIG: json.dumps(config, indent=2) + "\n",
1009
1161
  f".github/workflows/{config['workflow']}": json.dumps(workflow, indent=2)
1010
1162
  + "\n",
1011
1163
  }
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)
1164
+ write_new_files(root, outputs)
1023
1165
  return {
1024
1166
  "files": list(outputs),
1025
1167
  "requiredSecrets": [secret_prefix + name for name in secrets],
@@ -1028,6 +1170,108 @@ def initialize(argv):
1028
1170
  }
1029
1171
 
1030
1172
 
1173
+ def replace_file(target, content):
1174
+ with tempfile.NamedTemporaryFile(mode="w", dir=target.parent, delete=False) as file:
1175
+ file.write(content)
1176
+ temporary = file.name
1177
+ os.replace(temporary, target)
1178
+
1179
+
1180
+ def update_existing(root, spec):
1181
+ """Existing files win: only the Replay step and steps older setups generated change."""
1182
+ config = json.loads(within(root, CONFIG).read_text())
1183
+ validate_config(config)
1184
+ name = f".github/workflows/{config['workflow']}"
1185
+ workflow_path = within(root, name)
1186
+ text = workflow_path.read_text() if workflow_path.is_file() else ""
1187
+ try:
1188
+ workflow = json.loads(text)
1189
+ job = workflow["jobs"]["replay"]
1190
+ steps = job["steps"]
1191
+ except (ValueError, KeyError, TypeError) as error:
1192
+ # A workflow the customer rewrote as YAML is current when nothing older remains.
1193
+ if (
1194
+ "--cloud-execute" in text
1195
+ and OLD_UPLOAD_STEP not in text
1196
+ and ".bitfab/cloudReplay.py" not in text
1197
+ and not re.search(rf"timeout-minutes:\s*{OLD_JOB_TIMEOUT}\b", text)
1198
+ ):
1199
+ return {"files": [], "updated": False, "next": "Already up to date"}
1200
+ raise ValueError(
1201
+ 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"
1202
+ ) from error
1203
+ replay = [
1204
+ index
1205
+ for index, step in enumerate(steps)
1206
+ if isinstance(step, dict) and step.get("name") == "Replay"
1207
+ ]
1208
+ if len(replay) != 1:
1209
+ raise ValueError(f"{name} must contain exactly one step named Replay")
1210
+ step = steps[replay[0]]
1211
+ words = shlex.split(step.get("run", ""))
1212
+ if spec is not None and spec.get("cliCommand") is not None:
1213
+ cli_command = spec["cliCommand"]
1214
+ elif words[-1:] == ["--cloud-execute"]:
1215
+ cli_command = words[:-1]
1216
+ elif config.get("cliCommand") is not None:
1217
+ cli_command = config["cliCommand"]
1218
+ else:
1219
+ raise ValueError(
1220
+ "The Replay step does not run the SDK command; pass --config with a file whose cliCommand starts bitfab-replay from workingDirectory"
1221
+ )
1222
+ validate_cli_command(cli_command)
1223
+ env = {
1224
+ key: value
1225
+ for key, value in (step.get("env") or {}).items()
1226
+ if key not in RUNNER_ENV
1227
+ }
1228
+ extra = {
1229
+ key: value
1230
+ for key, value in step.items()
1231
+ if key not in ("name", "uses", "with", "run", "working-directory", "env")
1232
+ }
1233
+ before = json.dumps([config, workflow], sort_keys=True)
1234
+ # Edit in place so the customer's checkout options, extra steps, and job
1235
+ # settings survive; only the Replay step and our old result upload change.
1236
+ job["steps"] = [
1237
+ {**replay_step(config, cli_command, env), **extra}
1238
+ if index == replay[0]
1239
+ else entry
1240
+ for index, entry in enumerate(steps)
1241
+ if not (
1242
+ isinstance(entry, dict)
1243
+ and entry.get("name") == OLD_UPLOAD_STEP
1244
+ and str(entry.get("uses", "")).startswith("actions/upload-artifact@")
1245
+ )
1246
+ ]
1247
+ # The limit older setups generated; a value the customer chose stays.
1248
+ if job.get("timeout-minutes") == OLD_JOB_TIMEOUT:
1249
+ del job["timeout-minutes"]
1250
+ config.pop("cliCommand", None)
1251
+ # Setups before the SDK carried the script left a copy that nothing reads now.
1252
+ old_script = ".bitfab/cloudReplay.py"
1253
+ removable = [old_script] if within(root, old_script).exists() else []
1254
+ if json.dumps([config, workflow], sort_keys=True) == before:
1255
+ return {
1256
+ "files": [],
1257
+ "updated": False,
1258
+ "removable": removable,
1259
+ "next": "Already up to date",
1260
+ }
1261
+ outputs = {
1262
+ CONFIG: json.dumps(config, indent=2) + "\n",
1263
+ name: json.dumps(workflow, indent=2) + "\n",
1264
+ }
1265
+ for output, content in outputs.items():
1266
+ replace_file(within(root, output), content)
1267
+ return {
1268
+ "files": list(outputs),
1269
+ "updated": True,
1270
+ "removable": removable,
1271
+ "next": "Review the diff, merge the workflow to the default branch, then run --cloud-dry-run",
1272
+ }
1273
+
1274
+
1031
1275
  def main():
1032
1276
  try:
1033
1277
  if sys.argv[1:2] == ["--cloud-init"]:
@@ -1040,6 +1284,11 @@ def main():
1040
1284
  result = run_cli(sys.argv[1:])
1041
1285
  print(json.dumps(result, indent=2))
1042
1286
  if result.get("state") == "completed" and result.get("conclusion") != "success":
1287
+ if result.get("testRunId"):
1288
+ print(
1289
+ f"Cloud replay stopped early ({result.get('stoppedEarly', result.get('conclusion'))}). Traces that finished are saved in test run {result['testRunId']}.",
1290
+ file=sys.stderr,
1291
+ )
1043
1292
  return 1
1044
1293
  if result.get("state") == "completed":
1045
1294
  return report_errored_items(result)
@@ -6,7 +6,7 @@ require "tempfile"
6
6
 
7
7
  module Bitfab
8
8
  module CloudReplayCli
9
- HELP = "Direct GitHub replay: --cloud PIPELINE --trace-ids UUID[,UUID] [--registry PATH] [--max-concurrency 1..32] [--cloud-include FILE] [--cloud-dry-run] [--cloud-detach] [--cloud-request-id UUID]. Lifecycle: --cloud-status|--cloud-watch|--cloud-cancel|--cloud-cleanup UUID. Setup: --cloud-init --config FILE, --cloud-secrets --env-file FILE [NAME ...]. Requires git, gh auth login, Python 3.10+, and bitfab:setup cloud."
9
+ HELP = "Direct GitHub replay: --cloud PIPELINE --trace-ids UUID[,UUID] [--registry PATH] [--max-concurrency 1..32] [--cloud-include FILE] [--cloud-dry-run] [--cloud-detach] [--cloud-request-id UUID] [--cloud-timeout MINUTES]. Lifecycle: --cloud-status|--cloud-watch|--cloud-cancel|--cloud-cleanup UUID. Setup: --cloud-init [--config FILE] (creates a setup or updates it in place), --cloud-secrets --env-file FILE [NAME ...]. Requires git, gh auth login, Python 3.10+, and bitfab:setup cloud."
10
10
 
11
11
  HELPER = File.expand_path("cloudReplay.py", __dir__)
12
12
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bitfab
4
- VERSION = "0.62.4"
4
+ VERSION = "0.62.5"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bitfab
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.62.4
4
+ version: 0.62.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Harvest Team