backstitch 0.1.0__tar.gz
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.
- backstitch-0.1.0/.github/scripts/require_green_workflows.py +336 -0
- backstitch-0.1.0/.github/workflows/ci.yml +86 -0
- backstitch-0.1.0/.github/workflows/release-gate.yml +178 -0
- backstitch-0.1.0/.gitignore +232 -0
- backstitch-0.1.0/AGENTS.md +119 -0
- backstitch-0.1.0/CLAUDE.md +1 -0
- backstitch-0.1.0/LICENSE +21 -0
- backstitch-0.1.0/PKG-INFO +104 -0
- backstitch-0.1.0/README.md +76 -0
- backstitch-0.1.0/backstitch/__init__.py +7 -0
- backstitch-0.1.0/backstitch/__main__.py +8 -0
- backstitch-0.1.0/backstitch/analysis_llm.py +214 -0
- backstitch-0.1.0/backstitch/analysis_packets.py +202 -0
- backstitch-0.1.0/backstitch/analysis_results.py +248 -0
- backstitch-0.1.0/backstitch/cli.py +873 -0
- backstitch-0.1.0/backstitch/config.py +51 -0
- backstitch-0.1.0/backstitch/exclusions.py +526 -0
- backstitch-0.1.0/backstitch/grammar.py +49 -0
- backstitch-0.1.0/backstitch/markdown_specs.py +345 -0
- backstitch-0.1.0/backstitch/models.py +183 -0
- backstitch-0.1.0/backstitch/profiles.py +34 -0
- backstitch-0.1.0/backstitch/prompts/backstitch_style_analysis.md +36 -0
- backstitch-0.1.0/backstitch/py.typed +1 -0
- backstitch-0.1.0/backstitch/python_refs.py +380 -0
- backstitch-0.1.0/backstitch/reporting.py +90 -0
- backstitch-0.1.0/backstitch/resolver.py +926 -0
- backstitch-0.1.0/backstitch/settings.py +620 -0
- backstitch-0.1.0/backstitch/target_roots.py +88 -0
- backstitch-0.1.0/bin/release.py +997 -0
- backstitch-0.1.0/docs/README.md +90 -0
- backstitch-0.1.0/docs/agent-context/README.md +61 -0
- backstitch-0.1.0/docs/agent-context/context.index.yaml +41 -0
- backstitch-0.1.0/docs/agent-context/decision-hierarchy.md +84 -0
- backstitch-0.1.0/docs/agent-context/engineering-principles.md +179 -0
- backstitch-0.1.0/docs/agent-context/lessons.md +13 -0
- backstitch-0.1.0/docs/agent-context/principles.md +82 -0
- backstitch-0.1.0/docs/agent-context/runbooks/adversarial-acceptance-probes.md +102 -0
- backstitch-0.1.0/docs/agent-context/runbooks/hardening-plans.md +292 -0
- backstitch-0.1.0/docs/agent-context/runbooks/maintaining-traceability.md +81 -0
- backstitch-0.1.0/docs/agent-context/runbooks/review-loops-and-agent-bootstrap.md +118 -0
- backstitch-0.1.0/docs/agent-context/runbooks/skills-lifecycle.md +88 -0
- backstitch-0.1.0/docs/agent-context/runbooks/testing-patterns.md +131 -0
- backstitch-0.1.0/docs/agent-context/runbooks/writing-implementation-docs.md +99 -0
- backstitch-0.1.0/docs/agent-context/runbooks/writing-plans.md +643 -0
- backstitch-0.1.0/docs/agent-context/runbooks/writing-specs.md +194 -0
- backstitch-0.1.0/docs/implementation/00-implementation-index.md +23 -0
- backstitch-0.1.0/docs/implementation/01-documentation-system.md +91 -0
- backstitch-0.1.0/docs/implementation/02-repository-map.md +93 -0
- backstitch-0.1.0/docs/implementation/03-agent-inventory.md +56 -0
- backstitch-0.1.0/docs/implementation/04-backstitch-style-traceability.md +178 -0
- backstitch-0.1.0/docs/implementation/05-release-publishing.md +110 -0
- backstitch-0.1.0/docs/implementation/README.md +8 -0
- backstitch-0.1.0/docs/lessons.md +126 -0
- backstitch-0.1.0/docs/plans/2026-06-18-backstitch-style-spec-code-traceability-tool-plan.md +1006 -0
- backstitch-0.1.0/docs/plans/2026-07-01-backstitch-toml-configuration-plan.md +350 -0
- backstitch-0.1.0/docs/plans/2026-07-02-backstitch-four-way-reconciliation-plan.md +1123 -0
- backstitch-0.1.0/docs/plans/2026-07-02-backstitch-traceability-exclusions-plan.md +262 -0
- backstitch-0.1.0/docs/plans/2026-07-03-backstitch-release-publishing-plan.md +283 -0
- backstitch-0.1.0/docs/plans/2026-07-03-input-validation-invariants-plan.md +344 -0
- backstitch-0.1.0/docs/plans/2026-07-03-live-llm-tests-plan.md +663 -0
- backstitch-0.1.0/docs/plans/2026-07-03-local-llm-eval-lane-plan.md +589 -0
- backstitch-0.1.0/docs/plans/README.md +43 -0
- backstitch-0.1.0/docs/specs/00-specs-index.md +39 -0
- backstitch-0.1.0/docs/specs/01-development-documentation-operating-model.md +289 -0
- backstitch-0.1.0/docs/specs/02-backstitch-core.md +737 -0
- backstitch-0.1.0/docs/specs/03-backstitch-configuration.md +519 -0
- backstitch-0.1.0/docs/specs/04-backstitch-traceability-exclusions.md +370 -0
- backstitch-0.1.0/docs/specs/05-backstitch-invariants.md +380 -0
- backstitch-0.1.0/docs/specs/README.md +4 -0
- backstitch-0.1.0/pyproject.toml +115 -0
- backstitch-0.1.0/skills/README.md +40 -0
- backstitch-0.1.0/skills/_template/SKILL.md +41 -0
- backstitch-0.1.0/tests/__init__.py +0 -0
- backstitch-0.1.0/tests/acceptance/README.md +11 -0
- backstitch-0.1.0/tests/acceptance/__init__.py +0 -0
- backstitch-0.1.0/tests/acceptance/conftest.py +62 -0
- backstitch-0.1.0/tests/acceptance/test_probe_analysis.py +64 -0
- backstitch-0.1.0/tests/acceptance/test_probe_boundaries.py +115 -0
- backstitch-0.1.0/tests/acceptance/test_probe_committed_config.py +22 -0
- backstitch-0.1.0/tests/acceptance/test_probe_config.py +49 -0
- backstitch-0.1.0/tests/acceptance/test_probe_parsing.py +67 -0
- backstitch-0.1.0/tests/acceptance/test_probe_selfacceptance.py +87 -0
- backstitch-0.1.0/tests/conftest.py +23 -0
- backstitch-0.1.0/tests/fixtures/clean_project/docs/specs/01-Clean.md +10 -0
- backstitch-0.1.0/tests/fixtures/clean_project/pkg/mod.py +9 -0
- backstitch-0.1.0/tests/fixtures/config_project/.backstitch.toml +13 -0
- backstitch-0.1.0/tests/fixtures/noqa_scope_project/docs/specs/01-n.md +5 -0
- backstitch-0.1.0/tests/fixtures/noqa_scope_project/pkg/mod.py +10 -0
- backstitch-0.1.0/tests/fixtures/noqa_scope_project/pkg/moddoc.py +11 -0
- backstitch-0.1.0/tests/fixtures/path_ladder_project/docs/specs/01-Ladder.md +26 -0
- backstitch-0.1.0/tests/fixtures/path_ladder_project/pkg/inner/shared.py +1 -0
- backstitch-0.1.0/tests/fixtures/path_ladder_project/pkg/inner/unique.py +1 -0
- backstitch-0.1.0/tests/fixtures/path_ladder_project/pkg/other/shared.py +1 -0
- backstitch-0.1.0/tests/fixtures/traceability_project/docs/specifications/01-Core.md +34 -0
- backstitch-0.1.0/tests/fixtures/traceability_project/docs/specifications/01A-Core_Planned.md +7 -0
- backstitch-0.1.0/tests/fixtures/traceability_project/docs/specifications/02-Weft_Style.md +46 -0
- backstitch-0.1.0/tests/fixtures/traceability_project/src/ranges.py +47 -0
- backstitch-0.1.0/tests/fixtures/traceability_project/src/runtime.py +43 -0
- backstitch-0.1.0/tests/fixtures/traceability_project/tests/test_runtime.py +11 -0
- backstitch-0.1.0/tests/fixtures/traceability_project.expected.json +1005 -0
- backstitch-0.1.0/tests/live/test_live_llm.py +223 -0
- backstitch-0.1.0/tests/test_analysis_llm.py +245 -0
- backstitch-0.1.0/tests/test_analysis_packets.py +255 -0
- backstitch-0.1.0/tests/test_analysis_results.py +162 -0
- backstitch-0.1.0/tests/test_backstitch_corpus_traceability.py +68 -0
- backstitch-0.1.0/tests/test_behavior_freeze.py +82 -0
- backstitch-0.1.0/tests/test_bootstrap.py +13 -0
- backstitch-0.1.0/tests/test_cli.py +91 -0
- backstitch-0.1.0/tests/test_cli_config.py +83 -0
- backstitch-0.1.0/tests/test_config.py +53 -0
- backstitch-0.1.0/tests/test_exclusions.py +175 -0
- backstitch-0.1.0/tests/test_grammar.py +36 -0
- backstitch-0.1.0/tests/test_issue_code_coverage.py +137 -0
- backstitch-0.1.0/tests/test_markdown_specs.py +217 -0
- backstitch-0.1.0/tests/test_models.py +81 -0
- backstitch-0.1.0/tests/test_python_noqa.py +73 -0
- backstitch-0.1.0/tests/test_python_refs.py +142 -0
- backstitch-0.1.0/tests/test_release_script.py +303 -0
- backstitch-0.1.0/tests/test_release_workflow.py +64 -0
- backstitch-0.1.0/tests/test_release_workflow_gate.py +110 -0
- backstitch-0.1.0/tests/test_reporting.py +57 -0
- backstitch-0.1.0/tests/test_resolver.py +440 -0
- backstitch-0.1.0/tests/test_resolver_ladder.py +79 -0
- backstitch-0.1.0/tests/test_review_remediation.py +2088 -0
- backstitch-0.1.0/tests/test_settings.py +429 -0
- backstitch-0.1.0/tests/test_target_roots.py +117 -0
- backstitch-0.1.0/tests/test_weft_corpus_traceability.py +91 -0
- backstitch-0.1.0/uv.lock +755 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Require workflow runs for the current SHA to be green before release."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.parse
|
|
13
|
+
import urllib.request
|
|
14
|
+
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
SUCCESS_CONCLUSION = "success"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class WorkflowRun:
|
|
22
|
+
"""The workflow-run fields the release gate cares about."""
|
|
23
|
+
|
|
24
|
+
id: int
|
|
25
|
+
name: str
|
|
26
|
+
status: str
|
|
27
|
+
conclusion: str | None
|
|
28
|
+
url: str
|
|
29
|
+
created_at: str
|
|
30
|
+
run_attempt: int
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def from_api(cls, raw: Mapping[str, object]) -> WorkflowRun:
|
|
34
|
+
return cls(
|
|
35
|
+
id=_int_value(raw.get("id")),
|
|
36
|
+
name=str(raw.get("name") or ""),
|
|
37
|
+
status=str(raw.get("status") or ""),
|
|
38
|
+
conclusion=_optional_str(raw.get("conclusion")),
|
|
39
|
+
url=str(raw.get("html_url") or ""),
|
|
40
|
+
created_at=str(raw.get("created_at") or ""),
|
|
41
|
+
run_attempt=_int_value(raw.get("run_attempt"), default=1),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class GateCheck:
|
|
47
|
+
"""Result of evaluating the required workflow runs for one poll."""
|
|
48
|
+
|
|
49
|
+
passed: tuple[WorkflowRun, ...]
|
|
50
|
+
missing: tuple[str, ...]
|
|
51
|
+
pending: tuple[WorkflowRun, ...]
|
|
52
|
+
failed: tuple[WorkflowRun, ...]
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def ready(self) -> bool:
|
|
56
|
+
return not self.missing and not self.pending and not self.failed
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _optional_str(value: object) -> str | None:
|
|
60
|
+
return None if value is None else str(value)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _int_value(value: object, *, default: int = 0) -> int:
|
|
64
|
+
try:
|
|
65
|
+
return int(value) # type: ignore[arg-type]
|
|
66
|
+
except TypeError, ValueError:
|
|
67
|
+
return default
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def latest_runs_by_name(
|
|
71
|
+
runs: Iterable[WorkflowRun],
|
|
72
|
+
*,
|
|
73
|
+
exclude_run_id: int | str | None = None,
|
|
74
|
+
) -> dict[str, WorkflowRun]:
|
|
75
|
+
"""Return the latest observed run for each workflow name."""
|
|
76
|
+
|
|
77
|
+
excluded = str(exclude_run_id) if exclude_run_id is not None else None
|
|
78
|
+
latest: dict[str, WorkflowRun] = {}
|
|
79
|
+
latest_keys: dict[str, tuple[str, int, int]] = {}
|
|
80
|
+
|
|
81
|
+
for run in runs:
|
|
82
|
+
if excluded is not None and str(run.id) == excluded:
|
|
83
|
+
continue
|
|
84
|
+
if not run.name:
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
sort_key = (run.created_at, run.run_attempt, run.id)
|
|
88
|
+
if sort_key >= latest_keys.get(run.name, ("", 0, 0)):
|
|
89
|
+
latest[run.name] = run
|
|
90
|
+
latest_keys[run.name] = sort_key
|
|
91
|
+
|
|
92
|
+
return latest
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def evaluate_required_workflows(
|
|
96
|
+
runs: Iterable[WorkflowRun],
|
|
97
|
+
required_workflows: Sequence[str],
|
|
98
|
+
*,
|
|
99
|
+
exclude_run_id: int | str | None = None,
|
|
100
|
+
) -> GateCheck:
|
|
101
|
+
"""Check whether all required workflow runs completed successfully."""
|
|
102
|
+
|
|
103
|
+
latest = latest_runs_by_name(runs, exclude_run_id=exclude_run_id)
|
|
104
|
+
passed: list[WorkflowRun] = []
|
|
105
|
+
missing: list[str] = []
|
|
106
|
+
pending: list[WorkflowRun] = []
|
|
107
|
+
failed: list[WorkflowRun] = []
|
|
108
|
+
|
|
109
|
+
for name in required_workflows:
|
|
110
|
+
run = latest.get(name)
|
|
111
|
+
if run is None:
|
|
112
|
+
missing.append(name)
|
|
113
|
+
continue
|
|
114
|
+
if run.status != "completed":
|
|
115
|
+
pending.append(run)
|
|
116
|
+
continue
|
|
117
|
+
if run.conclusion != SUCCESS_CONCLUSION:
|
|
118
|
+
failed.append(run)
|
|
119
|
+
continue
|
|
120
|
+
passed.append(run)
|
|
121
|
+
|
|
122
|
+
return GateCheck(
|
|
123
|
+
passed=tuple(passed),
|
|
124
|
+
missing=tuple(missing),
|
|
125
|
+
pending=tuple(pending),
|
|
126
|
+
failed=tuple(failed),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def describe_gate_check(check: GateCheck) -> str:
|
|
131
|
+
"""Return a concise status line for logs."""
|
|
132
|
+
|
|
133
|
+
parts: list[str] = []
|
|
134
|
+
if check.passed:
|
|
135
|
+
parts.append(
|
|
136
|
+
"green: " + ", ".join(f"{run.name} ({run.url})" for run in check.passed)
|
|
137
|
+
)
|
|
138
|
+
if check.pending:
|
|
139
|
+
parts.append(
|
|
140
|
+
"pending: "
|
|
141
|
+
+ ", ".join(f"{run.name} [{run.status}]" for run in check.pending)
|
|
142
|
+
)
|
|
143
|
+
if check.missing:
|
|
144
|
+
parts.append("missing: " + ", ".join(check.missing))
|
|
145
|
+
if check.failed:
|
|
146
|
+
parts.append(
|
|
147
|
+
"failed: "
|
|
148
|
+
+ ", ".join(
|
|
149
|
+
f"{run.name} [{run.conclusion or run.status}] {run.url}"
|
|
150
|
+
for run in check.failed
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
return "; ".join(parts) if parts else "no required workflow runs found"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def fetch_workflow_runs(
|
|
157
|
+
*,
|
|
158
|
+
repo: str,
|
|
159
|
+
sha: str,
|
|
160
|
+
token: str,
|
|
161
|
+
api_url: str,
|
|
162
|
+
) -> tuple[WorkflowRun, ...]:
|
|
163
|
+
"""Fetch all GitHub Actions workflow runs for a commit SHA."""
|
|
164
|
+
|
|
165
|
+
runs: list[WorkflowRun] = []
|
|
166
|
+
page = 1
|
|
167
|
+
repo_path = urllib.parse.quote(repo, safe="/")
|
|
168
|
+
|
|
169
|
+
while True:
|
|
170
|
+
query = urllib.parse.urlencode(
|
|
171
|
+
{
|
|
172
|
+
"head_sha": sha,
|
|
173
|
+
"per_page": "100",
|
|
174
|
+
"page": str(page),
|
|
175
|
+
}
|
|
176
|
+
)
|
|
177
|
+
url = f"{api_url.rstrip('/')}/repos/{repo_path}/actions/runs?{query}"
|
|
178
|
+
payload = github_api_get(url, token=token)
|
|
179
|
+
raw_runs = payload.get("workflow_runs", [])
|
|
180
|
+
if not isinstance(raw_runs, list):
|
|
181
|
+
raise RuntimeError("GitHub API response did not include workflow_runs")
|
|
182
|
+
|
|
183
|
+
runs.extend(
|
|
184
|
+
WorkflowRun.from_api(run) for run in raw_runs if isinstance(run, Mapping)
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
if len(raw_runs) < 100:
|
|
188
|
+
return tuple(runs)
|
|
189
|
+
page += 1
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def github_api_get(url: str, *, token: str) -> dict[str, object]:
|
|
193
|
+
"""Return a JSON object from the GitHub API."""
|
|
194
|
+
|
|
195
|
+
request = urllib.request.Request(
|
|
196
|
+
url,
|
|
197
|
+
headers={
|
|
198
|
+
"Accept": "application/vnd.github+json",
|
|
199
|
+
"Authorization": f"Bearer {token}",
|
|
200
|
+
"User-Agent": "backstitch-release-gate",
|
|
201
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
202
|
+
},
|
|
203
|
+
)
|
|
204
|
+
try:
|
|
205
|
+
with urllib.request.urlopen(request, timeout=30) as response:
|
|
206
|
+
payload = json.load(response)
|
|
207
|
+
except urllib.error.HTTPError as exc:
|
|
208
|
+
detail = exc.read().decode("utf-8", errors="replace")
|
|
209
|
+
raise RuntimeError(f"GitHub API request failed: {exc.code} {detail}") from exc
|
|
210
|
+
except urllib.error.URLError as exc:
|
|
211
|
+
raise RuntimeError(f"GitHub API request failed: {exc}") from exc
|
|
212
|
+
|
|
213
|
+
if not isinstance(payload, dict):
|
|
214
|
+
raise RuntimeError("GitHub API response was not a JSON object")
|
|
215
|
+
return payload
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def wait_for_required_workflows(
|
|
219
|
+
*,
|
|
220
|
+
fetch_runs: Callable[[], tuple[WorkflowRun, ...]],
|
|
221
|
+
required_workflows: Sequence[str],
|
|
222
|
+
exclude_run_id: int | str | None,
|
|
223
|
+
timeout_seconds: int,
|
|
224
|
+
missing_timeout_seconds: int,
|
|
225
|
+
poll_interval_seconds: int,
|
|
226
|
+
) -> GateCheck:
|
|
227
|
+
"""Poll until required workflow runs are green or a failure is known."""
|
|
228
|
+
|
|
229
|
+
start = time.monotonic()
|
|
230
|
+
missing_since: float | None = None
|
|
231
|
+
last_check: GateCheck | None = None
|
|
232
|
+
|
|
233
|
+
while True:
|
|
234
|
+
check = evaluate_required_workflows(
|
|
235
|
+
fetch_runs(),
|
|
236
|
+
required_workflows,
|
|
237
|
+
exclude_run_id=exclude_run_id,
|
|
238
|
+
)
|
|
239
|
+
last_check = check
|
|
240
|
+
print(describe_gate_check(check), flush=True)
|
|
241
|
+
|
|
242
|
+
if check.ready:
|
|
243
|
+
return check
|
|
244
|
+
|
|
245
|
+
if check.failed:
|
|
246
|
+
raise RuntimeError(
|
|
247
|
+
"required workflow run failed; refusing to publish release"
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
now = time.monotonic()
|
|
251
|
+
if check.missing:
|
|
252
|
+
if missing_since is None:
|
|
253
|
+
missing_since = now
|
|
254
|
+
if now - missing_since >= missing_timeout_seconds:
|
|
255
|
+
raise RuntimeError(
|
|
256
|
+
"required workflow run was not found before the missing-run "
|
|
257
|
+
f"timeout: {', '.join(check.missing)}"
|
|
258
|
+
)
|
|
259
|
+
else:
|
|
260
|
+
missing_since = None
|
|
261
|
+
|
|
262
|
+
if last_check is not None and now - start >= timeout_seconds:
|
|
263
|
+
detail = describe_gate_check(last_check)
|
|
264
|
+
raise RuntimeError(
|
|
265
|
+
f"timed out waiting for required workflow runs to be green: {detail}"
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
time.sleep(poll_interval_seconds)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
272
|
+
parser = argparse.ArgumentParser(
|
|
273
|
+
description=(
|
|
274
|
+
"Wait until required GitHub Actions workflow runs for a commit SHA "
|
|
275
|
+
"have completed successfully."
|
|
276
|
+
)
|
|
277
|
+
)
|
|
278
|
+
parser.add_argument(
|
|
279
|
+
"--workflow",
|
|
280
|
+
action="append",
|
|
281
|
+
dest="required_workflows",
|
|
282
|
+
required=True,
|
|
283
|
+
help="Workflow name that must complete with conclusion=success.",
|
|
284
|
+
)
|
|
285
|
+
parser.add_argument("--repo", default=os.environ.get("GITHUB_REPOSITORY"))
|
|
286
|
+
parser.add_argument("--sha", default=os.environ.get("GITHUB_SHA"))
|
|
287
|
+
parser.add_argument(
|
|
288
|
+
"--exclude-run-id",
|
|
289
|
+
default=os.environ.get("GITHUB_RUN_ID"),
|
|
290
|
+
help="Workflow run id to ignore, usually the release workflow itself.",
|
|
291
|
+
)
|
|
292
|
+
parser.add_argument(
|
|
293
|
+
"--api-url",
|
|
294
|
+
default=os.environ.get("GITHUB_API_URL", "https://api.github.com"),
|
|
295
|
+
)
|
|
296
|
+
parser.add_argument("--timeout-seconds", type=int, default=7200)
|
|
297
|
+
parser.add_argument("--missing-timeout-seconds", type=int, default=300)
|
|
298
|
+
parser.add_argument("--poll-interval-seconds", type=int, default=30)
|
|
299
|
+
return parser
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
303
|
+
parser = build_parser()
|
|
304
|
+
args = parser.parse_args(argv)
|
|
305
|
+
|
|
306
|
+
token = os.environ.get("GITHUB_TOKEN")
|
|
307
|
+
if not token:
|
|
308
|
+
parser.error("GITHUB_TOKEN is required")
|
|
309
|
+
if not args.repo:
|
|
310
|
+
parser.error("--repo or GITHUB_REPOSITORY is required")
|
|
311
|
+
if not args.sha:
|
|
312
|
+
parser.error("--sha or GITHUB_SHA is required")
|
|
313
|
+
|
|
314
|
+
try:
|
|
315
|
+
wait_for_required_workflows(
|
|
316
|
+
fetch_runs=lambda: fetch_workflow_runs(
|
|
317
|
+
repo=args.repo,
|
|
318
|
+
sha=args.sha,
|
|
319
|
+
token=token,
|
|
320
|
+
api_url=args.api_url,
|
|
321
|
+
),
|
|
322
|
+
required_workflows=tuple(args.required_workflows),
|
|
323
|
+
exclude_run_id=args.exclude_run_id,
|
|
324
|
+
timeout_seconds=args.timeout_seconds,
|
|
325
|
+
missing_timeout_seconds=args.missing_timeout_seconds,
|
|
326
|
+
poll_interval_seconds=args.poll_interval_seconds,
|
|
327
|
+
)
|
|
328
|
+
except RuntimeError as exc:
|
|
329
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
330
|
+
return 1
|
|
331
|
+
|
|
332
|
+
return 0
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
if __name__ == "__main__":
|
|
336
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
# Hermetic CI runs on every push and PR with no secrets. The paid, flake-prone
|
|
4
|
+
# live-LLM job is a post-merge canary (push to main) plus an on-demand manual
|
|
5
|
+
# run (workflow_dispatch); it never runs on pull_request, so forked PRs -- which
|
|
6
|
+
# do not receive repository secrets -- cannot reach it.
|
|
7
|
+
on:
|
|
8
|
+
push:
|
|
9
|
+
pull_request:
|
|
10
|
+
workflow_dispatch:
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
test:
|
|
14
|
+
name: hermetic
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
# Pin reviewed at implementation time; re-check the action's latest major
|
|
19
|
+
# when touching this workflow.
|
|
20
|
+
- name: Install uv
|
|
21
|
+
uses: astral-sh/setup-uv@v5
|
|
22
|
+
with:
|
|
23
|
+
python-version: "3.14"
|
|
24
|
+
- name: Install dependencies
|
|
25
|
+
run: uv sync --extra dev
|
|
26
|
+
- name: Hermetic tests (live LLM deselected)
|
|
27
|
+
run: uv run pytest tests -q -m "not live_llm"
|
|
28
|
+
- name: Prove live tests skip without opt-in
|
|
29
|
+
# No BACKSTITCH_LIVE_LLM here: the module must skip, not call a provider.
|
|
30
|
+
# Expect exactly one skipped test; provider activity here is a failure.
|
|
31
|
+
run: uv run pytest tests/live/test_live_llm.py -q
|
|
32
|
+
- name: Lint
|
|
33
|
+
run: uv run ruff check .
|
|
34
|
+
- name: Format
|
|
35
|
+
run: >
|
|
36
|
+
uv run ruff format --check
|
|
37
|
+
backstitch
|
|
38
|
+
bin
|
|
39
|
+
.github/scripts
|
|
40
|
+
tests/test_release_script.py
|
|
41
|
+
tests/test_release_workflow.py
|
|
42
|
+
tests/test_release_workflow_gate.py
|
|
43
|
+
- name: Types
|
|
44
|
+
run: uv run mypy backstitch bin/release.py --config-file pyproject.toml
|
|
45
|
+
- name: Self-corpus gate
|
|
46
|
+
run: uv run backstitch check --repo-root .
|
|
47
|
+
|
|
48
|
+
live-llm:
|
|
49
|
+
name: live LLM
|
|
50
|
+
needs: test
|
|
51
|
+
# Event-based gate only. `secrets.*` is NOT available in job-level `if`
|
|
52
|
+
# (GitHub context table), so secret presence is checked inside the step.
|
|
53
|
+
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main') }}
|
|
54
|
+
runs-on: ubuntu-latest
|
|
55
|
+
env:
|
|
56
|
+
BACKSTITCH_LIVE_LLM: "1"
|
|
57
|
+
# MUST stay byte-identical to DEFAULT_BACKSTITCH_LIVE_LLM_MODEL in
|
|
58
|
+
# tests/live/test_live_llm.py -- one canonical value in two places. Update
|
|
59
|
+
# both together after re-checking `uv run llm models list` and OpenAI's
|
|
60
|
+
# model docs; availability changes faster than this repo.
|
|
61
|
+
DEFAULT_BACKSTITCH_LIVE_LLM_MODEL: "gpt-5.4-mini"
|
|
62
|
+
# Optional per-environment override; empty when the repo variable is unset.
|
|
63
|
+
BACKSTITCH_LIVE_LLM_MODEL: ${{ vars.BACKSTITCH_LIVE_LLM_MODEL }}
|
|
64
|
+
# Injected as env, never as a command-line argument. Empty string when the
|
|
65
|
+
# secret is not configured.
|
|
66
|
+
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
|
67
|
+
steps:
|
|
68
|
+
- uses: actions/checkout@v4
|
|
69
|
+
- name: Install uv
|
|
70
|
+
uses: astral-sh/setup-uv@v5
|
|
71
|
+
with:
|
|
72
|
+
python-version: "3.14"
|
|
73
|
+
- name: Install dependencies
|
|
74
|
+
run: uv sync --extra dev
|
|
75
|
+
- name: Run live LLM tests
|
|
76
|
+
run: |
|
|
77
|
+
if [ -z "${OPENAI_API_KEY}" ]; then
|
|
78
|
+
echo "::notice::OPENAI_API_KEY not set; skipping live LLM tests without failure"
|
|
79
|
+
exit 0
|
|
80
|
+
fi
|
|
81
|
+
LLM_MODEL="${BACKSTITCH_LIVE_LLM_MODEL:-$DEFAULT_BACKSTITCH_LIVE_LLM_MODEL}"
|
|
82
|
+
export LLM_MODEL
|
|
83
|
+
echo "::notice::running live LLM tests with model ${LLM_MODEL}"
|
|
84
|
+
# Fail fast (no network) if the model id is not registered in llm.
|
|
85
|
+
uv run python -c 'import llm, os; llm.get_model(os.environ["LLM_MODEL"])'
|
|
86
|
+
uv run pytest tests/live/test_live_llm.py -q
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
name: Release Gate (backstitch)
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
actions: read
|
|
10
|
+
contents: read
|
|
11
|
+
|
|
12
|
+
concurrency:
|
|
13
|
+
group: release-gate-${{ github.ref }}
|
|
14
|
+
cancel-in-progress: true
|
|
15
|
+
|
|
16
|
+
env:
|
|
17
|
+
UV_VERSION: "0.11.11"
|
|
18
|
+
PACKAGE_NAME: backstitch
|
|
19
|
+
PACKAGE_DIR: .
|
|
20
|
+
|
|
21
|
+
jobs:
|
|
22
|
+
require-ci:
|
|
23
|
+
runs-on: ubuntu-latest
|
|
24
|
+
timeout-minutes: 130
|
|
25
|
+
permissions:
|
|
26
|
+
actions: read
|
|
27
|
+
contents: read
|
|
28
|
+
steps:
|
|
29
|
+
- uses: actions/checkout@v4
|
|
30
|
+
|
|
31
|
+
- name: Require CI workflow to be green
|
|
32
|
+
env:
|
|
33
|
+
GITHUB_TOKEN: ${{ github.token }}
|
|
34
|
+
run: |
|
|
35
|
+
python .github/scripts/require_green_workflows.py \
|
|
36
|
+
--workflow "CI"
|
|
37
|
+
|
|
38
|
+
verify-tag-current:
|
|
39
|
+
runs-on: ubuntu-latest
|
|
40
|
+
timeout-minutes: 20
|
|
41
|
+
needs:
|
|
42
|
+
- require-ci
|
|
43
|
+
steps:
|
|
44
|
+
- uses: actions/checkout@v4
|
|
45
|
+
|
|
46
|
+
- name: Verify tag still points at tested commit
|
|
47
|
+
env:
|
|
48
|
+
EXPECTED_SHA: ${{ github.sha }}
|
|
49
|
+
TAG_NAME: ${{ github.ref_name }}
|
|
50
|
+
run: |
|
|
51
|
+
set -euo pipefail
|
|
52
|
+
remote_sha="$(git ls-remote --tags origin "refs/tags/${TAG_NAME}" "refs/tags/${TAG_NAME}^{}" | awk 'NR==1 { sha=$1 } END { print $1 ? $1 : sha }')"
|
|
53
|
+
if [ -z "${remote_sha}" ]; then
|
|
54
|
+
echo "Tag ${TAG_NAME} no longer exists on origin"
|
|
55
|
+
exit 1
|
|
56
|
+
fi
|
|
57
|
+
if [ "${remote_sha}" != "${EXPECTED_SHA}" ]; then
|
|
58
|
+
echo "Tag ${TAG_NAME} moved while the release gate was running"
|
|
59
|
+
echo "expected: ${EXPECTED_SHA}"
|
|
60
|
+
echo "actual: ${remote_sha}"
|
|
61
|
+
exit 1
|
|
62
|
+
fi
|
|
63
|
+
|
|
64
|
+
build:
|
|
65
|
+
needs:
|
|
66
|
+
- require-ci
|
|
67
|
+
- verify-tag-current
|
|
68
|
+
runs-on: ubuntu-latest
|
|
69
|
+
timeout-minutes: 50
|
|
70
|
+
permissions:
|
|
71
|
+
attestations: write
|
|
72
|
+
artifact-metadata: write
|
|
73
|
+
contents: read
|
|
74
|
+
id-token: write
|
|
75
|
+
steps:
|
|
76
|
+
- uses: actions/checkout@v4
|
|
77
|
+
with:
|
|
78
|
+
ref: ${{ github.sha }}
|
|
79
|
+
|
|
80
|
+
- name: Install uv
|
|
81
|
+
uses: astral-sh/setup-uv@v5
|
|
82
|
+
with:
|
|
83
|
+
version: ${{ env.UV_VERSION }}
|
|
84
|
+
python-version: "3.14"
|
|
85
|
+
enable-cache: true
|
|
86
|
+
|
|
87
|
+
- name: Verify checked out commit matches tested release commit
|
|
88
|
+
run: |
|
|
89
|
+
test "$(git rev-parse HEAD)" = "${{ github.sha }}"
|
|
90
|
+
|
|
91
|
+
- name: Build package
|
|
92
|
+
working-directory: ${{ env.PACKAGE_DIR }}
|
|
93
|
+
run: |
|
|
94
|
+
uv build
|
|
95
|
+
|
|
96
|
+
- name: Generate artifact attestation
|
|
97
|
+
id: attest
|
|
98
|
+
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1
|
|
99
|
+
with:
|
|
100
|
+
subject-path: ${{ env.PACKAGE_DIR }}/dist/*
|
|
101
|
+
|
|
102
|
+
- name: Stage attestation bundle
|
|
103
|
+
env:
|
|
104
|
+
BUNDLE_PATH: ${{ steps.attest.outputs.bundle-path }}
|
|
105
|
+
PACKAGE_NAME: ${{ env.PACKAGE_NAME }}
|
|
106
|
+
TAG_NAME: ${{ github.ref_name }}
|
|
107
|
+
run: |
|
|
108
|
+
set -euo pipefail
|
|
109
|
+
mkdir -p attestations
|
|
110
|
+
safe_tag="$(printf '%s' "${TAG_NAME}" | tr '/:' '--')"
|
|
111
|
+
cp "${BUNDLE_PATH}" "attestations/${PACKAGE_NAME}-${safe_tag}.sigstore.json"
|
|
112
|
+
|
|
113
|
+
- name: Store the distribution packages
|
|
114
|
+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
115
|
+
with:
|
|
116
|
+
name: ${{ env.PACKAGE_NAME }}-python-package-distributions
|
|
117
|
+
path: ${{ env.PACKAGE_DIR }}/dist/
|
|
118
|
+
|
|
119
|
+
- name: Store the attestation bundle
|
|
120
|
+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
121
|
+
with:
|
|
122
|
+
name: ${{ env.PACKAGE_NAME }}-attestations
|
|
123
|
+
path: attestations/*.sigstore.json
|
|
124
|
+
|
|
125
|
+
publish-to-pypi:
|
|
126
|
+
name: Publish to PyPI
|
|
127
|
+
needs:
|
|
128
|
+
- build
|
|
129
|
+
runs-on: ubuntu-latest
|
|
130
|
+
timeout-minutes: 50
|
|
131
|
+
environment:
|
|
132
|
+
name: pypi
|
|
133
|
+
url: https://pypi.org/p/backstitch
|
|
134
|
+
permissions:
|
|
135
|
+
id-token: write
|
|
136
|
+
|
|
137
|
+
steps:
|
|
138
|
+
- name: Download all the dists
|
|
139
|
+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
140
|
+
with:
|
|
141
|
+
name: ${{ env.PACKAGE_NAME }}-python-package-distributions
|
|
142
|
+
path: dist/
|
|
143
|
+
|
|
144
|
+
- name: Publish distribution to PyPI
|
|
145
|
+
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
|
|
146
|
+
|
|
147
|
+
github-release:
|
|
148
|
+
name: Upload to GitHub Release
|
|
149
|
+
needs:
|
|
150
|
+
- publish-to-pypi
|
|
151
|
+
runs-on: ubuntu-latest
|
|
152
|
+
timeout-minutes: 50
|
|
153
|
+
permissions:
|
|
154
|
+
actions: read
|
|
155
|
+
contents: write
|
|
156
|
+
|
|
157
|
+
steps:
|
|
158
|
+
- name: Download all the dists
|
|
159
|
+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
160
|
+
with:
|
|
161
|
+
name: ${{ env.PACKAGE_NAME }}-python-package-distributions
|
|
162
|
+
path: dist/
|
|
163
|
+
|
|
164
|
+
- name: Download attestation bundle
|
|
165
|
+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
166
|
+
with:
|
|
167
|
+
name: ${{ env.PACKAGE_NAME }}-attestations
|
|
168
|
+
path: attestations/
|
|
169
|
+
|
|
170
|
+
- name: Create GitHub Release and upload artifacts
|
|
171
|
+
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
|
|
172
|
+
with:
|
|
173
|
+
generate_release_notes: true
|
|
174
|
+
tag_name: ${{ github.ref_name }}
|
|
175
|
+
files: |
|
|
176
|
+
dist/*.tar.gz
|
|
177
|
+
dist/*.whl
|
|
178
|
+
attestations/*.sigstore.json
|