napkinstack 0.9.4__tar.gz → 0.9.6__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 (34) hide show
  1. {napkinstack-0.9.4 → napkinstack-0.9.6}/PKG-INFO +5 -4
  2. {napkinstack-0.9.4 → napkinstack-0.9.6}/README.md +4 -3
  3. {napkinstack-0.9.4 → napkinstack-0.9.6}/pyproject.toml +1 -1
  4. {napkinstack-0.9.4 → napkinstack-0.9.6}/pyproject.toml.orig +1 -1
  5. {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/cli.py +25 -2
  6. {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/fitness/boundaries.py +55 -1
  7. {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/fitness/manifests.py +50 -0
  8. {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/modules.py +43 -5
  9. {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/templates/module/MANIFEST.yaml +8 -0
  10. {napkinstack-0.9.4 → napkinstack-0.9.6}/LICENSE +0 -0
  11. {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/__init__.py +0 -0
  12. {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/assurance.py +0 -0
  13. {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/compat.py +0 -0
  14. {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/discovery.py +0 -0
  15. {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/doctor.py +0 -0
  16. {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/fitness/__init__.py +0 -0
  17. {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/fitness/hygiene.py +0 -0
  18. {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/fitness/plan.py +0 -0
  19. {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/fitness/pr_scope.py +0 -0
  20. {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/landed.py +0 -0
  21. {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/project.py +0 -0
  22. {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/provenance.py +0 -0
  23. {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/pull_request.py +0 -0
  24. {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/settings.py +0 -0
  25. {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/skills.py +0 -0
  26. {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/templates/contracts/AGENTS.md +0 -0
  27. {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/templates/contracts/MANIFEST.yaml +0 -0
  28. {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/templates/contracts/README.md +0 -0
  29. {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/templates/contracts/docs/runbook.md +0 -0
  30. {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/templates/contracts/tests/.gitkeep +0 -0
  31. {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/templates/module/AGENTS.md +0 -0
  32. {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/templates/module/README.md +0 -0
  33. {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/templates/module/src/.gitkeep +0 -0
  34. {napkinstack-0.9.4 → napkinstack-0.9.6}/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.9.4
3
+ Version: 0.9.6
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.9.4, published** on [PyPI](https://pypi.org/project/napkinstack/).
23
+ > **Status: v0.9.6, 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.9.4" nstack init my-project
88
- uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.9.4"
87
+ uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.9.6" nstack init my-project
88
+ uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.9.6"
89
89
  ```
90
90
 
91
91
  **Two channels, one published artefact.** The registry publishes: the PyPI artefact of a
@@ -117,6 +117,7 @@ Each project then pins its version and changes it through `nstack update`.
117
117
  | `nstack modules [--changed-since <base>]` | The project's modules, or those with a file changed since a base |
118
118
  | `nstack compat [module] --base <base>` | A contract version consumed or stable changes only with the project's merged comparator |
119
119
  | `nstack e2e [module]` | Runs the module's end-to-end scenarios, when declared |
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 |
120
121
  | `nstack pr-check` | The test sheet and the cycle, read from the pull request description |
121
122
  | `nstack landed --span <before>..<after>` | What reached this branch outside a pull request, recorded |
122
123
  | `nstack skills` | Exposes the playbooks as skills for the agent |
@@ -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.9.4, published** on [PyPI](https://pypi.org/project/napkinstack/).
8
+ > **Status: v0.9.6, 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.9.4" nstack init my-project
73
- uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.9.4"
72
+ uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.9.6" nstack init my-project
73
+ uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.9.6"
74
74
  ```
75
75
 
76
76
  **Two channels, one published artefact.** The registry publishes: the PyPI artefact of a
@@ -102,6 +102,7 @@ Each project then pins its version and changes it through `nstack update`.
102
102
  | `nstack modules [--changed-since <base>]` | The project's modules, or those with a file changed since a base |
103
103
  | `nstack compat [module] --base <base>` | A contract version consumed or stable changes only with the project's merged comparator |
104
104
  | `nstack e2e [module]` | Runs the module's end-to-end scenarios, when declared |
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 |
105
106
  | `nstack pr-check` | The test sheet and the cycle, read from the pull request description |
106
107
  | `nstack landed --span <before>..<after>` | What reached this branch outside a pull request, recorded |
107
108
  | `nstack skills` | Exposes the playbooks as skills for the agent |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "napkinstack"
3
- version = "0.9.4"
3
+ version = "0.9.6"
4
4
  description = "Engineering framework for several teams and their agents working in one repository: modules, contracts, guardrails in CI."
5
5
  requires-python = ">=3.12"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "napkinstack"
3
- version = "0.9.4"
3
+ version = "0.9.6"
4
4
  description = "Engineering framework for several teams and their agents working in one repository: modules, contracts, guardrails in CI."
5
5
  requires-python = ">=3.12"
6
6
  license = "MIT"
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import argparse
6
6
  import json
7
+ import os
7
8
  import sys
8
9
  from pathlib import Path
9
10
 
@@ -73,9 +74,9 @@ def build_parser() -> argparse.ArgumentParser:
73
74
  parser = argparse.ArgumentParser(prog="nstack", description="NapkinStack engine.")
74
75
  parser.add_argument("--version", action=_Version, nargs=0, help="the engine's version, and where it came from")
75
76
  sub = parser.add_subparsers(dest="command", required=True, metavar="command")
76
- _add(sub, "manifests", "manifests, lifecycles, deprecations (M1-M10)",
77
+ _add(sub, "manifests", "manifests, lifecycles, deprecations, what a release assembles (M1-M11)",
77
78
  lambda a: manifests.run(a.root))
78
- _add(sub, "boundaries", "declared graph against real graph (B1-B8)",
79
+ _add(sub, "boundaries", "declared graph against real graph, release artefacts included (B1-B9)",
79
80
  lambda a: boundaries.run(a.root))
80
81
  _add(sub, "plan", "the discovery, the charter and the cycles (C1-C8)", lambda a: plan.run(a.root))
81
82
  sk = _add(sub, "skills", "generates or checks the skills (S1-S4)",
@@ -115,6 +116,11 @@ def build_parser() -> argparse.ArgumentParser:
115
116
  lambda a: compat.run(a.root, a.module, a.base))
116
117
  cp.add_argument("module", nargs="?", help="module holding the contracts (default: all)")
117
118
  cp.add_argument("--base", default="origin/main")
119
+ rl = _add(sub, "release", "makes one module's artefacts, or all of them, each into dist/<module> "
120
+ "(commands.release), with what follows a named module passed to it",
121
+ lambda a: _release(a))
122
+ rl.add_argument("module", nargs="?", help="module name (default: all, each after what it assembles)")
123
+ rl.add_argument("arguments", nargs=argparse.REMAINDER, help="passed to the named module's command")
118
124
  rn = _add(sub, "run", "starts a module locally (commands.run), with what follows passed to it",
119
125
  lambda a: _run(a))
120
126
  rn.add_argument("module")
@@ -173,6 +179,15 @@ def _named_root(argv: list[str]) -> Path:
173
179
  return Path(".")
174
180
 
175
181
 
182
+ def _release(a) -> int:
183
+ """`release` passes what follows a named module to its command, as `run` does."""
184
+ root, arguments = a.root, list(a.arguments)
185
+ if "--root" in arguments[:-1]:
186
+ at = arguments.index("--root")
187
+ root, arguments = _root(arguments[at + 1]), arguments[:at] + arguments[at + 2:]
188
+ return modules.run_verb(root, "release", a.module, arguments)
189
+
190
+
176
191
  def _run(a) -> int:
177
192
  """`run` passes what follows the module to its command; `--root`, written there as the
178
193
  other verbs take it, is still the root."""
@@ -183,8 +198,16 @@ def _run(a) -> int:
183
198
  return modules.run_verb(root, "run", a.module, arguments)
184
199
 
185
200
 
201
+ # What git hands a hook to name its repository (D126): a hook of a linked worktree gets GIT_DIR,
202
+ # and every git the engine runs, in a module or a checkout of its own, would read that one.
203
+ # The engine names its project with --root; git finds the rest from where it runs.
204
+ GIT_LOCATION = ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR")
205
+
206
+
186
207
  def main(argv: list[str] | None = None) -> int:
187
208
  argv = sys.argv[1:] if argv is None else argv
209
+ for name in GIT_LOCATION:
210
+ os.environ.pop(name, None)
188
211
  if late := provenance.behind(_named_root(argv)):
189
212
  print(late, file=sys.stderr, flush=True)
190
213
  args = build_parser().parse_args(argv)
@@ -18,6 +18,8 @@ Rules:
18
18
  B7 a provided contract exists: its path holds its document, inside a module's folder
19
19
  B8 a contract consumed from a deprecated module (warning): a deprecated module takes no
20
20
  new consumer, and the existing ones migrate before its removal date
21
+ B9 another module's release artefacts — its folder of the project's dist/ — read only by a
22
+ module that declares it in `assembles` (D127)
21
23
 
22
24
  DETECTION — textual and deliberately simple, with no stack assumed.
23
25
  - A contract is read where a module's file names its path (contracts/billing-api/v1),
@@ -99,6 +101,10 @@ def load_modules(root: Path) -> dict[str, dict]:
99
101
  "provides": contract_entries(data, "provides"),
100
102
  "consumes": contract_entries(data, "consumes"),
101
103
  "owns_data": {table for table in owns if isinstance(table, str)},
104
+ "assembles": {other for other in (data.get("assembles") or []) if isinstance(other, str)}
105
+ if isinstance(data.get("assembles"), list) else set(),
106
+ "commands": {verb: command for verb, command in (data.get("commands") or {}).items()
107
+ if isinstance(command, str)} if isinstance(data.get("commands"), dict) else {},
102
108
  }
103
109
  return modules
104
110
 
@@ -256,17 +262,65 @@ def check_contracts(root: Path, modules: dict[str, dict]) -> dict[str, dict[str,
256
262
  return reads
257
263
 
258
264
 
265
+ # Another module's artefacts, as a release reads them: from its own artefacts folder, whatever
266
+ # the shell writes the variable ("$NSTACK_DIST", ${NSTACK_DIST}, %NSTACK_DIST%, $(NSTACK_DIST)…),
267
+ # or by a relative path that lands in the project's dist/ — never a dist/ of the module's own.
268
+ FROM_OWN = re.compile(r"NSTACK_DIST[\"'%)}\]]?/\.\./([A-Za-z0-9._-]+)")
269
+ RELATIVE = re.compile(r"(?<![\w.-])((?:\.\./)+)dist/([A-Za-z0-9._-]+)")
270
+
271
+
272
+ def artefact_reads(root: Path, mod: dict, relative: str, text: str) -> tuple[set[str], str]:
273
+ """The module folders whose artefacts a line reads, and the line without those reads."""
274
+ read: set[str] = set()
275
+
276
+ def taken(match: re.Match) -> str:
277
+ read.add(match.group(match.lastindex))
278
+ return " "
279
+
280
+ text = FROM_OWN.sub(taken, text)
281
+
282
+ def landed(match: re.Match) -> str:
283
+ # From the file, or from the module's folder, where its commands run.
284
+ for start in ((mod["path"] / relative).parent, mod["path"]):
285
+ where = start / match.group(1) / "dist" / match.group(2)
286
+ if Path(posixpath.normpath(where.as_posix())) == root / "dist" / match.group(2):
287
+ read.add(match.group(2))
288
+ return " "
289
+ return match.group(0)
290
+
291
+ return read, RELATIVE.sub(landed, text)
292
+
293
+
294
+ def unassembled(root: Path, name: str, mod: dict, read: set[str], by_folder: dict[str, str], where: str) -> None:
295
+ """B9: the artefacts read of a module this one does not assemble."""
296
+ for folder in sorted(read):
297
+ other_name = by_folder.get(folder)
298
+ if other_name and other_name != name and folder not in mod["assembles"]:
299
+ fail("B9", where, f"'{name}' reads the release artefacts of '{folder}' without assembling it.\n"
300
+ f" Action: add {folder} to assembles in its MANIFEST "
301
+ "(.nstack/handbook/09-platform.md §2), or stop reading them.")
302
+
303
+
259
304
  def check_code(root: Path, modules: dict[str, dict]) -> dict[str, set[str]]:
260
- """B2 and B5. Returns module -> modules whose code it references."""
305
+ """B2, B5 and B9. Returns module -> modules whose code it references."""
261
306
  code: dict[str, set[str]] = {name: set() for name in modules}
262
307
  others = {name: code_patterns(mod) for name, mod in modules.items()}
308
+ by_folder = {mod["dirname"]: name for name, mod in modules.items()}
263
309
  contracts = [str(p.get("path")).strip("/") for mod in modules.values() for p in mod["provides"] if p.get("path")]
264
310
  for name, mod in modules.items():
311
+ # The commands its manifest declares, a release's among them, run from its folder.
312
+ for verb, command in mod["commands"].items():
313
+ read, _ = artefact_reads(root, mod, "MANIFEST.yaml", command.replace("\\", "/"))
314
+ unassembled(root, name, mod, read, by_folder, f"{mod['path'].relative_to(root)}/MANIFEST.yaml (commands.{verb})")
265
315
  for relative, lines in read_files(mod):
266
316
  source = Path(relative).suffix in SOURCE_SUFFIXES
267
317
  for lineno, line in enumerate(lines, 1):
268
318
  imports = source and IMPORT_HINTS.search(line)
269
319
  text = line.replace("\\", "/")
320
+ # B9 - another module's artefacts: read here, they are its artefacts, not its code;
321
+ # what is left of the line is still B2's.
322
+ read, text = artefact_reads(root, mod, relative, text)
323
+ unassembled(root, name, mod, read, by_folder, f"{mod['path'].relative_to(root) / relative}:{lineno}")
270
324
  for other_name, (code_import, reach, block) in others.items():
271
325
  if other_name == name:
272
326
  continue
@@ -17,6 +17,8 @@ Rules:
17
17
  M8 runbook required when criticality >= high
18
18
  M9 complete file envelope (AGENTS.md, README.md, tests/)
19
19
  M10 user_facing declared: true when a user sees the module (.nstack/handbook/05-workflow.md §7)
20
+ M11 assembles names other modules, as a list, and no module waits for itself through it
21
+ (.nstack/handbook/09-platform.md §2, D127)
20
22
 
21
23
  Usage : nstack manifests [--root ROOT]
22
24
  Output: 0 if everything passes, 1 otherwise. Every failure explains the rule broken.
@@ -357,6 +359,53 @@ def comparator_owed(root: Path, manifests: list[Path]) -> None:
357
359
  "(.nstack/handbook/03-contracts.md §3).")
358
360
 
359
361
 
362
+ def assembled(manifests: list[Path], root: Path) -> None:
363
+ """M11: what a module's release assembles — other modules' artefacts — names them, and never
364
+ leads back to the module itself: `nstack release` runs each after what it assembles."""
365
+ wants: dict[str, list[str]] = {}
366
+ places: dict[str, str] = {}
367
+ for manifest in manifests:
368
+ text = manifest.read_text(encoding="utf-8")
369
+ places[manifest.parent.name] = f"{manifest.relative_to(root).as_posix()}:{line_of(text, 'assembles')}"
370
+ try:
371
+ data = yaml.safe_load(text) or {}
372
+ except yaml.YAMLError:
373
+ continue # M2 says it
374
+ listed = data.get("assembles") if isinstance(data, dict) else None
375
+ if listed is None:
376
+ continue
377
+ name = manifest.parent.name
378
+ if not isinstance(listed, list) or not all(isinstance(other, str) for other in listed):
379
+ fail(name, "M11", "assembles must be a list of module names.\n"
380
+ " Action: assembles: [<module>, …] — the modules whose release artefacts this one's release reads.",
381
+ places[name])
382
+ continue
383
+ wants[name] = listed
384
+ known = {manifest.parent.name for manifest in manifests}
385
+ for name, listed in wants.items():
386
+ for other in listed:
387
+ if other == name or other not in known:
388
+ fail(name, "M11", f"assembles '{other}', " + ("itself" if other == name else "a module that does not exist") + ".\n"
389
+ " Action: name another module of this project, or remove the entry (.nstack/handbook/09-platform.md §2).",
390
+ places[name])
391
+
392
+ def back(name: str, path: list[str]) -> list[str] | None:
393
+ for other in wants.get(name, []):
394
+ if other == path[0]:
395
+ return path + [other]
396
+ if other not in path and (found := back(other, path + [other])):
397
+ return found
398
+ return None
399
+
400
+ seen: set[frozenset[str]] = set()
401
+ for name in sorted(wants):
402
+ loop = back(name, [name])
403
+ if loop and frozenset(loop) not in seen:
404
+ seen.add(frozenset(loop))
405
+ fail(name, "M11", f"releases that wait for each other: {' → '.join(loop)}.\n"
406
+ " Action: one of them stops assembling the other.", places[name])
407
+
408
+
360
409
  def run(root: Path) -> int:
361
410
  failures.clear()
362
411
  warnings.clear()
@@ -372,6 +421,7 @@ def run(root: Path) -> int:
372
421
 
373
422
  for manifest in manifests:
374
423
  check_manifest(manifest, today, root)
424
+ assembled(manifests, root)
375
425
  comparator_owed(root, manifests)
376
426
 
377
427
  print(f"Manifests checked: {len(manifests)}")
@@ -1,6 +1,6 @@
1
1
  """
2
2
  A project's modules: creation (nstack new-module) and standard verbs (nstack bootstrap,
3
- check, test, run, e2e, clean), with no imposed stack (PRODUCT.md P1, PDR-0001 R5).
3
+ check, test, run, e2e, clean, release), with no imposed stack (PRODUCT.md P1, PDR-0001 R5).
4
4
 
5
5
  Each verb runs the command declared in the `commands` section of the module's
6
6
  MANIFEST.yaml, from its folder, through the system shell. NapkinStack never assumes a
@@ -10,6 +10,7 @@ borrowed from Nx (`nx test <project>`) and moon (`moon run project:task`).
10
10
 
11
11
  from __future__ import annotations
12
12
 
13
+ import os
13
14
  import re
14
15
  import shutil
15
16
  import shlex
@@ -31,6 +32,8 @@ LOGIN = r"[A-Za-z0-9](?:-?[A-Za-z0-9]){0,38}" # the same rule, unanchored: a ha
31
32
  OWNER = re.compile( # same rule as copier.yml: organisation/team, or a GitHub user
32
33
  rf"[A-Za-z0-9-]+/[A-Za-z0-9._-]+|{HANDLE}")
33
34
  OPTIONAL = {"bootstrap": "nothing to prepare", "e2e": "no end-to-end scenario", "clean": "nothing to clean"} # undeclared: skipped
35
+ # Undeclared, skipped when every module is released; asked of one module by name, it is owed.
36
+ ALL_ONLY = {"release": "nothing to release"}
34
37
  # What a criticality requires is the matrix of .nstack/handbook/05-workflow.md §7, read once (assurance).
35
38
 
36
39
  RUNBOOK = """# Runbook - {name}
@@ -301,13 +304,36 @@ def _modules(root: Path) -> dict[str, Path]:
301
304
  return {manifest.parent.name: manifest.parent for manifest in find_manifests(root)}
302
305
 
303
306
 
307
+ def release_order(known: dict[str, Path]) -> list[str]:
308
+ """Every module after those its release assembles (D127); otherwise in their order."""
309
+ wants = {}
310
+ for target, folder in known.items():
311
+ try:
312
+ listed = (yaml.safe_load((folder / "MANIFEST.yaml").read_text(encoding="utf-8")) or {}).get("assembles")
313
+ except (yaml.YAMLError, AttributeError):
314
+ listed = None
315
+ wants[target] = [other for other in listed if other in known] if isinstance(listed, list) else []
316
+ order: list[str] = []
317
+
318
+ def place(target: str, path: tuple[str, ...]) -> None:
319
+ if target in order or target in path: # a loop is M11's to refuse
320
+ return
321
+ for other in wants[target]:
322
+ place(other, path + (target,))
323
+ order.append(target)
324
+
325
+ for target in known:
326
+ place(target, ())
327
+ return order
328
+
329
+
304
330
  def run_verb(root: Path, verb: str, name: str | None, arguments: list[str] | None = None) -> int:
305
331
  known = _modules(root)
306
332
  if name is not None and name not in known:
307
333
  print(f"FAIL [{verb}] module '{name}' not found in {root}: no MANIFEST.yaml under that name.\n"
308
334
  f" Known modules: {', '.join(known) or 'none'}.")
309
335
  return 1
310
- targets = [name] if name is not None else list(known)
336
+ targets = [name] if name is not None else (release_order(known) if verb == "release" else list(known))
311
337
  if not targets:
312
338
  print(f"No module in {root}: nothing to run.")
313
339
  return 0
@@ -323,16 +349,28 @@ def run_verb(root: Path, verb: str, name: str | None, arguments: list[str] | Non
323
349
  return 1
324
350
  command = commands.get(verb)
325
351
  if not command:
326
- if verb in OPTIONAL:
327
- print(f"-> {target}: {verb} not declared, {OPTIONAL[verb]}.")
352
+ if verb in OPTIONAL or (verb in ALL_ONLY and name is None):
353
+ print(f"-> {target}: {verb} not declared, {OPTIONAL.get(verb) or ALL_ONLY[verb]}.")
328
354
  continue
329
355
  print(f"FAIL [{verb}] module '{target}': commands.{verb} not declared in {manifest}.\n"
330
356
  " Action: declare the module stack's command there (.nstack/handbook/09-platform.md §2).")
331
357
  return 1
332
358
  if arguments:
333
359
  command = " ".join([command, *map(shlex.quote, arguments)])
360
+ environment = None
361
+ if verb == "release":
362
+ # Its artefacts, and nothing left from an earlier release: dist/<module>, emptied first.
363
+ dist = root / "dist" / target
364
+ odd = next((path for path in (dist.parent, dist) if path.is_symlink() or (path.exists() and not path.is_dir())), None)
365
+ if odd is not None:
366
+ print(f"FAIL [release] {odd.relative_to(root)} is a link or a file, not a folder of the project's own: "
367
+ "nothing is emptied through it.\n Action: remove it, then release again.")
368
+ return 1
369
+ shutil.rmtree(dist, ignore_errors=True)
370
+ dist.mkdir(parents=True)
371
+ environment = {**os.environ, "NSTACK_DIST": str(dist.resolve())}
334
372
  print(f"-> {target}: {command}", flush=True)
335
- code = subprocess.run(command, shell=True, cwd=known[target]).returncode
373
+ code = subprocess.run(command, shell=True, cwd=known[target], env=environment).returncode
336
374
  if code:
337
375
  print(f"FAIL [{verb}] module '{target}': `{command}` exited with {code}.")
338
376
  return code
@@ -50,6 +50,11 @@ consumes: []
50
50
  # version: v1
51
51
  # module: identity
52
52
 
53
+ # Modules whose release artefacts this module's release assembles — what a user installs,
54
+ # made of theirs (.nstack/handbook/09-platform.md §2). Read in dist/<module>/, never in their
55
+ # folders; `nstack release` runs them first (M11, B9).
56
+ # assembles: []
57
+
53
58
  # Data owned. Nobody else accesses it directly.
54
59
  data:
55
60
  owns: []
@@ -75,6 +80,9 @@ commands:
75
80
  # e2e: # optional: end-to-end scenarios; evidence written to .evidence/
76
81
  # clean: # optional: remove what the commands above leave in this folder
77
82
  # # (build outputs); CI runs it after every job on a self-hosted runner
83
+ # release: # optional: the module's shippable artefacts, written into
84
+ # # $NSTACK_DIST (dist/<module>/); `nstack release <module> …`
85
+ # # passes the platform or target to it
78
86
 
79
87
  docs:
80
88
  readme: README.md
File without changes