Compare commits

..

16 Commits

Author SHA1 Message Date
Francesco Renzi
915e13c842 Integrate DAP debugger into JobRunner and StepsRunner 2026-03-11 08:56:08 -07:00
Francesco Renzi
17b05ddaa4 Add minimal DAP debug session with next/continue support 2026-03-11 08:55:54 -07:00
Francesco Renzi
9737dfadd5 Add DAP TCP server with reconnection support 2026-03-11 08:55:41 -07:00
Francesco Renzi
cca15de3b3 Add DAP protocol message types and service interfaces 2026-03-11 08:55:17 -07:00
Francesco Renzi
8b1b23b5ce Get EnableDebugger from job context 2026-03-10 04:13:39 -07:00
eric sciple
20111cbfda Support entrypoint and command for service containers (#4276) 2026-03-04 23:36:45 +00:00
Max Horstmann
8f01257663 Devcontainer: bump base image Ubuntu version (#4277) 2026-03-04 20:17:25 +00:00
eric sciple
8a73bccebb Fix parser comparison mismatches (#4273) 2026-03-03 05:38:16 +00:00
Tingluo Huang
a9a07a6553 Avoid throw in SelfUpdaters. (#4274) 2026-03-02 22:44:14 -05:00
github-actions[bot]
60a9422599 chore: update Node versions (#4272)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-02 13:51:11 +00:00
dependabot[bot]
985a06fcca Bump actions/download-artifact from 7 to 8 (#4269)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-27 09:18:13 +00:00
eric sciple
ae09a9d7b5 Fix composite post-step marker display names (#4267) 2026-02-26 08:36:55 -06:00
Tingluo Huang
7650fc432e Log inner exception message. (#4265) 2026-02-25 15:44:27 -05:00
Salman Chishti
bc00800857 Bump runner version to 2.332.0 and update release notes (#4264) 2026-02-25 13:36:47 +00:00
dependabot[bot]
86e23605d6 Bump @stylistic/eslint-plugin from 3.1.0 to 5.9.0 in /src/Misc/expressionFunc/hashFiles (#4257)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Salman Chishti <salmanmkc@GitHub.com>
2026-02-25 12:02:23 +00:00
dependabot[bot]
0fb7482206 Bump minimatch in /src/Misc/expressionFunc/hashFiles (#4261)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-25 11:56:32 +00:00
43 changed files with 4353 additions and 161 deletions

View File

@@ -1,8 +1,8 @@
{
"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.418"
},

View File

@@ -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: ./

View File

@@ -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.

View File

@@ -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"
}

View File

@@ -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",

View File

@@ -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.1"
NODE24_VERSION="24.14.0"
get_abs_path() {
# exploits the fact that pwd will print abs path when no args

View File

@@ -172,6 +172,7 @@ 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";

View File

@@ -23,7 +23,7 @@
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="System.IO.FileSystem.AccessControl" Version="5.0.0" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
<PackageReference Include="System.ServiceProcess.ServiceController" Version="10.0.3" />
<PackageReference Include="System.ServiceProcess.ServiceController" Version="8.0.1" />
</ItemGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">

View File

@@ -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
{

View File

@@ -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
{

View File

@@ -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;
}
@@ -818,7 +818,7 @@ namespace GitHub.Runner.Worker
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)
@@ -832,14 +832,14 @@ namespace GitHub.Runner.Worker
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;
}

View File

@@ -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;

View File

@@ -0,0 +1,644 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common;
using Newtonsoft.Json;
namespace GitHub.Runner.Worker.Dap
{
/// <summary>
/// Stores information about a completed step for stack trace display.
/// </summary>
internal sealed class CompletedStepInfo
{
public string DisplayName { get; set; }
public TaskResult? Result { get; set; }
public int FrameId { get; set; }
}
/// <summary>
/// Minimal production DAP debug session.
/// Handles step-level breakpoints with next/continue flow control,
/// client reconnection, and cancellation signal propagation.
///
/// Scope inspection, REPL, step manipulation, and time-travel debugging
/// are intentionally deferred to future iterations.
/// </summary>
public sealed class DapDebugSession : RunnerService, IDapDebugSession
{
// Thread ID for the single job execution thread
private const int JobThreadId = 1;
// Frame ID for the current step (always 1)
private const int CurrentFrameId = 1;
// Frame IDs for completed steps start at 1000
private const int CompletedFrameIdBase = 1000;
private IDapServer _server;
private DapSessionState _state = DapSessionState.WaitingForConnection;
// Synchronization for step execution
private TaskCompletionSource<DapCommand> _commandTcs;
private readonly object _stateLock = new object();
// Handshake completion — signaled when configurationDone is received
private readonly TaskCompletionSource<bool> _handshakeTcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
// Whether to pause before the next step (set by 'next' command)
private bool _pauseOnNextStep = true;
// Current execution context
private IStep _currentStep;
private IExecutionContext _jobContext;
private int _currentStepIndex;
// Track completed steps for stack trace
private readonly List<CompletedStepInfo> _completedSteps = new List<CompletedStepInfo>();
private int _nextCompletedFrameId = CompletedFrameIdBase;
// Client connection tracking for reconnection support
private volatile bool _isClientConnected;
public bool IsActive =>
_state == DapSessionState.Ready ||
_state == DapSessionState.Paused ||
_state == DapSessionState.Running;
public DapSessionState State => _state;
public override void Initialize(IHostContext hostContext)
{
base.Initialize(hostContext);
Trace.Info("DapDebugSession initialized");
}
public void SetDapServer(IDapServer server)
{
_server = server;
Trace.Info("DAP server reference set");
}
public async Task WaitForHandshakeAsync(CancellationToken cancellationToken)
{
Trace.Info("Waiting for DAP handshake (configurationDone)...");
using (cancellationToken.Register(() => _handshakeTcs.TrySetCanceled()))
{
await _handshakeTcs.Task;
}
Trace.Info("DAP handshake complete, session is ready");
}
#region Message Dispatch
public async Task HandleMessageAsync(string messageJson, CancellationToken cancellationToken)
{
Request request = null;
try
{
request = JsonConvert.DeserializeObject<Request>(messageJson);
if (request == null)
{
Trace.Warning("Failed to deserialize DAP request");
return;
}
Trace.Info($"Handling DAP request: {request.Command}");
var response = request.Command switch
{
"initialize" => HandleInitialize(request),
"attach" => HandleAttach(request),
"configurationDone" => HandleConfigurationDone(request),
"disconnect" => HandleDisconnect(request),
"threads" => HandleThreads(request),
"stackTrace" => HandleStackTrace(request),
"scopes" => HandleScopes(request),
"variables" => HandleVariables(request),
"continue" => HandleContinue(request),
"next" => HandleNext(request),
"setBreakpoints" => HandleSetBreakpoints(request),
"setExceptionBreakpoints" => HandleSetExceptionBreakpoints(request),
_ => CreateResponse(request, false, $"Unsupported command: {request.Command}", body: null)
};
response.RequestSeq = request.Seq;
response.Command = request.Command;
_server?.SendResponse(response);
}
catch (Exception ex)
{
Trace.Error($"Error handling request '{request?.Command}': {ex}");
if (request != null)
{
var errorResponse = CreateResponse(request, false, ex.Message, body: null);
errorResponse.RequestSeq = request.Seq;
errorResponse.Command = request.Command;
_server?.SendResponse(errorResponse);
}
}
await Task.CompletedTask;
}
#endregion
#region DAP Request Handlers
private Response HandleInitialize(Request request)
{
if (request.Arguments != null)
{
try
{
var clientCaps = request.Arguments.ToObject<InitializeRequestArguments>();
Trace.Info($"Client: {clientCaps?.ClientName ?? clientCaps?.ClientId ?? "unknown"}");
}
catch (Exception ex)
{
Trace.Warning($"Failed to parse initialize arguments: {ex.Message}");
}
}
_state = DapSessionState.Initializing;
// Build capabilities — MVP only supports configurationDone
var capabilities = new Capabilities
{
SupportsConfigurationDoneRequest = true,
// All other capabilities are false for MVP
SupportsFunctionBreakpoints = false,
SupportsConditionalBreakpoints = false,
SupportsEvaluateForHovers = false,
SupportsStepBack = false,
SupportsSetVariable = false,
SupportsRestartFrame = false,
SupportsGotoTargetsRequest = false,
SupportsStepInTargetsRequest = false,
SupportsCompletionsRequest = false,
SupportsModulesRequest = false,
SupportsTerminateRequest = false,
SupportTerminateDebuggee = false,
SupportsDelayedStackTraceLoading = false,
SupportsLoadedSourcesRequest = false,
SupportsProgressReporting = false,
SupportsRunInTerminalRequest = false,
SupportsCancelRequest = false,
SupportsExceptionOptions = false,
SupportsValueFormattingOptions = false,
SupportsExceptionInfoRequest = false,
};
// Send initialized event after a brief delay to ensure the
// response is delivered first (DAP spec requirement)
_ = Task.Run(async () =>
{
await Task.Delay(50);
_server?.SendEvent(new Event
{
EventType = "initialized"
});
Trace.Info("Sent initialized event");
});
Trace.Info("Initialize request handled, capabilities sent");
return CreateResponse(request, true, body: capabilities);
}
private Response HandleAttach(Request request)
{
Trace.Info("Attach request handled");
return CreateResponse(request, true, body: null);
}
private Response HandleConfigurationDone(Request request)
{
lock (_stateLock)
{
_state = DapSessionState.Ready;
}
_handshakeTcs.TrySetResult(true);
Trace.Info("Configuration done, debug session is ready");
return CreateResponse(request, true, body: null);
}
private Response HandleDisconnect(Request request)
{
Trace.Info("Disconnect request received");
lock (_stateLock)
{
_state = DapSessionState.Terminated;
// Release any blocked step execution
_commandTcs?.TrySetResult(DapCommand.Disconnect);
}
return CreateResponse(request, true, body: null);
}
private Response HandleThreads(Request request)
{
var body = new ThreadsResponseBody
{
Threads = new List<Thread>
{
new Thread
{
Id = JobThreadId,
Name = _jobContext != null
? $"Job: {_jobContext.GetGitHubContext("job") ?? "workflow job"}"
: "Job Thread"
}
}
};
return CreateResponse(request, true, body: body);
}
private Response HandleStackTrace(Request request)
{
var frames = new List<StackFrame>();
// Add current step as the top frame
if (_currentStep != null)
{
var resultIndicator = _currentStep.ExecutionContext?.Result != null
? $" [{_currentStep.ExecutionContext.Result}]"
: " [running]";
frames.Add(new StackFrame
{
Id = CurrentFrameId,
Name = $"{_currentStep.DisplayName ?? "Current Step"}{resultIndicator}",
Line = _currentStepIndex + 1,
Column = 1,
PresentationHint = "normal"
});
}
else
{
frames.Add(new StackFrame
{
Id = CurrentFrameId,
Name = "(no step executing)",
Line = 0,
Column = 1,
PresentationHint = "subtle"
});
}
// Add completed steps as additional frames (most recent first)
for (int i = _completedSteps.Count - 1; i >= 0; i--)
{
var completedStep = _completedSteps[i];
var resultStr = completedStep.Result.HasValue ? $" [{completedStep.Result}]" : "";
frames.Add(new StackFrame
{
Id = completedStep.FrameId,
Name = $"{completedStep.DisplayName}{resultStr}",
Line = 1,
Column = 1,
PresentationHint = "subtle"
});
}
var body = new StackTraceResponseBody
{
StackFrames = frames,
TotalFrames = frames.Count
};
return CreateResponse(request, true, body: body);
}
private Response HandleScopes(Request request)
{
// MVP: return empty scopes — scope inspection deferred
return CreateResponse(request, true, body: new ScopesResponseBody
{
Scopes = new List<Scope>()
});
}
private Response HandleVariables(Request request)
{
// MVP: return empty variables — variable inspection deferred
return CreateResponse(request, true, body: new VariablesResponseBody
{
Variables = new List<Variable>()
});
}
private Response HandleContinue(Request request)
{
Trace.Info("Continue command received");
lock (_stateLock)
{
if (_state == DapSessionState.Paused)
{
_state = DapSessionState.Running;
_pauseOnNextStep = false;
_commandTcs?.TrySetResult(DapCommand.Continue);
}
}
return CreateResponse(request, true, body: new ContinueResponseBody
{
AllThreadsContinued = true
});
}
private Response HandleNext(Request request)
{
Trace.Info("Next (step over) command received");
lock (_stateLock)
{
if (_state == DapSessionState.Paused)
{
_state = DapSessionState.Running;
_pauseOnNextStep = true;
_commandTcs?.TrySetResult(DapCommand.Next);
}
}
return CreateResponse(request, true, body: null);
}
private Response HandleSetBreakpoints(Request request)
{
// MVP: acknowledge but don't process breakpoints
// All steps pause automatically via _pauseOnNextStep
return CreateResponse(request, true, body: new { breakpoints = Array.Empty<object>() });
}
private Response HandleSetExceptionBreakpoints(Request request)
{
// MVP: acknowledge but don't process exception breakpoints
return CreateResponse(request, true, body: null);
}
#endregion
#region Step Lifecycle
public async Task OnStepStartingAsync(IStep step, IExecutionContext jobContext, bool isFirstStep, CancellationToken cancellationToken)
{
if (!IsActive)
{
return;
}
_currentStep = step;
_jobContext = jobContext;
_currentStepIndex = _completedSteps.Count;
// Determine if we should pause
bool shouldPause = isFirstStep || _pauseOnNextStep;
if (!shouldPause)
{
Trace.Info($"Step starting (not pausing): {step.DisplayName}");
return;
}
var reason = isFirstStep ? "entry" : "step";
var description = isFirstStep
? $"Stopped at job entry: {step.DisplayName}"
: $"Stopped before step: {step.DisplayName}";
Trace.Info($"Step starting: {step.DisplayName} (reason: {reason})");
// Send stopped event to debugger (only if client is connected)
SendStoppedEvent(reason, description);
// Wait for debugger command
await WaitForCommandAsync(cancellationToken);
}
public void OnStepCompleted(IStep step)
{
if (!IsActive)
{
return;
}
var result = step.ExecutionContext?.Result;
Trace.Info($"Step completed: {step.DisplayName}, result: {result}");
// Add to completed steps list for stack trace
_completedSteps.Add(new CompletedStepInfo
{
DisplayName = step.DisplayName,
Result = result,
FrameId = _nextCompletedFrameId++
});
}
public void OnJobCompleted()
{
if (!IsActive)
{
return;
}
Trace.Info("Job completed, sending terminated event");
lock (_stateLock)
{
_state = DapSessionState.Terminated;
}
_server?.SendEvent(new Event
{
EventType = "terminated",
Body = new TerminatedEventBody()
});
var exitCode = _jobContext?.Result == TaskResult.Succeeded ? 0 : 1;
_server?.SendEvent(new Event
{
EventType = "exited",
Body = new ExitedEventBody
{
ExitCode = exitCode
}
});
}
public void CancelSession()
{
Trace.Info("CancelSession called - terminating debug session");
lock (_stateLock)
{
if (_state == DapSessionState.Terminated)
{
Trace.Info("Session already terminated, ignoring CancelSession");
return;
}
_state = DapSessionState.Terminated;
}
// Send terminated event to debugger so it updates its UI
_server?.SendEvent(new Event
{
EventType = "terminated",
Body = new TerminatedEventBody()
});
// Send exited event with cancellation exit code (130 = SIGINT convention)
_server?.SendEvent(new Event
{
EventType = "exited",
Body = new ExitedEventBody { ExitCode = 130 }
});
// Release any pending command waits
_commandTcs?.TrySetResult(DapCommand.Disconnect);
// Release handshake wait if still pending
_handshakeTcs.TrySetCanceled();
Trace.Info("Debug session cancelled");
}
#endregion
#region Client Connection Tracking
public void HandleClientConnected()
{
_isClientConnected = true;
Trace.Info("Client connected to debug session");
// If we're paused, re-send the stopped event so the new client
// knows the current state (important for reconnection)
lock (_stateLock)
{
if (_state == DapSessionState.Paused && _currentStep != null)
{
Trace.Info("Re-sending stopped event to reconnected client");
var description = $"Stopped before step: {_currentStep.DisplayName}";
SendStoppedEvent("step", description);
}
}
}
public void HandleClientDisconnected()
{
_isClientConnected = false;
Trace.Info("Client disconnected from debug session");
// Intentionally do NOT release the command TCS here.
// The session stays paused, waiting for a client to reconnect.
// The server's connection loop will accept a new client and
// call HandleClientConnected, which re-sends the stopped event.
}
#endregion
#region Private Helpers
/// <summary>
/// Blocks the step execution thread until a debugger command is received
/// or the job is cancelled.
/// </summary>
private async Task WaitForCommandAsync(CancellationToken cancellationToken)
{
lock (_stateLock)
{
_state = DapSessionState.Paused;
_commandTcs = new TaskCompletionSource<DapCommand>(TaskCreationOptions.RunContinuationsAsynchronously);
}
Trace.Info("Waiting for debugger command...");
using (cancellationToken.Register(() =>
{
Trace.Info("Job cancellation detected, releasing debugger wait");
_commandTcs?.TrySetResult(DapCommand.Disconnect);
}))
{
var command = await _commandTcs.Task;
Trace.Info($"Received command: {command}");
lock (_stateLock)
{
if (_state == DapSessionState.Paused)
{
_state = DapSessionState.Running;
}
}
// Send continued event for normal flow commands
if (!cancellationToken.IsCancellationRequested &&
(command == DapCommand.Continue || command == DapCommand.Next))
{
_server?.SendEvent(new Event
{
EventType = "continued",
Body = new ContinuedEventBody
{
ThreadId = JobThreadId,
AllThreadsContinued = true
}
});
}
}
}
/// <summary>
/// Sends a stopped event to the connected client.
/// Silently no-ops if no client is connected.
/// </summary>
private void SendStoppedEvent(string reason, string description)
{
if (!_isClientConnected)
{
Trace.Info($"No client connected, deferring stopped event: {description}");
return;
}
_server?.SendEvent(new Event
{
EventType = "stopped",
Body = new StoppedEventBody
{
Reason = reason,
Description = description,
ThreadId = JobThreadId,
AllThreadsStopped = true
}
});
}
/// <summary>
/// Creates a DAP response with common fields pre-populated.
/// </summary>
private Response CreateResponse(Request request, bool success, string message = null, object body = null)
{
return new Response
{
Type = "response",
RequestSeq = request.Seq,
Command = request.Command,
Success = success,
Message = success ? null : message,
Body = body
};
}
#endregion
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,466 @@
using System;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using GitHub.Runner.Common;
using Newtonsoft.Json;
namespace GitHub.Runner.Worker.Dap
{
/// <summary>
/// Production TCP server for the Debug Adapter Protocol.
/// Handles Content-Length message framing, JSON serialization,
/// client reconnection, and graceful shutdown.
/// </summary>
public sealed class DapServer : RunnerService, IDapServer
{
private const string ContentLengthHeader = "Content-Length: ";
private TcpListener _listener;
private TcpClient _client;
private NetworkStream _stream;
private IDapDebugSession _session;
private CancellationTokenSource _cts;
private TaskCompletionSource<bool> _connectionTcs;
private readonly SemaphoreSlim _sendLock = new SemaphoreSlim(1, 1);
private int _nextSeq = 1;
private volatile bool _acceptConnections = true;
public override void Initialize(IHostContext hostContext)
{
base.Initialize(hostContext);
Trace.Info("DapServer initialized");
}
public void SetSession(IDapDebugSession session)
{
_session = session;
Trace.Info("Debug session set");
}
public async Task StartAsync(int port, CancellationToken cancellationToken)
{
Trace.Info($"Starting DAP server on port {port}");
_cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_connectionTcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
_listener = new TcpListener(IPAddress.Loopback, port);
_listener.Start();
Trace.Info($"DAP server listening on 127.0.0.1:{port}");
// Start the connection loop in the background
_ = ConnectionLoopAsync(_cts.Token);
await Task.CompletedTask;
}
/// <summary>
/// Accepts client connections in a loop, supporting reconnection.
/// When a client disconnects, the server waits for a new connection
/// without blocking step execution.
/// </summary>
private async Task ConnectionLoopAsync(CancellationToken cancellationToken)
{
while (_acceptConnections && !cancellationToken.IsCancellationRequested)
{
try
{
Trace.Info("Waiting for debug client connection...");
using (cancellationToken.Register(() =>
{
try { _listener?.Stop(); }
catch { /* listener already stopped */ }
}))
{
_client = await _listener.AcceptTcpClientAsync();
}
if (cancellationToken.IsCancellationRequested)
{
break;
}
_stream = _client.GetStream();
var remoteEndPoint = _client.Client.RemoteEndPoint;
Trace.Info($"Debug client connected from {remoteEndPoint}");
// Signal first connection (no-op on subsequent connections)
_connectionTcs.TrySetResult(true);
// Notify session of new client
_session?.HandleClientConnected();
// Process messages until client disconnects
await ProcessMessagesAsync(cancellationToken);
// Client disconnected — notify session and clean up
Trace.Info("Client disconnected, waiting for reconnection...");
_session?.HandleClientDisconnected();
CleanupConnection();
}
catch (ObjectDisposedException) when (cancellationToken.IsCancellationRequested)
{
break;
}
catch (SocketException) when (cancellationToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
Trace.Warning($"Connection error: {ex.Message}");
CleanupConnection();
if (!_acceptConnections || cancellationToken.IsCancellationRequested)
{
break;
}
// Brief delay before accepting next connection
try
{
await Task.Delay(100, cancellationToken);
}
catch (OperationCanceledException)
{
break;
}
}
}
_connectionTcs.TrySetCanceled();
Trace.Info("Connection loop ended");
}
/// <summary>
/// Cleans up the current client connection without stopping the listener.
/// </summary>
private void CleanupConnection()
{
try { _stream?.Close(); } catch { /* best effort */ }
try { _client?.Close(); } catch { /* best effort */ }
_stream = null;
_client = null;
}
public async Task WaitForConnectionAsync(CancellationToken cancellationToken)
{
Trace.Info("Waiting for debug client to connect...");
using (cancellationToken.Register(() => _connectionTcs.TrySetCanceled()))
{
await _connectionTcs.Task;
}
Trace.Info("Debug client connected");
}
public async Task StopAsync()
{
Trace.Info("Stopping DAP server");
_acceptConnections = false;
_cts?.Cancel();
CleanupConnection();
try { _listener?.Stop(); }
catch { /* best effort */ }
await Task.CompletedTask;
Trace.Info("DAP server stopped");
}
private async Task ProcessMessagesAsync(CancellationToken cancellationToken)
{
Trace.Info("Starting DAP message processing loop");
try
{
while (!cancellationToken.IsCancellationRequested && _client?.Connected == true)
{
var json = await ReadMessageAsync(cancellationToken);
if (json == null)
{
Trace.Info("Client disconnected (end of stream)");
break;
}
await ProcessSingleMessageAsync(json, cancellationToken);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
Trace.Info("Message processing cancelled");
}
catch (IOException ex)
{
Trace.Info($"Connection closed: {ex.Message}");
}
catch (Exception ex)
{
Trace.Error($"Error in message loop: {ex}");
}
Trace.Info("DAP message processing loop ended");
}
private async Task ProcessSingleMessageAsync(string json, CancellationToken cancellationToken)
{
Request request = null;
try
{
request = JsonConvert.DeserializeObject<Request>(json);
if (request == null || request.Type != "request")
{
Trace.Warning($"Received non-request message: {json}");
return;
}
Trace.Info($"Received request: seq={request.Seq}, command={request.Command}");
if (_session == null)
{
Trace.Error("No debug session configured");
SendErrorResponse(request, "No debug session configured");
return;
}
// Pass raw JSON to session — session handles deserialization, dispatch,
// and calls back to SendResponse when done.
await _session.HandleMessageAsync(json, cancellationToken);
}
catch (JsonException ex)
{
Trace.Error($"Failed to parse request: {ex.Message}");
}
catch (Exception ex)
{
Trace.Error($"Error processing request: {ex}");
if (request != null)
{
SendErrorResponse(request, ex.Message);
}
}
}
private void SendErrorResponse(Request request, string message)
{
var response = new Response
{
Type = "response",
RequestSeq = request.Seq,
Command = request.Command,
Success = false,
Message = message,
Body = new ErrorResponseBody
{
Error = new Message
{
Id = 1,
Format = message,
ShowUser = true
}
}
};
SendResponse(response);
}
/// <summary>
/// Reads a DAP message using Content-Length framing.
/// Format: Content-Length: N\r\n\r\n{json}
/// </summary>
private async Task<string> ReadMessageAsync(CancellationToken cancellationToken)
{
int contentLength = -1;
while (true)
{
var line = await ReadLineAsync(cancellationToken);
if (line == null)
{
return null;
}
if (line.Length == 0)
{
break;
}
if (line.StartsWith(ContentLengthHeader, StringComparison.OrdinalIgnoreCase))
{
var lengthStr = line.Substring(ContentLengthHeader.Length).Trim();
if (!int.TryParse(lengthStr, out contentLength))
{
throw new InvalidDataException($"Invalid Content-Length: {lengthStr}");
}
}
}
if (contentLength < 0)
{
throw new InvalidDataException("Missing Content-Length header");
}
var buffer = new byte[contentLength];
var totalRead = 0;
while (totalRead < contentLength)
{
var bytesRead = await _stream.ReadAsync(buffer, totalRead, contentLength - totalRead, cancellationToken);
if (bytesRead == 0)
{
throw new EndOfStreamException("Connection closed while reading message body");
}
totalRead += bytesRead;
}
var json = Encoding.UTF8.GetString(buffer);
Trace.Verbose($"Received: {json}");
return json;
}
/// <summary>
/// Reads a line terminated by \r\n from the network stream.
/// </summary>
private async Task<string> ReadLineAsync(CancellationToken cancellationToken)
{
var lineBuilder = new StringBuilder();
var buffer = new byte[1];
var previousWasCr = false;
while (true)
{
var bytesRead = await _stream.ReadAsync(buffer, 0, 1, cancellationToken);
if (bytesRead == 0)
{
return lineBuilder.Length > 0 ? lineBuilder.ToString() : null;
}
var c = (char)buffer[0];
if (c == '\n' && previousWasCr)
{
if (lineBuilder.Length > 0 && lineBuilder[lineBuilder.Length - 1] == '\r')
{
lineBuilder.Length--;
}
return lineBuilder.ToString();
}
previousWasCr = (c == '\r');
lineBuilder.Append(c);
}
}
/// <summary>
/// Serializes and writes a DAP message with Content-Length framing.
/// Must be called within the _sendLock.
/// </summary>
private void SendMessageInternal(ProtocolMessage message)
{
var json = JsonConvert.SerializeObject(message, new JsonSerializerSettings
{
NullValueHandling = NullValueHandling.Ignore
});
var bodyBytes = Encoding.UTF8.GetBytes(json);
var header = $"Content-Length: {bodyBytes.Length}\r\n\r\n";
var headerBytes = Encoding.ASCII.GetBytes(header);
_stream.Write(headerBytes, 0, headerBytes.Length);
_stream.Write(bodyBytes, 0, bodyBytes.Length);
_stream.Flush();
Trace.Verbose($"Sent: {json}");
}
public void SendMessage(ProtocolMessage message)
{
if (_stream == null)
{
return;
}
try
{
_sendLock.Wait();
try
{
message.Seq = _nextSeq++;
SendMessageInternal(message);
}
finally
{
_sendLock.Release();
}
}
catch (Exception ex)
{
Trace.Warning($"Failed to send message: {ex.Message}");
}
}
public void SendEvent(Event evt)
{
if (_stream == null)
{
Trace.Warning($"Cannot send event '{evt.EventType}': no client connected");
return;
}
try
{
_sendLock.Wait();
try
{
evt.Seq = _nextSeq++;
SendMessageInternal(evt);
}
finally
{
_sendLock.Release();
}
Trace.Info($"Sent event: {evt.EventType}");
}
catch (Exception ex)
{
Trace.Warning($"Failed to send event '{evt.EventType}': {ex.Message}");
}
}
public void SendResponse(Response response)
{
if (_stream == null)
{
Trace.Warning($"Cannot send response for '{response.Command}': no client connected");
return;
}
try
{
_sendLock.Wait();
try
{
response.Seq = _nextSeq++;
SendMessageInternal(response);
}
finally
{
_sendLock.Release();
}
Trace.Info($"Sent response: seq={response.Seq}, command={response.Command}, success={response.Success}");
}
catch (Exception ex)
{
Trace.Warning($"Failed to send response for '{response.Command}': {ex.Message}");
}
}
}
}

View File

@@ -0,0 +1,32 @@
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(DapDebugSession))]
public interface IDapDebugSession : IRunnerService
{
bool IsActive { get; }
DapSessionState State { get; }
void SetDapServer(IDapServer server);
Task WaitForHandshakeAsync(CancellationToken cancellationToken);
Task OnStepStartingAsync(IStep step, IExecutionContext jobContext, bool isFirstStep, CancellationToken cancellationToken);
void OnStepCompleted(IStep step);
void OnJobCompleted();
void CancelSession();
void HandleClientConnected();
void HandleClientDisconnected();
Task HandleMessageAsync(string messageJson, CancellationToken cancellationToken);
}
}

View File

@@ -0,0 +1,18 @@
using System.Threading;
using System.Threading.Tasks;
using GitHub.Runner.Common;
namespace GitHub.Runner.Worker.Dap
{
[ServiceLocator(Default = typeof(DapServer))]
public interface IDapServer : IRunnerService
{
void SetSession(IDapDebugSession session);
Task StartAsync(int port, CancellationToken cancellationToken);
Task WaitForConnectionAsync(CancellationToken cancellationToken);
Task StopAsync();
void SendMessage(ProtocolMessage message);
void SendEvent(Event evt);
void SendResponse(Response response);
}
}

View File

@@ -968,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;
}
@@ -1328,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()
@@ -1418,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
@@ -1433,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,
};
}

View File

@@ -27,6 +27,7 @@ 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; }

View File

@@ -312,7 +312,14 @@ namespace GitHub.Runner.Worker.Handlers
// Emit start marker after full context setup so display name expressions resolve correctly
if (emitCompositeMarkers)
{
step.TryUpdateDisplayName(out _);
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();
}

View File

@@ -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;
@@ -112,6 +113,9 @@ namespace GitHub.Runner.Worker
IExecutionContext jobContext = null;
CancellationTokenRegistration? runnerShutdownRegistration = null;
IDapServer dapServer = null;
IDapDebugSession debugSession = null;
CancellationTokenRegistration? dapCancellationRegistration = null;
try
{
// Create the job execution context.
@@ -121,6 +125,36 @@ namespace GitHub.Runner.Worker
jobContext.Start();
jobContext.Debug($"Starting: {message.JobDisplayName}");
if (jobContext.Global.EnableDebugger)
{
Trace.Info("Debugger enabled for this job run");
try
{
var port = 4711;
var portEnv = Environment.GetEnvironmentVariable("ACTIONS_DAP_PORT");
if (!string.IsNullOrEmpty(portEnv) && int.TryParse(portEnv, out var customPort))
{
port = customPort;
}
dapServer = HostContext.GetService<IDapServer>();
debugSession = HostContext.GetService<IDapDebugSession>();
dapServer.SetSession(debugSession);
debugSession.SetDapServer(dapServer);
await dapServer.StartAsync(port, jobRequestCancellationToken);
Trace.Info($"DAP server started on port {port}, listening for debugger client");
}
catch (Exception ex)
{
Trace.Warning($"Failed to start DAP server: {ex.Message}. Job will continue without debugging.");
dapServer = null;
debugSession = null;
}
}
runnerShutdownRegistration = HostContext.RunnerShutdownToken.Register(() =>
{
// log an issue, then runner get shutdown by Ctrl-C or Ctrl-Break.
@@ -219,6 +253,39 @@ 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 (dapServer != null && debugSession != null)
{
try
{
Trace.Info("Waiting for debugger client connection...");
await dapServer.WaitForConnectionAsync(jobRequestCancellationToken);
Trace.Info("Debugger client connected.");
await debugSession.WaitForHandshakeAsync(jobRequestCancellationToken);
Trace.Info("DAP handshake complete.");
dapCancellationRegistration = jobRequestCancellationToken.Register(() =>
{
Trace.Info("Job cancellation requested, cancelling debug session.");
debugSession.CancelSession();
});
}
catch (OperationCanceledException) when (jobRequestCancellationToken.IsCancellationRequested)
{
Trace.Info("Job was cancelled before debugger client connected. Continuing without debugger.");
dapServer = null;
debugSession = null;
}
catch (Exception ex)
{
Trace.Warning($"Failed to complete DAP handshake: {ex.Message}. Job will continue without debugging.");
dapServer = null;
debugSession = null;
}
}
// Run all job steps
Trace.Info("Run all job steps.");
var stepsRunner = HostContext.GetService<IStepsRunner>();
@@ -259,6 +326,25 @@ namespace GitHub.Runner.Worker
runnerShutdownRegistration = null;
}
if (dapCancellationRegistration.HasValue)
{
dapCancellationRegistration.Value.Dispose();
dapCancellationRegistration = null;
}
if (dapServer != null)
{
try
{
Trace.Info("Stopping DAP server");
await dapServer.StopAsync();
}
catch (Exception ex)
{
Trace.Warning($"Error stopping DAP server: {ex.Message}");
}
}
await ShutdownQueue(throwOnFailure: false);
}
}

View File

@@ -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
};
@@ -401,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;

View File

@@ -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,16 @@ namespace GitHub.Runner.Worker
jobContext.JobContext.Status = (jobContext.Result ?? TaskResult.Succeeded).ToActionResult();
var scopeInputs = new Dictionary<string, PipelineContextData>(StringComparer.OrdinalIgnoreCase);
bool checkPostJobActions = false;
IDapDebugSession debugSession = null;
try
{
debugSession = HostContext.GetService<IDapDebugSession>();
}
catch
{
// Debug session not available — continue without debugging
}
bool isFirstStep = true;
while (jobContext.JobSteps.Count > 0 || !checkPostJobActions)
{
if (jobContext.JobSteps.Count == 0 && !checkPostJobActions)
@@ -226,9 +237,35 @@ namespace GitHub.Runner.Worker
}
else
{
// Pause for DAP debugger before step execution
if (debugSession?.IsActive == true)
{
try
{
await debugSession.OnStepStartingAsync(step, jobContext, isFirstStep, jobContext.CancellationToken);
isFirstStep = false;
}
catch (Exception ex)
{
Trace.Warning($"DAP OnStepStarting error: {ex.Message}");
}
}
// Run the step
await RunStepAsync(step, jobContext.CancellationToken);
CompleteStep(step);
if (debugSession?.IsActive == true)
{
try
{
debugSession.OnStepCompleted(step);
}
catch (Exception ex)
{
Trace.Warning($"DAP OnStepCompleted error: {ex.Message}");
}
}
}
}
finally
@@ -255,6 +292,18 @@ namespace GitHub.Runner.Worker
Trace.Info($"Current state: job state = '{jobContext.Result}'");
}
if (debugSession?.IsActive == true)
{
try
{
debugSession.OnJobCompleted();
}
catch (Exception ex)
{
Trace.Warning($"DAP OnJobCompleted error: {ex.Message}");
}
}
}
private async Task RunStepAsync(IStep step, CancellationToken jobCancellationToken)

View File

@@ -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>

View File

@@ -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>

View File

@@ -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";

View File

@@ -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));
}

View File

@@ -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))
{

View File

@@ -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"
]
},

View File

@@ -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";

View File

@@ -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,22 +1192,30 @@ namespace GitHub.Actions.WorkflowParser.Conversion
}
}
// Trim "docker://"
var hadDockerPrefix = false;
if (!String.IsNullOrEmpty(result.Image) && result.Image.StartsWith(WorkflowTemplateConstants.DockerUriPrefix, StringComparison.Ordinal))
{
hadDockerPrefix = true;
result.Image = result.Image.Substring(WorkflowTemplateConstants.DockerUriPrefix.Length);
}
if (String.IsNullOrEmpty(result.Image))
{
// Only error during early validation (parse time)
// At runtime (expression evaluation), empty image = no container
if (isEarlyValidation)
// 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;
}
if (result.Image.StartsWith(WorkflowTemplateConstants.DockerUriPrefix, StringComparison.Ordinal))
{
result.Image = result.Image.Substring(WorkflowTemplateConstants.DockerUriPrefix.Length);
}
return result;
}
@@ -1188,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));
}

View File

@@ -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>

View File

@@ -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
};
}

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View 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('\'', '"');
}
}

View File

@@ -0,0 +1,611 @@
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using GitHub.DistributedTask.ObjectTemplating.Tokens;
using GitHub.DistributedTask.Pipelines.ContextData;
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Worker;
using GitHub.Runner.Worker.Dap;
using Moq;
using Newtonsoft.Json;
using Xunit;
namespace GitHub.Runner.Common.Tests.Worker
{
public sealed class DapDebugSessionL0
{
private DapDebugSession _session;
private Mock<IDapServer> _mockServer;
private List<Event> _sentEvents;
private List<Response> _sentResponses;
private TestHostContext CreateTestContext([CallerMemberName] string testName = "")
{
var hc = new TestHostContext(this, testName);
_session = new DapDebugSession();
_session.Initialize(hc);
_sentEvents = new List<Event>();
_sentResponses = new List<Response>();
_mockServer = new Mock<IDapServer>();
_mockServer.Setup(x => x.SendEvent(It.IsAny<Event>()))
.Callback<Event>(e => _sentEvents.Add(e));
_mockServer.Setup(x => x.SendResponse(It.IsAny<Response>()))
.Callback<Response>(r => _sentResponses.Add(r));
_session.SetDapServer(_mockServer.Object);
return hc;
}
private Mock<IStep> CreateMockStep(string displayName, TaskResult? result = null)
{
var mockEc = new Mock<IExecutionContext>();
mockEc.SetupAllProperties();
mockEc.Object.Result = result;
var mockStep = new Mock<IStep>();
mockStep.Setup(x => x.DisplayName).Returns(displayName);
mockStep.Setup(x => x.ExecutionContext).Returns(mockEc.Object);
return mockStep;
}
private Mock<IExecutionContext> CreateMockJobContext()
{
var mockJobContext = new Mock<IExecutionContext>();
mockJobContext.Setup(x => x.GetGitHubContext("job")).Returns("test-job");
return mockJobContext;
}
private async Task InitializeSessionAsync()
{
var initJson = JsonConvert.SerializeObject(new Request
{
Seq = 1,
Type = "request",
Command = "initialize"
});
await _session.HandleMessageAsync(initJson, CancellationToken.None);
var attachJson = JsonConvert.SerializeObject(new Request
{
Seq = 2,
Type = "request",
Command = "attach"
});
await _session.HandleMessageAsync(attachJson, CancellationToken.None);
var configJson = JsonConvert.SerializeObject(new Request
{
Seq = 3,
Type = "request",
Command = "configurationDone"
});
await _session.HandleMessageAsync(configJson, CancellationToken.None);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void InitialStateIsWaitingForConnection()
{
using (CreateTestContext())
{
Assert.Equal(DapSessionState.WaitingForConnection, _session.State);
Assert.False(_session.IsActive);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task InitializeHandlerSetsInitializingState()
{
using (CreateTestContext())
{
var json = JsonConvert.SerializeObject(new Request
{
Seq = 1,
Type = "request",
Command = "initialize"
});
await _session.HandleMessageAsync(json, CancellationToken.None);
Assert.Equal(DapSessionState.Initializing, _session.State);
Assert.Single(_sentResponses);
Assert.True(_sentResponses[0].Success);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task ConfigurationDoneSetsReadyState()
{
using (CreateTestContext())
{
await InitializeSessionAsync();
Assert.Equal(DapSessionState.Ready, _session.State);
Assert.True(_session.IsActive);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task OnStepStartingPausesAndSendsStoppedEvent()
{
using (CreateTestContext())
{
await InitializeSessionAsync();
_session.HandleClientConnected();
// Wait for the async initialized event to arrive, then clear
await Task.Delay(200);
_sentEvents.Clear();
var step = CreateMockStep("Checkout code");
var jobContext = CreateMockJobContext();
var cts = new CancellationTokenSource();
var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, cts.Token);
await Task.Delay(100);
Assert.False(stepTask.IsCompleted);
Assert.Equal(DapSessionState.Paused, _session.State);
var stoppedEvents = _sentEvents.FindAll(e => e.EventType == "stopped");
Assert.Single(stoppedEvents);
var continueJson = JsonConvert.SerializeObject(new Request
{
Seq = 10,
Type = "request",
Command = "continue"
});
await _session.HandleMessageAsync(continueJson, CancellationToken.None);
await Task.WhenAny(stepTask, Task.Delay(5000));
Assert.True(stepTask.IsCompleted);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task NextCommandPausesOnFollowingStep()
{
using (CreateTestContext())
{
await InitializeSessionAsync();
_session.HandleClientConnected();
_sentEvents.Clear();
var step1 = CreateMockStep("Step 1");
var jobContext = CreateMockJobContext();
var step1Task = _session.OnStepStartingAsync(step1.Object, jobContext.Object, isFirstStep: true, CancellationToken.None);
var nextJson = JsonConvert.SerializeObject(new Request
{
Seq = 10,
Type = "request",
Command = "next"
});
await _session.HandleMessageAsync(nextJson, CancellationToken.None);
await Task.WhenAny(step1Task, Task.Delay(5000));
Assert.True(step1Task.IsCompleted);
_session.OnStepCompleted(step1.Object);
_sentEvents.Clear();
var step2 = CreateMockStep("Step 2");
var step2Task = _session.OnStepStartingAsync(step2.Object, jobContext.Object, isFirstStep: false, CancellationToken.None);
await Task.Delay(100);
Assert.False(step2Task.IsCompleted);
Assert.Equal(DapSessionState.Paused, _session.State);
var stoppedEvents = _sentEvents.FindAll(e => e.EventType == "stopped");
Assert.Single(stoppedEvents);
var continueJson = JsonConvert.SerializeObject(new Request
{
Seq = 11,
Type = "request",
Command = "continue"
});
await _session.HandleMessageAsync(continueJson, CancellationToken.None);
await Task.WhenAny(step2Task, Task.Delay(5000));
Assert.True(step2Task.IsCompleted);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task ContinueCommandSkipsNextPause()
{
using (CreateTestContext())
{
await InitializeSessionAsync();
_session.HandleClientConnected();
_sentEvents.Clear();
var step1 = CreateMockStep("Step 1");
var jobContext = CreateMockJobContext();
var step1Task = _session.OnStepStartingAsync(step1.Object, jobContext.Object, isFirstStep: true, CancellationToken.None);
var continueJson = JsonConvert.SerializeObject(new Request
{
Seq = 10,
Type = "request",
Command = "continue"
});
await _session.HandleMessageAsync(continueJson, CancellationToken.None);
await Task.WhenAny(step1Task, Task.Delay(5000));
Assert.True(step1Task.IsCompleted);
_session.OnStepCompleted(step1.Object);
_sentEvents.Clear();
var step2 = CreateMockStep("Step 2");
var step2Task = _session.OnStepStartingAsync(step2.Object, jobContext.Object, isFirstStep: false, CancellationToken.None);
await Task.WhenAny(step2Task, Task.Delay(5000));
Assert.True(step2Task.IsCompleted);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task CancellationUnblocksPausedStep()
{
using (CreateTestContext())
{
await InitializeSessionAsync();
_session.HandleClientConnected();
_sentEvents.Clear();
var step = CreateMockStep("Step 1");
var jobContext = CreateMockJobContext();
var cts = new CancellationTokenSource();
var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, cts.Token);
await Task.Delay(100);
Assert.False(stepTask.IsCompleted);
Assert.Equal(DapSessionState.Paused, _session.State);
cts.Cancel();
await Task.WhenAny(stepTask, Task.Delay(5000));
Assert.True(stepTask.IsCompleted);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task CancelSessionSendsTerminatedAndExitedEvents()
{
using (CreateTestContext())
{
await InitializeSessionAsync();
_sentEvents.Clear();
_session.CancelSession();
Assert.Equal(DapSessionState.Terminated, _session.State);
Assert.False(_session.IsActive);
var terminatedEvents = _sentEvents.FindAll(e => e.EventType == "terminated");
var exitedEvents = _sentEvents.FindAll(e => e.EventType == "exited");
Assert.Single(terminatedEvents);
Assert.Single(exitedEvents);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task CancelSessionReleasesBlockedStep()
{
using (CreateTestContext())
{
await InitializeSessionAsync();
_session.HandleClientConnected();
_sentEvents.Clear();
var step = CreateMockStep("Blocked Step");
var jobContext = CreateMockJobContext();
var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None);
await Task.Delay(100);
Assert.False(stepTask.IsCompleted);
_session.CancelSession();
await Task.WhenAny(stepTask, Task.Delay(5000));
Assert.True(stepTask.IsCompleted);
Assert.Equal(DapSessionState.Terminated, _session.State);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task ReconnectionResendStoppedEvent()
{
using (CreateTestContext())
{
await InitializeSessionAsync();
_session.HandleClientConnected();
// Wait for the async initialized event to arrive, then clear
await Task.Delay(200);
_sentEvents.Clear();
var step = CreateMockStep("Step 1");
var jobContext = CreateMockJobContext();
var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None);
await Task.Delay(100);
Assert.Equal(DapSessionState.Paused, _session.State);
var stoppedEvents = _sentEvents.FindAll(e => e.EventType == "stopped");
Assert.Single(stoppedEvents);
_session.HandleClientDisconnected();
Assert.Equal(DapSessionState.Paused, _session.State);
_sentEvents.Clear();
_session.HandleClientConnected();
Assert.Single(_sentEvents);
Assert.Equal("stopped", _sentEvents[0].EventType);
var continueJson = JsonConvert.SerializeObject(new Request
{
Seq = 20,
Type = "request",
Command = "continue"
});
await _session.HandleMessageAsync(continueJson, CancellationToken.None);
await Task.WhenAny(stepTask, Task.Delay(5000));
Assert.True(stepTask.IsCompleted);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task DisconnectCommandTerminatesSession()
{
using (CreateTestContext())
{
await InitializeSessionAsync();
var disconnectJson = JsonConvert.SerializeObject(new Request
{
Seq = 10,
Type = "request",
Command = "disconnect"
});
await _session.HandleMessageAsync(disconnectJson, CancellationToken.None);
Assert.Equal(DapSessionState.Terminated, _session.State);
Assert.False(_session.IsActive);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task OnStepCompletedTracksCompletedSteps()
{
using (CreateTestContext())
{
await InitializeSessionAsync();
_session.HandleClientConnected();
var step1 = CreateMockStep("Step 1");
step1.Object.ExecutionContext.Result = TaskResult.Succeeded;
var jobContext = CreateMockJobContext();
var step1Task = _session.OnStepStartingAsync(step1.Object, jobContext.Object, isFirstStep: true, CancellationToken.None);
var continueJson = JsonConvert.SerializeObject(new Request
{
Seq = 10,
Type = "request",
Command = "continue"
});
await _session.HandleMessageAsync(continueJson, CancellationToken.None);
await Task.WhenAny(step1Task, Task.Delay(5000));
_session.OnStepCompleted(step1.Object);
var stackTraceJson = JsonConvert.SerializeObject(new Request
{
Seq = 11,
Type = "request",
Command = "stackTrace"
});
_sentResponses.Clear();
await _session.HandleMessageAsync(stackTraceJson, CancellationToken.None);
Assert.Single(_sentResponses);
Assert.True(_sentResponses[0].Success);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task OnJobCompletedSendsTerminatedAndExitedEvents()
{
using (CreateTestContext())
{
await InitializeSessionAsync();
_sentEvents.Clear();
_session.OnJobCompleted();
Assert.Equal(DapSessionState.Terminated, _session.State);
var terminatedEvents = _sentEvents.FindAll(e => e.EventType == "terminated");
var exitedEvents = _sentEvents.FindAll(e => e.EventType == "exited");
Assert.Single(terminatedEvents);
Assert.Single(exitedEvents);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task OnStepStartingNoOpWhenNotActive()
{
using (CreateTestContext())
{
var step = CreateMockStep("Step 1");
var jobContext = CreateMockJobContext();
var task = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None);
await Task.WhenAny(task, Task.Delay(5000));
Assert.True(task.IsCompleted);
_mockServer.Verify(x => x.SendEvent(It.IsAny<Event>()), Times.Never);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task ThreadsCommandReturnsJobThread()
{
using (CreateTestContext())
{
await InitializeSessionAsync();
var threadsJson = JsonConvert.SerializeObject(new Request
{
Seq = 10,
Type = "request",
Command = "threads"
});
_sentResponses.Clear();
await _session.HandleMessageAsync(threadsJson, CancellationToken.None);
Assert.Single(_sentResponses);
Assert.True(_sentResponses[0].Success);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task UnsupportedCommandReturnsErrorResponse()
{
using (CreateTestContext())
{
await InitializeSessionAsync();
var json = JsonConvert.SerializeObject(new Request
{
Seq = 99,
Type = "request",
Command = "stepIn"
});
_sentResponses.Clear();
await _session.HandleMessageAsync(json, CancellationToken.None);
Assert.Single(_sentResponses);
Assert.False(_sentResponses[0].Success);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task FullFlowInitAttachConfigStepContinueComplete()
{
using (CreateTestContext())
{
await InitializeSessionAsync();
_session.HandleClientConnected();
_sentEvents.Clear();
_sentResponses.Clear();
Assert.Equal(DapSessionState.Ready, _session.State);
var step = CreateMockStep("Run tests");
var jobContext = CreateMockJobContext();
var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None);
await Task.Delay(100);
Assert.Equal(DapSessionState.Paused, _session.State);
var stoppedEvents = _sentEvents.FindAll(e => e.EventType == "stopped");
Assert.Single(stoppedEvents);
var continueJson = JsonConvert.SerializeObject(new Request
{
Seq = 10,
Type = "request",
Command = "continue"
});
await _session.HandleMessageAsync(continueJson, CancellationToken.None);
await Task.WhenAny(stepTask, Task.Delay(5000));
Assert.True(stepTask.IsCompleted);
var continuedEvents = _sentEvents.FindAll(e => e.EventType == "continued");
Assert.Single(continuedEvents);
step.Object.ExecutionContext.Result = TaskResult.Succeeded;
_session.OnStepCompleted(step.Object);
_sentEvents.Clear();
_session.OnJobCompleted();
Assert.Equal(DapSessionState.Terminated, _session.State);
var terminatedEvents = _sentEvents.FindAll(e => e.EventType == "terminated");
var exitedEvents = _sentEvents.FindAll(e => e.EventType == "exited");
Assert.Single(terminatedEvents);
Assert.Single(exitedEvents);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task DoubleCancelSessionIsIdempotent()
{
using (CreateTestContext())
{
await InitializeSessionAsync();
_sentEvents.Clear();
_session.CancelSession();
_session.CancelSession();
Assert.Equal(DapSessionState.Terminated, _session.State);
var terminatedEvents = _sentEvents.FindAll(e => e.EventType == "terminated");
Assert.Single(terminatedEvents);
}
}
}
}

View 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);
}
}
}

View File

@@ -0,0 +1,170 @@
using System;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using GitHub.Runner.Worker.Dap;
using Moq;
using Xunit;
namespace GitHub.Runner.Common.Tests.Worker
{
public sealed class DapServerL0
{
private DapServer _server;
private TestHostContext CreateTestContext([CallerMemberName] string testName = "")
{
var hc = new TestHostContext(this, testName);
_server = new DapServer();
_server.Initialize(hc);
return hc;
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void InitializeSucceeds()
{
using (CreateTestContext())
{
Assert.NotNull(_server);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void SetSessionAcceptsMock()
{
using (CreateTestContext())
{
var mockSession = new Mock<IDapDebugSession>();
_server.SetSession(mockSession.Object);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void SendEventNoClientDoesNotThrow()
{
using (CreateTestContext())
{
var evt = new Event
{
EventType = "stopped",
Body = new StoppedEventBody
{
Reason = "entry",
ThreadId = 1,
AllThreadsStopped = true
}
};
_server.SendEvent(evt);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void SendResponseNoClientDoesNotThrow()
{
using (CreateTestContext())
{
var response = new Response
{
Type = "response",
RequestSeq = 1,
Command = "initialize",
Success = true
};
_server.SendResponse(response);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void SendMessageNoClientDoesNotThrow()
{
using (CreateTestContext())
{
var msg = new ProtocolMessage
{
Type = "response",
Seq = 1
};
_server.SendMessage(msg);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task StopWithoutStartDoesNotThrow()
{
using (CreateTestContext())
{
await _server.StopAsync();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task StartAndStopOnAvailablePort()
{
using (CreateTestContext())
{
var cts = new CancellationTokenSource();
await _server.StartAsync(0, cts.Token);
await _server.StopAsync();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task WaitForConnectionCancelledByCancellationToken()
{
using (CreateTestContext())
{
var cts = new CancellationTokenSource();
await _server.StartAsync(0, cts.Token);
var waitTask = _server.WaitForConnectionAsync(cts.Token);
cts.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
{
await waitTask;
});
await _server.StopAsync();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task StartAndStopMultipleTimesDoesNotThrow()
{
using (CreateTestContext())
{
var cts1 = new CancellationTokenSource();
await _server.StartAsync(0, cts1.Token);
await _server.StopAsync();
_server = new DapServer();
_server.Initialize(CreateTestContext());
var cts2 = new CancellationTokenSource();
await _server.StartAsync(0, cts2.Token);
await _server.StopAsync();
}
}
}
}

View File

@@ -1,14 +1,18 @@
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
{
@@ -250,6 +254,66 @@ namespace GitHub.Runner.Common.Tests.Worker.Handlers
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);

View File

@@ -36,7 +36,7 @@ namespace GitHub.Runner.Common.Tests.Worker
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
var token = new StringToken(null, null, null, "test-value");
var contextData = new DictionaryContextData();
@@ -63,7 +63,7 @@ namespace GitHub.Runner.Common.Tests.Worker
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
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.
@@ -98,7 +98,7 @@ namespace GitHub.Runner.Common.Tests.Worker
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
// Different results without cancellation — mismatch SHOULD be recorded.
var result = wrapper.EvaluateAndCompare<string, string>(
@@ -130,7 +130,7 @@ namespace GitHub.Runner.Common.Tests.Worker
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
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>();
@@ -156,7 +156,7 @@ namespace GitHub.Runner.Common.Tests.Worker
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
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();
@@ -184,7 +184,7 @@ namespace GitHub.Runner.Common.Tests.Worker
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
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>();
@@ -211,7 +211,7 @@ namespace GitHub.Runner.Common.Tests.Worker
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
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();
@@ -239,7 +239,7 @@ namespace GitHub.Runner.Common.Tests.Worker
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
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>();
@@ -265,7 +265,7 @@ namespace GitHub.Runner.Common.Tests.Worker
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
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>();
@@ -281,6 +281,140 @@ namespace GitHub.Runner.Common.Tests.Worker
}
}
[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")]
@@ -291,7 +425,7 @@ namespace GitHub.Runner.Common.Tests.Worker
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
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();
@@ -319,7 +453,7 @@ namespace GitHub.Runner.Common.Tests.Worker
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
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>();
@@ -348,7 +482,7 @@ namespace GitHub.Runner.Common.Tests.Worker
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
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();
@@ -376,7 +510,7 @@ namespace GitHub.Runner.Common.Tests.Worker
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
var contextData = new DictionaryContextData();
var functions = new List<LegacyExpressions.IFunctionInfo>();
@@ -391,6 +525,213 @@ namespace GitHub.Runner.Common.Tests.Worker
}
}
[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")]
@@ -401,7 +742,7 @@ namespace GitHub.Runner.Common.Tests.Worker
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object, allowServiceContainerCommand: false);
var contextData = new DictionaryContextData();
var functions = new List<LegacyExpressions.IFunctionInfo>();
@@ -430,7 +771,7 @@ namespace GitHub.Runner.Common.Tests.Worker
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
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.");
@@ -461,7 +802,7 @@ namespace GitHub.Runner.Common.Tests.Worker
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
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");
@@ -492,7 +833,7 @@ namespace GitHub.Runner.Common.Tests.Worker
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
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");

View File

@@ -1 +1 @@
2.331.0
2.332.0