create-forge 0.4.0__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. {create_forge-0.4.0 → create_forge-0.5.0}/CHANGELOG.md +16 -0
  2. {create_forge-0.4.0 → create_forge-0.5.0}/PKG-INFO +6 -5
  3. {create_forge-0.4.0 → create_forge-0.5.0}/README.md +4 -3
  4. {create_forge-0.4.0 → create_forge-0.5.0}/docs/README.md +4 -1
  5. {create_forge-0.4.0 → create_forge-0.5.0}/docs/adr/README.md +7 -0
  6. {create_forge-0.4.0 → create_forge-0.5.0}/docs/roadmap-v3/README.md +14 -12
  7. {create_forge-0.4.0 → create_forge-0.5.0}/docs/roadmap-v3/roadmap/18-engine-default-client-delivery-and-validation/README.md +10 -9
  8. {create_forge-0.4.0 → create_forge-0.5.0}/pyproject.toml +14 -6
  9. {create_forge-0.4.0 → create_forge-0.5.0}/src/create_forge/_engine_worker.py +27 -4
  10. create_forge-0.5.0/src/create_forge/capture.py +156 -0
  11. {create_forge-0.4.0 → create_forge-0.5.0}/src/create_forge/cli.py +61 -24
  12. {create_forge-0.4.0 → create_forge-0.5.0}/src/create_forge/compat.py +14 -8
  13. {create_forge-0.4.0 → create_forge-0.5.0}/src/create_forge/engine_source.py +13 -12
  14. {create_forge-0.4.0 → create_forge-0.5.0}/src/create_forge/lifecycle.py +6 -14
  15. create_forge-0.5.0/src/create_forge/paths.py +202 -0
  16. {create_forge-0.4.0 → create_forge-0.5.0}/src/create_forge/staging.py +20 -51
  17. {create_forge-0.4.0 → create_forge-0.5.0}/src/create_forge/templates.toml +2 -2
  18. {create_forge-0.4.0 → create_forge-0.5.0}/src/create_forge/update.py +295 -40
  19. {create_forge-0.4.0 → create_forge-0.5.0}/tests/conftest.py +10 -1
  20. {create_forge-0.4.0 → create_forge-0.5.0}/tests/installed_client.py +43 -7
  21. {create_forge-0.4.0 → create_forge-0.5.0}/tests/legacy_template.py +3 -9
  22. create_forge-0.5.0/tests/process.py +71 -0
  23. create_forge-0.5.0/tests/recovery_recipes.py +32 -0
  24. create_forge-0.5.0/tests/streamlit_recipes.py +50 -0
  25. {create_forge-0.4.0 → create_forge-0.5.0}/tests/test_archetype_parity.py +45 -0
  26. create_forge-0.5.0/tests/test_candidate_evidence.py +376 -0
  27. create_forge-0.5.0/tests/test_capture.py +254 -0
  28. {create_forge-0.4.0 → create_forge-0.5.0}/tests/test_cli.py +101 -12
  29. {create_forge-0.4.0 → create_forge-0.5.0}/tests/test_cutover_acceptance_contract.py +13 -7
  30. {create_forge-0.4.0 → create_forge-0.5.0}/tests/test_data_science_pipeline.py +1 -1
  31. {create_forge-0.4.0 → create_forge-0.5.0}/tests/test_downstream_reference.py +1 -1
  32. {create_forge-0.4.0 → create_forge-0.5.0}/tests/test_drift.py +5 -10
  33. {create_forge-0.4.0 → create_forge-0.5.0}/tests/test_e2e_engine_generation.py +29 -88
  34. {create_forge-0.4.0 → create_forge-0.5.0}/tests/test_e2e_generation.py +5 -23
  35. {create_forge-0.4.0 → create_forge-0.5.0}/tests/test_e2e_installed_cutover.py +56 -25
  36. {create_forge-0.4.0 → create_forge-0.5.0}/tests/test_e2e_installed_data_science.py +2 -2
  37. {create_forge-0.4.0 → create_forge-0.5.0}/tests/test_e2e_installed_rollout.py +5 -6
  38. create_forge-0.5.0/tests/test_e2e_installed_streamlit.py +601 -0
  39. create_forge-0.5.0/tests/test_e2e_installed_update_safety.py +686 -0
  40. {create_forge-0.4.0 → create_forge-0.5.0}/tests/test_engine_adapter.py +8 -7
  41. {create_forge-0.4.0 → create_forge-0.5.0}/tests/test_engine_contract.py +56 -2
  42. {create_forge-0.4.0 → create_forge-0.5.0}/tests/test_engine_cross_repository.py +8 -7
  43. {create_forge-0.4.0 → create_forge-0.5.0}/tests/test_engine_source.py +14 -13
  44. create_forge-0.5.0/tests/test_process_helper.py +93 -0
  45. {create_forge-0.4.0 → create_forge-0.5.0}/tests/test_roadmap_packs.py +3 -3
  46. {create_forge-0.4.0 → create_forge-0.5.0}/tests/test_sources.py +2 -8
  47. {create_forge-0.4.0 → create_forge-0.5.0}/tests/test_staging.py +57 -3
  48. create_forge-0.5.0/tests/test_streamlit_adoption.py +152 -0
  49. create_forge-0.5.0/tests/test_subprocess_decoding.py +494 -0
  50. create_forge-0.5.0/tests/test_subprocess_policy.py +203 -0
  51. create_forge-0.5.0/tests/test_supplied_candidate_wheel.py +68 -0
  52. {create_forge-0.4.0 → create_forge-0.5.0}/tests/test_update.py +2 -4
  53. create_forge-0.5.0/tests/test_update_containment.py +774 -0
  54. {create_forge-0.4.0 → create_forge-0.5.0}/tests/test_update_engine.py +35 -43
  55. {create_forge-0.4.0 → create_forge-0.5.0}/tests/test_update_network.py +2 -8
  56. create_forge-0.5.0/tests/test_update_recovery.py +640 -0
  57. create_forge-0.5.0/tests/test_update_safety_evidence.py +92 -0
  58. create_forge-0.5.0/tests/test_user_guide_recipes.py +159 -0
  59. create_forge-0.4.0/tests/test_user_guide_recipes.py +0 -68
  60. {create_forge-0.4.0 → create_forge-0.5.0}/.gitignore +0 -0
  61. {create_forge-0.4.0 → create_forge-0.5.0}/LICENSE +0 -0
  62. {create_forge-0.4.0 → create_forge-0.5.0}/docs/roadmap-v1/README.md +0 -0
  63. {create_forge-0.4.0 → create_forge-0.5.0}/docs/roadmap-v1/roadmap/00-governance-and-principles/README.md +0 -0
  64. {create_forge-0.4.0 → create_forge-0.5.0}/docs/roadmap-v1/roadmap/01-python-core/README.md +0 -0
  65. {create_forge-0.4.0 → create_forge-0.5.0}/docs/roadmap-v1/roadmap/02-developer-experience/README.md +0 -0
  66. {create_forge-0.4.0 → create_forge-0.5.0}/docs/roadmap-v1/roadmap/03-quality-and-ci/README.md +0 -0
  67. {create_forge-0.4.0 → create_forge-0.5.0}/docs/roadmap-v1/roadmap/04-runtime-and-configuration/README.md +0 -0
  68. {create_forge-0.4.0 → create_forge-0.5.0}/docs/roadmap-v1/roadmap/05-security-and-supply-chain/README.md +0 -0
  69. {create_forge-0.4.0 → create_forge-0.5.0}/docs/roadmap-v1/roadmap/06-extension-and-composition-contract/README.md +0 -0
  70. {create_forge-0.4.0 → create_forge-0.5.0}/docs/roadmap-v1/roadmap/07-forge-cli-integration/README.md +0 -0
  71. {create_forge-0.4.0 → create_forge-0.5.0}/docs/roadmap-v1/roadmap/08-reference-archetype-validation/README.md +0 -0
  72. {create_forge-0.4.0 → create_forge-0.5.0}/docs/roadmap-v1/roadmap/09-blueprint-compatibility/README.md +0 -0
  73. {create_forge-0.4.0 → create_forge-0.5.0}/docs/roadmap-v2/README.md +0 -0
  74. {create_forge-0.4.0 → create_forge-0.5.0}/docs/roadmap-v2/roadmap/10-data-science-architecture-contract/README.md +0 -0
  75. {create_forge-0.4.0 → create_forge-0.5.0}/docs/roadmap-v2/roadmap/11-reusable-data-science-capabilities/README.md +0 -0
  76. {create_forge-0.4.0 → create_forge-0.5.0}/docs/roadmap-v2/roadmap/12-data-science-archetype/README.md +0 -0
  77. {create_forge-0.4.0 → create_forge-0.5.0}/docs/roadmap-v2/roadmap/13-data-science-cli-integration/README.md +0 -0
  78. {create_forge-0.4.0 → create_forge-0.5.0}/docs/roadmap-v2/roadmap/14-data-science-validation-and-rollout/README.md +0 -0
  79. {create_forge-0.4.0 → create_forge-0.5.0}/docs/roadmap-v3/roadmap/15-engine-default-provider-contracts/README.md +0 -0
  80. {create_forge-0.4.0 → create_forge-0.5.0}/docs/roadmap-v3/roadmap/16-engine-default-client-contracts/README.md +0 -0
  81. {create_forge-0.4.0 → create_forge-0.5.0}/docs/roadmap-v3/roadmap/17-engine-default-provider-implementation-and-release/README.md +0 -0
  82. {create_forge-0.4.0 → create_forge-0.5.0}/docs/roadmap-v4/README.md +0 -0
  83. {create_forge-0.4.0 → create_forge-0.5.0}/docs/roadmap-v4/roadmap/19-streamlit-architecture-contracts/README.md +0 -0
  84. {create_forge-0.4.0 → create_forge-0.5.0}/docs/roadmap-v4/roadmap/20-streamlit-provider-implementation-and-release/README.md +0 -0
  85. {create_forge-0.4.0 → create_forge-0.5.0}/docs/roadmap-v4/roadmap/21-streamlit-client-adoption-and-rollout/README.md +0 -0
  86. {create_forge-0.4.0 → create_forge-0.5.0}/examples/README.md +0 -0
  87. {create_forge-0.4.0 → create_forge-0.5.0}/src/create_forge/__init__.py +0 -0
  88. {create_forge-0.4.0 → create_forge-0.5.0}/src/create_forge/config.py +0 -0
  89. {create_forge-0.4.0 → create_forge-0.5.0}/src/create_forge/descriptors.py +0 -0
  90. {create_forge-0.4.0 → create_forge-0.5.0}/src/create_forge/engine.py +0 -0
  91. {create_forge-0.4.0 → create_forge-0.5.0}/src/create_forge/models.py +0 -0
  92. {create_forge-0.4.0 → create_forge-0.5.0}/src/create_forge/pipeline.py +0 -0
  93. {create_forge-0.4.0 → create_forge-0.5.0}/src/create_forge/prompts.py +0 -0
  94. {create_forge-0.4.0 → create_forge-0.5.0}/src/create_forge/registry.py +0 -0
  95. {create_forge-0.4.0 → create_forge-0.5.0}/src/create_forge/runner.py +0 -0
  96. {create_forge-0.4.0 → create_forge-0.5.0}/src/create_forge/sources.py +0 -0
  97. {create_forge-0.4.0 → create_forge-0.5.0}/src/create_forge/spec.py +0 -0
  98. {create_forge-0.4.0 → create_forge-0.5.0}/tests/__init__.py +0 -0
  99. {create_forge-0.4.0 → create_forge-0.5.0}/tests/test_adr.py +0 -0
  100. {create_forge-0.4.0 → create_forge-0.5.0}/tests/test_component_selection.py +0 -0
  101. {create_forge-0.4.0 → create_forge-0.5.0}/tests/test_config.py +0 -0
  102. {create_forge-0.4.0 → create_forge-0.5.0}/tests/test_copier_cache.py +0 -0
  103. {create_forge-0.4.0 → create_forge-0.5.0}/tests/test_engine_default_contract.py +0 -0
  104. {create_forge-0.4.0 → create_forge-0.5.0}/tests/test_engine_lifecycle_contract.py +0 -0
  105. {create_forge-0.4.0 → create_forge-0.5.0}/tests/test_labels.py +0 -0
  106. {create_forge-0.4.0 → create_forge-0.5.0}/tests/test_lifecycle.py +0 -0
  107. {create_forge-0.4.0 → create_forge-0.5.0}/tests/test_models.py +0 -0
  108. {create_forge-0.4.0 → create_forge-0.5.0}/tests/test_pipeline.py +0 -0
  109. {create_forge-0.4.0 → create_forge-0.5.0}/tests/test_policy_hook.py +0 -0
  110. {create_forge-0.4.0 → create_forge-0.5.0}/tests/test_prompts.py +0 -0
  111. {create_forge-0.4.0 → create_forge-0.5.0}/tests/test_reference_client_boundary.py +0 -0
  112. {create_forge-0.4.0 → create_forge-0.5.0}/tests/test_registry.py +0 -0
  113. {create_forge-0.4.0 → create_forge-0.5.0}/tests/test_runner.py +0 -0
  114. {create_forge-0.4.0 → create_forge-0.5.0}/tests/test_spec.py +0 -0
  115. {create_forge-0.4.0 → create_forge-0.5.0}/tests/test_update_routing.py +0 -0
  116. {create_forge-0.4.0 → create_forge-0.5.0}/tests/test_workflows.py +0 -0
@@ -1,6 +1,22 @@
1
1
  # Changelog
2
2
 
3
3
  Generated by git-cliff from Conventional Commits.
4
+ ## [0.5.0] - 2026-09-21
5
+
6
+ ### Bug Fixes
7
+
8
+ - Contain every engine-update filesystem target (CF-22.01) (#207)
9
+ - Recover failed updates from the actual Git state (CF-22.02) (#208)
10
+ - Capture subprocess output as bytes and decode by explicit rule (CF-23.01) (#215)
11
+
12
+ ### Documentation
13
+
14
+ - Record create-forge 0.4.0 publication evidence (CF-18.07) (#185)
15
+
16
+ ### Testing
17
+
18
+ - Validate installed Streamlit generation and document usage (CF-21.02) (#187)
19
+ - Verify update safety through the installed console (CF-22.03) (#210)
4
20
  ## [0.4.0] - 2026-09-14
5
21
 
6
22
  ### Documentation
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: create-forge
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Scaffold modern Python projects from maintained templates.
5
5
  Project-URL: Homepage, https://github.com/Sandsy09/create-forge
6
6
  Project-URL: Repository, https://github.com/Sandsy09/create-forge
@@ -20,7 +20,7 @@ Classifier: Programming Language :: Python :: 3.14
20
20
  Classifier: Topic :: Software Development :: Code Generators
21
21
  Classifier: Typing :: Typed
22
22
  Requires-Python: >=3.11
23
- Requires-Dist: forge-template<0.6,>=0.5
23
+ Requires-Dist: forge-template<0.7,>=0.6
24
24
  Requires-Dist: pydantic>=2.10
25
25
  Requires-Dist: pyyaml>=6.0
26
26
  Requires-Dist: questionary>=2.0
@@ -170,9 +170,10 @@ uv run poe check
170
170
  `update` runs the engine-native Git-backed three-way merge against the
171
171
  project's committed `.forge/generation.json`. Review the resulting diff and
172
172
  resolve any conflict markers before committing. `--dry-run` prints the
173
- per-target classification without writing anything; a failed or interrupted
174
- update always leaves a recoverable working tree, printed on request:
175
- `git restore . && git clean -fd`.
173
+ per-target classification without writing anything. A failed or interrupted
174
+ update leaves a working tree you can restore to your last commit — the
175
+ [updates guide](https://sandsy09.github.io/create-forge/updates/) has the
176
+ recovery procedure, including for an update that is already staged.
176
177
 
177
178
  ## The `--legacy` Copier route
178
179
 
@@ -136,9 +136,10 @@ uv run poe check
136
136
  `update` runs the engine-native Git-backed three-way merge against the
137
137
  project's committed `.forge/generation.json`. Review the resulting diff and
138
138
  resolve any conflict markers before committing. `--dry-run` prints the
139
- per-target classification without writing anything; a failed or interrupted
140
- update always leaves a recoverable working tree, printed on request:
141
- `git restore . && git clean -fd`.
139
+ per-target classification without writing anything. A failed or interrupted
140
+ update leaves a working tree you can restore to your last commit — the
141
+ [updates guide](https://sandsy09.github.io/create-forge/updates/) has the
142
+ recovery procedure, including for an update that is already staged.
142
143
 
143
144
  ## The `--legacy` Copier route
144
145
 
@@ -40,7 +40,7 @@ still usually cites the ADR that authorised it.
40
40
 
41
41
  | Contract | Governs |
42
42
  | --- | --- |
43
- | [filesystem-generation.md](filesystem-generation.md) | Destination-conflict, staging, finalisation, and cleanup rules in `staging.py` (ADR 0015). |
43
+ | [filesystem-generation.md](filesystem-generation.md) | Destination-conflict, staging, finalisation, and cleanup rules in `staging.py` (ADR 0015), and the shared target-safety boundary in `paths.py` that both generation and engine-native `update` resolve every engine-supplied path through (ADR 0052). |
44
44
  | [end-to-end-tests.md](end-to-end-tests.md) | The fast/`network`/`e2e` test-tier split and what the real console script is proven to do (ADR 0016). |
45
45
 
46
46
  ## Validation records
@@ -55,6 +55,9 @@ Acceptance-checklist-to-named-test maps, each closing an epic or a release.
55
55
  | [release-0-3-0-validation.md](release-0-3-0-validation.md) | The published `create-forge 0.3.0` / `forge-template 0.4.1` pair verified against its own artefacts (ADR 0034). |
56
56
  | [engine-cutover-validation.md](engine-cutover-validation.md) | The installed-console cutover acceptance matrix's remaining rows, and the rewritten user guide (ADR 0048). |
57
57
  | [release-0-4-0-validation.md](release-0-4-0-validation.md) | The published `create-forge 0.4.0` / `forge-template 0.5.0` pair verified against its own artefacts, and the Engine-Default Cutover roadmap close-out (ADR 0049). |
58
+ | [installed-streamlit-validation.md](installed-streamlit-validation.md) | The four accepted Streamlit compositions, the previous-line provider, and the Streamlit failure cases through the installed candidate wheel (ADR 0051). |
59
+ | [subprocess-output.md](subprocess-output.md) | How every process the client starts is captured as bytes and decoded by an explicit rule (diagnostic, protocol, path/binary), the site inventory, the worker's UTF-8 wire format, and the test-harness rule (ADR 0055). |
60
+ | [update-safety-validation.md](update-safety-validation.md) | Update containment and recovery proven through the installed candidate wheel, the candidate's hashes and safety-relevant source digest, and CF-21.03's release prerequisite (ADR 0054). |
58
61
 
59
62
  ## Process and security
60
63
 
@@ -52,6 +52,13 @@ format](https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions
52
52
  - [0047 — Reject a pre-cutover `--engine-preview` project with one diagnostic, add no migration helper, and keep the Copier route reachable without a usable engine](0047-legacy-copier-retention-and-preview-transition.md)
53
53
  - [0048 — Complete the installed cutover acceptance evidence and the user guide's post-cutover voice](0048-installed-cutover-acceptance-evidence.md)
54
54
  - [0049 — Publish create-forge 0.4.0 and close the Engine-Default Cutover roadmap](0049-publish-0-4-0-and-close-roadmap-v3.md)
55
+ - [0050 — Adopt the forge-template 0.6 Streamlit provider line](0050-adopt-the-0-6-streamlit-provider-line.md)
56
+ - [0051 — Validate Streamlit through the installed create-forge candidate](0051-validate-installed-streamlit-generation.md)
57
+ - [0052 — Contain every client filesystem target behind one shared boundary](0052-contain-every-client-filesystem-target.md)
58
+ - [0053 — Recover a failed update from the repository's actual Git state](0053-recover-updates-from-the-actual-git-state.md)
59
+ - [0054 — Verify update safety through the installed console, and bind it to the release candidate](0054-verify-installed-update-safety-on-the-release-candidate.md)
60
+ - [0055 — Capture subprocess output as bytes, and decode it by an explicit rule](0055-capture-subprocess-output-as-bytes-and-decode-by-rule.md)
61
+ - [0056 — Publish create-forge 0.5.0 and close the Streamlit roadmap's client work](0056-publish-create-forge-0-5-0.md)
55
62
 
56
63
  Add a new record by copying the most recent one and incrementing the number.
57
64
  Records are immutable: supersede them rather than editing.
@@ -2,22 +2,24 @@
2
2
 
3
3
  ## Status
4
4
 
5
- Filed and open. This pack records 5 repository-owned epics and
5
+ Filed and delivered. This pack recorded 5 repository-owned epics and
6
6
  21 children for Stages 15–18, with verified issue numbers, labels,
7
- milestones, native parents and direct dependencies. No release or runtime
8
- implementation is claimed by these planning documents.
7
+ milestones, native parents and direct dependencies. The filed manifest, the
8
+ issue bodies, and `scripts/check_roadmaps.py`'s expected output stay
9
+ unchanged (`filed-open`) by design — completion is recorded in prose here
10
+ and in the evidence record below, not in the pack's own filing status; see
11
+ ADR 0049 decision 5.
9
12
 
10
- The `create-forge`-side implementation this pack planned is complete on
11
- `main`: CF-18.01 through CF-18.06 shipped the engine-default cutover, and
12
- CF-18.07 ([#164](https://github.com/Sandsy09/create-forge/issues/164), ADR
13
- 0049) is publishing it as `create-forge 0.4.0`, gated on
14
- [FT-18.01](https://github.com/Sandsy09/forge-template/issues/156)'s
15
- provider-side integrated validation. See `docs/release-0-4-0-validation.md`
16
- for the publication evidence once it lands.
13
+ The engine-default cutover this pack planned has shipped:
14
+ [FT-18.01](https://github.com/Sandsy09/forge-template/issues/156) closed the
15
+ provider-side integrated validation, and CF-18.07
16
+ ([#164](https://github.com/Sandsy09/create-forge/issues/164), ADR 0049)
17
+ published it as **`create-forge 0.4.0`**. See
18
+ `docs/release-0-4-0-validation.md` for the full publication evidence and
19
+ roadmap reconciliation.
17
20
 
18
21
  Working engine-native updates and continued support for existing Copier
19
- projects gate the engine-default release. Detailed architecture remains
20
- subject to the explicit decision children.
22
+ projects gated the engine-default release; both shipped.
21
23
 
22
24
  ## Read this pack
23
25
 
@@ -2,15 +2,16 @@
2
2
 
3
3
  ## Status and epics
4
4
 
5
- Filed and open.
6
- [CF-EPIC-18](https://github.com/Sandsy09/create-forge/issues/153),
7
- [FT-EPIC-18](https://github.com/Sandsy09/forge-template/issues/143).
8
-
9
- `CF-EPIC-18`'s six implementation children (CF-18.01–CF-18.06) have merged;
10
- CF-18.07 (ADR 0049) is publishing `create-forge 0.4.0`, gated on
11
- [FT-18.01](https://github.com/Sandsy09/forge-template/issues/156). See
12
- `docs/release-0-4-0-validation.md` for the publication evidence once it
13
- lands.
5
+ Filed and delivered on the `create-forge` side.
6
+ [CF-EPIC-18](https://github.com/Sandsy09/create-forge/issues/153) is closed:
7
+ all seven children merged, and CF-18.07 (ADR 0049) published
8
+ `create-forge 0.4.0` after
9
+ [FT-18.01](https://github.com/Sandsy09/forge-template/issues/156) closed the
10
+ provider-side integrated validation. See `docs/release-0-4-0-validation.md`
11
+ for the full publication evidence.
12
+ [FT-EPIC-18](https://github.com/Sandsy09/forge-template/issues/143) is
13
+ `forge-template`'s own epic; see that repository's roadmap record for its
14
+ status.
14
15
 
15
16
  ## Entry criteria
16
17
 
@@ -7,7 +7,7 @@ build-backend = "hatchling.build"
7
7
 
8
8
  [project]
9
9
  name = "create-forge"
10
- version = "0.4.0"
10
+ version = "0.5.0"
11
11
  description = "Scaffold modern Python projects from maintained templates."
12
12
  readme = "README.md"
13
13
  requires-python = ">=3.11"
@@ -32,11 +32,12 @@ dependencies = [
32
32
  # moves from the optional `engine` extra into a required dependency. ADR
33
33
  # 0018 assigned the first released range; ADR 0026 moved it to the
34
34
  # `forge-template` 0.4 line; ADR 0031 raised the floor to the reviewed
35
- # 0.4.1 release; this adopts the reviewed 0.5.0 cutover release (ADR 0042
36
- # / docs/engine-cutover-acceptance.md). src/create_forge/engine.py stays
37
- # the only module that imports it; src/create_forge/compat.py holds the
38
- # range itself so `doctor` can report it without importing the engine.
39
- "forge-template>=0.5,<0.6",
35
+ # 0.4.1 release; ADR 0042 adopted the reviewed 0.5.0 cutover release
36
+ # (docs/engine-cutover-acceptance.md); ADR 0050 (CF-21.01) adopts the
37
+ # reviewed 0.6.0 Streamlit provider release. src/create_forge/engine.py
38
+ # stays the only module that imports it; src/create_forge/compat.py holds
39
+ # the range itself so `doctor` can report it without importing the engine.
40
+ "forge-template>=0.6,<0.7",
40
41
  # ADR 0021 adds bounded uv to finalise the engine render's dynamic
41
42
  # lockfile; ADR 0038 reviewed that floor against the advisory record and
42
43
  # held it -- the whole `>=0.12,<0.13` range is already advisory-free. Now
@@ -223,6 +224,13 @@ docs = "mkdocs serve"
223
224
  # entry point -- see scripts/check_workflows.py.
224
225
  "check:workflows" = "python scripts/check_workflows.py"
225
226
 
227
+ # Prints the table that binds the installed update-safety evidence to one
228
+ # candidate: commit, wheel/sdist hashes, forge-template artefact hashes, tool
229
+ # versions, and the safety-relevant source digest a fast-suite test checks
230
+ # (ADR 0054, CF-22.03). Re-run on the release commit to refresh it; `--no-build`
231
+ # skips the build. See scripts/candidate_evidence.py.
232
+ "evidence:candidate" = "python scripts/candidate_evidence.py"
233
+
226
234
  # Reconciles a repo's GitHub labels against .github/labels.toml. Idempotent:
227
235
  # `gh label create --force` creates or updates. Pass extra args after `--`,
228
236
  # e.g. `uv run poe labels:sync --dry-run` or `--repo OWNER/NAME --prune`
@@ -148,6 +148,29 @@ def _dispatch(op: str, request: dict[str, object]) -> dict[str, object]:
148
148
  raise ValueError(msg)
149
149
 
150
150
 
151
+ def _read_request() -> str:
152
+ """The request, as strict UTF-8 from the binary stdin.
153
+
154
+ The protocol is UTF-8 in both directions whatever this interpreter's locale
155
+ is (CF-23.01, docs/subprocess-output.md), so the text layer -- which would
156
+ decode with the locale -- is bypassed. A byte that is not valid UTF-8 raises
157
+ here and is reported as a structured error by `main`, like any other bad
158
+ request. This file cannot import `create_forge.capture`: it runs where
159
+ `create_forge` is not installed.
160
+ """
161
+ return sys.stdin.buffer.read().decode("utf-8")
162
+
163
+
164
+ def _emit(response: dict[str, object]) -> None:
165
+ """Write exactly one JSON object and a newline, as UTF-8, to the binary stdout.
166
+
167
+ `json.dumps` keeps its default `ensure_ascii=True`, so the bytes are ASCII --
168
+ valid UTF-8 -- and there is no newline translation to differ by platform.
169
+ """
170
+ sys.stdout.buffer.write((json.dumps(response) + "\n").encode("utf-8"))
171
+ sys.stdout.buffer.flush()
172
+
173
+
151
174
  def main(argv: list[str]) -> int:
152
175
  """Read one JSON request from stdin, write one JSON response to stdout.
153
176
 
@@ -167,19 +190,19 @@ def main(argv: list[str]) -> int:
167
190
  "details": [],
168
191
  },
169
192
  }
170
- print(json.dumps(response))
193
+ _emit(response)
171
194
  return 0
172
195
 
173
196
  op = argv[1]
174
197
  try:
175
- raw = sys.stdin.read()
198
+ raw = _read_request()
176
199
  request = json.loads(raw) if raw.strip() else {}
177
200
  result = _dispatch(op, request)
178
201
  except Exception as exc:
179
- print(json.dumps({"ok": False, "error": _error_payload(exc)}))
202
+ _emit({"ok": False, "error": _error_payload(exc)})
180
203
  return 0
181
204
 
182
- print(json.dumps({"ok": True, "result": result}))
205
+ _emit({"ok": True, "result": result})
183
206
  return 0
184
207
 
185
208
 
@@ -0,0 +1,156 @@
1
+ """The one place subprocess output is captured, and the rules for decoding it.
2
+
3
+ CF-23.01 (ADR 0055; canonical `docs/subprocess-output.md`). `subprocess.run(...,
4
+ text=True)` decodes with the *host locale* -- `cp1252` on a default Windows
5
+ install -- so what a child writes and how this process reads it were only ever
6
+ lined up by accident. `git` and `uv` write UTF-8; a Python child writes the
7
+ locale encoding when piped. On a non-UTF-8 host a single undecodable byte in a
8
+ child's stderr raised inside `subprocess.run` and left `stdout` as `None`.
9
+ `PYTHONUTF8=1` hides that; it does not define what the bytes are.
10
+
11
+ So nothing in this package captures text. `run_captured` returns **bytes**, and a
12
+ caller decodes only what it genuinely reads, with one of three explicit rules:
13
+
14
+ - **Diagnostic** (`decode_diagnostic`): UTF-8, `errors="replace"`. For a fact
15
+ extracted from human-oriented output (a version token, whether a value is
16
+ set). It never raises, and its result is never shown raw: raw argv, stdout and
17
+ stderr are never echoed (ADR 0044/0045/0046), because they can carry a
18
+ credential.
19
+ - **Protocol** (`decode_protocol`, `parse_protocol_json`): UTF-8, **strict**.
20
+ Machine-readable output either parses or is rejected. Malformed bytes, a byte
21
+ order mark, or a body that is not JSON raise `ProtocolDecodeError`; they can
22
+ never quietly become valid protocol data.
23
+ - **Path or binary data**: bytes are left alone (`git merge-file`'s content), or
24
+ are turned into a path with `decode_path`, which is `os.fsdecode`: exactly the
25
+ filesystem's own round trip, with **no replacement character**. Replacing
26
+ bytes in a path would name a different file.
27
+
28
+ Most call sites read only the return code and so decode nothing at all.
29
+
30
+ Deliberately engine-free, like `staging.py`, `lifecycle.py`, `update.py` and
31
+ `engine_source.py`: nothing here imports `forge_template`
32
+ (`tests/test_engine_contract.py`'s `_SHIPPED_MODULES` guard covers it), and
33
+ `tests/test_subprocess_policy.py` fails if any other module in this package calls
34
+ `subprocess.run`/`Popen`/`check_output`, or asks `subprocess` to decode.
35
+
36
+ `OSError` (a missing or unlaunchable executable) and `subprocess.TimeoutExpired`
37
+ propagate unchanged, so each caller keeps translating them into its own safe
38
+ error, and return codes -- including the negative "killed by signal" codes on
39
+ POSIX -- reach the caller exactly as `subprocess.run` reports them.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import json
45
+ import os
46
+ import subprocess
47
+ from dataclasses import dataclass
48
+ from typing import TYPE_CHECKING
49
+
50
+ if TYPE_CHECKING:
51
+ from collections.abc import Mapping, Sequence
52
+ from pathlib import Path
53
+
54
+
55
+ class ProtocolDecodeError(ValueError):
56
+ """Protocol output that is not valid UTF-8 JSON.
57
+
58
+ Carries no output bytes: what a misbehaving child wrote is never forwarded.
59
+ """
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class Captured:
64
+ """One finished child process: its status and its raw, undecoded streams."""
65
+
66
+ returncode: int
67
+ stdout: bytes
68
+ stderr: bytes
69
+
70
+
71
+ def run_captured(
72
+ argv: Sequence[str],
73
+ *,
74
+ cwd: Path | None = None,
75
+ env: Mapping[str, str] | None = None,
76
+ stdin: bytes | None = None,
77
+ timeout: float | None = None,
78
+ ) -> Captured:
79
+ """Run `argv` without a shell and return its status and raw bytes.
80
+
81
+ Never passes `text=`, `encoding=` or `universal_newlines`, so nothing is
82
+ decoded and no newline is translated. `stdin`, when given, must already be
83
+ bytes: the caller states the encoding it sends. Never raises for a non-zero
84
+ exit (`check=False`); `OSError` and `subprocess.TimeoutExpired` propagate.
85
+ """
86
+ result = subprocess.run( # noqa: S603 - callers pass reviewed argv, never a shell
87
+ list(argv),
88
+ cwd=cwd,
89
+ env=dict(env) if env is not None else None,
90
+ input=stdin,
91
+ capture_output=True,
92
+ check=False,
93
+ timeout=timeout,
94
+ )
95
+ return Captured(result.returncode, result.stdout, result.stderr)
96
+
97
+
98
+ def decode_diagnostic(data: bytes) -> str:
99
+ """Decode human-oriented output as UTF-8, replacing what is not.
100
+
101
+ For extracting a fact, never for showing the output: see the module
102
+ docstring's rule on echoing raw process output.
103
+ """
104
+ return data.decode("utf-8", errors="replace")
105
+
106
+
107
+ def decode_protocol(data: bytes) -> str:
108
+ """Decode machine-readable output as strict UTF-8.
109
+
110
+ Raises:
111
+ ProtocolDecodeError: `data` is not valid UTF-8.
112
+ """
113
+ try:
114
+ return data.decode("utf-8")
115
+ except UnicodeDecodeError as exc:
116
+ msg = "protocol output is not valid UTF-8"
117
+ raise ProtocolDecodeError(msg) from exc
118
+
119
+
120
+ def parse_protocol_json(data: bytes) -> object:
121
+ """Parse machine-readable output: strict UTF-8, then strict JSON.
122
+
123
+ A byte order mark is left in place by the decode and refused by the parser,
124
+ so it is rejected rather than skipped.
125
+
126
+ Raises:
127
+ ProtocolDecodeError: `data` is not valid UTF-8, or is not JSON.
128
+ """
129
+ text = decode_protocol(data)
130
+ try:
131
+ return json.loads(text)
132
+ except (ValueError, RecursionError) as exc:
133
+ msg = "protocol output is not valid JSON"
134
+ raise ProtocolDecodeError(msg) from exc
135
+
136
+
137
+ def decode_path(data: bytes) -> str:
138
+ """Turn path bytes a child printed into a path, with no replacement.
139
+
140
+ `os.fsdecode` is the filesystem's own encoding on every host (UTF-8 on
141
+ Windows since Python 3.6, the locale with `surrogateescape` on POSIX), so the
142
+ result names the file the child meant and round-trips through
143
+ `os.fsencode`.
144
+ """
145
+ return os.fsdecode(data)
146
+
147
+
148
+ def display_safe(text: str) -> str:
149
+ r"""Make `text` printable without raising, without changing what it names.
150
+
151
+ A path decoded with `decode_path` can hold lone surrogates (bytes the
152
+ filesystem encoding could not decode); printing one raises. They are shown as
153
+ a visible `\udcXX` instead. The original string is untouched: use this only
154
+ at the point of display.
155
+ """
156
+ return text.encode("utf-8", errors="backslashreplace").decode("utf-8")
@@ -18,7 +18,7 @@ from rich.panel import Panel
18
18
  from rich.table import Table
19
19
  from rich.text import Text
20
20
 
21
- from create_forge import compat
21
+ from create_forge import capture, compat
22
22
  from create_forge.compat import (
23
23
  ENGINE_DISTRIBUTION,
24
24
  INTEGRATION_LINE,
@@ -1442,15 +1442,27 @@ def _run_engine_update( # noqa: PLR0915 - one branch per prepare/apply/degraded
1442
1442
  """The engine-native `update` route (ADR 0041 rules 9-23, ADR 0046)."""
1443
1443
  from create_forge import engine, pipeline, update # noqa: PLC0415
1444
1444
 
1445
+ # ADR 0053: recovery guidance is only ever printed once the clean-tree
1446
+ # precondition has passed. Before it, whatever is dirty is the user's own
1447
+ # work, and telling them to restore it away would destroy it.
1448
+ started = False
1445
1449
  try:
1446
1450
  update.require_clean_tree(project)
1451
+ started = True
1447
1452
  degraded_reason: str | None = None
1448
1453
 
1449
1454
  if degraded:
1450
1455
  recorded, new = pipeline.prepare_degraded_update(project)
1456
+ new_files = {file.target: file.content for file in new.files}
1457
+ update.preflight_update(
1458
+ project,
1459
+ metadata_filename=engine.generation_metadata_target(),
1460
+ targets=new_files,
1461
+ recorded_targets=recorded.digests,
1462
+ )
1451
1463
  outcome = update.degraded_plan(
1452
1464
  project,
1453
- {file.target: file.content for file in new.files},
1465
+ new_files,
1454
1466
  recorded_digests=recorded.digests,
1455
1467
  dry_run=dry_run,
1456
1468
  )
@@ -1463,15 +1475,31 @@ def _run_engine_update( # noqa: PLR0915 - one branch per prepare/apply/degraded
1463
1475
  err.print(f"[red]{exc}[/red]")
1464
1476
  raise typer.Exit(3) from exc
1465
1477
  recorded, new = pipeline.prepare_degraded_update(project)
1478
+ new_files = {file.target: file.content for file in new.files}
1479
+ update.preflight_update(
1480
+ project,
1481
+ metadata_filename=engine.generation_metadata_target(),
1482
+ targets=new_files,
1483
+ recorded_targets=recorded.digests,
1484
+ )
1466
1485
  outcome = update.degraded_plan(
1467
1486
  project,
1468
- {file.target: file.content for file in new.files},
1487
+ new_files,
1469
1488
  recorded_digests=recorded.digests,
1470
1489
  dry_run=dry_run,
1471
1490
  )
1472
1491
  degraded_reason = str(exc)
1473
1492
  else:
1474
1493
  new = preparation.new
1494
+ # ADR 0052: refuse the whole update before its first mutation.
1495
+ # Plan targets already cover every recorded target the plan
1496
+ # acts on, so the recorded document itself is not re-read here.
1497
+ update.preflight_update(
1498
+ project,
1499
+ metadata_filename=engine.generation_metadata_target(),
1500
+ targets=[item.target for item in preparation.plan.targets],
1501
+ renames=preparation.plan.renames,
1502
+ )
1475
1503
  update.apply_renames(project, preparation.plan.renames)
1476
1504
  outcome = update.apply_plan(
1477
1505
  project,
@@ -1492,8 +1520,10 @@ def _run_engine_update( # noqa: PLR0915 - one branch per prepare/apply/degraded
1492
1520
  msg = "the engine returned no generation metadata for this render"
1493
1521
  raise StagingError(msg)
1494
1522
  refreshed = engine.metadata_json(new.metadata, degraded_reason=degraded_reason)
1495
- (project / engine.generation_metadata_target()).write_text(
1496
- refreshed, encoding="utf-8"
1523
+ update.write_recorded(
1524
+ project,
1525
+ metadata_filename=engine.generation_metadata_target(),
1526
+ content=refreshed,
1497
1527
  )
1498
1528
  update.stage_result(project)
1499
1529
 
@@ -1502,21 +1532,37 @@ def _run_engine_update( # noqa: PLR0915 - one branch per prepare/apply/degraded
1502
1532
  _report_update_result(outcome)
1503
1533
  except KeyboardInterrupt:
1504
1534
  err.print("\n[dim]Cancelled.[/dim]")
1505
- err.print(f"[dim]Recover with: {update.ROLLBACK_HINT}[/dim]")
1535
+ _print_recovery(project, started=started)
1506
1536
  raise typer.Exit(130) from None
1507
1537
  except (update.UpdateError, StagingError) as exc:
1508
1538
  err.print(f"[red]{exc}[/red]")
1509
- err.print(f"[dim]Recover with: {update.ROLLBACK_HINT}[/dim]")
1539
+ _print_recovery(project, started=started)
1510
1540
  raise typer.Exit(1) from exc
1511
1541
  except engine.EngineCompatibilityError as exc:
1512
1542
  err.print(f"[red]{exc}[/red]")
1513
1543
  raise typer.Exit(3) from exc
1514
1544
  except engine.ForgeEngineError as exc:
1515
1545
  err.print(f"[red]{engine.explain(exc)}[/red]")
1516
- err.print(f"[dim]Recover with: {update.ROLLBACK_HINT}[/dim]")
1546
+ _print_recovery(project, started=started)
1517
1547
  raise typer.Exit(1) from exc
1518
1548
 
1519
1549
 
1550
+ def _print_recovery(project: Path, *, started: bool) -> None:
1551
+ """Rule 18 (ADR 0053): guidance for the repository's *actual* Git state.
1552
+
1553
+ Read from Git at failure time, so a failure that changed nothing says so
1554
+ rather than printing a command, and a staged or half-renamed tree gets one
1555
+ that actually restores it. Printed, never run. `soft_wrap` and no markup or
1556
+ highlighting keep a command on one line, unmangled, ready to paste.
1557
+ """
1558
+ if not started:
1559
+ return
1560
+ from create_forge import update # noqa: PLC0415
1561
+
1562
+ for line in update.recovery_guidance(project).lines():
1563
+ err.print(line, style="dim", markup=False, highlight=False, soft_wrap=True)
1564
+
1565
+
1520
1566
  def _markers(target: Console) -> tuple[str, str]:
1521
1567
  """Return (pass, fail) markers the console's encoding can actually render.
1522
1568
 
@@ -1889,16 +1935,11 @@ def _uv_version(uv_path: str | None) -> str | None:
1889
1935
  if not uv_path:
1890
1936
  return None
1891
1937
  try:
1892
- result = subprocess.run( # noqa: S603
1893
- [uv_path, "--version"],
1894
- capture_output=True,
1895
- text=True,
1896
- check=False,
1897
- timeout=5,
1898
- )
1938
+ result = capture.run_captured([uv_path, "--version"], timeout=5)
1899
1939
  except (OSError, subprocess.TimeoutExpired): # pragma: no cover
1900
1940
  return None
1901
- match result.stdout.split():
1941
+ # A diagnostic read (CF-23.01): lenient, and only a validated token escapes.
1942
+ match capture.decode_diagnostic(result.stdout).split():
1902
1943
  case [_, token, *_] if re.fullmatch(r"[0-9][0-9A-Za-z.+-]*", token):
1903
1944
  return token
1904
1945
  case _:
@@ -2048,13 +2089,9 @@ def config_show() -> None:
2048
2089
 
2049
2090
  def _git_config(key: str) -> str | None:
2050
2091
  try:
2051
- result = subprocess.run( # noqa: S603
2052
- ["git", "config", "--get", key], # noqa: S607
2053
- capture_output=True,
2054
- text=True,
2055
- check=False,
2056
- timeout=5,
2057
- )
2092
+ result = capture.run_captured(["git", "config", "--get", key], timeout=5)
2058
2093
  except (OSError, subprocess.TimeoutExpired): # pragma: no cover
2059
2094
  return None
2060
- return result.stdout.strip() or None
2095
+ # A diagnostic read (CF-23.01): a name may be non-ASCII, and doctor only asks
2096
+ # whether one is set, so a lenient decode is enough and cannot raise.
2097
+ return capture.decode_diagnostic(result.stdout).strip() or None
@@ -27,21 +27,24 @@ from packaging.version import Version
27
27
  ENGINE_DISTRIBUTION = "forge-template"
28
28
  """The PyPI distribution name the engine dependency declares."""
29
29
 
30
- INTEGRATION_LINE = "v0.4.x-engine"
30
+ INTEGRATION_LINE = "v0.5.x-engine"
31
31
  """The create-forge release line and its default generation architecture.
32
32
 
33
33
  This is explicit rather than derived from installed metadata so a new release
34
34
  line requires a deliberate compatibility review. ADR 0040 (CF-18.01) makes the
35
35
  engine the default `new` architecture; `new` is Copier-backed only under the
36
36
  explicit `--legacy` flag. ADR 0049 (CF-18.07) moved this from `v0.3.x-engine`
37
- to `v0.4.x-engine` for the published cutover release -- a shipped diagnostic
38
- surface (`doctor --json`'s `integration.line`) reviewed by hand, not derived.
37
+ to `v0.4.x-engine` for the published cutover release, and ADR 0056 (CF-21.03)
38
+ moves it to `v0.5.x-engine` for the release that crosses to the `forge-template`
39
+ `0.6` provider line (client `0.N.x` pairs with provider `0.(N+1)`) -- a shipped
40
+ diagnostic surface (`doctor --json`'s `integration.line`) reviewed by hand, not
41
+ derived.
39
42
  `tests/test_engine_contract.py`'s
40
43
  `test_diagnostic_integration_line_matches_package_release_line` checks this
41
44
  literal against `pyproject.toml`'s own `major.minor`.
42
45
  """
43
46
 
44
- SUPPORTED_ENGINE_RANGE = ">=0.5,<0.6"
47
+ SUPPORTED_ENGINE_RANGE = ">=0.6,<0.7"
45
48
  """The supported `forge-template` compatibility range.
46
49
 
47
50
  Pre-1.0, a supported range stays within one minor line -- see the
@@ -52,14 +55,17 @@ human-authored line crossing (ADR 0012), never a Dependabot proposal. ADR
52
55
  `forge-template` 0.4 line; ADR 0031 raised the lower bound to the reviewed
53
56
  `0.4.1` release. ADR 0040/0042 (CF-18.01) adopt the reviewed `0.5.0`
54
57
  engine-default cutover release, the first to publish `metadata_version` and
55
- component-manifest protocol `3`. `engine.py` checks an installed package
56
- against this range with `packaging.specifiers.SpecifierSet`.
58
+ component-manifest protocol `3`. ADR 0050 (CF-21.01) adopts the reviewed
59
+ `0.6.0` Streamlit provider release, which moves only the package version and
60
+ the discovered catalogue (a fifteenth component) and no protocol tuple.
61
+ `engine.py` checks an installed package against this range with
62
+ `packaging.specifiers.SpecifierSet`.
57
63
  """
58
64
 
59
65
  SUPPORTED_PROJECTSPEC_PROTOCOLS: tuple[int, ...] = (1,)
60
66
  """ProjectSpec wire protocols this create-forge release has implemented
61
- against. Unchanged across the `0.3.x` through `0.5.x` engine lines
62
- (ADR 0026, ADR 0042).
67
+ against. Unchanged across the `0.3.x` through `0.6.x` engine lines
68
+ (ADR 0026, ADR 0042, ADR 0050).
63
69
 
64
70
  Deliberately not read from the installed engine's own advertised protocols
65
71
  -- negotiation in `engine.py` compares the two sides rather than assuming