Skip to content

Commit 3bb4445

Browse files
committed
Add Stage 5 release promotion contract
1 parent a475205 commit 3bb4445

16 files changed

Lines changed: 1238 additions & 15 deletions

File tree

changelog.d/1037.added

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added the Stage 5 release promotion contract and runtime manifest output.

docs/engineering/pipeline-map.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1395,6 +1395,14 @@ class ReleasePromotionContext
13951395

13961396
Canonical run, candidate, release, and destination identity for Stage 5.
13971397

1398+
### `policyengine_us_data.release_promotion.contract.ReleasePromotionContractBuilder`
1399+
1400+
```python
1401+
class ReleasePromotionContractBuilder
1402+
```
1403+
1404+
Build a Stage 5 contract from candidate identity and promotion results.
1405+
13981406
### `modal_app.local_area._resolve_scope_fingerprint`
13991407

14001408
```python

docs/engineering/stages/release_promotion.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,3 +121,29 @@ cleanup `status` as `skipped`, `completed`, or `failed` on
121121
`CleanupPromotionResult`. Later contract, index, diagnostics, and status
122122
writers should read this typed material instead of scraping logs or
123123
reconstructing public paths independently.
124+
125+
## Release Promotion Contract
126+
127+
Stage 5 writes `release_promotion_contract.json` under the run-local
128+
`diagnostics/contracts/` directory after the promotion transaction succeeds and
129+
before the Stage 5 step manifest is completed. The contract is the semantic
130+
record for the Stage 5 boundary: it ties the canonical `run_id`, candidate
131+
identity, Stage 4 output contract reference when available, validation report
132+
paths, public Hugging Face and GCS refs, cleanup status, and typed
133+
`FullPromotionResult` into one durable `StageContract`.
134+
135+
The contract complements the public release files instead of replacing them:
136+
137+
- `release_manifest.json` and `releases/{version}/release_manifest.json` remain
138+
the public artifact inventory for the stable release.
139+
- `version_manifest.json` remains the public version registry used by clients
140+
and publication checks.
141+
- `releases/{version}/release-complete.json` remains the final completion
142+
marker and tag target proving the release was fully finalized.
143+
- `release_promotion_contract.json` remains run-scoped diagnostics material for
144+
dashboards, AI agents, rerun comparison, and promotion auditability.
145+
146+
Runtime step manifests for `5_validate_and_promote_release` should include the
147+
contract as a JSON `contract` output. They may still record legacy validated
148+
input artifacts for compatibility, but the contract is the preferred semantic
149+
entry point for Stage 5 status and lineage.

docs/generated/pipeline_api.json

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1232,7 +1232,7 @@
12321232
"docstring": "Typed result for a full Stage 5 release promotion transaction.",
12331233
"id": "full_promotion_result",
12341234
"kind": "class",
1235-
"line": 62,
1235+
"line": 63,
12361236
"metadata": {
12371237
"api_refs": [
12381238
"policyengine_us_data.release_promotion.results.FullPromotionResult",
@@ -3086,7 +3086,7 @@
30863086
"docstring": "Promote a completed pipeline run to production.\n\n1. Verify run status is \"completed\"\n2. Promote every staged artifact in one Hugging Face commit\n3. Upload/copy every artifact to GCS\n4. Finalize release_manifest.json, tag the release, and update\n version_manifest.json\n5. Update run status to \"promoted\"\n\nArgs:\n run_id: The run ID to promote.\n candidate_version: Candidate staging scope used for staged source files.\n release_version: Stable version used for final release metadata.\n\nReturns:\n Summary message.",
30873087
"id": "promote_pipeline_run",
30883088
"kind": "function",
3089-
"line": 1910,
3089+
"line": 2079,
30903090
"metadata": {
30913091
"api_refs": [
30923092
"modal_app.pipeline.promote_run"
@@ -3347,6 +3347,40 @@
33473347
"signature": "class ReleasePromotionContext",
33483348
"source_file": "policyengine_us_data/release_promotion/context.py"
33493349
},
3350+
"release_promotion_contract_builder": {
3351+
"docstring": "Build a Stage 5 contract from candidate identity and promotion results.",
3352+
"id": "release_promotion_contract_builder",
3353+
"kind": "class",
3354+
"line": 71,
3355+
"metadata": {
3356+
"api_refs": [
3357+
"policyengine_us_data.release_promotion.contract.ReleasePromotionContractBuilder"
3358+
],
3359+
"artifacts_in": [
3360+
"release candidate bundle",
3361+
"typed promotion result"
3362+
],
3363+
"artifacts_out": [
3364+
"release_promotion_contract.json"
3365+
],
3366+
"description": "Build the canonical Stage 5 release promotion contract.",
3367+
"id": "release_promotion_contract_builder",
3368+
"label": "ReleasePromotionContractBuilder",
3369+
"node_type": "library",
3370+
"pathways": [
3371+
"5_validate_and_promote_release"
3372+
],
3373+
"source_file": "policyengine_us_data/release_promotion/contract.py",
3374+
"stability": "moving",
3375+
"status": "transitional",
3376+
"validation_commands": [
3377+
"uv run pytest tests/unit/release_promotion/test_contract.py"
3378+
]
3379+
},
3380+
"object_path": "policyengine_us_data.release_promotion.contract.ReleasePromotionContractBuilder",
3381+
"signature": "class ReleasePromotionContractBuilder",
3382+
"source_file": "policyengine_us_data/release_promotion/contract.py"
3383+
},
33503384
"resolve_scope_fingerprint": {
33513385
"docstring": "Compute the scope fingerprint while preserving pinned resume values.",
33523386
"id": "resolve_scope_fingerprint",
@@ -3507,7 +3541,7 @@
35073541
"docstring": "Run the full pipeline end-to-end.\n\nArgs:\n branch: Git branch to build from.\n gpu: GPU type for regional calibration.\n epochs: Training epochs for regional calibration.\n national_gpu: GPU type for national calibration.\n national_epochs: Training epochs for national.\n num_workers: Number of parallel H5 workers.\n n_clones: Number of clones for H5 building.\n skip_national: Skip national calibration/H5.\n resume_run_id: Resume a previously failed run.\n clear_checkpoints: Wipe ALL checkpoints before building\n (default False). Normally not needed \u2014 checkpoints are\n scoped by commit SHA, so stale ones from other commits\n are cleaned automatically. Use True only to force a\n full rebuild of the current commit.\n candidate_version: Candidate staging scope used for HF staging.\n release_version: Final stable release version. Usually empty until\n promotion.\n base_release_version: Stable release current when this candidate was\n built.\n release_bump: Intended SemVer bump for this candidate.\n sha_override: Exact source SHA deployed by GitHub Actions. When\n provided, this is recorded instead of reading the current\n branch tip.\n run_id: Cross-system run ID created by GitHub.\n run_context: Serialized run context from the launcher workflow.\n modal_app_name: Deployed Modal app name for this run.\n modal_environment: Modal environment used for this run.\n chunked_matrix: Build the calibration matrix in clone-household\n chunks instead of the non-chunked path. Opt-in; default off.\n chunk_size: Clone-household columns per chunk when\n ``chunked_matrix`` is True.\n parallel_matrix: Fan chunked matrix building across Modal\n workers via ``build_matrix_chunk_worker``. Only meaningful\n when ``chunked_matrix`` is True; ignored otherwise.\n num_matrix_workers: Number of Modal workers when\n ``parallel_matrix`` is True.\n\nReturns:\n The run ID for use with promote.",
35083542
"id": "run_modal_pipeline",
35093543
"kind": "function",
3510-
"line": 943,
3544+
"line": 1112,
35113545
"metadata": {
35123546
"api_refs": [
35133547
"modal_app.pipeline.run_pipeline"
@@ -4387,7 +4421,7 @@
43874421
"docstring": "Verify deployed-image imports and subprocess seams.",
43884422
"id": "verify_runtime_seams",
43894423
"kind": "function",
4390-
"line": 569,
4424+
"line": 738,
43914425
"metadata": {
43924426
"api_refs": [
43934427
"modal_app.pipeline.verify_runtime_seams"

docs/generated/pipeline_map.json

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1504,6 +1504,31 @@
15041504
"uv run pytest tests/unit/release_promotion/test_candidate.py"
15051505
]
15061506
},
1507+
{
1508+
"api_refs": [
1509+
"policyengine_us_data.release_promotion.contract.ReleasePromotionContractBuilder"
1510+
],
1511+
"artifacts_in": [
1512+
"release candidate bundle",
1513+
"typed promotion result"
1514+
],
1515+
"artifacts_out": [
1516+
"release_promotion_contract.json"
1517+
],
1518+
"description": "Build the canonical Stage 5 release promotion contract.",
1519+
"id": "release_promotion_contract_builder",
1520+
"label": "ReleasePromotionContractBuilder",
1521+
"node_type": "library",
1522+
"pathways": [
1523+
"5_validate_and_promote_release"
1524+
],
1525+
"source_file": "policyengine_us_data/release_promotion/contract.py",
1526+
"stability": "moving",
1527+
"status": "transitional",
1528+
"validation_commands": [
1529+
"uv run pytest tests/unit/release_promotion/test_contract.py"
1530+
]
1531+
},
15071532
{
15081533
"api_refs": [
15091534
"policyengine_us_data.build_outputs.fingerprinting.FingerprintingService",
@@ -1971,9 +1996,9 @@
19711996
}
19721997
],
19731998
"metadata": {
1974-
"api_node_count": 95,
1999+
"api_node_count": 96,
19752000
"canonical_stage_count": 5,
1976-
"decorated_object_count": 153,
2001+
"decorated_object_count": 154,
19772002
"mapped_decorated_node_count": 58,
19782003
"stage_count": 17,
19792004
"substage_count": 17

modal_app/pipeline.py

Lines changed: 181 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,175 @@ def _promote_full_release_from_staging(
549549
)
550550

551551

552+
def _promotion_result_from_stdout(promotion_stdout: str):
553+
"""Parse typed promotion results from the promotion subprocess output."""
554+
555+
from policyengine_us_data.release_promotion import parse_full_promotion_result_json
556+
557+
try:
558+
return parse_full_promotion_result_json(promotion_stdout)
559+
except ValueError as exc:
560+
raise RuntimeError(
561+
"Full release promotion subprocess did not return a valid "
562+
"typed promotion result."
563+
) from exc
564+
565+
566+
def _release_promotion_context_from_run_context(run_context: RunContext):
567+
"""Build the Stage 5 library context from the orchestration run context."""
568+
569+
from policyengine_us_data.release_promotion import ReleasePromotionContext
570+
571+
return ReleasePromotionContext(
572+
run_id=run_context.run_id,
573+
candidate_version=run_context.candidate_version,
574+
release_version=run_context.release_version,
575+
hf_repo_name="policyengine/policyengine-us-data",
576+
gcs_bucket_name="policyengine-us-data",
577+
base_release_version=run_context.base_release_version or None,
578+
release_bump=run_context.release_bump or None,
579+
modal_app_name=run_context.modal_app_name or None,
580+
modal_environment=run_context.modal_environment or None,
581+
hf_staging_prefix=run_context.hf_staging_prefix or None,
582+
metadata={"run_context": run_context.to_dict()},
583+
)
584+
585+
586+
def _release_artifact_metadata_by_path(
587+
run_id: str,
588+
rel_paths: list[str],
589+
) -> dict[str, dict[str, object]]:
590+
"""Return local checksum/size metadata for staged release artifacts."""
591+
592+
metadata: dict[str, dict[str, object]] = {}
593+
for local_path, rel_path in _full_release_manifest_files(run_id, rel_paths):
594+
path = Path(local_path)
595+
if not path.exists() or not path.is_file():
596+
continue
597+
reference = ArtifactReference.from_path(path)
598+
metadata[rel_path] = {
599+
"sha256": f"sha256:{reference.sha256}",
600+
"size_bytes": reference.size_bytes,
601+
}
602+
return metadata
603+
604+
605+
def _stage4_output_contract_repo_path_if_available(run_id: str) -> str | None:
606+
"""Return the run-repo path for the Stage 4 contract when it exists locally."""
607+
608+
run_dir = _run_dir(run_id)
609+
candidates = (
610+
run_dir / "diagnostics" / "contracts" / "output_build_contract.json",
611+
run_dir / "contracts" / "output_build_contract.json",
612+
run_dir / "output_build_contract.json",
613+
)
614+
for path in candidates:
615+
if path.exists() and path.is_file():
616+
return f"calibration/runs/{run_id}/{path.relative_to(run_dir).as_posix()}"
617+
return None
618+
619+
620+
RUN_DIAGNOSTICS_VALIDATION_REPORT_FILENAMES = (
621+
"validation_report.json",
622+
"validation_summary.json",
623+
"validation_results.csv",
624+
"national_validation.txt",
625+
)
626+
RUN_DIAGNOSTICS_MANIFEST_FILENAMES = (
627+
"manifest.json",
628+
"diagnostics_manifest.json",
629+
)
630+
631+
632+
def _run_diagnostics_repo_path_if_available(run_id: str, filename: str) -> str | None:
633+
"""Return the repo path for a run-local diagnostics file when present."""
634+
635+
run_dir = _run_dir(run_id)
636+
path = run_dir / "diagnostics" / filename
637+
if not path.exists() or not path.is_file():
638+
return None
639+
return f"calibration/runs/{run_id}/{path.relative_to(run_dir).as_posix()}"
640+
641+
642+
def _run_validation_report_repo_paths_if_available(run_id: str) -> list[str]:
643+
"""Return uploaded-run paths for validation diagnostics available locally."""
644+
645+
return [
646+
repo_path
647+
for filename in RUN_DIAGNOSTICS_VALIDATION_REPORT_FILENAMES
648+
if (
649+
repo_path := _run_diagnostics_repo_path_if_available(
650+
run_id,
651+
filename,
652+
)
653+
)
654+
]
655+
656+
657+
def _run_diagnostics_manifest_repo_path_if_available(run_id: str) -> str | None:
658+
"""Return the run diagnostics manifest path when one exists locally."""
659+
660+
for filename in RUN_DIAGNOSTICS_MANIFEST_FILENAMES:
661+
repo_path = _run_diagnostics_repo_path_if_available(run_id, filename)
662+
if repo_path is not None:
663+
return repo_path
664+
return None
665+
666+
667+
def _write_release_promotion_contract_for_run(
668+
*,
669+
meta: RunMetadata,
670+
run_context: RunContext,
671+
rel_paths: list[str],
672+
promotion_result,
673+
) -> ArtifactReference:
674+
"""Write Stage 5's run-local contract and return its manifest reference."""
675+
676+
from policyengine_us_data.release_promotion import (
677+
build_legacy_release_candidate_bundle,
678+
release_promotion_contract_path,
679+
write_release_promotion_contract,
680+
)
681+
682+
run_dir = _run_dir(run_context.run_id)
683+
contract_path = release_promotion_contract_path(run_dir)
684+
candidate_bundle = build_legacy_release_candidate_bundle(
685+
context=_release_promotion_context_from_run_context(run_context),
686+
rel_paths=rel_paths,
687+
artifact_metadata_by_path=_release_artifact_metadata_by_path(
688+
run_context.run_id,
689+
rel_paths,
690+
),
691+
source_output_contract_path=_stage4_output_contract_repo_path_if_available(
692+
run_context.run_id
693+
),
694+
validation_report_paths=_run_validation_report_repo_paths_if_available(
695+
run_context.run_id
696+
),
697+
diagnostics_manifest_path=_run_diagnostics_manifest_repo_path_if_available(
698+
run_context.run_id
699+
),
700+
)
701+
write_release_promotion_contract(
702+
contract_path=contract_path,
703+
candidate_bundle=candidate_bundle,
704+
promotion_result=promotion_result,
705+
created_at=datetime.now(timezone.utc).isoformat(),
706+
code_sha=meta.sha,
707+
package_version=meta.version,
708+
metadata={
709+
"writer": "modal_app.pipeline.promote_run",
710+
"branch": meta.branch,
711+
},
712+
)
713+
return ArtifactReference.from_path(
714+
contract_path,
715+
role="contract",
716+
base_dir=run_dir,
717+
media_type="application/json",
718+
)
719+
720+
552721
@app.function(
553722
image=image,
554723
timeout=300,
@@ -2039,6 +2208,13 @@ def promote_run(
20392208
promotion_context.to_dict(),
20402209
)
20412210
print(f" {promotion_stdout}")
2211+
promotion_result = _promotion_result_from_stdout(promotion_stdout)
2212+
release_promotion_contract_ref = _write_release_promotion_contract_for_run(
2213+
meta=meta,
2214+
run_context=promotion_context,
2215+
rel_paths=rel_paths,
2216+
promotion_result=promotion_result,
2217+
)
20422218

20432219
# Update run status only after all required promotion work succeeds.
20442220
meta.status = "promoted"
@@ -2047,8 +2223,11 @@ def promote_run(
20472223
_complete_step_manifest(
20482224
promote_manifest,
20492225
outputs=[
2050-
ArtifactReference.from_dict(artifact)
2051-
for artifact in promote_inputs["validated_step_outputs"]
2226+
*[
2227+
ArtifactReference.from_dict(artifact)
2228+
for artifact in promote_inputs["validated_step_outputs"]
2229+
],
2230+
release_promotion_contract_ref,
20522231
],
20532232
reuse_decision="computed",
20542233
vol=pipeline_volume,

0 commit comments

Comments
 (0)