diff --git a/.github/actions/verified-release/action.yml b/.github/actions/verified-release/action.yml new file mode 100644 index 0000000..26ab134 --- /dev/null +++ b/.github/actions/verified-release/action.yml @@ -0,0 +1,60 @@ +name: Verify release identity +description: Bind a successful main CI run to its exact trusted release merge. +inputs: + ci-run-id: + required: true + description: Exact CI run to authorize. + expected-sha: + required: false + default: '' + description: Expected source SHA, when supplied by the event or previous gate. + require-release: + required: false + default: 'false' + description: Reject ordinary commits when authorizing publication or recovery. +outputs: + source-sha: + value: ${{ steps.verify.outputs.source-sha }} + description: Verified CI source SHA. + release-pr: + value: ${{ steps.verify.outputs.release-pr }} + description: Trusted release PR, or empty for an ordinary commit. +runs: + using: composite + steps: + - id: verify + shell: bash + env: + CI_RUN_ID: ${{ inputs.ci-run-id }} + EXPECTED_SHA: ${{ inputs.expected-sha }} + REQUIRE_RELEASE: ${{ inputs.require-release }} + run: | + set -euo pipefail + [[ "$CI_RUN_ID" =~ ^[0-9]+$ ]] || { echo 'Invalid CI run ID.' >&2; exit 1; } + run="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$CI_RUN_ID")" + jq -e --arg repository "$GITHUB_REPOSITORY" --arg id "$CI_RUN_ID" \ + '.id == ($id | tonumber) and .repository.full_name == $repository + and .path == ".github/workflows/ci.yml" and .event == "push" + and .head_branch == "main" and .status == "completed" and .conclusion == "success"' \ + <<<"$run" >/dev/null || { echo 'Expected successful main push CI from this repository.' >&2; exit 1; } + source_sha="$(jq -r .head_sha <<<"$run")" + [[ "$source_sha" =~ ^[0-9a-f]{40}$ ]] || exit 1 + [[ -z "$EXPECTED_SHA" || "$source_sha" == "$EXPECTED_SHA" ]] || { echo 'CI SHA mismatch.' >&2; exit 1; } + main_sha="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/main" --jq '.object.sha')" + comparison="$(gh api "repos/$GITHUB_REPOSITORY/compare/$source_sha...$main_sha" --jq .status)" + [[ "$comparison" == 'identical' || "$comparison" == 'ahead' ]] || { echo 'CI commit is no longer on main.' >&2; exit 1; } + pulls="$(gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/commits/$source_sha/pulls?per_page=100")" + matches="$(jq --arg repository "$GITHUB_REPOSITORY" --arg sha "$source_sha" \ + '[.[][] | select(.merged_at != null and .merge_commit_sha == $sha) + | select(.base.ref == "main" and .base.repo.full_name == $repository) + | select(.head.repo.full_name == $repository) + | select(.head.ref == "release-please--branches--main--components--codebase-graph") + | select(any(.labels[]; .name == "autorelease: pending" or .name == "autorelease: tagged"))] + | unique_by(.number)' <<<"$pulls")" + count="$(jq length <<<"$matches")" + [[ "$count" -le 1 ]] || { echo 'Ambiguous release merge.' >&2; exit 1; } + [[ "$REQUIRE_RELEASE" != 'true' || "$count" == 1 ]] || { echo 'CI is not for a trusted release merge.' >&2; exit 1; } + { + echo "source-sha=$source_sha" + echo "release-pr=$(jq -r '.[0].number // empty' <<<"$matches")" + } >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 71b54b9..375f00f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,35 +2,35 @@ name: Release on: workflow_run: - workflows: - - CI - types: - - completed - branches: - - main + workflows: [CI] + types: [completed] + branches: [main] workflow_dispatch: inputs: publish-existing-tag: - description: Existing vX.Y.Z tag to validate or re-publish. - required: true + description: Existing vX.Y.Z tag for native-assets-only recovery (exclusive with resume-ci-run). + required: false + type: string + resume-ci-run: + description: Successful main CI run for a release merge; resumes native assets and crates.io. + required: false type: string artifact-source: - description: Promote retained CI artifacts or rebuild all targets when any are missing. + description: Existing-tag recovery only; CI-run recovery always promotes the exact run. required: true type: choice default: promote - options: - - promote - - rebuild-if-missing + options: [promote, rebuild-if-missing] dry-run: - description: Validate the full release without publishing GitHub or Cargo assets. + description: Validate without creating tags, releases, labels, PRs, or publishing packages. required: true type: boolean - default: false + default: true concurrency: - group: release-${{ github.event_name == 'workflow_run' && 'main' || inputs.publish-existing-tag }} + group: release-main cancel-in-progress: false + queue: max permissions: contents: read @@ -40,115 +40,57 @@ env: CARGO_NET_RETRY: "10" jobs: - release-please: - name: release please - if: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'main' && github.event.workflow_run.conclusion == 'success' }} - runs-on: ubuntu-latest - timeout-minutes: 10 - permissions: - contents: write - pull-requests: write - outputs: - release-created: ${{ steps.release.outputs.release_created }} - tag-name: ${{ steps.release.outputs.tag_name }} - version: ${{ steps.release.outputs.version }} - ci-run-id: ${{ steps.trigger.outputs.ci-run-id }} - ci-head-sha: ${{ steps.trigger.outputs.ci-head-sha }} - steps: - - name: Verify successful CI is for the current main tip - id: trigger - shell: bash - env: - GH_TOKEN: ${{ github.token }} - CI_RUN_ID: ${{ github.event.workflow_run.id }} - CI_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} - run: | - set -euo pipefail - main_sha="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/main" --jq '.object.sha')" - if [[ "$main_sha" != "$CI_HEAD_SHA" ]]; then - echo "Skipping stale CI completion for $CI_HEAD_SHA; current main is $main_sha." - 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 - if: steps.trigger.outputs.current-tip == 'true' - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 - with: - 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: | - set -euo pipefail - main_sha="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/main" --jq '.object.sha')" - [[ "$main_sha" == "$CI_HEAD_SHA" ]] || { - 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 - fi - release-target: - name: resolve release target - needs: release-please - if: ${{ always() && (github.event_name == 'workflow_dispatch' || needs.release-please.result == 'success') }} + name: resolve verified release target + if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'workflow_run' && github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'main' && github.event.workflow_run.conclusion == 'success') }} runs-on: ubuntu-latest timeout-minutes: 10 permissions: + actions: read contents: read + pull-requests: read outputs: should-publish: ${{ steps.resolve.outputs.should-publish }} automatic: ${{ steps.resolve.outputs.automatic }} tag-name: ${{ steps.resolve.outputs.tag-name }} version: ${{ steps.resolve.outputs.version }} source-sha: ${{ steps.resolve.outputs.source-sha }} + release-pr: ${{ steps.verified.outputs.release-pr }} publish_assets: ${{ steps.resolve.outputs.publish_assets }} artifact-source: ${{ steps.resolve.outputs.artifact-source }} ci-run-id: ${{ steps.resolve.outputs.ci-run-id }} + dry-run: ${{ steps.resolve.outputs.dry-run }} steps: + - name: Check out workflow implementation + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ github.workflow_sha }} + - name: Validate recovery inputs + shell: bash + env: + MANUAL_TAG: ${{ inputs.publish-existing-tag }} + RESUME_CI_RUN: ${{ inputs.resume-ci-run }} + MANUAL_SOURCE: ${{ inputs.artifact-source }} + run: | + set -euo pipefail + if [[ "$GITHUB_EVENT_NAME" == 'workflow_dispatch' ]]; then + [[ -n "$MANUAL_TAG" && -z "$RESUME_CI_RUN" || -z "$MANUAL_TAG" && -n "$RESUME_CI_RUN" ]] || { + echo 'Choose exactly one of publish-existing-tag and resume-ci-run.' >&2; exit 1; + } + [[ -z "$RESUME_CI_RUN" || "$MANUAL_SOURCE" == 'promote' ]] || { + echo 'CI-run recovery must promote the verified artifacts.' >&2; exit 1; + } + fi + - name: Verify exact CI and release merge + id: verified + if: github.event_name == 'workflow_run' || inputs.resume-ci-run != '' + uses: ./.github/actions/verified-release + env: + GH_TOKEN: ${{ github.token }} + with: + ci-run-id: ${{ github.event.workflow_run.id || inputs.resume-ci-run }} + expected-sha: ${{ github.event.workflow_run.head_sha }} + require-release: ${{ github.event_name == 'workflow_dispatch' }} - name: Resolve tag and source SHA id: resolve shell: bash @@ -157,57 +99,41 @@ jobs: MANUAL_TAG: ${{ inputs.publish-existing-tag }} MANUAL_SOURCE: ${{ inputs.artifact-source }} MANUAL_DRY_RUN: ${{ inputs.dry-run }} - RELEASE_CREATED: ${{ needs.release-please.outputs.release-created }} - RELEASE_TAG: ${{ needs.release-please.outputs.tag-name }} - RELEASE_CI_RUN_ID: ${{ needs.release-please.outputs.ci-run-id }} - RELEASE_CI_HEAD_SHA: ${{ needs.release-please.outputs.ci-head-sha }} + CI_RUN_ID: ${{ github.event.workflow_run.id || inputs.resume-ci-run }} + VERIFIED_SHA: ${{ steps.verified.outputs.source-sha }} + RELEASE_PR: ${{ steps.verified.outputs.release-pr }} run: | set -euo pipefail - if [[ "$GITHUB_EVENT_NAME" == 'workflow_dispatch' ]]; then + tag='' + source_sha="$VERIFIED_SHA" + ci_run_id="$CI_RUN_ID" + should_publish=false + automatic=false + artifact_source=promote + dry_run=false + [[ "$GITHUB_EVENT_NAME" != 'workflow_dispatch' ]] || dry_run="$MANUAL_DRY_RUN" + if [[ -n "$MANUAL_TAG" ]]; then tag="$MANUAL_TAG" - [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "Invalid release tag: $tag" >&2; exit 1; } - source_sha="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$tag" --jq '.object.sha')" - object_type="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$tag" --jq '.object.type')" - if [[ "$object_type" == 'tag' ]]; then + [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo 'Invalid release tag.' >&2; exit 1; } + ref="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$tag")" + source_sha="$(jq -r .object.sha <<<"$ref")" + if [[ "$(jq -r .object.type <<<"$ref")" == 'tag' ]]; then source_sha="$(gh api "repos/$GITHUB_REPOSITORY/git/tags/$source_sha" --jq '.object.sha')" fi - automatic=false + ci_run_id='' 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 - tag="$RELEASE_TAG" - source_sha="$RELEASE_CI_HEAD_SHA" - ci_run_id="$RELEASE_CI_RUN_ID" - [[ "$source_sha" =~ ^[0-9a-f]{40}$ ]] || { echo "Invalid triggering CI SHA: $source_sha" >&2; exit 1; } - [[ "$ci_run_id" =~ ^[0-9]+$ ]] || { echo "Invalid triggering CI run ID: $ci_run_id" >&2; exit 1; } - tag_sha="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$tag" --jq '.object.sha')" - object_type="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$tag" --jq '.object.type')" - if [[ "$object_type" == 'tag' ]]; then - tag_sha="$(gh api "repos/$GITHUB_REPOSITORY/git/tags/$tag_sha" --jq '.object.sha')" - fi - [[ "$tag_sha" == "$source_sha" ]] || { echo "Release tag $tag resolves to $tag_sha, not triggering CI SHA $source_sha." >&2; exit 1; } + elif [[ -n "$RELEASE_PR" ]]; then + manifest="$(gh api "repos/$GITHUB_REPOSITORY/contents/.release-please-manifest.json?ref=$source_sha" --jq .content | base64 --decode)" + version="$(jq -er '.["."]' <<<"$manifest")" + tag="v$version" + [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo 'Invalid release manifest version.' >&2; exit 1; } automatic=true should_publish=true - dry_run=false - publish_assets=true - artifact_source=promote - else - tag='' - source_sha='' - automatic=false - should_publish=false - dry_run=true - publish_assets=false - artifact_source=promote - ci_run_id='' fi + [[ "$source_sha" =~ ^[0-9a-f]{40}$ ]] || { echo 'Invalid source SHA.' >&2; exit 1; } + publish_assets=false + [[ "$should_publish" != 'true' || "$dry_run" == 'true' ]] || publish_assets=true { echo "should-publish=$should_publish" echo "automatic=$automatic" @@ -217,7 +143,33 @@ jobs: echo "publish_assets=$publish_assets" echo "artifact-source=$artifact_source" echo "ci-run-id=$ci_run_id" + echo "dry-run=$dry_run" } >> "$GITHUB_OUTPUT" + echo "Source: $source_sha; CI: $ci_run_id; release PR: ${RELEASE_PR:-none}; tag: ${tag:-none}; dry run: $dry_run" >> "$GITHUB_STEP_SUMMARY" + - name: Check out immutable metadata source + if: steps.resolve.outputs.automatic == 'true' + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ steps.resolve.outputs.source-sha }} + path: release-source + - name: Prepare immutable release metadata + if: steps.resolve.outputs.automatic == 'true' + shell: bash + env: + SOURCE_SHA: ${{ steps.resolve.outputs.source-sha }} + RELEASE_TAG: ${{ steps.resolve.outputs.tag-name }} + run: | + set -euo pipefail + cargo run -p xtask --locked -- release-metadata --source-sha "$SOURCE_SHA" --source-dir release-source > release-metadata.json + jq -e --arg sha "$SOURCE_SHA" --arg tag "$RELEASE_TAG" '.source_sha == $sha and .tag_name == $tag' release-metadata.json >/dev/null + - name: Retain immutable release metadata + if: steps.resolve.outputs.automatic == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-metadata + path: release-metadata.json + if-no-files-found: error + retention-days: 7 ci-gate: name: exact-SHA CI gate @@ -257,11 +209,6 @@ jobs: echo "Triggering workflow run does not satisfy the exact-SHA main CI contract." >&2 exit 1 } - main_sha="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/main" --jq '.object.sha')" - [[ "$main_sha" == "$SOURCE_SHA" ]] || { - echo "Refusing to release stale CI SHA $SOURCE_SHA; current main is $main_sha." >&2 - exit 1 - } artifact_count="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$REQUESTED_CI_RUN_ID/artifacts?per_page=100" --jq '[.artifacts[] | select(.expired == false and (.name | startswith("native-")))] | length')" { echo "ci-run-id=$REQUESTED_CI_RUN_ID" @@ -276,8 +223,8 @@ jobs: deadline=$((SECONDS + 1800)) while (( SECONDS < deadline )); do - runs="$(gh api --paginate "repos/$GITHUB_REPOSITORY/actions/workflows/ci.yml/runs?event=push&head_sha=$SOURCE_SHA&per_page=100")" - run_id="$(jq -r --arg sha "$SOURCE_SHA" '[.workflow_runs[] | select(.head_sha == $sha and .event == "push" and .status == "completed" and .conclusion == "success")] | sort_by(.run_number) | last | .id // empty' <<<"$runs")" + runs="$(gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/actions/workflows/ci.yml/runs?event=push&head_sha=$SOURCE_SHA&per_page=100")" + run_id="$(jq -r --arg sha "$SOURCE_SHA" '[.[].workflow_runs[] | select(.head_sha == $sha and .head_branch == "main" and .path == ".github/workflows/ci.yml" and .event == "push" and .status == "completed" and .conclusion == "success")] | sort_by(.run_number) | last | .id // empty' <<<"$runs")" if [[ -n "$run_id" ]]; then artifact_count="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$run_id/artifacts?per_page=100" --jq '[.artifacts[] | select(.expired == false and (.name | startswith("native-")))] | length')" { @@ -290,7 +237,7 @@ jobs: } >> "$GITHUB_OUTPUT" exit 0 fi - failed="$(jq -r --arg sha "$SOURCE_SHA" '[.workflow_runs[] | select(.head_sha == $sha and .event == "push" and .status == "completed" and .conclusion != "success")] | length' <<<"$runs")" + failed="$(jq -r --arg sha "$SOURCE_SHA" '[.[].workflow_runs[] | select(.head_sha == $sha and .head_branch == "main" and .path == ".github/workflows/ci.yml" and .event == "push" and .status == "completed" and .conclusion != "success")] | length' <<<"$runs")" if [[ "$failed" != '0' ]]; then echo "Exact-SHA CI completed without success for $SOURCE_SHA." >&2 exit 1 @@ -460,12 +407,85 @@ jobs: permissions: actions: read contents: write + pull-requests: read steps: + - name: Check out workflow implementation + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ github.workflow_sha }} + - name: Revalidate exact release before publication + id: verified + if: needs.release-target.outputs.automatic == 'true' + uses: ./.github/actions/verified-release + env: + GH_TOKEN: ${{ github.token }} + with: + ci-run-id: ${{ needs.release-target.outputs.ci-run-id }} + expected-sha: ${{ needs.release-target.outputs.source-sha }} + require-release: 'true' + - name: Download immutable release metadata + if: needs.release-target.outputs.automatic == 'true' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-metadata + path: dist/metadata - name: Download validated release set uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: validated-release-assets path: dist/release-assets + - name: Create only the verified release + if: needs.release-target.outputs.automatic == 'true' + shell: bash + env: + GH_TOKEN: ${{ secrets.RELEASE_PUBLISH_TOKEN || github.token }} + SOURCE_SHA: ${{ needs.release-target.outputs.source-sha }} + RELEASE_TAG: ${{ needs.release-target.outputs.tag-name }} + RELEASE_PR: ${{ needs.release-target.outputs.release-pr }} + VERIFIED_PR: ${{ steps.verified.outputs.release-pr }} + run: | + set -euo pipefail + [[ "$VERIFIED_PR" == "$RELEASE_PR" ]] || { echo 'Release PR identity changed.' >&2; exit 1; } + jq -e --arg sha "$SOURCE_SHA" --arg tag "$RELEASE_TAG" '.source_sha == $sha and .tag_name == $tag' dist/metadata/release-metadata.json >/dev/null + optional_get() { + local response + if response="$(gh api "$1" 2>"$RUNNER_TEMP/release-api-error")"; then + printf '%s' "$response" + elif jq -e '.status == "404"' <<<"$response" >/dev/null 2>&1; then + printf 'null' + else + cat "$RUNNER_TEMP/release-api-error" >&2 + return 1 + fi + } + ref="$(optional_get "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG")" + if [[ "$ref" == 'null' ]]; then + gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" -f "ref=refs/tags/$RELEASE_TAG" -f "sha=$SOURCE_SHA" >/dev/null || { + echo 'Cannot create the exact tag. Historical workflow changes may require RELEASE_PUBLISH_TOKEN with Contents and Workflows write permissions.' >&2; exit 1; + } + ref="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG")" + fi + tag_sha="$(jq -r .object.sha <<<"$ref")" + if [[ "$(jq -r .object.type <<<"$ref")" == 'tag' ]]; then + tag_sha="$(gh api "repos/$GITHUB_REPOSITORY/git/tags/$tag_sha" --jq '.object.sha')" + fi + [[ "$tag_sha" == "$SOURCE_SHA" ]] || { echo 'Existing release tag points to a different commit.' >&2; exit 1; } + release="$(optional_get "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG")" + if [[ "$release" == 'null' ]]; then + releases="$(gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/releases?per_page=100")" + latest="$(jq -r --arg tag "$RELEASE_TAG" \ + '($tag | ltrimstr("v") | split(".") | map(tonumber)) as $version | + [.[][] | select(.draft == false and .prerelease == false) + | .tag_name | select(test("^v[0-9]+\\.[0-9]+\\.[0-9]+$")) + | ltrimstr("v") | split(".") | map(tonumber)] | all(. < $version)' <<<"$releases")" + jq --arg latest "$latest" \ + '{tag_name, target_commitish: .source_sha, name: .tag_name, body: .notes, + draft: false, prerelease: false, make_latest: $latest}' \ + dist/metadata/release-metadata.json > "$RUNNER_TEMP/release-request.json" + gh api --method POST "repos/$GITHUB_REPOSITORY/releases" --input "$RUNNER_TEMP/release-request.json" >/dev/null + else + jq -e --arg tag "$RELEASE_TAG" '.tag_name == $tag and .draft == false and .prerelease == false' <<<"$release" >/dev/null + fi - name: Upload complete native asset set shell: bash env: @@ -475,7 +495,6 @@ jobs: publish-crate: name: publish crates.io package needs: - - release-please - release-target - publish-release-assets # Keep the explicit status override through the final job so the intentionally @@ -483,10 +502,9 @@ jobs: 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.automatic == 'true' && needs.release-target.outputs.publish_assets == 'true' }} runs-on: ubuntu-latest @@ -557,3 +575,65 @@ jobs: echo "Publish failed; retrying in $sleep_seconds seconds." sleep "$sleep_seconds" done + + finalize-release: + name: finalize verified release PR + needs: [release-target, publish-release-assets, publish-crate] + if: ${{ always() && needs.release-target.result == 'success' && needs.publish-release-assets.result == 'success' && needs.publish-crate.result == 'success' && needs.release-target.outputs.automatic == 'true' }} + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - name: Mark only the completed release PR tagged + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_PR: ${{ needs.release-target.outputs.release-pr }} + run: | + set -euo pipefail + gh pr edit "$RELEASE_PR" --repo "$GITHUB_REPOSITORY" --add-label 'autorelease: tagged' --remove-label 'autorelease: pending' + + release-please: + name: maintain release proposal + needs: [release-target, finalize-release] + if: ${{ always() && needs.release-target.result == 'success' && needs.release-target.outputs.dry-run == 'false' && (github.event_name == 'workflow_run' || inputs.resume-ci-run != '') && (needs.release-target.outputs.should-publish == 'false' || needs.finalize-release.result == 'success') }} + runs-on: ubuntu-latest + permissions: + actions: read + contents: write + pull-requests: write + steps: + - name: Verify current main CI and detect blocked proposals + id: proposal + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + main_sha="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/main" --jq '.object.sha')" + runs="$(gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/actions/workflows/ci.yml/runs?event=push&head_sha=$main_sha&per_page=100")" + if ! jq -e --arg sha "$main_sha" 'any(.[].workflow_runs[]; .head_sha == $sha and .path == ".github/workflows/ci.yml" and .head_branch == "main" and .event == "push" and .status == "completed" and .conclusion == "success")' <<<"$runs" >/dev/null; then + echo 'Waiting for successful CI of current main before maintaining the next release proposal.' >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + pulls="$(gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/pulls?state=closed&base=main&per_page=100")" + pending="$(jq -r --arg repository "$GITHUB_REPOSITORY" '[.[][] | select(.merged_at != null and .head.repo.full_name == $repository) + | select(.head.ref == "release-please--branches--main--components--codebase-graph") + | select(any(.labels[]; .name == "autorelease: pending")) | .number] | join(", ")' <<<"$pulls")" + if [[ -n "$pending" ]]; then + echo "::error::Merged release PRs $pending remain unpublished. Resume each release merge's successful CI using resume-ci-run." + echo "Blocked by unpublished release PRs: $pending" >> "$GITHUB_STEP_SUMMARY" + exit 1 + fi + current="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/main" --jq '.object.sha')" + [[ "$current" != "$main_sha" ]] || echo 'ready=true' >> "$GITHUB_OUTPUT" + - name: Create or update release PR only + id: release + if: steps.proposal.outputs.ready == 'true' + uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 + with: + token: ${{ secrets.RELEASE_PLEASE_TOKEN || github.token }} + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + skip-github-release: true diff --git a/crates/xtask/src/main.rs b/crates/xtask/src/main.rs index 17d0ec6..8936faa 100644 --- a/crates/xtask/src/main.rs +++ b/crates/xtask/src/main.rs @@ -13,6 +13,8 @@ use std::process::{Command, Stdio}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use yaml_serde::Value as YamlValue; +mod release; + const CONFIRMATIONS: &[&str] = &["release-environment", "private-vulnerability-reporting"]; const CRATES_IO_PACKAGE_LIMIT_BYTES: u64 = 10 * 1024 * 1024; const NATIVE_TARGETS: [NativeTarget; 4] = [ @@ -97,6 +99,7 @@ fn run() -> Result<(), String> { match args.next().as_deref() { Some("release-gate") => release_gate(args.collect()), Some("check-workflows") => check_workflows_command(), + Some("release-metadata") => release::metadata_command(args.collect()), Some("native-test") => native_test(args.collect()), Some("native-artifact") => native_artifact(args.collect()), Some("validate-native-artifacts") => validate_native_artifacts(args.collect()), @@ -1112,11 +1115,10 @@ fn check_workflow_policy( ); } if yaml_path(release, &["concurrency", "group"]).and_then(YamlValue::as_str) - != Some( - "release-${{ github.event_name == 'workflow_run' && 'main' || inputs.publish-existing-tag }}", - ) + != Some("release-main") || yaml_path(release, &["concurrency", "cancel-in-progress"]).and_then(YamlValue::as_bool) != Some(false) + || yaml_path(release, &["concurrency", "queue"]).and_then(YamlValue::as_str) != Some("max") { issues.push( "FAIL: release-concurrency-invalid: automatic releases must serialize as release-main without cancellation." @@ -1124,128 +1126,139 @@ fn check_workflow_policy( ); } - let release_please = - yaml_path(release, &["jobs", "release-please"]).unwrap_or(&YamlValue::Null); + let release_target = + yaml_path(release, &["jobs", "release-target"]).unwrap_or(&YamlValue::Null); for marker in [ "github.event_name == 'workflow_run'", "github.event.workflow_run.event == 'push'", "github.event.workflow_run.head_branch == 'main'", "github.event.workflow_run.conclusion == 'success'", ] { - if !yaml_path(release_please, &["if"]) - .is_some_and(|condition| yaml_contains_string(condition, marker)) - { + if !yaml_path(release_target, &["if"]).is_some_and(|v| yaml_contains_string(v, marker)) { issues.push(format!( - "FAIL: release-success-guard-missing: release-please must require {marker}." + "FAIL: release-success-guard-missing: target must require {marker}." )); } } - let trigger_step = yaml_step_by_id(release_please, "trigger").unwrap_or(&YamlValue::Null); + let verified = yaml_step_by_id(release_target, "verified").unwrap_or(&YamlValue::Null); for (field, expected) in [ - ("CI_RUN_ID", "${{ github.event.workflow_run.id }}"), - ("CI_HEAD_SHA", "${{ github.event.workflow_run.head_sha }}"), - ] { - if yaml_path(trigger_step, &["env", field]).and_then(YamlValue::as_str) != Some(expected) { - issues.push(format!( - "FAIL: release-trigger-binding-missing: trigger step {field} must bind {expected}." - )); - } - } - 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", + ( + "ci-run-id", + "${{ github.event.workflow_run.id || inputs.resume-ci-run }}", + ), + ("expected-sha", "${{ github.event.workflow_run.head_sha }}"), + ( + "require-release", + "${{ github.event_name == 'workflow_dispatch' }}", + ), ] { - if !yaml_path(trigger_step, &["run"]).is_some_and(|run| yaml_contains_string(run, marker)) { + if yaml_path(verified, &["with", field]).and_then(YamlValue::as_str) != Some(expected) { issues.push(format!( - "FAIL: release-trigger-binding-missing: trigger step must contain {marker}." + "FAIL: release-trigger-binding-missing: verified {field} must bind {expected}." )); } } - let release_action = yaml_step_by_id(release_please, "release").unwrap_or(&YamlValue::Null); - if yaml_path(release_action, &["uses"]).and_then(YamlValue::as_str) - != Some("googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7") + if yaml_path(verified, &["uses"]).and_then(YamlValue::as_str) + != Some("./.github/actions/verified-release") { issues.push( - "FAIL: release-please-action-missing: release job must use the pinned release-please action." - .to_string(), + "FAIL: release-trigger-binding-missing: use the shared exact-release verifier.".into(), ); } - if yaml_path(release_action, &["with", "skip-github-release"]).and_then(YamlValue::as_str) - != Some("${{ steps.trigger.outputs.release-merge != 'true' }}") + let release_please = + yaml_path(release, &["jobs", "release-please"]).unwrap_or(&YamlValue::Null); + let action = yaml_step_by_id(release_please, "release").unwrap_or(&YamlValue::Null); + if yaml_path(action, &["uses"]).and_then(YamlValue::as_str) + != Some("googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7") { - issues.push( - "FAIL: release-publication-gate-missing: release-please must skip tag publication outside a verified release merge." - .to_string(), - ); + issues + .push("FAIL: release-please-action-missing: retain the pinned proposal action.".into()); } - let post_release_step = yaml_step_by_name( - release_please, - "Recheck current main tip after release-please", - ) - .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, "RELEASE_MERGE") - && yaml_contains_string( - run, - "\"$RELEASE_CREATED\" == 'true' && \"$RELEASE_MERGE\" != 'true'", - ) - }) + if yaml_path(action, &["with", "skip-github-release"]).and_then(YamlValue::as_bool) + != Some(true) { issues.push( - "FAIL: release-post-action-freshness-missing: release-please must recheck the current main tip and release SHA." - .to_string(), + "FAIL: release-publication-gate-missing: release-please must never create tags.".into(), ); } - if [ - release_please, - yaml_path(release, &["jobs", "release-target"]).unwrap_or(&YamlValue::Null), - yaml_path(release, &["jobs", "ci-gate"]).unwrap_or(&YamlValue::Null), - ] - .iter() - .any(|value| yaml_contains_string(value, "github.sha")) + let publisher = + yaml_path(release, &["jobs", "publish-release-assets"]).unwrap_or(&YamlValue::Null); + let recheck = yaml_step_by_id(publisher, "verified").unwrap_or(&YamlValue::Null); + if yaml_path(recheck, &["uses"]).and_then(YamlValue::as_str) + != Some("./.github/actions/verified-release") + || yaml_path(recheck, &["with", "require-release"]).and_then(YamlValue::as_str) + != Some("true") + || yaml_path(recheck, &["with", "expected-sha"]).and_then(YamlValue::as_str) + != Some("${{ needs.release-target.outputs.source-sha }}") + || yaml_path(recheck, &["with", "ci-run-id"]).and_then(YamlValue::as_str) + != Some("${{ needs.release-target.outputs.ci-run-id }}") { - issues.push( - "FAIL: release-github-sha-forbidden: workflow_run releases must use the triggering CI head SHA." - .to_string(), - ); + issues.push("FAIL: release-publication-revalidation-missing: recheck exact CI and release identity before mutation.".into()); } - - 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); - for (field, expected) in [ - ( - "RELEASE_CI_RUN_ID", - "${{ needs.release-please.outputs.ci-run-id }}", - ), - ( - "RELEASE_CI_HEAD_SHA", - "${{ needs.release-please.outputs.ci-head-sha }}", - ), + let create = yaml_step_by_name(publisher, "Create only the verified release") + .unwrap_or(&YamlValue::Null); + for marker in [ + "tag_sha", + "SOURCE_SHA", + "VERIFIED_PR", + "RELEASE_PR", + "target_commitish: .source_sha", + "git/refs", + "make_latest", ] { - if yaml_path(resolve_step, &["env", field]).and_then(YamlValue::as_str) != Some(expected) { + if !yaml_contains_string(create, marker) { issues.push(format!( - "FAIL: release-target-binding-missing: resolve step {field} must bind {expected}." + "FAIL: release-exact-publisher-missing: creation must preserve {marker}." )); } } - for marker in ["tag_sha", "source_sha"] { - if !yaml_path(resolve_step, &["run"]).is_some_and(|run| yaml_contains_string(run, marker)) { + for marker in [ + "always()", + "needs.validate-artifacts.result == 'success'", + "needs.release-target.outputs.publish_assets == 'true'", + ] { + if !yaml_path(publisher, &["if"]).is_some_and(|v| yaml_contains_string(v, marker)) { + issues.push(format!( + "FAIL: release-publication-gate-missing: publisher requires {marker}." + )); + } + } + let crate_job = yaml_path(release, &["jobs", "publish-crate"]).unwrap_or(&YamlValue::Null); + for marker in [ + "always()", + "needs.publish-release-assets.result == 'success'", + "needs.release-target.outputs.automatic == 'true'", + "needs.release-target.outputs.publish_assets == 'true'", + ] { + if !yaml_path(crate_job, &["if"]).is_some_and(|v| yaml_contains_string(v, marker)) { + issues.push(format!( + "FAIL: release-publication-gate-missing: crate job requires {marker}." + )); + } + } + for job in [ + release_target, + publisher, + yaml_path(release, &["jobs", "ci-gate"]).unwrap_or(&YamlValue::Null), + ] { + if yaml_contains_string(job, "github.sha") { + issues.push( + "FAIL: release-github-sha-forbidden: source identity must come from verified CI." + .into(), + ); + } + } + let proposal = yaml_step_by_id(release_please, "proposal").unwrap_or(&YamlValue::Null); + for marker in [ + "main_sha", + "actions/workflows/ci.yml/runs", + "autorelease: pending", + "::error::", + "resume-ci-run", + ] { + if !yaml_contains_string(proposal, marker) { issues.push(format!( - "FAIL: release-target-binding-missing: resolve step must preserve {marker}." + "FAIL: release-proposal-gate-missing: proposal preflight requires {marker}." )); } } @@ -2369,105 +2382,7 @@ mod tests { } fn valid_release_workflow_text() -> String { - r#"on: - workflow_run: - workflows: [CI] - types: [completed] - branches: [main] - workflow_dispatch: {} -concurrency: - group: release-${{ github.event_name == 'workflow_run' && 'main' || inputs.publish-existing-tag }} - cancel-in-progress: false -jobs: - release-please: - if: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'main' && github.event.workflow_run.conclusion == 'success' }} - outputs: - ci-run-id: ${{ steps.trigger.outputs.ci-run-id }} - ci-head-sha: ${{ steps.trigger.outputs.ci-head-sha }} - steps: - - id: trigger - env: - CI_RUN_ID: ${{ github.event.workflow_run.id }} - 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 }} - steps: - - id: resolve - env: - RELEASE_CI_RUN_ID: ${{ needs.release-please.outputs.ci-run-id }} - RELEASE_CI_HEAD_SHA: ${{ needs.release-please.outputs.ci-head-sha }} - run: | - tag_sha=tag - source_sha=source - ci-gate: - permissions: {actions: read} - outputs: {ci-run-id: x} - steps: - - id: wait - env: - REQUESTED_CI_RUN_ID: ${{ needs.release-target.outputs.ci-run-id }} - AUTOMATIC: ${{ needs.release-target.outputs.automatic }} - run: | - if [[ "$AUTOMATIC" == 'true' ]]; then - gh api "repos/$GITHUB_REPOSITORY/actions/runs/$REQUESTED_CI_RUN_ID" - jq '.path == ".github/workflows/ci.yml" and .event == "push" and .head_branch == "main" and .status == "completed" and .conclusion == "success"' - fi - select-artifacts: - steps: - - id: select - env: - AUTOMATIC: ${{ needs.release-target.outputs.automatic }} - run: | - if [[ "$AUTOMATIC" == 'false' && "$ARTIFACT_SOURCE" == 'rebuild-if-missing' ]]; then - echo rebuild - fi - rebuild-artifacts: {uses: './.github/workflows/native.yml'} - publish-release-assets: - permissions: {contents: write} - environment: {name: cargo} - steps: [{run: 'gh release upload'}] - publish-crate: - steps: - - {run: 'cargo publish --dry-run --locked'} - - {run: 'cargo package --locked --no-verify && cargo run -p xtask -- verify-crate-size package.crate'} - - name: Publish crates.io package - shell: bash - env: - CRATE_NAME: codebase-graph - CRATE_VERSION: ${{ needs.release-target.outputs.version }} - run: | - version_is_published() { - curl -fsS "https://crates.io/api/v1/crates/$CRATE_NAME/$CRATE_VERSION" - } - if version_is_published; then exit 0; fi - max_attempts=4 - for (( attempt=1; attempt<=max_attempts; attempt++ )); do - if cargo publish --locked; then exit 0; fi - if version_is_published; then exit 0; fi - sleep_seconds=$((15 * attempt)) - sleep "$sleep_seconds" - done -"# - .to_string() + include_str!("../../../.github/workflows/release.yml").to_string() } fn workflow_policy_issues(release_text: &str) -> Vec { @@ -2491,8 +2406,8 @@ jobs: #[test] fn workflow_policy_rejects_direct_release_push_trigger() { let broken = valid_release_workflow_text().replace( - " workflow_dispatch: {}", - " push: {branches: [main]}\n workflow_dispatch: {}", + " workflow_dispatch:", + " push: {branches: [main]}\n workflow_dispatch:", ); let issues = workflow_policy_issues(&broken); assert!( @@ -2536,7 +2451,7 @@ jobs: #[test] fn workflow_policy_rejects_missing_triggering_run_binding() { let broken = valid_release_workflow_text().replace( - "${{ github.event.workflow_run.id }}", + "${{ github.event.workflow_run.id || inputs.resume-ci-run }}", "${{ github.run_id }}", ); let issues = workflow_policy_issues(&broken); @@ -2548,86 +2463,61 @@ jobs: ); } - #[test] - fn workflow_policy_rejects_missing_post_action_freshness_check() { - let broken = valid_release_workflow_text().replace( - "Recheck current main tip after release-please", - "Do something unrelated", - ); - let issues = workflow_policy_issues(&broken); - assert!( - issues - .iter() - .any(|issue| issue.contains("release-post-action-freshness-missing")), - "{issues:?}" - ); - } - #[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:?}" - ); + let broken = valid_release_workflow_text() + .replace("skip-github-release: true", "skip-github-release: false"); + assert!(workflow_policy_issues(&broken) + .iter() + .any(|x| x.contains("release-publication-gate-missing"))); } #[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:?}" - ); + fn workflow_policy_requires_publication_revalidation() { + for (old, new) in [ + ("require-release: 'true'", "require-release: 'false'"), + ( + "expected-sha: ${{ needs.release-target.outputs.source-sha }}", + "expected-sha: unverified", + ), + ( + "ci-run-id: ${{ needs.release-target.outputs.ci-run-id }}", + "ci-run-id: unverified", + ), + ] { + let broken = valid_release_workflow_text().replace(old, new); + assert!( + workflow_policy_issues(&broken) + .iter() + .any(|x| x.contains("release-publication-revalidation-missing")), + "{old}" + ); + } } #[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\""), + fn workflow_policy_rejects_unvalidated_publishers() { + for old in [ + "needs.validate-artifacts.result == 'success'", + "needs.publish-release-assets.result == 'success'", + "needs.release-target.outputs.automatic == 'true'", ] { - let broken = valid_release_workflow_text().replace(trusted, untrusted); - let issues = workflow_policy_issues(&broken); + let broken = valid_release_workflow_text().replace(old, "true"); assert!( - issues + workflow_policy_issues(&broken) .iter() - .any(|issue| issue.contains("release-trigger-binding-missing")), - "{trusted}: {issues:?}" + .any(|x| x.contains("release-publication-gate-missing")), + "{old}" ); } } #[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:?}" - ); + fn workflow_policy_rejects_release_queue_replacement() { + let broken = valid_release_workflow_text().replace("queue: max", "queue: single"); + assert!(workflow_policy_issues(&broken) + .iter() + .any(|x| x.contains("release-concurrency-invalid"))); } #[test] @@ -2663,8 +2553,8 @@ jobs: } let broken = valid_release_workflow_text().replacen( - "if version_is_published; then exit 0; fi", - "if false; then exit 0; fi", + "if version_is_published; then", + "if false; then", 1, ); let issues = workflow_policy_issues(&broken); @@ -2678,10 +2568,8 @@ jobs: #[test] fn workflow_policy_rejects_missing_crate_size_gates() { - let broken_release = valid_release_workflow_text().replace( - "cargo package --locked --no-verify && cargo run -p xtask -- verify-crate-size package.crate", - "echo size unchecked", - ); + let broken_release = valid_release_workflow_text() + .replace("cargo package --locked --no-verify", "echo size unchecked"); let issues = workflow_policy_issues(&broken_release); assert!( issues diff --git a/crates/xtask/src/release.rs b/crates/xtask/src/release.rs new file mode 100644 index 0000000..45e1d32 --- /dev/null +++ b/crates/xtask/src/release.rs @@ -0,0 +1,143 @@ +//! Immutable release metadata. GitHub authorization and mutations stay in the workflow. +use serde::Serialize; +use serde_json::Value; +use std::{fs, path::Path, process::Command}; + +#[cfg(all(test, unix))] +mod workflow_tests; + +#[derive(Debug, Serialize)] +struct ReleaseMetadata { + source_sha: String, + version: String, + tag_name: String, + notes: String, +} + +pub(super) fn metadata_command(args: Vec) -> Result<(), String> { + let options = super::parse_options(&args, &["--source-sha", "--source-dir"])?; + let sha = super::required_option(&options, "--source-sha")?; + super::validate_commit_sha(sha)?; + let root = Path::new(super::required_option(&options, "--source-dir")?); + let head = Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(root) + .output() + .map_err(|e| e.to_string())?; + if !head.status.success() || String::from_utf8_lossy(&head.stdout).trim() != sha { + return Err("release metadata checkout does not match the verified CI SHA".into()); + } + let metadata = read_metadata(root, sha)?; + println!( + "{}", + serde_json::to_string(&metadata).map_err(|e| e.to_string())? + ); + Ok(()) +} + +fn read_metadata(root: &Path, sha: &str) -> Result { + let version = super::cargo_version(&root.join("Cargo.toml"))?; + let tag = format!("v{version}"); + super::release_version_from_tag(&tag)?; + let wiki_path = root.join("crates/k-wiki/Cargo.toml"); + let wiki = super::cargo_version(&wiki_path)?; + let dependency = super::dependency_version(&wiki_path, "codebase-graph")?; + let manifest: Value = serde_json::from_str( + &fs::read_to_string(root.join(".release-please-manifest.json")) + .map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string())?; + if wiki != version || dependency != version || manifest["."].as_str() != Some(&version) { + return Err("root, wiki, dependency, and release manifest versions must agree".into()); + } + let changelog = fs::read_to_string(root.join("CHANGELOG.md")).map_err(|e| e.to_string())?; + Ok(ReleaseMetadata { + source_sha: sha.into(), + notes: release_notes(&changelog, &version)?, + tag_name: tag, + version, + }) +} + +fn release_notes(changelog: &str, version: &str) -> Result { + let linked = format!("## [{version}]("); + let plain = format!("## {version}"); + let mut matches = 0; + let mut collecting = false; + let mut notes = Vec::new(); + for line in changelog.lines() { + if line.starts_with("## ") { + let selected = line.starts_with(&linked) + || line == plain + || line.starts_with(&format!("{plain} ")); + collecting = selected; + matches += usize::from(selected); + } + if collecting { + notes.push(line); + } + } + if matches != 1 || notes.iter().skip(1).all(|line| line.trim().is_empty()) { + return Err(format!( + "expected one nonempty changelog section for {version}" + )); + } + Ok(notes.join("\n").trim().to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn notes_only_include_the_exact_release() { + let text = "# Changelog\n## [2.1.0](url)\nnew\n## [2.0.0](url) (date)\n\n### Fixes\nverified\n## 1.0.0\nold\n"; + let notes = release_notes(text, "2.0.0").unwrap(); + assert!(notes.contains("verified")); + assert!(!notes.contains("new")); + assert!(!notes.contains("old")); + assert!(release_notes(text, "2.0").is_err()); + } + + #[test] + fn notes_reject_missing_duplicate_and_empty_sections() { + for text in [ + "## 2.0.1\nother", + "## 2.0.0\none\n## 2.0.0\ntwo", + "## 2.0.0\n\n", + ] { + assert!(release_notes(text, "2.0.0").is_err(), "{text}"); + } + } + + #[test] + fn metadata_rejects_inconsistent_versions() { + let root = super::super::unique_temp_dir("release_metadata").unwrap(); + fs::create_dir_all(root.join("crates/k-wiki")).unwrap(); + fs::write(root.join("Cargo.toml"), "version = \"2.0.0\"\n").unwrap(); + fs::write( + root.join("crates/k-wiki/Cargo.toml"), + "version = \"2.0.0\"\ncodebase-graph = { version = \"2.0.0\" }\n", + ) + .unwrap(); + fs::write( + root.join(".release-please-manifest.json"), + r#"{".":"2.0.1"}"#, + ) + .unwrap(); + assert!(read_metadata(&root, &"a".repeat(40)) + .unwrap_err() + .contains("versions must agree")); + fs::write( + root.join(".release-please-manifest.json"), + r#"{".":"2.0.0"}"#, + ) + .unwrap(); + fs::write(root.join("CHANGELOG.md"), "## 2.0.0\nverified\n").unwrap(); + assert_eq!( + read_metadata(&root, &"a".repeat(40)).unwrap().tag_name, + "v2.0.0" + ); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/crates/xtask/src/release/workflow_tests.rs b/crates/xtask/src/release/workflow_tests.rs new file mode 100644 index 0000000..04be89f --- /dev/null +++ b/crates/xtask/src/release/workflow_tests.rs @@ -0,0 +1,306 @@ +//! Execute the actual workflow Bash with a closed, local GitHub API fixture. +use serde_json::{json, Value}; +use std::{ + fs, + os::unix::fs::PermissionsExt, + path::PathBuf, + process::{Command, Output}, +}; + +const SHA: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const MAIN: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +struct Fixture { + root: PathBuf, + run: Value, + pulls: Value, +} + +impl Fixture { + fn new() -> Self { + let root = crate::unique_temp_dir("release_workflow").unwrap(); + fs::create_dir_all(root.join("bin")).unwrap(); + fs::create_dir_all(root.join("dist/metadata")).unwrap(); + fs::write( + root.join("bin/gh"), + r##"#!/bin/bash +set -euo pipefail +echo "$*" >> "$RUNNER_TEMP/calls" +case "$*" in + *"actions/runs/42") printf '%s' "$MOCK_RUN" ;; + *"git/ref/heads/main"*) printf '%s' "$MOCK_MAIN" ;; + *"compare/"*) printf '%s' "$MOCK_COMPARISON" ;; + *"/commits/"*"/pulls?per_page=100") printf '%s' "$MOCK_PULLS" ;; + *"contents/.release-please-manifest.json?"*) printf 'eyIuIjoiMi4wLjAifQ==' ;; + *"actions/workflows/ci.yml/runs?"*) printf '%s' "$MOCK_PROPOSAL_RUNS" ;; + *"pulls?state=closed"*) printf '%s' "$MOCK_PENDING_PULLS" ;; + *"--method POST"*"git/refs"*) + [[ "$*" == *"sha=$SOURCE_SHA"* ]] || exit 96 + jq -n --arg sha "$SOURCE_SHA" '{object:{type:"commit",sha:$sha}}' > "$RUNNER_TEMP/ref" + cat "$RUNNER_TEMP/ref" ;; + *"git/ref/tags/v2.0.0") + if [[ -e "$RUNNER_TEMP/ref" ]]; then cat "$RUNNER_TEMP/ref" + elif [[ "$MOCK_REF" != 'null' ]]; then printf '%s' "$MOCK_REF" + else printf '{"status":"404"}'; exit 1; fi ;; + *"--method POST"*"/releases --input "*) + cp "${!#}" "$RUNNER_TEMP/request" + printf '{"tag_name":"v2.0.0","draft":false,"prerelease":false}' > "$RUNNER_TEMP/release" + cat "$RUNNER_TEMP/release" ;; + *"releases/tags/v2.0.0") + if [[ -e "$RUNNER_TEMP/release" ]]; then cat "$RUNNER_TEMP/release" + else printf '{"status":"404"}'; exit 1; fi ;; + *"releases?per_page=100") printf '%s' "$MOCK_RELEASES" ;; + *) echo "Unexpected GitHub request: $*" >&2; exit 97 ;; +esac +"##, + ) + .unwrap(); + fs::set_permissions(root.join("bin/gh"), fs::Permissions::from_mode(0o755)).unwrap(); + fs::write(root.join("dist/metadata/release-metadata.json"), json!({ + "source_sha":SHA,"tag_name":"v2.0.0","version":"2.0.0","notes":"## 2.0.0\nVerified notes, not $(touch injected)." + }).to_string()).unwrap(); + Self { + root, + run: json!({"id":42,"repository":{"full_name":"owner/repo"},"path":".github/workflows/ci.yml","event":"push","head_branch":"main","status":"completed","conclusion":"success","head_sha":SHA}), + pulls: json!([[{ + "number":124,"merged_at":"2026-09-23","merge_commit_sha":SHA, + "base":{"ref":"main","repo":{"full_name":"owner/repo"}}, + "head":{"ref":"release-please--branches--main--components--codebase-graph","repo":{"full_name":"owner/repo"}}, + "labels":[{"name":"autorelease: pending"}] + }]]), + } + } + + fn execute(&self, script: &str, overrides: &[(&str, &str)]) -> Output { + fs::write(self.root.join("output"), "").unwrap(); + let mut command = Command::new("bash"); + command.args(["-c",script]).current_dir(&self.root).envs([ + ("PATH",format!("{}:{}",self.root.join("bin").display(),std::env::var("PATH").unwrap())), + ("GITHUB_REPOSITORY","owner/repo".into()),("CI_RUN_ID","42".into()), + ("EXPECTED_SHA",SHA.into()),("REQUIRE_RELEASE","true".into()), + ("MOCK_RUN",self.run.to_string()),("MOCK_PULLS",self.pulls.to_string()), + ("MOCK_MAIN",MAIN.into()),("MOCK_COMPARISON","ahead".into()), + ("MOCK_REF","null".into()),("MOCK_RELEASES","[]".into()), + ("MOCK_PENDING_PULLS",self.pulls.to_string()), + ("MOCK_PROPOSAL_RUNS",json!([{"workflow_runs":[{"path":".github/workflows/ci.yml","head_sha":MAIN,"head_branch":"main","event":"push","status":"completed","conclusion":"success"}]}]).to_string()), + ("SOURCE_SHA",SHA.into()),("RELEASE_TAG","v2.0.0".into()), + ("RELEASE_PR","124".into()),("VERIFIED_PR","124".into()), + ("RUNNER_TEMP",self.root.display().to_string()), + ("GITHUB_OUTPUT",self.root.join("output").display().to_string()), + ("GITHUB_STEP_SUMMARY",self.root.join("summary").display().to_string()), + ]).env_remove("GH_TOKEN").env_remove("GITHUB_TOKEN"); + command.envs(overrides.iter().copied()).output().unwrap() + } + + fn output(&self) -> String { + fs::read_to_string(self.root.join("output")).unwrap() + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } +} + +fn guard() -> String { + let action: yaml_serde::Value = yaml_serde::from_str(include_str!( + "../../../../.github/actions/verified-release/action.yml" + )) + .unwrap(); + action["runs"]["steps"][0]["run"].as_str().unwrap().into() +} + +fn step(job: &str, name: &str) -> String { + let workflow: yaml_serde::Value = + yaml_serde::from_str(include_str!("../../../../.github/workflows/release.yml")).unwrap(); + workflow["jobs"][job]["steps"] + .as_sequence() + .unwrap() + .iter() + .find(|s| s["name"].as_str() == Some(name)) + .unwrap()["run"] + .as_str() + .unwrap() + .into() +} + +fn assert_success(output: &Output) { + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn verified_release_survives_main_advancing_and_ignores_other_pending_releases() { + let mut f = Fixture::new(); + let mut other = f.pulls[0][0].clone(); + other["number"] = json!(125); + other["merge_commit_sha"] = json!(MAIN); + f.pulls[0].as_array_mut().unwrap().push(other); + assert_success(&f.execute(&guard(), &[])); + assert!(f.output().contains("release-pr=124\n")); + assert!(f.output().contains(&format!("source-sha={SHA}\n"))); +} + +#[test] +fn verified_release_rejects_failed_ci_and_untrusted_run_identity() { + for (key, value) in [ + ("conclusion", json!("failure")), + ("status", json!("in_progress")), + ("event", json!("pull_request")), + ("head_branch", json!("feature")), + ("path", json!(".github/workflows/other.yml")), + ("id", json!(43)), + ("repository", json!({"full_name":"fork/repo"})), + ] { + let mut f = Fixture::new(); + f.run[key] = value; + assert!(!f.execute(&guard(), &[]).status.success(), "{key}"); + } +} + +#[test] +fn verified_release_rejects_untrusted_prs_and_removed_history() { + for (path, value) in [ + ("/merge_commit_sha", json!(MAIN)), + ("/merged_at", Value::Null), + ("/base/ref", json!("other")), + ("/base/repo/full_name", json!("fork/repo")), + ("/head/repo/full_name", json!("fork/repo")), + ("/head/ref", json!("feature")), + ("/labels", json!([])), + ] { + let mut f = Fixture::new(); + *f.pulls[0][0].pointer_mut(path).unwrap() = value; + assert!(!f.execute(&guard(), &[]).status.success(), "{path}"); + } + let f = Fixture::new(); + assert!(!f + .execute(&guard(), &[("MOCK_COMPARISON", "diverged")]) + .status + .success()); + assert!(!f + .execute(&guard(), &[("EXPECTED_SHA", MAIN)]) + .status + .success()); +} + +#[test] +fn ordinary_commit_cannot_authorize_release_and_tagged_retry_still_can() { + let mut f = Fixture::new(); + f.pulls = json!([[]]); + assert!(!f.execute(&guard(), &[]).status.success()); + assert_success(&f.execute(&guard(), &[("REQUIRE_RELEASE", "false")])); + assert!(f.output().contains("release-pr=\n")); + let mut retry = Fixture::new(); + retry.pulls[0][0]["labels"] = json!([{"name":"autorelease: tagged"}]); + assert_success(&retry.execute(&guard(), &[])); +} + +#[test] +fn publisher_targets_original_sha_and_is_idempotent() { + let f = Fixture::new(); + let script = step("publish-release-assets", "Create only the verified release"); + assert_success(&f.execute(&script, &[])); + let request: Value = + serde_json::from_slice(&fs::read(f.root.join("request")).unwrap()).unwrap(); + assert_eq!(request["target_commitish"], SHA); + assert_eq!(request["tag_name"], "v2.0.0"); + assert_eq!(request["make_latest"], "true"); + assert!(!f.root.join("injected").exists()); + fs::write(f.root.join("calls"), "").unwrap(); + assert_success(&f.execute(&script, &[])); + assert!(!fs::read_to_string(f.root.join("calls")) + .unwrap() + .contains("--method POST")); +} + +#[test] +fn publisher_rejects_conflicting_tags_and_does_not_regress_latest() { + let f = Fixture::new(); + let script = step("publish-release-assets", "Create only the verified release"); + let wrong = json!({"object":{"sha":MAIN,"type":"commit"}}).to_string(); + assert!(!f.execute(&script, &[("MOCK_REF", &wrong)]).status.success()); + assert!(!f.root.join("request").exists()); + assert_success(&f.execute( + &script, + &[( + "MOCK_RELEASES", + r#"[[{"tag_name":"v3.0.0","draft":false,"prerelease":false}]]"#, + )], + )); + let request: Value = + serde_json::from_slice(&fs::read(f.root.join("request")).unwrap()).unwrap(); + assert_eq!(request["make_latest"], "false"); +} + +#[test] +fn proposal_reports_pending_release_then_proceeds_after_finalization() { + let f = Fixture::new(); + let script = step( + "release-please", + "Verify current main CI and detect blocked proposals", + ); + let blocked = f.execute(&script, &[]); + assert!(!blocked.status.success()); + assert!(String::from_utf8_lossy(&blocked.stdout).contains("resume-ci-run")); + let mut tagged = f.pulls.clone(); + tagged[0][0]["labels"] = json!([{"name":"autorelease: tagged"}]); + assert_success(&f.execute(&script, &[("MOCK_PENDING_PULLS", &tagged.to_string())])); + assert!(f.output().contains("ready=true")); + assert_success(&f.execute(&script, &[("MOCK_PROPOSAL_RUNS", "[]")])); + assert!(!f.output().contains("ready=true")); +} + +#[test] +fn ci_run_recovery_dry_run_validates_without_authorizing_publication() { + let f = Fixture::new(); + let script = step("release-target", "Resolve tag and source SHA"); + let inputs = [ + ("GITHUB_EVENT_NAME", "workflow_dispatch"), + ("MANUAL_TAG", ""), + ("MANUAL_SOURCE", "promote"), + ("MANUAL_DRY_RUN", "true"), + ("VERIFIED_SHA", SHA), + ]; + assert_success(&f.execute(&script, &inputs)); + let output = f.output(); + assert!(output.contains("should-publish=true\n")); + assert!(output.contains("automatic=true\n")); + assert!(output.contains("publish_assets=false\n")); + assert!(output.contains("ci-run-id=42\n")); + assert!(!fs::read_to_string(f.root.join("calls")) + .unwrap() + .contains("--method POST")); + let mut publishing = inputs; + publishing[3] = ("MANUAL_DRY_RUN", "false"); + assert_success(&f.execute(&script, &publishing)); + assert!(f.output().contains("publish_assets=true\n")); +} + +#[test] +fn recovery_requires_one_target_and_forbids_ci_run_rebuilds() { + let f = Fixture::new(); + let script = step("release-target", "Validate recovery inputs"); + for (tag, run, source, allowed) in [ + ("", "", "promote", false), + ("v2.0.0", "42", "promote", false), + ("", "42", "rebuild-if-missing", false), + ("", "42", "promote", true), + ("v2.0.0", "", "rebuild-if-missing", true), + ] { + let result = f.execute( + &script, + &[ + ("GITHUB_EVENT_NAME", "workflow_dispatch"), + ("MANUAL_TAG", tag), + ("RESUME_CI_RUN", run), + ("MANUAL_SOURCE", source), + ], + ); + assert_eq!(result.status.success(), allowed, "{tag}/{run}/{source}"); + } +} diff --git a/docs/release.md b/docs/release.md index 6754815..b8e1adf 100644 --- a/docs/release.md +++ b/docs/release.md @@ -1,14 +1,14 @@ # Release Process -`codebaseGraph` releases are managed by release-please. Main-branch CI builds and smoke-tests the complete native -archives. The Release workflow starts only after that entire CI workflow completes. A successful current-tip `main` -push lets release-please create or update its release pull request; merging that pull request creates a strict `vX.Y.Z` -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. 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. +Release-please manages version pull requests and changelogs. Publication is bound to the successful `main` CI run +for the exact release-PR merge commit. Subsequent merges do not invalidate that release while its commit remains +on `main`. The workflow validates all four retained native artifacts before creating its tag and GitHub Release, +then publishes the crate from the same source SHA. Release-please itself always runs with tag creation disabled. + +Failed CI, ordinary commits, ambiguous release PRs, and commits removed from main history cannot authorize publication. +After native assets and the crate succeed, the specific release PR is marked tagged and proposal maintenance resumes. +An outstanding merged pending-release PR is reported as a blocked workflow with its recovery instruction, not a silent +successful no-op. All automatic and manual runs serialize in `release-main`, with cancellation disabled and `queue: max`. ## One-Time Setup @@ -23,6 +23,12 @@ Set these `cargo` environment variables to `true` only after the corresponding o Add a `CARGO_REGISTRY_TOKEN` secret with permission to publish the `codebase-graph` crate. +The publisher uses `GITHUB_TOKEN` unless `RELEASE_PUBLISH_TOKEN` is configured in the `cargo` environment. +Historical tags whose workflow files differ from current main may require a repository-scoped token with Contents and +Workflows write permissions. The workflow fails clearly if GitHub refuses the exact tag; it never substitutes a newer +commit. An owner can instead create the exact verified tag/release after a successful recovery dry-run, then resume +asset and crate publication. Never copy a local CLI login token into repository secrets as part of recovery. + ## CI Pull requests targeting `main` and pushes to `main` run: @@ -43,22 +49,17 @@ Pull requests targeting `main` and pushes to `main` run: ## Release Flow -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 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 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. The upload uses bounded backoff and checks the exact immutable version before and after failures, so - a transient registry error or a lost success response can be retried safely. Manual recovery never publishes the - crate. +1. Merge ordinary PRs into `main`. Once current-main CI succeeds, release-please creates or updates the release PR. +2. Review and merge that release PR. Its merge commit must pass the entire `CI` push workflow. +3. Release verifies the run's repository, workflow path, event, branch, status, SHA, and exact trusted release PR identity. + The merge SHA must still be in main history; it need not be the latest main commit. +4. Immutable metadata must agree across the root package, wiki package/dependency, release manifest, and changelog. + Production checks and all four native artifacts are validated before any tag or release is created. +5. The single publisher creates only `vX.Y.Z` at that SHA, uploads the validated archives, and publishes the crate. + Existing matching tags/releases can be resumed. Conflicting tags fail without being moved. Delayed older versions + do not replace newer versions as GitHub's latest release. +6. After both publishers succeed, mark that PR `autorelease: tagged`, remove `autorelease: pending`, and run proposal + maintenance if current main has successful CI. Ordinary runs never tag an outstanding release as a side effect. Cargo's package verification compiles the extracted source package with the `dev` profile by default. That compile is not a distributed binary. The native GitHub Release archives are built separately with `cargo build --release`, and @@ -76,7 +77,7 @@ and provenance contract. Before publishing a production release, confirm: -- The exact tagged commit is the current `main` tip and matches the completed successful `ci.yml` push run that triggered +- The exact tagged commit remains in `main` history and matches the completed successful `ci.yml` push run that triggered Release, including Rust tests, formatting, linting, native package builds, advisory scanning, package dry-run, and artifact smoke. - Native Rust CLI and MCP entrypoints are required in production artifacts. @@ -123,16 +124,24 @@ commit SHA, version, and target. `provenance.json` is validation metadata and is ## Manual validation and recovery -Run the `Release` workflow manually with an existing strict tag: +Dispatch `Release` on `main` with exactly one target: + +- `resume-ci-run`: the successful main-push CI run of a trusted release PR merge. This can create a missing tag/release + and resumes both native assets and crates.io. It reuses the exact CI run and requires `artifact-source: promote`. +- `publish-existing-tag`: an existing strict `vX.Y.Z` tag. This legacy mode publishes native assets only and searches + for successful main-push CI at its exact SHA. It never publishes a crate or changes release PR labels. + +`dry-run` defaults to `true`. It resolves identity, runs the production gate, acquires artifacts, and validates the full +archive/checksum/provenance set without creating tags, releases, labels, proposals, or publishing crates. After checking +the successful dry-run, dispatch the same target with `dry-run: false` to publish. -- `artifact-source: promote` requires all four exact-SHA CI artifacts to remain available. -- `artifact-source: rebuild-if-missing` rebuilds **all four** targets through the same native workflow when any retained - artifact is missing or expired. It never mixes promoted and rebuilt targets. -- `dry-run: true` performs exact-SHA gating, promotion or recovery, archive/checksum/provenance validation, extraction, - and smoke checks without modifying a GitHub Release or publishing Cargo. +For existing-tag recovery, `artifact-source: rebuild-if-missing` rebuilds **all four** targets with the shared native +workflow if any retained artifact is unavailable. It never mixes promoted and rebuilt artifacts. Automatic and CI-run +recovery never substitute a run or rebuild missing artifacts. -Every manual mode still searches for and requires successful CI for the exact tag commit. Automatic mode never searches -for a substitute run: it validates and consumes the triggering run directly. Use dry-run first when exercising recovery. +To recover a blocked release, locate its merge SHA and successful `CI` run, dispatch a `resume-ci-run` dry-run, then +publish that exact target. Do not clear the pending label to bypass the block. Once finalization succeeds, later merged +changes can enter the next release PR. Repeating the same target is safe after a partial upload or lost response. The packaged installer validates both binaries against `checksums.txt`, runs `codebase-graph --help` plus `k-wiki --version`, and only then atomically diff --git a/knowledge/architecture/release-verification.md b/knowledge/architecture/release-verification.md index 3826291..7a3b9da 100644 --- a/knowledge/architecture/release-verification.md +++ b/knowledge/architecture/release-verification.md @@ -1,5 +1,5 @@ --- -description: Build-once promotion, CI-completion release orchestration, exact-run gating, and manual recovery. +description: Exact-commit publication, retained CI artifact promotion, proposal maintenance, and verified recovery. resource: repository-architecture tags: - architecture @@ -7,7 +7,7 @@ tags: - ci - provenance - release -timestamp: 2026-08-24 +timestamp: 2026-09-24 title: Native Release Verification type: architecture --- @@ -40,15 +40,19 @@ 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. 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. +The Release workflow starts after successful main-push CI completes. It binds publication to the triggering run ID and SHA and revalidates the repository, workflow path, event, branch, status, and conclusion through GitHub. Exactly one repository-owned release-please PR must have that exact merge SHA, target main, and carry the pending or tagged release label. The commit must remain in main history; subsequent ordinary merges do not invalidate its release. Failed, unrelated, ambiguous, or removed commits cannot authorize publication. -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-please only maintains version proposals and always has tag creation disabled. The targeted publisher handles one verified release, after production and complete-artifact validation. It derives version and release notes from the immutable checkout; the root package, wiki package and dependency, release manifest, and changelog must agree. Existing matching tags/releases can be retried, conflicting tags fail without being moved, and delayed older versions cannot replace newer versions as latest. + +Only after native assets and the crate succeed does finalization mark the selected PR tagged. Proposal maintenance requires successful current-main CI and explicitly fails with recovery instructions when merged pending release PRs still block the sequence. It never silently tags those other releases. Automatic and manual runs share the non-cancelling release-main concurrency group with queue: max, avoiding replacement of pending release work. + +The workflow implementation checkout uses github.workflow_sha; source identity always comes from verified CI or an existing tag. CI-run recovery can therefore validate old source commits using the current metadata helper. Artifact building and source package checks still use the immutable release source. ## Release promotion Automatic publication downloads all four retained internal artifacts from the exact CI run that triggered Release. A single validation job verifies target completeness, provenance, versions, digests, extraction, installers, and packaged behavior. Missing or expired artifacts stop automatic publication. -Only the single asset publisher receives `contents: write`; it uploads the already validated complete set. Crate publication is automatic-release-only, remains protected by the `cargo` environment, and starts after native asset publication succeeds. The environment restricts deployments to `main` but has no required reviewers, so publication remains unattended. +The single asset publisher creates the exact tag and GitHub Release and uploads the validated complete set. Crate publication is allowed for automatic releases and explicit CI-run recovery, remains protected by the `cargo` environment, and starts after native asset publication succeeds. Existing-tag manual recovery remains native-assets-only. The environment restricts deployments to `main` but has no required reviewers, so publication remains unattended. Crate upload is bounded and registry-aware. The publisher checks whether the exact immutable version already exists before uploading, retries transient failures with backoff, and checks again after every failed response so an accepted upload with a lost response is treated as success. Cargo's package verification may compile the extracted source package with the `dev` profile; that is not a distributed binary. Native release archives remain separate `--release` builds produced and smoked by the artifact contract. @@ -56,14 +60,14 @@ The compressed crates.io source package must not exceed 10 MiB. CI and Release v ## Recovery and dry-run -Manual dispatch always requires an existing tag and exact-SHA successful CI. Unlike automatic mode, manual recovery may search for the successful CI run for that tag SHA and wait for a concurrently running match for a bounded period: +Manual dispatch on main requires exactly one target: + +- resume-ci-run selects the release merge's own successful CI run, requires promotion of its retained artifacts, and can resume both native assets and crates.io, including creating a missing tag/release. +- publish-existing-tag selects an existing strict tag, locates exact-SHA successful main-push CI, and republishes native assets only. It retains manual-only rebuild-if-missing, which rebuilds all four targets without mixing artifact sources. -- `promote` fails if any retained artifact is unavailable. -- `rebuild-if-missing` is manual-only and rebuilds all four targets through the same reusable workflow. Promoted and rebuilt targets are never mixed. -- `dry-run` performs resolution, gating, artifact acquisition, validation, extraction, and smoke checks without modifying a GitHub release or publishing crates. -- Manual execution never publishes a crate. +Dry-run defaults to true and executes identity, production, and artifact checks without creating tags, releases, labels, proposals, or publishing crates. Dispatch the same target with dry-run false only after validation. Automatic and CI-run recovery never substitute a run or rebuild missing artifacts. Complete recovery finalizes the release PR before the next proposal is maintained; do not remove a pending label merely to suppress release-please's outstanding-release warning. -Manual release concurrency is scoped to the tag with cancellation disabled so duplicate attempts cannot race publication. +The asset publisher prefers the cargo environment's optional RELEASE_PUBLISH_TOKEN, falling back to GITHUB_TOKEN. Historical targets that differ from current main's workflow files may require a repository-scoped token with Contents and Workflows write permissions. If no such token is configured, an owner can create the exact tag/release after a successful dry-run and then resume the same target. Never copy a local CLI login credential into Actions secrets as a recovery shortcut. ## Change discipline diff --git a/knowledge/memory/episodic/successful-release-merge-starved-by-main-tip-guard-2026-09-23.md b/knowledge/memory/episodic/successful-release-merge-starved-by-main-tip-guard-2026-09-23.md new file mode 100644 index 0000000..647ad34 --- /dev/null +++ b/knowledge/memory/episodic/successful-release-merge-starved-by-main-tip-guard-2026-09-23.md @@ -0,0 +1,47 @@ +--- +agent_memory: + version: 1 + kind: episodic + scope: repository + status: active + owner: codex + created_at: 2026-09-23T06:00:00Z + last_verified_at: 2026-09-23T06:00:00Z + verified_by: codex + review_after: null + supersedes: [] + superseded_by: null + sources: + - kind: ci-log + reference: https://github.com/rabii-chaarani/codebaseGraph/actions/runs/35820576114/job/107057232758 + content_hash: null + - kind: ci-run + reference: https://github.com/rabii-chaarani/codebaseGraph/actions/runs/35818714588 + content_hash: null + - kind: source + reference: https://github.com/rabii-chaarani/codebaseGraph/blob/8c3bcf317d2bc8e5d803ee59b8f86678c1a55a1c/.github/workflows/release.yml + content_hash: null + - kind: documentation + reference: knowledge/architecture/release-verification.md + content_hash: null + history: + - from: candidate + to: active + actor: codex + at: 2026-09-23T06:00:00Z + reason: Reviewed against both GitHub Actions runs, the release job's exact skip log, release PR and descendant commit metadata, and the workflow at 8c3bcf3. Local workflow Git blob hash matches the immutable remote revision; all three tip guards and manual crate exclusion verified. +description: The current-tip publication policy can skip an otherwise verified release permanently; later ordinary CI and existing-tag recovery do not complete it. +tags: +- ci +- freshness +- release +- workflow-run +timestamp: 2026-09-23T06:00:00Z +title: A successful release merge can be stranded when main advances during CI +type: agent-memory +--- +Release run 35820576114 was triggered by successful main-push CI run 35818714588 for release PR #124 (2.0.0, c32f6ae3e2cf51f25278ceb3902e35c1cc1c9ac8). PR #123 advanced main to 8c3bcf317d2bc8e5d803ee59b8f86678c1a55a1c before that CI completed. The initial main-tip guard exited successfully with current-tip=false, so release-please and all publishers were skipped. This is a liveness limitation of the authored current-tip policy, not a CI failure or artifact failure. + +Later ordinary commits run release-please with tag creation disabled, so their successful CI cannot recover the pending release. Retrying the same stale Release logic also cannot help. Current-tip equality is enforced in three places: before release-please, after release-please, and in the automatic exact-SHA CI gate; changing only the first check is insufficient. Existing manual dispatch requires an existing tag and never publishes the crate. + +When investigating this pattern, distinguish a release merge whose own exact CI succeeded from an older release merge whose CI failed. Any proposed relaxation must retain exact merge identity, successful main-push CI, tag/SHA equality and same-run artifact provenance. Allowing publication after main advances would change the current documented architectural policy and has not been implemented by this investigation. \ No newline at end of file diff --git a/knowledge/memory/episodic/untagged-release-pr-blocks-later-proposals-2026-09-24.md b/knowledge/memory/episodic/untagged-release-pr-blocks-later-proposals-2026-09-24.md new file mode 100644 index 0000000..dec2a54 --- /dev/null +++ b/knowledge/memory/episodic/untagged-release-pr-blocks-later-proposals-2026-09-24.md @@ -0,0 +1,45 @@ +--- +agent_memory: + version: 1 + kind: episodic + scope: repository + status: active + owner: codex + created_at: 2026-09-24T03:29:06Z + last_verified_at: 2026-09-24T03:29:06Z + verified_by: codex + review_after: null + supersedes: [] + superseded_by: null + sources: + - kind: ci-log + reference: https://github.com/rabii-chaarani/codebaseGraph/actions/runs/35823493116 + content_hash: null + - kind: ci-log + reference: https://github.com/rabii-chaarani/codebaseGraph/actions/runs/35826294802 + content_hash: null + - kind: ci-log + reference: https://github.com/rabii-chaarani/codebaseGraph/actions/runs/35949576180 + content_hash: null + - kind: pull-request + reference: https://github.com/rabii-chaarani/codebaseGraph/pull/124 + content_hash: null + history: + - from: candidate + to: active + actor: codex + at: 2026-09-24T03:29:06Z + reason: 'Verified the identical outstanding-untagged-PR warning in three subsequent successful Release runs, successful originating CI runs, and PR #124''s current pending label.' +description: After a release is skipped, later green ordinary commits cannot even create the next release proposal until the outstanding release is resolved. +tags: +- ci +- failure-handling +- release +- release-please +timestamp: 2026-09-24T03:29:06Z +title: An untagged merged release PR blocks subsequent release proposals +type: agent-memory +--- +Release-please refuses to build a new release PR while a merged release PR remains untagged. After version 2.0.0 release PR #124 was skipped by the current-tip guard, later successful main CI for PRs #123, #114, and #125 reached release-please with skip-github-release=true. In all three Release logs it found PR #124 and emitted 'There are untagged, merged release PRs outstanding - aborting'. The workflows still completed successfully with no release-created output. + +The resulting block has two parts: ordinary runs cannot tag the older release, and release-please will not propose a subsequent release while that older merged PR has autorelease: pending. Preventing the original freshness race alone does not clear an already blocked repository. Recovery must explicitly resolve the outstanding release and preserve its exact-SHA CI provenance; do not remove the pending label or mark it tagged merely to silence the warning. Add a regression covering subsequent proposal creation after successful publication, and report outstanding merged releases clearly instead of presenting the run as a successful publication. \ No newline at end of file