engineering-process 2.4.0__tar.gz → 2.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.
- {engineering_process-2.4.0/engineering_process.egg-info → engineering_process-2.5.0}/PKG-INFO +19 -1
- {engineering_process-2.4.0 → engineering_process-2.5.0}/README.md +18 -0
- engineering_process-2.5.0/RELEASE_NOTES.md +17 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/__init__.py +1 -1
- {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/cli.py +144 -6
- {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/commands.py +93 -16
- {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/contracts.py +1 -0
- engineering_process-2.5.0/engineering_process/evidence.py +173 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/lifecycle.py +425 -22
- {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/pr_description.py +226 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/repository.py +31 -8
- {engineering_process-2.4.0 → engineering_process-2.5.0/engineering_process.egg-info}/PKG-INFO +19 -1
- {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process.egg-info/SOURCES.txt +2 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/process_assets/skills/change-plan/SKILL.md +10 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/process_assets/skills/change-review/SKILL.md +17 -3
- {engineering_process-2.4.0 → engineering_process-2.5.0}/process_assets/skills/change-verify/SKILL.md +29 -5
- {engineering_process-2.4.0 → engineering_process-2.5.0}/process_assets/skills/process-improve/SKILL.md +24 -9
- {engineering_process-2.4.0 → engineering_process-2.5.0}/pyproject.toml +2 -1
- {engineering_process-2.4.0 → engineering_process-2.5.0}/schemas/run.schema.json +2 -0
- engineering_process-2.5.0/schemas/verification-selection.schema.json +72 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/tests/test_architecture.py +1 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/tests/test_artifact_standards.py +384 -2
- {engineering_process-2.4.0 → engineering_process-2.5.0}/tests/test_automation.py +6 -1
- {engineering_process-2.4.0 → engineering_process-2.5.0}/tests/test_cli.py +107 -1
- {engineering_process-2.4.0 → engineering_process-2.5.0}/tests/test_commands.py +112 -3
- {engineering_process-2.4.0 → engineering_process-2.5.0}/tests/test_contracts.py +1 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/tests/test_lifecycle.py +453 -11
- {engineering_process-2.4.0 → engineering_process-2.5.0}/tests/test_release.py +2 -2
- {engineering_process-2.4.0 → engineering_process-2.5.0}/tests/test_repository.py +36 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/tests/test_review_contexts.py +4 -2
- {engineering_process-2.4.0 → engineering_process-2.5.0}/tests/test_skills.py +2 -0
- engineering_process-2.4.0/RELEASE_NOTES.md +0 -22
- {engineering_process-2.4.0 → engineering_process-2.5.0}/LICENSE +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/MANIFEST.in +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/__main__.py +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/_supervisor_contract.py +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/_supervisor_posix.py +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/_supervisor_windows.py +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/_windows_job.py +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/adoption.py +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/artifact_standards.py +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/automation_name.py +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/distribution.py +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/helper_launch.py +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/issue.py +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/production_engineering.py +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/project.py +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/publication_compat.py +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/release.py +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/release_notes.py +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/requirements-dev.txt +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/requirements-runtime.txt +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/review_contexts.py +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/skills.py +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/source_publication.py +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process/supervision.py +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process.egg-info/dependency_links.txt +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process.egg-info/entry_points.txt +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process.egg-info/requires.txt +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/engineering_process.egg-info/top_level.txt +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/process-graph.json +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/process_assets/skills/change-complete/SKILL.md +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/process_assets/skills/change-implement/SKILL.md +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/process_assets/skills/change-start/SKILL.md +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/process_assets/skills/deliver-change/SKILL.md +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/process_assets/skills/production-engineering/SKILL.md +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/process_assets/skills/production-engineering/invariants.json +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/process_assets/standards/automation-name.v1.json +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/process_assets/standards/issue.v1.json +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/process_assets/standards/pull-request.v1.json +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/process_assets/standards/release-notes.v1.json +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/schemas/artifact-selection.schema.json +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/schemas/artifact-standard.schema.json +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/schemas/automation-name-data.schema.json +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/schemas/change.schema.json +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/schemas/issue-data.schema.json +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/schemas/plan.schema.json +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/schemas/pr-description-data.schema.json +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/schemas/process-graph.schema.json +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/schemas/process-lock.schema.json +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/schemas/production-engineering.schema.json +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/schemas/project-legacy.schema.json +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/schemas/project.schema.json +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/schemas/receipt.schema.json +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/schemas/release-change.schema.json +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/schemas/release-notes-data.schema.json +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/schemas/release.schema.json +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/schemas/renovate-preset.schema.json +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/schemas/review.schema.json +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/setup.cfg +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/templates/AGENTS.process.md +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/templates/PULL_REQUEST_TEMPLATE.md +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/templates/adopt-process-windows-job.py +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/templates/adopt-process.py +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/templates/renovate.json +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/tests/test_adoption.py +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/tests/test_distribution.py +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/tests/test_production_engineering.py +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/tests/test_publication_compat.py +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/tests/test_publication_workflow.py +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/tests/test_pypi_cache.py +0 -0
- {engineering_process-2.4.0 → engineering_process-2.5.0}/tests/test_supervisor_posix.py +0 -0
{engineering_process-2.4.0/engineering_process.egg-info → engineering_process-2.5.0}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: engineering-process
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.5.0
|
|
4
4
|
Summary: A small agent-neutral engineering lifecycle with managed adoption
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/phuongnse/engineering-process
|
|
@@ -86,6 +86,16 @@ but cannot establish native conversation freshness or inspect removed history,
|
|
|
86
86
|
other clones or other repositories. Keep native creation, non-inherited dispatch
|
|
87
87
|
and effective settings evidence in the existing handoff.
|
|
88
88
|
|
|
89
|
+
Verification keeps two caller intents explicit. `change verify --profile PROFILE`
|
|
90
|
+
always refreshes the named profile. `change explain` is read-only; it shows the
|
|
91
|
+
accepted required profiles, valid prior evidence, remaining work, inapplicable
|
|
92
|
+
optional profiles, and blocked or unknown decisions. `change verify --remaining`
|
|
93
|
+
executes only remaining work and reuses a complete profile report only when the
|
|
94
|
+
candidate, contract/plan, project policy, process authority, runtime, and bounded
|
|
95
|
+
child environment match. Legacy evidence without that identity reruns. This is
|
|
96
|
+
whole-profile reuse: equal check IDs and ordered side effects are never merged, and
|
|
97
|
+
the lifecycle receipt still records only actual executions.
|
|
98
|
+
|
|
89
99
|
`processctl change review replace-reused` repairs only an initial pending assignment
|
|
90
100
|
proven to reuse another change's agent context, before a submitted review or normal
|
|
91
101
|
report file exists. It validates a fresh replacement and current verification,
|
|
@@ -502,6 +512,14 @@ Register implementation and run every required profile:
|
|
|
502
512
|
processctl change verify --change-id change-123 --profile development
|
|
503
513
|
processctl change verify --change-id change-123 --profile review
|
|
504
514
|
|
|
515
|
+
When continuing an incomplete verification, inspect and execute the necessary set:
|
|
516
|
+
|
|
517
|
+
processctl change explain --change-id change-123
|
|
518
|
+
processctl change verify --change-id change-123 --remaining
|
|
519
|
+
|
|
520
|
+
The explicit `--profile` form remains a refresh and is never silently converted to
|
|
521
|
+
reuse.
|
|
522
|
+
|
|
505
523
|
Assign an independent reviewer and submit its report:
|
|
506
524
|
|
|
507
525
|
processctl change review start \
|
|
@@ -62,6 +62,16 @@ but cannot establish native conversation freshness or inspect removed history,
|
|
|
62
62
|
other clones or other repositories. Keep native creation, non-inherited dispatch
|
|
63
63
|
and effective settings evidence in the existing handoff.
|
|
64
64
|
|
|
65
|
+
Verification keeps two caller intents explicit. `change verify --profile PROFILE`
|
|
66
|
+
always refreshes the named profile. `change explain` is read-only; it shows the
|
|
67
|
+
accepted required profiles, valid prior evidence, remaining work, inapplicable
|
|
68
|
+
optional profiles, and blocked or unknown decisions. `change verify --remaining`
|
|
69
|
+
executes only remaining work and reuses a complete profile report only when the
|
|
70
|
+
candidate, contract/plan, project policy, process authority, runtime, and bounded
|
|
71
|
+
child environment match. Legacy evidence without that identity reruns. This is
|
|
72
|
+
whole-profile reuse: equal check IDs and ordered side effects are never merged, and
|
|
73
|
+
the lifecycle receipt still records only actual executions.
|
|
74
|
+
|
|
65
75
|
`processctl change review replace-reused` repairs only an initial pending assignment
|
|
66
76
|
proven to reuse another change's agent context, before a submitted review or normal
|
|
67
77
|
report file exists. It validates a fresh replacement and current verification,
|
|
@@ -478,6 +488,14 @@ Register implementation and run every required profile:
|
|
|
478
488
|
processctl change verify --change-id change-123 --profile development
|
|
479
489
|
processctl change verify --change-id change-123 --profile review
|
|
480
490
|
|
|
491
|
+
When continuing an incomplete verification, inspect and execute the necessary set:
|
|
492
|
+
|
|
493
|
+
processctl change explain --change-id change-123
|
|
494
|
+
processctl change verify --change-id change-123 --remaining
|
|
495
|
+
|
|
496
|
+
The explicit `--profile` form remains a refresh and is never silently converted to
|
|
497
|
+
reuse.
|
|
498
|
+
|
|
481
499
|
Assign an independent reviewer and submit its report:
|
|
482
500
|
|
|
483
501
|
processctl change review start \
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Engineering Process v2.5.0
|
|
2
|
+
|
|
3
|
+
Changes since v2.4.0.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Resolve process issues \#197\-\#205\: durable feedback and review recovery\, snapshot and test cost\, guidance authority\, PR evidence\, adoption scope\, CI repetition\, and necessary verification\. ([#205](https://github.com/phuongnse/engineering-process/issues/205))
|
|
8
|
+
|
|
9
|
+
## Upgrade and compatibility
|
|
10
|
+
|
|
11
|
+
Merge the complete hash-locked package/adoption PR, update the local and CI environments to the selected version, and start a fresh agent session.
|
|
12
|
+
|
|
13
|
+
Consumer CI, naming conventions and branch-protection settings remain consumer-owned; adoption does not configure them automatically.
|
|
14
|
+
|
|
15
|
+
See [versioning and compatibility](https://github.com/phuongnse/engineering-process/blob/v2.5.0/VERSIONING.md) and [adoption guidance](https://github.com/phuongnse/engineering-process/blob/v2.5.0/SELF_HOSTING.md).
|
|
16
|
+
|
|
17
|
+
[Full change comparison](https://github.com/phuongnse/engineering-process/compare/v2.4.0...v2.5.0)
|
|
@@ -38,6 +38,8 @@ from .lifecycle import (
|
|
|
38
38
|
start_change,
|
|
39
39
|
start_review,
|
|
40
40
|
submit_review,
|
|
41
|
+
resolve_verification_work,
|
|
42
|
+
verify_remaining,
|
|
41
43
|
verify_change,
|
|
42
44
|
)
|
|
43
45
|
from .issue import render_issue
|
|
@@ -53,7 +55,13 @@ from .publication_compat import (
|
|
|
53
55
|
validate_range,
|
|
54
56
|
)
|
|
55
57
|
from .release import validate_release
|
|
56
|
-
from .pr_description import
|
|
58
|
+
from .pr_description import (
|
|
59
|
+
body_issues,
|
|
60
|
+
build_pr_description_data,
|
|
61
|
+
render_description,
|
|
62
|
+
render_renovate_preset,
|
|
63
|
+
render_template,
|
|
64
|
+
)
|
|
57
65
|
from .release_notes import render_notes
|
|
58
66
|
from .repository import repository_snapshot, same_checkpoint
|
|
59
67
|
from .skills import validate_skills
|
|
@@ -87,9 +95,16 @@ def _result(command: str, status: str = "passed", **details: Any) -> dict[str, A
|
|
|
87
95
|
return {"command": command, "status": status, **details}
|
|
88
96
|
|
|
89
97
|
|
|
90
|
-
def _state_result(
|
|
98
|
+
def _state_result(
|
|
99
|
+
command: str,
|
|
100
|
+
state: dict[str, Any],
|
|
101
|
+
*,
|
|
102
|
+
status: str = "passed",
|
|
103
|
+
**details: Any,
|
|
104
|
+
) -> dict[str, Any]:
|
|
91
105
|
return _result(
|
|
92
106
|
command,
|
|
107
|
+
status=status,
|
|
93
108
|
changeId=state["changeId"],
|
|
94
109
|
phase=state["phase"],
|
|
95
110
|
cycle=state["cycle"],
|
|
@@ -312,6 +327,54 @@ def command_change_implement(args: argparse.Namespace) -> Result:
|
|
|
312
327
|
def command_change_verify(args: argparse.Namespace) -> Result:
|
|
313
328
|
process_root = _process_root(args)
|
|
314
329
|
project = load_project(args.project_root, process_root)
|
|
330
|
+
if args.remaining:
|
|
331
|
+
state, selection = verify_remaining(
|
|
332
|
+
args.project_root, process_root, project, args.change_id
|
|
333
|
+
)
|
|
334
|
+
executions = []
|
|
335
|
+
failures = []
|
|
336
|
+
for profile in selection["executeProfiles"]:
|
|
337
|
+
report = state["verification"].get(profile)
|
|
338
|
+
if report is None:
|
|
339
|
+
failures.append(profile)
|
|
340
|
+
continue
|
|
341
|
+
if report["status"] != "passed":
|
|
342
|
+
failures.append(profile)
|
|
343
|
+
check_duration = sum(check["durationMs"] for check in report["checks"])
|
|
344
|
+
executions.append(
|
|
345
|
+
{
|
|
346
|
+
"profile": profile,
|
|
347
|
+
"status": report["status"],
|
|
348
|
+
"launchCount": len(report["checks"]),
|
|
349
|
+
"durationMs": report.get("durationMs", check_duration),
|
|
350
|
+
"checkDurationMs": check_duration,
|
|
351
|
+
"processOverheadMs": max(
|
|
352
|
+
0, report.get("durationMs", check_duration) - check_duration
|
|
353
|
+
),
|
|
354
|
+
}
|
|
355
|
+
)
|
|
356
|
+
contract = state.get("contract", {})
|
|
357
|
+
required_profiles = (
|
|
358
|
+
contract.get("document", {}).get("requiredProfiles", [])
|
|
359
|
+
if isinstance(contract, dict)
|
|
360
|
+
else []
|
|
361
|
+
)
|
|
362
|
+
verification = state.get("verification", {})
|
|
363
|
+
required_profiles_passed = bool(required_profiles) and all(
|
|
364
|
+
isinstance(verification.get(profile), dict)
|
|
365
|
+
and verification[profile].get("status") == "passed"
|
|
366
|
+
for profile in required_profiles
|
|
367
|
+
)
|
|
368
|
+
complete = state["phase"] == "verified" and required_profiles_passed
|
|
369
|
+
return _state_result(
|
|
370
|
+
"change verify",
|
|
371
|
+
state,
|
|
372
|
+
status="passed" if complete and not failures else "failed",
|
|
373
|
+
selection=selection,
|
|
374
|
+
execution="remaining",
|
|
375
|
+
executions=executions,
|
|
376
|
+
failures=failures,
|
|
377
|
+
), (0 if complete and not failures else 1)
|
|
315
378
|
state, report = verify_change(
|
|
316
379
|
args.project_root, process_root, project, args.change_id, args.profile
|
|
317
380
|
)
|
|
@@ -321,6 +384,15 @@ def command_change_verify(args: argparse.Namespace) -> Result:
|
|
|
321
384
|
), code
|
|
322
385
|
|
|
323
386
|
|
|
387
|
+
def command_change_explain(args: argparse.Namespace) -> Result:
|
|
388
|
+
process_root = _process_root(args)
|
|
389
|
+
project = load_project(args.project_root, process_root)
|
|
390
|
+
selection = resolve_verification_work(
|
|
391
|
+
args.project_root, process_root, project, args.change_id
|
|
392
|
+
)
|
|
393
|
+
return _result("change explain", selection=selection), 0
|
|
394
|
+
|
|
395
|
+
|
|
324
396
|
def command_change_review_start(args: argparse.Namespace) -> Result:
|
|
325
397
|
process_root = _process_root(args)
|
|
326
398
|
state = start_review(
|
|
@@ -434,13 +506,37 @@ def command_artifact(args: argparse.Namespace) -> Result:
|
|
|
434
506
|
f"{standard.document['adapter']} state must be "
|
|
435
507
|
+ " or ".join(sorted(allowed_states))
|
|
436
508
|
)
|
|
437
|
-
|
|
509
|
+
if getattr(args, "change_id", None) is not None and standard.document["adapter"] == "pr-description":
|
|
510
|
+
overrides = (
|
|
511
|
+
load_and_validate(
|
|
512
|
+
args.data_file,
|
|
513
|
+
"pr-description-data",
|
|
514
|
+
schema_root=schemas_root(process_root),
|
|
515
|
+
)
|
|
516
|
+
if args.data_file is not None
|
|
517
|
+
else None
|
|
518
|
+
)
|
|
519
|
+
data = build_pr_description_data(
|
|
520
|
+
args.project_root,
|
|
521
|
+
process_root,
|
|
522
|
+
args.change_id,
|
|
523
|
+
overrides=overrides,
|
|
524
|
+
standard=standard,
|
|
525
|
+
)
|
|
526
|
+
elif args.data_file is not None:
|
|
527
|
+
data = load_and_validate(
|
|
528
|
+
args.data_file,
|
|
529
|
+
standard.document["adapter"] + "-data",
|
|
530
|
+
schema_root=schemas_root(process_root),
|
|
531
|
+
)
|
|
532
|
+
else:
|
|
533
|
+
data = None
|
|
438
534
|
if data is not None:
|
|
439
535
|
renderer = {"pr-description": render_description, "release-notes": render_notes, "automation-name": render_name, "issue": render_issue}[standard.document["adapter"]]
|
|
440
536
|
payload = renderer(standard, data, state=state, process_root=process_root).encode("utf-8")
|
|
441
537
|
details["dataDigest"] = digest_json(data)
|
|
442
538
|
elif operation == "render" or standard.document["adapter"] != "pr-description":
|
|
443
|
-
raise ProcessError("this artifact operation requires --data-file")
|
|
539
|
+
raise ProcessError("this artifact operation requires --data-file or --change-id")
|
|
444
540
|
if operation == "validate":
|
|
445
541
|
actual = read_document(args.body_file)
|
|
446
542
|
if data is None:
|
|
@@ -460,6 +556,38 @@ def command_artifact(args: argparse.Namespace) -> Result:
|
|
|
460
556
|
return _result(f"artifact {operation}", status, **details, issues=issues), (1 if issues else 0)
|
|
461
557
|
|
|
462
558
|
|
|
559
|
+
def command_artifact_prepare_pr_data(args: argparse.Namespace) -> Result:
|
|
560
|
+
process_root = _process_root(args)
|
|
561
|
+
standard = resolve_standard(args.project_root, process_root, "pull-request")
|
|
562
|
+
overrides = (
|
|
563
|
+
load_and_validate(
|
|
564
|
+
args.data_file,
|
|
565
|
+
"pr-description-data",
|
|
566
|
+
schema_root=schemas_root(process_root),
|
|
567
|
+
)
|
|
568
|
+
if args.data_file is not None
|
|
569
|
+
else None
|
|
570
|
+
)
|
|
571
|
+
data = build_pr_description_data(
|
|
572
|
+
args.project_root,
|
|
573
|
+
process_root,
|
|
574
|
+
args.change_id,
|
|
575
|
+
overrides=overrides,
|
|
576
|
+
standard=standard,
|
|
577
|
+
)
|
|
578
|
+
payload = formatted_json_bytes(data)
|
|
579
|
+
details: dict[str, Any] = {
|
|
580
|
+
"changeId": args.change_id,
|
|
581
|
+
"dataDigest": digest_json(data),
|
|
582
|
+
}
|
|
583
|
+
if args.output is not None:
|
|
584
|
+
args.output.write_bytes(payload)
|
|
585
|
+
details["output"] = str(args.output)
|
|
586
|
+
else:
|
|
587
|
+
details["data"] = data
|
|
588
|
+
return _result("artifact prepare-pr-data", "passed", **details), 0
|
|
589
|
+
|
|
590
|
+
|
|
463
591
|
def _add_common(parser: argparse.ArgumentParser, *, project: bool = True) -> None:
|
|
464
592
|
if project:
|
|
465
593
|
parser.add_argument("--project-root", type=_root, default=Path.cwd())
|
|
@@ -536,7 +664,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
536
664
|
change_implement.add_argument("--change-id", required=True)
|
|
537
665
|
change_verify = _leaf(change_commands, "verify", command_change_verify)
|
|
538
666
|
change_verify.add_argument("--change-id", required=True)
|
|
539
|
-
change_verify.
|
|
667
|
+
verify_scope = change_verify.add_mutually_exclusive_group(required=True)
|
|
668
|
+
verify_scope.add_argument("--profile")
|
|
669
|
+
verify_scope.add_argument("--remaining", action="store_true")
|
|
670
|
+
|
|
671
|
+
change_explain = _leaf(change_commands, "explain", command_change_explain)
|
|
672
|
+
change_explain.add_argument("--change-id", required=True)
|
|
540
673
|
|
|
541
674
|
review = change_commands.add_parser("review")
|
|
542
675
|
review_commands = review.add_subparsers(dest="review_command", required=True)
|
|
@@ -564,12 +697,17 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
564
697
|
|
|
565
698
|
artifact = commands.add_parser("artifact", help="Generate and verify consumer-selected artifact standards")
|
|
566
699
|
artifact_commands = artifact.add_subparsers(dest="artifact_command", required=True)
|
|
700
|
+
prepare_pr_data = _leaf(artifact_commands, "prepare-pr-data", command_artifact_prepare_pr_data)
|
|
701
|
+
prepare_pr_data.add_argument("--change-id", required=True)
|
|
702
|
+
prepare_pr_data.add_argument("--data-file", type=Path)
|
|
703
|
+
prepare_pr_data.add_argument("--output", type=Path)
|
|
567
704
|
for name in ("show", "template", "renovate-preset", "render", "validate"):
|
|
568
705
|
leaf = _leaf(artifact_commands, name, command_artifact)
|
|
569
706
|
leaf.add_argument("--artifact", required=True)
|
|
570
707
|
if name in {"render", "validate"}:
|
|
571
708
|
leaf.add_argument("--state", choices=("draft", "ready", "open", "closed"))
|
|
572
|
-
leaf.add_argument("--
|
|
709
|
+
leaf.add_argument("--change-id")
|
|
710
|
+
leaf.add_argument("--data-file", type=Path, required=False)
|
|
573
711
|
if name == "validate":
|
|
574
712
|
leaf.add_argument("--body-file", type=Path, required=True)
|
|
575
713
|
else:
|
|
@@ -3,39 +3,113 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import hashlib
|
|
6
|
+
from contextlib import contextmanager
|
|
6
7
|
import os
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
import sys
|
|
9
10
|
import threading
|
|
10
11
|
import time
|
|
11
|
-
from typing import Any, BinaryIO
|
|
12
|
+
from typing import Any, BinaryIO, Iterator
|
|
12
13
|
|
|
13
14
|
from .contracts import ProcessError
|
|
15
|
+
from .evidence import child_environment, execution_identity as _execution_identity
|
|
14
16
|
from .supervision import process_supervisor
|
|
15
17
|
|
|
16
18
|
|
|
17
19
|
DEFAULT_OUTPUT_BYTES = 1_000_000
|
|
18
20
|
TERMINATION_SECONDS = 2
|
|
19
|
-
SECRET_MARKERS = ("TOKEN", "SECRET", "PASSWORD", "PASSWD", "API_KEY", "PRIVATE_KEY")
|
|
20
21
|
_EXECUTION_LOCK = threading.Lock()
|
|
21
22
|
|
|
22
23
|
|
|
23
24
|
def _child_environment() -> dict[str, str]:
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
25
|
+
return child_environment(executable=sys.executable)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def execution_identity() -> dict[str, Any]:
|
|
29
|
+
"""Return the bounded runtime inputs used to launch consumer checks."""
|
|
30
|
+
return _execution_identity(executable=sys.executable)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_LOCK_OWNERS = threading.local()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@contextmanager
|
|
37
|
+
def verification_lock(path: Path) -> Iterator[None]:
|
|
38
|
+
"""Prevent concurrent lifecycle requests from dispatching verification twice."""
|
|
39
|
+
lock_path = (
|
|
40
|
+
path.parent / ".verification.lock"
|
|
41
|
+
if os.name == "nt"
|
|
42
|
+
else path.parent
|
|
36
43
|
)
|
|
37
|
-
|
|
38
|
-
|
|
44
|
+
resolved = lock_path.resolve()
|
|
45
|
+
holders = getattr(_LOCK_OWNERS, "holders", None)
|
|
46
|
+
if holders is None:
|
|
47
|
+
holders = _LOCK_OWNERS.holders = {}
|
|
48
|
+
|
|
49
|
+
if resolved in holders:
|
|
50
|
+
holders[resolved] += 1
|
|
51
|
+
try:
|
|
52
|
+
yield
|
|
53
|
+
finally:
|
|
54
|
+
holders[resolved] -= 1
|
|
55
|
+
if holders[resolved] == 0:
|
|
56
|
+
del holders[resolved]
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
if os.name == "nt":
|
|
60
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_BINARY", 0)
|
|
62
|
+
flags |= getattr(os, "O_NOFOLLOW", 0)
|
|
63
|
+
else:
|
|
64
|
+
# Lock the run directory itself so replacing a profile lock entry cannot
|
|
65
|
+
# create a second lock inode while this operation is active.
|
|
66
|
+
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
|
67
|
+
try:
|
|
68
|
+
descriptor = os.open(lock_path, flags, 0o600)
|
|
69
|
+
except OSError as error:
|
|
70
|
+
raise ProcessError(f"cannot open verification lock: {error}") from error
|
|
71
|
+
locked = False
|
|
72
|
+
try:
|
|
73
|
+
if os.name == "nt":
|
|
74
|
+
opened = os.fstat(descriptor)
|
|
75
|
+
current = os.stat(lock_path)
|
|
76
|
+
if (current.st_dev, current.st_ino) != (opened.st_dev, opened.st_ino):
|
|
77
|
+
raise ProcessError("verification lock was replaced while opening")
|
|
78
|
+
import msvcrt
|
|
79
|
+
if os.fstat(descriptor).st_size == 0:
|
|
80
|
+
os.write(descriptor, b"0")
|
|
81
|
+
os.lseek(descriptor, 0, os.SEEK_SET)
|
|
82
|
+
msvcrt.locking(descriptor, msvcrt.LK_NBLCK, 1)
|
|
83
|
+
else:
|
|
84
|
+
import fcntl
|
|
85
|
+
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
86
|
+
locked = True
|
|
87
|
+
if os.name == "nt":
|
|
88
|
+
current = os.stat(lock_path)
|
|
89
|
+
if (current.st_dev, current.st_ino) != (opened.st_dev, opened.st_ino):
|
|
90
|
+
raise ProcessError("verification lock was replaced while acquiring")
|
|
91
|
+
except OSError as error:
|
|
92
|
+
os.close(descriptor)
|
|
93
|
+
raise ProcessError("verification for this profile is already running") from error
|
|
94
|
+
except ProcessError:
|
|
95
|
+
os.close(descriptor)
|
|
96
|
+
raise
|
|
97
|
+
holders[resolved] = 1
|
|
98
|
+
try:
|
|
99
|
+
yield
|
|
100
|
+
finally:
|
|
101
|
+
del holders[resolved]
|
|
102
|
+
if locked:
|
|
103
|
+
try:
|
|
104
|
+
if os.name == "nt":
|
|
105
|
+
import msvcrt
|
|
106
|
+
os.lseek(descriptor, 0, os.SEEK_SET)
|
|
107
|
+
msvcrt.locking(descriptor, msvcrt.LK_UNLCK, 1)
|
|
108
|
+
else:
|
|
109
|
+
import fcntl
|
|
110
|
+
fcntl.flock(descriptor, fcntl.LOCK_UN)
|
|
111
|
+
finally:
|
|
112
|
+
os.close(descriptor)
|
|
39
113
|
|
|
40
114
|
|
|
41
115
|
class _OutputBudget:
|
|
@@ -221,6 +295,7 @@ def run_profile(
|
|
|
221
295
|
)
|
|
222
296
|
checks = [configured_checks[check_position - 1]]
|
|
223
297
|
positions = [check_position]
|
|
298
|
+
started = time.monotonic()
|
|
224
299
|
reports: list[dict[str, Any]] = []
|
|
225
300
|
failed_position: int | None = None
|
|
226
301
|
for position, check in zip(positions, checks, strict=True):
|
|
@@ -234,6 +309,7 @@ def run_profile(
|
|
|
234
309
|
"status": "passed" if len(reports) == len(checks) and all(
|
|
235
310
|
report["status"] == "passed" for report in reports
|
|
236
311
|
) else "failed",
|
|
312
|
+
"durationMs": 0,
|
|
237
313
|
"scope": (
|
|
238
314
|
{"kind": "profile"}
|
|
239
315
|
if check_position is None
|
|
@@ -245,6 +321,7 @@ def run_profile(
|
|
|
245
321
|
),
|
|
246
322
|
"checks": reports,
|
|
247
323
|
}
|
|
324
|
+
result["durationMs"] = int((time.monotonic() - started) * 1000)
|
|
248
325
|
failed = next((report for report in reports if report["status"] == "failed"), None)
|
|
249
326
|
if failed is not None:
|
|
250
327
|
assert failed_position is not None
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Shared runtime and lifecycle evidence identity helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib import metadata
|
|
6
|
+
import hashlib
|
|
7
|
+
import os
|
|
8
|
+
import platform
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import sys
|
|
11
|
+
from typing import Any, Mapping
|
|
12
|
+
|
|
13
|
+
from . import VERSION
|
|
14
|
+
from .contracts import digest_json
|
|
15
|
+
from .distribution import distribution_digest
|
|
16
|
+
from .repository import same_checkpoint
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
SECRET_MARKERS = ("TOKEN", "SECRET", "PASSWORD", "PASSWD", "API_KEY", "PRIVATE_KEY")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def child_environment(
|
|
23
|
+
*,
|
|
24
|
+
executable: str | Path | None = None,
|
|
25
|
+
source: Mapping[str, str] | None = None,
|
|
26
|
+
) -> dict[str, str]:
|
|
27
|
+
"""Project the exact sanitized environment used by consumer checks."""
|
|
28
|
+
environment: dict[str, str] = {}
|
|
29
|
+
for name, value in (os.environ if source is None else source).items():
|
|
30
|
+
upper = name.upper()
|
|
31
|
+
if name in {"PYTHONHOME", "PYTHONPATH"}:
|
|
32
|
+
continue
|
|
33
|
+
if any(marker in upper for marker in SECRET_MARKERS):
|
|
34
|
+
continue
|
|
35
|
+
# Empty bindings remain meaningful inputs to a consumer command.
|
|
36
|
+
environment[name] = value
|
|
37
|
+
runtime_executable = Path(
|
|
38
|
+
sys.executable if executable is None else executable
|
|
39
|
+
).absolute()
|
|
40
|
+
runtime_directory = str(runtime_executable.parent)
|
|
41
|
+
inherited_path = environment.get("PATH", "")
|
|
42
|
+
path_entries = [entry for entry in inherited_path.split(os.pathsep) if entry]
|
|
43
|
+
runtime_is_first = bool(path_entries) and (
|
|
44
|
+
os.path.normcase(os.path.normpath(path_entries[0]))
|
|
45
|
+
== os.path.normcase(os.path.normpath(runtime_directory))
|
|
46
|
+
)
|
|
47
|
+
if not runtime_is_first:
|
|
48
|
+
path_entries.insert(0, runtime_directory)
|
|
49
|
+
environment["PATH"] = os.pathsep.join(path_entries)
|
|
50
|
+
environment["PYTHONUNBUFFERED"] = "1"
|
|
51
|
+
return environment
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def execution_identity(
|
|
55
|
+
*,
|
|
56
|
+
executable: str | Path | None = None,
|
|
57
|
+
source: Mapping[str, str] | None = None,
|
|
58
|
+
) -> dict[str, Any]:
|
|
59
|
+
"""Return the runtime inputs used to launch consumer checks."""
|
|
60
|
+
try:
|
|
61
|
+
# Keep duplicate distribution records: multiplicity is part of the
|
|
62
|
+
# environment identity, even when records have the same display name.
|
|
63
|
+
dependencies = sorted(
|
|
64
|
+
f"{distribution.name}=={distribution.version}@"
|
|
65
|
+
f"{Path(distribution.locate_file('')).resolve()}"
|
|
66
|
+
for distribution in metadata.distributions(path=_metadata_search_path())
|
|
67
|
+
)
|
|
68
|
+
except Exception:
|
|
69
|
+
dependency_identity: dict[str, Any] = {"known": False}
|
|
70
|
+
else:
|
|
71
|
+
dependency_identity = {
|
|
72
|
+
"known": True,
|
|
73
|
+
"count": len(dependencies),
|
|
74
|
+
"digest": "sha256:" + hashlib.sha256(
|
|
75
|
+
"\n".join(dependencies).encode("utf-8")
|
|
76
|
+
).hexdigest(),
|
|
77
|
+
}
|
|
78
|
+
runtime_executable = Path(
|
|
79
|
+
sys.executable if executable is None else executable
|
|
80
|
+
).absolute()
|
|
81
|
+
return {
|
|
82
|
+
"executable": str(runtime_executable),
|
|
83
|
+
"python": sys.version,
|
|
84
|
+
"platform": platform.platform(),
|
|
85
|
+
"environment": child_environment(
|
|
86
|
+
executable=runtime_executable, source=source
|
|
87
|
+
),
|
|
88
|
+
"dependencies": dependency_identity,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _metadata_search_path() -> list[str]:
|
|
93
|
+
paths: list[str] = []
|
|
94
|
+
seen: set[str] = set()
|
|
95
|
+
for entry in sys.path:
|
|
96
|
+
value = os.fspath(entry) if entry else os.curdir
|
|
97
|
+
key = os.path.normcase(str(Path(value).resolve()))
|
|
98
|
+
if key in seen:
|
|
99
|
+
continue
|
|
100
|
+
seen.add(key)
|
|
101
|
+
paths.append(value)
|
|
102
|
+
return paths
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def verification_input_digest(
|
|
106
|
+
project_root: Path,
|
|
107
|
+
process_root: Path,
|
|
108
|
+
project: dict[str, Any],
|
|
109
|
+
state: dict[str, Any],
|
|
110
|
+
profile: str,
|
|
111
|
+
*,
|
|
112
|
+
runtime: dict[str, Any] | None = None,
|
|
113
|
+
) -> str | None:
|
|
114
|
+
"""Bind reusable evidence to every input controlled by this process."""
|
|
115
|
+
runtime = runtime if runtime is not None else execution_identity()
|
|
116
|
+
dependencies = runtime.get("dependencies")
|
|
117
|
+
if not isinstance(dependencies, Mapping) or dependencies.get("known") is not True:
|
|
118
|
+
return None
|
|
119
|
+
return digest_json({
|
|
120
|
+
"authority": {
|
|
121
|
+
"version": VERSION,
|
|
122
|
+
"distribution": distribution_digest(process_root),
|
|
123
|
+
},
|
|
124
|
+
"project": project,
|
|
125
|
+
"contractDigest": state["contract"]["digest"],
|
|
126
|
+
"planDigest": state["plan"]["digest"] if state.get("plan") else None,
|
|
127
|
+
"comparisonBaseCommit": state.get("comparisonBaseCommit"),
|
|
128
|
+
"profile": profile,
|
|
129
|
+
"projectRoot": str(project_root.resolve()),
|
|
130
|
+
"runtime": runtime,
|
|
131
|
+
"host": platform.node(),
|
|
132
|
+
"pythonImplementation": sys.implementation.name,
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def verification_report_matches_inputs(
|
|
137
|
+
project_root: Path,
|
|
138
|
+
process_root: Path,
|
|
139
|
+
project: dict[str, Any],
|
|
140
|
+
state: dict[str, Any],
|
|
141
|
+
profile: str,
|
|
142
|
+
report: dict[str, Any],
|
|
143
|
+
checkpoint: dict[str, Any],
|
|
144
|
+
*,
|
|
145
|
+
require_input: bool,
|
|
146
|
+
require_explicit_scope: bool = False,
|
|
147
|
+
runtime: dict[str, Any] | None = None,
|
|
148
|
+
) -> bool:
|
|
149
|
+
"""Return whether one report is reusable for the supplied inputs."""
|
|
150
|
+
scope = report.get("scope")
|
|
151
|
+
if scope is None:
|
|
152
|
+
if require_explicit_scope:
|
|
153
|
+
return False
|
|
154
|
+
scope = {"kind": "profile"}
|
|
155
|
+
if (
|
|
156
|
+
report.get("profile") != profile
|
|
157
|
+
or report.get("status") != "passed"
|
|
158
|
+
or scope != {"kind": "profile"}
|
|
159
|
+
or not same_checkpoint(report.get("checkpoint", {}), checkpoint)
|
|
160
|
+
):
|
|
161
|
+
return False
|
|
162
|
+
recorded = report.get("inputDigest")
|
|
163
|
+
if recorded is None:
|
|
164
|
+
return not require_input
|
|
165
|
+
current = verification_input_digest(
|
|
166
|
+
project_root,
|
|
167
|
+
process_root,
|
|
168
|
+
project,
|
|
169
|
+
state,
|
|
170
|
+
profile,
|
|
171
|
+
runtime=runtime,
|
|
172
|
+
)
|
|
173
|
+
return current is not None and recorded == current
|