@techierathore/techieflow 1.0.3 → 1.1.0
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/.claude/commands/TechieFlow/agents/analyst.md +5 -8
- package/.claude/commands/TechieFlow/agents/architect.md +1 -10
- package/.claude/commands/TechieFlow/agents/flow-master.md +1 -0
- package/.claude/commands/TechieFlow/agents/verifier.md +1 -0
- package/.claude/commands/TechieFlow/tasks/_owner-language.md +27 -0
- package/.claude/commands/TechieFlow/tasks/_smoke-test-policy.md +1 -1
- package/.claude/commands/TechieFlow/tasks/_status-update-gate.md +4 -3
- package/.claude/commands/TechieFlow/tasks/amend-docs.md +6 -5
- package/.claude/commands/TechieFlow/tasks/build-phase.md +1 -1
- package/.claude/commands/TechieFlow/tasks/create-deep-research-prompt.md +4 -0
- package/.claude/commands/TechieFlow/tasks/create-doc.md +4 -0
- package/.claude/commands/TechieFlow/tasks/create-project-brief.md +22 -0
- package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +3 -3
- package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +3 -3
- package/.claude/commands/TechieFlow/tasks/facilitate-brainstorming-session.md +4 -0
- package/.claude/commands/TechieFlow/tasks/fix-issues.md +2 -2
- package/.claude/commands/TechieFlow/tasks/generate-html.md +7 -3
- package/.claude/commands/TechieFlow/tasks/handoff-phase.md +20 -18
- package/.claude/commands/TechieFlow/tasks/metrics-report.md +2 -0
- package/.claude/commands/TechieFlow/tasks/mockups.md +1 -1
- package/.claude/commands/TechieFlow/tasks/productguide.md +2 -2
- package/.claude/commands/TechieFlow/tasks/render-workflow-docs.md +1 -1
- package/.claude/commands/TechieFlow/tasks/triage-issues.md +2 -2
- package/.claude/commands/TechieFlow/tasks/verify-phase.md +1 -1
- package/.opencode/plugin/techieflow.js +181 -9
- package/.tfcore/agents/analyst.md +5 -8
- package/.tfcore/agents/architect.md +1 -10
- package/.tfcore/agents/flow-master.md +1 -0
- package/.tfcore/agents/verifier.md +1 -0
- package/.tfcore/hooks/block-git.sh +4 -2
- package/.tfcore/hooks/guard-build.sh +5 -1
- package/.tfcore/hooks/guard-db.sh +6 -4
- package/.tfcore/hooks/guard-metrics.sh +13 -1
- package/.tfcore/hooks/guard-status-html.sh +86 -2
- package/.tfcore/hooks/guard-verify-deps.sh +124 -0
- package/.tfcore/hooks/guard-verify.sh +8 -3
- package/.tfcore/hooks/sweep-artifacts.sh +46 -0
- package/.tfcore/routing.yaml +61 -0
- package/.tfcore/standards/owner-words.txt +23 -0
- package/.tfcore/tasks/_owner-language.md +27 -0
- package/.tfcore/tasks/_smoke-test-policy.md +1 -1
- package/.tfcore/tasks/_status-update-gate.md +4 -3
- package/.tfcore/tasks/amend-docs.md +6 -5
- package/.tfcore/tasks/build-phase.md +1 -1
- package/.tfcore/tasks/create-deep-research-prompt.md +4 -0
- package/.tfcore/tasks/create-doc.md +4 -0
- package/.tfcore/tasks/create-project-brief.md +22 -0
- package/.tfcore/tasks/day1-brownfield.md +3 -3
- package/.tfcore/tasks/day1-greenfield.md +3 -3
- package/.tfcore/tasks/facilitate-brainstorming-session.md +4 -0
- package/.tfcore/tasks/fix-issues.md +2 -2
- package/.tfcore/tasks/generate-html.md +7 -3
- package/.tfcore/tasks/handoff-phase.md +20 -18
- package/.tfcore/tasks/metrics-report.md +2 -0
- package/.tfcore/tasks/mockups.md +1 -1
- package/.tfcore/tasks/productguide.md +2 -2
- package/.tfcore/tasks/render-workflow-docs.md +1 -1
- package/.tfcore/tasks/triage-issues.md +2 -2
- package/.tfcore/tasks/verify-phase.md +1 -1
- package/.tfcore/telemetry/SCHEMA.md +61 -1
- package/.tfcore/telemetry/install-metrics.sh +3 -2
- package/.tfcore/telemetry/tf-metrics.sh +435 -21
- package/.tfcore/templates/v4custom/app-agents-md-tmpl.md +6 -0
- package/.tfcore/templates/v4custom/app-brd-tmpl.md +5 -3
- package/.tfcore/templates/v4custom/app-brief-tmpl.md +65 -0
- package/.tfcore/templates/v4custom/app-decision-request-tmpl.md +85 -0
- package/.tfcore/templates/v4custom/app-library-feedback-tmpl.md +86 -31
- package/.tfcore/templates/v4custom/app-phase-brd-tmpl.md +94 -0
- package/.tfcore/templates/v4custom/app-phase-uidesign-tmpl.md +60 -0
- package/.tfcore/templates/v4custom/app-uidesign-tmpl.md +4 -2
- package/.tfcore/templates/v4custom/build-subagent-prompt.md +2 -2
- package/.tfcore/templates/v4custom/html-render-shell.md +4 -2
- package/.tfcore/utils/__pycache__/tf-brd-status.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-checklist-edit.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-day1-files.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-devguide-list.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-doc-check.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-log-miss.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-misses-md.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-mockups-locate.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-opencode-audit.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-perf-grade.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-render-html.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-split-brd.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-status-facts.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-triage.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-verify-list.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-verify-verdict.cpython-312.pyc +0 -0
- package/.tfcore/utils/tf-assets-browser.mjs +96 -0
- package/.tfcore/utils/tf-assets.sh +97 -12
- package/.tfcore/utils/tf-brd-status.py +14 -5
- package/.tfcore/utils/tf-build-list.py +99 -11
- package/.tfcore/utils/tf-build-list.sh +5 -3
- package/.tfcore/utils/tf-build.sh +88 -15
- package/.tfcore/utils/tf-ci-repro.py +685 -0
- package/.tfcore/utils/tf-ci-repro.sh +24 -0
- package/.tfcore/utils/tf-devguide-list.py +47 -22
- package/.tfcore/utils/tf-doc-check.py +242 -10
- package/.tfcore/utils/tf-doc-tests.sh +79 -0
- package/.tfcore/utils/tf-emit.sh +413 -19
- package/.tfcore/utils/tf-feedback.sh +13 -0
- package/.tfcore/utils/tf-fix-close.sh +113 -31
- package/.tfcore/utils/tf-gitignore-audit.sh +50 -3
- package/.tfcore/utils/tf-goal.sh +105 -18
- package/.tfcore/utils/tf-harness.sh +9 -4
- package/.tfcore/utils/tf-lock.sh +29 -0
- package/.tfcore/utils/tf-log-miss.py +34 -8
- package/.tfcore/utils/tf-login.mjs +167 -0
- package/.tfcore/utils/tf-misses-md.py +24 -21
- package/.tfcore/utils/tf-misses-md.sh +5 -4
- package/.tfcore/utils/tf-mockup-parity.mjs +427 -63
- package/.tfcore/utils/tf-mockup-parity.sh +14 -2
- package/.tfcore/utils/tf-model-pick.sh +475 -0
- package/.tfcore/utils/tf-owner-text.py +233 -0
- package/.tfcore/utils/tf-owner-text.sh +14 -0
- package/.tfcore/utils/tf-phase.sh +33 -1
- package/.tfcore/utils/tf-portable.sh +230 -0
- package/.tfcore/utils/tf-render-html.py +80 -2
- package/.tfcore/utils/tf-routing-bind.sh +87 -1
- package/.tfcore/utils/tf-routing.sh +7 -0
- package/.tfcore/utils/tf-selfcheck.sh +356 -0
- package/.tfcore/utils/tf-split-brd.py +17 -2
- package/.tfcore/utils/tf-status-evidence.sh +11 -9
- package/.tfcore/utils/tf-status-facts.py +133 -44
- package/.tfcore/utils/tf-triage.py +62 -16
- package/.tfcore/utils/tf-triage.sh +2 -0
- package/.tfcore/utils/tf-verify-boot.sh +263 -43
- package/.tfcore/utils/tf-verify-emit.sh +20 -0
- package/.tfcore/utils/tf-verify-env.sh +33 -4
- package/.tfcore/utils/tf-verify-list.py +75 -8
- package/.tfcore/utils/tf-verify-screens.mjs +254 -43
- package/.tfcore/utils/tf-verify-screens.sh +18 -4
- package/.tfcore/utils/tf-verify-tests.sh +272 -28
- package/.tfcore/utils/tf-verify-verdict.py +59 -11
- package/.tfcore/utils/tf_defect.py +27 -0
- package/.tfcore/utils/tf_feedback.py +209 -0
- package/.tfcore/utils/tf_roadmap.py +35 -0
- package/README.md +19 -5
- package/docs/TechieFlow-Installation.md +5 -3
- package/opencode.jsonc +24 -0
- package/package.json +1 -1
- package/scripts/install.mjs +4 -0
- package/.tfcore/templates/architecture-tmpl.yaml +0 -650
- package/.tfcore/templates/brownfield-architecture-tmpl.yaml +0 -476
- package/.tfcore/templates/competitor-analysis-tmpl.yaml +0 -306
- package/.tfcore/templates/front-end-architecture-tmpl.yaml +0 -218
- package/.tfcore/templates/fullstack-architecture-tmpl.yaml +0 -823
- package/.tfcore/templates/market-research-tmpl.yaml +0 -252
- package/.tfcore/templates/project-brief-tmpl.yaml +0 -221
- package/.tfcore/utils/techieflow-doc-template.md +0 -325
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""tf-ci-repro.py — run a CI workflow's steps here the way a fresh runner does (Lekhak TF-019, 2026-09-30).
|
|
3
|
+
|
|
4
|
+
Called by tf-ci-repro.sh; see that file for the usage and the verdict lines.
|
|
5
|
+
|
|
6
|
+
A developer machine hides CI failures in two places: its package caches and its build output.
|
|
7
|
+
Lekhak's CI failed with NETSDK1112 (the win-x64 runtime pack was never downloaded) while the same
|
|
8
|
+
commands passed on the developer's machine, because the pack was already in ~/.nuget/packages.
|
|
9
|
+
triage-and-fix reported the failure fixed; the owner's next CI run failed again. This script:
|
|
10
|
+
* copies the repository without anything its .gitignore files ignore (no bin/, obj/,
|
|
11
|
+
node_modules/ ...), as a fresh checkout has none of them, to a folder outside the repository;
|
|
12
|
+
* points every package cache it knows at an empty folder (NuGet, npm, yarn, pnpm, pip, Go,
|
|
13
|
+
Gradle, Maven);
|
|
14
|
+
* runs the job's `run:` steps in order, in the shell the runner would use, and stops at the
|
|
15
|
+
first failing one, as the runner does.
|
|
16
|
+
`uses:` steps are not run: the copy stands in for the checkout, the tools installed here for the
|
|
17
|
+
setup actions, and a cache action is left out on purpose. A step that needs a secret is skipped:
|
|
18
|
+
this machine's own credentials stand in. A step that only installs tools on the machine
|
|
19
|
+
(workloads, apt, choco, brew, winget) is skipped unless --run-setup.
|
|
20
|
+
"""
|
|
21
|
+
import json, os, re, shutil, subprocess, sys, time
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
|
|
24
|
+
CACHES = [ # env var, folder name, whose cache
|
|
25
|
+
("NUGET_PACKAGES", "nuget", "NuGet"), ("NUGET_HTTP_CACHE_PATH", "nuget-http", None),
|
|
26
|
+
("npm_config_cache", "npm", "npm"), ("YARN_CACHE_FOLDER", "yarn", "yarn"),
|
|
27
|
+
("npm_config_store_dir", "pnpm", "pnpm"), ("PIP_CACHE_DIR", "pip", "pip"),
|
|
28
|
+
("GOMODCACHE", "gomod", "Go"), ("GRADLE_USER_HOME", "gradle", "Gradle"),
|
|
29
|
+
]
|
|
30
|
+
SETUP_RX = re.compile(r"^\s*(sudo\s+)?(dotnet\s+workload\s+(install|update)|apt(-get)?\s+(update|install)|"
|
|
31
|
+
r"choco\s+install|brew\s+install|winget\s+install|npm\s+(i|install)\s+(-g|--global))\b", re.I)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def say(*a):
|
|
35
|
+
print(*a, flush=True)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---- a workflow reader that needs no PyYAML -----------------------------------------------------
|
|
39
|
+
# A stock Mac's python3 has no PyYAML, and CI's stock-Mac job failed on it (2026-09-30). PyYAML is used
|
|
40
|
+
# when it is installed; otherwise this reader takes the part of YAML a workflow file uses: block
|
|
41
|
+
# mappings and sequences, | and > block scalars, quoted and plain scalars, and one-line [ ] and { }.
|
|
42
|
+
# Anchors, tags and multi-line flow collections are not read.
|
|
43
|
+
def _strip_comment(s):
|
|
44
|
+
q = None
|
|
45
|
+
for i, c in enumerate(s):
|
|
46
|
+
if q:
|
|
47
|
+
if c == q:
|
|
48
|
+
q = None
|
|
49
|
+
elif c in "'\"" and (i == 0 or s[i - 1] in " [{,:"):
|
|
50
|
+
q = c
|
|
51
|
+
elif c == "#" and (i == 0 or s[i - 1] in " \t"):
|
|
52
|
+
return s[:i].rstrip()
|
|
53
|
+
return s.rstrip()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _split_flow(s):
|
|
57
|
+
parts, depth, q, cur = [], 0, None, ""
|
|
58
|
+
for c in s:
|
|
59
|
+
if q:
|
|
60
|
+
q = None if c == q else q
|
|
61
|
+
elif c in "'\"":
|
|
62
|
+
q = c
|
|
63
|
+
elif c in "[{":
|
|
64
|
+
depth += 1
|
|
65
|
+
elif c in "]}":
|
|
66
|
+
depth -= 1
|
|
67
|
+
elif c == "," and depth == 0:
|
|
68
|
+
parts.append(cur.strip()); cur = ""; continue
|
|
69
|
+
cur += c
|
|
70
|
+
if cur.strip():
|
|
71
|
+
parts.append(cur.strip())
|
|
72
|
+
return parts
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _scalar(s):
|
|
76
|
+
s = s.strip()
|
|
77
|
+
if not s:
|
|
78
|
+
return None
|
|
79
|
+
if s[0] == "'" and s.endswith("'") and len(s) > 1:
|
|
80
|
+
return s[1:-1].replace("''", "'")
|
|
81
|
+
if s[0] == '"' and s.endswith('"') and len(s) > 1:
|
|
82
|
+
try:
|
|
83
|
+
return json.loads(s)
|
|
84
|
+
except ValueError:
|
|
85
|
+
return s[1:-1]
|
|
86
|
+
if s[0] == "[" and s.endswith("]"):
|
|
87
|
+
return [_scalar(p) for p in _split_flow(s[1:-1])]
|
|
88
|
+
if s[0] == "{" and s.endswith("}"):
|
|
89
|
+
out = {}
|
|
90
|
+
for p in _split_flow(s[1:-1]):
|
|
91
|
+
k, _, v = p.partition(":")
|
|
92
|
+
out[_scalar(k)] = _scalar(v)
|
|
93
|
+
return out
|
|
94
|
+
s = _strip_comment(s)
|
|
95
|
+
low = s.lower()
|
|
96
|
+
if low in ("true", "false"):
|
|
97
|
+
return low == "true"
|
|
98
|
+
if low in ("null", "~"):
|
|
99
|
+
return None
|
|
100
|
+
if re.match(r"^-?\d+$", s):
|
|
101
|
+
return int(s)
|
|
102
|
+
if re.match(r"^-?\d+\.\d+$", s):
|
|
103
|
+
return float(s)
|
|
104
|
+
return s
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
_KEY = re.compile(r"""^('(?:[^']|'')*'|"(?:[^"\\]|\\.)*"|[^\s'"#\-\[{][^:#]*?|-[^\s:][^:#]*?)\s*:(?:\s+|$)(.*)$""")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def mini_yaml(text):
|
|
111
|
+
lines = text.replace("\t", " ").splitlines()
|
|
112
|
+
ends_nl = text.endswith("\n") # a block scalar that ends the file keeps a newline only if the file has one
|
|
113
|
+
|
|
114
|
+
def meaningful(i):
|
|
115
|
+
while i < len(lines) and (not lines[i].strip() or lines[i].strip().startswith("#") or lines[i].strip() == "---"):
|
|
116
|
+
i += 1
|
|
117
|
+
return i
|
|
118
|
+
|
|
119
|
+
def ind(i):
|
|
120
|
+
return len(lines[i]) - len(lines[i].lstrip(" "))
|
|
121
|
+
|
|
122
|
+
def block_scalar(head, i, parent):
|
|
123
|
+
style, chomp = head[0], ("-" if "-" in head else "+" if "+" in head else "")
|
|
124
|
+
body, width = [], None
|
|
125
|
+
while i < len(lines) and (not lines[i].strip() or ind(i) > parent):
|
|
126
|
+
if lines[i].strip() and width is None:
|
|
127
|
+
width = ind(i)
|
|
128
|
+
body.append(lines[i][width:] if width is not None and len(lines[i]) > width else "")
|
|
129
|
+
i += 1
|
|
130
|
+
while body and chomp != "+" and body[-1] == "":
|
|
131
|
+
body.pop()
|
|
132
|
+
if style == "|":
|
|
133
|
+
txt = "\n".join(body)
|
|
134
|
+
else:
|
|
135
|
+
txt, prev_blank = "", False
|
|
136
|
+
for ln in body:
|
|
137
|
+
if not ln:
|
|
138
|
+
txt += "\n"; prev_blank = True
|
|
139
|
+
else:
|
|
140
|
+
txt += (ln if not txt or txt.endswith("\n") else " " + ln); prev_blank = False
|
|
141
|
+
last_line_open = i >= len(lines) and not ends_nl and not (chomp != "+" and not lines[-1].strip())
|
|
142
|
+
return txt + ("" if chomp == "-" or not txt or last_line_open else "\n"), i
|
|
143
|
+
|
|
144
|
+
def value(rest, i, n):
|
|
145
|
+
rest = _strip_comment(rest) if not rest.startswith(("'", '"')) else rest
|
|
146
|
+
if rest[:1] in ("|", ">"):
|
|
147
|
+
return block_scalar(rest, i, n)
|
|
148
|
+
if rest:
|
|
149
|
+
return _scalar(rest), i
|
|
150
|
+
j = meaningful(i)
|
|
151
|
+
if j < len(lines) and (ind(j) > n or (ind(j) == n and lines[j].lstrip().startswith("- ") )):
|
|
152
|
+
return node(j)
|
|
153
|
+
return None, i
|
|
154
|
+
|
|
155
|
+
def node(i):
|
|
156
|
+
i = meaningful(i)
|
|
157
|
+
if i >= len(lines):
|
|
158
|
+
return None, i
|
|
159
|
+
n = ind(i)
|
|
160
|
+
if lines[i].lstrip().startswith("-") and lines[i].strip() in ("-",) or lines[i].lstrip().startswith("- "):
|
|
161
|
+
seq = []
|
|
162
|
+
while i < len(lines) and ind(i) == n and (lines[i].strip() == "-" or lines[i].lstrip().startswith("- ")):
|
|
163
|
+
content = lines[i].strip()[1:].lstrip()
|
|
164
|
+
if not content:
|
|
165
|
+
v, i = node(i + 1)
|
|
166
|
+
elif _KEY.match(content) and not content.startswith(("'", '"', "[", "{")) or re.match(r"""^('[^']*'|"[^"]*")\s*:(\s|$)""", content):
|
|
167
|
+
lines[i] = " " * (len(lines[i]) - len(content)) + content
|
|
168
|
+
v, i = node(i)
|
|
169
|
+
else:
|
|
170
|
+
v, i = value(content, i + 1, n)
|
|
171
|
+
seq.append(v)
|
|
172
|
+
i = meaningful(i)
|
|
173
|
+
return seq, i
|
|
174
|
+
out = {}
|
|
175
|
+
while i < len(lines) and ind(i) == n and not lines[i].lstrip().startswith("- "):
|
|
176
|
+
m = _KEY.match(lines[i].strip())
|
|
177
|
+
if not m:
|
|
178
|
+
raise ValueError("line %d: cannot read %r" % (i + 1, lines[i].strip()))
|
|
179
|
+
out[_scalar(m.group(1))], i = value(m.group(2), i + 1, n)
|
|
180
|
+
i = meaningful(i)
|
|
181
|
+
return out, i
|
|
182
|
+
|
|
183
|
+
v, _ = node(0)
|
|
184
|
+
return v
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def load_workflow(path):
|
|
188
|
+
text = open(path, encoding="utf-8").read()
|
|
189
|
+
if not os.environ.get("TF_CI_REPRO_NO_PYYAML"):
|
|
190
|
+
try:
|
|
191
|
+
import yaml
|
|
192
|
+
return yaml.safe_load(text)
|
|
193
|
+
except ImportError:
|
|
194
|
+
pass
|
|
195
|
+
return mini_yaml(text)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# ---- a clean copy: what a fresh checkout holds ------------------------------------------------
|
|
199
|
+
# Every .gitignore in the tree is read the way git reads it (last matching rule wins, "!" re-includes,
|
|
200
|
+
# a trailing "/" means a folder, a "/" elsewhere anchors the rule to that .gitignore's folder, "**"
|
|
201
|
+
# crosses folders). An ignored folder is not entered. Done in Python so the copy needs no rsync
|
|
202
|
+
# `--filter` support, which a stock Mac's rsync may lack.
|
|
203
|
+
def _glob_rx(pat):
|
|
204
|
+
rx, i = "", 0
|
|
205
|
+
while i < len(pat):
|
|
206
|
+
c = pat[i]
|
|
207
|
+
if pat.startswith("**/", i):
|
|
208
|
+
rx += "(?:.*/)?"; i += 3; continue
|
|
209
|
+
if pat.startswith("/**", i) and i + 3 == len(pat):
|
|
210
|
+
rx += "/.*"; i += 3; continue
|
|
211
|
+
if pat.startswith("**", i):
|
|
212
|
+
rx += ".*"; i += 2; continue
|
|
213
|
+
if c == "*":
|
|
214
|
+
rx += "[^/]*"
|
|
215
|
+
elif c == "?":
|
|
216
|
+
rx += "[^/]"
|
|
217
|
+
elif c == "\\" and i + 1 < len(pat):
|
|
218
|
+
i += 1; rx += re.escape(pat[i])
|
|
219
|
+
elif c == "[":
|
|
220
|
+
j = pat.find("]", i + 1)
|
|
221
|
+
if j < 0:
|
|
222
|
+
rx += "\\["
|
|
223
|
+
else:
|
|
224
|
+
rx += "[" + pat[i + 1:j].replace("!", "^", 1) + "]"; i = j
|
|
225
|
+
else:
|
|
226
|
+
rx += re.escape(c)
|
|
227
|
+
i += 1
|
|
228
|
+
return rx
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _read_ignore(path):
|
|
232
|
+
rules = []
|
|
233
|
+
for ln in read_text(path).splitlines():
|
|
234
|
+
ln = ln.rstrip("\r")
|
|
235
|
+
if not ln.strip() or ln.startswith("#"):
|
|
236
|
+
continue
|
|
237
|
+
ln = re.sub(r"(?<!\\)\s+$", "", ln)
|
|
238
|
+
neg = ln.startswith("!")
|
|
239
|
+
if neg:
|
|
240
|
+
ln = ln[1:]
|
|
241
|
+
dir_only = ln.endswith("/")
|
|
242
|
+
ln = ln.rstrip("/")
|
|
243
|
+
anchored = "/" in ln
|
|
244
|
+
ln = ln.lstrip("/")
|
|
245
|
+
rules.append((re.compile("^" + _glob_rx(ln) + "$"), neg, dir_only, anchored))
|
|
246
|
+
return rules
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def clean_copy(src, dst):
|
|
250
|
+
stack = [] # (folder relative to src, rules)
|
|
251
|
+
|
|
252
|
+
def ignored(rel, is_dir):
|
|
253
|
+
hit = False
|
|
254
|
+
for base, rules in stack:
|
|
255
|
+
if base and not rel.startswith(base + "/"):
|
|
256
|
+
continue
|
|
257
|
+
sub = rel[len(base) + 1:] if base else rel
|
|
258
|
+
name = sub.rsplit("/", 1)[-1]
|
|
259
|
+
for rx, neg, dir_only, anchored in rules:
|
|
260
|
+
if dir_only and not is_dir:
|
|
261
|
+
continue
|
|
262
|
+
if rx.match(sub if anchored else name):
|
|
263
|
+
hit = not neg
|
|
264
|
+
return hit
|
|
265
|
+
|
|
266
|
+
n = 0
|
|
267
|
+
for root, dirs, files in os.walk(src):
|
|
268
|
+
rel_root = os.path.relpath(root, src).replace(os.sep, "/")
|
|
269
|
+
rel_root = "" if rel_root == "." else rel_root
|
|
270
|
+
while stack and stack[-1][0] and not (rel_root == stack[-1][0] or rel_root.startswith(stack[-1][0] + "/")):
|
|
271
|
+
stack.pop()
|
|
272
|
+
if ".gitignore" in files:
|
|
273
|
+
stack.append((rel_root, _read_ignore(os.path.join(root, ".gitignore"))))
|
|
274
|
+
keep = []
|
|
275
|
+
for d in sorted(dirs):
|
|
276
|
+
rel = (rel_root + "/" if rel_root else "") + d
|
|
277
|
+
if d == ".git" or rel == "tests/.artifacts" or ignored(rel, True):
|
|
278
|
+
continue
|
|
279
|
+
keep.append(d)
|
|
280
|
+
os.makedirs(os.path.join(dst, rel), exist_ok=True)
|
|
281
|
+
dirs[:] = keep
|
|
282
|
+
for f in files:
|
|
283
|
+
rel = (rel_root + "/" if rel_root else "") + f
|
|
284
|
+
if ignored(rel, False):
|
|
285
|
+
continue
|
|
286
|
+
s, t = os.path.join(root, f), os.path.join(dst, rel)
|
|
287
|
+
os.makedirs(os.path.dirname(t), exist_ok=True)
|
|
288
|
+
if os.path.islink(s):
|
|
289
|
+
os.symlink(os.readlink(s), t)
|
|
290
|
+
else:
|
|
291
|
+
shutil.copy2(s, t)
|
|
292
|
+
n += 1
|
|
293
|
+
return n
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def is_wsl():
|
|
297
|
+
try:
|
|
298
|
+
return "microsoft" in open("/proc/version").read().lower()
|
|
299
|
+
except OSError:
|
|
300
|
+
return False
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
HOST = "macos" if sys.platform == "darwin" else ("windows" if os.name == "nt" else ("wsl" if is_wsl() else "linux"))
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def winpath(p):
|
|
307
|
+
return subprocess.run(["wslpath", "-w", p], capture_output=True, text=True).stdout.strip() if HOST == "wsl" else p
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def read_text(p):
|
|
311
|
+
try:
|
|
312
|
+
b = open(p, "rb").read()
|
|
313
|
+
except OSError:
|
|
314
|
+
return ""
|
|
315
|
+
if b[:2] in (b"\xff\xfe", b"\xfe\xff"):
|
|
316
|
+
return b.decode("utf-16")
|
|
317
|
+
return b.decode("utf-8-sig", errors="replace")
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def parse_kv_file(p):
|
|
321
|
+
"""GITHUB_OUTPUT / GITHUB_ENV: name=value lines and name<<DELIM heredocs."""
|
|
322
|
+
out, lines, i = {}, read_text(p).replace("\r\n", "\n").split("\n"), 0
|
|
323
|
+
while i < len(lines):
|
|
324
|
+
ln = lines[i]; i += 1
|
|
325
|
+
m = re.match(r"^([^=<\s]+)<<(\S+)$", ln)
|
|
326
|
+
if m:
|
|
327
|
+
buf = []
|
|
328
|
+
while i < len(lines) and lines[i] != m.group(2):
|
|
329
|
+
buf.append(lines[i]); i += 1
|
|
330
|
+
i += 1; out[m.group(1)] = "\n".join(buf)
|
|
331
|
+
elif "=" in ln:
|
|
332
|
+
k, v = ln.split("=", 1); out[k.strip()] = v
|
|
333
|
+
return out
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
# ---- expressions ------------------------------------------------------------------------------
|
|
337
|
+
class NeedsSecret(Exception):
|
|
338
|
+
pass
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
class Unknown(Exception):
|
|
342
|
+
pass
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def evaluate(expr, ctx):
|
|
346
|
+
"""The small part of the ${{ }} language a run step or its `if:` usually needs."""
|
|
347
|
+
toks = re.findall(r"'(?:[^']|'')*'|&&|\|\||==|!=|!|\(|\)|[A-Za-z_][\w\-]*(?:\.[\w\-\*]+)*(?:\(\))?|-?\d+(?:\.\d+)?", expr)
|
|
348
|
+
if "".join(toks).replace(" ", "") != re.sub(r"\s+", "", expr):
|
|
349
|
+
raise Unknown(expr)
|
|
350
|
+
pos = [0]
|
|
351
|
+
|
|
352
|
+
def peek():
|
|
353
|
+
return toks[pos[0]] if pos[0] < len(toks) else None
|
|
354
|
+
|
|
355
|
+
def take():
|
|
356
|
+
t = toks[pos[0]]; pos[0] += 1; return t
|
|
357
|
+
|
|
358
|
+
def atom():
|
|
359
|
+
t = take()
|
|
360
|
+
if t == "(":
|
|
361
|
+
v = orx()
|
|
362
|
+
if take() != ")":
|
|
363
|
+
raise Unknown(expr)
|
|
364
|
+
return v
|
|
365
|
+
if t == "!":
|
|
366
|
+
return not truthy(atom())
|
|
367
|
+
if t.startswith("'"):
|
|
368
|
+
return t[1:-1].replace("''", "'")
|
|
369
|
+
if re.match(r"^-?\d", t):
|
|
370
|
+
return float(t)
|
|
371
|
+
if t in ("true", "false"):
|
|
372
|
+
return t == "true"
|
|
373
|
+
if t == "null":
|
|
374
|
+
return None
|
|
375
|
+
if t in ("always()", "success()"):
|
|
376
|
+
return True
|
|
377
|
+
if t in ("failure()", "cancelled()"):
|
|
378
|
+
return False
|
|
379
|
+
return lookup(t, ctx)
|
|
380
|
+
|
|
381
|
+
def cmp():
|
|
382
|
+
v = atom()
|
|
383
|
+
while peek() in ("==", "!="):
|
|
384
|
+
op, r = take(), atom()
|
|
385
|
+
eq = str(v).lower() == str(r).lower() if not (isinstance(v, float) and isinstance(r, float)) else v == r
|
|
386
|
+
v = eq if op == "==" else not eq
|
|
387
|
+
return v
|
|
388
|
+
|
|
389
|
+
def andx():
|
|
390
|
+
v = cmp()
|
|
391
|
+
while peek() == "&&":
|
|
392
|
+
take(); r = cmp(); v = r if truthy(v) else v
|
|
393
|
+
return v
|
|
394
|
+
|
|
395
|
+
def orx():
|
|
396
|
+
v = andx()
|
|
397
|
+
while peek() == "||":
|
|
398
|
+
take(); r = andx(); v = v if truthy(v) else r
|
|
399
|
+
return v
|
|
400
|
+
|
|
401
|
+
v = orx()
|
|
402
|
+
if pos[0] != len(toks):
|
|
403
|
+
raise Unknown(expr)
|
|
404
|
+
return v
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def truthy(v):
|
|
408
|
+
return v not in (None, False, "", 0, 0.0)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def lookup(name, ctx):
|
|
412
|
+
parts = name.split(".")
|
|
413
|
+
if parts[0] == "secrets":
|
|
414
|
+
raise NeedsSecret(name)
|
|
415
|
+
if parts[0] == "steps" and len(parts) == 4 and parts[2] == "outputs":
|
|
416
|
+
return ctx["outputs"].get(parts[1], {}).get(parts[3], "")
|
|
417
|
+
if parts[0] == "steps" and len(parts) == 3 and parts[2] in ("outcome", "conclusion"):
|
|
418
|
+
return ctx["outcome"].get(parts[1], "")
|
|
419
|
+
if parts[0] == "env" and len(parts) == 2:
|
|
420
|
+
return ctx["env"].get(parts[1], "")
|
|
421
|
+
if parts[0] == "matrix" and len(parts) == 2 and parts[1] in ctx["matrix"]:
|
|
422
|
+
return ctx["matrix"][parts[1]]
|
|
423
|
+
fixed = {"runner.os": ctx["runner_os"], "runner.temp": ctx["temp_shell"], "github.workspace": ctx["work_shell"],
|
|
424
|
+
"github.event_name": "workflow_dispatch", "github.run_number": "1", "github.run_id": "0"}
|
|
425
|
+
if name in fixed:
|
|
426
|
+
return fixed[name]
|
|
427
|
+
raise Unknown(name)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def expand(text, ctx):
|
|
431
|
+
def one(m):
|
|
432
|
+
v = evaluate(m.group(1), ctx)
|
|
433
|
+
if isinstance(v, bool):
|
|
434
|
+
return "true" if v else "false"
|
|
435
|
+
if isinstance(v, float) and v.is_integer():
|
|
436
|
+
return str(int(v))
|
|
437
|
+
return "" if v is None else str(v)
|
|
438
|
+
return re.sub(r"\$\{\{\s*(.*?)\s*\}\}", one, str(text), flags=re.S)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def secrets_in(obj):
|
|
442
|
+
return sorted(set(re.findall(r"secrets\.([A-Za-z_]\w*)", json.dumps(obj, default=str))))
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
# ---- main -------------------------------------------------------------------------------------
|
|
446
|
+
def pick_workflow(arg):
|
|
447
|
+
if arg:
|
|
448
|
+
return arg if os.path.isfile(arg) else None
|
|
449
|
+
found = sorted(os.path.join(".github/workflows", f) for f in os.listdir(".github/workflows")
|
|
450
|
+
if f.endswith((".yml", ".yaml"))) if os.path.isdir(".github/workflows") else []
|
|
451
|
+
if len(found) == 1:
|
|
452
|
+
return found[0]
|
|
453
|
+
_notrun("no workflow under .github/workflows; name one" if not found else
|
|
454
|
+
"%d workflows here (%s); name one" % (len(found), ", ".join(found)))
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _notrun(msg):
|
|
458
|
+
say("NOT-RUN " + msg)
|
|
459
|
+
sys.exit(2)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def runner_os(runs_on, matrix):
|
|
463
|
+
s = json.dumps(runs_on).lower()
|
|
464
|
+
if "matrix." in s:
|
|
465
|
+
for v in matrix.values():
|
|
466
|
+
s += " " + str(v).lower()
|
|
467
|
+
return "Windows" if "windows" in s else ("macOS" if "macos" in s else "Linux")
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def work_root():
|
|
471
|
+
base = os.environ.get("TF_CI_REPRO_DIR")
|
|
472
|
+
if not base and HOST == "wsl":
|
|
473
|
+
t = subprocess.run(["cmd.exe", "/c", "echo %TEMP%"], capture_output=True, text=True, cwd="/mnt/c").stdout.strip()
|
|
474
|
+
base = subprocess.run(["wslpath", "-u", t], capture_output=True, text=True).stdout.strip() if t else ""
|
|
475
|
+
if not base:
|
|
476
|
+
base = os.environ.get("TMPDIR") or "/tmp"
|
|
477
|
+
return os.path.join(base, "tf-ci-repro", "%s-%d" % (os.path.basename(os.getcwd()), os.getpid()))
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def main():
|
|
481
|
+
a = sys.argv[1:]
|
|
482
|
+
opt = {"workflow": None, "job": None, "list": False, "keep": False, "setup": False}
|
|
483
|
+
while a:
|
|
484
|
+
x = a.pop(0)
|
|
485
|
+
if x == "--job": opt["job"] = a.pop(0) if a else None
|
|
486
|
+
elif x == "--list": opt["list"] = True
|
|
487
|
+
elif x == "--keep": opt["keep"] = True
|
|
488
|
+
elif x == "--run-setup": opt["setup"] = True
|
|
489
|
+
elif x.startswith("-"): _notrun("unknown option %s" % x)
|
|
490
|
+
else: opt["workflow"] = x
|
|
491
|
+
wf_path = pick_workflow(opt["workflow"])
|
|
492
|
+
if not wf_path:
|
|
493
|
+
_notrun("no workflow file %s" % opt["workflow"])
|
|
494
|
+
try:
|
|
495
|
+
wf = load_workflow(wf_path) or {}
|
|
496
|
+
except ValueError as e:
|
|
497
|
+
_notrun("could not read %s without PyYAML (%s); python3 -m pip install pyyaml" % (wf_path, e))
|
|
498
|
+
jobs = wf.get("jobs") or {}
|
|
499
|
+
if not jobs:
|
|
500
|
+
_notrun("%s has no jobs" % wf_path)
|
|
501
|
+
if opt["job"] and opt["job"] not in jobs:
|
|
502
|
+
_notrun("no job %s in %s (jobs: %s)" % (opt["job"], wf_path, ", ".join(jobs)))
|
|
503
|
+
if not opt["job"] and len(jobs) > 1:
|
|
504
|
+
_notrun("%s has %d jobs (%s); name one with --job" % (wf_path, len(jobs), ", ".join(jobs)))
|
|
505
|
+
jid = opt["job"] or next(iter(jobs))
|
|
506
|
+
job = jobs[jid] or {}
|
|
507
|
+
if "uses" in job:
|
|
508
|
+
_notrun("job %s calls another workflow (%s); run that one" % (jid, job["uses"]))
|
|
509
|
+
mx = ((job.get("strategy") or {}).get("matrix") or {})
|
|
510
|
+
matrix = {k: (v[0] if isinstance(v, list) and v else v) for k, v in mx.items() if k not in ("include", "exclude")}
|
|
511
|
+
if not matrix and isinstance(mx.get("include"), list) and mx["include"]:
|
|
512
|
+
matrix = dict(mx["include"][0])
|
|
513
|
+
ros = runner_os(job.get("runs-on", ""), matrix)
|
|
514
|
+
if ros == "macOS" and HOST != "macos":
|
|
515
|
+
_notrun("job %s runs on macOS and this is %s; run it on a Mac" % (jid, HOST))
|
|
516
|
+
if ros == "Windows" and HOST not in ("windows", "wsl"):
|
|
517
|
+
_notrun("job %s runs on Windows and this is %s; run it on Windows or WSL" % (jid, HOST))
|
|
518
|
+
win = ros == "Windows" and HOST == "wsl" # the steps run on the Windows side, called from WSL
|
|
519
|
+
defaults = ((wf.get("defaults") or {}).get("run") or {}).copy()
|
|
520
|
+
defaults.update(((job.get("defaults") or {}).get("run") or {}))
|
|
521
|
+
steps = job.get("steps") or []
|
|
522
|
+
|
|
523
|
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
524
|
+
logdir = os.path.join("tests/.artifacts/ci-repro", stamp)
|
|
525
|
+
root = work_root()
|
|
526
|
+
work, cache, temp = os.path.join(root, "work"), os.path.join(root, "caches"), os.path.join(root, "tmp")
|
|
527
|
+
ctx = {"outputs": {}, "outcome": {}, "env": {}, "matrix": matrix, "runner_os": ros,
|
|
528
|
+
"temp_shell": winpath(temp) if win else temp, "work_shell": winpath(work) if win else work}
|
|
529
|
+
|
|
530
|
+
plan = []
|
|
531
|
+
for n, st in enumerate(steps, 1):
|
|
532
|
+
st = st or {}
|
|
533
|
+
name = st.get("name") or (("Run " + str(st["run"]).strip().splitlines()[0]) if st.get("run") else st.get("uses", "step %d" % n))
|
|
534
|
+
if "uses" in st:
|
|
535
|
+
u = str(st["uses"])
|
|
536
|
+
why = ("the copy stands in for the checkout" if u.startswith("actions/checkout") else
|
|
537
|
+
"left out on purpose: the caches start empty" if "/cache" in u else
|
|
538
|
+
"the tools installed here stand in" if "/setup-" in u else "an action is not run here")
|
|
539
|
+
plan.append((n, name, st, "skip", "uses %s — %s" % (u, why)))
|
|
540
|
+
continue
|
|
541
|
+
body = str(st.get("run") or "")
|
|
542
|
+
lines = [l for l in body.splitlines() if l.strip() and not l.strip().startswith("#")]
|
|
543
|
+
sec = secrets_in({"run": body, "env": st.get("env"), "if": st.get("if")})
|
|
544
|
+
if sec:
|
|
545
|
+
plan.append((n, name, st, "skip", "needs secrets.%s; this machine's own credentials stand in" % ", secrets.".join(sec)))
|
|
546
|
+
elif lines and all(SETUP_RX.match(l) or l.rstrip().endswith(("`", "\\")) for l in lines) and SETUP_RX.match(lines[0]) and not opt["setup"]:
|
|
547
|
+
plan.append((n, name, st, "skip", "sets up the machine; what is installed here stands in (--run-setup runs it)"))
|
|
548
|
+
else:
|
|
549
|
+
plan.append((n, name, st, "run", ""))
|
|
550
|
+
caches = sorted({w for _, _, w in CACHES if w})
|
|
551
|
+
if opt["list"]:
|
|
552
|
+
say("workflow %s, job %s (runs on %s; here: %s)" % (wf_path, jid, ros, HOST))
|
|
553
|
+
for n, name, st, what, why in plan:
|
|
554
|
+
say(" %-4s %2d %s%s" % (what, n, name, (" — " + why) if why else ""))
|
|
555
|
+
say("empty caches: %s; the copy leaves out everything .gitignore ignores" % ", ".join(caches))
|
|
556
|
+
return 0
|
|
557
|
+
|
|
558
|
+
os.makedirs(logdir, exist_ok=True)
|
|
559
|
+
shutil.rmtree(root, ignore_errors=True)
|
|
560
|
+
for d in (work, temp):
|
|
561
|
+
os.makedirs(d, exist_ok=True)
|
|
562
|
+
say("copying the repository to %s (without what .gitignore ignores) …" % work)
|
|
563
|
+
try:
|
|
564
|
+
clean_copy(".", work)
|
|
565
|
+
except OSError as e:
|
|
566
|
+
_notrun("could not copy the repository: %s" % e)
|
|
567
|
+
|
|
568
|
+
env = dict(os.environ)
|
|
569
|
+
wslenv = [x for x in env.get("WSLENV", "").split(":") if x]
|
|
570
|
+
pathvars = []
|
|
571
|
+
for var, folder, _ in CACHES:
|
|
572
|
+
os.makedirs(os.path.join(cache, folder), exist_ok=True)
|
|
573
|
+
env[var] = os.path.join(cache, folder); pathvars.append(var)
|
|
574
|
+
env["MAVEN_OPTS"] = (env.get("MAVEN_OPTS", "") + " -Dmaven.repo.local=" + (winpath(os.path.join(cache, "m2")) if win else os.path.join(cache, "m2"))).strip()
|
|
575
|
+
env.update({"CI": "true", "GITHUB_WORKSPACE": work, "RUNNER_TEMP": temp, "RUNNER_OS": ros})
|
|
576
|
+
pathvars += ["GITHUB_WORKSPACE", "RUNNER_TEMP"]
|
|
577
|
+
plain = ["CI", "RUNNER_OS", "MAVEN_OPTS"]
|
|
578
|
+
for src in (wf.get("env") or {}, job.get("env") or {}):
|
|
579
|
+
for k, v in src.items():
|
|
580
|
+
try:
|
|
581
|
+
ctx["env"][k] = expand(v, ctx)
|
|
582
|
+
except (NeedsSecret, Unknown):
|
|
583
|
+
ctx["env"][k] = ""
|
|
584
|
+
job_timeout = float(job.get("timeout-minutes") or 360) * 60
|
|
585
|
+
t_job = time.time()
|
|
586
|
+
verdict, ran = None, 0
|
|
587
|
+
for n, name, st, what, why in plan:
|
|
588
|
+
if what == "skip":
|
|
589
|
+
say("skip %2d %s — %s" % (n, name, why)); continue
|
|
590
|
+
sid = st.get("id") or "_%d" % n
|
|
591
|
+
if "if" in st:
|
|
592
|
+
cond = str(st["if"]).strip()
|
|
593
|
+
cond = re.sub(r"^\$\{\{\s*(.*?)\s*\}\}$", r"\1", cond, flags=re.S)
|
|
594
|
+
try:
|
|
595
|
+
if not truthy(evaluate(cond, ctx)):
|
|
596
|
+
say("skip %2d %s — its condition is false here (%s)" % (n, name, cond)); ctx["outcome"][sid] = "skipped"; continue
|
|
597
|
+
except NeedsSecret:
|
|
598
|
+
say("skip %2d %s — its condition reads a secret" % (n, name)); continue
|
|
599
|
+
except Unknown:
|
|
600
|
+
say("note %2d %s — condition %r not understood here; the step runs" % (n, name, cond))
|
|
601
|
+
try:
|
|
602
|
+
body = expand(st.get("run", ""), ctx)
|
|
603
|
+
senv = {k: expand(v, ctx) for k, v in (st.get("env") or {}).items()}
|
|
604
|
+
except Unknown as e:
|
|
605
|
+
say("skip %2d %s — uses ${{ %s }}, which this script cannot know here" % (n, name, e)); continue
|
|
606
|
+
files = {k: os.path.join(temp, "%s_%d" % (k.lower(), n)) for k in ("GITHUB_OUTPUT", "GITHUB_ENV", "GITHUB_PATH", "GITHUB_STEP_SUMMARY")}
|
|
607
|
+
for p in files.values():
|
|
608
|
+
open(p, "w").close()
|
|
609
|
+
senv_all = dict(env); senv_all.update(ctx["env"]); senv_all.update(senv); senv_all.update(files)
|
|
610
|
+
names = plain + list(ctx["env"]) + list(senv)
|
|
611
|
+
if win:
|
|
612
|
+
senv_all["WSLENV"] = ":".join(wslenv + [v + "/p" for v in pathvars + list(files)] + names)
|
|
613
|
+
shell = str(st.get("shell") or defaults.get("shell") or ("pwsh" if ros == "Windows" else "bash"))
|
|
614
|
+
wd = os.path.normpath(os.path.join(work, expand(st.get("working-directory") or defaults.get("working-directory") or ".", ctx)))
|
|
615
|
+
sp = os.path.join(temp, "step_%d" % n)
|
|
616
|
+
if shell in ("pwsh", "powershell"):
|
|
617
|
+
sp += ".ps1"
|
|
618
|
+
open(sp, "w", encoding="utf-8-sig").write("$ErrorActionPreference = 'stop'\n%s\nif ((Test-Path -LiteralPath variable:\\LASTEXITCODE)) { exit $LASTEXITCODE }\n" % body)
|
|
619
|
+
exe = (shell + ".exe") if win else shell
|
|
620
|
+
cmd = [exe, "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", winpath(sp) if win else sp]
|
|
621
|
+
elif shell == "cmd":
|
|
622
|
+
sp += ".cmd"
|
|
623
|
+
open(sp, "w", newline="\r\n").write("@echo off\n" + body + "\n")
|
|
624
|
+
cmd = ["cmd.exe", "/D", "/E:ON", "/V:OFF", "/S", "/C", "CALL " + (winpath(sp) if win else sp)]
|
|
625
|
+
elif shell == "python":
|
|
626
|
+
sp += ".py"; open(sp, "w").write(body); cmd = ["python3", sp]
|
|
627
|
+
elif shell.startswith(("bash", "sh")):
|
|
628
|
+
sp += ".sh"; open(sp, "w", newline="\n").write(body + "\n")
|
|
629
|
+
cmd = ["bash", "--noprofile", "--norc", "-eo", "pipefail", sp] if shell.startswith("bash") else ["sh", "-e", sp]
|
|
630
|
+
else:
|
|
631
|
+
say("skip %2d %s — shell %r is not supported here" % (n, name, shell)); continue
|
|
632
|
+
if win and shell in ("bash", "sh"):
|
|
633
|
+
say("note %2d %s — a bash step of a Windows job runs in WSL bash here, not Git Bash" % (n, name))
|
|
634
|
+
log = os.path.join(logdir, "step-%02d.log" % n)
|
|
635
|
+
t0 = time.time()
|
|
636
|
+
left = job_timeout - (t0 - t_job)
|
|
637
|
+
tmo = min(left, float(st.get("timeout-minutes") or 1e9) * 60)
|
|
638
|
+
say("run %2d %s …" % (n, name))
|
|
639
|
+
with open(log, "w", encoding="utf-8") as lf:
|
|
640
|
+
try:
|
|
641
|
+
rc = subprocess.run(cmd, cwd=wd, env=senv_all, stdout=lf, stderr=subprocess.STDOUT, timeout=max(tmo, 1)).returncode
|
|
642
|
+
except subprocess.TimeoutExpired:
|
|
643
|
+
rc = 124
|
|
644
|
+
except OSError as e:
|
|
645
|
+
lf.write(str(e)); rc = 127
|
|
646
|
+
secs = int(time.time() - t0)
|
|
647
|
+
ctx["outputs"][sid] = parse_kv_file(files["GITHUB_OUTPUT"])
|
|
648
|
+
ctx["env"].update(parse_kv_file(files["GITHUB_ENV"]))
|
|
649
|
+
ran += 1
|
|
650
|
+
if rc == 0:
|
|
651
|
+
ctx["outcome"][sid] = "success"
|
|
652
|
+
say("ok %2d %s (%d s)" % (n, name, secs)); continue
|
|
653
|
+
ctx["outcome"][sid] = "failure"
|
|
654
|
+
text = read_text(log)
|
|
655
|
+
first = next((l.strip() for l in text.splitlines() if re.search(r"\berror\b|\bERR!|Error:|FAILED|fatal:", l, re.I)), "")
|
|
656
|
+
if not first:
|
|
657
|
+
first = (text.strip().splitlines() or ["(no output)"])[-1].strip()
|
|
658
|
+
first = re.sub(r"\s+", " ", first)[:300]
|
|
659
|
+
if rc == 124:
|
|
660
|
+
first = "timed out after %d min" % (tmo // 60)
|
|
661
|
+
if st.get("continue-on-error") is True:
|
|
662
|
+
say("FAIL %2d %s (%d s, exit %d; continue-on-error) — %s" % (n, name, secs, rc, first)); continue
|
|
663
|
+
say("FAIL %2d %s (%d s, exit %d) — %s" % (n, name, secs, rc, first))
|
|
664
|
+
verdict = ("FAIL job %s: step %d \"%s\" failed (exit %d) with empty caches — %s — log %s"
|
|
665
|
+
% (jid, n, name, rc, first, log))
|
|
666
|
+
break
|
|
667
|
+
if not opt["keep"]:
|
|
668
|
+
shutil.rmtree(root, ignore_errors=True)
|
|
669
|
+
try:
|
|
670
|
+
os.rmdir(os.path.dirname(root)) # tf-ci-repro/, when no other run is using it
|
|
671
|
+
except OSError:
|
|
672
|
+
pass
|
|
673
|
+
else:
|
|
674
|
+
say("kept the copy and its caches at %s" % root)
|
|
675
|
+
if verdict:
|
|
676
|
+
say(verdict); return 1
|
|
677
|
+
if ran == 0:
|
|
678
|
+
_notrun("job %s has no step this script could run" % jid)
|
|
679
|
+
say("PASS job %s: %d run step(s) passed on a clean copy with empty caches (%s) — logs %s"
|
|
680
|
+
% (jid, ran, ", ".join(caches), logdir))
|
|
681
|
+
return 0
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
if __name__ == "__main__":
|
|
685
|
+
sys.exit(main())
|