mirror of
https://github.com/actions/runner.git
synced 2026-03-19 00:27:24 +08:00
Compare commits
65 Commits
users/eric
...
rentziass/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba771350ac | ||
|
|
a86ed1ec8f | ||
|
|
d15ab3d8fa | ||
|
|
fa298ebe45 | ||
|
|
da35402572 | ||
|
|
e3c0a6e119 | ||
|
|
f9919b2ba4 | ||
|
|
437e20db7d | ||
|
|
b36d9a6c92 | ||
|
|
4bf2b29367 | ||
|
|
7f39d40e38 | ||
|
|
9cd74b0f26 | ||
|
|
9d33c82d61 | ||
|
|
7d0f26a557 | ||
|
|
e11d6cfa59 | ||
|
|
00bde90018 | ||
|
|
5bad8cb359 | ||
|
|
8d1e06f436 | ||
|
|
649dc74be3 | ||
|
|
75760d1f34 | ||
|
|
e4406e035e | ||
|
|
a8f3b9195d | ||
|
|
8d6b38a428 | ||
|
|
860a919081 | ||
|
|
b76917a8a0 | ||
|
|
165fb90296 | ||
|
|
735dd69833 | ||
|
|
852e8721d0 | ||
|
|
2a98a8c955 | ||
|
|
f31e1c7c43 | ||
|
|
1573e36a44 | ||
|
|
0d33fd1930 | ||
|
|
2c65db137a | ||
|
|
3d8c844883 | ||
|
|
915e13c842 | ||
|
|
17b05ddaa4 | ||
|
|
9737dfadd5 | ||
|
|
cca15de3b3 | ||
|
|
8b1b23b5ce | ||
|
|
20111cbfda | ||
|
|
8f01257663 | ||
|
|
8a73bccebb | ||
|
|
a9a07a6553 | ||
|
|
60a9422599 | ||
|
|
985a06fcca | ||
|
|
ae09a9d7b5 | ||
|
|
7650fc432e | ||
|
|
bc00800857 | ||
|
|
86e23605d6 | ||
|
|
0fb7482206 | ||
|
|
052dfbdd68 | ||
|
|
ecb5f298fa | ||
|
|
a2b220990b | ||
|
|
9426c35fda | ||
|
|
72189aabf8 | ||
|
|
e012ab630b | ||
|
|
a798a45826 | ||
|
|
9efea31a89 | ||
|
|
6680090084 | ||
|
|
15cb558d8f | ||
|
|
d5a8a936c1 | ||
|
|
cdb77c6804 | ||
|
|
a4a19b152e | ||
|
|
1b5486aa8f | ||
|
|
4214709d1b |
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "Actions Runner Devcontainer",
|
||||
"image": "mcr.microsoft.com/devcontainers/base:focal",
|
||||
"image": "mcr.microsoft.com/devcontainers/base:noble",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:1": {},
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {},
|
||||
"ghcr.io/devcontainers/features/dotnet": {
|
||||
"version": "8.0.417"
|
||||
"version": "8.0.418"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"version": "20"
|
||||
|
||||
14
.github/workflows/release.yml
vendored
14
.github/workflows/release.yml
vendored
@@ -133,37 +133,37 @@ jobs:
|
||||
|
||||
# Download runner package tar.gz/zip produced by 'build' job
|
||||
- name: Download Artifact (win-x64)
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: runner-packages-win-x64
|
||||
path: ./
|
||||
- name: Download Artifact (win-arm64)
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: runner-packages-win-arm64
|
||||
path: ./
|
||||
- name: Download Artifact (osx-x64)
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: runner-packages-osx-x64
|
||||
path: ./
|
||||
- name: Download Artifact (osx-arm64)
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: runner-packages-osx-arm64
|
||||
path: ./
|
||||
- name: Download Artifact (linux-x64)
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: runner-packages-linux-x64
|
||||
path: ./
|
||||
- name: Download Artifact (linux-arm)
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: runner-packages-linux-arm
|
||||
path: ./
|
||||
- name: Download Artifact (linux-arm64)
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: runner-packages-linux-arm64
|
||||
path: ./
|
||||
|
||||
@@ -32,7 +32,7 @@ We are taking the following steps to better direct requests related to GitHub Ac
|
||||
|
||||
2. High Priority bugs can be reported through Community Discussions or you can report these to our support team https://support.github.com/contact/bug-report.
|
||||
|
||||
3. Security Issues should be handled as per our [security.md](security.md)
|
||||
3. Security Issues should be handled as per our [SECURITY.md](https://github.com/actions/runner?tab=security-ov-file)
|
||||
|
||||
We will still provide security updates for this project and fix major breaking changes during this time.
|
||||
|
||||
|
||||
@@ -28,8 +28,8 @@ Debian based OS (Debian, Ubuntu, Linux Mint)
|
||||
- liblttng-ust1 or liblttng-ust0
|
||||
- libkrb5-3
|
||||
- zlib1g
|
||||
- libssl1.1, libssl1.0.2 or libssl1.0.0
|
||||
- libicu63, libicu60, libicu57 or libicu55
|
||||
- libssl3t64, libssl3, libssl1.1, libssl1.0.2 or libssl1.0.0
|
||||
- libicu76, libicu75, ..., libicu66, libicu65, libicu63, libicu60, libicu57, libicu55, or libicu52
|
||||
|
||||
Fedora based OS (Fedora, Red Hat Enterprise Linux, CentOS, Oracle Linux 7)
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ RUN curl -f -L -o runner-container-hooks.zip https://github.com/actions/runner-c
|
||||
&& unzip ./runner-container-hooks.zip -d ./k8s \
|
||||
&& rm runner-container-hooks.zip
|
||||
|
||||
RUN curl -f -L -o runner-container-hooks.zip https://github.com/actions/runner-container-hooks/releases/download/v0.8.0/actions-runner-hooks-k8s-0.8.0.zip \
|
||||
RUN curl -f -L -o runner-container-hooks.zip https://github.com/actions/runner-container-hooks/releases/download/v0.8.1/actions-runner-hooks-k8s-0.8.1.zip \
|
||||
&& unzip ./runner-container-hooks.zip -d ./k8s-novolume \
|
||||
&& rm runner-container-hooks.zip
|
||||
|
||||
|
||||
@@ -1,27 +1,35 @@
|
||||
## What's Changed
|
||||
* Fix owner of /home/runner directory by @nikola-jokic in https://github.com/actions/runner/pull/4132
|
||||
* Update Docker to v29.0.2 and Buildx to v0.30.1 by @github-actions[bot] in https://github.com/actions/runner/pull/4135
|
||||
* Update workflow around runner docker image. by @TingluoHuang in https://github.com/actions/runner/pull/4133
|
||||
* Fix regex for validating runner version format by @TingluoHuang in https://github.com/actions/runner/pull/4136
|
||||
* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4144
|
||||
* Ensure safe_sleep tries alternative approaches by @TingluoHuang in https://github.com/actions/runner/pull/4146
|
||||
* Bump actions/github-script from 7 to 8 by @dependabot[bot] in https://github.com/actions/runner/pull/4137
|
||||
* Bump actions/checkout from 5 to 6 by @dependabot[bot] in https://github.com/actions/runner/pull/4130
|
||||
* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4149
|
||||
* Bump docker image to use ubuntu 24.04 by @TingluoHuang in https://github.com/actions/runner/pull/4018
|
||||
* Add support for case function by @AllanGuigou in https://github.com/actions/runner/pull/4147
|
||||
* Cleanup feature flag actions_container_action_runner_temp by @ericsciple in https://github.com/actions/runner/pull/4163
|
||||
* Bump actions/download-artifact from 6 to 7 by @dependabot[bot] in https://github.com/actions/runner/pull/4155
|
||||
* Bump actions/upload-artifact from 5 to 6 by @dependabot[bot] in https://github.com/actions/runner/pull/4157
|
||||
* Set ACTIONS_ORCHESTRATION_ID as env to actions. by @TingluoHuang in https://github.com/actions/runner/pull/4178
|
||||
* Allow hosted VM report job telemetry via .setup_info file. by @TingluoHuang in https://github.com/actions/runner/pull/4186
|
||||
* Bump typescript from 5.9.2 to 5.9.3 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4184
|
||||
* Bump Azure.Storage.Blobs from 12.26.0 to 12.27.0 by @dependabot[bot] in https://github.com/actions/runner/pull/4189
|
||||
* 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
|
||||
|
||||
## New Contributors
|
||||
* @AllanGuigou made their first contribution in https://github.com/actions/runner/pull/4147
|
||||
* @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
|
||||
|
||||
**Full Changelog**: https://github.com/actions/runner/compare/v2.330.0...v2.331.0
|
||||
**Full Changelog**: https://github.com/actions/runner/compare/v2.331.0...v2.332.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.
|
||||
|
||||
186
src/Misc/expressionFunc/hashFiles/package-lock.json
generated
186
src/Misc/expressionFunc/hashFiles/package-lock.json
generated
@@ -12,7 +12,7 @@
|
||||
"@actions/glob": "^0.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@stylistic/eslint-plugin": "^3.1.0",
|
||||
"@stylistic/eslint-plugin": "^5.9.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||
"@typescript-eslint/parser": "^8.0.0",
|
||||
@@ -75,11 +75,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint-community/eslint-utils": {
|
||||
"version": "4.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
|
||||
"integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==",
|
||||
"version": "4.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
|
||||
"integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eslint-visitor-keys": "^3.4.3"
|
||||
},
|
||||
@@ -229,23 +228,36 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@stylistic/eslint-plugin": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-3.1.0.tgz",
|
||||
"integrity": "sha512-pA6VOrOqk0+S8toJYhQGv2MWpQQR0QpeUo9AhNkC49Y26nxBQ/nH1rta9bUU1rPw2fJ1zZEMV5oCX5AazT7J2g==",
|
||||
"version": "5.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.9.0.tgz",
|
||||
"integrity": "sha512-FqqSkvDMYJReydrMhlugc71M76yLLQWNfmGq+SIlLa7N3kHp8Qq8i2PyWrVNAfjOyOIY+xv9XaaYwvVW7vroMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/utils": "^8.13.0",
|
||||
"eslint-visitor-keys": "^4.2.0",
|
||||
"espree": "^10.3.0",
|
||||
"@eslint-community/eslint-utils": "^4.9.1",
|
||||
"@typescript-eslint/types": "^8.56.0",
|
||||
"eslint-visitor-keys": "^4.2.1",
|
||||
"espree": "^10.4.0",
|
||||
"estraverse": "^5.3.0",
|
||||
"picomatch": "^4.0.2"
|
||||
"picomatch": "^4.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": ">=8.40.0"
|
||||
"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": {
|
||||
@@ -524,24 +536,34 @@
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||
"version": "9.0.7",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.7.tgz",
|
||||
"integrity": "sha512-MOwgjc8tfrpn5QQEvjijjmDVtMw2oL88ugTevzxQnzRLm6l3fVEF2gzU0kYeYYKD8C66+IdGX6peJ4MyUlUnPg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
"brace-expansion": "^5.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
@@ -1769,23 +1791,34 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-github/node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"node_modules/eslint-plugin-github/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-github/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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-github/node_modules/minimatch": {
|
||||
"version": "9.0.4",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
|
||||
"integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
|
||||
"version": "9.0.7",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.7.tgz",
|
||||
"integrity": "sha512-MOwgjc8tfrpn5QQEvjijjmDVtMw2oL88ugTevzxQnzRLm6l3fVEF2gzU0kYeYYKD8C66+IdGX6peJ4MyUlUnPg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
"brace-expansion": "^5.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
@@ -3300,9 +3333,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.4",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.4.tgz",
|
||||
"integrity": "sha512-twmL+S8+7yIsE9wsqgzU3E8/LumN3M3QELrBZ20OdmQ9jB2JvW5oZtBEmft84k/Gs5CG9mqtWc6Y9vW+JEzGxw==",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
@@ -4714,9 +4747,9 @@
|
||||
}
|
||||
},
|
||||
"@eslint-community/eslint-utils": {
|
||||
"version": "4.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
|
||||
"integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==",
|
||||
"version": "4.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
|
||||
"integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"eslint-visitor-keys": "^3.4.3"
|
||||
@@ -4821,18 +4854,25 @@
|
||||
}
|
||||
},
|
||||
"@stylistic/eslint-plugin": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-3.1.0.tgz",
|
||||
"integrity": "sha512-pA6VOrOqk0+S8toJYhQGv2MWpQQR0QpeUo9AhNkC49Y26nxBQ/nH1rta9bUU1rPw2fJ1zZEMV5oCX5AazT7J2g==",
|
||||
"version": "5.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.9.0.tgz",
|
||||
"integrity": "sha512-FqqSkvDMYJReydrMhlugc71M76yLLQWNfmGq+SIlLa7N3kHp8Qq8i2PyWrVNAfjOyOIY+xv9XaaYwvVW7vroMA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@typescript-eslint/utils": "^8.13.0",
|
||||
"eslint-visitor-keys": "^4.2.0",
|
||||
"espree": "^10.3.0",
|
||||
"@eslint-community/eslint-utils": "^4.9.1",
|
||||
"@typescript-eslint/types": "^8.56.0",
|
||||
"eslint-visitor-keys": "^4.2.1",
|
||||
"espree": "^10.4.0",
|
||||
"estraverse": "^5.3.0",
|
||||
"picomatch": "^4.0.2"
|
||||
"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",
|
||||
@@ -4992,22 +5032,28 @@
|
||||
"ts-api-utils": "^2.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true
|
||||
},
|
||||
"brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
|
||||
"integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"balanced-match": "^1.0.0"
|
||||
"balanced-match": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"minimatch": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||
"version": "9.0.7",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.7.tgz",
|
||||
"integrity": "sha512-MOwgjc8tfrpn5QQEvjijjmDVtMw2oL88ugTevzxQnzRLm6l3fVEF2gzU0kYeYYKD8C66+IdGX6peJ4MyUlUnPg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
"brace-expansion": "^5.0.2"
|
||||
}
|
||||
},
|
||||
"ts-api-utils": {
|
||||
@@ -5831,22 +5877,28 @@
|
||||
"eslint-visitor-keys": "^3.4.3"
|
||||
}
|
||||
},
|
||||
"balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true
|
||||
},
|
||||
"brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
|
||||
"integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"balanced-match": "^1.0.0"
|
||||
"balanced-match": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"minimatch": {
|
||||
"version": "9.0.4",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
|
||||
"integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
|
||||
"version": "9.0.7",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.7.tgz",
|
||||
"integrity": "sha512-MOwgjc8tfrpn5QQEvjijjmDVtMw2oL88ugTevzxQnzRLm6l3fVEF2gzU0kYeYYKD8C66+IdGX6peJ4MyUlUnPg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
"brace-expansion": "^5.0.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6883,9 +6935,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.4",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.4.tgz",
|
||||
"integrity": "sha512-twmL+S8+7yIsE9wsqgzU3E8/LumN3M3QELrBZ20OdmQ9jB2JvW5oZtBEmft84k/Gs5CG9mqtWc6Y9vW+JEzGxw==",
|
||||
"requires": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"@actions/glob": "^0.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@stylistic/eslint-plugin": "^3.1.0",
|
||||
"@stylistic/eslint-plugin": "^5.9.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||
"@typescript-eslint/parser": "^8.0.0",
|
||||
|
||||
@@ -7,7 +7,7 @@ NODE_ALPINE_URL=https://github.com/actions/alpine_nodejs/releases/download
|
||||
# When you update Node versions you must also create a new release of alpine_nodejs at that updated version.
|
||||
# Follow the instructions here: https://github.com/actions/alpine_nodejs?tab=readme-ov-file#getting-started
|
||||
NODE20_VERSION="20.20.0"
|
||||
NODE24_VERSION="24.13.0"
|
||||
NODE24_VERSION="24.14.0"
|
||||
|
||||
get_abs_path() {
|
||||
# exploits the fact that pwd will print abs path when no args
|
||||
|
||||
@@ -102,7 +102,7 @@ then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
apt_get_with_fallbacks libssl1.1$ libssl1.0.2$ libssl1.0.0$
|
||||
apt_get_with_fallbacks libssl3t64$ libssl3$ libssl1.1$ libssl1.0.2$ libssl1.0.0$
|
||||
if [ $? -ne 0 ]
|
||||
then
|
||||
echo "'$apt_get' failed with exit code '$?'"
|
||||
|
||||
@@ -204,6 +204,26 @@ namespace GitHub.Runner.Common
|
||||
return unescaped;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Escapes special characters in a value using the standard action command escape mappings.
|
||||
/// Iterates in reverse so that '%' is escaped first to avoid double-encoding.
|
||||
/// </summary>
|
||||
public static string EscapeValue(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
string escaped = value;
|
||||
for (int i = _escapeMappings.Length - 1; i >= 0; i--)
|
||||
{
|
||||
escaped = escaped.Replace(_escapeMappings[i].Token, _escapeMappings[i].Replacement);
|
||||
}
|
||||
|
||||
return escaped;
|
||||
}
|
||||
|
||||
private static string UnescapeProperty(string escaped)
|
||||
{
|
||||
if (string.IsNullOrEmpty(escaped))
|
||||
|
||||
@@ -75,6 +75,41 @@ namespace GitHub.Runner.Common
|
||||
{
|
||||
return UrlUtil.IsHostedServer(new UriBuilder(GitHubUrl));
|
||||
}
|
||||
else
|
||||
{
|
||||
// feature flag env in case the new logic is wrong.
|
||||
if (StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable("GITHUB_ACTIONS_RUNNER_FORCE_EMPTY_GITHUB_URL_IS_HOSTED")))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// GitHubUrl will be empty for jit configured runner
|
||||
// We will try to infer it from the ServerUrl/ServerUrlV2
|
||||
if (StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable("GITHUB_ACTIONS_RUNNER_FORCE_GHES")))
|
||||
{
|
||||
// Allow env to override and force GHES in case the inference logic is wrong.
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(ServerUrl))
|
||||
{
|
||||
// pipelines services
|
||||
var serverUrl = new UriBuilder(ServerUrl);
|
||||
return serverUrl.Host.EndsWith(".actions.githubusercontent.com", StringComparison.OrdinalIgnoreCase)
|
||||
|| serverUrl.Host.EndsWith(".codedev.ms", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(ServerUrlV2))
|
||||
{
|
||||
// broker-listener
|
||||
var serverUrlV2 = new UriBuilder(ServerUrlV2);
|
||||
return serverUrlV2.Host.EndsWith(".actions.githubusercontent.com", StringComparison.OrdinalIgnoreCase)
|
||||
|| serverUrlV2.Host.EndsWith(".githubapp.com", StringComparison.OrdinalIgnoreCase)
|
||||
|| serverUrlV2.Host.EndsWith(".ghe.com", StringComparison.OrdinalIgnoreCase)
|
||||
|| serverUrlV2.Host.EndsWith(".actions.localhost", StringComparison.OrdinalIgnoreCase)
|
||||
|| serverUrlV2.Host.EndsWith(".ghe.localhost", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
// Default to true since Hosted runners likely don't have this property set.
|
||||
return true;
|
||||
|
||||
@@ -172,8 +172,10 @@ namespace GitHub.Runner.Common
|
||||
public static readonly string SnapshotPreflightHostedRunnerCheck = "actions_snapshot_preflight_hosted_runner_check";
|
||||
public static readonly string SnapshotPreflightImageGenPoolCheck = "actions_snapshot_preflight_image_gen_pool_check";
|
||||
public static readonly string CompareWorkflowParser = "actions_runner_compare_workflow_parser";
|
||||
public static readonly string ServiceContainerCommand = "actions_service_container_command";
|
||||
public static readonly string SetOrchestrationIdEnvForActions = "actions_set_orchestration_id_env_for_actions";
|
||||
public static readonly string SendJobLevelAnnotations = "actions_send_job_level_annotations";
|
||||
public static readonly string EmitCompositeMarkers = "actions_runner_emit_composite_markers";
|
||||
}
|
||||
|
||||
// Node version migration related constants
|
||||
@@ -190,6 +192,10 @@ namespace GitHub.Runner.Common
|
||||
// Feature flags for controlling the migration phases
|
||||
public static readonly string UseNode24ByDefaultFlag = "actions.runner.usenode24bydefault";
|
||||
public static readonly string RequireNode24Flag = "actions.runner.requirenode24";
|
||||
public static readonly string WarnOnNode20Flag = "actions.runner.warnonnode20";
|
||||
|
||||
// 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/";
|
||||
}
|
||||
|
||||
public static readonly string InternalTelemetryIssueDataKey = "_internal_telemetry";
|
||||
@@ -284,6 +290,8 @@ namespace GitHub.Runner.Common
|
||||
public static readonly string ForcedActionsNodeVersion = "ACTIONS_RUNNER_FORCE_ACTIONS_NODE_VERSION";
|
||||
public static readonly string PrintLogToStdout = "ACTIONS_RUNNER_PRINT_LOG_TO_STDOUT";
|
||||
public static readonly string ActionArchiveCacheDirectory = "ACTIONS_RUNNER_ACTION_ARCHIVE_CACHE";
|
||||
public static readonly string SymlinkCachedActions = "ACTIONS_RUNNER_SYMLINK_CACHED_ACTIONS";
|
||||
public static readonly string EmitCompositeMarkers = "ACTIONS_RUNNER_EMIT_COMPOSITE_MARKERS";
|
||||
}
|
||||
|
||||
public static class System
|
||||
|
||||
@@ -178,8 +178,12 @@ namespace GitHub.Runner.Listener.Configuration
|
||||
}
|
||||
}
|
||||
|
||||
// Validate can connect.
|
||||
await _runnerServer.ConnectAsync(new Uri(runnerSettings.ServerUrl), creds);
|
||||
// Validate can connect using the obtained vss credentials.
|
||||
// In Runner Admin flow there's nothing new to test connection to at this point as registerToken is already validated via GetTenantCredential.
|
||||
if (!runnerSettings.UseRunnerAdminFlow)
|
||||
{
|
||||
await _runnerServer.ConnectAsync(new Uri(runnerSettings.ServerUrl), creds);
|
||||
}
|
||||
|
||||
_term.WriteLine();
|
||||
_term.WriteSuccessMessage("Connected to GitHub");
|
||||
|
||||
@@ -24,7 +24,7 @@ namespace GitHub.Runner.Listener
|
||||
public interface IJobDispatcher : IRunnerService
|
||||
{
|
||||
bool Busy { get; }
|
||||
TaskCompletionSource<bool> RunOnceJobCompleted { get; }
|
||||
TaskCompletionSource<TaskResult> RunOnceJobCompleted { get; }
|
||||
void Run(Pipelines.AgentJobRequestMessage message, bool runOnce = false);
|
||||
bool Cancel(JobCancelMessage message);
|
||||
Task WaitAsync(CancellationToken token);
|
||||
@@ -56,7 +56,7 @@ namespace GitHub.Runner.Listener
|
||||
// timeout limit can be overwritten by environment GITHUB_ACTIONS_RUNNER_CHANNEL_TIMEOUT
|
||||
private TimeSpan _channelTimeout;
|
||||
|
||||
private TaskCompletionSource<bool> _runOnceJobCompleted = new();
|
||||
private TaskCompletionSource<TaskResult> _runOnceJobCompleted = new();
|
||||
|
||||
public event EventHandler<JobStatusEventArgs> JobStatus;
|
||||
|
||||
@@ -82,7 +82,7 @@ namespace GitHub.Runner.Listener
|
||||
Trace.Info($"Set runner/worker IPC timeout to {_channelTimeout.TotalSeconds} seconds.");
|
||||
}
|
||||
|
||||
public TaskCompletionSource<bool> RunOnceJobCompleted => _runOnceJobCompleted;
|
||||
public TaskCompletionSource<TaskResult> RunOnceJobCompleted => _runOnceJobCompleted;
|
||||
|
||||
public bool Busy { get; private set; }
|
||||
|
||||
@@ -340,18 +340,19 @@ namespace GitHub.Runner.Listener
|
||||
|
||||
private async Task RunOnceAsync(Pipelines.AgentJobRequestMessage message, string orchestrationId, WorkerDispatcher previousJobDispatch, CancellationToken jobRequestCancellationToken, CancellationToken workerCancelTimeoutKillToken)
|
||||
{
|
||||
var jobResult = TaskResult.Succeeded;
|
||||
try
|
||||
{
|
||||
await RunAsync(message, orchestrationId, previousJobDispatch, jobRequestCancellationToken, workerCancelTimeoutKillToken);
|
||||
jobResult = await RunAsync(message, orchestrationId, previousJobDispatch, jobRequestCancellationToken, workerCancelTimeoutKillToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Trace.Info("Fire signal for one time used runner.");
|
||||
_runOnceJobCompleted.TrySetResult(true);
|
||||
_runOnceJobCompleted.TrySetResult(jobResult);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunAsync(Pipelines.AgentJobRequestMessage message, string orchestrationId, WorkerDispatcher previousJobDispatch, CancellationToken jobRequestCancellationToken, CancellationToken workerCancelTimeoutKillToken)
|
||||
private async Task<TaskResult> RunAsync(Pipelines.AgentJobRequestMessage message, string orchestrationId, WorkerDispatcher previousJobDispatch, CancellationToken jobRequestCancellationToken, CancellationToken workerCancelTimeoutKillToken)
|
||||
{
|
||||
Busy = true;
|
||||
try
|
||||
@@ -399,7 +400,7 @@ namespace GitHub.Runner.Listener
|
||||
{
|
||||
// renew job request task complete means we run out of retry for the first job request renew.
|
||||
Trace.Info($"Unable to renew job request for job {message.JobId} for the first time, stop dispatching job to worker.");
|
||||
return;
|
||||
return TaskResult.Abandoned;
|
||||
}
|
||||
|
||||
if (jobRequestCancellationToken.IsCancellationRequested)
|
||||
@@ -412,7 +413,7 @@ namespace GitHub.Runner.Listener
|
||||
|
||||
// complete job request with result Cancelled
|
||||
await CompleteJobRequestAsync(_poolId, message, systemConnection, lockToken, TaskResult.Canceled);
|
||||
return;
|
||||
return TaskResult.Canceled;
|
||||
}
|
||||
|
||||
HostContext.WritePerfCounter($"JobRequestRenewed_{requestId.ToString()}");
|
||||
@@ -523,7 +524,7 @@ namespace GitHub.Runner.Listener
|
||||
await renewJobRequest;
|
||||
|
||||
// not finish the job request since the job haven't run on worker at all, we will not going to set a result to server.
|
||||
return;
|
||||
return TaskResult.Failed;
|
||||
}
|
||||
|
||||
// we get first jobrequest renew succeed and start the worker process with the job message.
|
||||
@@ -604,7 +605,7 @@ namespace GitHub.Runner.Listener
|
||||
Trace.Error(detailInfo);
|
||||
}
|
||||
|
||||
return;
|
||||
return TaskResultUtil.TranslateFromReturnCode(returnCode);
|
||||
}
|
||||
else if (completedTask == renewJobRequest)
|
||||
{
|
||||
@@ -706,6 +707,8 @@ namespace GitHub.Runner.Listener
|
||||
|
||||
// complete job request
|
||||
await CompleteJobRequestAsync(_poolId, message, systemConnection, lockToken, resultOnAbandonOrCancel);
|
||||
|
||||
return resultOnAbandonOrCancel;
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -5,8 +5,8 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -324,8 +324,11 @@ namespace GitHub.Runner.Listener
|
||||
HostContext.EnableAuthMigration("EnableAuthMigrationByDefault");
|
||||
}
|
||||
|
||||
// hosted runner only run one job and would like to know the result of the job for telemetry and alerting on failure spike.
|
||||
var returnJobResultForHosted = StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable("ACTIONS_RUNNER_RETURN_JOB_RESULT_FOR_HOSTED"));
|
||||
|
||||
// Run the runner interactively or as service
|
||||
return await ExecuteRunnerAsync(settings, command.RunOnce || settings.Ephemeral);
|
||||
return await ExecuteRunnerAsync(settings, command.RunOnce || settings.Ephemeral || returnJobResultForHosted, returnJobResultForHosted);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -401,17 +404,32 @@ namespace GitHub.Runner.Listener
|
||||
}
|
||||
|
||||
//create worker manager, create message listener and start listening to the queue
|
||||
private async Task<int> RunAsync(RunnerSettings settings, bool runOnce = false)
|
||||
private async Task<int> RunAsync(RunnerSettings settings, bool runOnce = false, bool returnRunOnceJobResult = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
Trace.Info(nameof(RunAsync));
|
||||
|
||||
|
||||
// Validate directory permissions.
|
||||
string workDirectory = HostContext.GetDirectory(WellKnownDirectory.Work);
|
||||
Trace.Info($"Validating directory permissions for: '{workDirectory}'");
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(workDirectory);
|
||||
IOUtil.ValidateExecutePermission(workDirectory);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error(ex);
|
||||
_term.WriteError($"Fail to create and validate runner's work directory '{workDirectory}'.");
|
||||
return Constants.Runner.ReturnCode.TerminatedError;
|
||||
}
|
||||
|
||||
// First try using migrated settings if available
|
||||
var configManager = HostContext.GetService<IConfigurationManager>();
|
||||
RunnerSettings migratedSettings = null;
|
||||
|
||||
try
|
||||
|
||||
try
|
||||
{
|
||||
migratedSettings = configManager.LoadMigratedSettings();
|
||||
Trace.Info("Loaded migrated settings from .runner_migrated file");
|
||||
@@ -422,15 +440,15 @@ namespace GitHub.Runner.Listener
|
||||
// If migrated settings file doesn't exist or can't be loaded, we'll use the provided settings
|
||||
Trace.Info($"Failed to load migrated settings: {ex.Message}");
|
||||
}
|
||||
|
||||
|
||||
bool usedMigratedSettings = false;
|
||||
|
||||
|
||||
if (migratedSettings != null)
|
||||
{
|
||||
// Try to create session with migrated settings first
|
||||
Trace.Info("Attempting to create session using migrated settings");
|
||||
_listener = GetMessageListener(migratedSettings, isMigratedSettings: true);
|
||||
|
||||
|
||||
try
|
||||
{
|
||||
CreateSessionResult createSessionResult = await _listener.CreateSessionAsync(HostContext.RunnerShutdownToken);
|
||||
@@ -450,7 +468,7 @@ namespace GitHub.Runner.Listener
|
||||
Trace.Error($"Exception when creating session with migrated settings: {ex}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// If migrated settings weren't used or session creation failed, use original settings
|
||||
if (!usedMigratedSettings)
|
||||
{
|
||||
@@ -503,7 +521,7 @@ namespace GitHub.Runner.Listener
|
||||
restartSession = true;
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
TaskAgentMessage message = null;
|
||||
bool skipMessageDeletion = false;
|
||||
try
|
||||
@@ -565,6 +583,21 @@ namespace GitHub.Runner.Listener
|
||||
Trace.Info($"Ignore any exception after cancel message loop. {ex}");
|
||||
}
|
||||
|
||||
if (returnRunOnceJobResult)
|
||||
{
|
||||
try
|
||||
{
|
||||
var jobResult = await jobDispatcher.RunOnceJobCompleted.Task;
|
||||
return TaskResultUtil.TranslateToReturnCode(jobResult);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error("run once job finished with error.");
|
||||
Trace.Error(ex);
|
||||
return Constants.Runner.ReturnCode.TerminatedError;
|
||||
}
|
||||
}
|
||||
|
||||
return Constants.Runner.ReturnCode.Success;
|
||||
}
|
||||
}
|
||||
@@ -851,15 +884,15 @@ namespace GitHub.Runner.Listener
|
||||
return Constants.Runner.ReturnCode.Success;
|
||||
}
|
||||
|
||||
private async Task<int> ExecuteRunnerAsync(RunnerSettings settings, bool runOnce)
|
||||
private async Task<int> ExecuteRunnerAsync(RunnerSettings settings, bool runOnce, bool returnRunOnceJobResult)
|
||||
{
|
||||
int returnCode = Constants.Runner.ReturnCode.Success;
|
||||
bool restart = false;
|
||||
do
|
||||
{
|
||||
restart = false;
|
||||
returnCode = await RunAsync(settings, runOnce);
|
||||
|
||||
returnCode = await RunAsync(settings, runOnce, returnRunOnceJobResult);
|
||||
|
||||
if (returnCode == Constants.Runner.ReturnCode.RunnerConfigurationRefreshed)
|
||||
{
|
||||
Trace.Info("Runner configuration was refreshed, restarting session...");
|
||||
|
||||
@@ -120,8 +120,10 @@ namespace GitHub.Runner.Listener
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error(ex);
|
||||
_terminal.WriteError($"Runner update failed: {ex.Message}");
|
||||
_updateTrace.Enqueue(ex.ToString());
|
||||
throw;
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -120,8 +120,10 @@ namespace GitHub.Runner.Listener
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error(ex);
|
||||
_terminal.WriteError($"Runner update failed: {ex.Message}");
|
||||
_updateTrace.Enqueue(ex.ToString());
|
||||
throw;
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -93,6 +93,16 @@ namespace GitHub.Runner.Sdk
|
||||
}
|
||||
}
|
||||
|
||||
public static FileSystemInfo CreateSymbolicLink(string destDirectory, string srcDirectory)
|
||||
{
|
||||
// ensure directory chain exists
|
||||
Directory.CreateDirectory(destDirectory);
|
||||
// delete leaf directory
|
||||
Directory.Delete(destDirectory);
|
||||
// create symlink for the leaf directory
|
||||
return Directory.CreateSymbolicLink(destDirectory, srcDirectory);
|
||||
}
|
||||
|
||||
public static void Delete(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
DeleteDirectory(path, cancellationToken);
|
||||
|
||||
@@ -318,6 +318,17 @@ namespace GitHub.Runner.Worker
|
||||
context.AddIssue(issue, ExecutionContextLogOptions.Default);
|
||||
}
|
||||
|
||||
if (!context.Global.HasDeprecatedSetOutput)
|
||||
{
|
||||
context.Global.HasDeprecatedSetOutput = true;
|
||||
var telemetry = new JobTelemetry
|
||||
{
|
||||
Type = JobTelemetryType.ActionCommand,
|
||||
Message = "DeprecatedCommand: set-output"
|
||||
};
|
||||
context.Global.JobTelemetry.Add(telemetry);
|
||||
}
|
||||
|
||||
if (!command.Properties.TryGetValue(SetOutputCommandProperties.Name, out string outputName) || string.IsNullOrEmpty(outputName))
|
||||
{
|
||||
throw new Exception("Required field 'name' is missing in ##[set-output] command.");
|
||||
@@ -353,6 +364,17 @@ namespace GitHub.Runner.Worker
|
||||
context.AddIssue(issue, ExecutionContextLogOptions.Default);
|
||||
}
|
||||
|
||||
if (!context.Global.HasDeprecatedSaveState)
|
||||
{
|
||||
context.Global.HasDeprecatedSaveState = true;
|
||||
var telemetry = new JobTelemetry
|
||||
{
|
||||
Type = JobTelemetryType.ActionCommand,
|
||||
Message = "DeprecatedCommand: save-state"
|
||||
};
|
||||
context.Global.JobTelemetry.Add(telemetry);
|
||||
}
|
||||
|
||||
if (!command.Properties.TryGetValue(SaveStateCommandProperties.Name, out string stateName) || string.IsNullOrEmpty(stateName))
|
||||
{
|
||||
throw new Exception("Required field 'name' is missing in ##[save-state] command.");
|
||||
|
||||
@@ -111,7 +111,7 @@ namespace GitHub.Runner.Worker
|
||||
{
|
||||
// Log the error and fail the PrepareActionsAsync Initialization.
|
||||
Trace.Error($"Caught exception from PrepareActionsAsync Initialization: {ex}");
|
||||
executionContext.InfrastructureError(ex.Message, category: "resolve_action");
|
||||
executionContext.InfrastructureError(ex.InnerException?.Message ?? ex.Message, category: "resolve_action");
|
||||
executionContext.Result = TaskResult.Failed;
|
||||
throw;
|
||||
}
|
||||
@@ -773,10 +773,6 @@ namespace GitHub.Runner.Worker
|
||||
}
|
||||
else
|
||||
{
|
||||
// make sure we get a clean folder ready to use.
|
||||
IOUtil.DeleteDirectory(destDirectory, executionContext.CancellationToken);
|
||||
Directory.CreateDirectory(destDirectory);
|
||||
|
||||
if (downloadInfo.PackageDetails != null)
|
||||
{
|
||||
executionContext.Output($"##[group]Download immutable action package '{downloadInfo.NameWithOwner}@{downloadInfo.Ref}'");
|
||||
@@ -811,6 +807,50 @@ namespace GitHub.Runner.Worker
|
||||
if (!string.IsNullOrEmpty(actionArchiveCacheDir) &&
|
||||
Directory.Exists(actionArchiveCacheDir))
|
||||
{
|
||||
var symlinkCachedActions = StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable(Constants.Variables.Agent.SymlinkCachedActions));
|
||||
if (symlinkCachedActions)
|
||||
{
|
||||
Trace.Info($"Checking if can symlink '{downloadInfo.ResolvedNameWithOwner}@{downloadInfo.ResolvedSha}'");
|
||||
|
||||
var cacheDirectory = Path.Combine(actionArchiveCacheDir, downloadInfo.ResolvedNameWithOwner.Replace(Path.DirectorySeparatorChar, '_').Replace(Path.AltDirectorySeparatorChar, '_'), downloadInfo.ResolvedSha);
|
||||
if (Directory.Exists(cacheDirectory))
|
||||
{
|
||||
try
|
||||
{
|
||||
Trace.Info($"Found unpacked action directory '{cacheDirectory}' in cache directory '{actionArchiveCacheDir}'");
|
||||
|
||||
// repository archive from github always contains a nested folder
|
||||
var nestedDirectories = new DirectoryInfo(cacheDirectory).GetDirectories();
|
||||
if (nestedDirectories.Length != 1)
|
||||
{
|
||||
throw new InvalidOperationException($"'{cacheDirectory}' contains '{nestedDirectories.Length}' directories");
|
||||
}
|
||||
else
|
||||
{
|
||||
executionContext.Debug($"Symlink '{nestedDirectories[0].Name}' to '{destDirectory}'");
|
||||
// make sure we get a clean folder ready to use.
|
||||
IOUtil.DeleteDirectory(destDirectory, executionContext.CancellationToken);
|
||||
IOUtil.CreateSymbolicLink(destDirectory, nestedDirectories[0].FullName);
|
||||
}
|
||||
|
||||
executionContext.Debug($"Created symlink from cached directory '{cacheDirectory}' to '{destDirectory}'");
|
||||
executionContext.Global.JobTelemetry.Add(new JobTelemetry()
|
||||
{
|
||||
Type = JobTelemetryType.General,
|
||||
Message = $"Action archive cache usage: {downloadInfo.ResolvedNameWithOwner}@{downloadInfo.ResolvedSha} use cache {useActionArchiveCache} has cache {hasActionArchiveCache} via symlink"
|
||||
});
|
||||
|
||||
Trace.Info("Finished getting action repository.");
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error($"Failed to create symlink from cached directory '{cacheDirectory}' to '{destDirectory}'. Error: {ex}");
|
||||
// Fall through to normal download logic
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hasActionArchiveCache = true;
|
||||
Trace.Info($"Check if action archive '{downloadInfo.ResolvedNameWithOwner}@{downloadInfo.ResolvedSha}' already exists in cache directory '{actionArchiveCacheDir}'");
|
||||
#if OS_WINDOWS
|
||||
@@ -892,6 +932,10 @@ namespace GitHub.Runner.Worker
|
||||
}
|
||||
#endif
|
||||
|
||||
// make sure we get a clean folder ready to use.
|
||||
IOUtil.DeleteDirectory(destDirectory, executionContext.CancellationToken);
|
||||
Directory.CreateDirectory(destDirectory);
|
||||
|
||||
// repository archive from github always contains a nested folder
|
||||
var subDirectories = new DirectoryInfo(stagingDirectory).GetDirectories();
|
||||
if (subDirectories.Length != 1)
|
||||
|
||||
@@ -84,7 +84,8 @@ namespace GitHub.Runner.Worker
|
||||
"EvaluateContainerEnvironment",
|
||||
() => _legacyManager.EvaluateContainerEnvironment(executionContext, token, extraExpressionValues),
|
||||
() => _newManager.EvaluateContainerEnvironment(executionContext, ConvertToNewToken(token) as GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens.MappingToken, ConvertToNewExpressionValues(extraExpressionValues)),
|
||||
(legacyResult, newResult) => {
|
||||
(legacyResult, newResult) =>
|
||||
{
|
||||
var trace = HostContext.GetTrace(nameof(ActionManifestManagerWrapper));
|
||||
return CompareDictionaries(trace, legacyResult, newResult, "ContainerEnvironment");
|
||||
});
|
||||
@@ -165,9 +166,150 @@ namespace GitHub.Runner.Worker
|
||||
return null;
|
||||
}
|
||||
|
||||
// Serialize new steps and deserialize to old steps
|
||||
var json = StringUtil.ConvertToJson(newSteps, Newtonsoft.Json.Formatting.None);
|
||||
return StringUtil.ConvertFromJson<List<GitHub.DistributedTask.Pipelines.ActionStep>>(json);
|
||||
var result = new List<GitHub.DistributedTask.Pipelines.ActionStep>();
|
||||
foreach (var step in newSteps)
|
||||
{
|
||||
var actionStep = new GitHub.DistributedTask.Pipelines.ActionStep
|
||||
{
|
||||
ContextName = step.Id,
|
||||
};
|
||||
|
||||
if (step is GitHub.Actions.WorkflowParser.RunStep runStep)
|
||||
{
|
||||
actionStep.Condition = ExtractConditionString(runStep.If);
|
||||
actionStep.DisplayNameToken = ConvertToLegacyToken<TemplateToken>(runStep.Name);
|
||||
actionStep.ContinueOnError = ConvertToLegacyToken<TemplateToken>(runStep.ContinueOnError);
|
||||
actionStep.TimeoutInMinutes = ConvertToLegacyToken<TemplateToken>(runStep.TimeoutMinutes);
|
||||
actionStep.Environment = ConvertToLegacyToken<TemplateToken>(runStep.Env);
|
||||
actionStep.Reference = new GitHub.DistributedTask.Pipelines.ScriptReference();
|
||||
actionStep.Inputs = BuildRunStepInputs(runStep);
|
||||
}
|
||||
else if (step is GitHub.Actions.WorkflowParser.ActionStep usesStep)
|
||||
{
|
||||
actionStep.Condition = ExtractConditionString(usesStep.If);
|
||||
actionStep.DisplayNameToken = ConvertToLegacyToken<TemplateToken>(usesStep.Name);
|
||||
actionStep.ContinueOnError = ConvertToLegacyToken<TemplateToken>(usesStep.ContinueOnError);
|
||||
actionStep.TimeoutInMinutes = ConvertToLegacyToken<TemplateToken>(usesStep.TimeoutMinutes);
|
||||
actionStep.Environment = ConvertToLegacyToken<TemplateToken>(usesStep.Env);
|
||||
actionStep.Reference = ParseActionReference(usesStep.Uses?.Value);
|
||||
actionStep.Inputs = ConvertToLegacyToken<MappingToken>(usesStep.With);
|
||||
}
|
||||
|
||||
result.Add(actionStep);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private string ExtractConditionString(GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens.BasicExpressionToken ifToken)
|
||||
{
|
||||
if (ifToken == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// The Expression property is internal, so we use ToString() which formats as "${{ expr }}"
|
||||
// Then strip the delimiters to get just the expression
|
||||
var str = ifToken.ToString();
|
||||
if (str.StartsWith("${{") && str.EndsWith("}}"))
|
||||
{
|
||||
return str.Substring(3, str.Length - 5).Trim();
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
private MappingToken BuildRunStepInputs(GitHub.Actions.WorkflowParser.RunStep runStep)
|
||||
{
|
||||
var inputs = new MappingToken(null, null, null);
|
||||
|
||||
// script (from run)
|
||||
if (runStep.Run != null)
|
||||
{
|
||||
inputs.Add(
|
||||
new StringToken(null, null, null, "script"),
|
||||
ConvertToLegacyToken<TemplateToken>(runStep.Run));
|
||||
}
|
||||
|
||||
// shell
|
||||
if (runStep.Shell != null)
|
||||
{
|
||||
inputs.Add(
|
||||
new StringToken(null, null, null, "shell"),
|
||||
ConvertToLegacyToken<TemplateToken>(runStep.Shell));
|
||||
}
|
||||
|
||||
// working-directory
|
||||
if (runStep.WorkingDirectory != null)
|
||||
{
|
||||
inputs.Add(
|
||||
new StringToken(null, null, null, "workingDirectory"),
|
||||
ConvertToLegacyToken<TemplateToken>(runStep.WorkingDirectory));
|
||||
}
|
||||
|
||||
return inputs.Count > 0 ? inputs : null;
|
||||
}
|
||||
|
||||
private GitHub.DistributedTask.Pipelines.ActionStepDefinitionReference ParseActionReference(string uses)
|
||||
{
|
||||
if (string.IsNullOrEmpty(uses))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Docker reference: docker://image:tag
|
||||
if (uses.StartsWith("docker://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new GitHub.DistributedTask.Pipelines.ContainerRegistryReference
|
||||
{
|
||||
Image = uses.Substring("docker://".Length)
|
||||
};
|
||||
}
|
||||
|
||||
// Local path reference: ./path/to/action
|
||||
if (uses.StartsWith("./") || uses.StartsWith(".\\"))
|
||||
{
|
||||
return new GitHub.DistributedTask.Pipelines.RepositoryPathReference
|
||||
{
|
||||
RepositoryType = "self",
|
||||
Path = uses
|
||||
};
|
||||
}
|
||||
|
||||
// Repository reference: owner/repo@ref or owner/repo/path@ref
|
||||
var atIndex = uses.LastIndexOf('@');
|
||||
string refPart = null;
|
||||
string repoPart = uses;
|
||||
|
||||
if (atIndex > 0)
|
||||
{
|
||||
refPart = uses.Substring(atIndex + 1);
|
||||
repoPart = uses.Substring(0, atIndex);
|
||||
}
|
||||
|
||||
// Split by / to get owner/repo and optional path
|
||||
var parts = repoPart.Split('/');
|
||||
string name;
|
||||
string path = null;
|
||||
|
||||
if (parts.Length >= 2)
|
||||
{
|
||||
name = $"{parts[0]}/{parts[1]}";
|
||||
if (parts.Length > 2)
|
||||
{
|
||||
path = string.Join("/", parts, 2, parts.Length - 2);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
name = repoPart;
|
||||
}
|
||||
|
||||
return new GitHub.DistributedTask.Pipelines.RepositoryPathReference
|
||||
{
|
||||
RepositoryType = "GitHub",
|
||||
Name = name,
|
||||
Ref = refPart,
|
||||
Path = path
|
||||
};
|
||||
}
|
||||
|
||||
private T ConvertToLegacyToken<T>(GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens.TemplateToken newToken) where T : TemplateToken
|
||||
@@ -633,6 +775,14 @@ namespace GitHub.Runner.Worker
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for known equivalent error patterns (e.g., JSON parse errors)
|
||||
// where both parsers correctly reject invalid input but with different wording
|
||||
if (PipelineTemplateEvaluatorWrapper.HasJsonExceptionType(legacyException) && PipelineTemplateEvaluatorWrapper.HasJsonExceptionType(newException))
|
||||
{
|
||||
trace.Info("CompareExceptions - both exceptions are JSON parse errors, treating as matched");
|
||||
return true;
|
||||
}
|
||||
|
||||
// Compare exception messages recursively (including inner exceptions)
|
||||
var legacyMessages = GetExceptionMessages(legacyException);
|
||||
var newMessages = GetExceptionMessages(newException);
|
||||
@@ -697,5 +847,6 @@ namespace GitHub.Runner.Worker
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ namespace GitHub.Runner.Worker.Container
|
||||
this.ContainerImage = containerImage;
|
||||
this.ContainerDisplayName = $"{container.Alias}_{Pipelines.Validation.NameValidation.Sanitize(containerImage)}_{Guid.NewGuid().ToString("N").Substring(0, 6)}";
|
||||
this.ContainerCreateOptions = container.Options;
|
||||
this.ContainerEntryPoint = container.Entrypoint;
|
||||
this.ContainerEntryPointArgs = container.Command;
|
||||
_environmentVariables = container.Environment;
|
||||
this.IsJobContainer = isJobContainer;
|
||||
this.ContainerNetworkAlias = networkAlias;
|
||||
|
||||
1433
src/Runner.Worker/Dap/DapDebugger.cs
Normal file
1433
src/Runner.Worker/Dap/DapDebugger.cs
Normal file
File diff suppressed because it is too large
Load Diff
1231
src/Runner.Worker/Dap/DapMessages.cs
Normal file
1231
src/Runner.Worker/Dap/DapMessages.cs
Normal file
File diff suppressed because it is too large
Load Diff
369
src/Runner.Worker/Dap/DapReplExecutor.cs
Normal file
369
src/Runner.Worker/Dap/DapReplExecutor.cs
Normal file
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Executes <see cref="RunCommand"/> objects in the job's runtime context.
|
||||
///
|
||||
/// Mirrors the behavior of a normal workflow <c>run:</c> 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 <c>output</c> events with
|
||||
/// secrets masked before emission.
|
||||
/// </summary>
|
||||
internal sealed class DapReplExecutor
|
||||
{
|
||||
private readonly IHostContext _hostContext;
|
||||
private readonly Action<string, string> _sendOutput;
|
||||
private readonly Tracing _trace;
|
||||
|
||||
public DapReplExecutor(IHostContext hostContext, Action<string, string> sendOutput)
|
||||
{
|
||||
_hostContext = hostContext ?? throw new ArgumentNullException(nameof(hostContext));
|
||||
_sendOutput = sendOutput ?? throw new ArgumentNullException(nameof(sendOutput));
|
||||
_trace = hostContext.GetTrace(nameof(DapReplExecutor));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a <see cref="RunCommand"/> and returns the exit code as a
|
||||
/// formatted <see cref="EvaluateResponseBody"/>.
|
||||
/// </summary>
|
||||
public async Task<EvaluateResponseBody> 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<EvaluateResponseBody> 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<IProcessInvoker>())
|
||||
{
|
||||
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 */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Expands <c>${{ }}</c> expressions in the input string using the
|
||||
/// runner's template evaluator — the same evaluation path that processes
|
||||
/// step inputs before <see cref="ScriptHandler"/> runs them.
|
||||
///
|
||||
/// Each <c>${{ expr }}</c> occurrence is individually evaluated and
|
||||
/// replaced with its masked string result, mirroring the semantics of
|
||||
/// expression interpolation in a workflow <c>run:</c> step body.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the default shell the same way <see cref="ScriptHandler"/>
|
||||
/// does: check job defaults, then fall back to platform default.
|
||||
/// </summary>
|
||||
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<string>());
|
||||
var pwshPath = WhichUtil.Which("pwsh", false, _trace, prependPath);
|
||||
return !string.IsNullOrEmpty(pwshPath) ? "pwsh" : "powershell";
|
||||
#else
|
||||
return "sh";
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges the job context environment with any REPL-specific overrides.
|
||||
/// </summary>
|
||||
internal Dictionary<string, string> BuildEnvironment(
|
||||
IExecutionContext context,
|
||||
Dictionary<string, string> replEnv)
|
||||
{
|
||||
var env = new Dictionary<string, string>(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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
411
src/Runner.Worker/Dap/DapReplParser.cs
Normal file
411
src/Runner.Worker/Dap/DapReplParser.cs
Normal file
@@ -0,0 +1,411 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap
|
||||
{
|
||||
/// <summary>
|
||||
/// Base type for all REPL DSL commands.
|
||||
/// </summary>
|
||||
internal abstract class DapReplCommand
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>help</c> or <c>help("run")</c>
|
||||
/// </summary>
|
||||
internal sealed class HelpCommand : DapReplCommand
|
||||
{
|
||||
public string Topic { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>run("echo hello")</c> or
|
||||
/// <c>run("echo hello", shell: "bash", env: { FOO: "bar" }, working_directory: "/tmp")</c>
|
||||
/// </summary>
|
||||
internal sealed class RunCommand : DapReplCommand
|
||||
{
|
||||
public string Script { get; set; }
|
||||
public string Shell { get; set; }
|
||||
public Dictionary<string, string> Env { get; set; }
|
||||
public string WorkingDirectory { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses REPL input into typed <see cref="DapReplCommand"/> objects.
|
||||
///
|
||||
/// Grammar (intentionally minimal — extend as the DSL grows):
|
||||
/// <code>
|
||||
/// 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" }
|
||||
/// </code>
|
||||
///
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal static class DapReplParser
|
||||
{
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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
|
||||
|
||||
/// <summary>
|
||||
/// Given "cmd(...)" returns the inner content between the outer parens.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts a double-quoted string value, handling escaped quotes.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splits a comma-separated argument list, respecting quoted strings
|
||||
/// and nested braces so that <c>"a, b", env: { K: "V, W" }</c> is
|
||||
/// correctly split into two arguments.
|
||||
/// </summary>
|
||||
internal static List<string> SplitArguments(string input, out string error)
|
||||
{
|
||||
error = null;
|
||||
var result = new List<string>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses <c>{ KEY: "value", KEY2: "value2" }</c> into a dictionary.
|
||||
/// </summary>
|
||||
internal static Dictionary<string, string> 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<string, string>();
|
||||
}
|
||||
|
||||
var pairs = SplitArguments(inner, out error);
|
||||
if (error != null) return null;
|
||||
|
||||
var result = new Dictionary<string, string>(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
|
||||
}
|
||||
}
|
||||
387
src/Runner.Worker/Dap/DapVariableProvider.cs
Normal file
387
src/Runner.Worker/Dap/DapVariableProvider.cs
Normal file
@@ -0,0 +1,387 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using GitHub.DistributedTask.ObjectTemplating.Tokens;
|
||||
using GitHub.DistributedTask.Pipelines.ContextData;
|
||||
using GitHub.Runner.Common;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap
|
||||
{
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="GitHub.DistributedTask.Logging.ISecretMasker"/> 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.
|
||||
/// </summary>
|
||||
internal sealed class DapVariableProvider
|
||||
{
|
||||
// Well-known scope names that map to top-level expression contexts.
|
||||
// Order matters: the index determines the stable variablesReference ID.
|
||||
internal 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;
|
||||
|
||||
internal const string RedactedValue = "***";
|
||||
|
||||
private readonly IHostContext _hostContext;
|
||||
|
||||
// Maps dynamic variable reference IDs to the backing data and its
|
||||
// dot-separated path (e.g. "github.event.pull_request").
|
||||
private readonly Dictionary<int, (PipelineContextData Data, string Path)> _variableReferences = new();
|
||||
private int _nextVariableReference = DynamicReferenceBase;
|
||||
|
||||
public DapVariableProvider(IHostContext hostContext)
|
||||
{
|
||||
_hostContext = hostContext ?? throw new ArgumentNullException(nameof(hostContext));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public void Reset()
|
||||
{
|
||||
_variableReferences.Clear();
|
||||
_nextVariableReference = DynamicReferenceBase;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public List<Scope> GetScopes(IExecutionContext context)
|
||||
{
|
||||
var scopes = new List<Scope>();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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+).
|
||||
/// </summary>
|
||||
public List<Variable> GetVariables(IExecutionContext context, int variablesReference)
|
||||
{
|
||||
var variables = new List<Variable>();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the runner's secret masker to the given value.
|
||||
/// This is the single masking entry-point for all DAP-visible strings
|
||||
/// and is intentionally public so future DAP features (evaluate, REPL)
|
||||
/// can reuse it without duplicating policy.
|
||||
/// </summary>
|
||||
public string MaskSecrets(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return value ?? string.Empty;
|
||||
}
|
||||
|
||||
return _hostContext.SecretMasker.MaskSecrets(value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="GitHub.DistributedTask.Pipelines.ObjectTemplating.IPipelineTemplateEvaluator"/>
|
||||
/// so the full expression language is available (functions, operators,
|
||||
/// context access).
|
||||
/// </summary>
|
||||
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 = MaskSecrets(result ?? "null");
|
||||
|
||||
return new EvaluateResponseBody
|
||||
{
|
||||
Result = result,
|
||||
Type = InferResultType(result),
|
||||
VariablesReference = 0
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var errorMessage = MaskSecrets($"Evaluation error: {ex.Message}");
|
||||
return new EvaluateResponseBody
|
||||
{
|
||||
Result = errorMessage,
|
||||
Type = "string",
|
||||
VariablesReference = 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Infers a simple DAP type hint from the string representation of a result.
|
||||
/// </summary>
|
||||
internal static string InferResultType(string value)
|
||||
{
|
||||
if (value == null || value == "null")
|
||||
return "null";
|
||||
if (value == "true" || value == "false")
|
||||
return "boolean";
|
||||
if (double.TryParse(value, System.Globalization.NumberStyles.Any,
|
||||
System.Globalization.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<Variable> 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} }}}}"
|
||||
};
|
||||
|
||||
if (value == null)
|
||||
{
|
||||
variable.Value = isSecretsScope ? RedactedValue : "null";
|
||||
variable.Type = "null";
|
||||
variable.VariablesReference = 0;
|
||||
return variable;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
switch (value)
|
||||
{
|
||||
case StringContextData str:
|
||||
variable.Value = MaskSecrets(str.Value);
|
||||
variable.Type = "string";
|
||||
variable.VariablesReference = 0;
|
||||
break;
|
||||
|
||||
case NumberContextData num:
|
||||
variable.Value = MaskSecrets(num.Value.ToString("G15", System.Globalization.CultureInfo.InvariantCulture));
|
||||
variable.Type = "number";
|
||||
variable.VariablesReference = 0;
|
||||
break;
|
||||
|
||||
case BooleanContextData boolVal:
|
||||
variable.Value = MaskSecrets(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 = 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
|
||||
}
|
||||
}
|
||||
26
src/Runner.Worker/Dap/IDapDebugger.cs
Normal file
26
src/Runner.Worker/Dap/IDapDebugger.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Runner.Common;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap
|
||||
{
|
||||
public enum DapSessionState
|
||||
{
|
||||
WaitingForConnection,
|
||||
Initializing,
|
||||
Ready,
|
||||
Paused,
|
||||
Running,
|
||||
Terminated
|
||||
}
|
||||
|
||||
[ServiceLocator(Default = typeof(DapDebugger))]
|
||||
public interface IDapDebugger : IRunnerService
|
||||
{
|
||||
Task StartAsync(CancellationToken cancellationToken);
|
||||
Task WaitUntilReadyAsync(CancellationToken cancellationToken);
|
||||
Task OnStepStartingAsync(IStep step, IExecutionContext jobContext, CancellationToken cancellationToken);
|
||||
void OnStepCompleted(IStep step);
|
||||
Task OnJobCompletedAsync();
|
||||
}
|
||||
}
|
||||
@@ -856,6 +856,9 @@ namespace GitHub.Runner.Worker
|
||||
// Job level annotations
|
||||
Global.JobAnnotations = new List<Annotation>();
|
||||
|
||||
// Track Node.js 20 actions for deprecation warning
|
||||
Global.DeprecatedNode20Actions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Job Outputs
|
||||
JobOutputs = new Dictionary<string, VariableValue>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -965,6 +968,9 @@ namespace GitHub.Runner.Worker
|
||||
// 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;
|
||||
}
|
||||
@@ -1325,9 +1331,9 @@ namespace GitHub.Runner.Worker
|
||||
UpdateGlobalStepsContext();
|
||||
}
|
||||
|
||||
internal IPipelineTemplateEvaluator ToPipelineTemplateEvaluatorInternal(ObjectTemplating.ITraceWriter traceWriter = null)
|
||||
internal IPipelineTemplateEvaluator ToPipelineTemplateEvaluatorInternal(bool allowServiceContainerCommand, ObjectTemplating.ITraceWriter traceWriter = null)
|
||||
{
|
||||
return new PipelineTemplateEvaluatorWrapper(HostContext, this, traceWriter);
|
||||
return new PipelineTemplateEvaluatorWrapper(HostContext, this, allowServiceContainerCommand, traceWriter);
|
||||
}
|
||||
|
||||
private static void NoOp()
|
||||
@@ -1415,10 +1421,13 @@ namespace GitHub.Runner.Worker
|
||||
|
||||
public static IPipelineTemplateEvaluator ToPipelineTemplateEvaluator(this IExecutionContext context, ObjectTemplating.ITraceWriter traceWriter = null)
|
||||
{
|
||||
var allowServiceContainerCommand = (context.Global.Variables.GetBoolean(Constants.Runner.Features.ServiceContainerCommand) ?? false)
|
||||
|| StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable("ACTIONS_SERVICE_CONTAINER_COMMAND"));
|
||||
|
||||
// Create wrapper?
|
||||
if ((context.Global.Variables.GetBoolean(Constants.Runner.Features.CompareWorkflowParser) ?? false) || StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable("ACTIONS_RUNNER_COMPARE_WORKFLOW_PARSER")))
|
||||
{
|
||||
return (context as ExecutionContext).ToPipelineTemplateEvaluatorInternal(traceWriter);
|
||||
return (context as ExecutionContext).ToPipelineTemplateEvaluatorInternal(allowServiceContainerCommand, traceWriter);
|
||||
}
|
||||
|
||||
// Legacy
|
||||
@@ -1430,6 +1439,7 @@ namespace GitHub.Runner.Worker
|
||||
return new PipelineTemplateEvaluator(traceWriter, schema, context.Global.FileTable)
|
||||
{
|
||||
MaxErrorMessageLength = int.MaxValue, // Don't truncate error messages otherwise we might not scrub secrets correctly
|
||||
AllowServiceContainerCommand = allowServiceContainerCommand,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -27,9 +27,13 @@ namespace GitHub.Runner.Worker
|
||||
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; }
|
||||
public bool HasActionManifestMismatch { get; set; }
|
||||
public bool HasDeprecatedSetOutput { get; set; }
|
||||
public bool HasDeprecatedSaveState { get; set; }
|
||||
public HashSet<string> DeprecatedNode20Actions { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -226,6 +227,11 @@ namespace GitHub.Runner.Worker.Handlers
|
||||
{
|
||||
ArgUtil.NotNull(embeddedSteps, nameof(embeddedSteps));
|
||||
|
||||
bool emitCompositeMarkers =
|
||||
(ExecutionContext.Global.Variables.GetBoolean(Constants.Runner.Features.EmitCompositeMarkers) ?? false)
|
||||
|| StringUtil.ConvertToBoolean(
|
||||
System.Environment.GetEnvironmentVariable(Constants.Variables.Agent.EmitCompositeMarkers));
|
||||
|
||||
foreach (IStep step in embeddedSteps)
|
||||
{
|
||||
Trace.Info($"Processing embedded step: DisplayName='{step.DisplayName}'");
|
||||
@@ -297,6 +303,27 @@ namespace GitHub.Runner.Worker.Handlers
|
||||
SetStepConclusion(step, TaskResult.Failed);
|
||||
}
|
||||
|
||||
// Marker ID uses the step's fully qualified context name (ScopeName.ContextName),
|
||||
// which encodes the full composite nesting chain at any depth.
|
||||
var markerId = emitCompositeMarkers ? step.ExecutionContext.GetFullyQualifiedContextName() : null;
|
||||
var stepStopwatch = default(Stopwatch);
|
||||
var endMarkerEmitted = false;
|
||||
|
||||
// Emit start marker after full context setup so display name expressions resolve correctly
|
||||
if (emitCompositeMarkers)
|
||||
{
|
||||
try
|
||||
{
|
||||
step.EvaluateDisplayName(step.ExecutionContext.ExpressionValues, step.ExecutionContext, out _);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Warning("Caught exception while evaluating embedded step display name. {0}", ex);
|
||||
}
|
||||
ExecutionContext.Output($"##[start-action display={EscapeProperty(SanitizeDisplayName(step.DisplayName))};id={EscapeProperty(markerId)}]");
|
||||
stepStopwatch = Stopwatch.StartNew();
|
||||
}
|
||||
|
||||
// Register Callback
|
||||
CancellationTokenRegistration? jobCancelRegister = null;
|
||||
try
|
||||
@@ -381,6 +408,14 @@ namespace GitHub.Runner.Worker.Handlers
|
||||
// Condition is false
|
||||
Trace.Info("Skipping step due to condition evaluation.");
|
||||
SetStepConclusion(step, TaskResult.Skipped);
|
||||
|
||||
if (emitCompositeMarkers)
|
||||
{
|
||||
stepStopwatch.Stop();
|
||||
ExecutionContext.Output($"##[end-action id={EscapeProperty(markerId)};outcome=skipped;conclusion=skipped;duration_ms=0]");
|
||||
endMarkerEmitted = true;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
else if (conditionEvaluateError != null)
|
||||
@@ -389,13 +424,31 @@ namespace GitHub.Runner.Worker.Handlers
|
||||
step.ExecutionContext.Error(conditionEvaluateError);
|
||||
SetStepConclusion(step, TaskResult.Failed);
|
||||
ExecutionContext.Result = TaskResult.Failed;
|
||||
|
||||
if (emitCompositeMarkers)
|
||||
{
|
||||
stepStopwatch.Stop();
|
||||
ExecutionContext.Output($"##[end-action id={EscapeProperty(markerId)};outcome=failure;conclusion=failure;duration_ms={stepStopwatch.ElapsedMilliseconds}]");
|
||||
endMarkerEmitted = true;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
else
|
||||
{
|
||||
await RunStepAsync(step);
|
||||
}
|
||||
|
||||
if (emitCompositeMarkers)
|
||||
{
|
||||
stepStopwatch.Stop();
|
||||
// Outcome = raw result before continue-on-error (null when continue-on-error didn't fire)
|
||||
// Result = final result after continue-on-error
|
||||
var outcome = (step.ExecutionContext.Outcome ?? step.ExecutionContext.Result ?? TaskResult.Succeeded).ToActionResult().ToString().ToLowerInvariant();
|
||||
var conclusion = (step.ExecutionContext.Result ?? TaskResult.Succeeded).ToActionResult().ToString().ToLowerInvariant();
|
||||
ExecutionContext.Output($"##[end-action id={EscapeProperty(markerId)};outcome={outcome};conclusion={conclusion};duration_ms={stepStopwatch.ElapsedMilliseconds}]");
|
||||
endMarkerEmitted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -404,6 +457,14 @@ namespace GitHub.Runner.Worker.Handlers
|
||||
jobCancelRegister?.Dispose();
|
||||
jobCancelRegister = null;
|
||||
}
|
||||
|
||||
if (emitCompositeMarkers && !endMarkerEmitted)
|
||||
{
|
||||
stepStopwatch.Stop();
|
||||
var outcome = (step.ExecutionContext.Outcome ?? step.ExecutionContext.Result ?? TaskResult.Failed).ToActionResult().ToString().ToLowerInvariant();
|
||||
var conclusion = (step.ExecutionContext.Result ?? TaskResult.Failed).ToActionResult().ToString().ToLowerInvariant();
|
||||
ExecutionContext.Output($"##[end-action id={EscapeProperty(markerId)};outcome={outcome};conclusion={conclusion};duration_ms={stepStopwatch.ElapsedMilliseconds}]");
|
||||
}
|
||||
}
|
||||
// Check failed or cancelled
|
||||
if (step.ExecutionContext.Result == TaskResult.Failed || step.ExecutionContext.Result == TaskResult.Canceled)
|
||||
@@ -470,5 +531,44 @@ namespace GitHub.Runner.Worker.Handlers
|
||||
step.ExecutionContext.Result = result;
|
||||
step.ExecutionContext.UpdateGlobalStepsContext();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Escapes marker property values so they cannot break the ##[command key=value] format.
|
||||
/// Delegates to ActionCommand.EscapeValue which escapes `;`, `]`, `\r`, `\n`, and `%`.
|
||||
/// </summary>
|
||||
internal static string EscapeProperty(string value)
|
||||
{
|
||||
return ActionCommand.EscapeValue(value);
|
||||
}
|
||||
|
||||
/// <summary>Maximum character length for display names in markers to prevent log bloat.</summary>
|
||||
internal const int MaxDisplayNameLength = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a step display name for safe embedding in a marker property.
|
||||
/// Trims leading whitespace, drops everything after the first newline, and
|
||||
/// truncates to <see cref="MaxDisplayNameLength"/> characters.
|
||||
/// </summary>
|
||||
internal static string SanitizeDisplayName(string displayName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(displayName)) return displayName;
|
||||
|
||||
// Take first line only (FormatStepName in ActionRunner.cs already does this
|
||||
// for most cases, but be defensive for any code path that skips it)
|
||||
var result = displayName.TrimStart(' ', '\t', '\r', '\n');
|
||||
var firstNewLine = result.IndexOfAny(new[] { '\r', '\n' });
|
||||
if (firstNewLine >= 0)
|
||||
{
|
||||
result = result.Substring(0, firstNewLine);
|
||||
}
|
||||
|
||||
// Truncate excessively long names
|
||||
if (result.Length > MaxDisplayNameLength)
|
||||
{
|
||||
result = result.Substring(0, MaxDisplayNameLength);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,20 @@ namespace GitHub.Runner.Worker.Handlers
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if node20 was explicitly specified in the action
|
||||
// We don't modify if node24 was explicitly specified
|
||||
if (string.Equals(nodeData.NodeVersion, Constants.Runner.NodeMigration.Node20, StringComparison.InvariantCultureIgnoreCase))
|
||||
@@ -90,7 +104,8 @@ namespace GitHub.Runner.Worker.Handlers
|
||||
if (useNode24ByDefault && !requireNode24 && string.Equals(finalNodeVersion, Constants.Runner.NodeMigration.Node24, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string infoMessage = "Node 20 is being deprecated. This workflow is running with Node 24 by default. " +
|
||||
"If you need to temporarily use Node 20, you can set the ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true environment variable.";
|
||||
"If you need to temporarily use Node 20, you can set the ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true environment variable. " +
|
||||
$"For more information see: {Constants.Runner.NodeMigration.Node20DeprecationUrl}";
|
||||
executionContext.Output(infoMessage);
|
||||
}
|
||||
}
|
||||
@@ -129,5 +144,25 @@ namespace GitHub.Runner.Worker.Handlers
|
||||
handler.LocalActionContainerSetupSteps = localActionContainerSetupSteps;
|
||||
return handler;
|
||||
}
|
||||
|
||||
private static string GetActionName(Pipelines.ActionStepDefinitionReference action)
|
||||
{
|
||||
if (action is Pipelines.RepositoryPathReference repoRef)
|
||||
{
|
||||
var pathString = string.Empty;
|
||||
if (!string.IsNullOrEmpty(repoRef.Path))
|
||||
{
|
||||
pathString = string.IsNullOrEmpty(repoRef.Name)
|
||||
? repoRef.Path
|
||||
: $"/{repoRef.Path}";
|
||||
}
|
||||
var repoString = string.IsNullOrEmpty(repoRef.Ref)
|
||||
? $"{repoRef.Name}{pathString}"
|
||||
: $"{repoRef.Name}{pathString}@{repoRef.Ref}";
|
||||
return string.IsNullOrEmpty(repoString) ? null : repoString;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,14 @@ namespace GitHub.Runner.Worker.Handlers
|
||||
}
|
||||
}
|
||||
|
||||
// Strip runner-controlled markers from user output to prevent injection
|
||||
if (!String.IsNullOrEmpty(line) &&
|
||||
(line.Contains("##[start-action") || line.Contains("##[end-action")))
|
||||
{
|
||||
line = line.Replace("##[start-action", @"##[\start-action")
|
||||
.Replace("##[end-action", @"##[\end-action");
|
||||
}
|
||||
|
||||
// Problem matchers
|
||||
if (_matchers.Length > 0)
|
||||
{
|
||||
|
||||
4
src/Runner.Worker/InternalsVisibleTo.cs
Normal file
4
src/Runner.Worker/InternalsVisibleTo.cs
Normal file
@@ -0,0 +1,4 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("Test")]
|
||||
[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")]
|
||||
@@ -735,6 +735,15 @@ namespace GitHub.Runner.Worker
|
||||
context.Global.JobTelemetry.Add(new JobTelemetry() { Type = JobTelemetryType.ConnectivityCheck, Message = $"Fail to check service connectivity. {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
// Add deprecation warning annotation for Node.js 20 actions
|
||||
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}";
|
||||
context.Warning(deprecationMessage);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -13,6 +13,7 @@ using GitHub.DistributedTask.WebApi;
|
||||
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 @@ namespace GitHub.Runner.Worker
|
||||
|
||||
public sealed class JobRunner : RunnerService, IJobRunner
|
||||
{
|
||||
private const string DebuggerConnectionTelemetryPrefix = "DebuggerConnectionResult";
|
||||
private IJobServerQueue _jobServerQueue;
|
||||
private RunnerSettings _runnerSettings;
|
||||
private ITempDirectoryManager _tempDirectoryManager;
|
||||
@@ -112,6 +114,7 @@ namespace GitHub.Runner.Worker
|
||||
|
||||
IExecutionContext jobContext = null;
|
||||
CancellationTokenRegistration? runnerShutdownRegistration = null;
|
||||
IDapDebugger dapDebugger = null;
|
||||
try
|
||||
{
|
||||
// Create the job execution context.
|
||||
@@ -178,6 +181,26 @@ namespace GitHub.Runner.Worker
|
||||
_tempDirectoryManager = HostContext.GetService<ITempDirectoryManager>();
|
||||
_tempDirectoryManager.InitializeTempDirectory(jobContext);
|
||||
|
||||
// Setup the debugger
|
||||
if (jobContext.Global.EnableDebugger)
|
||||
{
|
||||
Trace.Info("Debugger enabled for this job run");
|
||||
|
||||
try
|
||||
{
|
||||
dapDebugger = HostContext.GetService<IDapDebugger>();
|
||||
await dapDebugger.StartAsync(jobRequestCancellationToken);
|
||||
}
|
||||
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<IJobExtension>();
|
||||
@@ -219,6 +242,33 @@ namespace GitHub.Runner.Worker
|
||||
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(jobRequestCancellationToken);
|
||||
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<IStepsRunner>();
|
||||
@@ -259,6 +309,11 @@ namespace GitHub.Runner.Worker
|
||||
runnerShutdownRegistration = null;
|
||||
}
|
||||
|
||||
if (dapDebugger != null)
|
||||
{
|
||||
await dapDebugger.OnJobCompletedAsync();
|
||||
}
|
||||
|
||||
await ShutdownQueue(throwOnFailure: false);
|
||||
}
|
||||
}
|
||||
@@ -440,6 +495,15 @@ namespace GitHub.Runner.Worker
|
||||
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> jobTelemetry)
|
||||
{
|
||||
foreach (var telemetryItem in jobTelemetry)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using GitHub.Actions.WorkflowParser;
|
||||
using GitHub.DistributedTask.Expressions2;
|
||||
@@ -23,6 +23,7 @@ namespace GitHub.Runner.Worker
|
||||
public PipelineTemplateEvaluatorWrapper(
|
||||
IHostContext hostContext,
|
||||
IExecutionContext context,
|
||||
bool allowServiceContainerCommand,
|
||||
ObjectTemplating.ITraceWriter traceWriter = null)
|
||||
{
|
||||
ArgUtil.NotNull(hostContext, nameof(hostContext));
|
||||
@@ -40,11 +41,14 @@ namespace GitHub.Runner.Worker
|
||||
_legacyEvaluator = new PipelineTemplateEvaluator(traceWriter, schema, context.Global.FileTable)
|
||||
{
|
||||
MaxErrorMessageLength = int.MaxValue, // Don't truncate error messages otherwise we might not scrub secrets correctly
|
||||
AllowServiceContainerCommand = allowServiceContainerCommand,
|
||||
};
|
||||
|
||||
// New evaluator
|
||||
var newTraceWriter = new GitHub.Actions.WorkflowParser.ObjectTemplating.EmptyTraceWriter();
|
||||
_newEvaluator = new WorkflowTemplateEvaluator(newTraceWriter, context.Global.FileTable, features: null)
|
||||
var features = WorkflowFeatures.GetDefaults();
|
||||
features.AllowServiceContainerCommand = allowServiceContainerCommand;
|
||||
_newEvaluator = new WorkflowTemplateEvaluator(newTraceWriter, context.Global.FileTable, features)
|
||||
{
|
||||
MaxErrorMessageLength = int.MaxValue, // Don't truncate error messages otherwise we might not scrub secrets correctly
|
||||
};
|
||||
@@ -216,12 +220,15 @@ namespace GitHub.Runner.Worker
|
||||
}
|
||||
}
|
||||
|
||||
private TLegacy EvaluateAndCompare<TLegacy, TNew>(
|
||||
internal TLegacy EvaluateAndCompare<TLegacy, TNew>(
|
||||
string methodName,
|
||||
Func<TLegacy> legacyEvaluator,
|
||||
Func<TNew> newEvaluator,
|
||||
Func<TLegacy, TNew, bool> resultComparer)
|
||||
{
|
||||
// Capture cancellation state before evaluation
|
||||
var cancellationRequestedBefore = _context.CancellationToken.IsCancellationRequested;
|
||||
|
||||
// Legacy evaluator
|
||||
var legacyException = default(Exception);
|
||||
var legacyResult = default(TLegacy);
|
||||
@@ -253,14 +260,18 @@ namespace GitHub.Runner.Worker
|
||||
newException = ex;
|
||||
}
|
||||
|
||||
// Capture cancellation state after evaluation
|
||||
var cancellationRequestedAfter = _context.CancellationToken.IsCancellationRequested;
|
||||
|
||||
// Compare results or exceptions
|
||||
bool hasMismatch = false;
|
||||
if (legacyException != null || newException != null)
|
||||
{
|
||||
// Either one or both threw exceptions - compare them
|
||||
if (!CompareExceptions(legacyException, newException))
|
||||
{
|
||||
_trace.Info($"{methodName} exception mismatch");
|
||||
RecordMismatch($"{methodName}");
|
||||
hasMismatch = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -269,6 +280,20 @@ namespace GitHub.Runner.Worker
|
||||
if (!resultComparer(legacyResult, newResult))
|
||||
{
|
||||
_trace.Info($"{methodName} mismatch");
|
||||
hasMismatch = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Only record mismatch if it wasn't caused by a cancellation race condition
|
||||
if (hasMismatch)
|
||||
{
|
||||
if (!cancellationRequestedBefore && cancellationRequestedAfter)
|
||||
{
|
||||
// Cancellation state changed during evaluation window - skip recording
|
||||
_trace.Info($"{methodName} mismatch skipped due to cancellation race condition");
|
||||
}
|
||||
else
|
||||
{
|
||||
RecordMismatch($"{methodName}");
|
||||
}
|
||||
}
|
||||
@@ -380,6 +405,18 @@ namespace GitHub.Runner.Worker
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(legacyResult.Entrypoint, newResult.Entrypoint, StringComparison.Ordinal))
|
||||
{
|
||||
_trace.Info($"CompareJobContainer mismatch - Entrypoint differs (legacy='{legacyResult.Entrypoint}', new='{newResult.Entrypoint}')");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(legacyResult.Command, newResult.Command, StringComparison.Ordinal))
|
||||
{
|
||||
_trace.Info($"CompareJobContainer mismatch - Command differs (legacy='{legacyResult.Command}', new='{newResult.Command}')");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!CompareDictionaries(legacyResult.Environment, newResult.Environment, "Environment"))
|
||||
{
|
||||
return false;
|
||||
@@ -612,6 +649,13 @@ namespace GitHub.Runner.Worker
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for known equivalent error patterns (e.g., JSON parse errors)
|
||||
// where both parsers correctly reject invalid input but with different wording
|
||||
if (IsKnownEquivalentErrorPattern(legacyException, newException))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Compare exception messages recursively (including inner exceptions)
|
||||
var legacyMessages = GetExceptionMessages(legacyException);
|
||||
var newMessages = GetExceptionMessages(newException);
|
||||
@@ -634,6 +678,67 @@ namespace GitHub.Runner.Worker
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if two exceptions match a known pattern where both parsers correctly reject
|
||||
/// invalid input but with different error messages (e.g., JSON parse errors from fromJSON).
|
||||
/// </summary>
|
||||
private bool IsKnownEquivalentErrorPattern(Exception legacyException, Exception newException)
|
||||
{
|
||||
// fromJSON('') - both parsers fail when parsing empty string as JSON
|
||||
// The error messages differ but both indicate JSON parsing failure.
|
||||
// Legacy throws raw JsonReaderException: "Error reading JToken from JsonReader..."
|
||||
// New wraps it: "Error parsing fromJson" with inner JsonReaderException
|
||||
// Both may be wrapped in TemplateValidationException: "The template is not valid..."
|
||||
if (HasJsonExceptionType(legacyException) && HasJsonExceptionType(newException))
|
||||
{
|
||||
_trace.Info("CompareExceptions - both exceptions are JSON parse errors, treating as matched");
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the exception chain contains a JSON-related exception type.
|
||||
/// </summary>
|
||||
internal static bool HasJsonExceptionType(Exception ex)
|
||||
{
|
||||
var toProcess = new Queue<Exception>();
|
||||
toProcess.Enqueue(ex);
|
||||
int count = 0;
|
||||
|
||||
while (toProcess.Count > 0 && count < 50)
|
||||
{
|
||||
var current = toProcess.Dequeue();
|
||||
if (current == null) continue;
|
||||
|
||||
count++;
|
||||
|
||||
if (current is Newtonsoft.Json.JsonReaderException ||
|
||||
current is System.Text.Json.JsonException)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (current is AggregateException aggregateEx)
|
||||
{
|
||||
foreach (var innerEx in aggregateEx.InnerExceptions)
|
||||
{
|
||||
if (innerEx != null && count < 50)
|
||||
{
|
||||
toProcess.Enqueue(innerEx);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (current.InnerException != null)
|
||||
{
|
||||
toProcess.Enqueue(current.InnerException);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private IList<string> GetExceptionMessages(Exception ex)
|
||||
{
|
||||
var messages = new List<string>();
|
||||
|
||||
@@ -10,6 +10,7 @@ using GitHub.DistributedTask.WebApi;
|
||||
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 @@ namespace GitHub.Runner.Worker
|
||||
jobContext.JobContext.Status = (jobContext.Result ?? TaskResult.Succeeded).ToActionResult();
|
||||
var scopeInputs = new Dictionary<string, PipelineContextData>(StringComparer.OrdinalIgnoreCase);
|
||||
bool checkPostJobActions = false;
|
||||
var dapDebugger = HostContext.GetService<IDapDebugger>();
|
||||
while (jobContext.JobSteps.Count > 0 || !checkPostJobActions)
|
||||
{
|
||||
if (jobContext.JobSteps.Count == 0 && !checkPostJobActions)
|
||||
@@ -226,9 +228,14 @@ namespace GitHub.Runner.Worker
|
||||
}
|
||||
else
|
||||
{
|
||||
// Pause for DAP debugger before step execution
|
||||
await dapDebugger?.OnStepStartingAsync(step, jobContext, jobContext.CancellationToken);
|
||||
|
||||
// Run the step
|
||||
await RunStepAsync(step, jobContext.CancellationToken);
|
||||
CompleteStep(step);
|
||||
|
||||
dapDebugger?.OnStepCompleted(step);
|
||||
}
|
||||
}
|
||||
finally
|
||||
@@ -255,6 +262,7 @@ namespace GitHub.Runner.Worker
|
||||
|
||||
Trace.Info($"Current state: job state = '{jobContext.Result}'");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private async Task RunStepAsync(IStep step, CancellationToken jobCancellationToken)
|
||||
|
||||
@@ -253,6 +253,13 @@ namespace GitHub.DistributedTask.Pipelines
|
||||
set;
|
||||
}
|
||||
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public bool EnableDebugger
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the collection of variables associated with the current context.
|
||||
/// </summary>
|
||||
|
||||
@@ -39,6 +39,24 @@ namespace GitHub.DistributedTask.Pipelines
|
||||
set;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the container entrypoint override.
|
||||
/// </summary>
|
||||
public String Entrypoint
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the container command and args (after the image name).
|
||||
/// </summary>
|
||||
public String Command
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the volumes which are mounted into the container.
|
||||
/// </summary>
|
||||
|
||||
@@ -47,6 +47,8 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
|
||||
public const String NumberStrategyContext = "number-strategy-context";
|
||||
public const String On = "on";
|
||||
public const String Options = "options";
|
||||
public const String Entrypoint = "entrypoint";
|
||||
public const String Command = "command";
|
||||
public const String Outputs = "outputs";
|
||||
public const String OutputsPattern = "needs.*.outputs";
|
||||
public const String Password = "password";
|
||||
|
||||
@@ -237,7 +237,8 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
|
||||
internal static JobContainer ConvertToJobContainer(
|
||||
TemplateContext context,
|
||||
TemplateToken value,
|
||||
bool allowExpressions = false)
|
||||
bool allowExpressions = false,
|
||||
bool allowServiceContainerCommand = false)
|
||||
{
|
||||
var result = new JobContainer();
|
||||
if (allowExpressions && value.Traverse().Any(x => x is ExpressionToken))
|
||||
@@ -280,6 +281,22 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
|
||||
case PipelineTemplateConstants.Options:
|
||||
result.Options = containerPropertyPair.Value.AssertString($"{PipelineTemplateConstants.Container} {propertyName}").Value;
|
||||
break;
|
||||
case PipelineTemplateConstants.Entrypoint:
|
||||
if (!allowServiceContainerCommand)
|
||||
{
|
||||
context.Error(containerPropertyPair.Key, $"The key '{PipelineTemplateConstants.Entrypoint}' is not allowed");
|
||||
break;
|
||||
}
|
||||
result.Entrypoint = containerPropertyPair.Value.AssertString($"{PipelineTemplateConstants.Container} {propertyName}").Value;
|
||||
break;
|
||||
case PipelineTemplateConstants.Command:
|
||||
if (!allowServiceContainerCommand)
|
||||
{
|
||||
context.Error(containerPropertyPair.Key, $"The key '{PipelineTemplateConstants.Command}' is not allowed");
|
||||
break;
|
||||
}
|
||||
result.Command = containerPropertyPair.Value.AssertString($"{PipelineTemplateConstants.Container} {propertyName}").Value;
|
||||
break;
|
||||
case PipelineTemplateConstants.Ports:
|
||||
var ports = containerPropertyPair.Value.AssertSequence($"{PipelineTemplateConstants.Container} {propertyName}");
|
||||
var portList = new List<String>(ports.Count);
|
||||
@@ -326,7 +343,8 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
|
||||
internal static List<KeyValuePair<String, JobContainer>> ConvertToJobServiceContainers(
|
||||
TemplateContext context,
|
||||
TemplateToken services,
|
||||
bool allowExpressions = false)
|
||||
bool allowExpressions = false,
|
||||
bool allowServiceContainerCommand = false)
|
||||
{
|
||||
var result = new List<KeyValuePair<String, JobContainer>>();
|
||||
|
||||
@@ -340,7 +358,7 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
|
||||
foreach (var servicePair in servicesMapping)
|
||||
{
|
||||
var networkAlias = servicePair.Key.AssertString("services key").Value;
|
||||
var container = ConvertToJobContainer(context, servicePair.Value);
|
||||
var container = ConvertToJobContainer(context, servicePair.Value, allowExpressions, allowServiceContainerCommand);
|
||||
result.Add(new KeyValuePair<String, JobContainer>(networkAlias, container));
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,8 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
|
||||
|
||||
public Int32 MaxResultSize { get; set; } = 10 * 1024 * 1024; // 10 mb
|
||||
|
||||
public bool AllowServiceContainerCommand { get; set; }
|
||||
|
||||
public Boolean EvaluateStepContinueOnError(
|
||||
TemplateToken token,
|
||||
DictionaryContextData contextData,
|
||||
@@ -357,7 +359,7 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
|
||||
{
|
||||
token = TemplateEvaluator.Evaluate(context, PipelineTemplateConstants.Services, token, 0, null, omitHeader: true);
|
||||
context.Errors.Check();
|
||||
result = PipelineTemplateConverter.ConvertToJobServiceContainers(context, token);
|
||||
result = PipelineTemplateConverter.ConvertToJobServiceContainers(context, token, allowServiceContainerCommand: AllowServiceContainerCommand);
|
||||
}
|
||||
catch (Exception ex) when (!(ex is TemplateValidationException))
|
||||
{
|
||||
|
||||
@@ -430,6 +430,21 @@
|
||||
}
|
||||
},
|
||||
|
||||
"service-container-mapping": {
|
||||
"mapping": {
|
||||
"properties": {
|
||||
"image": "string",
|
||||
"options": "string",
|
||||
"entrypoint": "string",
|
||||
"command": "string",
|
||||
"env": "container-env",
|
||||
"ports": "sequence-of-non-empty-string",
|
||||
"volumes": "sequence-of-non-empty-string",
|
||||
"credentials": "container-registry-credentials"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"services": {
|
||||
"context": [
|
||||
"github",
|
||||
@@ -454,7 +469,7 @@
|
||||
],
|
||||
"one-of": [
|
||||
"string",
|
||||
"container-mapping"
|
||||
"service-container-mapping"
|
||||
]
|
||||
},
|
||||
|
||||
|
||||
@@ -62,6 +62,8 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
||||
public const String NumberStrategyContext = "number-strategy-context";
|
||||
public const String On = "on";
|
||||
public const String Options = "options";
|
||||
public const String Entrypoint = "entrypoint";
|
||||
public const String Command = "command";
|
||||
public const String Org = "org";
|
||||
public const String Organization = "organization";
|
||||
public const String Outputs = "outputs";
|
||||
|
||||
@@ -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;
|
||||
@@ -43,7 +43,7 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
||||
{
|
||||
case WorkflowTemplateConstants.On:
|
||||
var inputTypes = ConvertToOnWorkflowDispatchInputTypes(workflowPair.Value);
|
||||
foreach(var item in inputTypes)
|
||||
foreach (var item in inputTypes)
|
||||
{
|
||||
result.InputTypes.TryAdd(item.Key, item.Value);
|
||||
}
|
||||
@@ -432,7 +432,7 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
||||
context.Error(snapshotToken, $"job {WorkflowTemplateConstants.Snapshot} {WorkflowTemplateConstants.ImageName} is required.");
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
return new Snapshot
|
||||
{
|
||||
ImageName = imageName,
|
||||
@@ -445,7 +445,7 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
||||
{
|
||||
var versionSegments = versionString.Split(".");
|
||||
|
||||
if (versionSegments.Length != 2 ||
|
||||
if (versionSegments.Length != 2 ||
|
||||
!versionSegments[1].Equals("*") ||
|
||||
!Int32.TryParse(versionSegments[0], NumberStyles.None, CultureInfo.InvariantCulture, result: out int parsedMajor) ||
|
||||
parsedMajor < 0)
|
||||
@@ -1079,7 +1079,8 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
||||
internal static JobContainer ConvertToJobContainer(
|
||||
TemplateContext context,
|
||||
TemplateToken value,
|
||||
bool isEarlyValidation = false)
|
||||
bool isEarlyValidation = false,
|
||||
bool isServiceContainer = false)
|
||||
{
|
||||
var result = new JobContainer();
|
||||
if (isEarlyValidation && value.Traverse().Any(x => x is ExpressionToken))
|
||||
@@ -1089,11 +1090,34 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
||||
|
||||
if (value is StringToken containerLiteral)
|
||||
{
|
||||
if (String.IsNullOrEmpty(containerLiteral.Value))
|
||||
// Trim "docker://"
|
||||
var trimmedImage = containerLiteral.Value;
|
||||
var hasDockerPrefix = containerLiteral.Value.StartsWith(WorkflowTemplateConstants.DockerUriPrefix, StringComparison.Ordinal);
|
||||
if (hasDockerPrefix)
|
||||
{
|
||||
trimmedImage = trimmedImage.Substring(WorkflowTemplateConstants.DockerUriPrefix.Length);
|
||||
}
|
||||
|
||||
// Empty shorthand after trimming "docker://" ?
|
||||
if (String.IsNullOrEmpty(trimmedImage))
|
||||
{
|
||||
// Error at parse-time for:
|
||||
// 1. container: 'docker://'
|
||||
// 2. services.foo: ''
|
||||
// 3. services.foo: 'docker://'
|
||||
//
|
||||
// Do not error for:
|
||||
// 1. container: ''
|
||||
if (isEarlyValidation && (hasDockerPrefix || isServiceContainer))
|
||||
{
|
||||
context.Error(value, "Container image cannot be empty");
|
||||
}
|
||||
|
||||
// Short-circuit
|
||||
return null;
|
||||
}
|
||||
|
||||
// Store original, trimmed further below
|
||||
result.Image = containerLiteral.Value;
|
||||
}
|
||||
else
|
||||
@@ -1122,6 +1146,22 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
||||
case WorkflowTemplateConstants.Options:
|
||||
result.Options = containerPropertyPair.Value.AssertString($"{WorkflowTemplateConstants.Container} {propertyName}").Value;
|
||||
break;
|
||||
case WorkflowTemplateConstants.Entrypoint:
|
||||
if (!context.GetFeatures().AllowServiceContainerCommand)
|
||||
{
|
||||
context.Error(containerPropertyPair.Key, $"The key '{WorkflowTemplateConstants.Entrypoint}' is not allowed");
|
||||
break;
|
||||
}
|
||||
result.Entrypoint = containerPropertyPair.Value.AssertString($"{WorkflowTemplateConstants.Container} {propertyName}").Value;
|
||||
break;
|
||||
case WorkflowTemplateConstants.Command:
|
||||
if (!context.GetFeatures().AllowServiceContainerCommand)
|
||||
{
|
||||
context.Error(containerPropertyPair.Key, $"The key '{WorkflowTemplateConstants.Command}' is not allowed");
|
||||
break;
|
||||
}
|
||||
result.Command = containerPropertyPair.Value.AssertString($"{WorkflowTemplateConstants.Container} {propertyName}").Value;
|
||||
break;
|
||||
case WorkflowTemplateConstants.Ports:
|
||||
var ports = containerPropertyPair.Value.AssertSequence($"{WorkflowTemplateConstants.Container} {propertyName}");
|
||||
var portList = new List<String>(ports.Count);
|
||||
@@ -1152,15 +1192,28 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
||||
}
|
||||
}
|
||||
|
||||
if (String.IsNullOrEmpty(result.Image))
|
||||
// Trim "docker://"
|
||||
var hadDockerPrefix = false;
|
||||
if (!String.IsNullOrEmpty(result.Image) && result.Image.StartsWith(WorkflowTemplateConstants.DockerUriPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
context.Error(value, "Container image cannot be empty");
|
||||
return null;
|
||||
hadDockerPrefix = true;
|
||||
result.Image = result.Image.Substring(WorkflowTemplateConstants.DockerUriPrefix.Length);
|
||||
}
|
||||
|
||||
if (result.Image.StartsWith(WorkflowTemplateConstants.DockerUriPrefix, StringComparison.Ordinal))
|
||||
if (String.IsNullOrEmpty(result.Image))
|
||||
{
|
||||
result.Image = result.Image.Substring(WorkflowTemplateConstants.DockerUriPrefix.Length);
|
||||
// Error at parse-time for:
|
||||
// 1. container: {image: 'docker://'}
|
||||
// 2. services.foo: {image: ''}
|
||||
// 3. services.foo: {image: 'docker://'}
|
||||
//
|
||||
// Do not error for:
|
||||
// 1. container: {image: ''}
|
||||
if (isEarlyValidation && (hadDockerPrefix || isServiceContainer))
|
||||
{
|
||||
context.Error(value, "Container image cannot be empty");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -1183,7 +1236,7 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
||||
foreach (var servicePair in servicesMapping)
|
||||
{
|
||||
var networkAlias = servicePair.Key.AssertString("services key").Value;
|
||||
var container = ConvertToJobContainer(context, servicePair.Value);
|
||||
var container = ConvertToJobContainer(context, servicePair.Value, isEarlyValidation, isServiceContainer: true);
|
||||
result.Add(new KeyValuePair<String, JobContainer>(networkAlias, container));
|
||||
}
|
||||
|
||||
@@ -1838,9 +1891,9 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
||||
case "actions":
|
||||
permissions.Actions = permissionLevel;
|
||||
break;
|
||||
case "artifact-metadata":
|
||||
permissions.ArtifactMetadata = permissionLevel;
|
||||
break;
|
||||
case "artifact-metadata":
|
||||
permissions.ArtifactMetadata = permissionLevel;
|
||||
break;
|
||||
case "attestations":
|
||||
permissions.Attestations = permissionLevel;
|
||||
break;
|
||||
|
||||
@@ -35,6 +35,24 @@ namespace GitHub.Actions.WorkflowParser
|
||||
set;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the container entrypoint override.
|
||||
/// </summary>
|
||||
public String Entrypoint
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the container command and args (after the image name).
|
||||
/// </summary>
|
||||
public String Command
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the volumes which are mounted into the container.
|
||||
/// </summary>
|
||||
|
||||
@@ -48,6 +48,13 @@ namespace GitHub.Actions.WorkflowParser
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public bool StrictJsonParsing { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether service containers may specify "entrypoint" and "command".
|
||||
/// Used during parsing and evaluation.
|
||||
/// </summary>
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public bool AllowServiceContainerCommand { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the default workflow features.
|
||||
/// </summary>
|
||||
@@ -60,6 +67,7 @@ namespace GitHub.Actions.WorkflowParser
|
||||
Snapshot = false, // Default to false since this feature is still in an experimental phase
|
||||
StrictJsonParsing = false, // Default to false since this is temporary for telemetry purposes only
|
||||
AllowModelsPermission = false, // Default to false since we want this to be disabled for all non-production environments
|
||||
AllowServiceContainerCommand = false, // Default to false since this feature is gated by actions_service_container_command
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2589,21 +2589,53 @@
|
||||
"mapping": {
|
||||
"properties": {
|
||||
"image": {
|
||||
"type": "non-empty-string",
|
||||
"description": "Use `jobs.<job_id>.container.image` to define the Docker image to use as the container to run the action. The value can be the Docker Hub image or a registry name."
|
||||
"type": "string",
|
||||
"description": "The Docker image to use as the container. The value can be the Docker Hub image or a registry name."
|
||||
},
|
||||
"options": {
|
||||
"type": "string",
|
||||
"description": "Use `jobs.<job_id>.container.options` to configure additional Docker container resource options."
|
||||
"description": "Additional Docker container resource options."
|
||||
},
|
||||
"env": "container-env",
|
||||
"ports": {
|
||||
"type": "sequence-of-non-empty-string",
|
||||
"description": "Use `jobs.<job_id>.container.ports` to set an array of ports to expose on the container."
|
||||
"description": "An array of ports to expose on the container."
|
||||
},
|
||||
"volumes": {
|
||||
"type": "sequence-of-non-empty-string",
|
||||
"description": "Use `jobs.<job_id>.container.volumes` to set an array of volumes for the container to use. You can use volumes to share data between services or other steps in a job. You can specify named Docker volumes, anonymous Docker volumes, or bind mounts on the host."
|
||||
"description": "An array of volumes for the container to use. You can use volumes to share data between services or other steps in a job. You can specify named Docker volumes, anonymous Docker volumes, or bind mounts on the host."
|
||||
},
|
||||
"credentials": "container-registry-credentials"
|
||||
}
|
||||
}
|
||||
},
|
||||
"service-container-mapping": {
|
||||
"mapping": {
|
||||
"properties": {
|
||||
"image": {
|
||||
"type": "string",
|
||||
"description": "The Docker image to use as the container. The value can be the Docker Hub image or a registry name."
|
||||
},
|
||||
"options": {
|
||||
"type": "string",
|
||||
"description": "Additional Docker container resource options."
|
||||
},
|
||||
"entrypoint": {
|
||||
"type": "string",
|
||||
"description": "Override the default ENTRYPOINT in the service container image."
|
||||
},
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "Override the default CMD in the service container image."
|
||||
},
|
||||
"env": "container-env",
|
||||
"ports": {
|
||||
"type": "sequence-of-non-empty-string",
|
||||
"description": "An array of ports to expose on the container."
|
||||
},
|
||||
"volumes": {
|
||||
"type": "sequence-of-non-empty-string",
|
||||
"description": "An array of volumes for the container to use. You can use volumes to share data between services or other steps in a job. You can specify named Docker volumes, anonymous Docker volumes, or bind mounts on the host."
|
||||
},
|
||||
"credentials": "container-registry-credentials"
|
||||
}
|
||||
@@ -2634,12 +2666,12 @@
|
||||
"matrix"
|
||||
],
|
||||
"one-of": [
|
||||
"non-empty-string",
|
||||
"container-mapping"
|
||||
"string",
|
||||
"service-container-mapping"
|
||||
]
|
||||
},
|
||||
"container-registry-credentials": {
|
||||
"description": "If the image's container registry requires authentication to pull the image, you can use `jobs.<job_id>.container.credentials` to set a map of the username and password. The credentials are the same values that you would provide to the `docker login` command.",
|
||||
"description": "If the container registry requires authentication to pull the image, set a map of the username and password. The credentials are the same values that you would provide to the `docker login` command.",
|
||||
"context": [
|
||||
"github",
|
||||
"inputs",
|
||||
@@ -2655,7 +2687,7 @@
|
||||
}
|
||||
},
|
||||
"container-env": {
|
||||
"description": "Use `jobs.<job_id>.container.env` to set a map of variables in the container.",
|
||||
"description": "A map of environment variables to set in the container.",
|
||||
"mapping": {
|
||||
"loose-key-type": "non-empty-string",
|
||||
"loose-value-type": "string-runner-context"
|
||||
|
||||
@@ -739,7 +739,8 @@ namespace GitHub.Runner.Common.Tests.Listener
|
||||
Assert.True(jobDispatcher.RunOnceJobCompleted.Task.IsCompleted, "JobDispatcher should set task complete token for one time agent.");
|
||||
if (jobDispatcher.RunOnceJobCompleted.Task.IsCompleted)
|
||||
{
|
||||
Assert.True(await jobDispatcher.RunOnceJobCompleted.Task, "JobDispatcher should set task complete token to 'TRUE' for one time agent.");
|
||||
var result = await jobDispatcher.RunOnceJobCompleted.Task;
|
||||
Assert.Equal(TaskResult.Succeeded, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,13 +295,13 @@ namespace GitHub.Runner.Common.Tests.Listener
|
||||
_messageListener.Setup(x => x.DeleteMessageAsync(It.IsAny<TaskAgentMessage>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var runOnceJobCompleted = new TaskCompletionSource<bool>();
|
||||
var runOnceJobCompleted = new TaskCompletionSource<TaskResult>();
|
||||
_jobDispatcher.Setup(x => x.RunOnceJobCompleted)
|
||||
.Returns(runOnceJobCompleted);
|
||||
_jobDispatcher.Setup(x => x.Run(It.IsAny<Pipelines.AgentJobRequestMessage>(), It.IsAny<bool>()))
|
||||
.Callback(() =>
|
||||
{
|
||||
runOnceJobCompleted.TrySetResult(true);
|
||||
runOnceJobCompleted.TrySetResult(TaskResult.Succeeded);
|
||||
});
|
||||
_jobNotification.Setup(x => x.StartClient(It.IsAny<String>()))
|
||||
.Callback(() =>
|
||||
@@ -399,13 +399,13 @@ namespace GitHub.Runner.Common.Tests.Listener
|
||||
_messageListener.Setup(x => x.DeleteMessageAsync(It.IsAny<TaskAgentMessage>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var runOnceJobCompleted = new TaskCompletionSource<bool>();
|
||||
var runOnceJobCompleted = new TaskCompletionSource<TaskResult>();
|
||||
_jobDispatcher.Setup(x => x.RunOnceJobCompleted)
|
||||
.Returns(runOnceJobCompleted);
|
||||
_jobDispatcher.Setup(x => x.Run(It.IsAny<Pipelines.AgentJobRequestMessage>(), It.IsAny<bool>()))
|
||||
.Callback(() =>
|
||||
{
|
||||
runOnceJobCompleted.TrySetResult(true);
|
||||
runOnceJobCompleted.TrySetResult(TaskResult.Succeeded);
|
||||
});
|
||||
_jobNotification.Setup(x => x.StartClient(It.IsAny<String>()))
|
||||
.Callback(() =>
|
||||
@@ -733,8 +733,8 @@ namespace GitHub.Runner.Common.Tests.Listener
|
||||
|
||||
_configStore.Setup(x => x.IsServiceConfigured()).Returns(false);
|
||||
|
||||
var completedTask = new TaskCompletionSource<bool>();
|
||||
completedTask.SetResult(true);
|
||||
var completedTask = new TaskCompletionSource<TaskResult>();
|
||||
completedTask.SetResult(TaskResult.Succeeded);
|
||||
_jobDispatcher.Setup(x => x.RunOnceJobCompleted).Returns(completedTask);
|
||||
|
||||
//Act
|
||||
@@ -834,8 +834,8 @@ namespace GitHub.Runner.Common.Tests.Listener
|
||||
|
||||
_configStore.Setup(x => x.IsServiceConfigured()).Returns(false);
|
||||
|
||||
var completedTask = new TaskCompletionSource<bool>();
|
||||
completedTask.SetResult(true);
|
||||
var completedTask = new TaskCompletionSource<TaskResult>();
|
||||
completedTask.SetResult(TaskResult.Succeeded);
|
||||
_jobDispatcher.Setup(x => x.RunOnceJobCompleted).Returns(completedTask);
|
||||
|
||||
//Act
|
||||
@@ -954,8 +954,8 @@ namespace GitHub.Runner.Common.Tests.Listener
|
||||
|
||||
_configStore.Setup(x => x.IsServiceConfigured()).Returns(false);
|
||||
|
||||
var completedTask = new TaskCompletionSource<bool>();
|
||||
completedTask.SetResult(true);
|
||||
var completedTask = new TaskCompletionSource<TaskResult>();
|
||||
completedTask.SetResult(TaskResult.Succeeded);
|
||||
_jobDispatcher.Setup(x => x.RunOnceJobCompleted).Returns(completedTask);
|
||||
|
||||
//Act
|
||||
|
||||
@@ -228,8 +228,8 @@ namespace GitHub.Runner.Common.Tests.Listener
|
||||
.Returns(Task.FromResult(new TaskAgent()));
|
||||
|
||||
|
||||
var ex = await Assert.ThrowsAsync<TaskCanceledException>(() => updater.SelfUpdate(_refreshMessage, _jobDispatcher.Object, true, hc.RunnerShutdownToken));
|
||||
Assert.Contains($"failed after {Constants.RunnerDownloadRetryMaxAttempts} download attempts", ex.Message);
|
||||
var result = await updater.SelfUpdate(_refreshMessage, _jobDispatcher.Object, true, hc.RunnerShutdownToken);
|
||||
Assert.False(result);
|
||||
}
|
||||
}
|
||||
finally
|
||||
@@ -281,8 +281,8 @@ namespace GitHub.Runner.Common.Tests.Listener
|
||||
.Returns(Task.FromResult(new TaskAgent()));
|
||||
|
||||
|
||||
var ex = await Assert.ThrowsAsync<Exception>(() => updater.SelfUpdate(_refreshMessage, _jobDispatcher.Object, true, hc.RunnerShutdownToken));
|
||||
Assert.Contains("did not match expected Runner Hash", ex.Message);
|
||||
var result = await updater.SelfUpdate(_refreshMessage, _jobDispatcher.Object, true, hc.RunnerShutdownToken);
|
||||
Assert.False(result);
|
||||
}
|
||||
}
|
||||
finally
|
||||
|
||||
@@ -170,8 +170,8 @@ namespace GitHub.Runner.Common.Tests.Listener
|
||||
DownloadUrl = "https://github.com/actions/runner/notexists"
|
||||
};
|
||||
|
||||
var ex = await Assert.ThrowsAsync<TaskCanceledException>(() => updater.SelfUpdate(message, _jobDispatcher.Object, true, hc.RunnerShutdownToken));
|
||||
Assert.Contains($"failed after {Constants.RunnerDownloadRetryMaxAttempts} download attempts", ex.Message);
|
||||
var result = await updater.SelfUpdate(message, _jobDispatcher.Object, true, hc.RunnerShutdownToken);
|
||||
Assert.False(result);
|
||||
}
|
||||
}
|
||||
finally
|
||||
@@ -220,8 +220,8 @@ namespace GitHub.Runner.Common.Tests.Listener
|
||||
SHA256Checksum = "badhash"
|
||||
};
|
||||
|
||||
var ex = await Assert.ThrowsAsync<Exception>(() => updater.SelfUpdate(message, _jobDispatcher.Object, true, hc.RunnerShutdownToken));
|
||||
Assert.Contains("did not match expected Runner Hash", ex.Message);
|
||||
var result = await updater.SelfUpdate(message, _jobDispatcher.Object, true, hc.RunnerShutdownToken);
|
||||
Assert.False(result);
|
||||
}
|
||||
}
|
||||
finally
|
||||
|
||||
76
src/Test/L0/Sdk/RSWebApi/AgentJobRequestMessageL0.cs
Normal file
76
src/Test/L0/Sdk/RSWebApi/AgentJobRequestMessageL0.cs
Normal file
@@ -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('\'', '"');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -457,6 +457,8 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
new SetEnvCommandExtension(),
|
||||
new WarningCommandExtension(),
|
||||
new AddMaskCommandExtension(),
|
||||
new SetOutputCommandExtension(),
|
||||
new SaveStateCommandExtension(),
|
||||
};
|
||||
foreach (var command in commands)
|
||||
{
|
||||
@@ -499,5 +501,53 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void SetOutputCommand_EmitsTelemetryOnce()
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
_ec.Object.Global.JobTelemetry = new List<JobTelemetry>();
|
||||
var reference = string.Empty;
|
||||
_ec.Setup(x => x.SetOutput(It.IsAny<string>(), It.IsAny<string>(), out reference));
|
||||
|
||||
// First set-output should add telemetry
|
||||
Assert.True(_commandManager.TryProcessCommand(_ec.Object, "::set-output name=foo::bar", null));
|
||||
Assert.Single(_ec.Object.Global.JobTelemetry);
|
||||
Assert.Equal(JobTelemetryType.ActionCommand, _ec.Object.Global.JobTelemetry[0].Type);
|
||||
Assert.Equal("DeprecatedCommand: set-output", _ec.Object.Global.JobTelemetry[0].Message);
|
||||
Assert.True(_ec.Object.Global.HasDeprecatedSetOutput);
|
||||
|
||||
// Second set-output should not add another telemetry entry
|
||||
Assert.True(_commandManager.TryProcessCommand(_ec.Object, "::set-output name=foo2::bar2", null));
|
||||
Assert.Single(_ec.Object.Global.JobTelemetry);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void SaveStateCommand_EmitsTelemetryOnce()
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
_ec.Object.Global.JobTelemetry = new List<JobTelemetry>();
|
||||
_ec.Setup(x => x.IsEmbedded).Returns(false);
|
||||
_ec.Setup(x => x.IntraActionState).Returns(new Dictionary<string, string>());
|
||||
|
||||
// First save-state should add telemetry
|
||||
Assert.True(_commandManager.TryProcessCommand(_ec.Object, "::save-state name=foo::bar", null));
|
||||
Assert.Single(_ec.Object.Global.JobTelemetry);
|
||||
Assert.Equal(JobTelemetryType.ActionCommand, _ec.Object.Global.JobTelemetry[0].Type);
|
||||
Assert.Equal("DeprecatedCommand: save-state", _ec.Object.Global.JobTelemetry[0].Message);
|
||||
Assert.True(_ec.Object.Global.HasDeprecatedSaveState);
|
||||
|
||||
// Second save-state should not add another telemetry entry
|
||||
Assert.True(_commandManager.TryProcessCommand(_ec.Object, "::save-state name=foo2::bar2", null));
|
||||
Assert.Single(_ec.Object.Global.JobTelemetry);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -468,6 +468,73 @@ runs:
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async void PrepareActions_SymlinkCacheIsReentrant()
|
||||
{
|
||||
try
|
||||
{
|
||||
//Arrange
|
||||
Environment.SetEnvironmentVariable(Constants.Variables.Agent.SymlinkCachedActions, "true");
|
||||
Setup();
|
||||
var actionId = Guid.NewGuid();
|
||||
var actions = new List<Pipelines.ActionStep>
|
||||
{
|
||||
new Pipelines.ActionStep()
|
||||
{
|
||||
Name = "action",
|
||||
Id = actionId,
|
||||
Reference = new Pipelines.RepositoryPathReference()
|
||||
{
|
||||
Name = "actions/checkout",
|
||||
Ref = "master",
|
||||
RepositoryType = "GitHub"
|
||||
}
|
||||
},
|
||||
new Pipelines.ActionStep()
|
||||
{
|
||||
Name = "action",
|
||||
Id = actionId,
|
||||
Reference = new Pipelines.RepositoryPathReference()
|
||||
{
|
||||
Name = "actions/checkout",
|
||||
Ref = "master",
|
||||
RepositoryType = "GitHub"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const string Content = @"
|
||||
name: 'Test'
|
||||
runs:
|
||||
using: 'node20'
|
||||
main: 'dist/index.js'
|
||||
";
|
||||
|
||||
string actionsArchive = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Temp), "actions_archive", "action_checkout");
|
||||
Directory.CreateDirectory(actionsArchive);
|
||||
Directory.CreateDirectory(Path.Combine(actionsArchive, "actions_checkout", "master-sha"));
|
||||
Directory.CreateDirectory(Path.Combine(actionsArchive, "actions_checkout", "master-sha", "content"));
|
||||
await File.WriteAllTextAsync(Path.Combine(actionsArchive, "actions_checkout", "master-sha", "content", "action.yml"), Content);
|
||||
Environment.SetEnvironmentVariable(Constants.Variables.Agent.ActionArchiveCacheDirectory, actionsArchive);
|
||||
|
||||
//Act
|
||||
await _actionManager.PrepareActionsAsync(_ec.Object, actions);
|
||||
|
||||
//Assert
|
||||
string destDirectory = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), "actions", "checkout", "master");
|
||||
Assert.True(Directory.Exists(destDirectory), "Destination directory does not exist");
|
||||
var di = new DirectoryInfo(destDirectory);
|
||||
Assert.NotNull(di.LinkTarget);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable(Constants.Variables.Agent.SymlinkCachedActions, null);
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
#if OS_LINUX
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
|
||||
424
src/Test/L0/Worker/ActionManifestParserComparisonL0.cs
Normal file
424
src/Test/L0/Worker/ActionManifestParserComparisonL0.cs
Normal file
@@ -0,0 +1,424 @@
|
||||
using GitHub.Actions.WorkflowParser;
|
||||
using GitHub.DistributedTask.ObjectTemplating.Tokens;
|
||||
using GitHub.DistributedTask.Pipelines.ContextData;
|
||||
using GitHub.DistributedTask.Pipelines.ObjectTemplating;
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Sdk;
|
||||
using GitHub.Runner.Worker;
|
||||
using LegacyContextData = GitHub.DistributedTask.Pipelines.ContextData;
|
||||
using LegacyExpressions = GitHub.DistributedTask.Expressions2;
|
||||
using Moq;
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using Xunit;
|
||||
|
||||
namespace GitHub.Runner.Common.Tests.Worker
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for parser comparison wrapper classes.
|
||||
/// </summary>
|
||||
public sealed class ActionManifestParserComparisonL0
|
||||
{
|
||||
private CancellationTokenSource _ecTokenSource;
|
||||
private Mock<IExecutionContext> _ec;
|
||||
private TestHostContext _hc;
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ConvertToLegacySteps_ProducesCorrectSteps_WithExplicitPropertyMapping()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Arrange - Test that ActionManifestManagerWrapper properly converts new steps to legacy format
|
||||
Setup();
|
||||
|
||||
// Enable comparison feature
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
// Register required services
|
||||
var legacyManager = new ActionManifestManagerLegacy();
|
||||
legacyManager.Initialize(_hc);
|
||||
_hc.SetSingleton<IActionManifestManagerLegacy>(legacyManager);
|
||||
|
||||
var newManager = new ActionManifestManager();
|
||||
newManager.Initialize(_hc);
|
||||
_hc.SetSingleton<IActionManifestManager>(newManager);
|
||||
|
||||
var wrapper = new ActionManifestManagerWrapper();
|
||||
wrapper.Initialize(_hc);
|
||||
|
||||
var manifestPath = Path.Combine(TestUtil.GetTestDataPath(), "conditional_composite_action.yml");
|
||||
|
||||
// Act - Load through the wrapper (which internally converts)
|
||||
var result = wrapper.Load(_ec.Object, manifestPath);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(ActionExecutionType.Composite, result.Execution.ExecutionType);
|
||||
|
||||
var compositeExecution = result.Execution as CompositeActionExecutionData;
|
||||
Assert.NotNull(compositeExecution);
|
||||
Assert.NotNull(compositeExecution.Steps);
|
||||
Assert.Equal(6, compositeExecution.Steps.Count);
|
||||
|
||||
// Verify steps are NOT null (this was the bug - JSON round-trip produced nulls)
|
||||
foreach (var step in compositeExecution.Steps)
|
||||
{
|
||||
Assert.NotNull(step);
|
||||
Assert.NotNull(step.Reference);
|
||||
Assert.IsType<GitHub.DistributedTask.Pipelines.ScriptReference>(step.Reference);
|
||||
}
|
||||
|
||||
// Verify step with condition
|
||||
var successStep = compositeExecution.Steps[2];
|
||||
Assert.Equal("success-conditional", successStep.ContextName);
|
||||
Assert.Equal("success()", successStep.Condition);
|
||||
|
||||
// Verify step with complex condition
|
||||
var lastStep = compositeExecution.Steps[5];
|
||||
Assert.Contains("inputs.exit-code == 1", lastStep.Condition);
|
||||
Assert.Contains("failure()", lastStep.Condition);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateJobContainer_EmptyImage_BothParsersReturnNull()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Arrange - Test that both parsers return null for empty container image at runtime
|
||||
Setup();
|
||||
|
||||
var fileTable = new List<string>();
|
||||
|
||||
// Create legacy evaluator
|
||||
var legacyTraceWriter = new GitHub.DistributedTask.ObjectTemplating.EmptyTraceWriter();
|
||||
var schema = PipelineTemplateSchemaFactory.GetSchema();
|
||||
var legacyEvaluator = new PipelineTemplateEvaluator(legacyTraceWriter, schema, fileTable);
|
||||
|
||||
// Create new evaluator
|
||||
var newTraceWriter = new GitHub.Actions.WorkflowParser.ObjectTemplating.EmptyTraceWriter();
|
||||
var newEvaluator = new WorkflowTemplateEvaluator(newTraceWriter, fileTable, features: null);
|
||||
|
||||
// Create a token representing an empty container image (simulates expression evaluated to empty string)
|
||||
var emptyImageToken = new StringToken(null, null, null, "");
|
||||
|
||||
var contextData = new DictionaryContextData();
|
||||
var expressionFunctions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
|
||||
// Act - Call both evaluators
|
||||
var legacyResult = legacyEvaluator.EvaluateJobContainer(emptyImageToken, contextData, expressionFunctions);
|
||||
|
||||
// Convert token for new evaluator
|
||||
var newToken = new GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens.StringToken(null, null, null, "");
|
||||
var newContextData = new GitHub.Actions.Expressions.Data.DictionaryExpressionData();
|
||||
var newExpressionFunctions = new List<GitHub.Actions.Expressions.IFunctionInfo>();
|
||||
|
||||
var newResult = newEvaluator.EvaluateJobContainer(newToken, newContextData, newExpressionFunctions);
|
||||
|
||||
// Assert - Both should return null for empty image (no container)
|
||||
Assert.Null(legacyResult);
|
||||
Assert.Null(newResult);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void FromJsonEmptyString_BothParsersFail_WithDifferentMessages()
|
||||
{
|
||||
// This test verifies that both parsers fail with different error messages when parsing fromJSON('')
|
||||
// The comparison layer should treat these as semantically equivalent (both are JSON parse errors)
|
||||
try
|
||||
{
|
||||
Setup();
|
||||
|
||||
var fileTable = new List<string>();
|
||||
|
||||
// Create legacy evaluator
|
||||
var legacyTraceWriter = new GitHub.DistributedTask.ObjectTemplating.EmptyTraceWriter();
|
||||
var schema = PipelineTemplateSchemaFactory.GetSchema();
|
||||
var legacyEvaluator = new PipelineTemplateEvaluator(legacyTraceWriter, schema, fileTable);
|
||||
|
||||
// Create new evaluator
|
||||
var newTraceWriter = new GitHub.Actions.WorkflowParser.ObjectTemplating.EmptyTraceWriter();
|
||||
var newEvaluator = new WorkflowTemplateEvaluator(newTraceWriter, fileTable, features: null);
|
||||
|
||||
// Create expression token for fromJSON('')
|
||||
var legacyToken = new BasicExpressionToken(null, null, null, "fromJson('')");
|
||||
var newToken = new GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens.BasicExpressionToken(null, null, null, "fromJson('')");
|
||||
|
||||
var contextData = new DictionaryContextData();
|
||||
var newContextData = new GitHub.Actions.Expressions.Data.DictionaryExpressionData();
|
||||
var expressionFunctions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
var newExpressionFunctions = new List<GitHub.Actions.Expressions.IFunctionInfo>();
|
||||
|
||||
// Act - Both should throw
|
||||
Exception legacyException = null;
|
||||
Exception newException = null;
|
||||
|
||||
try
|
||||
{
|
||||
legacyEvaluator.EvaluateStepDisplayName(legacyToken, contextData, expressionFunctions);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
legacyException = ex;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
newEvaluator.EvaluateStepName(newToken, newContextData, newExpressionFunctions);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
newException = ex;
|
||||
}
|
||||
|
||||
// Assert - Both threw exceptions
|
||||
Assert.NotNull(legacyException);
|
||||
Assert.NotNull(newException);
|
||||
|
||||
// Verify the error messages are different (which is why we need semantic comparison)
|
||||
Assert.NotEqual(legacyException.Message, newException.Message);
|
||||
|
||||
// Verify both are JSON parse errors (contain JSON-related error indicators)
|
||||
var legacyFullMsg = GetFullExceptionMessage(legacyException);
|
||||
var newFullMsg = GetFullExceptionMessage(newException);
|
||||
|
||||
// At least one should contain indicators of JSON parsing failure
|
||||
var legacyIsJsonError = legacyFullMsg.Contains("JToken") ||
|
||||
legacyFullMsg.Contains("JsonReader") ||
|
||||
legacyFullMsg.Contains("fromJson");
|
||||
var newIsJsonError = newFullMsg.Contains("JToken") ||
|
||||
newFullMsg.Contains("JsonReader") ||
|
||||
newFullMsg.Contains("fromJson");
|
||||
|
||||
Assert.True(legacyIsJsonError, $"Legacy exception should be JSON error: {legacyFullMsg}");
|
||||
Assert.True(newIsJsonError, $"New exception should be JSON error: {newFullMsg}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateDefaultInput_BothParsersAgree()
|
||||
{
|
||||
try
|
||||
{
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var legacyManager = new ActionManifestManagerLegacy();
|
||||
legacyManager.Initialize(_hc);
|
||||
_hc.SetSingleton<IActionManifestManagerLegacy>(legacyManager);
|
||||
|
||||
var newManager = new ActionManifestManager();
|
||||
newManager.Initialize(_hc);
|
||||
_hc.SetSingleton<IActionManifestManager>(newManager);
|
||||
|
||||
var wrapper = new ActionManifestManagerWrapper();
|
||||
wrapper.Initialize(_hc);
|
||||
|
||||
_ec.Object.ExpressionValues["github"] = new LegacyContextData.DictionaryContextData();
|
||||
_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<GitHub.Runner.Worker.Expressions.HashFilesFunction>("hashFiles", 1, 255));
|
||||
|
||||
var result = wrapper.EvaluateDefaultInput(_ec.Object, "testInput", new StringToken(null, null, null, "defaultValue"));
|
||||
|
||||
Assert.Equal("defaultValue", result);
|
||||
Assert.False(_ec.Object.Global.HasActionManifestMismatch);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateContainerArguments_BothParsersAgree()
|
||||
{
|
||||
try
|
||||
{
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var legacyManager = new ActionManifestManagerLegacy();
|
||||
legacyManager.Initialize(_hc);
|
||||
_hc.SetSingleton<IActionManifestManagerLegacy>(legacyManager);
|
||||
|
||||
var newManager = new ActionManifestManager();
|
||||
newManager.Initialize(_hc);
|
||||
_hc.SetSingleton<IActionManifestManager>(newManager);
|
||||
|
||||
var wrapper = new ActionManifestManagerWrapper();
|
||||
wrapper.Initialize(_hc);
|
||||
|
||||
var arguments = new SequenceToken(null, null, null);
|
||||
arguments.Add(new StringToken(null, null, null, "arg1"));
|
||||
arguments.Add(new StringToken(null, null, null, "arg2"));
|
||||
|
||||
var evaluateContext = new Dictionary<string, LegacyContextData.PipelineContextData>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var result = wrapper.EvaluateContainerArguments(_ec.Object, arguments, evaluateContext);
|
||||
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Equal("arg1", result[0]);
|
||||
Assert.Equal("arg2", result[1]);
|
||||
Assert.False(_ec.Object.Global.HasActionManifestMismatch);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateContainerEnvironment_BothParsersAgree()
|
||||
{
|
||||
try
|
||||
{
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var legacyManager = new ActionManifestManagerLegacy();
|
||||
legacyManager.Initialize(_hc);
|
||||
_hc.SetSingleton<IActionManifestManagerLegacy>(legacyManager);
|
||||
|
||||
var newManager = new ActionManifestManager();
|
||||
newManager.Initialize(_hc);
|
||||
_hc.SetSingleton<IActionManifestManager>(newManager);
|
||||
|
||||
var wrapper = new ActionManifestManagerWrapper();
|
||||
wrapper.Initialize(_hc);
|
||||
|
||||
var environment = new MappingToken(null, null, null);
|
||||
environment.Add(new StringToken(null, null, null, "hello"), new StringToken(null, null, null, "world"));
|
||||
|
||||
var evaluateContext = new Dictionary<string, LegacyContextData.PipelineContextData>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var result = wrapper.EvaluateContainerEnvironment(_ec.Object, environment, evaluateContext);
|
||||
|
||||
Assert.Equal(1, result.Count);
|
||||
Assert.Equal("world", result["hello"]);
|
||||
Assert.False(_ec.Object.Global.HasActionManifestMismatch);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateCompositeOutputs_BothParsersAgree()
|
||||
{
|
||||
try
|
||||
{
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var legacyManager = new ActionManifestManagerLegacy();
|
||||
legacyManager.Initialize(_hc);
|
||||
_hc.SetSingleton<IActionManifestManagerLegacy>(legacyManager);
|
||||
|
||||
var newManager = new ActionManifestManager();
|
||||
newManager.Initialize(_hc);
|
||||
_hc.SetSingleton<IActionManifestManager>(newManager);
|
||||
|
||||
var wrapper = new ActionManifestManagerWrapper();
|
||||
wrapper.Initialize(_hc);
|
||||
|
||||
var outputDef = new MappingToken(null, null, null);
|
||||
outputDef.Add(new StringToken(null, null, null, "description"), new StringToken(null, null, null, "test output"));
|
||||
outputDef.Add(new StringToken(null, null, null, "value"), new StringToken(null, null, null, "value1"));
|
||||
|
||||
var token = new MappingToken(null, null, null);
|
||||
token.Add(new StringToken(null, null, null, "output1"), outputDef);
|
||||
|
||||
var evaluateContext = new Dictionary<string, LegacyContextData.PipelineContextData>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var result = wrapper.EvaluateCompositeOutputs(_ec.Object, token, evaluateContext);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.False(_ec.Object.Global.HasActionManifestMismatch);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
private string GetFullExceptionMessage(Exception ex)
|
||||
{
|
||||
var messages = new List<string>();
|
||||
var current = ex;
|
||||
while (current != null)
|
||||
{
|
||||
messages.Add(current.Message);
|
||||
current = current.InnerException;
|
||||
}
|
||||
return string.Join(" -> ", messages);
|
||||
}
|
||||
|
||||
private void Setup([CallerMemberName] string name = "")
|
||||
{
|
||||
_ecTokenSource?.Dispose();
|
||||
_ecTokenSource = new CancellationTokenSource();
|
||||
|
||||
_hc = new TestHostContext(this, name);
|
||||
|
||||
var expressionValues = new LegacyContextData.DictionaryContextData();
|
||||
var expressionFunctions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
|
||||
_ec = new Mock<IExecutionContext>();
|
||||
_ec.Setup(x => x.Global)
|
||||
.Returns(new GlobalContext
|
||||
{
|
||||
FileTable = new List<String>(),
|
||||
Variables = new Variables(_hc, new Dictionary<string, VariableValue>()),
|
||||
WriteDebug = true,
|
||||
});
|
||||
_ec.Setup(x => x.CancellationToken).Returns(_ecTokenSource.Token);
|
||||
_ec.Setup(x => x.ExpressionValues).Returns(expressionValues);
|
||||
_ec.Setup(x => x.ExpressionFunctions).Returns(expressionFunctions);
|
||||
_ec.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>())).Callback((string tag, string message) => { _hc.GetTrace().Info($"{tag}{message}"); });
|
||||
_ec.Setup(x => x.AddIssue(It.IsAny<Issue>(), It.IsAny<ExecutionContextLogOptions>())).Callback((Issue issue, ExecutionContextLogOptions logOptions) => { _hc.GetTrace().Info($"[{issue.Type}]{logOptions.LogMessageOverride ?? issue.Message}"); });
|
||||
}
|
||||
|
||||
private void Teardown()
|
||||
{
|
||||
_hc?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
452
src/Test/L0/Worker/DapDebuggerL0.cs
Normal file
452
src/Test/L0/Worker/DapDebuggerL0.cs
Normal file
@@ -0,0 +1,452 @@
|
||||
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 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<Task> 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<TcpClient> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static async Task<string> 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);
|
||||
}
|
||||
|
||||
[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));
|
||||
await _debugger.StartAsync(cts.Token);
|
||||
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));
|
||||
await _debugger.StartAsync(cts.Token);
|
||||
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));
|
||||
await _debugger.StartAsync(cts.Token);
|
||||
|
||||
var waitTask = _debugger.WaitUntilReadyAsync(cts.Token);
|
||||
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 WaitUntilReadyRegistersCancellation()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var port = GetFreePort();
|
||||
await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () =>
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
await _debugger.StartAsync(cts.Token);
|
||||
|
||||
var waitTask = _debugger.WaitUntilReadyAsync(cts.Token);
|
||||
using var client = await ConnectClientAsync(port);
|
||||
await SendRequestAsync(client.GetStream(), new Request
|
||||
{
|
||||
Seq = 1,
|
||||
Type = "request",
|
||||
Command = "configurationDone"
|
||||
});
|
||||
|
||||
await waitTask;
|
||||
cts.Cancel();
|
||||
|
||||
Assert.True(SpinWait.SpinUntil(() => _debugger.State == DapSessionState.Terminated, TimeSpan.FromSeconds(5)));
|
||||
await _debugger.StopAsync();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
[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));
|
||||
await _debugger.StartAsync(cts.Token);
|
||||
|
||||
var waitTask = _debugger.WaitUntilReadyAsync(cts.Token);
|
||||
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(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
[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));
|
||||
await _debugger.StartAsync(cts.Token);
|
||||
|
||||
var waitTask = _debugger.WaitUntilReadyAsync(cts.Token);
|
||||
await Task.Delay(50);
|
||||
cts.Cancel();
|
||||
|
||||
var ex = await Assert.ThrowsAnyAsync<OperationCanceledException>(() => waitTask);
|
||||
Assert.IsNotType<TimeoutException>(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));
|
||||
await _debugger.StartAsync(cts.Token);
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
233
src/Test/L0/Worker/DapMessagesL0.cs
Normal file
233
src/Test/L0/Worker/DapMessagesL0.cs
Normal file
@@ -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<Request>(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<Response>(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<Capabilities>(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<ContinueResponseBody>(json);
|
||||
|
||||
Assert.True(deserialized.AllThreadsContinued);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ThreadsResponseBodySerialization()
|
||||
{
|
||||
var body = new ThreadsResponseBody
|
||||
{
|
||||
Threads = new List<Thread>
|
||||
{
|
||||
new Thread { Id = 1, Name = "Job Thread" }
|
||||
}
|
||||
};
|
||||
|
||||
var json = JsonConvert.SerializeObject(body);
|
||||
var deserialized = JsonConvert.DeserializeObject<ThreadsResponseBody>(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<StackFrame>(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<ExitedEventBody>(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<Request>(json);
|
||||
|
||||
Assert.Equal(5, request.Seq);
|
||||
Assert.Equal("request", request.Type);
|
||||
Assert.Equal("continue", request.Command);
|
||||
Assert.Equal(1, request.Arguments["threadId"].Value<int>());
|
||||
}
|
||||
|
||||
[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<ErrorResponseBody>(json);
|
||||
|
||||
Assert.Equal(1, deserialized.Error.Id);
|
||||
Assert.Equal("Something went wrong", deserialized.Error.Format);
|
||||
Assert.True(deserialized.Error.ShowUser);
|
||||
}
|
||||
}
|
||||
}
|
||||
237
src/Test/L0/Worker/DapReplExecutorL0.cs
Normal file
237
src/Test/L0/Worker/DapReplExecutorL0.cs
Normal file
@@ -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<Event> _sentEvents;
|
||||
|
||||
private TestHostContext CreateTestContext([CallerMemberName] string testName = "")
|
||||
{
|
||||
_hc = new TestHostContext(this, testName);
|
||||
_sentEvents = new List<Event>();
|
||||
_executor = new DapReplExecutor(_hc, (category, text) =>
|
||||
{
|
||||
_sentEvents.Add(new Event
|
||||
{
|
||||
EventType = "output",
|
||||
Body = new OutputEventBody
|
||||
{
|
||||
Category = category,
|
||||
Output = text
|
||||
}
|
||||
});
|
||||
});
|
||||
return _hc;
|
||||
}
|
||||
|
||||
private Mock<IExecutionContext> CreateMockContext(
|
||||
DictionaryContextData exprValues = null,
|
||||
IDictionary<string, IDictionary<string, string>> jobDefaults = null)
|
||||
{
|
||||
var mock = new Mock<IExecutionContext>();
|
||||
mock.Setup(x => x.ExpressionValues).Returns(exprValues ?? new DictionaryContextData());
|
||||
mock.Setup(x => x.ExpressionFunctions).Returns(new List<IFunctionInfo>());
|
||||
|
||||
var global = new GlobalContext
|
||||
{
|
||||
PrependPath = new List<string>(),
|
||||
JobDefaults = jobDefaults
|
||||
?? new Dictionary<string, IDictionary<string, string>>(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<string, IDictionary<string, string>>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["run"] = new Dictionary<string, string>(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<string, string> { { "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<string, string> { { "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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
314
src/Test/L0/Worker/DapReplParserL0.cs
Normal file
314
src/Test/L0/Worker/DapReplParserL0.cs
Normal file
@@ -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<HelpCommand>(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<HelpCommand>(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<HelpCommand>(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<RunCommand>(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<RunCommand>(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<RunCommand>(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<RunCommand>(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<RunCommand>(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<RunCommand>(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<RunCommand>(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<RunCommand>(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
|
||||
}
|
||||
}
|
||||
772
src/Test/L0/Worker/DapVariableProviderL0.cs
Normal file
772
src/Test/L0/Worker/DapVariableProviderL0.cs
Normal file
@@ -0,0 +1,772 @@
|
||||
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);
|
||||
return _hc;
|
||||
}
|
||||
|
||||
private Moq.Mock<GitHub.Runner.Worker.IExecutionContext> CreateMockContext(DictionaryContextData expressionValues)
|
||||
{
|
||||
var mock = new Moq.Mock<GitHub.Runner.Worker.IExecutionContext>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetScopes_StableVariablesReferenceIds()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
// Populate all 10 scopes and verify their reference IDs
|
||||
// are stable and based on array position
|
||||
var exprValues = new DictionaryContextData();
|
||||
foreach (var name in DapVariableProvider.ScopeNames)
|
||||
{
|
||||
exprValues[name] = new DictionaryContextData();
|
||||
}
|
||||
|
||||
var ctx = CreateMockContext(exprValues);
|
||||
var scopes = _provider.GetScopes(ctx.Object);
|
||||
|
||||
Assert.Equal(DapVariableProvider.ScopeNames.Length, scopes.Count);
|
||||
for (int i = 0; i < scopes.Count; i++)
|
||||
{
|
||||
Assert.Equal(DapVariableProvider.ScopeNames[i], scopes[i].Name);
|
||||
// Reference IDs are 1-based: index 0 -> ref 1, index 1 -> ref 2, etc.
|
||||
Assert.Equal(i + 1, scopes[i].VariablesReference);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#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(DapVariableProvider.RedactedValue, 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);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void MaskSecrets_DelegatesToHostContextSecretMasker()
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
hc.SecretMasker.AddValue("my-secret");
|
||||
|
||||
Assert.Equal("before-***-after", _provider.MaskSecrets("before-my-secret-after"));
|
||||
Assert.Equal("no secrets here", _provider.MaskSecrets("no secrets here"));
|
||||
Assert.Equal(string.Empty, _provider.MaskSecrets(null));
|
||||
Assert.Equal(string.Empty, _provider.MaskSecrets(string.Empty));
|
||||
}
|
||||
}
|
||||
|
||||
#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
|
||||
|
||||
/// <summary>
|
||||
/// Creates a mock execution context with Global set up so that
|
||||
/// ToPipelineTemplateEvaluator() works for real expression evaluation.
|
||||
/// </summary>
|
||||
private Moq.Mock<IExecutionContext> CreateEvaluatableContext(
|
||||
TestHostContext hc,
|
||||
DictionaryContextData expressionValues)
|
||||
{
|
||||
var mock = new Moq.Mock<IExecutionContext>();
|
||||
mock.Setup(x => x.ExpressionValues).Returns(expressionValues);
|
||||
mock.Setup(x => x.ExpressionFunctions)
|
||||
.Returns(new List<GitHub.DistributedTask.Expressions2.IFunctionInfo>());
|
||||
mock.Setup(x => x.Global).Returns(new GlobalContext
|
||||
{
|
||||
FileTable = new List<string>(),
|
||||
Variables = new Variables(hc, new Dictionary<string, VariableValue>()),
|
||||
});
|
||||
// ToPipelineTemplateEvaluator uses ToTemplateTraceWriter which calls
|
||||
// context.Write — provide a no-op so it doesn't NRE.
|
||||
mock.Setup(x => x.Write(Moq.It.IsAny<string>(), Moq.It.IsAny<string>()));
|
||||
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(DapVariableProvider.RedactedValue, 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(DapVariableProvider.RedactedValue, 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(DapVariableProvider.RedactedValue, 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(DapVariableProvider.RedactedValue, variables[0].Value);
|
||||
Assert.Equal(0, variables[0].VariablesReference);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -74,7 +74,7 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
@@ -116,5 +116,259 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
Assert.Equal("node24", handler.Data.NodeVersion);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Node20Action_TrackedWhenWarnFlagEnabled()
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
// Arrange.
|
||||
var hf = new HandlerFactory();
|
||||
hf.Initialize(hc);
|
||||
|
||||
var variables = new Dictionary<string, VariableValue>
|
||||
{
|
||||
{ Constants.Runner.NodeMigration.WarnOnNode20Flag, new VariableValue("true") }
|
||||
};
|
||||
Variables serverVariables = new(hc, variables);
|
||||
var deprecatedActions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
_ec.Setup(x => x.Global).Returns(new GlobalContext()
|
||||
{
|
||||
Variables = serverVariables,
|
||||
EnvironmentVariables = new Dictionary<string, string>(),
|
||||
DeprecatedNode20Actions = deprecatedActions
|
||||
});
|
||||
|
||||
var actionRef = new RepositoryPathReference
|
||||
{
|
||||
Name = "actions/checkout",
|
||||
Ref = "v4"
|
||||
};
|
||||
|
||||
// Act.
|
||||
var data = new NodeJSActionExecutionData();
|
||||
data.NodeVersion = "node20";
|
||||
hf.Create(
|
||||
_ec.Object,
|
||||
actionRef,
|
||||
new Mock<IStepHost>().Object,
|
||||
data,
|
||||
new Dictionary<string, string>(),
|
||||
new Dictionary<string, string>(),
|
||||
new Variables(hc, new Dictionary<string, VariableValue>()),
|
||||
"",
|
||||
new List<JobExtensionRunner>()
|
||||
);
|
||||
|
||||
// Assert.
|
||||
Assert.Contains("actions/checkout@v4", deprecatedActions);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Node20Action_NotTrackedWhenWarnFlagDisabled()
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
// Arrange.
|
||||
var hf = new HandlerFactory();
|
||||
hf.Initialize(hc);
|
||||
|
||||
var variables = new Dictionary<string, VariableValue>();
|
||||
Variables serverVariables = new(hc, variables);
|
||||
var deprecatedActions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
_ec.Setup(x => x.Global).Returns(new GlobalContext()
|
||||
{
|
||||
Variables = serverVariables,
|
||||
EnvironmentVariables = new Dictionary<string, string>(),
|
||||
DeprecatedNode20Actions = deprecatedActions
|
||||
});
|
||||
|
||||
var actionRef = new RepositoryPathReference
|
||||
{
|
||||
Name = "actions/checkout",
|
||||
Ref = "v4"
|
||||
};
|
||||
|
||||
// Act.
|
||||
var data = new NodeJSActionExecutionData();
|
||||
data.NodeVersion = "node20";
|
||||
hf.Create(
|
||||
_ec.Object,
|
||||
actionRef,
|
||||
new Mock<IStepHost>().Object,
|
||||
data,
|
||||
new Dictionary<string, string>(),
|
||||
new Dictionary<string, string>(),
|
||||
new Variables(hc, new Dictionary<string, VariableValue>()),
|
||||
"",
|
||||
new List<JobExtensionRunner>()
|
||||
);
|
||||
|
||||
// Assert - should not track when flag is disabled
|
||||
Assert.Empty(deprecatedActions);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Node24Action_NotTrackedEvenWhenWarnFlagEnabled()
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
// Arrange.
|
||||
var hf = new HandlerFactory();
|
||||
hf.Initialize(hc);
|
||||
|
||||
var variables = new Dictionary<string, VariableValue>
|
||||
{
|
||||
{ Constants.Runner.NodeMigration.WarnOnNode20Flag, new VariableValue("true") }
|
||||
};
|
||||
Variables serverVariables = new(hc, variables);
|
||||
var deprecatedActions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
_ec.Setup(x => x.Global).Returns(new GlobalContext()
|
||||
{
|
||||
Variables = serverVariables,
|
||||
EnvironmentVariables = new Dictionary<string, string>(),
|
||||
DeprecatedNode20Actions = deprecatedActions
|
||||
});
|
||||
|
||||
var actionRef = new RepositoryPathReference
|
||||
{
|
||||
Name = "actions/checkout",
|
||||
Ref = "v5"
|
||||
};
|
||||
|
||||
// Act.
|
||||
var data = new NodeJSActionExecutionData();
|
||||
data.NodeVersion = "node24";
|
||||
hf.Create(
|
||||
_ec.Object,
|
||||
actionRef,
|
||||
new Mock<IStepHost>().Object,
|
||||
data,
|
||||
new Dictionary<string, string>(),
|
||||
new Dictionary<string, string>(),
|
||||
new Variables(hc, new Dictionary<string, VariableValue>()),
|
||||
"",
|
||||
new List<JobExtensionRunner>()
|
||||
);
|
||||
|
||||
// Assert - node24 actions should not be tracked
|
||||
Assert.Empty(deprecatedActions);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Node12Action_TrackedAsDeprecatedWhenWarnFlagEnabled()
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
// Arrange.
|
||||
var hf = new HandlerFactory();
|
||||
hf.Initialize(hc);
|
||||
|
||||
var variables = new Dictionary<string, VariableValue>
|
||||
{
|
||||
{ Constants.Runner.NodeMigration.WarnOnNode20Flag, new VariableValue("true") }
|
||||
};
|
||||
Variables serverVariables = new(hc, variables);
|
||||
var deprecatedActions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
_ec.Setup(x => x.Global).Returns(new GlobalContext()
|
||||
{
|
||||
Variables = serverVariables,
|
||||
EnvironmentVariables = new Dictionary<string, string>(),
|
||||
DeprecatedNode20Actions = deprecatedActions
|
||||
});
|
||||
|
||||
var actionRef = new RepositoryPathReference
|
||||
{
|
||||
Name = "some-org/old-action",
|
||||
Ref = "v1"
|
||||
};
|
||||
|
||||
// Act - node12 gets migrated to node20, then should be tracked
|
||||
var data = new NodeJSActionExecutionData();
|
||||
data.NodeVersion = "node12";
|
||||
hf.Create(
|
||||
_ec.Object,
|
||||
actionRef,
|
||||
new Mock<IStepHost>().Object,
|
||||
data,
|
||||
new Dictionary<string, string>(),
|
||||
new Dictionary<string, string>(),
|
||||
new Variables(hc, new Dictionary<string, VariableValue>()),
|
||||
"",
|
||||
new List<JobExtensionRunner>()
|
||||
);
|
||||
|
||||
// Assert - node12 gets migrated to node20 and should be tracked
|
||||
Assert.Contains("some-org/old-action@v1", deprecatedActions);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void LocalNode20Action_TrackedWhenWarnFlagEnabled()
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
// Arrange.
|
||||
var hf = new HandlerFactory();
|
||||
hf.Initialize(hc);
|
||||
|
||||
var variables = new Dictionary<string, VariableValue>
|
||||
{
|
||||
{ Constants.Runner.NodeMigration.WarnOnNode20Flag, new VariableValue("true") }
|
||||
};
|
||||
Variables serverVariables = new(hc, variables);
|
||||
var deprecatedActions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
_ec.Setup(x => x.Global).Returns(new GlobalContext()
|
||||
{
|
||||
Variables = serverVariables,
|
||||
EnvironmentVariables = new Dictionary<string, string>(),
|
||||
DeprecatedNode20Actions = deprecatedActions
|
||||
});
|
||||
|
||||
// Local action: Name is empty, Path is the local path
|
||||
var actionRef = new RepositoryPathReference
|
||||
{
|
||||
Name = "",
|
||||
Path = "./.github/actions/my-action",
|
||||
RepositoryType = "self"
|
||||
};
|
||||
|
||||
// Act.
|
||||
var data = new NodeJSActionExecutionData();
|
||||
data.NodeVersion = "node20";
|
||||
hf.Create(
|
||||
_ec.Object,
|
||||
actionRef,
|
||||
new Mock<IStepHost>().Object,
|
||||
data,
|
||||
new Dictionary<string, string>(),
|
||||
new Dictionary<string, string>(),
|
||||
new Variables(hc, new Dictionary<string, VariableValue>()),
|
||||
"",
|
||||
new List<JobExtensionRunner>()
|
||||
);
|
||||
|
||||
// Assert - local action should be tracked with its path
|
||||
Assert.Contains("./.github/actions/my-action", deprecatedActions);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
335
src/Test/L0/Worker/Handlers/CompositeActionHandlerL0.cs
Normal file
335
src/Test/L0/Worker/Handlers/CompositeActionHandlerL0.cs
Normal file
@@ -0,0 +1,335 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.CompilerServices;
|
||||
using GitHub.DistributedTask.Pipelines;
|
||||
using GitHub.DistributedTask.Pipelines.ContextData;
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using GitHub.Runner.Sdk;
|
||||
using GitHub.Runner.Worker;
|
||||
using GitHub.Runner.Worker.Handlers;
|
||||
using Moq;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
using DTWebApi = GitHub.DistributedTask.WebApi;
|
||||
using Pipelines = GitHub.DistributedTask.Pipelines;
|
||||
|
||||
namespace GitHub.Runner.Common.Tests.Worker.Handlers
|
||||
{
|
||||
public sealed class CompositeActionHandlerL0
|
||||
{
|
||||
// Test EscapeProperty helper logic via reflection or by testing the markers output
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EscapeProperty_EscapesSpecialCharacters()
|
||||
{
|
||||
// Test the escaping logic that would be applied
|
||||
var input = "value;with%special\r\n]chars";
|
||||
var escaped = EscapeProperty(input);
|
||||
Assert.Equal("value%3Bwith%25special%0D%0A%5Dchars", escaped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EscapeProperty_HandlesNullAndEmpty()
|
||||
{
|
||||
Assert.Null(EscapeProperty(null));
|
||||
Assert.Equal("", EscapeProperty(""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void SanitizeDisplayName_TruncatesLongNames()
|
||||
{
|
||||
var longName = new string('a', 1500);
|
||||
var sanitized = SanitizeDisplayName(longName);
|
||||
Assert.Equal(CompositeActionHandler.MaxDisplayNameLength, sanitized.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void SanitizeDisplayName_TakesFirstLineOnly()
|
||||
{
|
||||
var multiline = "First line\nSecond line\nThird line";
|
||||
var sanitized = SanitizeDisplayName(multiline);
|
||||
Assert.Equal("First line", sanitized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void SanitizeDisplayName_TrimsLeadingWhitespace()
|
||||
{
|
||||
var withLeading = " \n \t Actual name\nSecond line";
|
||||
var sanitized = SanitizeDisplayName(withLeading);
|
||||
Assert.Equal("Actual name", sanitized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void SanitizeDisplayName_HandlesCarriageReturn()
|
||||
{
|
||||
var withCR = "First line\r\nSecond line";
|
||||
var sanitized = SanitizeDisplayName(withCR);
|
||||
Assert.Equal("First line", sanitized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void SanitizeDisplayName_HandlesNullAndEmpty()
|
||||
{
|
||||
Assert.Null(SanitizeDisplayName(null));
|
||||
Assert.Equal("", SanitizeDisplayName(""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EmitMarkers_DisplayNameEscaping()
|
||||
{
|
||||
// Verify that special characters in display names get escaped properly
|
||||
var displayName = "Step with semicolons; and more; here";
|
||||
var escaped = EscapeProperty(SanitizeDisplayName(displayName));
|
||||
Assert.Equal("Step with semicolons%3B and more%3B here", escaped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EmitMarkers_DisplayNameWithBrackets()
|
||||
{
|
||||
var displayName = "Step with [brackets] inside";
|
||||
var escaped = EscapeProperty(SanitizeDisplayName(displayName));
|
||||
Assert.Equal("Step with [brackets%5D inside", escaped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void StripUserEmittedMarkers_StartAction()
|
||||
{
|
||||
// Simulate what OutputManager does to strip markers
|
||||
var userLine = "##[start-action display=Fake;id=fake]";
|
||||
var stripped = StripMarkers(userLine);
|
||||
Assert.Equal(@"##[\start-action display=Fake;id=fake]", stripped);
|
||||
Assert.DoesNotContain("##[start-action", stripped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void StripUserEmittedMarkers_EndAction()
|
||||
{
|
||||
var userLine = "##[end-action id=fake;outcome=success;conclusion=success;duration_ms=100]";
|
||||
var stripped = StripMarkers(userLine);
|
||||
Assert.Equal(@"##[\end-action id=fake;outcome=success;conclusion=success;duration_ms=100]", stripped);
|
||||
Assert.DoesNotContain("##[end-action", stripped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void StripUserEmittedMarkers_PreservesOtherCommands()
|
||||
{
|
||||
var userLine = "##[group]My Group";
|
||||
var stripped = StripMarkers(userLine);
|
||||
Assert.Equal("##[group]My Group", stripped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void StripUserEmittedMarkers_HandlesEmbeddedMarkers()
|
||||
{
|
||||
var userLine = "Some text ##[start-action display=fake;id=fake] more text";
|
||||
var stripped = StripMarkers(userLine);
|
||||
Assert.Equal(@"Some text ##[\start-action display=fake;id=fake] more text", stripped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void TaskResultToActionResult_Success()
|
||||
{
|
||||
var result = GitHub.DistributedTask.WebApi.TaskResult.Succeeded;
|
||||
var actionResult = result.ToActionResult();
|
||||
Assert.Equal(ActionResult.Success, actionResult);
|
||||
Assert.Equal("success", actionResult.ToString().ToLowerInvariant());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void TaskResultToActionResult_Failure()
|
||||
{
|
||||
var result = GitHub.DistributedTask.WebApi.TaskResult.Failed;
|
||||
var actionResult = result.ToActionResult();
|
||||
Assert.Equal(ActionResult.Failure, actionResult);
|
||||
Assert.Equal("failure", actionResult.ToString().ToLowerInvariant());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void TaskResultToActionResult_Cancelled()
|
||||
{
|
||||
var result = GitHub.DistributedTask.WebApi.TaskResult.Canceled;
|
||||
var actionResult = result.ToActionResult();
|
||||
Assert.Equal(ActionResult.Cancelled, actionResult);
|
||||
Assert.Equal("cancelled", actionResult.ToString().ToLowerInvariant());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void TaskResultToActionResult_Skipped()
|
||||
{
|
||||
var result = GitHub.DistributedTask.WebApi.TaskResult.Skipped;
|
||||
var actionResult = result.ToActionResult();
|
||||
Assert.Equal(ActionResult.Skipped, actionResult);
|
||||
Assert.Equal("skipped", actionResult.ToString().ToLowerInvariant());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void MarkerFormat_StartAction()
|
||||
{
|
||||
var display = "My Step";
|
||||
var id = "my-step";
|
||||
var marker = $"##[start-action display={EscapeProperty(display)};id={EscapeProperty(id)}]";
|
||||
Assert.Equal("##[start-action display=My Step;id=my-step]", marker);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void MarkerFormat_EndAction()
|
||||
{
|
||||
var id = "my-step";
|
||||
var outcome = "success";
|
||||
var conclusion = "success";
|
||||
var durationMs = 1234;
|
||||
var marker = $"##[end-action id={EscapeProperty(id)};outcome={outcome};conclusion={conclusion};duration_ms={durationMs}]";
|
||||
Assert.Equal("##[end-action id=my-step;outcome=success;conclusion=success;duration_ms=1234]", marker);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void MarkerFormat_NestedId()
|
||||
{
|
||||
var prefix = "outer-composite";
|
||||
var contextName = "inner-step";
|
||||
var stepId = $"{prefix}.{contextName}";
|
||||
Assert.Equal("outer-composite.inner-step", stepId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void MarkerFormat_SkippedStep()
|
||||
{
|
||||
var id = "skipped-step";
|
||||
var marker = $"##[end-action id={EscapeProperty(id)};outcome=skipped;conclusion=skipped;duration_ms=0]";
|
||||
Assert.Equal("##[end-action id=skipped-step;outcome=skipped;conclusion=skipped;duration_ms=0]", marker);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void MarkerFormat_ContinueOnError()
|
||||
{
|
||||
// When continue-on-error is true and step fails:
|
||||
// outcome = failure (raw result)
|
||||
// conclusion = success (after continue-on-error applied)
|
||||
var id = "failing-step";
|
||||
var marker = $"##[end-action id={EscapeProperty(id)};outcome=failure;conclusion=success;duration_ms=500]";
|
||||
Assert.Equal("##[end-action id=failing-step;outcome=failure;conclusion=success;duration_ms=500]", marker);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void PostStepMarker_UsesEvaluatedDisplayName()
|
||||
{
|
||||
// Arrange: create an ActionRunner with a RepositoryPathReference (simulating actions/cache@v4)
|
||||
// and Stage = Post. Verify that EvaluateDisplayName produces the correct display name
|
||||
// so the composite marker emits "Run actions/cache@v4" instead of the fallback "run".
|
||||
var hc = new TestHostContext(this, nameof(PostStepMarker_UsesEvaluatedDisplayName));
|
||||
var actionManifestLegacy = new ActionManifestManagerLegacy();
|
||||
actionManifestLegacy.Initialize(hc);
|
||||
hc.SetSingleton<IActionManifestManagerLegacy>(actionManifestLegacy);
|
||||
var actionManifestNew = new ActionManifestManager();
|
||||
actionManifestNew.Initialize(hc);
|
||||
hc.SetSingleton<IActionManifestManager>(actionManifestNew);
|
||||
var actionManifestManager = new ActionManifestManagerWrapper();
|
||||
actionManifestManager.Initialize(hc);
|
||||
hc.SetSingleton<IActionManifestManagerWrapper>(actionManifestManager);
|
||||
|
||||
var ec = new Mock<IExecutionContext>();
|
||||
var contextData = new DictionaryContextData();
|
||||
var githubContext = new GitHubContext();
|
||||
githubContext.Add("event", JToken.Parse("{\"foo\":\"bar\"}").ToPipelineContextData());
|
||||
contextData.Add("github", githubContext);
|
||||
#if OS_WINDOWS
|
||||
contextData["env"] = new DictionaryContextData();
|
||||
#else
|
||||
contextData["env"] = new CaseSensitiveDictionaryContextData();
|
||||
#endif
|
||||
ec.Setup(x => x.Global).Returns(new GlobalContext());
|
||||
ec.Setup(x => x.ExpressionValues).Returns(contextData);
|
||||
ec.Setup(x => x.ExpressionFunctions).Returns(new List<GitHub.DistributedTask.Expressions2.IFunctionInfo>());
|
||||
ec.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>()));
|
||||
ec.Object.Global.Variables = new Variables(hc, new Dictionary<string, VariableValue>());
|
||||
|
||||
var actionRunner = new ActionRunner();
|
||||
actionRunner.Initialize(hc);
|
||||
actionRunner.ExecutionContext = ec.Object;
|
||||
actionRunner.Stage = ActionRunStage.Post;
|
||||
actionRunner.Action = new Pipelines.ActionStep()
|
||||
{
|
||||
Name = "cache",
|
||||
Id = Guid.NewGuid(),
|
||||
Reference = new Pipelines.RepositoryPathReference()
|
||||
{
|
||||
Name = "actions/cache",
|
||||
Ref = "v4"
|
||||
}
|
||||
};
|
||||
|
||||
// Act: call EvaluateDisplayName directly, which is what CompositeActionHandler now does
|
||||
// for embedded steps (including Post stage) instead of TryUpdateDisplayName.
|
||||
var result = actionRunner.EvaluateDisplayName(contextData, ec.Object, out bool updated);
|
||||
|
||||
// Assert: display name should be "Run actions/cache@v4", not the fallback "run"
|
||||
Assert.True(result);
|
||||
Assert.True(updated);
|
||||
Assert.Equal("Run actions/cache@v4", actionRunner.DisplayName);
|
||||
}
|
||||
|
||||
// Helper methods that call the real production code
|
||||
private static string EscapeProperty(string value) =>
|
||||
CompositeActionHandler.EscapeProperty(value);
|
||||
|
||||
private static string SanitizeDisplayName(string displayName) =>
|
||||
CompositeActionHandler.SanitizeDisplayName(displayName);
|
||||
|
||||
private static string StripMarkers(string line)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(line) &&
|
||||
(line.Contains("##[start-action") || line.Contains("##[end-action")))
|
||||
{
|
||||
line = line.Replace("##[start-action", @"##[\start-action")
|
||||
.Replace("##[end-action", @"##[\end-action");
|
||||
}
|
||||
return line;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ using GitHub.DistributedTask.ObjectTemplating.Tokens;
|
||||
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 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
|
||||
var _stepsRunner = new StepsRunner();
|
||||
_stepsRunner.Initialize(hc);
|
||||
|
||||
var mockDapDebugger = new Mock<IDapDebugger>();
|
||||
hc.SetSingleton(mockDapDebugger.Object);
|
||||
|
||||
await _stepsRunner.RunAsync(_jobEc);
|
||||
|
||||
Assert.Equal("Create custom image", snapshotStep.DisplayName);
|
||||
|
||||
@@ -1006,6 +1006,66 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void StripCompositeMarkers_StartAction()
|
||||
{
|
||||
using (Setup())
|
||||
using (_outputManager)
|
||||
{
|
||||
Process("##[start-action display=Fake;id=fake]");
|
||||
Assert.Single(_messages);
|
||||
Assert.Contains(@"##[\start-action", _messages[0]);
|
||||
Assert.DoesNotContain("##[start-action", _messages[0]);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void StripCompositeMarkers_EndAction()
|
||||
{
|
||||
using (Setup())
|
||||
using (_outputManager)
|
||||
{
|
||||
Process("##[end-action id=fake;outcome=success;conclusion=success;duration_ms=100]");
|
||||
Assert.Single(_messages);
|
||||
Assert.Contains(@"##[\end-action", _messages[0]);
|
||||
Assert.DoesNotContain("##[end-action", _messages[0]);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void StripCompositeMarkers_PreservesOtherCommands()
|
||||
{
|
||||
using (Setup())
|
||||
using (_outputManager)
|
||||
{
|
||||
Process("##[group]My Group");
|
||||
// Should not be stripped (not a composite marker)
|
||||
Assert.Single(_messages);
|
||||
Assert.Equal("##[group]My Group", _messages[0]);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void StripCompositeMarkers_EmbeddedInLine()
|
||||
{
|
||||
using (Setup())
|
||||
using (_outputManager)
|
||||
{
|
||||
Process("Some text ##[start-action display=fake;id=fake] more text");
|
||||
Assert.Single(_messages);
|
||||
Assert.Contains(@"##[\start-action", _messages[0]);
|
||||
Assert.DoesNotContain("##[start-action", _messages[0]);
|
||||
}
|
||||
}
|
||||
|
||||
private TestHostContext Setup(
|
||||
[CallerMemberName] string name = "",
|
||||
IssueMatchersConfig matchers = null,
|
||||
|
||||
891
src/Test/L0/Worker/PipelineTemplateEvaluatorWrapperL0.cs
Normal file
891
src/Test/L0/Worker/PipelineTemplateEvaluatorWrapperL0.cs
Normal file
@@ -0,0 +1,891 @@
|
||||
using GitHub.DistributedTask.ObjectTemplating.Tokens;
|
||||
using GitHub.DistributedTask.Pipelines.ContextData;
|
||||
using GitHub.DistributedTask.Pipelines.ObjectTemplating;
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Sdk;
|
||||
using GitHub.Runner.Worker;
|
||||
using LegacyContextData = GitHub.DistributedTask.Pipelines.ContextData;
|
||||
using LegacyExpressions = GitHub.DistributedTask.Expressions2;
|
||||
using Moq;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using Xunit;
|
||||
|
||||
namespace GitHub.Runner.Common.Tests.Worker
|
||||
{
|
||||
public sealed class PipelineTemplateEvaluatorWrapperL0
|
||||
{
|
||||
private CancellationTokenSource _ecTokenSource;
|
||||
private Mock<IExecutionContext> _ec;
|
||||
private TestHostContext _hc;
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// EvaluateAndCompare core behavior
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateAndCompare_DoesNotRecordMismatch_WhenResultsMatch()
|
||||
{
|
||||
try
|
||||
{
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
|
||||
var token = new StringToken(null, null, null, "test-value");
|
||||
var contextData = new DictionaryContextData();
|
||||
var expressionFunctions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
|
||||
var result = wrapper.EvaluateStepDisplayName(token, contextData, expressionFunctions);
|
||||
|
||||
Assert.Equal("test-value", result);
|
||||
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateAndCompare_SkipsMismatchRecording_WhenCancellationOccursDuringEvaluation()
|
||||
{
|
||||
try
|
||||
{
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
|
||||
// Call EvaluateAndCompare directly: the new evaluator cancels the token
|
||||
// and returns a different value, forcing hasMismatch = true.
|
||||
// Because cancellation flipped during the evaluation window, the
|
||||
// mismatch should be skipped.
|
||||
var result = wrapper.EvaluateAndCompare<string, string>(
|
||||
"TestCancellationSkip",
|
||||
() => "legacy-value",
|
||||
() =>
|
||||
{
|
||||
_ecTokenSource.Cancel();
|
||||
return "different-value";
|
||||
},
|
||||
(legacy, @new) => string.Equals(legacy, @new, StringComparison.Ordinal));
|
||||
|
||||
Assert.Equal("legacy-value", result);
|
||||
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateAndCompare_RecordsMismatch_WhenResultsDifferWithoutCancellation()
|
||||
{
|
||||
try
|
||||
{
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
|
||||
// Different results without cancellation — mismatch SHOULD be recorded.
|
||||
var result = wrapper.EvaluateAndCompare<string, string>(
|
||||
"TestMismatchRecorded",
|
||||
() => "legacy-value",
|
||||
() => "different-value",
|
||||
(legacy, @new) => string.Equals(legacy, @new, StringComparison.Ordinal));
|
||||
|
||||
Assert.Equal("legacy-value", result);
|
||||
Assert.True(_ec.Object.Global.HasTemplateEvaluatorMismatch);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Smoke tests — both parsers agree, no mismatch recorded
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateStepContinueOnError_BothParsersAgree()
|
||||
{
|
||||
try
|
||||
{
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
var token = new BooleanToken(null, null, null, true);
|
||||
var contextData = new DictionaryContextData();
|
||||
var functions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
|
||||
var result = wrapper.EvaluateStepContinueOnError(token, contextData, functions);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateStepEnvironment_BothParsersAgree()
|
||||
{
|
||||
try
|
||||
{
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
var token = new MappingToken(null, null, null);
|
||||
token.Add(new StringToken(null, null, null, "FOO"), new StringToken(null, null, null, "bar"));
|
||||
var contextData = new DictionaryContextData();
|
||||
var functions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
|
||||
var result = wrapper.EvaluateStepEnvironment(token, contextData, functions, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("bar", result["FOO"]);
|
||||
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateStepIf_BothParsersAgree()
|
||||
{
|
||||
try
|
||||
{
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
var token = new BasicExpressionToken(null, null, null, "true");
|
||||
var contextData = new DictionaryContextData();
|
||||
var functions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
var expressionState = new List<KeyValuePair<string, object>>();
|
||||
|
||||
var result = wrapper.EvaluateStepIf(token, contextData, functions, expressionState);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateStepInputs_BothParsersAgree()
|
||||
{
|
||||
try
|
||||
{
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
var token = new MappingToken(null, null, null);
|
||||
token.Add(new StringToken(null, null, null, "input1"), new StringToken(null, null, null, "val1"));
|
||||
var contextData = new DictionaryContextData();
|
||||
var functions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
|
||||
var result = wrapper.EvaluateStepInputs(token, contextData, functions);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("val1", result["input1"]);
|
||||
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateStepTimeout_BothParsersAgree()
|
||||
{
|
||||
try
|
||||
{
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
var token = new NumberToken(null, null, null, 10);
|
||||
var contextData = new DictionaryContextData();
|
||||
var functions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
|
||||
var result = wrapper.EvaluateStepTimeout(token, contextData, functions);
|
||||
|
||||
Assert.Equal(10, result);
|
||||
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateJobContainer_EmptyImage_BothParsersAgree()
|
||||
{
|
||||
try
|
||||
{
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
var token = new StringToken(null, null, null, "");
|
||||
var contextData = new DictionaryContextData();
|
||||
var functions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
|
||||
var result = wrapper.EvaluateJobContainer(token, contextData, functions);
|
||||
|
||||
Assert.Null(result);
|
||||
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateJobContainer_DockerPrefixOnly_BothParsersAgree()
|
||||
{
|
||||
try
|
||||
{
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
var token = new StringToken(null, null, null, "docker://");
|
||||
var contextData = new DictionaryContextData();
|
||||
var functions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
|
||||
var result = wrapper.EvaluateJobContainer(token, contextData, functions);
|
||||
|
||||
Assert.Null(result);
|
||||
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateJobContainer_DockerPrefixOnlyMapping_BothParsersAgree()
|
||||
{
|
||||
try
|
||||
{
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
var token = new MappingToken(null, null, null);
|
||||
token.Add(new StringToken(null, null, null, "image"), new StringToken(null, null, null, "docker://"));
|
||||
var contextData = new DictionaryContextData();
|
||||
var functions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
|
||||
var result = wrapper.EvaluateJobContainer(token, contextData, functions);
|
||||
|
||||
Assert.Null(result);
|
||||
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateJobContainer_EmptyImageMapping_BothParsersAgree()
|
||||
{
|
||||
try
|
||||
{
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
var token = new MappingToken(null, null, null);
|
||||
token.Add(new StringToken(null, null, null, "image"), new StringToken(null, null, null, ""));
|
||||
var contextData = new DictionaryContextData();
|
||||
var functions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
|
||||
var result = wrapper.EvaluateJobContainer(token, contextData, functions);
|
||||
|
||||
Assert.Null(result);
|
||||
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateJobContainer_ValidImage_BothParsersAgree()
|
||||
{
|
||||
try
|
||||
{
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
var token = new StringToken(null, null, null, "ubuntu:latest");
|
||||
var contextData = new DictionaryContextData();
|
||||
var functions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
|
||||
var result = wrapper.EvaluateJobContainer(token, contextData, functions);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("ubuntu:latest", result.Image);
|
||||
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateJobContainer_DockerPrefixWithImage_BothParsersAgree()
|
||||
{
|
||||
try
|
||||
{
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
var token = new StringToken(null, null, null, "docker://ubuntu:latest");
|
||||
var contextData = new DictionaryContextData();
|
||||
var functions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
|
||||
var result = wrapper.EvaluateJobContainer(token, contextData, functions);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("ubuntu:latest", result.Image);
|
||||
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateJobOutput_BothParsersAgree()
|
||||
{
|
||||
try
|
||||
{
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
var token = new MappingToken(null, null, null);
|
||||
token.Add(new StringToken(null, null, null, "out1"), new StringToken(null, null, null, "val1"));
|
||||
var contextData = new DictionaryContextData();
|
||||
var functions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
|
||||
var result = wrapper.EvaluateJobOutput(token, contextData, functions);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("val1", result["out1"]);
|
||||
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateEnvironmentUrl_BothParsersAgree()
|
||||
{
|
||||
try
|
||||
{
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
var token = new StringToken(null, null, null, "https://example.com");
|
||||
var contextData = new DictionaryContextData();
|
||||
var functions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
|
||||
var result = wrapper.EvaluateEnvironmentUrl(token, contextData, functions);
|
||||
|
||||
Assert.NotNull(result);
|
||||
var stringResult = result as StringToken;
|
||||
Assert.NotNull(stringResult);
|
||||
Assert.Equal("https://example.com", stringResult.Value);
|
||||
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateJobDefaultsRun_BothParsersAgree()
|
||||
{
|
||||
try
|
||||
{
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
var token = new MappingToken(null, null, null);
|
||||
token.Add(new StringToken(null, null, null, "shell"), new StringToken(null, null, null, "bash"));
|
||||
var contextData = new DictionaryContextData();
|
||||
var functions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
|
||||
var result = wrapper.EvaluateJobDefaultsRun(token, contextData, functions);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("bash", result["shell"]);
|
||||
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateJobServiceContainers_Null_BothParsersAgree()
|
||||
{
|
||||
try
|
||||
{
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
var contextData = new DictionaryContextData();
|
||||
var functions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
|
||||
var result = wrapper.EvaluateJobServiceContainers(null, contextData, functions);
|
||||
|
||||
Assert.Null(result);
|
||||
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateJobServiceContainers_EmptyImage_BothParsersAgree()
|
||||
{
|
||||
try
|
||||
{
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
// Build a services mapping token with one service whose image is empty string
|
||||
// Similar to: services: { db: { image: '' } }
|
||||
var servicesMapping = new MappingToken(null, null, null);
|
||||
var serviceMapping = new MappingToken(null, null, null);
|
||||
serviceMapping.Add(new StringToken(null, null, null, "image"), new StringToken(null, null, null, ""));
|
||||
servicesMapping.Add(new StringToken(null, null, null, "db"), serviceMapping);
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
var contextData = new DictionaryContextData();
|
||||
var functions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
|
||||
var result = wrapper.EvaluateJobServiceContainers(servicesMapping, contextData, functions);
|
||||
|
||||
// Should get a list with one entry where the container is null (empty image = no container)
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("db", result[0].Key);
|
||||
Assert.Null(result[0].Value);
|
||||
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateJobServiceContainers_DockerPrefixOnlyImage_BothParsersAgree()
|
||||
{
|
||||
try
|
||||
{
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var servicesMapping = new MappingToken(null, null, null);
|
||||
var serviceMapping = new MappingToken(null, null, null);
|
||||
serviceMapping.Add(new StringToken(null, null, null, "image"), new StringToken(null, null, null, "docker://"));
|
||||
servicesMapping.Add(new StringToken(null, null, null, "db"), serviceMapping);
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
var contextData = new DictionaryContextData();
|
||||
var functions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
|
||||
var result = wrapper.EvaluateJobServiceContainers(servicesMapping, contextData, functions);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("db", result[0].Key);
|
||||
Assert.Null(result[0].Value);
|
||||
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateJobServiceContainers_ExpressionEvalsToEmpty_BothParsersAgree()
|
||||
{
|
||||
try
|
||||
{
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
// Simulates: services: { db: { image: ${{ condition && 'img' || '' }} } }
|
||||
// where the expression evaluates to '' at runtime
|
||||
var servicesMapping = new MappingToken(null, null, null);
|
||||
var serviceMapping = new MappingToken(null, null, null);
|
||||
serviceMapping.Add(new StringToken(null, null, null, "image"), new BasicExpressionToken(null, null, null, "''"));
|
||||
servicesMapping.Add(new StringToken(null, null, null, "db"), serviceMapping);
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
var contextData = new DictionaryContextData();
|
||||
var functions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
|
||||
var result = wrapper.EvaluateJobServiceContainers(servicesMapping, contextData, functions);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("db", result[0].Key);
|
||||
Assert.Null(result[0].Value);
|
||||
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateJobServiceContainers_ValidImage_BothParsersAgree()
|
||||
{
|
||||
try
|
||||
{
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var servicesMapping = new MappingToken(null, null, null);
|
||||
var serviceMapping = new MappingToken(null, null, null);
|
||||
serviceMapping.Add(new StringToken(null, null, null, "image"), new StringToken(null, null, null, "postgres:latest"));
|
||||
servicesMapping.Add(new StringToken(null, null, null, "db"), serviceMapping);
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
var contextData = new DictionaryContextData();
|
||||
var functions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
|
||||
var result = wrapper.EvaluateJobServiceContainers(servicesMapping, contextData, functions);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("db", result[0].Key);
|
||||
Assert.NotNull(result[0].Value);
|
||||
Assert.Equal("postgres:latest", result[0].Value.Image);
|
||||
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateJobServiceContainers_EntrypointAndCommand_BothParsersAgree()
|
||||
{
|
||||
try
|
||||
{
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var servicesMapping = new MappingToken(null, null, null);
|
||||
var serviceMapping = new MappingToken(null, null, null);
|
||||
serviceMapping.Add(new StringToken(null, null, null, "image"), new StringToken(null, null, null, "postgres:latest"));
|
||||
serviceMapping.Add(new StringToken(null, null, null, "entrypoint"), new StringToken(null, null, null, "/bin/bash"));
|
||||
serviceMapping.Add(new StringToken(null, null, null, "command"), new StringToken(null, null, null, "-lc echo hi"));
|
||||
servicesMapping.Add(new StringToken(null, null, null, "db"), serviceMapping);
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: true);
|
||||
var contextData = new DictionaryContextData();
|
||||
var functions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
|
||||
var result = wrapper.EvaluateJobServiceContainers(servicesMapping, contextData, functions);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("db", result[0].Key);
|
||||
Assert.NotNull(result[0].Value);
|
||||
Assert.Equal("postgres:latest", result[0].Value.Image);
|
||||
Assert.Equal("/bin/bash", result[0].Value.Entrypoint);
|
||||
Assert.Equal("-lc echo hi", result[0].Value.Command);
|
||||
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateJobServiceContainers_EntrypointAndCommand_FlagOff_BothParsersAgree()
|
||||
{
|
||||
try
|
||||
{
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var servicesMapping = new MappingToken(null, null, null);
|
||||
var serviceMapping = new MappingToken(null, null, null);
|
||||
serviceMapping.Add(new StringToken(null, null, null, "image"), new StringToken(null, null, null, "postgres:latest"));
|
||||
serviceMapping.Add(new StringToken(null, null, null, "entrypoint"), new StringToken(null, null, null, "/bin/bash"));
|
||||
serviceMapping.Add(new StringToken(null, null, null, "command"), new StringToken(null, null, null, "-lc echo hi"));
|
||||
servicesMapping.Add(new StringToken(null, null, null, "db"), serviceMapping);
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
var contextData = new DictionaryContextData();
|
||||
var functions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
|
||||
Assert.Throws<GitHub.DistributedTask.ObjectTemplating.TemplateValidationException>(() =>
|
||||
wrapper.EvaluateJobServiceContainers(servicesMapping, contextData, functions));
|
||||
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateJobSnapshotRequest_Null_BothParsersAgree()
|
||||
{
|
||||
try
|
||||
{
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
var contextData = new DictionaryContextData();
|
||||
var functions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
|
||||
var result = wrapper.EvaluateJobSnapshotRequest(null, contextData, functions);
|
||||
|
||||
Assert.Null(result);
|
||||
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// JSON parse error equivalence via EvaluateAndCompare
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateAndCompare_JsonReaderExceptions_TreatedAsEquivalent()
|
||||
{
|
||||
try
|
||||
{
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
|
||||
// Both throw JsonReaderException with different messages — should be treated as equivalent
|
||||
var legacyEx = new Newtonsoft.Json.JsonReaderException("Error reading JToken from JsonReader. Path '', line 0, position 0.");
|
||||
var newEx = new Newtonsoft.Json.JsonReaderException("Error parsing fromJson", new Newtonsoft.Json.JsonReaderException("Unexpected end"));
|
||||
|
||||
Assert.Throws<Newtonsoft.Json.JsonReaderException>(() =>
|
||||
wrapper.EvaluateAndCompare<string, string>(
|
||||
"TestJsonEquivalence",
|
||||
() => throw legacyEx,
|
||||
() => throw newEx,
|
||||
(a, b) => string.Equals(a, b, StringComparison.Ordinal)));
|
||||
|
||||
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateAndCompare_MixedJsonExceptionTypes_TreatedAsEquivalent()
|
||||
{
|
||||
try
|
||||
{
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
|
||||
// Legacy throws Newtonsoft JsonReaderException, new throws System.Text.Json.JsonException
|
||||
var legacyEx = new Newtonsoft.Json.JsonReaderException("Error reading JToken");
|
||||
var newEx = new System.Text.Json.JsonException("Error parsing fromJson");
|
||||
|
||||
Assert.Throws<Newtonsoft.Json.JsonReaderException>(() =>
|
||||
wrapper.EvaluateAndCompare<string, string>(
|
||||
"TestMixedJsonTypes",
|
||||
() => throw legacyEx,
|
||||
() => throw newEx,
|
||||
(a, b) => string.Equals(a, b, StringComparison.Ordinal)));
|
||||
|
||||
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateAndCompare_NonJsonExceptions_RecordsMismatch()
|
||||
{
|
||||
try
|
||||
{
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
|
||||
|
||||
// Both throw non-JSON exceptions with different messages — should record mismatch
|
||||
var legacyEx = new InvalidOperationException("some error");
|
||||
var newEx = new InvalidOperationException("different error");
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
wrapper.EvaluateAndCompare<string, string>(
|
||||
"TestNonJsonMismatch",
|
||||
() => throw legacyEx,
|
||||
() => throw newEx,
|
||||
(a, b) => string.Equals(a, b, StringComparison.Ordinal)));
|
||||
|
||||
Assert.True(_ec.Object.Global.HasTemplateEvaluatorMismatch);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
private void Setup([CallerMemberName] string name = "")
|
||||
{
|
||||
_ecTokenSource?.Dispose();
|
||||
_ecTokenSource = new CancellationTokenSource();
|
||||
|
||||
_hc = new TestHostContext(this, name);
|
||||
|
||||
var expressionValues = new LegacyContextData.DictionaryContextData();
|
||||
var expressionFunctions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
|
||||
_ec = new Mock<IExecutionContext>();
|
||||
_ec.Setup(x => x.Global)
|
||||
.Returns(new GlobalContext
|
||||
{
|
||||
FileTable = new List<String>(),
|
||||
Variables = new Variables(_hc, new Dictionary<string, VariableValue>()),
|
||||
WriteDebug = true,
|
||||
});
|
||||
_ec.Setup(x => x.CancellationToken).Returns(_ecTokenSource.Token);
|
||||
_ec.Setup(x => x.ExpressionValues).Returns(expressionValues);
|
||||
_ec.Setup(x => x.ExpressionFunctions).Returns(expressionFunctions);
|
||||
_ec.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>())).Callback((string tag, string message) => { _hc.GetTrace().Info($"{tag}{message}"); });
|
||||
_ec.Setup(x => x.AddIssue(It.IsAny<Issue>(), It.IsAny<ExecutionContextLogOptions>())).Callback((Issue issue, ExecutionContextLogOptions logOptions) => { _hc.GetTrace().Info($"[{issue.Type}]{logOptions.LogMessageOverride ?? issue.Message}"); });
|
||||
}
|
||||
|
||||
private void Teardown()
|
||||
{
|
||||
_hc?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ using GitHub.DistributedTask.ObjectTemplating.Tokens;
|
||||
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 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
|
||||
_stepsRunner = new StepsRunner();
|
||||
_stepsRunner.Initialize(hc);
|
||||
|
||||
var mockDapDebugger = new Mock<IDapDebugger>();
|
||||
hc.SetSingleton(mockDapDebugger.Object);
|
||||
|
||||
return hc;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.417"
|
||||
DOTNETSDK_VERSION="8.0.418"
|
||||
DOTNETSDK_INSTALLDIR="$DOTNETSDK_ROOT/$DOTNETSDK_VERSION"
|
||||
RUNNER_VERSION=$(cat runnerversion)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "8.0.417"
|
||||
"version": "8.0.418"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
2.331.0
|
||||
2.332.0
|
||||
|
||||
Reference in New Issue
Block a user