From 56763cc082ba8d7047aa6b13cc32b3fcf3ee25f5 Mon Sep 17 00:00:00 2001 From: Adam Hamrick Date: Tue, 18 Aug 2026 14:54:24 -0400 Subject: [PATCH 1/3] feat(ci): universal Go CLI CI tool --- .github/actions/setup-solana/action.yml | 22 +- .github/workflows/ccip-system-tests.yaml | 50 +- .github/workflows/changesets-preview-pr.yml | 5 +- .github/workflows/cre-mixed-env-tests.yaml | 52 +- .../cre-regression-system-tests.yaml | 49 +- .github/workflows/cre-system-tests.yaml | 99 +--- .github/workflows/integration-tests.yml | 312 +++------- tools/bin/go_deployment_tests | 2 +- tools/ci-testshard/main.go | 173 ------ tools/ci-testshard/main_test.go | 271 --------- tools/ci/format_changelog | 172 ------ tools/ci/go.mod | 16 + tools/ci/go.sum | 20 + tools/ci/install_solana | 12 - tools/ci/install_stellar | 36 -- tools/ci/internal/changelog/format.go | 212 +++++++ tools/ci/internal/changelog/format_test.go | 93 +++ tools/ci/internal/cmd/changelog.go | 50 ++ tools/ci/internal/cmd/gating.go | 56 ++ tools/ci/internal/cmd/matrix.go | 126 ++++ tools/ci/internal/cmd/root.go | 37 ++ tools/ci/internal/cmd/testshard.go | 51 ++ tools/ci/internal/gating/gating.go | 97 +++ tools/ci/internal/gating/gating_test.go | 152 +++++ .../ci/internal/githuboutput/githuboutput.go | 55 ++ .../githuboutput/githuboutput_test.go | 50 ++ tools/ci/internal/matrix/matrix.go | 560 ++++++++++++++++++ tools/ci/internal/matrix/matrix_test.go | 234 ++++++++ tools/ci/internal/paths/paths.go | 36 ++ tools/ci/internal/paths/paths_test.go | 56 ++ tools/ci/internal/testshard/shard.go | 112 ++++ tools/ci/internal/testshard/shard_test.go | 80 +++ tools/ci/main.go | 16 + tools/ci/main_test.go | 153 +++++ tools/ci/wait-for-containers-to-stop.sh | 25 - 35 files changed, 2489 insertions(+), 1053 deletions(-) delete mode 100644 tools/ci-testshard/main.go delete mode 100644 tools/ci-testshard/main_test.go delete mode 100755 tools/ci/format_changelog create mode 100644 tools/ci/go.mod create mode 100644 tools/ci/go.sum delete mode 100755 tools/ci/install_solana delete mode 100755 tools/ci/install_stellar create mode 100644 tools/ci/internal/changelog/format.go create mode 100644 tools/ci/internal/changelog/format_test.go create mode 100644 tools/ci/internal/cmd/changelog.go create mode 100644 tools/ci/internal/cmd/gating.go create mode 100644 tools/ci/internal/cmd/matrix.go create mode 100644 tools/ci/internal/cmd/root.go create mode 100644 tools/ci/internal/cmd/testshard.go create mode 100644 tools/ci/internal/gating/gating.go create mode 100644 tools/ci/internal/gating/gating_test.go create mode 100644 tools/ci/internal/githuboutput/githuboutput.go create mode 100644 tools/ci/internal/githuboutput/githuboutput_test.go create mode 100644 tools/ci/internal/matrix/matrix.go create mode 100644 tools/ci/internal/matrix/matrix_test.go create mode 100644 tools/ci/internal/paths/paths.go create mode 100644 tools/ci/internal/paths/paths_test.go create mode 100644 tools/ci/internal/testshard/shard.go create mode 100644 tools/ci/internal/testshard/shard_test.go create mode 100644 tools/ci/main.go create mode 100644 tools/ci/main_test.go delete mode 100755 tools/ci/wait-for-containers-to-stop.sh diff --git a/.github/actions/setup-solana/action.yml b/.github/actions/setup-solana/action.yml index 6a21488b752..ac3e04ad853 100644 --- a/.github/actions/setup-solana/action.yml +++ b/.github/actions/setup-solana/action.yml @@ -1,10 +1,15 @@ name: Setup Solana CLI description: Setup solana CLI inputs: - base-path: - description: Path to the base of the repo + version: + description: Solana release version required: false - default: . + default: "v1.18.26" + shasum: + description: SHA256 checksum of install script + required: false + default: "cec72cde1cf36eb35cd8326245d23af0b6791fab68337c2953e2ca2a40af2c50" + runs: using: composite steps: @@ -14,15 +19,20 @@ runs: with: path: | ~/.local/share/solana - key: ${{ runner.os }}-${{ runner.arch }}-solana-cli-${{ hashFiles(format('{0}/tools/ci/install_solana', inputs.base-path)) }} + key: ${{ runner.os }}-${{ runner.arch }}-solana-cli-${{ inputs.version }}-${{ inputs.shasum }} restore-keys: | ${{ runner.os }}-${{ runner.arch }}-solana-cli- - if: ${{ steps.cache.outputs.cache-hit != 'true' }} name: Install solana cli shell: bash - working-directory: ${{ inputs.base-path }} - run: ./tools/ci/install_solana + run: | + set -euo pipefail + curl -sSfL "https://release.anza.xyz/${{ inputs.version }}/install" --output install_solana.sh + echo "${{ inputs.shasum }} install_solana.sh" | sha256sum --check + chmod +x install_solana.sh + sh -c ./install_solana.sh + rm -f install_solana.sh - name: Export solana path to env shell: bash diff --git a/.github/workflows/ccip-system-tests.yaml b/.github/workflows/ccip-system-tests.yaml index 8b3821c8d5c..b6f5b547071 100644 --- a/.github/workflows/ccip-system-tests.yaml +++ b/.github/workflows/ccip-system-tests.yaml @@ -31,6 +31,11 @@ on: "The version of Chainlink repository to use for the tests. If empty, defaults to github.sha." default: "" + test_matrix: + required: false + type: string + description: "Pre-computed test matrix JSON" + default: "" workflow_call: inputs: chainlink_image_repository_path: @@ -57,46 +62,53 @@ on: "The version of Chainlink repository to use for the tests. If empty, defaults to github.sha." default: "" + test_matrix: + required: false + type: string + description: "Pre-computed test matrix JSON" + default: "" jobs: define-test-matrix: + if: inputs.test_matrix == '' runs-on: ubuntu-latest outputs: matrix: ${{ steps.define-matrix.outputs.matrix }} permissions: contents: read steps: + - name: Checkout + uses: actions/checkout@v7 + with: + ref: ${{ inputs.chainlink_version || github.sha }} + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version-file: go.mod + cache: false + - name: Define test matrix id: define-matrix - shell: bash run: | - TESTS_JSON='[ - {"test_name":"Test_CCIPGasPriceUpdatesWriteFrequency","timeout":"15m","selected_network":"SIMULATED_1,SIMULATED_2"}, - {"test_name":"TestRMN_GlobalCurseTwoMessagesOnTwoLanes","timeout":"15m","selected_network":"SIMULATED_1,SIMULATED_2","rmn_rageproxy_version":"master-amd6416f5d86","rmn_afn2proxy_version":"master-amd64-10b42b2"}, - {"test_name":"TestDeleteCCIPJobs|TestRevokeJobs","timeout":"15m","selected_network":"SIMULATED_1,SIMULATED_2","job_timeout":20} - ]' - - tests=$(echo "$TESTS_JSON" | jq -c \ - --argjson run_id "${{ github.run_id }}" \ - --arg run_attempt "${{ github.run_attempt }}" ' - to_entries | map(.value + { - test_id: .key, - runs_on: ("runs-on=\($run_id)-\(.key)-\($run_attempt)/cpu=8/ram=64/family=r6i+r7i+r8i/spot=co/image=ubuntu24-full-x64/extras=s3-cache+tmpfs") - }) - ') - - echo "matrix=$tests" | tee -a "${GITHUB_OUTPUT}" + go -C tools/ci run . matrix \ + --suite='ccip' \ + --run-id='${{ github.run_id }}' \ + --attempt='${{ github.run_attempt }}' \ + --github-output run-ccip-tests: name: ${{ matrix.tests.test_name }} permissions: contents: read id-token: write + needs: [define-test-matrix] + if: !failure() && !cancelled() strategy: fail-fast: false matrix: - tests: ${{ fromJson(needs.define-test-matrix.outputs.matrix) }} - needs: [define-test-matrix] + tests: ${{ fromJson(inputs.test_matrix != '' && inputs.test_matrix || needs.define-test-matrix.outputs.matrix) }} runs-on: ${{ matrix.tests.runs_on }} environment: # http://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/control-deployments#using-environments-without-deployments diff --git a/.github/workflows/changesets-preview-pr.yml b/.github/workflows/changesets-preview-pr.yml index 1b134515d8d..f2128871fe8 100644 --- a/.github/workflows/changesets-preview-pr.yml +++ b/.github/workflows/changesets-preview-pr.yml @@ -44,7 +44,10 @@ jobs: - name: Generate new changelog if: steps.change.outputs.core-changeset == 'true' id: changelog - run: pnpm install && ./tools/ci/format_changelog + run: | + pnpm install + pnpm changeset version + go -C tools/ci run . changelog format env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/cre-mixed-env-tests.yaml b/.github/workflows/cre-mixed-env-tests.yaml index a476c56ef97..2d838d78333 100644 --- a/.github/workflows/cre-mixed-env-tests.yaml +++ b/.github/workflows/cre-mixed-env-tests.yaml @@ -56,6 +56,11 @@ on: fallback: a release/* base with no published base image is skipped (no correct baseline vs develop); every other base (develop, or a stacked feature branch) falls back to the develop nightly." + test_matrix: + required: false + type: string + description: "Pre-computed test matrix JSON" + default: "" workflow_call: inputs: chainlink_image_repository_path: @@ -101,48 +106,53 @@ on: fallback: a release/* base with no published base image is skipped (no correct baseline vs develop); every other base (develop, or a stacked feature branch) falls back to the develop nightly." + test_matrix: + required: false + type: string + description: "Pre-computed test matrix JSON" + default: "" jobs: define-test-matrix: + if: inputs.test_matrix == '' runs-on: ubuntu-latest outputs: matrix: ${{ steps.define-matrix.outputs.matrix }} permissions: contents: read steps: + - name: Checkout + uses: actions/checkout@v7 + with: + ref: ${{ inputs.chainlink_version || github.sha }} + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version-file: go.mod + cache: false + - name: Define mixed-env test matrix id: define-matrix - shell: bash run: | - # Tests worth running under mixed-env: the OCR3/DON2DON-heavy ones. - TESTS='[ - "Test_CRE_V2_Suite_Bucket_A", - "Test_CRE_V2_Suite_Bucket_B", - "Test_CRE_V2_EVM_Read_HeavyCalls", - "Test_CRE_V2_EVM_Read_StateQueries", - "Test_CRE_V2_EVM_Read_TxArtifacts" - ]' - matrix=$(jq -c -n \ - --argjson tests "$TESTS" \ - --argjson run_id "${{ github.run_id }}" \ - --arg run_attempt "${{ github.run_attempt }}" ' - $tests | to_entries | map({ - test_name: .value, - test_id: .key, - runs_on: "runs-on=\($run_id)-\(.key)-\($run_attempt)/cpu=16/ram=64/family=m7i+m8i/spot=co/image=ubuntu24-full-x64/extras=s3-cache+tmpfs" - })') - echo "matrix=$matrix" | tee -a "${GITHUB_OUTPUT}" + go -C tools/ci run . matrix \ + --suite='cre-mixed-env' \ + --run-id='${{ github.run_id }}' \ + --attempt='${{ github.run_attempt }}' \ + --github-output run-mixed-env-tests: name: ${{ matrix.tests.test_name }} (mixed-env) permissions: contents: read id-token: write + needs: [define-test-matrix] + if: !failure() && !cancelled() strategy: fail-fast: false matrix: - tests: ${{ fromJson(needs.define-test-matrix.outputs.matrix) }} - needs: [define-test-matrix] + tests: ${{ fromJson(inputs.test_matrix != '' && inputs.test_matrix || needs.define-test-matrix.outputs.matrix) }} runs-on: ${{ matrix.tests.runs_on }} environment: name: integration diff --git a/.github/workflows/cre-regression-system-tests.yaml b/.github/workflows/cre-regression-system-tests.yaml index 94cfebdbea8..c67cf4dc0b9 100644 --- a/.github/workflows/cre-regression-system-tests.yaml +++ b/.github/workflows/cre-regression-system-tests.yaml @@ -31,6 +31,11 @@ on: "The version of Chainlink repository to use for the tests. If empty, defaults to github.sha." default: "" + test_matrix: + required: false + type: string + description: "Pre-computed test matrix JSON" + default: "" workflow_call: inputs: chainlink_image_repository_path: @@ -57,9 +62,15 @@ on: "The version of Chainlink repository to use for the tests. If empty, defaults to github.sha." default: "" + test_matrix: + required: false + type: string + description: "Pre-computed test matrix JSON" + default: "" jobs: define-test-matrix: + if: inputs.test_matrix == '' runs-on: ubuntu-latest outputs: matrix: ${{ steps.define-matrix.outputs.matrix }} @@ -72,42 +83,32 @@ jobs: ref: ${{ inputs.chainlink_version || github.sha }} persist-credentials: false + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version-file: go.mod + cache: false + - name: Define test matrix id: define-matrix - shell: bash run: | - test_names=$(grep -rh -oP '^func \K(Test|Example)[^(]+' system-tests/tests/regression/cre/*_test.go) - - per_test_configs='{ - "Test_CRE_V2_Stellar_Regression": "configs/workflow-gateway-don-stellar.toml" - }' - - tests=$(echo "$test_names" | jq -c -R -s \ - --argjson run_id "${{ github.run_id }}" \ - --arg run_attempt "${{ github.run_attempt }}" \ - --argjson per "$per_test_configs" ' - split("\n") | map(select(length>0)) - | to_entries - | map({ - test_name: .value, - test_id: .key, - runs_on: "runs-on=\($run_id)-\(.key)-\($run_attempt)/cpu=16/ram=64/family=m7i+m8i/spot=co/image=ubuntu24-full-x64/extras=s3-cache+tmpfs", - configs: ($per[.value] // "configs/workflow-gateway-capabilities-don.toml") - }) - ') - - echo "matrix=$tests" | tee -a "${GITHUB_OUTPUT}" + go -C tools/ci run . matrix \ + --suite='cre-regression' \ + --run-id='${{ github.run_id }}' \ + --attempt='${{ github.run_attempt }}' \ + --github-output run-system-tests: name: ${{ matrix.tests.test_name }} ${{ matrix.tests.topology != '' && format(' ({0})', matrix.tests.topology) || '' }} permissions: contents: read id-token: write + needs: [define-test-matrix] + if: !failure() && !cancelled() strategy: fail-fast: false matrix: - tests: ${{fromJson(needs.define-test-matrix.outputs.matrix)}} - needs: [define-test-matrix] + tests: ${{ fromJson(inputs.test_matrix != '' && inputs.test_matrix || needs.define-test-matrix.outputs.matrix) }} # we need a `test_id` and `run_attempt` here to stop runner stealing # see: https://runs-on.com/guides/troubleshoot/#runner-stealing-and-matrix-jobs runs-on: ${{ matrix.tests.runs_on }} diff --git a/.github/workflows/cre-system-tests.yaml b/.github/workflows/cre-system-tests.yaml index 2ba0a2a6df9..ffa376ea3bb 100644 --- a/.github/workflows/cre-system-tests.yaml +++ b/.github/workflows/cre-system-tests.yaml @@ -31,6 +31,11 @@ on: "The version of Chainlink repository to use for the tests. If empty, defaults to github.sha." default: "" + test_matrix: + required: false + type: string + description: "Pre-computed test matrix JSON" + default: "" workflow_call: inputs: chainlink_image_repository_path: @@ -57,9 +62,15 @@ on: "The version of Chainlink repository to use for the tests. If empty, defaults to github.sha." default: "" + test_matrix: + required: false + type: string + description: "Pre-computed test matrix JSON" + default: "" jobs: define-test-matrix: + if: inputs.test_matrix == '' runs-on: ubuntu-latest outputs: matrix: ${{ steps.define-matrix.outputs.matrix }} @@ -72,92 +83,32 @@ jobs: ref: ${{ inputs.chainlink_version || github.sha }} persist-credentials: false + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version-file: go.mod + cache: false + - name: Define test matrix id: define-matrix - shell: bash run: | - TOPOLOGIES_JSON='[ - {"topology":"workflow-gateway-capabilities","configs":"configs/workflow-gateway-capabilities-don.toml"} - ]' - - PER_TEST_TOPOLOGIES_JSON='{ - "Test_CRE_V2_Suite_Bucket_B": [ - {"topology":"workflow-gateway-capabilities","configs":"configs/workflow-gateway-capabilities-don.toml"}, - {"topology":"workflow-gateway-capabilities-vault-jwt_auth-enabled","configs":"configs/workflow-gateway-capabilities-don-vault-jwt_auth-enabled.toml"}, - {"topology":"workflow-gateway-capabilities-vault-optimizations-enabled","configs":"configs/workflow-gateway-capabilities-don-vault-optimizations-enabled.toml"}, - {"topology":"workflow-gateway-capabilities-vault-stall-purge","configs":"configs/workflow-gateway-capabilities-don-vault-stall-purge.toml"} - ], - "Test_CRE_V2_Aptos_Suite": [ - {"topology":"workflow-gateway-aptos","configs":"configs/workflow-gateway-don-aptos.toml"} - ], - "Test_CRE_V2_Stellar_Suite": [ - {"topology":"workflow-gateway-stellar","configs":"configs/workflow-gateway-don-stellar.toml"} - ], - "Test_CRE_V2_Solana_Write": [ - {"topology":"workflow","configs":"configs/workflow-don-solana.toml"} - ], - "Test_CRE_V2_Solana_LogTrigger": [ - {"topology":"workflow","configs":"configs/workflow-don-solana.toml"} - ], - "Test_CRE_V2_Solana_Read_Accounts": [ - {"topology":"workflow","configs":"configs/workflow-don-solana.toml"} - ], - "Test_CRE_V2_Solana_Read_Block": [ - {"topology":"workflow","configs":"configs/workflow-don-solana.toml"} - ], - "Test_CRE_V2_Solana_Read_Tx": [ - {"topology":"workflow","configs":"configs/workflow-don-solana.toml"} - ], - "Test_CRE_V2_Sharding": [ - {"topology":"workflow-gateway-sharded","configs":"configs/workflow-gateway-sharded-don.toml"} - ], - "Test_CRE_V2_ShardManualAssignment": [ - {"topology":"workflow-gateway-sharded-manual","configs":"configs/workflow-gateway-sharded-manual.toml"} - ], - "Test_CRE_V2_ShardRingOCROverrides": [ - {"topology":"workflow-gateway-sharded-ringocr-overrides","configs":"configs/workflow-gateway-sharded-ringocr-overrides.toml"} - ], - "Test_CRE_V2_Module_Cache": [ - {"topology":"workflow-gateway-cache-test","configs":"configs/workflow-gateway-don-cache-test.toml"} - ], - "Test_CRE_V2_HTTP_Action_Multi_Gateway": [ - {"topology":"workflow-gateway-capabilities-multi-gateway","configs":"configs/workflow-gateway-capabilities-multi-gateway-don.toml"} - ] - }' - - test_names=$(grep -rh -oP '^func \K(Test|Example)[^(]+' system-tests/tests/smoke/cre/*_test.go | grep -v Test_Upgrade | grep -v '^TestMain$') - - tests=$(echo "$test_names" | jq -c -R -s \ - --argjson run_id "${{ github.run_id }}" \ - --arg run_attempt "${{ github.run_attempt }}" \ - --argjson tops "$TOPOLOGIES_JSON" \ - --argjson per "$PER_TEST_TOPOLOGIES_JSON" ' - (split("\n") | map(select(length>0))) as $names - | [ $names[] as $name | $tops[] | {test_name:$name} + . ] as $baseAll - | ($baseAll | map(select(.test_name as $n | ($per[$n] | type) == "array" | not))) as $base - | [ $per | to_entries[] as $e - | $e.value[] | {test_name: $e.key} + . - ] as $extra - | ($base + $extra) - | to_entries - | map(.value + { - test_id: .key, - runs_on: "runs-on=\($run_id)-\(.key)-\($run_attempt)/cpu=16/ram=64/family=m7i+m8i/spot=co/image=ubuntu24-full-x64/extras=s3-cache+tmpfs" - }) - ') - - echo "matrix=$tests" | tee -a "${GITHUB_OUTPUT}" + go -C tools/ci run . matrix \ + --suite=cre-smoke \ + --run-id='${{ github.run_id }}' \ + --attempt='${{ github.run_attempt }}' \ + --github-output run-system-tests: name: ${{ matrix.tests.test_name }} ${{ matrix.tests.topology != '' && format(' ({0})', matrix.tests.topology) || '' }} permissions: contents: read id-token: write + needs: [define-test-matrix] + if: !failure() && !cancelled() strategy: fail-fast: false matrix: - tests: ${{fromJson(needs.define-test-matrix.outputs.matrix)}} - needs: [define-test-matrix] + tests: ${{ fromJson(inputs.test_matrix != '' && inputs.test_matrix || needs.define-test-matrix.outputs.matrix) }} # we need a `test_id` and `run_attempt` here to stop runner stealing # see: https://runs-on.com/guides/troubleshoot/#runner-stealing-and-matrix-jobs runs-on: ${{ matrix.tests.runs_on }} diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 39a71350d88..f620e995eef 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -89,58 +89,23 @@ jobs: # http://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/control-deployments#using-environments-without-deployments name: integration deployment: false + permissions: + contents: read + pull-requests: read outputs: - general-changes: ${{ steps.changes.outputs.general_changes }} - core-changes: ${{ steps.changes.outputs.core_changes }} - cre-changes: ${{ steps.changes.outputs.cre_changes }} - ccip-changes: ${{ steps.changes.outputs.ccip_changes }} + cre-e2e: ${{ steps.triggers.outputs.cre-e2e }} + ccip-e2e: ${{ steps.triggers.outputs.ccip-e2e }} steps: - name: Checkout the repo uses: actions/checkout@v7 with: - persist-credentials: false + persist-credentials: ${{ github.event_name == 'merge_group' }} repository: smartcontractkit/chainlink ref: ${{ inputs.cl_ref }} - - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 - id: changes - with: - filters: | - general_changes: - - '.github/workflows/integration-tests.yml' - - '.github/workflows/cre-system-tests.yaml' - - '.github/workflows/cre-regression-system-tests.yaml' - - '.github/workflows/ccip-system-tests.yaml' - - 'GNUmakefile' - - 'core/chainlink.Dockerfile' - - 'plugins/chainlink.Dockerfile' - core_changes: &core_changes - - '**/*.go' - - '**/*go.sum' - - '**/*go.mod' - - '**/*Dockerfile' - - 'core/**/migrations/*.sql' - - 'core/**/config/**/*.toml' - - 'integration-tests/**/*.toml' - cre_changes: - - *core_changes - - 'core/scripts/cre/environment/**/*' - - 'system-tests/**' - - 'plugins/plugins.private.yaml' - - 'plugins/plugins.public.yaml' - ccip_changes: - - '.github/workflows/integration-tests.yml' - - '.github/workflows/ccip-system-tests.yaml' - - '.github/actions/**' - - 'integration-tests/**' - - 'core/capabilities/ccip/**' - - - name: Decide which tests to run (rollout-only) - # To validate that this properly tests, we will run this beside the dorny/paths-filter actions - # before using its output to actually gate any jobs. + - name: Decide which tests to run id: triggers uses: smartcontractkit/.github/actions/advanced-triggers@advanced-triggers/v1 - continue-on-error: true with: file-sets: | go-files: @@ -257,6 +222,69 @@ jobs: echo "builder-runner-label-plugins=${SH_BUILDER_RUNNER_PLUGINS}" | tee -a "$GITHUB_OUTPUT" fi + test-setup: + name: Test Setup & Gating Decisions + runs-on: ubuntu-latest + needs: [changes, labels] + permissions: + contents: read + outputs: + cre-should-run: ${{ steps.gate.outputs.cre-should-run }} + cre-with-regression: ${{ steps.gate.outputs.cre-with-regression }} + cre-run-mixed-env: ${{ steps.gate.outputs.cre-run-mixed-env }} + ccip-should-run: ${{ steps.gate.outputs.ccip-should-run }} + build-core-image: ${{ steps.gate.outputs.build-core-image }} + build-plugins-image: ${{ steps.gate.outputs.build-plugins-image }} + cre-matrix: ${{ steps.matrices.outputs.cre-matrix }} + cre-regression-matrix: ${{ steps.matrices.outputs.cre-regression-matrix }} + cre-mixed-env-matrix: ${{ steps.matrices.outputs.cre-mixed-env-matrix }} + ccip-matrix: ${{ steps.matrices.outputs.ccip-matrix }} + steps: + - name: Checkout the repo + uses: actions/checkout@v7 + with: + persist-credentials: false + repository: smartcontractkit/chainlink + ref: ${{ env.CHAINLINK_REF }} + + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version-file: go.mod + cache: false + + - name: Evaluate Gating Decisions + id: gate + env: + EVENT_NAME: ${{ github.event_name }} + REF_NAME: ${{ github.ref_name }} + REF_TYPE: ${{ github.ref_type }} + CRE_CHANGES: ${{ needs.changes.outputs.cre-e2e }} + CCIP_CHANGES: ${{ needs.changes.outputs.ccip-e2e }} + RUN_E2E_LABEL: ${{ needs.labels.outputs.run-e2e-tests-label-found }} + SKIP_REGRESSION_LABEL: ${{ needs.labels.outputs.skip-e2e-regression-label-found }} + SKIP_MIXED_ENV_LABEL: ${{ needs.labels.outputs.skip-mixed-env-label-found }} + run: go -C tools/ci run . gating + + - name: Generate Test Matrices + id: matrices + env: + RUN_ID: ${{ github.run_id }} + RUN_ATTEMPT: ${{ github.run_attempt }} + CRE_RUN: ${{ steps.gate.outputs.cre-should-run }} + CRE_REG_RUN: ${{ steps.gate.outputs.cre-with-regression }} + CRE_MIXED_RUN: ${{ steps.gate.outputs.cre-run-mixed-env }} + CCIP_RUN: ${{ steps.gate.outputs.ccip-should-run }} + run: | + go -C tools/ci run . matrix setup \ + --cre="${CRE_RUN}" \ + --cre-regression="${CRE_REG_RUN}" \ + --cre-mixed-env="${CRE_MIXED_RUN}" \ + --ccip="${CCIP_RUN}" \ + --run-id="${RUN_ID}" \ + --attempt="${RUN_ATTEMPT}" \ + --github-output + build-chainlink: name: Build Chainlink Image ${{ matrix.image.name }} if: github.actor != 'dependabot[bot]' @@ -269,8 +297,7 @@ jobs: [ labels, enforce-ctf-version, - run-core-cre-e2e-tests-setup, - run-ccip-v1-6-e2e-tests-setup, + test-setup, ] permissions: id-token: write @@ -283,21 +310,14 @@ jobs: dockerfile: core/chainlink.Dockerfile tag-suffix: "" cache-scope: core - any-should-run: >- - ${{ - needs.run-ccip-v1-6-e2e-tests-setup.outputs.should-run == 'true' || - needs.run-core-cre-e2e-tests-setup.outputs.should-run == 'true' - }} + any-should-run: ${{ needs.test-setup.outputs.build-core-image }} - name: (plugins) runner: ${{ needs.labels.outputs.builder-runner-label-plugins || 'ubuntu22.04-8cores-32GB' }} dockerfile: plugins/chainlink.Dockerfile tag-suffix: -plugins cache-scope: plugins - any-should-run: >- - ${{ - needs.run-core-cre-e2e-tests-setup.outputs.should-run == 'true' - }} + any-should-run: ${{ needs.test-setup.outputs.build-plugins-image }} steps: - name: Enable S3 Cache for Self-Hosted Runners uses: runs-on/action@4e5f72399b6b17f2e79c511c1b38a315a64d22dc # v2.2.0 @@ -324,8 +344,8 @@ jobs: elif [[ "${ANY_SHOULD_RUN}" == "true" && "${IMAGE_EXISTS}" != "true" ]]; then echo "We will build the image because the matrix's any-should-run is true and the image does not already exist in ECR." echo "any-should-run is true when:" - echo " - For the non-plugins image: the CCIP v1.6 tests will be run." - echo " - For the plugins image: the core cre e2e tests will be run." + echo " - For the non-plugins image: CCIP v1.6 tests or Core CRE tests will run." + echo " - For the plugins image: Core CRE tests will run." echo "build-image=true" | tee -a "$GITHUB_OUTPUT" else @@ -367,158 +387,54 @@ jobs: cache-map: | {"cache-mount/go-build-cache": {"target": "/var/cache-target", "id": "go-build-cache"}} - run-core-cre-e2e-tests-setup: - name: Run Core CRE E2E Tests Setup - runs-on: ubuntu-latest - needs: [changes, labels] - permissions: - contents: read - outputs: - should-run: ${{ steps.cre-e2e-tests.outputs.should-run }} - with-regression: ${{ steps.cre-regression-tests.outputs.with-regression }} - run-mixed-env: ${{ steps.cre-mixed-env-tests.outputs.run-mixed-env }} - steps: - - name: CRE E2E Tests - id: cre-e2e-tests - env: - GITHUB_EVENT_NAME: ${{ github.event_name }} - GITHUB_REF_TYPE: ${{ github.ref_type }} - CONTAINS_CHANGES: ${{ needs.changes.outputs.general-changes == 'true' || needs.changes.outputs.cre-changes == 'true' }} - RUN_E2E_TESTS_LABEL_FOUND: ${{ needs.labels.outputs.run-e2e-tests-label-found }} - run: | - SHOULD_RUN="false" - - # -- PULL REQUEST -- - if [[ "$GITHUB_EVENT_NAME" == 'pull_request' ]]; then - if [[ "$CONTAINS_CHANGES" == 'true' || "$RUN_E2E_TESTS_LABEL_FOUND" == 'true' ]]; then - SHOULD_RUN="true" - fi - - # -- MERGE GROUP -- - elif [[ "$GITHUB_EVENT_NAME" == 'merge_group' ]]; then - if [[ "$CONTAINS_CHANGES" == 'true' ]]; then - SHOULD_RUN="true" - fi - - # -- PUSH -- - elif [[ "$GITHUB_EVENT_NAME" == 'push' ]]; then - if [[ "$CONTAINS_CHANGES" == 'true' || "$GITHUB_REF_TYPE" == 'tag' ]]; then - SHOULD_RUN="true" - fi - - # -- WORKFLOW DISPATCH -- - elif [[ "$GITHUB_EVENT_NAME" == 'workflow_dispatch' ]]; then - SHOULD_RUN="true" - fi - - echo "should-run=${SHOULD_RUN}" | tee -a "$GITHUB_OUTPUT" - - - name: CRE E2E Regression Tests - id: cre-regression-tests - env: - GITHUB_EVENT_NAME: ${{ github.event_name }} - IS_DEFAULT_BRANCH: ${{ github.ref_name == 'develop' }} - SKIP_E2E_TESTS_REGRESSION_LABEL_FOUND: ${{ needs.labels.outputs.skip-e2e-regression-label-found }} - run: | - SHOULD_RUN_REGRESSION="false" - - # -- PULL REQUEST -- - if [[ "$GITHUB_EVENT_NAME" == 'pull_request' ]]; then - if [[ "$SKIP_E2E_TESTS_REGRESSION_LABEL_FOUND" != 'true' ]]; then - SHOULD_RUN_REGRESSION="true" - fi - - # -- PUSH -- - elif [[ "$GITHUB_EVENT_NAME" == 'push' ]]; then - if [[ "$IS_DEFAULT_BRANCH" == 'true' ]]; then - SHOULD_RUN_REGRESSION="true" - fi - - # -- WORKFLOW DISPATCH -- - elif [[ "$GITHUB_EVENT_NAME" == 'workflow_dispatch' ]]; then - SHOULD_RUN_REGRESSION="true" - fi - - echo "with-regression=${SHOULD_RUN_REGRESSION}" | tee -a "$GITHUB_OUTPUT" - - - name: CRE Mixed-Env Tests - id: cre-mixed-env-tests - env: - GITHUB_EVENT_NAME: ${{ github.event_name }} - IS_DEFAULT_BRANCH: ${{ github.ref_name == 'develop' }} - SKIP_MIXED_ENV_LABEL_FOUND: ${{ needs.labels.outputs.skip-mixed-env-label-found }} - run: | - RUN_MIXED_ENV="false" - - # -- PULL REQUEST -- - # Emergency escape hatch: apply the `skip-mixed-env` label to bypass the - # (required) mixed-env check without an admin. The job is then skipped, which - # the ETH Smoke Tests gate treats as a non-blocking warning. Like the regression - # tests, mixed-env is not run in the merge queue, so the bypass carries through. - if [[ "$GITHUB_EVENT_NAME" == 'pull_request' ]]; then - if [[ "$SKIP_MIXED_ENV_LABEL_FOUND" != 'true' ]]; then - RUN_MIXED_ENV="true" - fi - - # -- PUSH -- - elif [[ "$GITHUB_EVENT_NAME" == 'push' ]]; then - if [[ "$IS_DEFAULT_BRANCH" == 'true' ]]; then - RUN_MIXED_ENV="true" - fi - - # -- WORKFLOW DISPATCH -- - elif [[ "$GITHUB_EVENT_NAME" == 'workflow_dispatch' ]]; then - RUN_MIXED_ENV="true" - fi - - echo "run-mixed-env=${RUN_MIXED_ENV}" | tee -a "$GITHUB_OUTPUT" - run-core-cre-e2e-tests: name: Run Core CRE E2E Tests - needs: [build-chainlink, run-core-cre-e2e-tests-setup, compile-tests] + needs: [build-chainlink, test-setup, compile-tests] permissions: actions: read checks: write pull-requests: write id-token: write contents: read - if: needs.run-core-cre-e2e-tests-setup.outputs.should-run == 'true' + if: needs.test-setup.outputs.cre-should-run == 'true' uses: ./.github/workflows/cre-system-tests.yaml with: ecr: "sdlc" chainlink_image_repository_path: ${{ inputs.ecr_name || 'chainlink-integration-tests' }} chainlink_version: ${{ inputs.evm-ref || inputs.cl_ref || github.sha }} chainlink_image_tag: ${{ inputs.evm-ref && format('{0}', inputs.evm-ref) || inputs.cl_ref && format('{0}', inputs.cl_ref) || format('{0}', github.sha) }} + test_matrix: ${{ needs.test-setup.outputs.cre-matrix }} secrets: inherit run-core-cre-e2e-regression-tests: name: Run Core CRE E2E Regression Tests - needs: [build-chainlink, run-core-cre-e2e-tests-setup, compile-tests] + needs: [build-chainlink, test-setup, compile-tests] permissions: actions: read checks: write pull-requests: write id-token: write contents: read - if: needs.run-core-cre-e2e-tests-setup.outputs.should-run == 'true' && needs.run-core-cre-e2e-tests-setup.outputs.with-regression == 'true' + if: needs.test-setup.outputs.cre-should-run == 'true' && needs.test-setup.outputs.cre-with-regression == 'true' uses: ./.github/workflows/cre-regression-system-tests.yaml with: ecr: "sdlc" chainlink_image_repository_path: ${{ inputs.ecr_name || 'chainlink-integration-tests' }} chainlink_version: ${{ inputs.evm-ref || inputs.cl_ref || github.sha }} chainlink_image_tag: ${{ inputs.evm-ref && format('{0}', inputs.evm-ref) || inputs.cl_ref && format('{0}', inputs.cl_ref) || format('{0}', github.sha) }} + test_matrix: ${{ needs.test-setup.outputs.cre-regression-matrix }} secrets: inherit run-core-cre-mixed-env-tests: name: Run Core CRE Mixed-Env Tests - needs: [build-chainlink, run-core-cre-e2e-tests-setup, compile-tests] + needs: [build-chainlink, test-setup, compile-tests] permissions: actions: read checks: write pull-requests: write id-token: write contents: read - if: needs.run-core-cre-e2e-tests-setup.outputs.should-run == 'true' && needs.run-core-cre-e2e-tests-setup.outputs.run-mixed-env == 'true' + if: needs.test-setup.outputs.cre-should-run == 'true' && needs.test-setup.outputs.cre-run-mixed-env == 'true' uses: ./.github/workflows/cre-mixed-env-tests.yaml with: ecr: "sdlc" @@ -532,49 +448,9 @@ jobs: # any other base without a published image is skipped (no correct baseline). baseline_ref: ${{ github.event.pull_request.base.sha }} base_ref_name: ${{ github.event.pull_request.base.ref }} + test_matrix: ${{ needs.test-setup.outputs.cre-mixed-env-matrix }} secrets: inherit - run-ccip-v1-6-e2e-tests-setup: - name: Run CCIP v1.6 E2E Tests Setup - runs-on: ubuntu-latest - needs: [changes, labels] - permissions: - contents: read - outputs: - should-run: ${{ steps.form-inputs.outputs.should-run }} - steps: - - name: Form Inputs for CCIP v1.6 E2E Tests - id: form-inputs - env: - GITHUB_EVENT_NAME: ${{ github.event_name }} - GITHUB_REF_TYPE: ${{ github.ref_type }} - CONTAINS_CHANGES: ${{ needs.changes.outputs.ccip-changes == 'true' }} - RUN_E2E_TESTS_LABEL_FOUND: ${{ needs.labels.outputs.run-e2e-tests-label-found || 'false' }} - run: | - if [[ "${GITHUB_EVENT_NAME}" == 'pull_request' ]]; then - # CCIP v1.6 E2E Tests run on merge queue (merge_group), not on PRs - echo "should-run=false" | tee -a "$GITHUB_OUTPUT" - - elif [[ "${GITHUB_EVENT_NAME}" == 'merge_group' ]]; then - # Run CCIP v1.6 E2E Tests in the merge queue, if there are relevant changes - echo "should-run=${CONTAINS_CHANGES}" | tee -a "$GITHUB_OUTPUT" - - elif [[ "${GITHUB_EVENT_NAME}" == 'workflow_dispatch' ]]; then - # Always Run CCIP v1.6 E2E Tests on workflow dispatch - echo "should-run=true" | tee -a "$GITHUB_OUTPUT" - - elif [[ "${GITHUB_EVENT_NAME}" == 'push' ]]; then - # Run CCIP v1.6 E2E Tests on push events, only if there are relevant changes or if it's a tag push - if [[ "${CONTAINS_CHANGES}" == 'true' || "${GITHUB_REF_TYPE}" == 'tag' ]]; then - echo "should-run=true" | tee -a "$GITHUB_OUTPUT" - else - echo "should-run=false" | tee -a "$GITHUB_OUTPUT" - fi - - else - echo "should-run=false" | tee -a "$GITHUB_OUTPUT" - fi - # Central writer for the unified `integration-tests-v1` build cache consumed (restore-only) by # CRE smoke + regression matrices and CCIP test suites. # Keyed on go.sum hash to optimize parallel PR cache hits (Strategy A + B + C). @@ -583,12 +459,11 @@ jobs: if: >- ${{ github.actor != 'dependabot[bot]' && - (needs.run-core-cre-e2e-tests-setup.outputs.should-run == 'true' || - needs.run-ccip-v1-6-e2e-tests-setup.outputs.should-run == 'true') + (needs.test-setup.outputs.cre-should-run == 'true' || + needs.test-setup.outputs.ccip-should-run == 'true') }} needs: - - run-core-cre-e2e-tests-setup - - run-ccip-v1-6-e2e-tests-setup + - test-setup runs-on: runs-on=${{ github.run_id }}-compile/cpu=32/ram=64/family=c7i+c8i/spot=co/volume=100GB/extras=s3-cache environment: name: integration @@ -635,8 +510,8 @@ jobs: - name: Compile E2E tests shell: bash env: - SHOULD_RUN_CRE: ${{ needs.run-core-cre-e2e-tests-setup.outputs.should-run }} - SHOULD_RUN_CCIP: ${{ needs.run-ccip-v1-6-e2e-tests-setup.outputs.should-run }} + SHOULD_RUN_CRE: ${{ needs.test-setup.outputs.cre-should-run }} + SHOULD_RUN_CCIP: ${{ needs.test-setup.outputs.ccip-should-run }} run: | set -euo pipefail mkdir -p "$GITHUB_WORKSPACE/.gotmp" @@ -690,21 +565,21 @@ jobs: rm -rf "$GITHUB_WORKSPACE/.gotmp" - name: Cache Pre-Compiled CRE Test Binaries - if: needs.run-core-cre-e2e-tests-setup.outputs.should-run == 'true' + if: needs.test-setup.outputs.cre-should-run == 'true' uses: actions/cache/save@v6 with: path: system-tests/tests/bin/ key: test-binaries-cre-${{ inputs.evm-ref || inputs.cl_ref || github.sha }} - name: Cache Pre-Compiled CCIP Test Binaries - if: needs.run-ccip-v1-6-e2e-tests-setup.outputs.should-run == 'true' + if: needs.test-setup.outputs.ccip-should-run == 'true' uses: actions/cache/save@v6 with: path: integration-tests/bin/ key: test-binaries-${{ inputs.evm-ref || inputs.cl_ref || github.sha }} run-ccip-v1-6-e2e-tests: - needs: [run-ccip-v1-6-e2e-tests-setup, build-chainlink, compile-tests] + needs: [test-setup, build-chainlink, compile-tests] name: Run CCIP v1.6 E2E Tests permissions: actions: read @@ -712,13 +587,14 @@ jobs: pull-requests: write id-token: write contents: read - if: needs.run-ccip-v1-6-e2e-tests-setup.outputs.should-run == 'true' + if: needs.test-setup.outputs.ccip-should-run == 'true' uses: ./.github/workflows/ccip-system-tests.yaml with: ecr: "sdlc" chainlink_image_repository_path: ${{ inputs.ecr_name || 'chainlink-integration-tests' }} chainlink_version: ${{ inputs.evm-ref || inputs.cl_ref || github.sha }} chainlink_image_tag: ${{ inputs.evm-ref && format('{0}', inputs.evm-ref) || inputs.cl_ref && format('{0}', inputs.cl_ref) || format('{0}', github.sha) }} + test_matrix: ${{ needs.test-setup.outputs.ccip-matrix }} secrets: inherit check-e2e-test-results: diff --git a/tools/bin/go_deployment_tests b/tools/bin/go_deployment_tests index 2ad63d1ac81..72df85b137e 100755 --- a/tools/bin/go_deployment_tests +++ b/tools/bin/go_deployment_tests @@ -52,7 +52,7 @@ if [[ -z "$TEST_PACKAGE_OUTPUT" ]]; then fi mapfile -t TEST_PACKAGES <<<"$TEST_PACKAGE_OUTPUT" -SHARD_PACKAGE_OUTPUT=$(printf '%s\n' "${TEST_PACKAGES[@]}" | go run github.com/smartcontractkit/chainlink/v2/tools/ci-testshard list --shard-count "$GO_TEST_SHARD_COUNT" --shard-index "$GO_TEST_SHARD_INDEX") +SHARD_PACKAGE_OUTPUT=$(printf '%s\n' "${TEST_PACKAGES[@]}" | go -C "$SCRIPT_PATH/../ci" run . testshard list --shard-count "$GO_TEST_SHARD_COUNT" --shard-index "$GO_TEST_SHARD_INDEX") if [[ $? -ne 0 ]]; then exit 1 fi diff --git a/tools/ci-testshard/main.go b/tools/ci-testshard/main.go deleted file mode 100644 index 1541e43d700..00000000000 --- a/tools/ci-testshard/main.go +++ /dev/null @@ -1,173 +0,0 @@ -package main - -import ( - "bufio" - "errors" - "flag" - "fmt" - "hash/fnv" - "io" - "os" - "strings" -) - -func main() { - if err := run(os.Args[1:], os.Stdin, os.Stdout); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} - -func run(args []string, stdin io.Reader, stdout io.Writer) error { - if len(args) == 0 { - return usageError("expected subcommand: list or verify") - } - - switch args[0] { - case "list": - return runList(args[1:], stdin, stdout) - case "verify": - return runVerify(args[1:], stdin, stdout) - case "-h", "--help", "help": - printUsage(stdout) - return nil - default: - return usageError("unknown subcommand %q", args[0]) - } -} - -func runList(args []string, stdin io.Reader, stdout io.Writer) error { - fs := flag.NewFlagSet("list", flag.ContinueOnError) - fs.SetOutput(io.Discard) - - shardCount := fs.Int("shard-count", 1, "total number of shards") - shardIndex := fs.Int("shard-index", 0, "zero-based shard index") - - if err := fs.Parse(args); err != nil { - return usageError("%v", err) - } - if fs.NArg() != 0 { - return usageError("list takes no positional arguments") - } - - packages, err := readPackages(stdin) - if err != nil { - return err - } - if err := validateShardArgs(*shardCount, *shardIndex); err != nil { - return err - } - - for _, pkg := range packages { - if shardForPackage(pkg, *shardCount) == *shardIndex { - if _, err := fmt.Fprintln(stdout, pkg); err != nil { - return err - } - } - } - - return nil -} - -func runVerify(args []string, stdin io.Reader, stdout io.Writer) error { - fs := flag.NewFlagSet("verify", flag.ContinueOnError) - fs.SetOutput(io.Discard) - - shardCount := fs.Int("shard-count", 1, "total number of shards") - - if err := fs.Parse(args); err != nil { - return usageError("%v", err) - } - if fs.NArg() != 0 { - return usageError("verify takes no positional arguments") - } - - packages, err := readPackages(stdin) - if err != nil { - return err - } - if *shardCount < 1 { - return fmt.Errorf("invalid --shard-count %d: must be >= 1", *shardCount) - } - - shardSizes := make([]int, *shardCount) - seen := make(map[string]int, len(packages)) - for _, pkg := range packages { - shardIndex := shardForPackage(pkg, *shardCount) - shardSizes[shardIndex]++ - seen[pkg]++ - } - - for _, pkg := range packages { - if seen[pkg] != 1 { - return fmt.Errorf("package %q assigned %d times", pkg, seen[pkg]) - } - } - - if _, err := fmt.Fprintf(stdout, "verified %d packages across %d shards\n", len(packages), *shardCount); err != nil { - return err - } - for shardIndex, size := range shardSizes { - if _, err := fmt.Fprintf(stdout, "shard %d: %d packages\n", shardIndex, size); err != nil { - return err - } - } - - return nil -} - -func readPackages(r io.Reader) ([]string, error) { - scanner := bufio.NewScanner(r) - packages := make([]string, 0) - seen := make(map[string]struct{}) - - for scanner.Scan() { - pkg := strings.TrimSpace(scanner.Text()) - if pkg == "" { - continue - } - - if _, exists := seen[pkg]; exists { - return nil, fmt.Errorf("duplicate package path %q", pkg) - } - seen[pkg] = struct{}{} - packages = append(packages, pkg) - } - - if err := scanner.Err(); err != nil { - return nil, err - } - if len(packages) == 0 { - return nil, errors.New("no package paths provided on stdin") - } - - return packages, nil -} - -func validateShardArgs(shardCount, shardIndex int) error { - if shardCount < 1 { - return fmt.Errorf("invalid --shard-count %d: must be >= 1", shardCount) - } - if shardIndex < 0 || shardIndex >= shardCount { - return fmt.Errorf("invalid --shard-index %d: must be in [0,%d)", shardIndex, shardCount) - } - return nil -} - -func shardForPackage(pkg string, shardCount int) int { - hasher := fnv.New32a() - _, _ = hasher.Write([]byte(pkg)) // hash.Hash.Write on fnv (Fowler-Noll-Vo) never returns an error - return int(int64(hasher.Sum32()) % int64(shardCount)) -} - -func printUsage(w io.Writer) { - fmt.Fprintln(w, "usage: ci-testshard [flags]") - fmt.Fprintln(w, "") - fmt.Fprintln(w, "Commands:") - fmt.Fprintln(w, " list read newline-delimited package paths from stdin and emit one shard") - fmt.Fprintln(w, " verify read newline-delimited package paths from stdin and verify shard coverage") -} - -func usageError(format string, args ...any) error { - return fmt.Errorf(format, args...) -} diff --git a/tools/ci-testshard/main_test.go b/tools/ci-testshard/main_test.go deleted file mode 100644 index 3ee665ec6e6..00000000000 --- a/tools/ci-testshard/main_test.go +++ /dev/null @@ -1,271 +0,0 @@ -package main - -import ( - "bytes" - "fmt" - "strconv" - "strings" - "testing" -) - -func TestReadPackagesRejectsDuplicatePaths(t *testing.T) { - _, err := readPackages(strings.NewReader("pkg/a\npkg/a\n")) - if err == nil || !strings.Contains(err.Error(), `duplicate package path "pkg/a"`) { - t.Fatalf("expected duplicate package error, got %v", err) - } -} - -func TestReadPackagesRejectsEmptyInput(t *testing.T) { - _, err := readPackages(strings.NewReader("\n\n")) - if err == nil || !strings.Contains(err.Error(), "no package paths provided on stdin") { - t.Fatalf("expected empty input error, got %v", err) - } -} - -func TestReadPackagesTrimsWhitespace(t *testing.T) { - packages, err := readPackages(strings.NewReader(" pkg/a \n\tpkg/b\t\n")) - if err != nil { - t.Fatalf("readPackages failed: %v", err) - } - if len(packages) != 2 || packages[0] != "pkg/a" || packages[1] != "pkg/b" { - t.Fatalf("unexpected packages: %#v", packages) - } -} - -func TestReadPackagesIgnoresBlankLinesBetweenPackages(t *testing.T) { - packages, err := readPackages(strings.NewReader("pkg/a\n\n \n\t\npkg/b\n")) - if err != nil { - t.Fatalf("readPackages failed: %v", err) - } - if len(packages) != 2 || packages[0] != "pkg/a" || packages[1] != "pkg/b" { - t.Fatalf("unexpected packages: %#v", packages) - } -} - -func TestReadPackagesRejectsDuplicatePathsAfterTrimming(t *testing.T) { - _, err := readPackages(strings.NewReader("pkg/a\n pkg/a \n")) - if err == nil || !strings.Contains(err.Error(), `duplicate package path "pkg/a"`) { - t.Fatalf("expected duplicate package error after trimming, got %v", err) - } -} - -func TestListReturnsPartitionWithoutOverlap(t *testing.T) { - input := "pkg/a\npkg/b\npkg/c\npkg/d\n" - - seen := make(map[string]struct{}) - for shardIndex := range 4 { - packages := runListForTest(t, input, 4, shardIndex) - for _, pkg := range packages { - if _, exists := seen[pkg]; exists { - t.Fatalf("package %s appeared in multiple shards", pkg) - } - seen[pkg] = struct{}{} - } - } - - for _, pkg := range []string{"pkg/a", "pkg/b", "pkg/c", "pkg/d"} { - if _, exists := seen[pkg]; !exists { - t.Fatalf("package %s missing from shard union", pkg) - } - } -} - -func TestListWithSingleShardReturnsEntireInputInOrder(t *testing.T) { - input := "pkg/a\npkg/b\npkg/c\n" - packages := runListForTest(t, input, 1, 0) - want := []string{"pkg/a", "pkg/b", "pkg/c"} - if len(packages) != len(want) { - t.Fatalf("unexpected package count: got %d want %d (%v)", len(packages), len(want), packages) - } - for i := range want { - if packages[i] != want[i] { - t.Fatalf("unexpected package at %d: got %q want %q", i, packages[i], want[i]) - } - } -} - -func TestListProducesDeterministicOutput(t *testing.T) { - input := "pkg/a\npkg/b\npkg/c\npkg/d\npkg/e\n" - first := runListOutputForTest(t, input, 4, 2) - second := runListOutputForTest(t, input, 4, 2) - if first != second { - t.Fatalf("list output changed between runs:\nfirst:\n%s\nsecond:\n%s", first, second) - } -} - -func TestListCanProduceEmptyShard(t *testing.T) { - input := "pkg/a\npkg/b\n" - foundEmpty := false - for shardIndex := range 10 { - if output := runListOutputForTest(t, input, 10, shardIndex); output == "" { - foundEmpty = true - break - } - } - if !foundEmpty { - t.Fatal("expected at least one empty shard for 2 packages across 10 shards") - } -} - -func TestListAndVerifyAgreeOnPartition(t *testing.T) { - inputPackages := []string{ - "pkg/a", - "pkg/b", - "pkg/c", - "pkg/d", - "pkg/e", - "pkg/f", - } - input := strings.Join(inputPackages, "\n") + "\n" - seen := make(map[string]struct{}, len(inputPackages)) - - for shardIndex := range 4 { - for _, pkg := range runListForTest(t, input, 4, shardIndex) { - if _, exists := seen[pkg]; exists { - t.Fatalf("package %s appeared in multiple shards", pkg) - } - seen[pkg] = struct{}{} - } - } - - for _, pkg := range inputPackages { - if _, exists := seen[pkg]; !exists { - t.Fatalf("package %s missing from shard union", pkg) - } - } - - var stdout bytes.Buffer - if err := run([]string{"verify", "--shard-count", "4"}, strings.NewReader(input), &stdout); err != nil { - t.Fatalf("verify failed: %v", err) - } -} - -func TestVerifyAllowsEmptyShard(t *testing.T) { - var stdout bytes.Buffer - err := run([]string{"verify", "--shard-count", "10"}, strings.NewReader("pkg/a\npkg/b\n"), &stdout) - if err != nil { - t.Fatalf("verify failed: %v", err) - } - if !strings.Contains(stdout.String(), "verified 2 packages across 10 shards") { - t.Fatalf("unexpected verify output: %q", stdout.String()) - } -} - -func TestVerifyWithSingleShardCoversEntireInput(t *testing.T) { - var stdout bytes.Buffer - err := run([]string{"verify", "--shard-count", "1"}, strings.NewReader("pkg/a\npkg/b\npkg/c\n"), &stdout) - if err != nil { - t.Fatalf("verify failed: %v", err) - } - - output := stdout.String() - if !strings.Contains(output, "verified 3 packages across 1 shards") { - t.Fatalf("unexpected verify summary: %q", output) - } - if !strings.Contains(output, "shard 0: 3 packages") { - t.Fatalf("unexpected shard coverage: %q", output) - } -} - -func TestVerifyRejectsDuplicatePaths(t *testing.T) { - var stdout bytes.Buffer - err := run([]string{"verify", "--shard-count", "2"}, strings.NewReader("pkg/a\npkg/a\n"), &stdout) - if err == nil || !strings.Contains(err.Error(), `duplicate package path "pkg/a"`) { - t.Fatalf("expected duplicate package failure, got %v", err) - } -} - -func TestVerifyRejectsDuplicatePathsAmongOthers(t *testing.T) { - var stdout bytes.Buffer - err := run([]string{"verify", "--shard-count", "2"}, strings.NewReader("pkg/a\npkg/b\npkg/c\npkg/d\npkg/e\npkg/a\n"), &stdout) - if err == nil || !strings.Contains(err.Error(), `duplicate package path "pkg/a"`) { - t.Fatalf("expected duplicate package failure, got %v", err) - } -} - -func TestInvalidShardParamsFail(t *testing.T) { - tests := []struct { - name string - args []string - }{ - {name: "zero-count", args: []string{"list", "--shard-count", "0", "--shard-index", "0"}}, - {name: "negative-index", args: []string{"list", "--shard-count", "2", "--shard-index", "-1"}}, - {name: "index-out-of-range", args: []string{"list", "--shard-count", "2", "--shard-index", "2"}}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - err := run(tc.args, strings.NewReader("pkg/a\n"), &bytes.Buffer{}) - if err == nil { - t.Fatal("expected error") - } - }) - } -} - -func TestUnknownSubcommandFails(t *testing.T) { - err := run([]string{"wat"}, strings.NewReader("pkg/a\n"), &bytes.Buffer{}) - if err == nil || !strings.Contains(err.Error(), `unknown subcommand "wat"`) { - t.Fatalf("expected unknown subcommand error, got %v", err) - } -} - -func TestExtraPositionalArgsFail(t *testing.T) { - tests := []struct { - name string - args []string - want string - }{ - {name: "list", args: []string{"list", "--shard-count", "2", "--shard-index", "0", "extra"}, want: "list takes no positional arguments"}, - {name: "verify", args: []string{"verify", "--shard-count", "2", "extra"}, want: "verify takes no positional arguments"}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - err := run(tc.args, strings.NewReader("pkg/a\n"), &bytes.Buffer{}) - if err == nil || !strings.Contains(err.Error(), tc.want) { - t.Fatalf("expected %q, got %v", tc.want, err) - } - }) - } -} - -func TestLargePackageListParses(t *testing.T) { - var builder strings.Builder - for i := range 500 { - fmt.Fprintf(&builder, "pkg/%03d\n", i) - } - - packages, err := readPackages(strings.NewReader(builder.String())) - if err != nil { - t.Fatalf("readPackages failed: %v", err) - } - if len(packages) != 500 { - t.Fatalf("unexpected package count: got %d want 500", len(packages)) - } - if packages[0] != "pkg/000" || packages[499] != "pkg/499" { - t.Fatalf("unexpected package boundaries: first=%q last=%q", packages[0], packages[499]) - } -} - -func runListForTest(t *testing.T, input string, shardCount, shardIndex int) []string { - t.Helper() - output := runListOutputForTest(t, input, shardCount, shardIndex) - if output == "" { - return nil - } - return strings.Fields(output) -} - -func runListOutputForTest(t *testing.T, input string, shardCount, shardIndex int) string { - t.Helper() - var stdout bytes.Buffer - if err := run( - []string{"list", "--shard-count", strconv.Itoa(shardCount), "--shard-index", strconv.Itoa(shardIndex)}, - strings.NewReader(input), - &stdout, - ); err != nil { - t.Fatalf("list failed for shard %d/%d: %v", shardIndex, shardCount, err) - } - return stdout.String() -} diff --git a/tools/ci/format_changelog b/tools/ci/format_changelog deleted file mode 100755 index c83948a1d47..00000000000 --- a/tools/ci/format_changelog +++ /dev/null @@ -1,172 +0,0 @@ -#!/usr/bin/env bash - -# This script will generate the next release using changeset. -# However, since changeset has its own semvar versioning system -# and we have our custom tags, this script rewrites the CHANGELOG.md -# with each tag as its header and group respective changeset that has -# the tag under it. -# -# The workflow is here: -# https://github.com/smartcontractkit/chainlink/actions/workflows/changesets-preview-pr.yml - -set -euo pipefail - -if [[ -z "${GITHUB_OUTPUT:-}" ]]; then - echo "GITHUB_OUTPUT environment variable is not set." - exit 1 -fi - -create_changesets_json() { - echo "[[]]" > changesets.json -} - -create_tags_json() { - json="{}" - for tag in "${tags_list[@]}"; do - tag=${tag:1} - json=$(jq --arg k "$tag" '.[$k] = []' <<< "$json") - done - echo "$json" > tags.json -} - -append_changeset_content() { - if [[ $1 != "" ]]; then - jq --argjson idx "$changesets_index" --arg str "$1" \ - '.[$idx] += [$str]' changesets.json > tmp.json && mv tmp.json changesets.json - fi -} - -append_changelog_content() { - for tag in "${tags_list[@]}"; do - tag=${tag:1} - array_length=$(jq -r --arg key "$tag" '.[$key] | length' tags.json) - if [[ $array_length -eq 0 ]]; then - continue - fi - changesets=$(jq -r --arg key "$tag" '.[$key] | join("\n\n")' tags.json) - read -d '' changelog_content <> $GITHUB_OUTPUT - echo "${pr_body}" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT -} - -set_new_changelog_content() { - read -d '' new_changelog < CHANGELOG.md -} - -# checks for tags in each changeset entry and append to tags.json -match_tags() { - changesets_with_index=$(jq -r 'to_entries | .[] | "\(.key) \(.value | join(" "))"' changesets.json) - - echo "$changesets_with_index" | while IFS= read -r line; do - index="${line%% *}" - changeset_content="${line#* }" - changeset_formatted=$(jq -r --argjson idx "$index" '.[$idx] | join("\n")' changesets.json) - found_tag="" - for tag in "${tags_list[@]}"; do - if [[ "$changeset_content" =~ $tag ]]; then - found_tag=${tag:1} - jq --arg key "$found_tag" --arg val "$changeset_formatted" \ - '.[$key] += [$val]' tags.json > tmp.json && mv tmp.json tags.json - fi - done - if [[ $found_tag == "" ]] && [[ ! -z $changeset_content ]]; then - found_tag="untagged" - jq --arg key "$found_tag" --arg val "$changeset_formatted" \ - '.[$key] += [$val]' tags.json > tmp.json && mv tmp.json tags.json - fi - done -} - -cleanup() { - rm -f CHANGELOG.md.tmp - rm -f changesets.json - rm -f tags.json -} - -### SCRIPT STARTS HERE ### - -tail -n +2 CHANGELOG.md > CHANGELOG.md.tmp - -pnpm changeset version - -version=$(jq -r '.version' package.json) -echo "version=$version" >> $GITHUB_OUTPUT - -read -d '' changelog_content < 0 { + currentEntries = append(currentEntries, strings.TrimRight(currentEntry.String(), "\n")) + currentEntry.Reset() + } + inCurrentVersion = false + pastStarted = true + pastChangelog.WriteString(line + "\n") + continue + } + } + + if pastStarted { + pastChangelog.WriteString(line + "\n") + continue + } + + if inCurrentVersion { + // Skip subheaders like "### Minor Changes", "### Patch Changes" + if strings.HasPrefix(line, "### ") { + continue + } + + if strings.HasPrefix(line, "- ") { + if currentEntry.Len() > 0 { + currentEntries = append(currentEntries, strings.TrimRight(currentEntry.String(), "\n")) + currentEntry.Reset() + } + currentEntry.WriteString(line + "\n") + } else if currentEntry.Len() > 0 { + currentEntry.WriteString(line + "\n") + } + } + } + + if currentEntry.Len() > 0 { + currentEntries = append(currentEntries, strings.TrimRight(currentEntry.String(), "\n")) + } + + // Group entries by tags (an entry matching multiple tags appears under all matching tags) + tagMap := make(map[string][]string) + for _, entry := range currentEntries { + matchedAny := false + for _, tag := range tagsList { + if tag == "#untagged" { + continue + } + if strings.Contains(entry, tag) { + tagMap[tag] = append(tagMap[tag], entry) + matchedAny = true + } + } + if !matchedAny { + tagMap["#untagged"] = append(tagMap["#untagged"], entry) + } + } + + // Build grouped changelog section + var changelogSection strings.Builder + var prBodySection strings.Builder + + changelogSection.WriteString(fmt.Sprintf("## %s - PREVIEW\n", version)) + + for _, tag := range tagsList { + entries, exists := tagMap[tag] + if !exists || len(entries) == 0 { + continue + } + cleanTagName := strings.TrimPrefix(tag, "#") + heading := fmt.Sprintf("\n## %s\n\n", cleanTagName) + + changelogSection.WriteString(heading) + prBodySection.WriteString(heading) + + for _, e := range entries { + changelogSection.WriteString(e + "\n") + prBodySection.WriteString(e + "\n") + } + } + + // Compose full new CHANGELOG.md content + var newChangelog strings.Builder + newChangelog.WriteString("# Changelog Chainlink Core\n\n") + newChangelog.WriteString(changelogSection.String()) + newChangelog.WriteString("\n") + newChangelog.WriteString(pastChangelog.String()) + + // Compose PR body + var prBody string + prBodyCandidate := prHeader + prBodySection.String() + if len(prBodyCandidate) > maxPRDescLength { + prBody = prHeader + prTruncatedMsg + } else { + prBody = prBodyCandidate + } + + result := &Result{ + Version: version, + PRBody: prBody, + NewChangelog: newChangelog.String(), + } + + // Write updated changelog back to disk + if err := os.WriteFile(resolvedChangelog, []byte(newChangelog.String()), 0o600); err != nil { + return nil, fmt.Errorf("failed to write updated changelog %s: %w", resolvedChangelog, err) + } + + // Write to GITHUB_OUTPUT if requested + if writeGithubOutput { + if err := githuboutput.AppendVar("version", version); err != nil { + return nil, fmt.Errorf("failed to write version to GITHUB_OUTPUT: %w", err) + } + if err := githuboutput.AppendMultilineVar("pr_body", prBody); err != nil { + return nil, fmt.Errorf("failed to write pr_body to GITHUB_OUTPUT: %w", err) + } + } + + return result, nil +} diff --git a/tools/ci/internal/changelog/format_test.go b/tools/ci/internal/changelog/format_test.go new file mode 100644 index 00000000000..13326bf2af6 --- /dev/null +++ b/tools/ci/internal/changelog/format_test.go @@ -0,0 +1,93 @@ +package changelog + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParseVersion(t *testing.T) { + t.Parallel() + tempDir := t.TempDir() + pkgPath := filepath.Join(tempDir, "package.json") + require.NoError(t, os.WriteFile(pkgPath, []byte(`{"name": "chainlink", "version": "2.21.0"}`), 0600)) + + ver, err := ReadVersionFromPackageJSON(pkgPath) + require.NoError(t, err) + require.Equal(t, "2.21.0", ver) +} + +func TestFormatChangelog(t *testing.T) { + tempDir := t.TempDir() + pkgPath := filepath.Join(tempDir, "package.json") + changelogPath := filepath.Join(tempDir, "CHANGELOG.md") + outputFile := filepath.Join(tempDir, "github_output") + + require.NoError(t, os.WriteFile(pkgPath, []byte(`{"version": "2.21.0"}`), 0600)) + + sampleChangelog := `# Changelog Chainlink Core + +## 2.21.0 + +### Minor Changes + +- [#added] [#nops] Add new OCR3 consensus feature + extra details on consensus +- [#bugfix] Fix token expiration check +- Untagged change item + +## 2.20.0 + +- Old version change +` + require.NoError(t, os.WriteFile(changelogPath, []byte(sampleChangelog), 0600)) + + t.Setenv("GITHUB_OUTPUT", outputFile) + + res, err := Format(changelogPath, pkgPath, true) + require.NoError(t, err) + + require.Equal(t, "2.21.0", res.Version) + require.Contains(t, res.NewChangelog, "## 2.21.0 - PREVIEW") + require.Contains(t, res.NewChangelog, "## added") + require.Contains(t, res.NewChangelog, "## bugfix") + require.Contains(t, res.NewChangelog, "## untagged") + require.Contains(t, res.NewChangelog, "## 2.20.0") + + // PR body check + require.Contains(t, res.PRBody, "This PR is a preview of the changes") + require.Contains(t, res.PRBody, "## added") + + // Verify CHANGELOG.md was updated + diskContent, err := os.ReadFile(changelogPath) + require.NoError(t, err) + require.Equal(t, res.NewChangelog, string(diskContent)) + + // Verify GITHUB_OUTPUT was written + ghOutput, err := os.ReadFile(outputFile) + require.NoError(t, err) + require.Contains(t, string(ghOutput), "version=2.21.0") + require.Contains(t, string(ghOutput), "pr_body< to file specified in $GITHUB_OUTPUT") + + cmd.AddCommand(newMatrixSetupCmd(stdout)) + + return cmd +} + +func newMatrixSetupCmd(stdout io.Writer) *cobra.Command { + var ( + runID string + attempt string + creSmoke bool + creSmokeDir string + creRegression bool + creRegressionDir string + creMixedEnv bool + ccip bool + ccipDir string + githubOutput bool + ) + + cmd := &cobra.Command{ + Use: "setup", + Short: "Generate all enabled test matrices for integration-tests workflow setup", + Example: ` go run ./tools/ci matrix setup --cre=true --ccip=true --run-id=123 --attempt=1 --github-output`, + RunE: func(cmd *cobra.Command, args []string) error { + matrices, err := matrix.GenerateSetupMatrices(matrix.SetupOptions{ + RunID: runID, + RunAttempt: attempt, + CRESmoke: creSmoke, + CRESmokeDir: creSmokeDir, + CRERegression: creRegression, + CRERegressionDir: creRegressionDir, + CREMixedEnv: creMixedEnv, + CCIP: ccip, + CCIPDir: ccipDir, + }) + if err != nil { + return err + } + return matrix.WriteMultiOutput(stdout, matrices, githubOutput) + }, + } + + flags := cmd.Flags() + flags.StringVar(&runID, "run-id", "0", "GitHub Actions run ID for unique runner labels") + flags.StringVar(&attempt, "attempt", "1", "GitHub Actions run attempt for unique runner labels") + flags.BoolVar(&creSmoke, "cre", false, "Generate cre-matrix for CRE smoke tests") + flags.StringVar(&creSmokeDir, "cre-smoke-dir", "", "Custom dir for CRE smoke tests") + flags.BoolVar(&creRegression, "cre-regression", false, "Generate cre-regression-matrix for CRE regression tests") + flags.StringVar(&creRegressionDir, "cre-regression-dir", "", "Custom dir for CRE regression tests") + flags.BoolVar(&creMixedEnv, "cre-mixed-env", false, "Generate cre-mixed-env-matrix for CRE mixed-env tests") + flags.BoolVar(&ccip, "ccip", false, "Generate ccip-matrix for CCIP v1.6 tests") + flags.StringVar(&ccipDir, "ccip-dir", "", "Custom dir for CCIP tests") + flags.BoolVar(&githubOutput, "github-output", false, "Append outputs to file specified in $GITHUB_OUTPUT") + + return cmd +} diff --git a/tools/ci/internal/cmd/root.go b/tools/ci/internal/cmd/root.go new file mode 100644 index 00000000000..635063cdca2 --- /dev/null +++ b/tools/ci/internal/cmd/root.go @@ -0,0 +1,37 @@ +package cmd + +import ( + "context" + "io" + "os" + + "github.com/spf13/cobra" +) + +// NewRootCmd creates the root 'ci' command with all subcommands attached. +func NewRootCmd(stdin io.Reader, stdout, stderr io.Writer) *cobra.Command { + rootCmd := &cobra.Command{ + Use: "ci", + Short: "Chainlink CI automation tool", + Long: "Universal CI CLI for test matrix discovery, package sharding, changelog preview generation, and automation tooling.", + SilenceUsage: true, + SilenceErrors: true, + } + + rootCmd.SetIn(stdin) + rootCmd.SetOut(stdout) + rootCmd.SetErr(stderr) + + rootCmd.AddCommand(newMatrixCmd(stdout)) + rootCmd.AddCommand(newTestshardCmd(stdin, stdout, stderr)) + rootCmd.AddCommand(newChangelogCmd(stdout)) + rootCmd.AddCommand(newGatingCmd(stdout)) + + return rootCmd +} + +// Execute runs the root command bound to os.Stdin, os.Stdout, and os.Stderr. +func Execute(ctx context.Context) error { + cmd := NewRootCmd(os.Stdin, os.Stdout, os.Stderr) + return cmd.ExecuteContext(ctx) +} diff --git a/tools/ci/internal/cmd/testshard.go b/tools/ci/internal/cmd/testshard.go new file mode 100644 index 00000000000..b34fb6bae86 --- /dev/null +++ b/tools/ci/internal/cmd/testshard.go @@ -0,0 +1,51 @@ +package cmd + +import ( + "io" + + "github.com/spf13/cobra" + + "github.com/smartcontractkit/chainlink/v2/tools/ci/internal/testshard" +) + +func newTestshardCmd(stdin io.Reader, stdout, stderr io.Writer) *cobra.Command { + var ( + shardCount int + shardIndex int + ) + + cmd := &cobra.Command{ + Use: "testshard", + Short: "Assign or verify Go packages into deterministic test shards", + Long: "Reads package paths from stdin and either filters for a specific shard index or verifies shard coverage.", + } + + listCmd := &cobra.Command{ + Use: "list", + Short: "Filter stdin package paths for a specific shard index", + Example: " printf '%s\\n' pkgA pkgB | go run ./tools/ci testshard list --shard-count=4 --shard-index=0", + RunE: func(cmd *cobra.Command, args []string) error { + return testshard.List(stdin, stdout, shardCount, shardIndex) + }, + } + listCmd.Flags().IntVar(&shardCount, "shard-count", 0, "Total number of shards (must be >= 1)") + listCmd.Flags().IntVar(&shardIndex, "shard-index", -1, "Target shard index to list (0-based, must be in [0, shard-count))") + _ = listCmd.MarkFlagRequired("shard-count") + _ = listCmd.MarkFlagRequired("shard-index") + + verifyCmd := &cobra.Command{ + Use: "verify", + Short: "Verify that all stdin package paths are assigned across shards without duplicates", + Example: " printf '%s\\n' pkgA pkgB | go run ./tools/ci testshard verify --shard-count=4", + RunE: func(cmd *cobra.Command, args []string) error { + return testshard.Verify(stdin, stdout, shardCount) + }, + } + verifyCmd.Flags().IntVar(&shardCount, "shard-count", 0, "Total number of shards (must be >= 1)") + _ = verifyCmd.MarkFlagRequired("shard-count") + + cmd.AddCommand(listCmd) + cmd.AddCommand(verifyCmd) + + return cmd +} diff --git a/tools/ci/internal/gating/gating.go b/tools/ci/internal/gating/gating.go new file mode 100644 index 00000000000..940535e39d6 --- /dev/null +++ b/tools/ci/internal/gating/gating.go @@ -0,0 +1,97 @@ +package gating + +import ( + "fmt" + "strconv" + "strings" +) + +const ( + eventWorkflowDispatch = "workflow_dispatch" + eventPush = "push" + eventMergeGroup = "merge_group" + eventPullRequest = "pull_request" + refTypeTag = "tag" + defaultBranch = "develop" +) + +// Inputs are the signals that drive the integration-tests gating decisions. +type Inputs struct { + EventName string + RefName string + RefType string + CREChanges bool + CCIPChanges bool + RunE2ELabel bool + SkipRegressionLabel bool + SkipMixedEnvLabel bool +} + +// Decisions holds the computed integration-tests gates and derived image builds. +type Decisions struct { + CREShouldRun bool + CREWithRegression bool + CRERunMixedEnv bool + CCIPShouldRun bool + BuildCoreImage bool + BuildPluginsImage bool +} + +// Evaluate computes every gating decision from the given inputs. +func Evaluate(in Inputs) Decisions { + cre := in.EventName == eventWorkflowDispatch || + (in.EventName == eventPush && (in.CREChanges || in.RefType == refTypeTag)) || + (in.EventName == eventMergeGroup && in.CREChanges) || + (in.EventName == eventPullRequest && (in.CREChanges || in.RunE2ELabel)) + + ccip := in.EventName == eventWorkflowDispatch || + (in.EventName == eventPush && (in.CCIPChanges || in.RefType == refTypeTag)) || + (in.EventName == eventMergeGroup && in.CCIPChanges) + + regression := in.EventName == eventWorkflowDispatch || + (in.EventName == eventPush && in.RefName == defaultBranch) || + (in.EventName == eventPullRequest && !in.SkipRegressionLabel) + + mixedEnv := in.EventName == eventWorkflowDispatch || + (in.EventName == eventPush && in.RefName == defaultBranch) || + (in.EventName == eventPullRequest && !in.SkipMixedEnvLabel) + + return Decisions{ + CREShouldRun: cre, + CREWithRegression: regression, + CRERunMixedEnv: mixedEnv, + CCIPShouldRun: ccip, + BuildCoreImage: cre || ccip, + BuildPluginsImage: cre, + } +} + +// OutputVars renders the decisions as GitHub Actions output variables. +func (d Decisions) OutputVars() map[string]string { + return map[string]string{ + "cre-should-run": strconv.FormatBool(d.CREShouldRun), + "cre-with-regression": strconv.FormatBool(d.CREWithRegression), + "cre-run-mixed-env": strconv.FormatBool(d.CRERunMixedEnv), + "ccip-should-run": strconv.FormatBool(d.CCIPShouldRun), + "build-core-image": strconv.FormatBool(d.BuildCoreImage), + "build-plugins-image": strconv.FormatBool(d.BuildPluginsImage), + } +} + +// SummaryTable renders the step-summary markdown table for a set of decisions. +func (d Decisions) SummaryTable(in Inputs) string { + var b strings.Builder + b.WriteString("### Integration Test Gating Decisions\n\n") + b.WriteString("| Gate | Triggered? | Context |\n|:---|:---:|:---|\n") + fmt.Fprintf(&b, "| **Build Core Image** | `%t` | CCIP or CRE tests requested |\n", d.BuildCoreImage) + fmt.Fprintf(&b, "| **Build Plugins Image** | `%t` | CRE tests requested |\n", d.BuildPluginsImage) + fmt.Fprintf(&b, "| **Core CRE Smoke Tests** | `%t` | Event: `%s`, changes: `%t`, label: `%t` |\n", + d.CREShouldRun, in.EventName, in.CREChanges, in.RunE2ELabel) + fmt.Fprintf(&b, "| **Core CRE Regression Tests** | `%t` | skip-regression: `%t` |\n", + d.CREWithRegression, in.SkipRegressionLabel) + fmt.Fprintf(&b, "| **Core CRE Mixed-Env Tests** | `%t` | skip-mixed-env: `%t` |\n", + d.CRERunMixedEnv, in.SkipMixedEnvLabel) + fmt.Fprintf(&b, "| **CCIP v1.6 E2E Tests** | `%t` | Event: `%s`, changes: `%t` |\n", + d.CCIPShouldRun, in.EventName, in.CCIPChanges) + return b.String() +} diff --git a/tools/ci/internal/gating/gating_test.go b/tools/ci/internal/gating/gating_test.go new file mode 100644 index 00000000000..423741ca130 --- /dev/null +++ b/tools/ci/internal/gating/gating_test.go @@ -0,0 +1,152 @@ +package gating + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestEvaluate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in Inputs + expected Decisions + }{ + { + name: "workflow dispatch runs everything", + in: Inputs{EventName: "workflow_dispatch"}, + expected: Decisions{ + CREShouldRun: true, + CREWithRegression: true, + CRERunMixedEnv: true, + CCIPShouldRun: true, + BuildCoreImage: true, + BuildPluginsImage: true, + }, + }, + { + name: "push to develop without changes runs regression and mixed-env only", + in: Inputs{EventName: "push", RefName: "develop"}, + expected: Decisions{CREWithRegression: true, CRERunMixedEnv: true}, + }, + { + name: "push to feature branch with CRE changes only runs CRE", + in: Inputs{EventName: "push", RefName: "feature/x", CREChanges: true}, + expected: Decisions{ + CREShouldRun: true, + BuildCoreImage: true, + BuildPluginsImage: true, + }, + }, + { + name: "push of tag without changes runs CRE and CCIP", + in: Inputs{EventName: "push", RefName: "v2.58.0", RefType: "tag"}, + expected: Decisions{ + CREShouldRun: true, + CCIPShouldRun: true, + BuildCoreImage: true, + BuildPluginsImage: true, + }, + }, + { + name: "pull request without changes or labels only runs regression and mixed-env", + in: Inputs{EventName: "pull_request"}, + expected: Decisions{ + CREWithRegression: true, + CRERunMixedEnv: true, + }, + }, + { + name: "pull request with CRE changes runs CRE, regression, and mixed-env", + in: Inputs{EventName: "pull_request", CREChanges: true}, + expected: Decisions{ + CREShouldRun: true, + CREWithRegression: true, + CRERunMixedEnv: true, + BuildCoreImage: true, + BuildPluginsImage: true, + }, + }, + { + name: "pull request with run-e2e label runs CRE and builds plugins", + in: Inputs{EventName: "pull_request", RunE2ELabel: true}, + expected: Decisions{ + CREShouldRun: true, + CREWithRegression: true, + CRERunMixedEnv: true, + BuildCoreImage: true, + BuildPluginsImage: true, + }, + }, + { + name: "pull request with skip-regression label skips regression", + in: Inputs{EventName: "pull_request", SkipRegressionLabel: true}, + expected: Decisions{ + CRERunMixedEnv: true, + }, + }, + { + name: "pull request with skip-mixed-env label skips mixed-env", + in: Inputs{EventName: "pull_request", SkipMixedEnvLabel: true}, + expected: Decisions{ + CREWithRegression: true, + }, + }, + { + name: "merge_group with CCIP changes only runs CCIP", + in: Inputs{EventName: "merge_group", CCIPChanges: true}, + expected: Decisions{ + CCIPShouldRun: true, + BuildCoreImage: true, + }, + }, + { + name: "merge_group without changes runs nothing", + in: Inputs{EventName: "merge_group"}, + expected: Decisions{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tt.expected, Evaluate(tt.in)) + }) + } +} + +func TestOutputVars(t *testing.T) { + t.Parallel() + + vars := Decisions{ + CREShouldRun: true, + CREWithRegression: false, + CRERunMixedEnv: true, + CCIPShouldRun: false, + BuildCoreImage: true, + BuildPluginsImage: true, + }.OutputVars() + + require.Equal(t, map[string]string{ + "cre-should-run": "true", + "cre-with-regression": "false", + "cre-run-mixed-env": "true", + "ccip-should-run": "false", + "build-core-image": "true", + "build-plugins-image": "true", + }, vars) +} + +func TestSummaryTable(t *testing.T) { + t.Parallel() + + decisions := Evaluate(Inputs{EventName: "pull_request", CREChanges: true}) + table := decisions.SummaryTable(Inputs{EventName: "pull_request", CREChanges: true}) + + require.Contains(t, table, "### Integration Test Gating Decisions") + require.Contains(t, table, "| **Build Core Image** | `true` |") + require.Contains(t, table, "| **Core CRE Smoke Tests** | `true` | Event: `pull_request`") + require.Contains(t, table, "| **CCIP v1.6 E2E Tests** | `false` |") +} diff --git a/tools/ci/internal/githuboutput/githuboutput.go b/tools/ci/internal/githuboutput/githuboutput.go new file mode 100644 index 00000000000..f47fe0fdbb0 --- /dev/null +++ b/tools/ci/internal/githuboutput/githuboutput.go @@ -0,0 +1,55 @@ +package githuboutput + +import ( + "fmt" + "os" + "path/filepath" +) + +// EnvFilePath returns the path set in $GITHUB_OUTPUT, or "" when unset. +func EnvFilePath() string { + return os.Getenv("GITHUB_OUTPUT") +} + +// AppendVar appends a single-line k=v pair to $GITHUB_OUTPUT. No-op when $GITHUB_OUTPUT is unset. +func AppendVar(key, value string) error { + file := EnvFilePath() + if file == "" { + return nil + } + return AppendToFile(file, fmt.Sprintf("%s=%s\n", key, value)) +} + +// AppendMultilineVar appends a delimited (heredoc-style) variable to $GITHUB_OUTPUT. +// No-op when $GITHUB_OUTPUT is unset. +func AppendMultilineVar(key, value string) error { + file := EnvFilePath() + if file == "" { + return nil + } + return AppendToFile(file, fmt.Sprintf("%s<= 1", shardCount) + } + if shardIndex < 0 || shardIndex >= shardCount { + return fmt.Errorf("invalid --shard-index %d: must be in [0,%d)", shardIndex, shardCount) + } + return nil +} + +// List filters packages from r that belong to shardIndex. +func List(r io.Reader, w io.Writer, shardCount, shardIndex int) error { + packages, err := ReadPackages(r) + if err != nil { + return err + } + if err := ValidateShardArgs(shardCount, shardIndex); err != nil { + return err + } + + for _, pkg := range packages { + if ShardForPackage(pkg, shardCount) == shardIndex { + if _, err := fmt.Fprintln(w, pkg); err != nil { + return err + } + } + } + return nil +} + +// Verify checks that all packages from r are assigned to shards without overlap. +func Verify(r io.Reader, w io.Writer, shardCount int) error { + packages, err := ReadPackages(r) + if err != nil { + return err + } + if shardCount < 1 { + return fmt.Errorf("invalid --shard-count %d: must be >= 1", shardCount) + } + + shardSizes := make([]int, shardCount) + seen := make(map[string]int, len(packages)) + for _, pkg := range packages { + idx := ShardForPackage(pkg, shardCount) + shardSizes[idx]++ + seen[pkg]++ + } + + for _, pkg := range packages { + if seen[pkg] != 1 { + return fmt.Errorf("package %q assigned %d times", pkg, seen[pkg]) + } + } + + if _, err := fmt.Fprintf(w, "verified %d packages across %d shards\n", len(packages), shardCount); err != nil { + return err + } + for i, size := range shardSizes { + if _, err := fmt.Fprintf(w, "shard %d: %d packages\n", i, size); err != nil { + return err + } + } + return nil +} diff --git a/tools/ci/internal/testshard/shard_test.go b/tools/ci/internal/testshard/shard_test.go new file mode 100644 index 00000000000..f3c25b168c3 --- /dev/null +++ b/tools/ci/internal/testshard/shard_test.go @@ -0,0 +1,80 @@ +package testshard + +import ( + "bytes" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestShardForPackage(t *testing.T) { + t.Parallel() + pkg1 := "github.com/smartcontractkit/chainlink/v2/core/services/workflows" + pkg2 := "github.com/smartcontractkit/chainlink/v2/core/services/ocr2" + + shard0 := ShardForPackage(pkg1, 4) + shard1 := ShardForPackage(pkg2, 4) + + require.GreaterOrEqual(t, shard0, 0) + require.Less(t, shard0, 4) + require.GreaterOrEqual(t, shard1, 0) + require.Less(t, shard1, 4) + + // Deterministic + require.Equal(t, shard0, ShardForPackage(pkg1, 4)) +} + +func TestList(t *testing.T) { + t.Parallel() + input := strings.NewReader("pkg1\npkg2\npkg3\npkg4\n") + var stdout bytes.Buffer + + err := List(input, &stdout, 2, 0) + require.NoError(t, err) + + output := strings.TrimSpace(stdout.String()) + require.NotEmpty(t, output) + + // Output for shard 1 + var stdout1 bytes.Buffer + input1 := strings.NewReader("pkg1\npkg2\npkg3\npkg4\n") + err = List(input1, &stdout1, 2, 1) + require.NoError(t, err) + + output1 := strings.TrimSpace(stdout1.String()) + require.NotEmpty(t, output1) + + // Union should contain all 4 packages, intersection should be empty + shard0Pkgs := strings.Split(output, "\n") + shard1Pkgs := strings.Split(output1, "\n") + require.Equal(t, 4, len(shard0Pkgs)+len(shard1Pkgs)) +} + +func TestVerify(t *testing.T) { + t.Parallel() + input := strings.NewReader("pkg1\npkg2\npkg3\npkg4\n") + var stdout bytes.Buffer + + err := Verify(input, &stdout, 3) + require.NoError(t, err) + require.Contains(t, stdout.String(), "verified 4 packages across 3 shards") +} + +func TestVerify_DuplicatePackage(t *testing.T) { + t.Parallel() + input := strings.NewReader("pkg1\npkg2\npkg1\n") + var stdout bytes.Buffer + + err := Verify(input, &stdout, 2) + require.Error(t, err) + require.Contains(t, err.Error(), "duplicate package") +} + +func TestValidateShardArgs(t *testing.T) { + t.Parallel() + require.Error(t, ValidateShardArgs(0, 0)) + require.Error(t, ValidateShardArgs(2, 2)) + require.Error(t, ValidateShardArgs(2, -1)) + require.NoError(t, ValidateShardArgs(2, 1)) +} diff --git a/tools/ci/main.go b/tools/ci/main.go new file mode 100644 index 00000000000..b165bf77644 --- /dev/null +++ b/tools/ci/main.go @@ -0,0 +1,16 @@ +package main + +import ( + "context" + "fmt" + "os" + + "github.com/smartcontractkit/chainlink/v2/tools/ci/internal/cmd" +) + +func main() { + if err := cmd.Execute(context.Background()); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} diff --git a/tools/ci/main_test.go b/tools/ci/main_test.go new file mode 100644 index 00000000000..dfccb20f2ad --- /dev/null +++ b/tools/ci/main_test.go @@ -0,0 +1,153 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink/v2/tools/ci/internal/cmd" +) + +func TestRootHelp(t *testing.T) { + t.Parallel() + var stdout, stderr bytes.Buffer + rootCmd := cmd.NewRootCmd(nil, &stdout, &stderr) + rootCmd.SetArgs([]string{"--help"}) + + err := rootCmd.Execute() + require.NoError(t, err) + require.Contains(t, stdout.String(), "Universal CI CLI for test matrix discovery") + require.Contains(t, stdout.String(), "matrix") + require.Contains(t, stdout.String(), "testshard") + require.Contains(t, stdout.String(), "changelog") +} + +func TestMatrixSubcommand(t *testing.T) { + t.Parallel() + tempDir := t.TempDir() + sampleContent := `package sample_test +import "testing" +func Test_CRE_Example(t *testing.T) {} +` + require.NoError(t, os.WriteFile(filepath.Join(tempDir, "sample_test.go"), []byte(sampleContent), 0o600)) + + var stdout, stderr bytes.Buffer + rootCmd := cmd.NewRootCmd(nil, &stdout, &stderr) + rootCmd.SetArgs([]string{"matrix", "--dir=" + tempDir, "--run-id=99", "--attempt=1"}) + + err := rootCmd.Execute() + require.NoError(t, err) + require.Contains(t, stdout.String(), `"test_name":"Test_CRE_Example"`) + require.Contains(t, stdout.String(), `"runs_on":"runs-on=99-0-1/`) +} + +func TestTestshardSubcommand(t *testing.T) { + t.Parallel() + stdin := strings.NewReader("pkgA\npkgB\npkgC\n") + var stdout, stderr bytes.Buffer + rootCmd := cmd.NewRootCmd(stdin, &stdout, &stderr) + rootCmd.SetArgs([]string{"testshard", "list", "--shard-count=2", "--shard-index=0"}) + + err := rootCmd.Execute() + require.NoError(t, err) + require.NotEmpty(t, strings.TrimSpace(stdout.String())) +} + +func TestChangelogSubcommand(t *testing.T) { + t.Parallel() + tempDir := t.TempDir() + pkgPath := filepath.Join(tempDir, "package.json") + changelogPath := filepath.Join(tempDir, "CHANGELOG.md") + + require.NoError(t, os.WriteFile(pkgPath, []byte(`{"version": "1.0.0"}`), 0o600)) + require.NoError(t, os.WriteFile(changelogPath, []byte("# Changelog\n\n## 1.0.0\n\n- [#added] Feature X\n\n## 0.9.0\n"), 0o600)) + + var stdout, stderr bytes.Buffer + rootCmd := cmd.NewRootCmd(nil, &stdout, &stderr) + rootCmd.SetArgs([]string{"changelog", "format", "--changelog=" + changelogPath, "--package-json=" + pkgPath, "--github-output=false"}) + + err := rootCmd.Execute() + require.NoError(t, err) + require.Contains(t, stdout.String(), "Formatted changelog for version 1.0.0") +} + +func TestMatrixSuiteSubcommand(t *testing.T) { + t.Parallel() + tempDir := t.TempDir() + sampleContent := `package sample_test +import "testing" +func Test_CCIPGasPriceUpdatesWriteFrequency(t *testing.T) {} +` + require.NoError(t, os.WriteFile(filepath.Join(tempDir, "sample_test.go"), []byte(sampleContent), 0o600)) + + var stdout, stderr bytes.Buffer + rootCmd := cmd.NewRootCmd(nil, &stdout, &stderr) + rootCmd.SetArgs([]string{"matrix", "--suite=ccip", "--dir=" + tempDir, "--run-id=10", "--attempt=1"}) + + err := rootCmd.Execute() + require.NoError(t, err) + require.Contains(t, stdout.String(), `"test_name":"Test_CCIPGasPriceUpdatesWriteFrequency"`) +} + +func TestMatrixRegressionSuiteUsesSuiteDefaults(t *testing.T) { + t.Parallel() + tempDir := t.TempDir() + + sampleContent := `package sample_test +import "testing" +func Test_CRE_V2_Foo_Regression(t *testing.T) {} +` + require.NoError(t, os.WriteFile(filepath.Join(tempDir, "sample_test.go"), []byte(sampleContent), 0o600)) + + var stdout, stderr bytes.Buffer + rootCmd := cmd.NewRootCmd(nil, &stdout, &stderr) + rootCmd.SetArgs([]string{"matrix", "--suite=cre-regression", "--dir=" + tempDir, "--run-id=10", "--attempt=1"}) + + err := rootCmd.Execute() + require.NoError(t, err) + + require.Contains(t, stdout.String(), `"test_name":"Test_CRE_V2_Foo_Regression"`) +} + +func TestMatrixSetupSubcommand(t *testing.T) { + t.Parallel() + var stdout, stderr bytes.Buffer + rootCmd := cmd.NewRootCmd(nil, &stdout, &stderr) + rootCmd.SetArgs([]string{"matrix", "setup", "--ccip=true", "--cre-mixed-env=true", "--run-id=10", "--attempt=1"}) + + err := rootCmd.Execute() + require.NoError(t, err) + require.Contains(t, stdout.String(), `"ccip-matrix":`) + require.Contains(t, stdout.String(), `"cre-mixed-env-matrix":`) +} + +func TestGatingSubcommand(t *testing.T) { + outputFile := filepath.Join(t.TempDir(), "github_output") + t.Setenv("GITHUB_OUTPUT", outputFile) + t.Setenv("GITHUB_STEP_SUMMARY", "") + t.Setenv("EVENT_NAME", "pull_request") + t.Setenv("REF_NAME", "feature/x") + t.Setenv("REF_TYPE", "branch") + t.Setenv("CRE_CHANGES", "true") + t.Setenv("CCIP_CHANGES", "false") + + var stdout, stderr bytes.Buffer + rootCmd := cmd.NewRootCmd(nil, &stdout, &stderr) + rootCmd.SetArgs([]string{"gating"}) + + require.NoError(t, rootCmd.Execute()) + + content, err := os.ReadFile(outputFile) + require.NoError(t, err) + output := string(content) + require.Contains(t, output, "cre-should-run=true") + require.Contains(t, output, "cre-with-regression=true") + require.Contains(t, output, "cre-run-mixed-env=true") + require.Contains(t, output, "ccip-should-run=false") + require.Contains(t, output, "build-core-image=true") + require.Contains(t, output, "build-plugins-image=true") +} diff --git a/tools/ci/wait-for-containers-to-stop.sh b/tools/ci/wait-for-containers-to-stop.sh deleted file mode 100755 index 8aa5e6d7d8c..00000000000 --- a/tools/ci/wait-for-containers-to-stop.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env bash -# Description: Waits until the number of running Docker containers equals the target count, -# or until a specified timeout is reached. -# -# Usage: -# ./wait-for-docker-containers.sh [timeout_seconds] [target_container_count] -# -# timeout_seconds - Optional: Maximum seconds to wait (default: 30) -# target_container_count - Optional: Container count to wait for (default: 0) - -# Read parameters or use default values. -TIMEOUT=${1:-30} -TARGET_COUNT=${2:-0} - -# Calculate the end time using the built-in SECONDS variable. -end=$((SECONDS + TIMEOUT)) - -# Loop until the current docker container count equals the target count. -while [ "$(docker ps -q | wc -l)" -ne "$TARGET_COUNT" ]; do - # If the timeout has been reached, exit with an error. - if [ $SECONDS -ge $end ]; then - exit 1 - fi - sleep 1 -done \ No newline at end of file From f75d9048ffa2cac31ff4544185ac60b22300faaa Mon Sep 17 00:00:00 2001 From: Adam Hamrick Date: Tue, 18 Aug 2026 14:55:36 -0400 Subject: [PATCH 2/3] docs: readme --- tools/ci/README.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 tools/ci/README.md diff --git a/tools/ci/README.md b/tools/ci/README.md new file mode 100644 index 00000000000..736304edd5d --- /dev/null +++ b/tools/ci/README.md @@ -0,0 +1,3 @@ +# CI + +A Go CLI tool to eliminate complex bash and YAML in CI workflows, replacing them with clean, testable Go code. From d93ed856026113e79f4ba2bb59ec1e79667ade86 Mon Sep 17 00:00:00 2001 From: Adam Hamrick Date: Tue, 18 Aug 2026 15:03:13 -0400 Subject: [PATCH 3/3] lint --- tools/ci/internal/changelog/format.go | 4 ++-- tools/ci/internal/matrix/matrix.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/ci/internal/changelog/format.go b/tools/ci/internal/changelog/format.go index d0942ed9d10..d134ca3b96c 100644 --- a/tools/ci/internal/changelog/format.go +++ b/tools/ci/internal/changelog/format.go @@ -83,7 +83,7 @@ func Format(changelogPath, packageJSONPath string, writeGithubOutput bool) (*Res pastStarted bool ) - versionHeader := fmt.Sprintf("## %s", version) + versionHeader := "## " + version for _, line := range lines { if strings.HasPrefix(line, "## ") { @@ -152,7 +152,7 @@ func Format(changelogPath, packageJSONPath string, writeGithubOutput bool) (*Res var changelogSection strings.Builder var prBodySection strings.Builder - changelogSection.WriteString(fmt.Sprintf("## %s - PREVIEW\n", version)) + fmt.Fprintf(&changelogSection, "## %s - PREVIEW\n", version) for _, tag := range tagsList { entries, exists := tagMap[tag] diff --git a/tools/ci/internal/matrix/matrix.go b/tools/ci/internal/matrix/matrix.go index ce235bfb301..313b0e54730 100644 --- a/tools/ci/internal/matrix/matrix.go +++ b/tools/ci/internal/matrix/matrix.go @@ -39,7 +39,7 @@ const ( ) var perTestRegressionConfigs = map[string]string{ - "Test_CRE_V2_Stellar_Regression": "configs/workflow-gateway-don-stellar.toml", + "Test_CRE_V2_Stellar_Regression": "configs/workflow-gateway-don-stellar.toml", "TestCRE_V2_Stellar_Regression_E2E": "configs/workflow-gateway-don-stellar.toml", }