bitfab 0.64.8 → 0.64.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cloudReplay.py CHANGED
@@ -35,7 +35,8 @@ SDK_LANGUAGE_ENV = "BITFAB_SDK_LANGUAGE"
35
35
  CHECK_COMMAND_ENV = "BITFAB_REPLAY_CHECK"
36
36
  EXECUTION_TARGET_ENV = "BITFAB_REPLAY_EXECUTION_TARGET"
37
37
  HOSTED_EXECUTION_TARGET = "hosted"
38
- REQUEST_VERSION = 3
38
+ REQUEST_VERSION = 4
39
+ PLAIN_REQUEST_VERSION = 3
39
40
  OLDEST_DISPATCHED_VERSION = 2
40
41
  RESULT_LINE = "bitfab-replay-result "
41
42
  RESULT_CHUNK = 4000
@@ -92,6 +93,13 @@ PUSH_TRIGGER = re.compile(
92
93
  )
93
94
  DISPATCH_INPUTS = ("execution_id", "request")
94
95
  BRANCH_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._/-]*")
96
+ ENV_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
97
+ RESERVED_ENV_PREFIXES = ("GITHUB_", "RUNNER_", "ACTIONS_", "BITFAB_REPLAY_")
98
+ SECRET_ENV_NAME = re.compile(
99
+ r"(?:^|_)(?:KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIALS?)(?:_|$)", re.IGNORECASE
100
+ )
101
+ ENV_FLAG = "--cloud-env"
102
+ URL_PASSWORD = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*://[^/\s:@]*:[^/\s@]+@")
95
103
  FOLLOW_FLAGS = (
96
104
  "--cloud-status",
97
105
  "--cloud-watch",
@@ -152,6 +160,11 @@ Options added by --cloud:
152
160
  instead of the summary. Large runs can exceed what the
153
161
  GitHub job log holds.
154
162
  --cloud-timeout MIN Stop the replay after MIN minutes (1..7200).
163
+ --cloud-env NAME=VALUE Set an environment variable for this replay only; repeat it
164
+ for more. NAME alone copies the value from your shell. The
165
+ value is stored in the snapshot commit on GitHub, so this is
166
+ for settings, never secrets: names that look like secrets,
167
+ and names the workflow reads from a secret, are refused.
155
168
  --cloud-request-id ID Recover a submission whose response was lost.
156
169
  --cloud-ref BRANCH Replay BRANCH as it is on GitHub, with no snapshot of your
157
170
  working tree, and record the experiment on BRANCH. Meant for
@@ -163,12 +176,13 @@ A workflow can also dispatch bitfab-replay.yml itself on any branch other than a
163
176
  bitfab-replay/ snapshot branch, passing the replay as JSON in the request input:
164
177
 
165
178
  {"args": ["--registry", "scripts/replay.ts", "classify", "--dataset-ids", "UUID"],
166
- "cwd": "web", "timeoutMinutes": 60}
179
+ "cwd": "web", "timeoutMinutes": 60, "env": {"LOG_LEVEL": "debug"}}
167
180
 
168
181
  args are the replay options without --cloud. cwd is the directory to replay from,
169
182
  relative to the repository root, and defaults to the root. timeoutMinutes and
170
- fullOutput (true) match --cloud-timeout and --cloud-full-output. version and id are
171
- optional: version names the request format (3 now, 2 still accepted), and id is the
183
+ fullOutput (true) match --cloud-timeout and --cloud-full-output, and env holds the
184
+ values --cloud-env sets. version and id are optional: version names the request
185
+ format (4 when env is set, otherwise 3; 2 is still accepted), and id is the
172
186
  execution UUID, which must match an execution_id input when the workflow has one.
173
187
 
174
188
  The runner's job exits with the replay's exit code, so the Actions run fails when
@@ -595,7 +609,17 @@ def split_arguments(argv):
595
609
  name = option_name(token)
596
610
  if name in RENAMED_FLAGS:
597
611
  raise ValueError(f"{name} is gone; use {RENAMED_FLAGS[name]}")
598
- if name in CLOUD_VALUE_FLAGS:
612
+ if name == ENV_FLAG:
613
+ if "=" in token:
614
+ value = token.split("=", 1)[1]
615
+ index += 1
616
+ elif index + 1 < len(argv):
617
+ value = argv[index + 1]
618
+ index += 2
619
+ else:
620
+ raise ValueError(f"{ENV_FLAG} needs NAME=VALUE or NAME")
621
+ cloud.setdefault(ENV_FLAG, []).append(value)
622
+ elif name in CLOUD_VALUE_FLAGS:
599
623
  if "=" in token:
600
624
  value = token.split("=", 1)[1]
601
625
  index += 1
@@ -650,6 +674,8 @@ def parse(argv):
650
674
  raise ValueError(
651
675
  f"--cloud-ref takes a branch name such as dev, other than a {PREFIX} snapshot branch"
652
676
  )
677
+ if ENV_FLAG in cloud:
678
+ cloud[ENV_FLAG] = replay_environment(cloud[ENV_FLAG])
653
679
  operation = "submit"
654
680
  execution_id = cloud.get("--cloud-request-id") or str(uuid.uuid4())
655
681
  if not UUID.fullmatch(execution_id):
@@ -675,6 +701,62 @@ def validate_arguments(args):
675
701
  raise ValueError("Replay options exceed 16000 characters")
676
702
 
677
703
 
704
+ def replay_environment(assignments):
705
+ values = {}
706
+ for assignment in assignments:
707
+ name, separator, value = assignment.partition("=")
708
+ if not separator:
709
+ if name not in os.environ:
710
+ raise ValueError(
711
+ f"{ENV_FLAG} {name} copies {name} from your shell, and it is not set there; pass {ENV_FLAG} {name}=VALUE instead"
712
+ )
713
+ value = os.environ[name]
714
+ if name in values:
715
+ raise ValueError(f"{ENV_FLAG} sets {name} twice")
716
+ values[name] = value
717
+ validate_environment(values)
718
+ return values
719
+
720
+
721
+ def validate_environment(values):
722
+ if not isinstance(values, dict) or not all(
723
+ isinstance(name, str) and isinstance(value, str)
724
+ for name, value in values.items()
725
+ ):
726
+ raise ValueError("Replay environment must map names to string values")
727
+ for name, value in values.items():
728
+ if not ENV_NAME.fullmatch(name):
729
+ raise ValueError(
730
+ f"{name!r} is not an environment variable name; use letters, digits, and underscores"
731
+ )
732
+ if name.upper().startswith(RESERVED_ENV_PREFIXES) or name.upper() == "CI":
733
+ raise ValueError(
734
+ f"{name} belongs to GitHub Actions or the cloud runner and cannot be set per replay"
735
+ )
736
+ if SECRET_ENV_NAME.search(name):
737
+ raise ValueError(
738
+ f"{name} looks like a secret, and {ENV_FLAG} values are stored in the snapshot commit on GitHub. Map it in the workflow's replay step env from a GitHub secret instead, then set it with bitfab-replay --cloud-secrets"
739
+ )
740
+ if "\x00" in value or "\n" in value or "\r" in value:
741
+ raise ValueError(f"{name} must be a single-line value")
742
+ if URL_PASSWORD.search(value):
743
+ raise ValueError(
744
+ f"{name} holds a URL with a password, and {ENV_FLAG} values are stored in the snapshot commit on GitHub. Map it in the workflow's replay step env from a GitHub secret instead, then set it with bitfab-replay --cloud-secrets"
745
+ )
746
+ if sum(len(name) + len(value) for name, value in values.items()) > 16000:
747
+ raise ValueError("Replay environment exceeds 16000 characters")
748
+
749
+
750
+ def workflow_environment_conflicts(root, values):
751
+ path = running_workflow(root)
752
+ mapped = workflow_secrets(path.read_text()) if path.is_file() else {}
753
+ conflicts = sorted(name for name in values if name in mapped)
754
+ if conflicts:
755
+ raise ValueError(
756
+ f"The workflow already reads {', '.join(conflicts)} from a GitHub secret, so a replay cannot set it; update the secret with bitfab-replay --cloud-secrets"
757
+ )
758
+
759
+
678
760
  def dry_run(args):
679
761
  return "--dry-run" in args
680
762
 
@@ -705,7 +787,9 @@ def replay_request(root, parsed):
705
787
  index += 1
706
788
  validate_arguments(args)
707
789
  request = {
708
- "version": REQUEST_VERSION,
790
+ "version": REQUEST_VERSION
791
+ if parsed["cloud"].get(ENV_FLAG)
792
+ else PLAIN_REQUEST_VERSION,
709
793
  "id": parsed["id"],
710
794
  "cwd": working_directory(root),
711
795
  "args": args,
@@ -714,6 +798,8 @@ def replay_request(root, parsed):
714
798
  request["timeoutMinutes"] = int(parsed["cloud"]["--cloud-timeout"])
715
799
  if "--cloud-full-output" in parsed["switches"]:
716
800
  request["fullOutput"] = True
801
+ if parsed["cloud"].get(ENV_FLAG):
802
+ request["env"] = parsed["cloud"][ENV_FLAG]
717
803
  return request, files
718
804
 
719
805
 
@@ -1372,7 +1458,10 @@ def snapshot_request(root, commit):
1372
1458
  request = json.loads(body)
1373
1459
  except ValueError:
1374
1460
  request = None
1375
- if not isinstance(request, dict) or request.get("version") != REQUEST_VERSION:
1461
+ if not isinstance(request, dict) or request.get("version") not in (
1462
+ PLAIN_REQUEST_VERSION,
1463
+ REQUEST_VERSION,
1464
+ ):
1376
1465
  version = request.get("version", 0) if isinstance(request, dict) else 0
1377
1466
  newer = isinstance(version, int) and version > REQUEST_VERSION
1378
1467
  raise ValueError(
@@ -1385,6 +1474,8 @@ def snapshot_request(root, commit):
1385
1474
  "The runner replays only the snapshot branch its request names; start replays with bitfab-replay --cloud"
1386
1475
  )
1387
1476
  validate_arguments(request["args"])
1477
+ if "env" in request:
1478
+ validate_environment(request["env"])
1388
1479
  return request
1389
1480
 
1390
1481
 
@@ -1451,6 +1542,9 @@ def dispatched_request():
1451
1542
  normalized["timeoutMinutes"] = timeout
1452
1543
  if request.get("fullOutput") is True:
1453
1544
  normalized["fullOutput"] = True
1545
+ if request.get("env"):
1546
+ validate_environment(request["env"])
1547
+ normalized["env"] = request["env"]
1454
1548
  return normalized
1455
1549
 
1456
1550
 
@@ -1470,6 +1564,10 @@ def execute():
1470
1564
  request = runner_request(root, commit)
1471
1565
  directory = within(root, request["cwd"])
1472
1566
  check_secrets(root)
1567
+ environment = request.get("env") or {}
1568
+ if environment:
1569
+ workflow_environment_conflicts(root, environment)
1570
+ print(f"Setting {', '.join(sorted(environment))} from the request", flush=True)
1473
1571
  names = {option_name(value) for value in request["args"]}
1474
1572
  args = [
1475
1573
  *replay_command(),
@@ -1477,7 +1575,7 @@ def execute():
1477
1575
  *([] if names & set(CODE_CHANGE_FLAGS) else ["--no-code-change"]),
1478
1576
  ]
1479
1577
  if dry_run(request["args"]):
1480
- run_check_command(directory)
1578
+ run_check_command(directory, environment)
1481
1579
  experiment = {}
1482
1580
  previous = signal.signal(signal.SIGTERM, raise_interrupt)
1483
1581
  print(OUTPUT_BEGIN, flush=True)
@@ -1488,6 +1586,7 @@ def execute():
1488
1586
  request.get("timeoutMinutes"),
1489
1587
  experiment,
1490
1588
  summarize=request.get("fullOutput") is not True,
1589
+ environment=environment,
1491
1590
  )
1492
1591
  finally:
1493
1592
  print(OUTPUT_END, flush=True)
@@ -1537,13 +1636,17 @@ def check_secrets(root):
1537
1636
  )
1538
1637
 
1539
1638
 
1540
- def run_check_command(directory):
1639
+ def run_check_command(directory, environment=None):
1541
1640
  check = os.environ.get(CHECK_COMMAND_ENV, "").strip()
1542
1641
  if not check:
1543
1642
  return
1544
1643
  print(f"Running {CHECK_COMMAND_ENV}: {check}", flush=True)
1545
1644
  code = subprocess.run(
1546
- shlex.split(check), cwd=directory, stdin=subprocess.DEVNULL, check=False
1645
+ shlex.split(check),
1646
+ cwd=directory,
1647
+ env={**os.environ, **(environment or {})},
1648
+ stdin=subprocess.DEVNULL,
1649
+ check=False,
1547
1650
  ).returncode
1548
1651
  if code:
1549
1652
  raise ValueError(f"{CHECK_COMMAND_ENV} exited {code}; its output is above")
@@ -1772,13 +1875,19 @@ def summary_markdown(result):
1772
1875
  return text
1773
1876
 
1774
1877
 
1775
- def run_command(directory, args, timeout, experiment, *, summarize=True):
1878
+ def run_command(
1879
+ directory, args, timeout, experiment, *, summarize=True, environment=None
1880
+ ):
1776
1881
  stopped = None
1777
1882
  with tempfile.TemporaryFile() as output:
1778
1883
  with subprocess.Popen(
1779
1884
  args,
1780
1885
  cwd=directory,
1781
- env={**os.environ, EXECUTION_TARGET_ENV: HOSTED_EXECUTION_TARGET},
1886
+ env={
1887
+ **os.environ,
1888
+ **(environment or {}),
1889
+ EXECUTION_TARGET_ENV: HOSTED_EXECUTION_TARGET,
1890
+ },
1782
1891
  stdout=output,
1783
1892
  stderr=subprocess.PIPE,
1784
1893
  stdin=subprocess.DEVNULL,
@@ -2,7 +2,7 @@ import {
2
2
  BitfabError,
3
3
  warnOnce,
4
4
  writeOnce
5
- } from "./chunk-P2SYAT44.js";
5
+ } from "./chunk-NFMXJT4W.js";
6
6
 
7
7
  // src/codeChange.ts
8
8
  var NUL = String.fromCharCode(0);
@@ -348,7 +348,7 @@ async function seedFromRegistry(registry, pipeline, cases, options = {}) {
348
348
  sessionId: seedCase.sessionId
349
349
  })
350
350
  );
351
- const { flushTraces: flushTraces2 } = await import("./http-CWPHM5T3.js");
351
+ const { flushTraces: flushTraces2 } = await import("./http-UOFHA334.js");
352
352
  await flushTraces2(3e4);
353
353
  return { pipeline, traceFunctionKey, traceIds };
354
354
  }
@@ -1042,4 +1042,4 @@ export {
1042
1042
  runRegistryMain,
1043
1043
  runIfEntrypoint
1044
1044
  };
1045
- //# sourceMappingURL=chunk-25HVPGSF.js.map
1045
+ //# sourceMappingURL=chunk-7J3XIMC3.js.map
@@ -189,7 +189,7 @@ function encodeRequestBody(body) {
189
189
  }
190
190
 
191
191
  // src/version.generated.ts
192
- var __version__ = "0.64.8";
192
+ var __version__ = "0.64.9";
193
193
  var __packageName__ = "bitfab";
194
194
 
195
195
  // src/constants.ts
@@ -2949,4 +2949,4 @@ export {
2949
2949
  HttpClient,
2950
2950
  experimentIdWireFields
2951
2951
  };
2952
- //# sourceMappingURL=chunk-YGDXDDTX.js.map
2952
+ //# sourceMappingURL=chunk-CPRLBQ2W.js.map