diff --git a/.github/workflows/integ.yml b/.github/workflows/integ.yml index e6a059ff5..11a68d462 100644 --- a/.github/workflows/integ.yml +++ b/.github/workflows/integ.yml @@ -4,8 +4,16 @@ name: integ on: pull_request_target: branches: [] + types: + - opened + - synchronize + - reopened + - labeled + - unlabeled merge_group: {} workflow_dispatch: {} + schedule: + - cron: 0 6 * * * jobs: determine_env: runs-on: ubuntu-latest @@ -17,9 +25,9 @@ jobs: - name: Start requiring approval id: start_requiring_approval run: echo integ-approval > .envname - - name: Skip approval for mergeGroup or PR created from this repo - id: skip_approval_for_mergegroup_or_pr_created_from_this_repo - if: ${{ github.event_name == 'merge_group' || github.event.pull_request.head.repo.full_name == github.repository }} + - name: Skip approval for mergeGroup, schedule, or PR created from this repo + id: skip_approval_for_mergegroup_schedule_or_pr_created_from_this_repo + if: ${{ github.event_name == 'merge_group' || github.event_name == 'schedule' || github.event.pull_request.head.repo.full_name == github.repository }} run: echo no-approval > .envname - name: Output the value id: output @@ -44,8 +52,8 @@ jobs: id: checkout uses: actions/checkout@v7 with: - ref: ${{ github.event.pull_request.head.sha }} - repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha || github.sha }} + repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} allow-unsafe-pr-checkout: true - name: Fetch tags from origin repo id: fetch_tags_from_origin_repo @@ -74,24 +82,672 @@ jobs: env: RELEASE: "true" run: yarn projen build + - name: Bundle Verdaccio for the test jobs + id: bundle_verdaccio_for_the_test_jobs + run: |- + mkdir -p /tmp/verdaccio-bundle + (cd /tmp/verdaccio-bundle && npm install --no-bin-links --no-audit --no-fund --loglevel=error verdaccio@6.8) + tar czf .projen/verdaccio-bundle.tgz -C /tmp/verdaccio-bundle node_modules - name: Upload artifact id: build-artifact uses: actions/upload-artifact@v7 with: - name: build-artifact - path: packages/**/dist/js/*.tgz + name: build-artifact + path: packages/**/dist/js/*.tgz + overwrite: true + - name: Upload artifact + id: script-artifact + uses: actions/upload-artifact@v7 + with: + name: script-artifact + path: |- + .projen/*.sh + .projen/verdaccio-bundle.tgz + overwrite: true + include-hidden-files: true + integ_cli: + needs: prepare + runs-on: aws-cdk_ubuntu-latest_16-core + permissions: + contents: read + id-token: write + environment: run-tests + env: + NODE_NO_WARNINGS: "1" + MAVEN_ARGS: --no-transfer-progress + IS_CANARY: "true" + CI: "true" + defaults: + run: + shell: bash + if: github.event_name != 'merge_group' && !contains(github.event.pull_request.labels.*.name, 'pr/exempt-integ-test') && github.event_name != 'schedule' + steps: + - name: Download artifact + id: download_artifact + uses: actions/download-artifact@v8 + with: + artifact-ids: ${{needs.prepare.outputs.packagesArtifact}} + path: packages + - name: Download artifact + id: download_artifact_2 + uses: actions/download-artifact@v8 + with: + artifact-ids: ${{needs.prepare.outputs.scriptsArtifact}} + path: .projen + - name: Setup Node.js + id: setup_node_js + uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node }} + package-manager-cache: false + - name: Set up JDK 18 + id: set_up_jdk_18 + if: matrix.suite == 'init-java' || matrix.suite == 'cli-integ-tests' + uses: actions/setup-java@v5 + with: + java-version: "18" + distribution: corretto + - name: Set git identity + id: set_git_identity + run: |- + git config --global user.name "aws-cdk-cli-integ" + git config --global user.email "noreply@example.com" + - name: Prepare Verdaccio + id: prepare_verdaccio + run: chmod +x .projen/prepare-verdaccio.sh && .projen/prepare-verdaccio.sh + - name: Download and install the test artifact + id: download_and_install_the_test_artifact + run: npm install @aws-cdk-testing/cli-integ + - name: Determine latest package versions + id: versions + run: |- + CLI_VERSION=$(cd ${TMPDIR:-/tmp} && npm view aws-cdk version) + echo "CLI version: ${CLI_VERSION}" + echo "cli_version=${CLI_VERSION}" >> $GITHUB_OUTPUT + LIB_VERSION=$(cd ${TMPDIR:-/tmp} && npm view aws-cdk-lib version) + echo "lib version: ${LIB_VERSION}" + echo "lib_version=${LIB_VERSION}" >> $GITHUB_OUTPUT + - name: Authenticate Via OIDC Role + id: creds + uses: aws-actions/configure-aws-credentials@v6 + with: + aws-region: us-east-1 + role-duration-seconds: 3600 + role-to-assume: ${{ vars.CDK_ATMOSPHERE_PROD_OIDC_ROLE }} + role-session-name: run-tests@aws-cdk-cli-integ + output-credentials: true + - name: "Run the test suite: ${{ matrix.suite }}" + id: run_the_test_suite_matrix_suite + env: + JSII_SILENCE_WARNING_DEPRECATED_NODE_VERSION: "true" + JSII_SILENCE_WARNING_UNTESTED_NODE_VERSION: "true" + JSII_SILENCE_WARNING_KNOWN_BROKEN_NODE_VERSION: "true" + DOCKERHUB_DISABLED: "true" + CDK_INTEG_ATMOSPHERE_ENABLED: "true" + CDK_INTEG_ATMOSPHERE_ENDPOINT: ${{ vars.CDK_ATMOSPHERE_PROD_ENDPOINT }} + CDK_INTEG_ATMOSPHERE_POOL: ${{ vars.CDK_INTEG_ATMOSPHERE_POOL }} + CDK_MAJOR_VERSION: "2" + RELEASE_TAG: latest + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + INTEG_LOGS: logs + run: npx run-suite --shard="${{ matrix.shard }}/12" --use-cli-release=${{ steps.versions.outputs.cli_version }} --framework-version=${{ steps.versions.outputs.lib_version }} ${{ matrix.suite }} + - name: Set workflow summary + id: set_workflow_summary + if: always() + run: |- + if compgen -G "logs/md/*.md" > /dev/null; then + cat logs/md/*.md >> $GITHUB_STEP_SUMMARY; + fi + - name: Slugify artifact id + id: artifactid + if: always() + env: + INPUT: logs-${{ matrix.suite }}-${{ matrix.node }}-${{ matrix.shard }} + run: |- + slug=$(node -p 'process.env.INPUT.replace(/[^a-z0-9._-]/gi, "-")') + echo "slug=$slug" >> "$GITHUB_OUTPUT" + - name: Upload logs + id: logupload + if: always() + uses: actions/upload-artifact@v7 + with: + name: ${{ steps.artifactid.outputs.slug }} + path: logs/ + overwrite: true + - name: Append artifact URL + id: append_artifact_url + if: always() + run: |- + echo "" >> $GITHUB_STEP_SUMMARY + echo "[Logs](${{ steps.logupload.outputs.artifact-url }})" >> $GITHUB_STEP_SUMMARY + strategy: + fail-fast: false + matrix: + suite: + - cli-integ-tests + node: + - lts/* + shard: + - 1 + - 2 + - 3 + - 4 + - 5 + - 6 + - 7 + - 8 + - 9 + - 10 + - 11 + - 12 + integ_toolkit-lib: + needs: prepare + runs-on: aws-cdk_ubuntu-latest_16-core + permissions: + contents: read + id-token: write + environment: run-tests + env: + NODE_NO_WARNINGS: "1" + MAVEN_ARGS: --no-transfer-progress + IS_CANARY: "true" + CI: "true" + defaults: + run: + shell: bash + if: github.event_name != 'merge_group' && !contains(github.event.pull_request.labels.*.name, 'pr/exempt-integ-test') && github.event_name != 'schedule' + steps: + - name: Download artifact + id: download_artifact + uses: actions/download-artifact@v8 + with: + artifact-ids: ${{needs.prepare.outputs.packagesArtifact}} + path: packages + - name: Download artifact + id: download_artifact_2 + uses: actions/download-artifact@v8 + with: + artifact-ids: ${{needs.prepare.outputs.scriptsArtifact}} + path: .projen + - name: Setup Node.js + id: setup_node_js + uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node }} + package-manager-cache: false + - name: Set up JDK 18 + id: set_up_jdk_18 + if: matrix.suite == 'init-java' || matrix.suite == 'cli-integ-tests' + uses: actions/setup-java@v5 + with: + java-version: "18" + distribution: corretto + - name: Set git identity + id: set_git_identity + run: |- + git config --global user.name "aws-cdk-cli-integ" + git config --global user.email "noreply@example.com" + - name: Prepare Verdaccio + id: prepare_verdaccio + run: chmod +x .projen/prepare-verdaccio.sh && .projen/prepare-verdaccio.sh + - name: Download and install the test artifact + id: download_and_install_the_test_artifact + run: npm install @aws-cdk-testing/cli-integ + - name: Determine latest package versions + id: versions + run: |- + CLI_VERSION=$(cd ${TMPDIR:-/tmp} && npm view aws-cdk version) + echo "CLI version: ${CLI_VERSION}" + echo "cli_version=${CLI_VERSION}" >> $GITHUB_OUTPUT + LIB_VERSION=$(cd ${TMPDIR:-/tmp} && npm view aws-cdk-lib version) + echo "lib version: ${LIB_VERSION}" + echo "lib_version=${LIB_VERSION}" >> $GITHUB_OUTPUT + - name: Authenticate Via OIDC Role + id: creds + uses: aws-actions/configure-aws-credentials@v6 + with: + aws-region: us-east-1 + role-duration-seconds: 3600 + role-to-assume: ${{ vars.CDK_ATMOSPHERE_PROD_OIDC_ROLE }} + role-session-name: run-tests@aws-cdk-cli-integ + output-credentials: true + - name: "Run the test suite: ${{ matrix.suite }}" + id: run_the_test_suite_matrix_suite + env: + JSII_SILENCE_WARNING_DEPRECATED_NODE_VERSION: "true" + JSII_SILENCE_WARNING_UNTESTED_NODE_VERSION: "true" + JSII_SILENCE_WARNING_KNOWN_BROKEN_NODE_VERSION: "true" + DOCKERHUB_DISABLED: "true" + CDK_INTEG_ATMOSPHERE_ENABLED: "true" + CDK_INTEG_ATMOSPHERE_ENDPOINT: ${{ vars.CDK_ATMOSPHERE_PROD_ENDPOINT }} + CDK_INTEG_ATMOSPHERE_POOL: ${{ vars.CDK_INTEG_ATMOSPHERE_POOL }} + CDK_MAJOR_VERSION: "2" + RELEASE_TAG: latest + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + INTEG_LOGS: logs + run: npx run-suite --use-cli-release=${{ steps.versions.outputs.cli_version }} --framework-version=${{ steps.versions.outputs.lib_version }} ${{ matrix.suite }} + - name: Set workflow summary + id: set_workflow_summary + if: always() + run: |- + if compgen -G "logs/md/*.md" > /dev/null; then + cat logs/md/*.md >> $GITHUB_STEP_SUMMARY; + fi + - name: Slugify artifact id + id: artifactid + if: always() + env: + INPUT: logs-${{ matrix.suite }}-${{ matrix.node }} + run: |- + slug=$(node -p 'process.env.INPUT.replace(/[^a-z0-9._-]/gi, "-")') + echo "slug=$slug" >> "$GITHUB_OUTPUT" + - name: Upload logs + id: logupload + if: always() + uses: actions/upload-artifact@v7 + with: + name: ${{ steps.artifactid.outputs.slug }} + path: logs/ + overwrite: true + - name: Append artifact URL + id: append_artifact_url + if: always() + run: |- + echo "" >> $GITHUB_STEP_SUMMARY + echo "[Logs](${{ steps.logupload.outputs.artifact-url }})" >> $GITHUB_STEP_SUMMARY + strategy: + fail-fast: false + matrix: + suite: + - toolkit-lib-integ-tests + node: + - lts/* + - "20" + - "22" + - "24" + integ_telemetry: + needs: prepare + runs-on: aws-cdk_ubuntu-latest_16-core + permissions: + contents: read + id-token: write + environment: run-tests + env: + NODE_NO_WARNINGS: "1" + MAVEN_ARGS: --no-transfer-progress + IS_CANARY: "true" + CI: "true" + defaults: + run: + shell: bash + if: github.event_name != 'merge_group' && !contains(github.event.pull_request.labels.*.name, 'pr/exempt-integ-test') && github.event_name != 'schedule' + steps: + - name: Download artifact + id: download_artifact + uses: actions/download-artifact@v8 + with: + artifact-ids: ${{needs.prepare.outputs.packagesArtifact}} + path: packages + - name: Download artifact + id: download_artifact_2 + uses: actions/download-artifact@v8 + with: + artifact-ids: ${{needs.prepare.outputs.scriptsArtifact}} + path: .projen + - name: Setup Node.js + id: setup_node_js + uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node }} + package-manager-cache: false + - name: Set up JDK 18 + id: set_up_jdk_18 + if: matrix.suite == 'init-java' || matrix.suite == 'cli-integ-tests' + uses: actions/setup-java@v5 + with: + java-version: "18" + distribution: corretto + - name: Set git identity + id: set_git_identity + run: |- + git config --global user.name "aws-cdk-cli-integ" + git config --global user.email "noreply@example.com" + - name: Prepare Verdaccio + id: prepare_verdaccio + run: chmod +x .projen/prepare-verdaccio.sh && .projen/prepare-verdaccio.sh + - name: Download and install the test artifact + id: download_and_install_the_test_artifact + run: npm install @aws-cdk-testing/cli-integ + - name: Determine latest package versions + id: versions + run: |- + CLI_VERSION=$(cd ${TMPDIR:-/tmp} && npm view aws-cdk version) + echo "CLI version: ${CLI_VERSION}" + echo "cli_version=${CLI_VERSION}" >> $GITHUB_OUTPUT + LIB_VERSION=$(cd ${TMPDIR:-/tmp} && npm view aws-cdk-lib version) + echo "lib version: ${LIB_VERSION}" + echo "lib_version=${LIB_VERSION}" >> $GITHUB_OUTPUT + - name: Authenticate Via OIDC Role + id: creds + uses: aws-actions/configure-aws-credentials@v6 + with: + aws-region: us-east-1 + role-duration-seconds: 3600 + role-to-assume: ${{ vars.CDK_ATMOSPHERE_PROD_OIDC_ROLE }} + role-session-name: run-tests@aws-cdk-cli-integ + output-credentials: true + - name: "Run the test suite: ${{ matrix.suite }}" + id: run_the_test_suite_matrix_suite + env: + JSII_SILENCE_WARNING_DEPRECATED_NODE_VERSION: "true" + JSII_SILENCE_WARNING_UNTESTED_NODE_VERSION: "true" + JSII_SILENCE_WARNING_KNOWN_BROKEN_NODE_VERSION: "true" + DOCKERHUB_DISABLED: "true" + CDK_INTEG_ATMOSPHERE_ENABLED: "true" + CDK_INTEG_ATMOSPHERE_ENDPOINT: ${{ vars.CDK_ATMOSPHERE_PROD_ENDPOINT }} + CDK_INTEG_ATMOSPHERE_POOL: ${{ vars.CDK_INTEG_ATMOSPHERE_POOL }} + CDK_MAJOR_VERSION: "2" + RELEASE_TAG: latest + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + INTEG_LOGS: logs + run: npx run-suite --use-cli-release=${{ steps.versions.outputs.cli_version }} --framework-version=${{ steps.versions.outputs.lib_version }} ${{ matrix.suite }} + - name: Set workflow summary + id: set_workflow_summary + if: always() + run: |- + if compgen -G "logs/md/*.md" > /dev/null; then + cat logs/md/*.md >> $GITHUB_STEP_SUMMARY; + fi + - name: Slugify artifact id + id: artifactid + if: always() + env: + INPUT: logs-${{ matrix.suite }}-${{ matrix.node }} + run: |- + slug=$(node -p 'process.env.INPUT.replace(/[^a-z0-9._-]/gi, "-")') + echo "slug=$slug" >> "$GITHUB_OUTPUT" + - name: Upload logs + id: logupload + if: always() + uses: actions/upload-artifact@v7 + with: + name: ${{ steps.artifactid.outputs.slug }} + path: logs/ + overwrite: true + - name: Append artifact URL + id: append_artifact_url + if: always() + run: |- + echo "" >> $GITHUB_STEP_SUMMARY + echo "[Logs](${{ steps.logupload.outputs.artifact-url }})" >> $GITHUB_STEP_SUMMARY + strategy: + fail-fast: false + matrix: + suite: + - telemetry-integ-tests + node: + - lts/* + integ_init-templates: + needs: prepare + runs-on: aws-cdk_ubuntu-latest_16-core + permissions: + contents: read + id-token: write + environment: run-tests + env: + NODE_NO_WARNINGS: "1" + MAVEN_ARGS: --no-transfer-progress + IS_CANARY: "true" + CI: "true" + defaults: + run: + shell: bash + if: github.event_name != 'merge_group' && !contains(github.event.pull_request.labels.*.name, 'pr/exempt-integ-test') && github.event_name != 'schedule' + steps: + - name: Download artifact + id: download_artifact + uses: actions/download-artifact@v8 + with: + artifact-ids: ${{needs.prepare.outputs.packagesArtifact}} + path: packages + - name: Download artifact + id: download_artifact_2 + uses: actions/download-artifact@v8 + with: + artifact-ids: ${{needs.prepare.outputs.scriptsArtifact}} + path: .projen + - name: Setup Node.js + id: setup_node_js + uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node }} + package-manager-cache: false + - name: Set up JDK 18 + id: set_up_jdk_18 + if: matrix.suite == 'init-java' || matrix.suite == 'cli-integ-tests' + uses: actions/setup-java@v5 + with: + java-version: "18" + distribution: corretto + - name: Set git identity + id: set_git_identity + run: |- + git config --global user.name "aws-cdk-cli-integ" + git config --global user.email "noreply@example.com" + - name: Prepare Verdaccio + id: prepare_verdaccio + run: chmod +x .projen/prepare-verdaccio.sh && .projen/prepare-verdaccio.sh + - name: Download and install the test artifact + id: download_and_install_the_test_artifact + run: npm install @aws-cdk-testing/cli-integ + - name: Determine latest package versions + id: versions + run: |- + CLI_VERSION=$(cd ${TMPDIR:-/tmp} && npm view aws-cdk version) + echo "CLI version: ${CLI_VERSION}" + echo "cli_version=${CLI_VERSION}" >> $GITHUB_OUTPUT + LIB_VERSION=$(cd ${TMPDIR:-/tmp} && npm view aws-cdk-lib version) + echo "lib version: ${LIB_VERSION}" + echo "lib_version=${LIB_VERSION}" >> $GITHUB_OUTPUT + - name: Authenticate Via OIDC Role + id: creds + uses: aws-actions/configure-aws-credentials@v6 + with: + aws-region: us-east-1 + role-duration-seconds: 3600 + role-to-assume: ${{ vars.CDK_ATMOSPHERE_PROD_OIDC_ROLE }} + role-session-name: run-tests@aws-cdk-cli-integ + output-credentials: true + - name: "Run the test suite: ${{ matrix.suite }}" + id: run_the_test_suite_matrix_suite + env: + JSII_SILENCE_WARNING_DEPRECATED_NODE_VERSION: "true" + JSII_SILENCE_WARNING_UNTESTED_NODE_VERSION: "true" + JSII_SILENCE_WARNING_KNOWN_BROKEN_NODE_VERSION: "true" + DOCKERHUB_DISABLED: "true" + CDK_INTEG_ATMOSPHERE_ENABLED: "true" + CDK_INTEG_ATMOSPHERE_ENDPOINT: ${{ vars.CDK_ATMOSPHERE_PROD_ENDPOINT }} + CDK_INTEG_ATMOSPHERE_POOL: ${{ vars.CDK_INTEG_ATMOSPHERE_POOL }} + CDK_MAJOR_VERSION: "2" + RELEASE_TAG: latest + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + INTEG_LOGS: logs + run: npx run-suite --use-cli-release=${{ steps.versions.outputs.cli_version }} --framework-version=${{ steps.versions.outputs.lib_version }} ${{ matrix.suite }} + - name: Set workflow summary + id: set_workflow_summary + if: always() + run: |- + if compgen -G "logs/md/*.md" > /dev/null; then + cat logs/md/*.md >> $GITHUB_STEP_SUMMARY; + fi + - name: Slugify artifact id + id: artifactid + if: always() + env: + INPUT: logs-${{ matrix.suite }}-${{ matrix.node }} + run: |- + slug=$(node -p 'process.env.INPUT.replace(/[^a-z0-9._-]/gi, "-")') + echo "slug=$slug" >> "$GITHUB_OUTPUT" + - name: Upload logs + id: logupload + if: always() + uses: actions/upload-artifact@v7 + with: + name: ${{ steps.artifactid.outputs.slug }} + path: logs/ overwrite: true - - name: Upload artifact - id: script-artifact + - name: Append artifact URL + id: append_artifact_url + if: always() + run: |- + echo "" >> $GITHUB_STEP_SUMMARY + echo "[Logs](${{ steps.logupload.outputs.artifact-url }})" >> $GITHUB_STEP_SUMMARY + strategy: + fail-fast: false + matrix: + include: + - suite: init-typescript-app + node: "20" + - suite: init-typescript-app + node: "22" + - suite: init-typescript-app + node: "24" + suite: + - init-csharp + - init-fsharp + - init-go + - init-java + - init-javascript + - init-python + - init-typescript-app + - init-typescript-lib + node: + - lts/* + integ_tool-integrations: + needs: prepare + runs-on: aws-cdk_ubuntu-latest_16-core + permissions: + contents: read + id-token: write + environment: run-tests + env: + NODE_NO_WARNINGS: "1" + MAVEN_ARGS: --no-transfer-progress + IS_CANARY: "true" + CI: "true" + defaults: + run: + shell: bash + if: github.event_name != 'merge_group' && !contains(github.event.pull_request.labels.*.name, 'pr/exempt-integ-test') && github.event_name != 'schedule' + steps: + - name: Download artifact + id: download_artifact + uses: actions/download-artifact@v8 + with: + artifact-ids: ${{needs.prepare.outputs.packagesArtifact}} + path: packages + - name: Download artifact + id: download_artifact_2 + uses: actions/download-artifact@v8 + with: + artifact-ids: ${{needs.prepare.outputs.scriptsArtifact}} + path: .projen + - name: Setup Node.js + id: setup_node_js + uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node }} + package-manager-cache: false + - name: Set up JDK 18 + id: set_up_jdk_18 + if: matrix.suite == 'init-java' || matrix.suite == 'cli-integ-tests' + uses: actions/setup-java@v5 + with: + java-version: "18" + distribution: corretto + - name: Set git identity + id: set_git_identity + run: |- + git config --global user.name "aws-cdk-cli-integ" + git config --global user.email "noreply@example.com" + - name: Prepare Verdaccio + id: prepare_verdaccio + run: chmod +x .projen/prepare-verdaccio.sh && .projen/prepare-verdaccio.sh + - name: Download and install the test artifact + id: download_and_install_the_test_artifact + run: npm install @aws-cdk-testing/cli-integ + - name: Determine latest package versions + id: versions + run: |- + CLI_VERSION=$(cd ${TMPDIR:-/tmp} && npm view aws-cdk version) + echo "CLI version: ${CLI_VERSION}" + echo "cli_version=${CLI_VERSION}" >> $GITHUB_OUTPUT + LIB_VERSION=$(cd ${TMPDIR:-/tmp} && npm view aws-cdk-lib version) + echo "lib version: ${LIB_VERSION}" + echo "lib_version=${LIB_VERSION}" >> $GITHUB_OUTPUT + - name: Authenticate Via OIDC Role + id: creds + uses: aws-actions/configure-aws-credentials@v6 + with: + aws-region: us-east-1 + role-duration-seconds: 3600 + role-to-assume: ${{ vars.CDK_ATMOSPHERE_PROD_OIDC_ROLE }} + role-session-name: run-tests@aws-cdk-cli-integ + output-credentials: true + - name: "Run the test suite: ${{ matrix.suite }}" + id: run_the_test_suite_matrix_suite + env: + JSII_SILENCE_WARNING_DEPRECATED_NODE_VERSION: "true" + JSII_SILENCE_WARNING_UNTESTED_NODE_VERSION: "true" + JSII_SILENCE_WARNING_KNOWN_BROKEN_NODE_VERSION: "true" + DOCKERHUB_DISABLED: "true" + CDK_INTEG_ATMOSPHERE_ENABLED: "true" + CDK_INTEG_ATMOSPHERE_ENDPOINT: ${{ vars.CDK_ATMOSPHERE_PROD_ENDPOINT }} + CDK_INTEG_ATMOSPHERE_POOL: ${{ vars.CDK_INTEG_ATMOSPHERE_POOL }} + CDK_MAJOR_VERSION: "2" + RELEASE_TAG: latest + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + INTEG_LOGS: logs + run: npx run-suite --use-cli-release=${{ steps.versions.outputs.cli_version }} --framework-version=${{ steps.versions.outputs.lib_version }} ${{ matrix.suite }} + - name: Set workflow summary + id: set_workflow_summary + if: always() + run: |- + if compgen -G "logs/md/*.md" > /dev/null; then + cat logs/md/*.md >> $GITHUB_STEP_SUMMARY; + fi + - name: Slugify artifact id + id: artifactid + if: always() + env: + INPUT: logs-${{ matrix.suite }}-${{ matrix.node }} + run: |- + slug=$(node -p 'process.env.INPUT.replace(/[^a-z0-9._-]/gi, "-")') + echo "slug=$slug" >> "$GITHUB_OUTPUT" + - name: Upload logs + id: logupload + if: always() uses: actions/upload-artifact@v7 with: - name: script-artifact - path: .projen/*.sh + name: ${{ steps.artifactid.outputs.slug }} + path: logs/ overwrite: true - include-hidden-files: true - integ_cli: + - name: Append artifact URL + id: append_artifact_url + if: always() + run: |- + echo "" >> $GITHUB_STEP_SUMMARY + echo "[Logs](${{ steps.logupload.outputs.artifact-url }})" >> $GITHUB_STEP_SUMMARY + strategy: + fail-fast: false + matrix: + suite: + - tool-integrations + node: + - "20" + integ_cli_windows: needs: prepare - runs-on: aws-cdk_ubuntu-latest_16-core + runs-on: windows-latest permissions: contents: read id-token: write @@ -101,8 +757,48 @@ jobs: MAVEN_ARGS: --no-transfer-progress IS_CANARY: "true" CI: "true" - if: github.event_name != 'merge_group' && !contains(github.event.pull_request.labels.*.name, 'pr/exempt-integ-test') + CDK_INTEG_SKIP_TESTS_FILE: ${{ github.workspace }}\windows-skip-tests.txt + defaults: + run: + shell: bash + if: github.event_name != 'merge_group' && !contains(github.event.pull_request.labels.*.name, 'pr/exempt-integ-test') && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'pr/test-windows')) steps: + - name: Set up Dev Drive for TEMP and npm cache + id: set_up_dev_drive_for_temp_and_npm_cache + run: |- + $vhd = "C:\devdrive.vhdx" + $drive = (New-VHD -Path $vhd -SizeBytes 40GB -Dynamic | Mount-VHD -PassThru | Initialize-Disk -PassThru | New-Partition -AssignDriveLetter -UseMaximumSize | Format-Volume -DevDrive -Confirm:$false).DriveLetter + New-Item -ItemType Directory -Path "${drive}:\temp" | Out-Null + New-Item -ItemType Directory -Path "${drive}:\npm-cache" | Out-Null + echo "TEMP=${drive}:\temp" >> $env:GITHUB_ENV + echo "TMP=${drive}:\temp" >> $env:GITHUB_ENV + echo "npm_config_cache=${drive}:\npm-cache" >> $env:GITHUB_ENV + shell: powershell + - name: Write Windows skip-tests file + id: write_windows_skip-tests_file + run: |- + cat > windows-skip-tests.txt << 'EOF' + deploy same docker asset to multiple regions + deploy same docker asset to multiple stacks + deploy stack with multiple docker assets + deploy stack with docker asset + cdk-assets smoke test + deploy new style synthesis to new style bootstrap (with docker image) + Garbage Collection untags in-use ecr images + Garbage Collection keeps in use ecr images + Garbage Collection deletes unused ecr images + Garbage Collection tags unused ecr images + all calls from isolated container go through proxy + docker-credential-cdk-assets can assume role and fetch ECR credentials + toolkit deploy stack with multiple docker assets + CDK synth bundled functions as expected + CDK synth add the metadata properties expected by sam + can deploy with session tags on the deploy, lookup, file asset, and image asset publishing roles + generating and loading assembly + test resource import with construct that requires bundling + hotswap deployment supports Bedrock AgentCore Runtime + sam can locally test the synthesized cdk application + EOF - name: Download artifact id: download_artifact uses: actions/download-artifact@v8 @@ -183,7 +879,7 @@ jobs: id: artifactid if: always() env: - INPUT: logs-${{ matrix.suite }}-${{ matrix.node }}-${{ matrix.shard }} + INPUT: logs_windows-${{ matrix.suite }}-${{ matrix.node }}-${{ matrix.shard }} run: |- slug=$(node -p 'process.env.INPUT.replace(/[^a-z0-9._-]/gi, "-")') echo "slug=$slug" >> "$GITHUB_OUTPUT" @@ -201,6 +897,7 @@ jobs: run: |- echo "" >> $GITHUB_STEP_SUMMARY echo "[Logs](${{ steps.logupload.outputs.artifact-url }})" >> $GITHUB_STEP_SUMMARY + timeout-minutes: 90 strategy: fail-fast: false matrix: @@ -221,9 +918,9 @@ jobs: - 10 - 11 - 12 - integ_toolkit-lib: + integ_toolkit-lib_windows: needs: prepare - runs-on: aws-cdk_ubuntu-latest_16-core + runs-on: windows-latest permissions: contents: read id-token: write @@ -233,8 +930,48 @@ jobs: MAVEN_ARGS: --no-transfer-progress IS_CANARY: "true" CI: "true" - if: github.event_name != 'merge_group' && !contains(github.event.pull_request.labels.*.name, 'pr/exempt-integ-test') + CDK_INTEG_SKIP_TESTS_FILE: ${{ github.workspace }}\windows-skip-tests.txt + defaults: + run: + shell: bash + if: github.event_name != 'merge_group' && !contains(github.event.pull_request.labels.*.name, 'pr/exempt-integ-test') && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'pr/test-windows')) steps: + - name: Set up Dev Drive for TEMP and npm cache + id: set_up_dev_drive_for_temp_and_npm_cache + run: |- + $vhd = "C:\devdrive.vhdx" + $drive = (New-VHD -Path $vhd -SizeBytes 40GB -Dynamic | Mount-VHD -PassThru | Initialize-Disk -PassThru | New-Partition -AssignDriveLetter -UseMaximumSize | Format-Volume -DevDrive -Confirm:$false).DriveLetter + New-Item -ItemType Directory -Path "${drive}:\temp" | Out-Null + New-Item -ItemType Directory -Path "${drive}:\npm-cache" | Out-Null + echo "TEMP=${drive}:\temp" >> $env:GITHUB_ENV + echo "TMP=${drive}:\temp" >> $env:GITHUB_ENV + echo "npm_config_cache=${drive}:\npm-cache" >> $env:GITHUB_ENV + shell: powershell + - name: Write Windows skip-tests file + id: write_windows_skip-tests_file + run: |- + cat > windows-skip-tests.txt << 'EOF' + deploy same docker asset to multiple regions + deploy same docker asset to multiple stacks + deploy stack with multiple docker assets + deploy stack with docker asset + cdk-assets smoke test + deploy new style synthesis to new style bootstrap (with docker image) + Garbage Collection untags in-use ecr images + Garbage Collection keeps in use ecr images + Garbage Collection deletes unused ecr images + Garbage Collection tags unused ecr images + all calls from isolated container go through proxy + docker-credential-cdk-assets can assume role and fetch ECR credentials + toolkit deploy stack with multiple docker assets + CDK synth bundled functions as expected + CDK synth add the metadata properties expected by sam + can deploy with session tags on the deploy, lookup, file asset, and image asset publishing roles + generating and loading assembly + test resource import with construct that requires bundling + hotswap deployment supports Bedrock AgentCore Runtime + sam can locally test the synthesized cdk application + EOF - name: Download artifact id: download_artifact uses: actions/download-artifact@v8 @@ -315,7 +1052,7 @@ jobs: id: artifactid if: always() env: - INPUT: logs-${{ matrix.suite }}-${{ matrix.node }} + INPUT: logs_windows-${{ matrix.suite }}-${{ matrix.node }} run: |- slug=$(node -p 'process.env.INPUT.replace(/[^a-z0-9._-]/gi, "-")') echo "slug=$slug" >> "$GITHUB_OUTPUT" @@ -333,6 +1070,7 @@ jobs: run: |- echo "" >> $GITHUB_STEP_SUMMARY echo "[Logs](${{ steps.logupload.outputs.artifact-url }})" >> $GITHUB_STEP_SUMMARY + timeout-minutes: 90 strategy: fail-fast: false matrix: @@ -343,9 +1081,9 @@ jobs: - "20" - "22" - "24" - integ_telemetry: + integ_telemetry_windows: needs: prepare - runs-on: aws-cdk_ubuntu-latest_16-core + runs-on: windows-latest permissions: contents: read id-token: write @@ -355,8 +1093,48 @@ jobs: MAVEN_ARGS: --no-transfer-progress IS_CANARY: "true" CI: "true" - if: github.event_name != 'merge_group' && !contains(github.event.pull_request.labels.*.name, 'pr/exempt-integ-test') + CDK_INTEG_SKIP_TESTS_FILE: ${{ github.workspace }}\windows-skip-tests.txt + defaults: + run: + shell: bash + if: github.event_name != 'merge_group' && !contains(github.event.pull_request.labels.*.name, 'pr/exempt-integ-test') && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'pr/test-windows')) steps: + - name: Set up Dev Drive for TEMP and npm cache + id: set_up_dev_drive_for_temp_and_npm_cache + run: |- + $vhd = "C:\devdrive.vhdx" + $drive = (New-VHD -Path $vhd -SizeBytes 40GB -Dynamic | Mount-VHD -PassThru | Initialize-Disk -PassThru | New-Partition -AssignDriveLetter -UseMaximumSize | Format-Volume -DevDrive -Confirm:$false).DriveLetter + New-Item -ItemType Directory -Path "${drive}:\temp" | Out-Null + New-Item -ItemType Directory -Path "${drive}:\npm-cache" | Out-Null + echo "TEMP=${drive}:\temp" >> $env:GITHUB_ENV + echo "TMP=${drive}:\temp" >> $env:GITHUB_ENV + echo "npm_config_cache=${drive}:\npm-cache" >> $env:GITHUB_ENV + shell: powershell + - name: Write Windows skip-tests file + id: write_windows_skip-tests_file + run: |- + cat > windows-skip-tests.txt << 'EOF' + deploy same docker asset to multiple regions + deploy same docker asset to multiple stacks + deploy stack with multiple docker assets + deploy stack with docker asset + cdk-assets smoke test + deploy new style synthesis to new style bootstrap (with docker image) + Garbage Collection untags in-use ecr images + Garbage Collection keeps in use ecr images + Garbage Collection deletes unused ecr images + Garbage Collection tags unused ecr images + all calls from isolated container go through proxy + docker-credential-cdk-assets can assume role and fetch ECR credentials + toolkit deploy stack with multiple docker assets + CDK synth bundled functions as expected + CDK synth add the metadata properties expected by sam + can deploy with session tags on the deploy, lookup, file asset, and image asset publishing roles + generating and loading assembly + test resource import with construct that requires bundling + hotswap deployment supports Bedrock AgentCore Runtime + sam can locally test the synthesized cdk application + EOF - name: Download artifact id: download_artifact uses: actions/download-artifact@v8 @@ -437,7 +1215,7 @@ jobs: id: artifactid if: always() env: - INPUT: logs-${{ matrix.suite }}-${{ matrix.node }} + INPUT: logs_windows-${{ matrix.suite }}-${{ matrix.node }} run: |- slug=$(node -p 'process.env.INPUT.replace(/[^a-z0-9._-]/gi, "-")') echo "slug=$slug" >> "$GITHUB_OUTPUT" @@ -455,6 +1233,7 @@ jobs: run: |- echo "" >> $GITHUB_STEP_SUMMARY echo "[Logs](${{ steps.logupload.outputs.artifact-url }})" >> $GITHUB_STEP_SUMMARY + timeout-minutes: 90 strategy: fail-fast: false matrix: @@ -462,9 +1241,9 @@ jobs: - telemetry-integ-tests node: - lts/* - integ_init-templates: + integ_init-templates_windows: needs: prepare - runs-on: aws-cdk_ubuntu-latest_16-core + runs-on: windows-latest permissions: contents: read id-token: write @@ -474,8 +1253,48 @@ jobs: MAVEN_ARGS: --no-transfer-progress IS_CANARY: "true" CI: "true" - if: github.event_name != 'merge_group' && !contains(github.event.pull_request.labels.*.name, 'pr/exempt-integ-test') + CDK_INTEG_SKIP_TESTS_FILE: ${{ github.workspace }}\windows-skip-tests.txt + defaults: + run: + shell: bash + if: github.event_name != 'merge_group' && !contains(github.event.pull_request.labels.*.name, 'pr/exempt-integ-test') && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'pr/test-windows')) steps: + - name: Set up Dev Drive for TEMP and npm cache + id: set_up_dev_drive_for_temp_and_npm_cache + run: |- + $vhd = "C:\devdrive.vhdx" + $drive = (New-VHD -Path $vhd -SizeBytes 40GB -Dynamic | Mount-VHD -PassThru | Initialize-Disk -PassThru | New-Partition -AssignDriveLetter -UseMaximumSize | Format-Volume -DevDrive -Confirm:$false).DriveLetter + New-Item -ItemType Directory -Path "${drive}:\temp" | Out-Null + New-Item -ItemType Directory -Path "${drive}:\npm-cache" | Out-Null + echo "TEMP=${drive}:\temp" >> $env:GITHUB_ENV + echo "TMP=${drive}:\temp" >> $env:GITHUB_ENV + echo "npm_config_cache=${drive}:\npm-cache" >> $env:GITHUB_ENV + shell: powershell + - name: Write Windows skip-tests file + id: write_windows_skip-tests_file + run: |- + cat > windows-skip-tests.txt << 'EOF' + deploy same docker asset to multiple regions + deploy same docker asset to multiple stacks + deploy stack with multiple docker assets + deploy stack with docker asset + cdk-assets smoke test + deploy new style synthesis to new style bootstrap (with docker image) + Garbage Collection untags in-use ecr images + Garbage Collection keeps in use ecr images + Garbage Collection deletes unused ecr images + Garbage Collection tags unused ecr images + all calls from isolated container go through proxy + docker-credential-cdk-assets can assume role and fetch ECR credentials + toolkit deploy stack with multiple docker assets + CDK synth bundled functions as expected + CDK synth add the metadata properties expected by sam + can deploy with session tags on the deploy, lookup, file asset, and image asset publishing roles + generating and loading assembly + test resource import with construct that requires bundling + hotswap deployment supports Bedrock AgentCore Runtime + sam can locally test the synthesized cdk application + EOF - name: Download artifact id: download_artifact uses: actions/download-artifact@v8 @@ -556,7 +1375,7 @@ jobs: id: artifactid if: always() env: - INPUT: logs-${{ matrix.suite }}-${{ matrix.node }} + INPUT: logs_windows-${{ matrix.suite }}-${{ matrix.node }} run: |- slug=$(node -p 'process.env.INPUT.replace(/[^a-z0-9._-]/gi, "-")') echo "slug=$slug" >> "$GITHUB_OUTPUT" @@ -574,6 +1393,7 @@ jobs: run: |- echo "" >> $GITHUB_STEP_SUMMARY echo "[Logs](${{ steps.logupload.outputs.artifact-url }})" >> $GITHUB_STEP_SUMMARY + timeout-minutes: 90 strategy: fail-fast: false matrix: @@ -595,9 +1415,9 @@ jobs: - init-typescript-lib node: - lts/* - integ_tool-integrations: + integ_tool-integrations_windows: needs: prepare - runs-on: aws-cdk_ubuntu-latest_16-core + runs-on: windows-latest permissions: contents: read id-token: write @@ -607,8 +1427,48 @@ jobs: MAVEN_ARGS: --no-transfer-progress IS_CANARY: "true" CI: "true" - if: github.event_name != 'merge_group' && !contains(github.event.pull_request.labels.*.name, 'pr/exempt-integ-test') + CDK_INTEG_SKIP_TESTS_FILE: ${{ github.workspace }}\windows-skip-tests.txt + defaults: + run: + shell: bash + if: github.event_name != 'merge_group' && !contains(github.event.pull_request.labels.*.name, 'pr/exempt-integ-test') && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'pr/test-windows')) steps: + - name: Set up Dev Drive for TEMP and npm cache + id: set_up_dev_drive_for_temp_and_npm_cache + run: |- + $vhd = "C:\devdrive.vhdx" + $drive = (New-VHD -Path $vhd -SizeBytes 40GB -Dynamic | Mount-VHD -PassThru | Initialize-Disk -PassThru | New-Partition -AssignDriveLetter -UseMaximumSize | Format-Volume -DevDrive -Confirm:$false).DriveLetter + New-Item -ItemType Directory -Path "${drive}:\temp" | Out-Null + New-Item -ItemType Directory -Path "${drive}:\npm-cache" | Out-Null + echo "TEMP=${drive}:\temp" >> $env:GITHUB_ENV + echo "TMP=${drive}:\temp" >> $env:GITHUB_ENV + echo "npm_config_cache=${drive}:\npm-cache" >> $env:GITHUB_ENV + shell: powershell + - name: Write Windows skip-tests file + id: write_windows_skip-tests_file + run: |- + cat > windows-skip-tests.txt << 'EOF' + deploy same docker asset to multiple regions + deploy same docker asset to multiple stacks + deploy stack with multiple docker assets + deploy stack with docker asset + cdk-assets smoke test + deploy new style synthesis to new style bootstrap (with docker image) + Garbage Collection untags in-use ecr images + Garbage Collection keeps in use ecr images + Garbage Collection deletes unused ecr images + Garbage Collection tags unused ecr images + all calls from isolated container go through proxy + docker-credential-cdk-assets can assume role and fetch ECR credentials + toolkit deploy stack with multiple docker assets + CDK synth bundled functions as expected + CDK synth add the metadata properties expected by sam + can deploy with session tags on the deploy, lookup, file asset, and image asset publishing roles + generating and loading assembly + test resource import with construct that requires bundling + hotswap deployment supports Bedrock AgentCore Runtime + sam can locally test the synthesized cdk application + EOF - name: Download artifact id: download_artifact uses: actions/download-artifact@v8 @@ -689,7 +1549,7 @@ jobs: id: artifactid if: always() env: - INPUT: logs-${{ matrix.suite }}-${{ matrix.node }} + INPUT: logs_windows-${{ matrix.suite }}-${{ matrix.node }} run: |- slug=$(node -p 'process.env.INPUT.replace(/[^a-z0-9._-]/gi, "-")') echo "slug=$slug" >> "$GITHUB_OUTPUT" @@ -707,6 +1567,7 @@ jobs: run: |- echo "" >> $GITHUB_STEP_SUMMARY echo "[Logs](${{ steps.logupload.outputs.artifact-url }})" >> $GITHUB_STEP_SUMMARY + timeout-minutes: 90 strategy: fail-fast: false matrix: @@ -721,6 +1582,11 @@ jobs: - integ_telemetry - integ_init-templates - integ_tool-integrations + - integ_cli_windows + - integ_toolkit-lib_windows + - integ_telemetry_windows + - integ_init-templates_windows + - integ_tool-integrations_windows runs-on: ubuntu-latest permissions: {} if: always() @@ -740,7 +1606,52 @@ jobs: - name: integ_tool-integrations result id: integ_tool-integrations_result run: echo ${{ needs.integ_tool-integrations.result }} + - name: integ_cli_windows result + id: integ_cli_windows_result + run: echo ${{ needs.integ_cli_windows.result }} + - name: integ_toolkit-lib_windows result + id: integ_toolkit-lib_windows_result + run: echo ${{ needs.integ_toolkit-lib_windows.result }} + - name: integ_telemetry_windows result + id: integ_telemetry_windows_result + run: echo ${{ needs.integ_telemetry_windows.result }} + - name: integ_init-templates_windows result + id: integ_init-templates_windows_result + run: echo ${{ needs.integ_init-templates_windows.result }} + - name: integ_tool-integrations_windows result + id: integ_tool-integrations_windows_result + run: echo ${{ needs.integ_tool-integrations_windows.result }} - name: Set status based on test results id: set_status_based_on_test_results - if: ${{ !(contains(fromJSON('["success", "skipped"]'), needs.integ_cli.result) && contains(fromJSON('["success", "skipped"]'), needs.integ_toolkit-lib.result) && contains(fromJSON('["success", "skipped"]'), needs.integ_telemetry.result) && contains(fromJSON('["success", "skipped"]'), needs.integ_init-templates.result) && contains(fromJSON('["success", "skipped"]'), needs.integ_tool-integrations.result)) }} + if: ${{ !(contains(fromJSON('["success", "skipped"]'), needs.integ_cli.result) && contains(fromJSON('["success", "skipped"]'), needs.integ_toolkit-lib.result) && contains(fromJSON('["success", "skipped"]'), needs.integ_telemetry.result) && contains(fromJSON('["success", "skipped"]'), needs.integ_init-templates.result) && contains(fromJSON('["success", "skipped"]'), needs.integ_tool-integrations.result) && contains(fromJSON('["success", "skipped"]'), needs.integ_cli_windows.result) && contains(fromJSON('["success", "skipped"]'), needs.integ_toolkit-lib_windows.result) && contains(fromJSON('["success", "skipped"]'), needs.integ_telemetry_windows.result) && contains(fromJSON('["success", "skipped"]'), needs.integ_init-templates_windows.result) && contains(fromJSON('["success", "skipped"]'), needs.integ_tool-integrations_windows.result)) }} run: exit 1 + integ_windows_report_failure: + needs: + - integ_cli_windows + - integ_toolkit-lib_windows + - integ_telemetry_windows + - integ_init-templates_windows + - integ_tool-integrations_windows + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + RUN_SHA: ${{ github.sha }} + if: ${{ always() && github.event_name == 'schedule' && contains(needs.*.result, 'failure') }} + steps: + - name: File an issue + id: file_an_issue + run: |- + set -euo pipefail + + gh issue create \ + --title 'Windows integ nightly failed' \ + --label 'potential-regression' \ + --body "Nightly Windows integ run failed. + + Run: $RUN_URL + Commit: $RUN_SHA" diff --git a/.projenrc.ts b/.projenrc.ts index d599b69bc..cdfe794e1 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -1770,6 +1770,10 @@ new CdkCliIntegTestsWorkflow(repo, { testEnvironment: TEST_ENVIRONMENT, buildRunsOn: POWERFUL_RUNNER, testRunsOn: POWERFUL_RUNNER, + // Also run the integ suites on Windows to catch platform-specific + // regressions (paths, subprocess spawning). Uses the free standard runner + // for now; switch to a larger runner label once one is provisioned. + windowsTestRunsOn: 'windows-latest', allowUpstreamVersions: [ // cloud-assembly-schema gets referenced under multiple versions diff --git a/packages/@aws-cdk-testing/cli-integ/lib/integ-test.ts b/packages/@aws-cdk-testing/cli-integ/lib/integ-test.ts index 49832f245..aec8386d7 100644 --- a/packages/@aws-cdk-testing/cli-integ/lib/integ-test.ts +++ b/packages/@aws-cdk-testing/cli-integ/lib/integ-test.ts @@ -229,12 +229,38 @@ function slugify(x: string) { return x.replace(/[^a-zA-Z0-9_,]+/g, '-'); } -async function atomicWrite(fileName: string, contents: string) { +/** + * Write a file by writing to a temp file and renaming it into place. + * + * On POSIX the final rename atomically replaces any existing destination, and + * concurrent writers of the same target harmlessly clobber each other. On + * Windows, replacing a destination that another process currently has open (or + * that is in a "delete pending" state from a concurrent replace) fails with + * EPERM/EACCES. Multiple test workers rewrite shared log files (notably + * `0-header.md`) at once, so ride out that transient window by retrying the + * rename a handful of times before giving up. + */ +export async function atomicWrite(fileName: string, contents: string) { await fs.promises.mkdir(path.dirname(fileName), { recursive: true }); const tmp = `${fileName}.${process.pid}`; await fs.promises.writeFile(tmp, contents); - await fs.promises.rename(tmp, fileName); + + const RENAME_RETRYABLE = ['EPERM', 'EACCES']; + const maxAttempts = 10; + for (let attempt = 1; ; attempt++) { + try { + await fs.promises.rename(tmp, fileName); + return; + } catch (e: any) { + if (!RENAME_RETRYABLE.includes(e.code) || attempt >= maxAttempts) { + // Final failure: don't leave the temp file behind as litter. + await fs.promises.rm(tmp, { force: true }).catch(() => undefined); + throw e; + } + await new Promise(ok => setTimeout(ok, Math.floor(Math.random() * 20) + 5)); + } + } } function readSkipFile(filePath?: string): string[] { diff --git a/packages/@aws-cdk-testing/cli-integ/lib/npm.ts b/packages/@aws-cdk-testing/cli-integ/lib/npm.ts index 82c96a5f8..a2a20251a 100644 --- a/packages/@aws-cdk-testing/cli-integ/lib/npm.ts +++ b/packages/@aws-cdk-testing/cli-integ/lib/npm.ts @@ -40,7 +40,8 @@ export async function npmQueryInstalledVersion(packageName: string, dir: string) * Use NPM preinstalled on the machine to look up a list of TypeScript versions */ export function typescriptVersionsSync(): string[] { - const { stdout } = spawnSync('npm', ['--silent', 'view', `typescript@>=${MINIMUM_VERSION}`, 'version', '--json'], { encoding: 'utf-8' }); + // Invoke npm through Node: on Windows `npm` is a `.cmd` file, which spawnSync cannot execute directly + const { stdout } = spawnSync(process.execPath, [require.resolve('npm'), '--silent', 'view', `typescript@>=${MINIMUM_VERSION}`, 'version', '--json'], { encoding: 'utf-8' }); const versions: string[] = JSON.parse(stdout); return Array.from(new Set(versions.map(v => v.split('.').slice(0, 2).join('.')))); @@ -50,7 +51,7 @@ export function typescriptVersionsSync(): string[] { * Use NPM preinstalled on the machine to query publish times of versions */ export function typescriptVersionsYoungerThanDaysSync(days: number, versions: string[]): string[] { - const { stdout } = spawnSync('npm', ['--silent', 'view', 'typescript', 'time', '--json'], { encoding: 'utf-8' }); + const { stdout } = spawnSync(process.execPath, [require.resolve('npm'), '--silent', 'view', 'typescript', 'time', '--json'], { encoding: 'utf-8' }); const versionTsMap: Record = JSON.parse(stdout); const cutoffDate = new Date(Date.now() - (days * 24 * 3600 * 1000)); diff --git a/packages/@aws-cdk-testing/cli-integ/lib/process.ts b/packages/@aws-cdk-testing/cli-integ/lib/process.ts index 9b64ee585..5e08f966e 100644 --- a/packages/@aws-cdk-testing/cli-integ/lib/process.ts +++ b/packages/@aws-cdk-testing/cli-integ/lib/process.ts @@ -48,11 +48,23 @@ export class Process { * Spawn a process with a TTY attached. */ public static spawnTTY(command: string, args: string[], options: pty.IPtyForkOptions | pty.IWindowsPtyForkOptions = {}): IProcess { - const process = pty.spawn(command, args, { + // ConPTY resolves the spawned file with SearchPath, which only finds real + // executables — not the .cmd shims npm creates for CLI entrypoints. Route + // the command through the shell, like Process.spawn does with 'shell: true'. + if (process.platform === 'win32') { + args = ['/c', command, ...args]; + command = process.env.ComSpec ?? 'cmd.exe'; + } + const ptyProcess = pty.spawn(command, args, { name: 'xterm-color', + // Wide enough that no output line ever hits the terminal width: ConPTY + // (unlike Unix ptys) renders the screen buffer and inserts hard line + // breaks at the width, which splits long prompts across lines and + // breaks the line-based prompt matching in shell(). + cols: 512, ...options, }); - return new PtyProcess(process); + return new PtyProcess(ptyProcess); } /** diff --git a/packages/@aws-cdk-testing/cli-integ/lib/shell.ts b/packages/@aws-cdk-testing/cli-integ/lib/shell.ts index 436ee7633..910e9e727 100644 --- a/packages/@aws-cdk-testing/cli-integ/lib/shell.ts +++ b/packages/@aws-cdk-testing/cli-integ/lib/shell.ts @@ -282,7 +282,22 @@ export class ShellHelper { export function rimraf(fsPath: string): boolean { try { let success = true; - const isDir = fs.lstatSync(fsPath).isDirectory(); + const stat = fs.lstatSync(fsPath); + + // Remove links without recursing into their target: a directory may + // link to shared content that other tests are still using (e.g. the + // shared 'node_modules'). + if (stat.isSymbolicLink()) { + try { + fs.unlinkSync(fsPath); + } catch { + // On Windows, directory links (junctions) must be removed with rmdir + fs.rmdirSync(fsPath); + } + return true; + } + + const isDir = stat.isDirectory(); if (isDir) { for (const file of fs.readdirSync(fsPath)) { @@ -310,13 +325,13 @@ export function rimraf(fsPath: string): boolean { } export function addToShellPath(x: string) { - const parts = process.env.PATH?.split(':') ?? []; + const parts = process.env.PATH?.split(path.delimiter) ?? []; if (!parts.includes(x)) { parts.unshift(x); } - process.env.PATH = parts.join(':'); + process.env.PATH = parts.join(path.delimiter); } /** @@ -339,7 +354,28 @@ export function addToShellPath(x: string) { class LastLine { private lastLine: string = ''; + // win32 only: the last completed line that had visible content, see below + private lastVisibleLine: string = ''; + public append(chunk: string): void { + if (process.platform === 'win32') { + // ConPTY renders the screen buffer instead of streaming plain text: + // prompts are drawn with cursor-positioning escape sequences, padded + // with spaces to the terminal width, and followed by "lines" that + // contain nothing but more escape sequences. Match against the last + // line that had visible content, so control-only lines don't erase a + // prompt that was just drawn. + const lines = stripAnsi(chunk).split(/\r?\n/); + this.lastLine += lines[0]; + for (const line of lines.slice(1)) { + if (this.lastLine.trim().length > 0) { + this.lastVisibleLine = this.lastLine; + } + this.lastLine = line; + } + return; + } + const lines = chunk.split(os.EOL); if (lines.length === 1) { // chunk doesn't contain a new line so just append @@ -351,10 +387,30 @@ class LastLine { } public get(): string { + if (process.platform === 'win32' && this.lastLine.trim().length === 0) { + return this.lastVisibleLine; + } return this.lastLine; } public reset() { this.lastLine = ''; + this.lastVisibleLine = ''; } } + +const ESC = '\u001b'; +// CSI sequences (cursor movement, erase, colors) and OSC sequences (window title) +const ANSI_REGEX = new RegExp(`${ESC}\\[[0-9;?]*[@-~]|${ESC}\\][^${ESC}\\u0007]*(?:\\u0007|${ESC}\\\\)`, 'g'); + +/** + * Remove ANSI escape sequences from terminal output. + * + * Windows ConPTY renders the screen buffer rather than streaming plain text: + * once the cursor reaches the bottom of the buffer, lines arrive as absolute + * cursor-positioning sequences instead of newline-terminated text. Prompt + * matching must look at the text only. + */ +function stripAnsi(chunk: string): string { + return chunk.replace(ANSI_REGEX, ''); +} diff --git a/packages/@aws-cdk-testing/cli-integ/lib/with-cdk-app.ts b/packages/@aws-cdk-testing/cli-integ/lib/with-cdk-app.ts index 5923a445e..f3c46db71 100644 --- a/packages/@aws-cdk-testing/cli-integ/lib/with-cdk-app.ts +++ b/packages/@aws-cdk-testing/cli-integ/lib/with-cdk-app.ts @@ -1,5 +1,6 @@ /* eslint-disable no-console */ import assert from 'assert'; +import * as crypto from 'crypto'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -17,6 +18,7 @@ import { shell, ShellHelper, rimraf } from './shell'; import type { AwsContext, AwsContextOptions } from './with-aws'; import { atmosphereEnabled, withAws } from './with-aws'; import { withTimeout } from './with-timeout'; +import { XpMutexPool } from './xpmutex'; import { findYarnPackages } from './yarn'; export const DEFAULT_TEST_TIMEOUT_S = 20 * 60; @@ -279,9 +281,10 @@ export interface CdkDestroyCliOptions extends CdkCliOptions { * Prepare a target dir byreplicating a source directory */ export async function cloneDirectory(source: string, target: string, output?: NodeJS.WritableStream) { - await shell(['rm', '-rf', target], { outputs: output ? [output] : [] }); - await shell(['mkdir', '-p', target], { outputs: output ? [output] : [] }); - await shell(['cp', '-R', source + '/*', target], { outputs: output ? [output] : [] }); + output?.write(`Cloning ${source} into ${target}\n`); + await fs.promises.rm(target, { recursive: true, force: true }); + await fs.promises.mkdir(target, { recursive: true }); + await fs.promises.cp(source, target, { recursive: true }); } interface CommonCdkBootstrapCommandOptions { @@ -505,15 +508,33 @@ export class TestFixture extends ShellHelper { const tokenResponse = await this.aws.ecrPublic.send(new GetAuthorizationTokenCommand({})); const authData = tokenResponse.authorizationData?.authorizationToken; - const docker = process.env.CDK_DOCKER ?? 'docker'; - if (!authData) { throw new Error('Could not retrieve ECR public auth token.'); } + if (process.platform === 'win32') { + // `docker login` on Windows stores credentials through the wincred credential + // helper (auto-detected even if `credsStore` is empty in the config file), and + // wincred cannot store ECR tokens: they exceed Windows Credential Manager's + // 2560-byte limit ('The stub received bad data'). Write the auth directly into + // the per-test Docker config file instead, which is exactly what `docker login` + // produces on the Linux runners, where no credential helper is installed. + // The plaintext `auths` entry takes precedence over any credential helper. + await fs.promises.mkdir(this.dockerConfigDir, { recursive: true }); + await fs.promises.writeFile( + path.join(this.dockerConfigDir, 'config.json'), + JSON.stringify({ auths: { 'public.ecr.aws': { auth: authData } } }), + ); + return; + } + + const docker = process.env.CDK_DOCKER ?? 'docker'; + const decoded = Buffer.from(authData, 'base64').toString('utf-8'); const [username, password] = decoded.split(':'); + // Reference the password via an environment variable so it doesn't leak into + // process listings; the shell expands it. await this.shell([docker, 'login', '--username', username, '--password', '${ECR_PASSWORD}', @@ -1011,9 +1032,13 @@ function hasJsonFlag(args: string[]): boolean { /** * Install the given NPM packages, identified by their names and versions * - * Works by writing the packages to a `package.json` file, and - * then running NPM7's "install" on it. The use of NPM7 will automatically - * install required peerDependencies. + * Works by writing the packages to a `package.json` file, and then running NPM7's + * "install" on it. The use of NPM7 will automatically install required + * peerDependencies. + * + * The install itself is shared: because every test asks for the same handful of + * packages at the same resolved versions, they are installed once per machine and + * linked into each test directory. See `sharedPackageSetInstall`. * * If we're running in REPO mode and we find the package in the set of local * packages in the repository, we'll write the directory name to `package.json` @@ -1027,6 +1052,8 @@ function hasJsonFlag(args: string[]): boolean { * for Node's dependency lookup mechanism). */ export async function installNpmPackages(fixture: TestFixture, packages: Record) { + let hasLocalPackages = false; + if (process.env.REPO_ROOT) { const monoRepo = await findYarnPackages(process.env.REPO_ROOT); @@ -1034,6 +1061,7 @@ export async function installNpmPackages(fixture: TestFixture, packages: Record< for (const key of Object.keys(packages)) { if (key in monoRepo) { packages[key] = monoRepo[key]; + hasLocalPackages = true; } } } @@ -1045,6 +1073,99 @@ export async function installNpmPackages(fixture: TestFixture, packages: Record< devDependencies: packages, }, undefined, 2), { encoding: 'utf-8' }); + if (hasLocalPackages) { + // A local package is referenced by directory, so the package set no longer + // identifies its own contents: rebuilding changes what is on disk without + // changing the requested version. Install per test, so that the dev cycle + // of 'rebuild, rerun the test' keeps working. + await npmInstallWithRetry(fixture, fixture.integTestDir); + return; + } + + // Every test installs the same small set of packages, and `aws-cdk-lib` alone is + // tens of thousands of files, so installing per test is pure duplicated work: it + // is very slow on Windows (minutes instead of seconds), and on every platform it + // means many concurrent `npm install` processes, which is a source of ECONNRESET + // failures. Install each distinct package set once per machine and link it into + // the test directory instead. + const sharedNodeModules = await sharedPackageSetInstall(fixture, packages); + fs.symlinkSync( + sharedNodeModules, + path.join(fixture.integTestDir, 'node_modules'), + // Ignored on POSIX. On Windows a 'junction' works for unprivileged users, + // where a 'dir' symlink needs elevation. + process.platform === 'win32' ? 'junction' : 'dir', + ); + + // `npm` writes the lock file next to the `package.json` it installed, which is now + // the shared directory, so copy it back into the test directory. Constructs that + // bundle (`NodejsFunction`) find their project root by searching upwards from the + // app for a lock file, and bundle-mount that directory into Docker; without a lock + // file here the search escapes the test directory and synth fails. + fs.copyFileSync( + path.join(sharedNodeModules, '..', 'package-lock.json'), + path.join(fixture.integTestDir, 'package-lock.json'), + ); +} + +/** + * Mutex pool guarding the shared installs, created on first use. + * + * Constructing a pool starts an `fs.watch`, so don't do it for test runs that + * never install anything. + */ +let installMutexPool: XpMutexPool | undefined; + +/** + * Install the given package set into a machine-shared directory, once. + * + * Concurrent callers (jest workers are separate processes) coordinate through a + * cross-process mutex: whoever holds it installs, and everyone else waits and then + * finds the completion marker already there. A worker that dies while installing + * holds a lock nobody would ever release, so `XpMutex` reclaims it once the owning + * pid is gone. + * + * The shared directory is keyed on the requested package set. Those versions are + * always fully resolved by the time they get here (see `requestedVersion()` on the + * library sources), so the key identifies the contents and the directory can be + * reused across runs on the same machine. + * + * @returns the path of the installed `node_modules` directory. + */ +async function sharedPackageSetInstall(fixture: TestFixture, packages: Record): Promise { + const hash = crypto.createHash('sha256').update(JSON.stringify(packages)).digest('hex').slice(0, 16); + const sharedDir = path.join(os.tmpdir(), `cdk-integ-shared-${hash}`); + const nodeModules = path.join(sharedDir, 'node_modules'); + + // Only ever written after a successful install, so a half-installed directory + // (from a worker that was killed) is never handed out. + const completeMarker = path.join(sharedDir, '.install-complete'); + + if (fs.existsSync(completeMarker)) { + return nodeModules; + } + + if (!installMutexPool) { + installMutexPool = XpMutexPool.fromName('cdk-integ-shared-install'); + } + const lock = await installMutexPool.mutex(hash).acquire(); + try { + if (fs.existsSync(completeMarker)) { + return nodeModules; + } + + fixture.log(`Installing shared package set into '${sharedDir}'`); + fs.mkdirSync(sharedDir, { recursive: true }); + fs.copyFileSync(path.join(fixture.integTestDir, 'package.json'), path.join(sharedDir, 'package.json')); + await npmInstallWithRetry(fixture, sharedDir); + fs.writeFileSync(completeMarker, ''); + return nodeModules; + } finally { + await lock.release(); + } +} + +async function npmInstallWithRetry(fixture: TestFixture, cwd: string) { // we often ECONNRESET from NPM so lets retry. this might be because of high concurrency // which overwhelmes system resources. const timeoutMinutes = 10; @@ -1054,7 +1175,10 @@ export async function installNpmPackages(fixture: TestFixture, packages: Record< while (true) { try { // Now install that `package.json` using NPM7 - await fixture.shell(['node', require.resolve('npm'), 'install']); + await shell(['node', require.resolve('npm'), 'install'], { + cwd, + outputs: [fixture.output], + }); break; } catch (e: any) { if (Date.now() < timeoutDate.getTime() && fixture.output.toString().includes('ECONNRESET' )) { diff --git a/packages/@aws-cdk-testing/cli-integ/lib/xpmutex.ts b/packages/@aws-cdk-testing/cli-integ/lib/xpmutex.ts index 372395272..2787c1010 100644 --- a/packages/@aws-cdk-testing/cli-integ/lib/xpmutex.ts +++ b/packages/@aws-cdk-testing/cli-integ/lib/xpmutex.ts @@ -2,6 +2,30 @@ import { watch, promises as fs, mkdirSync } from 'fs'; import * as os from 'os'; import * as path from 'path'; +/** + * Error codes that mean "the lock file is currently held or in the middle of a + * transition", i.e. we could not create it right now and should back off. + * + * On POSIX the only such signal is `EEXIST` (the exclusive create found the + * file already there). On Windows a file that another process still has open, + * or that was just unlinked, enters a "delete pending" state: it lingers in the + * directory but `open()` against it fails with `EPERM`/`EACCES` instead of + * `EEXIST`. Under contention (many workers racing for the same lock) this is a + * routine, transient condition, not a fatal error, so we treat it the same as + * `EEXIST` and retry. + */ +const CONTENDED_CODES = ['EEXIST', 'EPERM', 'EACCES']; + +/** + * Error codes that mean "the lock file is not readable right now", which we + * treat as "it isn't there" and retry. + * + * `ENOENT` is the file being gone; on Windows `EPERM`/`EACCES` additionally + * cover the delete-pending window, where the name still exists but cannot be + * opened for reading. + */ +const UNREADABLE_CODES = ['ENOENT', 'EPERM', 'EACCES']; + export class XpMutexPool { public static fromDirectory(directory: string) { mkdirSync(directory, { recursive: true }); @@ -96,7 +120,9 @@ export class XpMutex { try { return await this.writePidFile('wx'); // Fails if the file already exists } catch (e: any) { - if (e.code !== 'EEXIST') { + // EEXIST: the lock is held. On Windows a delete-pending lock file + // surfaces as EPERM/EACCES instead; treat those the same way and retry. + if (!CONTENDED_CODES.includes(e.code)) { throw e; } } @@ -104,7 +130,9 @@ export class XpMutex { // File already exists. Read the contents, see if it's an existent PID (if so, the lock is taken) const ownerPid = await this.readPidFile(); if (ownerPid === undefined) { - // File got deleted just now, maybe we can acquire it again + // File got deleted just now (or is mid-transition on Windows). Pause + // briefly so we don't spin on a delete-pending file, then try again. + await randomSleep(10); continue; } if (processExists(ownerPid)) { @@ -164,7 +192,9 @@ export class XpMutex { try { contents = await fs.readFile(this.fileName, { encoding: 'utf-8' }); } catch (e: any) { - if (e.code === 'ENOENT') { + // ENOENT: the file is gone. On Windows a delete-pending file is still + // named but unreadable (EPERM/EACCES); treat it as gone and retry. + if (UNREADABLE_CODES.includes(e.code)) { return undefined; } throw e; diff --git a/packages/@aws-cdk-testing/cli-integ/test/integ-test.test.ts b/packages/@aws-cdk-testing/cli-integ/test/integ-test.test.ts new file mode 100644 index 000000000..7369ec7c7 --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/test/integ-test.test.ts @@ -0,0 +1,64 @@ +import { promises as fs } from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { atomicWrite } from '../lib/integ-test'; + +let dir: string; + +beforeEach(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'atomic-write-test-')); +}); + +afterEach(async () => { + await fs.rm(dir, { recursive: true, force: true }); +}); + +test('atomicWrite writes the file contents', async () => { + const target = path.join(dir, 'out.txt'); + await atomicWrite(target, 'hello'); + expect(await fs.readFile(target, 'utf-8')).toBe('hello'); +}); + +test('atomicWrite retries a Windows-style EPERM on rename and still writes the file', async () => { + // On Windows, renaming onto a destination another worker has open fails with + // EPERM. When several workers rewrite the same shared log file concurrently + // this is transient, so atomicWrite must retry rather than propagate it (which + // is what failed the migrate test on the Windows integ runner). + const target = path.join(dir, 'shared.md'); + + const realRename = fs.rename.bind(fs); + let epermInjected = 0; + const spy = jest.spyOn(fs, 'rename').mockImplementation((async (...args: any[]) => { + // Fail the first rename attempt once, as Windows would under contention. + if (epermInjected < 1) { + epermInjected++; + const e: any = new Error('EPERM: operation not permitted, rename'); + e.code = 'EPERM'; + throw e; + } + return realRename(...(args as Parameters)); + }) as unknown as typeof fs.rename); + + try { + await atomicWrite(target, 'body'); // would throw before the fix + expect(epermInjected).toBe(1); + expect(await fs.readFile(target, 'utf-8')).toBe('body'); + } finally { + spy.mockRestore(); + } +}); + +test('atomicWrite rethrows a non-retryable error', async () => { + const target = path.join(dir, 'nope.txt'); + const spy = jest.spyOn(fs, 'rename').mockImplementation((async () => { + const e: any = new Error('ENOSPC: no space left on device'); + e.code = 'ENOSPC'; + throw e; + }) as unknown as typeof fs.rename); + + try { + await expect(atomicWrite(target, 'x')).rejects.toThrow('ENOSPC'); + } finally { + spy.mockRestore(); + } +}); diff --git a/packages/@aws-cdk-testing/cli-integ/test/xpmutex.test.ts b/packages/@aws-cdk-testing/cli-integ/test/xpmutex.test.ts index 73e0d9140..7d10f49a9 100644 --- a/packages/@aws-cdk-testing/cli-integ/test/xpmutex.test.ts +++ b/packages/@aws-cdk-testing/cli-integ/test/xpmutex.test.ts @@ -1,3 +1,4 @@ +import { promises as fs } from 'fs'; import { XpMutexPool } from '../lib/xpmutex'; const POOL = XpMutexPool.fromName('test-pool'); @@ -30,6 +31,36 @@ test('acquire waits', async () => { await secondProcess; }); +test('a Windows delete-pending EPERM on create is treated as contention, not a fatal error', async () => { + // On Windows, creating the lock file can transiently fail with EPERM while a + // just-unlinked file is in "delete pending" state. The mutex must swallow + // that and retry rather than throwing it up to the caller (which is what + // took down the shared-install lock on the Windows integ runner). + const mux = POOL.mutex('windowsEperm'); + + const realOpen = fs.open.bind(fs); + let epermInjected = 0; + const spy = jest.spyOn(fs, 'open').mockImplementation((async (...args: any[]) => { + // Fail the first exclusive-create attempt exactly once, as Windows would. + if (args[1] === 'wx' && epermInjected < 1) { + epermInjected++; + const e: any = new Error("EPERM: operation not permitted, open ''"); + e.code = 'EPERM'; + throw e; + } + return realOpen(...(args as Parameters)); + }) as unknown as typeof fs.open); + + try { + // Would reject with EPERM before the fix; now it retries and succeeds. + const lock = await mux.acquire(); + expect(epermInjected).toBe(1); + await lock.release(); + } finally { + spy.mockRestore(); + } +}); + /** * Poll for some condition every 10ms */ diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/deploy/cdk-generating-and-loading-assembly.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/deploy/cdk-generating-and-loading-assembly.integtest.ts index b4106c500..893afa16f 100644 --- a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/deploy/cdk-generating-and-loading-assembly.integtest.ts +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/deploy/cdk-generating-and-loading-assembly.integtest.ts @@ -7,14 +7,14 @@ integTest( 'generating and loading assembly', withDefaultFixture(async (fixture) => { const asmOutputDir = `${fixture.integTestDir}-cdk-integ-asm`; - await fixture.shell(['rm', '-rf', asmOutputDir]); + await fs.rm(asmOutputDir, { recursive: true, force: true }); // Synthesize a Cloud Assembly tothe default directory (cdk.out) and a specific directory. await fixture.cdk(['synth']); await fixture.cdk(['synth', '--output', asmOutputDir]); // cdk.out in the current directory and the indicated --output should be the same - await fixture.shell(['diff', 'cdk.out', asmOutputDir]); + await assertDirsEqual(path.join(fixture.integTestDir, 'cdk.out'), asmOutputDir); // Check that we can 'ls' the synthesized asm. // Change to some random directory to make sure we're not accidentally loading cdk.json @@ -48,3 +48,28 @@ integTest( }), ); +/** + * Assert that two directories have the same files with the same contents (like `diff -r`) + */ +async function assertDirsEqual(dirA: string, dirB: string) { + const filesA = await relativeFiles(dirA); + const filesB = await relativeFiles(dirB); + expect(filesB).toEqual(filesA); + + for (const file of filesA) { + const contentsA = await fs.readFile(path.join(dirA, file), 'utf-8'); + const contentsB = await fs.readFile(path.join(dirB, file), 'utf-8'); + if (contentsA !== contentsB) { + throw new Error(`File ${file} differs between ${dirA} and ${dirB}`); + } + } +} + +async function relativeFiles(root: string): Promise { + const entries = await fs.readdir(root, { recursive: true, withFileTypes: true }); + return entries + .filter((e) => e.isFile()) + .map((e) => path.join(path.relative(root, e.parentPath), e.name)) + .sort(); +} + diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/synth/cdk-templates-on-disk-contain-metadata-resource.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/synth/cdk-templates-on-disk-contain-metadata-resource.integtest.ts index 8587a51ab..082d1db5f 100644 --- a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/synth/cdk-templates-on-disk-contain-metadata-resource.integtest.ts +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/synth/cdk-templates-on-disk-contain-metadata-resource.integtest.ts @@ -1,3 +1,5 @@ +import { promises as fs } from 'fs'; +import * as path from 'path'; import { integTest, withDefaultFixture } from '../../../lib'; integTest( @@ -7,17 +9,34 @@ integTest( await fixture.cdk(['synth', '--version-reporting=true']); // Load template from disk from root assembly - const templateContents = await fixture.shell(['cat', 'cdk.out/*-lambda.template.json']); + const templateContents = await readMatchingFile(path.join(fixture.integTestDir, 'cdk.out'), /^[^\\/]*-lambda\.template\.json$/); expect(JSON.parse(templateContents).Resources.CDKMetadata).toBeTruthy(); - // Load template from nested assembly - const nestedTemplateContents = await fixture.shell([ - 'cat', - 'cdk.out/assembly-*-stage/*StackInStage*.template.json', - ]); + // Load template from nested assembly (multiple stage assemblies exist; find the one holding StackInStage) + const nestedTemplate = await findMatchingFile( + path.join(fixture.integTestDir, 'cdk.out'), + /^assembly-.*-stage[\\/].*StackInStage.*\.template\.json$/, + ); + const nestedTemplateContents = await fs.readFile(nestedTemplate, 'utf-8'); expect(JSON.parse(nestedTemplateContents).Resources.CDKMetadata).toBeTruthy(); }), ); +/** + * Find a file whose path relative to `root` matches `pattern`, searching recursively (like a shell glob) + */ +async function findMatchingFile(root: string, pattern: RegExp): Promise { + const entries = await fs.readdir(root, { recursive: true, withFileTypes: true }); + const match = entries.find((e) => e.isFile() && pattern.test(path.join(path.relative(root, e.parentPath), e.name))); + if (!match) { + throw new Error(`No file matching ${pattern} found in ${root}`); + } + return path.join(match.parentPath, match.name); +} + +async function readMatchingFile(root: string, pattern: RegExp): Promise { + return fs.readFile(await findMatchingFile(root, pattern), 'utf-8'); +} + diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/watch/cdk-watch-detects-file-changes-with-directory-scoped-glob.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/watch/cdk-watch-detects-file-changes-with-directory-scoped-glob.integtest.ts index 62f2d6303..a3d226ede 100644 --- a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/watch/cdk-watch-detects-file-changes-with-directory-scoped-glob.integtest.ts +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/watch/cdk-watch-detects-file-changes-with-directory-scoped-glob.integtest.ts @@ -1,7 +1,6 @@ -import * as child_process from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; -import { waitForOutput, waitForCondition, safeKillProcess } from './watch-helpers'; +import { waitForOutput, waitForCondition, safeKillProcess, spawnWatch } from './watch-helpers'; import { integTest, withDefaultFixture } from '../../../lib'; jest.setTimeout(5 * 60 * 1000); // 5 minutes for watch tests @@ -34,11 +33,10 @@ integTest( let output = ''; // Start cdk watch - const watchProcess = child_process.spawn('cdk', [ + const watchProcess = spawnWatch([ 'watch', '--hotswap', '-v', fixture.fullStackName('test-1'), ], { cwd: fixture.integTestDir, - stdio: 'pipe', env: { ...process.env, ...fixture.cdkShellEnv() }, }); diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/watch/cdk-watch-detects-file-changes-with-glob-patterns-negative.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/watch/cdk-watch-detects-file-changes-with-glob-patterns-negative.integtest.ts index 1dcab2a1a..a95f23482 100644 --- a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/watch/cdk-watch-detects-file-changes-with-glob-patterns-negative.integtest.ts +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/watch/cdk-watch-detects-file-changes-with-glob-patterns-negative.integtest.ts @@ -1,7 +1,6 @@ -import * as child_process from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; -import { waitForOutput, safeKillProcess } from './watch-helpers'; +import { waitForOutput, safeKillProcess, spawnWatch } from './watch-helpers'; import { integTest, withDefaultFixture, sleep } from '../../../lib'; jest.setTimeout(5 * 60 * 1000); // 5 minutes for watch tests @@ -27,11 +26,10 @@ integTest( let output = ''; // Start cdk watch - const watchProcess = child_process.spawn('cdk', [ + const watchProcess = spawnWatch([ 'watch', '--hotswap', '-v', fixture.fullStackName('test-1'), ], { cwd: fixture.integTestDir, - stdio: 'pipe', env: { ...process.env, ...fixture.cdkShellEnv() }, }); diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/watch/cdk-watch-detects-file-changes-with-glob-patterns.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/watch/cdk-watch-detects-file-changes-with-glob-patterns.integtest.ts index 815a595fa..7b5f92bb8 100644 --- a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/watch/cdk-watch-detects-file-changes-with-glob-patterns.integtest.ts +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/watch/cdk-watch-detects-file-changes-with-glob-patterns.integtest.ts @@ -1,7 +1,6 @@ -import * as child_process from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; -import { waitForOutput, waitForCondition, safeKillProcess } from './watch-helpers'; +import { waitForOutput, waitForCondition, safeKillProcess, spawnWatch } from './watch-helpers'; import { integTest, withDefaultFixture } from '../../../lib'; jest.setTimeout(5 * 60 * 1000); // 5 minutes for watch tests @@ -26,11 +25,10 @@ integTest( let output = ''; // Start cdk watch - const watchProcess = child_process.spawn('cdk', [ + const watchProcess = spawnWatch([ 'watch', '--hotswap', '-v', fixture.fullStackName('test-1'), ], { cwd: fixture.integTestDir, - stdio: 'pipe', env: { ...process.env, ...fixture.cdkShellEnv() }, }); @@ -51,7 +49,8 @@ integTest( fixture.log('✓ Initial deployment completed'); // Update the test file timestamp to trigger a watch event - child_process.spawnSync('touch', [testFile]); + const now = new Date(); + fs.utimesSync(testFile, now, now); await waitForOutput(() => output, 'Detected change to'); fixture.log('✓ Watch detected file change'); diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/watch/watch-helpers.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/watch/watch-helpers.ts index bbe2a918d..ca983fb96 100644 --- a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/watch/watch-helpers.ts +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/watch/watch-helpers.ts @@ -1,4 +1,5 @@ -import type { ChildProcess } from 'node:child_process'; +import * as child_process from 'node:child_process'; +import type { ChildProcess, SpawnOptions } from 'node:child_process'; const DEFAULT_POLL_TIMEOUT = 120_000; // 2 minutes @@ -33,12 +34,32 @@ export async function waitForCondition(condition: () => boolean): Promise expect(condition()).toBe(true); } +/** + * Spawn a long-running `cdk watch` process. + * + * On Windows the CLI is an npm .cmd shim, which `spawn` can only start + * through a shell ('spawn cdk ENOENT' otherwise). + */ +export function spawnWatch(args: string[], options: SpawnOptions): ChildProcess { + return child_process.spawn('cdk', args, { + stdio: 'pipe', + shell: process.platform === 'win32', + ...options, + }); +} + /** * Kill a spawned process. */ export function safeKillProcess(proc: ChildProcess): void { try { - proc.kill('SIGKILL'); + if (process.platform === 'win32' && proc.pid !== undefined) { + // Kill the whole tree: the process was spawned through a shell, + // so proc.pid is the shell and 'cdk watch' is its child. + child_process.spawnSync('taskkill', ['/pid', proc.pid.toString(), '/T', '/F']); + } else { + proc.kill('SIGKILL'); + } } catch { // process may have already exited } diff --git a/packages/@aws-cdk-testing/cli-integ/tests/init-csharp/init-csharp.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/init-csharp/init-csharp.integtest.ts index 98fc4da23..617b2c3f2 100644 --- a/packages/@aws-cdk-testing/cli-integ/tests/init-csharp/init-csharp.integtest.ts +++ b/packages/@aws-cdk-testing/cli-integ/tests/init-csharp/init-csharp.integtest.ts @@ -10,6 +10,6 @@ import { integTest, withTemporaryDirectory, ShellHelper, withPackages } from '.. await shell.shell(['cdk', 'init', '--lib-version', context.library.requestedVersion(), '-l', 'csharp', template]); await context.library.initializeDotnetPackages(context.integTestDir); await shell.shell(['cdk', 'synth']); - }))); + })), 180_000); }); diff --git a/packages/@aws-cdk-testing/cli-integ/tests/init-fsharp/init-fsharp.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/init-fsharp/init-fsharp.integtest.ts index b53b28a91..81ac0d32c 100644 --- a/packages/@aws-cdk-testing/cli-integ/tests/init-fsharp/init-fsharp.integtest.ts +++ b/packages/@aws-cdk-testing/cli-integ/tests/init-fsharp/init-fsharp.integtest.ts @@ -10,6 +10,6 @@ import { integTest, withTemporaryDirectory, ShellHelper, withPackages } from '.. await shell.shell(['cdk', 'init', '--lib-version', context.library.requestedVersion(), '-l', 'fsharp', template]); await context.library.initializeDotnetPackages(context.integTestDir); await shell.shell(['cdk', 'synth']); - }))); + })), 240_000); }); diff --git a/packages/@aws-cdk-testing/cli-integ/tests/init-go/init-go.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/init-go/init-go.integtest.ts index cd256f723..8890b481e 100644 --- a/packages/@aws-cdk-testing/cli-integ/tests/init-go/init-go.integtest.ts +++ b/packages/@aws-cdk-testing/cli-integ/tests/init-go/init-go.integtest.ts @@ -25,5 +25,5 @@ import { integTest, withTemporaryDirectory, ShellHelper, withPackages } from '.. await shell.shell(['go', 'test']); await shell.shell(['cdk', 'synth']); - }))); + })), 240_000); }); diff --git a/packages/@aws-cdk-testing/cli-integ/tests/init-java/init-java.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/init-java/init-java.integtest.ts index dbeedda4e..60acc7f42 100644 --- a/packages/@aws-cdk-testing/cli-integ/tests/init-java/init-java.integtest.ts +++ b/packages/@aws-cdk-testing/cli-integ/tests/init-java/init-java.integtest.ts @@ -10,5 +10,5 @@ import { integTest, withTemporaryDirectory, ShellHelper, withPackages } from '.. await shell.shell(['cdk', 'init', '--lib-version', context.library.requestedVersion(), '-l', 'java', template]); await shell.shell(['mvn', 'package']); await shell.shell(['cdk', 'synth']); - }))); + })), 180_000); }); diff --git a/packages/@aws-cdk-testing/cli-integ/tests/init-javascript/init-javascript.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/init-javascript/init-javascript.integtest.ts index 1e01e9767..499fa7e55 100644 --- a/packages/@aws-cdk-testing/cli-integ/tests/init-javascript/init-javascript.integtest.ts +++ b/packages/@aws-cdk-testing/cli-integ/tests/init-javascript/init-javascript.integtest.ts @@ -13,7 +13,7 @@ import { integTest, withTemporaryDirectory, ShellHelper, withPackages } from '.. await shell.shell(['npm', 'run', 'test']); await shell.shell(['cdk', 'synth']); - }))); + })), 180_000); }); integTest('Test importing CDK from ESM', withTemporaryDirectory(withPackages(async (context) => { @@ -55,4 +55,4 @@ new TestjsStack(app, 'TestjsStack'); await fs.writeJson(path.join(context.integTestDir, 'cdk.json'), cdkJson); await shell.shell(['cdk', 'synth']); -}))); +})), 180_000); diff --git a/packages/@aws-cdk-testing/cli-integ/tests/init-python/init-python.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/init-python/init-python.integtest.ts index 4e4a89b22..61807c144 100644 --- a/packages/@aws-cdk-testing/cli-integ/tests/init-python/init-python.integtest.ts +++ b/packages/@aws-cdk-testing/cli-integ/tests/init-python/init-python.integtest.ts @@ -10,11 +10,13 @@ import { integTest, withTemporaryDirectory, ShellHelper, withPackages } from '.. await shell.shell(['cdk', 'init', '--lib-version', context.library.requestedVersion(), '-l', 'python', template]); const venvPath = path.resolve(context.integTestDir, '.venv'); - const venv = { PATH: `${venvPath}/bin:${process.env.PATH}`, VIRTUAL_ENV: venvPath }; + // Virtualenvs put binaries in 'Scripts' on Windows and 'bin' elsewhere + const venvBin = path.join(venvPath, process.platform === 'win32' ? 'Scripts' : 'bin'); + const venv = { PATH: `${venvBin}${path.delimiter}${process.env.PATH}`, VIRTUAL_ENV: venvPath }; - await shell.shell([`${venvPath}/bin/pip`, 'install', '-r', 'requirements.txt'], { modEnv: venv }); - await shell.shell([`${venvPath}/bin/pip`, 'install', '-r', 'requirements-dev.txt'], { modEnv: venv }); - await shell.shell([`${venvPath}/bin/pytest`], { modEnv: venv }); + await shell.shell([path.join(venvBin, 'pip'), 'install', '-r', 'requirements.txt'], { modEnv: venv }); + await shell.shell([path.join(venvBin, 'pip'), 'install', '-r', 'requirements-dev.txt'], { modEnv: venv }); + await shell.shell([path.join(venvBin, 'pytest')], { modEnv: venv }); await shell.shell(['cdk', 'synth'], { modEnv: venv }); - }))); + })), 240_000); }); diff --git a/packages/@aws-cdk-testing/cli-integ/tests/init-typescript-app/init-typescript-app.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/init-typescript-app/init-typescript-app.integtest.ts index 83025e56f..5f8796336 100644 --- a/packages/@aws-cdk-testing/cli-integ/tests/init-typescript-app/init-typescript-app.integtest.ts +++ b/packages/@aws-cdk-testing/cli-integ/tests/init-typescript-app/init-typescript-app.integtest.ts @@ -19,7 +19,7 @@ import { typescriptVersionsSync, typescriptVersionsYoungerThanDaysSync } from '. await shell.shell(['npm', 'run', 'test']); await shell.shell(['cdk', 'synth']); - })), 300_000); + })), 600_000); }); // Same as https://github.com/DefinitelyTyped/DefinitelyTyped?tab=readme-ov-file#support-window @@ -55,11 +55,11 @@ TYPESCRIPT_VERSIONS.forEach(tsVersion => { await shell.shell(['npm', 'ls']); // this will fail if we have unmet peer dependencies // We just removed the 'jest' dependency so remove the tests as well because they won't compile - await shell.shell(['rm', '-rf', 'test/']); + await fs.rm(path.join(context.integTestDir, 'test'), { recursive: true, force: true }); await shell.shell(['npm', 'run', 'build']); await shell.shell(['cdk', 'synth']); - }))); + })), 300_000); }); async function removeDevDependencies(context: TemporaryDirectoryContext) { diff --git a/packages/@aws-cdk-testing/cli-integ/tests/init-typescript-lib/init-typescript-lib.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/init-typescript-lib/init-typescript-lib.integtest.ts index 57d7adfdf..2f73b06ed 100644 --- a/packages/@aws-cdk-testing/cli-integ/tests/init-typescript-lib/init-typescript-lib.integtest.ts +++ b/packages/@aws-cdk-testing/cli-integ/tests/init-typescript-lib/init-typescript-lib.integtest.ts @@ -10,4 +10,4 @@ integTest('typescript init lib', withTemporaryDirectory(withPackages(async (cont await shell.shell(['npm', 'ls']); // this will fail if we have unmet peer dependencies await shell.shell(['npm', 'run', 'build']); await shell.shell(['npm', 'run', 'test']); -}))); +})), 300_000); diff --git a/packages/@aws-cdk-testing/cli-integ/tests/init-typescript-lib/use-lib-as-bundled-dependency.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/init-typescript-lib/use-lib-as-bundled-dependency.integtest.ts index c757fa7e7..9b22aab91 100644 --- a/packages/@aws-cdk-testing/cli-integ/tests/init-typescript-lib/use-lib-as-bundled-dependency.integtest.ts +++ b/packages/@aws-cdk-testing/cli-integ/tests/init-typescript-lib/use-lib-as-bundled-dependency.integtest.ts @@ -22,4 +22,4 @@ integTest('using aws-cdk-lib as a bundled dependency', withTemporaryDirectory(wi await fs.writeFile(packageJsonPath, JSON.stringify(packageJson, undefined, 2), 'utf-8'); await shell.shell(['npm', 'install']); -}))); +})), 300_000); diff --git a/projenrc/cdk-cli-integ-tests.ts b/projenrc/cdk-cli-integ-tests.ts index 3f670e331..d01087f7a 100644 --- a/projenrc/cdk-cli-integ-tests.ts +++ b/projenrc/cdk-cli-integ-tests.ts @@ -12,6 +12,75 @@ export function fixupTestTask(project: Project, taskName = 'test'): void { const NOT_FLAGGED_EXPR = "!contains(github.event.pull_request.labels.*.name, 'pr/exempt-integ-test')"; +/** + * Label that opts a pull request into the Windows integ suites. + * + * Apply it to a PR touching platform-sensitive code (paths, subprocess + * spawning, shell quoting); a failure then blocks the PR like any other integ + * failure. + */ +const WINDOWS_LABEL = 'pr/test-windows'; + +/** + * Label applied to the issue filed when the nightly Windows run fails. + * + * This is the repository's existing regression label, which is already wired up + * to page the team. `issue-regression-labeler` also manages it, so it is + * guaranteed to exist in the repository's label set. + */ +const REGRESSION_LABEL = 'potential-regression'; + +/** The nightly (schedule) event. */ +const IS_SCHEDULE = "github.event_name == 'schedule'"; + +/** + * Windows runs on the nightly, on a manual dispatch, or on a PR that opted in + * via label. + * + * `workflow_dispatch` is the only one of the three reachable from a branch (a + * schedule fires only on the default branch, and `pull_request_target` reads + * the workflow from the base branch), so it is what makes these jobs testable + * before they merge. + */ +const WINDOWS_REQUESTED_EXPR = [ + IS_SCHEDULE, + "github.event_name == 'workflow_dispatch'", + `contains(github.event.pull_request.labels.*.name, '${WINDOWS_LABEL}')`, +].join(' || '); + +/** + * Tests that build or run Linux Docker images. + * + * GitHub-hosted Windows runners run Docker in Windows-containers mode and + * cannot pull or build Linux images ('no matching manifest for windows/amd64'), + * so these tests are skipped on Windows. + */ +const DOCKER_TESTS_SKIPPED_ON_WINDOWS = [ + 'deploy same docker asset to multiple regions', + 'deploy same docker asset to multiple stacks', + 'deploy stack with multiple docker assets', + 'deploy stack with docker asset', + 'cdk-assets smoke test', + 'deploy new style synthesis to new style bootstrap (with docker image)', + 'Garbage Collection untags in-use ecr images', + 'Garbage Collection keeps in use ecr images', + 'Garbage Collection deletes unused ecr images', + 'Garbage Collection tags unused ecr images', + 'all calls from isolated container go through proxy', + 'docker-credential-cdk-assets can assume role and fetch ECR credentials', + 'toolkit deploy stack with multiple docker assets', + // These do not have 'docker' in the name, but build Linux images as a side + // effect: python lambda bundling, SAM asset bundling, a DockerImageAsset in + // the fixture stack, and a docker-app deploy from a copied assembly. + 'CDK synth bundled functions as expected', + 'CDK synth add the metadata properties expected by sam', + 'can deploy with session tags on the deploy, lookup, file asset, and image asset publishing roles', + 'generating and loading assembly', + 'test resource import with construct that requires bundling', + 'hotswap deployment supports Bedrock AgentCore Runtime', + 'sam can locally test the synthesized cdk application', +]; + function setupNodeStep(nodeVersion: string): github.workflows.JobStep { return { name: 'Setup Node.js', @@ -130,6 +199,17 @@ export interface CdkCliIntegTestsWorkflowProps { */ readonly testRunsOn: string; + /** + * If given, additionally run every integ test matrix job on this Windows + * runner (in addition to the `testRunsOn` runner). + * + * The Windows jobs are suffixed with `_windows` and run all steps under Git + * Bash so the shared bash step scripts keep working. + * + * @default - integ tests only run on `testRunsOn` + */ + readonly windowsTestRunsOn?: string; + /** * GitHub environment name for approvals * @@ -289,11 +369,33 @@ export class CdkCliIntegTestsWorkflow extends Component { committed: false, lines: [ '#!/bin/bash', - 'npm install -g verdaccio pm2', + // Verdaccio was installed once in the 'prepare' job and shipped here + // as a tarball; extracting it is much faster than an npm install, + // especially on Windows. No process manager: Verdaccio only has to + // outlive this job, and the runner kills leftover processes at job + // teardown. + // + // Fallback: if the tarball is not present (e.g. when pull_request_target + // uses the base branch workflow which lacks the bundle step), install + // Verdaccio on the fly. Slower, but keeps the run working. + 'mkdir -p $HOME/verdaccio-app', + 'if [ -f .projen/verdaccio-bundle.tgz ]; then', + ' tar xzf .projen/verdaccio-bundle.tgz -C $HOME/verdaccio-app', + 'else', + ' npm install --prefix $HOME/verdaccio-app --no-bin-links --no-audit --no-fund --loglevel=error verdaccio@6.8', + 'fi', 'mkdir -p $HOME/.config/verdaccio', `echo '${JSON.stringify(verdaccioConfig)}' > $HOME/.config/verdaccio/config.yaml`, - 'pm2 start verdaccio -- --config $HOME/.config/verdaccio/config.yaml', - 'sleep 5', // Wait for Verdaccio to start + // Point at Verdaccio's JS entrypoint; bin shims were not created + // (--no-bin-links) and would not be bash-spawnable on Windows anyway. + 'VERDACCIO_BIN="$HOME/verdaccio-app/node_modules/verdaccio/bin/verdaccio"', + 'nohup node "$VERDACCIO_BIN" --config $HOME/.config/verdaccio/config.yaml > verdaccio.log 2>&1 &', + // Wait for Verdaccio to accept requests instead of sleeping a fixed time + 'for i in $(seq 1 60); do', + ' if curl -fsS -o /dev/null http://localhost:4873/; then break; fi', + ' if [ $i -eq 60 ]; then echo "Verdaccio did not start:"; cat verdaccio.log; exit 1; fi', + ' sleep 1', + 'done', // Configure NPM to use local registry 'echo \'//localhost:4873/:_authToken="MWRjNDU3OTE1NTljYWUyOTFkMWJkOGUyYTIwZWMwNTI6YTgwZjkyNDE0NzgwYWQzNQ=="\' > ~/.npmrc', 'echo \'registry=http://localhost:4873/\' >> ~/.npmrc', @@ -307,11 +409,18 @@ export class CdkCliIntegTestsWorkflow extends Component { this.workflow.on({ pullRequestTarget: { branches: [], + // 'labeled'/'unlabeled' are not in GitHub's default set, and without + // them the Windows opt-in label would not take effect (or stop taking + // effect) until the next push. + types: ['opened', 'synchronize', 'reopened', 'labeled', 'unlabeled'], }, // Needs to trigger and report success on merge queue builds as well mergeGroup: {}, // Never hurts to be able to run this manually workflowDispatch: {}, + // Nightly Windows run: too slow to gate every PR on, so it runs + // unattended here and reports failures by filing an issue. + schedule: [{ cron: '0 6 * * *' }], }); // Determine the environment dynamically: PRs from the same repo and merge_group // events skip the approval environment, while external PRs require approval. @@ -333,8 +442,10 @@ export class CdkCliIntegTestsWorkflow extends Component { run: `echo ${this.props.approvalEnvironment} > .envname`, }, { - name: 'Skip approval for mergeGroup or PR created from this repo', - if: "${{ github.event_name == 'merge_group' || github.event.pull_request.head.repo.full_name == github.repository }}", + // The nightly is included because there is nobody waiting to approve + // it; without this it would hang. + name: 'Skip approval for mergeGroup, schedule, or PR created from this repo', + if: `\${{ github.event_name == 'merge_group' || ${IS_SCHEDULE} || github.event.pull_request.head.repo.full_name == github.repository }}`, run: 'echo no-approval > .envname', }, { @@ -388,8 +499,11 @@ export class CdkCliIntegTestsWorkflow extends Component { with: { // IMPORTANT! This must be `head.sha` not `head.ref`, otherwise we // are vulnerable to a TOCTOU attack. - 'ref': '${{ github.event.pull_request.head.sha }}', - 'repository': '${{ github.event.pull_request.head.repo.full_name }}', + // + // The fallbacks cover events with no pull request attached + // (schedule, workflow_dispatch), and resolve to the default branch. + 'ref': '${{ github.event.pull_request.head.sha || github.sha }}', + 'repository': '${{ github.event.pull_request.head.repo.full_name || github.repository }}', // Need to allow forks, the workflow has been reviewed and getting OIDC credentials is the point // Other credentials are environment protected // @see https://docs.github.com/en/actions/reference/security/securely-using-pull_request_target @@ -426,6 +540,26 @@ export class CdkCliIntegTestsWorkflow extends Component { RELEASE: 'true', }, }, + { + // Install Verdaccio once here and ship it to the test jobs as a + // tarball. Installing it in every job through npm costs ~60s on + // Windows runners (thousands of small file writes); extracting a + // single archive is much faster. Verdaccio has no native or + // platform-specific dependencies, so a Linux-built tree runs + // anywhere; --no-bin-links keeps symlinks out of the archive + // (jobs invoke the JS entrypoint directly). + name: 'Bundle Verdaccio for the test jobs', + run: [ + 'mkdir -p /tmp/verdaccio-bundle', + // The bundle is built once but runs under every Node version in + // the test matrix, so Verdaccio's engine range must include the + // oldest of them: 6.9 requires Node >= 22, 6.8 still allows 20. + // (A per-job npm install used to hide this by resolving an + // engines-compatible version for each job's own Node.) + '(cd /tmp/verdaccio-bundle && npm install --no-bin-links --no-audit --no-fund --loglevel=error verdaccio@6.8)', + 'tar czf .projen/verdaccio-bundle.tgz -C /tmp/verdaccio-bundle node_modules', + ].join('\n'), + }, github.WorkflowSteps.uploadArtifact({ id: 'build-artifact', with: { @@ -438,7 +572,10 @@ export class CdkCliIntegTestsWorkflow extends Component { id: 'script-artifact', with: { name: 'script-artifact', - path: '.projen/*.sh', + path: [ + '.projen/*.sh', + '.projen/verdaccio-bundle.tgz', + ].join('\n'), overwrite: true, includeHiddenFiles: true, }, @@ -449,36 +586,37 @@ export class CdkCliIntegTestsWorkflow extends Component { // Ensure this is an array const additionalNodeVersionsToTest = this.props.additionalNodeVersionsToTest ?? []; - const testJobs = [ + // The integ test suites, defined once and instantiated per platform. + const suites: Array<[string, MatrixIntegTestProps]> = [ // cli-integ-tests - this.addMatrixJob('cli', { + ['cli', { domain: { suite: ['cli-integ-tests'], shards: 12, }, - }), + }], // toolkit-lib - this.addMatrixJob('toolkit-lib', { + ['toolkit-lib', { domain: { suite: [ 'toolkit-lib-integ-tests', ], node: ['lts/*', ...additionalNodeVersionsToTest], }, - }), + }], // telemetry - this.addMatrixJob('telemetry', { + ['telemetry', { domain: { suite: [ 'telemetry-integ-tests', ], }, - }), + }], // init-templates - this.addMatrixJob('init-templates', { + ['init-templates', { domain: { suite: [ 'init-csharp', @@ -497,17 +635,37 @@ export class CdkCliIntegTestsWorkflow extends Component { suite: 'init-typescript-app', node, })), - }), + }], // We are finding that Amplify works on Node 20, but fails on Node >=22.10. Remove the 'lts/*' test and use a Node 20 for now. - this.addMatrixJob('tool-integrations', { + ['tool-integrations', { domain: { suite: ['tool-integrations'], node: ['20'], }, - }), + }], ]; + const linuxJobs = suites.map(([name, jobProps]) => this.addMatrixJob(name, jobProps, { + runsOn: this.props.testRunsOn, + // The nightly exists to cover Windows; Linux already runs on every PR, so + // repeating it there would consume Atmosphere environments for no signal. + extraCondition: "github.event_name != 'schedule'", + })); + + const windowsJobs = this.props.windowsTestRunsOn + ? suites.map(([name, jobProps]) => this.addMatrixJob(name, jobProps, { + runsOn: this.props.windowsTestRunsOn!, + suffix: '_windows', + windows: true, + // Only on the nightly, a manual dispatch, or when a PR opts in by label. + extraCondition: `(${WINDOWS_REQUESTED_EXPR})`, + timeoutMinutes: 90, + })) + : []; + + const testJobs = [...linuxJobs, ...windowsJobs]; + // Add a job that collates all matrix jobs into a single status // This is required so that we can setup required status checks // and if we ever change the test matrix, we don't need to update @@ -530,14 +688,74 @@ export class CdkCliIntegTestsWorkflow extends Component { }, ], }); + + if (windowsJobs.length > 0) { + this.addWindowsFailureReportJob(windowsJobs); + } } - private addMatrixJob(testName: string, props: MatrixIntegTestProps): string { - const jobName = `integ_${testName}`; + /** + * File an issue when the nightly Windows run fails. + * + * Schedule-only. A label-triggered PR run deliberately does not file an + * issue: the failure is already visible as a red check on the PR, and the + * label exists so a contributor can try Windows out, not to page anyone. + */ + private addWindowsFailureReportJob(windowsJobs: string[]): void { + this.workflow.addJob('integ_windows_report_failure', { + runsOn: ['ubuntu-latest'], + needs: windowsJobs, + permissions: { + contents: github.workflows.JobPermission.READ, + issues: github.workflows.JobPermission.WRITE, + }, + if: `\${{ always() && ${IS_SCHEDULE} && contains(needs.*.result, 'failure') }}`, + env: { + // MUST stay the default GITHUB_TOKEN. Issues created with it do not + // trigger other workflow runs, which is what keeps + // `issue-regression-labeler` from firing: that workflow strips + // 'potential-regression' from any issue whose body lacks the regression + // checkbox, and would silently undo the label we set here. Switching + // this to a PAT would stop the page from ever going out. + GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}', + // This job does not check out the repo, so `gh` cannot infer the + // repository from a git remote and needs it passed explicitly. + GH_REPO: '${{ github.repository }}', + // Interpolated here rather than in the `run` body: CheckGhaExpressions + // rejects `github.*` inside shell steps as an injection vector, so the + // step references it as a quoted shell variable instead. + RUN_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}', + // Dependency upgrades merge unattended overnight, so the nightly does + // not necessarily run against the same commit as the night before. + // Record what it did run against. + RUN_SHA: '${{ github.sha }}', + }, + steps: [ + { + name: 'File an issue', + run: [ + 'set -euo pipefail', + '', + 'gh issue create \\', + ' --title \'Windows integ nightly failed\' \\', + ` --label '${REGRESSION_LABEL}' \\`, + ' --body "Nightly Windows integ run failed.', + '', + 'Run: $RUN_URL', + 'Commit: $RUN_SHA"', + ].join('\n'), + }, + ], + }); + } + + private addMatrixJob(testName: string, props: MatrixIntegTestProps, platform: PlatformOptions): string { + const suffix = platform.suffix ?? ''; + const jobName = `integ_${testName}${suffix}`; let shard: any; let shardArg = ''; - let logName = 'logs-${{ matrix.suite }}-${{ matrix.node }}'; + let logName = `logs${suffix}-\${{ matrix.suite }}-\${{ matrix.node }}`; if (props.domain.shards) { shard = Array(props.domain.shards).fill(0).map((_, i) => i + 1); shardArg = ` --shard="\${{ matrix.shard }}/${props.domain.shards}"`; @@ -546,12 +764,19 @@ export class CdkCliIntegTestsWorkflow extends Component { this.workflow.addJob(jobName, { environment: this.props.testEnvironment, - runsOn: [this.props.testRunsOn], + runsOn: [platform.runsOn], needs: [this.JOB_PREPARE], permissions: { contents: github.workflows.JobPermission.READ, idToken: github.workflows.JobPermission.WRITE, }, + // The step scripts are written for bash; on Windows runners use Git Bash + // (preinstalled) so they run unchanged while still exercising Windows. + defaults: { + run: { + shell: 'bash', + }, + }, env: { // Integ tests heavily rely on processing stdout, node warnings (mostly deprecations) are muddying this. // We can disable any warnings here, there's plenty of other places we will see them. @@ -562,12 +787,23 @@ export class CdkCliIntegTestsWorkflow extends Component { // assumptions about the availability of source packages. IS_CANARY: 'true', CI: 'true', + ...platform.windows ? { + // The skip file is newline-separated; the CDK_INTEG_SKIP_TESTS + // environment variable is comma-separated and cannot express + // test names that contain commas. + CDK_INTEG_SKIP_TESTS_FILE: '${{ github.workspace }}\\windows-skip-tests.txt', + } : {}, // add extra env at end so it can override ...props.extraEnv, }, + ...platform.timeoutMinutes ? { timeoutMinutes: platform.timeoutMinutes } : {}, // Don't run again on the merge queue, we already got confirmation that it works and the // tests are quite expensive. - if: `github.event_name != 'merge_group' && ${NOT_FLAGGED_EXPR}`, + if: [ + "github.event_name != 'merge_group'", + NOT_FLAGGED_EXPR, + ...platform.extraCondition ? [platform.extraCondition] : [], + ].join(' && '), strategy: { failFast: false, matrix: { @@ -581,6 +817,33 @@ export class CdkCliIntegTestsWorkflow extends Component { }, }, steps: [ + ...platform.windows ? [{ + // The integ tests are dominated by npm installs and toolchain builds: + // many small file writes, which are slow on the runner's NTFS OS disk. + // A Dev Drive (ReFS VHDX) is much faster for this pattern. Create one + // and point TEMP at it, which is where all test fixtures live + // (the harness creates its working directories under os.tmpdir()). + name: 'Set up Dev Drive for TEMP and npm cache', + shell: 'powershell', + run: [ + '$vhd = "C:\\devdrive.vhdx"', + '$drive = (New-VHD -Path $vhd -SizeBytes 40GB -Dynamic | Mount-VHD -PassThru | Initialize-Disk -PassThru | New-Partition -AssignDriveLetter -UseMaximumSize | Format-Volume -DevDrive -Confirm:$false).DriveLetter', + 'New-Item -ItemType Directory -Path "${drive}:\\temp" | Out-Null', + 'New-Item -ItemType Directory -Path "${drive}:\\npm-cache" | Out-Null', + 'echo "TEMP=${drive}:\\temp" >> $env:GITHUB_ENV', + 'echo "TMP=${drive}:\\temp" >> $env:GITHUB_ENV', + // Every npm invocation in the job (global installs, per-test installs) + // reads and writes the cache, so move it onto the Dev Drive too + 'echo "npm_config_cache=${drive}:\\npm-cache" >> $env:GITHUB_ENV', + ].join('\n'), + }, { + name: 'Write Windows skip-tests file', + run: [ + 'cat > windows-skip-tests.txt << \'EOF\'', + ...DOCKER_TESTS_SKIPPED_ON_WINDOWS, + 'EOF', + ].join('\n'), + }] : [], github.WorkflowSteps.downloadArtifact({ with: { artifactIds: [`\${{needs.${this.JOB_PREPARE}.outputs.packagesArtifact}}`], @@ -689,3 +952,44 @@ interface MatrixIntegTestProps { readonly exclude?: github.workflows.JobMatrix['exclude']; readonly extraEnv?: Record; } + +interface PlatformOptions { + /** + * The runner label to run this instance of the job on. + */ + readonly runsOn: string; + + /** + * Suffix appended to the job name and log artifact names, to disambiguate + * multiple platform instances of the same suite. + * + * @default - no suffix + */ + readonly suffix?: string; + + /** + * Whether this job runs on a Windows runner. + * + * Adds Windows-specific setup steps. + * + * @default false + */ + readonly windows?: boolean; + + /** + * Hard cap on job duration, instead of GitHub's 6 hour default. + * + * Note this does not pre-empt AWS session expiry: Atmosphere credentials last + * 1 hour and are obtained part-way into the job, at a variable offset. + * + * @default - GitHub's default + */ + readonly timeoutMinutes?: number; + + /** + * Additional expression ANDed onto the job's `if` condition. + * + * @default - no additional condition + */ + readonly extraCondition?: string; +}