bitfab 0.64.18 → 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: 9b213b2d758ad1fbcafc95b9f3704d6fa06c5b288be373dfd9c97565c699eaa4
4
- data.tar.gz: 67f58161475956d24a8bac5592e080b42892b277fc0fd132e9a1b679254fddbc
3
+ metadata.gz: a4949c6f7ebe457c48b386ae3243b7b5f4735f9fb0e8c2248db559328ca2d532
4
+ data.tar.gz: cd72bfba1753c4c2a4baee978f2fd401fe9cecfccdf56b90dd01a70a49aea85d
5
5
  SHA512:
6
- metadata.gz: 54b089f5d3a87132403ec8bff156d0a2bcb596480956c14b9bdb8f158c877589a33a30a8ee1eafaed363ff1ac97b61638997abed639aeb487f16eaba6fd90c81
7
- data.tar.gz: c34da0a8b44cb56dad4d788188940fb11197b8377155ff614fb7dd930141dde8911f4bdbdb83480c0a1a4cca73748e2b296459430c3641002a8e1a4a8fe9b6c8
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 }}"
@@ -221,13 +231,15 @@ values --cloud-env sets. version and id are optional: version names the request
221
231
  format (4 when env is set, otherwise 3; 2 is still accepted), and id is the
222
232
  execution UUID, which must match an execution_id input when the workflow has one.
223
233
 
224
- The replay runs with the Bitfab API key it would use on your machine, so the
225
- experiment is recorded as yours. Before dispatch, the replay's registry is loaded
226
- locally to read its client's key, which the GitHub CLI stores as a GitHub secret
227
- named for this execution and deletes once the replay starts. Without the GitHub
228
- CLI, without permission to manage the repository's Actions secrets, with no key
229
- resolved locally, or with a workflow that declares no key_id input, the run uses
230
- 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.
231
243
 
232
244
  The runner's job exits with the replay's exit code, so the Actions run fails when
233
245
  the replay does. Traces that errored fail it only under --fail-on-error.
@@ -243,8 +255,11 @@ Set up once:
243
255
  Writes .github/workflows/bitfab-replay.yml, the only file cloud replay keeps
244
256
  in the repository, and lists what is left to do. On an existing workflow it
245
257
  adds only the lines a newer SDK needs and lists what it changed.
246
- --cloud-update [same options]
258
+ --cloud-update [same options] [--remove-old-key-secrets]
247
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.
248
263
  --cloud-secrets --env-file FILE [NAME ...] [--environment NAME] [--dry-run]
249
264
  Copies local values into the GitHub secrets the workflow reads.
250
265
 
@@ -1376,7 +1391,7 @@ def submit(root, repo, workflow, parsed, path):
1376
1391
  save(path, record)
1377
1392
  text = within(root, f"{WORKFLOW_DIRECTORY}/{workflow}").read_text()
1378
1393
  declared = declared_inputs(text)
1379
- key_inputs = store_key(root, record, declared, text)
1394
+ key_inputs = issue_key_token(root, record, text)
1380
1395
  save(path, record)
1381
1396
  return dispatch(
1382
1397
  record,
@@ -1385,7 +1400,7 @@ def submit(root, repo, workflow, parsed, path):
1385
1400
  )
1386
1401
 
1387
1402
 
1388
- def local_api_key(root, request):
1403
+ def local_client(root, request):
1389
1404
  try:
1390
1405
  output = command(
1391
1406
  [*replay_command(), *request["args"], PRINT_API_KEY_FLAG],
@@ -1399,59 +1414,101 @@ def local_api_key(root, request):
1399
1414
  if not line.startswith(API_KEY_LINE):
1400
1415
  continue
1401
1416
  try:
1402
- key = json.loads(line.removeprefix(API_KEY_LINE)).get("apiKey")
1403
- except (ValueError, AttributeError):
1417
+ reported = json.loads(line.removeprefix(API_KEY_LINE))
1418
+ except ValueError:
1419
+ return None
1420
+ if not isinstance(reported, dict):
1404
1421
  return None
1405
- 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("/")}
1406
1429
  return None
1407
1430
 
1408
1431
 
1409
- 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):
1410
1473
  shared = "so the run uses the workflow's BITFAB_API_KEY secret"
1411
- if KEY_INPUT not in declared:
1474
+ if KEY_INPUT not in declared_inputs(text):
1412
1475
  progress(
1413
- 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"
1414
1477
  )
1415
1478
  return {}
1416
- if shutil.which("gh") is None:
1479
+ if not grants_id_token(text):
1417
1480
  progress(
1418
- 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"
1419
1482
  )
1420
1483
  return {}
1421
- key = local_api_key(root, record["request"])
1422
- if key is None:
1484
+ client = local_client(root, record["request"])
1485
+ if client is None:
1423
1486
  progress(
1424
1487
  f"The replay's Bitfab client resolved no API key on this machine, {shared}"
1425
1488
  )
1426
1489
  return {}
1427
- key_id = record["id"].replace("-", "").upper()
1428
- name = KEY_SECRET_PREFIX + key_id
1429
- configured = ENVIRONMENT_SETTING.search(workflow_text)
1430
- environment = configured[1] if configured else None
1431
1490
  try:
1432
- command(
1433
- [
1434
- "gh",
1435
- "secret",
1436
- "set",
1437
- name,
1438
- "--repo",
1439
- record["repository"],
1440
- *(["--env", environment] if environment else []),
1441
- ],
1442
- input=key,
1443
- env={**os.environ, "GH_TOKEN": github_access()["token"]},
1444
- )
1445
- except (ValueError, RuntimeError, OSError, subprocess.SubprocessError):
1446
- progress(
1447
- 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
+ },
1448
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}")
1449
1507
  return {}
1450
- record["keySecret"] = {"name": name, "environment": environment}
1451
1508
  progress(
1452
- 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"
1453
1510
  )
1454
- return {KEY_INPUT: key_id}
1511
+ return {KEY_INPUT: token}
1455
1512
 
1456
1513
 
1457
1514
  def remove_key(record):
@@ -1606,7 +1663,7 @@ def submit_as_is(root, repo, workflow, parsed, path):
1606
1663
  progress(
1607
1664
  f"Replaying {branch} at {sha[:12]} as it is on GitHub, without local changes. Execution {execution_id}; resume with --cloud-watch {execution_id}"
1608
1665
  )
1609
- key_inputs = store_key(root, record, declared, text)
1666
+ key_inputs = issue_key_token(root, record, text)
1610
1667
  save(path, record)
1611
1668
  return dispatch(
1612
1669
  record,
@@ -1954,11 +2011,6 @@ def execute():
1954
2011
  )
1955
2012
  directory = within(root, request["cwd"])
1956
2013
  check_secrets(root)
1957
- if os.environ.get(API_KEY_ENV, "").strip():
1958
- print(
1959
- "Replaying with the Bitfab API key of the person who started this run",
1960
- flush=True,
1961
- )
1962
2014
  environment = request.get("env") or {}
1963
2015
  if environment:
1964
2016
  workflow_environment_conflicts(root, environment)
@@ -1981,6 +2033,7 @@ def execute():
1981
2033
  if dry_run(request["args"]):
1982
2034
  run_check_command(directory, environment)
1983
2035
  experiment = {}
2036
+ starter = starter_key(root, request)
1984
2037
  previous = signal.signal(signal.SIGTERM, raise_interrupt)
1985
2038
  print(OUTPUT_BEGIN, flush=True)
1986
2039
  try:
@@ -1995,6 +2048,7 @@ def execute():
1995
2048
  finally:
1996
2049
  print(OUTPUT_END, flush=True)
1997
2050
  signal.signal(signal.SIGTERM, previous)
2051
+ revoke_starter_key(starter)
1998
2052
  result = {
1999
2053
  "executionId": request["id"],
2000
2054
  "commitSha": commit,
@@ -2011,6 +2065,114 @@ def execute():
2011
2065
  return result
2012
2066
 
2013
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
+
2014
2176
  def running_workflow(root):
2015
2177
  reference = os.environ.get("GITHUB_WORKFLOW_REF", "")
2016
2178
  match = re.search(r"(\.github/workflows/[^@]+)@", reference)
@@ -2656,7 +2818,7 @@ def workflow_document(steps, run, directory, env, *, runs_on, environment):
2656
2818
  }
2657
2819
  }
2658
2820
  },
2659
- "permissions": {"contents": "read"},
2821
+ "permissions": {"contents": "read", "id-token": "write"},
2660
2822
  "jobs": {"replay": job},
2661
2823
  }
2662
2824
 
@@ -2712,38 +2874,11 @@ def without_yaml_comment(line):
2712
2874
  return re.sub(r"(^|\s)#.*", "", line)
2713
2875
 
2714
2876
 
2715
- def replay_step_key(lines):
2716
- for index, line in enumerate(lines):
2717
- if "--cloud-execute" not in without_yaml_comment(line):
2718
- continue
2719
- for above in range(index, -1, -1):
2720
- match = re.match(r"([ \t]*)(- +)?run[ \t]*:", lines[above])
2721
- if match and indentation(lines[above]) <= indentation(line):
2722
- return above, len(match[1]) + len(match[2] or "")
2723
- return None
2724
-
2725
-
2726
- def add_key_env(lines):
2727
- found = replay_step_key(lines)
2728
- if found is None:
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):
2729
2880
  return False
2730
- run, key = found
2731
- start = run
2732
- while start > 0 and not re.match(r"[ \t]*- ", lines[start]):
2733
- start -= 1
2734
- end = block_end(lines, run, key - 1)
2735
- entry = f'{API_KEY_ENV}: "{KEY_SECRET_REFERENCE}"'
2736
- for index in range(start, end):
2737
- line = lines[index]
2738
- if not re.fullmatch(r"[ \t]*(- +)?env[ \t]*:[ \t]*", line):
2739
- continue
2740
- if len(line) - len(line.lstrip(" \t-")) != key:
2741
- continue
2742
- entries = block_end(lines, index, key)
2743
- child = indentation(lines[index + 1]) if entries > index + 1 else key + 2
2744
- lines.insert(entries, " " * child + entry)
2745
- return True
2746
- lines[end:end] = [" " * key + "env:", " " * (key + 2) + entry]
2881
+ lines[:] = kept
2747
2882
  return True
2748
2883
 
2749
2884
 
@@ -2798,7 +2933,100 @@ def add_checkout_depth(lines):
2798
2933
  return "unchanged"
2799
2934
 
2800
2935
 
2801
- def update_workflow(root, path):
2936
+ def add_id_token_permission(lines):
2937
+ for index, line in enumerate(lines):
2938
+ if not re.fullmatch(r'"?permissions"?[ \t]*:[ \t]*(#.*)?', line):
2939
+ continue
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")
2950
+ return True
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
2994
+ return True
2995
+
2996
+
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):
2802
3030
  target = within(root, path)
2803
3031
  text = target.read_text()
2804
3032
  lines = text.split("\n")
@@ -2816,13 +3044,19 @@ def update_workflow(root, path):
2816
3044
  if not re.search(r'^"?run-name"?[ \t]*:', text, re.MULTILINE):
2817
3045
  add_run_name(lines)
2818
3046
  updated.append("Named each run after its execution")
2819
- if API_KEY_ENV not in text:
2820
- if add_key_env(lines):
2821
- 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
+ )
2822
3052
  else:
2823
3053
  by_hand.append(
2824
- 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"
2825
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
+ )
2826
3060
  depth = add_checkout_depth(lines)
2827
3061
  if depth == "updated":
2828
3062
  updated.append(
@@ -2848,18 +3082,22 @@ def update_workflow(root, path):
2848
3082
  steps.append(
2849
3083
  "Already set up. Edit the workflow directly to change its install steps, secrets, runner, or Environment"
2850
3084
  )
3085
+ leftovers, leftover_steps = leftover_key_secret_report(
3086
+ root, text, remove_old_key_secrets
3087
+ )
2851
3088
  return {
2852
3089
  "files": [path] if updated else [],
2853
3090
  "workflow": path,
2854
3091
  "updated": updated,
2855
- "next": steps,
3092
+ **leftovers,
3093
+ "next": [*steps, *leftover_steps],
2856
3094
  }
2857
3095
 
2858
3096
 
2859
3097
  def initialize(argv, name="--cloud-init"):
2860
3098
  parser = argparse.ArgumentParser(
2861
3099
  prog=f"bitfab-replay {name}",
2862
- 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.",
2863
3101
  )
2864
3102
  parser.add_argument(
2865
3103
  "--secret",
@@ -2882,6 +3120,11 @@ def initialize(argv, name="--cloud-init"):
2882
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',
2883
3121
  )
2884
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
+ )
2885
3128
  parser.add_argument(
2886
3129
  "--check",
2887
3130
  help='Command the runner also runs on a --cloud --dry-run, such as "node scripts/checkBucket.js"',
@@ -2891,7 +3134,11 @@ def initialize(argv, name="--cloud-init"):
2891
3134
  refuse_old_setup(root)
2892
3135
  existing = find_workflow(root)
2893
3136
  if existing is not None:
2894
- 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
+ )
2895
3142
  prefix = args.secret_prefix
2896
3143
  if prefix and not re.fullmatch(r"[A-Z][A-Z0-9_]*_", prefix):
2897
3144
  raise ValueError(
@@ -2910,7 +3157,6 @@ def initialize(argv, name="--cloud-init"):
2910
3157
  'Pass --run with the command that starts your registry program on the runner, such as --run "go run ./cmd/registry"'
2911
3158
  )
2912
3159
  env = {name: "${{ secrets." + prefix + name + " }}" for name in names}
2913
- env[API_KEY_ENV] = KEY_SECRET_REFERENCE
2914
3160
  if args.check:
2915
3161
  env[CHECK_COMMAND_ENV] = args.check
2916
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.18"
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.18
4
+ version: 0.64.19
5
5
  platform: ruby
6
6
  authors:
7
7
  - Harvest Team