napkinstack 0.9.6__tar.gz → 0.10.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.
- {napkinstack-0.9.6 → napkinstack-0.10.0}/PKG-INFO +5 -4
- {napkinstack-0.9.6 → napkinstack-0.10.0}/README.md +4 -3
- {napkinstack-0.9.6 → napkinstack-0.10.0}/pyproject.toml +1 -1
- {napkinstack-0.9.6 → napkinstack-0.10.0}/pyproject.toml.orig +1 -1
- napkinstack-0.10.0/src/napkinstack/attest.py +207 -0
- {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/cli.py +19 -4
- {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/doctor.py +5 -1
- {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/project.py +1 -0
- {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/pull_request.py +43 -0
- {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/settings.py +10 -2
- {napkinstack-0.9.6 → napkinstack-0.10.0}/LICENSE +0 -0
- {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/__init__.py +0 -0
- {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/assurance.py +0 -0
- {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/compat.py +0 -0
- {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/discovery.py +0 -0
- {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/fitness/__init__.py +0 -0
- {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/fitness/boundaries.py +0 -0
- {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/fitness/hygiene.py +0 -0
- {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/fitness/manifests.py +0 -0
- {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/fitness/plan.py +0 -0
- {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/fitness/pr_scope.py +0 -0
- {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/landed.py +0 -0
- {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/modules.py +0 -0
- {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/provenance.py +0 -0
- {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/skills.py +0 -0
- {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/templates/contracts/AGENTS.md +0 -0
- {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/templates/contracts/MANIFEST.yaml +0 -0
- {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/templates/contracts/README.md +0 -0
- {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/templates/contracts/docs/runbook.md +0 -0
- {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/templates/contracts/tests/.gitkeep +0 -0
- {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/templates/module/AGENTS.md +0 -0
- {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/templates/module/MANIFEST.yaml +0 -0
- {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/templates/module/README.md +0 -0
- {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/templates/module/src/.gitkeep +0 -0
- {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/templates/module/tests/.gitkeep +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: napkinstack
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.0
|
|
4
4
|
Summary: Engineering framework for several teams and their agents working in one repository: modules, contracts, guardrails in CI.
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -20,7 +20,7 @@ modules, contracts, guardrails in CI. On the Django or Rails model, one command
|
|
|
20
20
|
the project, which then receives new versions on demand; no application stack is imposed.
|
|
21
21
|
Positioning and vocabulary: [`PRODUCT.md`](PRODUCT.md) §1.
|
|
22
22
|
|
|
23
|
-
> **Status: v0.
|
|
23
|
+
> **Status: v0.10.0, published** on [PyPI](https://pypi.org/project/napkinstack/).
|
|
24
24
|
> What each version changes, engine and project apart: [`CHANGELOG.md`](CHANGELOG.md).
|
|
25
25
|
> What is open: [`docs/governance/workstreams.md`](docs/governance/workstreams.md).
|
|
26
26
|
|
|
@@ -84,8 +84,8 @@ To try it without installing anything, or to install it from this repository ins
|
|
|
84
84
|
registry — always pinned to a release tag:
|
|
85
85
|
|
|
86
86
|
```bash
|
|
87
|
-
uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.
|
|
88
|
-
uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.
|
|
87
|
+
uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.10.0" nstack init my-project
|
|
88
|
+
uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.10.0"
|
|
89
89
|
```
|
|
90
90
|
|
|
91
91
|
**Two channels, one published artefact.** The registry publishes: the PyPI artefact of a
|
|
@@ -120,6 +120,7 @@ Each project then pins its version and changes it through `nstack update`.
|
|
|
120
120
|
| `nstack release [module] [args]` | Makes each module's artefacts into `dist/<module>/`, each after what it assembles; what follows a named module goes to its command |
|
|
121
121
|
| `nstack pr-check` | The test sheet and the cycle, read from the pull request description |
|
|
122
122
|
| `nstack landed --span <before>..<after>` | What reached this branch outside a pull request, recorded |
|
|
123
|
+
| `nstack attest --base <base> --body-file <description>` | The CI's checks run here, on a clean head; the run recorded on the commit, in git (`refs/notes/nstack/checks`), and shown on GitHub as `nstack / local checks` |
|
|
123
124
|
| `nstack skills` | Exposes the playbooks as skills for the agent |
|
|
124
125
|
| `nstack update` | Lays the new version on a branch to review |
|
|
125
126
|
|
|
@@ -5,7 +5,7 @@ modules, contracts, guardrails in CI. On the Django or Rails model, one command
|
|
|
5
5
|
the project, which then receives new versions on demand; no application stack is imposed.
|
|
6
6
|
Positioning and vocabulary: [`PRODUCT.md`](PRODUCT.md) §1.
|
|
7
7
|
|
|
8
|
-
> **Status: v0.
|
|
8
|
+
> **Status: v0.10.0, published** on [PyPI](https://pypi.org/project/napkinstack/).
|
|
9
9
|
> What each version changes, engine and project apart: [`CHANGELOG.md`](CHANGELOG.md).
|
|
10
10
|
> What is open: [`docs/governance/workstreams.md`](docs/governance/workstreams.md).
|
|
11
11
|
|
|
@@ -69,8 +69,8 @@ To try it without installing anything, or to install it from this repository ins
|
|
|
69
69
|
registry — always pinned to a release tag:
|
|
70
70
|
|
|
71
71
|
```bash
|
|
72
|
-
uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.
|
|
73
|
-
uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.
|
|
72
|
+
uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.10.0" nstack init my-project
|
|
73
|
+
uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.10.0"
|
|
74
74
|
```
|
|
75
75
|
|
|
76
76
|
**Two channels, one published artefact.** The registry publishes: the PyPI artefact of a
|
|
@@ -105,6 +105,7 @@ Each project then pins its version and changes it through `nstack update`.
|
|
|
105
105
|
| `nstack release [module] [args]` | Makes each module's artefacts into `dist/<module>/`, each after what it assembles; what follows a named module goes to its command |
|
|
106
106
|
| `nstack pr-check` | The test sheet and the cycle, read from the pull request description |
|
|
107
107
|
| `nstack landed --span <before>..<after>` | What reached this branch outside a pull request, recorded |
|
|
108
|
+
| `nstack attest --base <base> --body-file <description>` | The CI's checks run here, on a clean head; the run recorded on the commit, in git (`refs/notes/nstack/checks`), and shown on GitHub as `nstack / local checks` |
|
|
108
109
|
| `nstack skills` | Exposes the playbooks as skills for the agent |
|
|
109
110
|
| `nstack update` | Lays the new version on a branch to review |
|
|
110
111
|
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Checks run where the team works, recorded in git (PDR-0013, ADR-0006).
|
|
3
|
+
|
|
4
|
+
When the forge cannot run a pull request's checks, the team runs them on a workstation: this
|
|
5
|
+
command runs every check the project's CI runs — from one list, bound to the workflows by a
|
|
6
|
+
test — on a clean tree at a commit, and appends the run to that commit's note under
|
|
7
|
+
`refs/notes/nstack/checks`, one JSON line per run. The note travels with the branch's remote
|
|
8
|
+
and is merged line by line; the latest line for a commit is its record. On GitHub, with a token
|
|
9
|
+
that may write statuses, the run is also shown as `nstack / local checks` — a name no CI job
|
|
10
|
+
has. A record is *attested*, never *checked*: it makes a claim traceable, not true.
|
|
11
|
+
|
|
12
|
+
Usage : nstack attest [--root ROOT] [--base BASE] [--body-file FILE] [--labels a,b]
|
|
13
|
+
Output: 0 when every check passed, 1 otherwise — the record is written either way.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import datetime
|
|
19
|
+
import hashlib
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
import tempfile
|
|
25
|
+
import urllib.request
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from napkinstack import provenance
|
|
30
|
+
|
|
31
|
+
NOTES = "nstack/checks"
|
|
32
|
+
RECORD = "attested-checks/v1"
|
|
33
|
+
CONTEXT = "nstack / local checks"
|
|
34
|
+
KEPT = 64 * 1024 # bytes of a step's output kept in the record; the whole is hashed
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Step:
|
|
39
|
+
signature: str # what the job's own `run` holds: the test binding the list to the workflows reads it
|
|
40
|
+
label: str
|
|
41
|
+
command: str # bash, at the root; NSTACK_BASE, NSTACK_BODY, NSTACK_TMP and PR_LABELS are set
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class Check:
|
|
46
|
+
name: str # the CI job's name
|
|
47
|
+
steps: tuple[Step, ...]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# The project's CI, job by job, as a workstation runs it (skeleton/.github/workflows/). A job or a
|
|
51
|
+
# step in one place and not the other fails platform/tests/test_attest.py.
|
|
52
|
+
CHECKS: tuple[Check, ...] = (
|
|
53
|
+
Check("Fitness functions", (
|
|
54
|
+
Step("nstack fitness", "Manifests, boundaries, skills, plan, hygiene", "nstack fitness --root ."),)),
|
|
55
|
+
Check("PR scope and review budget", (
|
|
56
|
+
Step("nstack pr-scope", "One PR = one module", 'nstack pr-scope --root . --base "$NSTACK_BASE"'),)),
|
|
57
|
+
Check("Hooks and secrets", (
|
|
58
|
+
Step("pre-commit run --all-files", "Hooks on every file",
|
|
59
|
+
"SKIP=gitleaks pre-commit run --all-files --show-diff-on-failure"),
|
|
60
|
+
Step("pre-commit run gitleaks-history", "Secrets in the whole git history",
|
|
61
|
+
"pre-commit run gitleaks-history --hook-stage manual --all-files"),
|
|
62
|
+
Step("pre-commit run no-session-link", "No private session link in a message",
|
|
63
|
+
'{ cat "$NSTACK_BODY"; git log --format=%B "$NSTACK_BASE..HEAD"; } > "$NSTACK_TMP/said.txt" '
|
|
64
|
+
'&& pre-commit run no-session-link --hook-stage commit-msg --commit-msg-filename "$NSTACK_TMP/said.txt"'),
|
|
65
|
+
)),
|
|
66
|
+
Check("Test sheet and cycle", (
|
|
67
|
+
Step("nstack pr-check", "Test sheet and cycle",
|
|
68
|
+
'nstack pr-check --root . --base "$NSTACK_BASE" --body-file "$NSTACK_BODY"'),)),
|
|
69
|
+
Check("Module checks", (
|
|
70
|
+
Step("bash .nstack/check-modules.sh", "Every changed module",
|
|
71
|
+
'BASE="$NSTACK_BASE" bash .nstack/check-modules.sh'),)),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _git(root: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess:
|
|
76
|
+
return subprocess.run(["git", *args], cwd=root, capture_output=True, text=True, check=check)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def capped(text: str) -> str:
|
|
80
|
+
"""A step's output as the record keeps it: whole up to KEPT bytes, else its head and its tail."""
|
|
81
|
+
data = text.encode("utf-8", "replace")
|
|
82
|
+
if len(data) <= KEPT:
|
|
83
|
+
return text
|
|
84
|
+
half = KEPT // 2
|
|
85
|
+
cut = len(data) - KEPT
|
|
86
|
+
return (data[:half].decode("utf-8", "ignore") + f"\n[… {cut} bytes cut …]\n"
|
|
87
|
+
+ data[-half:].decode("utf-8", "ignore"))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def latest(root: Path, commit: str) -> dict | None:
|
|
91
|
+
"""The record of a commit: the latest run its note holds, or None."""
|
|
92
|
+
shown = _git(root, "notes", f"--ref={NOTES}", "show", commit, check=False)
|
|
93
|
+
if shown.returncode != 0:
|
|
94
|
+
return None
|
|
95
|
+
runs = []
|
|
96
|
+
for line in shown.stdout.splitlines():
|
|
97
|
+
try:
|
|
98
|
+
run = json.loads(line)
|
|
99
|
+
except json.JSONDecodeError:
|
|
100
|
+
continue
|
|
101
|
+
if isinstance(run, dict) and run.get("record") == RECORD and run.get("commit") == commit:
|
|
102
|
+
runs.append(run)
|
|
103
|
+
return max(runs, key=lambda run: str(run.get("at", "")), default=None)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _remote(root: Path) -> str | None:
|
|
107
|
+
"""The branch's remote, else `origin`, else none."""
|
|
108
|
+
branch = _git(root, "symbolic-ref", "--quiet", "--short", "HEAD", check=False).stdout.strip()
|
|
109
|
+
if branch:
|
|
110
|
+
named = _git(root, "config", f"branch.{branch}.remote", check=False).stdout.strip()
|
|
111
|
+
if named:
|
|
112
|
+
return named
|
|
113
|
+
remotes = _git(root, "remote", check=False).stdout.split()
|
|
114
|
+
return "origin" if "origin" in remotes else None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def fetch(root: Path) -> str | None:
|
|
118
|
+
"""Brings the remote's notes in, merged line by line with ours; returns the remote's name."""
|
|
119
|
+
remote = _remote(root)
|
|
120
|
+
if remote is None:
|
|
121
|
+
return None
|
|
122
|
+
theirs = f"refs/notes/nstack-remote/{NOTES.split('/')[-1]}"
|
|
123
|
+
got = _git(root, "fetch", "--quiet", remote, f"+refs/notes/{NOTES}:{theirs}", check=False)
|
|
124
|
+
if got.returncode == 0:
|
|
125
|
+
_git(root, "notes", f"--ref={NOTES}", "merge", "--quiet", "--strategy=cat_sort_uniq", theirs, check=False)
|
|
126
|
+
return remote
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _session(root: Path, commit: str) -> str | None:
|
|
130
|
+
trailers = _git(root, "log", "-1", "--format=%(trailers:key=Agent-Session,valueonly)", commit).stdout
|
|
131
|
+
return trailers.strip().splitlines()[0] if trailers.strip() else None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _show(root: Path, commit: str, passed: int, total: int) -> str:
|
|
135
|
+
"""The forge's display, when there is one and a token: never a judgement, a view of the record."""
|
|
136
|
+
token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")
|
|
137
|
+
url = _git(root, "remote", "get-url", _remote(root) or "origin", check=False).stdout.strip()
|
|
138
|
+
from napkinstack.doctor import ON_GITHUB # the doctor's reading of a GitHub remote
|
|
139
|
+
|
|
140
|
+
found = ON_GITHUB.search(url)
|
|
141
|
+
if not token or not found:
|
|
142
|
+
return "not shown on a forge (no GitHub remote, or no token)"
|
|
143
|
+
api = os.environ.get("GITHUB_API_URL", "https://api.github.com").rstrip("/")
|
|
144
|
+
body = json.dumps({"state": "success" if passed == total else "failure", "context": CONTEXT,
|
|
145
|
+
"description": f"attested: {passed} of {total}"}).encode()
|
|
146
|
+
request = urllib.request.Request(f"{api}/repos/{found.group(1)}/statuses/{commit}", data=body, method="POST",
|
|
147
|
+
headers={"Accept": "application/vnd.github+json",
|
|
148
|
+
"Authorization": f"Bearer {token}"})
|
|
149
|
+
try:
|
|
150
|
+
with urllib.request.urlopen(request, timeout=10):
|
|
151
|
+
return f"shown on GitHub as '{CONTEXT}'"
|
|
152
|
+
except (OSError, ValueError) as error:
|
|
153
|
+
return f"not shown on GitHub ({error})"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def run(root: Path, base: str, body_file: Path | None, labels: str) -> int:
|
|
157
|
+
print(provenance.judged_by(root), flush=True)
|
|
158
|
+
if _git(root, "status", "--porcelain", "--untracked-files=normal").stdout.strip():
|
|
159
|
+
print("FAIL [attest] the tree holds changes: a record names one commit, and this tree is none.\n"
|
|
160
|
+
" Action: commit or stash them, then run nstack attest again.")
|
|
161
|
+
return 1
|
|
162
|
+
commit = _git(root, "rev-parse", "HEAD").stdout.strip()
|
|
163
|
+
engine, origin = provenance.engine()
|
|
164
|
+
env = dict(os.environ, PR_LABELS=labels)
|
|
165
|
+
# The engine that runs this command runs the checks: its folder first on the path.
|
|
166
|
+
env["PATH"] = os.pathsep.join([str(Path(sys.argv[0]).resolve().parent), env.get("PATH", "")])
|
|
167
|
+
results = []
|
|
168
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
169
|
+
body = body_file or Path(tmp, "body.md")
|
|
170
|
+
if body_file is None:
|
|
171
|
+
body.write_text("", encoding="utf-8")
|
|
172
|
+
# NSTACK_ATTESTING: pr-check, run as one of the checks, judges the pull request's rules
|
|
173
|
+
# without asking for the record this run is making (A1, A2) — found rehearsing v0.10.0.
|
|
174
|
+
env.update(NSTACK_BASE=base, NSTACK_BODY=str(Path(body).resolve()), NSTACK_TMP=tmp,
|
|
175
|
+
NSTACK_ATTESTING=commit)
|
|
176
|
+
for check in CHECKS:
|
|
177
|
+
steps = []
|
|
178
|
+
for step in check.steps:
|
|
179
|
+
done = subprocess.run(["bash", "-c", step.command], cwd=root, env=env,
|
|
180
|
+
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
|
181
|
+
steps.append({"label": step.label, "command": step.command, "exit": done.returncode,
|
|
182
|
+
"output": capped(done.stdout),
|
|
183
|
+
"sha256": hashlib.sha256(done.stdout.encode("utf-8", "replace")).hexdigest()})
|
|
184
|
+
passed = all(step["exit"] == 0 for step in steps)
|
|
185
|
+
results.append({"name": check.name, "passed": passed, "steps": steps})
|
|
186
|
+
failed = "; ".join(f"{s['label']} exited {s['exit']}" for s in steps if s["exit"])
|
|
187
|
+
print(f"FAIL [attest] {check.name}: {failed}" if failed else f"ok [attest] {check.name}", flush=True)
|
|
188
|
+
# Who ran it, as git would sign a commit made here — its environment and its configuration.
|
|
189
|
+
ident = _git(root, "var", "GIT_COMMITTER_IDENT", check=False).stdout.strip()
|
|
190
|
+
who = ident.rsplit(" ", 2)[0] if ident.count(" ") >= 2 else ident
|
|
191
|
+
record = {"record": RECORD, "commit": commit,
|
|
192
|
+
"at": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="microseconds"),
|
|
193
|
+
"engine": engine, "published": origin is None, "by": who, "session": _session(root, commit),
|
|
194
|
+
"base": base, "checks": results}
|
|
195
|
+
remote = fetch(root)
|
|
196
|
+
before = _git(root, "notes", f"--ref={NOTES}", "show", commit, check=False)
|
|
197
|
+
lines = (before.stdout.rstrip("\n") + "\n" if before.returncode == 0 and before.stdout.strip() else "")
|
|
198
|
+
lines += json.dumps(record, separators=(",", ":"), ensure_ascii=False) + "\n"
|
|
199
|
+
subprocess.run(["git", "notes", f"--ref={NOTES}", "add", "--force", "--file=-", commit], cwd=root,
|
|
200
|
+
input=lines, text=True, capture_output=True, check=True)
|
|
201
|
+
passed = sum(1 for result in results if result["passed"])
|
|
202
|
+
where = "local only: no remote"
|
|
203
|
+
if remote is not None:
|
|
204
|
+
pushed = _git(root, "push", "--quiet", remote, f"refs/notes/{NOTES}", check=False)
|
|
205
|
+
where = f"pushed to {remote}" if pushed.returncode == 0 else f"NOT pushed to {remote}: {pushed.stderr.strip()}"
|
|
206
|
+
print(f"Record of {commit[:12]}: attested: {passed} of {len(results)}; {where}; {_show(root, commit, passed, len(results))}.")
|
|
207
|
+
return 0 if passed == len(results) else 1
|
|
@@ -8,10 +8,19 @@ import os
|
|
|
8
8
|
import sys
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
|
|
11
|
-
from napkinstack import assurance
|
|
11
|
+
from napkinstack import assurance, attest
|
|
12
12
|
from napkinstack import (compat, discovery, doctor, landed, modules, provenance, settings,
|
|
13
13
|
pull_request, skills)
|
|
14
14
|
from napkinstack.fitness import boundaries, hygiene, manifests, plan, pr_scope
|
|
15
|
+
from napkinstack.project import ANSWERS
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _here() -> Path:
|
|
19
|
+
"""The project the current folder belongs to: the nearest folder, this one or above, holding
|
|
20
|
+
the project's answers — as git and cargo find their root (D130). Outside any project, the
|
|
21
|
+
current folder, as before."""
|
|
22
|
+
here = Path.cwd()
|
|
23
|
+
return next((folder for folder in (here, *here.parents) if (folder / ANSWERS).is_file()), here)
|
|
15
24
|
|
|
16
25
|
|
|
17
26
|
def _root(value: str) -> Path:
|
|
@@ -39,8 +48,9 @@ def _fitness(root: Path) -> int:
|
|
|
39
48
|
|
|
40
49
|
def _add(sub, name: str, help_: str, func) -> argparse.ArgumentParser:
|
|
41
50
|
parser = sub.add_parser(name, help=help_)
|
|
42
|
-
parser.add_argument("--root", type=_root, default=
|
|
43
|
-
help="project root (default:
|
|
51
|
+
parser.add_argument("--root", type=_root, default=_here(),
|
|
52
|
+
help="project root (default: the nearest folder holding .copier-answers.yml, "
|
|
53
|
+
"this one or above; else the current folder)")
|
|
44
54
|
parser.set_defaults(func=func)
|
|
45
55
|
return parser
|
|
46
56
|
|
|
@@ -146,6 +156,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
146
156
|
lambda a: landed.run(a.root, a.span, landed.from_environment(a.root)))
|
|
147
157
|
ld.add_argument("--span", default="HEAD~1..HEAD",
|
|
148
158
|
help="the commits to read, BEFORE..AFTER (default: the last commit)")
|
|
159
|
+
at = _add(sub, "attest", "runs the CI's checks here and records the run on the commit, in git",
|
|
160
|
+
lambda a: attest.run(a.root, a.base, a.body_file, a.labels))
|
|
161
|
+
at.add_argument("--base", default="origin/main")
|
|
162
|
+
at.add_argument("--body-file", type=Path, help="the pull request's description, for its checks")
|
|
163
|
+
at.add_argument("--labels", default="", help="the pull request's labels, comma-separated")
|
|
149
164
|
ds = _add(sub, "discover", "starts a discovery from an idea file, for the team's agent",
|
|
150
165
|
lambda a: discovery.run(a.root, a.idea))
|
|
151
166
|
ds.add_argument("idea", type=Path, help="the idea, a .md or .txt file")
|
|
@@ -176,7 +191,7 @@ def _named_root(argv: list[str]) -> Path:
|
|
|
176
191
|
return Path(argv[index + 1])
|
|
177
192
|
if word.startswith("--root="):
|
|
178
193
|
return Path(word.removeprefix("--root="))
|
|
179
|
-
return
|
|
194
|
+
return _here()
|
|
180
195
|
|
|
181
196
|
|
|
182
197
|
def _release(a) -> int:
|
|
@@ -69,7 +69,11 @@ API_VERSION = "2026-03-10"
|
|
|
69
69
|
JOBS = ("Fitness functions", "PR scope and review budget", "Hooks and secrets", "Test sheet and cycle",
|
|
70
70
|
"Module checks")
|
|
71
71
|
THIRD_PARTY_ACTIONS = ("astral-sh/setup-uv",) # non-GitHub actions of the skeleton workflows
|
|
72
|
-
|
|
72
|
+
# The pull requests' three exceptions, then what the shipped issue templates put on an issue: a
|
|
73
|
+
# label that does not exist is dropped by GitHub without a word (D128).
|
|
74
|
+
EXCEPTIONS = ("cross-module", "over-budget", "out-of-cycle")
|
|
75
|
+
ISSUES = ("request", "bug", "architecture", "debt", "acceptance")
|
|
76
|
+
LABELS = EXCEPTIONS + ISSUES
|
|
73
77
|
PUBLISHED = provenance.PUBLISHED # a published version is a vX.Y.Z tag (ADR-0002), as CI installs it
|
|
74
78
|
REMOTE = re.compile(r"https://|ssh://|git@[^:/]+:|gh:|gl:") # as .nstack/install-engine.sh reads them
|
|
75
79
|
ON_GITHUB = re.compile(r"(?:https://github\.com/|ssh://git@github\.com/|git@github\.com:|gh:)([^/]+/[^/]+?)(?:\.git)?/?$")
|
|
@@ -140,6 +140,7 @@ def init(destination: Path, answers: dict[str, str | None], source: str, ref: st
|
|
|
140
140
|
f"folder inside it — nstack init {destination}/<project>.")
|
|
141
141
|
return 1
|
|
142
142
|
data = {question: value for question, value in answers.items() if value is not None}
|
|
143
|
+
data.setdefault("checks", "forge") # a project starts judged on the forge; the decider may change it
|
|
143
144
|
try:
|
|
144
145
|
copier.run_copy(source, destination, data=data, vcs_ref=ref, quiet=True, unsafe=False)
|
|
145
146
|
except (CopierError, ValueError, OSError) as exc:
|
|
@@ -51,6 +51,7 @@ from napkinstack.fitness import plan
|
|
|
51
51
|
from napkinstack import assurance
|
|
52
52
|
from napkinstack.fitness.manifests import MODULE_DIRS, changed_files, find_manifests, is_description
|
|
53
53
|
from napkinstack.modules import LOGIN
|
|
54
|
+
from napkinstack.project import ANSWERS
|
|
54
55
|
|
|
55
56
|
CHARTER = plan.CHARTER.as_posix() # docs/project/charter.md, as the diff names it
|
|
56
57
|
|
|
@@ -388,6 +389,46 @@ def charter_without_pdr(root: Path, base: str, files: list[str]) -> list[str]:
|
|
|
388
389
|
"correction that decides nothing needs none (.nstack/playbooks/framing.md, Decisions)."]
|
|
389
390
|
|
|
390
391
|
|
|
392
|
+
def check_attested(root: Path, head: str, fail) -> str:
|
|
393
|
+
"""Where the checks are judged, as the decider's answer `checks` says (A1, A2): on the forge's
|
|
394
|
+
CI, or attested on a workstation by `nstack attest` — then a complete, passed record of the
|
|
395
|
+
head is required. Returns the line the summary prints."""
|
|
396
|
+
from napkinstack import attest # the record's reader, and the one list of checks
|
|
397
|
+
|
|
398
|
+
try:
|
|
399
|
+
answers = yaml.safe_load((root / ANSWERS).read_text(encoding="utf-8")) or {}
|
|
400
|
+
except (OSError, yaml.YAMLError):
|
|
401
|
+
answers = {}
|
|
402
|
+
attest.fetch(root)
|
|
403
|
+
record = attest.latest(root, head)
|
|
404
|
+
if answers.get("checks", "forge") != "attested":
|
|
405
|
+
shown = ""
|
|
406
|
+
if record:
|
|
407
|
+
passed = sum(1 for check in record["checks"] if check["passed"])
|
|
408
|
+
shown = f"; a record of the head: attested {passed} of {len(record['checks'])} (information)"
|
|
409
|
+
return "the forge's CI" + shown
|
|
410
|
+
if os.environ.get("NSTACK_ATTESTING") == head:
|
|
411
|
+
return "attested — this run is the record being made"
|
|
412
|
+
if os.environ.get("GITHUB_ACTIONS") == "true":
|
|
413
|
+
# CI's checkout keeps no credential to fetch the notes: where the checks are attested, the
|
|
414
|
+
# record is read where the merge is decided, never failed here for want of a fetch.
|
|
415
|
+
return "attested — judged where nstack attest runs, not in CI"
|
|
416
|
+
if record is None:
|
|
417
|
+
fail("A1", f"no record of the head {head[:7]}: this project's checks are attested, and none ran on "
|
|
418
|
+
"this commit.\n Action: run nstack attest on the head, then push.")
|
|
419
|
+
return "attested — no record of the head"
|
|
420
|
+
names = [check["name"] for check in record["checks"]]
|
|
421
|
+
missing = [check.name for check in attest.CHECKS if check.name not in names]
|
|
422
|
+
failed = [check["name"] for check in record["checks"] if not check["passed"]]
|
|
423
|
+
if missing or failed:
|
|
424
|
+
said = "; ".join(part for part in (f"missing: {', '.join(missing)}" if missing else "",
|
|
425
|
+
f"failed: {', '.join(failed)}" if failed else "") if part)
|
|
426
|
+
fail("A2", f"the record of the head {head[:7]} is not complete and passed ({said}).\n"
|
|
427
|
+
" Action: fix what failed, commit, and run nstack attest again.")
|
|
428
|
+
return "attested — the record of the head does not pass"
|
|
429
|
+
return f"attested on a workstation, by {record['by']}, at {record['at']}"
|
|
430
|
+
|
|
431
|
+
|
|
391
432
|
def run(root: Path, base: str, body_file: Path | None = None) -> int:
|
|
392
433
|
if body_file is not None:
|
|
393
434
|
body = body_file.read_text(encoding="utf-8")
|
|
@@ -423,9 +464,11 @@ def run(root: Path, base: str, body_file: Path | None = None) -> int:
|
|
|
423
464
|
lambda commit: bool(tree_of(commit)) and tree_of(commit) == tree_of(head))
|
|
424
465
|
if modules:
|
|
425
466
|
check_cycle(root, body, labels, datetime.date.today(), fail, warnings.append)
|
|
467
|
+
checks = check_attested(root, head, fail)
|
|
426
468
|
|
|
427
469
|
print(f"Modules touched : {len(modules)}" + "".join(f"\n - {folder}" for folder in modules))
|
|
428
470
|
print(f"Test sheet : {'required' if reasons else 'not required'}")
|
|
471
|
+
print(f"Checks : {checks}")
|
|
429
472
|
for line in summary(body, authorship) if reasons else []:
|
|
430
473
|
print(line)
|
|
431
474
|
for scenario in human_only:
|
|
@@ -39,10 +39,18 @@ from napkinstack.project import ANSWERS
|
|
|
39
39
|
RULESET_NAME = "nstack-checklist"
|
|
40
40
|
IN_RULESET = ("G1", "G2", "G3", "G4", "G13", "G17", "G18")
|
|
41
41
|
PULL_REQUEST = ("G1", "G2", "G3", "G13")
|
|
42
|
-
LABEL_COLOURS = {"cross-module": "d93f0b", "over-budget": "fbca04", "out-of-cycle": "5319e7"
|
|
42
|
+
LABEL_COLOURS = {"cross-module": "d93f0b", "over-budget": "fbca04", "out-of-cycle": "5319e7",
|
|
43
|
+
"request": "0e8a16", "bug": "d73a4a", "architecture": "1d76db", "debt": "c5def5",
|
|
44
|
+
"acceptance": "bfdadc"}
|
|
45
|
+
# An issue label means what its template says: the same words, the template being the source.
|
|
43
46
|
LABEL_MEANING = {"cross-module": "A pull request touching several modules, with its justification",
|
|
44
47
|
"over-budget": "A pull request beyond the review budget, with its justification",
|
|
45
|
-
"out-of-cycle": "Work outside the current cycle, with its justification"
|
|
48
|
+
"out-of-cycle": "Work outside the current cycle, with its justification",
|
|
49
|
+
"request": "Something asked for that no cycle plans yet",
|
|
50
|
+
"bug": "Incorrect behaviour in production or in test",
|
|
51
|
+
"architecture": "Boundary, contract, coupling, structuring decision",
|
|
52
|
+
"debt": "Identified debt, with its cost and its trigger",
|
|
53
|
+
"acceptance": "A deliverable tried by a person — what to run, what was seen"}
|
|
46
54
|
Call = tuple[str, str, str, dict | None] # (rules closed, method, path under the repository, body)
|
|
47
55
|
|
|
48
56
|
|
|
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
|
{napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/templates/contracts/docs/runbook.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|