diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 175d979..ef69f40 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,10 +70,32 @@ jobs: echo 'current-tip=false' >> "$GITHUB_OUTPUT" exit 0 fi + associated_pulls="$(gh api \ + -H 'Accept: application/vnd.github+json' \ + "repos/$GITHUB_REPOSITORY/commits/$CI_HEAD_SHA/pulls")" + release_merge_count="$(jq -r \ + --arg repository "$GITHUB_REPOSITORY" \ + '[.[] + | select(.merged_at != null) + | select(.base.ref == "main") + | select(.head.repo.full_name == $repository) + | select(.head.ref == "release-please--branches--main--components--codebase-graph") + | select([.labels[].name] | index("autorelease: pending") != null)] + | length' <<<"$associated_pulls")" + [[ "$release_merge_count" -le 1 ]] || { + echo "Expected at most one release-please pull request for $CI_HEAD_SHA; found $release_merge_count." >&2 + exit 1 + } + if [[ "$release_merge_count" == '1' ]]; then + release_merge=true + else + release_merge=false + fi { echo 'current-tip=true' echo "ci-run-id=$CI_RUN_ID" echo "ci-head-sha=$CI_HEAD_SHA" + echo "release-merge=$release_merge" } >> "$GITHUB_OUTPUT" - name: Create release pull request or GitHub release id: release @@ -83,12 +105,14 @@ jobs: token: ${{ secrets.RELEASE_PLEASE_TOKEN || github.token }} config-file: release-please-config.json manifest-file: .release-please-manifest.json + skip-github-release: ${{ steps.trigger.outputs.release-merge != 'true' }} - name: Recheck current main tip after release-please if: steps.trigger.outputs.current-tip == 'true' shell: bash env: GH_TOKEN: ${{ github.token }} CI_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + RELEASE_MERGE: ${{ steps.trigger.outputs.release-merge }} RELEASE_CREATED: ${{ steps.release.outputs.release_created }} RELEASE_SHA: ${{ steps.release.outputs.sha }} run: | @@ -98,6 +122,10 @@ jobs: echo "Main advanced to $main_sha while release-please was running; stopping publication for $CI_HEAD_SHA." >&2 exit 1 } + if [[ "$RELEASE_CREATED" == 'true' && "$RELEASE_MERGE" != 'true' ]]; then + echo "Release-please created a release outside a verified release merge." >&2 + exit 1 + fi if [[ "$RELEASE_CREATED" == 'true' && "$RELEASE_SHA" != "$CI_HEAD_SHA" ]]; then echo "Release-please created a release for $RELEASE_SHA, not triggering CI SHA $CI_HEAD_SHA." >&2 exit 1 @@ -117,7 +145,7 @@ jobs: tag-name: ${{ steps.resolve.outputs.tag-name }} version: ${{ steps.resolve.outputs.version }} source-sha: ${{ steps.resolve.outputs.source-sha }} - dry-run: ${{ steps.resolve.outputs.dry-run }} + publish_assets: ${{ steps.resolve.outputs.publish_assets }} artifact-source: ${{ steps.resolve.outputs.artifact-source }} ci-run-id: ${{ steps.resolve.outputs.ci-run-id }} steps: @@ -146,6 +174,11 @@ jobs: automatic=false should_publish=true dry_run="$MANUAL_DRY_RUN" + if [[ "$dry_run" == 'true' ]]; then + publish_assets=false + else + publish_assets=true + fi artifact_source="$MANUAL_SOURCE" ci_run_id='' elif [[ "$RELEASE_CREATED" == 'true' ]]; then @@ -163,6 +196,7 @@ jobs: automatic=true should_publish=true dry_run=false + publish_assets=true artifact_source=promote else tag='' @@ -170,6 +204,7 @@ jobs: automatic=false should_publish=false dry_run=true + publish_assets=false artifact_source=promote ci_run_id='' fi @@ -179,7 +214,7 @@ jobs: echo "tag-name=$tag" echo "version=${tag#v}" echo "source-sha=$source_sha" - echo "dry-run=$dry_run" + echo "publish_assets=$publish_assets" echo "artifact-source=$artifact_source" echo "ci-run-id=$ci_run_id" } >> "$GITHUB_OUTPUT" @@ -409,7 +444,7 @@ jobs: needs: - release-target - validate-artifacts - if: ${{ needs.release-target.outputs.dry-run == 'false' }} + if: ${{ needs.release-target.outputs.publish_assets == 'true' }} runs-on: ubuntu-latest timeout-minutes: 10 environment: @@ -435,7 +470,7 @@ jobs: - release-please - release-target - publish-release-assets - if: ${{ needs.release-please.outputs.release-created == 'true' && needs.release-target.outputs.dry-run == 'false' }} + if: ${{ 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 1c46575..a9d6b1a 100644 --- a/crates/xtask/src/main.rs +++ b/crates/xtask/src/main.rs @@ -1111,7 +1111,17 @@ fn check_workflow_policy( )); } } - for marker in ["git/ref/heads/main", "current-tip"] { + for marker in [ + "git/ref/heads/main", + "current-tip", + "commits/$CI_HEAD_SHA/pulls", + ".merged_at != null", + ".base.ref == \"main\"", + ".head.repo.full_name == $repository", + ".head.ref == \"release-please--branches--main--components--codebase-graph\"", + "autorelease: pending", + "release-merge", + ] { if !yaml_path(trigger_step, &["run"]).is_some_and(|run| yaml_contains_string(run, marker)) { issues.push(format!( "FAIL: release-trigger-binding-missing: trigger step must contain {marker}." @@ -1127,6 +1137,14 @@ fn check_workflow_policy( .to_string(), ); } + if yaml_path(release_action, &["with", "skip-github-release"]).and_then(YamlValue::as_str) + != Some("${{ steps.trigger.outputs.release-merge != 'true' }}") + { + issues.push( + "FAIL: release-publication-gate-missing: release-please must skip tag publication outside a verified release merge." + .to_string(), + ); + } let post_release_step = yaml_step_by_name( release_please, "Recheck current main tip after release-please", @@ -1134,8 +1152,16 @@ fn check_workflow_policy( .unwrap_or(&YamlValue::Null); if yaml_path(post_release_step, &["env", "RELEASE_SHA"]).and_then(YamlValue::as_str) != Some("${{ steps.release.outputs.sha }}") + || yaml_path(post_release_step, &["env", "RELEASE_MERGE"]).and_then(YamlValue::as_str) + != Some("${{ steps.trigger.outputs.release-merge }}") || !yaml_path(post_release_step, &["run"]).is_some_and(|run| { - yaml_contains_string(run, "main_sha") && yaml_contains_string(run, "CI_HEAD_SHA") + yaml_contains_string(run, "main_sha") + && yaml_contains_string(run, "CI_HEAD_SHA") + && yaml_contains_string(run, "RELEASE_MERGE") + && yaml_contains_string( + run, + "\"$RELEASE_CREATED\" == 'true' && \"$RELEASE_MERGE\" != 'true'", + ) }) { issues.push( @@ -1160,6 +1186,16 @@ fn check_workflow_policy( let release_target = yaml_path(release, &["jobs", "release-target"]).unwrap_or(&YamlValue::Null); let resolve_step = yaml_step_by_id(release_target, "resolve").unwrap_or(&YamlValue::Null); + if yaml_path(release_target, &["outputs", "publish_assets"]).and_then(YamlValue::as_str) + != Some("${{ steps.resolve.outputs.publish_assets }}") + || !yaml_path(resolve_step, &["run"]) + .is_some_and(|run| yaml_contains_string(run, "publish_assets")) + { + issues.push( + "FAIL: release-publication-output-missing: release target must emit an affirmative publish_assets output." + .to_string(), + ); + } for (field, expected) in [ ( "RELEASE_CI_RUN_ID", @@ -1265,6 +1301,37 @@ fn check_workflow_policy( .to_string(), ); } + 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' }}") + { + issues.push( + "FAIL: release-publication-condition-invalid: asset publication must require 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, + "needs.release-please.outputs.release-created == 'true'", + ) && yaml_contains_string( + condition, + "needs.release-target.outputs.publish_assets == 'true'", + ) + }) { + issues.push( + "FAIL: release-crate-publication-condition-invalid: crate publication must require a created release and affirmative publish_assets authorization." + .to_string(), + ); + } + if yaml_contains_string(release, "outputs.dry-run == 'false'") { + issues.push( + "FAIL: release-negative-dry-run-condition: publication must use affirmative authorization instead of a negated dry-run output." + .to_string(), + ); + } if yaml_path( release, &["jobs", "publish-release-assets", "permissions", "contents"], @@ -2296,18 +2363,26 @@ jobs: CI_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} run: | main_sha="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/main" --jq '.object.sha')" + associated_pulls="$(gh api "repos/$GITHUB_REPOSITORY/commits/$CI_HEAD_SHA/pulls")" + release_merge_count="$(jq -r --arg repository "$GITHUB_REPOSITORY" '[.[] | select(.merged_at != null) | select(.base.ref == "main") | select(.head.repo.full_name == $repository) | select(.head.ref == "release-please--branches--main--components--codebase-graph") | select([.labels[].name] | index("autorelease: pending") != null)] | length' <<<"$associated_pulls")" echo 'current-tip=true' + echo 'release-merge=true' - id: release uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 + with: + skip-github-release: ${{ steps.trigger.outputs.release-merge != 'true' }} - name: Recheck current main tip after release-please env: + RELEASE_MERGE: ${{ steps.trigger.outputs.release-merge }} RELEASE_SHA: ${{ steps.release.outputs.sha }} run: | main_sha=current test "$main_sha" = "$CI_HEAD_SHA" + if [[ "$RELEASE_CREATED" == 'true' && "$RELEASE_MERGE" != 'true' ]]; then exit 1; fi release-target: outputs: ci-run-id: ${{ steps.resolve.outputs.ci-run-id }} + publish_assets: ${{ steps.resolve.outputs.publish_assets }} steps: - id: resolve env: @@ -2316,6 +2391,7 @@ jobs: run: | tag_sha=tag source_sha=source + publish_assets=true ci-gate: permissions: {actions: read} outputs: {ci-run-id: x} @@ -2340,10 +2416,12 @@ jobs: fi rebuild-artifacts: {uses: './.github/workflows/native.yml'} publish-release-assets: + if: ${{ 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' }} steps: - {run: 'cargo publish --dry-run --locked'} - {run: 'cargo publish --locked'} @@ -2444,6 +2522,88 @@ jobs: ); } + #[test] + fn workflow_policy_rejects_unconditional_release_publication() { + let broken = valid_release_workflow_text().replace( + "${{ steps.trigger.outputs.release-merge != 'true' }}", + "false", + ); + let issues = workflow_policy_issues(&broken); + assert!( + issues + .iter() + .any(|issue| issue.contains("release-publication-gate-missing")), + "{issues:?}" + ); + } + + #[test] + fn workflow_policy_rejects_missing_release_merge_detection() { + let broken = valid_release_workflow_text().replace( + ".head.ref == \"release-please--branches--main--components--codebase-graph\"", + "ordinary-feature-branch", + ); + let issues = workflow_policy_issues(&broken); + assert!( + issues + .iter() + .any(|issue| issue.contains("release-trigger-binding-missing")), + "{issues:?}" + ); + } + + #[test] + fn workflow_policy_rejects_untrusted_release_merge_identity() { + for (trusted, untrusted) in [ + ( + ".head.repo.full_name == $repository", + ".head.repo.full_name != $repository", + ), + ("autorelease: pending", "ordinary-label"), + (".merged_at != null", ".merged_at == null"), + (".base.ref == \"main\"", ".base.ref == \"other\""), + ] { + let broken = valid_release_workflow_text().replace(trusted, untrusted); + let issues = workflow_policy_issues(&broken); + assert!( + issues + .iter() + .any(|issue| issue.contains("release-trigger-binding-missing")), + "{trusted}: {issues:?}" + ); + } + } + + #[test] + fn workflow_policy_rejects_missing_post_action_release_merge_guard() { + let broken = valid_release_workflow_text().replace( + "\"$RELEASE_CREATED\" == 'true' && \"$RELEASE_MERGE\" != 'true'", + "\"$RELEASE_CREATED\" == 'true' && \"$RELEASE_MERGE\" == 'true'", + ); + let issues = workflow_policy_issues(&broken); + assert!( + issues + .iter() + .any(|issue| issue.contains("release-post-action-freshness-missing")), + "{issues:?}" + ); + } + + #[test] + fn workflow_policy_rejects_negated_dry_run_publication() { + let broken = valid_release_workflow_text().replace( + "outputs.publish_assets == 'true'", + "outputs.dry-run == 'false'", + ); + let issues = workflow_policy_issues(&broken); + assert!( + issues + .iter() + .any(|issue| issue.contains("release-negative-dry-run-condition")), + "{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 2e040ca..e033132 100644 --- a/docs/release.md +++ b/docs/release.md @@ -6,7 +6,9 @@ push lets release-please create or update its release pull request; merging that tag, validates and promotes the triggering CI run's retained artifacts, and publishes `codebase-graph` to crates.io. Failed, cancelled, pull-request, and non-main completions perform no release mutation. A completion that is already stale is skipped before release-please; if `main` advances while release-please is running, a post-action guard stops all asset -and crate publication. +and crate publication. Successful CI for an ordinary main commit may create or update the release proposal, but it runs +release-please with tag creation disabled. Tag and GitHub Release publication is enabled only when the successful CI SHA +is the merge commit of a release-please pull request. ## One-Time Setup @@ -42,16 +44,23 @@ Pull requests targeting `main` and pushes to `main` run: 1. Merge normal pull requests into `main` with Conventional Commit-style titles or squash commit messages. 2. After the complete `CI` push workflow succeeds, `Release` verifies that its triggering run is the current `main` tip - and passes that exact run ID and SHA to release-please. It rechecks the tip after release-please before continuing. + and requires exactly one associated merged pull request from the repository-owned release-please branch with its + pending-release label before enabling publication. Ordinary commits allow release-please to manage release proposals + with tag creation disabled. 3. Release-please opens or updates a release pull request that changes `CHANGELOG.md`, `.release-please-manifest.json`, root `Cargo.toml`, and `crates/k-wiki/Cargo.toml` together. 4. Review and merge the release pull request when ready to publish. Its `main` CI must complete successfully like any other merge. -5. 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. +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. 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. +If the release pull request merge fails CI, later successful commits cannot publish its stale tag. A corrected release +must be represented by a new release pull request whose own merge commit passes CI, preserving the exact-run artifact +and provenance contract. + ## Release Gate Before publishing a production release, confirm: diff --git a/knowledge/architecture/release-verification.md b/knowledge/architecture/release-verification.md index ce5ea44..ca6c112 100644 --- a/knowledge/architecture/release-verification.md +++ b/knowledge/architecture/release-verification.md @@ -7,7 +7,7 @@ tags: - ci - provenance - release -timestamp: 2026-08-14 +timestamp: 2026-08-17 title: Native Release Verification type: architecture --- @@ -40,9 +40,9 @@ Pull requests validate artifacts without retaining them. Main pushes retain all ## Automatic release orchestration -The Release workflow is triggered by completion of the `CI` workflow on `main`, not independently by a branch push. Release-please runs only when the triggering workflow was a completed successful `push` run on `main` and its `head_sha` is still the current `main` tip. Failed, cancelled, pull-request, and non-main completions may create a skipped Release workflow record but cannot mutate release state. A completion that is already stale is rejected before release-please. Because GitHub does not provide an atomic branch-tip check plus action invocation, the workflow rechecks the current tip and release SHA immediately after release-please; if `main` advanced during the action, all artifact and crate publication stops. +The Release workflow is triggered by completion of the `CI` workflow on `main`, not independently by a branch push. Release-please runs only when the triggering workflow was a completed successful `push` run on `main` and its `head_sha` is still the current `main` tip. Before invoking release-please, the workflow classifies whether that SHA belongs to exactly one merged pull request targeting `main` from the repository-owned release-please branch with the pending-release label; ambiguous or untrusted identities fail closed. Ordinary successful commits run release-please with GitHub Release and tag creation disabled, allowing proposal maintenance without publishing a stale pending release. Only a successful release-merge commit enables tag creation. Failed, cancelled, pull-request, and non-main completions may create a skipped Release workflow record but cannot mutate release state. A completion that is already stale is rejected before release-please. Because GitHub does not provide an atomic branch-tip check plus action invocation, the workflow rechecks the current tip, release classification, and release SHA immediately after release-please; if `main` advanced during the action, all artifact and crate publication stops. -Automatic mode binds release identity directly to `github.event.workflow_run.id` and `github.event.workflow_run.head_sha`. It revalidates that run's workflow path, event, branch, status, conclusion, and SHA, and it requires any release-please tag to resolve to that same SHA. Automatic mode never uses `github.sha`, polls for a substitute CI run, or rebuilds missing artifacts. Automatic runs serialize in the `release-main` concurrency group without cancellation, so only a successful current-tip completion owns orchestration. +Automatic mode binds release identity directly to `github.event.workflow_run.id` and `github.event.workflow_run.head_sha`. It revalidates that run's workflow path, event, branch, status, conclusion, and SHA, and it requires any release-please tag to resolve to that same SHA. If a release-merge commit fails CI, a later ordinary commit cannot publish its pending tag; the corrected release must be represented by a new release pull request whose merge commit passes CI. Automatic mode never uses `github.sha`, polls for a substitute CI run, or rebuilds missing artifacts. Automatic runs serialize in the `release-main` concurrency group without cancellation, so only a successful current-tip completion owns orchestration. ## Release promotion