diff --git a/patches/last_processed_commit.txt b/patches/last_processed_commit.txt index 50b48bf13bb..4376a4d86a0 100644 --- a/patches/last_processed_commit.txt +++ b/patches/last_processed_commit.txt @@ -1 +1 @@ -40dd583defe00b07f45ff976c95b564f7929bf69 +f0c228635e874e39eb53db0bf091c4925e53cbb1 diff --git a/patches/runner-main-sdk8-ppc64le.patch b/patches/runner-main-sdk8-ppc64le.patch index 192c69836d5..fe80d6feba6 100644 --- a/patches/runner-main-sdk8-ppc64le.patch +++ b/patches/runner-main-sdk8-ppc64le.patch @@ -86,7 +86,7 @@ index 14cc6bab..c8ed8b92 100755 if ! [ -x "$(command -v ldconfig)" ]; then diff --git a/src/Runner.Common/Constants.cs b/src/Runner.Common/Constants.cs -index 58395898..b3dc2e83 100644 +index 3326e947..02b3ada4 100644 --- a/src/Runner.Common/Constants.cs +++ b/src/Runner.Common/Constants.cs @@ -59,7 +59,9 @@ namespace GitHub.Runner.Common @@ -216,7 +216,7 @@ index a5a19aea..b2086aa5 100644 NU1701;NU1603;NU1603;xUnit2013;SYSLIB0050;SYSLIB0051 diff --git a/src/dev.sh b/src/dev.sh -index 716fa08e..5f20ca6a 100755 +index df0a4328..76b78740 100755 --- a/src/dev.sh +++ b/src/dev.sh @@ -54,6 +54,8 @@ elif [[ "$CURRENT_PLATFORM" == 'linux' ]]; then @@ -284,4 +284,4 @@ index 056a312e..3f9a3679 100644 -# From upstream commit: 40dd583defe00b07f45ff976c95b564f7929bf69 +# From upstream commit: f0c228635e874e39eb53db0bf091c4925e53cbb1 diff --git a/patches/runner-main-sdk8-s390x.patch b/patches/runner-main-sdk8-s390x.patch index 192c69836d5..fe80d6feba6 100644 --- a/patches/runner-main-sdk8-s390x.patch +++ b/patches/runner-main-sdk8-s390x.patch @@ -86,7 +86,7 @@ index 14cc6bab..c8ed8b92 100755 if ! [ -x "$(command -v ldconfig)" ]; then diff --git a/src/Runner.Common/Constants.cs b/src/Runner.Common/Constants.cs -index 58395898..b3dc2e83 100644 +index 3326e947..02b3ada4 100644 --- a/src/Runner.Common/Constants.cs +++ b/src/Runner.Common/Constants.cs @@ -59,7 +59,9 @@ namespace GitHub.Runner.Common @@ -216,7 +216,7 @@ index a5a19aea..b2086aa5 100644 NU1701;NU1603;NU1603;xUnit2013;SYSLIB0050;SYSLIB0051 diff --git a/src/dev.sh b/src/dev.sh -index 716fa08e..5f20ca6a 100755 +index df0a4328..76b78740 100755 --- a/src/dev.sh +++ b/src/dev.sh @@ -54,6 +54,8 @@ elif [[ "$CURRENT_PLATFORM" == 'linux' ]]; then @@ -284,4 +284,4 @@ index 056a312e..3f9a3679 100644 -# From upstream commit: 40dd583defe00b07f45ff976c95b564f7929bf69 +# From upstream commit: f0c228635e874e39eb53db0bf091c4925e53cbb1 diff --git a/releaseNote.md b/releaseNote.md index b4915dd6146..e8e76b6cf54 100644 --- a/releaseNote.md +++ b/releaseNote.md @@ -1,35 +1,33 @@ ## What's Changed -* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4200 -* Update dotnet sdk to latest version @8.0.417 by @github-actions[bot] in https://github.com/actions/runner/pull/4201 -* Bump System.Formats.Asn1 and System.Security.Cryptography.Pkcs by @dependabot[bot] in https://github.com/actions/runner/pull/4202 -* Allow empty container options by @ericsciple in https://github.com/actions/runner/pull/4208 -* Update Docker to v29.1.5 and Buildx to v0.31.0 by @github-actions[bot] in https://github.com/actions/runner/pull/4212 -* Report job level annotations by @TingluoHuang in https://github.com/actions/runner/pull/4216 -* Fix local action display name showing `Run /./` instead of `Run ./` by @ericsciple in https://github.com/actions/runner/pull/4218 -* Update Docker to v29.2.0 and Buildx to v0.31.1 by @github-actions[bot] in https://github.com/actions/runner/pull/4219 -* Add support for libssl3 and libssl3t64 for newer Debian/Ubuntu versions by @nekketsuuu in https://github.com/actions/runner/pull/4213 -* Validate work dir during runner start up. by @TingluoHuang in https://github.com/actions/runner/pull/4227 -* Bump hook to 0.8.1 by @nikola-jokic in https://github.com/actions/runner/pull/4222 -* Support return job result as exitcode in hosted runner. by @TingluoHuang in https://github.com/actions/runner/pull/4233 -* Add telemetry tracking for deprecated set-output and save-state commands by @ericsciple in https://github.com/actions/runner/pull/4221 -* Fix parser comparison mismatches by @ericsciple in https://github.com/actions/runner/pull/4220 -* Remove unnecessary connection test during some registration flows by @zarenner in https://github.com/actions/runner/pull/4244 -* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4249 -* Update dotnet sdk to latest version @8.0.418 by @github-actions[bot] in https://github.com/actions/runner/pull/4250 -* Fix link to SECURITY.md in README by @TingluoHuang in https://github.com/actions/runner/pull/4253 -* Try to infer runner is on hosted/ghes when githuburl is empty. by @TingluoHuang in https://github.com/actions/runner/pull/4254 -* Add Node.js 20 deprecation warning annotation (Phase 1) by @salmanmkc in https://github.com/actions/runner/pull/4242 -* Update Node.js 20 deprecation date to June 2nd, 2026 by @salmanmkc in https://github.com/actions/runner/pull/4258 -* Composite Action Step Markers by @ericsciple in https://github.com/actions/runner/pull/4243 -* Symlink actions cache by @paveliak in https://github.com/actions/runner/pull/4260 -* Bump minimatch in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4261 -* Bump @stylistic/eslint-plugin from 3.1.0 to 5.9.0 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4257 +* Log inner exception message. by @TingluoHuang in https://github.com/actions/runner/pull/4265 +* Fix composite post-step marker display names by @ericsciple in https://github.com/actions/runner/pull/4267 +* Bump actions/download-artifact from 7 to 8 by @dependabot[bot] in https://github.com/actions/runner/pull/4269 +* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4272 +* Avoid throw in SelfUpdaters. by @TingluoHuang in https://github.com/actions/runner/pull/4274 +* Fix parser comparison mismatches by @ericsciple in https://github.com/actions/runner/pull/4273 +* Devcontainer: bump base image Ubuntu version by @MaxHorstmann in https://github.com/actions/runner/pull/4277 +* Support `entrypoint` and `command` for service containers by @ericsciple in https://github.com/actions/runner/pull/4276 +* Bump actions/upload-artifact from 6 to 7 by @dependabot[bot] in https://github.com/actions/runner/pull/4270 +* Bump docker/login-action from 3 to 4 by @dependabot[bot] in https://github.com/actions/runner/pull/4278 +* Fix positional arg bug in ExpressionParser.CreateTree by @ericsciple in https://github.com/actions/runner/pull/4279 +* Bump docker/build-push-action from 6 to 7 by @dependabot[bot] in https://github.com/actions/runner/pull/4283 +* Bump docker/setup-buildx-action from 3 to 4 by @dependabot[bot] in https://github.com/actions/runner/pull/4282 +* Bump actions/attest-build-provenance from 3 to 4 by @dependabot[bot] in https://github.com/actions/runner/pull/4266 +* Bump @stylistic/eslint-plugin from 5.9.0 to 5.10.0 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4281 +* Update Docker to v29.3.0 and Buildx to v0.32.1 by @github-actions[bot] in https://github.com/actions/runner/pull/4286 +* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4287 +* Fix cancellation token race during parser comparison by @ericsciple in https://github.com/actions/runner/pull/4280 +* Bump @typescript-eslint/eslint-plugin from 8.47.0 to 8.54.0 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4230 +* Exit with specified exit code when runner is outdated by @nikola-jokic in https://github.com/actions/runner/pull/4285 +* Report infra_error for action download failures. by @TingluoHuang in https://github.com/actions/runner/pull/4294 +* Update dotnet sdk to latest version @8.0.419 by @github-actions[bot] in https://github.com/actions/runner/pull/4301 +* Node 24 enforcement + Linux ARM32 deprecation support by @salmanmkc in https://github.com/actions/runner/pull/4303 +* Bump @typescript-eslint/eslint-plugin from 8.54.0 to 8.57.1 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4304 ## New Contributors -* @nekketsuuu made their first contribution in https://github.com/actions/runner/pull/4213 -* @zarenner made their first contribution in https://github.com/actions/runner/pull/4244 +* @MaxHorstmann made their first contribution in https://github.com/actions/runner/pull/4277 -**Full Changelog**: https://github.com/actions/runner/compare/v2.331.0...v2.332.0 +**Full Changelog**: https://github.com/actions/runner/compare/v2.332.0...v2.333.0 _Note: Actions Runner follows a progressive release policy, so the latest release might not be available to your enterprise, organization, or repository yet. To confirm which version of the Actions Runner you should expect, please view the download instructions for your enterprise, organization, or repository. diff --git a/src/Misc/expressionFunc/hashFiles/package-lock.json b/src/Misc/expressionFunc/hashFiles/package-lock.json index 8d141cac50c..d8099aa529e 100644 --- a/src/Misc/expressionFunc/hashFiles/package-lock.json +++ b/src/Misc/expressionFunc/hashFiles/package-lock.json @@ -14,7 +14,7 @@ "devDependencies": { "@stylistic/eslint-plugin": "^5.10.0", "@types/node": "^22.0.0", - "@typescript-eslint/eslint-plugin": "^8.0.0", + "@typescript-eslint/eslint-plugin": "^8.57.2", "@typescript-eslint/parser": "^8.0.0", "@vercel/ncc": "^0.38.3", "eslint": "^8.47.0", @@ -93,9 +93,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", - "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" @@ -247,19 +247,6 @@ "eslint": "^9.0.0 || ^10.0.0" } }, - "node_modules/@stylistic/eslint-plugin/node_modules/@typescript-eslint/types": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.0.tgz", - "integrity": "sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==", - "dev": true, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, "node_modules/@stylistic/eslint-plugin/node_modules/eslint-visitor-keys": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", @@ -321,21 +308,19 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.47.0.tgz", - "integrity": "sha512-fe0rz9WJQ5t2iaLfdbDc9T80GJy0AeO453q8C3YCilnGozvOyCG5t+EZtg7j7D88+c3FipfP/x+wzGnh1xp8ZA==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz", + "integrity": "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==", "dev": true, - "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.47.0", - "@typescript-eslint/type-utils": "8.47.0", - "@typescript-eslint/utils": "8.47.0", - "@typescript-eslint/visitor-keys": "8.47.0", - "graphemer": "^1.4.0", - "ignore": "^7.0.0", + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/type-utils": "8.57.2", + "@typescript-eslint/utils": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.0" + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -345,8 +330,8 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.47.0", - "eslint": "^8.57.0 || ^9.0.0", + "@typescript-eslint/parser": "^8.57.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, @@ -361,11 +346,10 @@ } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", "dev": true, - "license": "MIT", "engines": { "node": ">=18.12" }, @@ -374,17 +358,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.47.0.tgz", - "integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.2.tgz", + "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", "dev": true, - "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.47.0", - "@typescript-eslint/types": "8.47.0", - "@typescript-eslint/typescript-estree": "8.47.0", - "@typescript-eslint/visitor-keys": "8.47.0", - "debug": "^4.3.4" + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -394,20 +377,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.47.0.tgz", - "integrity": "sha512-2X4BX8hUeB5JcA1TQJ7GjcgulXQ+5UkNb0DL8gHsHUHdFoiCTJoYLTpib3LtSDPZsRET5ygN4qqIWrHyYIKERA==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz", + "integrity": "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==", "dev": true, - "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.47.0", - "@typescript-eslint/types": "^8.47.0", - "debug": "^4.3.4" + "@typescript-eslint/tsconfig-utils": "^8.57.2", + "@typescript-eslint/types": "^8.57.2", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -421,14 +403,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.47.0.tgz", - "integrity": "sha512-a0TTJk4HXMkfpFkL9/WaGTNuv7JWfFTQFJd6zS9dVAjKsojmv9HT55xzbEpnZoY+VUb+YXLMp+ihMLz/UlZfDg==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.2.tgz", + "integrity": "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==", "dev": true, - "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.47.0", - "@typescript-eslint/visitor-keys": "8.47.0" + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -439,11 +420,10 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.47.0.tgz", - "integrity": "sha512-ybUAvjy4ZCL11uryalkKxuT3w3sXJAuWhOoGS3T/Wu+iUu1tGJmk5ytSY8gbdACNARmcYEB0COksD2j6hfGK2g==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz", + "integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==", "dev": true, - "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -456,17 +436,16 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.47.0.tgz", - "integrity": "sha512-QC9RiCmZ2HmIdCEvhd1aJELBlD93ErziOXXlHEZyuBo3tBiAZieya0HLIxp+DoDWlsQqDawyKuNEhORyku+P8A==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz", + "integrity": "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==", "dev": true, - "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.47.0", - "@typescript-eslint/typescript-estree": "8.47.0", - "@typescript-eslint/utils": "8.47.0", - "debug": "^4.3.4", - "ts-api-utils": "^2.1.0" + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/utils": "8.57.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -476,16 +455,15 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/type-utils/node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, - "license": "MIT", "engines": { "node": ">=18.12" }, @@ -494,11 +472,10 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.47.0.tgz", - "integrity": "sha512-nHAE6bMKsizhA2uuYZbEbmp5z2UpffNrPEqiKIeN7VsV6UY/roxanWfoRrf6x/k9+Obf+GQdkm0nPU+vnMXo9A==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz", + "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==", "dev": true, - "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -508,22 +485,20 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.47.0.tgz", - "integrity": "sha512-k6ti9UepJf5NpzCjH31hQNLHQWupTRPhZ+KFF8WtTuTpy7uHPfeg2NM7cP27aCGajoEplxJDFVCEm9TGPYyiVg==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz", + "integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==", "dev": true, - "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.47.0", - "@typescript-eslint/tsconfig-utils": "8.47.0", - "@typescript-eslint/types": "8.47.0", - "@typescript-eslint/visitor-keys": "8.47.0", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.1.0" + "@typescript-eslint/project-service": "8.57.2", + "@typescript-eslint/tsconfig-utils": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -546,9 +521,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", "dev": true, "dependencies": { "balanced-match": "^4.0.2" @@ -558,26 +533,25 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.7", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.7.tgz", - "integrity": "sha512-MOwgjc8tfrpn5QQEvjijjmDVtMw2oL88ugTevzxQnzRLm6l3fVEF2gzU0kYeYYKD8C66+IdGX6peJ4MyUlUnPg==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, "dependencies": { "brace-expansion": "^5.0.2" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, - "license": "MIT", "engines": { "node": ">=18.12" }, @@ -586,16 +560,15 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.47.0.tgz", - "integrity": "sha512-g7XrNf25iL4TJOiPqatNuaChyqt49a/onq5YsJ9+hXeugK+41LVg7AxikMfM02PC6jbNtZLCJj6AUcQXJS/jGQ==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.2.tgz", + "integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==", "dev": true, - "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.47.0", - "@typescript-eslint/types": "8.47.0", - "@typescript-eslint/typescript-estree": "8.47.0" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -605,19 +578,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.47.0.tgz", - "integrity": "sha512-SIV3/6eftCy1bNzCQoPmbWsRLujS8t5iDIZ4spZOBHqrM+yfX2ogg8Tt3PDTAVKw3sSCiUgg30uOAvK2r9zGjQ==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz", + "integrity": "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==", "dev": true, - "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.47.0", - "eslint-visitor-keys": "^4.2.1" + "@typescript-eslint/types": "8.57.2", + "eslint-visitor-keys": "^5.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -628,13 +600,12 @@ } }, "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, - "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -1150,11 +1121,10 @@ "dev": true }, "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, - "license": "MIT", "dependencies": { "ms": "^2.1.3" }, @@ -2217,9 +2187,9 @@ } }, "node_modules/flatted": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz", - "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true }, "node_modules/for-each": { @@ -4019,9 +3989,9 @@ } }, "node_modules/semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -4319,6 +4289,51 @@ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/titleize": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/titleize/-/titleize-3.0.0.tgz", @@ -4756,9 +4771,9 @@ } }, "@eslint-community/regexpp": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", - "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true }, "@eslint/eslintrc": { @@ -4867,12 +4882,6 @@ "picomatch": "^4.0.3" }, "dependencies": { - "@typescript-eslint/types": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.0.tgz", - "integrity": "sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==", - "dev": true - }, "eslint-visitor-keys": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", @@ -4914,20 +4923,19 @@ } }, "@typescript-eslint/eslint-plugin": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.47.0.tgz", - "integrity": "sha512-fe0rz9WJQ5t2iaLfdbDc9T80GJy0AeO453q8C3YCilnGozvOyCG5t+EZtg7j7D88+c3FipfP/x+wzGnh1xp8ZA==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz", + "integrity": "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==", "dev": true, "requires": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.47.0", - "@typescript-eslint/type-utils": "8.47.0", - "@typescript-eslint/utils": "8.47.0", - "@typescript-eslint/visitor-keys": "8.47.0", - "graphemer": "^1.4.0", - "ignore": "^7.0.0", + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/type-utils": "8.57.2", + "@typescript-eslint/utils": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.0" + "ts-api-utils": "^2.4.0" }, "dependencies": { "ignore": { @@ -4937,99 +4945,98 @@ "dev": true }, "ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", "dev": true, "requires": {} } } }, "@typescript-eslint/parser": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.47.0.tgz", - "integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.2.tgz", + "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", "dev": true, "requires": { - "@typescript-eslint/scope-manager": "8.47.0", - "@typescript-eslint/types": "8.47.0", - "@typescript-eslint/typescript-estree": "8.47.0", - "@typescript-eslint/visitor-keys": "8.47.0", - "debug": "^4.3.4" + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "debug": "^4.4.3" } }, "@typescript-eslint/project-service": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.47.0.tgz", - "integrity": "sha512-2X4BX8hUeB5JcA1TQJ7GjcgulXQ+5UkNb0DL8gHsHUHdFoiCTJoYLTpib3LtSDPZsRET5ygN4qqIWrHyYIKERA==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz", + "integrity": "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==", "dev": true, "requires": { - "@typescript-eslint/tsconfig-utils": "^8.47.0", - "@typescript-eslint/types": "^8.47.0", - "debug": "^4.3.4" + "@typescript-eslint/tsconfig-utils": "^8.57.2", + "@typescript-eslint/types": "^8.57.2", + "debug": "^4.4.3" } }, "@typescript-eslint/scope-manager": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.47.0.tgz", - "integrity": "sha512-a0TTJk4HXMkfpFkL9/WaGTNuv7JWfFTQFJd6zS9dVAjKsojmv9HT55xzbEpnZoY+VUb+YXLMp+ihMLz/UlZfDg==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.2.tgz", + "integrity": "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==", "dev": true, "requires": { - "@typescript-eslint/types": "8.47.0", - "@typescript-eslint/visitor-keys": "8.47.0" + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2" } }, "@typescript-eslint/tsconfig-utils": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.47.0.tgz", - "integrity": "sha512-ybUAvjy4ZCL11uryalkKxuT3w3sXJAuWhOoGS3T/Wu+iUu1tGJmk5ytSY8gbdACNARmcYEB0COksD2j6hfGK2g==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz", + "integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==", "dev": true, "requires": {} }, "@typescript-eslint/type-utils": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.47.0.tgz", - "integrity": "sha512-QC9RiCmZ2HmIdCEvhd1aJELBlD93ErziOXXlHEZyuBo3tBiAZieya0HLIxp+DoDWlsQqDawyKuNEhORyku+P8A==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz", + "integrity": "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==", "dev": true, "requires": { - "@typescript-eslint/types": "8.47.0", - "@typescript-eslint/typescript-estree": "8.47.0", - "@typescript-eslint/utils": "8.47.0", - "debug": "^4.3.4", - "ts-api-utils": "^2.1.0" + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/utils": "8.57.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" }, "dependencies": { "ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "requires": {} } } }, "@typescript-eslint/types": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.47.0.tgz", - "integrity": "sha512-nHAE6bMKsizhA2uuYZbEbmp5z2UpffNrPEqiKIeN7VsV6UY/roxanWfoRrf6x/k9+Obf+GQdkm0nPU+vnMXo9A==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz", + "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==", "dev": true }, "@typescript-eslint/typescript-estree": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.47.0.tgz", - "integrity": "sha512-k6ti9UepJf5NpzCjH31hQNLHQWupTRPhZ+KFF8WtTuTpy7uHPfeg2NM7cP27aCGajoEplxJDFVCEm9TGPYyiVg==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz", + "integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==", "dev": true, "requires": { - "@typescript-eslint/project-service": "8.47.0", - "@typescript-eslint/tsconfig-utils": "8.47.0", - "@typescript-eslint/types": "8.47.0", - "@typescript-eslint/visitor-keys": "8.47.0", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.1.0" + "@typescript-eslint/project-service": "8.57.2", + "@typescript-eslint/tsconfig-utils": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" }, "dependencies": { "balanced-match": { @@ -5039,58 +5046,58 @@ "dev": true }, "brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", "dev": true, "requires": { "balanced-match": "^4.0.2" } }, "minimatch": { - "version": "9.0.7", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.7.tgz", - "integrity": "sha512-MOwgjc8tfrpn5QQEvjijjmDVtMw2oL88ugTevzxQnzRLm6l3fVEF2gzU0kYeYYKD8C66+IdGX6peJ4MyUlUnPg==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, "requires": { "brace-expansion": "^5.0.2" } }, "ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "requires": {} } } }, "@typescript-eslint/utils": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.47.0.tgz", - "integrity": "sha512-g7XrNf25iL4TJOiPqatNuaChyqt49a/onq5YsJ9+hXeugK+41LVg7AxikMfM02PC6jbNtZLCJj6AUcQXJS/jGQ==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.2.tgz", + "integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==", "dev": true, "requires": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.47.0", - "@typescript-eslint/types": "8.47.0", - "@typescript-eslint/typescript-estree": "8.47.0" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2" } }, "@typescript-eslint/visitor-keys": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.47.0.tgz", - "integrity": "sha512-SIV3/6eftCy1bNzCQoPmbWsRLujS8t5iDIZ4spZOBHqrM+yfX2ogg8Tt3PDTAVKw3sSCiUgg30uOAvK2r9zGjQ==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz", + "integrity": "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==", "dev": true, "requires": { - "@typescript-eslint/types": "8.47.0", - "eslint-visitor-keys": "^4.2.1" + "@typescript-eslint/types": "8.57.2", + "eslint-visitor-keys": "^5.0.0" }, "dependencies": { "eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true } } @@ -5438,9 +5445,9 @@ "dev": true }, "debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "requires": { "ms": "^2.1.3" @@ -6191,9 +6198,9 @@ } }, "flatted": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz", - "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true }, "for-each": { @@ -7389,9 +7396,9 @@ } }, "semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true }, "shebang-command": { @@ -7587,6 +7594,31 @@ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, + "tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "requires": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "dependencies": { + "fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "requires": {} + }, + "picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true + } + } + }, "titleize": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/titleize/-/titleize-3.0.0.tgz", diff --git a/src/Misc/expressionFunc/hashFiles/package.json b/src/Misc/expressionFunc/hashFiles/package.json index 01ea1459f17..0097a84b7e3 100644 --- a/src/Misc/expressionFunc/hashFiles/package.json +++ b/src/Misc/expressionFunc/hashFiles/package.json @@ -37,7 +37,7 @@ "devDependencies": { "@stylistic/eslint-plugin": "^5.10.0", "@types/node": "^22.0.0", - "@typescript-eslint/eslint-plugin": "^8.0.0", + "@typescript-eslint/eslint-plugin": "^8.57.2", "@typescript-eslint/parser": "^8.0.0", "@vercel/ncc": "^0.38.3", "eslint": "^8.47.0", diff --git a/src/Misc/layoutroot/run-helper.cmd.template b/src/Misc/layoutroot/run-helper.cmd.template index 6b594d4f357..389280ef7c8 100644 --- a/src/Misc/layoutroot/run-helper.cmd.template +++ b/src/Misc/layoutroot/run-helper.cmd.template @@ -10,6 +10,13 @@ if %ERRORLEVEL% EQU 0 ( exit /b 0 ) +if "%ACTIONS_RUNNER_RETURN_VERSION_DEPRECATED_EXIT_CODE%"=="1" ( + if %ERRORLEVEL% EQU 7 ( + echo "Runner listener exit with deprecated version error code: %ERRORLEVEL%." + exit /b %ERRORLEVEL% + ) +) + if %ERRORLEVEL% EQU 1 ( echo "Runner listener exit with terminated error, stop the service, no retry needed." exit /b 0 diff --git a/src/Misc/layoutroot/run-helper.sh.template b/src/Misc/layoutroot/run-helper.sh.template index 9f2b3cc4457..813c747355a 100755 --- a/src/Misc/layoutroot/run-helper.sh.template +++ b/src/Misc/layoutroot/run-helper.sh.template @@ -34,11 +34,13 @@ fi updateFile="update.finished" "$DIR"/bin/Runner.Listener run $* - returnCode=$? if [[ $returnCode == 0 ]]; then echo "Runner listener exit with 0 return code, stop the service, no retry needed." exit 0 +elif [[ "$ACTIONS_RUNNER_RETURN_VERSION_DEPRECATED_EXIT_CODE" == "1" && $returnCode -eq 7 ]]; then + echo "Runner listener exit with deprecated version exit code: ${returnCode}." + exit "$returnCode" elif [[ $returnCode == 1 ]]; then echo "Runner listener exit with terminated error, stop the service, no retry needed." exit 0 diff --git a/src/Misc/layoutroot/run.cmd b/src/Misc/layoutroot/run.cmd index 692b38f9b9f..d0a4052c9bc 100644 --- a/src/Misc/layoutroot/run.cmd +++ b/src/Misc/layoutroot/run.cmd @@ -25,7 +25,14 @@ call "%~dp0run-helper.cmd" %* if %ERRORLEVEL% EQU 1 ( echo "Restarting runner..." goto :launch_helper -) else ( - echo "Exiting runner..." - exit /b 0 ) + +if "%ACTIONS_RUNNER_RETURN_VERSION_DEPRECATED_EXIT_CODE%"=="1" ( + if %ERRORLEVEL% EQU 7 ( + echo "Exiting runner with deprecated version error code: %ERRORLEVEL%" + exit /b %ERRORLEVEL% + ) +) + +echo "Exiting runner..." +exit /b 0 diff --git a/src/Misc/layoutroot/run.sh b/src/Misc/layoutroot/run.sh index 57f18ee00e1..27b3cb404b2 100755 --- a/src/Misc/layoutroot/run.sh +++ b/src/Misc/layoutroot/run.sh @@ -19,6 +19,9 @@ run() { returnCode=$? if [[ $returnCode -eq 2 ]]; then echo "Restarting runner..." + elif [[ "$ACTIONS_RUNNER_RETURN_VERSION_DEPRECATED_EXIT_CODE" == "1" && $returnCode -eq 7 ]]; then + echo "Exiting runner..." + exit "$returnCode" else echo "Exiting runner..." exit 0 @@ -42,6 +45,9 @@ runWithManualTrap() { returnCode=$? if [[ $returnCode -eq 2 ]]; then echo "Restarting runner..." + elif [[ "$ACTIONS_RUNNER_RETURN_VERSION_DEPRECATED_EXIT_CODE" == "1" && $returnCode -eq 7 ]]; then + echo "Exiting runner..." + exit "$returnCode" else echo "Exiting runner..." # Unregister signal handling before exit diff --git a/src/Runner.Common/Constants.cs b/src/Runner.Common/Constants.cs index 583958981a9..3326e947d73 100644 --- a/src/Runner.Common/Constants.cs +++ b/src/Runner.Common/Constants.cs @@ -159,6 +159,7 @@ public static class ReturnCode // and the runner should be restarted. This is a temporary code and will be removed in the future after // the runner is migrated to runner admin. public const int RunnerConfigurationRefreshed = 6; + public const int RunnerVersionDeprecated = 7; } public static class Features @@ -194,8 +195,22 @@ public static class NodeMigration public static readonly string RequireNode24Flag = "actions.runner.requirenode24"; public static readonly string WarnOnNode20Flag = "actions.runner.warnonnode20"; + // Feature flags for Linux ARM32 deprecation + public static readonly string DeprecateLinuxArm32Flag = "actions_runner_deprecate_linux_arm32"; + public static readonly string KillLinuxArm32Flag = "actions_runner_kill_linux_arm32"; + // Blog post URL for Node 20 deprecation public static readonly string Node20DeprecationUrl = "https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/"; + + // Node 20 migration dates (hardcoded fallbacks, can be overridden via job variables) + public static readonly string Node24DefaultDate = "June 2nd, 2026"; + public static readonly string Node20RemovalDate = "September 16th, 2026"; + + // Variable keys for server-overridable dates + public static readonly string Node24DefaultDateVariable = "actions_runner_node24_default_date"; + public static readonly string Node20RemovalDateVariable = "actions_runner_node20_removal_date"; + + public static readonly string LinuxArm32DeprecationMessage = "Linux ARM32 runners are deprecated and will no longer be supported after {0}. Please migrate to a supported platform."; } public static readonly string InternalTelemetryIssueDataKey = "_internal_telemetry"; @@ -277,6 +292,7 @@ public static class Actions public static readonly string AllowUnsupportedCommands = "ACTIONS_ALLOW_UNSECURE_COMMANDS"; public static readonly string AllowUnsupportedStopCommandTokens = "ACTIONS_ALLOW_UNSECURE_STOPCOMMAND_TOKENS"; public static readonly string RequireJobContainer = "ACTIONS_RUNNER_REQUIRE_JOB_CONTAINER"; + public static readonly string ReturnVersionDeprecatedExitCode = "ACTIONS_RUNNER_RETURN_VERSION_DEPRECATED_EXIT_CODE"; public static readonly string RunnerDebug = "ACTIONS_RUNNER_DEBUG"; public static readonly string StepDebug = "ACTIONS_STEP_DEBUG"; } diff --git a/src/Runner.Common/Util/NodeUtil.cs b/src/Runner.Common/Util/NodeUtil.cs index ff1a7a0af53..d87224f9820 100644 --- a/src/Runner.Common/Util/NodeUtil.cs +++ b/src/Runner.Common/Util/NodeUtil.cs @@ -58,7 +58,7 @@ public static (string nodeVersion, string warningMessage) DetermineActionsNodeVe { return (Constants.Runner.NodeMigration.Node24, null); } - + // Get environment variable details with source information var forceNode24Details = GetEnvironmentVariableDetails( Constants.Runner.NodeMigration.ForceNode24Variable, workflowEnvironment); @@ -108,14 +108,50 @@ public static (string nodeVersion, string warningMessage) DetermineActionsNodeVe /// /// Checks if Node24 is requested but running on ARM32 Linux, and determines if fallback is needed. + /// Also handles ARM32 deprecation and kill switch phases. /// /// The preferred Node version + /// Feature flag indicating ARM32 Linux is deprecated + /// Feature flag indicating ARM32 Linux should no longer work /// A tuple containing the adjusted node version and an optional warning message - public static (string nodeVersion, string warningMessage) CheckNodeVersionForLinuxArm32(string preferredVersion) + public static (string nodeVersion, string warningMessage) CheckNodeVersionForLinuxArm32( + string preferredVersion, + bool deprecateArm32 = false, + bool killArm32 = false, + string node20RemovalDate = null) { - if (string.Equals(preferredVersion, Constants.Runner.NodeMigration.Node24, StringComparison.OrdinalIgnoreCase) && - Constants.Runner.PlatformArchitecture.Equals(Constants.Architecture.Arm) && - Constants.Runner.Platform.Equals(Constants.OSPlatform.Linux)) + bool isArm32Linux = Constants.Runner.PlatformArchitecture.Equals(Constants.Architecture.Arm) && + Constants.Runner.Platform.Equals(Constants.OSPlatform.Linux); + + if (!isArm32Linux) + { + return (preferredVersion, null); + } + + // ARM32 kill switch: runner should no longer work on this platform + if (killArm32) + { + return (null, "Linux ARM32 runners are no longer supported. Please migrate to a supported platform."); + } + + // ARM32 deprecation warning: continue using node20 but warn about upcoming end of support + if (deprecateArm32) + { + string effectiveDate = string.IsNullOrEmpty(node20RemovalDate) ? Constants.Runner.NodeMigration.Node20RemovalDate : node20RemovalDate; + string deprecationWarning = string.Format( + Constants.Runner.NodeMigration.LinuxArm32DeprecationMessage, + effectiveDate); + + if (string.Equals(preferredVersion, Constants.Runner.NodeMigration.Node24, StringComparison.OrdinalIgnoreCase)) + { + return (Constants.Runner.NodeMigration.Node20, deprecationWarning); + } + + return (preferredVersion, deprecationWarning); + } + + // Legacy behavior: fall back to node20 if node24 was requested on ARM32 + if (string.Equals(preferredVersion, Constants.Runner.NodeMigration.Node24, StringComparison.OrdinalIgnoreCase)) { return (Constants.Runner.NodeMigration.Node20, "Node 24 is not supported on Linux ARM32 platforms. Falling back to Node 20."); } diff --git a/src/Runner.Listener/Program.cs b/src/Runner.Listener/Program.cs index 80852d32c4d..d2923736402 100644 --- a/src/Runner.Listener/Program.cs +++ b/src/Runner.Listener/Program.cs @@ -141,9 +141,9 @@ private async static Task MainAsync(IHostContext context, string[] args) } catch (AccessDeniedException e) when (e.ErrorCode == 1) { - terminal.WriteError($"An error occured: {e.Message}"); + terminal.WriteError($"An error occurred: {e.Message}"); trace.Error(e); - return Constants.Runner.ReturnCode.TerminatedError; + return GetRunnerVersionDeprecatedExitCode(); } catch (RunnerNotFoundException e) { @@ -159,6 +159,16 @@ private async static Task MainAsync(IHostContext context, string[] args) } } + private static int GetRunnerVersionDeprecatedExitCode() + { + if (StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable(Constants.Variables.Actions.ReturnVersionDeprecatedExitCode))) + { + return Constants.Runner.ReturnCode.RunnerVersionDeprecated; + } + + return Constants.Runner.ReturnCode.TerminatedError; + } + private static void LoadAndSetEnv() { var binDir = Path.GetDirectoryName(Assembly.GetEntryAssembly().Location); diff --git a/src/Runner.Worker/ActionManager.cs b/src/Runner.Worker/ActionManager.cs index 38c2ab8b320..6c066a150b4 100644 --- a/src/Runner.Worker/ActionManager.cs +++ b/src/Runner.Worker/ActionManager.cs @@ -115,6 +115,14 @@ public sealed class ActionManager : RunnerService, IActionManager executionContext.Result = TaskResult.Failed; throw; } + catch (FailedToDownloadActionException ex) + { + // Log the error and fail the PrepareActionsAsync Initialization. + Trace.Error($"Caught exception from PrepareActionsAsync Initialization: {ex}"); + executionContext.InfrastructureError(ex.InnerException?.Message ?? ex.Message, category: "error_download_action"); + executionContext.Result = TaskResult.Failed; + throw; + } catch (InvalidActionArchiveException ex) { // Log the error and fail the PrepareActionsAsync Initialization. @@ -1157,93 +1165,102 @@ private async Task DownloadRepositoryArchive(IExecutionContext executionContext, // Allow up to 20 * 60s for any action to be downloaded from github graph. int timeoutSeconds = 20 * 60; - while (retryCount < 3) + try { - string requestId = string.Empty; - using (var actionDownloadTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds))) - using (var actionDownloadCancellation = CancellationTokenSource.CreateLinkedTokenSource(actionDownloadTimeout.Token, executionContext.CancellationToken)) + while (retryCount < 3) { - try + string requestId = string.Empty; + using (var actionDownloadTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds))) + using (var actionDownloadCancellation = CancellationTokenSource.CreateLinkedTokenSource(actionDownloadTimeout.Token, executionContext.CancellationToken)) { - //open zip stream in async mode - using (FileStream fs = new(archiveFile, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: _defaultFileStreamBufferSize, useAsync: true)) - using (var httpClientHandler = HostContext.CreateHttpClientHandler()) - using (var httpClient = new HttpClient(httpClientHandler)) + try { - httpClient.DefaultRequestHeaders.Authorization = CreateAuthHeader(downloadAuthToken); - - httpClient.DefaultRequestHeaders.UserAgent.AddRange(HostContext.UserAgents); - using (var response = await httpClient.GetAsync(downloadUrl)) + //open zip stream in async mode + using (FileStream fs = new(archiveFile, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: _defaultFileStreamBufferSize, useAsync: true)) + using (var httpClientHandler = HostContext.CreateHttpClientHandler()) + using (var httpClient = new HttpClient(httpClientHandler)) { - requestId = UrlUtil.GetGitHubRequestId(response.Headers); - if (!string.IsNullOrEmpty(requestId)) - { - Trace.Info($"Request URL: {downloadUrl} X-GitHub-Request-Id: {requestId} Http Status: {response.StatusCode}"); - } + httpClient.DefaultRequestHeaders.Authorization = CreateAuthHeader(downloadAuthToken); - if (response.IsSuccessStatusCode) + httpClient.DefaultRequestHeaders.UserAgent.AddRange(HostContext.UserAgents); + using (var response = await httpClient.GetAsync(downloadUrl)) { - using (var result = await response.Content.ReadAsStreamAsync()) + requestId = UrlUtil.GetGitHubRequestId(response.Headers); + if (!string.IsNullOrEmpty(requestId)) { - await result.CopyToAsync(fs, _defaultCopyBufferSize, actionDownloadCancellation.Token); - await fs.FlushAsync(actionDownloadCancellation.Token); + Trace.Info($"Request URL: {downloadUrl} X-GitHub-Request-Id: {requestId} Http Status: {response.StatusCode}"); + } - // download succeed, break out the retry loop. - break; + if (response.IsSuccessStatusCode) + { + using (var result = await response.Content.ReadAsStreamAsync()) + { + await result.CopyToAsync(fs, _defaultCopyBufferSize, actionDownloadCancellation.Token); + await fs.FlushAsync(actionDownloadCancellation.Token); + + // download succeed, break out the retry loop. + break; + } + } + else if (response.StatusCode == HttpStatusCode.NotFound) + { + // It doesn't make sense to retry in this case, so just stop + throw new ActionNotFoundException(new Uri(downloadUrl), requestId); + } + else + { + // Something else bad happened, let's go to our retry logic + response.EnsureSuccessStatusCode(); } - } - else if (response.StatusCode == HttpStatusCode.NotFound) - { - // It doesn't make sense to retry in this case, so just stop - throw new ActionNotFoundException(new Uri(downloadUrl), requestId); - } - else - { - // Something else bad happened, let's go to our retry logic - response.EnsureSuccessStatusCode(); } } } - } - catch (OperationCanceledException) when (executionContext.CancellationToken.IsCancellationRequested) - { - Trace.Info("Action download has been cancelled."); - throw; - } - catch (OperationCanceledException ex) when (!executionContext.CancellationToken.IsCancellationRequested && retryCount >= 2) - { - Trace.Info($"Action download final retry timeout after {timeoutSeconds} seconds."); - throw new TimeoutException($"Action '{downloadUrl}' download has timed out. Error: {ex.Message} {requestId}"); - } - catch (ActionNotFoundException) - { - Trace.Info($"The action at '{downloadUrl}' does not exist"); - throw; - } - catch (Exception ex) when (retryCount < 2) - { - retryCount++; - Trace.Error($"Fail to download archive '{downloadUrl}' -- Attempt: {retryCount}"); - Trace.Error(ex); - if (actionDownloadTimeout.Token.IsCancellationRequested) + catch (OperationCanceledException) when (executionContext.CancellationToken.IsCancellationRequested) { - // action download didn't finish within timeout - executionContext.Warning($"Action '{downloadUrl}' didn't finish download within {timeoutSeconds} seconds. {requestId}"); + Trace.Info("Action download has been cancelled."); + throw; } - else + catch (OperationCanceledException ex) when (!executionContext.CancellationToken.IsCancellationRequested && retryCount >= 2) + { + Trace.Info($"Action download final retry timeout after {timeoutSeconds} seconds."); + throw new TimeoutException($"Action '{downloadUrl}' download has timed out. Error: {ex.Message} {requestId}"); + } + catch (ActionNotFoundException) { - executionContext.Warning($"Failed to download action '{downloadUrl}'. Error: {ex.Message} {requestId}"); + Trace.Info($"The action at '{downloadUrl}' does not exist"); + throw; + } + catch (Exception ex) when (retryCount < 2) + { + retryCount++; + Trace.Error($"Fail to download archive '{downloadUrl}' -- Attempt: {retryCount}"); + Trace.Error(ex); + if (actionDownloadTimeout.Token.IsCancellationRequested) + { + // action download didn't finish within timeout + executionContext.Warning($"Action '{downloadUrl}' didn't finish download within {timeoutSeconds} seconds. {requestId}"); + } + else + { + executionContext.Warning($"Failed to download action '{downloadUrl}'. Error: {ex.Message} {requestId}"); + } } } - } - if (String.IsNullOrEmpty(Environment.GetEnvironmentVariable("_GITHUB_ACTION_DOWNLOAD_NO_BACKOFF"))) - { - var backOff = BackoffTimerHelper.GetRandomBackoff(TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30)); - executionContext.Warning($"Back off {backOff.TotalSeconds} seconds before retry."); - await Task.Delay(backOff); + if (String.IsNullOrEmpty(Environment.GetEnvironmentVariable("_GITHUB_ACTION_DOWNLOAD_NO_BACKOFF"))) + { + var backOff = BackoffTimerHelper.GetRandomBackoff(TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30)); + executionContext.Warning($"Back off {backOff.TotalSeconds} seconds before retry."); + await Task.Delay(backOff); + } } } + catch (Exception ex) when (!(ex is OperationCanceledException) && !executionContext.CancellationToken.IsCancellationRequested) + { + Trace.Error($"Failed to download archive '{downloadUrl}' after {retryCount + 1} attempts."); + Trace.Error(ex); + throw new FailedToDownloadActionException($"Failed to download archive '{downloadUrl}' after {retryCount + 1} attempts.", ex); + } ArgUtil.NotNullOrEmpty(archiveFile, nameof(archiveFile)); executionContext.Debug($"Download '{downloadUrl}' to '{archiveFile}'"); diff --git a/src/Runner.Worker/ActionManifestManager.cs b/src/Runner.Worker/ActionManifestManager.cs index a70592381c7..014c053aa59 100644 --- a/src/Runner.Worker/ActionManifestManager.cs +++ b/src/Runner.Worker/ActionManifestManager.cs @@ -316,7 +316,6 @@ private TemplateContext CreateTemplateContext( Schema = _actionManifestSchema, // TODO: Switch to real tracewriter for cutover TraceWriter = new GitHub.Actions.WorkflowParser.ObjectTemplating.EmptyTraceWriter(), - AllowCaseFunction = false, }; // Expression values from execution context diff --git a/src/Runner.Worker/ActionManifestManagerLegacy.cs b/src/Runner.Worker/ActionManifestManagerLegacy.cs index c332efd2eb7..d423aff8603 100644 --- a/src/Runner.Worker/ActionManifestManagerLegacy.cs +++ b/src/Runner.Worker/ActionManifestManagerLegacy.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Threading; @@ -315,7 +315,6 @@ private TemplateContext CreateTemplateContext( maxBytes: 10 * 1024 * 1024), Schema = _actionManifestSchema, TraceWriter = executionContext.ToTemplateTraceWriter(), - AllowCaseFunction = false, }; // Expression values from execution context diff --git a/src/Runner.Worker/Dap/DapDebugger.cs b/src/Runner.Worker/Dap/DapDebugger.cs new file mode 100644 index 00000000000..9d0acca680d --- /dev/null +++ b/src/Runner.Worker/Dap/DapDebugger.cs @@ -0,0 +1,1299 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using GitHub.DistributedTask.WebApi; +using GitHub.Runner.Common; +using GitHub.Runner.Sdk; +using Newtonsoft.Json; + +namespace GitHub.Runner.Worker.Dap +{ + /// + /// Stores information about a completed step for stack trace display. + /// + internal sealed class CompletedStepInfo + { + public string DisplayName { get; set; } + public TaskResult? Result { get; set; } + public int FrameId { get; set; } + } + + /// + /// Single public facade for the Debug Adapter Protocol subsystem. + /// Owns the full transport, handshake, step-level pauses, variable + /// inspection, reconnection, and cancellation flow. + /// + public sealed class DapDebugger : RunnerService, IDapDebugger + { + private const int _defaultPort = 4711; + private const int _defaultTimeoutMinutes = 15; + private const string _portEnvironmentVariable = "ACTIONS_RUNNER_DAP_PORT"; + private const string _timeoutEnvironmentVariable = "ACTIONS_RUNNER_DAP_CONNECTION_TIMEOUT"; + private const string _contentLengthHeader = "Content-Length: "; + private const int _maxMessageSize = 10 * 1024 * 1024; // 10 MB + private const int _maxHeaderLineLength = 8192; // 8 KB + private const int _connectionRetryDelayMilliseconds = 500; + + // Thread ID for the single job execution thread + private const int _jobThreadId = 1; + + // Frame ID for the current step (always 1) + private const int _currentFrameId = 1; + + // Frame IDs for completed steps start at 1000 + private const int _completedFrameIdBase = 1000; + + private TcpListener _listener; + private TcpClient _client; + private NetworkStream _stream; + private readonly SemaphoreSlim _sendLock = new SemaphoreSlim(1, 1); + private int _nextSeq = 1; + private Task _connectionLoopTask; + private volatile DapSessionState _state = DapSessionState.NotStarted; + private CancellationTokenRegistration? _cancellationRegistration; + private bool _isFirstStep = true; + + // Synchronization for step execution + private TaskCompletionSource _commandTcs; + private readonly object _stateLock = new object(); + + // Session readiness — signaled when configurationDone is received + private TaskCompletionSource _readyTcs; + + // Whether to pause before the next step (set by 'next' command) + private bool _pauseOnNextStep = true; + + // Current execution context + private IStep _currentStep; + private IExecutionContext _jobContext; + private int _currentStepIndex; + + // Track completed steps for stack trace + private readonly List _completedSteps = new List(); + private int _nextCompletedFrameId = _completedFrameIdBase; + + // Client connection tracking for reconnection support + private volatile bool _isClientConnected; + + // Scope/variable inspection provider — reusable by future DAP features + private DapVariableProvider _variableProvider; + + // REPL command executor for run() commands + private DapReplExecutor _replExecutor; + + public bool IsActive => + _state == DapSessionState.Ready || + _state == DapSessionState.Paused || + _state == DapSessionState.Running; + + internal DapSessionState State => _state; + + public override void Initialize(IHostContext hostContext) + { + base.Initialize(hostContext); + _variableProvider = new DapVariableProvider(hostContext.SecretMasker); + _replExecutor = new DapReplExecutor(hostContext, SendOutput); + Trace.Info("DapDebugger initialized"); + } + + public Task StartAsync(IExecutionContext jobContext) + { + ArgUtil.NotNull(jobContext, nameof(jobContext)); + var port = ResolvePort(); + + Trace.Info($"Starting DAP debugger on port {port}"); + + _jobContext = jobContext; + _readyTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + _listener = new TcpListener(IPAddress.Loopback, port); + _listener.Start(); + Trace.Info($"DAP debugger listening on {_listener.LocalEndpoint}"); + + _state = DapSessionState.WaitingForConnection; + _connectionLoopTask = ConnectionLoopAsync(jobContext.CancellationToken); + + _cancellationRegistration = jobContext.CancellationToken.Register(() => + { + Trace.Info("Job cancellation requested, unblocking pending waits."); + _readyTcs?.TrySetCanceled(); + _commandTcs?.TrySetResult(DapCommand.Disconnect); + }); + + Trace.Info($"DAP debugger started on port {port}"); + return Task.CompletedTask; + } + + public async Task WaitUntilReadyAsync() + { + if (_state == DapSessionState.NotStarted || _listener == null || _jobContext == null) + { + return; + } + + var timeoutMinutes = ResolveTimeout(); + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMinutes(timeoutMinutes)); + + try + { + Trace.Info($"Waiting for debugger client connection (timeout: {timeoutMinutes} minutes)..."); + using (timeoutCts.Token.Register(() => _readyTcs?.TrySetCanceled())) + { + await _readyTcs.Task; + } + + Trace.Info("DAP debugger ready."); + } + catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !_jobContext.CancellationToken.IsCancellationRequested) + { + throw new TimeoutException($"No debugger client connected within {timeoutMinutes} minutes."); + } + } + + public async Task OnJobCompletedAsync() + { + if (_state != DapSessionState.NotStarted) + { + try + { + OnJobCompleted(); + } + catch (Exception ex) + { + Trace.Warning($"DAP OnJobCompleted error: {ex.Message}"); + } + } + + await StopAsync(); + } + + public async Task StopAsync() + { + if (_cancellationRegistration.HasValue) + { + _cancellationRegistration.Value.Dispose(); + _cancellationRegistration = null; + } + + if (_state != DapSessionState.NotStarted) + { + try + { + Trace.Info("Stopping DAP debugger"); + + CleanupConnection(); + + try { _listener?.Stop(); } + catch { /* best effort */ } + + if (_connectionLoopTask != null) + { + try + { + await Task.WhenAny(_connectionLoopTask, Task.Delay(5000)); + } + catch { /* best effort */ } + } + } + catch (Exception ex) + { + Trace.Error("Error stopping DAP debugger"); + Trace.Error(ex); + } + } + + lock (_stateLock) + { + if (_state != DapSessionState.NotStarted && _state != DapSessionState.Terminated) + { + _state = DapSessionState.Terminated; + } + } + + _isClientConnected = false; + _listener = null; + _client = null; + _stream = null; + _readyTcs = null; + _connectionLoopTask = null; + } + + public async Task OnStepStartingAsync(IStep step) + { + if (!IsActive) + { + return; + } + + try + { + bool isFirst = _isFirstStep; + _isFirstStep = false; + await OnStepStartingAsync(step, isFirst); + } + catch (Exception ex) + { + Trace.Warning($"DAP OnStepStarting error: {ex.Message}"); + } + } + + public void OnStepCompleted(IStep step) + { + if (!IsActive) + { + return; + } + + try + { + var result = step.ExecutionContext?.Result; + Trace.Info("Step completed"); + + // Add to completed steps list for stack trace + lock (_stateLock) + { + if (_state != DapSessionState.Ready && + _state != DapSessionState.Paused && + _state != DapSessionState.Running) + { + return; + } + + _completedSteps.Add(new CompletedStepInfo + { + DisplayName = step.DisplayName, + Result = result, + FrameId = _nextCompletedFrameId++ + }); + } + } + catch (Exception ex) + { + Trace.Warning($"DAP OnStepCompleted error: {ex.Message}"); + } + } + + internal async Task HandleMessageAsync(string messageJson, CancellationToken cancellationToken) + { + Request request = null; + try + { + request = JsonConvert.DeserializeObject(messageJson); + if (request == null) + { + Trace.Warning("Failed to deserialize DAP request"); + return; + } + + if (!string.Equals(request.Type, "request", StringComparison.OrdinalIgnoreCase)) + { + Trace.Warning("Received DAP message that was not a request"); + return; + } + + Trace.Info("Handling DAP request"); + + Response response; + if (request.Command == "evaluate") + { + response = await HandleEvaluateAsync(request, cancellationToken); + } + else + { + response = request.Command switch + { + "initialize" => HandleInitialize(request), + "attach" => HandleAttach(request), + "configurationDone" => HandleConfigurationDone(request), + "disconnect" => HandleDisconnect(request), + "threads" => HandleThreads(request), + "stackTrace" => HandleStackTrace(request), + "scopes" => HandleScopes(request), + "variables" => HandleVariables(request), + "continue" => HandleContinue(request), + "next" => HandleNext(request), + "setBreakpoints" => HandleSetBreakpoints(request), + "setExceptionBreakpoints" => HandleSetExceptionBreakpoints(request), + "completions" => HandleCompletions(request), + "stepIn" => CreateResponse(request, false, "Step In is not supported. Actions jobs debug at the step level - use 'next' to advance to the next step.", body: null), + "stepOut" => CreateResponse(request, false, "Step Out is not supported. Actions jobs debug at the step level - use 'continue' to resume.", body: null), + "stepBack" => CreateResponse(request, false, "Step Back is not yet supported.", body: null), + "reverseContinue" => CreateResponse(request, false, "Reverse Continue is not yet supported.", body: null), + "pause" => CreateResponse(request, false, "Pause is not supported. The debugger pauses automatically at step boundaries.", body: null), + _ => CreateResponse(request, false, $"Unsupported command: {request.Command}", body: null) + }; + } + + response.RequestSeq = request.Seq; + response.Command = request.Command; + + SendResponse(response); + + if (request.Command == "initialize") + { + SendEvent(new Event + { + EventType = "initialized" + }); + Trace.Info("Sent initialized event"); + } + } + catch (Exception ex) + { + Trace.Error($"Error handling DAP request ({ex.GetType().Name})"); + if (request != null) + { + var maskedMessage = HostContext?.SecretMasker?.MaskSecrets(ex.Message) ?? ex.Message; + var errorResponse = CreateResponse(request, false, maskedMessage, body: null); + errorResponse.RequestSeq = request.Seq; + errorResponse.Command = request.Command; + SendResponse(errorResponse); + } + } + } + + internal void HandleClientConnected() + { + _isClientConnected = true; + Trace.Info("Client connected to debug session"); + + // If we're paused, re-send the stopped event so the new client + // knows the current state (important for reconnection) + string description = null; + lock (_stateLock) + { + if (_state == DapSessionState.Paused && _currentStep != null) + { + description = $"Stopped before step: {_currentStep.DisplayName}"; + } + } + + if (description != null) + { + Trace.Info("Re-sending stopped event to reconnected client"); + SendStoppedEvent("step", description); + } + } + + internal void HandleClientDisconnected() + { + _isClientConnected = false; + Trace.Info("Client disconnected from debug session"); + + // Intentionally do NOT release the command TCS here. + // The session stays paused, waiting for a client to reconnect. + // The debugger's connection loop will accept a new client and + // call HandleClientConnected, which re-sends the stopped event. + } + + private async Task ConnectionLoopAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + Trace.Info("Waiting for debug client connection..."); + _client = await _listener.AcceptTcpClientAsync(); + + if (cancellationToken.IsCancellationRequested) + { + break; + } + + _stream = _client.GetStream(); + var remoteEndPoint = _client.Client.RemoteEndPoint; + Trace.Info($"Debug client connected from {remoteEndPoint}"); + + HandleClientConnected(); + + // Enter message processing loop until client disconnects or cancellation is requested + await ProcessMessagesAsync(cancellationToken); + + Trace.Info("Client disconnected, waiting for reconnection..."); + HandleClientDisconnected(); + CleanupConnection(); + } + catch (Exception ex) + { + CleanupConnection(); + + if (cancellationToken.IsCancellationRequested) + { + break; + } + + Trace.Error("Debugger connection error"); + Trace.Error(ex); + + try + { + await Task.Delay(_connectionRetryDelayMilliseconds, cancellationToken); + } + catch (OperationCanceledException) + { + break; + } + } + } + + Trace.Info("Connection loop ended"); + } + + private void CleanupConnection() + { + _sendLock.Wait(); + try + { + try { _stream?.Close(); } catch { /* best effort */ } + try { _client?.Close(); } catch { /* best effort */ } + _stream = null; + _client = null; + } + finally + { + _sendLock.Release(); + } + } + + private async Task ProcessMessagesAsync(CancellationToken cancellationToken) + { + Trace.Info("Starting DAP message processing loop"); + + try + { + while (!cancellationToken.IsCancellationRequested && _client?.Connected == true) + { + var json = await ReadMessageAsync(cancellationToken); + if (json == null) + { + Trace.Info("Client disconnected (end of stream)"); + break; + } + + await HandleMessageAsync(json, cancellationToken); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + Trace.Info("Message processing cancelled"); + } + catch (IOException ex) + { + Trace.Info($"Connection closed ({ex.GetType().Name})"); + } + catch (Exception ex) + { + Trace.Error($"Error in message loop ({ex.GetType().Name})"); + } + + Trace.Info("DAP message processing loop ended"); + } + + private async Task ReadMessageAsync(CancellationToken cancellationToken) + { + int contentLength = -1; + + while (true) + { + var line = await ReadLineAsync(cancellationToken); + if (line == null) + { + return null; + } + + if (line.Length == 0) + { + break; + } + + if (line.StartsWith(_contentLengthHeader, StringComparison.OrdinalIgnoreCase)) + { + var lengthStr = line.Substring(_contentLengthHeader.Length).Trim(); + if (!int.TryParse(lengthStr, out contentLength)) + { + throw new InvalidDataException($"Invalid Content-Length: {lengthStr}"); + } + } + } + + if (contentLength < 0) + { + throw new InvalidDataException("Missing Content-Length header"); + } + + if (contentLength > _maxMessageSize) + { + throw new InvalidDataException($"Message size {contentLength} exceeds maximum allowed size of {_maxMessageSize}"); + } + + var buffer = new byte[contentLength]; + var totalRead = 0; + while (totalRead < contentLength) + { + var bytesRead = await _stream.ReadAsync(buffer, totalRead, contentLength - totalRead, cancellationToken); + if (bytesRead == 0) + { + throw new EndOfStreamException("Connection closed while reading message body"); + } + totalRead += bytesRead; + } + + var json = Encoding.UTF8.GetString(buffer); + Trace.Verbose("Received DAP message body"); + return json; + } + + private async Task ReadLineAsync(CancellationToken cancellationToken) + { + var lineBuilder = new StringBuilder(); + var buffer = new byte[1]; + var previousWasCr = false; + + while (true) + { + var bytesRead = await _stream.ReadAsync(buffer, 0, 1, cancellationToken); + if (bytesRead == 0) + { + return lineBuilder.Length > 0 ? lineBuilder.ToString() : null; + } + + var c = (char)buffer[0]; + + if (c == '\n' && previousWasCr) + { + if (lineBuilder.Length > 0 && lineBuilder[lineBuilder.Length - 1] == '\r') + { + lineBuilder.Length--; + } + return lineBuilder.ToString(); + } + + previousWasCr = c == '\r'; + lineBuilder.Append(c); + + if (lineBuilder.Length > _maxHeaderLineLength) + { + throw new InvalidDataException($"Header line exceeds maximum length of {_maxHeaderLineLength}"); + } + } + } + + /// + /// Serializes and writes a DAP message with Content-Length framing. + /// Must be called within the _sendLock. + /// + /// Secret masking is intentionally NOT applied here at the serialization + /// layer. Masking the raw JSON would corrupt protocol envelope fields + /// (type, event, command, seq) if a secret collides with those strings. + /// Instead, each DAP producer masks user-visible text at the point of + /// construction via the runner's SecretMasker. See DapVariableProvider, + /// DapReplExecutor, and DapDebugger for the call sites. + /// + private void SendMessageInternal(ProtocolMessage message) + { + var json = JsonConvert.SerializeObject(message, new JsonSerializerSettings + { + NullValueHandling = NullValueHandling.Ignore + }); + + var bodyBytes = Encoding.UTF8.GetBytes(json); + var header = $"Content-Length: {bodyBytes.Length}\r\n\r\n"; + var headerBytes = Encoding.ASCII.GetBytes(header); + + _stream.Write(headerBytes, 0, headerBytes.Length); + _stream.Write(bodyBytes, 0, bodyBytes.Length); + _stream.Flush(); + + Trace.Verbose("Sent DAP message"); + } + + private void SendMessage(ProtocolMessage message) + { + try + { + _sendLock.Wait(); + try + { + if (_stream == null) + { + Trace.Warning("Cannot send message: no client connected"); + return; + } + + message.Seq = _nextSeq++; + SendMessageInternal(message); + } + finally + { + _sendLock.Release(); + } + + Trace.Info("Sent message"); + } + catch (Exception ex) + { + Trace.Warning($"Failed to send message ({ex.GetType().Name})"); + } + } + + private void SendEvent(Event evt) + { + SendMessage(evt); + } + + private void SendResponse(Response response) + { + SendMessage(response); + } + + private void SendOutput(string category, string text) + { + SendEvent(new Event + { + EventType = "output", + Body = new OutputEventBody + { + Category = category, + Output = text + } + }); + } + + internal async Task OnStepStartingAsync(IStep step, bool isFirstStep) + { + bool pauseOnNextStep; + CancellationToken cancellationToken; + lock (_stateLock) + { + if (_state != DapSessionState.Ready && + _state != DapSessionState.Paused && + _state != DapSessionState.Running) + { + return; + } + + _currentStep = step; + _currentStepIndex = _completedSteps.Count; + pauseOnNextStep = _pauseOnNextStep; + cancellationToken = _jobContext?.CancellationToken ?? CancellationToken.None; + } + + // Reset variable references so stale nested refs from the + // previous step are not served to the client. + _variableProvider?.Reset(); + + // Determine if we should pause + bool shouldPause = isFirstStep || pauseOnNextStep; + + if (!shouldPause) + { + Trace.Info("Step starting without debugger pause"); + return; + } + + var reason = isFirstStep ? "entry" : "step"; + var description = isFirstStep + ? $"Stopped at job entry: {step.DisplayName}" + : $"Stopped before step: {step.DisplayName}"; + + Trace.Info("Step starting with debugger pause"); + + // Send stopped event to debugger (only if client is connected) + SendStoppedEvent(reason, description); + + // Wait for debugger command + await WaitForCommandAsync(cancellationToken); + } + + internal void OnJobCompleted() + { + Trace.Info("Job completed, sending terminated event"); + + int exitCode; + lock (_stateLock) + { + if (_state == DapSessionState.Terminated) + { + Trace.Info("Session already terminated, skipping OnJobCompleted events"); + return; + } + _state = DapSessionState.Terminated; + exitCode = _jobContext?.Result == TaskResult.Succeeded ? 0 : 1; + } + + SendEvent(new Event + { + EventType = "terminated", + Body = new TerminatedEventBody() + }); + + SendEvent(new Event + { + EventType = "exited", + Body = new ExitedEventBody + { + ExitCode = exitCode + } + }); + } + + private Response HandleInitialize(Request request) + { + if (request.Arguments != null) + { + try + { + request.Arguments.ToObject(); + Trace.Info("Initialize arguments received"); + } + catch (Exception ex) + { + Trace.Warning($"Failed to parse initialize arguments ({ex.GetType().Name})"); + } + } + + lock (_stateLock) + { + _state = DapSessionState.Initializing; + } + + // Build capabilities — MVP only supports configurationDone + var capabilities = new Capabilities + { + SupportsConfigurationDoneRequest = true, + SupportsEvaluateForHovers = true, + + // All other capabilities are false for MVP + SupportsFunctionBreakpoints = false, + SupportsConditionalBreakpoints = false, + SupportsStepBack = false, + SupportsSetVariable = false, + SupportsRestartFrame = false, + SupportsGotoTargetsRequest = false, + SupportsStepInTargetsRequest = false, + SupportsCompletionsRequest = true, + SupportsModulesRequest = false, + SupportsTerminateRequest = false, + SupportTerminateDebuggee = false, + SupportsDelayedStackTraceLoading = false, + SupportsLoadedSourcesRequest = false, + SupportsProgressReporting = false, + SupportsRunInTerminalRequest = false, + SupportsCancelRequest = false, + SupportsExceptionOptions = false, + SupportsValueFormattingOptions = false, + SupportsExceptionInfoRequest = false, + }; + + Trace.Info("Initialize request handled, capabilities sent"); + return CreateResponse(request, true, body: capabilities); + } + + private Response HandleAttach(Request request) + { + Trace.Info("Attach request handled"); + return CreateResponse(request, true, body: null); + } + + private Response HandleConfigurationDone(Request request) + { + lock (_stateLock) + { + _state = DapSessionState.Ready; + } + + _readyTcs.TrySetResult(true); + + Trace.Info("Configuration done, debug session is ready"); + return CreateResponse(request, true, body: null); + } + + private Response HandleDisconnect(Request request) + { + Trace.Info("Disconnect request received"); + + lock (_stateLock) + { + _state = DapSessionState.Terminated; + + // Release any blocked step execution + _commandTcs?.TrySetResult(DapCommand.Disconnect); + } + + return CreateResponse(request, true, body: null); + } + + private Response HandleThreads(Request request) + { + IExecutionContext jobContext; + lock (_stateLock) + { + jobContext = _jobContext; + } + + var threadName = jobContext != null + ? MaskUserVisibleText($"Job: {jobContext.GetGitHubContext("job") ?? "workflow job"}") + : "Job Thread"; + + var body = new ThreadsResponseBody + { + Threads = new List + { + new Thread + { + Id = _jobThreadId, + Name = threadName + } + } + }; + + return CreateResponse(request, true, body: body); + } + + private Response HandleStackTrace(Request request) + { + IStep currentStep; + int currentStepIndex; + CompletedStepInfo[] completedSteps; + lock (_stateLock) + { + currentStep = _currentStep; + currentStepIndex = _currentStepIndex; + completedSteps = _completedSteps.ToArray(); + } + + var frames = new List(); + + // Add current step as the top frame + if (currentStep != null) + { + var resultIndicator = currentStep.ExecutionContext?.Result != null + ? $" [{currentStep.ExecutionContext.Result}]" + : " [running]"; + + frames.Add(new StackFrame + { + Id = _currentFrameId, + Name = MaskUserVisibleText($"{currentStep.DisplayName ?? "Current Step"}{resultIndicator}"), + Line = currentStepIndex + 1, + Column = 1, + PresentationHint = "normal" + }); + } + else + { + frames.Add(new StackFrame + { + Id = _currentFrameId, + Name = "(no step executing)", + Line = 0, + Column = 1, + PresentationHint = "subtle" + }); + } + + // Add completed steps as additional frames (most recent first) + for (int i = completedSteps.Length - 1; i >= 0; i--) + { + var completedStep = completedSteps[i]; + var resultStr = completedStep.Result.HasValue ? $" [{completedStep.Result}]" : ""; + frames.Add(new StackFrame + { + Id = completedStep.FrameId, + Name = MaskUserVisibleText($"{completedStep.DisplayName}{resultStr}"), + Line = 1, + Column = 1, + PresentationHint = "subtle" + }); + } + + var body = new StackTraceResponseBody + { + StackFrames = frames, + TotalFrames = frames.Count + }; + + return CreateResponse(request, true, body: body); + } + + private Response HandleScopes(Request request) + { + var args = request.Arguments?.ToObject(); + var frameId = args?.FrameId ?? _currentFrameId; + + var context = GetExecutionContextForFrame(frameId); + if (context == null) + { + return CreateResponse(request, true, body: new ScopesResponseBody + { + Scopes = new List() + }); + } + + var scopes = _variableProvider.GetScopes(context); + return CreateResponse(request, true, body: new ScopesResponseBody + { + Scopes = scopes + }); + } + + private Response HandleVariables(Request request) + { + var args = request.Arguments?.ToObject(); + var variablesRef = args?.VariablesReference ?? 0; + + var context = GetCurrentExecutionContext(); + if (context == null) + { + return CreateResponse(request, true, body: new VariablesResponseBody + { + Variables = new List() + }); + } + + var variables = _variableProvider.GetVariables(context, variablesRef); + return CreateResponse(request, true, body: new VariablesResponseBody + { + Variables = variables + }); + } + + private async Task HandleEvaluateAsync(Request request, CancellationToken cancellationToken) + { + var args = request.Arguments?.ToObject(); + var expression = args?.Expression ?? string.Empty; + var frameId = args?.FrameId ?? _currentFrameId; + var evalContext = args?.Context ?? "hover"; + + Trace.Info("Evaluate request received"); + + // REPL context -> route through the DSL dispatcher + if (string.Equals(evalContext, "repl", StringComparison.OrdinalIgnoreCase)) + { + var result = await HandleReplInputAsync(expression, frameId, cancellationToken); + return CreateResponse(request, true, body: result); + } + + // Watch/hover/variables/clipboard -> expression evaluation only + var context = GetExecutionContextForFrame(frameId); + var evalResult = _variableProvider.EvaluateExpression(expression, context); + return CreateResponse(request, true, body: evalResult); + } + + /// + /// Routes REPL input through the DSL parser. If the input matches a + /// known command it is dispatched; otherwise it falls through to + /// expression evaluation. + /// + private async Task HandleReplInputAsync( + string input, + int frameId, + CancellationToken cancellationToken) + { + // Try to parse as a DSL command + var command = DapReplParser.TryParse(input, out var parseError); + + if (parseError != null) + { + return new EvaluateResponseBody + { + Result = parseError, + Type = "error", + VariablesReference = 0 + }; + } + + if (command != null) + { + return await DispatchReplCommandAsync(command, frameId, cancellationToken); + } + + // Not a DSL command -> evaluate as a GitHub Actions expression + // (this lets the REPL console also work for ad-hoc expression queries) + var context = GetExecutionContextForFrame(frameId); + return _variableProvider.EvaluateExpression(input, context); + } + + private async Task DispatchReplCommandAsync( + DapReplCommand command, + int frameId, + CancellationToken cancellationToken) + { + switch (command) + { + case HelpCommand help: + var helpText = string.IsNullOrEmpty(help.Topic) + ? DapReplParser.GetGeneralHelp() + : help.Topic.Equals("run", StringComparison.OrdinalIgnoreCase) + ? DapReplParser.GetRunHelp() + : $"Unknown help topic: {help.Topic}. Try: help or help(\"run\")"; + return new EvaluateResponseBody + { + Result = helpText, + Type = "string", + VariablesReference = 0 + }; + + case RunCommand run: + var context = GetExecutionContextForFrame(frameId); + return await _replExecutor.ExecuteRunCommandAsync(run, context, cancellationToken); + + default: + return new EvaluateResponseBody + { + Result = $"Unknown command type: {command.GetType().Name}", + Type = "error", + VariablesReference = 0 + }; + } + } + + private Response HandleCompletions(Request request) + { + var args = request.Arguments?.ToObject(); + var text = args?.Text ?? string.Empty; + + var items = new List(); + + // Offer DSL commands when the user is starting to type + if (string.IsNullOrEmpty(text) || "help".StartsWith(text, StringComparison.OrdinalIgnoreCase)) + { + items.Add(new CompletionItem + { + Label = "help", + Text = "help", + Detail = "Show available debug console commands", + Type = "function" + }); + } + if (string.IsNullOrEmpty(text) || "help(\"run\")".StartsWith(text, StringComparison.OrdinalIgnoreCase)) + { + items.Add(new CompletionItem + { + Label = "help(\"run\")", + Text = "help(\"run\")", + Detail = "Show help for the run command", + Type = "function" + }); + } + if (string.IsNullOrEmpty(text) || "run(".StartsWith(text, StringComparison.OrdinalIgnoreCase) + || text.StartsWith("run(", StringComparison.OrdinalIgnoreCase)) + { + items.Add(new CompletionItem + { + Label = "run(\"...\")", + Text = "run(\"", + Detail = "Execute a script (like a workflow run step)", + Type = "function" + }); + } + + return CreateResponse(request, true, body: new CompletionsResponseBody + { + Targets = items + }); + } + + private Response HandleContinue(Request request) + { + Trace.Info("Continue command received"); + + lock (_stateLock) + { + if (_state == DapSessionState.Paused) + { + _state = DapSessionState.Running; + _pauseOnNextStep = false; + _commandTcs?.TrySetResult(DapCommand.Continue); + } + } + + return CreateResponse(request, true, body: new ContinueResponseBody + { + AllThreadsContinued = true + }); + } + + private Response HandleNext(Request request) + { + Trace.Info("Next (step over) command received"); + + lock (_stateLock) + { + if (_state == DapSessionState.Paused) + { + _state = DapSessionState.Running; + _pauseOnNextStep = true; + _commandTcs?.TrySetResult(DapCommand.Next); + } + } + + return CreateResponse(request, true, body: null); + } + + private Response HandleSetBreakpoints(Request request) + { + // MVP: acknowledge but don't process breakpoints + // All steps pause automatically via _pauseOnNextStep + return CreateResponse(request, true, body: new { breakpoints = Array.Empty() }); + } + + private Response HandleSetExceptionBreakpoints(Request request) + { + // MVP: acknowledge but don't process exception breakpoints + return CreateResponse(request, true, body: null); + } + + /// + /// Blocks the step execution thread until a debugger command is received + /// or the job is cancelled. Job cancellation is handled by the registration + /// in StartAsync which sets _commandTcs to Disconnect. + /// + private async Task WaitForCommandAsync(CancellationToken cancellationToken) + { + lock (_stateLock) + { + if (_state == DapSessionState.Terminated) + { + return; + } + _state = DapSessionState.Paused; + _commandTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + + Trace.Info("Waiting for debugger command..."); + + var command = await _commandTcs.Task; + + Trace.Info("Received debugger command"); + + lock (_stateLock) + { + if (_state == DapSessionState.Paused) + { + _state = DapSessionState.Running; + } + } + + // Send continued event for normal flow commands + if (!cancellationToken.IsCancellationRequested && + (command == DapCommand.Continue || command == DapCommand.Next)) + { + SendEvent(new Event + { + EventType = "continued", + Body = new ContinuedEventBody + { + ThreadId = _jobThreadId, + AllThreadsContinued = true + } + }); + } + } + + /// + /// Resolves the execution context for a given stack frame ID. + /// Frame 1 = current step; frames 1000+ = completed steps (no + /// context available - those steps have already finished). + /// Falls back to the job-level context when no step is active. + /// + private IExecutionContext GetExecutionContextForFrame(int frameId) + { + if (frameId == _currentFrameId) + { + return GetCurrentExecutionContext(); + } + + // Completed-step frames don't carry a live execution context. + return null; + } + + private IExecutionContext GetCurrentExecutionContext() + { + lock (_stateLock) + { + return _currentStep?.ExecutionContext ?? _jobContext; + } + } + + /// + /// Sends a stopped event to the connected client. + /// Silently no-ops if no client is connected. + /// + private void SendStoppedEvent(string reason, string description) + { + if (!_isClientConnected) + { + Trace.Info("No client connected, deferring stopped event"); + return; + } + + SendEvent(new Event + { + EventType = "stopped", + Body = new StoppedEventBody + { + Reason = reason, + Description = MaskUserVisibleText(description), + ThreadId = _jobThreadId, + AllThreadsStopped = true + } + }); + } + + private string MaskUserVisibleText(string value) + { + if (string.IsNullOrEmpty(value)) + { + return value ?? string.Empty; + } + + return HostContext?.SecretMasker?.MaskSecrets(value) ?? value; + } + + /// + /// Creates a DAP response with common fields pre-populated. + /// + private Response CreateResponse(Request request, bool success, string message = null, object body = null) + { + return new Response + { + Type = "response", + RequestSeq = request.Seq, + Command = request.Command, + Success = success, + Message = success ? null : message, + Body = body + }; + } + + internal int ResolvePort() + { + var portEnv = Environment.GetEnvironmentVariable(_portEnvironmentVariable); + if (!string.IsNullOrEmpty(portEnv) && int.TryParse(portEnv, out var customPort) && customPort > 1024 && customPort <= 65535) + { + Trace.Info($"Using custom DAP port {customPort} from {_portEnvironmentVariable}"); + return customPort; + } + + return _defaultPort; + } + + internal int ResolveTimeout() + { + var timeoutEnv = Environment.GetEnvironmentVariable(_timeoutEnvironmentVariable); + if (!string.IsNullOrEmpty(timeoutEnv) && int.TryParse(timeoutEnv, out var customTimeout) && customTimeout > 0) + { + Trace.Info($"Using custom DAP timeout {customTimeout} minutes from {_timeoutEnvironmentVariable}"); + return customTimeout; + } + + return _defaultTimeoutMinutes; + } + } +} diff --git a/src/Runner.Worker/Dap/DapMessages.cs b/src/Runner.Worker/Dap/DapMessages.cs new file mode 100644 index 00000000000..53cd7a436b8 --- /dev/null +++ b/src/Runner.Worker/Dap/DapMessages.cs @@ -0,0 +1,1231 @@ +using System.Collections.Generic; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace GitHub.Runner.Worker.Dap +{ + public enum DapCommand + { + Continue, + Next, + StepIn, + StepOut, + Disconnect + } + + /// + /// Base class of requests, responses, and events per DAP specification. + /// + public class ProtocolMessage + { + /// + /// Sequence number of the message (also known as message ID). + /// The seq for the first message sent by a client or debug adapter is 1, + /// and for each subsequent message is 1 greater than the previous message. + /// + [JsonProperty("seq")] + public int Seq { get; set; } + + /// + /// Message type: 'request', 'response', 'event' + /// + [JsonProperty("type")] + public string Type { get; set; } + } + + /// + /// A client or debug adapter initiated request. + /// + public class Request : ProtocolMessage + { + /// + /// The command to execute. + /// + [JsonProperty("command")] + public string Command { get; set; } + + /// + /// Object containing arguments for the command. + /// Using JObject for flexibility with different argument types. + /// + [JsonProperty("arguments")] + public JObject Arguments { get; set; } + } + + /// + /// Response for a request. + /// + public class Response : ProtocolMessage + { + /// + /// Sequence number of the corresponding request. + /// + [JsonProperty("request_seq")] + public int RequestSeq { get; set; } + + /// + /// Outcome of the request. If true, the request was successful. + /// + [JsonProperty("success")] + public bool Success { get; set; } + + /// + /// The command requested. + /// + [JsonProperty("command")] + public string Command { get; set; } + + /// + /// Contains the raw error in short form if success is false. + /// + [JsonProperty("message", NullValueHandling = NullValueHandling.Ignore)] + public string Message { get; set; } + + /// + /// Contains request result if success is true and error details if success is false. + /// + [JsonProperty("body", NullValueHandling = NullValueHandling.Ignore)] + public object Body { get; set; } + } + + /// + /// A debug adapter initiated event. + /// + public class Event : ProtocolMessage + { + public Event() + { + Type = "event"; + } + + /// + /// Type of event. + /// + [JsonProperty("event")] + public string EventType { get; set; } + + /// + /// Event-specific information. + /// + [JsonProperty("body", NullValueHandling = NullValueHandling.Ignore)] + public object Body { get; set; } + } + + #region Initialize Request/Response + + /// + /// Arguments for 'initialize' request. + /// + public class InitializeRequestArguments + { + /// + /// The ID of the client using this adapter. + /// + [JsonProperty("clientID")] + public string ClientId { get; set; } + + /// + /// The human-readable name of the client using this adapter. + /// + [JsonProperty("clientName")] + public string ClientName { get; set; } + + /// + /// The ID of the debug adapter. + /// + [JsonProperty("adapterID")] + public string AdapterId { get; set; } + + /// + /// The ISO-639 locale of the client using this adapter, e.g. en-US or de-CH. + /// + [JsonProperty("locale")] + public string Locale { get; set; } + + /// + /// If true all line numbers are 1-based (default). + /// + [JsonProperty("linesStartAt1")] + public bool LinesStartAt1 { get; set; } = true; + + /// + /// If true all column numbers are 1-based (default). + /// + [JsonProperty("columnsStartAt1")] + public bool ColumnsStartAt1 { get; set; } = true; + + /// + /// Determines in what format paths are specified. The default is 'path'. + /// + [JsonProperty("pathFormat")] + public string PathFormat { get; set; } = "path"; + + /// + /// Client supports the type attribute for variables. + /// + [JsonProperty("supportsVariableType")] + public bool SupportsVariableType { get; set; } + + /// + /// Client supports the paging of variables. + /// + [JsonProperty("supportsVariablePaging")] + public bool SupportsVariablePaging { get; set; } + + /// + /// Client supports the runInTerminal request. + /// + [JsonProperty("supportsRunInTerminalRequest")] + public bool SupportsRunInTerminalRequest { get; set; } + + /// + /// Client supports memory references. + /// + [JsonProperty("supportsMemoryReferences")] + public bool SupportsMemoryReferences { get; set; } + + /// + /// Client supports progress reporting. + /// + [JsonProperty("supportsProgressReporting")] + public bool SupportsProgressReporting { get; set; } + } + + /// + /// Debug adapter capabilities returned in InitializeResponse. + /// + public class Capabilities + { + /// + /// The debug adapter supports the configurationDone request. + /// + [JsonProperty("supportsConfigurationDoneRequest")] + public bool SupportsConfigurationDoneRequest { get; set; } + + /// + /// The debug adapter supports function breakpoints. + /// + [JsonProperty("supportsFunctionBreakpoints")] + public bool SupportsFunctionBreakpoints { get; set; } + + /// + /// The debug adapter supports conditional breakpoints. + /// + [JsonProperty("supportsConditionalBreakpoints")] + public bool SupportsConditionalBreakpoints { get; set; } + + /// + /// The debug adapter supports a (side effect free) evaluate request for data hovers. + /// + [JsonProperty("supportsEvaluateForHovers")] + public bool SupportsEvaluateForHovers { get; set; } + + /// + /// The debug adapter supports stepping back via the stepBack and reverseContinue requests. + /// + [JsonProperty("supportsStepBack")] + public bool SupportsStepBack { get; set; } + + /// + /// The debug adapter supports setting a variable to a value. + /// + [JsonProperty("supportsSetVariable")] + public bool SupportsSetVariable { get; set; } + + /// + /// The debug adapter supports restarting a frame. + /// + [JsonProperty("supportsRestartFrame")] + public bool SupportsRestartFrame { get; set; } + + /// + /// The debug adapter supports the gotoTargets request. + /// + [JsonProperty("supportsGotoTargetsRequest")] + public bool SupportsGotoTargetsRequest { get; set; } + + /// + /// The debug adapter supports the stepInTargets request. + /// + [JsonProperty("supportsStepInTargetsRequest")] + public bool SupportsStepInTargetsRequest { get; set; } + + /// + /// The debug adapter supports the completions request. + /// + [JsonProperty("supportsCompletionsRequest")] + public bool SupportsCompletionsRequest { get; set; } + + /// + /// The debug adapter supports the modules request. + /// + [JsonProperty("supportsModulesRequest")] + public bool SupportsModulesRequest { get; set; } + + /// + /// The debug adapter supports the terminate request. + /// + [JsonProperty("supportsTerminateRequest")] + public bool SupportsTerminateRequest { get; set; } + + /// + /// The debug adapter supports the terminateDebuggee attribute on the disconnect request. + /// + [JsonProperty("supportTerminateDebuggee")] + public bool SupportTerminateDebuggee { get; set; } + + /// + /// The debug adapter supports the delayed loading of parts of the stack. + /// + [JsonProperty("supportsDelayedStackTraceLoading")] + public bool SupportsDelayedStackTraceLoading { get; set; } + + /// + /// The debug adapter supports the loadedSources request. + /// + [JsonProperty("supportsLoadedSourcesRequest")] + public bool SupportsLoadedSourcesRequest { get; set; } + + /// + /// The debug adapter supports sending progress reporting events. + /// + [JsonProperty("supportsProgressReporting")] + public bool SupportsProgressReporting { get; set; } + + /// + /// The debug adapter supports the runInTerminal request. + /// + [JsonProperty("supportsRunInTerminalRequest")] + public bool SupportsRunInTerminalRequest { get; set; } + + /// + /// The debug adapter supports the cancel request. + /// + [JsonProperty("supportsCancelRequest")] + public bool SupportsCancelRequest { get; set; } + + /// + /// The debug adapter supports exception options. + /// + [JsonProperty("supportsExceptionOptions")] + public bool SupportsExceptionOptions { get; set; } + + /// + /// The debug adapter supports value formatting options. + /// + [JsonProperty("supportsValueFormattingOptions")] + public bool SupportsValueFormattingOptions { get; set; } + + /// + /// The debug adapter supports exception info request. + /// + [JsonProperty("supportsExceptionInfoRequest")] + public bool SupportsExceptionInfoRequest { get; set; } + } + + #endregion + + #region Attach Request + + /// + /// Arguments for 'attach' request. Additional attributes are implementation specific. + /// + public class AttachRequestArguments + { + /// + /// Arbitrary data from the previous, restarted session. + /// + [JsonProperty("__restart", NullValueHandling = NullValueHandling.Ignore)] + public object Restart { get; set; } + } + + #endregion + + #region Disconnect Request + + /// + /// Arguments for 'disconnect' request. + /// + public class DisconnectRequestArguments + { + /// + /// A value of true indicates that this disconnect request is part of a restart sequence. + /// + [JsonProperty("restart")] + public bool Restart { get; set; } + + /// + /// Indicates whether the debuggee should be terminated when the debugger is disconnected. + /// + [JsonProperty("terminateDebuggee")] + public bool TerminateDebuggee { get; set; } + + /// + /// Indicates whether the debuggee should stay suspended when the debugger is disconnected. + /// + [JsonProperty("suspendDebuggee")] + public bool SuspendDebuggee { get; set; } + } + + #endregion + + #region Threads Request/Response + + /// + /// A Thread in DAP represents a unit of execution. + /// For Actions runner, we have a single thread representing the job. + /// + public class Thread + { + /// + /// Unique identifier for the thread. + /// + [JsonProperty("id")] + public int Id { get; set; } + + /// + /// The name of the thread. + /// + [JsonProperty("name")] + public string Name { get; set; } + } + + /// + /// Response body for 'threads' request. + /// + public class ThreadsResponseBody + { + /// + /// All threads. + /// + [JsonProperty("threads")] + public List Threads { get; set; } = new List(); + } + + #endregion + + #region StackTrace Request/Response + + /// + /// Arguments for 'stackTrace' request. + /// + public class StackTraceArguments + { + /// + /// Retrieve the stacktrace for this thread. + /// + [JsonProperty("threadId")] + public int ThreadId { get; set; } + + /// + /// The index of the first frame to return. + /// + [JsonProperty("startFrame")] + public int? StartFrame { get; set; } + + /// + /// The maximum number of frames to return. + /// + [JsonProperty("levels")] + public int? Levels { get; set; } + } + + /// + /// A Stackframe contains the source location. + /// For Actions runner, each step is a stack frame. + /// + public class StackFrame + { + /// + /// An identifier for the stack frame. + /// + [JsonProperty("id")] + public int Id { get; set; } + + /// + /// The name of the stack frame, typically a method name. + /// For Actions, this is the step display name. + /// + [JsonProperty("name")] + public string Name { get; set; } + + /// + /// The source of the frame. + /// + [JsonProperty("source", NullValueHandling = NullValueHandling.Ignore)] + public Source Source { get; set; } + + /// + /// The line within the source of the frame. + /// + [JsonProperty("line")] + public int Line { get; set; } + + /// + /// Start position of the range covered by the stack frame. + /// + [JsonProperty("column")] + public int Column { get; set; } + + /// + /// The end line of the range covered by the stack frame. + /// + [JsonProperty("endLine", NullValueHandling = NullValueHandling.Ignore)] + public int? EndLine { get; set; } + + /// + /// End position of the range covered by the stack frame. + /// + [JsonProperty("endColumn", NullValueHandling = NullValueHandling.Ignore)] + public int? EndColumn { get; set; } + + /// + /// A hint for how to present this frame in the UI. + /// + [JsonProperty("presentationHint", NullValueHandling = NullValueHandling.Ignore)] + public string PresentationHint { get; set; } + } + + /// + /// A Source is a descriptor for source code. + /// + public class Source + { + /// + /// The short name of the source. + /// + [JsonProperty("name", NullValueHandling = NullValueHandling.Ignore)] + public string Name { get; set; } + + /// + /// The path of the source to be shown in the UI. + /// + [JsonProperty("path", NullValueHandling = NullValueHandling.Ignore)] + public string Path { get; set; } + + /// + /// If the value > 0 the contents of the source must be retrieved through + /// the 'source' request (even if a path is specified). + /// + [JsonProperty("sourceReference", NullValueHandling = NullValueHandling.Ignore)] + public int? SourceReference { get; set; } + + /// + /// A hint for how to present the source in the UI. + /// + [JsonProperty("presentationHint", NullValueHandling = NullValueHandling.Ignore)] + public string PresentationHint { get; set; } + } + + /// + /// Response body for 'stackTrace' request. + /// + public class StackTraceResponseBody + { + /// + /// The frames of the stack frame. + /// + [JsonProperty("stackFrames")] + public List StackFrames { get; set; } = new List(); + + /// + /// The total number of frames available in the stack. + /// + [JsonProperty("totalFrames", NullValueHandling = NullValueHandling.Ignore)] + public int? TotalFrames { get; set; } + } + + #endregion + + #region Scopes Request/Response + + /// + /// Arguments for 'scopes' request. + /// + public class ScopesArguments + { + /// + /// Retrieve the scopes for the stack frame identified by frameId. + /// + [JsonProperty("frameId")] + public int FrameId { get; set; } + } + + /// + /// A Scope is a named container for variables. + /// For Actions runner, scopes are: github, env, inputs, steps, secrets, runner, job + /// + public class Scope + { + /// + /// Name of the scope such as 'Arguments', 'Locals', or 'Registers'. + /// For Actions: 'github', 'env', 'inputs', 'steps', 'secrets', 'runner', 'job' + /// + [JsonProperty("name")] + public string Name { get; set; } + + /// + /// A hint for how to present this scope in the UI. + /// + [JsonProperty("presentationHint", NullValueHandling = NullValueHandling.Ignore)] + public string PresentationHint { get; set; } + + /// + /// The variables of this scope can be retrieved by passing the value of + /// variablesReference to the variables request. + /// + [JsonProperty("variablesReference")] + public int VariablesReference { get; set; } + + /// + /// The number of named variables in this scope. + /// + [JsonProperty("namedVariables", NullValueHandling = NullValueHandling.Ignore)] + public int? NamedVariables { get; set; } + + /// + /// The number of indexed variables in this scope. + /// + [JsonProperty("indexedVariables", NullValueHandling = NullValueHandling.Ignore)] + public int? IndexedVariables { get; set; } + + /// + /// If true, the number of variables in this scope is large or expensive to retrieve. + /// + [JsonProperty("expensive")] + public bool Expensive { get; set; } + + /// + /// The source for this scope. + /// + [JsonProperty("source", NullValueHandling = NullValueHandling.Ignore)] + public Source Source { get; set; } + + /// + /// The start line of the range covered by this scope. + /// + [JsonProperty("line", NullValueHandling = NullValueHandling.Ignore)] + public int? Line { get; set; } + + /// + /// Start position of the range covered by this scope. + /// + [JsonProperty("column", NullValueHandling = NullValueHandling.Ignore)] + public int? Column { get; set; } + + /// + /// The end line of the range covered by this scope. + /// + [JsonProperty("endLine", NullValueHandling = NullValueHandling.Ignore)] + public int? EndLine { get; set; } + + /// + /// End position of the range covered by this scope. + /// + [JsonProperty("endColumn", NullValueHandling = NullValueHandling.Ignore)] + public int? EndColumn { get; set; } + } + + /// + /// Response body for 'scopes' request. + /// + public class ScopesResponseBody + { + /// + /// The scopes of the stack frame. + /// + [JsonProperty("scopes")] + public List Scopes { get; set; } = new List(); + } + + #endregion + + #region Variables Request/Response + + /// + /// Arguments for 'variables' request. + /// + public class VariablesArguments + { + /// + /// The variable for which to retrieve its children. + /// + [JsonProperty("variablesReference")] + public int VariablesReference { get; set; } + + /// + /// Filter to limit the child variables to either named or indexed. + /// + [JsonProperty("filter", NullValueHandling = NullValueHandling.Ignore)] + public string Filter { get; set; } + + /// + /// The index of the first variable to return. + /// + [JsonProperty("start", NullValueHandling = NullValueHandling.Ignore)] + public int? Start { get; set; } + + /// + /// The number of variables to return. + /// + [JsonProperty("count", NullValueHandling = NullValueHandling.Ignore)] + public int? Count { get; set; } + } + + /// + /// A Variable is a name/value pair. + /// + public class Variable + { + /// + /// The variable's name. + /// + [JsonProperty("name")] + public string Name { get; set; } + + /// + /// The variable's value. + /// + [JsonProperty("value")] + public string Value { get; set; } + + /// + /// The type of the variable's value. + /// + [JsonProperty("type", NullValueHandling = NullValueHandling.Ignore)] + public string Type { get; set; } + + /// + /// If variablesReference is > 0, the variable is structured and its children + /// can be retrieved by passing variablesReference to the variables request. + /// + [JsonProperty("variablesReference")] + public int VariablesReference { get; set; } + + /// + /// The number of named child variables. + /// + [JsonProperty("namedVariables", NullValueHandling = NullValueHandling.Ignore)] + public int? NamedVariables { get; set; } + + /// + /// The number of indexed child variables. + /// + [JsonProperty("indexedVariables", NullValueHandling = NullValueHandling.Ignore)] + public int? IndexedVariables { get; set; } + + /// + /// A memory reference to a location appropriate for this result. + /// + [JsonProperty("memoryReference", NullValueHandling = NullValueHandling.Ignore)] + public string MemoryReference { get; set; } + + /// + /// A reference that allows the client to request the location where the + /// variable's value is declared. + /// + [JsonProperty("declarationLocationReference", NullValueHandling = NullValueHandling.Ignore)] + public int? DeclarationLocationReference { get; set; } + + /// + /// The evaluatable name of this variable which can be passed to the evaluate + /// request to fetch the variable's value. + /// + [JsonProperty("evaluateName", NullValueHandling = NullValueHandling.Ignore)] + public string EvaluateName { get; set; } + } + + /// + /// Response body for 'variables' request. + /// + public class VariablesResponseBody + { + /// + /// All (or a range) of variables for the given variable reference. + /// + [JsonProperty("variables")] + public List Variables { get; set; } = new List(); + } + + #endregion + + #region Continue Request/Response + + /// + /// Arguments for 'continue' request. + /// + public class ContinueArguments + { + /// + /// Specifies the active thread. If the debug adapter supports single thread + /// execution, setting this will resume only the specified thread. + /// + [JsonProperty("threadId")] + public int ThreadId { get; set; } + + /// + /// If this flag is true, execution is resumed only for the thread with given + /// threadId. If false, all threads are resumed. + /// + [JsonProperty("singleThread")] + public bool SingleThread { get; set; } + } + + /// + /// Response body for 'continue' request. + /// + public class ContinueResponseBody + { + /// + /// If true, all threads are resumed. If false, only the thread with the given + /// threadId is resumed. + /// + [JsonProperty("allThreadsContinued")] + public bool AllThreadsContinued { get; set; } = true; + } + + #endregion + + #region Next Request + + /// + /// Arguments for 'next' request. + /// + public class NextArguments + { + /// + /// Specifies the thread for which to resume execution for one step. + /// + [JsonProperty("threadId")] + public int ThreadId { get; set; } + + /// + /// Stepping granularity. + /// + [JsonProperty("granularity", NullValueHandling = NullValueHandling.Ignore)] + public string Granularity { get; set; } + + /// + /// If this flag is true, all other suspended threads are not resumed. + /// + [JsonProperty("singleThread")] + public bool SingleThread { get; set; } + } + + #endregion + + #region Evaluate Request/Response + + /// + /// Arguments for 'evaluate' request. + /// + public class EvaluateArguments + { + /// + /// The expression to evaluate. + /// + [JsonProperty("expression")] + public string Expression { get; set; } + + /// + /// Evaluate the expression in the scope of this stack frame. + /// + [JsonProperty("frameId", NullValueHandling = NullValueHandling.Ignore)] + public int? FrameId { get; set; } + + /// + /// The context in which the evaluate request is used. + /// Values: 'watch', 'repl', 'hover', 'clipboard', 'variables' + /// + [JsonProperty("context", NullValueHandling = NullValueHandling.Ignore)] + public string Context { get; set; } + } + + /// + /// Response body for 'evaluate' request. + /// + public class EvaluateResponseBody + { + /// + /// The result of the evaluate request. + /// + [JsonProperty("result")] + public string Result { get; set; } + + /// + /// The type of the evaluate result. + /// + [JsonProperty("type", NullValueHandling = NullValueHandling.Ignore)] + public string Type { get; set; } + + /// + /// If variablesReference is > 0, the evaluate result is structured. + /// + [JsonProperty("variablesReference")] + public int VariablesReference { get; set; } + + /// + /// The number of named child variables. + /// + [JsonProperty("namedVariables", NullValueHandling = NullValueHandling.Ignore)] + public int? NamedVariables { get; set; } + + /// + /// The number of indexed child variables. + /// + [JsonProperty("indexedVariables", NullValueHandling = NullValueHandling.Ignore)] + public int? IndexedVariables { get; set; } + + /// + /// A memory reference to a location appropriate for this result. + /// + [JsonProperty("memoryReference", NullValueHandling = NullValueHandling.Ignore)] + public string MemoryReference { get; set; } + } + + #endregion + + #region Completions Request/Response + + /// + /// Arguments for 'completions' request. + /// + public class CompletionsArguments + { + /// + /// Returns completions in the scope of this stack frame. + /// + [JsonProperty("frameId", NullValueHandling = NullValueHandling.Ignore)] + public int? FrameId { get; set; } + + /// + /// One or more source lines. Typically this is the text users have typed + /// in the debug console (REPL). + /// + [JsonProperty("text")] + public string Text { get; set; } + + /// + /// The position within 'text' for which to determine the completion proposals. + /// It is measured in UTF-16 code units. + /// + [JsonProperty("column")] + public int Column { get; set; } + + /// + /// A line for which to determine the completion proposals. + /// If missing the first line of the text is assumed. + /// + [JsonProperty("line", NullValueHandling = NullValueHandling.Ignore)] + public int? Line { get; set; } + } + + /// + /// A completion item in the debug console. + /// + public class CompletionItem + { + /// + /// The label of this completion item. + /// + [JsonProperty("label")] + public string Label { get; set; } + + /// + /// If text is returned and not an empty string, then it is inserted instead + /// of the label. + /// + [JsonProperty("text", NullValueHandling = NullValueHandling.Ignore)] + public string Text { get; set; } + + /// + /// A human-readable string with additional information about this item. + /// + [JsonProperty("detail", NullValueHandling = NullValueHandling.Ignore)] + public string Detail { get; set; } + + /// + /// The item's type. Typically the client uses this information to render the item + /// in the UI with an icon. + /// Values: 'method', 'function', 'constructor', 'field', 'variable', 'class', + /// 'interface', 'module', 'property', 'unit', 'value', 'enum', 'keyword', + /// 'snippet', 'text', 'color', 'file', 'reference', 'customcolor' + /// + [JsonProperty("type", NullValueHandling = NullValueHandling.Ignore)] + public string Type { get; set; } + + /// + /// Start position (0-based) within 'text' that should be replaced + /// by the completion text. + /// + [JsonProperty("start", NullValueHandling = NullValueHandling.Ignore)] + public int? Start { get; set; } + + /// + /// Length of the text that should be replaced by the completion text. + /// + [JsonProperty("length", NullValueHandling = NullValueHandling.Ignore)] + public int? Length { get; set; } + } + + /// + /// Response body for 'completions' request. + /// + public class CompletionsResponseBody + { + /// + /// The possible completions. + /// + [JsonProperty("targets")] + public List Targets { get; set; } = new List(); + } + + #endregion + + #region Events + + /// + /// Body for 'stopped' event. + /// The event indicates that the execution of the debuggee has stopped. + /// + public class StoppedEventBody + { + /// + /// The reason for the event. For backward compatibility this string is shown + /// in the UI if the description attribute is missing. + /// Values: 'step', 'breakpoint', 'exception', 'pause', 'entry', 'goto', + /// 'function breakpoint', 'data breakpoint', 'instruction breakpoint' + /// + [JsonProperty("reason")] + public string Reason { get; set; } + + /// + /// The full reason for the event, e.g. 'Paused on exception'. + /// This string is shown in the UI as is and can be translated. + /// + [JsonProperty("description", NullValueHandling = NullValueHandling.Ignore)] + public string Description { get; set; } + + /// + /// The thread which was stopped. + /// + [JsonProperty("threadId", NullValueHandling = NullValueHandling.Ignore)] + public int? ThreadId { get; set; } + + /// + /// A value of true hints to the client that this event should not change the focus. + /// + [JsonProperty("preserveFocusHint", NullValueHandling = NullValueHandling.Ignore)] + public bool? PreserveFocusHint { get; set; } + + /// + /// Additional information. E.g. if reason is 'exception', text contains the + /// exception name. + /// + [JsonProperty("text", NullValueHandling = NullValueHandling.Ignore)] + public string Text { get; set; } + + /// + /// If allThreadsStopped is true, a debug adapter can announce that all threads + /// have stopped. + /// + [JsonProperty("allThreadsStopped", NullValueHandling = NullValueHandling.Ignore)] + public bool? AllThreadsStopped { get; set; } + + /// + /// Ids of the breakpoints that triggered the event. + /// + [JsonProperty("hitBreakpointIds", NullValueHandling = NullValueHandling.Ignore)] + public List HitBreakpointIds { get; set; } + } + + /// + /// Body for 'continued' event. + /// The event indicates that the execution of the debuggee has continued. + /// + public class ContinuedEventBody + { + /// + /// The thread which was continued. + /// + [JsonProperty("threadId")] + public int ThreadId { get; set; } + + /// + /// If true, all threads have been resumed. + /// + [JsonProperty("allThreadsContinued", NullValueHandling = NullValueHandling.Ignore)] + public bool? AllThreadsContinued { get; set; } + } + + /// + /// Body for 'terminated' event. + /// The event indicates that debugging of the debuggee has terminated. + /// + public class TerminatedEventBody + { + /// + /// A debug adapter may set restart to true to request that the client + /// restarts the session. + /// + [JsonProperty("restart", NullValueHandling = NullValueHandling.Ignore)] + public object Restart { get; set; } + } + + /// + /// Body for 'output' event. + /// The event indicates that the target has produced some output. + /// + public class OutputEventBody + { + /// + /// The output category. If not specified, 'console' is assumed. + /// Values: 'console', 'important', 'stdout', 'stderr', 'telemetry' + /// + [JsonProperty("category", NullValueHandling = NullValueHandling.Ignore)] + public string Category { get; set; } + + /// + /// The output to report. + /// + [JsonProperty("output")] + public string Output { get; set; } + + /// + /// Support for keeping an output log organized by grouping related messages. + /// Values: 'start', 'startCollapsed', 'end' + /// + [JsonProperty("group", NullValueHandling = NullValueHandling.Ignore)] + public string Group { get; set; } + + /// + /// If variablesReference is > 0, the output contains objects which can be + /// retrieved by passing variablesReference to the variables request. + /// + [JsonProperty("variablesReference", NullValueHandling = NullValueHandling.Ignore)] + public int? VariablesReference { get; set; } + + /// + /// The source location where the output was produced. + /// + [JsonProperty("source", NullValueHandling = NullValueHandling.Ignore)] + public Source Source { get; set; } + + /// + /// The source location's line where the output was produced. + /// + [JsonProperty("line", NullValueHandling = NullValueHandling.Ignore)] + public int? Line { get; set; } + + /// + /// The position in line where the output was produced. + /// + [JsonProperty("column", NullValueHandling = NullValueHandling.Ignore)] + public int? Column { get; set; } + + /// + /// Additional data to report. + /// + [JsonProperty("data", NullValueHandling = NullValueHandling.Ignore)] + public object Data { get; set; } + } + + /// + /// Body for 'thread' event. + /// The event indicates that a thread has started or exited. + /// + public class ThreadEventBody + { + /// + /// The reason for the event. + /// Values: 'started', 'exited' + /// + [JsonProperty("reason")] + public string Reason { get; set; } + + /// + /// The identifier of the thread. + /// + [JsonProperty("threadId")] + public int ThreadId { get; set; } + } + + /// + /// Body for 'exited' event. + /// The event indicates that the debuggee has exited and returns its exit code. + /// + public class ExitedEventBody + { + /// + /// The exit code returned from the debuggee. + /// + [JsonProperty("exitCode")] + public int ExitCode { get; set; } + } + + #endregion + + #region Error Response + + /// + /// A structured error message. + /// + public class Message + { + /// + /// Unique identifier for the message. + /// + [JsonProperty("id")] + public int Id { get; set; } + + /// + /// A format string for the message. + /// + [JsonProperty("format")] + public string Format { get; set; } + + /// + /// An object used as a dictionary for looking up the variables in the format string. + /// + [JsonProperty("variables", NullValueHandling = NullValueHandling.Ignore)] + public Dictionary Variables { get; set; } + + /// + /// If true send to telemetry. + /// + [JsonProperty("sendTelemetry", NullValueHandling = NullValueHandling.Ignore)] + public bool? SendTelemetry { get; set; } + + /// + /// If true show user. + /// + [JsonProperty("showUser", NullValueHandling = NullValueHandling.Ignore)] + public bool? ShowUser { get; set; } + + /// + /// A url where additional information about this message can be found. + /// + [JsonProperty("url", NullValueHandling = NullValueHandling.Ignore)] + public string Url { get; set; } + + /// + /// A label that is presented to the user as the UI for opening the url. + /// + [JsonProperty("urlLabel", NullValueHandling = NullValueHandling.Ignore)] + public string UrlLabel { get; set; } + } + + /// + /// Body for error responses. + /// + public class ErrorResponseBody + { + /// + /// A structured error message. + /// + [JsonProperty("error", NullValueHandling = NullValueHandling.Ignore)] + public Message Error { get; set; } + } + + #endregion +} diff --git a/src/Runner.Worker/Dap/DapReplExecutor.cs b/src/Runner.Worker/Dap/DapReplExecutor.cs new file mode 100644 index 00000000000..751f92c514c --- /dev/null +++ b/src/Runner.Worker/Dap/DapReplExecutor.cs @@ -0,0 +1,369 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using GitHub.DistributedTask.Pipelines.ContextData; +using GitHub.Runner.Common; +using GitHub.Runner.Common.Util; +using GitHub.Runner.Sdk; +using GitHub.Runner.Worker.Handlers; + +namespace GitHub.Runner.Worker.Dap +{ + /// + /// Executes objects in the job's runtime context. + /// + /// Mirrors the behavior of a normal workflow run: step as closely + /// as possible by reusing the runner's existing shell-resolution logic, + /// script fixup helpers, and process execution infrastructure. + /// + /// Output is streamed to the debugger via DAP output events with + /// secrets masked before emission. + /// + internal sealed class DapReplExecutor + { + private readonly IHostContext _hostContext; + private readonly Action _sendOutput; + private readonly Tracing _trace; + + public DapReplExecutor(IHostContext hostContext, Action sendOutput) + { + _hostContext = hostContext ?? throw new ArgumentNullException(nameof(hostContext)); + _sendOutput = sendOutput ?? throw new ArgumentNullException(nameof(sendOutput)); + _trace = hostContext.GetTrace(nameof(DapReplExecutor)); + } + + /// + /// Executes a and returns the exit code as a + /// formatted . + /// + public async Task ExecuteRunCommandAsync( + RunCommand command, + IExecutionContext context, + CancellationToken cancellationToken) + { + if (context == null) + { + return ErrorResult("No execution context available. The debugger must be paused at a step to run commands."); + } + + try + { + return await ExecuteScriptAsync(command, context, cancellationToken); + } + catch (Exception ex) + { + _trace.Error($"REPL run command failed ({ex.GetType().Name})"); + var maskedError = _hostContext.SecretMasker.MaskSecrets(ex.Message); + return ErrorResult($"Command failed: {maskedError}"); + } + } + + private async Task ExecuteScriptAsync( + RunCommand command, + IExecutionContext context, + CancellationToken cancellationToken) + { + // 1. Resolve shell — same logic as ScriptHandler + string shellCommand; + string argFormat; + + if (!string.IsNullOrEmpty(command.Shell)) + { + // Explicit shell from the DSL + var parsed = ScriptHandlerHelpers.ParseShellOptionString(command.Shell); + shellCommand = parsed.shellCommand; + argFormat = string.IsNullOrEmpty(parsed.shellArgs) + ? ScriptHandlerHelpers.GetScriptArgumentsFormat(shellCommand) + : parsed.shellArgs; + } + else + { + // Default shell — mirrors ScriptHandler platform defaults + shellCommand = ResolveDefaultShell(context); + argFormat = ScriptHandlerHelpers.GetScriptArgumentsFormat(shellCommand); + } + + _trace.Info("Resolved REPL shell"); + + // 2. Expand ${{ }} expressions in the script body, just like + // ActionRunner evaluates step inputs before ScriptHandler sees them + var contents = ExpandExpressions(command.Script, context); + contents = ScriptHandlerHelpers.FixUpScriptContents(shellCommand, contents); + + // Write to a temp file (same pattern as ScriptHandler) + var extension = ScriptHandlerHelpers.GetScriptFileExtension(shellCommand); + var scriptFilePath = Path.Combine( + _hostContext.GetDirectory(WellKnownDirectory.Temp), + $"dap_repl_{Guid.NewGuid()}{extension}"); + + Encoding encoding = new UTF8Encoding(false); +#if OS_WINDOWS + contents = contents.Replace("\r\n", "\n").Replace("\n", "\r\n"); + encoding = Console.InputEncoding.CodePage != 65001 + ? Console.InputEncoding + : encoding; +#endif + File.WriteAllText(scriptFilePath, contents, encoding); + + try + { + // 3. Format arguments with script path + var resolvedPath = scriptFilePath.Replace("\"", "\\\""); + if (string.IsNullOrEmpty(argFormat) || !argFormat.Contains("{0}")) + { + return ErrorResult($"Invalid shell option '{shellCommand}'. Shell must be a valid built-in (bash, sh, cmd, powershell, pwsh) or a format string containing '{{0}}'"); + } + var arguments = string.Format(argFormat, resolvedPath); + + // 4. Resolve shell command path + string prependPath = string.Join( + Path.PathSeparator.ToString(), + Enumerable.Reverse(context.Global.PrependPath)); + var commandPath = WhichUtil.Which(shellCommand, false, _trace, prependPath) + ?? shellCommand; + + // 5. Build environment — merge from execution context like a real step + var environment = BuildEnvironment(context, command.Env); + + // 6. Resolve working directory + var workingDirectory = command.WorkingDirectory; + if (string.IsNullOrEmpty(workingDirectory)) + { + var githubContext = context.ExpressionValues.TryGetValue("github", out var gh) + ? gh as DictionaryContextData + : null; + var workspace = githubContext?.TryGetValue("workspace", out var ws) == true + ? (ws as StringContextData)?.Value + : null; + workingDirectory = workspace ?? _hostContext.GetDirectory(WellKnownDirectory.Work); + } + + _trace.Info("Executing REPL command"); + + // Stream execution info to debugger + SendOutput("console", $"$ {shellCommand} {command.Script.Substring(0, Math.Min(command.Script.Length, 80))}{(command.Script.Length > 80 ? "..." : "")}\n"); + + // 7. Execute via IProcessInvoker (same as DefaultStepHost) + int exitCode; + using (var processInvoker = _hostContext.CreateService()) + { + processInvoker.OutputDataReceived += (sender, args) => + { + if (!string.IsNullOrEmpty(args.Data)) + { + var masked = _hostContext.SecretMasker.MaskSecrets(args.Data); + SendOutput("stdout", masked + "\n"); + } + }; + + processInvoker.ErrorDataReceived += (sender, args) => + { + if (!string.IsNullOrEmpty(args.Data)) + { + var masked = _hostContext.SecretMasker.MaskSecrets(args.Data); + SendOutput("stderr", masked + "\n"); + } + }; + + exitCode = await processInvoker.ExecuteAsync( + workingDirectory: workingDirectory, + fileName: commandPath, + arguments: arguments, + environment: environment, + requireExitCodeZero: false, + outputEncoding: null, + killProcessOnCancel: true, + cancellationToken: cancellationToken); + } + + _trace.Info($"REPL command exited with code {exitCode}"); + + // 8. Return only the exit code summary (output was already streamed) + return new EvaluateResponseBody + { + Result = exitCode == 0 ? $"(exit code: {exitCode})" : $"Process completed with exit code {exitCode}.", + Type = exitCode == 0 ? "string" : "error", + VariablesReference = 0 + }; + } + finally + { + // Clean up temp script file + try { File.Delete(scriptFilePath); } + catch { /* best effort */ } + } + } + + /// + /// Expands ${{ }} expressions in the input string using the + /// runner's template evaluator — the same evaluation path that processes + /// step inputs before runs them. + /// + /// Each ${{ expr }} occurrence is individually evaluated and + /// replaced with its masked string result, mirroring the semantics of + /// expression interpolation in a workflow run: step body. + /// + internal string ExpandExpressions(string input, IExecutionContext context) + { + if (string.IsNullOrEmpty(input) || !input.Contains("${{")) + { + return input ?? string.Empty; + } + + var result = new StringBuilder(); + int pos = 0; + + while (pos < input.Length) + { + var start = input.IndexOf("${{", pos, StringComparison.Ordinal); + if (start < 0) + { + result.Append(input, pos, input.Length - pos); + break; + } + + // Append the literal text before the expression + result.Append(input, pos, start - pos); + + var end = input.IndexOf("}}", start + 3, StringComparison.Ordinal); + if (end < 0) + { + // Unterminated expression — keep literal + result.Append(input, start, input.Length - start); + break; + } + + var expr = input.Substring(start + 3, end - start - 3).Trim(); + end += 2; // skip past "}}" + + // Evaluate the expression + try + { + var templateEvaluator = context.ToPipelineTemplateEvaluator(); + var token = new GitHub.DistributedTask.ObjectTemplating.Tokens.BasicExpressionToken( + null, null, null, expr); + var evaluated = templateEvaluator.EvaluateStepDisplayName( + token, + context.ExpressionValues, + context.ExpressionFunctions); + result.Append(_hostContext.SecretMasker.MaskSecrets(evaluated ?? string.Empty)); + } + catch (Exception ex) + { + _trace.Warning($"Expression expansion failed ({ex.GetType().Name})"); + // Keep the original expression literal on failure + result.Append(input, start, end - start); + } + + pos = end; + } + + return result.ToString(); + } + + /// + /// Resolves the default shell the same way + /// does: check job defaults, then fall back to platform default. + /// + internal string ResolveDefaultShell(IExecutionContext context) + { + // Check job defaults + if (context.Global?.JobDefaults != null && + context.Global.JobDefaults.TryGetValue("run", out var runDefaults) && + runDefaults.TryGetValue("shell", out var defaultShell) && + !string.IsNullOrEmpty(defaultShell)) + { + _trace.Info("Using job default shell"); + return defaultShell; + } + +#if OS_WINDOWS + string prependPath = string.Join( + Path.PathSeparator.ToString(), + context.Global?.PrependPath != null ? Enumerable.Reverse(context.Global.PrependPath) : Array.Empty()); + var pwshPath = WhichUtil.Which("pwsh", false, _trace, prependPath); + return !string.IsNullOrEmpty(pwshPath) ? "pwsh" : "powershell"; +#else + return "sh"; +#endif + } + + /// + /// Merges the job context environment with any REPL-specific overrides. + /// + internal Dictionary BuildEnvironment( + IExecutionContext context, + Dictionary replEnv) + { + var env = new Dictionary(VarUtil.EnvironmentVariableKeyComparer); + + // Pull environment from the execution context (same as ActionRunner) + if (context.ExpressionValues.TryGetValue("env", out var envData)) + { + if (envData is DictionaryContextData dictEnv) + { + foreach (var pair in dictEnv) + { + if (pair.Value is StringContextData str) + { + env[pair.Key] = str.Value; + } + } + } + else if (envData is CaseSensitiveDictionaryContextData csEnv) + { + foreach (var pair in csEnv) + { + if (pair.Value is StringContextData str) + { + env[pair.Key] = str.Value; + } + } + } + } + + // Expose runtime context variables to the environment (GITHUB_*, RUNNER_*, etc.) + foreach (var ctxPair in context.ExpressionValues) + { + if (ctxPair.Value is IEnvironmentContextData runtimeContext && runtimeContext != null) + { + foreach (var rtEnv in runtimeContext.GetRuntimeEnvironmentVariables()) + { + env[rtEnv.Key] = rtEnv.Value; + } + } + } + + // Apply REPL-specific overrides last (so they win), + // expanding any ${{ }} expressions in the values + if (replEnv != null) + { + foreach (var pair in replEnv) + { + env[pair.Key] = ExpandExpressions(pair.Value, context); + } + } + + return env; + } + + private void SendOutput(string category, string text) + { + _sendOutput(category, text); + } + + private static EvaluateResponseBody ErrorResult(string message) + { + return new EvaluateResponseBody + { + Result = message, + Type = "error", + VariablesReference = 0 + }; + } + } +} diff --git a/src/Runner.Worker/Dap/DapReplParser.cs b/src/Runner.Worker/Dap/DapReplParser.cs new file mode 100644 index 00000000000..c23f4c181a2 --- /dev/null +++ b/src/Runner.Worker/Dap/DapReplParser.cs @@ -0,0 +1,411 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace GitHub.Runner.Worker.Dap +{ + /// + /// Base type for all REPL DSL commands. + /// + internal abstract class DapReplCommand + { + } + + /// + /// help or help("run") + /// + internal sealed class HelpCommand : DapReplCommand + { + public string Topic { get; set; } + } + + /// + /// run("echo hello") or + /// run("echo hello", shell: "bash", env: { FOO: "bar" }, working_directory: "/tmp") + /// + internal sealed class RunCommand : DapReplCommand + { + public string Script { get; set; } + public string Shell { get; set; } + public Dictionary Env { get; set; } + public string WorkingDirectory { get; set; } + } + + /// + /// Parses REPL input into typed objects. + /// + /// Grammar (intentionally minimal — extend as the DSL grows): + /// + /// help → HelpCommand { Topic = null } + /// help("run") → HelpCommand { Topic = "run" } + /// run("script body") → RunCommand { Script = "script body" } + /// run("script", shell: "bash") → RunCommand { Shell = "bash" } + /// run("script", env: { K: "V" }) → RunCommand { Env = { K → V } } + /// run("script", working_directory: "p")→ RunCommand { WorkingDirectory = "p" } + /// + /// + /// Parsing is intentionally hand-rolled rather than regex-based so it can + /// handle nested braces, quoted strings with escapes, and grow to support + /// future commands without accumulating regex complexity. + /// + internal static class DapReplParser + { + /// + /// Attempts to parse REPL input into a command. Returns null if the + /// input does not match any known DSL command (i.e. it should be + /// treated as an expression instead). + /// + internal static DapReplCommand TryParse(string input, out string error) + { + error = null; + if (string.IsNullOrWhiteSpace(input)) + { + return null; + } + + var trimmed = input.Trim(); + + // help / help("topic") + if (trimmed.Equals("help", StringComparison.OrdinalIgnoreCase) || + trimmed.StartsWith("help(", StringComparison.OrdinalIgnoreCase)) + { + return ParseHelp(trimmed, out error); + } + + // run("...") + if (trimmed.StartsWith("run(", StringComparison.OrdinalIgnoreCase)) + { + return ParseRun(trimmed, out error); + } + + // Not a DSL command + return null; + } + + internal static string GetGeneralHelp() + { + return """ + Actions Debug Console + + Commands: + help Show this help + help("run") Show help for the run command + run("script") Execute a script (like a workflow run step) + + Anything else is evaluated as a GitHub Actions expression. + Example: github.repository + Example: ${{ github.event_name }} + + """; + } + + internal static string GetRunHelp() + { + return """ + run command — execute a script in the job context + + Usage: + run("echo hello") + run("echo $FOO", shell: "bash") + run("echo $FOO", env: { FOO: "bar" }) + run("ls", working_directory: "/tmp") + run("echo $X", shell: "bash", env: { X: "1" }, working_directory: "/tmp") + + Options: + shell: Shell to use (default: job default, e.g. bash) + env: Extra environment variables as { KEY: "value" } + working_directory: Working directory for the command + + Behavior: + - Equivalent to a workflow `run:` step + - Expressions in the script body are expanded (${{ ... }}) + - Output is streamed in real time and secrets are masked + + """; + } + + #region Parsers + + private static HelpCommand ParseHelp(string input, out string error) + { + error = null; + if (input.Equals("help", StringComparison.OrdinalIgnoreCase)) + { + return new HelpCommand(); + } + + // help("topic") + var inner = ExtractParenthesizedArgs(input, "help", out error); + if (error != null) return null; + + var topic = ExtractQuotedString(inner.Trim(), out error); + if (error != null) return null; + + return new HelpCommand { Topic = topic }; + } + + private static RunCommand ParseRun(string input, out string error) + { + error = null; + + var inner = ExtractParenthesizedArgs(input, "run", out error); + if (error != null) return null; + + // Split into argument list respecting quotes and braces + var args = SplitArguments(inner, out error); + if (error != null) return null; + if (args.Count == 0) + { + error = "run() requires a script argument. Example: run(\"echo hello\")"; + return null; + } + + // First arg must be the script body (a quoted string) + var script = ExtractQuotedString(args[0].Trim(), out error); + if (error != null) + { + error = $"First argument to run() must be a quoted string. {error}"; + return null; + } + + var cmd = new RunCommand { Script = script }; + + // Parse remaining keyword arguments + for (int i = 1; i < args.Count; i++) + { + var kv = args[i].Trim(); + var colonIdx = kv.IndexOf(':'); + if (colonIdx <= 0) + { + error = $"Expected keyword argument (e.g. shell: \"bash\"), got: {kv}"; + return null; + } + + var key = kv.Substring(0, colonIdx).Trim(); + var value = kv.Substring(colonIdx + 1).Trim(); + + switch (key.ToLowerInvariant()) + { + case "shell": + cmd.Shell = ExtractQuotedString(value, out error); + if (error != null) { error = $"shell: {error}"; return null; } + break; + + case "working_directory": + cmd.WorkingDirectory = ExtractQuotedString(value, out error); + if (error != null) { error = $"working_directory: {error}"; return null; } + break; + + case "env": + cmd.Env = ParseEnvBlock(value, out error); + if (error != null) { error = $"env: {error}"; return null; } + break; + + default: + error = $"Unknown option: {key}. Valid options: shell, env, working_directory"; + return null; + } + } + + return cmd; + } + + #endregion + + #region Low-level parsing helpers + + /// + /// Given "cmd(...)" returns the inner content between the outer parens. + /// + private static string ExtractParenthesizedArgs(string input, string prefix, out string error) + { + error = null; + var start = prefix.Length; // skip "cmd" + if (start >= input.Length || input[start] != '(') + { + error = $"Expected '(' after {prefix}"; + return null; + } + + if (input[input.Length - 1] != ')') + { + error = $"Expected ')' at end of {prefix}(...)"; + return null; + } + + return input.Substring(start + 1, input.Length - start - 2); + } + + /// + /// Extracts a double-quoted string value, handling escaped quotes. + /// + internal static string ExtractQuotedString(string input, out string error) + { + error = null; + if (string.IsNullOrEmpty(input)) + { + error = "Expected a quoted string, got empty input"; + return null; + } + + if (input[0] != '"') + { + error = $"Expected a quoted string starting with \", got: {Truncate(input, 40)}"; + return null; + } + + var sb = new StringBuilder(); + for (int i = 1; i < input.Length; i++) + { + if (input[i] == '\\' && i + 1 < input.Length) + { + sb.Append(input[i + 1]); + i++; + } + else if (input[i] == '"') + { + // Check nothing meaningful follows the closing quote + var rest = input.Substring(i + 1).Trim(); + if (rest.Length > 0) + { + error = $"Unexpected content after closing quote: {Truncate(rest, 40)}"; + return null; + } + return sb.ToString(); + } + else + { + sb.Append(input[i]); + } + } + + error = "Unterminated string (missing closing \")"; + return null; + } + + /// + /// Splits a comma-separated argument list, respecting quoted strings + /// and nested braces so that "a, b", env: { K: "V, W" } is + /// correctly split into two arguments. + /// + internal static List SplitArguments(string input, out string error) + { + error = null; + var result = new List(); + var current = new StringBuilder(); + int depth = 0; + bool inQuote = false; + + for (int i = 0; i < input.Length; i++) + { + var ch = input[i]; + + if (ch == '\\' && inQuote && i + 1 < input.Length) + { + current.Append(ch); + current.Append(input[++i]); + continue; + } + + if (ch == '"') + { + inQuote = !inQuote; + current.Append(ch); + continue; + } + + if (!inQuote) + { + if (ch == '{') + { + depth++; + current.Append(ch); + continue; + } + if (ch == '}') + { + depth--; + current.Append(ch); + continue; + } + if (ch == ',' && depth == 0) + { + result.Add(current.ToString()); + current.Clear(); + continue; + } + } + + current.Append(ch); + } + + if (inQuote) + { + error = "Unterminated string in arguments"; + return null; + } + if (depth != 0) + { + error = "Unmatched braces in arguments"; + return null; + } + + if (current.Length > 0) + { + result.Add(current.ToString()); + } + + return result; + } + + /// + /// Parses { KEY: "value", KEY2: "value2" } into a dictionary. + /// + internal static Dictionary ParseEnvBlock(string input, out string error) + { + error = null; + var trimmed = input.Trim(); + if (!trimmed.StartsWith("{") || !trimmed.EndsWith("}")) + { + error = "Expected env block in the form { KEY: \"value\" }"; + return null; + } + + var inner = trimmed.Substring(1, trimmed.Length - 2).Trim(); + if (string.IsNullOrEmpty(inner)) + { + return new Dictionary(); + } + + var pairs = SplitArguments(inner, out error); + if (error != null) return null; + + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var pair in pairs) + { + var colonIdx = pair.IndexOf(':'); + if (colonIdx <= 0) + { + error = $"Expected KEY: \"value\" pair, got: {Truncate(pair.Trim(), 40)}"; + return null; + } + + var key = pair.Substring(0, colonIdx).Trim(); + var val = ExtractQuotedString(pair.Substring(colonIdx + 1).Trim(), out error); + if (error != null) return null; + + result[key] = val; + } + + return result; + } + + private static string Truncate(string value, int maxLength) + { + if (value == null) return "(null)"; + return value.Length <= maxLength ? value : value.Substring(0, maxLength) + "..."; + } + + #endregion + } +} diff --git a/src/Runner.Worker/Dap/DapVariableProvider.cs b/src/Runner.Worker/Dap/DapVariableProvider.cs new file mode 100644 index 00000000000..85c11f55301 --- /dev/null +++ b/src/Runner.Worker/Dap/DapVariableProvider.cs @@ -0,0 +1,373 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using GitHub.DistributedTask.Logging; +using GitHub.DistributedTask.ObjectTemplating.Tokens; +using GitHub.DistributedTask.Pipelines.ContextData; + +namespace GitHub.Runner.Worker.Dap +{ + /// + /// Maps runner execution context data to DAP scopes and variables. + /// + /// This is the single point where runner context values are materialized + /// for the debugger. All values pass through the runner's existing + /// so the DAP + /// surface never exposes anything beyond what a normal CI log would show. + /// + /// The secrets scope is intentionally opaque: keys are visible but every + /// value is replaced with a constant redaction marker. + /// + /// Designed to be reusable by future DAP features (evaluate, hover, REPL) + /// so that masking policy is never duplicated. + /// + internal sealed class DapVariableProvider + { + // Well-known scope names that map to top-level expression contexts. + // Order matters: the index determines the stable variablesReference ID. + private static readonly string[] _scopeNames = + { + "github", "env", "runner", "job", "steps", + "secrets", "inputs", "vars", "matrix", "needs" + }; + + // Scope references occupy the range [1, ScopeReferenceMax]. + private const int _scopeReferenceBase = 1; + private const int _scopeReferenceMax = 100; + + // Dynamic (nested) variable references start above the scope range. + private const int _dynamicReferenceBase = 101; + + private const string _redactedValue = "***"; + + private readonly ISecretMasker _secretMasker; + + // Maps dynamic variable reference IDs to the backing data and its + // dot-separated path (e.g. "github.event.pull_request"). + private readonly Dictionary _variableReferences = new(); + private int _nextVariableReference = _dynamicReferenceBase; + + public DapVariableProvider(ISecretMasker secretMasker) + { + _secretMasker = secretMasker ?? throw new ArgumentNullException(nameof(secretMasker)); + } + + /// + /// Clears all dynamic variable references. + /// Call this whenever the paused execution context changes (e.g. new step) + /// so that stale nested references are not served to the client. + /// + public void Reset() + { + _variableReferences.Clear(); + _nextVariableReference = _dynamicReferenceBase; + } + + /// + /// Returns the list of DAP scopes for the given execution context. + /// Each scope corresponds to a well-known runner expression context + /// (github, env, secrets, …) and carries a stable variablesReference + /// that the client can use to drill into variables. + /// + public List GetScopes(IExecutionContext context) + { + var scopes = new List(); + + if (context?.ExpressionValues == null) + { + return scopes; + } + + for (int i = 0; i < _scopeNames.Length; i++) + { + var scopeName = _scopeNames[i]; + if (!context.ExpressionValues.TryGetValue(scopeName, out var value) || value == null) + { + continue; + } + + var scope = new Scope + { + Name = scopeName, + VariablesReference = _scopeReferenceBase + i, + Expensive = false, + PresentationHint = scopeName == "secrets" ? "registers" : null + }; + + if (value is DictionaryContextData dict) + { + scope.NamedVariables = dict.Count; + } + else if (value is CaseSensitiveDictionaryContextData csDict) + { + scope.NamedVariables = csDict.Count; + } + + scopes.Add(scope); + } + + return scopes; + } + + /// + /// Returns the child variables for a given variablesReference. + /// The reference may point at a top-level scope (1–100) or a + /// dynamically registered nested container (101+). + /// + public List GetVariables(IExecutionContext context, int variablesReference) + { + var variables = new List(); + + if (context?.ExpressionValues == null) + { + return variables; + } + + PipelineContextData data = null; + string basePath = null; + bool isSecretsScope = false; + + if (variablesReference >= _scopeReferenceBase && variablesReference <= _scopeReferenceMax) + { + var scopeIndex = variablesReference - _scopeReferenceBase; + if (scopeIndex < _scopeNames.Length) + { + var scopeName = _scopeNames[scopeIndex]; + isSecretsScope = scopeName == "secrets"; + if (context.ExpressionValues.TryGetValue(scopeName, out data)) + { + basePath = scopeName; + } + } + } + else if (_variableReferences.TryGetValue(variablesReference, out var refData)) + { + data = refData.Data; + basePath = refData.Path; + isSecretsScope = basePath?.StartsWith("secrets", StringComparison.OrdinalIgnoreCase) == true; + } + + if (data == null) + { + return variables; + } + + ConvertToVariables(data, basePath, isSecretsScope, variables); + return variables; + } + + /// + /// Evaluates a GitHub Actions expression (e.g. "github.repository", + /// "${{ github.event_name }}") in the context of the current step and + /// returns a masked result suitable for the DAP evaluate response. + /// + /// Uses the runner's standard + /// so the full expression language is available (functions, operators, + /// context access). + /// + public EvaluateResponseBody EvaluateExpression(string expression, IExecutionContext context) + { + if (context?.ExpressionValues == null) + { + return new EvaluateResponseBody + { + Result = "(no execution context available)", + Type = "string", + VariablesReference = 0 + }; + } + + // Strip ${{ }} wrapper if present + var expr = expression?.Trim() ?? string.Empty; + if (expr.StartsWith("${{") && expr.EndsWith("}}")) + { + expr = expr.Substring(3, expr.Length - 5).Trim(); + } + + if (string.IsNullOrEmpty(expr)) + { + return new EvaluateResponseBody + { + Result = string.Empty, + Type = "string", + VariablesReference = 0 + }; + } + + try + { + var templateEvaluator = context.ToPipelineTemplateEvaluator(); + var token = new BasicExpressionToken(null, null, null, expr); + + var result = templateEvaluator.EvaluateStepDisplayName( + token, + context.ExpressionValues, + context.ExpressionFunctions); + + result = _secretMasker.MaskSecrets(result ?? "null"); + + return new EvaluateResponseBody + { + Result = result, + Type = InferResultType(result), + VariablesReference = 0 + }; + } + catch (Exception ex) + { + var errorMessage = _secretMasker.MaskSecrets($"Evaluation error: {ex.Message}"); + return new EvaluateResponseBody + { + Result = errorMessage, + Type = "string", + VariablesReference = 0 + }; + } + } + + /// + /// Infers a simple DAP type hint from the string representation of a result. + /// + internal static string InferResultType(string value) + { + value = value?.ToLower(); + if (value == null || value == "null") + return "null"; + if (value == "true" || value == "false") + return "boolean"; + if (double.TryParse(value, NumberStyles.Any, + CultureInfo.InvariantCulture, out _)) + return "number"; + if (value.StartsWith("{") || value.StartsWith("[")) + return "object"; + return "string"; + } + + #region Private helpers + + private void ConvertToVariables( + PipelineContextData data, + string basePath, + bool isSecretsScope, + List variables) + { + switch (data) + { + case DictionaryContextData dict: + foreach (var pair in dict) + { + variables.Add(CreateVariable(pair.Key, pair.Value, basePath, isSecretsScope)); + } + break; + + case CaseSensitiveDictionaryContextData csDict: + foreach (var pair in csDict) + { + variables.Add(CreateVariable(pair.Key, pair.Value, basePath, isSecretsScope)); + } + break; + + case ArrayContextData array: + for (int i = 0; i < array.Count; i++) + { + var variable = CreateVariable($"[{i}]", array[i], basePath, isSecretsScope); + variables.Add(variable); + } + break; + } + } + + private Variable CreateVariable( + string name, + PipelineContextData value, + string basePath, + bool isSecretsScope) + { + var childPath = string.IsNullOrEmpty(basePath) ? name : $"{basePath}.{name}"; + var variable = new Variable + { + Name = name, + EvaluateName = $"${{{{ {childPath} }}}}" + }; + + // Secrets scope: redact ALL values regardless of underlying type. + // Keys are visible but values are always replaced with the + // redaction marker, and nested containers are not drillable. + if (isSecretsScope) + { + variable.Value = _redactedValue; + variable.Type = "string"; + variable.VariablesReference = 0; + return variable; + } + + if (value == null) + { + variable.Value = "null"; + variable.Type = "null"; + variable.VariablesReference = 0; + return variable; + } + + switch (value) + { + case StringContextData str: + variable.Value = _secretMasker.MaskSecrets(str.Value); + variable.Type = "string"; + variable.VariablesReference = 0; + break; + + case NumberContextData num: + variable.Value = _secretMasker.MaskSecrets(num.Value.ToString("G15", CultureInfo.InvariantCulture)); + variable.Type = "number"; + variable.VariablesReference = 0; + break; + + case BooleanContextData boolVal: + variable.Value = boolVal.Value ? "true" : "false"; + variable.Type = "boolean"; + variable.VariablesReference = 0; + break; + + case DictionaryContextData dict: + variable.Value = $"Object ({dict.Count} properties)"; + variable.Type = "object"; + variable.VariablesReference = RegisterVariableReference(dict, childPath); + variable.NamedVariables = dict.Count; + break; + + case CaseSensitiveDictionaryContextData csDict: + variable.Value = $"Object ({csDict.Count} properties)"; + variable.Type = "object"; + variable.VariablesReference = RegisterVariableReference(csDict, childPath); + variable.NamedVariables = csDict.Count; + break; + + case ArrayContextData array: + variable.Value = $"Array ({array.Count} items)"; + variable.Type = "array"; + variable.VariablesReference = RegisterVariableReference(array, childPath); + variable.IndexedVariables = array.Count; + break; + + default: + var rawValue = value.ToJToken()?.ToString() ?? "unknown"; + variable.Value = _secretMasker.MaskSecrets(rawValue); + variable.Type = value.GetType().Name; + variable.VariablesReference = 0; + break; + } + + return variable; + } + + private int RegisterVariableReference(PipelineContextData data, string path) + { + var reference = _nextVariableReference++; + _variableReferences[reference] = (data, path); + return reference; + } + + #endregion + } +} diff --git a/src/Runner.Worker/Dap/IDapDebugger.cs b/src/Runner.Worker/Dap/IDapDebugger.cs new file mode 100644 index 00000000000..533626a4287 --- /dev/null +++ b/src/Runner.Worker/Dap/IDapDebugger.cs @@ -0,0 +1,26 @@ +using System.Threading.Tasks; +using GitHub.Runner.Common; + +namespace GitHub.Runner.Worker.Dap +{ + public enum DapSessionState + { + NotStarted, + WaitingForConnection, + Initializing, + Ready, + Paused, + Running, + Terminated + } + + [ServiceLocator(Default = typeof(DapDebugger))] + public interface IDapDebugger : IRunnerService + { + Task StartAsync(IExecutionContext jobContext); + Task WaitUntilReadyAsync(); + Task OnStepStartingAsync(IStep step); + void OnStepCompleted(IStep step); + Task OnJobCompletedAsync(); + } +} diff --git a/src/Runner.Worker/ExecutionContext.cs b/src/Runner.Worker/ExecutionContext.cs index f4a020c475e..70f2a47afb6 100644 --- a/src/Runner.Worker/ExecutionContext.cs +++ b/src/Runner.Worker/ExecutionContext.cs @@ -854,6 +854,12 @@ public void InitializeJob(Pipelines.AgentJobRequestMessage message, Cancellation // Track Node.js 20 actions for deprecation warning Global.DeprecatedNode20Actions = new HashSet(StringComparer.OrdinalIgnoreCase); + // Track actions upgraded from Node.js 20 to Node.js 24 + Global.UpgradedToNode24Actions = new HashSet(StringComparer.OrdinalIgnoreCase); + + // Track actions stuck on Node.js 20 due to ARM32 (separate from general deprecation) + Global.Arm32Node20Actions = new HashSet(StringComparer.OrdinalIgnoreCase); + // Job Outputs JobOutputs = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -963,6 +969,9 @@ public void InitializeJob(Pipelines.AgentJobRequestMessage message, Cancellation // Verbosity (from GitHub.Step_Debug). Global.WriteDebug = Global.Variables.Step_Debug ?? false; + // Debugger enabled flag (from acquire response). + Global.EnableDebugger = message.EnableDebugger; + // Hook up JobServerQueueThrottling event, we will log warning on server tarpit. _jobServerQueue.JobServerQueueThrottling += JobServerQueueThrottling_EventReceived; } diff --git a/src/Runner.Worker/GlobalContext.cs b/src/Runner.Worker/GlobalContext.cs index 27c326d68f9..60b4ef1fea8 100644 --- a/src/Runner.Worker/GlobalContext.cs +++ b/src/Runner.Worker/GlobalContext.cs @@ -27,6 +27,7 @@ public sealed class GlobalContext public StepsContext StepsContext { get; set; } public Variables Variables { get; set; } public bool WriteDebug { get; set; } + public bool EnableDebugger { get; set; } public string InfrastructureFailureCategory { get; set; } public JObject ContainerHookState { get; set; } public bool HasTemplateEvaluatorMismatch { get; set; } @@ -34,5 +35,7 @@ public sealed class GlobalContext public bool HasDeprecatedSetOutput { get; set; } public bool HasDeprecatedSaveState { get; set; } public HashSet DeprecatedNode20Actions { get; set; } + public HashSet UpgradedToNode24Actions { get; set; } + public HashSet Arm32Node20Actions { get; set; } } } diff --git a/src/Runner.Worker/Handlers/HandlerFactory.cs b/src/Runner.Worker/Handlers/HandlerFactory.cs index e9e2a5a6011..8044f091da2 100644 --- a/src/Runner.Worker/Handlers/HandlerFactory.cs +++ b/src/Runner.Worker/Handlers/HandlerFactory.cs @@ -25,6 +25,14 @@ IHandler Create( public sealed class HandlerFactory : RunnerService, IHandlerFactory { + internal static bool ShouldTrackAsArm32Node20(bool deprecateArm32, string preferredNodeVersion, string finalNodeVersion, string platformWarningMessage) + { + return deprecateArm32 && + !string.IsNullOrEmpty(platformWarningMessage) && + string.Equals(preferredNodeVersion, Constants.Runner.NodeMigration.Node24, StringComparison.OrdinalIgnoreCase) && + string.Equals(finalNodeVersion, Constants.Runner.NodeMigration.Node20, StringComparison.OrdinalIgnoreCase); + } + public IHandler Create( IExecutionContext executionContext, Pipelines.ActionStepDefinitionReference action, @@ -65,19 +73,12 @@ public IHandler Create( nodeData.NodeVersion = Common.Constants.Runner.NodeMigration.Node20; } - // Track Node.js 20 actions for deprecation annotation - if (string.Equals(nodeData.NodeVersion, Constants.Runner.NodeMigration.Node20, StringComparison.InvariantCultureIgnoreCase)) - { - bool warnOnNode20 = executionContext.Global.Variables?.GetBoolean(Constants.Runner.NodeMigration.WarnOnNode20Flag) ?? false; - if (warnOnNode20) - { - string actionName = GetActionName(action); - if (!string.IsNullOrEmpty(actionName)) - { - executionContext.Global.DeprecatedNode20Actions?.Add(actionName); - } - } - } + // Read flags early; actionName is also resolved up front for tracking after version is determined + bool warnOnNode20 = executionContext.Global.Variables?.GetBoolean(Constants.Runner.NodeMigration.WarnOnNode20Flag) ?? false; + bool deprecateArm32 = executionContext.Global.Variables?.GetBoolean(Constants.Runner.NodeMigration.DeprecateLinuxArm32Flag) ?? false; + bool killArm32 = executionContext.Global.Variables?.GetBoolean(Constants.Runner.NodeMigration.KillLinuxArm32Flag) ?? false; + string node20RemovalDate = executionContext.Global.Variables?.Get(Constants.Runner.NodeMigration.Node20RemovalDateVariable); + string actionName = GetActionName(action); // Check if node20 was explicitly specified in the action // We don't modify if node24 was explicitly specified @@ -87,7 +88,15 @@ public IHandler Create( bool requireNode24 = executionContext.Global.Variables?.GetBoolean(Constants.Runner.NodeMigration.RequireNode24Flag) ?? false; var (nodeVersion, configWarningMessage) = NodeUtil.DetermineActionsNodeVersion(environment, useNode24ByDefault, requireNode24); - var (finalNodeVersion, platformWarningMessage) = NodeUtil.CheckNodeVersionForLinuxArm32(nodeVersion); + var (finalNodeVersion, platformWarningMessage) = NodeUtil.CheckNodeVersionForLinuxArm32(nodeVersion, deprecateArm32, killArm32, node20RemovalDate); + + // ARM32 kill switch: fail the step + if (finalNodeVersion == null) + { + executionContext.Error(platformWarningMessage); + throw new InvalidOperationException(platformWarningMessage); + } + nodeData.NodeVersion = finalNodeVersion; if (!string.IsNullOrEmpty(configWarningMessage)) @@ -100,6 +109,26 @@ public IHandler Create( executionContext.Warning(platformWarningMessage); } + // Track actions based on their final node version + if (!string.IsNullOrEmpty(actionName)) + { + if (string.Equals(finalNodeVersion, Constants.Runner.NodeMigration.Node24, StringComparison.OrdinalIgnoreCase)) + { + // Action was upgraded from node20 to node24 + executionContext.Global.UpgradedToNode24Actions?.Add(actionName); + } + else if (ShouldTrackAsArm32Node20(deprecateArm32, nodeVersion, finalNodeVersion, platformWarningMessage)) + { + // Action is on node20 because ARM32 can't run node24 + executionContext.Global.Arm32Node20Actions?.Add(actionName); + } + else if (warnOnNode20) + { + // Action is still running on node20 (general case) + executionContext.Global.DeprecatedNode20Actions?.Add(actionName); + } + } + // Show information about Node 24 migration in Phase 2 if (useNode24ByDefault && !requireNode24 && string.Equals(finalNodeVersion, Constants.Runner.NodeMigration.Node24, StringComparison.OrdinalIgnoreCase)) { @@ -109,6 +138,30 @@ public IHandler Create( executionContext.Output(infoMessage); } } + else if (string.Equals(nodeData.NodeVersion, Constants.Runner.NodeMigration.Node24, StringComparison.InvariantCultureIgnoreCase)) + { + var (finalNodeVersion, platformWarningMessage) = NodeUtil.CheckNodeVersionForLinuxArm32(nodeData.NodeVersion, deprecateArm32, killArm32, node20RemovalDate); + + // ARM32 kill switch: fail the step + if (finalNodeVersion == null) + { + executionContext.Error(platformWarningMessage); + throw new InvalidOperationException(platformWarningMessage); + } + + var preferredVersion = nodeData.NodeVersion; + nodeData.NodeVersion = finalNodeVersion; + + if (!string.IsNullOrEmpty(platformWarningMessage)) + { + executionContext.Warning(platformWarningMessage); + } + + if (!string.IsNullOrEmpty(actionName) && ShouldTrackAsArm32Node20(deprecateArm32, preferredVersion, finalNodeVersion, platformWarningMessage)) + { + executionContext.Global.Arm32Node20Actions?.Add(actionName); + } + } (handler as INodeScriptActionHandler).Data = nodeData; } diff --git a/src/Runner.Worker/Handlers/StepHost.cs b/src/Runner.Worker/Handlers/StepHost.cs index 211009658e4..91f3154626c 100644 --- a/src/Runner.Worker/Handlers/StepHost.cs +++ b/src/Runner.Worker/Handlers/StepHost.cs @@ -58,13 +58,23 @@ public string ResolvePathForStepHost(IExecutionContext executionContext, string public Task DetermineNodeRuntimeVersion(IExecutionContext executionContext, string preferredVersion) { - // Use NodeUtil to check if Node24 is requested but we're on ARM32 Linux - var (nodeVersion, warningMessage) = Common.Util.NodeUtil.CheckNodeVersionForLinuxArm32(preferredVersion); + bool deprecateArm32 = executionContext.Global.Variables?.GetBoolean(Constants.Runner.NodeMigration.DeprecateLinuxArm32Flag) ?? false; + bool killArm32 = executionContext.Global.Variables?.GetBoolean(Constants.Runner.NodeMigration.KillLinuxArm32Flag) ?? false; + string node20RemovalDate = executionContext.Global.Variables?.Get(Constants.Runner.NodeMigration.Node20RemovalDateVariable); + + var (nodeVersion, warningMessage) = Common.Util.NodeUtil.CheckNodeVersionForLinuxArm32(preferredVersion, deprecateArm32, killArm32, node20RemovalDate); + + if (nodeVersion == null) + { + executionContext.Error(warningMessage); + throw new InvalidOperationException(warningMessage); + } + if (!string.IsNullOrEmpty(warningMessage)) { executionContext.Warning(warningMessage); } - + return Task.FromResult(nodeVersion); } @@ -142,8 +152,18 @@ public string ResolvePathForStepHost(IExecutionContext executionContext, string public async Task DetermineNodeRuntimeVersion(IExecutionContext executionContext, string preferredVersion) { - // Use NodeUtil to check if Node24 is requested but we're on ARM32 Linux - var (nodeExternal, warningMessage) = Common.Util.NodeUtil.CheckNodeVersionForLinuxArm32(preferredVersion); + bool deprecateArm32 = executionContext.Global.Variables?.GetBoolean(Constants.Runner.NodeMigration.DeprecateLinuxArm32Flag) ?? false; + bool killArm32 = executionContext.Global.Variables?.GetBoolean(Constants.Runner.NodeMigration.KillLinuxArm32Flag) ?? false; + string node20RemovalDate = executionContext.Global.Variables?.Get(Constants.Runner.NodeMigration.Node20RemovalDateVariable); + + var (nodeExternal, warningMessage) = Common.Util.NodeUtil.CheckNodeVersionForLinuxArm32(preferredVersion, deprecateArm32, killArm32, node20RemovalDate); + + if (nodeExternal == null) + { + executionContext.Error(warningMessage); + throw new InvalidOperationException(warningMessage); + } + if (!string.IsNullOrEmpty(warningMessage)) { executionContext.Warning(warningMessage); @@ -273,8 +293,18 @@ await containerHookManager.RunScriptStepAsync(context, private string CheckPlatformForAlpineContainer(IExecutionContext executionContext, string preferredVersion) { - // Use NodeUtil to check if Node24 is requested but we're on ARM32 Linux - var (nodeExternal, warningMessage) = Common.Util.NodeUtil.CheckNodeVersionForLinuxArm32(preferredVersion); + bool deprecateArm32 = executionContext.Global.Variables?.GetBoolean(Constants.Runner.NodeMigration.DeprecateLinuxArm32Flag) ?? false; + bool killArm32 = executionContext.Global.Variables?.GetBoolean(Constants.Runner.NodeMigration.KillLinuxArm32Flag) ?? false; + string node20RemovalDate = executionContext.Global.Variables?.Get(Constants.Runner.NodeMigration.Node20RemovalDateVariable); + + var (nodeExternal, warningMessage) = Common.Util.NodeUtil.CheckNodeVersionForLinuxArm32(preferredVersion, deprecateArm32, killArm32, node20RemovalDate); + + if (nodeExternal == null) + { + executionContext.Error(warningMessage); + throw new InvalidOperationException(warningMessage); + } + if (!string.IsNullOrEmpty(warningMessage)) { executionContext.Warning(warningMessage); diff --git a/src/Runner.Worker/InternalsVisibleTo.cs b/src/Runner.Worker/InternalsVisibleTo.cs index a825116a601..de556bce35f 100644 --- a/src/Runner.Worker/InternalsVisibleTo.cs +++ b/src/Runner.Worker/InternalsVisibleTo.cs @@ -1,3 +1,4 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("Test")] +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] diff --git a/src/Runner.Worker/JobExtension.cs b/src/Runner.Worker/JobExtension.cs index c210ebeb80a..f3757642947 100644 --- a/src/Runner.Worker/JobExtension.cs +++ b/src/Runner.Worker/JobExtension.cs @@ -736,14 +736,38 @@ public async Task FinalizeJob(IExecutionContext jobContext, Pipelines.AgentJobRe } } - // Add deprecation warning annotation for Node.js 20 actions + // Read dates from server variables with hardcoded fallbacks + var node24DefaultDateRaw = context.Global.Variables?.Get(Constants.Runner.NodeMigration.Node24DefaultDateVariable); + var node24DefaultDate = string.IsNullOrEmpty(node24DefaultDateRaw) ? Constants.Runner.NodeMigration.Node24DefaultDate : node24DefaultDateRaw; + var node20RemovalDateRaw = context.Global.Variables?.Get(Constants.Runner.NodeMigration.Node20RemovalDateVariable); + var node20RemovalDate = string.IsNullOrEmpty(node20RemovalDateRaw) ? Constants.Runner.NodeMigration.Node20RemovalDate : node20RemovalDateRaw; + + // Add deprecation warning annotation for Node.js 20 actions (Phase 1 - actions still running on node20) if (context.Global.DeprecatedNode20Actions?.Count > 0) { var sortedActions = context.Global.DeprecatedNode20Actions.OrderBy(a => a, StringComparer.OrdinalIgnoreCase); var actionsList = string.Join(", ", sortedActions); - var deprecationMessage = $"Node.js 20 actions are deprecated. The following actions are running on Node.js 20 and may not work as expected: {actionsList}. Actions will be forced to run with Node.js 24 by default starting June 2nd, 2026. Please check if updated versions of these actions are available that support Node.js 24. To opt into Node.js 24 now, set the FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true environment variable on the runner or in your workflow file. Once Node.js 24 becomes the default, you can temporarily opt out by setting ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true. For more information see: {Constants.Runner.NodeMigration.Node20DeprecationUrl}"; + var deprecationMessage = $"Node.js 20 actions are deprecated. The following actions are running on Node.js 20 and may not work as expected: {actionsList}. Actions will be forced to run with Node.js 24 by default starting {node24DefaultDate}. Node.js 20 will be removed from the runner on {node20RemovalDate}. Please check if updated versions of these actions are available that support Node.js 24. To opt into Node.js 24 now, set the FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true environment variable on the runner or in your workflow file. Once Node.js 24 becomes the default, you can temporarily opt out by setting ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true. For more information see: {Constants.Runner.NodeMigration.Node20DeprecationUrl}"; context.Warning(deprecationMessage); } + + // Add annotation for actions upgraded from Node.js 20 to Node.js 24 (Phase 2/3) + if (context.Global.UpgradedToNode24Actions?.Count > 0) + { + var sortedActions = context.Global.UpgradedToNode24Actions.OrderBy(a => a, StringComparer.OrdinalIgnoreCase); + var actionsList = string.Join(", ", sortedActions); + var upgradeMessage = $"Node.js 20 is deprecated. The following actions target Node.js 20 but are being forced to run on Node.js 24: {actionsList}. For more information see: {Constants.Runner.NodeMigration.Node20DeprecationUrl}"; + context.Warning(upgradeMessage); + } + + // Add annotation for ARM32 actions stuck on Node.js 20 (ARM32 can't run node24) + if (context.Global.Arm32Node20Actions?.Count > 0) + { + var sortedActions = context.Global.Arm32Node20Actions.OrderBy(a => a, StringComparer.OrdinalIgnoreCase); + var actionsList = string.Join(", ", sortedActions); + var arm32Message = $"The following actions are running on Node.js 20 because Node.js 24 is not available on Linux ARM32: {actionsList}. Linux ARM32 runners are deprecated and will no longer be supported after {node20RemovalDate}. Please migrate to a supported platform. For more information see: {Constants.Runner.NodeMigration.Node20DeprecationUrl}"; + context.Warning(arm32Message); + } } catch (Exception ex) { diff --git a/src/Runner.Worker/JobRunner.cs b/src/Runner.Worker/JobRunner.cs index 72ee5a403ad..10623bbef10 100644 --- a/src/Runner.Worker/JobRunner.cs +++ b/src/Runner.Worker/JobRunner.cs @@ -13,6 +13,7 @@ using GitHub.Runner.Common; using GitHub.Runner.Common.Util; using GitHub.Runner.Sdk; +using GitHub.Runner.Worker.Dap; using GitHub.Services.Common; using GitHub.Services.WebApi; using Sdk.RSWebApi.Contracts; @@ -28,6 +29,7 @@ public interface IJobRunner : IRunnerService public sealed class JobRunner : RunnerService, IJobRunner { + private const string DebuggerConnectionTelemetryPrefix = "DebuggerConnectionResult"; private IJobServerQueue _jobServerQueue; private RunnerSettings _runnerSettings; private ITempDirectoryManager _tempDirectoryManager; @@ -112,6 +114,7 @@ public async Task RunAsync(AgentJobRequestMessage message, Cancellat IExecutionContext jobContext = null; CancellationTokenRegistration? runnerShutdownRegistration = null; + IDapDebugger dapDebugger = null; try { // Create the job execution context. @@ -178,6 +181,26 @@ public async Task RunAsync(AgentJobRequestMessage message, Cancellat _tempDirectoryManager = HostContext.GetService(); _tempDirectoryManager.InitializeTempDirectory(jobContext); + // Setup the debugger + if (jobContext.Global.EnableDebugger) + { + Trace.Info("Debugger enabled for this job run"); + + try + { + dapDebugger = HostContext.GetService(); + await dapDebugger.StartAsync(jobContext); + } + catch (Exception ex) + { + Trace.Error($"Failed to start DAP debugger: {ex.Message}"); + AddDebuggerConnectionTelemetry(jobContext, $"Failed: {ex.Message}"); + jobContext.Error("Failed to start debugger."); + return await CompleteJobAsync(server, jobContext, message, TaskResult.Failed); + } + } + + // Get the job extension. Trace.Info("Getting job extension."); IJobExtension jobExtension = HostContext.CreateService(); @@ -219,6 +242,33 @@ public async Task RunAsync(AgentJobRequestMessage message, Cancellat await Task.WhenAny(_jobServerQueue.JobRecordUpdated.Task, Task.Delay(1000)); } + // Wait for DAP debugger client connection and handshake after "Set up job" + // so the job page shows the setup step before we block on the debugger + if (dapDebugger != null) + { + try + { + await dapDebugger.WaitUntilReadyAsync(); + AddDebuggerConnectionTelemetry(jobContext, "Connected"); + } + catch (OperationCanceledException) when (jobRequestCancellationToken.IsCancellationRequested) + { + Trace.Info("Job was cancelled before debugger client connected."); + AddDebuggerConnectionTelemetry(jobContext, "Canceled"); + jobContext.Error("Job was cancelled before debugger client connected."); + return await CompleteJobAsync(server, jobContext, message, TaskResult.Canceled); + } + catch (Exception ex) + { + Trace.Error($"DAP debugger failed to become ready: {ex.Message}"); + AddDebuggerConnectionTelemetry(jobContext, $"Failed: {ex.Message}"); + + // If debugging was requested but the debugger is not available, fail the job + jobContext.Error("The debugger failed to start or no debugger client connected in time."); + return await CompleteJobAsync(server, jobContext, message, TaskResult.Failed); + } + } + // Run all job steps Trace.Info("Run all job steps."); var stepsRunner = HostContext.GetService(); @@ -259,6 +309,11 @@ public async Task RunAsync(AgentJobRequestMessage message, Cancellat runnerShutdownRegistration = null; } + if (dapDebugger != null) + { + await dapDebugger.OnJobCompletedAsync(); + } + await ShutdownQueue(throwOnFailure: false); } } @@ -440,6 +495,15 @@ private async Task CompleteJobAsync(IJobServer jobServer, IExecution throw new AggregateException(exceptions); } + private static void AddDebuggerConnectionTelemetry(IExecutionContext jobContext, string result) + { + jobContext.Global.JobTelemetry.Add(new JobTelemetry + { + Type = JobTelemetryType.General, + Message = $"{DebuggerConnectionTelemetryPrefix}: {result}" + }); + } + private void MaskTelemetrySecrets(List jobTelemetry) { foreach (var telemetryItem in jobTelemetry) diff --git a/src/Runner.Worker/StepsRunner.cs b/src/Runner.Worker/StepsRunner.cs index 83ce87f6480..21bdfa6f779 100644 --- a/src/Runner.Worker/StepsRunner.cs +++ b/src/Runner.Worker/StepsRunner.cs @@ -10,6 +10,7 @@ using GitHub.Runner.Common; using GitHub.Runner.Common.Util; using GitHub.Runner.Sdk; +using GitHub.Runner.Worker.Dap; using GitHub.Runner.Worker.Expressions; namespace GitHub.Runner.Worker @@ -50,6 +51,7 @@ public async Task RunAsync(IExecutionContext jobContext) jobContext.JobContext.Status = (jobContext.Result ?? TaskResult.Succeeded).ToActionResult(); var scopeInputs = new Dictionary(StringComparer.OrdinalIgnoreCase); bool checkPostJobActions = false; + var dapDebugger = HostContext.GetService(); while (jobContext.JobSteps.Count > 0 || !checkPostJobActions) { if (jobContext.JobSteps.Count == 0 && !checkPostJobActions) @@ -226,9 +228,14 @@ public async Task RunAsync(IExecutionContext jobContext) } else { + // Pause for DAP debugger before step execution + await dapDebugger?.OnStepStartingAsync(step); + // Run the step await RunStepAsync(step, jobContext.CancellationToken); CompleteStep(step); + + dapDebugger?.OnStepCompleted(step); } } finally @@ -255,6 +262,7 @@ public async Task RunAsync(IExecutionContext jobContext) Trace.Info($"Current state: job state = '{jobContext.Result}'"); } + } private async Task RunStepAsync(IStep step, CancellationToken jobCancellationToken) diff --git a/src/Sdk/DTExpressions2/Expressions2/ExpressionParser.cs b/src/Sdk/DTExpressions2/Expressions2/ExpressionParser.cs index f0dac3d24aa..95c6698c0d7 100644 --- a/src/Sdk/DTExpressions2/Expressions2/ExpressionParser.cs +++ b/src/Sdk/DTExpressions2/Expressions2/ExpressionParser.cs @@ -17,10 +17,9 @@ public IExpressionNode CreateTree( String expression, ITraceWriter trace, IEnumerable namedValues, - IEnumerable functions, - Boolean allowCaseFunction = true) + IEnumerable functions) { - var context = new ParseContext(expression, trace, namedValues, functions, allowCaseFunction: allowCaseFunction); + var context = new ParseContext(expression, trace, namedValues, functions); context.Trace.Info($"Parsing expression: <{expression}>"); return CreateTree(context); } @@ -416,12 +415,6 @@ private static Boolean TryGetFunctionInfo( String name, out IFunctionInfo functionInfo) { - if (String.Equals(name, "case", StringComparison.OrdinalIgnoreCase) && !context.AllowCaseFunction) - { - functionInfo = null; - return false; - } - return ExpressionConstants.WellKnownFunctions.TryGetValue(name, out functionInfo) || context.ExtensionFunctions.TryGetValue(name, out functionInfo); } @@ -429,7 +422,6 @@ private static Boolean TryGetFunctionInfo( private sealed class ParseContext { public Boolean AllowUnknownKeywords; - public Boolean AllowCaseFunction; public readonly String Expression; public readonly Dictionary ExtensionFunctions = new Dictionary(StringComparer.OrdinalIgnoreCase); public readonly Dictionary ExtensionNamedValues = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -445,8 +437,7 @@ public ParseContext( ITraceWriter trace, IEnumerable namedValues, IEnumerable functions, - Boolean allowUnknownKeywords = false, - Boolean allowCaseFunction = true) + Boolean allowUnknownKeywords = false) { Expression = expression ?? String.Empty; if (Expression.Length > ExpressionConstants.MaxLength) @@ -467,7 +458,6 @@ public ParseContext( LexicalAnalyzer = new LexicalAnalyzer(Expression); AllowUnknownKeywords = allowUnknownKeywords; - AllowCaseFunction = allowCaseFunction; } private class NoOperationTraceWriter : ITraceWriter diff --git a/src/Sdk/DTObjectTemplating/ObjectTemplating/TemplateContext.cs b/src/Sdk/DTObjectTemplating/ObjectTemplating/TemplateContext.cs index d93eda398ac..af8cf76eca6 100644 --- a/src/Sdk/DTObjectTemplating/ObjectTemplating/TemplateContext.cs +++ b/src/Sdk/DTObjectTemplating/ObjectTemplating/TemplateContext.cs @@ -86,12 +86,6 @@ internal IDictionary State internal ITraceWriter TraceWriter { get; set; } - /// - /// Gets or sets a value indicating whether the case expression function is allowed. - /// Defaults to true. Set to false to disable the case function. - /// - internal Boolean AllowCaseFunction { get; set; } = true; - private IDictionary FileIds { get diff --git a/src/Sdk/DTObjectTemplating/ObjectTemplating/Tokens/TemplateToken.cs b/src/Sdk/DTObjectTemplating/ObjectTemplating/Tokens/TemplateToken.cs index 870163b7605..21a8aab7899 100644 --- a/src/Sdk/DTObjectTemplating/ObjectTemplating/Tokens/TemplateToken.cs +++ b/src/Sdk/DTObjectTemplating/ObjectTemplating/Tokens/TemplateToken.cs @@ -57,7 +57,7 @@ protected StringToken EvaluateStringToken( var originalBytes = context.Memory.CurrentBytes; try { - var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction); + var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions); var options = new EvaluationOptions { MaxMemory = context.Memory.MaxBytes, @@ -94,7 +94,7 @@ protected SequenceToken EvaluateSequenceToken( var originalBytes = context.Memory.CurrentBytes; try { - var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction); + var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions); var options = new EvaluationOptions { MaxMemory = context.Memory.MaxBytes, @@ -123,7 +123,7 @@ protected MappingToken EvaluateMappingToken( var originalBytes = context.Memory.CurrentBytes; try { - var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction); + var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions); var options = new EvaluationOptions { MaxMemory = context.Memory.MaxBytes, @@ -152,7 +152,7 @@ protected TemplateToken EvaluateTemplateToken( var originalBytes = context.Memory.CurrentBytes; try { - var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction); + var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions); var options = new EvaluationOptions { MaxMemory = context.Memory.MaxBytes, diff --git a/src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs b/src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs index e6ecbf4509d..328f6216081 100644 --- a/src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs +++ b/src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs @@ -253,6 +253,13 @@ public String BillingOwnerId set; } + [DataMember(EmitDefaultValue = false)] + public bool EnableDebugger + { + get; + set; + } + /// /// Gets the collection of variables associated with the current context. /// diff --git a/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConverter.cs b/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConverter.cs index 87bb00baec8..38176eb36c2 100644 --- a/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConverter.cs +++ b/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConverter.cs @@ -681,7 +681,7 @@ private static String ConvertToIfCondition( var node = default(ExpressionNode); try { - node = expressionParser.CreateTree(condition, null, namedValues, functions, allowCaseFunction: context.AllowCaseFunction) as ExpressionNode; + node = expressionParser.CreateTree(condition, null, namedValues, functions) as ExpressionNode; } catch (Exception ex) { diff --git a/src/Sdk/DTWebApi/WebApi/Exceptions.cs b/src/Sdk/DTWebApi/WebApi/Exceptions.cs index ee47f137063..90b93e04ec3 100644 --- a/src/Sdk/DTWebApi/WebApi/Exceptions.cs +++ b/src/Sdk/DTWebApi/WebApi/Exceptions.cs @@ -2556,6 +2556,25 @@ private FailedToResolveActionDownloadInfoException(SerializationInfo info, Strea } } + [Serializable] + public sealed class FailedToDownloadActionException : DistributedTaskException + { + public FailedToDownloadActionException(String message) + : base(message) + { + } + + public FailedToDownloadActionException(String message, Exception innerException) + : base(message, innerException) + { + } + + private FailedToDownloadActionException(SerializationInfo info, StreamingContext context) + : base(info, context) + { + } + } + [Serializable] public sealed class InvalidActionArchiveException : DistributedTaskException { diff --git a/src/Sdk/Expressions/ExpressionParser.cs b/src/Sdk/Expressions/ExpressionParser.cs index fd7dd1b458b..3c4ad104f5c 100644 --- a/src/Sdk/Expressions/ExpressionParser.cs +++ b/src/Sdk/Expressions/ExpressionParser.cs @@ -1,4 +1,4 @@ -#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references +#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references using System; using System.Collections.Generic; @@ -17,10 +17,9 @@ public IExpressionNode CreateTree( String expression, ITraceWriter trace, IEnumerable namedValues, - IEnumerable functions, - Boolean allowCaseFunction = true) + IEnumerable functions) { - var context = new ParseContext(expression, trace, namedValues, functions, allowCaseFunction: allowCaseFunction); + var context = new ParseContext(expression, trace, namedValues, functions); context.Trace.Info($"Parsing expression: <{expression}>"); return CreateTree(context); } @@ -322,7 +321,7 @@ private static void FlushTopEndParameters(ParseContext context) context.Operators.Pop(); } var functionOperands = PopOperands(context, parameterCount); - + // Node already exists on the operand stack function = (Function)context.Operands.Peek(); @@ -416,12 +415,6 @@ private static Boolean TryGetFunctionInfo( String name, out IFunctionInfo functionInfo) { - if (String.Equals(name, "case", StringComparison.OrdinalIgnoreCase) && !context.AllowCaseFunction) - { - functionInfo = null; - return false; - } - return ExpressionConstants.WellKnownFunctions.TryGetValue(name, out functionInfo) || context.ExtensionFunctions.TryGetValue(name, out functionInfo); } @@ -429,7 +422,6 @@ private static Boolean TryGetFunctionInfo( private sealed class ParseContext { public Boolean AllowUnknownKeywords; - public Boolean AllowCaseFunction; public readonly String Expression; public readonly Dictionary ExtensionFunctions = new Dictionary(StringComparer.OrdinalIgnoreCase); public readonly Dictionary ExtensionNamedValues = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -445,8 +437,7 @@ public ParseContext( ITraceWriter trace, IEnumerable namedValues, IEnumerable functions, - Boolean allowUnknownKeywords = false, - Boolean allowCaseFunction = true) + Boolean allowUnknownKeywords = false) { Expression = expression ?? String.Empty; if (Expression.Length > ExpressionConstants.MaxLength) @@ -467,7 +458,6 @@ public ParseContext( LexicalAnalyzer = new LexicalAnalyzer(Expression); AllowUnknownKeywords = allowUnknownKeywords; - AllowCaseFunction = allowCaseFunction; } private class NoOperationTraceWriter : ITraceWriter diff --git a/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs b/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs index 8ae6ea0c9f7..0c53f87fcbd 100644 --- a/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs +++ b/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs @@ -1828,7 +1828,7 @@ private static BasicExpressionToken ConvertToIfCondition( var node = default(ExpressionNode); try { - node = expressionParser.CreateTree(condition, null, namedValues, functions, allowCaseFunction: context.AllowCaseFunction) as ExpressionNode; + node = expressionParser.CreateTree(condition, null, namedValues, functions) as ExpressionNode; } catch (Exception ex) { diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/TemplateContext.cs b/src/Sdk/WorkflowParser/ObjectTemplating/TemplateContext.cs index 6e35e850be3..13a11ca2ceb 100644 --- a/src/Sdk/WorkflowParser/ObjectTemplating/TemplateContext.cs +++ b/src/Sdk/WorkflowParser/ObjectTemplating/TemplateContext.cs @@ -1,4 +1,4 @@ -#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references +#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references using System; using System.Collections.Generic; @@ -113,12 +113,6 @@ public IDictionary State /// internal Boolean StrictJsonParsing { get; set; } - /// - /// Gets or sets a value indicating whether the case expression function is allowed. - /// Defaults to true. Set to false to disable the case function. - /// - internal Boolean AllowCaseFunction { get; set; } = true; - internal ITraceWriter TraceWriter { get; set; } private IDictionary FileIds diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/TemplateToken.cs b/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/TemplateToken.cs index 0c1295ccb7b..fe90b4957de 100644 --- a/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/TemplateToken.cs +++ b/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/TemplateToken.cs @@ -1,4 +1,4 @@ -#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references +#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references using System; using System.Collections.Generic; @@ -55,7 +55,7 @@ protected StringToken EvaluateStringToken( var originalBytes = context.Memory.CurrentBytes; try { - var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction); + var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions); var options = new EvaluationOptions { MaxMemory = context.Memory.MaxBytes, @@ -93,7 +93,7 @@ protected SequenceToken EvaluateSequenceToken( var originalBytes = context.Memory.CurrentBytes; try { - var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction); + var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions); var options = new EvaluationOptions { MaxMemory = context.Memory.MaxBytes, @@ -123,7 +123,7 @@ protected MappingToken EvaluateMappingToken( var originalBytes = context.Memory.CurrentBytes; try { - var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction); + var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions); var options = new EvaluationOptions { MaxMemory = context.Memory.MaxBytes, @@ -153,7 +153,7 @@ protected TemplateToken EvaluateTemplateToken( var originalBytes = context.Memory.CurrentBytes; try { - var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction); + var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions); var options = new EvaluationOptions { MaxMemory = context.Memory.MaxBytes, @@ -289,4 +289,4 @@ private MappingToken CreateMappingToken(TemplateContext context) return result; } } -} \ No newline at end of file +} diff --git a/src/Test/L0/Sdk/ExpressionParserL0.cs b/src/Test/L0/Sdk/ExpressionParserL0.cs index 6512ec9e74a..313d038cd6a 100644 --- a/src/Test/L0/Sdk/ExpressionParserL0.cs +++ b/src/Test/L0/Sdk/ExpressionParserL0.cs @@ -1,4 +1,4 @@ -using GitHub.DistributedTask.Expressions2; +using GitHub.DistributedTask.Expressions2; using GitHub.DistributedTask.Expressions2.Sdk; using GitHub.DistributedTask.ObjectTemplating; using System; @@ -9,7 +9,7 @@ namespace GitHub.Runner.Common.Tests.Sdk { /// /// Regression tests for ExpressionParser.CreateTree to verify that - /// allowCaseFunction does not accidentally set allowUnknownKeywords. + /// the case function does not accidentally set allowUnknownKeywords. /// public sealed class ExpressionParserL0 { @@ -18,7 +18,7 @@ public sealed class ExpressionParserL0 [Trait("Category", "Sdk")] public void CreateTree_RejectsUnrecognizedNamedValue() { - // Regression: allowCaseFunction was passed positionally into + // Regression: the case function parameter was passed positionally into // the allowUnknownKeywords parameter, causing all named values // to be silently accepted. var parser = new ExpressionParser(); @@ -52,7 +52,7 @@ public void CreateTree_AcceptsRecognizedNamedValue() [Fact] [Trait("Level", "L0")] [Trait("Category", "Sdk")] - public void CreateTree_CaseFunctionWorks_WhenAllowed() + public void CreateTree_CaseFunctionWorks() { var parser = new ExpressionParser(); var namedValues = new List @@ -60,35 +60,17 @@ public void CreateTree_CaseFunctionWorks_WhenAllowed() new NamedValueInfo("github"), }; - var node = parser.CreateTree("case(github.event_name, 'push', 'Push Event')", null, namedValues, null, allowCaseFunction: true); + var node = parser.CreateTree("case(github.event_name, 'push', 'Push Event')", null, namedValues, null); Assert.NotNull(node); } - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Sdk")] - public void CreateTree_CaseFunctionRejected_WhenDisallowed() - { - var parser = new ExpressionParser(); - var namedValues = new List - { - new NamedValueInfo("github"), - }; - - var ex = Assert.Throws(() => - parser.CreateTree("case(github.event_name, 'push', 'Push Event')", null, namedValues, null, allowCaseFunction: false)); - - Assert.Contains("Unrecognized function", ex.Message); - } - [Fact] [Trait("Level", "L0")] [Trait("Category", "Sdk")] public void CreateTree_CaseFunctionDoesNotAffectUnknownKeywords() { - // The key regression test: with allowCaseFunction=true (default), - // unrecognized named values must still be rejected. + // The key regression test: unrecognized named values must still be rejected. var parser = new ExpressionParser(); var namedValues = new List { @@ -96,7 +78,7 @@ public void CreateTree_CaseFunctionDoesNotAffectUnknownKeywords() }; var ex = Assert.Throws(() => - parser.CreateTree("github.ref", null, namedValues, null, allowCaseFunction: true)); + parser.CreateTree("github.ref", null, namedValues, null)); Assert.Contains("Unrecognized named-value", ex.Message); } diff --git a/src/Test/L0/Sdk/RSWebApi/AgentJobRequestMessageL0.cs b/src/Test/L0/Sdk/RSWebApi/AgentJobRequestMessageL0.cs new file mode 100644 index 00000000000..33b30d30836 --- /dev/null +++ b/src/Test/L0/Sdk/RSWebApi/AgentJobRequestMessageL0.cs @@ -0,0 +1,76 @@ +using System.Collections.Generic; +using System.IO; +using System.Runtime.Serialization.Json; +using System.Text; +using Xunit; +using GitHub.DistributedTask.Pipelines; + +namespace GitHub.Actions.RunService.WebApi.Tests; + +public sealed class AgentJobRequestMessageL0 +{ + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Common")] + public void VerifyEnableDebuggerDeserialization_WithTrue() + { + // Arrange + var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage)); + string jsonWithEnabledDebugger = DoubleQuotify("{'EnableDebugger': true}"); + + // Act + using var stream = new MemoryStream(); + stream.Write(Encoding.UTF8.GetBytes(jsonWithEnabledDebugger)); + stream.Position = 0; + var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage; + + // Assert + Assert.NotNull(recoveredMessage); + Assert.True(recoveredMessage.EnableDebugger, "EnableDebugger should be true when JSON contains 'EnableDebugger': true"); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Common")] + public void VerifyEnableDebuggerDeserialization_DefaultToFalse() + { + // Arrange + var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage)); + string jsonWithoutDebugger = DoubleQuotify("{'messageType': 'PipelineAgentJobRequest'}"); + + // Act + using var stream = new MemoryStream(); + stream.Write(Encoding.UTF8.GetBytes(jsonWithoutDebugger)); + stream.Position = 0; + var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage; + + // Assert + Assert.NotNull(recoveredMessage); + Assert.False(recoveredMessage.EnableDebugger, "EnableDebugger should default to false when JSON field is absent"); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Common")] + public void VerifyEnableDebuggerDeserialization_WithFalse() + { + // Arrange + var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage)); + string jsonWithDisabledDebugger = DoubleQuotify("{'EnableDebugger': false}"); + + // Act + using var stream = new MemoryStream(); + stream.Write(Encoding.UTF8.GetBytes(jsonWithDisabledDebugger)); + stream.Position = 0; + var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage; + + // Assert + Assert.NotNull(recoveredMessage); + Assert.False(recoveredMessage.EnableDebugger, "EnableDebugger should be false when JSON contains 'EnableDebugger': false"); + } + + private static string DoubleQuotify(string text) + { + return text.Replace('\'', '"'); + } +} diff --git a/src/Test/L0/ServiceInterfacesL0.cs b/src/Test/L0/ServiceInterfacesL0.cs index 59b890285be..ec8a270f5e7 100644 --- a/src/Test/L0/ServiceInterfacesL0.cs +++ b/src/Test/L0/ServiceInterfacesL0.cs @@ -2,6 +2,7 @@ using GitHub.Runner.Listener.Check; using GitHub.Runner.Listener.Configuration; using GitHub.Runner.Worker; +using GitHub.Runner.Worker.Dap; using GitHub.Runner.Worker.Container.ContainerHooks; using GitHub.Runner.Worker.Handlers; using System; diff --git a/src/Test/L0/Worker/ActionManagerL0.cs b/src/Test/L0/Worker/ActionManagerL0.cs index 5aa1f2dbc20..c16dd55ae04 100644 --- a/src/Test/L0/Worker/ActionManagerL0.cs +++ b/src/Test/L0/Worker/ActionManagerL0.cs @@ -198,7 +198,8 @@ public async void PrepareActions_DownloadUnknownActionFromGraph_OnPremises_Legac Func action = async () => await _actionManager.PrepareActionsAsync(_ec.Object, actions); //Assert - await Assert.ThrowsAsync(action); + var ex = await Assert.ThrowsAsync(action); + Assert.IsType(ex.InnerException); var watermarkFile = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), ActionName, "main.completed"); Assert.False(File.Exists(watermarkFile)); diff --git a/src/Test/L0/Worker/ActionManifestManagerL0.cs b/src/Test/L0/Worker/ActionManifestManagerL0.cs index 9d707c3daa5..6a3da0c7209 100644 --- a/src/Test/L0/Worker/ActionManifestManagerL0.cs +++ b/src/Test/L0/Worker/ActionManifestManagerL0.cs @@ -504,7 +504,7 @@ public void Load_Node20Action() } } - [Fact] + [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] public void Load_Node24Action() @@ -1006,6 +1006,45 @@ private void Setup([CallerMemberName] string name = "") _ec.Setup(x => x.AddIssue(It.IsAny(), It.IsAny())).Callback((Issue issue, ExecutionContextLogOptions logOptions) => { _hc.GetTrace().Info($"[{issue.Type}]{logOptions.LogMessageOverride ?? issue.Message}"); }); } + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Evaluate_Default_Input_Case_Function() + { + try + { + //Arrange + Setup(); + + var actionManifest = new ActionManifestManager(); + actionManifest.Initialize(_hc); + + _ec.Object.ExpressionValues["github"] = new LegacyContextData.DictionaryContextData + { + { "ref", new LegacyContextData.StringContextData("refs/heads/main") }, + }; + _ec.Object.ExpressionValues["strategy"] = new LegacyContextData.DictionaryContextData(); + _ec.Object.ExpressionValues["matrix"] = new LegacyContextData.DictionaryContextData(); + _ec.Object.ExpressionValues["steps"] = new LegacyContextData.DictionaryContextData(); + _ec.Object.ExpressionValues["job"] = new LegacyContextData.DictionaryContextData(); + _ec.Object.ExpressionValues["runner"] = new LegacyContextData.DictionaryContextData(); + _ec.Object.ExpressionValues["env"] = new LegacyContextData.DictionaryContextData(); + _ec.Object.ExpressionFunctions.Add(new LegacyExpressions.FunctionInfo("hashFiles", 1, 255)); + + // Act — evaluate a case() expression as a default input value. + // The feature flag is set, so this should succeed. + var token = new BasicExpressionToken(null, null, null, "case(true, 'matched', 'default')"); + var result = actionManifest.EvaluateDefaultInput(_ec.Object, "testInput", token); + + // Assert — case() should evaluate successfully + Assert.Equal("matched", result); + } + finally + { + Teardown(); + } + } + private void Teardown() { _hc?.Dispose(); diff --git a/src/Test/L0/Worker/DapDebuggerL0.cs b/src/Test/L0/Worker/DapDebuggerL0.cs new file mode 100644 index 00000000000..f2c8557d15b --- /dev/null +++ b/src/Test/L0/Worker/DapDebuggerL0.cs @@ -0,0 +1,616 @@ +using System; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using GitHub.Runner.Worker; +using GitHub.Runner.Worker.Dap; +using Newtonsoft.Json; +using Xunit; + +namespace GitHub.Runner.Common.Tests.Worker +{ + public sealed class DapDebuggerL0 + { + private const string PortEnvironmentVariable = "ACTIONS_RUNNER_DAP_PORT"; + private const string TimeoutEnvironmentVariable = "ACTIONS_RUNNER_DAP_CONNECTION_TIMEOUT"; + private DapDebugger _debugger; + + private TestHostContext CreateTestContext([CallerMemberName] string testName = "") + { + var hc = new TestHostContext(this, testName); + _debugger = new DapDebugger(); + _debugger.Initialize(hc); + return hc; + } + + private static async Task WithEnvironmentVariableAsync(string name, string value, Func action) + { + var originalValue = Environment.GetEnvironmentVariable(name); + Environment.SetEnvironmentVariable(name, value); + try + { + await action(); + } + finally + { + Environment.SetEnvironmentVariable(name, originalValue); + } + } + + private static void WithEnvironmentVariable(string name, string value, Action action) + { + var originalValue = Environment.GetEnvironmentVariable(name); + Environment.SetEnvironmentVariable(name, value); + try + { + action(); + } + finally + { + Environment.SetEnvironmentVariable(name, originalValue); + } + } + + private static int GetFreePort() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + return ((IPEndPoint)listener.LocalEndpoint).Port; + } + + private static async Task ConnectClientAsync(int port) + { + var client = new TcpClient(); + await client.ConnectAsync(IPAddress.Loopback, port); + return client; + } + + private static async Task SendRequestAsync(NetworkStream stream, Request request) + { + var json = JsonConvert.SerializeObject(request); + var body = Encoding.UTF8.GetBytes(json); + var header = $"Content-Length: {body.Length}\r\n\r\n"; + var headerBytes = Encoding.ASCII.GetBytes(header); + + await stream.WriteAsync(headerBytes, 0, headerBytes.Length); + await stream.WriteAsync(body, 0, body.Length); + await stream.FlushAsync(); + } + + /// + /// Reads a single DAP-framed message from a stream with a timeout. + /// Parses the Content-Length header, reads exactly that many bytes, + /// and returns the JSON body. Fails with a clear error on timeout. + /// + private static async Task ReadDapMessageAsync(NetworkStream stream, TimeSpan timeout) + { + using var cts = new CancellationTokenSource(timeout); + var token = cts.Token; + + var headerBuilder = new StringBuilder(); + var buffer = new byte[1]; + var contentLength = -1; + + while (true) + { + var readTask = stream.ReadAsync(buffer, 0, 1, token); + var bytesRead = await readTask; + if (bytesRead == 0) + { + throw new EndOfStreamException("Connection closed while reading DAP headers"); + } + + headerBuilder.Append((char)buffer[0]); + var headers = headerBuilder.ToString(); + if (headers.EndsWith("\r\n\r\n", StringComparison.Ordinal)) + { + foreach (var line in headers.Split(new[] { "\r\n" }, StringSplitOptions.RemoveEmptyEntries)) + { + if (line.StartsWith("Content-Length: ", StringComparison.OrdinalIgnoreCase)) + { + contentLength = int.Parse(line.Substring("Content-Length: ".Length).Trim()); + } + } + break; + } + } + + if (contentLength < 0) + { + throw new InvalidOperationException("No Content-Length header found in DAP message"); + } + + var body = new byte[contentLength]; + var totalRead = 0; + while (totalRead < contentLength) + { + var bytesRead = await stream.ReadAsync(body, totalRead, contentLength - totalRead, token); + if (bytesRead == 0) + { + throw new EndOfStreamException("Connection closed while reading DAP body"); + } + totalRead += bytesRead; + } + + return Encoding.UTF8.GetString(body); + } + + private static Mock CreateJobContext(CancellationToken cancellationToken, string jobName = null) + { + var jobContext = new Mock(); + jobContext.Setup(x => x.CancellationToken).Returns(cancellationToken); + jobContext + .Setup(x => x.GetGitHubContext(It.IsAny())) + .Returns((string contextName) => string.Equals(contextName, "job", StringComparison.Ordinal) ? jobName : null); + return jobContext; + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void InitializeSucceeds() + { + using (CreateTestContext()) + { + Assert.NotNull(_debugger); + Assert.False(_debugger.IsActive); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ResolvePortUsesCustomPortFromEnvironment() + { + using (CreateTestContext()) + { + WithEnvironmentVariable(PortEnvironmentVariable, "9999", () => + { + Assert.Equal(9999, _debugger.ResolvePort()); + }); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ResolvePortIgnoresInvalidPortFromEnvironment() + { + using (CreateTestContext()) + { + WithEnvironmentVariable(PortEnvironmentVariable, "not-a-number", () => + { + Assert.Equal(4711, _debugger.ResolvePort()); + }); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ResolvePortIgnoresOutOfRangePortFromEnvironment() + { + using (CreateTestContext()) + { + WithEnvironmentVariable(PortEnvironmentVariable, "99999", () => + { + Assert.Equal(4711, _debugger.ResolvePort()); + }); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ResolveTimeoutUsesCustomTimeoutFromEnvironment() + { + using (CreateTestContext()) + { + WithEnvironmentVariable(TimeoutEnvironmentVariable, "30", () => + { + Assert.Equal(30, _debugger.ResolveTimeout()); + }); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ResolveTimeoutIgnoresInvalidTimeoutFromEnvironment() + { + using (CreateTestContext()) + { + WithEnvironmentVariable(TimeoutEnvironmentVariable, "not-a-number", () => + { + Assert.Equal(15, _debugger.ResolveTimeout()); + }); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ResolveTimeoutIgnoresZeroTimeoutFromEnvironment() + { + using (CreateTestContext()) + { + WithEnvironmentVariable(TimeoutEnvironmentVariable, "0", () => + { + Assert.Equal(15, _debugger.ResolveTimeout()); + }); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task StartAndStopLifecycle() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () => + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContext(cts.Token); + await _debugger.StartAsync(jobContext.Object); + using var client = await ConnectClientAsync(port); + Assert.True(client.Connected); + await _debugger.StopAsync(); + }); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task StartAndStopMultipleTimesDoesNotThrow() + { + using (CreateTestContext()) + { + foreach (var port in new[] { GetFreePort(), GetFreePort() }) + { + await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () => + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContext(cts.Token); + await _debugger.StartAsync(jobContext.Object); + await _debugger.StopAsync(); + }); + } + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task WaitUntilReadyCompletesAfterClientConnectionAndConfigurationDone() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () => + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContext(cts.Token); + await _debugger.StartAsync(jobContext.Object); + + var waitTask = _debugger.WaitUntilReadyAsync(); + using var client = await ConnectClientAsync(port); + await SendRequestAsync(client.GetStream(), new Request + { + Seq = 1, + Type = "request", + Command = "configurationDone" + }); + + await waitTask; + Assert.Equal(DapSessionState.Ready, _debugger.State); + await _debugger.StopAsync(); + }); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task StartStoresJobContextForThreadsRequest() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () => + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContext(cts.Token, "ci-job"); + await _debugger.StartAsync(jobContext.Object); + using var client = await ConnectClientAsync(port); + var stream = client.GetStream(); + await SendRequestAsync(client.GetStream(), new Request + { + Seq = 1, + Type = "request", + Command = "threads" + }); + + var response = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"command\":\"threads\"", response); + Assert.Contains("\"name\":\"Job: ci-job\"", response); + await _debugger.StopAsync(); + }); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task CancellationUnblocksAndOnJobCompletedTerminates() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () => + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContext(cts.Token); + await _debugger.StartAsync(jobContext.Object); + + var waitTask = _debugger.WaitUntilReadyAsync(); + using var client = await ConnectClientAsync(port); + await SendRequestAsync(client.GetStream(), new Request + { + Seq = 1, + Type = "request", + Command = "configurationDone" + }); + + await waitTask; + cts.Cancel(); + + // In the real runner, JobRunner always calls OnJobCompletedAsync + // from a finally block. The cancellation callback only unblocks + // pending waits; OnJobCompletedAsync handles state + cleanup. + await _debugger.OnJobCompletedAsync(); + Assert.Equal(DapSessionState.Terminated, _debugger.State); + }); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task StopWithoutStartDoesNotThrow() + { + using (CreateTestContext()) + { + await _debugger.StopAsync(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task OnJobCompletedTerminatesSession() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () => + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContext(cts.Token); + await _debugger.StartAsync(jobContext.Object); + + var waitTask = _debugger.WaitUntilReadyAsync(); + using var client = await ConnectClientAsync(port); + await SendRequestAsync(client.GetStream(), new Request + { + Seq = 1, + Type = "request", + Command = "configurationDone" + }); + + await waitTask; + await _debugger.OnJobCompletedAsync(); + Assert.Equal(DapSessionState.Terminated, _debugger.State); + }); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task WaitUntilReadyBeforeStartIsNoOp() + { + using (CreateTestContext()) + { + await _debugger.WaitUntilReadyAsync(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task WaitUntilReadyJobCancellationPropagatesAsOperationCancelledException() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () => + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContext(cts.Token); + await _debugger.StartAsync(jobContext.Object); + + var waitTask = _debugger.WaitUntilReadyAsync(); + await Task.Delay(50); + cts.Cancel(); + + var ex = await Assert.ThrowsAnyAsync(() => waitTask); + Assert.IsNotType(ex); + await _debugger.StopAsync(); + }); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task InitializeRequestOverSocketPreservesProtocolMetadataWhenSecretsCollide() + { + using (var hc = CreateTestContext()) + { + hc.SecretMasker.AddValue("response"); + hc.SecretMasker.AddValue("initialize"); + hc.SecretMasker.AddValue("event"); + hc.SecretMasker.AddValue("initialized"); + + var port = GetFreePort(); + await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () => + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContext(cts.Token); + await _debugger.StartAsync(jobContext.Object); + using var client = await ConnectClientAsync(port); + var stream = client.GetStream(); + + await SendRequestAsync(stream, new Request + { + Seq = 1, + Type = "request", + Command = "initialize" + }); + + var response = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"type\":\"response\"", response); + Assert.Contains("\"command\":\"initialize\"", response); + Assert.Contains("\"success\":true", response); + + var initializedEvent = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"type\":\"event\"", initializedEvent); + Assert.Contains("\"event\":\"initialized\"", initializedEvent); + + await _debugger.StopAsync(); + }); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task CancellationDuringStepPauseReleasesWait() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () => + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContext(cts.Token); + await _debugger.StartAsync(jobContext.Object); + + // Complete handshake so session is ready + var waitTask = _debugger.WaitUntilReadyAsync(); + using var client = await ConnectClientAsync(port); + var stream = client.GetStream(); + await SendRequestAsync(stream, new Request + { + Seq = 1, + Type = "request", + Command = "configurationDone" + }); + await waitTask; + + // Simulate a step starting (which pauses) + var step = new Mock(); + step.Setup(s => s.DisplayName).Returns("Test Step"); + step.Setup(s => s.ExecutionContext).Returns((IExecutionContext)null); + var stepTask = _debugger.OnStepStartingAsync(step.Object); + + // Give the step time to pause + await Task.Delay(50); + + // Cancel the job — should release the step pause + cts.Cancel(); + await stepTask; + + // In the real runner, OnJobCompletedAsync always follows. + await _debugger.OnJobCompletedAsync(); + Assert.Equal(DapSessionState.Terminated, _debugger.State); + }); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task StopAsyncSafeAtAnyLifecyclePoint() + { + using (CreateTestContext()) + { + // StopAsync before start + await _debugger.StopAsync(); + + // Start then immediate stop (no connection, no WaitUntilReady) + var port = GetFreePort(); + await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () => + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContext(cts.Token); + await _debugger.StartAsync(jobContext.Object); + await _debugger.StopAsync(); + }); + + // StopAsync after already stopped + await _debugger.StopAsync(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task OnJobCompletedSendsTerminatedAndExitedEvents() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () => + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContext(cts.Token); + await _debugger.StartAsync(jobContext.Object); + + var waitTask = _debugger.WaitUntilReadyAsync(); + using var client = await ConnectClientAsync(port); + var stream = client.GetStream(); + await SendRequestAsync(stream, new Request + { + Seq = 1, + Type = "request", + Command = "configurationDone" + }); + + // Read the configurationDone response + await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + await waitTask; + + // Complete the job — events are sent via OnJobCompletedAsync + await _debugger.OnJobCompletedAsync(); + + var msg1 = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + var msg2 = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + + // Both events should arrive (order may vary) + var combined = msg1 + msg2; + Assert.Contains("\"event\":\"terminated\"", combined); + Assert.Contains("\"event\":\"exited\"", combined); + }); + } + } + } +} diff --git a/src/Test/L0/Worker/DapMessagesL0.cs b/src/Test/L0/Worker/DapMessagesL0.cs new file mode 100644 index 00000000000..1b828571736 --- /dev/null +++ b/src/Test/L0/Worker/DapMessagesL0.cs @@ -0,0 +1,233 @@ +using System.Collections.Generic; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Xunit; +using GitHub.Runner.Worker.Dap; + +namespace GitHub.Runner.Common.Tests.Worker +{ + public sealed class DapMessagesL0 + { + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void RequestSerializesCorrectly() + { + var request = new Request + { + Seq = 1, + Type = "request", + Command = "initialize", + Arguments = JObject.FromObject(new { clientID = "test-client" }) + }; + + var json = JsonConvert.SerializeObject(request); + var deserialized = JsonConvert.DeserializeObject(json); + + Assert.Equal(1, deserialized.Seq); + Assert.Equal("request", deserialized.Type); + Assert.Equal("initialize", deserialized.Command); + Assert.Equal("test-client", deserialized.Arguments["clientID"].ToString()); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ResponseSerializesCorrectly() + { + var response = new Response + { + Seq = 2, + Type = "response", + RequestSeq = 1, + Success = true, + Command = "initialize", + Body = new Capabilities { SupportsConfigurationDoneRequest = true } + }; + + var json = JsonConvert.SerializeObject(response); + var deserialized = JsonConvert.DeserializeObject(json); + + Assert.Equal(2, deserialized.Seq); + Assert.Equal("response", deserialized.Type); + Assert.Equal(1, deserialized.RequestSeq); + Assert.True(deserialized.Success); + Assert.Equal("initialize", deserialized.Command); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EventSerializesWithCorrectType() + { + var evt = new Event + { + EventType = "stopped", + Body = new StoppedEventBody + { + Reason = "entry", + Description = "Stopped at entry", + ThreadId = 1, + AllThreadsStopped = true + } + }; + + Assert.Equal("event", evt.Type); + + var json = JsonConvert.SerializeObject(evt); + Assert.Contains("\"type\":\"event\"", json); + Assert.Contains("\"event\":\"stopped\"", json); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void StoppedEventBodyOmitsNullFields() + { + var body = new StoppedEventBody + { + Reason = "step" + }; + + var json = JsonConvert.SerializeObject(body); + Assert.Contains("\"reason\":\"step\"", json); + Assert.DoesNotContain("\"threadId\"", json); + Assert.DoesNotContain("\"allThreadsStopped\"", json); + Assert.DoesNotContain("\"description\"", json); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void CapabilitiesMvpDefaults() + { + var caps = new Capabilities + { + SupportsConfigurationDoneRequest = true, + SupportsFunctionBreakpoints = false, + SupportsStepBack = false + }; + + var json = JsonConvert.SerializeObject(caps); + var deserialized = JsonConvert.DeserializeObject(json); + + Assert.True(deserialized.SupportsConfigurationDoneRequest); + Assert.False(deserialized.SupportsFunctionBreakpoints); + Assert.False(deserialized.SupportsStepBack); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ContinueResponseBodySerialization() + { + var body = new ContinueResponseBody { AllThreadsContinued = true }; + var json = JsonConvert.SerializeObject(body); + var deserialized = JsonConvert.DeserializeObject(json); + + Assert.True(deserialized.AllThreadsContinued); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ThreadsResponseBodySerialization() + { + var body = new ThreadsResponseBody + { + Threads = new List + { + new Thread { Id = 1, Name = "Job Thread" } + } + }; + + var json = JsonConvert.SerializeObject(body); + var deserialized = JsonConvert.DeserializeObject(json); + + Assert.Single(deserialized.Threads); + Assert.Equal(1, deserialized.Threads[0].Id); + Assert.Equal("Job Thread", deserialized.Threads[0].Name); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void StackFrameSerialization() + { + var frame = new StackFrame + { + Id = 1, + Name = "Step: Checkout", + Line = 1, + Column = 1, + PresentationHint = "normal" + }; + + var json = JsonConvert.SerializeObject(frame); + var deserialized = JsonConvert.DeserializeObject(json); + + Assert.Equal(1, deserialized.Id); + Assert.Equal("Step: Checkout", deserialized.Name); + Assert.Equal("normal", deserialized.PresentationHint); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ExitedEventBodySerialization() + { + var body = new ExitedEventBody { ExitCode = 130 }; + var json = JsonConvert.SerializeObject(body); + var deserialized = JsonConvert.DeserializeObject(json); + + Assert.Equal(130, deserialized.ExitCode); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void DapCommandEnumValues() + { + Assert.Equal(0, (int)DapCommand.Continue); + Assert.Equal(1, (int)DapCommand.Next); + Assert.Equal(4, (int)DapCommand.Disconnect); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void RequestDeserializesFromRawJson() + { + var json = @"{""seq"":5,""type"":""request"",""command"":""continue"",""arguments"":{""threadId"":1}}"; + var request = JsonConvert.DeserializeObject(json); + + Assert.Equal(5, request.Seq); + Assert.Equal("request", request.Type); + Assert.Equal("continue", request.Command); + Assert.Equal(1, request.Arguments["threadId"].Value()); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ErrorResponseBodySerialization() + { + var body = new ErrorResponseBody + { + Error = new Message + { + Id = 1, + Format = "Something went wrong", + ShowUser = true + } + }; + + var json = JsonConvert.SerializeObject(body); + var deserialized = JsonConvert.DeserializeObject(json); + + Assert.Equal(1, deserialized.Error.Id); + Assert.Equal("Something went wrong", deserialized.Error.Format); + Assert.True(deserialized.Error.ShowUser); + } + } +} diff --git a/src/Test/L0/Worker/DapReplExecutorL0.cs b/src/Test/L0/Worker/DapReplExecutorL0.cs new file mode 100644 index 00000000000..687d2093a02 --- /dev/null +++ b/src/Test/L0/Worker/DapReplExecutorL0.cs @@ -0,0 +1,237 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using GitHub.DistributedTask.Expressions2; +using GitHub.DistributedTask.Pipelines.ContextData; +using GitHub.Runner.Common.Tests; +using GitHub.Runner.Worker; +using GitHub.Runner.Worker.Dap; +using Moq; +using Xunit; + +namespace GitHub.Runner.Common.Tests.Worker +{ + public sealed class DapReplExecutorL0 + { + private TestHostContext _hc; + private DapReplExecutor _executor; + private List _sentEvents; + + private TestHostContext CreateTestContext([CallerMemberName] string testName = "") + { + _hc = new TestHostContext(this, testName); + _sentEvents = new List(); + _executor = new DapReplExecutor(_hc, (category, text) => + { + _sentEvents.Add(new Event + { + EventType = "output", + Body = new OutputEventBody + { + Category = category, + Output = text + } + }); + }); + return _hc; + } + + private Mock CreateMockContext( + DictionaryContextData exprValues = null, + IDictionary> jobDefaults = null) + { + var mock = new Mock(); + mock.Setup(x => x.ExpressionValues).Returns(exprValues ?? new DictionaryContextData()); + mock.Setup(x => x.ExpressionFunctions).Returns(new List()); + + var global = new GlobalContext + { + PrependPath = new List(), + JobDefaults = jobDefaults + ?? new Dictionary>(StringComparer.OrdinalIgnoreCase), + }; + mock.Setup(x => x.Global).Returns(global); + + return mock; + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task ExecuteRunCommand_NullContext_ReturnsError() + { + using (CreateTestContext()) + { + var command = new RunCommand { Script = "echo hello" }; + var result = await _executor.ExecuteRunCommandAsync(command, null, CancellationToken.None); + + Assert.Equal("error", result.Type); + Assert.Contains("No execution context available", result.Result); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ExpandExpressions_NoExpressions_ReturnsInput() + { + using (CreateTestContext()) + { + var context = CreateMockContext(); + var result = _executor.ExpandExpressions("echo hello", context.Object); + + Assert.Equal("echo hello", result); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ExpandExpressions_NullInput_ReturnsEmpty() + { + using (CreateTestContext()) + { + var context = CreateMockContext(); + var result = _executor.ExpandExpressions(null, context.Object); + + Assert.Equal(string.Empty, result); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ExpandExpressions_EmptyInput_ReturnsEmpty() + { + using (CreateTestContext()) + { + var context = CreateMockContext(); + var result = _executor.ExpandExpressions("", context.Object); + + Assert.Equal(string.Empty, result); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ExpandExpressions_UnterminatedExpression_KeepsLiteral() + { + using (CreateTestContext()) + { + var context = CreateMockContext(); + var result = _executor.ExpandExpressions("echo ${{ github.repo", context.Object); + + Assert.Equal("echo ${{ github.repo", result); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ResolveDefaultShell_NoJobDefaults_ReturnsPlatformDefault() + { + using (CreateTestContext()) + { + var context = CreateMockContext(); + var result = _executor.ResolveDefaultShell(context.Object); + +#if OS_WINDOWS + Assert.True(result == "pwsh" || result == "powershell"); +#else + Assert.Equal("sh", result); +#endif + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ResolveDefaultShell_WithJobDefault_ReturnsJobDefault() + { + using (CreateTestContext()) + { + var jobDefaults = new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + ["run"] = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["shell"] = "bash" + } + }; + var context = CreateMockContext(jobDefaults: jobDefaults); + var result = _executor.ResolveDefaultShell(context.Object); + + Assert.Equal("bash", result); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void BuildEnvironment_MergesEnvContextAndReplOverrides() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + var envData = new DictionaryContextData + { + ["FOO"] = new StringContextData("bar"), + }; + exprValues["env"] = envData; + + var context = CreateMockContext(exprValues); + var replEnv = new Dictionary { { "BAZ", "qux" } }; + var result = _executor.BuildEnvironment(context.Object, replEnv); + + Assert.Equal("bar", result["FOO"]); + Assert.Equal("qux", result["BAZ"]); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void BuildEnvironment_ReplOverridesWin() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + var envData = new DictionaryContextData + { + ["FOO"] = new StringContextData("original"), + }; + exprValues["env"] = envData; + + var context = CreateMockContext(exprValues); + var replEnv = new Dictionary { { "FOO", "override" } }; + var result = _executor.BuildEnvironment(context.Object, replEnv); + + Assert.Equal("override", result["FOO"]); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void BuildEnvironment_NullReplEnv_ReturnsContextEnvOnly() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + var envData = new DictionaryContextData + { + ["FOO"] = new StringContextData("bar"), + }; + exprValues["env"] = envData; + + var context = CreateMockContext(exprValues); + var result = _executor.BuildEnvironment(context.Object, null); + + Assert.Equal("bar", result["FOO"]); + Assert.False(result.ContainsKey("BAZ")); + } + } + } +} diff --git a/src/Test/L0/Worker/DapReplParserL0.cs b/src/Test/L0/Worker/DapReplParserL0.cs new file mode 100644 index 00000000000..0a15a37f400 --- /dev/null +++ b/src/Test/L0/Worker/DapReplParserL0.cs @@ -0,0 +1,314 @@ +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using GitHub.Runner.Common.Tests; +using GitHub.Runner.Worker.Dap; +using Xunit; + +namespace GitHub.Runner.Common.Tests.Worker +{ + public sealed class DapReplParserL0 + { + #region help command + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_HelpReturnsHelpCommand() + { + var cmd = DapReplParser.TryParse("help", out var error); + + Assert.Null(error); + var help = Assert.IsType(cmd); + Assert.Null(help.Topic); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_HelpCaseInsensitive() + { + var cmd = DapReplParser.TryParse("Help", out var error); + Assert.Null(error); + Assert.IsType(cmd); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_HelpWithTopic() + { + var cmd = DapReplParser.TryParse("help(\"run\")", out var error); + + Assert.Null(error); + var help = Assert.IsType(cmd); + Assert.Equal("run", help.Topic); + } + + #endregion + + #region run command — basic + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_RunSimpleScript() + { + var cmd = DapReplParser.TryParse("run(\"echo hello\")", out var error); + + Assert.Null(error); + var run = Assert.IsType(cmd); + Assert.Equal("echo hello", run.Script); + Assert.Null(run.Shell); + Assert.Null(run.Env); + Assert.Null(run.WorkingDirectory); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_RunWithShell() + { + var cmd = DapReplParser.TryParse("run(\"echo hello\", shell: \"bash\")", out var error); + + Assert.Null(error); + var run = Assert.IsType(cmd); + Assert.Equal("echo hello", run.Script); + Assert.Equal("bash", run.Shell); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_RunWithWorkingDirectory() + { + var cmd = DapReplParser.TryParse("run(\"ls\", working_directory: \"/tmp\")", out var error); + + Assert.Null(error); + var run = Assert.IsType(cmd); + Assert.Equal("ls", run.Script); + Assert.Equal("/tmp", run.WorkingDirectory); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_RunWithEnv() + { + var cmd = DapReplParser.TryParse("run(\"echo $FOO\", env: { FOO: \"bar\" })", out var error); + + Assert.Null(error); + var run = Assert.IsType(cmd); + Assert.Equal("echo $FOO", run.Script); + Assert.NotNull(run.Env); + Assert.Equal("bar", run.Env["FOO"]); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_RunWithMultipleEnvVars() + { + var cmd = DapReplParser.TryParse("run(\"echo\", env: { A: \"1\", B: \"2\" })", out var error); + + Assert.Null(error); + var run = Assert.IsType(cmd); + Assert.Equal(2, run.Env.Count); + Assert.Equal("1", run.Env["A"]); + Assert.Equal("2", run.Env["B"]); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_RunWithAllOptions() + { + var input = "run(\"echo $X\", shell: \"zsh\", env: { X: \"1\" }, working_directory: \"/tmp\")"; + var cmd = DapReplParser.TryParse(input, out var error); + + Assert.Null(error); + var run = Assert.IsType(cmd); + Assert.Equal("echo $X", run.Script); + Assert.Equal("zsh", run.Shell); + Assert.Equal("1", run.Env["X"]); + Assert.Equal("/tmp", run.WorkingDirectory); + } + + #endregion + + #region run command — edge cases + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_RunWithEscapedQuotes() + { + var cmd = DapReplParser.TryParse("run(\"echo \\\"hello\\\"\")", out var error); + + Assert.Null(error); + var run = Assert.IsType(cmd); + Assert.Equal("echo \"hello\"", run.Script); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_RunWithCommaInEnvValue() + { + var cmd = DapReplParser.TryParse("run(\"echo\", env: { CSV: \"a,b,c\" })", out var error); + + Assert.Null(error); + var run = Assert.IsType(cmd); + Assert.Equal("a,b,c", run.Env["CSV"]); + } + + #endregion + + #region error cases + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_RunEmptyArgsReturnsError() + { + var cmd = DapReplParser.TryParse("run()", out var error); + + Assert.NotNull(error); + Assert.Null(cmd); + Assert.Contains("requires a script argument", error); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_RunUnquotedArgReturnsError() + { + var cmd = DapReplParser.TryParse("run(echo hello)", out var error); + + Assert.NotNull(error); + Assert.Null(cmd); + Assert.Contains("quoted string", error); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_RunUnknownOptionReturnsError() + { + var cmd = DapReplParser.TryParse("run(\"echo\", timeout: \"10\")", out var error); + + Assert.NotNull(error); + Assert.Null(cmd); + Assert.Contains("Unknown option", error); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_RunMissingClosingParenReturnsError() + { + var cmd = DapReplParser.TryParse("run(\"echo\"", out var error); + + Assert.NotNull(error); + Assert.Null(cmd); + } + + #endregion + + #region non-DSL input falls through + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_ExpressionReturnsNull() + { + var cmd = DapReplParser.TryParse("github.repository", out var error); + + Assert.Null(error); + Assert.Null(cmd); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_WrappedExpressionReturnsNull() + { + var cmd = DapReplParser.TryParse("${{ github.event_name }}", out var error); + + Assert.Null(error); + Assert.Null(cmd); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_EmptyInputReturnsNull() + { + var cmd = DapReplParser.TryParse("", out var error); + Assert.Null(error); + Assert.Null(cmd); + + cmd = DapReplParser.TryParse(null, out error); + Assert.Null(error); + Assert.Null(cmd); + } + + #endregion + + #region help text + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetGeneralHelp_ContainsCommands() + { + var help = DapReplParser.GetGeneralHelp(); + + Assert.Contains("help", help); + Assert.Contains("run", help); + Assert.Contains("expression", help, System.StringComparison.OrdinalIgnoreCase); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetRunHelp_ContainsOptions() + { + var help = DapReplParser.GetRunHelp(); + + Assert.Contains("shell", help); + Assert.Contains("env", help); + Assert.Contains("working_directory", help); + } + + #endregion + + #region internal parser helpers + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void SplitArguments_HandlesNestedBraces() + { + var args = DapReplParser.SplitArguments("\"hello\", env: { A: \"1\", B: \"2\" }", out var error); + + Assert.Null(error); + Assert.Equal(2, args.Count); + Assert.Equal("\"hello\"", args[0].Trim()); + Assert.Contains("A:", args[1]); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ParseEnvBlock_HandlesEmptyBlock() + { + var result = DapReplParser.ParseEnvBlock("{ }", out var error); + + Assert.Null(error); + Assert.NotNull(result); + Assert.Empty(result); + } + + #endregion + } +} diff --git a/src/Test/L0/Worker/DapVariableProviderL0.cs b/src/Test/L0/Worker/DapVariableProviderL0.cs new file mode 100644 index 00000000000..401fd098fe8 --- /dev/null +++ b/src/Test/L0/Worker/DapVariableProviderL0.cs @@ -0,0 +1,728 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using GitHub.DistributedTask.Pipelines.ContextData; +using GitHub.DistributedTask.WebApi; +using GitHub.Runner.Common; +using GitHub.Runner.Common.Tests; +using GitHub.Runner.Worker; +using GitHub.Runner.Worker.Dap; +using Xunit; + +namespace GitHub.Runner.Common.Tests.Worker +{ + public sealed class DapVariableProviderL0 + { + private TestHostContext _hc; + private DapVariableProvider _provider; + + private TestHostContext CreateTestContext([CallerMemberName] string testName = "") + { + _hc = new TestHostContext(this, testName); + _provider = new DapVariableProvider(_hc.SecretMasker); + return _hc; + } + + private Moq.Mock CreateMockContext(DictionaryContextData expressionValues) + { + var mock = new Moq.Mock(); + mock.Setup(x => x.ExpressionValues).Returns(expressionValues); + return mock; + } + + #region GetScopes tests + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetScopes_ReturnsEmptyWhenContextIsNull() + { + using (CreateTestContext()) + { + var scopes = _provider.GetScopes(null); + Assert.Empty(scopes); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetScopes_ReturnsOnlyPopulatedScopes() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + exprValues["github"] = new DictionaryContextData + { + { "repository", new StringContextData("owner/repo") } + }; + exprValues["env"] = new DictionaryContextData + { + { "CI", new StringContextData("true") }, + { "HOME", new StringContextData("/home/runner") } + }; + // "runner" is not set — should not appear in scopes + + var ctx = CreateMockContext(exprValues); + var scopes = _provider.GetScopes(ctx.Object); + + Assert.Equal(2, scopes.Count); + Assert.Equal("github", scopes[0].Name); + Assert.Equal("env", scopes[1].Name); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetScopes_ReportsNamedVariableCount() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + exprValues["env"] = new DictionaryContextData + { + { "A", new StringContextData("1") }, + { "B", new StringContextData("2") }, + { "C", new StringContextData("3") } + }; + + var ctx = CreateMockContext(exprValues); + var scopes = _provider.GetScopes(ctx.Object); + + Assert.Single(scopes); + Assert.Equal(3, scopes[0].NamedVariables); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetScopes_SecretsGetSpecialPresentationHint() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + exprValues["secrets"] = new DictionaryContextData + { + { "MY_SECRET", new StringContextData("super-secret") } + }; + exprValues["env"] = new DictionaryContextData + { + { "CI", new StringContextData("true") } + }; + + var ctx = CreateMockContext(exprValues); + var scopes = _provider.GetScopes(ctx.Object); + + var envScope = scopes.Find(s => s.Name == "env"); + var secretsScope = scopes.Find(s => s.Name == "secrets"); + + Assert.NotNull(envScope); + Assert.Null(envScope.PresentationHint); + + Assert.NotNull(secretsScope); + Assert.Equal("registers", secretsScope.PresentationHint); + } + } + + #endregion + + #region GetVariables — basic types + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetVariables_ReturnsEmptyWhenContextIsNull() + { + using (CreateTestContext()) + { + var variables = _provider.GetVariables(null, 1); + Assert.Empty(variables); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetVariables_ReturnsStringVariables() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + exprValues["env"] = new DictionaryContextData + { + { "CI", new StringContextData("true") }, + { "HOME", new StringContextData("/home/runner") } + }; + + var ctx = CreateMockContext(exprValues); + // "env" is at ScopeNames index 1 → variablesReference = 2 + var variables = _provider.GetVariables(ctx.Object, 2); + + Assert.Equal(2, variables.Count); + + var ciVar = variables.Find(v => v.Name == "CI"); + Assert.NotNull(ciVar); + Assert.Equal("true", ciVar.Value); + Assert.Equal("string", ciVar.Type); + Assert.Equal(0, ciVar.VariablesReference); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetVariables_ReturnsBooleanVariables() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + exprValues["github"] = new DictionaryContextData + { + { "event_name", new StringContextData("push") }, + }; + // Use a nested dict with boolean to test + var jobDict = new DictionaryContextData(); + // BooleanContextData is a valid PipelineContextData type + // but job context typically has strings. Use env scope instead. + exprValues["env"] = new DictionaryContextData + { + { "flag", new BooleanContextData(true) } + }; + + var ctx = CreateMockContext(exprValues); + // "env" is at index 1 → ref 2 + var variables = _provider.GetVariables(ctx.Object, 2); + + var flagVar = variables.Find(v => v.Name == "flag"); + Assert.NotNull(flagVar); + Assert.Equal("true", flagVar.Value); + Assert.Equal("boolean", flagVar.Type); + Assert.Equal(0, flagVar.VariablesReference); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetVariables_ReturnsNumberVariables() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + exprValues["env"] = new DictionaryContextData + { + { "count", new NumberContextData(42) } + }; + + var ctx = CreateMockContext(exprValues); + var variables = _provider.GetVariables(ctx.Object, 2); + + var countVar = variables.Find(v => v.Name == "count"); + Assert.NotNull(countVar); + Assert.Equal("42", countVar.Value); + Assert.Equal("number", countVar.Type); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetVariables_HandlesNullValues() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + var dict = new DictionaryContextData(); + dict["present"] = new StringContextData("yes"); + dict["missing"] = null; + exprValues["env"] = dict; + + var ctx = CreateMockContext(exprValues); + var variables = _provider.GetVariables(ctx.Object, 2); + + var nullVar = variables.Find(v => v.Name == "missing"); + Assert.NotNull(nullVar); + Assert.Equal("null", nullVar.Value); + Assert.Equal("null", nullVar.Type); + Assert.Equal(0, nullVar.VariablesReference); + } + } + + #endregion + + #region GetVariables — nested expansion + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetVariables_NestedDictionaryIsExpandable() + { + using (CreateTestContext()) + { + var innerDict = new DictionaryContextData + { + { "name", new StringContextData("push") }, + { "ref", new StringContextData("refs/heads/main") } + }; + var exprValues = new DictionaryContextData(); + exprValues["github"] = new DictionaryContextData + { + { "event", innerDict } + }; + + var ctx = CreateMockContext(exprValues); + // "github" is at index 0 → ref 1 + var variables = _provider.GetVariables(ctx.Object, 1); + + var eventVar = variables.Find(v => v.Name == "event"); + Assert.NotNull(eventVar); + Assert.Equal("object", eventVar.Type); + Assert.True(eventVar.VariablesReference > 0, "Nested dict should have a non-zero variablesReference"); + Assert.Equal(2, eventVar.NamedVariables); + + // Now expand it + var children = _provider.GetVariables(ctx.Object, eventVar.VariablesReference); + Assert.Equal(2, children.Count); + + var nameVar = children.Find(v => v.Name == "name"); + Assert.NotNull(nameVar); + Assert.Equal("push", nameVar.Value); + Assert.Equal("${{ github.event.name }}", nameVar.EvaluateName); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetVariables_NestedArrayIsExpandable() + { + using (CreateTestContext()) + { + var array = new ArrayContextData(); + array.Add(new StringContextData("item0")); + array.Add(new StringContextData("item1")); + + var exprValues = new DictionaryContextData(); + exprValues["env"] = new DictionaryContextData + { + { "list", array } + }; + + var ctx = CreateMockContext(exprValues); + var variables = _provider.GetVariables(ctx.Object, 2); + + var listVar = variables.Find(v => v.Name == "list"); + Assert.NotNull(listVar); + Assert.Equal("array", listVar.Type); + Assert.True(listVar.VariablesReference > 0); + Assert.Equal(2, listVar.IndexedVariables); + + // Expand the array + var items = _provider.GetVariables(ctx.Object, listVar.VariablesReference); + Assert.Equal(2, items.Count); + Assert.Equal("[0]", items[0].Name); + Assert.Equal("item0", items[0].Value); + Assert.Equal("[1]", items[1].Name); + Assert.Equal("item1", items[1].Value); + } + } + + #endregion + + #region Secret masking + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetVariables_SecretsScopeValuesAreRedacted() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + exprValues["secrets"] = new DictionaryContextData + { + { "MY_TOKEN", new StringContextData("ghp_abc123secret") }, + { "DB_PASSWORD", new StringContextData("p@ssword!") } + }; + + var ctx = CreateMockContext(exprValues); + // "secrets" is at index 5 → ref 6 + var variables = _provider.GetVariables(ctx.Object, 6); + + Assert.Equal(2, variables.Count); + foreach (var v in variables) + { + Assert.Equal("***", v.Value); + Assert.Equal("string", v.Type); + } + + // Keys should still be visible + Assert.Contains(variables, v => v.Name == "MY_TOKEN"); + Assert.Contains(variables, v => v.Name == "DB_PASSWORD"); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetVariables_NonSecretScopeValuesMaskedBySecretMasker() + { + using (var hc = CreateTestContext()) + { + // Register a known secret value with the masker + hc.SecretMasker.AddValue("super-secret-token"); + + var exprValues = new DictionaryContextData(); + exprValues["env"] = new DictionaryContextData + { + { "SAFE", new StringContextData("hello world") }, + { "LEAKED", new StringContextData("prefix-super-secret-token-suffix") } + }; + + var ctx = CreateMockContext(exprValues); + var variables = _provider.GetVariables(ctx.Object, 2); + + var safeVar = variables.Find(v => v.Name == "SAFE"); + Assert.NotNull(safeVar); + Assert.Equal("hello world", safeVar.Value); + + var leakedVar = variables.Find(v => v.Name == "LEAKED"); + Assert.NotNull(leakedVar); + Assert.DoesNotContain("super-secret-token", leakedVar.Value); + Assert.Contains("***", leakedVar.Value); + } + } + + #endregion + + #region Reset + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Reset_InvalidatesNestedReferences() + { + using (CreateTestContext()) + { + var innerDict = new DictionaryContextData + { + { "name", new StringContextData("push") } + }; + var exprValues = new DictionaryContextData(); + exprValues["github"] = new DictionaryContextData + { + { "event", innerDict } + }; + + var ctx = CreateMockContext(exprValues); + var variables = _provider.GetVariables(ctx.Object, 1); + var eventVar = variables.Find(v => v.Name == "event"); + Assert.True(eventVar.VariablesReference > 0); + + var savedRef = eventVar.VariablesReference; + + // Reset should clear all dynamic references + _provider.Reset(); + + var children = _provider.GetVariables(ctx.Object, savedRef); + Assert.Empty(children); + } + } + + #endregion + + #region EvaluateName + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetVariables_SetsEvaluateNameWithDotPath() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + exprValues["github"] = new DictionaryContextData + { + { "repository", new StringContextData("owner/repo") } + }; + + var ctx = CreateMockContext(exprValues); + var variables = _provider.GetVariables(ctx.Object, 1); + + var repoVar = variables.Find(v => v.Name == "repository"); + Assert.NotNull(repoVar); + Assert.Equal("${{ github.repository }}", repoVar.EvaluateName); + } + } + + #endregion + + #region EvaluateExpression + + /// + /// Creates a mock execution context with Global set up so that + /// ToPipelineTemplateEvaluator() works for real expression evaluation. + /// + private Moq.Mock CreateEvaluatableContext( + TestHostContext hc, + DictionaryContextData expressionValues) + { + var mock = new Moq.Mock(); + mock.Setup(x => x.ExpressionValues).Returns(expressionValues); + mock.Setup(x => x.ExpressionFunctions) + .Returns(new List()); + mock.Setup(x => x.Global).Returns(new GlobalContext + { + FileTable = new List(), + Variables = new Variables(hc, new Dictionary()), + }); + // ToPipelineTemplateEvaluator uses ToTemplateTraceWriter which calls + // context.Write — provide a no-op so it doesn't NRE. + mock.Setup(x => x.Write(Moq.It.IsAny(), Moq.It.IsAny())); + return mock; + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateExpression_ReturnsValueForSimpleExpression() + { + using (var hc = CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + exprValues["github"] = new DictionaryContextData + { + { "repository", new StringContextData("owner/repo") } + }; + + var ctx = CreateEvaluatableContext(hc, exprValues); + var result = _provider.EvaluateExpression("github.repository", ctx.Object); + + Assert.Equal("owner/repo", result.Result); + Assert.Equal("string", result.Type); + Assert.Equal(0, result.VariablesReference); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateExpression_StripsWrapperSyntax() + { + using (var hc = CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + exprValues["github"] = new DictionaryContextData + { + { "event_name", new StringContextData("push") } + }; + + var ctx = CreateEvaluatableContext(hc, exprValues); + var result = _provider.EvaluateExpression("${{ github.event_name }}", ctx.Object); + + Assert.Equal("push", result.Result); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateExpression_MasksSecretInResult() + { + using (var hc = CreateTestContext()) + { + hc.SecretMasker.AddValue("super-secret"); + + var exprValues = new DictionaryContextData(); + exprValues["env"] = new DictionaryContextData + { + { "TOKEN", new StringContextData("super-secret") } + }; + + var ctx = CreateEvaluatableContext(hc, exprValues); + var result = _provider.EvaluateExpression("env.TOKEN", ctx.Object); + + Assert.DoesNotContain("super-secret", result.Result); + Assert.Contains("***", result.Result); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateExpression_ReturnsErrorForInvalidExpression() + { + using (var hc = CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + exprValues["github"] = new DictionaryContextData(); + + var ctx = CreateEvaluatableContext(hc, exprValues); + // An invalid expression syntax should not throw — it should + // return an error result. + var result = _provider.EvaluateExpression("!!!invalid[[", ctx.Object); + + Assert.Contains("error", result.Result, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateExpression_ReturnsMessageWhenNoContext() + { + using (CreateTestContext()) + { + var result = _provider.EvaluateExpression("github.repository", null); + + Assert.Contains("no execution context", result.Result, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateExpression_ReturnsEmptyForEmptyExpression() + { + using (var hc = CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + var ctx = CreateEvaluatableContext(hc, exprValues); + var result = _provider.EvaluateExpression("", ctx.Object); + + Assert.Equal(string.Empty, result.Result); + } + } + + #endregion + + #region InferResultType + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void InferResultType_ClassifiesCorrectly() + { + using (CreateTestContext()) + { + Assert.Equal("null", DapVariableProvider.InferResultType(null)); + Assert.Equal("null", DapVariableProvider.InferResultType("null")); + Assert.Equal("boolean", DapVariableProvider.InferResultType("true")); + Assert.Equal("boolean", DapVariableProvider.InferResultType("false")); + Assert.Equal("number", DapVariableProvider.InferResultType("42")); + Assert.Equal("number", DapVariableProvider.InferResultType("3.14")); + Assert.Equal("object", DapVariableProvider.InferResultType("{\"key\":\"val\"}")); + Assert.Equal("object", DapVariableProvider.InferResultType("[1,2,3]")); + Assert.Equal("string", DapVariableProvider.InferResultType("hello world")); + Assert.Equal("string", DapVariableProvider.InferResultType("owner/repo")); + } + } + + #endregion + + #region Non-string secret type redaction + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetVariables_SecretsScopeRedactsNumberContextData() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + exprValues["secrets"] = new DictionaryContextData + { + { "NUMERIC_SECRET", new NumberContextData(12345) } + }; + + var ctx = CreateMockContext(exprValues); + var variables = _provider.GetVariables(ctx.Object, 6); + + Assert.Single(variables); + Assert.Equal("NUMERIC_SECRET", variables[0].Name); + Assert.Equal("***", variables[0].Value); + Assert.Equal("string", variables[0].Type); + Assert.Equal(0, variables[0].VariablesReference); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetVariables_SecretsScopeRedactsBooleanContextData() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + exprValues["secrets"] = new DictionaryContextData + { + { "BOOL_SECRET", new BooleanContextData(true) } + }; + + var ctx = CreateMockContext(exprValues); + var variables = _provider.GetVariables(ctx.Object, 6); + + Assert.Single(variables); + Assert.Equal("BOOL_SECRET", variables[0].Name); + Assert.Equal("***", variables[0].Value); + Assert.Equal("string", variables[0].Type); + Assert.Equal(0, variables[0].VariablesReference); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetVariables_SecretsScopeRedactsNestedDictionary() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + exprValues["secrets"] = new DictionaryContextData + { + { "NESTED_SECRET", new DictionaryContextData + { + { "inner_key", new StringContextData("inner_value") } + } + } + }; + + var ctx = CreateMockContext(exprValues); + var variables = _provider.GetVariables(ctx.Object, 6); + + Assert.Single(variables); + Assert.Equal("NESTED_SECRET", variables[0].Name); + Assert.Equal("***", variables[0].Value); + Assert.Equal("string", variables[0].Type); + // Nested container should NOT be drillable under secrets + Assert.Equal(0, variables[0].VariablesReference); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetVariables_SecretsScopeRedactsNullValue() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + var secrets = new DictionaryContextData(); + secrets["NULL_SECRET"] = null; + exprValues["secrets"] = secrets; + + var ctx = CreateMockContext(exprValues); + var variables = _provider.GetVariables(ctx.Object, 6); + + Assert.Single(variables); + Assert.Equal("NULL_SECRET", variables[0].Name); + Assert.Equal("***", variables[0].Value); + Assert.Equal(0, variables[0].VariablesReference); + } + } + + #endregion + } +} diff --git a/src/Test/L0/Worker/HandlerFactoryL0.cs b/src/Test/L0/Worker/HandlerFactoryL0.cs index 85a70ff5284..421c9211c43 100644 --- a/src/Test/L0/Worker/HandlerFactoryL0.cs +++ b/src/Test/L0/Worker/HandlerFactoryL0.cs @@ -370,5 +370,504 @@ public void LocalNode20Action_TrackedWhenWarnFlagEnabled() Assert.Contains("./.github/actions/my-action", deprecatedActions); } } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Node20Action_TrackedAsUpgradedWhenUseNode24ByDefaultEnabled() + { + using (TestHostContext hc = CreateTestContext()) + { + // Arrange. + var hf = new HandlerFactory(); + hf.Initialize(hc); + + var variables = new Dictionary + { + { Constants.Runner.NodeMigration.WarnOnNode20Flag, new VariableValue("true") }, + { Constants.Runner.NodeMigration.UseNode24ByDefaultFlag, new VariableValue("true") } + }; + Variables serverVariables = new(hc, variables); + var deprecatedActions = new HashSet(StringComparer.OrdinalIgnoreCase); + var upgradedActions = new HashSet(StringComparer.OrdinalIgnoreCase); + + _ec.Setup(x => x.Global).Returns(new GlobalContext() + { + Variables = serverVariables, + EnvironmentVariables = new Dictionary(), + DeprecatedNode20Actions = deprecatedActions, + UpgradedToNode24Actions = upgradedActions + }); + + var actionRef = new RepositoryPathReference + { + Name = "actions/checkout", + Ref = "v4" + }; + + // Act. + var data = new NodeJSActionExecutionData(); + data.NodeVersion = "node20"; + var handler = hf.Create( + _ec.Object, + actionRef, + new Mock().Object, + data, + new Dictionary(), + new Dictionary(), + new Variables(hc, new Dictionary()), + "", + new List() + ) as INodeScriptActionHandler; + + // On non-ARM32 platforms, action should be upgraded to node24 + // and tracked in UpgradedToNode24Actions, NOT in DeprecatedNode20Actions + bool isArm32Linux = System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture == System.Runtime.InteropServices.Architecture.Arm && + System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Linux); + + if (!isArm32Linux) + { + Assert.Equal("node24", handler.Data.NodeVersion); + Assert.Contains("actions/checkout@v4", upgradedActions); + Assert.DoesNotContain("actions/checkout@v4", deprecatedActions); + } + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Node20Action_NotUpgradedWhenPhase1Only() + { + using (TestHostContext hc = CreateTestContext()) + { + // Arrange. + var hf = new HandlerFactory(); + hf.Initialize(hc); + + var variables = new Dictionary + { + { Constants.Runner.NodeMigration.WarnOnNode20Flag, new VariableValue("true") } + }; + Variables serverVariables = new(hc, variables); + var deprecatedActions = new HashSet(StringComparer.OrdinalIgnoreCase); + var upgradedActions = new HashSet(StringComparer.OrdinalIgnoreCase); + + _ec.Setup(x => x.Global).Returns(new GlobalContext() + { + Variables = serverVariables, + EnvironmentVariables = new Dictionary(), + DeprecatedNode20Actions = deprecatedActions, + UpgradedToNode24Actions = upgradedActions + }); + + var actionRef = new RepositoryPathReference + { + Name = "actions/checkout", + Ref = "v4" + }; + + // Act. + var data = new NodeJSActionExecutionData(); + data.NodeVersion = "node20"; + var handler = hf.Create( + _ec.Object, + actionRef, + new Mock().Object, + data, + new Dictionary(), + new Dictionary(), + new Variables(hc, new Dictionary()), + "", + new List() + ) as INodeScriptActionHandler; + + // In Phase 1 (no UseNode24ByDefault), action stays on node20 + // and should be in DeprecatedNode20Actions + Assert.Equal("node20", handler.Data.NodeVersion); + Assert.Contains("actions/checkout@v4", deprecatedActions); + Assert.Empty(upgradedActions); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ExplicitNode24Action_KillArm32Flag_ThrowsOnArm32() + { + using (TestHostContext hc = CreateTestContext()) + { + // Arrange. + var hf = new HandlerFactory(); + hf.Initialize(hc); + + var variables = new Dictionary + { + { Constants.Runner.NodeMigration.KillLinuxArm32Flag, new VariableValue("true") } + }; + Variables serverVariables = new(hc, variables); + + _ec.Setup(x => x.Global).Returns(new GlobalContext() + { + Variables = serverVariables, + EnvironmentVariables = new Dictionary() + }); + + var actionRef = new RepositoryPathReference + { + Name = "actions/checkout", + Ref = "v5" + }; + + // Act - action explicitly declares node24 + var data = new NodeJSActionExecutionData(); + data.NodeVersion = "node24"; + + bool isArm32Linux = System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture == System.Runtime.InteropServices.Architecture.Arm && + System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Linux); + + if (isArm32Linux) + { + // On ARM32 Linux, kill flag should cause the handler to throw + Assert.Throws(() => hf.Create( + _ec.Object, + actionRef, + new Mock().Object, + data, + new Dictionary(), + new Dictionary(), + new Variables(hc, new Dictionary()), + "", + new List() + )); + } + else + { + // On other platforms, should proceed normally + var handler = hf.Create( + _ec.Object, + actionRef, + new Mock().Object, + data, + new Dictionary(), + new Dictionary(), + new Variables(hc, new Dictionary()), + "", + new List() + ) as INodeScriptActionHandler; + + Assert.Equal("node24", handler.Data.NodeVersion); + } + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ExplicitNode24Action_DeprecateArm32Flag_DowngradesToNode20OnArm32() + { + using (TestHostContext hc = CreateTestContext()) + { + // Arrange. + var hf = new HandlerFactory(); + hf.Initialize(hc); + + var variables = new Dictionary + { + { Constants.Runner.NodeMigration.DeprecateLinuxArm32Flag, new VariableValue("true") } + }; + Variables serverVariables = new(hc, variables); + var arm32Actions = new HashSet(StringComparer.OrdinalIgnoreCase); + + _ec.Setup(x => x.Global).Returns(new GlobalContext() + { + Variables = serverVariables, + EnvironmentVariables = new Dictionary(), + Arm32Node20Actions = arm32Actions + }); + + var actionRef = new RepositoryPathReference + { + Name = "actions/checkout", + Ref = "v5" + }; + + // Act - action explicitly declares node24 + var data = new NodeJSActionExecutionData(); + data.NodeVersion = "node24"; + var handler = hf.Create( + _ec.Object, + actionRef, + new Mock().Object, + data, + new Dictionary(), + new Dictionary(), + new Variables(hc, new Dictionary()), + "", + new List() + ) as INodeScriptActionHandler; + + bool isArm32Linux = System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture == System.Runtime.InteropServices.Architecture.Arm && + System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Linux); + + if (isArm32Linux) + { + // On ARM32 Linux, should downgrade to node20 and track + Assert.Equal("node20", handler.Data.NodeVersion); + Assert.Contains("actions/checkout@v5", arm32Actions); + } + else + { + // On other platforms, should remain node24 + Assert.Equal("node24", handler.Data.NodeVersion); + Assert.Empty(arm32Actions); + } + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ExplicitNode24Action_NoArm32Flags_StaysNode24() + { + using (TestHostContext hc = CreateTestContext()) + { + // Arrange. + var hf = new HandlerFactory(); + hf.Initialize(hc); + + var variables = new Dictionary(); + Variables serverVariables = new(hc, variables); + var arm32Actions = new HashSet(StringComparer.OrdinalIgnoreCase); + var deprecatedActions = new HashSet(StringComparer.OrdinalIgnoreCase); + + _ec.Setup(x => x.Global).Returns(new GlobalContext() + { + Variables = serverVariables, + EnvironmentVariables = new Dictionary(), + Arm32Node20Actions = arm32Actions, + DeprecatedNode20Actions = deprecatedActions + }); + + var actionRef = new RepositoryPathReference + { + Name = "actions/checkout", + Ref = "v5" + }; + + // Act - action explicitly declares node24, no ARM32 flags + var data = new NodeJSActionExecutionData(); + data.NodeVersion = "node24"; + var handler = hf.Create( + _ec.Object, + actionRef, + new Mock().Object, + data, + new Dictionary(), + new Dictionary(), + new Variables(hc, new Dictionary()), + "", + new List() + ) as INodeScriptActionHandler; + + // On non-ARM32 platforms, should stay node24 and not be tracked in any list + bool isArm32Linux = System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture == System.Runtime.InteropServices.Architecture.Arm && + System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Linux); + + if (!isArm32Linux) + { + Assert.Equal("node24", handler.Data.NodeVersion); + Assert.Empty(arm32Actions); + Assert.Empty(deprecatedActions); + } + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Node20Action_RequireNode24_ForcesNode24() + { + using (TestHostContext hc = CreateTestContext()) + { + // Arrange. + var hf = new HandlerFactory(); + hf.Initialize(hc); + + var variables = new Dictionary + { + { Constants.Runner.NodeMigration.RequireNode24Flag, new VariableValue("true") }, + { Constants.Runner.NodeMigration.WarnOnNode20Flag, new VariableValue("true") } + }; + Variables serverVariables = new(hc, variables); + var upgradedActions = new HashSet(StringComparer.OrdinalIgnoreCase); + var deprecatedActions = new HashSet(StringComparer.OrdinalIgnoreCase); + + _ec.Setup(x => x.Global).Returns(new GlobalContext() + { + Variables = serverVariables, + EnvironmentVariables = new Dictionary(), + UpgradedToNode24Actions = upgradedActions, + DeprecatedNode20Actions = deprecatedActions + }); + + var actionRef = new RepositoryPathReference + { + Name = "actions/checkout", + Ref = "v4" + }; + + // Act. + var data = new NodeJSActionExecutionData(); + data.NodeVersion = "node20"; + + bool isArm32Linux = System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture == System.Runtime.InteropServices.Architecture.Arm && + System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Linux); + + if (!isArm32Linux) + { + var handler = hf.Create( + _ec.Object, + actionRef, + new Mock().Object, + data, + new Dictionary(), + new Dictionary(), + new Variables(hc, new Dictionary()), + "", + new List() + ) as INodeScriptActionHandler; + + // Phase 3: RequireNode24 forces node24, ignoring env vars + Assert.Equal("node24", handler.Data.NodeVersion); + Assert.Contains("actions/checkout@v4", upgradedActions); + Assert.Empty(deprecatedActions); + } + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Node20Action_KillArm32Flag_ThrowsOnArm32() + { + using (TestHostContext hc = CreateTestContext()) + { + // Arrange. + var hf = new HandlerFactory(); + hf.Initialize(hc); + + var variables = new Dictionary + { + { Constants.Runner.NodeMigration.KillLinuxArm32Flag, new VariableValue("true") } + }; + Variables serverVariables = new(hc, variables); + + _ec.Setup(x => x.Global).Returns(new GlobalContext() + { + Variables = serverVariables, + EnvironmentVariables = new Dictionary() + }); + + var actionRef = new RepositoryPathReference + { + Name = "actions/checkout", + Ref = "v4" + }; + + var data = new NodeJSActionExecutionData(); + data.NodeVersion = "node20"; + + bool isArm32Linux = System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture == System.Runtime.InteropServices.Architecture.Arm && + System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Linux); + + if (isArm32Linux) + { + Assert.Throws(() => hf.Create( + _ec.Object, + actionRef, + new Mock().Object, + data, + new Dictionary(), + new Dictionary(), + new Variables(hc, new Dictionary()), + "", + new List() + )); + } + else + { + // On non-ARM32, should proceed normally (node20 stays) + var handler = hf.Create( + _ec.Object, + actionRef, + new Mock().Object, + data, + new Dictionary(), + new Dictionary(), + new Variables(hc, new Dictionary()), + "", + new List() + ) as INodeScriptActionHandler; + + Assert.Equal("node20", handler.Data.NodeVersion); + } + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ExplicitNode24Action_DeprecateArm32_UsesOriginalVersionForTracking() + { + // Regression test: verifies that when an action explicitly declares node24 + // and ARM32 deprecation downgrades it to node20, the tracking call uses + // the original preferred version ("node24"), not the already-overwritten + // nodeData.NodeVersion ("node20"). Without this fix, ShouldTrackAsArm32Node20 + // would receive (preferred="node20", final="node20") and never return true. + string originalPreferred = "node24"; + string finalAfterArm32Downgrade = "node20"; + string deprecationWarning = "Linux ARM32 runners are deprecated and will no longer be supported after September 16th, 2026. Please migrate to a supported platform."; + + // Correct: use the original preferred version before assignment + bool correctTracking = HandlerFactory.ShouldTrackAsArm32Node20( + deprecateArm32: true, + preferredNodeVersion: originalPreferred, + finalNodeVersion: finalAfterArm32Downgrade, + platformWarningMessage: deprecationWarning); + Assert.True(correctTracking); + + // Bug scenario: if nodeData.NodeVersion was already overwritten to finalNodeVersion + bool buggyTracking = HandlerFactory.ShouldTrackAsArm32Node20( + deprecateArm32: true, + preferredNodeVersion: finalAfterArm32Downgrade, + finalNodeVersion: finalAfterArm32Downgrade, + platformWarningMessage: deprecationWarning); + Assert.False(buggyTracking); + } + + [Theory] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + [InlineData(true, "node24", "node20", "Linux ARM32 runners are deprecated", true)] + [InlineData(true, "node20", "node20", "Linux ARM32 runners are deprecated", false)] + [InlineData(true, "node24", "node24", "Linux ARM32 runners are deprecated", false)] + [InlineData(true, "node24", "node20", null, false)] + [InlineData(false, "node24", "node20", "Linux ARM32 runners are deprecated", false)] + public void ShouldTrackAsArm32Node20_ClassifiesOnlyPlatformDowngrades( + bool deprecateArm32, + string preferredNodeVersion, + string finalNodeVersion, + string platformWarningMessage, + bool expected) + { + bool actual = HandlerFactory.ShouldTrackAsArm32Node20( + deprecateArm32, + preferredNodeVersion, + finalNodeVersion, + platformWarningMessage); + + Assert.Equal(expected, actual); + } } } diff --git a/src/Test/L0/Worker/JobExtensionL0.cs b/src/Test/L0/Worker/JobExtensionL0.cs index 60814998ef3..40c495a8183 100644 --- a/src/Test/L0/Worker/JobExtensionL0.cs +++ b/src/Test/L0/Worker/JobExtensionL0.cs @@ -8,6 +8,7 @@ using GitHub.DistributedTask.Pipelines.ObjectTemplating; using GitHub.DistributedTask.WebApi; using GitHub.Runner.Worker; +using GitHub.Runner.Worker.Dap; using Moq; using Xunit; using Pipelines = GitHub.DistributedTask.Pipelines; @@ -547,6 +548,10 @@ private async Task EnsureSnapshotPostJobStepForToken(TemplateToken snapshotToken var _stepsRunner = new StepsRunner(); _stepsRunner.Initialize(hc); + + var mockDapDebugger = new Mock(); + hc.SetSingleton(mockDapDebugger.Object); + await _stepsRunner.RunAsync(_jobEc); Assert.Equal("Create custom image", snapshotStep.DisplayName); diff --git a/src/Test/L0/Worker/StepHostNodeVersionL0.cs b/src/Test/L0/Worker/StepHostNodeVersionL0.cs index 6ba8c9fa4e7..1c626c5d6ee 100644 --- a/src/Test/L0/Worker/StepHostNodeVersionL0.cs +++ b/src/Test/L0/Worker/StepHostNodeVersionL0.cs @@ -59,5 +59,161 @@ public void CheckNodeVersionForArm32_PassThroughNonNode24Versions() Assert.Equal("node20", nodeVersion); Assert.Null(warningMessage); } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void CheckNodeVersionForArm32_DeprecationFlagShowsWarning() + { + string preferredVersion = "node24"; + var (nodeVersion, warningMessage) = Common.Util.NodeUtil.CheckNodeVersionForLinuxArm32(preferredVersion, deprecateArm32: true); + + bool isArm32 = RuntimeInformation.ProcessArchitecture == Architecture.Arm || + Environment.GetEnvironmentVariable("PROCESSOR_ARCHITECTURE")?.Contains("ARM") == true; + bool isLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux); + + if (isArm32 && isLinux) + { + Assert.Equal("node20", nodeVersion); + Assert.NotNull(warningMessage); + Assert.Contains("deprecated", warningMessage); + Assert.Contains("no longer be supported", warningMessage); + } + else + { + Assert.Equal("node24", nodeVersion); + Assert.Null(warningMessage); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void CheckNodeVersionForArm32_DeprecationFlagWithNode20PassesThrough() + { + // Even with deprecation flag, node20 should pass through (not downgraded further) + string preferredVersion = "node20"; + var (nodeVersion, warningMessage) = Common.Util.NodeUtil.CheckNodeVersionForLinuxArm32(preferredVersion, deprecateArm32: true); + + bool isArm32 = RuntimeInformation.ProcessArchitecture == Architecture.Arm || + Environment.GetEnvironmentVariable("PROCESSOR_ARCHITECTURE")?.Contains("ARM") == true; + bool isLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux); + + if (isArm32 && isLinux) + { + Assert.Equal("node20", nodeVersion); + Assert.NotNull(warningMessage); + Assert.Contains("deprecated", warningMessage); + } + else + { + Assert.Equal("node20", nodeVersion); + Assert.Null(warningMessage); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void CheckNodeVersionForArm32_KillFlagReturnsNull() + { + string preferredVersion = "node24"; + var (nodeVersion, warningMessage) = Common.Util.NodeUtil.CheckNodeVersionForLinuxArm32(preferredVersion, killArm32: true); + + bool isArm32 = RuntimeInformation.ProcessArchitecture == Architecture.Arm || + Environment.GetEnvironmentVariable("PROCESSOR_ARCHITECTURE")?.Contains("ARM") == true; + bool isLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux); + + if (isArm32 && isLinux) + { + Assert.Null(nodeVersion); + Assert.NotNull(warningMessage); + Assert.Contains("no longer supported", warningMessage); + } + else + { + Assert.Equal("node24", nodeVersion); + Assert.Null(warningMessage); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void CheckNodeVersionForArm32_KillTakesPrecedenceOverDeprecation() + { + string preferredVersion = "node20"; + var (nodeVersion, warningMessage) = Common.Util.NodeUtil.CheckNodeVersionForLinuxArm32(preferredVersion, deprecateArm32: true, killArm32: true); + + bool isArm32 = RuntimeInformation.ProcessArchitecture == Architecture.Arm || + Environment.GetEnvironmentVariable("PROCESSOR_ARCHITECTURE")?.Contains("ARM") == true; + bool isLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux); + + if (isArm32 && isLinux) + { + Assert.Null(nodeVersion); + Assert.NotNull(warningMessage); + Assert.Contains("no longer supported", warningMessage); + } + else + { + Assert.Equal("node20", nodeVersion); + Assert.Null(warningMessage); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void CheckNodeVersionForArm32_ServerOverridableDateUsedInDeprecationWarning() + { + string preferredVersion = "node24"; + string customDate = "December 1st, 2027"; + var (nodeVersion, warningMessage) = Common.Util.NodeUtil.CheckNodeVersionForLinuxArm32( + preferredVersion, deprecateArm32: true, node20RemovalDate: customDate); + + bool isArm32 = RuntimeInformation.ProcessArchitecture == Architecture.Arm || + Environment.GetEnvironmentVariable("PROCESSOR_ARCHITECTURE")?.Contains("ARM") == true; + bool isLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux); + + if (isArm32 && isLinux) + { + Assert.Equal("node20", nodeVersion); + Assert.NotNull(warningMessage); + Assert.Contains(customDate, warningMessage); + Assert.DoesNotContain(Constants.Runner.NodeMigration.Node20RemovalDate, warningMessage); + } + else + { + Assert.Equal("node24", nodeVersion); + Assert.Null(warningMessage); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void CheckNodeVersionForArm32_FallbackDateUsedWhenNoOverride() + { + string preferredVersion = "node24"; + var (nodeVersion, warningMessage) = Common.Util.NodeUtil.CheckNodeVersionForLinuxArm32( + preferredVersion, deprecateArm32: true); + + bool isArm32 = RuntimeInformation.ProcessArchitecture == Architecture.Arm || + Environment.GetEnvironmentVariable("PROCESSOR_ARCHITECTURE")?.Contains("ARM") == true; + bool isLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux); + + if (isArm32 && isLinux) + { + Assert.Equal("node20", nodeVersion); + Assert.NotNull(warningMessage); + Assert.Contains(Constants.Runner.NodeMigration.Node20RemovalDate, warningMessage); + } + else + { + Assert.Equal("node24", nodeVersion); + Assert.Null(warningMessage); + } + } } } diff --git a/src/Test/L0/Worker/StepsRunnerL0.cs b/src/Test/L0/Worker/StepsRunnerL0.cs index a22dc618f8d..2ab9f57fda9 100644 --- a/src/Test/L0/Worker/StepsRunnerL0.cs +++ b/src/Test/L0/Worker/StepsRunnerL0.cs @@ -12,6 +12,7 @@ using GitHub.DistributedTask.WebApi; using GitHub.Runner.Common.Util; using GitHub.Runner.Worker; +using GitHub.Runner.Worker.Dap; namespace GitHub.Runner.Common.Tests.Worker { @@ -61,6 +62,10 @@ private TestHostContext CreateTestContext([CallerMemberName] String testName = " _stepsRunner = new StepsRunner(); _stepsRunner.Initialize(hc); + + var mockDapDebugger = new Mock(); + hc.SetSingleton(mockDapDebugger.Object); + return hc; } diff --git a/src/dev.sh b/src/dev.sh index 716fa08e677..df0a432824b 100755 --- a/src/dev.sh +++ b/src/dev.sh @@ -17,7 +17,7 @@ LAYOUT_DIR="$SCRIPT_DIR/../_layout" DOWNLOAD_DIR="$SCRIPT_DIR/../_downloads/netcore2x" PACKAGE_DIR="$SCRIPT_DIR/../_package" DOTNETSDK_ROOT="$SCRIPT_DIR/../_dotnetsdk" -DOTNETSDK_VERSION="8.0.418" +DOTNETSDK_VERSION="8.0.419" DOTNETSDK_INSTALLDIR="$DOTNETSDK_ROOT/$DOTNETSDK_VERSION" RUNNER_VERSION=$(cat runnerversion) diff --git a/src/global.json b/src/global.json index 519b109fa8a..23550e5a088 100644 --- a/src/global.json +++ b/src/global.json @@ -1,5 +1,5 @@ { "sdk": { - "version": "8.0.418" + "version": "8.0.419" } } diff --git a/src/runnerversion b/src/runnerversion index 96f36b02aa8..6ead143b7c0 100644 --- a/src/runnerversion +++ b/src/runnerversion @@ -1 +1 @@ -2.332.0 +2.333.0