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