From dc93b880bdd9dcdd967d376430024903c0d5b1bc Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Sat, 3 Oct 2026 23:35:26 +0200 Subject: [PATCH 1/7] =?UTF-8?q?=F0=9F=91=B7=20make=20release=20finalizatio?= =?UTF-8?q?n=20recoverable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow maintainers to resume OCI asset attachment from the original verified artifact after NuGet publication. Keep recovery from rebuilding or republishing immutable products, and leave GitHub Release publication as a human decision. --- .github/workflows/release.yml | 356 +++++++++++++++++++++------------- 1 file changed, 223 insertions(+), 133 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6e5789ce..b85d793a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,12 +3,22 @@ on: push: tags: - 'v*' + workflow_dispatch: + inputs: + tag: + type: string + description: Existing release tag to recover, e.g. v10.8.0. + required: true + source_run_id: + type: string + description: GitHub Actions release run containing the already-built release artifacts. + required: true permissions: contents: read concurrency: - group: cuemon-release-${{ github.ref }} + group: cuemon-release-${{ inputs.tag || github.ref_name }} cancel-in-progress: false jobs: @@ -17,10 +27,13 @@ jobs: runs-on: ubuntu-26.04 permissions: contents: read + actions: read outputs: version: ${{ steps.validate.outputs.version }} tag: ${{ steps.validate.outputs.tag }} sha: ${{ steps.validate.outputs.sha }} + source_run_id: ${{ steps.validate.outputs.source_run_id }} + artifact_id: ${{ steps.validate.outputs.artifact_id }} steps: # git-checkout fetches full history and tags for MinVer and ancestry validation. - name: Checkout the triggering SHA @@ -34,12 +47,18 @@ jobs: env: RELEASE_REF: ${{ github.ref }} RELEASE_SHA: ${{ github.sha }} - RELEASE_TAG: ${{ github.ref_name }} + RELEASE_TAG: ${{ inputs.tag || github.ref_name }} + SOURCE_RUN_ID: ${{ inputs.source_run_id }} GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - if [[ "$RELEASE_REF" != "refs/tags/$RELEASE_TAG" ]]; then + if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then + if [[ "$RELEASE_REF" != "refs/heads/main" || ! "$SOURCE_RUN_ID" =~ ^[1-9][0-9]*$ ]]; then + echo "::error::Recovery must run from main with an existing numeric source_run_id." + exit 1 + fi + elif [[ "$GITHUB_EVENT_NAME" != "push" || "$RELEASE_REF" != "refs/tags/$RELEASE_TAG" ]]; then echo "::error::Release must be triggered by a Git tag; received '$RELEASE_REF'." exit 1 fi @@ -59,14 +78,16 @@ jobs: echo "::error::Triggering tag '$RELEASE_TAG' is missing or does not resolve to a Git commit." exit 1 fi - if ! event_sha="$(git rev-parse --verify "$RELEASE_SHA^{commit}" 2>/dev/null)"; then - echo "::error::Triggering SHA '$RELEASE_SHA' does not resolve to a Git commit." - exit 1 - fi - checked_out_sha="$(git rev-parse HEAD)" - if [[ "$tagged_sha" != "$event_sha" || "$checked_out_sha" != "$event_sha" ]]; then - echo "::error::Tag '$RELEASE_TAG' resolves to '$tagged_sha', checkout to '$checked_out_sha', and triggering commit to '$event_sha'. The tag may have moved; restore the original tag before retrying." - exit 1 + if [[ "$GITHUB_EVENT_NAME" == "push" ]]; then + if ! event_sha="$(git rev-parse --verify "$RELEASE_SHA^{commit}" 2>/dev/null)"; then + echo "::error::Triggering SHA '$RELEASE_SHA' does not resolve to a Git commit." + exit 1 + fi + checked_out_sha="$(git rev-parse HEAD)" + if [[ "$tagged_sha" != "$event_sha" || "$checked_out_sha" != "$event_sha" ]]; then + echo "::error::Tag '$RELEASE_TAG' resolves to '$tagged_sha', checkout to '$checked_out_sha', and triggering commit to '$event_sha'. The tag may have moved; restore the original tag before retrying." + exit 1 + fi fi RELEASE_SHA="$tagged_sha" @@ -79,23 +100,43 @@ jobs: exit 1 fi - set +e - release_json="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" 2>&1)" - release_status=$? - set -e - if [[ "$release_status" -eq 0 ]]; then - expected_prerelease=false - if [[ "$RELEASE_VERSION" == *-* ]]; then expected_prerelease=true; fi - if ! jq -e --arg tag "$RELEASE_TAG" --argjson prerelease "$expected_prerelease" \ - '.tag_name == $tag and .prerelease == $prerelease and (.draft | type == "boolean")' <<< "$release_json" > /dev/null; then - echo "::error::Existing GitHub Release conflicts with tag '$RELEASE_TAG' or its prerelease identity. Review the existing release before retrying." + if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then + source_run="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$SOURCE_RUN_ID")" + if ! jq -e --arg repo "$GITHUB_REPOSITORY" --arg tag "$RELEASE_TAG" --arg sha "$RELEASE_SHA" \ + '.repository.full_name == $repo and .head_repository.full_name == $repo and + .event == "push" and .path == ".github/workflows/release.yml" and + .head_branch == $tag and .head_sha == $sha' <<< "$source_run" >/dev/null; then + echo "::error::Source run '$SOURCE_RUN_ID' must be this repository's tag-push release.yml run for '$RELEASE_TAG' at '$RELEASE_SHA'. No artifacts will be consumed." exit 1 fi - echo "Compatible GitHub Release '$RELEASE_TAG' already exists; retaining its draft/published state." - elif [[ "$release_json" != *"HTTP 404"* ]]; then - echo "::error::Could not check GitHub Release '$RELEASE_TAG'." - echo "$release_json" - exit 1 + + source_jobs="$(gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/actions/runs/$SOURCE_RUN_ID/jobs?per_page=100")" + if ! jq -e --arg prefix "Publish NuGet v$RELEASE_VERSION /" ' + [.[].jobs[]] as $jobs | + ([$jobs[] | select(.name | startswith($prefix))] | any(.conclusion == "success")) and + ([$jobs[] | select(.name | startswith($prefix))] | all(.conclusion == "success" or .conclusion == "skipped")) and + ($jobs | any(.name == "Validate release tag and authoritative main history" and .conclusion == "success")) and + ($jobs | any(.name == "Build multi-platform DocFX OCI artifact once" and .conclusion == "success")) + ' <<< "$source_jobs" >/dev/null; then + echo "::error::Source run '$SOURCE_RUN_ID' has no confirmed successful tag validation, NuGet publication and DocFX OCI build. This recovery only resumes asset attachment after those durable boundaries. Inspect the source jobs; do not republish NuGet." + exit 1 + fi + + source_artifacts="$(gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/actions/runs/$SOURCE_RUN_ID/artifacts?per_page=100")" + artifact_json="$(jq -c --arg name "DocFX-OCI-$RELEASE_VERSION" '[.[].artifacts[] | select(.name == $name)]' <<< "$source_artifacts")" + if ! jq -e --arg sha "$RELEASE_SHA" --arg tag "$RELEASE_TAG" --argjson run "$SOURCE_RUN_ID" \ + --argjson repo "$(jq '.repository.id' <<< "$source_run")" ' + length == 1 and (.[0] | .expired == false and + (.expires_at | fromdateiso8601) > now and (.digest | test("^sha256:[0-9a-f]{64}$")) and + .workflow_run.id == $run and .workflow_run.repository_id == $repo and + .workflow_run.head_repository_id == $repo and .workflow_run.head_branch == $tag and + .workflow_run.head_sha == $sha)' <<< "$artifact_json" >/dev/null; then + echo "::error::Expected unique, unexpired, SHA-256 identified DocFX-OCI-$RELEASE_VERSION artifact from run '$SOURCE_RUN_ID' for '$RELEASE_SHA'. Restore access to that exact artifact; recovery will not rebuild replacement bytes." + exit 1 + fi + echo "source_run_id=$SOURCE_RUN_ID" >> "$GITHUB_OUTPUT" + echo "artifact_id=$(jq -r '.[0].id' <<< "$artifact_json")" >> "$GITHUB_OUTPUT" + echo "NuGet v$RELEASE_VERSION: already published (source run confirmed); recovery will not invoke publication." fi { @@ -114,6 +155,7 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" release_packages: + if: ${{ github.event_name == 'push' }} name: Build and validate Release packages needs: [release_preflight] runs-on: ubuntu-26.04 @@ -174,6 +216,7 @@ jobs: retention-days: 30 publish_nuget: + if: ${{ github.event_name == 'push' }} name: Publish NuGet v${{ needs.release_preflight.outputs.version }} needs: [release_preflight, release_packages] uses: codebeltnet/jobs-nuget-push/.github/workflows/default.yml@v3 @@ -189,70 +232,83 @@ jobs: NUGET_TOKEN: ${{ secrets.NUGET_TOKEN }} draft_github_release: - name: Create draft GitHub Release for the existing tag - needs: [release_preflight, release_packages, publish_nuget] + if: ${{ !cancelled() && needs.release_preflight.result == 'success' && (github.event_name == 'workflow_dispatch' || needs.publish_nuget.result == 'success') }} + name: Resolve draft GitHub Release for the existing tag + needs: [release_preflight, publish_nuget] runs-on: ubuntu-26.04 permissions: contents: write + outputs: + release_id: ${{ steps.draft.outputs.release_id }} steps: - - name: Checkout the released SHA - uses: codebeltnet/git-checkout@v1 - with: - ref: ${{ needs.release_preflight.outputs.sha }} - - name: Verify the existing release tag still identifies the released SHA shell: bash env: + GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ needs.release_preflight.outputs.tag }} RELEASE_SHA: ${{ needs.release_preflight.outputs.sha }} run: | set -euo pipefail - if ! tagged_sha="$(git rev-parse --verify "refs/tags/$RELEASE_TAG^{commit}" 2>/dev/null)"; then - echo "::error::Release tag '$RELEASE_TAG' is missing or does not resolve to a commit; restore the original tag before retrying." - exit 1 - fi - if [[ "$tagged_sha" != "$RELEASE_SHA" ]]; then - echo "::error::Tag '$RELEASE_TAG' points to '$tagged_sha', not released SHA '$RELEASE_SHA'. Restore the original tag before retrying." + ref_json="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG")" + object_type="$(jq -r '.object.type' <<< "$ref_json")" + object_sha="$(jq -r '.object.sha' <<< "$ref_json")" + while [[ "$object_type" == "tag" ]]; do + tag_json="$(gh api "repos/$GITHUB_REPOSITORY/git/tags/$object_sha")" + object_type="$(jq -r '.object.type' <<< "$tag_json")" + object_sha="$(jq -r '.object.sha' <<< "$tag_json")" + done + if [[ "$object_type" != "commit" || "$object_sha" != "$RELEASE_SHA" ]]; then + echo "::error::Live tag '$RELEASE_TAG' does not identify released SHA '$RELEASE_SHA'. Restore the original tag before retrying." exit 1 fi - - name: Create or verify the draft GitHub Release + - id: draft + name: Create or resolve the draft GitHub Release ID shell: bash env: GH_TOKEN: ${{ github.token }} RELEASE_VERSION: ${{ needs.release_preflight.outputs.version }} RELEASE_TAG: ${{ needs.release_preflight.outputs.tag }} + RELEASE_SHA: ${{ needs.release_preflight.outputs.sha }} run: | set -euo pipefail - set +e - release_json="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" 2>&1)" - release_status=$? - set -e - - if [[ "$release_status" -eq 0 ]]; then - release_tag="$(jq -r '.tag_name' <<< "$release_json")" - if [[ "$release_tag" != "$RELEASE_TAG" ]]; then - echo "::error::GitHub returned release '$release_tag' for requested tag '$RELEASE_TAG'." - exit 1 - fi - echo "GitHub Release '$RELEASE_TAG' already exists; retaining its current draft/published state." - exit 0 + # Listing with contents:write includes drafts; tag-oriented lookup can return 404 for them. + releases="$(gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/releases?per_page=100")" + matches="$(jq -c --arg tag "$RELEASE_TAG" '[.[][] | select(.tag_name == $tag)]' <<< "$releases")" + count="$(jq 'length' <<< "$matches")" + if [[ "$count" -gt 1 ]]; then + echo "::error::Multiple GitHub Releases identify '$RELEASE_TAG'; resolve the ambiguity before recovery." + exit 1 fi - if [[ "$release_json" != *"HTTP 404"* ]]; then - echo "::error::Could not check GitHub Release '$RELEASE_TAG'." - echo "$release_json" + expected_prerelease=false + if [[ "$RELEASE_VERSION" == *-* ]]; then expected_prerelease=true; fi + if [[ "$count" -eq 1 ]]; then + release_json="$(jq -c '.[0]' <<< "$matches")" + elif [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then + echo "::error::Recovery requires the existing draft GitHub Release for '$RELEASE_TAG'. Restore that object; recovery will not create a replacement release." exit 1 + else + notes="$(gh api --method POST "repos/$GITHUB_REPOSITORY/releases/generate-notes" \ + -f tag_name="$RELEASE_TAG" -f target_commitish="$RELEASE_SHA" --jq '.body')" + # Capture the ID directly from creation, never rediscover the new draft by tag. + release_json="$(gh api --method POST "repos/$GITHUB_REPOSITORY/releases" \ + -f tag_name="$RELEASE_TAG" -f target_commitish="$RELEASE_SHA" \ + -f name="Cuemon $RELEASE_TAG" -f body="$notes" \ + -F draft=true -F prerelease="$expected_prerelease")" fi - release_args=(--draft --verify-tag --title "Cuemon $RELEASE_TAG" --generate-notes) - if [[ "$RELEASE_VERSION" == *-* ]]; then - release_args+=(--prerelease) + if ! jq -e --arg tag "$RELEASE_TAG" --argjson prerelease "$expected_prerelease" \ + '.tag_name == $tag and .draft == true and .prerelease == $prerelease and + (.id | type == "number" and . > 0)' <<< "$release_json" >/dev/null; then + echo "::error::GitHub Release must be a draft with tag '$RELEASE_TAG' and its expected prerelease identity. Published releases cannot be finalized by recovery." + exit 1 fi - gh release create "$RELEASE_TAG" "${release_args[@]}" + echo "release_id=$(jq -r '.id' <<< "$release_json")" >> "$GITHUB_OUTPUT" docfx_oci_build: + if: ${{ github.event_name == 'push' }} name: Build multi-platform DocFX OCI artifact once needs: [release_preflight, publish_nuget] runs-on: ubuntu-26.04 @@ -305,18 +361,32 @@ jobs: retention-days: 30 upload_docfx_release_asset: + if: ${{ !cancelled() && needs.release_preflight.result == 'success' && needs.draft_github_release.result == 'success' && (github.event_name == 'workflow_dispatch' || needs.docfx_oci_build.result == 'success') }} name: Attach the built DocFX OCI artifact to its release needs: [release_preflight, draft_github_release, docfx_oci_build] runs-on: ubuntu-26.04 permissions: contents: write + actions: read steps: - name: Download the already-built OCI artifact + if: ${{ github.event_name == 'push' }} uses: actions/download-artifact@v8 with: name: DocFX-OCI-${{ needs.release_preflight.outputs.version }} path: ${{ runner.temp }}/docfx-release-asset + - name: Recover the exact OCI artifact from the source release run + if: ${{ github.event_name == 'workflow_dispatch' }} + uses: actions/download-artifact@v8 + with: + artifact-ids: ${{ needs.release_preflight.outputs.artifact_id }} + run-id: ${{ needs.release_preflight.outputs.source_run_id }} + repository: ${{ github.repository }} + github-token: ${{ github.token }} + digest-mismatch: error + path: ${{ runner.temp }}/docfx-release-asset + - name: Revalidate transferred OCI artifact uses: codebeltnet/oci-artifact-verify@v1 with: @@ -331,76 +401,22 @@ jobs: GH_TOKEN: ${{ github.token }} RELEASE_VERSION: ${{ needs.release_preflight.outputs.version }} RELEASE_TAG: ${{ needs.release_preflight.outputs.tag }} + RELEASE_SHA: ${{ needs.release_preflight.outputs.sha }} + RELEASE_ID: ${{ needs.draft_github_release.outputs.release_id }} run: | set -euo pipefail archive_name="cuemon-docfx-$RELEASE_VERSION.oci.tar" checksum_name="$archive_name.sha256" - archive="$RUNNER_TEMP/docfx-release-asset/$archive_name" - checksum="$RUNNER_TEMP/docfx-release-asset/$checksum_name" existing_dir="$RUNNER_TEMP/existing-docfx-assets" mkdir -p "$existing_dir" - asset_names="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" --jq '.assets[].name')" - has_archive=false - has_checksum=false - if grep -Fxq "$archive_name" <<< "$asset_names"; then has_archive=true; fi - if grep -Fxq "$checksum_name" <<< "$asset_names"; then has_checksum=true; fi - - if [[ "$has_archive" == "true" ]]; then - gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --pattern "$archive_name" --dir "$existing_dir" - if ! cmp -s "$archive" "$existing_dir/$archive_name"; then - echo "::error::Release asset '$archive_name' already exists with different bytes; refusing to overwrite it." - exit 1 - fi - fi - - if [[ "$has_checksum" == "true" ]]; then - gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --pattern "$checksum_name" --dir "$existing_dir" - if ! cmp -s "$checksum" "$existing_dir/$checksum_name"; then - echo "::error::Release asset '$checksum_name' already exists with different bytes; refusing to overwrite it." - exit 1 - fi - fi - - if [[ "$has_archive" == "false" && "$has_checksum" == "false" ]]; then - gh release upload "$RELEASE_TAG" "$archive" "$checksum" --repo "$GITHUB_REPOSITORY" - elif [[ "$has_archive" == "true" && "$has_checksum" == "false" ]]; then - gh release upload "$RELEASE_TAG" "$checksum" --repo "$GITHUB_REPOSITORY" - elif [[ "$has_archive" == "false" && "$has_checksum" == "true" ]]; then - gh release upload "$RELEASE_TAG" "$archive" --repo "$GITHUB_REPOSITORY" - else - echo "The exact versioned OCI archive and checksum are already attached to '$RELEASE_TAG'." - fi - - publish_github_release: - name: Publish GitHub Release after the OCI asset is attached - needs: [release_preflight, upload_docfx_release_asset] - runs-on: ubuntu-26.04 - permissions: - contents: write - steps: - - name: Publish the versioned GitHub Release - shell: bash - env: - GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ needs.release_preflight.outputs.tag }} - RELEASE_SHA: ${{ needs.release_preflight.outputs.sha }} - run: | - set -euo pipefail - release_json="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG")" - release_id="$(jq -r '.id' <<< "$release_json")" - is_draft="$(jq -r '.draft' <<< "$release_json")" - if [[ "$is_draft" == "false" ]]; then - echo "GitHub Release '$RELEASE_TAG' is already published." - exit 0 - fi - if [[ "$is_draft" != "true" || ! "$release_id" =~ ^[0-9]+$ ]]; then - echo "::error::GitHub Release '$RELEASE_TAG' is not in a publishable draft state." + if [[ ! "$RELEASE_ID" =~ ^[1-9][0-9]*$ ]]; then + echo "::error::Missing immutable GitHub Release ID for '$RELEASE_TAG'." exit 1 fi - # Resolve the live remote tag immediately before publication, not the earlier checkout. + # Resolve the live tag before mutation; main's dispatch SHA is never product identity. ref_json="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG")" object_type="$(jq -r '.object.type' <<< "$ref_json")" object_sha="$(jq -r '.object.sha' <<< "$ref_json")" @@ -410,13 +426,49 @@ jobs: object_sha="$(jq -r '.object.sha' <<< "$tag_json")" done if [[ "$object_type" != "commit" || ! "$object_sha" =~ ^[0-9a-fA-F]{40}$ || "$object_sha" != "$RELEASE_SHA" ]]; then - echo "::error::Release tag '$RELEASE_TAG' no longer identifies built commit '$RELEASE_SHA'; refusing to publish the draft." + echo "::error::Release tag '$RELEASE_TAG' no longer identifies built commit '$RELEASE_SHA'; refusing to attach assets." exit 1 fi - gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$release_id" -F draft=false + release_json="$(gh api "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID")" + if ! jq -e --arg tag "$RELEASE_TAG" --argjson id "$RELEASE_ID" \ + '.id == $id and .tag_name == $tag and .draft == true' <<< "$release_json" >/dev/null; then + echo "::error::Release ID '$RELEASE_ID' must still identify the draft for '$RELEASE_TAG'." + exit 1 + fi + assets="$(gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?per_page=100")" + missing_assets=() + # Validate every existing asset before uploading anything. Never overwrite different bytes. + for asset_name in "$archive_name" "$checksum_name"; do + matches="$(jq -c --arg name "$asset_name" '[.[][] | select(.name == $name)]' <<< "$assets")" + count="$(jq 'length' <<< "$matches")" + if [[ "$count" -gt 1 ]]; then + echo "::error::Multiple assets named '$asset_name' exist on release ID '$RELEASE_ID'." + exit 1 + elif [[ "$count" -eq 1 ]]; then + asset_id="$(jq -r '.[0].id' <<< "$matches")" + gh api -H 'Accept: application/octet-stream' \ + "repos/$GITHUB_REPOSITORY/releases/assets/$asset_id" > "$existing_dir/$asset_name" + if ! cmp -s "$RUNNER_TEMP/docfx-release-asset/$asset_name" "$existing_dir/$asset_name"; then + echo "::error::Release asset '$asset_name' already exists with different bytes; refusing to overwrite it." + exit 1 + fi + echo "Exact asset '$asset_name' already attached to release ID '$RELEASE_ID'." + else + missing_assets+=("$asset_name") + fi + done + + for asset_name in "${missing_assets[@]}"; do + gh api --method POST \ + "https://uploads.github.com/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?name=$asset_name" \ + -H 'Content-Type: application/octet-stream' \ + --input "$RUNNER_TEMP/docfx-release-asset/$asset_name" > /dev/null + done + echo "OCI archive and checksum attached to draft release ID '$RELEASE_ID'; awaiting manual publication." prepare_release_tests: + if: ${{ github.event_name == 'push' }} name: Discover post-release test projects needs: [release_preflight, publish_nuget] runs-on: ubuntu-26.04 @@ -443,6 +495,7 @@ jobs: run: echo "${{ steps.test-projects.outputs.result }}" post_release_tests: + if: ${{ github.event_name == 'push' }} name: Post-release Release tests - ${{ matrix.project }} needs: [release_preflight, prepare_release_tests, publish_nuget] strategy: @@ -485,6 +538,7 @@ jobs: retention-days: 30 post_release_integration_test: + if: ${{ github.event_name == 'push' }} name: Post-release SQL Server integration test needs: [release_preflight, publish_nuget] runs-on: ubuntu-26.04 @@ -539,7 +593,7 @@ jobs: command: down post_release_sonarcloud: - if: ${{ always() && needs.publish_nuget.result == 'success' }} + if: ${{ always() && github.event_name == 'push' && needs.publish_nuget.result == 'success' }} name: Post-release SonarCloud analysis needs: [release_preflight, publish_nuget, prepare_release_tests, post_release_tests, post_release_integration_test] runs-on: ubuntu-26.04 @@ -581,7 +635,7 @@ jobs: token: ${{ secrets.SONAR_TOKEN }} post_release_codecov: - if: ${{ always() && needs.publish_nuget.result == 'success' }} + if: ${{ always() && github.event_name == 'push' && needs.publish_nuget.result == 'success' }} name: Post-release Codecov upload needs: [release_preflight, publish_nuget, prepare_release_tests, post_release_tests, post_release_integration_test] permissions: @@ -595,7 +649,7 @@ jobs: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} post_release_codeql: - if: ${{ always() && needs.publish_nuget.result == 'success' }} + if: ${{ always() && github.event_name == 'push' && needs.publish_nuget.result == 'success' }} name: Post-release CodeQL analysis needs: [release_preflight, publish_nuget, prepare_release_tests, post_release_tests, post_release_integration_test] runs-on: ubuntu-26.04 @@ -637,7 +691,6 @@ jobs: - draft_github_release - docfx_oci_build - upload_docfx_release_asset - - publish_github_release - prepare_release_tests - post_release_tests - post_release_integration_test @@ -655,12 +708,14 @@ jobs: RELEASE_VERSION: ${{ needs.release_preflight.outputs.version }} RELEASE_SHA: ${{ needs.release_preflight.outputs.sha }} RELEASE_TAG: ${{ needs.release_preflight.outputs.tag }} + RELEASE_ID: ${{ needs.draft_github_release.outputs.release_id }} + SOURCE_RUN_ID: ${{ inputs.source_run_id }} + REQUESTED_TAG: ${{ inputs.tag }} PACKAGE_BUILD_RESULT: ${{ needs.release_packages.result }} NUGET_RESULT: ${{ needs.publish_nuget.result }} DRAFT_RELEASE_RESULT: ${{ needs.draft_github_release.result }} DOCFX_BUILD_RESULT: ${{ needs.docfx_oci_build.result }} DOCFX_ASSET_RESULT: ${{ needs.upload_docfx_release_asset.result }} - GITHUB_RELEASE_RESULT: ${{ needs.publish_github_release.result }} TEST_DISCOVERY_RESULT: ${{ needs.prepare_release_tests.result }} TEST_RESULT: ${{ needs.post_release_tests.result }} INTEGRATION_RESULT: ${{ needs.post_release_integration_test.result }} @@ -670,6 +725,39 @@ jobs: run: | set -euo pipefail + if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then + { + echo "## Partial-release recovery status" + echo + echo "- Requested tag: \`${RELEASE_TAG:-$REQUESTED_TAG}\`" + echo "- Authoritative SHA: \`${RELEASE_SHA:-not validated}\`" + echo "- Source run: \`$SOURCE_RUN_ID\`" + echo "- Recovery validation: \`$PREFLIGHT_RESULT\`" + if [[ "$PREFLIGHT_RESULT" == "success" ]]; then + echo "- Git tag $RELEASE_TAG: valid" + echo "- NuGet v$RELEASE_VERSION: already published (source run confirmed); publication not invoked" + else + echo "- Existing product publication: not changed; source identity/publication not confirmed" + fi + if [[ "$DRAFT_RELEASE_RESULT" == "success" ]]; then + echo "- GitHub draft release: recovered (ID \`$RELEASE_ID\`)" + else + echo "- GitHub draft resolution: \`$DRAFT_RELEASE_RESULT\`" + fi + if [[ "$DOCFX_ASSET_RESULT" == "success" ]]; then + echo "- DocFX OCI artifact: recovered and verified from run $SOURCE_RUN_ID" + echo '- OCI assets: attached (exact existing assets accepted)' + echo '- Recovery: complete' + else + echo "- OCI recovery/attachment orchestration: \`$DOCFX_ASSET_RESULT\`" + echo '- Recovery incomplete; inspect the failed step. No immutable products were rebuilt or republished.' + fi + echo '- GitHub Release: manual publication required; recovery never publishes it' + echo '- Deployment: not started by this workflow' + } >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + { echo "## Cuemon release status" echo @@ -681,13 +769,15 @@ jobs: echo "- Draft GitHub Release: \`$DRAFT_RELEASE_RESULT\`" echo "- DocFX OCI build: \`$DOCFX_BUILD_RESULT\`" echo "- DocFX release asset: \`$DOCFX_ASSET_RESULT\`" - echo "- Published GitHub Release: \`$GITHUB_RELEASE_RESULT\`" + echo "- GitHub Release ID: \`${RELEASE_ID:-not resolved}\`" + echo '- GitHub Release: awaiting manual publication once draft assets and assurance are reviewed' + echo '- Deployment: not started by this workflow' echo } >> "$GITHUB_STEP_SUMMARY" if [[ "$PREFLIGHT_RESULT" != "success" ]]; then { - echo "No release was attempted because the triggering tag, SemVer, main ancestry or existing release identity failed preflight validation." + echo "No release was attempted because the triggering tag, SemVer or main ancestry failed preflight validation." } >> "$GITHUB_STEP_SUMMARY" elif [[ "$PACKAGE_BUILD_RESULT" != "success" ]]; then { @@ -707,11 +797,11 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" elif [[ "$DOCFX_BUILD_RESULT" != "success" || "$DOCFX_ASSET_RESULT" != "success" ]]; then { - echo "NuGet packages were published for the existing source tag, but the DocFX OCI artifact or release asset upload failed. GitHub Release publication waits for the verified OCI archive and checksum, so the draft remains unpublished." + echo "NuGet packages were published for the existing source tag, but DocFX finalization did not complete. If the DocFX-OCI-$RELEASE_VERSION Actions artifact was persisted, dispatch recovery from main with tag '$RELEASE_TAG' and source_run_id '$GITHUB_RUN_ID' to reuse those exact bytes. Do not replay NuGet publication. The GitHub Release awaits finalization and manual publication." } >> "$GITHUB_STEP_SUMMARY" - elif [[ "$GITHUB_RELEASE_RESULT" != "success" ]]; then + else { - echo "NuGet packages, the source tag and the OCI release assets are ready, but GitHub Release publication failed or could not be confirmed. Check whether the release is still a draft before retrying the idempotent publish job." + echo "NuGet packages are published and the verified OCI assets are attached to draft release ID '$RELEASE_ID'. Review/edit the draft and assurance results, then publish it manually to declare it deployable. Production environment approval remains a separate deployment decision." } >> "$GITHUB_STEP_SUMMARY" fi @@ -719,10 +809,10 @@ jobs: ( "$TEST_DISCOVERY_RESULT" != "success" || "$TEST_RESULT" != "success" || \ "$INTEGRATION_RESULT" != "success" || "$SONAR_RESULT" != "success" || \ "$CODECOV_RESULT" != "success" || "$CODEQL_RESULT" != "success" ) ]]; then - echo "::warning::Product release succeeded; post-release assurance failed." + echo "::warning::NuGet publication succeeded; post-release assurance did not complete successfully." { echo - echo "**Product release succeeded; post-release assurance failed.**" + echo "**NuGet publication succeeded; post-release assurance did not complete successfully.**" echo echo "Post-release test discovery: \`$TEST_DISCOVERY_RESULT\`; test matrix: \`$TEST_RESULT\`; SQL Server integration: \`$INTEGRATION_RESULT\`; SonarCloud: \`$SONAR_RESULT\`; Codecov: \`$CODECOV_RESULT\`; CodeQL: \`$CODEQL_RESULT\`." echo "The package and tag remain released. These findings do not roll back publication; resolve them against the recorded SHA." From 8d6e68a083e851f47515405554c1903f996c2b2a Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Sat, 3 Oct 2026 23:35:34 +0200 Subject: [PATCH 2/7] =?UTF-8?q?=F0=9F=92=AC=20clarify=20release=20recovery?= =?UTF-8?q?=20guidance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Explain how maintainers review draft releases and recover OCI asset attachment from the original Actions artifact. This keeps the human publication decision and separate deployment approval clear. --- .github/CONTRIBUTING.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 6756c67b..33f59ffe 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -75,9 +75,13 @@ Package-specific release notes live under `.nuget//PackageReleaseNo After PR validation and merge, a maintainer creates and pushes a `vX.Y.Z` tag (or `vX.Y.Z-prerelease`, without build metadata) for the intended commit in `main` history. The tag push starts `release.yml`, which checks the tag identity and ancestry, builds signed Release packages from that commit, validates their versions and existing NuGet content, and sends the validated package artifact to the protected `Production` publication job. -After NuGet publication, the workflow runs post-release tests and analysis and builds the multi-platform DocFX OCI image from the same commit. The verified archive and SHA-256 checksum are attached to a draft GitHub Release before that release is published. Post-release assurance failures do not roll back published packages; inspect the release summary and resolve failures against its recorded commit. +After NuGet publication, the workflow runs post-release tests and analysis and builds the multi-platform DocFX OCI image from the same commit. The verified archive and SHA-256 checksum are attached to a draft GitHub Release using its numeric release ID. The workflow leaves the release as a draft. A maintainer reviews/edits the release and assurance results, then presses **Publish** to declare it deployable. Post-release assurance failures do not roll back published packages; inspect the release summary and resolve failures against its recorded commit. -A published GitHub Release starts `deploy.yml`. To retry deployment, dispatch that workflow from `main` with the existing published release tag. Deployment requires the versioned OCI archive and checksum, resolves the tag to its source commit, and promotes the verified image to JCR through `Production` without rebuilding it. The workflow reports the immutable image digest for a Kubernetes handoff; this repository does not perform the Kubernetes rollout. +If OCI asset attachment fails after NuGet publication and the OCI build succeeded, dispatch `release.yml` from **main** with the existing `tag` and original `source_run_id`. It never rebuilds packages/images, invokes NuGet publication, reruns assurance, creates a replacement release, or publishes the draft. Missing, expired or unverifiable artifacts cause failure with diagnostics rather than replacement bytes. + +Recovery verifies the artifact's download digest, archive checksum, OCI version/revision and `linux/amd64`/`linux/arm64` coverage before attachment. Exact existing release assets are accepted; conflicting bytes fail before mutation, and only missing assets are uploaded. Recovery can be dispatched again after a partial upload. Fix orchestration on main and resume from the last durable successful boundary; do not replay immutable publication because later finalization failed. Actions artifacts are retained for 30 days, so recover while the source artifact is available. + +A human-published GitHub Release starts `deploy.yml`. To retry deployment, dispatch that workflow from `main` with the existing published release tag. Deployment requires the versioned OCI archive and checksum, resolves the tag to its source commit, and promotes the verified image to JCR through `Production` without rebuilding it. Publishing the GitHub Release and approving the Production environment are separate human decisions. The workflow reports the immutable image digest for a Kubernetes handoff; this repository does not perform the Kubernetes rollout. If publication fails, inspect the job results before retrying. A partially completed NuGet push may already have published some packages; rerun the failed publication job to reuse its validated artifact. Keep release tags fixed: publication rechecks the live tag against the built commit and rejects a mismatch. From 857c08cbcbe28d588ebfbc72008f6a8d848d739e Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Sat, 3 Oct 2026 23:35:35 +0200 Subject: [PATCH 3/7] =?UTF-8?q?=F0=9F=93=9D=20clarify=20tracked=20bot=20fo?= =?UTF-8?q?lder=20policy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make clear that the README is intentionally tracked while other bot working material stays local. This prevents the directory guidance from contradicting the repository contents. --- .bot/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.bot/README.md b/.bot/README.md index 2cfca899..3f81cdf0 100644 --- a/.bot/README.md +++ b/.bot/README.md @@ -7,4 +7,4 @@ This folder is reserved for local-only AI working material such as: - design alternatives - temporary agent state -Keep this folder out of source control. Move only finalized, non-confidential guidance into `AGENTS.md` or `.github/copilot-instructions.md`. +The `.bot/README.md` file itself is intentionally tracked in source control. All other `.bot/` working material remains local-only and excluded from source control. Move only finalized, non-confidential guidance into `AGENTS.md` or `.github/copilot-instructions.md`. From c962966b0a68ff1d866e314f63faff6df9dbf2f7 Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Sat, 3 Oct 2026 23:35:43 +0200 Subject: [PATCH 4/7] =?UTF-8?q?=F0=9F=93=9D=20scope=20docfx=20artifact=20r?= =?UTF-8?q?estrictions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Limit the temporary artifact restrictions to files created by DocFX maintenance. This preserves the documentation workflow safeguards without presenting them as repository-wide file rules. --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index eb904b59..8eb3b28a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -285,7 +285,7 @@ Preserve manual documentation edits. Prefer additive changes, but correct stale Preserve working Markdown links, `Related:` references, and historical URL citations during prose rewrites. Remove or replace a URL only after directly verifying that the current destination returns HTTP 404. Timeouts, 403s, rate limits, DNS failures, and other lookup problems are not removal evidence. -Interim scratch artifacts do not belong in the repository working tree. Store assessment queues, project manifests, review reports, captured validator output, progress notes, and one-off helper scripts in temp or session storage instead. New working-tree files are only legitimate when they are the managed `AGENTS.md` block, the active `docfx.json`, the deterministic `skip-compile-allowlist.json` waiver file when one is truly required, or DocFX-authored namespace/type Markdown that maps to a real public namespace or type. Everything else is blocking cleanup work, not a documentation deliverable. The validator auto-detects generic-arity type families (such as `MutableTuple`1`..`MutableTuple`N`) and skips redundant sibling examples from the public API surface alone, so no family-skip manifest is ever written into the repository. +The following restrictions on new working-tree files apply only to files created as part of the DocFX documentation-maintenance process, not to the repository in general. Interim scratch artifacts from this process do not belong in the repository working tree. Store assessment queues, project manifests, review reports, captured validator output, progress notes, and one-off helper scripts in temp or session storage instead. New working-tree files are only legitimate when they are the managed `AGENTS.md` block, the active `docfx.json`, the deterministic `skip-compile-allowlist.json` waiver file when one is truly required, or DocFX-authored namespace/type Markdown that maps to a real public namespace or type. Everything else is blocking cleanup work, not a documentation deliverable. The validator auto-detects generic-arity type families (such as `MutableTuple`1`..`MutableTuple`N`) and skips redundant sibling examples from the public API surface alone, so no family-skip manifest is ever written into the repository. Skip markers are waivers, not fixes. A skip marker only suppresses compilation when it both existed before the current run and matches an entry in `.docfx/skip-compile-allowlist.json`. Each allowlist entry must include `diagnosticCode`, `filePath`, `uid` or `symbol`, `reason`, `approval`, and `lifetime` (`temporary` or `permanent`). Newly introduced or unallowlisted skip markers remain fail-level diagnostics and do not permit a completion claim. From 98dbfde4fa687f29a806c400013c4c62aaad55b2 Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Sun, 4 Oct 2026 00:13:45 +0200 Subject: [PATCH 5/7] =?UTF-8?q?=F0=9F=91=B7=20add=20release=20assurance=20?= =?UTF-8?q?recovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Let maintainers replay post-release tests and repository-health analyses against the exact released SHA after correcting workflow telemetry. Verify that SonarCloud, Codecov and CodeQL record the canonical main identity before treating the replay as successful. --- .github/workflows/release.yml | 227 +++++++++++++++++++++------------- 1 file changed, 144 insertions(+), 83 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b85d793a..9d82d643 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,6 +13,14 @@ on: type: string description: GitHub Actions release run containing the already-built release artifacts. required: true + recovery: + type: choice + description: Recover OCI asset attachment or replay assurance against main at the released SHA. + required: true + default: assets + options: + - assets + - assurance permissions: contents: read @@ -49,6 +57,7 @@ jobs: RELEASE_SHA: ${{ github.sha }} RELEASE_TAG: ${{ inputs.tag || github.ref_name }} SOURCE_RUN_ID: ${{ inputs.source_run_id }} + RECOVERY_MODE: ${{ inputs.recovery || 'assets' }} GH_TOKEN: ${{ github.token }} run: | set -euo pipefail @@ -115,27 +124,35 @@ jobs: [.[].jobs[]] as $jobs | ([$jobs[] | select(.name | startswith($prefix))] | any(.conclusion == "success")) and ([$jobs[] | select(.name | startswith($prefix))] | all(.conclusion == "success" or .conclusion == "skipped")) and - ($jobs | any(.name == "Validate release tag and authoritative main history" and .conclusion == "success")) and - ($jobs | any(.name == "Build multi-platform DocFX OCI artifact once" and .conclusion == "success")) + ($jobs | any(.name == "Validate release tag and authoritative main history" and .conclusion == "success")) ' <<< "$source_jobs" >/dev/null; then - echo "::error::Source run '$SOURCE_RUN_ID' has no confirmed successful tag validation, NuGet publication and DocFX OCI build. This recovery only resumes asset attachment after those durable boundaries. Inspect the source jobs; do not republish NuGet." + echo "::error::Source run '$SOURCE_RUN_ID' has no confirmed successful tag validation and NuGet publication. Inspect the source jobs; do not republish NuGet." exit 1 fi - source_artifacts="$(gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/actions/runs/$SOURCE_RUN_ID/artifacts?per_page=100")" - artifact_json="$(jq -c --arg name "DocFX-OCI-$RELEASE_VERSION" '[.[].artifacts[] | select(.name == $name)]' <<< "$source_artifacts")" - if ! jq -e --arg sha "$RELEASE_SHA" --arg tag "$RELEASE_TAG" --argjson run "$SOURCE_RUN_ID" \ - --argjson repo "$(jq '.repository.id' <<< "$source_run")" ' - length == 1 and (.[0] | .expired == false and - (.expires_at | fromdateiso8601) > now and (.digest | test("^sha256:[0-9a-f]{64}$")) and - .workflow_run.id == $run and .workflow_run.repository_id == $repo and - .workflow_run.head_repository_id == $repo and .workflow_run.head_branch == $tag and - .workflow_run.head_sha == $sha)' <<< "$artifact_json" >/dev/null; then - echo "::error::Expected unique, unexpired, SHA-256 identified DocFX-OCI-$RELEASE_VERSION artifact from run '$SOURCE_RUN_ID' for '$RELEASE_SHA'. Restore access to that exact artifact; recovery will not rebuild replacement bytes." + if [[ "$RECOVERY_MODE" == "assets" ]]; then + if ! jq -e '[.[].jobs[]] | any(.name == "Build multi-platform DocFX OCI artifact once" and .conclusion == "success")' <<< "$source_jobs" >/dev/null; then + echo "::error::Source run '$SOURCE_RUN_ID' has no confirmed successful DocFX OCI build. Asset recovery cannot rebuild replacement bytes." + exit 1 + fi + source_artifacts="$(gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/actions/runs/$SOURCE_RUN_ID/artifacts?per_page=100")" + artifact_json="$(jq -c --arg name "DocFX-OCI-$RELEASE_VERSION" '[.[].artifacts[] | select(.name == $name)]' <<< "$source_artifacts")" + if ! jq -e --arg sha "$RELEASE_SHA" --arg tag "$RELEASE_TAG" --argjson run "$SOURCE_RUN_ID" \ + --argjson repo "$(jq '.repository.id' <<< "$source_run")" ' + length == 1 and (.[0] | .expired == false and + (.expires_at | fromdateiso8601) > now and (.digest | test("^sha256:[0-9a-f]{64}$")) and + .workflow_run.id == $run and .workflow_run.repository_id == $repo and + .workflow_run.head_repository_id == $repo and .workflow_run.head_branch == $tag and + .workflow_run.head_sha == $sha)' <<< "$artifact_json" >/dev/null; then + echo "::error::Expected unique, unexpired, SHA-256 identified DocFX-OCI-$RELEASE_VERSION artifact from run '$SOURCE_RUN_ID' for '$RELEASE_SHA'. Restore access to that exact artifact; recovery will not rebuild replacement bytes." + exit 1 + fi + echo "artifact_id=$(jq -r '.[0].id' <<< "$artifact_json")" >> "$GITHUB_OUTPUT" + elif [[ "$RECOVERY_MODE" != "assurance" ]]; then + echo "::error::Unknown recovery mode '$RECOVERY_MODE'. Select assets or assurance." exit 1 fi echo "source_run_id=$SOURCE_RUN_ID" >> "$GITHUB_OUTPUT" - echo "artifact_id=$(jq -r '.[0].id' <<< "$artifact_json")" >> "$GITHUB_OUTPUT" echo "NuGet v$RELEASE_VERSION: already published (source run confirmed); recovery will not invoke publication." fi @@ -232,7 +249,7 @@ jobs: NUGET_TOKEN: ${{ secrets.NUGET_TOKEN }} draft_github_release: - if: ${{ !cancelled() && needs.release_preflight.result == 'success' && (github.event_name == 'workflow_dispatch' || needs.publish_nuget.result == 'success') }} + if: ${{ !cancelled() && needs.release_preflight.result == 'success' && ((github.event_name == 'workflow_dispatch' && inputs.recovery == 'assets') || (github.event_name == 'push' && needs.publish_nuget.result == 'success')) }} name: Resolve draft GitHub Release for the existing tag needs: [release_preflight, publish_nuget] runs-on: ubuntu-26.04 @@ -361,7 +378,7 @@ jobs: retention-days: 30 upload_docfx_release_asset: - if: ${{ !cancelled() && needs.release_preflight.result == 'success' && needs.draft_github_release.result == 'success' && (github.event_name == 'workflow_dispatch' || needs.docfx_oci_build.result == 'success') }} + if: ${{ !cancelled() && needs.release_preflight.result == 'success' && needs.draft_github_release.result == 'success' && ((github.event_name == 'workflow_dispatch' && inputs.recovery == 'assets') || (github.event_name == 'push' && needs.docfx_oci_build.result == 'success')) }} name: Attach the built DocFX OCI artifact to its release needs: [release_preflight, draft_github_release, docfx_oci_build] runs-on: ubuntu-26.04 @@ -468,7 +485,7 @@ jobs: echo "OCI archive and checksum attached to draft release ID '$RELEASE_ID'; awaiting manual publication." prepare_release_tests: - if: ${{ github.event_name == 'push' }} + if: ${{ !cancelled() && needs.release_preflight.result == 'success' && ((github.event_name == 'push' && needs.publish_nuget.result == 'success') || (github.event_name == 'workflow_dispatch' && inputs.recovery == 'assurance')) }} name: Discover post-release test projects needs: [release_preflight, publish_nuget] runs-on: ubuntu-26.04 @@ -495,7 +512,7 @@ jobs: run: echo "${{ steps.test-projects.outputs.result }}" post_release_tests: - if: ${{ github.event_name == 'push' }} + if: ${{ !cancelled() && needs.release_preflight.result == 'success' && needs.prepare_release_tests.result == 'success' && ((github.event_name == 'push' && needs.publish_nuget.result == 'success') || (github.event_name == 'workflow_dispatch' && inputs.recovery == 'assurance')) }} name: Post-release Release tests - ${{ matrix.project }} needs: [release_preflight, prepare_release_tests, publish_nuget] strategy: @@ -538,7 +555,7 @@ jobs: retention-days: 30 post_release_integration_test: - if: ${{ github.event_name == 'push' }} + if: ${{ !cancelled() && needs.release_preflight.result == 'success' && ((github.event_name == 'push' && needs.publish_nuget.result == 'success') || (github.event_name == 'workflow_dispatch' && inputs.recovery == 'assurance')) }} name: Post-release SQL Server integration test needs: [release_preflight, publish_nuget] runs-on: ubuntu-26.04 @@ -593,49 +610,29 @@ jobs: command: down post_release_sonarcloud: - if: ${{ always() && github.event_name == 'push' && needs.publish_nuget.result == 'success' }} + if: ${{ !cancelled() && needs.release_preflight.result == 'success' && ((github.event_name == 'push' && needs.publish_nuget.result == 'success') || (github.event_name == 'workflow_dispatch' && inputs.recovery == 'assurance')) }} name: Post-release SonarCloud analysis needs: [release_preflight, publish_nuget, prepare_release_tests, post_release_tests, post_release_integration_test] - runs-on: ubuntu-26.04 - timeout-minutes: 45 permissions: contents: read - steps: - - name: Checkout the released SHA as main analysis - uses: codebeltnet/git-checkout@v1 - with: - ref: ${{ needs.release_preflight.outputs.sha }} - - - name: Install .NET - uses: codebeltnet/install-dotnet@v3 - - - name: Install .NET Tool - Sonar Scanner - uses: codebeltnet/dotnet-tool-install-sonarscanner@v3 - - - name: Restore released source dependencies - uses: codebeltnet/dotnet-restore@v3 - - - name: Begin SonarCloud analysis for the released version - uses: codebeltnet/sonarcloud-scan@v2 - with: - token: ${{ secrets.SONAR_TOKEN }} - organization: geekle - projectKey: Cuemon - version: ${{ needs.release_preflight.outputs.version }} - - - name: Build released source for SonarCloud - uses: codebeltnet/dotnet-build@v4 - with: - configuration: Release - build-switches: -p:SkipSignAssembly=true - - - name: Finalize SonarCloud analysis - uses: codebeltnet/sonarcloud-scan-finalize@v1 - with: - token: ${{ secrets.SONAR_TOKEN }} + uses: codebeltnet/jobs-sonarcloud/.github/workflows/default.yml@v3 + with: + organization: geekle + projectKey: Cuemon + version: ${{ needs.release_preflight.outputs.version }} + ref: ${{ needs.release_preflight.outputs.sha }} + configuration: Release + timeout-minutes: 45 + # Custom parameters replace the workflow defaults; retain its exclusions. + parameters: >- + -d:sonar.exclusions='**/obj/**,**/bin/**' + -d:sonar.branch.name=main + -d:sonar.scm.revision=${{ needs.release_preflight.outputs.sha }} + secrets: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} post_release_codecov: - if: ${{ always() && github.event_name == 'push' && needs.publish_nuget.result == 'success' }} + if: ${{ !cancelled() && needs.release_preflight.result == 'success' && ((github.event_name == 'push' && needs.publish_nuget.result == 'success') || (github.event_name == 'workflow_dispatch' && inputs.recovery == 'assurance')) }} name: Post-release Codecov upload needs: [release_preflight, publish_nuget, prepare_release_tests, post_release_tests, post_release_integration_test] permissions: @@ -645,41 +642,78 @@ jobs: repository: codebeltnet/cuemon configuration: .github/codecov.yml ref: ${{ needs.release_preflight.outputs.sha }} + branch: main + commit: ${{ needs.release_preflight.outputs.sha }} secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} post_release_codeql: - if: ${{ always() && github.event_name == 'push' && needs.publish_nuget.result == 'success' }} + if: ${{ !cancelled() && needs.release_preflight.result == 'success' && ((github.event_name == 'push' && needs.publish_nuget.result == 'success') || (github.event_name == 'workflow_dispatch' && inputs.recovery == 'assurance')) }} name: Post-release CodeQL analysis needs: [release_preflight, publish_nuget, prepare_release_tests, post_release_tests, post_release_integration_test] - runs-on: ubuntu-26.04 - timeout-minutes: 45 permissions: contents: read security-events: write - steps: - - name: Checkout the released SHA as main analysis - uses: codebeltnet/git-checkout@v1 - with: - ref: ${{ needs.release_preflight.outputs.sha }} - - - name: Install .NET - uses: codebeltnet/install-dotnet@v3 - - - name: Restore released source dependencies - uses: codebeltnet/dotnet-restore@v3 - - - name: Begin CodeQL analysis - uses: codebeltnet/codeql-scan@v1 - - - name: Build released source for CodeQL - uses: codebeltnet/dotnet-build@v4 - with: - configuration: Release - build-switches: -p:SkipSignAssembly=true + uses: codebeltnet/jobs-codeql/.github/workflows/default.yml@v3 + with: + ref: ${{ needs.release_preflight.outputs.sha }} + analysis-ref: refs/heads/main + analysis-sha: ${{ needs.release_preflight.outputs.sha }} + configuration: Release + timeout-minutes: 45 - - name: Finalize CodeQL analysis - uses: codebeltnet/codeql-scan-finalize@v1 + verify_assurance_identity: + if: ${{ !cancelled() && needs.release_preflight.result == 'success' && needs.post_release_sonarcloud.result == 'success' && needs.post_release_codecov.result == 'success' && needs.post_release_codeql.result == 'success' }} + name: Verify released SHA on canonical main in quality services + needs: [release_preflight, post_release_sonarcloud, post_release_codecov, post_release_codeql] + runs-on: ubuntu-26.04 + timeout-minutes: 10 + permissions: + contents: read + security-events: read + steps: + - name: Verify service records for main at the released SHA + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_SHA: ${{ needs.release_preflight.outputs.sha }} + RELEASE_VERSION: ${{ needs.release_preflight.outputs.version }} + run: | + set -euo pipefail + # Upload completion can precede service-side processing. Bound the wait. + for attempt in {1..20}; do + sonar_ok=false + codecov_ok=false + codeql_ok=false + if sonar="$(curl --fail --silent --show-error 'https://sonarcloud.io/api/project_analyses/search?project=Cuemon&branch=main&ps=1')" && + jq -e --arg sha "$RELEASE_SHA" --arg version "$RELEASE_VERSION" \ + '.analyses[0] | .revision == $sha and .projectVersion == $version' <<< "$sonar" >/dev/null; then + sonar_ok=true + fi + if codecov="$(curl --fail --silent --show-error "https://api.codecov.io/api/v2/github/codebeltnet/repos/cuemon/commits/$RELEASE_SHA/")" && + jq -e --arg sha "$RELEASE_SHA" \ + '.branch == "main" and .commitid == $sha and .state == "complete"' <<< "$codecov" >/dev/null; then + codecov_ok=true + fi + if codeql="$(gh api "repos/$GITHUB_REPOSITORY/code-scanning/analyses?tool_name=CodeQL&ref=refs%2Fheads%2Fmain&per_page=100")" && + jq -e --arg sha "$RELEASE_SHA" \ + 'any(.[]; .tool.name == "CodeQL" and .ref == "refs/heads/main" and .commit_sha == $sha and .error == "")' <<< "$codeql" >/dev/null; then + codeql_ok=true + fi + echo "Service identity check $attempt/20 for $RELEASE_SHA: SonarCloud=$sonar_ok; Codecov=$codecov_ok; CodeQL=$codeql_ok" + if [[ "$sonar_ok" == true && "$codecov_ok" == true && "$codeql_ok" == true ]]; then + { + echo '## Post-release service identity verified' + echo "- SonarCloud: main; SCM revision \`$RELEASE_SHA\`; project version \`$RELEASE_VERSION\`" + echo "- Codecov: main; commit \`$RELEASE_SHA\`" + echo "- CodeQL: refs/heads/main; commit \`$RELEASE_SHA\`" + } >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + if [[ "$attempt" -lt 20 ]]; then sleep 15; fi + done + echo "::error::Services did not confirm canonical main at released SHA '$RELEASE_SHA' (SonarCloud=$sonar_ok; Codecov=$codecov_ok; CodeQL=$codeql_ok). Inspect service records; successful uploads alone do not establish assurance." + exit 1 release_summary: if: ${{ always() }} @@ -697,6 +731,7 @@ jobs: - post_release_sonarcloud - post_release_codecov - post_release_codeql + - verify_assurance_identity runs-on: ubuntu-26.04 permissions: contents: read @@ -711,6 +746,7 @@ jobs: RELEASE_ID: ${{ needs.draft_github_release.outputs.release_id }} SOURCE_RUN_ID: ${{ inputs.source_run_id }} REQUESTED_TAG: ${{ inputs.tag }} + RECOVERY_MODE: ${{ inputs.recovery || 'assets' }} PACKAGE_BUILD_RESULT: ${{ needs.release_packages.result }} NUGET_RESULT: ${{ needs.publish_nuget.result }} DRAFT_RELEASE_RESULT: ${{ needs.draft_github_release.result }} @@ -722,10 +758,33 @@ jobs: SONAR_RESULT: ${{ needs.post_release_sonarcloud.result }} CODECOV_RESULT: ${{ needs.post_release_codecov.result }} CODEQL_RESULT: ${{ needs.post_release_codeql.result }} + IDENTITY_RESULT: ${{ needs.verify_assurance_identity.result }} run: | set -euo pipefail if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then + if [[ "$RECOVERY_MODE" == "assurance" ]]; then + { + echo '## Post-release assurance recovery status' + echo "- Tag: \`${RELEASE_TAG:-$REQUESTED_TAG}\`" + echo "- Released SHA: \`${RELEASE_SHA:-not validated}\`" + echo "- Source run: \`$SOURCE_RUN_ID\`" + echo "- Recovery validation: \`$PREFLIGHT_RESULT\`" + echo '- Reporting identity: SonarCloud/Codecov main; CodeQL refs/heads/main at the released SHA' + echo "- Test discovery: \`$TEST_DISCOVERY_RESULT\`; test matrix: \`$TEST_RESULT\`; SQL Server integration: \`$INTEGRATION_RESULT\`" + echo "- SonarCloud: \`$SONAR_RESULT\`; Codecov: \`$CODECOV_RESULT\`; CodeQL: \`$CODEQL_RESULT\`" + echo "- Service identity verification: \`$IDENTITY_RESULT\`" + echo '- NuGet publication, OCI build/attachment, GitHub Release and deployment: not invoked' + } >> "$GITHUB_STEP_SUMMARY" + if [[ "$PREFLIGHT_RESULT" != "success" || "$TEST_DISCOVERY_RESULT" != "success" || + "$TEST_RESULT" != "success" || "$INTEGRATION_RESULT" != "success" || + "$SONAR_RESULT" != "success" || "$CODECOV_RESULT" != "success" || + "$CODEQL_RESULT" != "success" || "$IDENTITY_RESULT" != "success" ]]; then + echo '::error::Assurance recovery did not complete. Inspect the failed tests, analysis or service identity check.' + exit 1 + fi + exit 0 + fi { echo "## Partial-release recovery status" echo @@ -764,6 +823,7 @@ jobs: echo "- Tag: \`${RELEASE_TAG:-not validated}\`" echo "- Version: \`${RELEASE_VERSION:-not validated}\`" echo "- Released SHA: \`${RELEASE_SHA:-not validated}\`" + echo '- Repository health identity: SonarCloud/Codecov main; CodeQL refs/heads/main at the released SHA' echo "- Release package build: \`$PACKAGE_BUILD_RESULT\`" echo "- NuGet publication: \`$NUGET_RESULT\`" echo "- Draft GitHub Release: \`$DRAFT_RELEASE_RESULT\`" @@ -808,13 +868,14 @@ jobs: if [[ "$NUGET_RESULT" == "success" && \ ( "$TEST_DISCOVERY_RESULT" != "success" || "$TEST_RESULT" != "success" || \ "$INTEGRATION_RESULT" != "success" || "$SONAR_RESULT" != "success" || \ - "$CODECOV_RESULT" != "success" || "$CODEQL_RESULT" != "success" ) ]]; then + "$CODECOV_RESULT" != "success" || "$CODEQL_RESULT" != "success" || \ + "$IDENTITY_RESULT" != "success" ) ]]; then echo "::warning::NuGet publication succeeded; post-release assurance did not complete successfully." { echo echo "**NuGet publication succeeded; post-release assurance did not complete successfully.**" echo - echo "Post-release test discovery: \`$TEST_DISCOVERY_RESULT\`; test matrix: \`$TEST_RESULT\`; SQL Server integration: \`$INTEGRATION_RESULT\`; SonarCloud: \`$SONAR_RESULT\`; Codecov: \`$CODECOV_RESULT\`; CodeQL: \`$CODEQL_RESULT\`." + echo "Post-release test discovery: \`$TEST_DISCOVERY_RESULT\`; test matrix: \`$TEST_RESULT\`; SQL Server integration: \`$INTEGRATION_RESULT\`; SonarCloud: \`$SONAR_RESULT\`; Codecov: \`$CODECOV_RESULT\`; CodeQL: \`$CODEQL_RESULT\`; service identity: \`$IDENTITY_RESULT\`." echo "The package and tag remain released. These findings do not roll back publication; resolve them against the recorded SHA." } >> "$GITHUB_STEP_SUMMARY" fi From c3821a0c7ed9ddee9a3a387113dcf797d3806896 Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Sun, 4 Oct 2026 00:13:46 +0200 Subject: [PATCH 6/7] =?UTF-8?q?=F0=9F=92=AC=20document=20release=20assuran?= =?UTF-8?q?ce=20recovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the reporting branch and commit identities required for release assurance, plus the recovery dispatch maintainers can use to replay checks against the released SHA. --- .github/CONTRIBUTING.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 33f59ffe..c5f96e2f 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -77,7 +77,21 @@ After PR validation and merge, a maintainer creates and pushes a `vX.Y.Z` tag (o After NuGet publication, the workflow runs post-release tests and analysis and builds the multi-platform DocFX OCI image from the same commit. The verified archive and SHA-256 checksum are attached to a draft GitHub Release using its numeric release ID. The workflow leaves the release as a draft. A maintainer reviews/edits the release and assurance results, then presses **Publish** to declare it deployable. Post-release assurance failures do not roll back published packages; inspect the release summary and resolve failures against its recorded commit. -If OCI asset attachment fails after NuGet publication and the OCI build succeeded, dispatch `release.yml` from **main** with the existing `tag` and original `source_run_id`. It never rebuilds packages/images, invokes NuGet publication, reruns assurance, creates a replacement release, or publishes the draft. Missing, expired or unverifiable artifacts cause failure with diagnostics rather than replacement bytes. +The Git tag identifies the release; `main` identifies repository health; the released SHA ties them together. Release assurance calls the same reusable workflows as `pr.yml`: `jobs-sonarcloud@v3`, `jobs-codecov@v1`, and `jobs-codeql@v3`. Assurance always checks out the exact released tag's commit using each workflow's `ref` input, even if `main` has advanced. SonarCloud explicitly reports `sonar.branch.name=main`, the released SemVer as project version, and the released SHA as SCM revision through the existing `parameters` input, retaining the scanner's default exclusions. Codecov explicitly reports `branch: main` and `commit: ` independently of its checkout `ref`. CodeQL uses `analysis-ref: refs/heads/main` and `analysis-sha: `, forwarded to the finalize action's `ref` and `sha` inputs. SonarCloud and CodeQL request Release builds; callers that omit configuration retain Debug builds. Release tags must never become analysis branches. + +Before accepting assurance, verify service records rather than relying on successful Actions jobs: SonarCloud's `api/project_analyses/search?project=Cuemon&branch=main` must contain the released `revision` and `projectVersion`; Codecov's `api/v2/github/codebeltnet/repos/cuemon/commits//` must report `branch: main` and the expected `commitid`; GitHub's `repos/codebeltnet/cuemon/code-scanning/analyses?tool_name=CodeQL&ref=refs%2Fheads%2Fmain` must contain the expected `commit_sha`. For `v10.8.0`, every check must identify `33e6e756984e5d21a4d0e6b9171d417a58a779f1` on the canonical main branch. Any assurance replay must use these same identities and the exact released SHA; rerunning a historical job does not pick up corrected workflow parameters. + +If OCI asset attachment fails after NuGet publication and the OCI build succeeded, dispatch `release.yml` from **main** with `recovery: assets` (the default), the existing `tag` and original `source_run_id`. This mode never rebuilds packages/images, invokes NuGet publication, reruns assurance, creates a replacement release, or publishes the draft. Missing, expired or unverifiable artifacts cause failure with diagnostics rather than replacement bytes. + +To correct or replay repository-health telemetry, dispatch the corrected workflow from **main** with `recovery: assurance`, the existing release `tag`, and its original tag-push `source_run_id`. Preflight confirms the source run's successful tag validation and NuGet publication, resolves the unchanged tag, and checks its membership in main history. This mode regenerates tests/coverage and all three analyses from that exact released SHA. It does not require the original OCI artifacts or alter NuGet packages, OCI assets, the GitHub Release, or deployment. A service-record verification job waits for processing, then fails unless SonarCloud reports main with the released SHA and SemVer, Codecov reports main with that SHA, and CodeQL records refs/heads/main with that SHA. Assurance recovery fails when tests, analysis, or service verification fail. + +Publish the backward-compatible `codecov-scan@v1` and `codeql-scan-finalize@v1` extensions, then `jobs-codecov@v1`, `jobs-sonarcloud@v3`, and `jobs-codeql@v3`, before publishing the Cuemon workflow that uses their new inputs. For the existing release, the recovery request is: + +```powershell +gh workflow run release.yml --repo codebeltnet/cuemon --ref main -f recovery=assurance -f tag=v10.8.0 -f source_run_id=37153698543 +``` + +This command requires the corrected workflow and wrappers to be available remotely. Checkout and all service verification must identify `main @ 33e6e756984e5d21a4d0e6b9171d417a58a779f1`; the current tip of main is only the orchestration source. Recovery verifies the artifact's download digest, archive checksum, OCI version/revision and `linux/amd64`/`linux/arm64` coverage before attachment. Exact existing release assets are accepted; conflicting bytes fail before mutation, and only missing assets are uploaded. Recovery can be dispatched again after a partial upload. Fix orchestration on main and resume from the last durable successful boundary; do not replay immutable publication because later finalization failed. Actions artifacts are retained for 30 days, so recover while the source artifact is available. From c7f0c1238930e94d1fda1b5cad941ed61649a495 Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Sun, 4 Oct 2026 00:26:32 +0200 Subject: [PATCH 7/7] =?UTF-8?q?=F0=9F=90=9B=20handle=20release=20recovery?= =?UTF-8?q?=20edge=20cases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow asset recovery to restore a missing draft release and search paginated SonarCloud analyses for the released revision. This keeps recovery reliable after release edits or newer main analyses. --- .github/workflows/release.yml | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9d82d643..4a4ee7f3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -303,9 +303,6 @@ jobs: if [[ "$RELEASE_VERSION" == *-* ]]; then expected_prerelease=true; fi if [[ "$count" -eq 1 ]]; then release_json="$(jq -c '.[0]' <<< "$matches")" - elif [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then - echo "::error::Recovery requires the existing draft GitHub Release for '$RELEASE_TAG'. Restore that object; recovery will not create a replacement release." - exit 1 else notes="$(gh api --method POST "repos/$GITHUB_REPOSITORY/releases/generate-notes" \ -f tag_name="$RELEASE_TAG" -f target_commitish="$RELEASE_SHA" --jq '.body')" @@ -685,11 +682,19 @@ jobs: sonar_ok=false codecov_ok=false codeql_ok=false - if sonar="$(curl --fail --silent --show-error 'https://sonarcloud.io/api/project_analyses/search?project=Cuemon&branch=main&ps=1')" && - jq -e --arg sha "$RELEASE_SHA" --arg version "$RELEASE_VERSION" \ - '.analyses[0] | .revision == $sha and .projectVersion == $version' <<< "$sonar" >/dev/null; then - sonar_ok=true - fi + # Newer main analyses must not hide the released revision, including on later pages. + sonar_page=1 + while sonar="$(curl --fail --silent --show-error "https://sonarcloud.io/api/project_analyses/search?project=Cuemon&branch=main&ps=100&p=$sonar_page")"; do + if jq -e --arg sha "$RELEASE_SHA" --arg version "$RELEASE_VERSION" \ + 'any(.analyses[]; .revision == $sha and .projectVersion == $version)' <<< "$sonar" >/dev/null; then + sonar_ok=true + break + fi + if ! jq -e '.paging.pageIndex * .paging.pageSize < .paging.total' <<< "$sonar" >/dev/null; then + break + fi + sonar_page=$((sonar_page + 1)) + done if codecov="$(curl --fail --silent --show-error "https://api.codecov.io/api/v2/github/codebeltnet/repos/cuemon/commits/$RELEASE_SHA/")" && jq -e --arg sha "$RELEASE_SHA" \ '.branch == "main" and .commitid == $sha and .state == "complete"' <<< "$codecov" >/dev/null; then