bitfab 0.63.0 → 0.63.2

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.
@@ -3,38 +3,42 @@ from __future__ import annotations
3
3
  import argparse
4
4
  import base64
5
5
  import contextlib
6
+ import functools
6
7
  import json
7
8
  import os
8
9
  import re
9
10
  import shlex
11
+ import shutil
10
12
  import signal
13
+ import ssl
11
14
  import subprocess
12
15
  import sys
13
16
  import tempfile
14
17
  import threading
15
18
  import time
19
+ import urllib.error
20
+ import urllib.request
16
21
  import uuid
17
22
  from pathlib import Path
18
23
  from urllib.parse import urlencode
19
24
 
20
- CONFIG = ".bitfab/cloud.json"
25
+ WORKFLOW = "bitfab-replay.yml"
26
+ WORKFLOW_NAMES = (WORKFLOW, "bitfab-replay.yaml")
27
+ WORKFLOW_DIRECTORY = ".github/workflows"
28
+ OLD_CONFIG = ".bitfab/cloud.json"
29
+ OLD_SCRIPT = ".bitfab/cloudReplay.py"
30
+ OLD_RUNNER_ENV = ("BITFAB_CLOUD_REQUEST", "BITFAB_EXECUTION_ID", "BITFAB_COMMIT_SHA")
31
+ OLD_UPLOAD_STEP = "Save replay identity"
32
+ OLD_JOB_TIMEOUT = 35
21
33
  PREFIX = "bitfab-replay/"
22
34
  DEFAULT_SECRET_PREFIX = "BITFAB_CLOUD_"
23
35
  API_VERSION = "2026-03-10"
24
- # The SDK finds runs by this title and feeds them through these inputs. Never change them.
25
36
  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
37
  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
38
+ REPLAY_COMMAND_ENV = "BITFAB_REPLAY_COMMAND"
39
+ SDK_LANGUAGE_ENV = "BITFAB_SDK_LANGUAGE"
40
+ CHECK_COMMAND_ENV = "BITFAB_REPLAY_CHECK"
41
+ REQUEST_VERSION = 2
38
42
  ITEM_ERROR_FIELDS = (
39
43
  "error",
40
44
  "traceError",
@@ -42,26 +46,34 @@ ITEM_ERROR_FIELDS = (
42
46
  "replayError",
43
47
  "replay_error",
44
48
  )
45
- UUID = re.compile(r"^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$")
46
- SHA = re.compile(r"^[0-9a-f]{40}$")
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
49
  ITEM_ID_FIELDS = ("originalTraceId", "original_trace_id", "traceId", "trace_id")
51
50
  ITEM_ERROR_LINES = 20
52
51
  ITEM_ERROR_LENGTH = 500
53
52
  OUTPUT_TAIL_LENGTH = 4000
53
+ UUID = re.compile(r"^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$")
54
+ SHA = re.compile(r"^[0-9a-f]{40}$")
54
55
  ENV_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
55
- CLOUD_VALUE_FLAGS = (
56
- "--registry",
57
- "--trace-ids",
58
- "--max-concurrency",
56
+ EXPERIMENT_LINE = re.compile(rb"^\[replay\] Experiment ([0-9a-f-]{36}):")
57
+ SECRET_REFERENCE = re.compile(
58
+ r"[\"']?([A-Za-z_][A-Za-z0-9_]*)[\"']?[ \t]*:[ \t]*[\"']?\$\{\{\s*secrets(?:\.([A-Za-z_][A-Za-z0-9_]*)|\[\s*[\"']([A-Za-z_][A-Za-z0-9_]*)[\"']\s*\])\s*\}\}"
59
+ )
60
+ ENVIRONMENT_SETTING = re.compile(
61
+ r"^[ \t]*\"?environment\"?[ \t]*:[ \t]*[\"']?([\w.-]+)[\"']?[ \t]*,?[ \t]*$",
62
+ re.MULTILINE,
63
+ )
64
+ PUSH_TRIGGER = re.compile(
65
+ r"^[ \t]*\"?(?:on\"?[ \t]*:.*\bpush\b|push\"?[ \t]*:)", re.MULTILINE
66
+ )
67
+ LIFECYCLE_FLAGS = (
59
68
  "--cloud-status",
60
69
  "--cloud-watch",
61
70
  "--cloud-cancel",
62
71
  "--cloud-cleanup",
63
- "--cloud-request-id",
72
+ )
73
+ CLOUD_VALUE_FLAGS = (
74
+ *LIFECYCLE_FLAGS,
64
75
  "--cloud-include",
76
+ "--cloud-request-id",
65
77
  "--cloud-timeout",
66
78
  )
67
79
  CLOUD_SWITCHES = (
@@ -70,41 +82,63 @@ CLOUD_SWITCHES = (
70
82
  "--cloud-detach",
71
83
  "--cloud-check",
72
84
  "--fail-on-error",
73
- "-h",
74
- "--help",
85
+ "--dry-run",
75
86
  )
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",
87
+ CHECK_SWITCHES = ("--cloud-check", "--dry-run")
88
+ SEED_FLAGS = ("--seed", "--cases", "--from-trace", "--run")
89
+ PATH_FLAGS = ("--registry", "--params", "--code-change")
90
+ CODE_CHANGE_FLAGS = ("--code-change", "--no-code-change")
91
+ SELECTION_FLAGS = ("--trace-ids", "--dataset-ids", "--dataset-id", "--resume")
92
+ ACTIONS = {
93
+ "checkout": "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1",
94
+ "node": "actions/setup-node@820762786026740c76f36085b0efc47a31fe5020",
95
+ "pnpm": "pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413",
96
+ "bun": "oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6",
97
+ "python": "actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97",
98
+ "uv": "astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7",
99
+ "ruby": "ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8",
100
+ "go": "actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e",
82
101
  }
83
- PATH_FLAGS = ("--params", "--code-change")
84
- SELECTION_FLAGS = ("--dataset-ids", "--dataset-id", "--resume")
85
- HELP = """GitHub cloud replay (requires git, gh login, and Python 3.10+).
86
- --cloud PIPELINE --trace-ids UUID[,UUID] [--registry PATH]
87
- [--max-concurrency 1..32] [--cloud-request-id UUID]
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.
99
- --cloud-status UUID | --cloud-watch UUID | --cloud-cancel UUID
100
- --cloud-cleanup UUID
101
- --cloud-init [--config SPEC] (creates a setup, or brings an existing one up to date)
102
- --cloud-secrets --env-file FILE [NAME ...]
103
- PIPELINE may be any pipeline in the registry, unless .bitfab/cloud.json names one.
104
- Snapshot tracked working files without changing HEAD, the index, or local files.
105
- New files require explicit --cloud-include. Credentials and ignored files are refused.
106
- By default wait for completion and remove the remote snapshot branch. Detached runs
107
- continue on GitHub; watch/status/cleanup can recover them using the printed UUID.
102
+ NO_GITHUB = (
103
+ "Cloud replay needs access to GitHub. Any one of these works: "
104
+ "log in with the GitHub CLI (gh auth login); "
105
+ "set GH_TOKEN to a token that can push to the repository and run its Actions; "
106
+ "or push to github.com over https once, so git stores a credential for it"
107
+ )
108
+ HELP = """Cloud replay: run the replay you run locally on GitHub Actions by adding --cloud.
109
+
110
+ bitfab-replay --registry scripts/replay.ts classify --trace-ids UUID --cloud
111
+
112
+ Every replay option works as it does locally and is passed to the runner as given.
113
+ The runner starts the same bitfab-replay from the same directory, on a snapshot of
114
+ your working tree. New files need --cloud-include PATH. Credentials and ignored files
115
+ are refused. Requires git and Python 3.10+ on macOS or Linux, with a github.com origin.
116
+
117
+ Options added by --cloud:
118
+ --dry-run Check the runner without replaying: every secret the workflow
119
+ maps has a value, BITFAB_REPLAY_CHECK passes when set, and the
120
+ traces resolve. --cloud-check is the same.
121
+ --cloud-dry-run Show what the snapshot would contain and push nothing.
122
+ --cloud-include PATH Add a new file to the snapshot (repeatable).
123
+ --cloud-detach Return after dispatch instead of waiting.
124
+ --cloud-timeout MIN Stop the replay after MIN minutes (1..7200).
125
+ --cloud-request-id ID Recover a submission whose response was lost.
126
+ --fail-on-error Exit 1 when any replayed item errored.
127
+
128
+ Follow a replay by the execution UUID it prints:
129
+ --cloud-status ID | --cloud-watch ID | --cloud-cancel ID | --cloud-cleanup ID
130
+
131
+ Set up once:
132
+ --cloud-init [--secret NAME ...] [--environment NAME] [--run COMMAND]
133
+ [--runs-on LABEL] [--secret-prefix PREFIX] [--check COMMAND]
134
+ Writes .github/workflows/bitfab-replay.yml, the only file cloud replay keeps
135
+ in the repository. Run it again to update a setup made by an older SDK.
136
+ --cloud-secrets --env-file FILE [NAME ...] [--environment NAME] [--dry-run]
137
+ Copy local values into the GitHub secrets the workflow reads.
138
+
139
+ GitHub access comes from the GitHub CLI when it is logged in, otherwise from GH_TOKEN
140
+ or GITHUB_TOKEN, otherwise from the github.com credential git already stores.
141
+ Only --cloud-secrets needs the GitHub CLI itself.
108
142
  """
109
143
 
110
144
 
@@ -126,7 +160,6 @@ def command(args, *, cwd=None, env=None, timeout=60, input=None):
126
160
  timeout=timeout,
127
161
  )
128
162
  if result.returncode:
129
- # Child errors can contain credential-bearing URLs or application output.
130
163
  status = re.search(r"\bHTTP (\d{3})\b", result.stderr or "")
131
164
  http_status = int(status[1]) if status else None
132
165
  raise CommandError(
@@ -160,6 +193,24 @@ def within(root, value):
160
193
  return path
161
194
 
162
195
 
196
+ def repository_path(root, path):
197
+ try:
198
+ relative = Path(path).resolve().relative_to(root).as_posix()
199
+ except ValueError as error:
200
+ raise ValueError(f"{path} must be inside the repository") from error
201
+ if sensitive(relative):
202
+ raise ValueError(f"Refusing credential-like file: {relative}")
203
+ return relative
204
+
205
+
206
+ def working_directory(root):
207
+ try:
208
+ relative = Path.cwd().resolve().relative_to(root).as_posix()
209
+ except ValueError as error:
210
+ raise ValueError("Run cloud replay from inside the repository") from error
211
+ return relative or "."
212
+
213
+
163
214
  def repository(root):
164
215
  remote = git(root, "remote", "get-url", "origin")
165
216
  match = re.fullmatch(
@@ -169,7 +220,6 @@ def repository(root):
169
220
  raise ValueError(
170
221
  "origin must be a github.com repository without embedded credentials"
171
222
  )
172
- # A pushurl can silently send the snapshot somewhere other than origin's fetch URL.
173
223
  push = git(root, "remote", "get-url", "--push", "origin")
174
224
  other = re.fullmatch(
175
225
  r"(?:git@github\.com:|https://github\.com/)([\w.-]+/[\w.-]+?)(?:\.git)?", push
@@ -179,15 +229,31 @@ def repository(root):
179
229
  return match[1]
180
230
 
181
231
 
182
- def configuration(root):
183
- path = within(root, CONFIG)
184
- if not path.is_file():
232
+ def find_workflow(root):
233
+ for name in WORKFLOW_NAMES:
234
+ if within(root, f"{WORKFLOW_DIRECTORY}/{name}").is_file():
235
+ return name
236
+ return None
237
+
238
+
239
+ def setup_workflow(root):
240
+ if within(root, OLD_CONFIG).exists():
241
+ raise ValueError(
242
+ f"This cloud replay setup was made by an older SDK. Run bitfab-replay --cloud-init once to move it into the workflow and remove {OLD_CONFIG}"
243
+ )
244
+ name = find_workflow(root)
245
+ if name is None:
185
246
  raise ValueError(
186
- f"Run bitfab:setup cloud first. Expected {CONFIG} inside this repository."
247
+ f"No {WORKFLOW_DIRECTORY}/{WORKFLOW} yet. Run bitfab-replay --cloud-init, or ask your coding agent for bitfab:setup cloud"
187
248
  )
188
- config = json.loads(path.read_text())
189
- validate_config(config)
190
- return config
249
+ return name
250
+
251
+
252
+ def workflow_secrets(text):
253
+ return {
254
+ name: dotted or bracketed
255
+ for name, dotted, bracketed in SECRET_REFERENCE.findall(text)
256
+ }
191
257
 
192
258
 
193
259
  ENV_ASSIGNMENT = re.compile(r"[ \t]*(?:export[ \t]+)?([A-Za-z_][A-Za-z0-9_]*)[ \t]*=")
@@ -243,12 +309,12 @@ def parse_environment_file(text):
243
309
  def configure_secrets(argv):
244
310
  parser = argparse.ArgumentParser(
245
311
  prog="bitfab-replay --cloud-secrets",
246
- 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.",
312
+ description="Copy named values from local environment files into the GitHub Actions secrets the cloud replay workflow reads. Values are piped to gh on standard input and never printed, logged, or passed as arguments.",
247
313
  )
248
314
  parser.add_argument(
249
315
  "names",
250
316
  nargs="*",
251
- help="Environment variable names to copy; defaults to the secrets recorded by setup",
317
+ help="Environment variable names to copy; defaults to every secret the workflow maps",
252
318
  )
253
319
  parser.add_argument(
254
320
  "--env-file",
@@ -258,7 +324,7 @@ def configure_secrets(argv):
258
324
  )
259
325
  parser.add_argument(
260
326
  "--environment",
261
- help="Set GitHub Environment secrets instead of repository ones",
327
+ help="GitHub Environment to set the secrets in; defaults to the workflow's environment",
262
328
  )
263
329
  parser.add_argument(
264
330
  "--dry-run",
@@ -267,11 +333,17 @@ def configure_secrets(argv):
267
333
  )
268
334
  args = parser.parse_args(argv)
269
335
  root = root_directory()
270
- config = configuration(root)
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")
336
+ workflow = setup_workflow(root)
337
+ text = within(root, f"{WORKFLOW_DIRECTORY}/{workflow}").read_text()
338
+ targets = workflow_secrets(text)
339
+ names = args.names or list(targets)
340
+ unknown = [name for name in names if name not in targets]
341
+ if unknown:
342
+ raise ValueError(
343
+ f"The workflow does not read {', '.join(unknown)}. Add each one to the Replay step's env as NAME: ${{{{ secrets.{DEFAULT_SECRET_PREFIX}NAME }}}} first"
344
+ )
345
+ configured = ENVIRONMENT_SETTING.search(text)
346
+ environment = args.environment or (configured[1] if configured else None)
275
347
  values = {}
276
348
  for path in args.env_file:
277
349
  for key, value in parse_environment_file(
@@ -279,318 +351,264 @@ def configure_secrets(argv):
279
351
  ).items():
280
352
  values.setdefault(key, value)
281
353
  repo = repository(root)
282
- assigned = []
283
- missing = []
284
- empty = []
354
+ found, missing, empty = [], [], []
285
355
  for name in names:
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)
356
+ value = values.get(name, values.get(targets[name]))
289
357
  if value is None:
290
358
  missing.append(name)
291
- continue
292
- if not value:
359
+ elif not value:
293
360
  empty.append(name)
294
- continue
361
+ else:
362
+ found.append(name)
363
+ if found and not args.dry_run and shutil.which("gh") is None:
364
+ where = (
365
+ f"https://github.com/{repo}/settings/environments"
366
+ if environment
367
+ else f"https://github.com/{repo}/settings/secrets/actions"
368
+ )
369
+ raise ValueError(
370
+ f"Copying secrets needs the GitHub CLI, which encrypts each value for GitHub. Install it (https://cli.github.com), or create {', '.join(targets[name] for name in found)} by hand at {where}"
371
+ )
372
+ for name in found:
295
373
  if not args.dry_run:
296
374
  command(
297
375
  [
298
376
  "gh",
299
377
  "secret",
300
378
  "set",
301
- target,
379
+ targets[name],
302
380
  "--repo",
303
381
  repo,
304
- *(["--env", args.environment] if args.environment else []),
382
+ *(["--env", environment] if environment else []),
305
383
  ],
306
- input=value,
384
+ input=values.get(name, values.get(targets[name])),
307
385
  )
308
- assigned.append(target)
309
386
  return {
310
387
  "repository": repo,
311
- "environment": args.environment,
388
+ "environment": environment,
312
389
  "dryRun": args.dry_run,
313
- "set": assigned,
390
+ "set": [targets[name] for name in found],
314
391
  "missing": missing,
315
392
  "empty": empty,
316
393
  "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",
317
394
  }
318
395
 
319
396
 
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
397
+ @functools.cache
398
+ def github_access():
399
+ if shutil.which("gh") is not None:
400
+ with contextlib.suppress(RuntimeError, OSError, subprocess.SubprocessError):
401
+ command(["gh", "auth", "status", "--hostname", "github.com"])
402
+ return {"source": "the GitHub CLI", "token": None}
403
+ for name in ("GH_TOKEN", "GITHUB_TOKEN"):
404
+ if os.environ.get(name):
405
+ return {"source": name, "token": os.environ[name]}
406
+ token = stored_git_credential()
407
+ if token:
408
+ return {"source": "the github.com credential git stores", "token": token}
409
+ raise ValueError(NO_GITHUB)
327
410
 
328
411
 
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
-
362
- def validate_config(config):
363
- if config.get("version") != 1 or config.get("provider") != "github":
364
- raise ValueError("Run bitfab:setup cloud to configure the GitHub provider")
365
- if not re.fullmatch(r"[\w-]+\.ya?ml", config.get("workflow", "")):
366
- raise ValueError("Invalid workflow filename")
367
- relative_path(config["workingDirectory"])
368
- if config.get("registry") is not None:
369
- relative_path(config["registry"])
370
- pipeline = config.get("pipeline")
371
- if pipeline is not None and not (
372
- isinstance(pipeline, str) and PIPELINE.fullmatch(pipeline)
373
- ):
374
- raise ValueError("Invalid pipeline name")
375
- args = config.get("command")
376
- if (
377
- not isinstance(args, list)
378
- or not args
379
- or not all(isinstance(v, str) and v and "\x00" not in v for v in args)
380
- ):
381
- raise ValueError("command must be a nonempty JSON argument array")
382
- if any(v.startswith("--cloud") for v in args):
383
- raise ValueError(
384
- "The runner command must execute locally, not recursively dispatch"
385
- )
386
- if "cliCommand" in config:
387
- validate_cli_command(config["cliCommand"])
388
- if config.get("pushTriggersReviewed") is not True:
389
- raise ValueError(
390
- "Setup must review push-triggered CI/deployments and set pushTriggersReviewed=true"
391
- )
392
- prefix = config.get("secretPrefix", "")
393
- if not isinstance(prefix, str) or (
394
- prefix and not re.fullmatch(r"[A-Z][A-Z0-9_]*_", prefix)
395
- ):
396
- raise ValueError(
397
- "secretPrefix must be uppercase and end with an underscore, such as BITFAB_CLOUD_"
398
- )
399
- names = config.get("secrets", [])
400
- if not isinstance(names, list) or not all(
401
- isinstance(name, str) and re.fullmatch(r"[A-Z_][A-Z0-9_]*", name)
402
- for name in names
403
- ):
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"
412
+ def stored_git_credential():
413
+ try:
414
+ result = subprocess.run(
415
+ ["git", "credential", "fill"],
416
+ input="protocol=https\nhost=github.com\n\n",
417
+ text=True,
418
+ capture_output=True,
419
+ check=False,
420
+ timeout=15,
421
+ env={
422
+ **os.environ,
423
+ "GIT_TERMINAL_PROMPT": "0",
424
+ "GCM_INTERACTIVE": "never",
425
+ "GIT_ASKPASS": "",
426
+ "SSH_ASKPASS": "",
427
+ },
428
428
  )
429
-
430
-
431
- def check_pipeline(config, pipeline):
432
- if not isinstance(pipeline, str) or not PIPELINE.fullmatch(pipeline):
433
- raise ValueError("Invalid pipeline name")
434
- allowed = config.get("pipeline")
435
- if allowed is not None and pipeline != allowed:
436
- raise ValueError("Pipeline does not match .bitfab/cloud.json")
437
-
438
-
439
- def gh(repo, *args, payload=None):
440
- return command(
441
- [
442
- "gh",
443
- "api",
444
- "--hostname",
445
- "github.com",
446
- "-H",
447
- f"X-GitHub-Api-Version: {API_VERSION}",
448
- "--jq",
449
- "tojson",
450
- *args,
451
- *(["--input", "-"] if payload is not None else []),
452
- ],
453
- input=json.dumps(payload) if payload is not None else None,
454
- )
429
+ except (OSError, subprocess.SubprocessError):
430
+ return None
431
+ if result.returncode:
432
+ return None
433
+ for line in result.stdout.splitlines():
434
+ if line.startswith("password="):
435
+ return line.removeprefix("password=") or None
436
+ return None
455
437
 
456
438
 
457
439
  def api(repo, suffix, *, method="GET", payload=None):
458
- result = gh(
459
- repo,
460
- f"repos/{repo}" + (f"/{suffix}" if suffix else ""),
461
- "--method",
462
- method,
463
- payload=payload,
440
+ path = f"repos/{repo}" + (f"/{suffix}" if suffix else "")
441
+ access = github_access()
442
+ data = None if payload is None else json.dumps(payload)
443
+ if access["token"] is None:
444
+ result = command(
445
+ [
446
+ "gh",
447
+ "api",
448
+ "--hostname",
449
+ "github.com",
450
+ "-H",
451
+ f"X-GitHub-Api-Version: {API_VERSION}",
452
+ "--jq",
453
+ "tojson",
454
+ path,
455
+ "--method",
456
+ method,
457
+ *(["--input", "-"] if data is not None else []),
458
+ ],
459
+ input=data,
460
+ )
461
+ return json.loads(result) if result else None
462
+ request = urllib.request.Request(
463
+ f"https://api.github.com/{path}",
464
+ method=method,
465
+ data=None if data is None else data.encode(),
466
+ headers={
467
+ "Accept": "application/vnd.github+json",
468
+ "Authorization": f"Bearer {access['token']}",
469
+ "X-GitHub-Api-Version": API_VERSION,
470
+ "User-Agent": "bitfab-cloud-replay",
471
+ **({"Content-Type": "application/json"} if data is not None else {}),
472
+ },
464
473
  )
465
- return json.loads(result) if result else None
466
-
467
-
468
- def split_replay_options(argv):
469
- cloud, options = [], []
474
+ try:
475
+ with urllib.request.urlopen(request, timeout=60) as response:
476
+ body = response.read()
477
+ except urllib.error.HTTPError as error:
478
+ raise CommandError(
479
+ f"GitHub {method} {path.split('?')[0]} failed (HTTP {error.code}) using {access['source']}",
480
+ error.code,
481
+ ) from None
482
+ except (urllib.error.URLError, OSError) as error:
483
+ reason = getattr(error, "reason", error)
484
+ if isinstance(reason, ssl.SSLCertVerificationError):
485
+ raise CommandError(
486
+ "Python cannot verify GitHub's certificate because it has no certificate store. "
487
+ "On a python.org install, run Install Certificates.command from its Applications folder, "
488
+ "set SSL_CERT_FILE to a certificate bundle, or log in with the GitHub CLI (gh auth login), which cloud replay uses instead"
489
+ ) from None
490
+ raise CommandError(f"Could not reach api.github.com: {reason}") from None
491
+ return json.loads(body) if body.strip() else None
492
+
493
+
494
+ def split_arguments(argv):
495
+ cloud, includes, switches, replay = {}, [], set(), []
470
496
  index = 0
471
497
  while index < len(argv):
472
498
  token = argv[index]
473
499
  name = token.split("=", 1)[0]
474
500
  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)
501
+ if "=" in token:
502
+ value = token.split("=", 1)[1]
495
503
  index += 1
496
- elif options:
497
- raise ValueError(
498
- "Put the pipeline right after --cloud, before replay options"
499
- )
504
+ elif index + 1 < len(argv):
505
+ value = argv[index + 1]
506
+ index += 2
507
+ else:
508
+ raise ValueError(f"{name} needs a value")
509
+ if name == "--cloud-include":
510
+ includes.append(value)
511
+ elif name in cloud:
512
+ raise ValueError(f"{name} was given twice")
513
+ else:
514
+ cloud[name] = value
515
+ elif token in CLOUD_SWITCHES:
516
+ switches.add(token)
517
+ index += 1
518
+ elif name in SEED_FLAGS:
519
+ raise ValueError(f"{name} seeds traces; run it locally, without --cloud")
500
520
  else:
501
- cloud.append(token)
521
+ replay.append(token)
502
522
  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
523
+ return cloud, includes, switches, replay
511
524
 
512
525
 
513
526
  def parse(argv):
514
- argv, options = split_replay_options(argv)
515
- flags = [value.split("=", 1)[0] for value in argv if value.startswith("--")]
516
- if any(flags.count(flag) > 1 for flag in flags if flag != "--cloud-include"):
517
- raise ValueError("Duplicate cloud option")
518
- parser = argparse.ArgumentParser(
519
- description=HELP,
520
- formatter_class=argparse.RawDescriptionHelpFormatter,
521
- allow_abbrev=False,
522
- )
523
- parser.add_argument("pipeline", nargs="?")
524
- parser.add_argument("--registry")
525
- parser.add_argument("--cloud", action="store_true")
526
- for name in ("status", "watch", "cancel", "cleanup"):
527
- parser.add_argument(f"--cloud-{name}")
528
- parser.add_argument("--trace-ids")
529
- parser.add_argument("--max-concurrency", type=int, default=1)
530
- parser.add_argument("--cloud-request-id")
531
- parser.add_argument("--cloud-include", action="append", default=[])
532
- parser.add_argument("--cloud-dry-run", action="store_true")
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")
537
- args = parser.parse_args(argv)
538
- args.options = options
539
- operations = [
540
- name
541
- for name in ("status", "watch", "cancel", "cleanup")
542
- if getattr(args, "cloud_" + name)
543
- ]
544
- if operations:
545
- if len(operations) != 1 or len(argv) != 2 or options:
527
+ cloud, includes, switches, replay = split_arguments(argv)
528
+ lifecycle = [flag for flag in LIFECYCLE_FLAGS if flag in cloud]
529
+ if lifecycle:
530
+ if len(lifecycle) != 1 or len(cloud) != 1 or includes or switches or replay:
546
531
  raise ValueError("Cloud lifecycle commands take only their execution UUID")
547
- operation = operations[0]
548
- execution_id = getattr(args, "cloud_" + operation)
532
+ operation = lifecycle[0].removeprefix("--cloud-")
533
+ execution_id = cloud[lifecycle[0]]
549
534
  else:
550
- if not args.cloud or not args.pipeline:
551
- raise ValueError(HELP)
552
- if not args.trace_ids and not any(flag in options for flag in SELECTION_FLAGS):
535
+ if not replay:
536
+ raise ValueError(
537
+ "Add --cloud to the replay command you run locally, such as bitfab-replay --registry scripts/replay.ts classify --trace-ids UUID --cloud"
538
+ )
539
+ if not any(token.split("=", 1)[0] in SELECTION_FLAGS for token in replay):
553
540
  raise ValueError(
554
541
  "Select traces with --trace-ids, --dataset-ids, or --resume"
555
542
  )
556
- operation = "submit"
557
- execution_id = args.cloud_request_id or str(uuid.uuid4())
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")
564
- if not 1 <= args.max_concurrency <= 32:
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:
543
+ timeout = cloud.get("--cloud-timeout")
544
+ if timeout is not None and not (
545
+ timeout.isdigit() and 1 <= int(timeout) <= 7200
546
+ ):
568
547
  raise ValueError("--cloud-timeout must be 1..7200 minutes")
548
+ operation = "submit"
549
+ execution_id = cloud.get("--cloud-request-id") or str(uuid.uuid4())
569
550
  if not UUID.fullmatch(execution_id):
570
551
  raise ValueError("Execution ID must be a UUID")
571
- return args, operation, execution_id
552
+ return {
553
+ "operation": operation,
554
+ "id": execution_id,
555
+ "cloud": cloud,
556
+ "includes": includes,
557
+ "switches": switches,
558
+ "replay": replay,
559
+ }
572
560
 
573
561
 
574
- def option_paths(options):
575
- return [value for flag, value in zip(options, options[1:]) if flag in PATH_FLAGS]
562
+ def validate_arguments(args):
563
+ if not isinstance(args, list) or not all(isinstance(value, str) for value in args):
564
+ raise ValueError("Replay arguments must be a list of strings")
565
+ for value in args:
566
+ if "\x00" in value or "\n" in value or len(value) > 1000:
567
+ raise ValueError(
568
+ "Replay options must be single-line values of at most 1000 characters"
569
+ )
570
+ if value.startswith("--cloud"):
571
+ raise ValueError("The runner replays locally and never dispatches again")
572
+ if sum(len(value) for value in args) > 16000:
573
+ raise ValueError("Replay options exceed 16000 characters")
576
574
 
577
575
 
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
576
+ def replay_request(root, parsed):
577
+ cwd = Path.cwd().resolve()
578
+ args, files = [], []
584
579
 
580
+ def snapshot_path(value):
581
+ path = cwd / value
582
+ files.append(repository_path(root, path))
583
+ return (
584
+ os.path.relpath(path.resolve(), cwd) if Path(value).is_absolute() else value
585
+ )
585
586
 
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
587
+ replay = parsed["replay"]
588
+ index = 0
589
+ while index < len(replay):
590
+ token = replay[index]
591
+ name, separator, value = token.partition("=")
592
+ if name in PATH_FLAGS and separator:
593
+ args.append(f"{name}={snapshot_path(value)}")
594
+ elif name in PATH_FLAGS and index + 1 < len(replay):
595
+ args += [token, snapshot_path(replay[index + 1])]
596
+ index += 1
597
+ else:
598
+ args.append(token)
599
+ index += 1
600
+ validate_arguments(args)
601
+ request = {
602
+ "version": REQUEST_VERSION,
603
+ "id": parsed["id"],
604
+ "cwd": working_directory(root),
605
+ "args": args,
606
+ }
607
+ if "--cloud-timeout" in parsed["cloud"]:
608
+ request["timeoutMinutes"] = int(parsed["cloud"]["--cloud-timeout"])
609
+ if parsed["switches"] & set(CHECK_SWITCHES):
610
+ request["check"] = True
611
+ return request, files
594
612
 
595
613
 
596
614
  def sensitive(path):
@@ -616,16 +634,15 @@ def sensitive(path):
616
634
  )
617
635
 
618
636
 
619
- def snapshot(root, config, args, execution_id, files=()):
637
+ def snapshot(root, workflow, includes, files, execution_id, *, dry_run=False):
620
638
  if git(root, "ls-files", "-u"):
621
639
  raise ValueError("Resolve merge conflicts before snapshotting")
622
640
  head = git(root, "rev-parse", "HEAD")
623
- includes = []
624
- for value in args.cloud_include:
641
+ included = []
642
+ for value in includes:
625
643
  path = within(root, value)
626
644
  if not path.is_file() or path.is_symlink() or (root / value).is_symlink():
627
645
  raise ValueError("--cloud-include requires individual regular files")
628
- # Never force-add ignored content, even when explicitly requested.
629
646
  ignored = subprocess.run(
630
647
  ["git", "-C", str(root), "check-ignore", "--quiet", "--", value],
631
648
  check=False,
@@ -635,13 +652,13 @@ def snapshot(root, config, args, execution_id, files=()):
635
652
  raise ValueError("Unable to determine whether the included file is ignored")
636
653
  if ignored == 0 or sensitive(value):
637
654
  raise ValueError(f"Refusing ignored or credential-like file: {value}")
638
- includes.append(value)
655
+ included.append(value)
639
656
  with tempfile.TemporaryDirectory(prefix="bitfab-cloud-index-") as directory:
640
657
  env = {**os.environ, "GIT_INDEX_FILE": str(Path(directory) / "index")}
641
658
  git(root, "read-tree", head, env=env)
642
659
  git(root, "add", "-u", "--", ".", env=env)
643
- if includes:
644
- git(root, "--literal-pathspecs", "add", "--", *includes, env=env)
660
+ if included:
661
+ git(root, "--literal-pathspecs", "add", "--", *included, env=env)
645
662
  entries = git(root, "ls-files", "--stage", "-z", env=env).split("\x00")
646
663
  for entry in filter(None, entries):
647
664
  metadata, path = entry.split("\t", 1)
@@ -653,9 +670,7 @@ def snapshot(root, config, args, execution_id, files=()):
653
670
  raise ValueError(
654
671
  f"Refusing credential-like tracked file in snapshot: {path}"
655
672
  )
656
- required = [CONFIG, f".github/workflows/{config['workflow']}", *files]
657
- if config.get("registry") is not None:
658
- required.append(config["registry"])
673
+ required = [f"{WORKFLOW_DIRECTORY}/{workflow}", *files]
659
674
  tracked = set(
660
675
  git(
661
676
  root, "--literal-pathspecs", "ls-files", "-z", "--", *required, env=env
@@ -669,8 +684,8 @@ def snapshot(root, config, args, execution_id, files=()):
669
684
  + ". Commit these files, or pass --cloud-include for each one"
670
685
  )
671
686
  tree = git(root, "write-tree", env=env)
672
- files = git(root, "diff", "--name-only", head, tree).splitlines()
673
- if args.cloud_dry_run:
687
+ changed = git(root, "diff", "--name-only", head, tree).splitlines()
688
+ if dry_run:
674
689
  new_files = set(
675
690
  git(root, "ls-files", "--others", "--exclude-standard").splitlines()
676
691
  )
@@ -681,11 +696,10 @@ def snapshot(root, config, args, execution_id, files=()):
681
696
  )
682
697
  return {
683
698
  "baseSha": head,
684
- "files": files,
685
- "includedFiles": includes,
686
- "omittedNewFiles": sorted(new_files - set(includes)),
699
+ "files": changed,
700
+ "includedFiles": included,
701
+ "omittedNewFiles": sorted(new_files - set(included)),
687
702
  }
688
- # commit-tree adds an unreachable object, never touching HEAD or the real index.
689
703
  sha = git(
690
704
  root,
691
705
  "commit-tree",
@@ -694,7 +708,7 @@ def snapshot(root, config, args, execution_id, files=()):
694
708
  head,
695
709
  input=f"Bitfab replay {execution_id}\n",
696
710
  )
697
- return {"baseSha": head, "sha": sha, "files": files}
711
+ return {"baseSha": head, "sha": sha, "files": changed}
698
712
 
699
713
 
700
714
  def state_directory(root):
@@ -780,9 +794,12 @@ def status(record, *, fetch_result=True):
780
794
  try:
781
795
  result = read_result(record)
782
796
  except RuntimeError:
783
- # A run that failed before its replay started reports no result.
784
797
  if success:
785
798
  raise
799
+ result = None
800
+ if result is None:
801
+ if success:
802
+ raise ValueError("The GitHub job succeeded but left no replay result")
786
803
  record["resultChecked"] = True
787
804
  return record
788
805
  if (
@@ -810,16 +827,6 @@ def status(record, *, fetch_result=True):
810
827
 
811
828
 
812
829
  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
830
  repo = record["repository"]
824
831
  jobs = api(repo, f"actions/runs/{record['runId']}/jobs?per_page=100") or {}
825
832
  for job in jobs.get("jobs", []):
@@ -838,28 +845,6 @@ def read_result_annotation(record):
838
845
  return None
839
846
 
840
847
 
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
-
863
848
  def cleanup(root, record):
864
849
  if record.get("cleaned"):
865
850
  return
@@ -891,70 +876,56 @@ def http_status(error):
891
876
  return f" (HTTP {status})" if status else ""
892
877
 
893
878
 
894
- def preflight(repo, config):
879
+ def preflight(repo, workflow):
880
+ access = github_access()
895
881
  try:
896
- command(["gh", "auth", "status", "--hostname", "github.com"])
882
+ details = api(repo, "")
897
883
  except RuntimeError as error:
898
884
  raise ValueError(
899
- "gh is not logged in to github.com; run gh auth login"
885
+ f"GitHub access from {access['source']} cannot read {repo}{http_status(error)}; use an account or token with write access to it"
900
886
  ) from error
901
- try:
902
- api(repo, "")
903
- except RuntimeError as error:
887
+ if (details or {}).get("permissions", {}).get("push") is False:
904
888
  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']}"
889
+ f"GitHub access from {access['source']} cannot push to {repo}; use an account or token with write access to it"
890
+ )
891
+ path = f"{WORKFLOW_DIRECTORY}/{workflow}"
908
892
  try:
909
- workflow = api(repo, f"actions/workflows/{config['workflow']}")
893
+ registered = api(repo, f"actions/workflows/{workflow}")
910
894
  except RuntimeError as error:
911
895
  raise ValueError(
912
896
  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
897
  ) from error
914
- if workflow.get("state") != "active":
898
+ if registered.get("state") != "active":
915
899
  raise ValueError(
916
- f"{path} is {workflow.get('state')}; enable it in the repository's Actions tab"
900
+ f"{path} is {registered.get('state')}; enable it in the repository's Actions tab"
917
901
  )
918
902
 
919
903
 
920
904
  def run_cli(argv):
921
- args, operation, execution_id = parse(argv)
905
+ parsed = parse(argv)
906
+ operation, execution_id = parsed["operation"], parsed["id"]
922
907
  if os.name != "posix" or sys.version_info < (3, 10):
923
908
  raise ValueError("Cloud replay currently requires macOS/Linux and Python 3.10+")
924
909
  root = root_directory()
925
- config = configuration(root) if operation == "submit" else None
910
+ workflow = setup_workflow(root) if operation == "submit" else None
926
911
  repo = repository(root)
927
912
  directory = state_directory(root)
928
913
  path = directory / f"{execution_id}.json"
929
914
  with execution_lock(directory, execution_id):
930
915
  if operation == "submit":
931
- within(root, config["workingDirectory"])
932
- check_pipeline(config, args.pipeline)
933
- if args.registry is not None:
934
- registry = Path(args.registry).resolve().relative_to(root).as_posix()
935
- if registry != config.get("registry"):
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
- )
940
- request = {
941
- "id": execution_id,
942
- "pipeline": args.pipeline,
943
- "maxConcurrency": args.max_concurrency,
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
953
- if args.cloud_dry_run:
916
+ request, files = replay_request(root, parsed)
917
+ if "--cloud-dry-run" in parsed["switches"]:
954
918
  return {
955
919
  "dryRun": True,
956
920
  "repository": repo,
957
- **snapshot(root, config, args, execution_id, option_paths(options)),
921
+ **snapshot(
922
+ root,
923
+ workflow,
924
+ parsed["includes"],
925
+ files,
926
+ execution_id,
927
+ dry_run=True,
928
+ ),
958
929
  }
959
930
  if path.exists():
960
931
  record = json.loads(path.read_text())
@@ -965,27 +936,32 @@ def run_cli(argv):
965
936
  raise ValueError(
966
937
  "Execution ID already belongs to a different request"
967
938
  )
968
- # Never redispatch an uncertain request, even after a lost response.
969
939
  status(record)
970
940
  if record["state"] == "prepared":
971
941
  raise ValueError(
972
942
  "Submission stopped before dispatch. Use --cloud-cleanup, then submit a new execution UUID"
973
943
  )
974
944
  else:
975
- preflight(repo, config)
945
+ preflight(repo, workflow)
946
+ if request.get("check"):
947
+ print(
948
+ "Cloud dry run: dispatching a GitHub run that checks secrets and resolves the traces without replaying. It receives the workflow's secrets. Use --cloud-dry-run to only review the snapshot.",
949
+ file=sys.stderr,
950
+ flush=True,
951
+ )
976
952
  source = snapshot(
977
- root, config, args, execution_id, option_paths(options)
953
+ root, workflow, parsed["includes"], files, execution_id
978
954
  )
979
955
  record = {
980
956
  "id": execution_id,
981
957
  "repository": repo,
982
- "workflow": config["workflow"],
958
+ "workflow": workflow,
983
959
  "branch": PREFIX + execution_id,
984
960
  "request": request,
985
961
  "state": "prepared",
986
962
  **source,
987
963
  }
988
- if args.fail_on_error:
964
+ if "--fail-on-error" in parsed["switches"]:
989
965
  record["failOnError"] = True
990
966
  save(path, record)
991
967
  print(
@@ -995,7 +971,6 @@ def run_cli(argv):
995
971
  )
996
972
  ref = "refs/heads/" + record["branch"]
997
973
  try:
998
- # Empty lease asserts that the temporary remote branch does not exist.
999
974
  git(
1000
975
  root,
1001
976
  "push",
@@ -1013,7 +988,7 @@ def run_cli(argv):
1013
988
  try:
1014
989
  response = api(
1015
990
  repo,
1016
- f"actions/workflows/{config['workflow']}/dispatches",
991
+ f"actions/workflows/{workflow}/dispatches",
1017
992
  method="POST",
1018
993
  payload={
1019
994
  "ref": record["branch"],
@@ -1025,7 +1000,7 @@ def run_cli(argv):
1025
1000
  )
1026
1001
  except RuntimeError as error:
1027
1002
  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}"
1003
+ f"Dispatching {workflow} on {record['branch']} failed{http_status(error)}; the GitHub 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
1004
  ) from error
1030
1005
  if isinstance(response, dict) and response.get("workflow_run_id"):
1031
1006
  record["runId"] = response["workflow_run_id"]
@@ -1049,11 +1024,10 @@ def run_cli(argv):
1049
1024
  record["state"] = "cancel_requested"
1050
1025
  save(path, record)
1051
1026
  if record["state"] == "prepared" and operation != "cleanup":
1052
- save(path, record)
1053
1027
  return record
1054
- if operation == "watch" or (operation == "submit" and not args.cloud_detach):
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.
1028
+ if operation == "watch" or (
1029
+ operation == "submit" and "--cloud-detach" not in parsed["switches"]
1030
+ ):
1057
1031
  started = time.monotonic()
1058
1032
  while record["state"] != "completed":
1059
1033
  status(record)
@@ -1080,70 +1054,74 @@ def run_cli(argv):
1080
1054
  return record
1081
1055
 
1082
1056
 
1057
+ def replay_command():
1058
+ try:
1059
+ value = json.loads(os.environ.get(REPLAY_COMMAND_ENV, ""))
1060
+ except ValueError:
1061
+ value = None
1062
+ if (
1063
+ not isinstance(value, list)
1064
+ or not value
1065
+ or not all(isinstance(part, str) and part for part in value)
1066
+ ):
1067
+ raise ValueError(
1068
+ "Start the Replay step with the SDK's bitfab-replay command and --cloud-execute, so the runner knows how to start the replay"
1069
+ )
1070
+ return value
1071
+
1072
+
1073
+ def dispatched_request():
1074
+ event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text())
1075
+ inputs = event.get("inputs") or {}
1076
+ request = json.loads(base64.b64decode(inputs.get("request", ""), validate=True))
1077
+ if request.get("id") != inputs.get("execution_id"):
1078
+ raise ValueError("Replay request does not match the dispatched execution")
1079
+ version = request.get("version", 0)
1080
+ if version < REQUEST_VERSION or "args" not in request:
1081
+ raise ValueError(
1082
+ "This replay was submitted by an older SDK than the one the snapshot installs; update the SDK you run locally to match the lockfile"
1083
+ )
1084
+ if version > REQUEST_VERSION:
1085
+ raise ValueError(
1086
+ "This replay was submitted by a newer SDK than the one the snapshot installs; update the SDK in the lockfile to match the one you run locally"
1087
+ )
1088
+ validate_arguments(request["args"])
1089
+ return request
1090
+
1091
+
1083
1092
  def execute():
1084
1093
  if os.environ.get("GITHUB_RUN_ATTEMPT") != "1":
1085
1094
  raise ValueError("Submit a new replay instead of rerunning an Actions job")
1086
- request = json.loads(
1087
- base64.b64decode(os.environ["BITFAB_CLOUD_REQUEST"], validate=True)
1088
- )
1095
+ request = dispatched_request()
1089
1096
  root = root_directory()
1090
- if git(root, "rev-parse", "HEAD") != os.environ["GITHUB_SHA"]:
1097
+ commit = os.environ["GITHUB_SHA"]
1098
+ if git(root, "rev-parse", "HEAD") != commit:
1091
1099
  raise ValueError("Runner checkout does not match the dispatched snapshot SHA")
1092
- config = configuration(root)
1093
- check_pipeline(config, request["pipeline"])
1094
- if request["id"] != os.environ["BITFAB_EXECUTION_ID"]:
1095
- raise ValueError("Replay request does not match the configured execution")
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(
1102
- [
1103
- "--cloud",
1104
- request["pipeline"],
1105
- *traces,
1106
- "--max-concurrency",
1107
- str(request["maxConcurrency"]),
1108
- "--cloud-request-id",
1109
- request["id"],
1110
- *([] if timeout is None else ["--cloud-timeout", str(timeout)]),
1111
- *(["--cloud-check"] if request.get("check") else []),
1112
- *options,
1113
- ]
1114
- )
1115
- if parsed.options != options:
1116
- raise ValueError("Replay options were not in the form the SDK sends")
1117
- check_secrets(config)
1100
+ directory = within(root, request["cwd"])
1101
+ check_secrets(root)
1102
+ names = {value.split("=", 1)[0] for value in request["args"]}
1118
1103
  args = [
1119
- *config["command"],
1120
- request["pipeline"],
1121
- *traces,
1122
- "--max-concurrency",
1123
- str(request["maxConcurrency"]),
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
- ),
1104
+ *replay_command(),
1105
+ *request["args"],
1106
+ *([] if names & set(CODE_CHANGE_FLAGS) else ["--no-code-change"]),
1130
1107
  ]
1108
+ identity = {"executionId": request["id"], "commitSha": commit}
1131
1109
  if request.get("check"):
1132
- summary = run_check(root, config, request, args)
1110
+ summary = {**identity, **run_check(directory, args)}
1133
1111
  write_result(summary)
1134
1112
  return summary
1135
1113
  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
1114
  previous = signal.signal(signal.SIGTERM, raise_interrupt)
1139
1115
  try:
1140
- summary = run_replay(root, config, request, args, timeout, experiment)
1116
+ summary = {
1117
+ **identity,
1118
+ **run_replay(directory, args, request.get("timeoutMinutes"), experiment),
1119
+ }
1141
1120
  except BaseException as error:
1142
1121
  if UUID.fullmatch(experiment.get("id", "")):
1143
1122
  write_result(
1144
1123
  {
1145
- "executionId": request["id"],
1146
- "commitSha": os.environ["GITHUB_SHA"],
1124
+ **identity,
1147
1125
  "testRunId": experiment["id"],
1148
1126
  "stoppedEarly": (str(error) or type(error).__name__)[:300],
1149
1127
  }
@@ -1155,16 +1133,18 @@ def execute():
1155
1133
  return summary
1156
1134
 
1157
1135
 
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)
1136
+ def running_workflow(root):
1137
+ reference = os.environ.get("GITHUB_WORKFLOW_REF", "")
1138
+ match = re.search(r"(\.github/workflows/[^@]+)@", reference)
1139
+ if match:
1140
+ return within(root, match[1])
1141
+ return within(root, f"{WORKFLOW_DIRECTORY}/{find_workflow(root) or WORKFLOW}")
1163
1142
 
1164
1143
 
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)]
1144
+ def check_secrets(root):
1145
+ path = running_workflow(root)
1146
+ targets = workflow_secrets(path.read_text()) if path.is_file() else {}
1147
+ empty = [name for name in targets if os.environ.get(name) == ""]
1168
1148
  if empty:
1169
1149
  raise ValueError(
1170
1150
  "These replay environment variables are empty on the runner: "
@@ -1173,16 +1153,16 @@ def check_secrets(config):
1173
1153
  )
1174
1154
 
1175
1155
 
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)
1156
+ def run_check(directory, args):
1157
+ check = os.environ.get(CHECK_COMMAND_ENV, "").strip()
1158
+ if check:
1159
+ print(f"Running {CHECK_COMMAND_ENV}: {check}", flush=True)
1180
1160
  code = subprocess.run(
1181
- config["checkCommand"], cwd=directory, stdin=subprocess.DEVNULL, check=False
1161
+ shlex.split(check), cwd=directory, stdin=subprocess.DEVNULL, check=False
1182
1162
  ).returncode
1183
1163
  if code:
1184
- raise ValueError(f"checkCommand exited {code}; its output is above")
1185
- result = run_command(root, config, [*args, "--dry-run"], None, {})
1164
+ raise ValueError(f"{CHECK_COMMAND_ENV} exited {code}; its output is above")
1165
+ result = run_command(directory, [*args, "--dry-run"], None, {})
1186
1166
  items = result.get("items")
1187
1167
  if not isinstance(items, list):
1188
1168
  raise ValueError("The replay dry run did not return its resolved items")
@@ -1192,12 +1172,7 @@ def run_check(root, config, request, args):
1192
1172
  raise ValueError(
1193
1173
  f"{len(errors)} of {len(items)} traces failed to resolve; the errors are above"
1194
1174
  )
1195
- return {
1196
- "executionId": request["id"],
1197
- "commitSha": os.environ["GITHUB_SHA"],
1198
- "check": "passed",
1199
- "resolved": len(items),
1200
- }
1175
+ return {"check": "passed", "resolved": len(items)}
1201
1176
 
1202
1177
 
1203
1178
  def item_errors(items):
@@ -1260,32 +1235,28 @@ def forward_stderr(stream, experiment):
1260
1235
 
1261
1236
 
1262
1237
  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
1238
  message = base64.b64encode(json.dumps(summary).encode()).decode()
1266
1239
  print(f"::notice title={RESULT_TITLE}::{message}", flush=True)
1267
1240
  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
- )
1241
+ heading = "Bitfab cloud check"
1242
+ lines = [
1243
+ f"Passed: every secret has a value and {summary['resolved']} traces resolved. Commit: `{summary['commitSha']}`"
1244
+ ]
1281
1245
  else:
1282
- lines.append(f"Replayed: {summary['replayed']}, errored: {summary['errored']}")
1246
+ heading = "Bitfab replay"
1247
+ lines = [
1248
+ f"Test run: `{summary['testRunId']}`",
1249
+ f"Commit: `{summary['commitSha']}`",
1250
+ f"Stopped early: {summary['stoppedEarly']}. Traces that finished are saved in this test run."
1251
+ if "stoppedEarly" in summary
1252
+ else f"Replayed: {summary['replayed']}, errored: {summary['errored']}",
1253
+ ]
1283
1254
  with Path(os.environ["GITHUB_STEP_SUMMARY"]).open("a") as file:
1284
- file.write("### Bitfab replay\n\n" + "\n\n".join(lines) + "\n")
1255
+ file.write(f"### {heading}\n\n" + "\n\n".join(lines) + "\n")
1285
1256
 
1286
1257
 
1287
- def run_replay(root, config, request, args, timeout, experiment):
1288
- result = run_command(root, config, args, timeout, experiment)
1258
+ def run_replay(directory, args, timeout, experiment):
1259
+ result = run_command(directory, args, timeout, experiment)
1289
1260
  test_run = result.get("testRunId", result.get("test_run_id"))
1290
1261
  if not isinstance(test_run, str) or not UUID.fullmatch(test_run):
1291
1262
  raise ValueError("Replay did not return a valid persisted test run UUID")
@@ -1295,13 +1266,7 @@ def run_replay(root, config, request, args, timeout, experiment):
1295
1266
  replayed = [item for item in items if not carried_over(item)]
1296
1267
  errors = item_errors(replayed)
1297
1268
  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
- }
1269
+ return {"testRunId": test_run, "replayed": len(replayed), "errored": len(errors)}
1305
1270
 
1306
1271
 
1307
1272
  def carried_over(item):
@@ -1321,11 +1286,11 @@ def print_output_tail(text):
1321
1286
  )
1322
1287
 
1323
1288
 
1324
- def run_command(root, config, args, timeout, experiment):
1289
+ def run_command(directory, args, timeout, experiment):
1325
1290
  with tempfile.TemporaryFile() as output:
1326
1291
  with subprocess.Popen(
1327
1292
  args,
1328
- cwd=within(root, config["workingDirectory"]),
1293
+ cwd=directory,
1329
1294
  stdout=output,
1330
1295
  stderr=subprocess.PIPE,
1331
1296
  stdin=subprocess.DEVNULL,
@@ -1336,7 +1301,6 @@ def run_command(root, config, args, timeout, experiment):
1336
1301
  )
1337
1302
  reader.start()
1338
1303
  try:
1339
- # Without --cloud-timeout the job's own timeout is the only limit.
1340
1304
  deadline = None if timeout is None else time.monotonic() + timeout * 60
1341
1305
  while child.poll() is None:
1342
1306
  if os.fstat(output.fileno()).st_size > 16 * 1024 * 1024:
@@ -1350,7 +1314,6 @@ def run_command(root, config, args, timeout, experiment):
1350
1314
  except BaseException:
1351
1315
  with contextlib.suppress(ProcessLookupError):
1352
1316
  os.killpg(child.pid, signal.SIGTERM)
1353
- # Time for the SDK to mark the experiment interrupted before the hard kill.
1354
1317
  with contextlib.suppress(subprocess.TimeoutExpired):
1355
1318
  child.wait(timeout=30)
1356
1319
  with contextlib.suppress(ProcessLookupError):
@@ -1399,7 +1362,7 @@ def replay_counts(result):
1399
1362
  )
1400
1363
  or errored > replayed
1401
1364
  ):
1402
- raise ValueError("Replay result artifact has invalid item counts")
1365
+ raise ValueError("Replay result has invalid item counts")
1403
1366
  return replayed, errored
1404
1367
 
1405
1368
 
@@ -1420,32 +1383,261 @@ def report_errored_items(record):
1420
1383
  return 0
1421
1384
 
1422
1385
 
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},
1386
+ PLAIN_SCALAR = re.compile(r"[A-Za-z0-9_./][A-Za-z0-9_ ./@*+=,()-]*")
1387
+ YAML_WORDS = {"true", "false", "yes", "no", "on", "off", "null", "~", "y", "n"}
1388
+
1389
+
1390
+ def yaml_scalar(value):
1391
+ if isinstance(value, (dict, list)):
1392
+ return "{}" if isinstance(value, dict) else "[]"
1393
+ if isinstance(value, bool):
1394
+ return "true" if value else "false"
1395
+ if isinstance(value, int):
1396
+ return str(value)
1397
+ text = str(value)
1398
+ if (
1399
+ PLAIN_SCALAR.fullmatch(text)
1400
+ and text == text.strip()
1401
+ and text.lower() not in YAML_WORDS
1402
+ and not re.fullmatch(r"[\d._+-]+", text)
1403
+ ):
1404
+ return text
1405
+ return json.dumps(text)
1406
+
1407
+
1408
+ def yaml_key(key):
1409
+ return "on" if key == "on" else yaml_scalar(str(key))
1410
+
1411
+
1412
+ def yaml_lines(value, indent=0):
1413
+ pad = " " * indent
1414
+ lines = []
1415
+ if isinstance(value, dict):
1416
+ for key, item in value.items():
1417
+ if isinstance(item, (dict, list)) and item:
1418
+ lines.append(f"{pad}{yaml_key(key)}:")
1419
+ lines += yaml_lines(item, indent + 1)
1420
+ else:
1421
+ lines.append(f"{pad}{yaml_key(key)}: {yaml_scalar(item)}")
1422
+ else:
1423
+ for item in value:
1424
+ if isinstance(item, dict) and item:
1425
+ nested = yaml_lines(item, indent + 1)
1426
+ lines.append(f"{pad}- {nested[0].lstrip()}")
1427
+ lines += nested[1:]
1428
+ else:
1429
+ lines.append(f"{pad}- {yaml_scalar(item)}")
1430
+ return lines
1431
+
1432
+
1433
+ def to_yaml(document):
1434
+ return "\n".join(yaml_lines(document)) + "\n"
1435
+
1436
+
1437
+ def find_upward(root, start, names):
1438
+ directory = start
1439
+ while True:
1440
+ for name in names:
1441
+ if (directory / name).is_file():
1442
+ return directory, name
1443
+ if directory == root:
1444
+ return None, None
1445
+ directory = directory.parent
1446
+
1447
+
1448
+ def step_directory(root, directory):
1449
+ relative = directory.relative_to(root).as_posix()
1450
+ return {} if relative in ("", ".") else {"working-directory": relative}
1451
+
1452
+
1453
+ def detect_language(root, start):
1454
+ markers = {
1455
+ "package.json": "typescript",
1456
+ "pyproject.toml": "python",
1457
+ "requirements.txt": "python",
1458
+ "Gemfile": "ruby",
1459
+ "go.mod": "go",
1429
1460
  }
1461
+ _, found = find_upward(root, start, list(markers))
1462
+ if found is None:
1463
+ raise ValueError(
1464
+ "Could not tell which Bitfab SDK this project uses; run --cloud-init through your SDK's bitfab-replay command"
1465
+ )
1466
+ return markers[found]
1430
1467
 
1431
1468
 
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": [
1469
+ def version_input(root, start, key, names, default):
1470
+ directory, name = find_upward(root, start, names)
1471
+ if name is None:
1472
+ return {f"{key}-version": default}
1473
+ return {f"{key}-version-file": (directory / name).relative_to(root).as_posix()}
1474
+
1475
+
1476
+ def typescript_project(root, start):
1477
+ directory, lockfile = find_upward(
1478
+ root,
1479
+ start,
1480
+ ["pnpm-lock.yaml", "bun.lock", "bun.lockb", "yarn.lock", "package-lock.json"],
1481
+ )
1482
+ manager = {
1483
+ "pnpm-lock.yaml": "pnpm",
1484
+ "bun.lock": "bun",
1485
+ "bun.lockb": "bun",
1486
+ "yarn.lock": "yarn",
1487
+ }.get(lockfile, "npm")
1488
+ install = {
1489
+ "pnpm": "pnpm install --frozen-lockfile",
1490
+ "bun": "bun install --frozen-lockfile",
1491
+ "yarn": "yarn install --frozen-lockfile",
1492
+ "npm": "npm ci" if lockfile else "npm install",
1493
+ }[manager]
1494
+ node = {
1495
+ "name": "Set up Node.js",
1496
+ "uses": ACTIONS["node"],
1497
+ "with": {
1498
+ **version_input(root, start, "node", [".nvmrc", ".node-version"], "lts/*"),
1499
+ **(
1500
+ {"cache": manager}
1501
+ if manager in ("pnpm", "yarn", "npm") and lockfile
1502
+ else {}
1503
+ ),
1504
+ },
1505
+ }
1506
+ steps = []
1507
+ if manager == "pnpm":
1508
+ steps.append({"name": "Install pnpm", "uses": ACTIONS["pnpm"]})
1509
+ if manager == "bun":
1510
+ steps.append({"name": "Install Bun", "uses": ACTIONS["bun"]})
1511
+ steps += [
1512
+ node,
1513
+ {
1514
+ "name": "Install dependencies",
1515
+ **step_directory(root, directory or start),
1516
+ "run": install,
1517
+ },
1518
+ ]
1519
+ run = {
1520
+ "pnpm": "pnpm exec bitfab-replay",
1521
+ "bun": "bunx bitfab-replay",
1522
+ "yarn": "yarn bitfab-replay",
1523
+ "npm": "npx --no-install bitfab-replay",
1524
+ }[manager]
1525
+ return steps, run
1526
+
1527
+
1528
+ def python_project(root, start):
1529
+ directory, lockfile = find_upward(
1530
+ root, start, ["uv.lock", "poetry.lock", "requirements.txt", "pyproject.toml"]
1531
+ )
1532
+ location = step_directory(root, directory or start)
1533
+ python = {
1534
+ "name": "Set up Python",
1535
+ "uses": ACTIONS["python"],
1536
+ "with": version_input(root, start, "python", [".python-version"], "3.12"),
1537
+ }
1538
+ if lockfile == "uv.lock":
1539
+ return [
1540
+ {"name": "Install uv", "uses": ACTIONS["uv"]},
1541
+ {"name": "Install dependencies", **location, "run": "uv sync --frozen"},
1542
+ ], "uv run bitfab-replay"
1543
+ if lockfile == "poetry.lock":
1544
+ return [
1545
+ python,
1437
1546
  {
1438
- "name": "Check out replay snapshot",
1439
- "uses": "actions/checkout@11d5960a326750d5838078e36cf38b85af677262",
1440
- "with": {"ref": "${{ github.sha }}", "persist-credentials": False},
1547
+ "name": "Install dependencies",
1548
+ **location,
1549
+ "run": "pipx install poetry && poetry install --no-interaction",
1441
1550
  },
1442
- *setup_steps,
1443
- replay,
1444
- ],
1551
+ ], "poetry run bitfab-replay"
1552
+ install = (
1553
+ "pip install -r requirements.txt"
1554
+ if lockfile == "requirements.txt"
1555
+ else "pip install ."
1556
+ )
1557
+ return [
1558
+ python,
1559
+ {"name": "Install dependencies", **location, "run": install},
1560
+ ], "bitfab-replay"
1561
+
1562
+
1563
+ def ruby_project(root, start):
1564
+ directory, _ = find_upward(root, start, ["Gemfile"])
1565
+ location = step_directory(root, directory or start)
1566
+ return [
1567
+ {
1568
+ "name": "Set up Ruby",
1569
+ "uses": ACTIONS["ruby"],
1570
+ "with": {"bundler-cache": True, **location},
1571
+ }
1572
+ ], "bundle exec bitfab-replay"
1573
+
1574
+
1575
+ def go_project(root, start):
1576
+ directory, name = find_upward(root, start, ["go.mod"])
1577
+ version = (
1578
+ {"go-version-file": (directory / name).relative_to(root).as_posix()}
1579
+ if name
1580
+ else {"go-version": "stable"}
1581
+ )
1582
+ return [{"name": "Set up Go", "uses": ACTIONS["go"], "with": version}], None
1583
+
1584
+
1585
+ PROJECTS = {
1586
+ "typescript": typescript_project,
1587
+ "python": python_project,
1588
+ "ruby": ruby_project,
1589
+ "go": go_project,
1590
+ }
1591
+
1592
+
1593
+ def push_triggers(root, workflow):
1594
+ found = []
1595
+ directory = root / WORKFLOW_DIRECTORY
1596
+ for path in sorted(directory.glob("*.y*ml")) if directory.is_dir() else []:
1597
+ text = path.read_text(errors="replace")
1598
+ if path.name != workflow and PUSH_TRIGGER.search(text) and PREFIX not in text:
1599
+ found.append(path.relative_to(root).as_posix())
1600
+ for path in (root / "vercel.json", Path.cwd() / "vercel.json"):
1601
+ relative = path.resolve().relative_to(root).as_posix()
1602
+ if (
1603
+ path.is_file()
1604
+ and PREFIX not in path.read_text(errors="replace")
1605
+ and relative not in found
1606
+ ):
1607
+ found.append(relative)
1608
+ return found
1609
+
1610
+
1611
+ def reference(kind, name):
1612
+ return "${{ " + kind + "." + name + " }}"
1613
+
1614
+
1615
+ def secret_env(names, prefix):
1616
+ return {name: reference("secrets", prefix + name) for name in names}
1617
+
1618
+
1619
+ def replay_step(run, directory, env):
1620
+ return {
1621
+ "name": "Replay",
1622
+ **({} if directory == "." else {"working-directory": directory}),
1623
+ "run": f"{run} --cloud-execute",
1624
+ "env": env,
1445
1625
  }
1446
- for key in ("environment", "services"):
1447
- if key in job_options:
1448
- job[key] = job_options[key]
1626
+
1627
+
1628
+ def workflow_document(steps, replay, *, runs_on="ubuntu-24.04", environment=None):
1629
+ job = {"runs-on": runs_on}
1630
+ if environment:
1631
+ job["environment"] = environment
1632
+ job["steps"] = [
1633
+ {
1634
+ "name": "Check out the replay snapshot",
1635
+ "uses": ACTIONS["checkout"],
1636
+ "with": {"persist-credentials": False},
1637
+ },
1638
+ *steps,
1639
+ replay,
1640
+ ]
1449
1641
  return {
1450
1642
  "name": "Bitfab cloud replay",
1451
1643
  "run-name": RUN_NAME,
@@ -1462,99 +1654,90 @@ def workflow_document(job_options, setup_steps, replay):
1462
1654
  }
1463
1655
 
1464
1656
 
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
-
1478
1657
  def initialize(argv):
1479
1658
  parser = argparse.ArgumentParser(
1480
1659
  prog="bitfab-replay --cloud-init",
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.",
1660
+ description="Write .github/workflows/bitfab-replay.yml, the only file cloud replay keeps in the repository. It detects the runtime, package manager, and install command from the directory you run it in. Run it again to update a setup made by an older SDK; a current setup is left alone, so edit the workflow directly to change it.",
1661
+ )
1662
+ parser.add_argument(
1663
+ "--secret",
1664
+ action="append",
1665
+ default=[],
1666
+ metavar="NAME",
1667
+ help="Environment variable the replay reads from a GitHub secret, such as OPENAI_API_KEY; repeatable. BITFAB_API_KEY is always included",
1482
1668
  )
1483
1669
  parser.add_argument(
1484
- "--config",
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',
1670
+ "--secret-prefix",
1671
+ default=DEFAULT_SECRET_PREFIX,
1672
+ help=f"Prefix of the GitHub secret each value is read from (default {DEFAULT_SECRET_PREFIX}, so OPENAI_API_KEY reads {DEFAULT_SECRET_PREFIX}OPENAI_API_KEY); pass an empty string to reuse secrets under their own names",
1673
+ )
1674
+ parser.add_argument(
1675
+ "--environment",
1676
+ help="GitHub Environment that holds the secrets, such as bitfab-replay; add required reviewers to it so no run gets credentials until someone approves",
1677
+ )
1678
+ parser.add_argument(
1679
+ "--run",
1680
+ help='Command that starts the SDK\'s bitfab-replay on the runner, such as "pnpm exec bitfab-replay"; detected for TypeScript, Python, and Ruby, required for Go (such as "go run ./cmd/registry")',
1681
+ )
1682
+ parser.add_argument("--runs-on", default="ubuntu-24.04", help="Runner label")
1683
+ parser.add_argument(
1684
+ "--check",
1685
+ help='Command the runner also runs during a cloud dry run, such as "node scripts/checkBucket.js"',
1486
1686
  )
1487
1687
  args = parser.parse_args(argv)
1488
- spec = json.loads(Path(args.config).read_text()) if args.config else None
1489
1688
  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")
1494
- config = {
1495
- key: spec[key]
1496
- for key in (
1497
- "version",
1498
- "provider",
1499
- "workflow",
1500
- "workingDirectory",
1501
- "registry",
1502
- "command",
1503
- "pushTriggersReviewed",
1689
+ if within(root, OLD_CONFIG).exists():
1690
+ return migrate(root, args)
1691
+ existing = find_workflow(root)
1692
+ if existing is not None:
1693
+ return {
1694
+ "files": [],
1695
+ "updated": False,
1696
+ "workflow": f"{WORKFLOW_DIRECTORY}/{existing}",
1697
+ "next": "Already set up. Edit the workflow directly to change its install steps, secrets, runner, or Environment",
1698
+ }
1699
+ prefix = args.secret_prefix
1700
+ if prefix and not re.fullmatch(r"[A-Z][A-Z0-9_]*_", prefix):
1701
+ raise ValueError(
1702
+ "--secret-prefix must be uppercase and end with an underscore, such as BITFAB_CLOUD_"
1504
1703
  )
1505
- }
1506
- config["pipeline"] = spec.get("pipeline")
1507
- secrets = spec.get("secrets", ["BITFAB_API_KEY"])
1508
- variables = spec.get("variables", [])
1509
- secret_prefix = spec.get("secretPrefix")
1510
- if secret_prefix is None:
1511
- secret_prefix = DEFAULT_SECRET_PREFIX
1512
- config["secrets"] = secrets
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"]
1523
- validate_config(config)
1524
- validate_cli_command(spec.get("cliCommand"))
1525
- if set(secrets) & set(variables):
1526
- raise ValueError("Secret names and variable names must not overlap")
1527
- if "BITFAB_API_KEY" not in secrets or not all(
1528
- re.fullmatch(r"[A-Z_][A-Z0-9_]*", name) for name in [*secrets, *variables]
1529
- ):
1704
+ names = list(dict.fromkeys(["BITFAB_API_KEY", *args.secret]))
1705
+ if not all(re.fullmatch(r"[A-Z_][A-Z0-9_]*", name) for name in names):
1706
+ raise ValueError("Secret names must be uppercase environment variable names")
1707
+ start = Path.cwd().resolve()
1708
+ directory = working_directory(root)
1709
+ language = os.environ.get(SDK_LANGUAGE_ENV) or detect_language(root, start)
1710
+ steps, run = PROJECTS[language](root, start)
1711
+ run = args.run or run
1712
+ if not run:
1530
1713
  raise ValueError(
1531
- "Supply uppercase secret/variable names, including BITFAB_API_KEY; never values"
1714
+ 'Pass --run with the command that starts your registry program on the runner, such as --run "go run ./cmd/registry"'
1532
1715
  )
1533
- steps = spec.get("setupSteps")
1534
- if (
1535
- not isinstance(steps, list)
1536
- or not steps
1537
- or not all(isinstance(step, dict) for step in steps)
1538
- ):
1539
- raise ValueError("setupSteps must contain reviewed GitHub Actions setup steps")
1716
+ env = secret_env(names, prefix)
1717
+ if args.check:
1718
+ env[CHECK_COMMAND_ENV] = args.check
1540
1719
  workflow = workflow_document(
1541
- spec, steps, replay_step(config, spec["cliCommand"], runner_env(config))
1720
+ steps,
1721
+ replay_step(run, directory, env),
1722
+ runs_on=args.runs_on,
1723
+ environment=args.environment,
1542
1724
  )
1543
- outputs = {
1544
- CONFIG: json.dumps(config, indent=2) + "\n",
1545
- f".github/workflows/{config['workflow']}": json.dumps(workflow, indent=2)
1546
- + "\n",
1547
- }
1548
- write_new_files(root, outputs)
1725
+ path = f"{WORKFLOW_DIRECTORY}/{WORKFLOW}"
1726
+ target = within(root, path)
1727
+ target.parent.mkdir(parents=True, exist_ok=True)
1728
+ with target.open("x") as file:
1729
+ file.write(to_yaml(workflow))
1549
1730
  return {
1550
- "files": list(outputs),
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",
1731
+ "files": [path],
1732
+ "detected": {
1733
+ "language": language,
1734
+ "install": [step.get("run") or step["uses"] for step in steps],
1735
+ "replay": replay_step(run, directory, env)["run"],
1736
+ },
1737
+ "secrets": sorted(prefix + name for name in names),
1738
+ "environment": args.environment,
1739
+ "reviewPushTriggers": push_triggers(root, WORKFLOW),
1740
+ "next": "Review the install steps against your CI and add anything detection missed, such as a build or code-generation step or a monorepo install filter; if you normally run replay through a wrapper script that sets environment variables, add them to the Replay step's env. Then create the secrets, exclude bitfab-replay/** from the push-triggered CI and deployments listed, merge the workflow to the default branch once so GitHub registers it, then run your replay with --cloud --dry-run",
1558
1741
  }
1559
1742
 
1560
1743
 
@@ -1565,28 +1748,41 @@ def replace_file(target, content):
1565
1748
  os.replace(temporary, target)
1566
1749
 
1567
1750
 
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 ""
1751
+ def remove_old_files(root):
1752
+ removed = []
1753
+ for old in (OLD_CONFIG, OLD_SCRIPT):
1754
+ if within(root, old).exists():
1755
+ within(root, old).unlink()
1756
+ removed.append(old)
1757
+ return removed
1758
+
1759
+
1760
+ def migrate(root, args):
1761
+ config = json.loads(within(root, OLD_CONFIG).read_text())
1762
+ name = config.get("workflow", WORKFLOW)
1763
+ path = f"{WORKFLOW_DIRECTORY}/{name}"
1764
+ text = within(root, path).read_text() if within(root, path).is_file() else ""
1575
1765
  try:
1576
1766
  workflow = json.loads(text)
1577
1767
  job = workflow["jobs"]["replay"]
1578
1768
  steps = job["steps"]
1579
1769
  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)
1770
+ if "--cloud-execute" in text and not any(
1771
+ old in text for old in (OLD_SCRIPT, OLD_UPLOAD_STEP, *OLD_RUNNER_ENV)
1586
1772
  ):
1587
- return {"files": [], "updated": False, "next": "Already up to date"}
1773
+ if config.get("checkCommand") and CHECK_COMMAND_ENV not in text:
1774
+ raise ValueError(
1775
+ f"{OLD_CONFIG} has a checkCommand the workflow lacks. Add {CHECK_COMMAND_ENV}: {shlex.join(config['checkCommand'])} to the Replay step's env, then run --cloud-init again"
1776
+ ) from error
1777
+ return {
1778
+ "files": [],
1779
+ "removed": remove_old_files(root),
1780
+ "updated": True,
1781
+ **dropped_command(config),
1782
+ "next": "The workflow already holds these settings, so only the old files were removed",
1783
+ }
1588
1784
  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"
1785
+ f"{path} is not the workflow --cloud-init generated. Make its Replay step run the SDK's bitfab-replay command with --cloud-execute and read each secret in its env, then delete {OLD_CONFIG}"
1590
1786
  ) from error
1591
1787
  replay = [
1592
1788
  index
@@ -1594,41 +1790,42 @@ def update_existing(root, spec):
1594
1790
  if isinstance(step, dict) and step.get("name") == "Replay"
1595
1791
  ]
1596
1792
  if len(replay) != 1:
1597
- raise ValueError(f"{name} must contain exactly one step named Replay")
1793
+ raise ValueError(f"{path} must contain exactly one step named Replay")
1598
1794
  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"]
1795
+ words = shlex.split(step.get("run") or "")
1796
+ if args.run:
1797
+ run = args.run
1798
+ elif words[-1:] == ["--cloud-execute"] and OLD_SCRIPT not in step.get("run", ""):
1799
+ run = shlex.join(words[:-1])
1800
+ elif config.get("cliCommand"):
1801
+ run = shlex.join(config["cliCommand"])
1606
1802
  else:
1607
1803
  raise ValueError(
1608
- "The Replay step does not run the SDK command; pass --config with a file whose cliCommand starts bitfab-replay from workingDirectory"
1804
+ 'The Replay step does not start the SDK\'s bitfab-replay command; pass --run with the command that does, such as --run "npx --no-install bitfab-replay"'
1609
1805
  )
1610
- validate_cli_command(cli_command)
1806
+ prefix = config.get("secretPrefix", "")
1611
1807
  env = {
1612
- key: value
1613
- for key, value in (step.get("env") or {}).items()
1614
- if key not in RUNNER_ENV
1808
+ **secret_env(config.get("secrets", []), prefix),
1809
+ **{
1810
+ key: value
1811
+ for key, value in (step.get("env") or {}).items()
1812
+ if key not in OLD_RUNNER_ENV
1813
+ },
1615
1814
  }
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 = {
1815
+ for variable, source in (config.get("env") or {}).items():
1816
+ kind, target = next(iter(source.items()))
1817
+ env.setdefault(
1818
+ variable, reference("secrets" if kind == "secret" else "vars", target)
1819
+ )
1820
+ if config.get("checkCommand"):
1821
+ env[CHECK_COMMAND_ENV] = shlex.join(config["checkCommand"])
1822
+ kept = {
1623
1823
  key: value
1624
1824
  for key, value in step.items()
1625
1825
  if key not in ("name", "uses", "with", "run", "working-directory", "env")
1626
1826
  }
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
1827
  job["steps"] = [
1631
- {**replay_step(config, cli_command, env), **extra}
1828
+ {**replay_step(run, config.get("workingDirectory", "."), env), **kept}
1632
1829
  if index == replay[0]
1633
1830
  else entry
1634
1831
  for index, entry in enumerate(steps)
@@ -1638,57 +1835,42 @@ def update_existing(root, spec):
1638
1835
  and str(entry.get("uses", "")).startswith("actions/upload-artifact@")
1639
1836
  )
1640
1837
  ]
1641
- # The limit older setups generated; a value the customer chose stays.
1642
1838
  if job.get("timeout-minutes") == OLD_JOB_TIMEOUT:
1643
1839
  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",
1840
+ replace_file(within(root, path), to_yaml(workflow))
1841
+ result = {
1842
+ "files": [path],
1843
+ "removed": remove_old_files(root),
1844
+ "updated": True,
1845
+ "next": "Review the diff. Replays run the workflow in their own snapshot, so the change applies without merging",
1670
1846
  }
1671
- for output, content in outputs.items():
1672
- replace_file(within(root, output), content)
1847
+ return {**result, **dropped_command(config)}
1848
+
1849
+
1850
+ def dropped_command(config):
1851
+ if not config.get("command"):
1852
+ return {}
1673
1853
  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",
1854
+ "droppedCommand": config["command"],
1855
+ "droppedCommandNext": "The runner now starts the same bitfab-replay you run locally, from the same directory and with the same arguments, so this command is no longer used. "
1856
+ "If it also set environment variables, add them to the Replay step's env",
1679
1857
  }
1680
1858
 
1681
1859
 
1682
1860
  def main():
1861
+ argv = sys.argv[1:]
1683
1862
  try:
1684
- if sys.argv[1:2] == ["--cloud-init"]:
1685
- result = initialize(sys.argv[2:])
1686
- elif sys.argv[1:2] == ["--cloud-secrets"]:
1687
- result = configure_secrets(sys.argv[2:])
1688
- elif sys.argv[1:] == ["--cloud-execute"]:
1863
+ if argv[:1] == ["--cloud-init"]:
1864
+ result = initialize(argv[1:])
1865
+ elif argv[:1] == ["--cloud-secrets"]:
1866
+ result = configure_secrets(argv[1:])
1867
+ elif argv == ["--cloud-execute"]:
1689
1868
  result = execute()
1869
+ elif "-h" in argv or "--help" in argv:
1870
+ print(HELP, end="")
1871
+ return 0
1690
1872
  else:
1691
- result = run_cli(sys.argv[1:])
1873
+ result = run_cli(argv)
1692
1874
  print(json.dumps(result, indent=2))
1693
1875
  if result.get("state") == "completed" and result.get("conclusion") != "success":
1694
1876
  if result.get("request", {}).get("check"):