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.
- {beacon_framework-0.2.0 → beacon_framework-0.3.0}/PKG-INFO +14 -8
- {beacon_framework-0.2.0 → beacon_framework-0.3.0}/README.md +13 -7
- {beacon_framework-0.2.0 → beacon_framework-0.3.0}/pyproject.toml +3 -1
- {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/__init__.py +1 -1
- {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/cli.py +35 -0
- beacon_framework-0.3.0/src/beacon/doctor.py +265 -0
- {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/integrations/__init__.py +2 -1
- beacon_framework-0.3.0/src/beacon/integrations/release.py +185 -0
- beacon_framework-0.3.0/src/beacon/resources/integrations/release/PUBLISHING.md +84 -0
- beacon_framework-0.3.0/src/beacon/resources/integrations/release/psr_config.toml +37 -0
- beacon_framework-0.3.0/src/beacon/resources/integrations/release/workflows/release.yml +70 -0
- {beacon_framework-0.2.0 → beacon_framework-0.3.0}/.gitignore +0 -0
- {beacon_framework-0.2.0 → beacon_framework-0.3.0}/BEACON.md +0 -0
- {beacon_framework-0.2.0 → beacon_framework-0.3.0}/LICENSE +0 -0
- {beacon_framework-0.2.0 → beacon_framework-0.3.0}/pragmatic-principles.md +0 -0
- {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/__main__.py +0 -0
- {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/installer.py +0 -0
- {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/integrations/claude_code.py +0 -0
- {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/manifest.py +0 -0
- {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/resources/beacon.md +0 -0
- {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/resources/integrations/claude_code/CLAUDE.md.fragment +0 -0
- {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/resources/integrations/claude_code/commands/design/diagram.md +0 -0
- {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/resources/integrations/claude_code/commands/design/evaluate.md +0 -0
- {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/resources/integrations/claude_code/commands/design/wardley.md +0 -0
- {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/resources/integrations/claude_code/commands/git/feature.md +0 -0
- {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/resources/integrations/claude_code/commands/git/pr.md +0 -0
- {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/resources/integrations/claude_code/commands/git/release.md +0 -0
- {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/resources/integrations/claude_code/commands/init.md +0 -0
- {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/resources/project-management/ADRs/ADR-000-template.md +0 -0
- {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/resources/project-management/ADRs/README.md +0 -0
- {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/resources/project-management/Background/00-problem-statement.md +0 -0
- {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/resources/project-management/Background/01-final-architecture-document.md +0 -0
- {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/resources/project-management/Prompts/01-SEED.md +0 -0
- {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/resources/project-management/Prompts/02-DESIGN.md +0 -0
- {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/resources/project-management/Prompts/03-BUILD.md +0 -0
- {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/resources/project-management/Prompts/04-SHIP.md +0 -0
- {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/resources/project-management/Roadmap/README.md +0 -0
- {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/resources/project-management/Roadmap/archive/.gitkeep +0 -0
- {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/resources/project-management/Work/README.md +0 -0
- {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/resources/project-management/Work/analysis/.gitkeep +0 -0
- {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/resources/project-management/Work/planning/.gitkeep +0 -0
- {beacon_framework-0.2.0 → beacon_framework-0.3.0}/src/beacon/resources/project-management/Work/sessions/.gitkeep +0 -0
- {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.
|
|
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]
|
|
41
|
-
beacon upgrade [--here]
|
|
42
|
-
beacon check [--here]
|
|
43
|
-
beacon
|
|
44
|
-
beacon integration
|
|
45
|
-
beacon integration
|
|
46
|
-
beacon
|
|
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]
|
|
21
|
-
beacon upgrade [--here]
|
|
22
|
-
beacon check [--here]
|
|
23
|
-
beacon
|
|
24
|
-
beacon integration
|
|
25
|
-
beacon integration
|
|
26
|
-
beacon
|
|
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.
|
|
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",
|
|
@@ -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 }}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|