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.
Files changed (128) hide show
  1. backstitch-0.1.0/.github/scripts/require_green_workflows.py +336 -0
  2. backstitch-0.1.0/.github/workflows/ci.yml +86 -0
  3. backstitch-0.1.0/.github/workflows/release-gate.yml +178 -0
  4. backstitch-0.1.0/.gitignore +232 -0
  5. backstitch-0.1.0/AGENTS.md +119 -0
  6. backstitch-0.1.0/CLAUDE.md +1 -0
  7. backstitch-0.1.0/LICENSE +21 -0
  8. backstitch-0.1.0/PKG-INFO +104 -0
  9. backstitch-0.1.0/README.md +76 -0
  10. backstitch-0.1.0/backstitch/__init__.py +7 -0
  11. backstitch-0.1.0/backstitch/__main__.py +8 -0
  12. backstitch-0.1.0/backstitch/analysis_llm.py +214 -0
  13. backstitch-0.1.0/backstitch/analysis_packets.py +202 -0
  14. backstitch-0.1.0/backstitch/analysis_results.py +248 -0
  15. backstitch-0.1.0/backstitch/cli.py +873 -0
  16. backstitch-0.1.0/backstitch/config.py +51 -0
  17. backstitch-0.1.0/backstitch/exclusions.py +526 -0
  18. backstitch-0.1.0/backstitch/grammar.py +49 -0
  19. backstitch-0.1.0/backstitch/markdown_specs.py +345 -0
  20. backstitch-0.1.0/backstitch/models.py +183 -0
  21. backstitch-0.1.0/backstitch/profiles.py +34 -0
  22. backstitch-0.1.0/backstitch/prompts/backstitch_style_analysis.md +36 -0
  23. backstitch-0.1.0/backstitch/py.typed +1 -0
  24. backstitch-0.1.0/backstitch/python_refs.py +380 -0
  25. backstitch-0.1.0/backstitch/reporting.py +90 -0
  26. backstitch-0.1.0/backstitch/resolver.py +926 -0
  27. backstitch-0.1.0/backstitch/settings.py +620 -0
  28. backstitch-0.1.0/backstitch/target_roots.py +88 -0
  29. backstitch-0.1.0/bin/release.py +997 -0
  30. backstitch-0.1.0/docs/README.md +90 -0
  31. backstitch-0.1.0/docs/agent-context/README.md +61 -0
  32. backstitch-0.1.0/docs/agent-context/context.index.yaml +41 -0
  33. backstitch-0.1.0/docs/agent-context/decision-hierarchy.md +84 -0
  34. backstitch-0.1.0/docs/agent-context/engineering-principles.md +179 -0
  35. backstitch-0.1.0/docs/agent-context/lessons.md +13 -0
  36. backstitch-0.1.0/docs/agent-context/principles.md +82 -0
  37. backstitch-0.1.0/docs/agent-context/runbooks/adversarial-acceptance-probes.md +102 -0
  38. backstitch-0.1.0/docs/agent-context/runbooks/hardening-plans.md +292 -0
  39. backstitch-0.1.0/docs/agent-context/runbooks/maintaining-traceability.md +81 -0
  40. backstitch-0.1.0/docs/agent-context/runbooks/review-loops-and-agent-bootstrap.md +118 -0
  41. backstitch-0.1.0/docs/agent-context/runbooks/skills-lifecycle.md +88 -0
  42. backstitch-0.1.0/docs/agent-context/runbooks/testing-patterns.md +131 -0
  43. backstitch-0.1.0/docs/agent-context/runbooks/writing-implementation-docs.md +99 -0
  44. backstitch-0.1.0/docs/agent-context/runbooks/writing-plans.md +643 -0
  45. backstitch-0.1.0/docs/agent-context/runbooks/writing-specs.md +194 -0
  46. backstitch-0.1.0/docs/implementation/00-implementation-index.md +23 -0
  47. backstitch-0.1.0/docs/implementation/01-documentation-system.md +91 -0
  48. backstitch-0.1.0/docs/implementation/02-repository-map.md +93 -0
  49. backstitch-0.1.0/docs/implementation/03-agent-inventory.md +56 -0
  50. backstitch-0.1.0/docs/implementation/04-backstitch-style-traceability.md +178 -0
  51. backstitch-0.1.0/docs/implementation/05-release-publishing.md +110 -0
  52. backstitch-0.1.0/docs/implementation/README.md +8 -0
  53. backstitch-0.1.0/docs/lessons.md +126 -0
  54. backstitch-0.1.0/docs/plans/2026-06-18-backstitch-style-spec-code-traceability-tool-plan.md +1006 -0
  55. backstitch-0.1.0/docs/plans/2026-07-01-backstitch-toml-configuration-plan.md +350 -0
  56. backstitch-0.1.0/docs/plans/2026-07-02-backstitch-four-way-reconciliation-plan.md +1123 -0
  57. backstitch-0.1.0/docs/plans/2026-07-02-backstitch-traceability-exclusions-plan.md +262 -0
  58. backstitch-0.1.0/docs/plans/2026-07-03-backstitch-release-publishing-plan.md +283 -0
  59. backstitch-0.1.0/docs/plans/2026-07-03-input-validation-invariants-plan.md +344 -0
  60. backstitch-0.1.0/docs/plans/2026-07-03-live-llm-tests-plan.md +663 -0
  61. backstitch-0.1.0/docs/plans/2026-07-03-local-llm-eval-lane-plan.md +589 -0
  62. backstitch-0.1.0/docs/plans/README.md +43 -0
  63. backstitch-0.1.0/docs/specs/00-specs-index.md +39 -0
  64. backstitch-0.1.0/docs/specs/01-development-documentation-operating-model.md +289 -0
  65. backstitch-0.1.0/docs/specs/02-backstitch-core.md +737 -0
  66. backstitch-0.1.0/docs/specs/03-backstitch-configuration.md +519 -0
  67. backstitch-0.1.0/docs/specs/04-backstitch-traceability-exclusions.md +370 -0
  68. backstitch-0.1.0/docs/specs/05-backstitch-invariants.md +380 -0
  69. backstitch-0.1.0/docs/specs/README.md +4 -0
  70. backstitch-0.1.0/pyproject.toml +115 -0
  71. backstitch-0.1.0/skills/README.md +40 -0
  72. backstitch-0.1.0/skills/_template/SKILL.md +41 -0
  73. backstitch-0.1.0/tests/__init__.py +0 -0
  74. backstitch-0.1.0/tests/acceptance/README.md +11 -0
  75. backstitch-0.1.0/tests/acceptance/__init__.py +0 -0
  76. backstitch-0.1.0/tests/acceptance/conftest.py +62 -0
  77. backstitch-0.1.0/tests/acceptance/test_probe_analysis.py +64 -0
  78. backstitch-0.1.0/tests/acceptance/test_probe_boundaries.py +115 -0
  79. backstitch-0.1.0/tests/acceptance/test_probe_committed_config.py +22 -0
  80. backstitch-0.1.0/tests/acceptance/test_probe_config.py +49 -0
  81. backstitch-0.1.0/tests/acceptance/test_probe_parsing.py +67 -0
  82. backstitch-0.1.0/tests/acceptance/test_probe_selfacceptance.py +87 -0
  83. backstitch-0.1.0/tests/conftest.py +23 -0
  84. backstitch-0.1.0/tests/fixtures/clean_project/docs/specs/01-Clean.md +10 -0
  85. backstitch-0.1.0/tests/fixtures/clean_project/pkg/mod.py +9 -0
  86. backstitch-0.1.0/tests/fixtures/config_project/.backstitch.toml +13 -0
  87. backstitch-0.1.0/tests/fixtures/noqa_scope_project/docs/specs/01-n.md +5 -0
  88. backstitch-0.1.0/tests/fixtures/noqa_scope_project/pkg/mod.py +10 -0
  89. backstitch-0.1.0/tests/fixtures/noqa_scope_project/pkg/moddoc.py +11 -0
  90. backstitch-0.1.0/tests/fixtures/path_ladder_project/docs/specs/01-Ladder.md +26 -0
  91. backstitch-0.1.0/tests/fixtures/path_ladder_project/pkg/inner/shared.py +1 -0
  92. backstitch-0.1.0/tests/fixtures/path_ladder_project/pkg/inner/unique.py +1 -0
  93. backstitch-0.1.0/tests/fixtures/path_ladder_project/pkg/other/shared.py +1 -0
  94. backstitch-0.1.0/tests/fixtures/traceability_project/docs/specifications/01-Core.md +34 -0
  95. backstitch-0.1.0/tests/fixtures/traceability_project/docs/specifications/01A-Core_Planned.md +7 -0
  96. backstitch-0.1.0/tests/fixtures/traceability_project/docs/specifications/02-Weft_Style.md +46 -0
  97. backstitch-0.1.0/tests/fixtures/traceability_project/src/ranges.py +47 -0
  98. backstitch-0.1.0/tests/fixtures/traceability_project/src/runtime.py +43 -0
  99. backstitch-0.1.0/tests/fixtures/traceability_project/tests/test_runtime.py +11 -0
  100. backstitch-0.1.0/tests/fixtures/traceability_project.expected.json +1005 -0
  101. backstitch-0.1.0/tests/live/test_live_llm.py +223 -0
  102. backstitch-0.1.0/tests/test_analysis_llm.py +245 -0
  103. backstitch-0.1.0/tests/test_analysis_packets.py +255 -0
  104. backstitch-0.1.0/tests/test_analysis_results.py +162 -0
  105. backstitch-0.1.0/tests/test_backstitch_corpus_traceability.py +68 -0
  106. backstitch-0.1.0/tests/test_behavior_freeze.py +82 -0
  107. backstitch-0.1.0/tests/test_bootstrap.py +13 -0
  108. backstitch-0.1.0/tests/test_cli.py +91 -0
  109. backstitch-0.1.0/tests/test_cli_config.py +83 -0
  110. backstitch-0.1.0/tests/test_config.py +53 -0
  111. backstitch-0.1.0/tests/test_exclusions.py +175 -0
  112. backstitch-0.1.0/tests/test_grammar.py +36 -0
  113. backstitch-0.1.0/tests/test_issue_code_coverage.py +137 -0
  114. backstitch-0.1.0/tests/test_markdown_specs.py +217 -0
  115. backstitch-0.1.0/tests/test_models.py +81 -0
  116. backstitch-0.1.0/tests/test_python_noqa.py +73 -0
  117. backstitch-0.1.0/tests/test_python_refs.py +142 -0
  118. backstitch-0.1.0/tests/test_release_script.py +303 -0
  119. backstitch-0.1.0/tests/test_release_workflow.py +64 -0
  120. backstitch-0.1.0/tests/test_release_workflow_gate.py +110 -0
  121. backstitch-0.1.0/tests/test_reporting.py +57 -0
  122. backstitch-0.1.0/tests/test_resolver.py +440 -0
  123. backstitch-0.1.0/tests/test_resolver_ladder.py +79 -0
  124. backstitch-0.1.0/tests/test_review_remediation.py +2088 -0
  125. backstitch-0.1.0/tests/test_settings.py +429 -0
  126. backstitch-0.1.0/tests/test_target_roots.py +117 -0
  127. backstitch-0.1.0/tests/test_weft_corpus_traceability.py +91 -0
  128. 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