bitfab 0.62.2 → 0.62.4

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 (32) hide show
  1. package/cloudReplay.py +1065 -0
  2. package/dist/{chunk-OLVCZPQW.js → chunk-4KCUJ5WL.js} +14 -6
  3. package/dist/chunk-4KCUJ5WL.js.map +1 -0
  4. package/dist/{chunk-RIYIEUW4.js → chunk-CO2HRQEG.js} +2 -2
  5. package/dist/{chunk-CCR7VOXC.js → chunk-GWX7C2CB.js} +2 -2
  6. package/dist/{chunk-CCR7VOXC.js.map → chunk-GWX7C2CB.js.map} +1 -1
  7. package/dist/{chunk-54YGZ2J6.js → chunk-GXD74YMV.js} +3 -3
  8. package/dist/{chunk-Q53HNU3K.js → chunk-IHJDEWXY.js} +2 -2
  9. package/dist/{chunk-Q53HNU3K.js.map → chunk-IHJDEWXY.js.map} +1 -1
  10. package/dist/{http-BMF67MZ2.js → http-4T6FFHWZ.js} +2 -2
  11. package/dist/{http-EVGEAKX2.js → http-NSUBKKB5.js} +2 -2
  12. package/dist/index.cjs +9 -1
  13. package/dist/index.cjs.map +1 -1
  14. package/dist/index.d.cts +19 -2
  15. package/dist/index.d.ts +19 -2
  16. package/dist/index.js +3 -3
  17. package/dist/node.cjs +9 -1
  18. package/dist/node.cjs.map +1 -1
  19. package/dist/node.d.cts +1 -1
  20. package/dist/node.d.ts +1 -1
  21. package/dist/node.js +3 -3
  22. package/dist/{replay-EBTPRVWQ.js → replay-I3ENLBXT.js} +3 -3
  23. package/dist/replayCli.js +8 -23
  24. package/dist/replayCli.js.map +1 -1
  25. package/dist/seedCli.js +2 -2
  26. package/package.json +3 -2
  27. package/dist/chunk-OLVCZPQW.js.map +0 -1
  28. /package/dist/{chunk-RIYIEUW4.js.map → chunk-CO2HRQEG.js.map} +0 -0
  29. /package/dist/{chunk-54YGZ2J6.js.map → chunk-GXD74YMV.js.map} +0 -0
  30. /package/dist/{http-BMF67MZ2.js.map → http-4T6FFHWZ.js.map} +0 -0
  31. /package/dist/{http-EVGEAKX2.js.map → http-NSUBKKB5.js.map} +0 -0
  32. /package/dist/{replay-EBTPRVWQ.js.map → replay-I3ENLBXT.js.map} +0 -0
package/cloudReplay.py ADDED
@@ -0,0 +1,1065 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import base64
5
+ import contextlib
6
+ import json
7
+ import os
8
+ import re
9
+ import shlex
10
+ import signal
11
+ import subprocess
12
+ import sys
13
+ import tempfile
14
+ import time
15
+ import uuid
16
+ from pathlib import Path
17
+ from urllib.parse import urlencode
18
+
19
+ CONFIG = ".bitfab/cloud.json"
20
+ PREFIX = "bitfab-replay/"
21
+ DEFAULT_SECRET_PREFIX = "BITFAB_CLOUD_"
22
+ API_VERSION = "2026-03-10"
23
+ ITEM_ERROR_FIELDS = (
24
+ "error",
25
+ "traceError",
26
+ "trace_error",
27
+ "replayError",
28
+ "replay_error",
29
+ )
30
+ UUID = re.compile(r"^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$")
31
+ SHA = re.compile(r"^[0-9a-f]{40}$")
32
+ PIPELINE = re.compile(r"^[\w.][\w.-]*$")
33
+ HELP = """GitHub cloud replay (requires git, gh login, and Python 3.10+).
34
+ --cloud PIPELINE --trace-ids UUID[,UUID] [--registry PATH]
35
+ [--max-concurrency 1..32] [--cloud-request-id UUID]
36
+ [--cloud-include PATH ...] [--cloud-dry-run] [--cloud-detach]
37
+ --cloud-status UUID | --cloud-watch UUID | --cloud-cancel UUID
38
+ --cloud-cleanup UUID
39
+ --cloud-init --config SPEC | --cloud-secrets --env-file FILE [NAME ...]
40
+ PIPELINE may be any pipeline in the registry, unless .bitfab/cloud.json names one.
41
+ Snapshot tracked working files without changing HEAD, the index, or local files.
42
+ New files require explicit --cloud-include. Credentials and ignored files are refused.
43
+ By default wait for completion and remove the remote snapshot branch. Detached runs
44
+ continue on GitHub; watch/status/cleanup can recover them using the printed UUID.
45
+ """
46
+
47
+
48
+ def command(args, *, cwd=None, env=None, timeout=60, input=None):
49
+ result = subprocess.run(
50
+ args,
51
+ cwd=cwd,
52
+ env=env,
53
+ input=input,
54
+ text=True,
55
+ check=False,
56
+ capture_output=True,
57
+ timeout=timeout,
58
+ )
59
+ if result.returncode:
60
+ # 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"
63
+ )
64
+ return result.stdout.strip()
65
+
66
+
67
+ def git(root, *args, env=None, input=None):
68
+ return command(["git", "-C", str(root), *args], env=env, input=input)
69
+
70
+
71
+ def root_directory():
72
+ return Path(command(["git", "rev-parse", "--show-toplevel"])).resolve()
73
+
74
+
75
+ def relative_path(value):
76
+ if not isinstance(value, str) or not value or "\\" in value or "\x00" in value:
77
+ raise ValueError("Expected a repository-relative path")
78
+ if Path(value).is_absolute() or ".." in Path(value).parts or value.startswith("-"):
79
+ raise ValueError("Paths must stay inside the repository")
80
+ return value
81
+
82
+
83
+ def within(root, value):
84
+ path = (root / relative_path(value)).resolve()
85
+ path.relative_to(root)
86
+ return path
87
+
88
+
89
+ def repository(root):
90
+ remote = git(root, "remote", "get-url", "origin")
91
+ match = re.fullmatch(
92
+ r"(?:git@github\.com:|https://github\.com/)([\w.-]+/[\w.-]+?)(?:\.git)?", remote
93
+ )
94
+ if match is None:
95
+ raise ValueError(
96
+ "origin must be a github.com repository without embedded credentials"
97
+ )
98
+ # A pushurl can silently send the snapshot somewhere other than origin's fetch URL.
99
+ push = git(root, "remote", "get-url", "--push", "origin")
100
+ other = re.fullmatch(
101
+ r"(?:git@github\.com:|https://github\.com/)([\w.-]+/[\w.-]+?)(?:\.git)?", push
102
+ )
103
+ if other is None or other[1].lower() != match[1].lower():
104
+ raise ValueError("origin fetch and push repositories must match")
105
+ return match[1]
106
+
107
+
108
+ def configuration(root):
109
+ path = within(root, CONFIG)
110
+ if not path.is_file():
111
+ raise ValueError(
112
+ f"Run bitfab:setup cloud first. Expected {CONFIG} inside this repository."
113
+ )
114
+ config = json.loads(path.read_text())
115
+ validate_config(config)
116
+ return config
117
+
118
+
119
+ ENV_ASSIGNMENT = re.compile(r"[ \t]*(?:export[ \t]+)?([A-Za-z_][A-Za-z0-9_]*)[ \t]*=")
120
+
121
+ ENV_ESCAPES = {"n": "\n", "r": "\r", "t": "\t", '"': '"', "\\": "\\"}
122
+
123
+
124
+ def read_environment_value(text, assignment_end, name):
125
+ start = assignment_end
126
+ while start < len(text) and text[start] in " \t":
127
+ start += 1
128
+ if start < len(text) and text[start] in ("'", '"'):
129
+ quote = text[start]
130
+ pieces = []
131
+ position = start + 1
132
+ while position < len(text):
133
+ character = text[position]
134
+ if character == "\\" and quote == '"' and position + 1 < len(text):
135
+ following = text[position + 1]
136
+ pieces.append(ENV_ESCAPES.get(following, character + following))
137
+ position += 2
138
+ continue
139
+ if character == quote:
140
+ return "".join(pieces), position + 1
141
+ pieces.append(character)
142
+ position += 1
143
+ raise ValueError(f"{name} opens a quote that the file never closes")
144
+ end = text.find("\n", assignment_end)
145
+ end = len(text) if end == -1 else end
146
+ raw = text[assignment_end:end]
147
+ comment = re.search(r"\s#", raw)
148
+ return (raw if comment is None else raw[: comment.start()]).strip(), end
149
+
150
+
151
+ def parse_environment_file(text):
152
+ values = {}
153
+ position = 0
154
+ while position < len(text):
155
+ end = text.find("\n", position)
156
+ end = len(text) if end == -1 else end
157
+ line = text[position:end]
158
+ match = ENV_ASSIGNMENT.match(line)
159
+ if match is None or line.lstrip().startswith("#"):
160
+ position = end + 1
161
+ continue
162
+ value, consumed = read_environment_value(text, position + match.end(), match[1])
163
+ values[match[1]] = value
164
+ newline = text.find("\n", consumed)
165
+ position = len(text) if newline == -1 else newline + 1
166
+ return values
167
+
168
+
169
+ def configure_secrets(argv):
170
+ parser = argparse.ArgumentParser(
171
+ prog="bitfab-replay --cloud-secrets",
172
+ description="Copy named values from local environment files into GitHub Actions secrets. Values are piped to gh on standard input and never printed, logged, or passed as arguments.",
173
+ )
174
+ parser.add_argument(
175
+ "names",
176
+ nargs="*",
177
+ help="Environment variable names to copy; defaults to the secrets recorded by setup",
178
+ )
179
+ parser.add_argument(
180
+ "--env-file",
181
+ action="append",
182
+ required=True,
183
+ help="Repository-relative environment file to read; repeatable, earliest definition wins",
184
+ )
185
+ parser.add_argument(
186
+ "--environment",
187
+ help="Set GitHub Environment secrets instead of repository ones",
188
+ )
189
+ parser.add_argument(
190
+ "--dry-run",
191
+ action="store_true",
192
+ help="Report which names were found without writing anything to GitHub",
193
+ )
194
+ args = parser.parse_args(argv)
195
+ root = root_directory()
196
+ 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")
201
+ values = {}
202
+ for path in args.env_file:
203
+ for key, value in parse_environment_file(
204
+ within(root, path).read_text()
205
+ ).items():
206
+ values.setdefault(key, value)
207
+ repo = repository(root)
208
+ assigned = []
209
+ missing = []
210
+ empty = []
211
+ for name in names:
212
+ value = values.get(name)
213
+ if value is None:
214
+ missing.append(name)
215
+ continue
216
+ if not value:
217
+ empty.append(name)
218
+ continue
219
+ target = prefix + name
220
+ if not args.dry_run:
221
+ command(
222
+ [
223
+ "gh",
224
+ "secret",
225
+ "set",
226
+ target,
227
+ "--repo",
228
+ repo,
229
+ *(["--env", args.environment] if args.environment else []),
230
+ ],
231
+ input=value,
232
+ )
233
+ assigned.append(target)
234
+ return {
235
+ "repository": repo,
236
+ "environment": args.environment,
237
+ "dryRun": args.dry_run,
238
+ "set": assigned,
239
+ "missing": missing,
240
+ "empty": empty,
241
+ "next": "Create the missing secrets by hand; empty local values were skipped because an empty secret overrides a working default with nothing; a wrong value only surfaces when the first real replay runs",
242
+ }
243
+
244
+
245
+ def validate_config(config):
246
+ if config.get("version") != 1 or config.get("provider") != "github":
247
+ raise ValueError("Run bitfab:setup cloud to configure the GitHub provider")
248
+ if not re.fullmatch(r"[\w-]+\.ya?ml", config.get("workflow", "")):
249
+ raise ValueError("Invalid workflow filename")
250
+ relative_path(config["workingDirectory"])
251
+ if config.get("registry") is not None:
252
+ relative_path(config["registry"])
253
+ pipeline = config.get("pipeline")
254
+ if pipeline is not None and not (
255
+ isinstance(pipeline, str) and PIPELINE.fullmatch(pipeline)
256
+ ):
257
+ raise ValueError("Invalid pipeline name")
258
+ args = config.get("command")
259
+ if (
260
+ not isinstance(args, list)
261
+ or not args
262
+ or not all(isinstance(v, str) and v and "\x00" not in v for v in args)
263
+ ):
264
+ raise ValueError("command must be a nonempty JSON argument array")
265
+ if any(v.startswith("--cloud") for v in args):
266
+ raise ValueError(
267
+ "The runner command must execute locally, not recursively dispatch"
268
+ )
269
+ if config.get("pushTriggersReviewed") is not True:
270
+ raise ValueError(
271
+ "Setup must review push-triggered CI/deployments and set pushTriggersReviewed=true"
272
+ )
273
+ prefix = config.get("secretPrefix", "")
274
+ if not isinstance(prefix, str) or (
275
+ prefix and not re.fullmatch(r"[A-Z][A-Z0-9_]*_", prefix)
276
+ ):
277
+ raise ValueError(
278
+ "secretPrefix must be uppercase and end with an underscore, such as BITFAB_CLOUD_"
279
+ )
280
+ names = config.get("secrets", [])
281
+ if not isinstance(names, list) or not all(
282
+ isinstance(name, str) and re.fullmatch(r"[A-Z_][A-Z0-9_]*", name)
283
+ for name in names
284
+ ):
285
+ raise ValueError("secrets must be uppercase environment variable names")
286
+
287
+
288
+ def check_pipeline(config, pipeline):
289
+ if not isinstance(pipeline, str) or not PIPELINE.fullmatch(pipeline):
290
+ raise ValueError("Invalid pipeline name")
291
+ allowed = config.get("pipeline")
292
+ if allowed is not None and pipeline != allowed:
293
+ raise ValueError("Pipeline does not match .bitfab/cloud.json")
294
+
295
+
296
+ def gh(repo, *args, payload=None):
297
+ return command(
298
+ [
299
+ "gh",
300
+ "api",
301
+ "--hostname",
302
+ "github.com",
303
+ "-H",
304
+ f"X-GitHub-Api-Version: {API_VERSION}",
305
+ "--jq",
306
+ "tojson",
307
+ *args,
308
+ *(["--input", "-"] if payload is not None else []),
309
+ ],
310
+ input=json.dumps(payload) if payload is not None else None,
311
+ )
312
+
313
+
314
+ def api(repo, suffix, *, method="GET", payload=None):
315
+ result = gh(
316
+ repo,
317
+ f"repos/{repo}" + (f"/{suffix}" if suffix else ""),
318
+ "--method",
319
+ method,
320
+ payload=payload,
321
+ )
322
+ return json.loads(result) if result else None
323
+
324
+
325
+ def parse(argv):
326
+ flags = [value.split("=", 1)[0] for value in argv if value.startswith("--")]
327
+ if any(flags.count(flag) > 1 for flag in flags if flag != "--cloud-include"):
328
+ raise ValueError("Duplicate cloud option")
329
+ parser = argparse.ArgumentParser(
330
+ description=HELP,
331
+ formatter_class=argparse.RawDescriptionHelpFormatter,
332
+ allow_abbrev=False,
333
+ )
334
+ parser.add_argument("pipeline", nargs="?")
335
+ parser.add_argument("--registry")
336
+ parser.add_argument("--cloud", action="store_true")
337
+ for name in ("status", "watch", "cancel", "cleanup"):
338
+ parser.add_argument(f"--cloud-{name}")
339
+ parser.add_argument("--trace-ids")
340
+ parser.add_argument("--max-concurrency", type=int, default=1)
341
+ parser.add_argument("--cloud-request-id")
342
+ parser.add_argument("--cloud-include", action="append", default=[])
343
+ parser.add_argument("--cloud-dry-run", action="store_true")
344
+ parser.add_argument("--cloud-detach", action="store_true")
345
+ args = parser.parse_args(argv)
346
+ operations = [
347
+ name
348
+ for name in ("status", "watch", "cancel", "cleanup")
349
+ if getattr(args, "cloud_" + name)
350
+ ]
351
+ if operations:
352
+ if len(operations) != 1 or len(argv) != 2:
353
+ raise ValueError("Cloud lifecycle commands take only their execution UUID")
354
+ operation = operations[0]
355
+ execution_id = getattr(args, "cloud_" + operation)
356
+ else:
357
+ if not args.cloud or not args.pipeline or not args.trace_ids:
358
+ raise ValueError(HELP)
359
+ operation = "submit"
360
+ 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")
364
+ if not 1 <= args.max_concurrency <= 32:
365
+ raise ValueError("--max-concurrency must be 1..32")
366
+ if not UUID.fullmatch(execution_id):
367
+ raise ValueError("Execution ID must be a UUID")
368
+ return args, operation, execution_id
369
+
370
+
371
+ def sensitive(path):
372
+ parts = Path(path).parts
373
+ name = Path(path).name.lower()
374
+ return (
375
+ any(part in (".git", ".ssh", ".aws") for part in parts)
376
+ or name == ".env"
377
+ or (
378
+ name.startswith(".env.")
379
+ and name not in (".env.example", ".env.sample", ".env.template")
380
+ )
381
+ or name in (".npmrc", ".pypirc", ".netrc", "id_rsa", "id_ed25519")
382
+ or name.endswith((".pem", ".key", ".p12", ".pfx", ".local.json"))
383
+ or name
384
+ in (
385
+ "credentials",
386
+ "credentials.json",
387
+ "secrets.json",
388
+ "secrets.yml",
389
+ "secrets.yaml",
390
+ )
391
+ )
392
+
393
+
394
+ def snapshot(root, config, args, execution_id):
395
+ if git(root, "ls-files", "-u"):
396
+ raise ValueError("Resolve merge conflicts before snapshotting")
397
+ head = git(root, "rev-parse", "HEAD")
398
+ includes = []
399
+ for value in args.cloud_include:
400
+ path = within(root, value)
401
+ if not path.is_file() or path.is_symlink() or (root / value).is_symlink():
402
+ raise ValueError("--cloud-include requires individual regular files")
403
+ # Never force-add ignored content, even when explicitly requested.
404
+ ignored = subprocess.run(
405
+ ["git", "-C", str(root), "check-ignore", "--quiet", "--", value],
406
+ check=False,
407
+ timeout=15,
408
+ ).returncode
409
+ if ignored not in (0, 1):
410
+ raise ValueError("Unable to determine whether the included file is ignored")
411
+ if ignored == 0 or sensitive(value):
412
+ raise ValueError(f"Refusing ignored or credential-like file: {value}")
413
+ includes.append(value)
414
+ with tempfile.TemporaryDirectory(prefix="bitfab-cloud-index-") as directory:
415
+ env = {**os.environ, "GIT_INDEX_FILE": str(Path(directory) / "index")}
416
+ git(root, "read-tree", head, env=env)
417
+ git(root, "add", "-u", "--", ".", env=env)
418
+ if includes:
419
+ git(root, "--literal-pathspecs", "add", "--", *includes, env=env)
420
+ entries = git(root, "ls-files", "--stage", "-z", env=env).split("\x00")
421
+ for entry in filter(None, entries):
422
+ metadata, path = entry.split("\t", 1)
423
+ if metadata.startswith("160000"):
424
+ raise ValueError(
425
+ "Submodules/gitlinks require explicit setup support before cloud snapshots"
426
+ )
427
+ if sensitive(path):
428
+ raise ValueError(
429
+ f"Refusing credential-like tracked file in snapshot: {path}"
430
+ )
431
+ required = [CONFIG, f".github/workflows/{config['workflow']}"]
432
+ if config.get("registry") is not None:
433
+ required.append(config["registry"])
434
+ tracked = set(
435
+ git(
436
+ root, "--literal-pathspecs", "ls-files", "-z", "--", *required, env=env
437
+ ).split("\x00")
438
+ )
439
+ untracked = [path for path in required if path not in tracked]
440
+ if untracked:
441
+ raise ValueError(
442
+ "Not in the snapshot: "
443
+ + ", ".join(untracked)
444
+ + ". Commit these files, or pass --cloud-include for each one"
445
+ )
446
+ tree = git(root, "write-tree", env=env)
447
+ files = git(root, "diff", "--name-only", head, tree).splitlines()
448
+ if args.cloud_dry_run:
449
+ new_files = set(
450
+ git(root, "ls-files", "--others", "--exclude-standard").splitlines()
451
+ )
452
+ new_files.update(
453
+ git(
454
+ root, "diff", "--cached", "--name-only", "--diff-filter=A"
455
+ ).splitlines()
456
+ )
457
+ return {
458
+ "baseSha": head,
459
+ "files": files,
460
+ "includedFiles": includes,
461
+ "omittedNewFiles": sorted(new_files - set(includes)),
462
+ }
463
+ # commit-tree adds an unreachable object, never touching HEAD or the real index.
464
+ sha = git(
465
+ root,
466
+ "commit-tree",
467
+ tree,
468
+ "-p",
469
+ head,
470
+ input=f"Bitfab replay {execution_id}\n",
471
+ )
472
+ return {"baseSha": head, "sha": sha, "files": files}
473
+
474
+
475
+ def state_directory(root):
476
+ path = Path(git(root, "rev-parse", "--absolute-git-dir")) / "bitfab-cloud"
477
+ path.mkdir(mode=0o700, exist_ok=True)
478
+ return path
479
+
480
+
481
+ def save(path, record):
482
+ with tempfile.NamedTemporaryFile(mode="w", dir=path.parent, delete=False) as file:
483
+ json.dump(record, file)
484
+ file.write("\n")
485
+ name = file.name
486
+ os.replace(name, path)
487
+
488
+
489
+ @contextlib.contextmanager
490
+ def execution_lock(directory, execution_id):
491
+ import fcntl
492
+
493
+ with (directory / f"{execution_id}.lock").open("a") as lock:
494
+ try:
495
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
496
+ except BlockingIOError as error:
497
+ raise ValueError(
498
+ "Another command is operating on this cloud execution"
499
+ ) from error
500
+ yield
501
+
502
+
503
+ def find_run(record):
504
+ query = urlencode(
505
+ {"event": "workflow_dispatch", "branch": record["branch"], "per_page": 100}
506
+ )
507
+ runs = api(
508
+ record["repository"], f"actions/workflows/{record['workflow']}/runs?{query}"
509
+ )
510
+ matches = [
511
+ run
512
+ for run in runs["workflow_runs"]
513
+ if run["head_sha"] == record["sha"]
514
+ and run.get("display_title") == f"Bitfab replay {record['id']}"
515
+ ]
516
+ if len(matches) > 1:
517
+ raise ValueError(
518
+ "Multiple matching GitHub runs; inspect Actions before cleanup"
519
+ )
520
+ return matches[0] if matches else None
521
+
522
+
523
+ def status(record, *, fetch_result=True):
524
+ if record["state"] == "prepared":
525
+ return record
526
+ run = (
527
+ api(record["repository"], f"actions/runs/{record['runId']}")
528
+ if record.get("runId")
529
+ else find_run(record)
530
+ )
531
+ if run is None:
532
+ record["state"] = "dispatch_unknown"
533
+ return record
534
+ if run["head_sha"] != record["sha"] or run["head_branch"] != record["branch"]:
535
+ raise ValueError("GitHub run does not match the recorded execution")
536
+ if run.get("display_title") != f"Bitfab replay {record['id']}":
537
+ if run["status"] == "completed":
538
+ raise ValueError("GitHub run does not match the recorded execution")
539
+ record.update(runId=run["id"], url=run["html_url"], state=run["status"])
540
+ return record
541
+ record.update(
542
+ runId=run["id"],
543
+ url=run["html_url"],
544
+ state=run["status"],
545
+ conclusion=run.get("conclusion"),
546
+ )
547
+ if (
548
+ fetch_result
549
+ and record["state"] == "completed"
550
+ and record["conclusion"] == "success"
551
+ and not record.get("testRunId")
552
+ ):
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", ""))
576
+ ):
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
583
+
584
+
585
+ def cleanup(root, record):
586
+ if record.get("cleaned"):
587
+ return
588
+ if record.get("state") not in ("completed", "prepared"):
589
+ raise ValueError(
590
+ "Cleanup requires a confirmed completed GitHub run; cancel and wait first"
591
+ )
592
+ if record["branch"] != PREFIX + record["id"] or not SHA.fullmatch(record["sha"]):
593
+ raise ValueError("Refusing cleanup of an unowned branch")
594
+ ref = "refs/heads/" + record["branch"]
595
+ remote = git(root, "ls-remote", "--heads", "origin", ref)
596
+ if remote:
597
+ if remote.split()[0] != record["sha"]:
598
+ raise ValueError(
599
+ "Snapshot branch moved; refusing to delete someone else's changes"
600
+ )
601
+ git(
602
+ root,
603
+ "push",
604
+ f"--force-with-lease={ref}:{record['sha']}",
605
+ "origin",
606
+ f":{ref}",
607
+ )
608
+ record["cleaned"] = True
609
+
610
+
611
+ def preflight(repo, config):
612
+ info = api(repo, "")
613
+ workflow = api(repo, f"actions/workflows/{config['workflow']}")
614
+ 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
+ )
621
+
622
+
623
+ def run_cli(argv):
624
+ args, operation, execution_id = parse(argv)
625
+ if os.name != "posix" or sys.version_info < (3, 10):
626
+ raise ValueError("Cloud replay currently requires macOS/Linux and Python 3.10+")
627
+ root = root_directory()
628
+ config = configuration(root) if operation == "submit" else None
629
+ repo = repository(root)
630
+ directory = state_directory(root)
631
+ path = directory / f"{execution_id}.json"
632
+ with execution_lock(directory, execution_id):
633
+ if operation == "submit":
634
+ within(root, config["workingDirectory"])
635
+ check_pipeline(config, args.pipeline)
636
+ if args.registry is not None:
637
+ registry = Path(args.registry).resolve().relative_to(root).as_posix()
638
+ if registry != config.get("registry"):
639
+ raise ValueError("Registry does not match .bitfab/cloud.json")
640
+ request = {
641
+ "id": execution_id,
642
+ "pipeline": args.pipeline,
643
+ "traceIds": args.trace_ids.split(","),
644
+ "maxConcurrency": args.max_concurrency,
645
+ }
646
+ if args.cloud_dry_run:
647
+ return {
648
+ "dryRun": True,
649
+ "repository": repo,
650
+ **snapshot(root, config, args, execution_id),
651
+ }
652
+ if path.exists():
653
+ record = json.loads(path.read_text())
654
+ if (
655
+ record["request"] != request
656
+ or record["repository"].lower() != repo.lower()
657
+ ):
658
+ raise ValueError(
659
+ "Execution ID already belongs to a different request"
660
+ )
661
+ # Never redispatch an uncertain request, even after a lost response.
662
+ status(record)
663
+ if record["state"] == "prepared":
664
+ raise ValueError(
665
+ "Submission stopped before dispatch. Use --cloud-cleanup, then submit a new execution UUID"
666
+ )
667
+ else:
668
+ command(["gh", "auth", "status", "--hostname", "github.com"])
669
+ preflight(repo, config)
670
+ source = snapshot(root, config, args, execution_id)
671
+ record = {
672
+ "id": execution_id,
673
+ "repository": repo,
674
+ "workflow": config["workflow"],
675
+ "branch": PREFIX + execution_id,
676
+ "request": request,
677
+ "state": "prepared",
678
+ **source,
679
+ }
680
+ save(path, record)
681
+ print(
682
+ f"Cloud execution {execution_id}. Recover with --cloud-status {execution_id}",
683
+ file=sys.stderr,
684
+ flush=True,
685
+ )
686
+ 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
+ )
695
+ record["state"] = "dispatch_unknown"
696
+ save(path, record)
697
+ 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
+ )
707
+ if isinstance(response, dict) and response.get("workflow_run_id"):
708
+ record["runId"] = response["workflow_run_id"]
709
+ record["url"] = response.get("html_url")
710
+ record["state"] = "queued"
711
+ else:
712
+ if not path.exists():
713
+ raise ValueError(
714
+ "No local execution record for this UUID in this worktree"
715
+ )
716
+ record = json.loads(path.read_text())
717
+ if record["repository"].lower() != repo.lower():
718
+ raise ValueError("origin no longer matches the execution repository")
719
+ status(record, fetch_result=operation != "cleanup")
720
+ if operation == "cancel" and record["state"] != "completed":
721
+ if not record.get("runId"):
722
+ raise ValueError(
723
+ "Run not yet found; inspect Actions and retry status before cancelling"
724
+ )
725
+ api(repo, f"actions/runs/{record['runId']}/cancel", method="POST")
726
+ record["state"] = "cancel_requested"
727
+ save(path, record)
728
+ if record["state"] == "prepared" and operation != "cleanup":
729
+ save(path, record)
730
+ return record
731
+ 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:
734
+ status(record)
735
+ save(path, record)
736
+ if record["state"] != "completed":
737
+ 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
+ if record["state"] == "completed" or (
744
+ operation == "cleanup" and record["state"] == "prepared"
745
+ ):
746
+ cleanup(root, record)
747
+ elif operation == "cleanup":
748
+ raise ValueError("Execution is not completed; no branch was deleted")
749
+ save(path, record)
750
+ return record
751
+
752
+
753
+ def execute():
754
+ if os.environ.get("GITHUB_RUN_ATTEMPT") != "1":
755
+ raise ValueError("Submit a new replay instead of rerunning an Actions job")
756
+ request = json.loads(
757
+ base64.b64decode(os.environ["BITFAB_CLOUD_REQUEST"], validate=True)
758
+ )
759
+ root = root_directory()
760
+ if git(root, "rev-parse", "HEAD") != os.environ["GITHUB_SHA"]:
761
+ raise ValueError("Runner checkout does not match the dispatched snapshot SHA")
762
+ config = configuration(root)
763
+ check_pipeline(config, request["pipeline"])
764
+ if request["id"] != os.environ["BITFAB_EXECUTION_ID"]:
765
+ raise ValueError("Replay request does not match the configured execution")
766
+ parse(
767
+ [
768
+ "--cloud",
769
+ request["pipeline"],
770
+ "--trace-ids",
771
+ ",".join(request["traceIds"]),
772
+ "--max-concurrency",
773
+ str(request["maxConcurrency"]),
774
+ "--cloud-request-id",
775
+ request["id"],
776
+ ]
777
+ )
778
+ if not os.environ.get("BITFAB_API_KEY"):
779
+ raise ValueError("Configure the BITFAB_API_KEY GitHub secret")
780
+ args = [
781
+ *config["command"],
782
+ request["pipeline"],
783
+ "--trace-ids",
784
+ ",".join(request["traceIds"]),
785
+ "--max-concurrency",
786
+ str(request["maxConcurrency"]),
787
+ "--no-code-change",
788
+ ]
789
+ with tempfile.TemporaryFile() as output:
790
+ with subprocess.Popen(
791
+ args,
792
+ cwd=within(root, config["workingDirectory"]),
793
+ stdout=output,
794
+ stdin=subprocess.DEVNULL,
795
+ start_new_session=True,
796
+ ) as child:
797
+ try:
798
+ deadline = time.monotonic() + 25 * 60
799
+ while child.poll() is None:
800
+ if os.fstat(output.fileno()).st_size > 16 * 1024 * 1024:
801
+ raise ValueError("Replay output exceeded 16 MiB")
802
+ if time.monotonic() >= deadline:
803
+ raise subprocess.TimeoutExpired(args, 25 * 60)
804
+ time.sleep(0.1)
805
+ code = child.returncode
806
+ except BaseException:
807
+ with contextlib.suppress(ProcessLookupError):
808
+ os.killpg(child.pid, signal.SIGTERM)
809
+ with contextlib.suppress(subprocess.TimeoutExpired):
810
+ child.wait(timeout=10)
811
+ with contextlib.suppress(ProcessLookupError):
812
+ os.killpg(child.pid, signal.SIGKILL)
813
+ child.wait()
814
+ raise
815
+ if output.tell() > 16 * 1024 * 1024:
816
+ raise ValueError("Replay output exceeded 16 MiB")
817
+ output.seek(0)
818
+ text = output.read().decode()
819
+ if code:
820
+ raise ValueError(f"Replay command exited {code}; inspect the job logs")
821
+ decoder = json.JSONDecoder()
822
+ result = None
823
+ for offset in [0, *[i + 1 for i, value in enumerate(text) if value == "\n"]]:
824
+ if text.startswith("{", offset):
825
+ try:
826
+ value, end = decoder.raw_decode(text, offset)
827
+ if not text[end:].strip() and isinstance(value, dict):
828
+ result = value
829
+ except json.JSONDecodeError:
830
+ 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
855
+
856
+
857
+ def item_errored(item):
858
+ return isinstance(item, dict) and any(
859
+ item.get(field) is not None for field in ITEM_ERROR_FIELDS
860
+ )
861
+
862
+
863
+ def replay_counts(result):
864
+ replayed, errored = result.get("replayed"), result.get("errored")
865
+ if replayed is None and errored is None:
866
+ return None
867
+ if (
868
+ not all(
869
+ isinstance(value, int) and not isinstance(value, bool) and value >= 0
870
+ for value in (replayed, errored)
871
+ )
872
+ or errored > replayed
873
+ ):
874
+ raise ValueError("Replay result artifact has invalid item counts")
875
+ return replayed, errored
876
+
877
+
878
+ def report_errored_items(record):
879
+ replayed, errored = record.get("replayed"), record.get("errored")
880
+ if not errored:
881
+ return 0
882
+ if errored == replayed:
883
+ print(
884
+ f"Cloud replay: every replayed trace errored ({errored} of {replayed}), so nothing ran successfully. Open test run {record.get('testRunId')} to see why.",
885
+ file=sys.stderr,
886
+ )
887
+ return 1
888
+ print(
889
+ f"Cloud replay: {errored} of {replayed} replayed traces errored. Open test run {record.get('testRunId')} to see why.",
890
+ file=sys.stderr,
891
+ )
892
+ return 0
893
+
894
+
895
+ def initialize(argv):
896
+ parser = argparse.ArgumentParser(
897
+ prog="bitfab-replay --cloud-init",
898
+ description="Install direct GitHub cloud replay from a reviewed JSON setup specification. Existing files are never overwritten.",
899
+ )
900
+ parser.add_argument(
901
+ "--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",
904
+ )
905
+ args = parser.parse_args(argv)
906
+ spec = json.loads(Path(args.config).read_text())
907
+ config = {
908
+ key: spec[key]
909
+ for key in (
910
+ "version",
911
+ "provider",
912
+ "workflow",
913
+ "workingDirectory",
914
+ "registry",
915
+ "command",
916
+ "pushTriggersReviewed",
917
+ )
918
+ }
919
+ config["pipeline"] = spec.get("pipeline")
920
+ secrets = spec.get("secrets", ["BITFAB_API_KEY"])
921
+ variables = spec.get("variables", [])
922
+ secret_prefix = spec.get("secretPrefix")
923
+ if secret_prefix is None:
924
+ secret_prefix = DEFAULT_SECRET_PREFIX
925
+ config["secrets"] = secrets
926
+ config["secretPrefix"] = secret_prefix
927
+ validate_config(config)
928
+ if set(secrets) & set(variables):
929
+ raise ValueError("Secret names and variable names must not overlap")
930
+ if "BITFAB_API_KEY" not in secrets or not all(
931
+ re.fullmatch(r"[A-Z_][A-Z0-9_]*", name) for name in [*secrets, *variables]
932
+ ):
933
+ raise ValueError(
934
+ "Supply uppercase secret/variable names, including BITFAB_API_KEY; never values"
935
+ )
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
+ steps = spec.get("setupSteps")
947
+ if (
948
+ not isinstance(steps, list)
949
+ or not steps
950
+ or not all(isinstance(step, dict) for step in steps)
951
+ ):
952
+ 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 }}",
959
+ )
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
+ outputs = {
1008
+ CONFIG: json.dumps(config, indent=2) + "\n",
1009
+ f".github/workflows/{config['workflow']}": json.dumps(workflow, indent=2)
1010
+ + "\n",
1011
+ }
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)
1023
+ return {
1024
+ "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",
1028
+ }
1029
+
1030
+
1031
+ def main():
1032
+ try:
1033
+ if sys.argv[1:2] == ["--cloud-init"]:
1034
+ result = initialize(sys.argv[2:])
1035
+ elif sys.argv[1:2] == ["--cloud-secrets"]:
1036
+ result = configure_secrets(sys.argv[2:])
1037
+ elif sys.argv[1:] == ["--cloud-execute"]:
1038
+ result = execute()
1039
+ else:
1040
+ result = run_cli(sys.argv[1:])
1041
+ print(json.dumps(result, indent=2))
1042
+ if result.get("state") == "completed" and result.get("conclusion") != "success":
1043
+ return 1
1044
+ if result.get("state") == "completed":
1045
+ return report_errored_items(result)
1046
+ return 0
1047
+ except KeyboardInterrupt:
1048
+ print(
1049
+ "Detached. The GitHub job continues; use the printed execution UUID to resume or cancel.",
1050
+ file=sys.stderr,
1051
+ )
1052
+ return 130
1053
+ except (
1054
+ ValueError,
1055
+ RuntimeError,
1056
+ OSError,
1057
+ subprocess.SubprocessError,
1058
+ KeyError,
1059
+ ) as error:
1060
+ print(f"Cloud replay: {error}", file=sys.stderr)
1061
+ return 1
1062
+
1063
+
1064
+ if __name__ == "__main__":
1065
+ raise SystemExit(main())