beacon-framework 0.2.0__tar.gz → 0.3.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 (43) hide show
  1. {beacon_framework-0.2.0 → beacon_framework-0.3.0}/PKG-INFO +14 -8
  2. {beacon_framework-0.2.0 → beacon_framework-0.3.0}/README.md +13 -7
  3. {beacon_framework-0.2.0 → beacon_framework-0.3.0}/pyproject.toml +3 -1
  4. {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/__init__.py +1 -1
  5. {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/cli.py +35 -0
  6. beacon_framework-0.3.0/src/beacon/doctor.py +265 -0
  7. {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/integrations/__init__.py +2 -1
  8. beacon_framework-0.3.0/src/beacon/integrations/release.py +185 -0
  9. beacon_framework-0.3.0/src/beacon/resources/integrations/release/PUBLISHING.md +84 -0
  10. beacon_framework-0.3.0/src/beacon/resources/integrations/release/psr_config.toml +37 -0
  11. beacon_framework-0.3.0/src/beacon/resources/integrations/release/workflows/release.yml +70 -0
  12. {beacon_framework-0.2.0 → beacon_framework-0.3.0}/.gitignore +0 -0
  13. {beacon_framework-0.2.0 → beacon_framework-0.3.0}/BEACON.md +0 -0
  14. {beacon_framework-0.2.0 → beacon_framework-0.3.0}/LICENSE +0 -0
  15. {beacon_framework-0.2.0 → beacon_framework-0.3.0}/pragmatic-principles.md +0 -0
  16. {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/__main__.py +0 -0
  17. {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/installer.py +0 -0
  18. {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/integrations/claude_code.py +0 -0
  19. {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/manifest.py +0 -0
  20. {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/resources/beacon.md +0 -0
  21. {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/resources/integrations/claude_code/CLAUDE.md.fragment +0 -0
  22. {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/resources/integrations/claude_code/commands/design/diagram.md +0 -0
  23. {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/resources/integrations/claude_code/commands/design/evaluate.md +0 -0
  24. {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/resources/integrations/claude_code/commands/design/wardley.md +0 -0
  25. {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/resources/integrations/claude_code/commands/git/feature.md +0 -0
  26. {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/resources/integrations/claude_code/commands/git/pr.md +0 -0
  27. {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/resources/integrations/claude_code/commands/git/release.md +0 -0
  28. {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/resources/integrations/claude_code/commands/init.md +0 -0
  29. {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/resources/project-management/ADRs/ADR-000-template.md +0 -0
  30. {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/resources/project-management/ADRs/README.md +0 -0
  31. {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/resources/project-management/Background/00-problem-statement.md +0 -0
  32. {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/resources/project-management/Background/01-final-architecture-document.md +0 -0
  33. {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/resources/project-management/Prompts/01-SEED.md +0 -0
  34. {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/resources/project-management/Prompts/02-DESIGN.md +0 -0
  35. {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/resources/project-management/Prompts/03-BUILD.md +0 -0
  36. {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/resources/project-management/Prompts/04-SHIP.md +0 -0
  37. {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/resources/project-management/Roadmap/README.md +0 -0
  38. {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/resources/project-management/Roadmap/archive/.gitkeep +0 -0
  39. {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/resources/project-management/Work/README.md +0 -0
  40. {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/resources/project-management/Work/analysis/.gitkeep +0 -0
  41. {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/resources/project-management/Work/planning/.gitkeep +0 -0
  42. {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/resources/project-management/Work/sessions/.gitkeep +0 -0
  43. {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/upgrader.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: beacon-framework
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Light-touch, pragmatic, artifact-driven framework for AI-assisted software delivery
5
5
  Project-URL: Repository, https://github.com/darth-veitcher/beacon
6
6
  Author: darth-veitcher
@@ -37,15 +37,21 @@ That writes the BEACON skeleton into the current directory and wires up Claude C
37
37
  ## Commands
38
38
 
39
39
  ```bash
40
- beacon init [--here] [--ai claude] # install into a project (idempotent)
41
- beacon upgrade [--here] # refresh framework files only
42
- beacon check [--here] # validate the install
43
- beacon integration list # list AI integrations
44
- beacon integration add <name> # wire up an AI tool
45
- beacon integration remove <name> # remove an AI tool
46
- beacon version
40
+ beacon init [--here] [--ai claude] # install into a project (idempotent)
41
+ beacon upgrade [--here] # refresh framework files only
42
+ beacon check [--here] # validate the install (files-present)
43
+ beacon doctor [--here] [--strict] # semantic health check (placeholders, stale notes, ADR gaps, drift)
44
+ beacon integration list # list available integrations
45
+ beacon integration add <name> [--here] # add an integration (e.g. `claude`, `release`)
46
+ beacon integration remove <name> [--here] # remove an integration
47
+ beacon --version / beacon -V # show installed version
47
48
  ```
48
49
 
50
+ ### Integrations
51
+
52
+ - **`claude`** — Claude Code slash commands and the `<!-- BEACON -->` block in `.claude/CLAUDE.md`. Installed by default.
53
+ - **`release`** — Production-ready release pipeline: a GitHub Actions workflow that uses `python-semantic-release` + PyPI Trusted Publishing to ship `main` pushes to PyPI and `develop` pushes to TestPyPI. Drops `PUBLISHING.md` with the one-time setup instructions and injects a fenced `[tool.semantic_release]` block into your `pyproject.toml`.
54
+
49
55
  ## What gets installed
50
56
 
51
57
  ```
@@ -17,15 +17,21 @@ That writes the BEACON skeleton into the current directory and wires up Claude C
17
17
  ## Commands
18
18
 
19
19
  ```bash
20
- beacon init [--here] [--ai claude] # install into a project (idempotent)
21
- beacon upgrade [--here] # refresh framework files only
22
- beacon check [--here] # validate the install
23
- beacon integration list # list AI integrations
24
- beacon integration add <name> # wire up an AI tool
25
- beacon integration remove <name> # remove an AI tool
26
- beacon version
20
+ beacon init [--here] [--ai claude] # install into a project (idempotent)
21
+ beacon upgrade [--here] # refresh framework files only
22
+ beacon check [--here] # validate the install (files-present)
23
+ beacon doctor [--here] [--strict] # semantic health check (placeholders, stale notes, ADR gaps, drift)
24
+ beacon integration list # list available integrations
25
+ beacon integration add <name> [--here] # add an integration (e.g. `claude`, `release`)
26
+ beacon integration remove <name> [--here] # remove an integration
27
+ beacon --version / beacon -V # show installed version
27
28
  ```
28
29
 
30
+ ### Integrations
31
+
32
+ - **`claude`** — Claude Code slash commands and the `<!-- BEACON -->` block in `.claude/CLAUDE.md`. Installed by default.
33
+ - **`release`** — Production-ready release pipeline: a GitHub Actions workflow that uses `python-semantic-release` + PyPI Trusted Publishing to ship `main` pushes to PyPI and `develop` pushes to TestPyPI. Drops `PUBLISHING.md` with the one-time setup instructions and injects a fenced `[tool.semantic_release]` block into your `pyproject.toml`.
34
+
29
35
  ## What gets installed
30
36
 
31
37
  ```
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "beacon-framework"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Light-touch, pragmatic, artifact-driven framework for AI-assisted software delivery"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -33,6 +33,7 @@ Repository = "https://github.com/darth-veitcher/beacon"
33
33
 
34
34
 
35
35
 
36
+
36
37
  [tool.hatch.build.targets.wheel]
37
38
  packages = ["src/beacon"]
38
39
  # Include the bundled resources tree (markdown templates, prompts, .gitkeep
@@ -101,6 +102,7 @@ exclude_commit_patterns = ["^chore\\(release\\):"]
101
102
 
102
103
 
103
104
 
105
+
104
106
  [dependency-groups]
105
107
  dev = [
106
108
  "pytest>=8.0",
@@ -1,3 +1,3 @@
1
1
  """BEACON Framework: lifecycle and discipline for artifact-driven AI-assisted delivery."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.0"
@@ -8,6 +8,7 @@ import typer
8
8
  from rich.console import Console
9
9
 
10
10
  from beacon import __version__, installer, integrations
11
+ from beacon.doctor import Status, run_all, summarise
11
12
  from beacon.manifest import MANIFEST_RELPATH, Manifest
12
13
  from beacon.upgrader import upgrade as upgrade_project
13
14
 
@@ -150,6 +151,40 @@ def check(
150
151
  )
151
152
 
152
153
 
154
+ @app.command()
155
+ def doctor(
156
+ path: Path | None = typer.Argument(None, help="Project directory (default: cwd)."),
157
+ here: bool = typer.Option(False, "--here", help="Doctor the current directory."),
158
+ strict: bool = typer.Option(
159
+ False,
160
+ "--strict",
161
+ help="Promote warnings to failures (CI mode).",
162
+ ),
163
+ ) -> None:
164
+ """Run semantic health checks against an installed BEACON project.
165
+
166
+ Where ``beacon check`` verifies that framework files *exist*, ``beacon
167
+ doctor`` examines their *content* — flags placeholder text, stale work
168
+ notes, ADR gaps, and drifted framework files. Use ``--strict`` in CI.
169
+ """
170
+ root = _resolve_root(here, path)
171
+ checks = run_all(root)
172
+ colour = {
173
+ Status.OK: "green",
174
+ Status.WARN: "yellow",
175
+ Status.FAIL: "red",
176
+ }
177
+ for c in checks:
178
+ console.print(f"[{colour[c.status]}]{c.icon}[/{colour[c.status]}] {c.name}: {c.message}")
179
+ ok, warn, fail, exit_code = summarise(checks, strict=strict)
180
+ console.print(
181
+ f"\n[dim]ok={ok} warn={warn} fail={fail}"
182
+ f"{' (strict: warn→fail)' if strict and warn else ''}[/dim]"
183
+ )
184
+ if exit_code:
185
+ raise typer.Exit(code=exit_code)
186
+
187
+
153
188
  @integration_app.command("list")
154
189
  def integration_list() -> None:
155
190
  """List available AI integrations."""
@@ -0,0 +1,265 @@
1
+ """Semantic health checks for an installed BEACON project.
2
+
3
+ Where ``beacon check`` only verifies that the framework files exist, ``beacon
4
+ doctor`` looks at their *content* — catches placeholder text, stale work
5
+ notes, ADR gaps, and drifted framework files. Each check returns a tri-state
6
+ result so the agent (or a human) sees which areas of the project have rotted.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ import subprocess
13
+ from dataclasses import dataclass
14
+ from datetime import datetime, timedelta, timezone
15
+ from enum import Enum
16
+ from importlib import resources
17
+ from pathlib import Path
18
+
19
+ from beacon.manifest import Manifest
20
+
21
+ STALE_SESSION_AGE = timedelta(days=30)
22
+ """Sessions older than this trigger a promote-or-delete warning."""
23
+
24
+ ADR_HEURISTIC_COMMITS = 20
25
+ """Below this commit count the ADR-coverage check stays quiet."""
26
+
27
+ PLACEHOLDER_TOKENS = (
28
+ # Markers from the shipped problem-statement template (Background/00-…)
29
+ "[Replace with your problem statement]",
30
+ "[Specific person, role, or team",
31
+ "[When and where they encounter this problem]",
32
+ "[What they do today and why it falls short]",
33
+ "[Outcome 1",
34
+ "[One paragraph on the business or human impact",
35
+ "[Technical, organisational, or timeline constraints",
36
+ # Markers from the architecture template (Background/01-…)
37
+ "[Project Name]",
38
+ "[one-line project description]",
39
+ # Markers from the roadmap template
40
+ "[bullet title]",
41
+ "[Hardcoded end-to-end]",
42
+ )
43
+
44
+
45
+ class Status(str, Enum):
46
+ OK = "ok"
47
+ WARN = "warn"
48
+ FAIL = "fail"
49
+
50
+
51
+ @dataclass
52
+ class Check:
53
+ name: str
54
+ status: Status
55
+ message: str
56
+
57
+ @property
58
+ def icon(self) -> str:
59
+ return {Status.OK: "✓", Status.WARN: "!", Status.FAIL: "✗"}[self.status]
60
+
61
+
62
+ def _resource_root() -> Path:
63
+ return Path(str(resources.files("beacon").joinpath("resources")))
64
+
65
+
66
+ def _check_manifest(root: Path) -> Check:
67
+ manifest = Manifest.load(root)
68
+ if manifest is None:
69
+ return Check(
70
+ "manifest",
71
+ Status.FAIL,
72
+ f"No BEACON manifest at {root}/project-management/.beacon/init-options.json. "
73
+ "Run `beacon init` first.",
74
+ )
75
+ return Check("manifest", Status.OK, f"BEACON {manifest.beacon_version} installed.")
76
+
77
+
78
+ def _check_problem_statement(root: Path) -> Check:
79
+ path = root / "project-management/Background/00-problem-statement.md"
80
+ if not path.exists():
81
+ return Check(
82
+ "problem-statement",
83
+ Status.FAIL,
84
+ f"Missing {path.relative_to(root)}. Run `beacon init` or `beacon upgrade`.",
85
+ )
86
+ content = path.read_text()
87
+ hits = [tok for tok in PLACEHOLDER_TOKENS if tok in content]
88
+ if hits:
89
+ return Check(
90
+ "problem-statement",
91
+ Status.FAIL,
92
+ f"Placeholder text still in problem statement: {', '.join(hits)}. "
93
+ "Fill in 00-problem-statement.md before opening a feature branch.",
94
+ )
95
+ return Check(
96
+ "problem-statement",
97
+ Status.OK,
98
+ "Problem statement contains no template placeholders.",
99
+ )
100
+
101
+
102
+ _ACTIVE_BULLET_PATTERN = re.compile(r"^\*\*#\d+\s*—.+\*\*", re.MULTILINE)
103
+ _TEMPLATE_BULLET_PATTERN = re.compile(r"^\*\*#N\s*—\s*\[bullet title\]\*\*", re.MULTILINE)
104
+
105
+
106
+ def _check_roadmap(root: Path) -> Check:
107
+ path = root / "project-management/Roadmap/README.md"
108
+ if not path.exists():
109
+ return Check(
110
+ "roadmap",
111
+ Status.WARN,
112
+ f"Missing {path.relative_to(root)} — projects without a roadmap will drift.",
113
+ )
114
+ content = path.read_text()
115
+ if _TEMPLATE_BULLET_PATTERN.search(content) and not _ACTIVE_BULLET_PATTERN.search(content):
116
+ return Check(
117
+ "roadmap",
118
+ Status.WARN,
119
+ "Roadmap still shows the template '#N — [bullet title]'. "
120
+ "Update Active Bullet when you start work.",
121
+ )
122
+ if not _ACTIVE_BULLET_PATTERN.search(content):
123
+ return Check(
124
+ "roadmap",
125
+ Status.WARN,
126
+ "No Active Bullet line matching '**#N — title**' in Roadmap. "
127
+ "Set one when starting a bullet.",
128
+ )
129
+ return Check("roadmap", Status.OK, "Roadmap has an Active Bullet.")
130
+
131
+
132
+ def _check_stale_sessions(root: Path) -> Check:
133
+ sessions_dir = root / "project-management/Work/sessions"
134
+ if not sessions_dir.exists():
135
+ return Check("sessions", Status.OK, "No Work/sessions directory yet.")
136
+ cutoff = datetime.now(timezone.utc) - STALE_SESSION_AGE
137
+ stale: list[str] = []
138
+ for p in sessions_dir.glob("*.md"):
139
+ mtime = datetime.fromtimestamp(p.stat().st_mtime, tz=timezone.utc)
140
+ if mtime < cutoff:
141
+ stale.append(p.name)
142
+ if stale:
143
+ names = ", ".join(sorted(stale)[:3]) + ("…" if len(stale) > 3 else "")
144
+ return Check(
145
+ "sessions",
146
+ Status.WARN,
147
+ f"{len(stale)} session file(s) older than {STALE_SESSION_AGE.days} days ({names}). "
148
+ "Promote insights to ADRs or delete.",
149
+ )
150
+ return Check("sessions", Status.OK, "No stale session files.")
151
+
152
+
153
+ def _commit_count(root: Path) -> int | None:
154
+ """Count commits reachable from HEAD, or ``None`` if not a git repo."""
155
+ try:
156
+ out = subprocess.run(
157
+ ["git", "rev-list", "--count", "HEAD"],
158
+ cwd=root,
159
+ check=True,
160
+ capture_output=True,
161
+ text=True,
162
+ )
163
+ return int(out.stdout.strip())
164
+ except (subprocess.CalledProcessError, FileNotFoundError, ValueError):
165
+ return None
166
+
167
+
168
+ def _check_adr_coverage(root: Path) -> Check:
169
+ adrs_dir = root / "project-management/ADRs"
170
+ if not adrs_dir.exists():
171
+ return Check(
172
+ "adr-coverage",
173
+ Status.WARN,
174
+ "No ADRs directory; `beacon init` should have created one.",
175
+ )
176
+ real_adrs = [p for p in adrs_dir.glob("ADR-*.md") if not p.name.startswith("ADR-000-")]
177
+ commits = _commit_count(root)
178
+ if commits is None:
179
+ if real_adrs:
180
+ return Check(
181
+ "adr-coverage",
182
+ Status.OK,
183
+ f"{len(real_adrs)} ADR(s) on file (git history unavailable for heuristic).",
184
+ )
185
+ return Check(
186
+ "adr-coverage",
187
+ Status.OK,
188
+ "No ADRs yet (no git history to weigh against).",
189
+ )
190
+ if commits >= ADR_HEURISTIC_COMMITS and not real_adrs:
191
+ return Check(
192
+ "adr-coverage",
193
+ Status.WARN,
194
+ f"{commits} commits and zero non-template ADRs. "
195
+ "Significant decisions are probably going unrecorded.",
196
+ )
197
+ return Check(
198
+ "adr-coverage",
199
+ Status.OK,
200
+ f"{len(real_adrs)} ADR(s) on file across {commits} commits.",
201
+ )
202
+
203
+
204
+ def _check_framework_drift(root: Path) -> Check:
205
+ manifest = Manifest.load(root)
206
+ if manifest is None:
207
+ # Already surfaced by _check_manifest; don't double-report.
208
+ return Check("drift", Status.OK, "(skipped — no manifest)")
209
+ src_root = _resource_root()
210
+ drifted: list[str] = []
211
+ missing: list[str] = []
212
+ for rel in manifest.framework_files:
213
+ # Skip non-resource files (e.g. anything an integration writes to
214
+ # paths outside resources/, like .claude/CLAUDE.md whose content is
215
+ # legitimately user-edited around the BEACON marker block).
216
+ if rel.startswith(".claude/"):
217
+ continue
218
+ src = src_root / rel
219
+ dst = root / rel
220
+ if not src.exists():
221
+ continue
222
+ if not dst.exists():
223
+ missing.append(rel)
224
+ continue
225
+ if src.read_bytes() != dst.read_bytes():
226
+ drifted.append(rel)
227
+ if missing:
228
+ return Check(
229
+ "drift",
230
+ Status.FAIL,
231
+ f"{len(missing)} framework file(s) missing: {', '.join(missing[:3])}"
232
+ f"{'…' if len(missing) > 3 else ''}. Run `beacon upgrade`.",
233
+ )
234
+ if drifted:
235
+ return Check(
236
+ "drift",
237
+ Status.WARN,
238
+ f"{len(drifted)} framework file(s) modified on disk: "
239
+ f"{', '.join(drifted[:3])}{'…' if len(drifted) > 3 else ''}. "
240
+ "`beacon upgrade` will overwrite them.",
241
+ )
242
+ return Check("drift", Status.OK, "Framework files match shipped resources.")
243
+
244
+
245
+ CHECKS = (
246
+ _check_manifest,
247
+ _check_problem_statement,
248
+ _check_roadmap,
249
+ _check_stale_sessions,
250
+ _check_adr_coverage,
251
+ _check_framework_drift,
252
+ )
253
+
254
+
255
+ def run_all(root: Path) -> list[Check]:
256
+ return [check(root) for check in CHECKS]
257
+
258
+
259
+ def summarise(checks: list[Check], *, strict: bool) -> tuple[int, int, int, int]:
260
+ """Return (ok, warn, fail, exit_code). Strict promotes WARN to FAIL."""
261
+ ok = sum(1 for c in checks if c.status is Status.OK)
262
+ warn = sum(1 for c in checks if c.status is Status.WARN)
263
+ fail = sum(1 for c in checks if c.status is Status.FAIL)
264
+ failing = fail + (warn if strict else 0)
265
+ return ok, warn, fail, 1 if failing else 0
@@ -9,7 +9,7 @@ from __future__ import annotations
9
9
  from collections.abc import Callable
10
10
  from pathlib import Path
11
11
 
12
- from beacon.integrations import claude_code
12
+ from beacon.integrations import claude_code, release
13
13
 
14
14
  # Each entry exposes (install, remove) callables. Both take ``project_root`` and
15
15
  # return the list of relative paths written or removed.
@@ -17,6 +17,7 @@ Integration = tuple[Callable[[Path], list[str]], Callable[[Path], list[str]]]
17
17
 
18
18
  REGISTRY: dict[str, Integration] = {
19
19
  "claude": (claude_code.install, claude_code.remove),
20
+ "release": (release.install, release.remove),
20
21
  }
21
22
 
22
23
 
@@ -0,0 +1,185 @@
1
+ """Release-pipeline integration.
2
+
3
+ Packages the python-semantic-release + PyPI Trusted Publishing setup we
4
+ shipped for beacon itself as a reusable integration. Consumers run:
5
+
6
+ beacon integration add release
7
+
8
+ and get the GitHub Actions workflow, a PUBLISHING.md, and an injected
9
+ ``[tool.semantic_release]`` block in their ``pyproject.toml``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from importlib import resources
16
+ from pathlib import Path
17
+
18
+ INTEGRATION_NAME = "release"
19
+ RESOURCE_SUBDIR = "integrations/release"
20
+
21
+ # Files written into the consumer's project. Tuples of (resource-relative
22
+ # path, project-relative destination, framework_or_user_seeded).
23
+ #
24
+ # - framework: always overwritten by install/upgrade (BEACON owns it)
25
+ # - user_seeded: written only if absent (user may edit thereafter)
26
+ _FILES: tuple[tuple[str, str, str], ...] = (
27
+ ("workflows/release.yml", ".github/workflows/release.yml", "framework"),
28
+ ("PUBLISHING.md", "PUBLISHING.md", "user_seeded"),
29
+ )
30
+
31
+ _PSR_CONFIG_NAME = "psr_config.toml"
32
+ """Resource file containing the [tool.semantic_release] block to merge."""
33
+
34
+ # Sentinel header used to find and strip the PSR block on `remove`.
35
+ _PSR_HEADER_RE = re.compile(r"^\[tool\.semantic_release(?:\..+)?\]\s*$", re.MULTILINE)
36
+ _PSR_FENCE_START = "# ──── BEACON release integration: python-semantic-release config ────"
37
+ _PSR_FENCE_END = "# ──── end BEACON release integration ────"
38
+
39
+
40
+ def _resource_root() -> Path:
41
+ return Path(str(resources.files("beacon").joinpath("resources").joinpath(RESOURCE_SUBDIR)))
42
+
43
+
44
+ def _read_resource(rel: str) -> str:
45
+ return (_resource_root() / rel).read_text()
46
+
47
+
48
+ # ---- pyproject.toml introspection -----------------------------------------
49
+
50
+
51
+ def _package_name(project_root: Path) -> str:
52
+ """Best-effort read of the consumer's package name from pyproject.toml."""
53
+ pyproject = project_root / "pyproject.toml"
54
+ if not pyproject.exists():
55
+ return "your-package"
56
+ try:
57
+ import tomllib
58
+ except ImportError: # Python < 3.11 — package is 3.10+ but tomllib is 3.11+
59
+ import tomli as tomllib # type: ignore[no-redef]
60
+ try:
61
+ data = tomllib.loads(pyproject.read_text())
62
+ return str(data.get("project", {}).get("name") or "your-package")
63
+ except Exception:
64
+ return "your-package"
65
+
66
+
67
+ def _git_owner_repo(project_root: Path) -> tuple[str, str]:
68
+ """Best-effort parse of ``OWNER`` and ``REPO`` from ``.git/config``."""
69
+ cfg = project_root / ".git" / "config"
70
+ if cfg.exists():
71
+ m = re.search(
72
+ r"github\.com[:/]([\w-]+)/([\w.-]+?)(?:\.git)?\s*$", cfg.read_text(), re.MULTILINE
73
+ )
74
+ if m:
75
+ return m.group(1), m.group(2)
76
+ return "your-org", project_root.name
77
+
78
+
79
+ def _render(content: str, project_root: Path) -> str:
80
+ """Substitute templating tokens before writing into the consumer project."""
81
+ name = _package_name(project_root)
82
+ owner, repo = _git_owner_repo(project_root)
83
+ return (
84
+ content.replace("{{PACKAGE_NAME}}", name)
85
+ .replace("{{OWNER}}", owner)
86
+ .replace("{{REPO}}", repo)
87
+ )
88
+
89
+
90
+ # ---- pyproject.toml merge ------------------------------------------------
91
+
92
+
93
+ def _has_psr_config(pyproject_text: str) -> bool:
94
+ return bool(_PSR_HEADER_RE.search(pyproject_text))
95
+
96
+
97
+ def _inject_psr_block(pyproject_text: str, block: str) -> str:
98
+ """Append the fenced PSR block to the consumer's pyproject content."""
99
+ fenced = f"\n\n{_PSR_FENCE_START}\n{block.strip()}\n{_PSR_FENCE_END}\n"
100
+ return pyproject_text.rstrip() + fenced
101
+
102
+
103
+ def _strip_psr_block(pyproject_text: str) -> str:
104
+ """Remove the BEACON-fenced PSR block from pyproject content.
105
+
106
+ Only removes content inside the fence sentinels so we never delete a
107
+ hand-authored PSR config that happens to live in the same file.
108
+ """
109
+ pattern = re.compile(
110
+ r"\n*" + re.escape(_PSR_FENCE_START) + r".*?" + re.escape(_PSR_FENCE_END) + r"\n?",
111
+ re.DOTALL,
112
+ )
113
+ return pattern.sub("\n", pyproject_text).rstrip() + "\n"
114
+
115
+
116
+ # ---- install / remove ----------------------------------------------------
117
+
118
+
119
+ def install(project_root: Path) -> list[str]:
120
+ """Install the release pipeline. Returns relpaths written."""
121
+ written: list[str] = []
122
+
123
+ for src_rel, dst_rel, mode in _FILES:
124
+ dst = project_root / dst_rel
125
+ if mode == "user_seeded" and dst.exists():
126
+ continue
127
+ dst.parent.mkdir(parents=True, exist_ok=True)
128
+ content = _render(_read_resource(src_rel), project_root)
129
+ dst.write_text(content)
130
+ written.append(dst_rel)
131
+
132
+ # Inject PSR config into pyproject.toml
133
+ pyproject = project_root / "pyproject.toml"
134
+ if pyproject.exists():
135
+ text = pyproject.read_text()
136
+ if _PSR_FENCE_START in text:
137
+ # Already installed — refresh the fenced block (framework-owned).
138
+ text = _strip_psr_block(text)
139
+ text = _inject_psr_block(text, _read_resource(_PSR_CONFIG_NAME))
140
+ pyproject.write_text(text)
141
+ written.append("pyproject.toml")
142
+ elif _has_psr_config(text):
143
+ # User has their own PSR config; don't touch it.
144
+ print(
145
+ " ⚠ pyproject.toml already has [tool.semantic_release] — "
146
+ "leaving it untouched. Reconcile manually if needed."
147
+ )
148
+ else:
149
+ pyproject.write_text(_inject_psr_block(text, _read_resource(_PSR_CONFIG_NAME)))
150
+ written.append("pyproject.toml")
151
+ else:
152
+ # No pyproject.toml in the project — write a stub
153
+ text = '[project]\nname = "' + _package_name(project_root) + '"\nversion = "0.1.0"\n'
154
+ pyproject.write_text(_inject_psr_block(text, _read_resource(_PSR_CONFIG_NAME)))
155
+ written.append("pyproject.toml")
156
+
157
+ return written
158
+
159
+
160
+ def remove(project_root: Path) -> list[str]:
161
+ """Remove the release pipeline. Returns relpaths removed/modified."""
162
+ removed: list[str] = []
163
+
164
+ for _, dst_rel, mode in _FILES:
165
+ if mode != "framework":
166
+ continue # leave user_seeded files (e.g. PUBLISHING.md) alone
167
+ dst = project_root / dst_rel
168
+ if dst.exists():
169
+ dst.unlink()
170
+ removed.append(dst_rel)
171
+ # Best-effort tidy of empty parents (.github/workflows)
172
+ for parent in (dst.parent, dst.parent.parent):
173
+ try:
174
+ parent.rmdir()
175
+ except OSError:
176
+ break
177
+
178
+ pyproject = project_root / "pyproject.toml"
179
+ if pyproject.exists():
180
+ text = pyproject.read_text()
181
+ if _PSR_FENCE_START in text:
182
+ pyproject.write_text(_strip_psr_block(text))
183
+ removed.append("pyproject.toml")
184
+
185
+ return removed
@@ -0,0 +1,84 @@
1
+ # Publishing `{{PACKAGE_NAME}}`
2
+
3
+ Releases are automated by [python-semantic-release][psr] + GitHub Actions. The workflow at [`.github/workflows/release.yml`](.github/workflows/release.yml) runs on every push to `main` and `develop`.
4
+
5
+ | Branch | Channel | Version style | Trigger |
6
+ |---|---|---|---|
7
+ | `main` | [PyPI](https://pypi.org/project/{{PACKAGE_NAME}}/) | `0.2.0`, `0.3.0`, … | merge PR into `main` |
8
+ | `develop` | [TestPyPI](https://test.pypi.org/project/{{PACKAGE_NAME}}/) | `0.2.0-dev.1`, `0.2.0-dev.2`, … | push to `develop` |
9
+
10
+ ## How versions are computed
11
+
12
+ [python-semantic-release][psr] reads Conventional Commits since the last tag and bumps accordingly:
13
+
14
+ | Commit prefix | Bump |
15
+ |---|---|
16
+ | `feat:` | minor (0.X.0) |
17
+ | `fix:` / `perf:` | patch (0.X.Y) |
18
+ | Footer contains `BREAKING CHANGE:` | major — **suppressed** while at 0.x (see `major_on_zero = false` in `pyproject.toml`) |
19
+
20
+ The release workflow:
21
+
22
+ 1. Inspects commits, computes the next version
23
+ 2. Updates `pyproject.toml:project.version`
24
+ 3. Generates / appends to `CHANGELOG.md`
25
+ 4. Commits the bump (`chore(release): X.Y.Z`)
26
+ 5. Tags `vX.Y.Z`
27
+ 6. Builds wheel + sdist with `uv build`
28
+ 7. Publishes to PyPI (main) or TestPyPI (develop)
29
+ 8. Creates a GitHub Release and attaches the dists
30
+
31
+ ## One-time setup (you must do this once before the first release)
32
+
33
+ ### 1. Reserve the package name with PyPI Trusted Publishing (OIDC)
34
+
35
+ PyPI Trusted Publishing means no long-lived API tokens — GitHub Actions authenticates to PyPI via short-lived OIDC tokens scoped to a specific workflow + environment.
36
+
37
+ **PyPI** — https://pypi.org/manage/account/publishing/ → *Add a new pending publisher*:
38
+
39
+ | Field | Value |
40
+ |---|---|
41
+ | PyPI project name | `{{PACKAGE_NAME}}` |
42
+ | Owner | `{{OWNER}}` |
43
+ | Repository name | `{{REPO}}` |
44
+ | Workflow filename | `release.yml` |
45
+ | Environment name | `pypi` |
46
+
47
+ **TestPyPI** — https://test.pypi.org/manage/account/publishing/ → same form:
48
+
49
+ | Field | Value |
50
+ |---|---|
51
+ | PyPI project name | `{{PACKAGE_NAME}}` |
52
+ | Owner | `{{OWNER}}` |
53
+ | Repository name | `{{REPO}}` |
54
+ | Workflow filename | `release.yml` |
55
+ | Environment name | `testpypi` |
56
+
57
+ Both stay "pending" until the first publish, which claims the name on each index.
58
+
59
+ ### 2. Create the matching GitHub environments
60
+
61
+ In this repo's settings → *Environments* → *New environment*, create two:
62
+
63
+ - `pypi` — optionally add a *Required reviewers* protection rule so a human approves each PyPI publish.
64
+ - `testpypi` — typically no protection rules (it is the canary).
65
+
66
+ ### 3. Create the `develop` branch
67
+
68
+ ```bash
69
+ git checkout main
70
+ git pull
71
+ git checkout -b develop
72
+ git push -u origin develop
73
+ ```
74
+
75
+ Feature branches → PR into `develop` (→ TestPyPI on merge), `develop` → PR into `main` (→ PyPI on merge).
76
+
77
+ ## Verifying a release
78
+
79
+ ```bash
80
+ uvx --from {{PACKAGE_NAME}} <your-cli> --version # stable
81
+ uvx --index-url https://test.pypi.org/simple/ --from {{PACKAGE_NAME}} <your-cli> --version # pre-release
82
+ ```
83
+
84
+ [psr]: https://python-semantic-release.readthedocs.io/
@@ -0,0 +1,37 @@
1
+ # ──────────────────────────────────────────────────────────────────────
2
+ # Release automation — python-semantic-release reads Conventional Commits
3
+ # (feat → minor, fix/perf → patch, BREAKING CHANGE → major). Pushes to
4
+ # `main` produce stable releases on PyPI; pushes to `develop` produce
5
+ # pre-releases (e.g. 0.2.0-dev.1) on TestPyPI. See PUBLISHING.md for the
6
+ # one-time PyPI/TestPyPI Trusted Publisher setup.
7
+ # Installed by `beacon integration add release`.
8
+ # ──────────────────────────────────────────────────────────────────────
9
+
10
+ [tool.semantic_release]
11
+ version_toml = ["pyproject.toml:project.version"]
12
+ # If you maintain a `__version__` string in your package, add it here too:
13
+ # version_variables = ["src/<your_package>/__init__.py:__version__"]
14
+ build_command = "python -m pip install --quiet uv && uv build"
15
+ major_on_zero = false # stay in 0.x until v1 is explicit
16
+ allow_zero_version = true # permit 0.x line
17
+ commit_parser = "conventional"
18
+
19
+ [tool.semantic_release.commit_parser_options]
20
+ minor_tags = ["feat"]
21
+ patch_tags = ["fix", "perf"]
22
+ allowed_tags = [
23
+ "feat", "fix", "perf", "refactor", "docs",
24
+ "test", "chore", "ci", "build", "style", "revert",
25
+ ]
26
+
27
+ [tool.semantic_release.branches.main]
28
+ match = "^main$"
29
+ prerelease = false
30
+
31
+ [tool.semantic_release.branches.develop]
32
+ match = "^develop$"
33
+ prerelease = true
34
+ prerelease_token = "dev" # → 0.2.0-dev.1, 0.2.0-dev.2, …
35
+
36
+ [tool.semantic_release.changelog]
37
+ exclude_commit_patterns = ["^chore\\(release\\):"]
@@ -0,0 +1,70 @@
1
+ name: Release
2
+
3
+ # Branch → distribution channel:
4
+ # main → PyPI (stable releases)
5
+ # develop → TestPyPI (pre-releases, e.g. 0.2.0-dev.1)
6
+ #
7
+ # Versioning is driven by python-semantic-release reading Conventional
8
+ # Commits since the last tag:
9
+ # feat: → minor bump
10
+ # fix:, perf: → patch bump
11
+ # BREAKING CHANGE → major bump (suppressed while at 0.x — see pyproject)
12
+ #
13
+ # Both PyPI and TestPyPI use Trusted Publishing (OIDC) — no API tokens.
14
+ # One-time setup is in PUBLISHING.md.
15
+
16
+ on:
17
+ push:
18
+ branches: [main, develop]
19
+ workflow_dispatch:
20
+
21
+ # Never run two releases at once on the same branch — they would race on
22
+ # tags and dist files.
23
+ concurrency:
24
+ group: release-${{ github.ref }}
25
+ cancel-in-progress: false
26
+
27
+ permissions:
28
+ contents: write # required: PSR pushes the version-bump commit and creates the tag/release
29
+ id-token: write # required: OIDC token for PyPI / TestPyPI trusted publishing
30
+
31
+ jobs:
32
+ release:
33
+ name: Release ${{ github.ref_name == 'main' && 'to PyPI' || 'pre-release to TestPyPI' }}
34
+ runs-on: ubuntu-latest
35
+ environment:
36
+ name: ${{ github.ref_name == 'main' && 'pypi' || 'testpypi' }}
37
+ url: ${{ github.ref_name == 'main' && format('https://pypi.org/project/{0}/', '{{PACKAGE_NAME}}') || format('https://test.pypi.org/project/{0}/', '{{PACKAGE_NAME}}') }}
38
+
39
+ steps:
40
+ - name: Check out (full history for semantic-release commit analysis)
41
+ uses: actions/checkout@v4
42
+ with:
43
+ fetch-depth: 0
44
+ token: ${{ secrets.GITHUB_TOKEN }}
45
+
46
+ - name: Install uv
47
+ uses: astral-sh/setup-uv@v4
48
+
49
+ - name: Python Semantic Release
50
+ id: psr
51
+ uses: python-semantic-release/python-semantic-release@v9
52
+ with:
53
+ github_token: ${{ secrets.GITHUB_TOKEN }}
54
+
55
+ - name: Publish to PyPI
56
+ if: steps.psr.outputs.released == 'true' && github.ref_name == 'main'
57
+ uses: pypa/gh-action-pypi-publish@release/v1
58
+
59
+ - name: Publish pre-release to TestPyPI
60
+ if: steps.psr.outputs.released == 'true' && github.ref_name == 'develop'
61
+ uses: pypa/gh-action-pypi-publish@release/v1
62
+ with:
63
+ repository-url: https://test.pypi.org/legacy/
64
+
65
+ - name: Attach distributions to GitHub Release
66
+ if: steps.psr.outputs.released == 'true'
67
+ uses: python-semantic-release/publish-action@v9
68
+ with:
69
+ github_token: ${{ secrets.GITHUB_TOKEN }}
70
+ tag: ${{ steps.psr.outputs.tag }}