bitfab 0.64.17 → 0.64.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8a86045717d7c97884ea2005ef2e965cf02e53afcc57b4ae9681b5d75d64303f
4
- data.tar.gz: 9f4903a7ad1771622b2608838667441d4d5ea8b3fcab14597f7af9a70b16882a
3
+ metadata.gz: a4949c6f7ebe457c48b386ae3243b7b5f4735f9fb0e8c2248db559328ca2d532
4
+ data.tar.gz: cd72bfba1753c4c2a4baee978f2fd401fe9cecfccdf56b90dd01a70a49aea85d
5
5
  SHA512:
6
- metadata.gz: 267f523ef41019703bc9c35c4c1c608d07583b7e1a50b4108ca7379cbf9d93dce08b057cccb723ca18504b794b7d460a4e0653cc1a64aee8476fb1c78a3fb862
7
- data.tar.gz: 75a9dec7bf4ff1c8c8a2140f3ebd9a2a1fc04737ea691fc9560711fe035872d3990a31d2216a720e2da25581ec93988a60dadcf962854ff16a8bad078a3045a0
6
+ metadata.gz: 525e777fd4ae7517aa2bec6efabb08afd0d944efb17a270952ec8bd762e04c5f78dba6070fe0e2b33f7174ec0c198c5c55bf3c20a2e55511f7b0c9ae2232745e
7
+ data.tar.gz: d68cbd2fd4dbc0c84e50c773f3e99b3e7edc5bf7e7cd481734db313e62c63169fe2332c1dbbcaedf5172760aa840dd7db8aa100cdb6fe6c46b8034569e4b35eb
@@ -40,9 +40,19 @@ API_KEY_ENV = "BITFAB_REPLAY_BITFAB_API_KEY"
40
40
  PRINT_API_KEY_FLAG = "--print-api-key"
41
41
  API_KEY_LINE = "@@bitfab:api-key "
42
42
  KEY_INPUT = "key_id"
43
- KEY_SECRET_PREFIX = "BITFAB_REPLAY_KEY_"
44
- KEY_SECRET_REFERENCE = (
45
- "${{ secrets[format('" + KEY_SECRET_PREFIX + "{0}', inputs." + KEY_INPUT + ")] }}"
43
+ KEY_TOKEN_PREFIX = "bfcr_"
44
+ LEFTOVER_KEY_SECRET_PREFIX = "BITFAB_REPLAY_KEY_"
45
+ SECRETS_PER_PAGE = 100
46
+ LEGACY_KEY_SECRET_REFERENCE = re.compile(
47
+ r"^[ \t]*\"?"
48
+ + API_KEY_ENV
49
+ + r"\"?[ \t]*:[ \t]*[\"']?\$\{\{\s*secrets\[\s*format\(.*$"
50
+ )
51
+ DEFAULT_SERVICE_URL = "https://bitfab.ai"
52
+ OIDC_AUDIENCE = "bitfab-cloud-replay"
53
+ ID_TOKEN_PERMISSION = re.compile(
54
+ r"^[ \t]*(\"?id-token\"?[ \t]*:[ \t]*[\"']?write[\"']?|\"?permissions\"?[ \t]*:[ \t]*[\"']?write-all[\"']?)[ \t]*(#.*)?$",
55
+ re.MULTILINE,
46
56
  )
47
57
  API_KEY_PROBE_SECONDS = 120
48
58
  RUN_NAME = "Bitfab replay ${{ inputs.execution_id || github.ref_name }}"
@@ -137,6 +147,11 @@ RENAMED_FLAGS = {
137
147
  SEED_FLAGS = ("--seed", "--cases", "--from-trace", "--run")
138
148
  PATH_FLAGS = ("--registry", "--params", "--code-change")
139
149
  CODE_CHANGE_FLAGS = ("--code-change", "--no-code-change")
150
+ CHECKOUT_DEPTH = 2
151
+ CODE_CHANGE_PATH_ENV = "BITFAB_CODE_CHANGE_PATH"
152
+ CODE_CHANGE_MAX_FILES = 60
153
+ CODE_CHANGE_MAX_FILE_BYTES = 500_000
154
+ CODE_CHANGE_MAX_TOTAL_BYTES = 2_000_000
140
155
  CHECK_ARGUMENTS_SECONDS = 300
141
156
  CHECK_ARGUMENTS_FLAG = "--check-arguments"
142
157
  REPLAY_ARGUMENTS_FLAG = "--replay-arguments"
@@ -165,6 +180,8 @@ Every replay option works as it does locally, including --dry-run and --fail-on-
165
180
  and your SDK checks them before anything is uploaded. The runner replays a snapshot of
166
181
  your working tree (changed files, plus new files git does not ignore) from the same
167
182
  directory, and this command prints the replay's output and exits with its exit code.
183
+ The experiment records your branch, the commit the snapshot was taken on, and the
184
+ snapshot's changed files as its code change, as it would for a local replay.
168
185
  Credential-like files that are not on GitHub yet are refused unless you name them with
169
186
  --cloud-allow-file. Requires git and Python 3.10+ on macOS or Linux, with a github.com
170
187
  origin.
@@ -214,13 +231,15 @@ values --cloud-env sets. version and id are optional: version names the request
214
231
  format (4 when env is set, otherwise 3; 2 is still accepted), and id is the
215
232
  execution UUID, which must match an execution_id input when the workflow has one.
216
233
 
217
- The replay runs with the Bitfab API key it would use on your machine, so the
218
- experiment is recorded as yours. Before dispatch, the replay's registry is loaded
219
- locally to read its client's key, which the GitHub CLI stores as a GitHub secret
220
- named for this execution and deletes once the replay starts. Without the GitHub
221
- CLI, without permission to manage the repository's Actions secrets, with no key
222
- resolved locally, or with a workflow that declares no key_id input, the run uses
223
- the workflow's BITFAB_API_KEY secret instead.
234
+ The replay runs as the person who started it, so the experiment is recorded as
235
+ yours. Before dispatch, the replay's registry is loaded locally to read its
236
+ client's key, which asks Bitfab for a one-time token passed as the key_id input,
237
+ tied to the repository, branch, and workflow of the run. The runner trades that
238
+ token, together with the GitHub Actions OIDC token of its own run, for a key that
239
+ works only until the replay ends, so only that run can use it; the token alone
240
+ opens nothing. With no key resolved locally, a token Bitfab will not issue or
241
+ trade, or a workflow that declares no key_id input or does not grant
242
+ id-token: write, the run uses the workflow's BITFAB_API_KEY secret instead.
224
243
 
225
244
  The runner's job exits with the replay's exit code, so the Actions run fails when
226
245
  the replay does. Traces that errored fail it only under --fail-on-error.
@@ -236,8 +255,11 @@ Set up once:
236
255
  Writes .github/workflows/bitfab-replay.yml, the only file cloud replay keeps
237
256
  in the repository, and lists what is left to do. On an existing workflow it
238
257
  adds only the lines a newer SDK needs and lists what it changed.
239
- --cloud-update [same options]
258
+ --cloud-update [same options] [--remove-old-key-secrets]
240
259
  The same command as --cloud-init, for bringing an existing setup up to date.
260
+ It also lists BITFAB_REPLAY_KEY_* secrets an older SDK left in the
261
+ repository, each a copy of someone's Bitfab API key, and with
262
+ --remove-old-key-secrets deletes them.
241
263
  --cloud-secrets --env-file FILE [NAME ...] [--environment NAME] [--dry-run]
242
264
  Copies local values into the GitHub secrets the workflow reads.
243
265
 
@@ -887,6 +909,13 @@ def replay_request(root, parsed):
887
909
  return request, files
888
910
 
889
911
 
912
+ def current_branch(root):
913
+ try:
914
+ return git(root, "symbolic-ref", "--short", "-q", "HEAD") or None
915
+ except CommandError:
916
+ return None
917
+
918
+
890
919
  def sensitive(path):
891
920
  parts = Path(path).parts
892
921
  name = Path(path).name.lower()
@@ -1300,6 +1329,9 @@ def allowed_files(root, parsed):
1300
1329
  def submit(root, repo, workflow, parsed, path):
1301
1330
  execution_id = parsed["id"]
1302
1331
  request, files = replay_request(root, parsed)
1332
+ branch = current_branch(root)
1333
+ if branch:
1334
+ request["branch"] = branch
1303
1335
  check_arguments(parsed["replay"])
1304
1336
  allowed = allowed_files(root, parsed)
1305
1337
  if "--cloud-preview" in parsed["switches"]:
@@ -1359,7 +1391,7 @@ def submit(root, repo, workflow, parsed, path):
1359
1391
  save(path, record)
1360
1392
  text = within(root, f"{WORKFLOW_DIRECTORY}/{workflow}").read_text()
1361
1393
  declared = declared_inputs(text)
1362
- key_inputs = store_key(root, record, declared, text)
1394
+ key_inputs = issue_key_token(root, record, text)
1363
1395
  save(path, record)
1364
1396
  return dispatch(
1365
1397
  record,
@@ -1368,7 +1400,7 @@ def submit(root, repo, workflow, parsed, path):
1368
1400
  )
1369
1401
 
1370
1402
 
1371
- def local_api_key(root, request):
1403
+ def local_client(root, request):
1372
1404
  try:
1373
1405
  output = command(
1374
1406
  [*replay_command(), *request["args"], PRINT_API_KEY_FLAG],
@@ -1382,59 +1414,101 @@ def local_api_key(root, request):
1382
1414
  if not line.startswith(API_KEY_LINE):
1383
1415
  continue
1384
1416
  try:
1385
- key = json.loads(line.removeprefix(API_KEY_LINE)).get("apiKey")
1386
- except (ValueError, AttributeError):
1417
+ reported = json.loads(line.removeprefix(API_KEY_LINE))
1418
+ except ValueError:
1419
+ return None
1420
+ if not isinstance(reported, dict):
1387
1421
  return None
1388
- return key if isinstance(key, str) and key.strip() else None
1422
+ key = reported.get("apiKey")
1423
+ if not isinstance(key, str) or not key.strip():
1424
+ return None
1425
+ url = reported.get("serviceUrl")
1426
+ if not isinstance(url, str) or not url.strip():
1427
+ url = DEFAULT_SERVICE_URL
1428
+ return {"apiKey": key, "serviceUrl": url.rstrip("/")}
1389
1429
  return None
1390
1430
 
1391
1431
 
1392
- def store_key(root, record, declared, workflow_text):
1432
+ def bitfab_api(client, path, *, method="POST", payload=None):
1433
+ data = None if payload is None else json.dumps(payload).encode()
1434
+ request = urllib.request.Request(
1435
+ f"{client['serviceUrl']}/api/sdk/{path}",
1436
+ method=method,
1437
+ data=data,
1438
+ headers={
1439
+ **(
1440
+ {"Authorization": f"Bearer {client['apiKey']}"}
1441
+ if client.get("apiKey")
1442
+ else {}
1443
+ ),
1444
+ "User-Agent": "bitfab-cloud-replay",
1445
+ **({"Content-Type": "application/json"} if data is not None else {}),
1446
+ },
1447
+ )
1448
+ try:
1449
+ with urllib.request.urlopen(request, timeout=60) as response:
1450
+ body = response.read()
1451
+ except urllib.error.HTTPError as error:
1452
+ try:
1453
+ reason = json.loads(error.read()).get("error")
1454
+ except (ValueError, AttributeError, OSError):
1455
+ reason = None
1456
+ raise CommandError(
1457
+ f"Bitfab {method} {path} failed (HTTP {error.code})"
1458
+ + (f": {reason}" if reason else ""),
1459
+ error.code,
1460
+ ) from None
1461
+ except (urllib.error.URLError, OSError) as error:
1462
+ raise CommandError(
1463
+ f"Could not reach {client['serviceUrl']}: {getattr(error, 'reason', error)}"
1464
+ ) from None
1465
+ return json.loads(body) if body.strip() else {}
1466
+
1467
+
1468
+ def grants_id_token(text):
1469
+ return ID_TOKEN_PERMISSION.search(text) is not None
1470
+
1471
+
1472
+ def issue_key_token(root, record, text):
1393
1473
  shared = "so the run uses the workflow's BITFAB_API_KEY secret"
1394
- if KEY_INPUT not in declared:
1474
+ if KEY_INPUT not in declared_inputs(text):
1395
1475
  progress(
1396
- f"The workflow takes no {KEY_INPUT} input, {shared}. To replay with your own Bitfab API key, run bitfab-replay --cloud-init, which adds the {KEY_INPUT} input and {API_KEY_ENV} to the workflow"
1476
+ f"The workflow takes no {KEY_INPUT} input, {shared}. To replay as yourself, run bitfab-replay --cloud-update, which adds the {KEY_INPUT} input to the workflow"
1397
1477
  )
1398
1478
  return {}
1399
- if shutil.which("gh") is None:
1479
+ if not grants_id_token(text):
1400
1480
  progress(
1401
- f"Replaying with your own Bitfab API key needs the GitHub CLI (https://cli.github.com) to store it as a GitHub secret, {shared}"
1481
+ f"The workflow does not grant id-token: write, so its run cannot prove to Bitfab which run it is, {shared}. To replay as yourself, run bitfab-replay --cloud-update, which adds it"
1402
1482
  )
1403
1483
  return {}
1404
- key = local_api_key(root, record["request"])
1405
- if key is None:
1484
+ client = local_client(root, record["request"])
1485
+ if client is None:
1406
1486
  progress(
1407
1487
  f"The replay's Bitfab client resolved no API key on this machine, {shared}"
1408
1488
  )
1409
1489
  return {}
1410
- key_id = record["id"].replace("-", "").upper()
1411
- name = KEY_SECRET_PREFIX + key_id
1412
- configured = ENVIRONMENT_SETTING.search(workflow_text)
1413
- environment = configured[1] if configured else None
1414
1490
  try:
1415
- command(
1416
- [
1417
- "gh",
1418
- "secret",
1419
- "set",
1420
- name,
1421
- "--repo",
1422
- record["repository"],
1423
- *(["--env", environment] if environment else []),
1424
- ],
1425
- input=key,
1426
- env={**os.environ, "GH_TOKEN": github_access()["token"]},
1427
- )
1428
- except (ValueError, RuntimeError, OSError, subprocess.SubprocessError):
1429
- progress(
1430
- f"Could not store your Bitfab API key as the GitHub secret {name}, which needs permission to manage the repository's Actions secrets, {shared}"
1491
+ issued = bitfab_api(
1492
+ client,
1493
+ "replay/cloudKey",
1494
+ payload={
1495
+ "executionId": record["id"],
1496
+ "repository": record["repository"],
1497
+ "ref": "refs/heads/" + record["branch"],
1498
+ "workflow": record["workflow"],
1499
+ },
1431
1500
  )
1501
+ except CommandError as error:
1502
+ progress(f"{error}, {shared}")
1503
+ return {}
1504
+ token = issued.get("token")
1505
+ if not isinstance(token, str) or not token.startswith(KEY_TOKEN_PREFIX):
1506
+ progress(f"Bitfab issued no replay token, {shared}")
1432
1507
  return {}
1433
- record["keySecret"] = {"name": name, "environment": environment}
1434
1508
  progress(
1435
- f"Replaying with the Bitfab API key from this machine, stored as the GitHub secret {name} until the replay starts"
1509
+ f"Replaying as you: the run gets a one-time token that only {record['workflow']} running on {record['branch']} in {record['repository']} can use"
1436
1510
  )
1437
- return {KEY_INPUT: key_id}
1511
+ return {KEY_INPUT: token}
1438
1512
 
1439
1513
 
1440
1514
  def remove_key(record):
@@ -1589,7 +1663,7 @@ def submit_as_is(root, repo, workflow, parsed, path):
1589
1663
  progress(
1590
1664
  f"Replaying {branch} at {sha[:12]} as it is on GitHub, without local changes. Execution {execution_id}; resume with --cloud-watch {execution_id}"
1591
1665
  )
1592
- key_inputs = store_key(root, record, declared, text)
1666
+ key_inputs = issue_key_token(root, record, text)
1593
1667
  save(path, record)
1594
1668
  return dispatch(
1595
1669
  record,
@@ -1817,6 +1891,111 @@ def runner_request(root, commit):
1817
1891
  return dispatched_request()
1818
1892
 
1819
1893
 
1894
+ def check_out_snapshot_base(root, commit, branch):
1895
+ try:
1896
+ base = git(root, "rev-parse", "--verify", "-q", f"{commit}^")
1897
+ except CommandError:
1898
+ print(
1899
+ "This checkout does not include the commit the snapshot was taken on, so the experiment records no code change; set fetch-depth: 2 on the workflow's actions/checkout step, or run bitfab-replay --cloud-update",
1900
+ flush=True,
1901
+ )
1902
+ return None
1903
+ if isinstance(branch, str) and BRANCH_NAME.fullmatch(branch):
1904
+ try:
1905
+ git(root, "update-ref", f"refs/heads/{branch}", base)
1906
+ git(root, "symbolic-ref", "HEAD", f"refs/heads/{branch}")
1907
+ return base
1908
+ except CommandError:
1909
+ pass
1910
+ git(root, "reset", "-q", "--soft", base)
1911
+ return base
1912
+
1913
+
1914
+ def replay_name(args):
1915
+ for index, value in enumerate(args):
1916
+ if value.startswith("--name="):
1917
+ return value.partition("=")[2]
1918
+ if value == "--name" and index + 1 < len(args):
1919
+ return args[index + 1]
1920
+ return None
1921
+
1922
+
1923
+ def git_blob(root, ref, path):
1924
+ try:
1925
+ size = int(git(root, "cat-file", "-s", f"{ref}:{path}"))
1926
+ except (CommandError, ValueError):
1927
+ return None
1928
+ if size > CODE_CHANGE_MAX_FILE_BYTES:
1929
+ return None
1930
+ result = subprocess.run(
1931
+ ["git", "-C", str(root), "show", f"{ref}:{path}"],
1932
+ capture_output=True,
1933
+ check=False,
1934
+ )
1935
+ if result.returncode or b"\x00" in result.stdout[:8000]:
1936
+ return None
1937
+ return result.stdout.decode("utf-8", "replace").replace("\r\n", "\n")
1938
+
1939
+
1940
+ def changed_paths(root, base, commit):
1941
+ raw = git(
1942
+ root,
1943
+ "diff",
1944
+ "--name-status",
1945
+ "--find-renames",
1946
+ "-z",
1947
+ base,
1948
+ commit,
1949
+ "--",
1950
+ ":!.bitfab",
1951
+ )
1952
+ parts = [part for part in raw.split("\x00") if part]
1953
+ index = 0
1954
+ while index + 1 < len(parts):
1955
+ status, before_path = parts[index][0], parts[index + 1]
1956
+ index += 2
1957
+ path = before_path
1958
+ if status in "RC" and index < len(parts):
1959
+ path = parts[index]
1960
+ index += 1
1961
+ yield status, before_path, path
1962
+
1963
+
1964
+ def snapshot_code_change(root, base, commit, name):
1965
+ files, total = [], 0
1966
+ for status, before_path, path in changed_paths(root, base, commit):
1967
+ if len(files) >= CODE_CHANGE_MAX_FILES:
1968
+ break
1969
+ before = "" if status == "A" else git_blob(root, base, before_path)
1970
+ after = "" if status == "D" else git_blob(root, commit, path)
1971
+ if before is None or after is None or before == after:
1972
+ continue
1973
+ size = len(before.encode()) + len(after.encode())
1974
+ if total + size > CODE_CHANGE_MAX_TOTAL_BYTES:
1975
+ continue
1976
+ total += size
1977
+ files.append({"path": path, "before": before, "after": after})
1978
+ if not files:
1979
+ return None
1980
+ head = (name or "").strip() or git(root, "log", "-1", "--format=%s", base)
1981
+ word = "file" if len(files) == 1 else "files"
1982
+ return {
1983
+ "description": f"{head or 'Working-tree change'} ({len(files)} {word} changed uncommitted (vs HEAD))",
1984
+ "files": files,
1985
+ }
1986
+
1987
+
1988
+ def write_code_change(change):
1989
+ handle, path = tempfile.mkstemp(
1990
+ prefix="bitfab-code-change-",
1991
+ suffix=".json",
1992
+ dir=os.environ.get("RUNNER_TEMP") or None,
1993
+ )
1994
+ with os.fdopen(handle, "w") as out:
1995
+ json.dump(change, out)
1996
+ return path
1997
+
1998
+
1820
1999
  def execute():
1821
2000
  if os.environ.get("GITHUB_RUN_ATTEMPT") != "1":
1822
2001
  raise ValueError("Submit a new replay instead of rerunning an Actions job")
@@ -1825,26 +2004,36 @@ def execute():
1825
2004
  if git(root, "rev-parse", "HEAD") != commit:
1826
2005
  raise ValueError("Runner checkout does not match the dispatched commit")
1827
2006
  request = runner_request(root, commit)
2007
+ base = (
2008
+ check_out_snapshot_base(root, commit, request.get("branch"))
2009
+ if os.environ.get("GITHUB_REF_NAME", "").startswith(PREFIX)
2010
+ else None
2011
+ )
1828
2012
  directory = within(root, request["cwd"])
1829
2013
  check_secrets(root)
1830
- if os.environ.get(API_KEY_ENV, "").strip():
1831
- print(
1832
- "Replaying with the Bitfab API key of the person who started this run",
1833
- flush=True,
1834
- )
1835
2014
  environment = request.get("env") or {}
1836
2015
  if environment:
1837
2016
  workflow_environment_conflicts(root, environment)
1838
2017
  print(f"Setting {', '.join(sorted(environment))} from the request", flush=True)
1839
2018
  names = {option_name(value) for value in request["args"]}
2019
+ explicit_change = bool(names & set(CODE_CHANGE_FLAGS))
2020
+ change = (
2021
+ None
2022
+ if explicit_change or base is None
2023
+ else snapshot_code_change(root, base, commit, replay_name(request["args"]))
2024
+ )
2025
+ run_environment = dict(environment)
2026
+ if change:
2027
+ run_environment[CODE_CHANGE_PATH_ENV] = write_code_change(change)
1840
2028
  args = [
1841
2029
  *replay_command(),
1842
2030
  *request["args"],
1843
- *([] if names & set(CODE_CHANGE_FLAGS) else ["--no-code-change"]),
2031
+ *([] if explicit_change or change else ["--no-code-change"]),
1844
2032
  ]
1845
2033
  if dry_run(request["args"]):
1846
2034
  run_check_command(directory, environment)
1847
2035
  experiment = {}
2036
+ starter = starter_key(root, request)
1848
2037
  previous = signal.signal(signal.SIGTERM, raise_interrupt)
1849
2038
  print(OUTPUT_BEGIN, flush=True)
1850
2039
  try:
@@ -1854,11 +2043,12 @@ def execute():
1854
2043
  request.get("timeoutMinutes"),
1855
2044
  experiment,
1856
2045
  summarize=request.get("fullOutput") is not True,
1857
- environment=environment,
2046
+ environment=run_environment,
1858
2047
  )
1859
2048
  finally:
1860
2049
  print(OUTPUT_END, flush=True)
1861
2050
  signal.signal(signal.SIGTERM, previous)
2051
+ revoke_starter_key(starter)
1862
2052
  result = {
1863
2053
  "executionId": request["id"],
1864
2054
  "commitSha": commit,
@@ -1875,6 +2065,114 @@ def execute():
1875
2065
  return result
1876
2066
 
1877
2067
 
2068
+ def runner_warning(message):
2069
+ print(f"::warning title=Bitfab replay::{message}", flush=True)
2070
+
2071
+
2072
+ def warn_if_workflow_outdated():
2073
+ problems = []
2074
+ if API_KEY_ENV in os.environ:
2075
+ problems.append(
2076
+ f"still sets {API_KEY_ENV} from a secret named by the run's inputs, which makes GitHub give this run every secret in the repository"
2077
+ )
2078
+ if not os.environ.get("ACTIONS_ID_TOKEN_REQUEST_URL"):
2079
+ problems.append(
2080
+ "does not grant id-token: write, so replays use the workflow's BITFAB_API_KEY secret instead of running as the person who started them"
2081
+ )
2082
+ if problems:
2083
+ runner_warning(
2084
+ f"This workflow {' and '.join(problems)}. Run bitfab-replay --cloud-update and commit the workflow it updates"
2085
+ )
2086
+
2087
+
2088
+ def starter_key(root, request):
2089
+ warn_if_workflow_outdated()
2090
+ if os.environ.get(API_KEY_ENV, "").strip():
2091
+ print(
2092
+ "Replaying with the Bitfab API key of the person who started this run",
2093
+ flush=True,
2094
+ )
2095
+ return None
2096
+ token = str(dispatched_inputs().get(KEY_INPUT) or "").strip()
2097
+ if not token.startswith(KEY_TOKEN_PREFIX):
2098
+ return None
2099
+ shared = "so this run uses the workflow's BITFAB_API_KEY secret"
2100
+ runner = local_client(root, request)
2101
+ if runner is None:
2102
+ runner_warning(
2103
+ f"The replay's Bitfab client resolved no API key on the runner, {shared}"
2104
+ )
2105
+ return None
2106
+ try:
2107
+ identity = github_run_identity()
2108
+ except CommandError as error:
2109
+ runner_warning(f"{error}, {shared}")
2110
+ return None
2111
+ timeout = request.get("timeoutMinutes")
2112
+ try:
2113
+ redeemed = bitfab_api(
2114
+ {"serviceUrl": runner["serviceUrl"]},
2115
+ "replay/cloudKey/redeem",
2116
+ payload={
2117
+ "token": token,
2118
+ "oidcToken": identity,
2119
+ **({"minutes": timeout} if timeout else {}),
2120
+ },
2121
+ )
2122
+ except CommandError as error:
2123
+ runner_warning(f"{error}, {shared}")
2124
+ return None
2125
+ key = redeemed.get("apiKey")
2126
+ if not isinstance(key, str) or not key.strip():
2127
+ runner_warning(f"Bitfab returned no key for the replay token, {shared}")
2128
+ return None
2129
+ print(f"::add-mask::{key}", flush=True)
2130
+ os.environ[API_KEY_ENV] = key
2131
+ print(
2132
+ "Replaying as the person who started this run, with a key that is turned off when the replay ends",
2133
+ flush=True,
2134
+ )
2135
+ return {"apiKey": key, "serviceUrl": runner["serviceUrl"]}
2136
+
2137
+
2138
+ def github_run_identity():
2139
+ url = os.environ.get("ACTIONS_ID_TOKEN_REQUEST_URL")
2140
+ bearer = os.environ.get("ACTIONS_ID_TOKEN_REQUEST_TOKEN")
2141
+ if not url or not bearer:
2142
+ raise CommandError(
2143
+ "The workflow does not grant id-token: write, so this run cannot prove to Bitfab which run it is; run bitfab-replay --cloud-update to add it"
2144
+ )
2145
+ separator = "&" if "?" in url else "?"
2146
+ request = urllib.request.Request(
2147
+ f"{url}{separator}{urlencode({'audience': OIDC_AUDIENCE})}",
2148
+ headers={
2149
+ "Authorization": f"Bearer {bearer}",
2150
+ "User-Agent": "bitfab-cloud-replay",
2151
+ },
2152
+ )
2153
+ try:
2154
+ with urllib.request.urlopen(request, timeout=30) as response:
2155
+ value = json.loads(response.read()).get("value")
2156
+ except (urllib.error.URLError, OSError, ValueError, AttributeError) as error:
2157
+ raise CommandError(
2158
+ f"GitHub did not issue this run's OIDC token: {getattr(error, 'reason', error)}"
2159
+ ) from None
2160
+ if not isinstance(value, str) or not value:
2161
+ raise CommandError("GitHub returned no OIDC token for this run")
2162
+ return value
2163
+
2164
+
2165
+ def revoke_starter_key(starter):
2166
+ if starter is None:
2167
+ return
2168
+ try:
2169
+ bitfab_api(starter, "replay/cloudKey", method="DELETE")
2170
+ except CommandError as error:
2171
+ runner_warning(
2172
+ f"Could not turn off this replay's key ({error}); it expires on its own 30 minutes after the replay's timeout"
2173
+ )
2174
+
2175
+
1878
2176
  def running_workflow(root):
1879
2177
  reference = os.environ.get("GITHUB_WORKFLOW_REF", "")
1880
2178
  match = re.search(r"(\.github/workflows/[^@]+)@", reference)
@@ -2497,7 +2795,10 @@ def workflow_document(steps, run, directory, env, *, runs_on, environment):
2497
2795
  if environment:
2498
2796
  job["environment"] = environment
2499
2797
  job["steps"] = [
2500
- {"uses": ACTIONS["checkout"], "with": {"persist-credentials": False}},
2798
+ {
2799
+ "uses": ACTIONS["checkout"],
2800
+ "with": {"persist-credentials": False, "fetch-depth": CHECKOUT_DEPTH},
2801
+ },
2501
2802
  *steps,
2502
2803
  {
2503
2804
  **({} if directory == "." else {"working-directory": directory}),
@@ -2517,7 +2818,7 @@ def workflow_document(steps, run, directory, env, *, runs_on, environment):
2517
2818
  }
2518
2819
  }
2519
2820
  },
2520
- "permissions": {"contents": "read"},
2821
+ "permissions": {"contents": "read", "id-token": "write"},
2521
2822
  "jobs": {"replay": job},
2522
2823
  }
2523
2824
 
@@ -2573,42 +2874,159 @@ def without_yaml_comment(line):
2573
2874
  return re.sub(r"(^|\s)#.*", "", line)
2574
2875
 
2575
2876
 
2576
- def replay_step_key(lines):
2577
- for index, line in enumerate(lines):
2578
- if "--cloud-execute" not in without_yaml_comment(line):
2877
+ def remove_legacy_key_env(lines):
2878
+ kept = [line for line in lines if not LEGACY_KEY_SECRET_REFERENCE.match(line)]
2879
+ if len(kept) == len(lines):
2880
+ return False
2881
+ lines[:] = kept
2882
+ return True
2883
+
2884
+
2885
+ def raise_checkout_depth(lines, start, end):
2886
+ for at in range(start, end):
2887
+ if not re.match(r'[ \t]*"?fetch-depth"?[ \t]*:', lines[at]):
2579
2888
  continue
2580
- for above in range(index, -1, -1):
2581
- match = re.match(r"([ \t]*)(- +)?run[ \t]*:", lines[above])
2582
- if match and indentation(lines[above]) <= indentation(line):
2583
- return above, len(match[1]) + len(match[2] or "")
2889
+ depth = re.match(
2890
+ r'([ \t]*"?fetch-depth"?[ \t]*:[ \t]*)["\']?(\d+)["\']?(.*)',
2891
+ lines[at],
2892
+ )
2893
+ if depth is None or int(depth[2]) == 0 or int(depth[2]) >= CHECKOUT_DEPTH:
2894
+ return False
2895
+ lines[at] = f"{depth[1]}{CHECKOUT_DEPTH}{depth[3]}"
2896
+ return True
2584
2897
  return None
2585
2898
 
2586
2899
 
2587
- def add_key_env(lines):
2588
- found = replay_step_key(lines)
2589
- if found is None:
2590
- return False
2591
- run, key = found
2592
- start = run
2593
- while start > 0 and not re.match(r"[ \t]*- ", lines[start]):
2594
- start -= 1
2595
- end = block_end(lines, run, key - 1)
2596
- entry = f'{API_KEY_ENV}: "{KEY_SECRET_REFERENCE}"'
2597
- for index in range(start, end):
2598
- line = lines[index]
2599
- if not re.fullmatch(r"[ \t]*(- +)?env[ \t]*:[ \t]*", line):
2900
+ def add_checkout_depth(lines):
2901
+ for index, line in enumerate(lines):
2902
+ match = re.match(
2903
+ r'([ \t]*)(- +)?"?uses"?[ \t]*:[ \t]*"?actions/checkout@',
2904
+ without_yaml_comment(line),
2905
+ )
2906
+ if not match:
2600
2907
  continue
2601
- if len(line) - len(line.lstrip(" \t-")) != key:
2908
+ key = len(match[1]) + len(match[2] or "")
2909
+ start = index
2910
+ while start > 0 and not re.match(r"[ \t]*- ", lines[start]):
2911
+ start -= 1
2912
+ end = block_end(lines, start, key - 1)
2913
+ raised = raise_checkout_depth(lines, start, end)
2914
+ if raised is not None:
2915
+ return "updated" if raised else "unchanged"
2916
+ entry = f"fetch-depth: {CHECKOUT_DEPTH}"
2917
+ for at in range(start, end):
2918
+ with_key = re.fullmatch(
2919
+ r"[ \t]*(- +)?with[ \t]*:(.*)", without_yaml_comment(lines[at])
2920
+ )
2921
+ if not with_key:
2922
+ continue
2923
+ if len(lines[at]) - len(lines[at].lstrip(" \t-")) != key:
2924
+ continue
2925
+ if with_key[2].strip():
2926
+ return "by hand"
2927
+ entries = block_end(lines, at, key)
2928
+ child = indentation(lines[at + 1]) if entries > at + 1 else key + 2
2929
+ lines.insert(entries, " " * child + entry)
2930
+ return "updated"
2931
+ lines[end:end] = [" " * key + "with:", " " * (key + 2) + entry]
2932
+ return "updated"
2933
+ return "unchanged"
2934
+
2935
+
2936
+ def add_id_token_permission(lines):
2937
+ for index, line in enumerate(lines):
2938
+ if not re.fullmatch(r'"?permissions"?[ \t]*:[ \t]*(#.*)?', line):
2602
2939
  continue
2603
- entries = block_end(lines, index, key)
2604
- child = indentation(lines[index + 1]) if entries > index + 1 else key + 2
2605
- lines.insert(entries, " " * child + entry)
2940
+ end = block_end(lines, index, 0)
2941
+ child = next(
2942
+ (
2943
+ lines[row][: indentation(lines[row])]
2944
+ for row in range(index + 1, end)
2945
+ if lines[row].strip()
2946
+ ),
2947
+ " ",
2948
+ )
2949
+ lines.insert(end, f"{child}id-token: write")
2606
2950
  return True
2607
- lines[end:end] = [" " * key + "env:", " " * (key + 2) + entry]
2951
+ return False
2952
+
2953
+
2954
+ def secret_scope(environment):
2955
+ return f"environments/{environment}/" if environment else "actions/"
2956
+
2957
+
2958
+ def secret_names(repo, environment):
2959
+ names, page = [], 1
2960
+ while True:
2961
+ listed = (
2962
+ api(
2963
+ repo,
2964
+ f"{secret_scope(environment)}secrets?per_page={SECRETS_PER_PAGE}&page={page}",
2965
+ )
2966
+ or {}
2967
+ )
2968
+ batch = [secret["name"] for secret in listed.get("secrets", [])]
2969
+ names.extend(batch)
2970
+ if len(batch) < SECRETS_PER_PAGE:
2971
+ return names
2972
+ page += 1
2973
+
2974
+
2975
+ def leftover_key_secrets(repo, environment):
2976
+ return [
2977
+ {"name": name, "environment": scope}
2978
+ for scope in dict.fromkeys([None, environment])
2979
+ for name in secret_names(repo, scope)
2980
+ if name.startswith(LEFTOVER_KEY_SECRET_PREFIX)
2981
+ ]
2982
+
2983
+
2984
+ def delete_secret(repo, secret):
2985
+ try:
2986
+ api(
2987
+ repo,
2988
+ f"{secret_scope(secret['environment'])}secrets/{secret['name']}",
2989
+ method="DELETE",
2990
+ )
2991
+ except CommandError as error:
2992
+ if error.http_status != 404:
2993
+ return False
2608
2994
  return True
2609
2995
 
2610
2996
 
2611
- def update_workflow(root, path):
2997
+ def leftover_key_secret_report(root, text, remove):
2998
+ try:
2999
+ repo = repository(root)
3000
+ except ValueError:
3001
+ return {}, []
3002
+ configured = ENVIRONMENT_SETTING.search(text)
3003
+ environment = configured[1] if configured else None
3004
+ pages = " and ".join(
3005
+ dict.fromkeys(secrets_page(repo, scope) for scope in (None, environment))
3006
+ )
3007
+ try:
3008
+ found = leftover_key_secrets(repo, environment)
3009
+ except (ValueError, RuntimeError):
3010
+ return {}, [
3011
+ f"Could not list the repository's secrets to look for {LEFTOVER_KEY_SECRET_PREFIX}* secrets an older SDK left behind, each a copy of someone's Bitfab API key; delete any at {pages}"
3012
+ ]
3013
+ if not found:
3014
+ return {}, []
3015
+ if not remove:
3016
+ return {"oldKeySecrets": [secret["name"] for secret in found]}, [
3017
+ f"Delete {len(found)} {LEFTOVER_KEY_SECRET_PREFIX}* secret{'s' if len(found) != 1 else ''} an older SDK left behind when a replay could not remove {'them' if len(found) != 1 else 'it'}. Each holds a copy of the Bitfab API key of the person who started that replay, and nothing reads them now. Run bitfab-replay --cloud-update --remove-old-key-secrets, or delete them at {pages}"
3018
+ ]
3019
+ removed = [secret for secret in found if delete_secret(repo, secret)]
3020
+ failed = [secret["name"] for secret in found if secret not in removed]
3021
+ report = {"removedOldKeySecrets": [secret["name"] for secret in removed]}
3022
+ if not failed:
3023
+ return report, []
3024
+ return {**report, "oldKeySecrets": failed}, [
3025
+ f"Could not delete {', '.join(failed)}, which needs permission to manage the repository's Actions secrets; delete them at {pages}"
3026
+ ]
3027
+
3028
+
3029
+ def update_workflow(root, path, *, remove_old_key_secrets=False):
2612
3030
  target = within(root, path)
2613
3031
  text = target.read_text()
2614
3032
  lines = text.split("\n")
@@ -2626,13 +3044,28 @@ def update_workflow(root, path):
2626
3044
  if not re.search(r'^"?run-name"?[ \t]*:', text, re.MULTILINE):
2627
3045
  add_run_name(lines)
2628
3046
  updated.append("Named each run after its execution")
2629
- if API_KEY_ENV not in text:
2630
- if add_key_env(lines):
2631
- updated.append(f"Added {API_KEY_ENV} to the replay step's env")
3047
+ if not grants_id_token(text):
3048
+ if add_id_token_permission(lines):
3049
+ updated.append(
3050
+ "Granted id-token: write, so a run can prove to Bitfab which run it is and get the key of the person who started it"
3051
+ )
2632
3052
  else:
2633
3053
  by_hand.append(
2634
- f'{API_KEY_ENV}: "{KEY_SECRET_REFERENCE}" in the replay step\'s env'
3054
+ "id-token: write under the workflow's top-level permissions block"
2635
3055
  )
3056
+ if remove_legacy_key_env(lines):
3057
+ updated.append(
3058
+ f"Removed the {API_KEY_ENV} line, which read a secret by a name built from the run's inputs and so gave the runner every secret in the repository; the runner now gets the key from Bitfab"
3059
+ )
3060
+ depth = add_checkout_depth(lines)
3061
+ if depth == "updated":
3062
+ updated.append(
3063
+ "Checked out the commit each snapshot is taken on, so experiments record the code change"
3064
+ )
3065
+ elif depth == "by hand":
3066
+ by_hand.append(
3067
+ f"fetch-depth: {CHECKOUT_DEPTH} in the actions/checkout step's with"
3068
+ )
2636
3069
  if updated:
2637
3070
  target.write_text("\n".join(lines))
2638
3071
  steps = []
@@ -2649,18 +3082,22 @@ def update_workflow(root, path):
2649
3082
  steps.append(
2650
3083
  "Already set up. Edit the workflow directly to change its install steps, secrets, runner, or Environment"
2651
3084
  )
3085
+ leftovers, leftover_steps = leftover_key_secret_report(
3086
+ root, text, remove_old_key_secrets
3087
+ )
2652
3088
  return {
2653
3089
  "files": [path] if updated else [],
2654
3090
  "workflow": path,
2655
3091
  "updated": updated,
2656
- "next": steps,
3092
+ **leftovers,
3093
+ "next": [*steps, *leftover_steps],
2657
3094
  }
2658
3095
 
2659
3096
 
2660
3097
  def initialize(argv, name="--cloud-init"):
2661
3098
  parser = argparse.ArgumentParser(
2662
3099
  prog=f"bitfab-replay {name}",
2663
- description="Write .github/workflows/bitfab-replay.yml, the only file cloud replay keeps in the repository, and list what is left to do. It detects the runtime, package manager, install command, and replay command from the directory you run it in. On an existing workflow it only adds what a newer SDK needs (the execution_id and key_id inputs, the run name, and BITFAB_REPLAY_BITFAB_API_KEY) and lists what it changed; edit it directly for anything else.",
3100
+ description="Write .github/workflows/bitfab-replay.yml, the only file cloud replay keeps in the repository, and list what is left to do. It detects the runtime, package manager, install command, and replay command from the directory you run it in. On an existing workflow it only adds what a newer SDK needs (the execution_id and key_id inputs, the run name, and the id-token: write permission), removes the BITFAB_REPLAY_BITFAB_API_KEY line older SDKs wrote, lists what it changed, and lists the BITFAB_REPLAY_KEY_* secrets older SDKs left in the repository; edit it directly for anything else.",
2664
3101
  )
2665
3102
  parser.add_argument(
2666
3103
  "--secret",
@@ -2683,6 +3120,11 @@ def initialize(argv, name="--cloud-init"):
2683
3120
  help='Command that starts the SDK\'s bitfab-replay on the runner, such as "pnpm exec bitfab-replay"; detected for every SDK, including the Go program that calls RunCloudReplayCLI',
2684
3121
  )
2685
3122
  parser.add_argument("--runs-on", default="ubuntu-24.04", help="Runner label")
3123
+ parser.add_argument(
3124
+ "--remove-old-key-secrets",
3125
+ action="store_true",
3126
+ help=f"On an existing workflow, also delete the {LEFTOVER_KEY_SECRET_PREFIX}* GitHub secrets an older SDK left behind, each a copy of the Bitfab API key of the person who started a replay",
3127
+ )
2686
3128
  parser.add_argument(
2687
3129
  "--check",
2688
3130
  help='Command the runner also runs on a --cloud --dry-run, such as "node scripts/checkBucket.js"',
@@ -2692,7 +3134,11 @@ def initialize(argv, name="--cloud-init"):
2692
3134
  refuse_old_setup(root)
2693
3135
  existing = find_workflow(root)
2694
3136
  if existing is not None:
2695
- return update_workflow(root, f"{WORKFLOW_DIRECTORY}/{existing}")
3137
+ return update_workflow(
3138
+ root,
3139
+ f"{WORKFLOW_DIRECTORY}/{existing}",
3140
+ remove_old_key_secrets=args.remove_old_key_secrets,
3141
+ )
2696
3142
  prefix = args.secret_prefix
2697
3143
  if prefix and not re.fullmatch(r"[A-Z][A-Z0-9_]*_", prefix):
2698
3144
  raise ValueError(
@@ -2711,7 +3157,6 @@ def initialize(argv, name="--cloud-init"):
2711
3157
  'Pass --run with the command that starts your registry program on the runner, such as --run "go run ./cmd/registry"'
2712
3158
  )
2713
3159
  env = {name: "${{ secrets." + prefix + name + " }}" for name in names}
2714
- env[API_KEY_ENV] = KEY_SECRET_REFERENCE
2715
3160
  if args.check:
2716
3161
  env[CHECK_COMMAND_ENV] = args.check
2717
3162
  path = f"{WORKFLOW_DIRECTORY}/{WORKFLOW}"
@@ -53,8 +53,9 @@ module Bitfab
53
53
 
54
54
  def print_api_key(registry, replay_argv, stdout:)
55
55
  pipeline = ReplayCli.parse(registry, replay_argv.dup).fetch(:pipeline)
56
- key = registry.fetch(pipeline).client.send(:lookup_api_key)
57
- stdout.puts "#{BITFAB_API_KEY_PREFIX}#{JSON.generate({apiKey: key&.to_s})}"
56
+ client = registry.fetch(pipeline).client
57
+ key = client.send(:lookup_api_key)
58
+ stdout.puts "#{BITFAB_API_KEY_PREFIX}#{JSON.generate({apiKey: key&.to_s, serviceUrl: client.service_url})}"
58
59
  nil
59
60
  end
60
61
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bitfab
4
- VERSION = "0.64.17"
4
+ VERSION = "0.64.19"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bitfab
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.64.17
4
+ version: 0.64.19
5
5
  platform: ruby
6
6
  authors:
7
7
  - Harvest Team