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 +4 -4
- data/lib/bitfab/cloudReplay.py +535 -90
- data/lib/bitfab/replay_cli.rb +3 -2
- data/lib/bitfab/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a4949c6f7ebe457c48b386ae3243b7b5f4735f9fb0e8c2248db559328ca2d532
|
|
4
|
+
data.tar.gz: cd72bfba1753c4c2a4baee978f2fd401fe9cecfccdf56b90dd01a70a49aea85d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 525e777fd4ae7517aa2bec6efabb08afd0d944efb17a270952ec8bd762e04c5f78dba6070fe0e2b33f7174ec0c198c5c55bf3c20a2e55511f7b0c9ae2232745e
|
|
7
|
+
data.tar.gz: d68cbd2fd4dbc0c84e50c773f3e99b3e7edc5bf7e7cd481734db313e62c63169fe2332c1dbbcaedf5172760aa840dd7db8aa100cdb6fe6c46b8034569e4b35eb
|
data/lib/bitfab/cloudReplay.py
CHANGED
|
@@ -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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
1386
|
-
except
|
|
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
|
-
|
|
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
|
|
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
|
|
1474
|
+
if KEY_INPUT not in declared_inputs(text):
|
|
1395
1475
|
progress(
|
|
1396
|
-
f"The workflow takes no {KEY_INPUT} input, {shared}. To replay
|
|
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
|
|
1479
|
+
if not grants_id_token(text):
|
|
1400
1480
|
progress(
|
|
1401
|
-
f"
|
|
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
|
-
|
|
1405
|
-
if
|
|
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
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
"
|
|
1420
|
-
|
|
1421
|
-
"
|
|
1422
|
-
record["
|
|
1423
|
-
|
|
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
|
|
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:
|
|
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 =
|
|
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
|
|
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=
|
|
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
|
-
{
|
|
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
|
|
2577
|
-
|
|
2578
|
-
|
|
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
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
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
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2604
|
-
child =
|
|
2605
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
2630
|
-
if
|
|
2631
|
-
updated.append(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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}"
|
data/lib/bitfab/replay_cli.rb
CHANGED
|
@@ -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
|
-
|
|
57
|
-
|
|
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
|
|
data/lib/bitfab/version.rb
CHANGED