Skip to content

Commit 6db8293

Browse files
committed
Add Stage 5 release promotion contract
1 parent 0924580 commit 6db8293

8 files changed

Lines changed: 928 additions & 2 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/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.

modal_app/pipeline.py

Lines changed: 128 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,122 @@ def _promote_full_release_from_staging(
524524
)
525525

526526

527+
def _promotion_result_from_stdout(promotion_stdout: str):
528+
"""Parse typed promotion results from the promotion subprocess output."""
529+
530+
from policyengine_us_data.release_promotion import FullPromotionResult
531+
532+
try:
533+
payload = json.loads(promotion_stdout)
534+
except json.JSONDecodeError as exc:
535+
raise RuntimeError(
536+
"Full release promotion subprocess did not return JSON."
537+
) from exc
538+
return FullPromotionResult.from_legacy_dict(payload)
539+
540+
541+
def _release_promotion_context_from_run_context(run_context: RunContext):
542+
"""Build the Stage 5 library context from the orchestration run context."""
543+
544+
from policyengine_us_data.release_promotion import ReleasePromotionContext
545+
546+
return ReleasePromotionContext(
547+
run_id=run_context.run_id,
548+
candidate_version=run_context.candidate_version,
549+
release_version=run_context.release_version,
550+
hf_repo_name="policyengine/policyengine-us-data",
551+
gcs_bucket_name="policyengine-us-data",
552+
base_release_version=run_context.base_release_version or None,
553+
release_bump=run_context.release_bump or None,
554+
modal_app_name=run_context.modal_app_name or None,
555+
modal_environment=run_context.modal_environment or None,
556+
hf_staging_prefix=run_context.hf_staging_prefix or None,
557+
metadata={"run_context": run_context.to_dict()},
558+
)
559+
560+
561+
def _release_artifact_metadata_by_path(
562+
run_id: str,
563+
rel_paths: list[str],
564+
) -> dict[str, dict[str, object]]:
565+
"""Return local checksum/size metadata for staged release artifacts."""
566+
567+
metadata: dict[str, dict[str, object]] = {}
568+
for local_path, rel_path in _full_release_manifest_files(run_id, rel_paths):
569+
path = Path(local_path)
570+
if not path.exists() or not path.is_file():
571+
continue
572+
reference = ArtifactReference.from_path(path)
573+
metadata[rel_path] = {
574+
"sha256": f"sha256:{reference.sha256}",
575+
"size_bytes": reference.size_bytes,
576+
}
577+
return metadata
578+
579+
580+
def _stage4_output_contract_repo_path_if_available(run_id: str) -> str | None:
581+
"""Return the run-repo path for the Stage 4 contract when it exists locally."""
582+
583+
run_dir = _run_dir(run_id)
584+
candidates = (
585+
run_dir / "diagnostics" / "contracts" / "output_build_contract.json",
586+
run_dir / "contracts" / "output_build_contract.json",
587+
run_dir / "output_build_contract.json",
588+
)
589+
for path in candidates:
590+
if path.exists() and path.is_file():
591+
return f"calibration/runs/{run_id}/{path.relative_to(run_dir).as_posix()}"
592+
return None
593+
594+
595+
def _write_release_promotion_contract_for_run(
596+
*,
597+
meta: RunMetadata,
598+
run_context: RunContext,
599+
rel_paths: list[str],
600+
promotion_result,
601+
) -> ArtifactReference:
602+
"""Write Stage 5's run-local contract and return its manifest reference."""
603+
604+
from policyengine_us_data.release_promotion import (
605+
build_legacy_release_candidate_bundle,
606+
release_promotion_contract_path,
607+
write_release_promotion_contract,
608+
)
609+
610+
run_dir = _run_dir(run_context.run_id)
611+
contract_path = release_promotion_contract_path(run_dir)
612+
candidate_bundle = build_legacy_release_candidate_bundle(
613+
context=_release_promotion_context_from_run_context(run_context),
614+
rel_paths=rel_paths,
615+
artifact_metadata_by_path=_release_artifact_metadata_by_path(
616+
run_context.run_id,
617+
rel_paths,
618+
),
619+
source_output_contract_path=_stage4_output_contract_repo_path_if_available(
620+
run_context.run_id
621+
),
622+
)
623+
write_release_promotion_contract(
624+
contract_path=contract_path,
625+
candidate_bundle=candidate_bundle,
626+
promotion_result=promotion_result,
627+
created_at=datetime.now(timezone.utc).isoformat(),
628+
code_sha=meta.sha,
629+
package_version=meta.version,
630+
metadata={
631+
"writer": "modal_app.pipeline.promote_run",
632+
"branch": meta.branch,
633+
},
634+
)
635+
return ArtifactReference.from_path(
636+
contract_path,
637+
role="contract",
638+
base_dir=run_dir,
639+
media_type="application/json",
640+
)
641+
642+
527643
@app.function(
528644
image=image,
529645
timeout=300,
@@ -2013,6 +2129,13 @@ def promote_run(
20132129
promotion_context.to_dict(),
20142130
)
20152131
print(f" {promotion_stdout}")
2132+
promotion_result = _promotion_result_from_stdout(promotion_stdout)
2133+
release_promotion_contract_ref = _write_release_promotion_contract_for_run(
2134+
meta=meta,
2135+
run_context=promotion_context,
2136+
rel_paths=rel_paths,
2137+
promotion_result=promotion_result,
2138+
)
20162139

20172140
# Update run status only after all required promotion work succeeds.
20182141
meta.status = "promoted"
@@ -2021,8 +2144,11 @@ def promote_run(
20212144
_complete_step_manifest(
20222145
promote_manifest,
20232146
outputs=[
2024-
ArtifactReference.from_dict(artifact)
2025-
for artifact in promote_inputs["validated_step_outputs"]
2147+
*[
2148+
ArtifactReference.from_dict(artifact)
2149+
for artifact in promote_inputs["validated_step_outputs"]
2150+
],
2151+
release_promotion_contract_ref,
20262152
],
20272153
reuse_decision="computed",
20282154
vol=pipeline_volume,

policyengine_us_data/release_promotion/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,15 @@
2424
build_release_candidate_bundle_from_stage4_contract,
2525
read_stage4_release_candidate_bundle,
2626
)
27+
from .contract import (
28+
RELEASE_PROMOTION_CONTRACT_FILENAME,
29+
RELEASE_PROMOTION_CONTRACT_TYPE,
30+
ReleasePromotionContractBuilder,
31+
build_release_promotion_contract,
32+
release_promotion_contract_path,
33+
release_promotion_contract_repo_path,
34+
write_release_promotion_contract,
35+
)
2736
from .results import (
2837
CleanupPromotionResult,
2938
CompletionMarkerPromotionResult,
@@ -48,13 +57,16 @@
4857
"BASE_RELEASE_ARTIFACT_PATHS",
4958
"DEFAULT_REQUIRED_RELEASE_ARTIFACT_FAMILIES",
5059
"RELEASE_VALIDATION_SUBSTAGE_ID",
60+
"RELEASE_PROMOTION_CONTRACT_FILENAME",
61+
"RELEASE_PROMOTION_CONTRACT_TYPE",
5162
"CleanupPromotionResult",
5263
"CompletionMarkerPromotionResult",
5364
"FullPromotionResult",
5465
"GcsPromotionResult",
5566
"HuggingFacePromotionResult",
5667
"ReleaseArtifactSpec",
5768
"ReleaseCandidateInputBundle",
69+
"ReleasePromotionContractBuilder",
5870
"ReleasePromotionContext",
5971
"ReleaseCandidateValidationDependencies",
6072
"ReleaseCandidateValidator",
@@ -63,6 +75,7 @@
6375
"VALIDATION_REPORT_POLICY_PRESENCE_ONLY",
6476
"VALIDATION_REPORT_POLICY_REQUIRE_PASSING",
6577
"build_legacy_release_candidate_bundle",
78+
"build_release_promotion_contract",
6679
"build_release_candidate_bundle_from_stage4_contract",
6780
"build_release_candidate_shape_report",
6881
"default_release_candidate_validation_dependencies",
@@ -71,6 +84,9 @@
7184
"infer_release_artifact_spec",
7285
"logical_name_for_release_path",
7386
"normalize_release_path",
87+
"release_promotion_contract_path",
88+
"release_promotion_contract_repo_path",
7489
"read_stage4_release_candidate_bundle",
7590
"strip_staging_prefix",
91+
"write_release_promotion_contract",
7692
]

0 commit comments

Comments
 (0)