diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ef69f40..1af1306 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -444,7 +444,15 @@ jobs: needs: - release-target - validate-artifacts - if: ${{ needs.release-target.outputs.publish_assets == 'true' }} + # Artifact promotion intentionally skips rebuild-artifacts. Override that skipped + # ancestor only after every direct publication prerequisite succeeds. + if: >- + ${{ + always() + && needs.release-target.result == 'success' + && needs.validate-artifacts.result == 'success' + && needs.release-target.outputs.publish_assets == 'true' + }} runs-on: ubuntu-latest timeout-minutes: 10 environment: @@ -470,7 +478,17 @@ jobs: - release-please - release-target - publish-release-assets - if: ${{ needs.release-please.outputs.release-created == 'true' && needs.release-target.outputs.publish_assets == 'true' }} + # Keep the explicit status override through the final job so the intentionally + # skipped rebuild ancestor cannot suppress crate publication either. + if: >- + ${{ + always() + && needs.release-please.result == 'success' + && needs.release-target.result == 'success' + && needs.publish-release-assets.result == 'success' + && needs.release-please.outputs.release-created == 'true' + && needs.release-target.outputs.publish_assets == 'true' + }} runs-on: ubuntu-latest timeout-minutes: 20 environment: diff --git a/crates/xtask/src/main.rs b/crates/xtask/src/main.rs index a9d6b1a..0acc111 100644 --- a/crates/xtask/src/main.rs +++ b/crates/xtask/src/main.rs @@ -1303,26 +1303,36 @@ fn check_workflow_policy( } let publish_assets = yaml_path(release, &["jobs", "publish-release-assets"]).unwrap_or(&YamlValue::Null); - if yaml_path(publish_assets, &["if"]).and_then(YamlValue::as_str) - != Some("${{ needs.release-target.outputs.publish_assets == 'true' }}") - { + if !yaml_path(publish_assets, &["if"]).is_some_and(|condition| { + [ + "always()", + "needs.release-target.result == 'success'", + "needs.validate-artifacts.result == 'success'", + "needs.release-target.outputs.publish_assets == 'true'", + ] + .iter() + .all(|marker| yaml_contains_string(condition, marker)) + }) { issues.push( - "FAIL: release-publication-condition-invalid: asset publication must require affirmative publish_assets authorization." + "FAIL: release-publication-condition-invalid: asset publication must override skipped ancestors while requiring successful validation and affirmative publish_assets authorization." .to_string(), ); } let publish_crate = yaml_path(release, &["jobs", "publish-crate"]).unwrap_or(&YamlValue::Null); if !yaml_path(publish_crate, &["if"]).is_some_and(|condition| { - yaml_contains_string( - condition, + [ + "always()", + "needs.release-please.result == 'success'", + "needs.release-target.result == 'success'", + "needs.publish-release-assets.result == 'success'", "needs.release-please.outputs.release-created == 'true'", - ) && yaml_contains_string( - condition, "needs.release-target.outputs.publish_assets == 'true'", - ) + ] + .iter() + .all(|marker| yaml_contains_string(condition, marker)) }) { issues.push( - "FAIL: release-crate-publication-condition-invalid: crate publication must require a created release and affirmative publish_assets authorization." + "FAIL: release-crate-publication-condition-invalid: crate publication must override skipped ancestors while requiring successful publication prerequisites, a created release, and affirmative publish_assets authorization." .to_string(), ); } @@ -2416,12 +2426,14 @@ jobs: fi rebuild-artifacts: {uses: './.github/workflows/native.yml'} publish-release-assets: - if: ${{ needs.release-target.outputs.publish_assets == 'true' }} + needs: [release-target, validate-artifacts] + if: ${{ always() && needs.release-target.result == 'success' && needs.validate-artifacts.result == 'success' && needs.release-target.outputs.publish_assets == 'true' }} permissions: {contents: write} environment: {name: cargo} steps: [{run: 'gh release upload'}] publish-crate: - if: ${{ needs.release-please.outputs.release-created == 'true' && needs.release-target.outputs.publish_assets == 'true' }} + needs: [release-please, release-target, publish-release-assets] + if: ${{ always() && needs.release-please.result == 'success' && needs.release-target.result == 'success' && needs.publish-release-assets.result == 'success' && needs.release-please.outputs.release-created == 'true' && needs.release-target.outputs.publish_assets == 'true' }} steps: - {run: 'cargo publish --dry-run --locked'} - {run: 'cargo publish --locked'} @@ -2604,6 +2616,36 @@ jobs: ); } + #[test] + fn workflow_policy_rejects_asset_publication_without_skipped_ancestor_override() { + let broken = valid_release_workflow_text().replace( + "${{ always() && needs.release-target.result == 'success' && needs.validate-artifacts.result == 'success' && needs.release-target.outputs.publish_assets == 'true' }}", + "${{ needs.release-target.outputs.publish_assets == 'true' }}", + ); + let issues = workflow_policy_issues(&broken); + assert!( + issues + .iter() + .any(|issue| issue.contains("release-publication-condition-invalid")), + "{issues:?}" + ); + } + + #[test] + fn workflow_policy_rejects_crate_publication_without_skipped_ancestor_override() { + let broken = valid_release_workflow_text().replace( + "${{ always() && needs.release-please.result == 'success' && needs.release-target.result == 'success' && needs.publish-release-assets.result == 'success' && needs.release-please.outputs.release-created == 'true' && needs.release-target.outputs.publish_assets == 'true' }}", + "${{ needs.release-please.outputs.release-created == 'true' && needs.release-target.outputs.publish_assets == 'true' }}", + ); + let issues = workflow_policy_issues(&broken); + assert!( + issues + .iter() + .any(|issue| issue.contains("release-crate-publication-condition-invalid")), + "{issues:?}" + ); + } + #[test] fn workflow_policy_rejects_automatic_rebuild_recovery() { let broken = valid_release_workflow_text() diff --git a/docs/release.md b/docs/release.md index e033132..3616405 100644 --- a/docs/release.md +++ b/docs/release.md @@ -53,7 +53,9 @@ Pull requests targeting `main` and pushes to `main` run: other merge. 5. The successful CI run for the release pull request merge enables tag creation. The resulting Release run creates the `vX.Y.Z` tag, proves that the tag resolves to the triggering CI SHA, validates all four - archives/checksums/provenance records from that exact run, and uploads the public assets from one publisher. + archives/checksums/provenance records from that exact run, and uploads the public assets from one publisher. Promotion + intentionally skips the rebuild job; the asset and crate publishers explicitly override that skipped ancestor only + after their direct prerequisites succeed, so GitHub cannot silently suppress publication after validation. 6. `cargo publish --dry-run --locked` runs at the immutable tag, then the crate publishes automatically after native assets succeed. Manual recovery never publishes the crate.