diff --git a/demos/opentofu-plan/src/app.rs b/demos/opentofu-plan/src/app.rs index 7d71cb7..fb273cb 100644 --- a/demos/opentofu-plan/src/app.rs +++ b/demos/opentofu-plan/src/app.rs @@ -1338,7 +1338,7 @@ mod tests { artifacts: MemoryPlanArtifactStore, backend: Arc, nonce_byte: u8, - ) -> WorkflowOutcome { + ) -> Result { let demo = demo_with_nonce(nonce_byte); let handle = artifacts .put(SavedPlanArtifact::new(auths_opentofu::test_support::PLAN_BYTES.to_vec()).unwrap()) @@ -1362,7 +1362,6 @@ mod tests { proof: demo.auths.proof, auths_request: demo.auths.request, }) - .unwrap() } async fn request( @@ -1447,52 +1446,55 @@ mod tests { #[test] fn competing_same_scope_actions_execute_one_provider_effect() { - let state = tempfile::tempdir().unwrap(); - let demo = demo_with_nonce(0x11); - let lifecycle_store = - fixture_lifecycle_store(&state.path().join("lifecycle"), &demo.product.action); - let artifacts = MemoryPlanArtifactStore::default(); - let backend = Arc::new(OpenTofuBackend::fixture(demo.product.evidence)); - let barrier = Arc::new(Barrier::new(2)); - - let handles = [0x11, 0x22] - .into_iter() - .map(|nonce_byte| { - let lifecycle_store = Arc::clone(&lifecycle_store); - let artifacts = artifacts.clone(); - let backend = Arc::clone(&backend); - let barrier = Arc::clone(&barrier); - thread::spawn(move || { - barrier.wait(); - execute_fixture_workflow(lifecycle_store, artifacts, backend, nonce_byte) + for _ in 0..32 { + let state = tempfile::tempdir().unwrap(); + let demo = demo_with_nonce(0x11); + let lifecycle_store = + fixture_lifecycle_store(&state.path().join("lifecycle"), &demo.product.action); + let artifacts = MemoryPlanArtifactStore::default(); + let backend = Arc::new(OpenTofuBackend::fixture(demo.product.evidence)); + let barrier = Arc::new(Barrier::new(2)); + + let handles = [0x11, 0x22] + .into_iter() + .map(|nonce_byte| { + let lifecycle_store = Arc::clone(&lifecycle_store); + let artifacts = artifacts.clone(); + let backend = Arc::clone(&backend); + let barrier = Arc::clone(&barrier); + thread::spawn(move || { + barrier.wait(); + execute_fixture_workflow(lifecycle_store, artifacts, backend, nonce_byte) + }) }) - }) - .collect::>(); - let outcomes = handles - .into_iter() - .map(|handle| handle.join().unwrap()) - .collect::>(); - - assert_eq!(backend.apply_calls(), 1); - assert_eq!( - outcomes - .iter() - .filter(|outcome| matches!(outcome, WorkflowOutcome::Executed { .. })) - .count(), - 1 - ); - assert_eq!( - outcomes - .iter() - .filter(|outcome| { - matches!( - outcome, - WorkflowOutcome::Replay { .. } | WorkflowOutcome::Conflict { .. } - ) - }) - .count(), - 1 - ); + .collect::>(); + let outcomes = handles + .into_iter() + .map(|handle| handle.join().unwrap()) + .collect::>(); + + assert_eq!(backend.apply_calls(), 1); + assert_eq!( + outcomes + .iter() + .filter(|outcome| matches!(outcome, Ok(WorkflowOutcome::Executed { .. }))) + .count(), + 1 + ); + assert_eq!( + outcomes + .iter() + .filter(|outcome| { + matches!( + outcome, + Ok(WorkflowOutcome::Replay { .. } | WorkflowOutcome::Conflict { .. }) + | Err(ServiceError::StateChanged) + ) + }) + .count(), + 1 + ); + } } #[test] @@ -1511,14 +1513,14 @@ mod tests { Arc::clone(&backend), 0x11, ); - assert!(matches!(first, WorkflowOutcome::OutcomeUnknown { .. })); + assert!(matches!(first, Ok(WorkflowOutcome::OutcomeUnknown { .. }))); assert_eq!(backend.apply_calls(), 1); drop(first_store); let reopened_store = fixture_lifecycle_store(&lifecycle_path, &demo.product.action); let recovered = execute_fixture_workflow(reopened_store, artifacts, Arc::clone(&backend), 0x11); - assert!(matches!(recovered, WorkflowOutcome::Executed { .. })); + assert!(matches!(recovered, Ok(WorkflowOutcome::Executed { .. }))); assert_eq!(backend.apply_calls(), 1); } diff --git a/docs/plans/PHASE_7_RELEASE_READINESS_AUDIT.md b/docs/plans/PHASE_7_RELEASE_READINESS_AUDIT.md index a6d018f..103eaca 100644 --- a/docs/plans/PHASE_7_RELEASE_READINESS_AUDIT.md +++ b/docs/plans/PHASE_7_RELEASE_READINESS_AUDIT.md @@ -1,5 +1,54 @@ # Phase 7 release readiness audit +## Current deterministic preflight audit (2026-08-03) + +The current reusable release builder was audited from candidate validation +through artifact upload after a preparation run exposed a nondeterministic +OpenTofu concurrency-test failure. The production runtime had failed closed: +one competing action executed and the other observed changed state. The test +incorrectly assumed the loser could only receive a replay or conflict outcome. +It now accepts the typed `StateChanged` outcome and repeats the synchronized +race 32 times while proving that exactly one provider effect occurs. + +The audit also found that `release-check` used to run two deterministic gates +only after the ordinary pull-request suite: workspace tests with default +features disabled and canonical wire verification. Both now run through +`release_preflight()` inside authoritative CI. `release-check` calls that same +path through `ci()` and no longer repeats those gates or the package check. +This makes the pull request, local release check, and hosted release builder +share one implementation rather than three command lists that can drift. + +The end-to-end local audit then found a second late-only defect in release +evidence assembly: the deterministic source archive used a GNU tar header whose +single 100-byte name field could not encode a canonical fixture path after the +release prefix was added. The archive now uses the UStar prefix field, and its +determinism test includes the exact formerly failing fixture path. A complete +local `cargo xtask release-check` subsequently passed and generated validated +inputs for 28 crate archives and 33 total release subjects. + +### Current gate map + +| Builder stage | Pull-request or repository-local prevention | Why it remains in preparation | +| --- | --- | --- | +| Candidate commit, tag shape, repository IDs, clean checkout | Release workflow contract validation and release-control tests | The real commit, ref, and hosted repository context exist only at runtime | +| Environment-bound OIDC subject | Pinned workflow text is checked by `release-contract` | A genuine GitHub OIDC token cannot be reproduced safely in local or pull-request CI | +| Production source closure | Formal-translation CI checks the committed closure before tool setup | Rechecked against the exact candidate before expensive formal work | +| Authoritative Rust checks | Authoritative CI runs format, architecture, semantic freeze, naming, repository policy, all-target/all-feature check, tests, Clippy, Rustdoc, MSRV, platform, and fuzz smoke | The isolated builder must verify the exact candidate independently | +| Release compilation profiles | Authoritative CI now runs all workspace tests with no default features and canonical wire verification through `release_preflight()` | Rechecked through the same function in `release-check` | +| Formal properties and exact translation | Formal-translation CI runs Lean, Kani, source-closure validation, and exact Aeneas/Charon reproduction twice | The builder reproduces the exact translation with its pinned tools and preserves candidate evidence | +| Compliance and package subjects | Compliance CI runs ABI, exchange/product/profile/domain/matrix/binding/package/WASM/live-demo checks and emits its report | Candidate packages and compliance evidence are regenerated from the isolated checkout | +| Dependency and secret policy | Dedicated CI phases run pinned cargo-deny and gitleaks | cargo-deny is rechecked with the builder's pinned release toolchain; secret scanning is source-history policy rather than an artifact-generation step | +| Release-evidence generation | Schema, catalogue, workflow, archive, manifest, checksum, and adversarial validator tests run in ordinary CI | Final evidence assembly consumes formal, compliance, package, platform, and exact-commit outputs produced together in the isolated builder | +| SLSA attestation and verification | Workflow contract tests pin the action, subject set, repository, issuer constraints, signer workflow, and digest checks | Signing, transparency data, and verification require the real protected environment and GitHub identity | +| Manifest finalization and immutable upload | Release-control tests reject subject drift and rebuilding during promotion | Attestation bundle paths, hosted digests, and artifact IDs exist only in the preparation run | + +The remaining preparation-only work is deliberately limited to checks that +need the actual protected GitHub identity or the complete set of exact outputs +from one isolated build. A pull request must now fail before merge for either +Rust feature profile, wire drift, the stressed OpenTofu race contract, package +construction, formal drift, documentation warnings, dependency policy, or the +other deterministic gates enumerated above. + ## Status and reviewed baseline Historical read-only repository audit for AP-SPEC-032. It records the baseline diff --git a/release/semantic-freeze.json b/release/semantic-freeze.json index 0f539e5..9f5d120 100644 --- a/release/semantic-freeze.json +++ b/release/semantic-freeze.json @@ -1,6 +1,6 @@ { "schema": "auths.semantic-freeze/1", - "freezeVersion": 11, + "freezeVersion": 12, "publicSurface": { "rustRoots": [ "auths", @@ -592,7 +592,7 @@ }, { "id": "auths.release.public-surface", - "version": 11, + "version": 12, "classification": "release-metadata", "categories": [ "package-names", @@ -659,7 +659,7 @@ "xtask/src/release_control.rs", "xtask/src/semantic_freeze.rs" ], - "sha256": "6a3fc5e05f10d97a721ad899045234944d78b3647af66d66869d1683fdfe8530" + "sha256": "018187c9cc5065e2f614d4f2071d9f05ce7ff0160a4f3fe9ede428511da8639d" } ] } diff --git a/xtask/src/checks.rs b/xtask/src/checks.rs index 6a739a8..b286538 100644 --- a/xtask/src/checks.rs +++ b/xtask/src/checks.rs @@ -17,6 +17,7 @@ pub(crate) fn ci_authoritative() -> Result<(), String> { repository_hygiene()?; cargo(&["check", "--workspace", "--all-targets", "--all-features"])?; cargo(&["test", "--workspace", "--all-features"])?; + release_preflight()?; cargo(&[ "clippy", "--workspace", @@ -33,6 +34,18 @@ pub(crate) fn ci_authoritative() -> Result<(), String> { fuzz_smoke() } +/// Runs the deterministic compilation-profile and canonical-byte gates that +/// release preparation depends on in addition to the all-features workspace +/// suite. +/// +/// Keep this in authoritative pull-request CI. A release candidate must not be +/// the first place that no-default-features compilation or wire drift is +/// discovered. +pub(crate) fn release_preflight() -> Result<(), String> { + cargo(&["test", "--workspace", "--no-default-features"])?; + wire(false) +} + pub(crate) fn release_documentation() -> Result<(), String> { let status = Command::new("cargo") .args(["doc", "--workspace", "--all-features", "--no-deps"]) diff --git a/xtask/src/release.rs b/xtask/src/release.rs index dce6052..705eaf7 100644 --- a/xtask/src/release.rs +++ b/xtask/src/release.rs @@ -142,9 +142,6 @@ pub(crate) fn release_check() -> Result<(), String> { validate_release_tag(&tag, env!("CARGO_PKG_VERSION"))?; } ci()?; - cargo(&["test", "--workspace", "--no-default-features"])?; - wire(false)?; - package_check()?; release_evidence()?; println!("release checks passed"); Ok(()) @@ -787,7 +784,11 @@ fn write_deterministic_archive( for (relative, mode) in files { let bytes = fs::read(root().join(relative)) .map_err(|error| format!("could not read archive input {relative}: {error}"))?; - let mut header = tar::Header::new_gnu(); + // UStar keeps the path prefix in its dedicated 155-byte field. The + // GNU header layout reuses that field and therefore rejects otherwise + // valid repository paths once the release archive prefix pushes them + // beyond the 100-byte name field. + let mut header = tar::Header::new_ustar(); header .set_path(Path::new(prefix).join(relative)) .map_err(|error| format!("could not encode archive path {relative}: {error}"))?; @@ -1760,7 +1761,14 @@ mod tests { fn release_archive_encoding_is_deterministic() { let first = root().join("target/release-archive-determinism-a.tar.zst"); let second = root().join("target/release-archive-determinism-b.tar.zst"); - let files = BTreeMap::from([("Cargo.toml".to_owned(), 0o644)]); + let files = BTreeMap::from([ + ("Cargo.toml".to_owned(), 0o644), + ( + "core/fixtures/v1/indeterminate/accepted-extension-without-handler.action.cbor" + .to_owned(), + 0o644, + ), + ]); write_deterministic_archive(&first, "auths-test", &files, 1) .expect("first deterministic archive"); write_deterministic_archive(&second, "auths-test", &files, 1) diff --git a/xtask/src/semantic_freeze.rs b/xtask/src/semantic_freeze.rs index 0dc3bb7..8731a31 100644 --- a/xtask/src/semantic_freeze.rs +++ b/xtask/src/semantic_freeze.rs @@ -4,7 +4,7 @@ use crate::*; const INVENTORY_PATH: &str = "release/semantic-freeze.json"; const INVENTORY_SCHEMA: &str = "auths.semantic-freeze/1"; -const FREEZE_VERSION: u64 = 11; +const FREEZE_VERSION: u64 = 12; const PUBLIC_RUST_ROOTS: [&str; 2] = ["auths", "auths-sdk"]; const PUBLIC_RUST_CLOSURE: [&str; 28] = [ "auths", @@ -321,7 +321,7 @@ fn generate_inventory() -> Result { ]); entries.push(freeze_entry( "auths.release.public-surface", - 11, + 12, FreezeClassification::ReleaseMetadata, &[ "package-names",