From 271966582e5145e95740fc57b01455a3be866ea3 Mon Sep 17 00:00:00 2001 From: XuPeng-SH Date: Mon, 24 Aug 2026 11:41:27 +0800 Subject: [PATCH 01/12] ci: retry UT coverage artifact uploads --- .github/workflows/coverage-ut.yaml | 40 +++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/.github/workflows/coverage-ut.yaml b/.github/workflows/coverage-ut.yaml index 5c99140..4c9483b 100644 --- a/.github/workflows/coverage-ut.yaml +++ b/.github/workflows/coverage-ut.yaml @@ -137,6 +137,8 @@ jobs: # the UT-only minimum while we validate the external-storage timeout fix. runs-on: amd64-mo-guangzhou-2xlarge16 timeout-minutes: 60 + env: + UT_COVERAGE_ARTIFACT_NAME: ut-coverage-generation-${{ inputs.bvt_generation || format('{0}-{1}', github.run_id, github.run_attempt) }}-attempt-${{ github.run_attempt }} steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: @@ -425,14 +427,46 @@ jobs: echo '- OOM signal: not detected in available runner diagnostics.' fi } >> "$GITHUB_STEP_SUMMARY" - - name: Upload UT coverage profile + # Artifact-service finalization can fail transiently after the expensive + # coverage run has already completed. Retry only the upload so a network + # reset does not force another 45-minute test run. Later attempts replace + # any incomplete artifact left by an ambiguous finalize response. + - name: Upload UT coverage profile (attempt 1/3) + id: upload_ut_coverage_1 if: ${{ always() && !cancelled() && steps.coverage_ut.outcome == 'success' }} + continue-on-error: true uses: actions/upload-artifact@v7 with: - name: ut-coverage-generation-${{ inputs.bvt_generation || format('{0}-{1}', github.run_id, github.run_attempt) }}-attempt-${{ github.run_attempt }} + name: ${{ env.UT_COVERAGE_ARTIFACT_NAME }} path: ${{ runner.temp }}/ut-coverage.out - if-no-files-found: warn + if-no-files-found: error + retention-days: 7 + - name: Back off before UT coverage upload retry 1 + if: ${{ always() && !cancelled() && steps.coverage_ut.outcome == 'success' && steps.upload_ut_coverage_1.outcome == 'failure' }} + run: sleep 5 + - name: Upload UT coverage profile (attempt 2/3) + id: upload_ut_coverage_2 + if: ${{ always() && !cancelled() && steps.coverage_ut.outcome == 'success' && steps.upload_ut_coverage_1.outcome == 'failure' }} + continue-on-error: true + uses: actions/upload-artifact@v7 + with: + name: ${{ env.UT_COVERAGE_ARTIFACT_NAME }} + path: ${{ runner.temp }}/ut-coverage.out + if-no-files-found: error + retention-days: 7 + overwrite: true + - name: Back off before UT coverage upload retry 2 + if: ${{ always() && !cancelled() && steps.coverage_ut.outcome == 'success' && steps.upload_ut_coverage_1.outcome == 'failure' && steps.upload_ut_coverage_2.outcome == 'failure' }} + run: sleep 15 + - name: Upload UT coverage profile (attempt 3/3) + if: ${{ always() && !cancelled() && steps.coverage_ut.outcome == 'success' && steps.upload_ut_coverage_1.outcome == 'failure' && steps.upload_ut_coverage_2.outcome == 'failure' }} + uses: actions/upload-artifact@v7 + with: + name: ${{ env.UT_COVERAGE_ARTIFACT_NAME }} + path: ${{ runner.temp }}/ut-coverage.out + if-no-files-found: error retention-days: 7 + overwrite: true - name: Upload failed UT diagnostics if: ${{ always() && !cancelled() && steps.coverage_ut.outcome == 'failure' }} uses: actions/upload-artifact@v7 From 1862bf91987cc2279df353ac12d16519506e8c24 Mon Sep 17 00:00:00 2001 From: XuPeng-SH Date: Mon, 24 Aug 2026 11:54:36 +0800 Subject: [PATCH 02/12] ci: retry critical artifact uploads --- .github/workflows/build-mo.yaml | 3 +- .github/workflows/check-action-file.yaml | 8 +- .github/workflows/coverage-merge.yaml | 3 +- .github/workflows/coverage-ut.yaml | 40 +--------- .github/workflows/e2e-compose-parallel.yaml | 2 +- .../workflows/e2e-standalone-parallel.yaml | 2 +- .../upload-artifact-with-retry/action.yaml | 77 +++++++++++++++++++ 7 files changed, 93 insertions(+), 42 deletions(-) create mode 100644 actions/upload-artifact-with-retry/action.yaml diff --git a/.github/workflows/build-mo.yaml b/.github/workflows/build-mo.yaml index ddb3192..613b245 100644 --- a/.github/workflows/build-mo.yaml +++ b/.github/workflows/build-mo.yaml @@ -205,9 +205,10 @@ jobs: echo "artifact_name=mo-shared-build-attempt-${{ github.run_attempt }}" >> "$GITHUB_OUTPUT" - name: Upload build artifact - uses: actions/upload-artifact@v7 + uses: matrixorigin/CI/actions/upload-artifact-with-retry@main with: name: mo-shared-build-attempt-${{ github.run_attempt }} path: ${{ runner.temp }}/mo-build/mo-build.tar.gz if-no-files-found: error retention-days: 3 + compression-level: 0 diff --git a/.github/workflows/check-action-file.yaml b/.github/workflows/check-action-file.yaml index 0e4c67f..7c87330 100644 --- a/.github/workflows/check-action-file.yaml +++ b/.github/workflows/check-action-file.yaml @@ -7,6 +7,7 @@ on: - 'release/*' paths: - '.github/workflows/*.yaml' + - 'actions/upload-artifact-with-retry/action.yaml' permissions: contents: read @@ -30,4 +31,9 @@ jobs: uses: dsanders11/json-schema-validate-action@v2.0.0 with: schema: https://json.schemastore.org/github-workflow.json - files: .github/workflows/**.yaml + files: .github/workflows/**.yaml + - name: Validate artifact retry action metadata + uses: dsanders11/json-schema-validate-action@v2.0.0 + with: + schema: https://json.schemastore.org/github-action.json + files: actions/upload-artifact-with-retry/action.yaml diff --git a/.github/workflows/coverage-merge.yaml b/.github/workflows/coverage-merge.yaml index 6058e28..64d3603 100644 --- a/.github/workflows/coverage-merge.yaml +++ b/.github/workflows/coverage-merge.yaml @@ -258,7 +258,7 @@ jobs: fi - name: Upload coverage result if: ${{ always() && !cancelled() && steps.merge_coverage.outputs.has_pr_coverage == 'true' }} - uses: actions/upload-artifact@v7 + uses: matrixorigin/CI/actions/upload-artifact-with-retry@main with: name: final-result-files path: | @@ -268,6 +268,7 @@ jobs: ${{ github.workspace }}/coverage-summary.json if-no-files-found: error retention-days: 7 + overwrite: true - name: Upload coverage merge diagnostics if: ${{ failure() && !cancelled() }} uses: actions/upload-artifact@v7 diff --git a/.github/workflows/coverage-ut.yaml b/.github/workflows/coverage-ut.yaml index 4c9483b..c511e95 100644 --- a/.github/workflows/coverage-ut.yaml +++ b/.github/workflows/coverage-ut.yaml @@ -137,8 +137,6 @@ jobs: # the UT-only minimum while we validate the external-storage timeout fix. runs-on: amd64-mo-guangzhou-2xlarge16 timeout-minutes: 60 - env: - UT_COVERAGE_ARTIFACT_NAME: ut-coverage-generation-${{ inputs.bvt_generation || format('{0}-{1}', github.run_id, github.run_attempt) }}-attempt-${{ github.run_attempt }} steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: @@ -427,46 +425,14 @@ jobs: echo '- OOM signal: not detected in available runner diagnostics.' fi } >> "$GITHUB_STEP_SUMMARY" - # Artifact-service finalization can fail transiently after the expensive - # coverage run has already completed. Retry only the upload so a network - # reset does not force another 45-minute test run. Later attempts replace - # any incomplete artifact left by an ambiguous finalize response. - - name: Upload UT coverage profile (attempt 1/3) - id: upload_ut_coverage_1 + - name: Upload UT coverage profile if: ${{ always() && !cancelled() && steps.coverage_ut.outcome == 'success' }} - continue-on-error: true - uses: actions/upload-artifact@v7 - with: - name: ${{ env.UT_COVERAGE_ARTIFACT_NAME }} - path: ${{ runner.temp }}/ut-coverage.out - if-no-files-found: error - retention-days: 7 - - name: Back off before UT coverage upload retry 1 - if: ${{ always() && !cancelled() && steps.coverage_ut.outcome == 'success' && steps.upload_ut_coverage_1.outcome == 'failure' }} - run: sleep 5 - - name: Upload UT coverage profile (attempt 2/3) - id: upload_ut_coverage_2 - if: ${{ always() && !cancelled() && steps.coverage_ut.outcome == 'success' && steps.upload_ut_coverage_1.outcome == 'failure' }} - continue-on-error: true - uses: actions/upload-artifact@v7 - with: - name: ${{ env.UT_COVERAGE_ARTIFACT_NAME }} - path: ${{ runner.temp }}/ut-coverage.out - if-no-files-found: error - retention-days: 7 - overwrite: true - - name: Back off before UT coverage upload retry 2 - if: ${{ always() && !cancelled() && steps.coverage_ut.outcome == 'success' && steps.upload_ut_coverage_1.outcome == 'failure' && steps.upload_ut_coverage_2.outcome == 'failure' }} - run: sleep 15 - - name: Upload UT coverage profile (attempt 3/3) - if: ${{ always() && !cancelled() && steps.coverage_ut.outcome == 'success' && steps.upload_ut_coverage_1.outcome == 'failure' && steps.upload_ut_coverage_2.outcome == 'failure' }} - uses: actions/upload-artifact@v7 + uses: matrixorigin/CI/actions/upload-artifact-with-retry@main with: - name: ${{ env.UT_COVERAGE_ARTIFACT_NAME }} + name: ut-coverage-generation-${{ inputs.bvt_generation || format('{0}-{1}', github.run_id, github.run_attempt) }}-attempt-${{ github.run_attempt }} path: ${{ runner.temp }}/ut-coverage.out if-no-files-found: error retention-days: 7 - overwrite: true - name: Upload failed UT diagnostics if: ${{ always() && !cancelled() && steps.coverage_ut.outcome == 'failure' }} uses: actions/upload-artifact@v7 diff --git a/.github/workflows/e2e-compose-parallel.yaml b/.github/workflows/e2e-compose-parallel.yaml index 1cf90f5..c346196 100644 --- a/.github/workflows/e2e-compose-parallel.yaml +++ b/.github/workflows/e2e-compose-parallel.yaml @@ -491,7 +491,7 @@ jobs: - name: Upload Compose BVT coverage if: ${{ always() && !cancelled() }} - uses: actions/upload-artifact@v7 + uses: matrixorigin/CI/actions/upload-artifact-with-retry@main with: name: bvt-coverage-compose-generation-${{ steps.bvt_on_pr_version.outputs.bvt_generation || format('{0}-{1}', github.run_id, github.run_attempt) }}-attempt-${{ github.run_attempt }} path: | diff --git a/.github/workflows/e2e-standalone-parallel.yaml b/.github/workflows/e2e-standalone-parallel.yaml index 93cf277..5467362 100644 --- a/.github/workflows/e2e-standalone-parallel.yaml +++ b/.github/workflows/e2e-standalone-parallel.yaml @@ -530,7 +530,7 @@ jobs: - name: Upload Compose + Pessimistic BVT coverage if: ${{ always() && !cancelled() }} - uses: actions/upload-artifact@v7 + uses: matrixorigin/CI/actions/upload-artifact-with-retry@main with: name: bvt-coverage-pessimistic-generation-${{ steps.bvt_on_pr_version.outputs.bvt_generation || format('{0}-{1}', github.run_id, github.run_attempt) }}-attempt-${{ github.run_attempt }} path: | diff --git a/actions/upload-artifact-with-retry/action.yaml b/actions/upload-artifact-with-retry/action.yaml new file mode 100644 index 0000000..7af0df6 --- /dev/null +++ b/actions/upload-artifact-with-retry/action.yaml @@ -0,0 +1,77 @@ +name: Upload artifact with retry +description: Retry transient GitHub artifact-service failures without rerunning producer work. + +inputs: + name: + description: Artifact name owned by one job within the workflow run; retries overwrite this name. + required: true + path: + description: File, directory, or multiline path specification to upload. + required: true + if-no-files-found: + description: Behavior when no input files exist. + required: false + default: error + retention-days: + description: Number of days to retain the artifact. + required: false + default: "7" + overwrite: + description: Delete an existing same-name artifact before the first attempt. + required: false + default: "false" + compression-level: + description: Zlib compression level passed to actions/upload-artifact. + required: false + default: "6" + +runs: + using: composite + steps: + - name: Upload artifact (attempt 1/3) + id: upload_1 + continue-on-error: true + uses: actions/upload-artifact@v7 + with: + name: ${{ inputs.name }} + path: ${{ inputs.path }} + if-no-files-found: ${{ inputs.if-no-files-found }} + retention-days: ${{ inputs.retention-days }} + overwrite: ${{ inputs.overwrite }} + compression-level: ${{ inputs.compression-level }} + + - name: Back off before artifact upload retry 1 + if: ${{ always() && !cancelled() && steps.upload_1.outcome == 'failure' }} + shell: bash + run: sleep 5 + + - name: Upload artifact (attempt 2/3) + id: upload_2 + if: ${{ always() && !cancelled() && steps.upload_1.outcome == 'failure' }} + continue-on-error: true + uses: actions/upload-artifact@v7 + with: + name: ${{ inputs.name }} + path: ${{ inputs.path }} + if-no-files-found: ${{ inputs.if-no-files-found }} + retention-days: ${{ inputs.retention-days }} + compression-level: ${{ inputs.compression-level }} + # A failed finalize response is ambiguous: delete any partial or + # already-finalized generation before publishing the retry. + overwrite: true + + - name: Back off before artifact upload retry 2 + if: ${{ always() && !cancelled() && steps.upload_1.outcome == 'failure' && steps.upload_2.outcome == 'failure' }} + shell: bash + run: sleep 15 + + - name: Upload artifact (attempt 3/3) + if: ${{ always() && !cancelled() && steps.upload_1.outcome == 'failure' && steps.upload_2.outcome == 'failure' }} + uses: actions/upload-artifact@v7 + with: + name: ${{ inputs.name }} + path: ${{ inputs.path }} + if-no-files-found: ${{ inputs.if-no-files-found }} + retention-days: ${{ inputs.retention-days }} + compression-level: ${{ inputs.compression-level }} + overwrite: true From 7436fd189a0b8de29094f82191d9ecfe57134d86 Mon Sep 17 00:00:00 2001 From: XuPeng-SH Date: Mon, 24 Aug 2026 12:01:14 +0800 Subject: [PATCH 03/12] ci: preserve failure-path artifact uploads --- actions/upload-artifact-with-retry/action.yaml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/actions/upload-artifact-with-retry/action.yaml b/actions/upload-artifact-with-retry/action.yaml index 7af0df6..8f29ca9 100644 --- a/actions/upload-artifact-with-retry/action.yaml +++ b/actions/upload-artifact-with-retry/action.yaml @@ -30,6 +30,9 @@ runs: steps: - name: Upload artifact (attempt 1/3) id: upload_1 + # Preserve an outer caller's always() semantics when the job was already + # failing before it entered this composite action. + if: ${{ !cancelled() }} continue-on-error: true uses: actions/upload-artifact@v7 with: @@ -41,13 +44,13 @@ runs: compression-level: ${{ inputs.compression-level }} - name: Back off before artifact upload retry 1 - if: ${{ always() && !cancelled() && steps.upload_1.outcome == 'failure' }} + if: ${{ !cancelled() && steps.upload_1.outcome == 'failure' }} shell: bash run: sleep 5 - name: Upload artifact (attempt 2/3) id: upload_2 - if: ${{ always() && !cancelled() && steps.upload_1.outcome == 'failure' }} + if: ${{ !cancelled() && steps.upload_1.outcome == 'failure' }} continue-on-error: true uses: actions/upload-artifact@v7 with: @@ -61,12 +64,12 @@ runs: overwrite: true - name: Back off before artifact upload retry 2 - if: ${{ always() && !cancelled() && steps.upload_1.outcome == 'failure' && steps.upload_2.outcome == 'failure' }} + if: ${{ !cancelled() && steps.upload_1.outcome == 'failure' && steps.upload_2.outcome == 'failure' }} shell: bash run: sleep 15 - name: Upload artifact (attempt 3/3) - if: ${{ always() && !cancelled() && steps.upload_1.outcome == 'failure' && steps.upload_2.outcome == 'failure' }} + if: ${{ !cancelled() && steps.upload_1.outcome == 'failure' && steps.upload_2.outcome == 'failure' }} uses: actions/upload-artifact@v7 with: name: ${{ inputs.name }} From cbf81f53f339ac77f9a305b1364549cef69d30ce Mon Sep 17 00:00:00 2001 From: XuPeng-SH Date: Mon, 24 Aug 2026 12:09:31 +0800 Subject: [PATCH 04/12] ci: isolate diagnostic artifact failures --- .github/workflows/e2e-compose-parallel.yaml | 22 +++++++++++-------- .../workflows/e2e-standalone-parallel.yaml | 22 +++++++++++-------- 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/.github/workflows/e2e-compose-parallel.yaml b/.github/workflows/e2e-compose-parallel.yaml index c346196..2fa8858 100644 --- a/.github/workflows/e2e-compose-parallel.yaml +++ b/.github/workflows/e2e-compose-parallel.yaml @@ -421,15 +421,6 @@ jobs: echo '```' } | tee -a "$GITHUB_STEP_SUMMARY" - - name: Upload Compose BVT execution log - if: ${{ always() && !cancelled() }} - uses: actions/upload-artifact@v7 - with: - name: bvt-execution-compose - path: ${{ runner.temp }}/bvt-compose.log - if-no-files-found: warn - retention-days: 7 - - name: Print Docker Info Before Container Shutdown if: ${{ always() }} run: | @@ -500,6 +491,19 @@ jobs: if-no-files-found: warn retention-days: 7 + - name: Upload Compose BVT execution log + if: ${{ always() && !cancelled() }} + # This log is diagnostic: publish it after the required coverage and + # do not turn a successful producer into a failed prerequisite. + continue-on-error: true + uses: matrixorigin/CI/actions/upload-artifact-with-retry@main + with: + name: bvt-execution-compose + path: ${{ runner.temp }}/bvt-compose.log + if-no-files-found: warn + retention-days: 7 + overwrite: true + - uses: actions/upload-artifact@v7 if: ${{ failure() || cancelled()}} continue-on-error: true diff --git a/.github/workflows/e2e-standalone-parallel.yaml b/.github/workflows/e2e-standalone-parallel.yaml index 5467362..f913592 100644 --- a/.github/workflows/e2e-standalone-parallel.yaml +++ b/.github/workflows/e2e-standalone-parallel.yaml @@ -466,15 +466,6 @@ jobs: echo '```' } | tee -a "$GITHUB_STEP_SUMMARY" - - name: Upload Compose + Pessimistic BVT execution log - if: ${{ always() && !cancelled() }} - uses: actions/upload-artifact@v7 - with: - name: bvt-execution-pessimistic - path: ${{ runner.temp }}/bvt-pessimistic.log - if-no-files-found: warn - retention-days: 7 - - name: Print Docker Info Before Container Shutdown if: ${{ always() }} run: | @@ -539,6 +530,19 @@ jobs: if-no-files-found: warn retention-days: 7 + - name: Upload Compose + Pessimistic BVT execution log + if: ${{ always() && !cancelled() }} + # This log is diagnostic: publish it after the required coverage and + # do not turn a successful producer into a failed prerequisite. + continue-on-error: true + uses: matrixorigin/CI/actions/upload-artifact-with-retry@main + with: + name: bvt-execution-pessimistic + path: ${{ runner.temp }}/bvt-pessimistic.log + if-no-files-found: warn + retention-days: 7 + overwrite: true + - name: generate upload files if: ${{ always() }} continue-on-error: true From 283f7f381cca1f26b988fbc29d7b3cd36ee05b9d Mon Sep 17 00:00:00 2001 From: XuPeng-SH Date: Mon, 24 Aug 2026 12:50:03 +0800 Subject: [PATCH 05/12] ci: improve artifact retry diagnostics --- .github/workflows/e2e-compose-parallel.yaml | 3 +- .../workflows/e2e-standalone-parallel.yaml | 3 +- .../upload-artifact-with-retry/action.yaml | 42 ++++++++++++++++++- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/.github/workflows/e2e-compose-parallel.yaml b/.github/workflows/e2e-compose-parallel.yaml index 2fa8858..9d8bfc1 100644 --- a/.github/workflows/e2e-compose-parallel.yaml +++ b/.github/workflows/e2e-compose-parallel.yaml @@ -498,11 +498,10 @@ jobs: continue-on-error: true uses: matrixorigin/CI/actions/upload-artifact-with-retry@main with: - name: bvt-execution-compose + name: bvt-execution-compose-attempt-${{ github.run_attempt }} path: ${{ runner.temp }}/bvt-compose.log if-no-files-found: warn retention-days: 7 - overwrite: true - uses: actions/upload-artifact@v7 if: ${{ failure() || cancelled()}} diff --git a/.github/workflows/e2e-standalone-parallel.yaml b/.github/workflows/e2e-standalone-parallel.yaml index f913592..b30343e 100644 --- a/.github/workflows/e2e-standalone-parallel.yaml +++ b/.github/workflows/e2e-standalone-parallel.yaml @@ -537,11 +537,10 @@ jobs: continue-on-error: true uses: matrixorigin/CI/actions/upload-artifact-with-retry@main with: - name: bvt-execution-pessimistic + name: bvt-execution-pessimistic-attempt-${{ github.run_attempt }} path: ${{ runner.temp }}/bvt-pessimistic.log if-no-files-found: warn retention-days: 7 - overwrite: true - name: generate upload files if: ${{ always() }} diff --git a/actions/upload-artifact-with-retry/action.yaml b/actions/upload-artifact-with-retry/action.yaml index 8f29ca9..a59775f 100644 --- a/actions/upload-artifact-with-retry/action.yaml +++ b/actions/upload-artifact-with-retry/action.yaml @@ -25,6 +25,17 @@ inputs: required: false default: "6" +outputs: + artifact-id: + description: GitHub ID of the artifact published by the successful attempt. + value: ${{ steps.upload_3.outcome == 'success' && steps.upload_3.outputs['artifact-id'] || steps.upload_2.outcome == 'success' && steps.upload_2.outputs['artifact-id'] || steps.upload_1.outcome == 'success' && steps.upload_1.outputs['artifact-id'] || '' }} + artifact-url: + description: Authenticated download URL of the artifact published by the successful attempt. + value: ${{ steps.upload_3.outcome == 'success' && steps.upload_3.outputs['artifact-url'] || steps.upload_2.outcome == 'success' && steps.upload_2.outputs['artifact-url'] || steps.upload_1.outcome == 'success' && steps.upload_1.outputs['artifact-url'] || '' }} + artifact-digest: + description: SHA-256 digest of the artifact published by the successful attempt. + value: ${{ steps.upload_3.outcome == 'success' && steps.upload_3.outputs['artifact-digest'] || steps.upload_2.outcome == 'success' && steps.upload_2.outputs['artifact-digest'] || steps.upload_1.outcome == 'success' && steps.upload_1.outputs['artifact-digest'] || '' }} + runs: using: composite steps: @@ -45,8 +56,11 @@ runs: - name: Back off before artifact upload retry 1 if: ${{ !cancelled() && steps.upload_1.outcome == 'failure' }} + continue-on-error: true shell: bash - run: sleep 5 + run: | + echo '::warning title=Artifact upload retry::Attempt 1/3 failed; retrying in 5 seconds.' + sleep 5 - name: Upload artifact (attempt 2/3) id: upload_2 @@ -63,13 +77,24 @@ runs: # already-finalized generation before publishing the retry. overwrite: true + - name: Report artifact upload recovery on attempt 2/3 + if: ${{ !cancelled() && steps.upload_1.outcome == 'failure' && steps.upload_2.outcome == 'success' }} + continue-on-error: true + shell: bash + run: echo '::notice title=Artifact upload recovered::Artifact publication succeeded on attempt 2/3.' + - name: Back off before artifact upload retry 2 if: ${{ !cancelled() && steps.upload_1.outcome == 'failure' && steps.upload_2.outcome == 'failure' }} + continue-on-error: true shell: bash - run: sleep 15 + run: | + echo '::warning title=Artifact upload retry::Attempt 2/3 failed; retrying in 15 seconds.' + sleep 15 - name: Upload artifact (attempt 3/3) if: ${{ !cancelled() && steps.upload_1.outcome == 'failure' && steps.upload_2.outcome == 'failure' }} + id: upload_3 + continue-on-error: true uses: actions/upload-artifact@v7 with: name: ${{ inputs.name }} @@ -78,3 +103,16 @@ runs: retention-days: ${{ inputs.retention-days }} compression-level: ${{ inputs.compression-level }} overwrite: true + + - name: Report artifact upload recovery on attempt 3/3 + if: ${{ !cancelled() && steps.upload_1.outcome == 'failure' && steps.upload_2.outcome == 'failure' && steps.upload_3.outcome == 'success' }} + continue-on-error: true + shell: bash + run: echo '::notice title=Artifact upload recovered::Artifact publication succeeded on attempt 3/3.' + + - name: Fail after exhausting artifact upload attempts + if: ${{ !cancelled() && steps.upload_1.outcome == 'failure' && steps.upload_2.outcome == 'failure' && steps.upload_3.outcome == 'failure' }} + shell: bash + run: | + echo '::error title=Artifact upload failed::All 3 attempts failed. See the preceding upload steps for the service error.' + exit 1 From efcc683e1547143e4bced91a96c7cfe8c2cb2a80 Mon Sep 17 00:00:00 2001 From: XuPeng-SH Date: Mon, 24 Aug 2026 12:54:03 +0800 Subject: [PATCH 06/12] ci: version failure diagnostics by attempt --- .github/workflows/coverage-merge.yaml | 5 +++-- .github/workflows/e2e-compose-parallel.yaml | 5 +++-- .github/workflows/e2e-standalone-parallel.yaml | 5 +++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/coverage-merge.yaml b/.github/workflows/coverage-merge.yaml index 64d3603..929787f 100644 --- a/.github/workflows/coverage-merge.yaml +++ b/.github/workflows/coverage-merge.yaml @@ -254,7 +254,7 @@ jobs: if [ "${{ steps.merge_coverage.outcome }}" = 'success' ]; then echo '- Result: `final-result-files` contains the changed-code profile, merged profile, summary, and PR coverage HTML.' >> "$GITHUB_STEP_SUMMARY" else - echo '- Diagnostics: `coverage-merge-debug` contains the input inventory and PR diff; `final-result-files` retains any merged outputs.' >> "$GITHUB_STEP_SUMMARY" + echo '- Diagnostics artifact (best-effort): `coverage-merge-debug-attempt-${{ github.run_attempt }}` contains the input inventory and PR diff when publication succeeds; `final-result-files` retains any merged outputs.' >> "$GITHUB_STEP_SUMMARY" fi - name: Upload coverage result if: ${{ always() && !cancelled() && steps.merge_coverage.outputs.has_pr_coverage == 'true' }} @@ -271,9 +271,10 @@ jobs: overwrite: true - name: Upload coverage merge diagnostics if: ${{ failure() && !cancelled() }} + continue-on-error: true uses: actions/upload-artifact@v7 with: - name: coverage-merge-debug + name: coverage-merge-debug-attempt-${{ github.run_attempt }} path: | ${{ runner.temp }}/coverage-inputs.txt ${{ runner.temp }}/coverage-parser.log diff --git a/.github/workflows/e2e-compose-parallel.yaml b/.github/workflows/e2e-compose-parallel.yaml index 9d8bfc1..72b67de 100644 --- a/.github/workflows/e2e-compose-parallel.yaml +++ b/.github/workflows/e2e-compose-parallel.yaml @@ -503,11 +503,12 @@ jobs: if-no-files-found: warn retention-days: 7 - - uses: actions/upload-artifact@v7 + - name: Upload Compose BVT failure diagnostics if: ${{ failure() || cancelled()}} continue-on-error: true + uses: actions/upload-artifact@v7 with: - name: Compose-multi-cn-e2e-bvt-test-docker-log(PROXY) + name: Compose-multi-cn-e2e-bvt-test-docker-log(PROXY)-attempt-${{ github.run_attempt }} path: | ${{ github.workspace }}/docker-compose-log retention-days: 7 diff --git a/.github/workflows/e2e-standalone-parallel.yaml b/.github/workflows/e2e-standalone-parallel.yaml index b30343e..7fe1d49 100644 --- a/.github/workflows/e2e-standalone-parallel.yaml +++ b/.github/workflows/e2e-standalone-parallel.yaml @@ -556,11 +556,12 @@ jobs: mv ${{ github.workspace }}/docker-compose-log ${{ github.workspace }}/upload/ fi - - uses: actions/upload-artifact@v7 + - name: Upload Compose + Pessimistic BVT failure diagnostics if: ${{ failure() || cancelled()}} continue-on-error: true + uses: actions/upload-artifact@v7 with: - name: Compose-multi-CN-e2e-BVT-Test-on-Linux-x64(PESSIMISTIC)-reports + name: Compose-multi-CN-e2e-BVT-Test-on-Linux-x64(PESSIMISTIC)-reports-attempt-${{ github.run_attempt }} path: | ${{ github.workspace }}/upload ${{ github.workspace }}/docker-compose-log From d6634879cef648721d9f18d093fd965ad882d5c7 Mon Sep 17 00:00:00 2001 From: XuPeng-SH Date: Mon, 24 Aug 2026 13:00:01 +0800 Subject: [PATCH 07/12] ci: report final artifact publication status --- .github/workflows/coverage-merge.yaml | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/workflows/coverage-merge.yaml b/.github/workflows/coverage-merge.yaml index 929787f..bf75c81 100644 --- a/.github/workflows/coverage-merge.yaml +++ b/.github/workflows/coverage-merge.yaml @@ -252,11 +252,12 @@ jobs: cat "${RUNNER_TEMP}/coverage-inputs.txt" >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true echo '```' >> "$GITHUB_STEP_SUMMARY" if [ "${{ steps.merge_coverage.outcome }}" = 'success' ]; then - echo '- Result: `final-result-files` contains the changed-code profile, merged profile, summary, and PR coverage HTML.' >> "$GITHUB_STEP_SUMMARY" + echo '- Result prepared for publication as `final-result-files`: changed-code profile, merged profile, summary, and PR coverage HTML.' >> "$GITHUB_STEP_SUMMARY" else echo '- Diagnostics artifact (best-effort): `coverage-merge-debug-attempt-${{ github.run_attempt }}` contains the input inventory and PR diff when publication succeeds; `final-result-files` retains any merged outputs.' >> "$GITHUB_STEP_SUMMARY" fi - name: Upload coverage result + id: upload_coverage_result if: ${{ always() && !cancelled() && steps.merge_coverage.outputs.has_pr_coverage == 'true' }} uses: matrixorigin/CI/actions/upload-artifact-with-retry@main with: @@ -269,6 +270,22 @@ jobs: if-no-files-found: error retention-days: 7 overwrite: true + - name: Report coverage artifact publication + if: ${{ always() && !cancelled() && steps.merge_coverage.outputs.has_pr_coverage == 'true' }} + continue-on-error: true + env: + UPLOAD_OUTCOME: ${{ steps.upload_coverage_result.outcome }} + ARTIFACT_URL: ${{ steps.upload_coverage_result.outputs.artifact-url }} + run: | + if [ "${UPLOAD_OUTCOME}" = 'success' ]; then + if [ -n "${ARTIFACT_URL}" ]; then + echo "- Artifact publication: succeeded ([download \`final-result-files\`](${ARTIFACT_URL}))." >> "$GITHUB_STEP_SUMMARY" + else + echo '- Artifact publication: succeeded as `final-result-files`.' >> "$GITHUB_STEP_SUMMARY" + fi + else + echo '- Artifact publication: failed after 3 attempts; see `Upload coverage result` for the underlying service errors.' >> "$GITHUB_STEP_SUMMARY" + fi - name: Upload coverage merge diagnostics if: ${{ failure() && !cancelled() }} continue-on-error: true From 7815779408963d89cb827de8c8f17cda5cdc0fda Mon Sep 17 00:00:00 2001 From: XuPeng-SH Date: Mon, 24 Aug 2026 13:05:27 +0800 Subject: [PATCH 08/12] ci: harden failure diagnostic uploads --- .github/workflows/coverage-ut.yaml | 1 + .github/workflows/e2e-compose-parallel.yaml | 5 +++-- .../workflows/e2e-standalone-parallel.yaml | 12 ++++++----- .github/workflows/e2e-upgrade.yaml | 20 +++++++++++-------- 4 files changed, 23 insertions(+), 15 deletions(-) diff --git a/.github/workflows/coverage-ut.yaml b/.github/workflows/coverage-ut.yaml index c511e95..2dc2316 100644 --- a/.github/workflows/coverage-ut.yaml +++ b/.github/workflows/coverage-ut.yaml @@ -435,6 +435,7 @@ jobs: retention-days: 7 - name: Upload failed UT diagnostics if: ${{ always() && !cancelled() && steps.coverage_ut.outcome == 'failure' }} + continue-on-error: true uses: actions/upload-artifact@v7 with: name: ut-coverage-diagnostics-attempt-${{ github.run_attempt }} diff --git a/.github/workflows/e2e-compose-parallel.yaml b/.github/workflows/e2e-compose-parallel.yaml index 72b67de..fdf8df0 100644 --- a/.github/workflows/e2e-compose-parallel.yaml +++ b/.github/workflows/e2e-compose-parallel.yaml @@ -187,11 +187,12 @@ jobs: docker compose -f etc/launch-tae-compose/compose.yaml --profile launch-multi-cn down --remove-orphans docker volume rm launch-tae-compose_minio_storage - - uses: actions/upload-artifact@v7 + - name: Upload optimistic Compose BVT failure diagnostics + uses: actions/upload-artifact@v7 if: ${{ failure() || cancelled()}} continue-on-error: true with: - name: Compose-multi-cn-e2e-bvt-test-docker-log(Optimistic,PUSH) + name: Compose-multi-cn-e2e-bvt-test-docker-log(Optimistic,PUSH)-attempt-${{ github.run_attempt }} path: | ${{ github.workspace }}/docker-compose-log retention-days: 7 diff --git a/.github/workflows/e2e-standalone-parallel.yaml b/.github/workflows/e2e-standalone-parallel.yaml index 7fe1d49..b3b57e0 100644 --- a/.github/workflows/e2e-standalone-parallel.yaml +++ b/.github/workflows/e2e-standalone-parallel.yaml @@ -142,11 +142,12 @@ jobs: mv ${{ github.workspace }}/head/mo-service.log ${{ github.workspace }}/upload/ mv ${{ github.workspace }}/mo-tester ${{ github.workspace }}/upload/ mv ${{ github.workspace }}/head/dump-stacks* ${{ github.workspace }}/upload/ - - uses: actions/upload-artifact@v7 + - name: Upload optimistic standalone BVT failure diagnostics + uses: actions/upload-artifact@v7 if: ${{ failure() || cancelled()}} continue-on-error: true with: - name: Standalone-e2e-BVT-Test-on-Linux-x64(LAUNCH,Optimistic)-reports + name: Standalone-e2e-BVT-Test-on-Linux-x64(LAUNCH,Optimistic)-reports-attempt-${{ github.run_attempt }} path: | ${{ github.workspace }}/upload retention-days: 7 @@ -155,7 +156,7 @@ jobs: if: ${{ failure() }} continue-on-error: true with: - name: Standalone-e2e-BVT-Test-on-Linux-x64(LAUNCH,Optimistic)-mo-data + name: Standalone-e2e-BVT-Test-on-Linux-x64(LAUNCH,Optimistic)-mo-data-attempt-${{ github.run_attempt }} path: | ${{ github.workspace }}/head/mo-data retention-days: 7 @@ -243,11 +244,12 @@ jobs: mv ${{ github.workspace }}/head/mo-service.log ${{ github.workspace }}/upload/ mv ${{ github.workspace }}/mo-tester ${{ github.workspace }}/upload/ mv ${{ github.workspace }}/head/dump-stacks.log ${{ github.workspace }}/upload/ - - uses: actions/upload-artifact@v7 + - name: Upload proxy standalone BVT failure diagnostics + uses: actions/upload-artifact@v7 if: ${{ failure() || cancelled()}} continue-on-error: true with: - name: Standalone-Multi-CN-e2e-BVT-Test-on-Linux-x64(LAUNCH,PROXY)-reports + name: Standalone-Multi-CN-e2e-BVT-Test-on-Linux-x64(LAUNCH,PROXY)-reports-attempt-${{ github.run_attempt }} path: | ${{ github.workspace }}/upload retention-days: 7 diff --git a/.github/workflows/e2e-upgrade.yaml b/.github/workflows/e2e-upgrade.yaml index 2154f94..760ceac 100644 --- a/.github/workflows/e2e-upgrade.yaml +++ b/.github/workflows/e2e-upgrade.yaml @@ -230,20 +230,22 @@ jobs: mv $GITHUB_WORKSPACE/upload/workspace/mo-service-upstream $GITHUB_WORKSPACE/upload-2/ mv $GITHUB_WORKSPACE/upload/workspace/mo-data $GITHUB_WORKSPACE/upload-2/ - - uses: actions/upload-artifact@v7 + - name: Upload compatibility target failure reports + uses: actions/upload-artifact@v7 if: ${{ failure() || cancelled()}} continue-on-error: true with: - name: Compatibility-Test-Target-on-Linux-x64(LAUNCH)-reports + name: Compatibility-Test-Target-on-Linux-x64(LAUNCH)-reports-attempt-${{ github.run_attempt }} path: | ${{ github.workspace }}/upload-1 retention-days: 7 - - uses: actions/upload-artifact@v7 + - name: Upload compatibility target failure data + uses: actions/upload-artifact@v7 if: ${{ failure() || cancelled()}} continue-on-error: true with: - name: Compatibility-Test-Target-on-Linux-x64(LAUNCH)-mo-data + name: Compatibility-Test-Target-on-Linux-x64(LAUNCH)-mo-data-attempt-${{ github.run_attempt }} path: | ${{ github.workspace }}/upload-2 retention-days: 7 @@ -482,20 +484,22 @@ jobs: mv $GITHUB_WORKSPACE/upload/workspace/mo-service-upstream $GITHUB_WORKSPACE/upload-2/ mv $GITHUB_WORKSPACE/upload/workspace/mo-data $GITHUB_WORKSPACE/upload-2/ - - uses: actions/upload-artifact@v7 + - name: Upload compatibility release failure reports + uses: actions/upload-artifact@v7 if: ${{ failure() || cancelled()}} continue-on-error: true with: - name: Compatibility-Test-Release-on-Linux-x64(LAUNCH)-reports + name: Compatibility-Test-Release-on-Linux-x64(LAUNCH)-reports-attempt-${{ github.run_attempt }} path: | ${{ github.workspace }}/upload-1 retention-days: 7 - - uses: actions/upload-artifact@v7 + - name: Upload compatibility release failure data + uses: actions/upload-artifact@v7 if: ${{ failure() || cancelled()}} continue-on-error: true with: - name: Compatibility-Test-Release-on-Linux-x64(LAUNCH)-mo-data + name: Compatibility-Test-Release-on-Linux-x64(LAUNCH)-mo-data-attempt-${{ github.run_attempt }} path: | ${{ github.workspace }}/upload-2 retention-days: 7 From 04b979b1e6bea3bdce168f158fc297e7b624399a Mon Sep 17 00:00:00 2001 From: XuPeng-SH Date: Mon, 24 Aug 2026 13:24:01 +0800 Subject: [PATCH 09/12] ci: enforce artifact retry workflow contracts --- .github/actionlint.yaml | 30 +++ .github/workflows/check-action-file.yaml | 36 ++- .github/workflows/coverage-merge.yaml | 2 +- .../upload-artifact-with-retry/action.yaml | 4 +- .../state-machine.schema.json | 223 ++++++++++++++++++ 5 files changed, 291 insertions(+), 4 deletions(-) create mode 100644 .github/actionlint.yaml create mode 100644 actions/upload-artifact-with-retry/state-machine.schema.json diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000..39759a4 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,30 @@ +self-hosted-runner: + labels: + - amd64-mo-guangzhou-2xlarge16 + - amd64-mo-guangzhou-2xlarge32 + - amd64-mo-guangzhou-medium8 + - amd64-mo-shanghai-4xlarge32 + - amd64-mo-shanghai-dind + - arm64-mo-guangzhou-2xlarge16 + - arm64-mo-shanghai-4c8g + - arm64-mo-shanghai-8c16g + - mac-arm64-mini + - macos-12 + - macos-13 + +paths: + .github/workflows/**/*.{yml,yaml}: + ignore: + # These jobs are deliberately retained but disabled for later reuse. + - 'constant expression "false" in condition' + # Reusable-workflow-only contexts are supplied by MatrixOne callers. + - 'property "workflow_(repository|sha)" is not defined' + .github/workflows/robot.yaml: + ignore: + # Existing legacy workflow; keep new changes clean without broadening + # this artifact-resilience PR into a robot migration. + - 'workflow command "set-output" was deprecated' + .github/workflows/utils.yaml: + ignore: + # Legacy release workflow pinned from MatrixOne at CI@release/3.0-dev. + - 'property "setup_mo_test_env" is not defined' diff --git a/.github/workflows/check-action-file.yaml b/.github/workflows/check-action-file.yaml index 7c87330..14585aa 100644 --- a/.github/workflows/check-action-file.yaml +++ b/.github/workflows/check-action-file.yaml @@ -7,7 +7,10 @@ on: - 'release/*' paths: - '.github/workflows/*.yaml' + - '.github/workflows/*.yml' + - '.github/actionlint.yaml' - 'actions/upload-artifact-with-retry/action.yaml' + - 'actions/upload-artifact-with-retry/state-machine.schema.json' permissions: contents: read @@ -18,6 +21,7 @@ jobs: permissions: contents: read runs-on: arm64-mo-shanghai-4c8g + timeout-minutes: 10 steps: - uses: actions/checkout@v6 - name: Check for any changed workflows @@ -26,14 +30,44 @@ jobs: with: files: | .github/workflows/**.yaml + .github/workflows/**.yml - name: Validate workflows if: steps.check-for-changed-workflows.outputs.any_changed == 'true' uses: dsanders11/json-schema-validate-action@v2.0.0 with: schema: https://json.schemastore.org/github-workflow.json - files: .github/workflows/**.yaml + files: | + .github/workflows/**.yaml + .github/workflows/**.yml + - name: Install actionlint + id: install-actionlint + env: + ACTIONLINT_VERSION: 1.7.12 + ACTIONLINT_SHA256: 325e971b6ba9bfa504672e29be93c24981eeb1c07576d730e9f7c8805afff0c6 + run: | + set -euo pipefail + archive="${RUNNER_TEMP}/actionlint.tar.gz" + install_dir="${RUNNER_TEMP}/actionlint-bin" + curl --fail --silent --show-error --location \ + --connect-timeout 10 --max-time 60 --retry 5 --retry-all-errors \ + "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_arm64.tar.gz" \ + --output "${archive}" + echo "${ACTIONLINT_SHA256} ${archive}" | sha256sum --check --strict + mkdir -p "${install_dir}" + tar -xzf "${archive}" -C "${install_dir}" actionlint + echo "executable=${install_dir}/actionlint" >> "$GITHUB_OUTPUT" + - name: Lint workflows + # Keep this gate focused on workflow/action semantics. The repository + # has a separate backlog of legacy shellcheck and pyflakes findings. + run: ${{ steps.install-actionlint.outputs.executable }} -shellcheck= -pyflakes= -color - name: Validate artifact retry action metadata uses: dsanders11/json-schema-validate-action@v2.0.0 with: schema: https://json.schemastore.org/github-action.json files: actions/upload-artifact-with-retry/action.yaml + - name: Validate artifact retry state machine + uses: dsanders11/json-schema-validate-action@v2.0.0 + with: + schema: ./actions/upload-artifact-with-retry/state-machine.schema.json + files: actions/upload-artifact-with-retry/action.yaml + all-errors: true diff --git a/.github/workflows/coverage-merge.yaml b/.github/workflows/coverage-merge.yaml index bf75c81..f5f99b4 100644 --- a/.github/workflows/coverage-merge.yaml +++ b/.github/workflows/coverage-merge.yaml @@ -284,7 +284,7 @@ jobs: echo '- Artifact publication: succeeded as `final-result-files`.' >> "$GITHUB_STEP_SUMMARY" fi else - echo '- Artifact publication: failed after 3 attempts; see `Upload coverage result` for the underlying service errors.' >> "$GITHUB_STEP_SUMMARY" + echo '- Artifact publication: failed after 3 attempts; see `Upload coverage result` for the underlying upload errors.' >> "$GITHUB_STEP_SUMMARY" fi - name: Upload coverage merge diagnostics if: ${{ failure() && !cancelled() }} diff --git a/actions/upload-artifact-with-retry/action.yaml b/actions/upload-artifact-with-retry/action.yaml index a59775f..6fa1f10 100644 --- a/actions/upload-artifact-with-retry/action.yaml +++ b/actions/upload-artifact-with-retry/action.yaml @@ -1,5 +1,5 @@ name: Upload artifact with retry -description: Retry transient GitHub artifact-service failures without rerunning producer work. +description: Retry artifact publication failures without rerunning producer work. inputs: name: @@ -114,5 +114,5 @@ runs: if: ${{ !cancelled() && steps.upload_1.outcome == 'failure' && steps.upload_2.outcome == 'failure' && steps.upload_3.outcome == 'failure' }} shell: bash run: | - echo '::error title=Artifact upload failed::All 3 attempts failed. See the preceding upload steps for the service error.' + echo '::error title=Artifact upload failed::All 3 attempts failed. See the preceding upload steps for the root error.' exit 1 diff --git a/actions/upload-artifact-with-retry/state-machine.schema.json b/actions/upload-artifact-with-retry/state-machine.schema.json new file mode 100644 index 0000000..03958eb --- /dev/null +++ b/actions/upload-artifact-with-retry/state-machine.schema.json @@ -0,0 +1,223 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Upload artifact retry state-machine contract", + "type": "object", + "required": ["inputs", "outputs", "runs"], + "properties": { + "inputs": { + "type": "object", + "required": [ + "name", + "path", + "if-no-files-found", + "retention-days", + "overwrite", + "compression-level" + ], + "properties": { + "name": { + "type": "object", + "required": ["required"], + "properties": { + "required": {"const": true} + } + }, + "path": { + "type": "object", + "required": ["required"], + "properties": { + "required": {"const": true} + } + } + } + }, + "outputs": { + "type": "object", + "required": ["artifact-id", "artifact-url", "artifact-digest"], + "properties": { + "artifact-id": { + "type": "object", + "required": ["value"], + "properties": { + "value": { + "const": "${{ steps.upload_3.outcome == 'success' && steps.upload_3.outputs['artifact-id'] || steps.upload_2.outcome == 'success' && steps.upload_2.outputs['artifact-id'] || steps.upload_1.outcome == 'success' && steps.upload_1.outputs['artifact-id'] || '' }}" + } + } + }, + "artifact-url": { + "type": "object", + "required": ["value"], + "properties": { + "value": { + "const": "${{ steps.upload_3.outcome == 'success' && steps.upload_3.outputs['artifact-url'] || steps.upload_2.outcome == 'success' && steps.upload_2.outputs['artifact-url'] || steps.upload_1.outcome == 'success' && steps.upload_1.outputs['artifact-url'] || '' }}" + } + } + }, + "artifact-digest": { + "type": "object", + "required": ["value"], + "properties": { + "value": { + "const": "${{ steps.upload_3.outcome == 'success' && steps.upload_3.outputs['artifact-digest'] || steps.upload_2.outcome == 'success' && steps.upload_2.outputs['artifact-digest'] || steps.upload_1.outcome == 'success' && steps.upload_1.outputs['artifact-digest'] || '' }}" + } + } + } + } + }, + "runs": { + "type": "object", + "required": ["using", "steps"], + "properties": { + "using": {"const": "composite"}, + "steps": { + "type": "array", + "minItems": 8, + "maxItems": 8, + "items": [ + { + "allOf": [ + {"$ref": "#/definitions/upload"}, + { + "properties": { + "id": {"const": "upload_1"}, + "if": {"const": "${{ !cancelled() }}"}, + "with": { + "properties": { + "overwrite": {"const": "${{ inputs.overwrite }}"} + } + } + } + } + ] + }, + { + "allOf": [ + {"$ref": "#/definitions/best-effort-shell"}, + { + "properties": { + "if": {"const": "${{ !cancelled() && steps.upload_1.outcome == 'failure' }}"}, + "run": {"type": "string", "pattern": "sleep 5"} + } + } + ] + }, + { + "allOf": [ + {"$ref": "#/definitions/upload"}, + { + "properties": { + "id": {"const": "upload_2"}, + "if": {"const": "${{ !cancelled() && steps.upload_1.outcome == 'failure' }}"}, + "with": { + "properties": { + "overwrite": {"const": true} + } + } + } + } + ] + }, + { + "allOf": [ + {"$ref": "#/definitions/best-effort-shell"}, + { + "properties": { + "if": {"const": "${{ !cancelled() && steps.upload_1.outcome == 'failure' && steps.upload_2.outcome == 'success' }}"}, + "run": {"type": "string", "pattern": "succeeded on attempt 2/3"} + } + } + ] + }, + { + "allOf": [ + {"$ref": "#/definitions/best-effort-shell"}, + { + "properties": { + "if": {"const": "${{ !cancelled() && steps.upload_1.outcome == 'failure' && steps.upload_2.outcome == 'failure' }}"}, + "run": {"type": "string", "pattern": "sleep 15"} + } + } + ] + }, + { + "allOf": [ + {"$ref": "#/definitions/upload"}, + { + "properties": { + "id": {"const": "upload_3"}, + "if": {"const": "${{ !cancelled() && steps.upload_1.outcome == 'failure' && steps.upload_2.outcome == 'failure' }}"}, + "with": { + "properties": { + "overwrite": {"const": true} + } + } + } + } + ] + }, + { + "allOf": [ + {"$ref": "#/definitions/best-effort-shell"}, + { + "properties": { + "if": {"const": "${{ !cancelled() && steps.upload_1.outcome == 'failure' && steps.upload_2.outcome == 'failure' && steps.upload_3.outcome == 'success' }}"}, + "run": {"type": "string", "pattern": "succeeded on attempt 3/3"} + } + } + ] + }, + { + "type": "object", + "required": ["if", "shell", "run"], + "not": {"required": ["continue-on-error"]}, + "properties": { + "if": {"const": "${{ !cancelled() && steps.upload_1.outcome == 'failure' && steps.upload_2.outcome == 'failure' && steps.upload_3.outcome == 'failure' }}"}, + "shell": {"const": "bash"}, + "run": {"type": "string", "pattern": "exit 1"} + } + } + ], + "additionalItems": false + } + } + } + }, + "definitions": { + "upload": { + "type": "object", + "required": ["id", "if", "continue-on-error", "uses", "with"], + "properties": { + "continue-on-error": {"const": true}, + "uses": {"const": "actions/upload-artifact@v7"}, + "with": { + "type": "object", + "required": [ + "name", + "path", + "if-no-files-found", + "retention-days", + "overwrite", + "compression-level" + ], + "properties": { + "name": {"const": "${{ inputs.name }}"}, + "path": {"const": "${{ inputs.path }}"}, + "if-no-files-found": {"const": "${{ inputs.if-no-files-found }}"}, + "retention-days": {"const": "${{ inputs.retention-days }}"}, + "overwrite": {}, + "compression-level": {"const": "${{ inputs.compression-level }}"} + }, + "additionalProperties": false + } + } + }, + "best-effort-shell": { + "type": "object", + "required": ["if", "continue-on-error", "shell", "run"], + "properties": { + "continue-on-error": {"const": true}, + "shell": {"const": "bash"} + } + } + } +} From b46ddbb106a4978dfa03456ae66b1c00f4149c55 Mon Sep 17 00:00:00 2001 From: XuPeng-SH Date: Mon, 24 Aug 2026 13:45:27 +0800 Subject: [PATCH 10/12] ci: harden workflow validation signals --- .github/actionlint.yaml | 23 ++++++-- .github/workflows/check-action-file.yaml | 54 +++++++++++++++++++ .github/workflows/ci.yaml | 2 +- .github/workflows/release.yaml | 2 +- .../state-machine.schema.json | 28 ++++++++-- 5 files changed, 100 insertions(+), 9 deletions(-) diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml index 39759a4..5e38f26 100644 --- a/.github/actionlint.yaml +++ b/.github/actionlint.yaml @@ -9,16 +9,30 @@ self-hosted-runner: - arm64-mo-shanghai-4c8g - arm64-mo-shanghai-8c16g - mac-arm64-mini - - macos-12 - - macos-13 paths: - .github/workflows/**/*.{yml,yaml}: + .github/workflows/ci.yaml: ignore: - # These jobs are deliberately retained but disabled for later reuse. - 'constant expression "false" in condition' + .github/workflows/coverage-ut.yaml: + ignore: # Reusable-workflow-only contexts are supplied by MatrixOne callers. - 'property "workflow_(repository|sha)" is not defined' + .github/workflows/e2e-compose-parallel.yaml: + ignore: + - 'constant expression "false" in condition' + .github/workflows/e2e-compose.yaml: + ignore: + - 'constant expression "false" in condition' + .github/workflows/e2e-standalone-parallel.yaml: + ignore: + - 'constant expression "false" in condition' + .github/workflows/e2e-standalone.yaml: + ignore: + - 'constant expression "false" in condition' + .github/workflows/e2e-upgrade.yaml: + ignore: + - 'constant expression "false" in condition' .github/workflows/robot.yaml: ignore: # Existing legacy workflow; keep new changes clean without broadening @@ -26,5 +40,6 @@ paths: - 'workflow command "set-output" was deprecated' .github/workflows/utils.yaml: ignore: + - 'constant expression "false" in condition' # Legacy release workflow pinned from MatrixOne at CI@release/3.0-dev. - 'property "setup_mo_test_env" is not defined' diff --git a/.github/workflows/check-action-file.yaml b/.github/workflows/check-action-file.yaml index 14585aa..c1f56ec 100644 --- a/.github/workflows/check-action-file.yaml +++ b/.github/workflows/check-action-file.yaml @@ -60,6 +60,60 @@ jobs: # Keep this gate focused on workflow/action semantics. The repository # has a separate backlog of legacy shellcheck and pyflakes findings. run: ${{ steps.install-actionlint.outputs.executable }} -shellcheck= -pyflakes= -color + - name: Guard deliberate workflow disablements + env: + ACTIONLINT: ${{ steps.install-actionlint.outputs.executable }} + shell: bash + run: | + set -euo pipefail + # Record semantic locations and a short source hash, not line + # numbers, so unrelated edits do not churn the baseline. + expected="$(printf '%s\n' \ + '.github/workflows/ci.yaml:ut-mac-x86:9:a81c7d5095cf' \ + '.github/workflows/e2e-compose-parallel.yaml:bvt-docker-compose-push:9:57d5b9f8aae4' \ + '.github/workflows/e2e-compose.yaml:bvt-docker-compose-push:9:57d5b9f8aae4' \ + '.github/workflows/e2e-standalone-parallel.yaml:bvt-linux-x86:9:57d5b9f8aae4' \ + '.github/workflows/e2e-standalone-parallel.yaml:multi-cn-proxy-bvt-linux-x86:9:57d5b9f8aae4' \ + '.github/workflows/e2e-standalone.yaml:bvt-linux-x86:9:57d5b9f8aae4' \ + '.github/workflows/e2e-upgrade.yaml:upgrade-ci-release-linux-amd64:9:57d5b9f8aae4' \ + '.github/workflows/e2e-upgrade.yaml:upgrade-ci-target-linux-amd64:9:57d5b9f8aae4' \ + '.github/workflows/utils.yaml:pr_basic_benchmarks:9:57d5b9f8aae4' + )" + findings="$( + "${ACTIONLINT}" -config-file /dev/null -shellcheck= -pyflakes= \ + -format '{{range .}}{{.Filepath}}\t{{.Line}}\t{{.Column}}\t{{.Kind}}\t{{.Message}}\n{{end}}' \ + 2>/dev/null || true + )" + actual="$( + while IFS=$'\t' read -r file line column kind message; do + if [ "${kind}" != 'if-cond' ]; then + continue + fi + metadata="$(awk -v target="${line}" ' + /^ [A-Za-z0-9_-]+:$/ { + job = $1 + sub(/:$/, "", job) + } + NR == target { + source = $0 + sub(/^[[:space:]]+/, "", source) + sub(/[[:space:]]+$/, "", source) + print job "\t" source + exit + } + ' "${file}")" + job="${metadata%%$'\t'*}" + source="${metadata#*$'\t'}" + digest="$(printf '%s' "${source}" | sha256sum | cut -c1-12)" + printf '%s:%s:%s:%s\n' "${file}" "${job}" "${column}" "${digest}" + done <<< "${findings}" | LC_ALL=C sort + )" + if ! diff -u <(printf '%s\n' "${expected}") <(printf '%s\n' "${actual}"); then + echo '::error title=Workflow disablement drift::The set of constant-false conditions changed. Remove accidental disablements, or update the explicit baseline when the change is intentional.' + printf '%s\n' "${findings}" | + awk -F '\t' '$4 == "if-cond" { print $1 ":" $2 ":" $3 ": " $5 }' + exit 1 + fi - name: Validate artifact retry action metadata uses: dsanders11/json-schema-validate-action@v2.0.0 with: diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4229ecc..6d15422 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -304,7 +304,7 @@ jobs: #if: ${{ !github.event.pull_request.draft }} if: ${{ false }} environment: ci - runs-on: macos-12 + runs-on: macos-15-intel name: UT Test on Darwin/x86 steps: diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 87c1d04..58099a2 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -158,7 +158,7 @@ jobs: darwin-x86-build-job: environment: ci - runs-on: macos-13 + runs-on: macos-15-intel steps: # GitHub Hosted MacOS runner cannot connect to time.apple.com, so use time.windows.com - name: Sync System Time diff --git a/actions/upload-artifact-with-retry/state-machine.schema.json b/actions/upload-artifact-with-retry/state-machine.schema.json index 03958eb..459541d 100644 --- a/actions/upload-artifact-with-retry/state-machine.schema.json +++ b/actions/upload-artifact-with-retry/state-machine.schema.json @@ -28,7 +28,11 @@ "properties": { "required": {"const": true} } - } + }, + "if-no-files-found": {}, + "retention-days": {}, + "overwrite": {}, + "compression-level": {} } }, "outputs": { @@ -78,10 +82,12 @@ "allOf": [ {"$ref": "#/definitions/upload"}, { + "type": "object", "properties": { "id": {"const": "upload_1"}, "if": {"const": "${{ !cancelled() }}"}, "with": { + "type": "object", "properties": { "overwrite": {"const": "${{ inputs.overwrite }}"} } @@ -94,6 +100,7 @@ "allOf": [ {"$ref": "#/definitions/best-effort-shell"}, { + "type": "object", "properties": { "if": {"const": "${{ !cancelled() && steps.upload_1.outcome == 'failure' }}"}, "run": {"type": "string", "pattern": "sleep 5"} @@ -105,10 +112,12 @@ "allOf": [ {"$ref": "#/definitions/upload"}, { + "type": "object", "properties": { "id": {"const": "upload_2"}, "if": {"const": "${{ !cancelled() && steps.upload_1.outcome == 'failure' }}"}, "with": { + "type": "object", "properties": { "overwrite": {"const": true} } @@ -121,6 +130,7 @@ "allOf": [ {"$ref": "#/definitions/best-effort-shell"}, { + "type": "object", "properties": { "if": {"const": "${{ !cancelled() && steps.upload_1.outcome == 'failure' && steps.upload_2.outcome == 'success' }}"}, "run": {"type": "string", "pattern": "succeeded on attempt 2/3"} @@ -132,6 +142,7 @@ "allOf": [ {"$ref": "#/definitions/best-effort-shell"}, { + "type": "object", "properties": { "if": {"const": "${{ !cancelled() && steps.upload_1.outcome == 'failure' && steps.upload_2.outcome == 'failure' }}"}, "run": {"type": "string", "pattern": "sleep 15"} @@ -143,10 +154,12 @@ "allOf": [ {"$ref": "#/definitions/upload"}, { + "type": "object", "properties": { "id": {"const": "upload_3"}, "if": {"const": "${{ !cancelled() && steps.upload_1.outcome == 'failure' && steps.upload_2.outcome == 'failure' }}"}, "with": { + "type": "object", "properties": { "overwrite": {"const": true} } @@ -159,6 +172,7 @@ "allOf": [ {"$ref": "#/definitions/best-effort-shell"}, { + "type": "object", "properties": { "if": {"const": "${{ !cancelled() && steps.upload_1.outcome == 'failure' && steps.upload_2.outcome == 'failure' && steps.upload_3.outcome == 'success' }}"}, "run": {"type": "string", "pattern": "succeeded on attempt 3/3"} @@ -169,7 +183,11 @@ { "type": "object", "required": ["if", "shell", "run"], - "not": {"required": ["continue-on-error"]}, + "not": { + "type": "object", + "required": ["continue-on-error"], + "properties": {"continue-on-error": {}} + }, "properties": { "if": {"const": "${{ !cancelled() && steps.upload_1.outcome == 'failure' && steps.upload_2.outcome == 'failure' && steps.upload_3.outcome == 'failure' }}"}, "shell": {"const": "bash"}, @@ -187,6 +205,8 @@ "type": "object", "required": ["id", "if", "continue-on-error", "uses", "with"], "properties": { + "id": {}, + "if": {}, "continue-on-error": {"const": true}, "uses": {"const": "actions/upload-artifact@v7"}, "with": { @@ -215,8 +235,10 @@ "type": "object", "required": ["if", "continue-on-error", "shell", "run"], "properties": { + "if": {}, "continue-on-error": {"const": true}, - "shell": {"const": "bash"} + "shell": {"const": "bash"}, + "run": {} } } } From 82dd114ca0948cd19e0af94eb353d3b42ed4b84e Mon Sep 17 00:00:00 2001 From: XuPeng-SH Date: Mon, 24 Aug 2026 14:58:42 +0800 Subject: [PATCH 11/12] ci: harden workflow supply chain contracts --- .github/workflows/check-action-file.yaml | 18 ++---- .../upload-artifact-with-retry/action.yaml | 6 +- .../state-machine.schema.json | 58 +++++++++++++++---- 3 files changed, 57 insertions(+), 25 deletions(-) diff --git a/.github/workflows/check-action-file.yaml b/.github/workflows/check-action-file.yaml index c1f56ec..aa79f55 100644 --- a/.github/workflows/check-action-file.yaml +++ b/.github/workflows/check-action-file.yaml @@ -23,17 +23,11 @@ jobs: runs-on: arm64-mo-shanghai-4c8g timeout-minutes: 10 steps: - - uses: actions/checkout@v6 - - name: Check for any changed workflows - id: check-for-changed-workflows - uses: tj-actions/changed-files@v47 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: - files: | - .github/workflows/**.yaml - .github/workflows/**.yml - - name: Validate workflows - if: steps.check-for-changed-workflows.outputs.any_changed == 'true' - uses: dsanders11/json-schema-validate-action@v2.0.0 + persist-credentials: false + - name: Validate all workflows + uses: dsanders11/json-schema-validate-action@eddf079f55830cc9a916a3c512ba9086240d2fea # v2.0.0 with: schema: https://json.schemastore.org/github-workflow.json files: | @@ -115,12 +109,12 @@ jobs: exit 1 fi - name: Validate artifact retry action metadata - uses: dsanders11/json-schema-validate-action@v2.0.0 + uses: dsanders11/json-schema-validate-action@eddf079f55830cc9a916a3c512ba9086240d2fea # v2.0.0 with: schema: https://json.schemastore.org/github-action.json files: actions/upload-artifact-with-retry/action.yaml - name: Validate artifact retry state machine - uses: dsanders11/json-schema-validate-action@v2.0.0 + uses: dsanders11/json-schema-validate-action@eddf079f55830cc9a916a3c512ba9086240d2fea # v2.0.0 with: schema: ./actions/upload-artifact-with-retry/state-machine.schema.json files: actions/upload-artifact-with-retry/action.yaml diff --git a/actions/upload-artifact-with-retry/action.yaml b/actions/upload-artifact-with-retry/action.yaml index 6fa1f10..5d274f7 100644 --- a/actions/upload-artifact-with-retry/action.yaml +++ b/actions/upload-artifact-with-retry/action.yaml @@ -45,7 +45,7 @@ runs: # failing before it entered this composite action. if: ${{ !cancelled() }} continue-on-error: true - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ${{ inputs.name }} path: ${{ inputs.path }} @@ -66,7 +66,7 @@ runs: id: upload_2 if: ${{ !cancelled() && steps.upload_1.outcome == 'failure' }} continue-on-error: true - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ${{ inputs.name }} path: ${{ inputs.path }} @@ -95,7 +95,7 @@ runs: if: ${{ !cancelled() && steps.upload_1.outcome == 'failure' && steps.upload_2.outcome == 'failure' }} id: upload_3 continue-on-error: true - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ${{ inputs.name }} path: ${{ inputs.path }} diff --git a/actions/upload-artifact-with-retry/state-machine.schema.json b/actions/upload-artifact-with-retry/state-machine.schema.json index 459541d..c365e85 100644 --- a/actions/upload-artifact-with-retry/state-machine.schema.json +++ b/actions/upload-artifact-with-retry/state-machine.schema.json @@ -18,6 +18,11 @@ "name": { "type": "object", "required": ["required"], + "not": { + "type": "object", + "required": ["default"], + "properties": {"default": {}} + }, "properties": { "required": {"const": true} } @@ -25,14 +30,47 @@ "path": { "type": "object", "required": ["required"], + "not": { + "type": "object", + "required": ["default"], + "properties": {"default": {}} + }, "properties": { "required": {"const": true} } }, - "if-no-files-found": {}, - "retention-days": {}, - "overwrite": {}, - "compression-level": {} + "if-no-files-found": { + "type": "object", + "required": ["required", "default"], + "properties": { + "required": {"const": false}, + "default": {"const": "error"} + } + }, + "retention-days": { + "type": "object", + "required": ["required", "default"], + "properties": { + "required": {"const": false}, + "default": {"const": "7"} + } + }, + "overwrite": { + "type": "object", + "required": ["required", "default"], + "properties": { + "required": {"const": false}, + "default": {"const": "false"} + } + }, + "compression-level": { + "type": "object", + "required": ["required", "default"], + "properties": { + "required": {"const": false}, + "default": {"const": "6"} + } + } } }, "outputs": { @@ -103,7 +141,7 @@ "type": "object", "properties": { "if": {"const": "${{ !cancelled() && steps.upload_1.outcome == 'failure' }}"}, - "run": {"type": "string", "pattern": "sleep 5"} + "run": {"const": "echo '::warning title=Artifact upload retry::Attempt 1/3 failed; retrying in 5 seconds.'\nsleep 5\n"} } } ] @@ -133,7 +171,7 @@ "type": "object", "properties": { "if": {"const": "${{ !cancelled() && steps.upload_1.outcome == 'failure' && steps.upload_2.outcome == 'success' }}"}, - "run": {"type": "string", "pattern": "succeeded on attempt 2/3"} + "run": {"const": "echo '::notice title=Artifact upload recovered::Artifact publication succeeded on attempt 2/3.'"} } } ] @@ -145,7 +183,7 @@ "type": "object", "properties": { "if": {"const": "${{ !cancelled() && steps.upload_1.outcome == 'failure' && steps.upload_2.outcome == 'failure' }}"}, - "run": {"type": "string", "pattern": "sleep 15"} + "run": {"const": "echo '::warning title=Artifact upload retry::Attempt 2/3 failed; retrying in 15 seconds.'\nsleep 15\n"} } } ] @@ -175,7 +213,7 @@ "type": "object", "properties": { "if": {"const": "${{ !cancelled() && steps.upload_1.outcome == 'failure' && steps.upload_2.outcome == 'failure' && steps.upload_3.outcome == 'success' }}"}, - "run": {"type": "string", "pattern": "succeeded on attempt 3/3"} + "run": {"const": "echo '::notice title=Artifact upload recovered::Artifact publication succeeded on attempt 3/3.'"} } } ] @@ -191,7 +229,7 @@ "properties": { "if": {"const": "${{ !cancelled() && steps.upload_1.outcome == 'failure' && steps.upload_2.outcome == 'failure' && steps.upload_3.outcome == 'failure' }}"}, "shell": {"const": "bash"}, - "run": {"type": "string", "pattern": "exit 1"} + "run": {"const": "echo '::error title=Artifact upload failed::All 3 attempts failed. See the preceding upload steps for the root error.'\nexit 1\n"} } } ], @@ -208,7 +246,7 @@ "id": {}, "if": {}, "continue-on-error": {"const": true}, - "uses": {"const": "actions/upload-artifact@v7"}, + "uses": {"const": "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a"}, "with": { "type": "object", "required": [ From 0d9fc847c991f1a5e5fa59c89f21d9fdc24a330f Mon Sep 17 00:00:00 2001 From: XuPeng-SH Date: Mon, 24 Aug 2026 15:34:10 +0800 Subject: [PATCH 12/12] ci: harden artifact retry generations --- .github/workflows/check-action-file.yaml | 4 + .github/workflows/coverage-merge.yaml | 50 +++- .../upload-artifact-with-retry/action.yaml | 31 ++- .../state-machine.schema.json | 96 ++++++-- scripts/select_coverage_artifacts.py | 185 ++++++++++++++ scripts/test_select_coverage_artifacts.py | 230 ++++++++++++++++++ 6 files changed, 553 insertions(+), 43 deletions(-) create mode 100644 scripts/select_coverage_artifacts.py create mode 100644 scripts/test_select_coverage_artifacts.py diff --git a/.github/workflows/check-action-file.yaml b/.github/workflows/check-action-file.yaml index aa79f55..5969eaf 100644 --- a/.github/workflows/check-action-file.yaml +++ b/.github/workflows/check-action-file.yaml @@ -11,6 +11,8 @@ on: - '.github/actionlint.yaml' - 'actions/upload-artifact-with-retry/action.yaml' - 'actions/upload-artifact-with-retry/state-machine.schema.json' + - 'scripts/select_coverage_artifacts.py' + - 'scripts/test_select_coverage_artifacts.py' permissions: contents: read @@ -54,6 +56,8 @@ jobs: # Keep this gate focused on workflow/action semantics. The repository # has a separate backlog of legacy shellcheck and pyflakes findings. run: ${{ steps.install-actionlint.outputs.executable }} -shellcheck= -pyflakes= -color + - name: Test coverage artifact generation selection + run: python3 scripts/test_select_coverage_artifacts.py -v - name: Guard deliberate workflow disablements env: ACTIONLINT: ${{ steps.install-actionlint.outputs.executable }} diff --git a/.github/workflows/coverage-merge.yaml b/.github/workflows/coverage-merge.yaml index f5f99b4..97482f3 100644 --- a/.github/workflows/coverage-merge.yaml +++ b/.github/workflows/coverage-merge.yaml @@ -87,23 +87,35 @@ jobs: "https://api.github.com/repos/${pr_repo}/pulls/${pr_number}" -o diff.patch fi - name: Download test coverage artifacts - uses: actions/download-artifact@v7 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: # A full rerun rotates the shared generation. Filter out artifacts # left by previous attempts while retaining successful producers # from an earlier attempt during "Re-run failed jobs". pattern: ${{ inputs.expected_bvt_generation != '' && format('*coverage*generation-{0}-*', inputs.expected_bvt_generation) || '*coverage*' }} - merge-multiple: true - path: ${{ github.workspace }}/coverage-artifacts + # Keep attempts isolated. Concurrent extraction into one directory + # makes same-name profiles overwrite each other nondeterministically. + merge-multiple: false + path: ${{ runner.temp }}/coverage-artifacts + - name: Select latest coverage producer attempts + env: + EXPECTED_BVT_GENERATION: ${{ inputs.expected_bvt_generation }} + run: | + set -euo pipefail + find "${RUNNER_TEMP}/coverage-artifacts" -type f -printf '%P %s bytes\n' | sort \ + | tee "${RUNNER_TEMP}/coverage-inputs.txt" + python3 "$GITHUB_WORKSPACE/CI/scripts/select_coverage_artifacts.py" \ + --input "${RUNNER_TEMP}/coverage-artifacts" \ + --output "${RUNNER_TEMP}/coverage-selected" \ + --expected-generation "${EXPECTED_BVT_GENERATION}" \ + --manifest "${RUNNER_TEMP}/coverage-selected-artifacts.json" - name: Merge profiles and check coverage rate id: merge_coverage run: | set -euo pipefail - artifact_dir="$GITHUB_WORKSPACE/coverage-artifacts" - processed_dir="$GITHUB_WORKSPACE/coverage-processed" + artifact_dir="${RUNNER_TEMP}/coverage-selected" + processed_dir="${RUNNER_TEMP}/coverage-processed" mkdir -p "${processed_dir}" - find "${artifact_dir}" -type f -printf '%P %s bytes\n' | sort \ - | tee "${RUNNER_TEMP}/coverage-inputs.txt" mapfile -t ut_profiles < <(find "${artifact_dir}" -type f -name 'ut-coverage.out' | sort) mapfile -t bvt_profiles < <(find "${artifact_dir}" -type f -name 'bvt-*.out' | sort) @@ -199,7 +211,11 @@ jobs: set -euo pipefail echo '### Coverage merge result' >> "$GITHUB_STEP_SUMMARY" echo "- Merge step: \`${{ steps.merge_coverage.outcome }}\`" >> "$GITHUB_STEP_SUMMARY" - echo '- Expected inputs: one UT profile and two BVT profiles.' >> "$GITHUB_STEP_SUMMARY" + if [ '${{ github.base_ref }}' = 'main' ]; then + echo '- Expected inputs: one UT profile and two BVT profiles.' >> "$GITHUB_STEP_SUMMARY" + else + echo '- Expected inputs: one UT profile and either zero or two BVT profiles for legacy branches.' >> "$GITHUB_STEP_SUMMARY" + fi summary="$GITHUB_WORKSPACE/coverage-summary.json" if [ -s "${summary}" ]; then overall=$(jq -r '.overall_coverage' "${summary}") @@ -246,15 +262,26 @@ jobs: echo '```' >> "$GITHUB_STEP_SUMMARY" fi echo '' >> "$GITHUB_STEP_SUMMARY" - echo '#### Downloaded profiles' >> "$GITHUB_STEP_SUMMARY" + echo '#### Downloaded coverage artifacts' >> "$GITHUB_STEP_SUMMARY" echo '' >> "$GITHUB_STEP_SUMMARY" echo '```text' >> "$GITHUB_STEP_SUMMARY" cat "${RUNNER_TEMP}/coverage-inputs.txt" >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true echo '```' >> "$GITHUB_STEP_SUMMARY" - if [ "${{ steps.merge_coverage.outcome }}" = 'success' ]; then + if [ -s "${RUNNER_TEMP}/coverage-selected-artifacts.json" ]; then + echo '' >> "$GITHUB_STEP_SUMMARY" + echo '#### Selected producer attempts' >> "$GITHUB_STEP_SUMMARY" + echo '' >> "$GITHUB_STEP_SUMMARY" + echo '```json' >> "$GITHUB_STEP_SUMMARY" + jq . "${RUNNER_TEMP}/coverage-selected-artifacts.json" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + fi + merge_outcome='${{ steps.merge_coverage.outcome }}' + if [ "${merge_outcome}" = 'success' ]; then echo '- Result prepared for publication as `final-result-files`: changed-code profile, merged profile, summary, and PR coverage HTML.' >> "$GITHUB_STEP_SUMMARY" + elif [ "${merge_outcome}" = 'failure' ]; then + echo '- Diagnostics artifact (best-effort): `coverage-merge-debug-attempt-${{ github.run_attempt }}` contains the input inventory and PR diff when publication succeeds; any merged outputs are prepared for publication as `final-result-files`.' >> "$GITHUB_STEP_SUMMARY" else - echo '- Diagnostics artifact (best-effort): `coverage-merge-debug-attempt-${{ github.run_attempt }}` contains the input inventory and PR diff when publication succeeds; `final-result-files` retains any merged outputs.' >> "$GITHUB_STEP_SUMMARY" + echo "- Result not prepared because the merge step was \`${merge_outcome}\`. See the first failed prerequisite or artifact-selection step." >> "$GITHUB_STEP_SUMMARY" fi - name: Upload coverage result id: upload_coverage_result @@ -296,6 +323,7 @@ jobs: ${{ runner.temp }}/coverage-inputs.txt ${{ runner.temp }}/coverage-parser.log ${{ runner.temp }}/bvt-group-manifests.json + ${{ runner.temp }}/coverage-selected-artifacts.json ${{ github.workspace }}/matrixone/diff.patch ${{ github.workspace }}/coverage-summary.json if-no-files-found: warn diff --git a/actions/upload-artifact-with-retry/action.yaml b/actions/upload-artifact-with-retry/action.yaml index 5d274f7..467073d 100644 --- a/actions/upload-artifact-with-retry/action.yaml +++ b/actions/upload-artifact-with-retry/action.yaml @@ -58,9 +58,12 @@ runs: if: ${{ !cancelled() && steps.upload_1.outcome == 'failure' }} continue-on-error: true shell: bash + env: + BASH_ENV: /dev/null + PATH: /usr/bin:/bin run: | - echo '::warning title=Artifact upload retry::Attempt 1/3 failed; retrying in 5 seconds.' - sleep 5 + builtin echo '::warning title=Artifact upload retry::Attempt 1/3 failed; retrying in 5 seconds.' + command sleep 5 - name: Upload artifact (attempt 2/3) id: upload_2 @@ -81,15 +84,21 @@ runs: if: ${{ !cancelled() && steps.upload_1.outcome == 'failure' && steps.upload_2.outcome == 'success' }} continue-on-error: true shell: bash - run: echo '::notice title=Artifact upload recovered::Artifact publication succeeded on attempt 2/3.' + env: + BASH_ENV: /dev/null + PATH: /usr/bin:/bin + run: builtin echo '::notice title=Artifact upload recovered::Artifact publication succeeded on attempt 2/3.' - name: Back off before artifact upload retry 2 if: ${{ !cancelled() && steps.upload_1.outcome == 'failure' && steps.upload_2.outcome == 'failure' }} continue-on-error: true shell: bash + env: + BASH_ENV: /dev/null + PATH: /usr/bin:/bin run: | - echo '::warning title=Artifact upload retry::Attempt 2/3 failed; retrying in 15 seconds.' - sleep 15 + builtin echo '::warning title=Artifact upload retry::Attempt 2/3 failed; retrying in 15 seconds.' + command sleep 15 - name: Upload artifact (attempt 3/3) if: ${{ !cancelled() && steps.upload_1.outcome == 'failure' && steps.upload_2.outcome == 'failure' }} @@ -108,11 +117,17 @@ runs: if: ${{ !cancelled() && steps.upload_1.outcome == 'failure' && steps.upload_2.outcome == 'failure' && steps.upload_3.outcome == 'success' }} continue-on-error: true shell: bash - run: echo '::notice title=Artifact upload recovered::Artifact publication succeeded on attempt 3/3.' + env: + BASH_ENV: /dev/null + PATH: /usr/bin:/bin + run: builtin echo '::notice title=Artifact upload recovered::Artifact publication succeeded on attempt 3/3.' - name: Fail after exhausting artifact upload attempts if: ${{ !cancelled() && steps.upload_1.outcome == 'failure' && steps.upload_2.outcome == 'failure' && steps.upload_3.outcome == 'failure' }} shell: bash + env: + BASH_ENV: /dev/null + PATH: /usr/bin:/bin run: | - echo '::error title=Artifact upload failed::All 3 attempts failed. See the preceding upload steps for the root error.' - exit 1 + builtin echo '::error title=Artifact upload failed::All 3 attempts failed. See the preceding upload steps for the root error.' + builtin exit 1 diff --git a/actions/upload-artifact-with-retry/state-machine.schema.json b/actions/upload-artifact-with-retry/state-machine.schema.json index c365e85..0d34bfe 100644 --- a/actions/upload-artifact-with-retry/state-machine.schema.json +++ b/actions/upload-artifact-with-retry/state-machine.schema.json @@ -2,8 +2,10 @@ "$schema": "http://json-schema.org/draft-07/schema#", "title": "Upload artifact retry state-machine contract", "type": "object", - "required": ["inputs", "outputs", "runs"], + "required": ["name", "description", "inputs", "outputs", "runs"], "properties": { + "name": {"type": "string"}, + "description": {"type": "string"}, "inputs": { "type": "object", "required": [ @@ -24,8 +26,10 @@ "properties": {"default": {}} }, "properties": { + "description": {"type": "string"}, "required": {"const": true} - } + }, + "additionalProperties": false }, "path": { "type": "object", @@ -36,42 +40,53 @@ "properties": {"default": {}} }, "properties": { + "description": {"type": "string"}, "required": {"const": true} - } + }, + "additionalProperties": false }, "if-no-files-found": { "type": "object", "required": ["required", "default"], "properties": { + "description": {"type": "string"}, "required": {"const": false}, "default": {"const": "error"} - } + }, + "additionalProperties": false }, "retention-days": { "type": "object", "required": ["required", "default"], "properties": { + "description": {"type": "string"}, "required": {"const": false}, "default": {"const": "7"} - } + }, + "additionalProperties": false }, "overwrite": { "type": "object", "required": ["required", "default"], "properties": { + "description": {"type": "string"}, "required": {"const": false}, "default": {"const": "false"} - } + }, + "additionalProperties": false }, "compression-level": { "type": "object", "required": ["required", "default"], "properties": { + "description": {"type": "string"}, "required": {"const": false}, "default": {"const": "6"} - } + }, + "additionalProperties": false } - } + }, + "additionalProperties": false }, "outputs": { "type": "object", @@ -81,30 +96,37 @@ "type": "object", "required": ["value"], "properties": { + "description": {"type": "string"}, "value": { "const": "${{ steps.upload_3.outcome == 'success' && steps.upload_3.outputs['artifact-id'] || steps.upload_2.outcome == 'success' && steps.upload_2.outputs['artifact-id'] || steps.upload_1.outcome == 'success' && steps.upload_1.outputs['artifact-id'] || '' }}" } - } + }, + "additionalProperties": false }, "artifact-url": { "type": "object", "required": ["value"], "properties": { + "description": {"type": "string"}, "value": { "const": "${{ steps.upload_3.outcome == 'success' && steps.upload_3.outputs['artifact-url'] || steps.upload_2.outcome == 'success' && steps.upload_2.outputs['artifact-url'] || steps.upload_1.outcome == 'success' && steps.upload_1.outputs['artifact-url'] || '' }}" } - } + }, + "additionalProperties": false }, "artifact-digest": { "type": "object", "required": ["value"], "properties": { + "description": {"type": "string"}, "value": { "const": "${{ steps.upload_3.outcome == 'success' && steps.upload_3.outputs['artifact-digest'] || steps.upload_2.outcome == 'success' && steps.upload_2.outputs['artifact-digest'] || steps.upload_1.outcome == 'success' && steps.upload_1.outputs['artifact-digest'] || '' }}" } - } + }, + "additionalProperties": false } - } + }, + "additionalProperties": false }, "runs": { "type": "object", @@ -122,6 +144,7 @@ { "type": "object", "properties": { + "name": {"const": "Upload artifact (attempt 1/3)"}, "id": {"const": "upload_1"}, "if": {"const": "${{ !cancelled() }}"}, "with": { @@ -140,8 +163,9 @@ { "type": "object", "properties": { + "name": {"const": "Back off before artifact upload retry 1"}, "if": {"const": "${{ !cancelled() && steps.upload_1.outcome == 'failure' }}"}, - "run": {"const": "echo '::warning title=Artifact upload retry::Attempt 1/3 failed; retrying in 5 seconds.'\nsleep 5\n"} + "run": {"const": "builtin echo '::warning title=Artifact upload retry::Attempt 1/3 failed; retrying in 5 seconds.'\ncommand sleep 5\n"} } } ] @@ -152,6 +176,7 @@ { "type": "object", "properties": { + "name": {"const": "Upload artifact (attempt 2/3)"}, "id": {"const": "upload_2"}, "if": {"const": "${{ !cancelled() && steps.upload_1.outcome == 'failure' }}"}, "with": { @@ -170,8 +195,9 @@ { "type": "object", "properties": { + "name": {"const": "Report artifact upload recovery on attempt 2/3"}, "if": {"const": "${{ !cancelled() && steps.upload_1.outcome == 'failure' && steps.upload_2.outcome == 'success' }}"}, - "run": {"const": "echo '::notice title=Artifact upload recovered::Artifact publication succeeded on attempt 2/3.'"} + "run": {"const": "builtin echo '::notice title=Artifact upload recovered::Artifact publication succeeded on attempt 2/3.'"} } } ] @@ -182,8 +208,9 @@ { "type": "object", "properties": { + "name": {"const": "Back off before artifact upload retry 2"}, "if": {"const": "${{ !cancelled() && steps.upload_1.outcome == 'failure' && steps.upload_2.outcome == 'failure' }}"}, - "run": {"const": "echo '::warning title=Artifact upload retry::Attempt 2/3 failed; retrying in 15 seconds.'\nsleep 15\n"} + "run": {"const": "builtin echo '::warning title=Artifact upload retry::Attempt 2/3 failed; retrying in 15 seconds.'\ncommand sleep 15\n"} } } ] @@ -194,6 +221,7 @@ { "type": "object", "properties": { + "name": {"const": "Upload artifact (attempt 3/3)"}, "id": {"const": "upload_3"}, "if": {"const": "${{ !cancelled() && steps.upload_1.outcome == 'failure' && steps.upload_2.outcome == 'failure' }}"}, "with": { @@ -212,37 +240,44 @@ { "type": "object", "properties": { + "name": {"const": "Report artifact upload recovery on attempt 3/3"}, "if": {"const": "${{ !cancelled() && steps.upload_1.outcome == 'failure' && steps.upload_2.outcome == 'failure' && steps.upload_3.outcome == 'success' }}"}, - "run": {"const": "echo '::notice title=Artifact upload recovered::Artifact publication succeeded on attempt 3/3.'"} + "run": {"const": "builtin echo '::notice title=Artifact upload recovered::Artifact publication succeeded on attempt 3/3.'"} } } ] }, { "type": "object", - "required": ["if", "shell", "run"], + "required": ["name", "if", "shell", "env", "run"], "not": { "type": "object", "required": ["continue-on-error"], "properties": {"continue-on-error": {}} }, "properties": { + "name": {"const": "Fail after exhausting artifact upload attempts"}, "if": {"const": "${{ !cancelled() && steps.upload_1.outcome == 'failure' && steps.upload_2.outcome == 'failure' && steps.upload_3.outcome == 'failure' }}"}, "shell": {"const": "bash"}, - "run": {"const": "echo '::error title=Artifact upload failed::All 3 attempts failed. See the preceding upload steps for the root error.'\nexit 1\n"} - } + "env": {"$ref": "#/definitions/sanitized-shell-env"}, + "run": {"const": "builtin echo '::error title=Artifact upload failed::All 3 attempts failed. See the preceding upload steps for the root error.'\nbuiltin exit 1\n"} + }, + "additionalProperties": false } ], "additionalItems": false } - } + }, + "additionalProperties": false } }, + "additionalProperties": false, "definitions": { "upload": { "type": "object", - "required": ["id", "if", "continue-on-error", "uses", "with"], + "required": ["name", "id", "if", "continue-on-error", "uses", "with"], "properties": { + "name": {"type": "string"}, "id": {}, "if": {}, "continue-on-error": {"const": true}, @@ -267,17 +302,30 @@ }, "additionalProperties": false } - } + }, + "additionalProperties": false }, "best-effort-shell": { "type": "object", - "required": ["if", "continue-on-error", "shell", "run"], + "required": ["name", "if", "continue-on-error", "shell", "env", "run"], "properties": { + "name": {"type": "string"}, "if": {}, "continue-on-error": {"const": true}, "shell": {"const": "bash"}, + "env": {"$ref": "#/definitions/sanitized-shell-env"}, "run": {} - } + }, + "additionalProperties": false + }, + "sanitized-shell-env": { + "type": "object", + "required": ["BASH_ENV", "PATH"], + "properties": { + "BASH_ENV": {"const": "/dev/null"}, + "PATH": {"const": "/usr/bin:/bin"} + }, + "additionalProperties": false } } } diff --git a/scripts/select_coverage_artifacts.py b/scripts/select_coverage_artifacts.py new file mode 100644 index 0000000..e2aac42 --- /dev/null +++ b/scripts/select_coverage_artifacts.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +"""Select one deterministic coverage artifact per producer generation.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + + +PRODUCERS = ( + "ut-coverage", + "bvt-coverage-compose", + "bvt-coverage-pessimistic", +) +ARTIFACT_NAME = re.compile( + r"^(ut-coverage|bvt-coverage-compose|bvt-coverage-pessimistic)" + r"-generation-(.+)-attempt-([1-9][0-9]*)$" +) + + +class SelectionError(RuntimeError): + """The downloaded artifacts cannot form one coherent producer set.""" + + +@dataclass(frozen=True) +class Candidate: + producer: str + generation: str + attempt: int + path: Path + + +def discover_candidates(download_dir: Path) -> list[Candidate]: + candidates = [] + for path in sorted(download_dir.iterdir()): + if not path.is_dir(): + continue + match = ARTIFACT_NAME.fullmatch(path.name) + if match is None: + continue + producer, generation, attempt = match.groups() + candidates.append(Candidate(producer, generation, int(attempt), path)) + return candidates + + +def _complete_generations(candidates: Iterable[Candidate]) -> set[str]: + by_producer = {producer: set() for producer in PRODUCERS} + for candidate in candidates: + by_producer[candidate.producer].add(candidate.generation) + return set.intersection(*(by_producer[producer] for producer in PRODUCERS)) + + +def select_candidates( + candidates: Iterable[Candidate], expected_generation: str = "" +) -> dict[str, Candidate]: + candidates = list(candidates) + complete_generations = _complete_generations(candidates) + if expected_generation: + if expected_generation not in complete_generations: + available = ", ".join(sorted(complete_generations)) or "none" + raise SelectionError( + f"generation {expected_generation!r} does not have all three " + f"coverage producers; complete generations: {available}" + ) + generation = expected_generation + else: + if len(complete_generations) != 1: + available = ", ".join(sorted(complete_generations)) or "none" + raise SelectionError( + "expected exactly one complete coverage generation when no " + f"generation was requested; found: {available}" + ) + generation = next(iter(complete_generations)) + + selected = {} + for producer in PRODUCERS: + matches = [ + candidate + for candidate in candidates + if candidate.producer == producer and candidate.generation == generation + ] + selected[producer] = max(matches, key=lambda candidate: candidate.attempt) + return selected + + +def link_selected(selected: dict[str, Candidate], output_dir: Path) -> None: + output_dir.mkdir(parents=True, exist_ok=False) + for producer in PRODUCERS: + candidate = selected[producer] + for source in sorted(candidate.path.rglob("*")): + if source.is_symlink(): + raise SelectionError( + f"artifact {candidate.path.name!r} contains unsupported symlink " + f"{source.relative_to(candidate.path)}" + ) + relative = source.relative_to(candidate.path) + destination = output_dir / relative + if source.is_dir(): + destination.mkdir(parents=True, exist_ok=True) + continue + if destination.exists(): + raise SelectionError( + f"selected artifacts both contain {relative}; refusing an " + "order-dependent overwrite" + ) + if not source.is_file(): + raise SelectionError( + f"artifact {candidate.path.name!r} contains unsupported " + f"non-regular file {relative}" + ) + destination.parent.mkdir(parents=True, exist_ok=True) + # Input and output both live under RUNNER_TEMP, so a hard link + # isolates the selected namespace without copying large profiles. + os.link(source, destination) + + +def selection_document(selected: dict[str, Candidate]) -> dict[str, object]: + generation = selected[PRODUCERS[0]].generation + return { + "schema_version": 1, + "generation": generation, + "producers": { + producer: { + "artifact": selected[producer].path.name, + "attempt": selected[producer].attempt, + } + for producer in PRODUCERS + }, + } + + +def workflow_command_escape(value: object) -> str: + return ( + str(value) + .replace("%", "%25") + .replace("\r", "%0D") + .replace("\n", "%0A") + ) + + +def main() -> int: + parser = argparse.ArgumentParser( + description=( + "Select the highest run attempt for each coverage producer without " + "merging stale artifacts into the same directory." + ) + ) + parser.add_argument("--input", required=True, type=Path) + parser.add_argument("--output", required=True, type=Path) + parser.add_argument("--expected-generation", default="") + parser.add_argument("--manifest", required=True, type=Path) + args = parser.parse_args() + + try: + candidates = discover_candidates(args.input) + selected = select_candidates(candidates, args.expected_generation) + link_selected(selected, args.output) + document = selection_document(selected) + args.manifest.parent.mkdir(parents=True, exist_ok=True) + args.manifest.write_text(json.dumps(document, indent=2) + "\n") + except (OSError, SelectionError) as error: + print( + "::error title=Coverage artifact selection::" + f"{workflow_command_escape(error)}", + file=sys.stderr, + ) + return 1 + + for producer in PRODUCERS: + candidate = selected[producer] + print( + f"selected {producer}: {candidate.path.name} " + f"(generation {candidate.generation}, attempt {candidate.attempt})" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test_select_coverage_artifacts.py b/scripts/test_select_coverage_artifacts.py new file mode 100644 index 0000000..9322267 --- /dev/null +++ b/scripts/test_select_coverage_artifacts.py @@ -0,0 +1,230 @@ +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +from select_coverage_artifacts import ( + SelectionError, + discover_candidates, + link_selected, + select_candidates, + selection_document, +) + + +SCRIPT = Path(__file__).with_name("select_coverage_artifacts.py") + + +class CoverageArtifactSelectionTest(unittest.TestCase): + def setUp(self): + self.temporary_directory = tempfile.TemporaryDirectory() + self.root = Path(self.temporary_directory.name) + self.downloads = self.root / "downloads" + self.downloads.mkdir() + + def tearDown(self): + self.temporary_directory.cleanup() + + def artifact( + self, + producer: str, + generation: str, + attempt: int, + files: dict[str, str], + ) -> None: + directory = ( + self.downloads + / f"{producer}-generation-{generation}-attempt-{attempt}" + ) + directory.mkdir() + for relative_path, content in files.items(): + path = directory / relative_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + + def complete_generation(self, generation: str, attempt: int = 1) -> None: + self.artifact( + "ut-coverage", generation, attempt, {"ut-coverage.out": "ut"} + ) + self.artifact( + "bvt-coverage-compose", + generation, + attempt, + { + "bvt-compose.out": "compose", + "bvt-compose-manifest.json": "{}", + }, + ) + self.artifact( + "bvt-coverage-pessimistic", + generation, + attempt, + { + "bvt-pessimistic.out": "pessimistic", + "bvt-pessimistic-manifest.json": "{}", + }, + ) + + def select(self, generation: str = ""): + return select_candidates(discover_candidates(self.downloads), generation) + + def test_selects_highest_attempt_per_producer(self): + self.complete_generation("run-1") + self.artifact( + "ut-coverage", "run-1", 3, {"ut-coverage.out": "fresh-ut"} + ) + self.artifact( + "bvt-coverage-compose", + "run-1", + 2, + { + "bvt-compose.out": "fresh-compose", + "bvt-compose-manifest.json": "{\"attempt\": 2}", + }, + ) + + selected = self.select("run-1") + output = self.root / "selected" + link_selected(selected, output) + + self.assertEqual(selected["ut-coverage"].attempt, 3) + self.assertEqual(selected["bvt-coverage-compose"].attempt, 2) + self.assertEqual(selected["bvt-coverage-pessimistic"].attempt, 1) + self.assertEqual((output / "ut-coverage.out").read_text(), "fresh-ut") + self.assertEqual( + (output / "ut-coverage.out").stat().st_ino, + (selected["ut-coverage"].path / "ut-coverage.out").stat().st_ino, + ) + self.assertEqual( + (output / "bvt-compose.out").read_text(), "fresh-compose" + ) + self.assertEqual( + selection_document(selected)["producers"]["ut-coverage"]["attempt"], + 3, + ) + + def test_expected_generation_ignores_newer_unrelated_generation(self): + self.complete_generation("wanted", attempt=1) + self.complete_generation("other", attempt=9) + + selected = self.select("wanted") + + self.assertEqual( + {candidate.generation for candidate in selected.values()}, {"wanted"} + ) + + def test_without_expected_generation_requires_one_complete_generation(self): + self.complete_generation("first") + self.complete_generation("second") + + with self.assertRaisesRegex(SelectionError, "exactly one complete"): + self.select() + + def test_selects_from_githubs_maximum_run_attempt_set(self): + for attempt in range(1, 52): + self.complete_generation("run-1", attempt=attempt) + + selected = self.select("run-1") + + self.assertEqual( + {candidate.attempt for candidate in selected.values()}, {51} + ) + + def test_missing_producer_fails_closed(self): + self.artifact("ut-coverage", "run-1", 1, {"ut-coverage.out": "ut"}) + self.artifact( + "bvt-coverage-compose", + "run-1", + 1, + {"bvt-compose-manifest.json": "{}"}, + ) + + with self.assertRaisesRegex(SelectionError, "does not have all three"): + self.select("run-1") + + def test_newest_attempt_does_not_fall_back_to_stale_files(self): + self.complete_generation("run-1") + self.artifact("ut-coverage", "run-1", 2, {"diagnostic.txt": "missing"}) + + selected = self.select("run-1") + output = self.root / "selected" + link_selected(selected, output) + + self.assertFalse((output / "ut-coverage.out").exists()) + self.assertTrue((output / "diagnostic.txt").exists()) + + def test_duplicate_relative_paths_fail_instead_of_overwriting(self): + self.complete_generation("run-1") + self.artifact( + "ut-coverage", "run-1", 2, {"shared.txt": "from-ut"} + ) + self.artifact( + "bvt-coverage-compose", + "run-1", + 2, + {"shared.txt": "from-compose"}, + ) + + with self.assertRaisesRegex(SelectionError, "order-dependent overwrite"): + link_selected(self.select("run-1"), self.root / "selected") + + def test_cli_writes_auditable_selection_manifest(self): + self.complete_generation("run-1", attempt=4) + output = self.root / "selected" + manifest = self.root / "selection.json" + + result = subprocess.run( + [ + sys.executable, + str(SCRIPT), + "--input", + str(self.downloads), + "--output", + str(output), + "--expected-generation", + "run-1", + "--manifest", + str(manifest), + ], + check=False, + capture_output=True, + text=True, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("selected ut-coverage", result.stdout) + document = json.loads(manifest.read_text()) + self.assertEqual(document["generation"], "run-1") + self.assertEqual(document["producers"]["ut-coverage"]["attempt"], 4) + + def test_cli_failure_emits_a_github_error_annotation(self): + self.artifact("ut-coverage", "run-1", 1, {"ut-coverage.out": "ut"}) + + result = subprocess.run( + [ + sys.executable, + str(SCRIPT), + "--input", + str(self.downloads), + "--output", + str(self.root / "selected"), + "--expected-generation", + "run-1", + "--manifest", + str(self.root / "selection.json"), + ], + check=False, + capture_output=True, + text=True, + ) + + self.assertEqual(result.returncode, 1) + self.assertIn( + "::error title=Coverage artifact selection::", result.stderr + ) + + +if __name__ == "__main__": + unittest.main()