Compare commits

..

72 Commits

Author SHA1 Message Date
Francesco Renzi
63ecc21590 Hook job lifecycle into DAP debugger behind containment gate
Wires three runner-internal call sites into the DAP debugger:
  - JobRunner: notifies the debugger once Pre/Main/Post step lists
    are populated, so it can seed the execution view and predict
    Post placeholders.
  - ExecutionContext.RegisterPostJobStep: notifies the debugger when
    a post step is registered, so it can claim the predicted
    placeholder or append a new entry.
  - StepsRunner: notifies the debugger when a Main step is skipped
    by its if: condition, so predicted Post placeholders for that
    step can be marked as skipped.

Each call site gates on jobContext.Global.Debugger?.Enabled == true
before calling HostContext.GetService<IDapDebugger>(). Without the
gate, GetService<T>() auto-instantiates the default singleton for
every non-debug job, breaking the 'no debugger, no risk' containment
property.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-12 13:11:17 -07:00
Francesco Renzi
0387c108c0 Wire execution-view source and stack handlers in DapDebugger
Adds DAP source, stackTrace, and loadedSources handlers backed by a
synthesized JobExecutionView. The view is served via sourceReference,
with a synthetic .yml path so clients pick YAML syntax highlighting.
User-visible text (path, name, content) is run through the secret
masker.

Adds three lifecycle methods to IDapDebugger:
  - OnJobStepsInitializedAsync seeds the view and synthesizes Post
    placeholders predictively from action Pre/Post metadata, so VS
    Code's sourceReference cache hits a stable view.
  - OnPostStepRegistered claims a predicted placeholder when the real
    Post IActionRunner is registered, or appends a new entry.
  - OnStepCompleted marks predicted Post placeholders as skipped when
    their Main step is skipped.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-12 13:11:08 -07:00
Francesco Renzi
3275feadce Add StepEntryTranslator for IStep to view entry mapping
Translates IStep instances (ActionRunner, JobExtensionRunner, etc.)
into JobExecutionViewEntry. Filters auto-generated step IDs so only
explicit IDs surface in the view, and emits step parameters via the
TemplateTokenYamlAdapter to preserve pre-evaluation expressions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-12 13:10:34 -07:00
Francesco Renzi
387a1befd7 Add TemplateTokenYamlAdapter for pre-evaluation YAML rendering
IObjectWriter that bridges the runner's TemplateWriter to YamlDotNet
so TemplateToken trees can be serialized with ${{ }} expressions
preserved. Lets the execution view show step parameters (with:, env:,
if:, etc.) exactly as authored in the workflow file, before any
expression evaluation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-12 13:10:27 -07:00
Francesco Renzi
e179495f1f Add JobExecutionView state container
Thread-safe append-only container of JobExecutionViewEntry. Supports
predictive Post-step placeholders via TryClaim(matchKey, step) and
TryMarkSkipped(matchKey) so the rendered view can reflect Main steps
that are skipped by their if: condition. Provides line lookup by
IStep for DAP stack-trace frame construction.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-12 13:10:22 -07:00
Francesco Renzi
941e43aea5 Add JobExecutionViewRenderer for DAP source
Pure renderer that emits a phase-keyed YAML view (setup/pre/main/
post/cleanup) of a job's runner execution plan. Tracks per-entry
start lines so the debugger can map IStep instances back to source
locations. Includes a skipped-step annotation for predicted Post
placeholders whose Main step never executes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-12 13:10:15 -07:00
Francesco Renzi
89b6b32e3e feedback 2026-05-06 07:48:47 -07:00
Francesco Renzi
da5bc7d859 feedback 2026-05-06 04:06:38 -07:00
Francesco Renzi
c4c1b25b56 review 2026-05-05 09:48:49 -07:00
Francesco Renzi
2d81971188 Move dap setup to setup job step 2026-05-05 08:51:19 -07:00
Yashwanth Anantharaju
5ed0c52e21 fix: expand commit hash regex to support SHA-256 (64-char) hashes (#4347)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-04 14:22:18 -04:00
Paulo Santos
16c8a91b21 Update setup job starting logs (#4383) 2026-05-04 07:49:30 -04:00
Tingluo Huang
4550db3c89 Not retry and report action download 403. (#4391) 2026-04-29 10:44:26 -04:00
Jeff Martin
b06c585762 feat: propagate actions dependencies (#4372) 2026-04-23 20:32:07 +00:00
dependabot[bot]
c6f978fd5f Bump @actions/glob from 0.6.1 to 0.7.0 in /src/Misc/expressionFunc/hashFiles (#4367)
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-04-23 09:55:48 +01:00
dependabot[bot]
d1690af497 Bump System.ServiceProcess.ServiceController from 10.0.6 to 10.0.7 (#4370)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-22 16:09:28 +01:00
Salman Chishti
c87d955bad Prepping runner release 2.334.0 (#4365) 2026-04-21 19:40:21 +01:00
dependabot[bot]
7407189cf5 Bump Microsoft.DevTunnels.Connections from 1.3.16 to 1.3.39 (#4339)
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-04-21 17:49:08 +01:00
dependabot[bot]
a84fb3602d Bump typescript from 6.0.2 to 6.0.3 in /src/Misc/expressionFunc/hashFiles (#4353)
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-04-21 17:39:38 +01:00
dependabot[bot]
84598e03fa Bump System.ServiceProcess.ServiceController from 10.0.3 to 10.0.6 (#4358)
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-04-21 17:38:26 +01:00
dependabot[bot]
8fa7457bbf Bump @typescript-eslint/eslint-plugin from 8.58.1 to 8.59.0 in /src/Misc/expressionFunc/hashFiles (#4359)
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-04-21 16:36:21 +00:00
Salman Chishti
00af8379a2 Add vulnerability-alerts permission (#4350) 2026-04-21 17:31:10 +01:00
dependabot[bot]
6692e6a590 Bump System.Formats.Asn1 and System.Security.Cryptography.Pkcs (#4362)
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-04-21 16:06:40 +00:00
dependabot[bot]
cacb25d2ed Bump @typescript-eslint/parser from 8.58.1 to 8.59.0 in /src/Misc/expressionFunc/hashFiles (#4360)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-21 15:15:02 +01:00
github-actions[bot]
c6ca9f6edb Update dotnet sdk to latest version @8.0.420 (#4356)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-20 17:06:35 +00:00
Copilot
fad1253513 Bump Docker version to 29.4.0 (#4352)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: luketomlinson <19689611+luketomlinson@users.noreply.github.com>
2026-04-20 10:45:12 -04:00
github-actions[bot]
45debbd528 chore: update Node versions (#4355)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-20 12:44:20 +00:00
Francesco Renzi
43e5211996 Add WS bridge over DAP TCP server (#4328)
Co-authored-by: Tingluo Huang <tingluohuang@github.com>
2026-04-17 09:18:01 -04:00
Salman Chishti
4a587ada27 feat: add job.workflow_* typed accessors to JobContext (#4335) 2026-04-10 19:39:33 +01:00
Copilot
182a433782 Bump System.Formats.Asn1, Cryptography.Pkcs, ProtectedData, ServiceController, CodePages, Threading.Channels, @actions/glob, @typescript-eslint/parser, lint-staged, picomatch (#4333)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Salman Chishti <salmanmkc@GitHub.com>
2026-04-10 12:40:28 +01:00
Salman Chishti
8d35e710da fix: only show changed versions in node upgrade PR description (#4332) 2026-04-10 11:34:08 +01:00
dependabot[bot]
2bcc65e864 Bump typescript from 5.9.3 to 6.0.2 in /src/Misc/expressionFunc/hashFiles (#4329)
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-04-10 09:01:34 +01:00
dependabot[bot]
1ba5fdfd88 Bump actions/github-script from 8 to 9 (#4331)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 08:54:56 +01:00
dependabot[bot]
580116c18b Bump @typescript-eslint/eslint-plugin from 8.57.2 to 8.58.1 in /src/Misc/expressionFunc/hashFiles (#4327)
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-04-08 07:44:38 +00:00
github-actions[bot]
c9a1751d87 Update Docker to v29.3.1 and Buildx to v0.33.0 (#4324)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Salman Chishti <salmanmkc@GitHub.com>
2026-04-08 08:40:32 +01:00
Francesco Renzi
7711dc53e2 Add devtunnel connection for debugger jobs (#4317) 2026-04-07 12:51:33 +00:00
dependabot[bot]
df507886cb Bump brace-expansion in /src/Misc/expressionFunc/hashFiles (#4318)
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-04-01 13:19:42 +01:00
Tingluo Huang
5c6dd47e76 Add support for Bearer token in action archive downloads (#4321) 2026-03-31 17:51:01 -04:00
Stefan Penner
7ff994b932 Batch and deduplicate action resolution across composite depths (#4296)
Co-authored-by: Stefan Penner <spenner@linkedin.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 13:28:43 -04:00
github-actions[bot]
b9275b59cf chore: update Node versions (#4319)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-30 12:58:17 +00:00
eric sciple
f0c228635e Remove AllowCaseFunction feature flag (#4316) 2026-03-27 11:45:42 -05:00
dependabot[bot]
9728019b24 Bump @typescript-eslint/eslint-plugin from 8.57.1 to 8.57.2 in /src/Misc/expressionFunc/hashFiles (#4310)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-24 10:40:31 +00:00
Francesco Renzi
e17e7aabbf Add DAP server (#4298)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Tingluo Huang <tingluohuang@github.com>
2026-03-23 14:02:15 +00:00
dependabot[bot]
4259ffb6dc Bump flatted from 3.2.7 to 3.4.2 in /src/Misc/expressionFunc/hashFiles (#4307)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-20 01:02:30 +00:00
Salman Chishti
4e8e1ff020 prep new runner release 2.333.0 (#4306) 2026-03-18 16:51:00 +00:00
dependabot[bot]
b6cca8fb99 Bump @typescript-eslint/eslint-plugin from 8.54.0 to 8.57.1 in /src/Misc/expressionFunc/hashFiles (#4304)
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-03-18 16:26:33 +00:00
Salman Chishti
18d0789c74 Node 24 enforcement + Linux ARM32 deprecation support (#4303) 2026-03-17 18:58:34 +00:00
github-actions[bot]
c985a9ff03 Update dotnet sdk to latest version @8.0.419 (#4301)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Tingluo Huang <tingluohuang@github.com>
2026-03-16 13:48:09 +00:00
Tingluo Huang
45ed15ddf3 Report infra_error for action download failures. (#4294) 2026-03-16 13:31:57 +00:00
Nikola Jokic
c5dcf59d26 Exit with specified exit code when runner is outdated (#4285)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-13 14:16:31 -04:00
dependabot[bot]
c7f6c49ba0 Bump @typescript-eslint/eslint-plugin from 8.47.0 to 8.54.0 in /src/Misc/expressionFunc/hashFiles (#4230)
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-03-11 10:21:47 +00:00
eric sciple
40dd583def Fix cancellation token race during parser comparison (#4280) 2026-03-09 16:10:08 +00:00
github-actions[bot]
68f2e9adb7 chore: update Node versions (#4287)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-09 13:02:32 +00:00
github-actions[bot]
2b98d42113 Update Docker to v29.3.0 and Buildx to v0.32.1 (#4286)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-09 00:37:54 +00:00
dependabot[bot]
ce8ce410b0 Bump @stylistic/eslint-plugin from 5.9.0 to 5.10.0 in /src/Misc/expressionFunc/hashFiles (#4281)
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-03-07 22:13:23 +00:00
dependabot[bot]
5310e90af2 Bump actions/attest-build-provenance from 3 to 4 (#4266)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-07 01:59:22 +00:00
dependabot[bot]
98323280e8 Bump docker/setup-buildx-action from 3 to 4 (#4282)
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-03-07 01:49:28 +00:00
dependabot[bot]
5ef3270368 Bump docker/build-push-action from 6 to 7 (#4283)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-07 01:44:58 +00:00
eric sciple
1138dd80f7 Fix positional arg bug in ExpressionParser.CreateTree (#4279) 2026-03-05 14:56:28 -06:00
dependabot[bot]
99910ca83e Bump docker/login-action from 3 to 4 (#4278)
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-03-05 15:45:49 +00:00
dependabot[bot]
bcd04cfbf0 Bump actions/upload-artifact from 6 to 7 (#4270)
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-03-05 14:55:48 +00: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
116 changed files with 17177 additions and 1095 deletions

View File

@@ -1,10 +1,10 @@
{
"name": "Actions Runner Devcontainer",
"image": "mcr.microsoft.com/devcontainers/base:focal",
"image": "mcr.microsoft.com/devcontainers/base:noble",
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:1": {},
"ghcr.io/devcontainers/features/docker-in-docker:2": {},
"ghcr.io/devcontainers/features/dotnet": {
"version": "8.0.418"
"version": "8.0.420"
},
"ghcr.io/devcontainers/features/node:1": {
"version": "20"

View File

@@ -78,7 +78,7 @@ jobs:
# Upload runner package tar.gz/zip as artifact
- name: Publish Artifact
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: runner-package-${{ matrix.runtime }}
path: |
@@ -99,7 +99,7 @@ jobs:
- name: Get latest runner version
id: latest_runner
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
@@ -111,10 +111,10 @@ jobs:
core.setOutput('version', version);
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Build Docker image
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: ./images
load: true

View File

@@ -26,7 +26,7 @@ jobs:
- name: Compute image version
id: image
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
const fs = require('fs');
@@ -38,10 +38,10 @@ jobs:
core.setOutput('version', runnerVersion);
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -49,7 +49,7 @@ jobs:
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: ./images
platforms: |
@@ -68,7 +68,7 @@ jobs:
org.opencontainers.image.description=https://github.com/actions/runner/releases/tag/v${{ steps.image.outputs.version }}
- name: Generate attestation
uses: actions/attest-build-provenance@v3
uses: actions/attest-build-provenance@v4
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
subject-digest: ${{ steps.build-and-push.outputs.digest }}

View File

@@ -159,18 +159,36 @@ jobs:
git config --global user.name "github-actions[bot]"
git config --global user.email "<41898282+github-actions[bot]@users.noreply.github.com>"
# Build version summary for commit message and PR body (only include changed versions)
COMMIT_VERSIONS=""
PR_VERSION_LINES=""
if [ "${{ steps.node-versions.outputs.needs_update20 }}" == "true" ]; then
COMMIT_VERSIONS="20: $NODE20_VERSION"
PR_VERSION_LINES="- Node 20: ${{ steps.node-versions.outputs.current_node20 }} → $NODE20_VERSION"
fi
if [ "${{ steps.node-versions.outputs.needs_update24 }}" == "true" ]; then
if [ -n "$COMMIT_VERSIONS" ]; then
COMMIT_VERSIONS="$COMMIT_VERSIONS, 24: $NODE24_VERSION"
else
COMMIT_VERSIONS="24: $NODE24_VERSION"
fi
PR_VERSION_LINES="${PR_VERSION_LINES:+$PR_VERSION_LINES
}- Node 24: ${{ steps.node-versions.outputs.current_node24 }} → $NODE24_VERSION"
fi
# Create branch and commit changes
branch_name="chore/update-node"
git checkout -b "$branch_name"
git commit -a -m "chore: update Node versions (20: $NODE20_VERSION, 24: $NODE24_VERSION)"
git commit -a -m "chore: update Node versions ($COMMIT_VERSIONS)"
git push --force origin "$branch_name"
# Create PR body using here-doc for proper formatting
cat > pr_body.txt << EOF
Automated Node.js version update:
- Node 20: ${{ steps.node-versions.outputs.current_node20 }} → $NODE20_VERSION
- Node 24: ${{ steps.node-versions.outputs.current_node24 }} → $NODE24_VERSION
$PR_VERSION_LINES
This update ensures we're using the latest stable Node.js versions for security and performance improvements.

View File

@@ -16,7 +16,7 @@ jobs:
# Make sure ./releaseVersion match ./src/runnerversion
# Query GitHub release ensure version is not used
- name: Check version
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
@@ -118,7 +118,7 @@ jobs:
# Upload runner package tar.gz/zip as artifact.
- name: Publish Artifact
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: runner-packages-${{ matrix.runtime }}
path: |
@@ -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: ./
@@ -171,7 +171,7 @@ jobs:
# Create ReleaseNote file
- name: Create ReleaseNote
id: releaseNote
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
@@ -300,7 +300,7 @@ jobs:
- name: Compute image version
id: image
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
const fs = require('fs');
@@ -309,10 +309,10 @@ jobs:
core.setOutput('version', runnerVersion);
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -320,7 +320,7 @@ jobs:
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: ./images
platforms: |
@@ -339,7 +339,7 @@ jobs:
org.opencontainers.image.description=https://github.com/actions/runner/releases/tag/v${{ steps.image.outputs.version }}
- name: Generate attestation
uses: actions/attest-build-provenance@v3
uses: actions/attest-build-provenance@v4
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
subject-digest: ${{ steps.build-and-push.outputs.digest }}

View File

@@ -5,8 +5,8 @@ ARG TARGETOS
ARG TARGETARCH
ARG RUNNER_VERSION
ARG RUNNER_CONTAINER_HOOKS_VERSION=0.7.0
ARG DOCKER_VERSION=29.2.0
ARG BUILDX_VERSION=0.31.1
ARG DOCKER_VERSION=29.4.0
ARG BUILDX_VERSION=0.33.0
RUN apt update -y && apt install curl unzip -y

View File

@@ -1,27 +1,36 @@
## 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
* Bump flatted from 3.2.7 to 3.4.2 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4307
* Add DAP server by @rentziass in https://github.com/actions/runner/pull/4298
* Bump @typescript-eslint/eslint-plugin from 8.57.1 to 8.57.2 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4310
* Remove AllowCaseFunction feature flag by @ericsciple in https://github.com/actions/runner/pull/4316
* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4319
* Batch and deduplicate action resolution across composite depths by @stefanpenner in https://github.com/actions/runner/pull/4296
* Add support for Bearer token in action archive downloads by @TingluoHuang in https://github.com/actions/runner/pull/4321
* Bump brace-expansion in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4318
* Add devtunnel connection for debugger jobs by @rentziass in https://github.com/actions/runner/pull/4317
* Update Docker to v29.3.1 and Buildx to v0.33.0 by @github-actions[bot] in https://github.com/actions/runner/pull/4324
* Bump @typescript-eslint/eslint-plugin from 8.57.2 to 8.58.1 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4327
* Bump actions/github-script from 8 to 9 by @dependabot[bot] in https://github.com/actions/runner/pull/4331
* Bump typescript from 5.9.3 to 6.0.2 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4329
* fix: only show changed versions in node upgrade PR description by @salmanmkc in https://github.com/actions/runner/pull/4332
* Bump System.Formats.Asn1, Cryptography.Pkcs, ProtectedData, ServiceController, CodePages, Threading.Channels, @actions/glob, @typescript-eslint/parser, lint-staged, picomatch by @Copilot in https://github.com/actions/runner/pull/4333
* feat: add `job.workflow_*` typed accessors to JobContext by @salmanmkc in https://github.com/actions/runner/pull/4335
* Add WS bridge over DAP TCP server by @rentziass in https://github.com/actions/runner/pull/4328
* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4355
* Bump Docker version to 29.4.0 by @Copilot in https://github.com/actions/runner/pull/4352
* Update dotnet sdk to latest version @8.0.420 by @github-actions[bot] in https://github.com/actions/runner/pull/4356
* Bump @typescript-eslint/parser from 8.58.1 to 8.59.0 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4360
* Bump System.Formats.Asn1 and System.Security.Cryptography.Pkcs by @dependabot[bot] in https://github.com/actions/runner/pull/4362
* Add vulnerability-alerts permission by @salmanmkc in https://github.com/actions/runner/pull/4350
* Bump @typescript-eslint/eslint-plugin from 8.58.1 to 8.59.0 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4359
* Bump System.ServiceProcess.ServiceController from 10.0.3 to 10.0.6 by @dependabot[bot] in https://github.com/actions/runner/pull/4358
* Bump typescript from 6.0.2 to 6.0.3 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4353
* Bump Microsoft.DevTunnels.Connections from 1.3.16 to 1.3.39 by @dependabot[bot] in https://github.com/actions/runner/pull/4339
## New Contributors
* @AllanGuigou made their first contribution in https://github.com/actions/runner/pull/4147
* @stefanpenner made their first contribution in https://github.com/actions/runner/pull/4296
**Full Changelog**: https://github.com/actions/runner/compare/v2.330.0...v2.331.0
**Full Changelog**: https://github.com/actions/runner/compare/v2.333.1...v2.334.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.

File diff suppressed because it is too large Load Diff

View File

@@ -32,20 +32,20 @@
"author": "GitHub Actions",
"license": "MIT",
"dependencies": {
"@actions/glob": "^0.4.0"
"@actions/glob": "^0.7.0"
},
"devDependencies": {
"@stylistic/eslint-plugin": "^3.1.0",
"@stylistic/eslint-plugin": "^5.10.0",
"@types/node": "^22.0.0",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"@typescript-eslint/eslint-plugin": "^8.59.0",
"@typescript-eslint/parser": "^8.59.0",
"@vercel/ncc": "^0.38.3",
"eslint": "^8.47.0",
"eslint-plugin-github": "^4.10.2",
"eslint-plugin-prettier": "^5.0.0",
"husky": "^9.1.7",
"lint-staged": "^15.5.0",
"lint-staged": "^16.4.0",
"prettier": "^3.0.3",
"typescript": "^5.9.3"
"typescript": "^6.0.3"
}
}

View File

@@ -6,8 +6,8 @@ NODE_URL=https://nodejs.org/dist
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"
NODE20_VERSION="20.20.2"
NODE24_VERSION="24.15.0"
get_abs_path() {
# exploits the fact that pwd will print abs path when no args

View File

@@ -10,6 +10,13 @@ if %ERRORLEVEL% EQU 0 (
exit /b 0
)
if "%ACTIONS_RUNNER_RETURN_VERSION_DEPRECATED_EXIT_CODE%"=="1" (
if %ERRORLEVEL% EQU 7 (
echo "Runner listener exit with deprecated version error code: %ERRORLEVEL%."
exit /b %ERRORLEVEL%
)
)
if %ERRORLEVEL% EQU 1 (
echo "Runner listener exit with terminated error, stop the service, no retry needed."
exit /b 0

View File

@@ -34,11 +34,13 @@ fi
updateFile="update.finished"
"$DIR"/bin/Runner.Listener run $*
returnCode=$?
if [[ $returnCode == 0 ]]; then
echo "Runner listener exit with 0 return code, stop the service, no retry needed."
exit 0
elif [[ "$ACTIONS_RUNNER_RETURN_VERSION_DEPRECATED_EXIT_CODE" == "1" && $returnCode -eq 7 ]]; then
echo "Runner listener exit with deprecated version exit code: ${returnCode}."
exit "$returnCode"
elif [[ $returnCode == 1 ]]; then
echo "Runner listener exit with terminated error, stop the service, no retry needed."
exit 0

View File

@@ -25,7 +25,14 @@ call "%~dp0run-helper.cmd" %*
if %ERRORLEVEL% EQU 1 (
echo "Restarting runner..."
goto :launch_helper
) else (
echo "Exiting runner..."
exit /b 0
)
if "%ACTIONS_RUNNER_RETURN_VERSION_DEPRECATED_EXIT_CODE%"=="1" (
if %ERRORLEVEL% EQU 7 (
echo "Exiting runner with deprecated version error code: %ERRORLEVEL%"
exit /b %ERRORLEVEL%
)
)
echo "Exiting runner..."
exit /b 0

View File

@@ -19,6 +19,9 @@ run() {
returnCode=$?
if [[ $returnCode -eq 2 ]]; then
echo "Restarting runner..."
elif [[ "$ACTIONS_RUNNER_RETURN_VERSION_DEPRECATED_EXIT_CODE" == "1" && $returnCode -eq 7 ]]; then
echo "Exiting runner..."
exit "$returnCode"
else
echo "Exiting runner..."
exit 0
@@ -42,6 +45,9 @@ runWithManualTrap() {
returnCode=$?
if [[ $returnCode -eq 2 ]]; then
echo "Restarting runner..."
elif [[ "$ACTIONS_RUNNER_RETURN_VERSION_DEPRECATED_EXIT_CODE" == "1" && $returnCode -eq 7 ]]; then
echo "Exiting runner..."
exit "$returnCode"
else
echo "Exiting runner..."
# Unregister signal handling before exit

View File

@@ -159,6 +159,7 @@ namespace GitHub.Runner.Common
// and the runner should be restarted. This is a temporary code and will be removed in the future after
// the runner is migrated to runner admin.
public const int RunnerConfigurationRefreshed = 6;
public const int RunnerVersionDeprecated = 7;
}
public static class Features
@@ -172,9 +173,12 @@ 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";
public static readonly string BatchActionResolution = "actions_batch_action_resolution";
public static readonly string UseBearerTokenForCodeload = "actions_use_bearer_token_for_codeload";
}
// Node version migration related constants
@@ -193,8 +197,22 @@ namespace GitHub.Runner.Common
public static readonly string RequireNode24Flag = "actions.runner.requirenode24";
public static readonly string WarnOnNode20Flag = "actions.runner.warnonnode20";
// Feature flags for Linux ARM32 deprecation
public static readonly string DeprecateLinuxArm32Flag = "actions_runner_deprecate_linux_arm32";
public static readonly string KillLinuxArm32Flag = "actions_runner_kill_linux_arm32";
// Blog post URL for Node 20 deprecation
public static readonly string Node20DeprecationUrl = "https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/";
// Node 20 migration dates (hardcoded fallbacks, can be overridden via job variables)
public static readonly string Node24DefaultDate = "June 2nd, 2026";
public static readonly string Node20RemovalDate = "September 16th, 2026";
// Variable keys for server-overridable dates
public static readonly string Node24DefaultDateVariable = "actions_runner_node24_default_date";
public static readonly string Node20RemovalDateVariable = "actions_runner_node20_removal_date";
public static readonly string LinuxArm32DeprecationMessage = "Linux ARM32 runners are deprecated and will no longer be supported after {0}. Please migrate to a supported platform.";
}
public static readonly string InternalTelemetryIssueDataKey = "_internal_telemetry";
@@ -276,6 +294,7 @@ namespace GitHub.Runner.Common
public static readonly string AllowUnsupportedCommands = "ACTIONS_ALLOW_UNSECURE_COMMANDS";
public static readonly string AllowUnsupportedStopCommandTokens = "ACTIONS_ALLOW_UNSECURE_STOPCOMMAND_TOKENS";
public static readonly string RequireJobContainer = "ACTIONS_RUNNER_REQUIRE_JOB_CONTAINER";
public static readonly string ReturnVersionDeprecatedExitCode = "ACTIONS_RUNNER_RETURN_VERSION_DEPRECATED_EXIT_CODE";
public static readonly string RunnerDebug = "ACTIONS_RUNNER_DEBUG";
public static readonly string StepDebug = "ACTIONS_STEP_DEBUG";
}

View File

@@ -17,9 +17,9 @@
<ItemGroup>
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
<PackageReference Include="System.Text.Encoding.CodePages" Version="8.0.0" />
<PackageReference Include="System.Threading.Channels" Version="8.0.0" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.3" />
<PackageReference Include="System.Text.Encoding.CodePages" Version="10.0.3" />
<PackageReference Include="System.Threading.Channels" Version="10.0.3" />
</ItemGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">

View File

@@ -12,6 +12,13 @@ namespace GitHub.Runner.Common
private ISecretMasker _secretMasker;
private TraceSource _traceSource;
/// <summary>
/// The underlying <see cref="System.Diagnostics.TraceSource"/> for this instance.
/// Useful when third-party libraries require a <see cref="System.Diagnostics.TraceSource"/>
/// to route their diagnostics into the runner's log infrastructure.
/// </summary>
public TraceSource Source => _traceSource;
public Tracing(string name, ISecretMasker secretMasker, SourceSwitch sourceSwitch, HostTraceListener traceListener, StdoutTraceListener stdoutTraceListener = null)
{
ArgUtil.NotNull(secretMasker, nameof(secretMasker));

View File

@@ -58,7 +58,7 @@ namespace GitHub.Runner.Common.Util
{
return (Constants.Runner.NodeMigration.Node24, null);
}
// Get environment variable details with source information
var forceNode24Details = GetEnvironmentVariableDetails(
Constants.Runner.NodeMigration.ForceNode24Variable, workflowEnvironment);
@@ -108,14 +108,50 @@ namespace GitHub.Runner.Common.Util
/// <summary>
/// Checks if Node24 is requested but running on ARM32 Linux, and determines if fallback is needed.
/// Also handles ARM32 deprecation and kill switch phases.
/// </summary>
/// <param name="preferredVersion">The preferred Node version</param>
/// <param name="deprecateArm32">Feature flag indicating ARM32 Linux is deprecated</param>
/// <param name="killArm32">Feature flag indicating ARM32 Linux should no longer work</param>
/// <returns>A tuple containing the adjusted node version and an optional warning message</returns>
public static (string nodeVersion, string warningMessage) CheckNodeVersionForLinuxArm32(string preferredVersion)
public static (string nodeVersion, string warningMessage) CheckNodeVersionForLinuxArm32(
string preferredVersion,
bool deprecateArm32 = false,
bool killArm32 = false,
string node20RemovalDate = null)
{
if (string.Equals(preferredVersion, Constants.Runner.NodeMigration.Node24, StringComparison.OrdinalIgnoreCase) &&
Constants.Runner.PlatformArchitecture.Equals(Constants.Architecture.Arm) &&
Constants.Runner.Platform.Equals(Constants.OSPlatform.Linux))
bool isArm32Linux = Constants.Runner.PlatformArchitecture.Equals(Constants.Architecture.Arm) &&
Constants.Runner.Platform.Equals(Constants.OSPlatform.Linux);
if (!isArm32Linux)
{
return (preferredVersion, null);
}
// ARM32 kill switch: runner should no longer work on this platform
if (killArm32)
{
return (null, "Linux ARM32 runners are no longer supported. Please migrate to a supported platform.");
}
// ARM32 deprecation warning: continue using node20 but warn about upcoming end of support
if (deprecateArm32)
{
string effectiveDate = string.IsNullOrEmpty(node20RemovalDate) ? Constants.Runner.NodeMigration.Node20RemovalDate : node20RemovalDate;
string deprecationWarning = string.Format(
Constants.Runner.NodeMigration.LinuxArm32DeprecationMessage,
effectiveDate);
if (string.Equals(preferredVersion, Constants.Runner.NodeMigration.Node24, StringComparison.OrdinalIgnoreCase))
{
return (Constants.Runner.NodeMigration.Node20, deprecationWarning);
}
return (preferredVersion, deprecationWarning);
}
// Legacy behavior: fall back to node20 if node24 was requested on ARM32
if (string.Equals(preferredVersion, Constants.Runner.NodeMigration.Node24, StringComparison.OrdinalIgnoreCase))
{
return (Constants.Runner.NodeMigration.Node20, "Node 24 is not supported on Linux ARM32 platforms. Falling back to Node 20.");
}

View File

@@ -141,9 +141,9 @@ namespace GitHub.Runner.Listener
}
catch (AccessDeniedException e) when (e.ErrorCode == 1)
{
terminal.WriteError($"An error occured: {e.Message}");
terminal.WriteError($"An error occurred: {e.Message}");
trace.Error(e);
return Constants.Runner.ReturnCode.TerminatedError;
return GetRunnerVersionDeprecatedExitCode();
}
catch (RunnerNotFoundException e)
{
@@ -159,6 +159,16 @@ namespace GitHub.Runner.Listener
}
}
private static int GetRunnerVersionDeprecatedExitCode()
{
if (StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable(Constants.Variables.Actions.ReturnVersionDeprecatedExitCode)))
{
return Constants.Runner.ReturnCode.RunnerVersionDeprecated;
}
return Constants.Runner.ReturnCode.TerminatedError;
}
private static void LoadAndSetEnv()
{
var binDir = Path.GetDirectoryName(Assembly.GetEntryAssembly().Location);

View File

@@ -22,8 +22,8 @@
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
<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.Security.Cryptography.ProtectedData" Version="10.0.3" />
<PackageReference Include="System.ServiceProcess.ServiceController" Version="10.0.7" />
</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

@@ -12,8 +12,6 @@ namespace GitHub.Runner.Plugins.Repository.v1_0
{
public class CheckoutTask : IRunnerActionPlugin
{
private readonly Regex _validSha1 = new(@"\b[0-9a-f]{40}\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled, TimeSpan.FromSeconds(2));
public async Task RunAsync(RunnerActionPluginExecutionContext executionContext, CancellationToken token)
{
string runnerWorkspace = executionContext.GetRunnerContext("workspace");
@@ -99,7 +97,7 @@ namespace GitHub.Runner.Plugins.Repository.v1_0
{
sourceBranch = refInput;
sourceVersion = executionContext.GetInput(Pipelines.PipelineConstants.CheckoutTaskInputs.Version); // version get removed when checkout move to repo in the graph
if (string.IsNullOrEmpty(sourceVersion) && RegexUtility.IsMatch(sourceBranch, WellKnownRegularExpressions.SHA1))
if (string.IsNullOrEmpty(sourceVersion) && RegexUtility.IsMatch(sourceBranch, WellKnownRegularExpressions.CommitHash))
{
sourceVersion = sourceBranch;

View File

@@ -96,7 +96,7 @@ namespace GitHub.Runner.Plugins.Repository.v1_1
{
sourceBranch = refInput;
sourceVersion = executionContext.GetInput(Pipelines.PipelineConstants.CheckoutTaskInputs.Version); // version get removed when checkout move to repo in the graph
if (string.IsNullOrEmpty(sourceVersion) && RegexUtility.IsMatch(sourceBranch, WellKnownRegularExpressions.SHA1))
if (string.IsNullOrEmpty(sourceVersion) && RegexUtility.IsMatch(sourceBranch, WellKnownRegularExpressions.CommitHash))
{
sourceVersion = sourceBranch;
// If Ref is a SHA and the repo is self, we need to use github.ref as source branch since it might be refs/pull/*

View File

@@ -15,9 +15,9 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="System.Text.Encoding.CodePages" Version="8.0.0" />
<PackageReference Include="System.Text.Encoding.CodePages" Version="10.0.3" />
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
<PackageReference Include="System.Threading.Channels" Version="8.0.0" />
<PackageReference Include="System.Threading.Channels" Version="10.0.3" />
</ItemGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">

View File

@@ -79,6 +79,13 @@ namespace GitHub.Runner.Worker
PreStepTracker = new Dictionary<Guid, IActionRunner>()
};
var containerSetupSteps = new List<JobExtensionRunner>();
var batchActionResolution = (executionContext.Global.Variables.GetBoolean(Constants.Runner.Features.BatchActionResolution) ?? false)
|| StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION"));
// Stack-local cache: same action (owner/repo@ref) is resolved only once,
// even if it appears at multiple depths in a composite tree.
var resolvedDownloadInfos = batchActionResolution
? new Dictionary<string, WebApi.ActionDownloadInfo>(StringComparer.Ordinal)
: null;
var depth = 0;
// We are running at the start of a job
if (rootStepId == default(Guid))
@@ -105,13 +112,23 @@ namespace GitHub.Runner.Worker
PrepareActionsState result = new PrepareActionsState();
try
{
result = await PrepareActionsRecursiveAsync(executionContext, state, actions, depth, rootStepId);
result = batchActionResolution
? await PrepareActionsRecursiveAsync(executionContext, state, actions, resolvedDownloadInfos, depth, rootStepId)
: await PrepareActionsRecursiveLegacyAsync(executionContext, state, actions, depth, rootStepId);
}
catch (FailedToResolveActionDownloadInfoException ex)
{
// 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;
}
catch (FailedToDownloadActionException ex)
{
// Log the error and fail the PrepareActionsAsync Initialization.
Trace.Error($"Caught exception from PrepareActionsAsync Initialization: {ex}");
executionContext.InfrastructureError(ex.InnerException?.Message ?? ex.Message, category: "error_download_action");
executionContext.Result = TaskResult.Failed;
throw;
}
@@ -161,7 +178,192 @@ namespace GitHub.Runner.Worker
return new PrepareResult(containerSetupSteps, result.PreStepTracker);
}
private async Task<PrepareActionsState> PrepareActionsRecursiveAsync(IExecutionContext executionContext, PrepareActionsState state, IEnumerable<Pipelines.ActionStep> actions, Int32 depth = 0, Guid parentStepId = default(Guid))
private async Task<PrepareActionsState> PrepareActionsRecursiveAsync(IExecutionContext executionContext, PrepareActionsState state, IEnumerable<Pipelines.ActionStep> actions, Dictionary<string, WebApi.ActionDownloadInfo> resolvedDownloadInfos, Int32 depth = 0, Guid parentStepId = default(Guid))
{
ArgUtil.NotNull(executionContext, nameof(executionContext));
if (depth > Constants.CompositeActionsMaxDepth)
{
throw new Exception($"Composite action depth exceeded max depth {Constants.CompositeActionsMaxDepth}");
}
var repositoryActions = new List<Pipelines.ActionStep>();
foreach (var action in actions)
{
if (action.Reference.Type == Pipelines.ActionSourceType.ContainerRegistry)
{
ArgUtil.NotNull(action, nameof(action));
var containerReference = action.Reference as Pipelines.ContainerRegistryReference;
ArgUtil.NotNull(containerReference, nameof(containerReference));
ArgUtil.NotNullOrEmpty(containerReference.Image, nameof(containerReference.Image));
if (!state.ImagesToPull.ContainsKey(containerReference.Image))
{
state.ImagesToPull[containerReference.Image] = new List<Guid>();
}
Trace.Info($"Action {action.Name} ({action.Id}) needs to pull image '{containerReference.Image}'");
state.ImagesToPull[containerReference.Image].Add(action.Id);
}
else if (action.Reference.Type == Pipelines.ActionSourceType.Repository)
{
repositoryActions.Add(action);
}
}
if (repositoryActions.Count > 0)
{
// Resolve download info, skipping any actions already cached.
await ResolveNewActionsAsync(executionContext, repositoryActions, resolvedDownloadInfos);
// Download each action.
foreach (var action in repositoryActions)
{
var lookupKey = GetDownloadInfoLookupKey(action);
if (string.IsNullOrEmpty(lookupKey))
{
continue;
}
if (!resolvedDownloadInfos.TryGetValue(lookupKey, out var downloadInfo))
{
throw new Exception($"Missing download info for {lookupKey}");
}
await DownloadRepositoryActionAsync(executionContext, downloadInfo);
}
// Parse action.yml and collect composite sub-actions for batched
// resolution below. Pre/post step registration is deferred until
// after recursion so that HasPre/HasPost reflect the full subtree.
var nextLevel = new List<(Pipelines.ActionStep action, Guid parentId)>();
foreach (var action in repositoryActions)
{
var setupInfo = PrepareRepositoryActionAsync(executionContext, action);
if (setupInfo != null && setupInfo.Container != null)
{
if (!string.IsNullOrEmpty(setupInfo.Container.Image))
{
if (!state.ImagesToPull.ContainsKey(setupInfo.Container.Image))
{
state.ImagesToPull[setupInfo.Container.Image] = new List<Guid>();
}
Trace.Info($"Action {action.Name} ({action.Id}) from repository '{setupInfo.Container.ActionRepository}' needs to pull image '{setupInfo.Container.Image}'");
state.ImagesToPull[setupInfo.Container.Image].Add(action.Id);
}
else
{
ArgUtil.NotNullOrEmpty(setupInfo.Container.ActionRepository, nameof(setupInfo.Container.ActionRepository));
if (!state.ImagesToBuild.ContainsKey(setupInfo.Container.ActionRepository))
{
state.ImagesToBuild[setupInfo.Container.ActionRepository] = new List<Guid>();
}
Trace.Info($"Action {action.Name} ({action.Id}) from repository '{setupInfo.Container.ActionRepository}' needs to build image '{setupInfo.Container.Dockerfile}'");
state.ImagesToBuild[setupInfo.Container.ActionRepository].Add(action.Id);
state.ImagesToBuildInfo[setupInfo.Container.ActionRepository] = setupInfo.Container;
}
}
else if (setupInfo != null && setupInfo.Steps != null && setupInfo.Steps.Count > 0)
{
foreach (var step in setupInfo.Steps)
{
nextLevel.Add((step, action.Id));
}
}
}
// Resolve all next-level sub-actions in one batch API call,
// then recurse per parent (which hits the cache, not the API).
if (nextLevel.Count > 0)
{
var nextLevelRepoActions = nextLevel
.Where(x => x.action.Reference.Type == Pipelines.ActionSourceType.Repository)
.Select(x => x.action)
.ToList();
await ResolveNewActionsAsync(executionContext, nextLevelRepoActions, resolvedDownloadInfos);
foreach (var group in nextLevel.GroupBy(x => x.parentId))
{
var groupActions = group.Select(x => x.action).ToList();
state = await PrepareActionsRecursiveAsync(executionContext, state, groupActions, resolvedDownloadInfos, depth + 1, group.Key);
}
}
// Register pre/post steps after recursion so that HasPre/HasPost
// are correct (they depend on _cachedEmbeddedPreSteps/PostSteps
// being populated by the recursive calls above).
foreach (var action in repositoryActions)
{
var repoAction = action.Reference as Pipelines.RepositoryPathReference;
if (repoAction.RepositoryType != Pipelines.PipelineConstants.SelfAlias)
{
var definition = LoadAction(executionContext, action);
if (definition.Data.Execution.HasPre)
{
Trace.Info($"Add 'pre' execution for {action.Id}");
// Root Step
if (depth < 1)
{
var actionRunner = HostContext.CreateService<IActionRunner>();
actionRunner.Action = action;
actionRunner.Stage = ActionRunStage.Pre;
actionRunner.Condition = definition.Data.Execution.InitCondition;
state.PreStepTracker[action.Id] = actionRunner;
}
// Embedded Step
else
{
if (!_cachedEmbeddedPreSteps.ContainsKey(parentStepId))
{
_cachedEmbeddedPreSteps[parentStepId] = new List<Pipelines.ActionStep>();
}
// Clone action so we can modify the condition without affecting the original
var clonedAction = action.Clone() as Pipelines.ActionStep;
clonedAction.Condition = definition.Data.Execution.InitCondition;
_cachedEmbeddedPreSteps[parentStepId].Add(clonedAction);
}
}
if (definition.Data.Execution.HasPost && depth > 0)
{
if (!_cachedEmbeddedPostSteps.ContainsKey(parentStepId))
{
// If we haven't done so already, add the parent to the post steps
_cachedEmbeddedPostSteps[parentStepId] = new Stack<Pipelines.ActionStep>();
}
// Clone action so we can modify the condition without affecting the original
var clonedAction = action.Clone() as Pipelines.ActionStep;
clonedAction.Condition = definition.Data.Execution.CleanupCondition;
_cachedEmbeddedPostSteps[parentStepId].Push(clonedAction);
}
}
else if (depth > 0)
{
// if we're in a composite action and haven't loaded the local action yet
// we assume it has a post step
if (!_cachedEmbeddedPostSteps.ContainsKey(parentStepId))
{
// If we haven't done so already, add the parent to the post steps
_cachedEmbeddedPostSteps[parentStepId] = new Stack<Pipelines.ActionStep>();
}
// Clone action so we can modify the condition without affecting the original
var clonedAction = action.Clone() as Pipelines.ActionStep;
_cachedEmbeddedPostSteps[parentStepId].Push(clonedAction);
}
}
}
return state;
}
/// <summary>
/// Legacy (non-batched) action resolution. Each composite resolves its
/// sub-actions individually, with no cross-depth deduplication.
/// Used when the BatchActionResolution feature flag is disabled.
/// </summary>
private async Task<PrepareActionsState> PrepareActionsRecursiveLegacyAsync(IExecutionContext executionContext, PrepareActionsState state, IEnumerable<Pipelines.ActionStep> actions, Int32 depth = 0, Guid parentStepId = default(Guid))
{
ArgUtil.NotNull(executionContext, nameof(executionContext));
if (depth > Constants.CompositeActionsMaxDepth)
@@ -247,7 +449,7 @@ namespace GitHub.Runner.Worker
}
else if (setupInfo != null && setupInfo.Steps != null && setupInfo.Steps.Count > 0)
{
state = await PrepareActionsRecursiveAsync(executionContext, state, setupInfo.Steps, depth + 1, action.Id);
state = await PrepareActionsRecursiveLegacyAsync(executionContext, state, setupInfo.Steps, depth + 1, action.Id);
}
var repoAction = action.Reference as Pipelines.RepositoryPathReference;
if (repoAction.RepositoryType != Pipelines.PipelineConstants.SelfAlias)
@@ -678,6 +880,11 @@ namespace GitHub.Runner.Worker
return new Dictionary<string, WebApi.ActionDownloadInfo>();
}
// Pass lockfile dependencies to Launch when present, so it can
// perform ref-scoped policy matching with the original refs.
var deps = executionContext.Global.ActionsDependencies;
IList<string> dependencies = (deps != null && deps.Count > 0) ? deps : null;
// Resolve download info
var launchServer = HostContext.GetService<ILaunchServer>();
var jobServer = HostContext.GetService<IJobServer>();
@@ -689,7 +896,7 @@ namespace GitHub.Runner.Worker
if (MessageUtil.IsRunServiceJob(executionContext.Global.Variables.Get(Constants.Variables.System.JobRequestType)))
{
var displayHelpfulActionsDownloadErrors = executionContext.Global.Variables.GetBoolean(Constants.Runner.Features.DisplayHelpfulActionsDownloadErrors) ?? false;
actionDownloadInfos = await launchServer.ResolveActionsDownloadInfoAsync(executionContext.Global.Plan.PlanId, executionContext.Root.Id, new WebApi.ActionReferenceList { Actions = actionReferences }, executionContext.CancellationToken, displayHelpfulActionsDownloadErrors);
actionDownloadInfos = await launchServer.ResolveActionsDownloadInfoAsync(executionContext.Global.Plan.PlanId, executionContext.Root.Id, new WebApi.ActionReferenceList { Actions = actionReferences, Dependencies = dependencies }, executionContext.CancellationToken, displayHelpfulActionsDownloadErrors);
}
else
{
@@ -754,6 +961,33 @@ namespace GitHub.Runner.Worker
return actionDownloadInfos.Actions;
}
/// <summary>
/// Only resolves actions not already in resolvedDownloadInfos.
/// Results are cached for reuse at deeper recursion levels.
/// </summary>
private async Task ResolveNewActionsAsync(IExecutionContext executionContext, List<Pipelines.ActionStep> actions, Dictionary<string, WebApi.ActionDownloadInfo> resolvedDownloadInfos)
{
var actionsToResolve = new List<Pipelines.ActionStep>();
var pendingKeys = new HashSet<string>(StringComparer.Ordinal);
foreach (var action in actions)
{
var lookupKey = GetDownloadInfoLookupKey(action);
if (!string.IsNullOrEmpty(lookupKey) && !resolvedDownloadInfos.ContainsKey(lookupKey) && pendingKeys.Add(lookupKey))
{
actionsToResolve.Add(action);
}
}
if (actionsToResolve.Count > 0)
{
var downloadInfos = await GetDownloadInfoAsync(executionContext, actionsToResolve);
foreach (var kvp in downloadInfos)
{
resolvedDownloadInfos[kvp.Key] = kvp.Value;
}
}
}
private async Task DownloadRepositoryActionAsync(IExecutionContext executionContext, WebApi.ActionDownloadInfo downloadInfo)
{
Trace.Entering();
@@ -818,7 +1052,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 +1066,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;
}
@@ -1138,16 +1372,29 @@ namespace GitHub.Runner.Worker
return $"{repositoryReference.Name}@{repositoryReference.Ref}";
}
private AuthenticationHeaderValue CreateAuthHeader(string token)
private AuthenticationHeaderValue CreateAuthHeader(IExecutionContext executionContext, string downloadUrl, string token)
{
if (string.IsNullOrEmpty(token))
{
return null;
}
var base64EncodingToken = Convert.ToBase64String(Encoding.UTF8.GetBytes($"x-access-token:{token}"));
HostContext.SecretMasker.AddValue(base64EncodingToken);
return new AuthenticationHeaderValue("Basic", base64EncodingToken);
if (executionContext.Global.Variables.GetBoolean(Constants.Runner.Features.UseBearerTokenForCodeload) == true &&
Uri.TryCreate(downloadUrl, UriKind.Absolute, out var parsedUrl) &&
!string.IsNullOrEmpty(parsedUrl?.Host) &&
!string.IsNullOrEmpty(parsedUrl?.PathAndQuery) &&
(parsedUrl.Host.StartsWith("codeload.", StringComparison.OrdinalIgnoreCase) || parsedUrl.PathAndQuery.StartsWith("/_codeload/", StringComparison.OrdinalIgnoreCase)))
{
Trace.Info("Using Bearer token for action archive download directly to codeload.");
return new AuthenticationHeaderValue("Bearer", token);
}
else
{
Trace.Info("Using Basic token for action archive download.");
var base64EncodingToken = Convert.ToBase64String(Encoding.UTF8.GetBytes($"x-access-token:{token}"));
HostContext.SecretMasker.AddValue(base64EncodingToken);
return new AuthenticationHeaderValue("Basic", base64EncodingToken);
}
}
private async Task DownloadRepositoryArchive(IExecutionContext executionContext, string downloadUrl, string downloadAuthToken, string archiveFile)
@@ -1157,93 +1404,112 @@ namespace GitHub.Runner.Worker
// Allow up to 20 * 60s for any action to be downloaded from github graph.
int timeoutSeconds = 20 * 60;
while (retryCount < 3)
try
{
string requestId = string.Empty;
using (var actionDownloadTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds)))
using (var actionDownloadCancellation = CancellationTokenSource.CreateLinkedTokenSource(actionDownloadTimeout.Token, executionContext.CancellationToken))
while (retryCount < 3)
{
try
string requestId = string.Empty;
using (var actionDownloadTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds)))
using (var actionDownloadCancellation = CancellationTokenSource.CreateLinkedTokenSource(actionDownloadTimeout.Token, executionContext.CancellationToken))
{
//open zip stream in async mode
using (FileStream fs = new(archiveFile, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: _defaultFileStreamBufferSize, useAsync: true))
using (var httpClientHandler = HostContext.CreateHttpClientHandler())
using (var httpClient = new HttpClient(httpClientHandler))
try
{
httpClient.DefaultRequestHeaders.Authorization = CreateAuthHeader(downloadAuthToken);
httpClient.DefaultRequestHeaders.UserAgent.AddRange(HostContext.UserAgents);
using (var response = await httpClient.GetAsync(downloadUrl))
//open zip stream in async mode
using (FileStream fs = new(archiveFile, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: _defaultFileStreamBufferSize, useAsync: true))
using (var httpClientHandler = HostContext.CreateHttpClientHandler())
using (var httpClient = new HttpClient(httpClientHandler))
{
requestId = UrlUtil.GetGitHubRequestId(response.Headers);
if (!string.IsNullOrEmpty(requestId))
{
Trace.Info($"Request URL: {downloadUrl} X-GitHub-Request-Id: {requestId} Http Status: {response.StatusCode}");
}
httpClient.DefaultRequestHeaders.Authorization = CreateAuthHeader(executionContext, downloadUrl, downloadAuthToken);
if (response.IsSuccessStatusCode)
httpClient.DefaultRequestHeaders.UserAgent.AddRange(HostContext.UserAgents);
using (var response = await httpClient.GetAsync(downloadUrl))
{
using (var result = await response.Content.ReadAsStreamAsync())
requestId = UrlUtil.GetGitHubRequestId(response.Headers);
if (!string.IsNullOrEmpty(requestId))
{
await result.CopyToAsync(fs, _defaultCopyBufferSize, actionDownloadCancellation.Token);
await fs.FlushAsync(actionDownloadCancellation.Token);
// download succeed, break out the retry loop.
break;
Trace.Info($"Request URL: {downloadUrl} X-GitHub-Request-Id: {requestId} Http Status: {response.StatusCode}");
}
if (response.IsSuccessStatusCode)
{
using (var result = await response.Content.ReadAsStreamAsync())
{
await result.CopyToAsync(fs, _defaultCopyBufferSize, actionDownloadCancellation.Token);
await fs.FlushAsync(actionDownloadCancellation.Token);
// download succeed, break out the retry loop.
break;
}
}
else if (response.StatusCode == HttpStatusCode.NotFound)
{
// It doesn't make sense to retry in this case, so just stop
throw new ActionNotFoundException(new Uri(downloadUrl), requestId);
}
else if (response.StatusCode == HttpStatusCode.Forbidden)
{
// It doesn't make sense to retry in this case, so just stop
throw new AccessDeniedException($"Access denied to '{downloadUrl}' ({requestId})");
}
else
{
// Something else bad happened, let's go to our retry logic
response.EnsureSuccessStatusCode();
}
}
else if (response.StatusCode == HttpStatusCode.NotFound)
{
// It doesn't make sense to retry in this case, so just stop
throw new ActionNotFoundException(new Uri(downloadUrl), requestId);
}
else
{
// Something else bad happened, let's go to our retry logic
response.EnsureSuccessStatusCode();
}
}
}
}
catch (OperationCanceledException) when (executionContext.CancellationToken.IsCancellationRequested)
{
Trace.Info("Action download has been cancelled.");
throw;
}
catch (OperationCanceledException ex) when (!executionContext.CancellationToken.IsCancellationRequested && retryCount >= 2)
{
Trace.Info($"Action download final retry timeout after {timeoutSeconds} seconds.");
throw new TimeoutException($"Action '{downloadUrl}' download has timed out. Error: {ex.Message} {requestId}");
}
catch (ActionNotFoundException)
{
Trace.Info($"The action at '{downloadUrl}' does not exist");
throw;
}
catch (Exception ex) when (retryCount < 2)
{
retryCount++;
Trace.Error($"Fail to download archive '{downloadUrl}' -- Attempt: {retryCount}");
Trace.Error(ex);
if (actionDownloadTimeout.Token.IsCancellationRequested)
catch (OperationCanceledException) when (executionContext.CancellationToken.IsCancellationRequested)
{
// action download didn't finish within timeout
executionContext.Warning($"Action '{downloadUrl}' didn't finish download within {timeoutSeconds} seconds. {requestId}");
Trace.Info("Action download has been cancelled.");
throw;
}
else
catch (OperationCanceledException ex) when (!executionContext.CancellationToken.IsCancellationRequested && retryCount >= 2)
{
executionContext.Warning($"Failed to download action '{downloadUrl}'. Error: {ex.Message} {requestId}");
Trace.Info($"Action download final retry timeout after {timeoutSeconds} seconds.");
throw new TimeoutException($"Action '{downloadUrl}' download has timed out. Error: {ex.Message} {requestId}");
}
catch (ActionNotFoundException)
{
Trace.Info($"The action at '{downloadUrl}' does not exist");
throw;
}
catch (AccessDeniedException)
{
Trace.Info($"Access denied to '{downloadUrl}'");
throw;
}
catch (Exception ex) when (retryCount < 2)
{
retryCount++;
Trace.Error($"Fail to download archive '{downloadUrl}' -- Attempt: {retryCount}");
Trace.Error(ex);
if (actionDownloadTimeout.Token.IsCancellationRequested)
{
// action download didn't finish within timeout
executionContext.Warning($"Action '{downloadUrl}' didn't finish download within {timeoutSeconds} seconds. {requestId}");
}
else
{
executionContext.Warning($"Failed to download action '{downloadUrl}'. Error: {ex.Message} {requestId}");
}
}
}
}
if (String.IsNullOrEmpty(Environment.GetEnvironmentVariable("_GITHUB_ACTION_DOWNLOAD_NO_BACKOFF")))
{
var backOff = BackoffTimerHelper.GetRandomBackoff(TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30));
executionContext.Warning($"Back off {backOff.TotalSeconds} seconds before retry.");
await Task.Delay(backOff);
if (String.IsNullOrEmpty(Environment.GetEnvironmentVariable("_GITHUB_ACTION_DOWNLOAD_NO_BACKOFF")))
{
var backOff = BackoffTimerHelper.GetRandomBackoff(TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30));
executionContext.Warning($"Back off {backOff.TotalSeconds} seconds before retry.");
await Task.Delay(backOff);
}
}
}
catch (Exception ex) when (!(ex is AccessDeniedException) && !(ex is OperationCanceledException) && !executionContext.CancellationToken.IsCancellationRequested)
{
Trace.Error($"Failed to download archive '{downloadUrl}' after {retryCount + 1} attempts.");
Trace.Error(ex);
throw new FailedToDownloadActionException($"Failed to download archive '{downloadUrl}' after {retryCount + 1} attempts.", ex);
}
ArgUtil.NotNullOrEmpty(archiveFile, nameof(archiveFile));
executionContext.Debug($"Download '{downloadUrl}' to '{archiveFile}'");

View File

@@ -316,7 +316,6 @@ namespace GitHub.Runner.Worker
Schema = _actionManifestSchema,
// TODO: Switch to real tracewriter for cutover
TraceWriter = new GitHub.Actions.WorkflowParser.ObjectTemplating.EmptyTraceWriter(),
AllowCaseFunction = false,
};
// Expression values from execution context

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
@@ -315,7 +315,6 @@ namespace GitHub.Runner.Worker
maxBytes: 10 * 1024 * 1024),
Schema = _actionManifestSchema,
TraceWriter = executionContext.ToTemplateTraceWriter(),
AllowCaseFunction = false,
};
// Expression values from execution context

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;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,369 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using GitHub.DistributedTask.Pipelines.ContextData;
using GitHub.Runner.Common;
using GitHub.Runner.Common.Util;
using GitHub.Runner.Sdk;
using GitHub.Runner.Worker.Handlers;
namespace GitHub.Runner.Worker.Dap
{
/// <summary>
/// Executes <see cref="RunCommand"/> objects in the job's runtime context.
///
/// Mirrors the behavior of a normal workflow <c>run:</c> step as closely
/// as possible by reusing the runner's existing shell-resolution logic,
/// script fixup helpers, and process execution infrastructure.
///
/// Output is streamed to the debugger via DAP <c>output</c> events with
/// secrets masked before emission.
/// </summary>
internal sealed class DapReplExecutor
{
private readonly IHostContext _hostContext;
private readonly Action<string, string> _sendOutput;
private readonly Tracing _trace;
public DapReplExecutor(IHostContext hostContext, Action<string, string> sendOutput)
{
_hostContext = hostContext ?? throw new ArgumentNullException(nameof(hostContext));
_sendOutput = sendOutput ?? throw new ArgumentNullException(nameof(sendOutput));
_trace = hostContext.GetTrace(nameof(DapReplExecutor));
}
/// <summary>
/// Executes a <see cref="RunCommand"/> and returns the exit code as a
/// formatted <see cref="EvaluateResponseBody"/>.
/// </summary>
public async Task<EvaluateResponseBody> ExecuteRunCommandAsync(
RunCommand command,
IExecutionContext context,
CancellationToken cancellationToken)
{
if (context == null)
{
return ErrorResult("No execution context available. The debugger must be paused at a step to run commands.");
}
try
{
return await ExecuteScriptAsync(command, context, cancellationToken);
}
catch (Exception ex)
{
_trace.Error($"REPL run command failed ({ex.GetType().Name})");
var maskedError = _hostContext.SecretMasker.MaskSecrets(ex.Message);
return ErrorResult($"Command failed: {maskedError}");
}
}
private async Task<EvaluateResponseBody> ExecuteScriptAsync(
RunCommand command,
IExecutionContext context,
CancellationToken cancellationToken)
{
// 1. Resolve shell — same logic as ScriptHandler
string shellCommand;
string argFormat;
if (!string.IsNullOrEmpty(command.Shell))
{
// Explicit shell from the DSL
var parsed = ScriptHandlerHelpers.ParseShellOptionString(command.Shell);
shellCommand = parsed.shellCommand;
argFormat = string.IsNullOrEmpty(parsed.shellArgs)
? ScriptHandlerHelpers.GetScriptArgumentsFormat(shellCommand)
: parsed.shellArgs;
}
else
{
// Default shell — mirrors ScriptHandler platform defaults
shellCommand = ResolveDefaultShell(context);
argFormat = ScriptHandlerHelpers.GetScriptArgumentsFormat(shellCommand);
}
_trace.Info("Resolved REPL shell");
// 2. Expand ${{ }} expressions in the script body, just like
// ActionRunner evaluates step inputs before ScriptHandler sees them
var contents = ExpandExpressions(command.Script, context);
contents = ScriptHandlerHelpers.FixUpScriptContents(shellCommand, contents);
// Write to a temp file (same pattern as ScriptHandler)
var extension = ScriptHandlerHelpers.GetScriptFileExtension(shellCommand);
var scriptFilePath = Path.Combine(
_hostContext.GetDirectory(WellKnownDirectory.Temp),
$"dap_repl_{Guid.NewGuid()}{extension}");
Encoding encoding = new UTF8Encoding(false);
#if OS_WINDOWS
contents = contents.Replace("\r\n", "\n").Replace("\n", "\r\n");
encoding = Console.InputEncoding.CodePage != 65001
? Console.InputEncoding
: encoding;
#endif
File.WriteAllText(scriptFilePath, contents, encoding);
try
{
// 3. Format arguments with script path
var resolvedPath = scriptFilePath.Replace("\"", "\\\"");
if (string.IsNullOrEmpty(argFormat) || !argFormat.Contains("{0}"))
{
return ErrorResult($"Invalid shell option '{shellCommand}'. Shell must be a valid built-in (bash, sh, cmd, powershell, pwsh) or a format string containing '{{0}}'");
}
var arguments = string.Format(argFormat, resolvedPath);
// 4. Resolve shell command path
string prependPath = string.Join(
Path.PathSeparator.ToString(),
Enumerable.Reverse(context.Global.PrependPath));
var commandPath = WhichUtil.Which(shellCommand, false, _trace, prependPath)
?? shellCommand;
// 5. Build environment — merge from execution context like a real step
var environment = BuildEnvironment(context, command.Env);
// 6. Resolve working directory
var workingDirectory = command.WorkingDirectory;
if (string.IsNullOrEmpty(workingDirectory))
{
var githubContext = context.ExpressionValues.TryGetValue("github", out var gh)
? gh as DictionaryContextData
: null;
var workspace = githubContext?.TryGetValue("workspace", out var ws) == true
? (ws as StringContextData)?.Value
: null;
workingDirectory = workspace ?? _hostContext.GetDirectory(WellKnownDirectory.Work);
}
_trace.Info("Executing REPL command");
// Stream execution info to debugger
SendOutput("console", $"$ {shellCommand} {command.Script.Substring(0, Math.Min(command.Script.Length, 80))}{(command.Script.Length > 80 ? "..." : "")}\n");
// 7. Execute via IProcessInvoker (same as DefaultStepHost)
int exitCode;
using (var processInvoker = _hostContext.CreateService<IProcessInvoker>())
{
processInvoker.OutputDataReceived += (sender, args) =>
{
if (!string.IsNullOrEmpty(args.Data))
{
var masked = _hostContext.SecretMasker.MaskSecrets(args.Data);
SendOutput("stdout", masked + "\n");
}
};
processInvoker.ErrorDataReceived += (sender, args) =>
{
if (!string.IsNullOrEmpty(args.Data))
{
var masked = _hostContext.SecretMasker.MaskSecrets(args.Data);
SendOutput("stderr", masked + "\n");
}
};
exitCode = await processInvoker.ExecuteAsync(
workingDirectory: workingDirectory,
fileName: commandPath,
arguments: arguments,
environment: environment,
requireExitCodeZero: false,
outputEncoding: null,
killProcessOnCancel: true,
cancellationToken: cancellationToken);
}
_trace.Info($"REPL command exited with code {exitCode}");
// 8. Return only the exit code summary (output was already streamed)
return new EvaluateResponseBody
{
Result = exitCode == 0 ? $"(exit code: {exitCode})" : $"Process completed with exit code {exitCode}.",
Type = exitCode == 0 ? "string" : "error",
VariablesReference = 0
};
}
finally
{
// Clean up temp script file
try { File.Delete(scriptFilePath); }
catch { /* best effort */ }
}
}
/// <summary>
/// Expands <c>${{ }}</c> expressions in the input string using the
/// runner's template evaluator — the same evaluation path that processes
/// step inputs before <see cref="ScriptHandler"/> runs them.
///
/// Each <c>${{ expr }}</c> occurrence is individually evaluated and
/// replaced with its masked string result, mirroring the semantics of
/// expression interpolation in a workflow <c>run:</c> step body.
/// </summary>
internal string ExpandExpressions(string input, IExecutionContext context)
{
if (string.IsNullOrEmpty(input) || !input.Contains("${{"))
{
return input ?? string.Empty;
}
var result = new StringBuilder();
int pos = 0;
while (pos < input.Length)
{
var start = input.IndexOf("${{", pos, StringComparison.Ordinal);
if (start < 0)
{
result.Append(input, pos, input.Length - pos);
break;
}
// Append the literal text before the expression
result.Append(input, pos, start - pos);
var end = input.IndexOf("}}", start + 3, StringComparison.Ordinal);
if (end < 0)
{
// Unterminated expression — keep literal
result.Append(input, start, input.Length - start);
break;
}
var expr = input.Substring(start + 3, end - start - 3).Trim();
end += 2; // skip past "}}"
// Evaluate the expression
try
{
var templateEvaluator = context.ToPipelineTemplateEvaluator();
var token = new GitHub.DistributedTask.ObjectTemplating.Tokens.BasicExpressionToken(
null, null, null, expr);
var evaluated = templateEvaluator.EvaluateStepDisplayName(
token,
context.ExpressionValues,
context.ExpressionFunctions);
result.Append(_hostContext.SecretMasker.MaskSecrets(evaluated ?? string.Empty));
}
catch (Exception ex)
{
_trace.Warning($"Expression expansion failed ({ex.GetType().Name})");
// Keep the original expression literal on failure
result.Append(input, start, end - start);
}
pos = end;
}
return result.ToString();
}
/// <summary>
/// Resolves the default shell the same way <see cref="ScriptHandler"/>
/// does: check job defaults, then fall back to platform default.
/// </summary>
internal string ResolveDefaultShell(IExecutionContext context)
{
// Check job defaults
if (context.Global?.JobDefaults != null &&
context.Global.JobDefaults.TryGetValue("run", out var runDefaults) &&
runDefaults.TryGetValue("shell", out var defaultShell) &&
!string.IsNullOrEmpty(defaultShell))
{
_trace.Info("Using job default shell");
return defaultShell;
}
#if OS_WINDOWS
string prependPath = string.Join(
Path.PathSeparator.ToString(),
context.Global?.PrependPath != null ? Enumerable.Reverse(context.Global.PrependPath) : Array.Empty<string>());
var pwshPath = WhichUtil.Which("pwsh", false, _trace, prependPath);
return !string.IsNullOrEmpty(pwshPath) ? "pwsh" : "powershell";
#else
return "sh";
#endif
}
/// <summary>
/// Merges the job context environment with any REPL-specific overrides.
/// </summary>
internal Dictionary<string, string> BuildEnvironment(
IExecutionContext context,
Dictionary<string, string> replEnv)
{
var env = new Dictionary<string, string>(VarUtil.EnvironmentVariableKeyComparer);
// Pull environment from the execution context (same as ActionRunner)
if (context.ExpressionValues.TryGetValue("env", out var envData))
{
if (envData is DictionaryContextData dictEnv)
{
foreach (var pair in dictEnv)
{
if (pair.Value is StringContextData str)
{
env[pair.Key] = str.Value;
}
}
}
else if (envData is CaseSensitiveDictionaryContextData csEnv)
{
foreach (var pair in csEnv)
{
if (pair.Value is StringContextData str)
{
env[pair.Key] = str.Value;
}
}
}
}
// Expose runtime context variables to the environment (GITHUB_*, RUNNER_*, etc.)
foreach (var ctxPair in context.ExpressionValues)
{
if (ctxPair.Value is IEnvironmentContextData runtimeContext && runtimeContext != null)
{
foreach (var rtEnv in runtimeContext.GetRuntimeEnvironmentVariables())
{
env[rtEnv.Key] = rtEnv.Value;
}
}
}
// Apply REPL-specific overrides last (so they win),
// expanding any ${{ }} expressions in the values
if (replEnv != null)
{
foreach (var pair in replEnv)
{
env[pair.Key] = ExpandExpressions(pair.Value, context);
}
}
return env;
}
private void SendOutput(string category, string text)
{
_sendOutput(category, text);
}
private static EvaluateResponseBody ErrorResult(string message)
{
return new EvaluateResponseBody
{
Result = message,
Type = "error",
VariablesReference = 0
};
}
}
}

View File

@@ -0,0 +1,411 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace GitHub.Runner.Worker.Dap
{
/// <summary>
/// Base type for all REPL DSL commands.
/// </summary>
internal abstract class DapReplCommand
{
}
/// <summary>
/// <c>help</c> or <c>help("run")</c>
/// </summary>
internal sealed class HelpCommand : DapReplCommand
{
public string Topic { get; set; }
}
/// <summary>
/// <c>run("echo hello")</c> or
/// <c>run("echo hello", shell: "bash", env: { FOO: "bar" }, working_directory: "/tmp")</c>
/// </summary>
internal sealed class RunCommand : DapReplCommand
{
public string Script { get; set; }
public string Shell { get; set; }
public Dictionary<string, string> Env { get; set; }
public string WorkingDirectory { get; set; }
}
/// <summary>
/// Parses REPL input into typed <see cref="DapReplCommand"/> objects.
///
/// Grammar (intentionally minimal — extend as the DSL grows):
/// <code>
/// help → HelpCommand { Topic = null }
/// help("run") → HelpCommand { Topic = "run" }
/// run("script body") → RunCommand { Script = "script body" }
/// run("script", shell: "bash") → RunCommand { Shell = "bash" }
/// run("script", env: { K: "V" }) → RunCommand { Env = { K → V } }
/// run("script", working_directory: "p")→ RunCommand { WorkingDirectory = "p" }
/// </code>
///
/// Parsing is intentionally hand-rolled rather than regex-based so it can
/// handle nested braces, quoted strings with escapes, and grow to support
/// future commands without accumulating regex complexity.
/// </summary>
internal static class DapReplParser
{
/// <summary>
/// Attempts to parse REPL input into a command. Returns null if the
/// input does not match any known DSL command (i.e. it should be
/// treated as an expression instead).
/// </summary>
internal static DapReplCommand TryParse(string input, out string error)
{
error = null;
if (string.IsNullOrWhiteSpace(input))
{
return null;
}
var trimmed = input.Trim();
// help / help("topic")
if (trimmed.Equals("help", StringComparison.OrdinalIgnoreCase) ||
trimmed.StartsWith("help(", StringComparison.OrdinalIgnoreCase))
{
return ParseHelp(trimmed, out error);
}
// run("...")
if (trimmed.StartsWith("run(", StringComparison.OrdinalIgnoreCase))
{
return ParseRun(trimmed, out error);
}
// Not a DSL command
return null;
}
internal static string GetGeneralHelp()
{
return """
Actions Debug Console
Commands:
help Show this help
help("run") Show help for the run command
run("script") Execute a script (like a workflow run step)
Anything else is evaluated as a GitHub Actions expression.
Example: github.repository
Example: ${{ github.event_name }}
""";
}
internal static string GetRunHelp()
{
return """
run command execute a script in the job context
Usage:
run("echo hello")
run("echo $FOO", shell: "bash")
run("echo $FOO", env: { FOO: "bar" })
run("ls", working_directory: "/tmp")
run("echo $X", shell: "bash", env: { X: "1" }, working_directory: "/tmp")
Options:
shell: Shell to use (default: job default, e.g. bash)
env: Extra environment variables as { KEY: "value" }
working_directory: Working directory for the command
Behavior:
- Equivalent to a workflow `run:` step
- Expressions in the script body are expanded (${{ ... }})
- Output is streamed in real time and secrets are masked
""";
}
#region Parsers
private static HelpCommand ParseHelp(string input, out string error)
{
error = null;
if (input.Equals("help", StringComparison.OrdinalIgnoreCase))
{
return new HelpCommand();
}
// help("topic")
var inner = ExtractParenthesizedArgs(input, "help", out error);
if (error != null) return null;
var topic = ExtractQuotedString(inner.Trim(), out error);
if (error != null) return null;
return new HelpCommand { Topic = topic };
}
private static RunCommand ParseRun(string input, out string error)
{
error = null;
var inner = ExtractParenthesizedArgs(input, "run", out error);
if (error != null) return null;
// Split into argument list respecting quotes and braces
var args = SplitArguments(inner, out error);
if (error != null) return null;
if (args.Count == 0)
{
error = "run() requires a script argument. Example: run(\"echo hello\")";
return null;
}
// First arg must be the script body (a quoted string)
var script = ExtractQuotedString(args[0].Trim(), out error);
if (error != null)
{
error = $"First argument to run() must be a quoted string. {error}";
return null;
}
var cmd = new RunCommand { Script = script };
// Parse remaining keyword arguments
for (int i = 1; i < args.Count; i++)
{
var kv = args[i].Trim();
var colonIdx = kv.IndexOf(':');
if (colonIdx <= 0)
{
error = $"Expected keyword argument (e.g. shell: \"bash\"), got: {kv}";
return null;
}
var key = kv.Substring(0, colonIdx).Trim();
var value = kv.Substring(colonIdx + 1).Trim();
switch (key.ToLowerInvariant())
{
case "shell":
cmd.Shell = ExtractQuotedString(value, out error);
if (error != null) { error = $"shell: {error}"; return null; }
break;
case "working_directory":
cmd.WorkingDirectory = ExtractQuotedString(value, out error);
if (error != null) { error = $"working_directory: {error}"; return null; }
break;
case "env":
cmd.Env = ParseEnvBlock(value, out error);
if (error != null) { error = $"env: {error}"; return null; }
break;
default:
error = $"Unknown option: {key}. Valid options: shell, env, working_directory";
return null;
}
}
return cmd;
}
#endregion
#region Low-level parsing helpers
/// <summary>
/// Given "cmd(...)" returns the inner content between the outer parens.
/// </summary>
private static string ExtractParenthesizedArgs(string input, string prefix, out string error)
{
error = null;
var start = prefix.Length; // skip "cmd"
if (start >= input.Length || input[start] != '(')
{
error = $"Expected '(' after {prefix}";
return null;
}
if (input[input.Length - 1] != ')')
{
error = $"Expected ')' at end of {prefix}(...)";
return null;
}
return input.Substring(start + 1, input.Length - start - 2);
}
/// <summary>
/// Extracts a double-quoted string value, handling escaped quotes.
/// </summary>
internal static string ExtractQuotedString(string input, out string error)
{
error = null;
if (string.IsNullOrEmpty(input))
{
error = "Expected a quoted string, got empty input";
return null;
}
if (input[0] != '"')
{
error = $"Expected a quoted string starting with \", got: {Truncate(input, 40)}";
return null;
}
var sb = new StringBuilder();
for (int i = 1; i < input.Length; i++)
{
if (input[i] == '\\' && i + 1 < input.Length)
{
sb.Append(input[i + 1]);
i++;
}
else if (input[i] == '"')
{
// Check nothing meaningful follows the closing quote
var rest = input.Substring(i + 1).Trim();
if (rest.Length > 0)
{
error = $"Unexpected content after closing quote: {Truncate(rest, 40)}";
return null;
}
return sb.ToString();
}
else
{
sb.Append(input[i]);
}
}
error = "Unterminated string (missing closing \")";
return null;
}
/// <summary>
/// Splits a comma-separated argument list, respecting quoted strings
/// and nested braces so that <c>"a, b", env: { K: "V, W" }</c> is
/// correctly split into two arguments.
/// </summary>
internal static List<string> SplitArguments(string input, out string error)
{
error = null;
var result = new List<string>();
var current = new StringBuilder();
int depth = 0;
bool inQuote = false;
for (int i = 0; i < input.Length; i++)
{
var ch = input[i];
if (ch == '\\' && inQuote && i + 1 < input.Length)
{
current.Append(ch);
current.Append(input[++i]);
continue;
}
if (ch == '"')
{
inQuote = !inQuote;
current.Append(ch);
continue;
}
if (!inQuote)
{
if (ch == '{')
{
depth++;
current.Append(ch);
continue;
}
if (ch == '}')
{
depth--;
current.Append(ch);
continue;
}
if (ch == ',' && depth == 0)
{
result.Add(current.ToString());
current.Clear();
continue;
}
}
current.Append(ch);
}
if (inQuote)
{
error = "Unterminated string in arguments";
return null;
}
if (depth != 0)
{
error = "Unmatched braces in arguments";
return null;
}
if (current.Length > 0)
{
result.Add(current.ToString());
}
return result;
}
/// <summary>
/// Parses <c>{ KEY: "value", KEY2: "value2" }</c> into a dictionary.
/// </summary>
internal static Dictionary<string, string> ParseEnvBlock(string input, out string error)
{
error = null;
var trimmed = input.Trim();
if (!trimmed.StartsWith("{") || !trimmed.EndsWith("}"))
{
error = "Expected env block in the form { KEY: \"value\" }";
return null;
}
var inner = trimmed.Substring(1, trimmed.Length - 2).Trim();
if (string.IsNullOrEmpty(inner))
{
return new Dictionary<string, string>();
}
var pairs = SplitArguments(inner, out error);
if (error != null) return null;
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in pairs)
{
var colonIdx = pair.IndexOf(':');
if (colonIdx <= 0)
{
error = $"Expected KEY: \"value\" pair, got: {Truncate(pair.Trim(), 40)}";
return null;
}
var key = pair.Substring(0, colonIdx).Trim();
var val = ExtractQuotedString(pair.Substring(colonIdx + 1).Trim(), out error);
if (error != null) return null;
result[key] = val;
}
return result;
}
private static string Truncate(string value, int maxLength)
{
if (value == null) return "(null)";
return value.Length <= maxLength ? value : value.Substring(0, maxLength) + "...";
}
#endregion
}
}

View File

@@ -0,0 +1,373 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using GitHub.DistributedTask.Logging;
using GitHub.DistributedTask.ObjectTemplating.Tokens;
using GitHub.DistributedTask.Pipelines.ContextData;
namespace GitHub.Runner.Worker.Dap
{
/// <summary>
/// Maps runner execution context data to DAP scopes and variables.
///
/// This is the single point where runner context values are materialized
/// for the debugger. All values pass through the runner's existing
/// <see cref="GitHub.DistributedTask.Logging.ISecretMasker"/> so the DAP
/// surface never exposes anything beyond what a normal CI log would show.
///
/// The secrets scope is intentionally opaque: keys are visible but every
/// value is replaced with a constant redaction marker.
///
/// Designed to be reusable by future DAP features (evaluate, hover, REPL)
/// so that masking policy is never duplicated.
/// </summary>
internal sealed class DapVariableProvider
{
// Well-known scope names that map to top-level expression contexts.
// Order matters: the index determines the stable variablesReference ID.
private static readonly string[] _scopeNames =
{
"github", "env", "runner", "job", "steps",
"secrets", "inputs", "vars", "matrix", "needs"
};
// Scope references occupy the range [1, ScopeReferenceMax].
private const int _scopeReferenceBase = 1;
private const int _scopeReferenceMax = 100;
// Dynamic (nested) variable references start above the scope range.
private const int _dynamicReferenceBase = 101;
private const string _redactedValue = "***";
private readonly ISecretMasker _secretMasker;
// Maps dynamic variable reference IDs to the backing data and its
// dot-separated path (e.g. "github.event.pull_request").
private readonly Dictionary<int, (PipelineContextData Data, string Path)> _variableReferences = new();
private int _nextVariableReference = _dynamicReferenceBase;
public DapVariableProvider(ISecretMasker secretMasker)
{
_secretMasker = secretMasker ?? throw new ArgumentNullException(nameof(secretMasker));
}
/// <summary>
/// Clears all dynamic variable references.
/// Call this whenever the paused execution context changes (e.g. new step)
/// so that stale nested references are not served to the client.
/// </summary>
public void Reset()
{
_variableReferences.Clear();
_nextVariableReference = _dynamicReferenceBase;
}
/// <summary>
/// Returns the list of DAP scopes for the given execution context.
/// Each scope corresponds to a well-known runner expression context
/// (github, env, secrets, …) and carries a stable variablesReference
/// that the client can use to drill into variables.
/// </summary>
public List<Scope> GetScopes(IExecutionContext context)
{
var scopes = new List<Scope>();
if (context?.ExpressionValues == null)
{
return scopes;
}
for (int i = 0; i < _scopeNames.Length; i++)
{
var scopeName = _scopeNames[i];
if (!context.ExpressionValues.TryGetValue(scopeName, out var value) || value == null)
{
continue;
}
var scope = new Scope
{
Name = scopeName,
VariablesReference = _scopeReferenceBase + i,
Expensive = false,
PresentationHint = scopeName == "secrets" ? "registers" : null
};
if (value is DictionaryContextData dict)
{
scope.NamedVariables = dict.Count;
}
else if (value is CaseSensitiveDictionaryContextData csDict)
{
scope.NamedVariables = csDict.Count;
}
scopes.Add(scope);
}
return scopes;
}
/// <summary>
/// Returns the child variables for a given variablesReference.
/// The reference may point at a top-level scope (1100) or a
/// dynamically registered nested container (101+).
/// </summary>
public List<Variable> GetVariables(IExecutionContext context, int variablesReference)
{
var variables = new List<Variable>();
if (context?.ExpressionValues == null)
{
return variables;
}
PipelineContextData data = null;
string basePath = null;
bool isSecretsScope = false;
if (variablesReference >= _scopeReferenceBase && variablesReference <= _scopeReferenceMax)
{
var scopeIndex = variablesReference - _scopeReferenceBase;
if (scopeIndex < _scopeNames.Length)
{
var scopeName = _scopeNames[scopeIndex];
isSecretsScope = scopeName == "secrets";
if (context.ExpressionValues.TryGetValue(scopeName, out data))
{
basePath = scopeName;
}
}
}
else if (_variableReferences.TryGetValue(variablesReference, out var refData))
{
data = refData.Data;
basePath = refData.Path;
isSecretsScope = basePath?.StartsWith("secrets", StringComparison.OrdinalIgnoreCase) == true;
}
if (data == null)
{
return variables;
}
ConvertToVariables(data, basePath, isSecretsScope, variables);
return variables;
}
/// <summary>
/// Evaluates a GitHub Actions expression (e.g. "github.repository",
/// "${{ github.event_name }}") in the context of the current step and
/// returns a masked result suitable for the DAP evaluate response.
///
/// Uses the runner's standard <see cref="GitHub.DistributedTask.Pipelines.ObjectTemplating.IPipelineTemplateEvaluator"/>
/// so the full expression language is available (functions, operators,
/// context access).
/// </summary>
public EvaluateResponseBody EvaluateExpression(string expression, IExecutionContext context)
{
if (context?.ExpressionValues == null)
{
return new EvaluateResponseBody
{
Result = "(no execution context available)",
Type = "string",
VariablesReference = 0
};
}
// Strip ${{ }} wrapper if present
var expr = expression?.Trim() ?? string.Empty;
if (expr.StartsWith("${{") && expr.EndsWith("}}"))
{
expr = expr.Substring(3, expr.Length - 5).Trim();
}
if (string.IsNullOrEmpty(expr))
{
return new EvaluateResponseBody
{
Result = string.Empty,
Type = "string",
VariablesReference = 0
};
}
try
{
var templateEvaluator = context.ToPipelineTemplateEvaluator();
var token = new BasicExpressionToken(null, null, null, expr);
var result = templateEvaluator.EvaluateStepDisplayName(
token,
context.ExpressionValues,
context.ExpressionFunctions);
result = _secretMasker.MaskSecrets(result ?? "null");
return new EvaluateResponseBody
{
Result = result,
Type = InferResultType(result),
VariablesReference = 0
};
}
catch (Exception ex)
{
var errorMessage = _secretMasker.MaskSecrets($"Evaluation error: {ex.Message}");
return new EvaluateResponseBody
{
Result = errorMessage,
Type = "string",
VariablesReference = 0
};
}
}
/// <summary>
/// Infers a simple DAP type hint from the string representation of a result.
/// </summary>
internal static string InferResultType(string value)
{
value = value?.ToLower();
if (value == null || value == "null")
return "null";
if (value == "true" || value == "false")
return "boolean";
if (double.TryParse(value, NumberStyles.Any,
CultureInfo.InvariantCulture, out _))
return "number";
if (value.StartsWith("{") || value.StartsWith("["))
return "object";
return "string";
}
#region Private helpers
private void ConvertToVariables(
PipelineContextData data,
string basePath,
bool isSecretsScope,
List<Variable> variables)
{
switch (data)
{
case DictionaryContextData dict:
foreach (var pair in dict)
{
variables.Add(CreateVariable(pair.Key, pair.Value, basePath, isSecretsScope));
}
break;
case CaseSensitiveDictionaryContextData csDict:
foreach (var pair in csDict)
{
variables.Add(CreateVariable(pair.Key, pair.Value, basePath, isSecretsScope));
}
break;
case ArrayContextData array:
for (int i = 0; i < array.Count; i++)
{
var variable = CreateVariable($"[{i}]", array[i], basePath, isSecretsScope);
variables.Add(variable);
}
break;
}
}
private Variable CreateVariable(
string name,
PipelineContextData value,
string basePath,
bool isSecretsScope)
{
var childPath = string.IsNullOrEmpty(basePath) ? name : $"{basePath}.{name}";
var variable = new Variable
{
Name = name,
EvaluateName = $"${{{{ {childPath} }}}}"
};
// Secrets scope: redact ALL values regardless of underlying type.
// Keys are visible but values are always replaced with the
// redaction marker, and nested containers are not drillable.
if (isSecretsScope)
{
variable.Value = _redactedValue;
variable.Type = "string";
variable.VariablesReference = 0;
return variable;
}
if (value == null)
{
variable.Value = "null";
variable.Type = "null";
variable.VariablesReference = 0;
return variable;
}
switch (value)
{
case StringContextData str:
variable.Value = _secretMasker.MaskSecrets(str.Value);
variable.Type = "string";
variable.VariablesReference = 0;
break;
case NumberContextData num:
variable.Value = _secretMasker.MaskSecrets(num.Value.ToString("G15", CultureInfo.InvariantCulture));
variable.Type = "number";
variable.VariablesReference = 0;
break;
case BooleanContextData boolVal:
variable.Value = boolVal.Value ? "true" : "false";
variable.Type = "boolean";
variable.VariablesReference = 0;
break;
case DictionaryContextData dict:
variable.Value = $"Object ({dict.Count} properties)";
variable.Type = "object";
variable.VariablesReference = RegisterVariableReference(dict, childPath);
variable.NamedVariables = dict.Count;
break;
case CaseSensitiveDictionaryContextData csDict:
variable.Value = $"Object ({csDict.Count} properties)";
variable.Type = "object";
variable.VariablesReference = RegisterVariableReference(csDict, childPath);
variable.NamedVariables = csDict.Count;
break;
case ArrayContextData array:
variable.Value = $"Array ({array.Count} items)";
variable.Type = "array";
variable.VariablesReference = RegisterVariableReference(array, childPath);
variable.IndexedVariables = array.Count;
break;
default:
var rawValue = value.ToJToken()?.ToString() ?? "unknown";
variable.Value = _secretMasker.MaskSecrets(rawValue);
variable.Type = value.GetType().Name;
variable.VariablesReference = 0;
break;
}
return variable;
}
private int RegisterVariableReference(PipelineContextData data, string path)
{
var reference = _nextVariableReference++;
_variableReferences[reference] = (data, path);
return reference;
}
#endregion
}
}

View File

@@ -0,0 +1,33 @@
using GitHub.DistributedTask.Pipelines;
namespace GitHub.Runner.Worker.Dap
{
/// <summary>
/// Consolidated runtime configuration for the job debugger.
/// Populated once from the acquire response and owned by <see cref="GlobalContext"/>.
/// </summary>
public sealed class DebuggerConfig
{
public DebuggerConfig(bool enabled, DebuggerTunnelInfo tunnel)
{
Enabled = enabled;
Tunnel = tunnel;
}
/// <summary>Whether the debugger is enabled for this job.</summary>
public bool Enabled { get; }
/// <summary>
/// Dev Tunnel details for remote debugging.
/// Required when <see cref="Enabled"/> is true.
/// </summary>
public DebuggerTunnelInfo Tunnel { get; }
/// <summary>Whether the tunnel configuration is complete and valid.</summary>
public bool HasValidTunnel => Tunnel != null
&& !string.IsNullOrEmpty(Tunnel.TunnelId)
&& !string.IsNullOrEmpty(Tunnel.ClusterId)
&& !string.IsNullOrEmpty(Tunnel.HostToken)
&& Tunnel.Port >= 1024 && Tunnel.Port <= 65535;
}
}

View File

@@ -0,0 +1,45 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using GitHub.Runner.Common;
namespace GitHub.Runner.Worker.Dap
{
public enum DapSessionState
{
NotStarted,
WaitingForConnection,
Initializing,
Ready,
Paused,
Running,
Terminated
}
[ServiceLocator(Default = typeof(DapDebugger))]
public interface IDapDebugger : IRunnerService
{
Task StartAsync(IExecutionContext jobContext);
Task WaitUntilReadyAsync();
Task OnStepStartingAsync(IStep step);
void OnStepCompleted(IStep step);
/// <summary>
/// Called after JobExtension.InitializeJob has returned and the initial
/// step queue + post-step stack have been populated. The debugger uses
/// these snapshots to build the synthesized job execution view served
/// via the DAP source request.
/// </summary>
Task OnJobStepsInitializedAsync(IEnumerable<IStep> mainQueue, IEnumerable<IStep> initialPostStack);
/// <summary>
/// Called from ExecutionContext.RegisterPostJobStep after a post-step
/// is pushed onto the post-job stack. The debugger appends the step
/// to the running execution view so the rendered YAML reflects the
/// newly-known post-step.
/// </summary>
void OnPostStepRegistered(IStep step);
Task OnJobCompletedAsync();
Task StopAsync();
}
}

View File

@@ -0,0 +1,12 @@
using System.Threading.Tasks;
using GitHub.Runner.Common;
namespace GitHub.Runner.Worker.Dap
{
[ServiceLocator(Default = typeof(WebSocketDapBridge))]
public interface IWebSocketDapBridge : IRunnerService
{
void Start(int listenPort, int targetPort);
Task ShutdownAsync();
}
}

View File

@@ -0,0 +1,299 @@
using System;
using System.Collections.Generic;
using GitHub.Runner.Sdk;
namespace GitHub.Runner.Worker.Dap
{
/// <summary>
/// Stateful, append-only container that wraps <see cref="JobExecutionViewRenderer"/>
/// for runtime use. Maintains a mutable list of entries, caches the rendered YAML,
/// and provides O(1) lookup from <see cref="IStep"/> identity to the current line
/// in the rendered YAML where that step's <c>- step:</c> key appears.
///
/// Append-only growth model: post-steps are discovered lazily during execution
/// and appended. Setup/pre/main entry line numbers are stable across appends —
/// only the synthetic Cleanup boundary (which is not tracked here) shifts.
/// </summary>
internal sealed class JobExecutionView
{
private readonly object _lock = new();
private readonly string _jobId;
private readonly List<JobExecutionViewEntry> _entries = new();
private readonly List<IStep> _stepIdentities = new();
private readonly Dictionary<IStep, int> _lineByStep =
new(ReferenceEqualityComparer.Instance);
// Map matchKey -> entry index for placeholders awaiting a future
// TryClaim. Removed when claimed.
private readonly Dictionary<string, int> _unclaimedByKey =
new(StringComparer.Ordinal);
private string _yaml;
private IReadOnlyList<int> _entryStartLines = Array.Empty<int>();
public JobExecutionView(string jobId)
{
if (string.IsNullOrWhiteSpace(jobId))
{
throw new ArgumentException("jobId must not be null or whitespace.", nameof(jobId));
}
_jobId = jobId;
Render();
}
public string JobId
{
get { return _jobId; }
}
/// <summary>
/// Currently rendered YAML. Always reflects all entries appended so far,
/// plus the synthetic Setup header and Cleanup footer emitted by the renderer.
/// </summary>
public string Yaml
{
get
{
lock (_lock)
{
return _yaml;
}
}
}
/// <summary>Number of entries (excludes synthetic Setup/Cleanup boundaries).</summary>
public int EntryCount
{
get
{
lock (_lock)
{
return _entries.Count;
}
}
}
/// <summary>
/// 1-based line where entry <paramref name="entryIndex"/>'s <c>- step:</c> key
/// currently appears in <see cref="Yaml"/>.
/// </summary>
public int GetLine(int entryIndex)
{
lock (_lock)
{
if (entryIndex < 0 || entryIndex >= _entries.Count)
{
throw new ArgumentOutOfRangeException(nameof(entryIndex));
}
return _entryStartLines[entryIndex];
}
}
/// <summary>
/// 1-based line for the entry whose <see cref="IStep"/> reference identity
/// matches <paramref name="step"/>. Returns null if <paramref name="step"/>
/// is null or has not been registered.
/// </summary>
public int? TryGetLineForStep(IStep step)
{
if (step == null)
{
return null;
}
lock (_lock)
{
if (_lineByStep.TryGetValue(step, out var line))
{
return line;
}
return null;
}
}
/// <summary>
/// Append a new entry. If <paramref name="stepIdentity"/> is non-null,
/// registers the IStep -> line mapping for later lookup. If
/// <paramref name="matchKey"/> is non-null, the entry is registered
/// as an unclaimed placeholder that a future
/// <see cref="TryClaim(string, IStep)"/> call can bind to a real
/// IStep (used by the predictive Post-step path). Re-renders the
/// YAML and updates the start-line table.
/// </summary>
/// <returns>1-based line number of the newly-appended entry's <c>- step:</c> key.</returns>
public int Append(JobExecutionViewEntry entry, IStep stepIdentity = null, string matchKey = null)
{
if (entry == null)
{
throw new ArgumentNullException(nameof(entry));
}
lock (_lock)
{
if (stepIdentity != null && _lineByStep.ContainsKey(stepIdentity))
{
throw new InvalidOperationException("step already registered in execution view");
}
if (matchKey != null && _unclaimedByKey.ContainsKey(matchKey))
{
throw new InvalidOperationException($"matchKey already registered: {matchKey}");
}
_entries.Add(entry);
_stepIdentities.Add(stepIdentity);
Render();
int index = _entries.Count - 1;
if (matchKey != null)
{
_unclaimedByKey[matchKey] = index;
}
return _entryStartLines[index];
}
}
/// <summary>
/// Bind a previously-appended placeholder entry (registered via
/// <see cref="Append(JobExecutionViewEntry, IStep, string)"/> with
/// a non-null <c>matchKey</c>) to a real <see cref="IStep"/>.
/// Returns the 1-based line of the now-claimed entry on success.
/// Returns null when no unclaimed placeholder exists for
/// <paramref name="matchKey"/>, OR when <paramref name="stepIdentity"/>
/// is already registered for a different entry (defensive).
/// Does not re-render: claim only updates the IStep -> line index.
/// </summary>
public int? TryClaim(string matchKey, IStep stepIdentity)
{
if (matchKey == null)
{
throw new ArgumentNullException(nameof(matchKey));
}
if (stepIdentity == null)
{
throw new ArgumentNullException(nameof(stepIdentity));
}
lock (_lock)
{
if (!_unclaimedByKey.TryGetValue(matchKey, out int index))
{
return null;
}
if (_lineByStep.ContainsKey(stepIdentity))
{
// Bail rather than double-register the step.
return null;
}
_unclaimedByKey.Remove(matchKey);
_stepIdentities[index] = stepIdentity;
_lineByStep[stepIdentity] = _entryStartLines[index];
return _entryStartLines[index];
}
}
/// <summary>
/// Mark a previously-appended unclaimed placeholder as skipped. Used
/// when the predicting Main step never runs (skipped by <c>if:</c>),
/// so its predicted Post-step placeholder should not appear as a
/// step that will execute. Re-renders the view (inline comment only
/// — subsequent entry line numbers stay stable).
/// </summary>
/// <returns>
/// true if a matching unclaimed placeholder was marked; false when
/// no placeholder exists for <paramref name="matchKey"/>, or the
/// placeholder has already been claimed (claim wins).
/// </returns>
public bool TryMarkSkipped(string matchKey)
{
ArgUtil.NotNull(matchKey, nameof(matchKey));
lock (_lock)
{
if (!_unclaimedByKey.TryGetValue(matchKey, out int index))
{
return false;
}
// Defensive: only mark if it's still an unclaimed placeholder.
if (_stepIdentities[index] != null)
{
return false;
}
if (_entries[index].IsSkipped)
{
// Idempotent — already marked.
return true;
}
_entries[index].IsSkipped = true;
_unclaimedByKey.Remove(matchKey);
Render();
return true;
}
}
/// <summary>
/// Bulk-append for the initial population. Equivalent to calling
/// <see cref="Append"/> once per pair, but renders only once at the end.
/// State is left unchanged if any input is invalid.
/// </summary>
public void AppendRange(IEnumerable<(JobExecutionViewEntry entry, IStep stepIdentity)> items)
{
ArgUtil.NotNull(items, nameof(items));
// Materialize first so we don't enumerate twice.
var materialized = new List<(JobExecutionViewEntry entry, IStep stepIdentity)>(items);
for (int i = 0; i < materialized.Count; i++)
{
if (materialized[i].entry == null)
{
throw new ArgumentException($"items[{i}].entry is null.", nameof(items));
}
}
lock (_lock)
{
// Validate no duplicates within the input or with existing identities,
// before mutating state.
var seen = new HashSet<IStep>(ReferenceEqualityComparer.Instance);
foreach (var (_, stepIdentity) in materialized)
{
if (stepIdentity == null)
{
continue;
}
if (_lineByStep.ContainsKey(stepIdentity) || !seen.Add(stepIdentity))
{
throw new InvalidOperationException("step already registered in execution view");
}
}
foreach (var (entry, stepIdentity) in materialized)
{
_entries.Add(entry);
_stepIdentities.Add(stepIdentity);
}
Render();
}
}
// Caller MUST hold _lock (constructor's call is safe — no concurrent access yet).
private void Render()
{
var result = JobExecutionViewRenderer.Render(_jobId, _entries.AsReadOnly());
_yaml = result.Yaml;
_entryStartLines = result.EntryStartLines;
_lineByStep.Clear();
for (int i = 0; i < _stepIdentities.Count; i++)
{
var step = _stepIdentities[i];
if (step != null)
{
_lineByStep[step] = _entryStartLines[i];
}
}
}
}
}

View File

@@ -0,0 +1,391 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
using GitHub.Runner.Sdk;
using YamlDotNet.Core;
using YamlDotNet.Core.Events;
namespace GitHub.Runner.Worker.Dap
{
/// <summary>
/// Phase a step occupies in the runner's flat execution sequence.
/// Setup and Cleanup are NOT modeled here — they are synthetic
/// boundaries hard-coded by <see cref="JobExecutionViewRenderer"/>
/// and cannot be constructed by callers.
/// </summary>
internal enum JobExecutionPhase
{
Pre,
Main,
Post,
}
/// <summary>
/// One step in the rendered execution view. Pure data; no link to
/// any worker type. Phase 2 will translate runner step objects
/// into instances of this record.
/// </summary>
internal sealed class JobExecutionViewEntry
{
public JobExecutionViewEntry(
JobExecutionPhase phase,
string displayName,
string uses = null,
string run = null,
string sourcePath = null,
int sourceLine = 0,
string id = null,
string @if = null,
string continueOnError = null,
string timeoutMinutes = null,
string envYaml = null,
string withYaml = null,
string shell = null,
string workingDirectory = null)
{
if (string.IsNullOrWhiteSpace(displayName))
{
throw new ArgumentException("displayName must not be null or whitespace.", nameof(displayName));
}
if (sourcePath != null && sourceLine < 1)
{
throw new ArgumentException(
"sourceLine must be >= 1 when sourcePath is provided.",
nameof(sourceLine));
}
Phase = phase;
DisplayName = displayName;
Uses = uses;
Run = run;
SourcePath = sourcePath;
SourceLine = sourceLine;
Id = id;
If = @if;
ContinueOnError = continueOnError;
TimeoutMinutes = timeoutMinutes;
EnvYaml = envYaml;
WithYaml = withYaml;
Shell = shell;
WorkingDirectory = workingDirectory;
}
public JobExecutionPhase Phase { get; }
public string DisplayName { get; }
public string Uses { get; }
public string Run { get; }
public string SourcePath { get; }
public int SourceLine { get; }
public string Id { get; }
public string If { get; }
public string ContinueOnError { get; }
public string TimeoutMinutes { get; }
// Pre-serialized YAML fragment, already indented for embedding
// under the entry's `env:` key (6-space child indent).
public string EnvYaml { get; }
public string WithYaml { get; }
public string Shell { get; }
public string WorkingDirectory { get; }
/// <summary>
/// Set when the corresponding step was skipped (e.g. predicted Post
/// placeholder for a Main step that never executed because its
/// <c>if:</c> evaluated false). Rendered as an inline YAML comment
/// on the entry's <c>- step:</c> line so subsequent entry line
/// numbers stay stable.
/// </summary>
public bool IsSkipped { get; internal set; }
}
/// <summary>
/// Output of <see cref="JobExecutionViewRenderer.Render"/>: the YAML
/// document plus a parallel array of 1-based line numbers, one per
/// input entry, where each entry's <c>- step:</c> key appears.
/// Synthetic Setup/Cleanup boundaries are not tracked here.
/// </summary>
internal readonly struct RenderResult
{
public RenderResult(string yaml, IReadOnlyList<int> entryStartLines)
{
Yaml = yaml;
EntryStartLines = entryStartLines;
}
public string Yaml { get; }
public IReadOnlyList<int> EntryStartLines { get; }
}
/// <summary>
/// Renders a job's execution-view YAML. Pure function; no I/O,
/// no logging, no static state. Output format and Setup/Cleanup
/// boundaries are fixed; callers cannot influence them.
///
/// Output is structured as phase-keyed top-level sections:
/// <c>setup:</c>, <c>pre:</c>, <c>main:</c>, <c>post:</c>, <c>cleanup:</c>.
/// <c>setup:</c> and <c>cleanup:</c> always render; <c>pre:</c>,
/// <c>main:</c>, <c>post:</c> only render when they contain at least
/// one entry.
/// </summary>
internal static class JobExecutionViewRenderer
{
public static RenderResult Render(string jobId, IReadOnlyList<JobExecutionViewEntry> entries)
{
if (string.IsNullOrWhiteSpace(jobId))
{
throw new ArgumentException("jobId must not be null or whitespace.", nameof(jobId));
}
ArgUtil.NotNull(entries, nameof(entries));
// Pre-validate non-null entries before any output, so partial
// state is never observed by callers.
for (int i = 0; i < entries.Count; i++)
{
if (entries[i] == null)
{
throw new ArgumentException($"entries[{i}] is null.", nameof(entries));
}
}
var sb = new StringBuilder();
var startLines = new int[entries.Count];
int newlinesEmitted = 0;
// Header (3 lines).
sb.Append("# Job: ").Append(FormatScalar(jobId)).Append('\n');
sb.Append("# Runner execution plan — read-only.\n");
sb.Append('\n');
newlinesEmitted += 3;
// setup: section — always present.
sb.Append("setup:\n");
sb.Append(" - step: Setup job\n");
newlinesEmitted += 2;
// Render phase sections in fixed order. Each emits a leading
// blank line separator before its header.
EmitPhaseSection(sb, "pre", JobExecutionPhase.Pre, entries, startLines, ref newlinesEmitted);
EmitPhaseSection(sb, "main", JobExecutionPhase.Main, entries, startLines, ref newlinesEmitted);
EmitPhaseSection(sb, "post", JobExecutionPhase.Post, entries, startLines, ref newlinesEmitted);
// cleanup: section — always present, preceded by a blank line.
sb.Append('\n');
sb.Append("cleanup:\n");
sb.Append(" - step: Complete job\n");
return new RenderResult(sb.ToString(), Array.AsReadOnly(startLines));
}
private static void EmitPhaseSection(
StringBuilder sb,
string sectionName,
JobExecutionPhase phase,
IReadOnlyList<JobExecutionViewEntry> entries,
int[] startLines,
ref int newlinesEmitted)
{
// Skip the section entirely if no entries belong to this phase.
bool any = false;
for (int i = 0; i < entries.Count; i++)
{
if (entries[i].Phase == phase) { any = true; break; }
}
if (!any)
{
return;
}
// Blank line separator + section header.
sb.Append('\n');
sb.Append(sectionName).Append(":\n");
newlinesEmitted += 2;
for (int i = 0; i < entries.Count; i++)
{
var entry = entries[i];
if (entry.Phase != phase)
{
continue;
}
// 1-based line of the `- step:` key for this entry.
startLines[i] = newlinesEmitted + 1;
sb.Append(" - step: ").Append(FormatScalar(entry.DisplayName));
if (entry.IsSkipped)
{
// Inline comment — keeps following entry line numbers stable.
sb.Append(" # (skipped — main step did not execute)");
}
sb.Append('\n');
newlinesEmitted++;
switch (phase)
{
case JobExecutionPhase.Pre:
case JobExecutionPhase.Post:
if (!string.IsNullOrEmpty(entry.Uses))
{
sb.Append(" action: ").Append(FormatScalar(entry.Uses)).Append('\n');
newlinesEmitted++;
}
// No source: annotation for pre/post.
break;
case JobExecutionPhase.Main:
if (!string.IsNullOrEmpty(entry.Id))
{
sb.Append(" id: ").Append(FormatScalar(entry.Id)).Append('\n');
newlinesEmitted++;
}
if (!string.IsNullOrEmpty(entry.Uses))
{
sb.Append(" uses: ").Append(FormatScalar(entry.Uses)).Append('\n');
newlinesEmitted++;
}
if (!string.IsNullOrEmpty(entry.Run))
{
if (entry.Run.IndexOf('\n') < 0)
{
sb.Append(" run: ").Append(FormatScalar(entry.Run)).Append('\n');
newlinesEmitted++;
}
else
{
sb.Append(" run: |\n");
newlinesEmitted++;
newlinesEmitted += AppendIndentedBlock(sb, entry.Run, " ");
}
}
if (!string.IsNullOrEmpty(entry.If))
{
sb.Append(" if: ").Append(FormatScalar(entry.If)).Append('\n');
newlinesEmitted++;
}
if (!string.IsNullOrEmpty(entry.ContinueOnError))
{
sb.Append(" continue-on-error: ").Append(entry.ContinueOnError).Append('\n');
newlinesEmitted++;
}
if (!string.IsNullOrEmpty(entry.TimeoutMinutes))
{
sb.Append(" timeout-minutes: ").Append(entry.TimeoutMinutes).Append('\n');
newlinesEmitted++;
}
if (!string.IsNullOrEmpty(entry.EnvYaml))
{
sb.Append(" env:\n");
newlinesEmitted++;
sb.Append(entry.EnvYaml).Append('\n');
newlinesEmitted += CountChar(entry.EnvYaml, '\n') + 1;
}
if (!string.IsNullOrEmpty(entry.WithYaml))
{
sb.Append(" with:\n");
newlinesEmitted++;
sb.Append(entry.WithYaml).Append('\n');
newlinesEmitted += CountChar(entry.WithYaml, '\n') + 1;
}
if (!string.IsNullOrEmpty(entry.Shell))
{
sb.Append(" shell: ").Append(FormatScalar(entry.Shell)).Append('\n');
newlinesEmitted++;
}
if (!string.IsNullOrEmpty(entry.WorkingDirectory))
{
sb.Append(" working-directory: ").Append(FormatScalar(entry.WorkingDirectory)).Append('\n');
newlinesEmitted++;
}
if (entry.SourcePath != null)
{
sb.Append(" source: ")
.Append(entry.SourcePath)
.Append(':')
.Append(entry.SourceLine.ToString(CultureInfo.InvariantCulture))
.Append('\n');
newlinesEmitted++;
}
break;
}
}
}
private static int AppendIndentedBlock(StringBuilder sb, string text, string indent)
{
int newlines = 0;
int i = 0;
while (i < text.Length)
{
int end = text.IndexOf('\n', i);
int lineEnd = end < 0 ? text.Length : end;
int trimEnd = lineEnd;
if (trimEnd > i && text[trimEnd - 1] == '\r')
{
trimEnd--;
}
if (trimEnd > i)
{
sb.Append(indent);
sb.Append(text, i, trimEnd - i);
}
sb.Append('\n');
newlines++;
if (end < 0)
{
break;
}
i = end + 1;
}
return newlines;
}
private static int CountChar(string s, char c)
{
int n = 0;
for (int i = 0; i < s.Length; i++)
{
if (s[i] == c) n++;
}
return n;
}
/// <summary>
/// Formats a single string as a YAML 1.x flow scalar, delegating
/// quoting/escaping decisions to YamlDotNet. This avoids maintaining
/// our own escape table for every YAML-significant character: we
/// just emit the value through the YAML library and use whichever
/// scalar style (plain, single-quoted, double-quoted) it picks.
/// A new <see cref="Emitter"/> is created per call, so the helper
/// is safe to invoke concurrently.
/// </summary>
internal static string FormatScalar(string value)
{
if (value == null)
{
throw new ArgumentNullException(nameof(value));
}
using var sw = new StringWriter(CultureInfo.InvariantCulture);
var emitter = new Emitter(sw);
emitter.Emit(new StreamStart());
emitter.Emit(new DocumentStart(null, null, true));
emitter.Emit(new Scalar(null, null, value, ScalarStyle.Any, true, true));
emitter.Emit(new DocumentEnd(true));
emitter.Emit(new StreamEnd());
string raw = sw.ToString();
if (raw.StartsWith("--- ", StringComparison.Ordinal))
{
raw = raw.Substring(4);
}
raw = raw.TrimEnd('\n');
const string DocEndMarker = "\n...";
if (raw.EndsWith(DocEndMarker, StringComparison.Ordinal))
{
raw = raw.Substring(0, raw.Length - DocEndMarker.Length);
}
return raw.TrimEnd('\n');
}
}
}

View File

@@ -0,0 +1,238 @@
using System;
using System.Collections.Generic;
using GitHub.DistributedTask.ObjectTemplating.Tokens;
using GitHub.DistributedTask.Pipelines;
using GitHub.Runner.Sdk;
namespace GitHub.Runner.Worker.Dap
{
/// <summary>
/// Translates runner <see cref="IStep"/> instances into pure-data
/// <see cref="JobExecutionViewEntry"/> records used by the DAP debugger
/// execution view. Filters out runner-internal steps (e.g.
/// <see cref="JobExtensionRunner"/>) so the rendered view only shows
/// user-visible workflow steps.
/// </summary>
internal static class StepEntryTranslator
{
// Run-step internals carried on ActionStep.Inputs that are NOT
// user-authored `with:` entries.
private static readonly HashSet<string> RunStepInternalKeys = new(StringComparer.Ordinal)
{
"script",
"shell",
"working-directory",
};
/// <summary>
/// Translate an IStep into a JobExecutionViewEntry.
/// </summary>
/// <param name="step">The IStep to translate. Must not be null.</param>
/// <returns>
/// A JobExecutionViewEntry, or null if the step is not user-visible
/// (JobExtensionRunner and any other non-IActionRunner IStep impls).
/// </returns>
public static JobExecutionViewEntry TryTranslate(IStep step)
{
ArgUtil.NotNull(step, nameof(step));
if (step is JobExtensionRunner)
{
return null;
}
if (step is not IActionRunner actionRunner)
{
return null;
}
var phase = actionRunner.Stage switch
{
ActionRunStage.Pre => JobExecutionPhase.Pre,
ActionRunStage.Post => JobExecutionPhase.Post,
_ => JobExecutionPhase.Main,
};
string displayName = actionRunner.DisplayName;
if (string.IsNullOrWhiteSpace(displayName))
{
displayName = "run";
}
string uses = null;
string run = null;
string id = null;
string ifCond = null;
string continueOnError = null;
string timeoutMinutes = null;
string envYaml = null;
string withYaml = null;
string shell = null;
string workingDirectory = null;
var action = actionRunner.Action;
var reference = action?.Reference;
bool isScript = reference?.Type == ActionSourceType.Script;
if (reference != null && !isScript)
{
uses = FormatActionReference(reference);
}
// Only the user-visible Main entry surfaces authored params.
// Pre/Post stay minimal (step + action) — they reference the
// same Action as the Main entry, and duplicating params adds
// noise without information.
if (phase == JobExecutionPhase.Main && action != null)
{
id = FilterAuthoredId(action.ContextName);
if (!string.IsNullOrEmpty(action.Condition))
{
ifCond = action.Condition;
}
if (action.ContinueOnError != null)
{
continueOnError = TemplateTokenYamlAdapter.Serialize(action.ContinueOnError, indentSpaces: 0);
}
if (action.TimeoutInMinutes != null)
{
timeoutMinutes = TemplateTokenYamlAdapter.Serialize(action.TimeoutInMinutes, indentSpaces: 0);
}
if (action.Environment is MappingToken envMap && envMap.Count > 0)
{
envYaml = TemplateTokenYamlAdapter.Serialize(envMap, indentSpaces: 6);
}
else if (action.Environment != null && !(action.Environment is MappingToken))
{
// Unusual but possible: env: ${{ ... }} expression form.
envYaml = TemplateTokenYamlAdapter.Serialize(action.Environment, indentSpaces: 6);
}
if (isScript)
{
var inputs = action.Inputs as MappingToken;
if (inputs != null)
{
if (TryGetMapValue(inputs, "script", out var scriptTok) && scriptTok != null)
{
run = scriptTok.ToString();
}
if (TryGetMapValue(inputs, "shell", out var shellTok) && shellTok != null)
{
string shellText = shellTok.ToString();
if (!string.IsNullOrEmpty(shellText))
{
shell = shellText;
}
}
if (TryGetMapValue(inputs, "working-directory", out var wdTok) && wdTok != null)
{
string wdText = wdTok.ToString();
if (!string.IsNullOrEmpty(wdText))
{
workingDirectory = wdText;
}
}
}
}
else
{
// Action step: surface `with:` entries, filtering any
// run-step internal keys defensively.
if (action.Inputs is MappingToken withMap && withMap.Count > 0)
{
var filtered = FilterMapping(withMap, RunStepInternalKeys);
if (filtered != null && filtered.Count > 0)
{
withYaml = TemplateTokenYamlAdapter.Serialize(filtered, indentSpaces: 6);
}
}
}
}
// Source annotation (SourcePath/SourceLine) requires a public
// seam onto TemplateToken position info — not wired yet.
return new JobExecutionViewEntry(
phase: phase,
displayName: displayName,
uses: uses,
run: run,
sourcePath: null,
sourceLine: 0,
id: id,
@if: ifCond,
continueOnError: continueOnError,
timeoutMinutes: timeoutMinutes,
envYaml: envYaml,
withYaml: withYaml,
shell: shell,
workingDirectory: workingDirectory);
}
/// <summary>
/// Auto-generated step IDs are noise in the view: filter them out.
/// The runner's convention (see ExecutionContext) is that auto-
/// generated context names start with <c>__</c>. Only user-authored
/// IDs survive the filter.
/// </summary>
internal static string FilterAuthoredId(string contextName)
{
if (string.IsNullOrWhiteSpace(contextName))
{
return null;
}
if (contextName.StartsWith("__", StringComparison.Ordinal))
{
return null;
}
return contextName;
}
private static bool TryGetMapValue(MappingToken map, string key, out TemplateToken value)
{
foreach (var pair in map)
{
if (pair.Key is StringToken s && string.Equals(s.Value, key, StringComparison.Ordinal))
{
value = pair.Value;
return true;
}
}
value = null;
return false;
}
private static MappingToken FilterMapping(MappingToken source, HashSet<string> excludeKeys)
{
var copy = new MappingToken(source.FileId, source.Line, source.Column);
foreach (var pair in source)
{
if (pair.Key is StringToken sk && excludeKeys.Contains(sk.Value))
{
continue;
}
copy.Add(pair);
}
return copy;
}
internal static string FormatActionReference(ActionStepDefinitionReference reference)
{
switch (reference)
{
case RepositoryPathReference repo:
var path = string.IsNullOrEmpty(repo.Path) ? string.Empty : $"/{repo.Path}";
return string.IsNullOrEmpty(repo.Ref)
? $"{repo.Name}{path}"
: $"{repo.Name}{path}@{repo.Ref}";
case ContainerRegistryReference container:
return container.Image;
default:
return reference.ToString();
}
}
}
}

View File

@@ -0,0 +1,148 @@
using System;
using System.Globalization;
using System.IO;
using GitHub.DistributedTask.ObjectTemplating;
using GitHub.DistributedTask.ObjectTemplating.Tokens;
using GitHub.Runner.Sdk;
using YamlDotNet.Core;
using YamlDotNet.Core.Events;
namespace GitHub.Runner.Worker.Dap
{
/// <summary>
/// Adapts a YamlDotNet <see cref="IEmitter"/> as a DT
/// <see cref="IObjectWriter"/> so a <see cref="TemplateToken"/> DOM
/// can be serialized back to YAML preserving its pre-evaluation form
/// (basic <c>${{ }}</c> expressions are written through verbatim).
///
/// Used by the DAP execution view to surface user-authored step
/// parameters (<c>env:</c>, <c>with:</c>, <c>run:</c>, ...) without
/// any expression substitution.
/// </summary>
internal sealed class TemplateTokenYamlAdapter : IObjectWriter
{
private readonly IEmitter _emitter;
public TemplateTokenYamlAdapter(IEmitter emitter)
{
ArgUtil.NotNull(emitter, nameof(emitter));
_emitter = emitter;
}
public void WriteStart()
{
_emitter.Emit(new StreamStart());
_emitter.Emit(new DocumentStart(null, null, true));
}
public void WriteEnd()
{
_emitter.Emit(new DocumentEnd(true));
_emitter.Emit(new StreamEnd());
}
public void WriteNull() =>
_emitter.Emit(new Scalar(null, null, "null", ScalarStyle.Plain, true, false));
public void WriteBoolean(bool value) =>
_emitter.Emit(new Scalar(null, null, value ? "true" : "false", ScalarStyle.Plain, true, false));
public void WriteNumber(double value) =>
_emitter.Emit(new Scalar(null, null, value.ToString("R", CultureInfo.InvariantCulture), ScalarStyle.Plain, true, false));
public void WriteString(string value)
{
if (value == null)
{
WriteNull();
return;
}
// Multi-line strings render as block literal so embedded
// newlines survive the YAML round trip.
var style = value.IndexOf('\n') >= 0 ? ScalarStyle.Literal : ScalarStyle.Any;
_emitter.Emit(new Scalar(null, null, value, style, true, true));
}
public void WriteSequenceStart() =>
_emitter.Emit(new SequenceStart(null, null, true, SequenceStyle.Any));
public void WriteSequenceEnd() =>
_emitter.Emit(new SequenceEnd());
public void WriteMappingStart() =>
_emitter.Emit(new MappingStart(null, null, true, MappingStyle.Any));
public void WriteMappingEnd() =>
_emitter.Emit(new MappingEnd());
/// <summary>
/// Serialize a TemplateToken to a YAML fragment ready to embed
/// under a parent key. Each non-empty line is prefixed by
/// <paramref name="indentSpaces"/> spaces. Trailing newlines and
/// the YAML stream start/document markers are stripped, so the
/// caller controls line breaks.
/// </summary>
/// <remarks>
/// Empty mappings render as <c>{}</c> and empty sequences as
/// <c>[]</c> via YamlDotNet's flow style fallback for empty
/// collections.
/// </remarks>
internal static string Serialize(TemplateToken token, int indentSpaces)
{
if (indentSpaces < 0)
{
throw new ArgumentOutOfRangeException(nameof(indentSpaces));
}
using var sw = new StringWriter(CultureInfo.InvariantCulture);
var emitter = new Emitter(sw);
var adapter = new TemplateTokenYamlAdapter(emitter);
TemplateWriter.Write(adapter, token);
string raw = sw.ToString();
// Strip YAML document markers ("--- " prefix and "\n..." suffix).
if (raw.StartsWith("--- ", StringComparison.Ordinal))
{
raw = raw.Substring(4);
}
const string DocEndMarker = "\n...";
if (raw.EndsWith(DocEndMarker + "\n", StringComparison.Ordinal))
{
raw = raw.Substring(0, raw.Length - DocEndMarker.Length - 1);
}
else if (raw.EndsWith(DocEndMarker, StringComparison.Ordinal))
{
raw = raw.Substring(0, raw.Length - DocEndMarker.Length);
}
raw = raw.TrimEnd('\n');
if (indentSpaces == 0)
{
return raw;
}
// Re-indent every non-empty line. Empty lines remain empty
// so YAML block-literal blank lines stay valid.
var pad = new string(' ', indentSpaces);
var sb = new System.Text.StringBuilder(raw.Length + indentSpaces * 4);
int i = 0;
while (i < raw.Length)
{
int end = raw.IndexOf('\n', i);
int lineEnd = end < 0 ? raw.Length : end;
if (lineEnd > i)
{
sb.Append(pad);
sb.Append(raw, i, lineEnd - i);
}
if (end < 0)
{
break;
}
sb.Append('\n');
i = end + 1;
}
return sb.ToString();
}
}
}

View File

@@ -0,0 +1,839 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Net.WebSockets;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using GitHub.Runner.Common;
namespace GitHub.Runner.Worker.Dap
{
internal sealed class WebSocketDapBridge : RunnerService, IWebSocketDapBridge
{
internal enum IncomingStreamPrefixKind
{
Unknown,
HttpWebSocketUpgrade,
PreUpgradedWebSocket,
WebSocketReservedBits,
Http2Preface,
TlsClientHello,
}
private const int _bufferSize = 32 * 1024;
private const int _maxHeaderLineLength = 8 * 1024;
private const int _defaultMaxInboundMessageSize = 10 * 1024 * 1024; // 10 MB
private static readonly TimeSpan _keepAliveInterval = TimeSpan.FromSeconds(30);
private static readonly TimeSpan _closeTimeout = TimeSpan.FromSeconds(5);
private static readonly TimeSpan _handshakeTimeout = TimeSpan.FromSeconds(10);
private const string _webSocketAcceptMagic = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
private const int _maxHeaderCount = 64;
private static readonly byte[] _headerEndMarker = new byte[] { (byte)'\r', (byte)'\n', (byte)'\r', (byte)'\n' };
private int _listenPort;
private int _targetPort;
private TcpListener _listener;
private CancellationTokenSource _loopCts;
private Task _acceptLoopTask;
public int MaxInboundMessageSize { get; set; } = _defaultMaxInboundMessageSize;
internal int ListenPort => (_listener?.LocalEndpoint as IPEndPoint)?.Port ?? 0;
public void Start(int listenPort, int targetPort)
{
if (_listener != null)
{
throw new InvalidOperationException("WebSocket DAP bridge already started.");
}
_listenPort = listenPort;
_targetPort = targetPort;
_listener = new TcpListener(IPAddress.Loopback, _listenPort);
_listener.Start();
_loopCts = new CancellationTokenSource();
_acceptLoopTask = AcceptLoopAsync(_loopCts.Token);
Trace.Info($"WebSocket DAP bridge listening on {_listener.LocalEndpoint} -> 127.0.0.1:{_targetPort}");
}
public async Task ShutdownAsync()
{
_loopCts?.Cancel();
try
{
_listener?.Stop();
}
catch (Exception ex)
{
Trace.Warning($"Error stopping listener during shutdown ({ex.GetType().Name})");
}
if (_acceptLoopTask != null)
{
try
{
await _acceptLoopTask;
}
catch (OperationCanceledException)
{
// expected on shutdown
}
}
_loopCts?.Dispose();
_loopCts = null;
_listener = null;
_acceptLoopTask = null;
}
private async Task AcceptLoopAsync(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
TcpClient client = null;
try
{
client = await _listener.AcceptTcpClientAsync(cancellationToken);
client.NoDelay = true;
await HandleClientAsync(client, cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
client?.Dispose();
Trace.Error($"WebSocket DAP bridge connection error");
Trace.Error(ex);
}
finally
{
client?.Dispose();
}
}
Trace.Info("WebSocket DAP bridge accept loop ended");
}
private async Task HandleClientAsync(TcpClient incomingClient, CancellationToken cancellationToken)
{
using (var incomingStream = incomingClient.GetStream())
{
Trace.Info($"WebSocket DAP bridge accepted client {incomingClient.Client.RemoteEndPoint}");
WebSocket webSocket;
using (var handshakeCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken))
{
handshakeCts.CancelAfter(_handshakeTimeout);
try
{
webSocket = await AcceptWebSocketAsync(incomingStream, handshakeCts.Token);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
Trace.Warning("WebSocket handshake timed out");
return;
}
}
if (webSocket == null)
{
return;
}
using (webSocket)
using (var dapClient = new TcpClient())
{
dapClient.NoDelay = true;
await dapClient.ConnectAsync(IPAddress.Loopback, _targetPort, cancellationToken);
using (var dapStream = dapClient.GetStream())
using (var sessionCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken))
{
var proxyToken = sessionCts.Token;
var wsToTcpTask = PumpWebSocketToTcpAsync(webSocket, dapStream, proxyToken);
var tcpToWsTask = PumpTcpToWebSocketAsync(dapStream, webSocket, proxyToken);
await Task.WhenAny(wsToTcpTask, tcpToWsTask);
sessionCts.Cancel();
await CloseWebSocketAsync(webSocket);
try
{
await Task.WhenAll(wsToTcpTask, tcpToWsTask);
}
catch (OperationCanceledException) when (proxyToken.IsCancellationRequested)
{
// expected during shutdown
}
catch (Exception ex)
{
Trace.Warning($"DAP protocol error: {ex}");
}
}
}
}
}
private async Task<WebSocket> AcceptWebSocketAsync(NetworkStream stream, CancellationToken cancellationToken)
{
var initialBytes = await ReadInitialBytesAsync(stream, cancellationToken);
if (initialBytes == null || initialBytes.Length == 0)
{
return null;
}
var prefixKind = ClassifyIncomingStreamPrefix(initialBytes);
if (prefixKind == IncomingStreamPrefixKind.PreUpgradedWebSocket)
{
Trace.Info($"Treating incoming tunnel stream as an already-upgraded websocket connection ({DescribeInitialBytes(initialBytes)})");
return WebSocket.CreateFromStream(
new ReplayableStream(stream, initialBytes),
isServer: true,
subProtocol: null,
keepAliveInterval: _keepAliveInterval);
}
if (prefixKind != IncomingStreamPrefixKind.HttpWebSocketUpgrade)
{
Trace.Warning($"Unsupported debugger tunnel stream prefix ({prefixKind}): {DescribeInitialBytes(initialBytes)}");
return null;
}
var handshakeStream = new ReplayableStream(stream, initialBytes);
var requestLine = await ReadLineAsync(handshakeStream, cancellationToken);
if (string.IsNullOrEmpty(requestLine))
{
return null;
}
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
while (!cancellationToken.IsCancellationRequested)
{
if (headers.Count >= _maxHeaderCount)
{
Trace.Warning($"Rejected WebSocket request with too many headers (>{_maxHeaderCount})");
await WriteHttpErrorAsync(stream, HttpStatusCode.BadRequest, "Too many headers.", cancellationToken);
return null;
}
var line = await ReadLineAsync(handshakeStream, cancellationToken);
if (line == null)
{
return null;
}
if (line.Length == 0)
{
break;
}
var separatorIndex = line.IndexOf(':');
if (separatorIndex <= 0)
{
await WriteHttpErrorAsync(stream, HttpStatusCode.BadRequest, "Invalid HTTP header.", cancellationToken);
return null;
}
var headerName = line.Substring(0, separatorIndex).Trim();
var headerValue = line.Substring(separatorIndex + 1).Trim();
if (headers.TryGetValue(headerName, out var existingValue))
{
headers[headerName] = $"{existingValue}, {headerValue}";
}
else
{
headers[headerName] = headerValue;
}
}
if (!IsValidWebSocketRequest(requestLine, headers))
{
var method = requestLine.Split(' ')[0];
Trace.Info($"Rejected non-websocket request (method={method})");
await WriteHttpErrorAsync(stream, HttpStatusCode.BadRequest, "Expected a websocket upgrade request.", cancellationToken);
return null;
}
if (!headers.TryGetValue("Sec-WebSocket-Version", out var webSocketVersion) ||
!string.Equals(webSocketVersion.Trim(), "13", StringComparison.Ordinal))
{
Trace.Warning("Rejected WebSocket request with unsupported version");
await WriteHttpErrorAsync(stream, (HttpStatusCode)426, "Unsupported WebSocket version. Expected: 13.", cancellationToken);
return null;
}
var webSocketKey = headers["Sec-WebSocket-Key"];
if (!IsValidWebSocketKey(webSocketKey))
{
Trace.Warning("Rejected WebSocket request with invalid Sec-WebSocket-Key");
await WriteHttpErrorAsync(stream, HttpStatusCode.BadRequest, "Invalid Sec-WebSocket-Key.", cancellationToken);
return null;
}
var acceptValue = ComputeAcceptValue(webSocketKey);
var responseBytes = Encoding.ASCII.GetBytes(
"HTTP/1.1 101 Switching Protocols\r\n" +
"Connection: Upgrade\r\n" +
"Upgrade: websocket\r\n" +
$"Sec-WebSocket-Accept: {acceptValue}\r\n" +
"\r\n");
await handshakeStream.WriteAsync(responseBytes, 0, responseBytes.Length, cancellationToken);
await handshakeStream.FlushAsync(cancellationToken);
Trace.Info("WebSocket DAP bridge completed websocket handshake");
return WebSocket.CreateFromStream(handshakeStream, isServer: true, subProtocol: null, keepAliveInterval: _keepAliveInterval);
}
private async Task PumpWebSocketToTcpAsync(WebSocket source, NetworkStream destination, CancellationToken cancellationToken)
{
var buffer = new byte[_bufferSize];
while (!cancellationToken.IsCancellationRequested)
{
using (var messageStream = new MemoryStream())
{
WebSocketReceiveResult result;
do
{
result = await source.ReceiveAsync(new ArraySegment<byte>(buffer), cancellationToken);
if (result.MessageType == WebSocketMessageType.Close)
{
return;
}
if (result.MessageType != WebSocketMessageType.Binary &&
result.MessageType != WebSocketMessageType.Text)
{
break;
}
if (result.Count > 0)
{
if (messageStream.Length + result.Count > MaxInboundMessageSize)
{
Trace.Warning($"WebSocket message exceeds maximum allowed size of {MaxInboundMessageSize} bytes, closing connection");
await source.CloseAsync(
WebSocketCloseStatus.MessageTooBig,
$"Message exceeds {MaxInboundMessageSize} byte limit",
CancellationToken.None);
return;
}
messageStream.Write(buffer, 0, result.Count);
}
}
while (!result.EndOfMessage && !cancellationToken.IsCancellationRequested);
if (result.MessageType != WebSocketMessageType.Binary &&
result.MessageType != WebSocketMessageType.Text)
{
continue;
}
var messageBytes = messageStream.ToArray();
if (messageBytes.Length == 0)
{
continue;
}
var contentLengthHeader = Encoding.ASCII.GetBytes($"Content-Length: {messageBytes.Length}\r\n\r\n");
await destination.WriteAsync(contentLengthHeader, 0, contentLengthHeader.Length, cancellationToken);
await destination.WriteAsync(messageBytes, 0, messageBytes.Length, cancellationToken);
await destination.FlushAsync(cancellationToken);
}
}
}
private static async Task PumpTcpToWebSocketAsync(NetworkStream source, WebSocket destination, CancellationToken cancellationToken)
{
var readBuffer = new byte[_bufferSize];
var dapBuffer = new List<byte>();
while (!cancellationToken.IsCancellationRequested)
{
var bytesRead = await source.ReadAsync(readBuffer, 0, readBuffer.Length, cancellationToken);
if (bytesRead == 0)
{
break;
}
dapBuffer.AddRange(new ArraySegment<byte>(readBuffer, 0, bytesRead));
while (TryParseDapMessage(dapBuffer, out var messageBody))
{
await destination.SendAsync(
new ArraySegment<byte>(messageBody),
WebSocketMessageType.Text,
endOfMessage: true,
cancellationToken);
}
}
}
private static bool TryParseDapMessage(List<byte> buffer, out byte[] messageBody)
{
messageBody = null;
var headerEndIndex = FindSequence(buffer, _headerEndMarker);
if (headerEndIndex == -1)
{
return false;
}
var headerBytes = buffer.GetRange(0, headerEndIndex).ToArray();
var headerText = Encoding.ASCII.GetString(headerBytes);
var contentLength = -1;
foreach (var line in headerText.Split(new[] { "\r\n" }, StringSplitOptions.RemoveEmptyEntries))
{
if (line.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase))
{
var valueStart = line.IndexOf(':') + 1;
if (int.TryParse(line.Substring(valueStart).Trim(), out var parsedLength))
{
contentLength = parsedLength;
break;
}
}
}
if (contentLength < 0)
{
throw new InvalidOperationException("DAP message missing or unparseable Content-Length header; tearing down session.");
}
var messageStart = headerEndIndex + 4;
var messageEnd = messageStart + contentLength;
if (buffer.Count < messageEnd)
{
return false;
}
messageBody = buffer.GetRange(messageStart, contentLength).ToArray();
buffer.RemoveRange(0, messageEnd);
return true;
}
private static int FindSequence(List<byte> buffer, byte[] sequence)
{
if (buffer.Count < sequence.Length)
{
return -1;
}
for (int i = 0; i <= buffer.Count - sequence.Length; i++)
{
var match = true;
for (int j = 0; j < sequence.Length; j++)
{
if (buffer[i + j] != sequence[j])
{
match = false;
break;
}
}
if (match)
{
return i;
}
}
return -1;
}
private static bool IsValidWebSocketRequest(string requestLine, IDictionary<string, string> headers)
{
if (string.IsNullOrWhiteSpace(requestLine))
{
return false;
}
var requestLineParts = requestLine.Split(' ');
if (requestLineParts.Length < 3 || !string.Equals(requestLineParts[0], "GET", StringComparison.OrdinalIgnoreCase))
{
return false;
}
return HeaderContainsToken(headers, "Connection", "Upgrade") &&
HeaderContainsToken(headers, "Upgrade", "websocket") &&
headers.ContainsKey("Sec-WebSocket-Key");
}
private static bool HeaderContainsToken(IDictionary<string, string> headers, string headerName, string expectedToken)
{
if (!headers.TryGetValue(headerName, out var headerValue) || string.IsNullOrWhiteSpace(headerValue))
{
return false;
}
return headerValue
.Split(',')
.Select(token => token.Trim())
.Any(token => string.Equals(token, expectedToken, StringComparison.OrdinalIgnoreCase));
}
private static string ComputeAcceptValue(string webSocketKey)
{
using (var sha1 = SHA1.Create())
{
var inputBytes = Encoding.ASCII.GetBytes($"{webSocketKey}{_webSocketAcceptMagic}");
var hashBytes = sha1.ComputeHash(inputBytes);
return Convert.ToBase64String(hashBytes);
}
}
private static bool IsValidWebSocketKey(string key)
{
if (string.IsNullOrEmpty(key) || key.IndexOfAny(new[] { '\r', '\n' }) >= 0)
{
return false;
}
try
{
var decoded = Convert.FromBase64String(key);
return decoded.Length == 16;
}
catch (FormatException)
{
return false;
}
}
private static async Task<string> ReadLineAsync(Stream stream, CancellationToken cancellationToken)
{
var lineBuilder = new StringBuilder();
var buffer = new byte[1];
var previousWasCarriageReturn = false;
while (true)
{
var bytesRead = await stream.ReadAsync(buffer, 0, 1, cancellationToken);
if (bytesRead == 0)
{
return lineBuilder.Length > 0 ? lineBuilder.ToString() : null;
}
var currentChar = (char)buffer[0];
if (currentChar == '\n' && previousWasCarriageReturn)
{
if (lineBuilder.Length > 0 && lineBuilder[lineBuilder.Length - 1] == '\r')
{
lineBuilder.Length--;
}
return lineBuilder.ToString();
}
previousWasCarriageReturn = currentChar == '\r';
lineBuilder.Append(currentChar);
if (lineBuilder.Length > _maxHeaderLineLength)
{
throw new InvalidDataException($"HTTP header line exceeds maximum length of {_maxHeaderLineLength}");
}
}
}
private static async Task<byte[]> ReadInitialBytesAsync(NetworkStream stream, CancellationToken cancellationToken)
{
var buffer = new byte[4];
var totalRead = 0;
while (totalRead < buffer.Length)
{
var bytesRead = await stream.ReadAsync(buffer, totalRead, buffer.Length - totalRead, cancellationToken);
if (bytesRead == 0)
{
break;
}
totalRead += bytesRead;
}
if (totalRead == 0)
{
return Array.Empty<byte>();
}
if (totalRead == buffer.Length)
{
return buffer;
}
var initialBytes = new byte[totalRead];
Array.Copy(buffer, initialBytes, totalRead);
return initialBytes;
}
internal static IncomingStreamPrefixKind ClassifyIncomingStreamPrefix(byte[] initialBytes)
{
if (LooksLikeHttpUpgrade(initialBytes))
{
return IncomingStreamPrefixKind.HttpWebSocketUpgrade;
}
if (LooksLikeHttp2Preface(initialBytes))
{
return IncomingStreamPrefixKind.Http2Preface;
}
if (LooksLikeTlsClientHello(initialBytes))
{
return IncomingStreamPrefixKind.TlsClientHello;
}
if (LooksLikeWebSocketFramePrefix(initialBytes, requireReservedBitsClear: false))
{
return HasReservedBitsSet(initialBytes[0])
? IncomingStreamPrefixKind.WebSocketReservedBits
: IncomingStreamPrefixKind.PreUpgradedWebSocket;
}
return IncomingStreamPrefixKind.Unknown;
}
internal static string DescribeInitialBytes(byte[] initialBytes)
{
if (initialBytes == null || initialBytes.Length == 0)
{
return "no bytes read";
}
var hex = BitConverter.ToString(initialBytes);
var ascii = new string(initialBytes.Select(value => value >= 32 && value <= 126 ? (char)value : '.').ToArray());
return $"hex={hex}, ascii=\"{ascii}\"";
}
private static bool LooksLikeHttpUpgrade(byte[] initialBytes)
{
if (initialBytes == null || initialBytes.Length < 4)
{
return false;
}
return initialBytes[0] == (byte)'G' &&
initialBytes[1] == (byte)'E' &&
initialBytes[2] == (byte)'T' &&
initialBytes[3] == (byte)' ';
}
private static bool LooksLikeHttp2Preface(byte[] initialBytes)
{
if (initialBytes == null || initialBytes.Length < 4)
{
return false;
}
return initialBytes[0] == (byte)'P' &&
initialBytes[1] == (byte)'R' &&
initialBytes[2] == (byte)'I' &&
initialBytes[3] == (byte)' ';
}
private static bool LooksLikeTlsClientHello(byte[] initialBytes)
{
if (initialBytes == null || initialBytes.Length < 3)
{
return false;
}
return initialBytes[0] == 0x16 &&
initialBytes[1] == 0x03 &&
initialBytes[2] >= 0x00 &&
initialBytes[2] <= 0x04;
}
private static bool LooksLikeWebSocketFramePrefix(byte[] initialBytes, bool requireReservedBitsClear)
{
if (initialBytes == null || initialBytes.Length < 2)
{
return false;
}
var firstByte = initialBytes[0];
var secondByte = initialBytes[1];
var opcode = firstByte & 0x0F;
var isMasked = (secondByte & 0x80) != 0;
if (!isMasked || !IsSupportedWebSocketOpcode(opcode))
{
return false;
}
return !requireReservedBitsClear || !HasReservedBitsSet(firstByte);
}
private static bool HasReservedBitsSet(byte firstByte)
{
return (firstByte & 0x70) != 0;
}
private static bool IsSupportedWebSocketOpcode(int opcode)
{
switch (opcode)
{
case 0x0:
case 0x1:
case 0x2:
case 0x8:
case 0x9:
case 0xA:
return true;
default:
return false;
}
}
private static async Task WriteHttpErrorAsync(
NetworkStream stream,
HttpStatusCode statusCode,
string message,
CancellationToken cancellationToken)
{
var bodyBytes = Encoding.UTF8.GetBytes(message);
var responseBytes = Encoding.ASCII.GetBytes(
$"HTTP/1.1 {(int)statusCode} {statusCode}\r\n" +
"Connection: close\r\n" +
"Content-Type: text/plain; charset=utf-8\r\n" +
$"Content-Length: {bodyBytes.Length}\r\n" +
"Sec-WebSocket-Version: 13\r\n" +
"\r\n");
await stream.WriteAsync(responseBytes, 0, responseBytes.Length, cancellationToken);
await stream.WriteAsync(bodyBytes, 0, bodyBytes.Length, cancellationToken);
await stream.FlushAsync(cancellationToken);
}
private static async Task CloseWebSocketAsync(WebSocket webSocket)
{
if (webSocket == null)
{
return;
}
if (webSocket.State != WebSocketState.Open &&
webSocket.State != WebSocketState.CloseReceived)
{
return;
}
try
{
using var cts = new CancellationTokenSource(_closeTimeout);
await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, string.Empty, cts.Token);
}
catch (OperationCanceledException)
{
// Graceful close timed out, abort the connection.
webSocket.Abort();
}
catch (WebSocketException)
{
// Peer already disconnected.
}
}
private sealed class ReplayableStream : Stream
{
private readonly Stream _innerStream;
private readonly byte[] _prefixBytes;
private int _prefixOffset;
public ReplayableStream(Stream innerStream, byte[] prefixBytes)
{
_innerStream = innerStream ?? throw new ArgumentNullException(nameof(innerStream));
_prefixBytes = prefixBytes ?? Array.Empty<byte>();
}
public override bool CanRead => _innerStream.CanRead;
public override bool CanSeek => false;
public override bool CanWrite => _innerStream.CanWrite;
public override long Length => throw new NotSupportedException();
public override long Position
{
get => throw new NotSupportedException();
set => throw new NotSupportedException();
}
public override void Flush() => _innerStream.Flush();
public override Task FlushAsync(CancellationToken cancellationToken) => _innerStream.FlushAsync(cancellationToken);
public override int Read(byte[] buffer, int offset, int count)
{
if (TryReadPrefix(buffer, offset, count, out var bytesRead))
{
return bytesRead;
}
return _innerStream.Read(buffer, offset, count);
}
public override async Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
if (TryReadPrefix(buffer, offset, count, out var bytesRead))
{
return bytesRead;
}
return await _innerStream.ReadAsync(buffer, offset, count, cancellationToken);
}
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{
if (_prefixOffset < _prefixBytes.Length)
{
var bytesToCopy = Math.Min(buffer.Length, _prefixBytes.Length - _prefixOffset);
new ReadOnlySpan<byte>(_prefixBytes, _prefixOffset, bytesToCopy).CopyTo(buffer.Span);
_prefixOffset += bytesToCopy;
return bytesToCopy;
}
return await _innerStream.ReadAsync(buffer, cancellationToken);
}
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => _innerStream.Write(buffer, offset, count);
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) =>
_innerStream.WriteAsync(buffer, offset, count, cancellationToken);
public override ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default) =>
_innerStream.WriteAsync(buffer, cancellationToken);
private bool TryReadPrefix(byte[] buffer, int offset, int count, out int bytesRead)
{
if (_prefixOffset >= _prefixBytes.Length)
{
bytesRead = 0;
return false;
}
bytesRead = Math.Min(count, _prefixBytes.Length - _prefixOffset);
Array.Copy(_prefixBytes, _prefixOffset, buffer, offset, bytesRead);
_prefixOffset += bytesRead;
return true;
}
}
}
}

View File

@@ -77,8 +77,7 @@ namespace GitHub.Runner.Worker
List<string> StepEnvironmentOverrides { get; }
ExecutionContext Root { get; }
ExecutionContext Parent { get; }
IExecutionContext Root { get; }
// Initialize
void InitializeJob(Pipelines.AgentJobRequestMessage message, CancellationToken token);
@@ -251,7 +250,9 @@ namespace GitHub.Runner.Worker
}
}
public ExecutionContext Root
IExecutionContext IExecutionContext.Root => Root;
private ExecutionContext Root
{
get
{
@@ -266,13 +267,7 @@ namespace GitHub.Runner.Worker
}
}
public ExecutionContext Parent
{
get
{
return _parentExecutionContext;
}
}
public JobContext JobContext
{
@@ -343,6 +338,14 @@ namespace GitHub.Runner.Worker
step.ExecutionContext = Root.CreatePostChild(step.DisplayName, IntraActionState, siblingScopeName);
Root.PostJobSteps.Push(step);
// Only consult the DAP debugger when it was actually enabled for this job.
// Without this guard, HostContext.GetService<IDapDebugger>() would auto-
// instantiate the default singleton for every non-debug job, violating the
// "no debugger, no risk" containment property.
if (Global.Debugger?.Enabled == true)
{
HostContext.GetService<Dap.IDapDebugger>().OnPostStepRegistered(step);
}
}
public IExecutionContext CreateChild(
@@ -859,6 +862,12 @@ namespace GitHub.Runner.Worker
// Track Node.js 20 actions for deprecation warning
Global.DeprecatedNode20Actions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// Track actions upgraded from Node.js 20 to Node.js 24
Global.UpgradedToNode24Actions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// Track actions stuck on Node.js 20 due to ARM32 (separate from general deprecation)
Global.Arm32Node20Actions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// Job Outputs
JobOutputs = new Dictionary<string, VariableValue>(StringComparer.OrdinalIgnoreCase);
@@ -874,6 +883,9 @@ namespace GitHub.Runner.Worker
// File table
Global.FileTable = new List<String>(message.FileTable ?? new string[0]);
// Workflow dependencies (lockfile pins)
Global.ActionsDependencies = message.ActionsDependencies;
// What type of job request is running (i.e. Run Service vs. pipelines)
Global.Variables.Set(Constants.Variables.System.JobRequestType, message.MessageType);
@@ -891,15 +903,12 @@ namespace GitHub.Runner.Worker
Trace.Info("Initializing Job context");
var jobContext = new JobContext();
if (Global.Variables.GetBoolean(Constants.Runner.Features.AddCheckRunIdToJobContext) ?? false)
ExpressionValues.TryGetValue("job", out var jobDictionary);
if (jobDictionary != null)
{
ExpressionValues.TryGetValue("job", out var jobDictionary);
if (jobDictionary != null)
foreach (var pair in jobDictionary.AssertDictionary("job"))
{
foreach (var pair in jobDictionary.AssertDictionary("job"))
{
jobContext[pair.Key] = pair.Value;
}
jobContext[pair.Key] = pair.Value;
}
}
ExpressionValues["job"] = jobContext;
@@ -968,6 +977,9 @@ namespace GitHub.Runner.Worker
// Verbosity (from GitHub.Step_Debug).
Global.WriteDebug = Global.Variables.Step_Debug ?? false;
// Debugger enabled flag (from acquire response).
Global.Debugger = new Dap.DebuggerConfig(message.EnableDebugger, message.DebuggerTunnel);
// Hook up JobServerQueueThrottling event, we will log warning on server tarpit.
_jobServerQueue.JobServerQueueThrottling += JobServerQueueThrottling_EventReceived;
}
@@ -1328,9 +1340,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 +1430,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 +1448,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

@@ -4,6 +4,7 @@ using GitHub.Actions.RunService.WebApi;
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common.Util;
using GitHub.Runner.Worker.Container;
using GitHub.Runner.Worker.Dap;
using Newtonsoft.Json.Linq;
using Sdk.RSWebApi.Contracts;
@@ -27,6 +28,7 @@ namespace GitHub.Runner.Worker
public StepsContext StepsContext { get; set; }
public Variables Variables { get; set; }
public bool WriteDebug { get; set; }
public DebuggerConfig Debugger { get; set; }
public string InfrastructureFailureCategory { get; set; }
public JObject ContainerHookState { get; set; }
public bool HasTemplateEvaluatorMismatch { get; set; }
@@ -34,5 +36,8 @@ namespace GitHub.Runner.Worker
public bool HasDeprecatedSetOutput { get; set; }
public bool HasDeprecatedSaveState { get; set; }
public HashSet<string> DeprecatedNode20Actions { get; set; }
public HashSet<string> UpgradedToNode24Actions { get; set; }
public HashSet<string> Arm32Node20Actions { get; set; }
public IList<String> ActionsDependencies { 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

@@ -25,6 +25,14 @@ namespace GitHub.Runner.Worker.Handlers
public sealed class HandlerFactory : RunnerService, IHandlerFactory
{
internal static bool ShouldTrackAsArm32Node20(bool deprecateArm32, string preferredNodeVersion, string finalNodeVersion, string platformWarningMessage)
{
return deprecateArm32 &&
!string.IsNullOrEmpty(platformWarningMessage) &&
string.Equals(preferredNodeVersion, Constants.Runner.NodeMigration.Node24, StringComparison.OrdinalIgnoreCase) &&
string.Equals(finalNodeVersion, Constants.Runner.NodeMigration.Node20, StringComparison.OrdinalIgnoreCase);
}
public IHandler Create(
IExecutionContext executionContext,
Pipelines.ActionStepDefinitionReference action,
@@ -65,19 +73,12 @@ namespace GitHub.Runner.Worker.Handlers
nodeData.NodeVersion = Common.Constants.Runner.NodeMigration.Node20;
}
// Track Node.js 20 actions for deprecation annotation
if (string.Equals(nodeData.NodeVersion, Constants.Runner.NodeMigration.Node20, StringComparison.InvariantCultureIgnoreCase))
{
bool warnOnNode20 = executionContext.Global.Variables?.GetBoolean(Constants.Runner.NodeMigration.WarnOnNode20Flag) ?? false;
if (warnOnNode20)
{
string actionName = GetActionName(action);
if (!string.IsNullOrEmpty(actionName))
{
executionContext.Global.DeprecatedNode20Actions?.Add(actionName);
}
}
}
// Read flags early; actionName is also resolved up front for tracking after version is determined
bool warnOnNode20 = executionContext.Global.Variables?.GetBoolean(Constants.Runner.NodeMigration.WarnOnNode20Flag) ?? false;
bool deprecateArm32 = executionContext.Global.Variables?.GetBoolean(Constants.Runner.NodeMigration.DeprecateLinuxArm32Flag) ?? false;
bool killArm32 = executionContext.Global.Variables?.GetBoolean(Constants.Runner.NodeMigration.KillLinuxArm32Flag) ?? false;
string node20RemovalDate = executionContext.Global.Variables?.Get(Constants.Runner.NodeMigration.Node20RemovalDateVariable);
string actionName = GetActionName(action);
// Check if node20 was explicitly specified in the action
// We don't modify if node24 was explicitly specified
@@ -87,7 +88,15 @@ namespace GitHub.Runner.Worker.Handlers
bool requireNode24 = executionContext.Global.Variables?.GetBoolean(Constants.Runner.NodeMigration.RequireNode24Flag) ?? false;
var (nodeVersion, configWarningMessage) = NodeUtil.DetermineActionsNodeVersion(environment, useNode24ByDefault, requireNode24);
var (finalNodeVersion, platformWarningMessage) = NodeUtil.CheckNodeVersionForLinuxArm32(nodeVersion);
var (finalNodeVersion, platformWarningMessage) = NodeUtil.CheckNodeVersionForLinuxArm32(nodeVersion, deprecateArm32, killArm32, node20RemovalDate);
// ARM32 kill switch: fail the step
if (finalNodeVersion == null)
{
executionContext.Error(platformWarningMessage);
throw new InvalidOperationException(platformWarningMessage);
}
nodeData.NodeVersion = finalNodeVersion;
if (!string.IsNullOrEmpty(configWarningMessage))
@@ -100,6 +109,26 @@ namespace GitHub.Runner.Worker.Handlers
executionContext.Warning(platformWarningMessage);
}
// Track actions based on their final node version
if (!string.IsNullOrEmpty(actionName))
{
if (string.Equals(finalNodeVersion, Constants.Runner.NodeMigration.Node24, StringComparison.OrdinalIgnoreCase))
{
// Action was upgraded from node20 to node24
executionContext.Global.UpgradedToNode24Actions?.Add(actionName);
}
else if (ShouldTrackAsArm32Node20(deprecateArm32, nodeVersion, finalNodeVersion, platformWarningMessage))
{
// Action is on node20 because ARM32 can't run node24
executionContext.Global.Arm32Node20Actions?.Add(actionName);
}
else if (warnOnNode20)
{
// Action is still running on node20 (general case)
executionContext.Global.DeprecatedNode20Actions?.Add(actionName);
}
}
// Show information about Node 24 migration in Phase 2
if (useNode24ByDefault && !requireNode24 && string.Equals(finalNodeVersion, Constants.Runner.NodeMigration.Node24, StringComparison.OrdinalIgnoreCase))
{
@@ -109,6 +138,30 @@ namespace GitHub.Runner.Worker.Handlers
executionContext.Output(infoMessage);
}
}
else if (string.Equals(nodeData.NodeVersion, Constants.Runner.NodeMigration.Node24, StringComparison.InvariantCultureIgnoreCase))
{
var (finalNodeVersion, platformWarningMessage) = NodeUtil.CheckNodeVersionForLinuxArm32(nodeData.NodeVersion, deprecateArm32, killArm32, node20RemovalDate);
// ARM32 kill switch: fail the step
if (finalNodeVersion == null)
{
executionContext.Error(platformWarningMessage);
throw new InvalidOperationException(platformWarningMessage);
}
var preferredVersion = nodeData.NodeVersion;
nodeData.NodeVersion = finalNodeVersion;
if (!string.IsNullOrEmpty(platformWarningMessage))
{
executionContext.Warning(platformWarningMessage);
}
if (!string.IsNullOrEmpty(actionName) && ShouldTrackAsArm32Node20(deprecateArm32, preferredVersion, finalNodeVersion, platformWarningMessage))
{
executionContext.Global.Arm32Node20Actions?.Add(actionName);
}
}
(handler as INodeScriptActionHandler).Data = nodeData;
}

View File

@@ -58,13 +58,23 @@ namespace GitHub.Runner.Worker.Handlers
public Task<string> DetermineNodeRuntimeVersion(IExecutionContext executionContext, string preferredVersion)
{
// Use NodeUtil to check if Node24 is requested but we're on ARM32 Linux
var (nodeVersion, warningMessage) = Common.Util.NodeUtil.CheckNodeVersionForLinuxArm32(preferredVersion);
bool deprecateArm32 = executionContext.Global.Variables?.GetBoolean(Constants.Runner.NodeMigration.DeprecateLinuxArm32Flag) ?? false;
bool killArm32 = executionContext.Global.Variables?.GetBoolean(Constants.Runner.NodeMigration.KillLinuxArm32Flag) ?? false;
string node20RemovalDate = executionContext.Global.Variables?.Get(Constants.Runner.NodeMigration.Node20RemovalDateVariable);
var (nodeVersion, warningMessage) = Common.Util.NodeUtil.CheckNodeVersionForLinuxArm32(preferredVersion, deprecateArm32, killArm32, node20RemovalDate);
if (nodeVersion == null)
{
executionContext.Error(warningMessage);
throw new InvalidOperationException(warningMessage);
}
if (!string.IsNullOrEmpty(warningMessage))
{
executionContext.Warning(warningMessage);
}
return Task.FromResult(nodeVersion);
}
@@ -142,8 +152,18 @@ namespace GitHub.Runner.Worker.Handlers
public async Task<string> DetermineNodeRuntimeVersion(IExecutionContext executionContext, string preferredVersion)
{
// Use NodeUtil to check if Node24 is requested but we're on ARM32 Linux
var (nodeExternal, warningMessage) = Common.Util.NodeUtil.CheckNodeVersionForLinuxArm32(preferredVersion);
bool deprecateArm32 = executionContext.Global.Variables?.GetBoolean(Constants.Runner.NodeMigration.DeprecateLinuxArm32Flag) ?? false;
bool killArm32 = executionContext.Global.Variables?.GetBoolean(Constants.Runner.NodeMigration.KillLinuxArm32Flag) ?? false;
string node20RemovalDate = executionContext.Global.Variables?.Get(Constants.Runner.NodeMigration.Node20RemovalDateVariable);
var (nodeExternal, warningMessage) = Common.Util.NodeUtil.CheckNodeVersionForLinuxArm32(preferredVersion, deprecateArm32, killArm32, node20RemovalDate);
if (nodeExternal == null)
{
executionContext.Error(warningMessage);
throw new InvalidOperationException(warningMessage);
}
if (!string.IsNullOrEmpty(warningMessage))
{
executionContext.Warning(warningMessage);
@@ -273,8 +293,18 @@ namespace GitHub.Runner.Worker.Handlers
private string CheckPlatformForAlpineContainer(IExecutionContext executionContext, string preferredVersion)
{
// Use NodeUtil to check if Node24 is requested but we're on ARM32 Linux
var (nodeExternal, warningMessage) = Common.Util.NodeUtil.CheckNodeVersionForLinuxArm32(preferredVersion);
bool deprecateArm32 = executionContext.Global.Variables?.GetBoolean(Constants.Runner.NodeMigration.DeprecateLinuxArm32Flag) ?? false;
bool killArm32 = executionContext.Global.Variables?.GetBoolean(Constants.Runner.NodeMigration.KillLinuxArm32Flag) ?? false;
string node20RemovalDate = executionContext.Global.Variables?.Get(Constants.Runner.NodeMigration.Node20RemovalDateVariable);
var (nodeExternal, warningMessage) = Common.Util.NodeUtil.CheckNodeVersionForLinuxArm32(preferredVersion, deprecateArm32, killArm32, node20RemovalDate);
if (nodeExternal == null)
{
executionContext.Error(warningMessage);
throw new InvalidOperationException(warningMessage);
}
if (!string.IsNullOrEmpty(warningMessage))
{
executionContext.Warning(warningMessage);

View File

@@ -1,3 +1,4 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Test")]
[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")]

View File

@@ -82,5 +82,69 @@ namespace GitHub.Runner.Worker
}
}
}
public string WorkflowRef
{
get
{
if (this.TryGetValue("workflow_ref", out var value) && value is StringContextData str)
{
return str.Value;
}
return null;
}
set
{
this["workflow_ref"] = value != null ? new StringContextData(value) : null;
}
}
public string WorkflowSha
{
get
{
if (this.TryGetValue("workflow_sha", out var value) && value is StringContextData str)
{
return str.Value;
}
return null;
}
set
{
this["workflow_sha"] = value != null ? new StringContextData(value) : null;
}
}
public string WorkflowRepository
{
get
{
if (this.TryGetValue("workflow_repository", out var value) && value is StringContextData str)
{
return str.Value;
}
return null;
}
set
{
this["workflow_repository"] = value != null ? new StringContextData(value) : null;
}
}
public string WorkflowFilePath
{
get
{
if (this.TryGetValue("workflow_file_path", out var value) && value is StringContextData str)
{
return str.Value;
}
return null;
}
set
{
this["workflow_file_path"] = value != null ? new StringContextData(value) : null;
}
}
}
}

View File

@@ -16,6 +16,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 Newtonsoft.Json;
using Pipelines = GitHub.DistributedTask.Pipelines;
@@ -50,6 +51,7 @@ namespace GitHub.Runner.Worker
private Task _diskSpaceCheckTask = null;
private CancellationTokenSource _serviceConnectivityCheckToken = new();
private Task _serviceConnectivityCheckTask = null;
private IDapDebugger _dapDebugger;
// Download all required actions.
// Make sure all condition inputs are valid.
@@ -67,6 +69,7 @@ namespace GitHub.Runner.Worker
List<IStep> preJobSteps = new();
List<IStep> jobSteps = new();
var initSucceeded = false;
using (var register = jobContext.CancellationToken.Register(() => { context.CancelToken(); }))
{
try
@@ -77,20 +80,25 @@ namespace GitHub.Runner.Worker
var setting = HostContext.GetService<IConfigurationStore>().GetSettings();
var credFile = HostContext.GetConfigFile(WellKnownConfigFile.Credentials);
if (File.Exists(credFile))
var credData = File.Exists(credFile) ? IOUtil.LoadObject<CredentialData>(credFile) : null;
// self-hosted runner is the only runner type using OAuth, can be identified via clientId
if (credData != null &&
credData.Data.TryGetValue("clientId", out _))
{
var credData = IOUtil.LoadObject<CredentialData>(credFile);
if (credData != null &&
credData.Data.TryGetValue("clientId", out var clientId))
context.Output($"Runner name: '{setting.AgentName}'");
// use system variable for group name since self-hosted runners can be renamed
if (message.Variables.TryGetValue("system.runnerGroupName", out VariableValue runnerGroupName))
{
// print out HostName for self-hosted runner
context.Output($"Runner name: '{setting.AgentName}'");
if (message.Variables.TryGetValue("system.runnerGroupName", out VariableValue runnerGroupName))
{
context.Output($"Runner group name: '{runnerGroupName.Value}'");
}
context.Output($"Machine name: '{Environment.MachineName}'");
context.Output($"Runner group name: '{runnerGroupName.Value}'");
}
// print out machine name for self-hosted runner
context.Output($"Machine name: '{Environment.MachineName}'");
}
// print runner info for lhr runners, skips standard runners (PoolId = 0)
else if (setting.PoolId > 0 && !string.IsNullOrEmpty(setting.PoolName) && !string.IsNullOrEmpty(setting.AgentName))
{
context.Output($"Runner name: '{setting.AgentName}'");
context.Output($"Runner group name: '{setting.PoolName}'");
}
var setupInfoFile = HostContext.GetConfigFile(WellKnownConfigFile.SetupInfo);
@@ -476,6 +484,41 @@ namespace GitHub.Runner.Worker
Trace.Info($"Start checking service connectivity in background.");
_serviceConnectivityCheckTask = CheckServiceConnectivityAsync(context, _serviceConnectivityCheckToken.Token);
// Start the DAP debugger and wait for a client connection inside
// "Set up job" so the step stays in-progress while we wait.
if (jobContext.Global.Debugger?.Enabled == true)
{
Trace.Info("Debugger enabled — starting inside Set up job");
context.Output("Starting debugger…");
try
{
_dapDebugger = HostContext.GetService<IDapDebugger>();
await _dapDebugger.StartAsync(jobContext);
context.Output("Waiting for debugger client to connect…");
await _dapDebugger.WaitUntilReadyAsync();
context.Output("Debugger connected.");
AddDebuggerConnectionTelemetry(jobContext, "Connected");
}
catch (OperationCanceledException) when (jobContext.CancellationToken.IsCancellationRequested)
{
Trace.Info("Job was cancelled before debugger client connected.");
AddDebuggerConnectionTelemetry(jobContext, "Canceled");
context.Error("Job was cancelled before debugger client connected.");
throw;
}
catch (Exception ex)
{
Trace.Error($"DAP debugger failed: {ex.Message}");
AddDebuggerConnectionTelemetry(jobContext, $"Failed: {ex.GetType().Name}");
context.Error("The debugger failed to start or no debugger client connected in time.");
throw;
}
}
initSucceeded = true;
return steps;
}
catch (OperationCanceledException ex) when (jobContext.CancellationToken.IsCancellationRequested)
@@ -496,12 +539,36 @@ namespace GitHub.Runner.Worker
}
finally
{
// If InitializeJob failed after the debugger was started,
// tear down the transport here since FinalizeJob won't run.
if (!initSucceeded && _dapDebugger != null)
{
try
{
await _dapDebugger.StopAsync();
}
catch (Exception ex)
{
Trace.Warning($"DAP debugger cleanup during failed init: {ex.Message}");
}
_dapDebugger = null;
}
context.Debug("Finishing: Set up job");
context.Complete();
}
}
}
private static void AddDebuggerConnectionTelemetry(IExecutionContext jobContext, string result)
{
jobContext.Global.JobTelemetry.Add(new JobTelemetry
{
Type = JobTelemetryType.General,
Message = $"DebuggerConnectionResult: {result}"
});
}
private string GetWorkflowReference(IDictionary<string, VariableValue> variables)
{
var reference = "";
@@ -736,14 +803,38 @@ namespace GitHub.Runner.Worker
}
}
// Add deprecation warning annotation for Node.js 20 actions
// Read dates from server variables with hardcoded fallbacks
var node24DefaultDateRaw = context.Global.Variables?.Get(Constants.Runner.NodeMigration.Node24DefaultDateVariable);
var node24DefaultDate = string.IsNullOrEmpty(node24DefaultDateRaw) ? Constants.Runner.NodeMigration.Node24DefaultDate : node24DefaultDateRaw;
var node20RemovalDateRaw = context.Global.Variables?.Get(Constants.Runner.NodeMigration.Node20RemovalDateVariable);
var node20RemovalDate = string.IsNullOrEmpty(node20RemovalDateRaw) ? Constants.Runner.NodeMigration.Node20RemovalDate : node20RemovalDateRaw;
// Add deprecation warning annotation for Node.js 20 actions (Phase 1 - actions still running on node20)
if (context.Global.DeprecatedNode20Actions?.Count > 0)
{
var sortedActions = context.Global.DeprecatedNode20Actions.OrderBy(a => a, StringComparer.OrdinalIgnoreCase);
var actionsList = string.Join(", ", sortedActions);
var deprecationMessage = $"Node.js 20 actions are deprecated. The following actions are running on Node.js 20 and may not work as expected: {actionsList}. Actions will be forced to run with Node.js 24 by default starting June 2nd, 2026. Please check if updated versions of these actions are available that support Node.js 24. To opt into Node.js 24 now, set the FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true environment variable on the runner or in your workflow file. Once Node.js 24 becomes the default, you can temporarily opt out by setting ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true. For more information see: {Constants.Runner.NodeMigration.Node20DeprecationUrl}";
var deprecationMessage = $"Node.js 20 actions are deprecated. The following actions are running on Node.js 20 and may not work as expected: {actionsList}. Actions will be forced to run with Node.js 24 by default starting {node24DefaultDate}. Node.js 20 will be removed from the runner on {node20RemovalDate}. Please check if updated versions of these actions are available that support Node.js 24. To opt into Node.js 24 now, set the FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true environment variable on the runner or in your workflow file. Once Node.js 24 becomes the default, you can temporarily opt out by setting ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true. For more information see: {Constants.Runner.NodeMigration.Node20DeprecationUrl}";
context.Warning(deprecationMessage);
}
// Add annotation for actions upgraded from Node.js 20 to Node.js 24 (Phase 2/3)
if (context.Global.UpgradedToNode24Actions?.Count > 0)
{
var sortedActions = context.Global.UpgradedToNode24Actions.OrderBy(a => a, StringComparer.OrdinalIgnoreCase);
var actionsList = string.Join(", ", sortedActions);
var upgradeMessage = $"Node.js 20 is deprecated. The following actions target Node.js 20 but are being forced to run on Node.js 24: {actionsList}. For more information see: {Constants.Runner.NodeMigration.Node20DeprecationUrl}";
context.Warning(upgradeMessage);
}
// Add annotation for ARM32 actions stuck on Node.js 20 (ARM32 can't run node24)
if (context.Global.Arm32Node20Actions?.Count > 0)
{
var sortedActions = context.Global.Arm32Node20Actions.OrderBy(a => a, StringComparer.OrdinalIgnoreCase);
var actionsList = string.Join(", ", sortedActions);
var arm32Message = $"The following actions are running on Node.js 20 because Node.js 24 is not available on Linux ARM32: {actionsList}. Linux ARM32 runners are deprecated and will no longer be supported after {node20RemovalDate}. Please migrate to a supported platform. For more information see: {Constants.Runner.NodeMigration.Node20DeprecationUrl}";
context.Warning(arm32Message);
}
}
catch (Exception ex)
{
@@ -753,6 +844,34 @@ namespace GitHub.Runner.Worker
}
finally
{
// Pause for debugger inspection, then tear down the DAP session.
// OnJobCompletedAsync pauses first, then sends terminated/exited
// events and stops the transport.
if (_dapDebugger != null)
{
context.Output("Job completed — pausing for debugger inspection. Press continue to finish.");
try
{
await _dapDebugger.OnJobCompletedAsync();
}
catch (Exception ex)
{
Trace.Warning($"DAP debugger completion error: {ex.Message}");
}
finally
{
try
{
await _dapDebugger.StopAsync();
}
catch (Exception ex)
{
Trace.Warning($"DAP debugger stop error: {ex.Message}");
}
}
_dapDebugger = null;
}
context.Debug("Finishing: Complete job");
context.Complete();
}

View File

@@ -178,6 +178,7 @@ namespace GitHub.Runner.Worker
_tempDirectoryManager = HostContext.GetService<ITempDirectoryManager>();
_tempDirectoryManager.InitializeTempDirectory(jobContext);
// Get the job extension.
Trace.Info("Getting job extension.");
IJobExtension jobExtension = HostContext.CreateService<IJobExtension>();
@@ -229,6 +230,24 @@ namespace GitHub.Runner.Worker
jobContext.JobSteps.Enqueue(step);
}
if (jobContext.Global.Debugger?.Enabled == true)
{
// Only consult the DAP debugger when it was actually enabled for this job.
// Without this guard, HostContext.GetService<IDapDebugger>() would auto-
// instantiate the default singleton for every non-debug job, violating the
// "no debugger, no risk" containment property.
var dapDebugger = HostContext.GetService<Dap.IDapDebugger>();
try
{
await dapDebugger.OnJobStepsInitializedAsync(jobContext.JobSteps, jobContext.PostJobSteps);
}
catch (Exception ex)
{
Trace.Warning("DAP OnJobStepsInitialized error; continuing without DAP view.");
Trace.Error(ex);
}
}
await stepsRunner.RunAsync(jobContext);
}
catch (Exception ex)

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Threading;
using GitHub.Actions.WorkflowParser;
using GitHub.DistributedTask.Expressions2;
using GitHub.DistributedTask.ObjectTemplating.Tokens;
@@ -23,6 +24,7 @@ namespace GitHub.Runner.Worker
public PipelineTemplateEvaluatorWrapper(
IHostContext hostContext,
IExecutionContext context,
bool allowServiceContainerCommand,
ObjectTemplating.ITraceWriter traceWriter = null)
{
ArgUtil.NotNull(hostContext, nameof(hostContext));
@@ -40,11 +42,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
};
@@ -222,8 +227,12 @@ namespace GitHub.Runner.Worker
Func<TNew> newEvaluator,
Func<TLegacy, TNew, bool> resultComparer)
{
// Capture cancellation state before evaluation
var cancellationRequestedBefore = _context.CancellationToken.IsCancellationRequested;
// Use the root (job-level) cancellation token to detect cancellation race conditions.
// The step-level token only fires on step timeout, not on job cancellation.
// Job cancellation mutates JobContext.Status which expression functions read,
// so we need the root token to properly detect cancellation between evaluator runs.
var rootCancellationToken = _context.Root?.CancellationToken ?? CancellationToken.None;
var cancellationRequestedBefore = rootCancellationToken.IsCancellationRequested;
// Legacy evaluator
var legacyException = default(Exception);
@@ -257,7 +266,7 @@ namespace GitHub.Runner.Worker
}
// Capture cancellation state after evaluation
var cancellationRequestedAfter = _context.CancellationToken.IsCancellationRequested;
var cancellationRequestedAfter = rootCancellationToken.IsCancellationRequested;
// Compare results or exceptions
bool hasMismatch = false;
@@ -401,6 +410,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

@@ -19,10 +19,11 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
<PackageReference Include="System.ServiceProcess.ServiceController" Version="8.0.1" />
<PackageReference Include="System.Threading.Channels" Version="8.0.0" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.3" />
<PackageReference Include="System.ServiceProcess.ServiceController" Version="10.0.3" />
<PackageReference Include="System.Threading.Channels" Version="10.0.3" />
<PackageReference Include="YamlDotNet.Signed" Version="5.3.0" />
<PackageReference Include="Microsoft.DevTunnels.Connections" Version="1.3.39" />
</ItemGroup>
<ItemGroup>

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,7 @@ namespace GitHub.Runner.Worker
jobContext.JobContext.Status = (jobContext.Result ?? TaskResult.Succeeded).ToActionResult();
var scopeInputs = new Dictionary<string, PipelineContextData>(StringComparer.OrdinalIgnoreCase);
bool checkPostJobActions = false;
var dapDebugger = HostContext.GetService<IDapDebugger>();
while (jobContext.JobSteps.Count > 0 || !checkPostJobActions)
{
if (jobContext.JobSteps.Count == 0 && !checkPostJobActions)
@@ -217,18 +219,29 @@ namespace GitHub.Runner.Worker
// Condition is false
Trace.Info("Skipping step due to condition evaluation.");
CompleteStep(step, TaskResult.Skipped, resultCode: conditionTraceWriter.Trace);
// Notify the DAP debugger so any predicted Post-step
// placeholder for this Main step can be marked as
// skipped — otherwise the rendered view leaves a
// stale "Post X" entry for a step that never ran.
dapDebugger?.OnStepCompleted(step);
}
else if (conditionEvaluateError != null)
{
// Condition error
step.ExecutionContext.Error(conditionEvaluateError);
CompleteStep(step, TaskResult.Failed);
dapDebugger?.OnStepCompleted(step);
}
else
{
// Pause for DAP debugger before step execution
await dapDebugger?.OnStepStartingAsync(step);
// Run the step
await RunStepAsync(step, jobContext.CancellationToken);
CompleteStep(step);
dapDebugger?.OnStepCompleted(step);
}
}
finally
@@ -255,6 +268,7 @@ namespace GitHub.Runner.Worker
Trace.Info($"Current state: job state = '{jobContext.Result}'");
}
}
private async Task RunStepAsync(IStep step, CancellationToken jobCancellationToken)

View File

@@ -17,10 +17,9 @@ namespace GitHub.DistributedTask.Expressions2
String expression,
ITraceWriter trace,
IEnumerable<INamedValueInfo> namedValues,
IEnumerable<IFunctionInfo> functions,
Boolean allowCaseFunction = true)
IEnumerable<IFunctionInfo> functions)
{
var context = new ParseContext(expression, trace, namedValues, functions, allowCaseFunction);
var context = new ParseContext(expression, trace, namedValues, functions);
context.Trace.Info($"Parsing expression: <{expression}>");
return CreateTree(context);
}
@@ -416,12 +415,6 @@ namespace GitHub.DistributedTask.Expressions2
String name,
out IFunctionInfo functionInfo)
{
if (String.Equals(name, "case", StringComparison.OrdinalIgnoreCase) && !context.AllowCaseFunction)
{
functionInfo = null;
return false;
}
return ExpressionConstants.WellKnownFunctions.TryGetValue(name, out functionInfo) ||
context.ExtensionFunctions.TryGetValue(name, out functionInfo);
}
@@ -429,7 +422,6 @@ namespace GitHub.DistributedTask.Expressions2
private sealed class ParseContext
{
public Boolean AllowUnknownKeywords;
public Boolean AllowCaseFunction;
public readonly String Expression;
public readonly Dictionary<String, IFunctionInfo> ExtensionFunctions = new Dictionary<String, IFunctionInfo>(StringComparer.OrdinalIgnoreCase);
public readonly Dictionary<String, INamedValueInfo> ExtensionNamedValues = new Dictionary<String, INamedValueInfo>(StringComparer.OrdinalIgnoreCase);
@@ -445,8 +437,7 @@ namespace GitHub.DistributedTask.Expressions2
ITraceWriter trace,
IEnumerable<INamedValueInfo> namedValues,
IEnumerable<IFunctionInfo> functions,
Boolean allowUnknownKeywords = false,
Boolean allowCaseFunction = true)
Boolean allowUnknownKeywords = false)
{
Expression = expression ?? String.Empty;
if (Expression.Length > ExpressionConstants.MaxLength)
@@ -467,7 +458,6 @@ namespace GitHub.DistributedTask.Expressions2
LexicalAnalyzer = new LexicalAnalyzer(Expression);
AllowUnknownKeywords = allowUnknownKeywords;
AllowCaseFunction = allowCaseFunction;
}
private class NoOperationTraceWriter : ITraceWriter

View File

@@ -86,12 +86,6 @@ namespace GitHub.DistributedTask.ObjectTemplating
internal ITraceWriter TraceWriter { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the case expression function is allowed.
/// Defaults to true. Set to false to disable the case function.
/// </summary>
internal Boolean AllowCaseFunction { get; set; } = true;
private IDictionary<String, Int32> FileIds
{
get

View File

@@ -57,7 +57,7 @@ namespace GitHub.DistributedTask.ObjectTemplating.Tokens
var originalBytes = context.Memory.CurrentBytes;
try
{
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction);
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions);
var options = new EvaluationOptions
{
MaxMemory = context.Memory.MaxBytes,
@@ -94,7 +94,7 @@ namespace GitHub.DistributedTask.ObjectTemplating.Tokens
var originalBytes = context.Memory.CurrentBytes;
try
{
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction);
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions);
var options = new EvaluationOptions
{
MaxMemory = context.Memory.MaxBytes,
@@ -123,7 +123,7 @@ namespace GitHub.DistributedTask.ObjectTemplating.Tokens
var originalBytes = context.Memory.CurrentBytes;
try
{
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction);
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions);
var options = new EvaluationOptions
{
MaxMemory = context.Memory.MaxBytes,
@@ -152,7 +152,7 @@ namespace GitHub.DistributedTask.ObjectTemplating.Tokens
var originalBytes = context.Memory.CurrentBytes;
try
{
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction);
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions);
var options = new EvaluationOptions
{
MaxMemory = context.Memory.MaxBytes,

View File

@@ -253,6 +253,35 @@ namespace GitHub.DistributedTask.Pipelines
set;
}
[DataMember(EmitDefaultValue = false)]
public bool EnableDebugger
{
get;
set;
}
[DataMember(EmitDefaultValue = false)]
public DebuggerTunnelInfo DebuggerTunnel
{
get;
set;
}
/// <summary>
/// Gets the workflow-level action dependencies (lockfile entries)
/// </summary>
public IList<String> ActionsDependencies
{
get
{
if (m_actionsDependencies == null)
{
m_actionsDependencies = new List<String>();
}
return m_actionsDependencies;
}
}
/// <summary>
/// Gets the collection of variables associated with the current context.
/// </summary>
@@ -427,6 +456,11 @@ namespace GitHub.DistributedTask.Pipelines
m_variables = null;
}
if (m_actionsDependencies?.Count == 0)
{
m_actionsDependencies = null;
}
// todo: remove after feature-flag DistributedTask.EvaluateContainerOnRunner is enabled everywhere
if (!string.IsNullOrEmpty(m_jobContainerResourceAlias))
{
@@ -452,6 +486,9 @@ namespace GitHub.DistributedTask.Pipelines
[DataMember(Name = "Variables", EmitDefaultValue = false)]
private IDictionary<String, VariableValue> m_variables;
[DataMember(Name = "dependencies", EmitDefaultValue = false)]
private List<String> m_actionsDependencies;
// todo: remove after feature-flag DistributedTask.EvaluateContainerOnRunner is enabled everywhere
[DataMember(Name = "JobSidecarContainers", EmitDefaultValue = false)]
private IDictionary<String, String> m_jobSidecarContainers;

View File

@@ -0,0 +1,24 @@
using System.Runtime.Serialization;
namespace GitHub.DistributedTask.Pipelines
{
/// <summary>
/// Dev Tunnel information the runner needs to host the debugger tunnel.
/// Matches the run-service <c>DebuggerTunnel</c> contract.
/// </summary>
[DataContract]
public sealed class DebuggerTunnelInfo
{
[DataMember(EmitDefaultValue = false)]
public string TunnelId { get; set; }
[DataMember(EmitDefaultValue = false)]
public string ClusterId { get; set; }
[DataMember(EmitDefaultValue = false)]
public string HostToken { get; set; }
[DataMember(EmitDefaultValue = false)]
public ushort Port { get; set; }
}
}

View File

@@ -8,6 +8,7 @@ namespace GitHub.DistributedTask.Pipelines.Expressions
public const String Email = nameof(Email);
public const String IPv4Address = nameof(IPv4Address);
public const String SHA1 = nameof(SHA1);
public const String CommitHash = nameof(CommitHash);
public const String Url = nameof(Url);
/// <summary>
@@ -24,7 +25,8 @@ namespace GitHub.DistributedTask.Pipelines.Expressions
case IPv4Address:
return s_validIPv4Address;
case SHA1:
return s_validSha1;
case CommitHash:
return s_validCommitHash;
case Url:
return s_validUrl;
default:
@@ -46,9 +48,9 @@ namespace GitHub.DistributedTask.Pipelines.Expressions
)
);
// 40 hex characters
private static readonly Lazy<Regex> s_validSha1 = new Lazy<Regex>(() => new Regex(
@"\b[0-9a-f]{40}\b",
// 40 or 64 hex characters (SHA-1 or SHA-256 commit hash)
private static readonly Lazy<Regex> s_validCommitHash = new Lazy<Regex>(() => new Regex(
@"\b(?:[0-9a-f]{40}|[0-9a-f]{64})\b",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled, RegexUtility.GetRegexTimeOut()
)
);

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));
}
@@ -663,7 +681,7 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
var node = default(ExpressionNode);
try
{
node = expressionParser.CreateTree(condition, null, namedValues, functions, allowCaseFunction: context.AllowCaseFunction) as ExpressionNode;
node = expressionParser.CreateTree(condition, null, namedValues, functions) as ExpressionNode;
}
catch (Exception ex)
{

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

@@ -12,5 +12,12 @@ namespace GitHub.DistributedTask.WebApi
get;
set;
}
[DataMember(EmitDefaultValue = false)]
public IList<string> Dependencies
{
get;
set;
}
}
}

View File

@@ -2556,6 +2556,25 @@ namespace GitHub.DistributedTask.WebApi
}
}
[Serializable]
public sealed class FailedToDownloadActionException : DistributedTaskException
{
public FailedToDownloadActionException(String message)
: base(message)
{
}
public FailedToDownloadActionException(String message, Exception innerException)
: base(message, innerException)
{
}
private FailedToDownloadActionException(SerializationInfo info, StreamingContext context)
: base(info, context)
{
}
}
[Serializable]
public sealed class InvalidActionArchiveException : DistributedTaskException
{

View File

@@ -1,4 +1,4 @@
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
using System;
using System.Collections.Generic;
@@ -17,10 +17,9 @@ namespace GitHub.Actions.Expressions
String expression,
ITraceWriter trace,
IEnumerable<INamedValueInfo> namedValues,
IEnumerable<IFunctionInfo> functions,
Boolean allowCaseFunction = true)
IEnumerable<IFunctionInfo> functions)
{
var context = new ParseContext(expression, trace, namedValues, functions, allowCaseFunction: allowCaseFunction);
var context = new ParseContext(expression, trace, namedValues, functions);
context.Trace.Info($"Parsing expression: <{expression}>");
return CreateTree(context);
}
@@ -322,7 +321,7 @@ namespace GitHub.Actions.Expressions
context.Operators.Pop();
}
var functionOperands = PopOperands(context, parameterCount);
// Node already exists on the operand stack
function = (Function)context.Operands.Peek();
@@ -416,12 +415,6 @@ namespace GitHub.Actions.Expressions
String name,
out IFunctionInfo functionInfo)
{
if (String.Equals(name, "case", StringComparison.OrdinalIgnoreCase) && !context.AllowCaseFunction)
{
functionInfo = null;
return false;
}
return ExpressionConstants.WellKnownFunctions.TryGetValue(name, out functionInfo) ||
context.ExtensionFunctions.TryGetValue(name, out functionInfo);
}
@@ -429,7 +422,6 @@ namespace GitHub.Actions.Expressions
private sealed class ParseContext
{
public Boolean AllowUnknownKeywords;
public Boolean AllowCaseFunction;
public readonly String Expression;
public readonly Dictionary<String, IFunctionInfo> ExtensionFunctions = new Dictionary<String, IFunctionInfo>(StringComparer.OrdinalIgnoreCase);
public readonly Dictionary<String, INamedValueInfo> ExtensionNamedValues = new Dictionary<String, INamedValueInfo>(StringComparer.OrdinalIgnoreCase);
@@ -445,8 +437,7 @@ namespace GitHub.Actions.Expressions
ITraceWriter trace,
IEnumerable<INamedValueInfo> namedValues,
IEnumerable<IFunctionInfo> functions,
Boolean allowUnknownKeywords = false,
Boolean allowCaseFunction = true)
Boolean allowUnknownKeywords = false)
{
Expression = expression ?? String.Empty;
if (Expression.Length > ExpressionConstants.MaxLength)
@@ -467,7 +458,6 @@ namespace GitHub.Actions.Expressions
LexicalAnalyzer = new LexicalAnalyzer(Expression);
AllowUnknownKeywords = allowUnknownKeywords;
AllowCaseFunction = allowCaseFunction;
}
private class NoOperationTraceWriter : ITraceWriter

View File

@@ -23,14 +23,14 @@
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Microsoft.AspNet.WebApi.Client" Version="6.0.0" />
<PackageReference Include="System.Security.Cryptography.Cng" Version="5.0.0" />
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="10.0.2" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="10.0.6" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.3" />
<PackageReference Include="Minimatch" Version="2.0.0" />
<PackageReference Include="YamlDotNet.Signed" Version="5.3.0" />
<PackageReference Include="System.Net.Http" Version="4.3.4" />
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1" />
<PackageReference Include="System.Private.Uri" Version="4.3.2" />
<PackageReference Include="System.Formats.Asn1" Version="10.0.2" />
<PackageReference Include="System.Formats.Asn1" Version="10.0.6" />
</ItemGroup>
<ItemGroup>

View File

@@ -22,6 +22,9 @@ namespace GitHub.Services.Launch.Contracts
{
[DataMember(EmitDefaultValue = false, Name = "actions")]
public IList<ActionReferenceRequest> Actions { get; set; }
[DataMember(EmitDefaultValue = false, Name = "actions_dependencies")]
public IList<string> ActionsDependencies { get; set; }
}
[DataContract]

View File

@@ -97,7 +97,8 @@ namespace GitHub.Services.Launch.Client
{
return new ActionReferenceRequestList
{
Actions = actionReferenceList.Actions?.Select(ToGitHubData).ToList()
Actions = actionReferenceList.Actions?.Select(ToGitHubData).ToList(),
ActionsDependencies = actionReferenceList.Dependencies
};
}

View File

@@ -32,7 +32,7 @@ namespace GitHub.Actions.WorkflowParser.Conversion
return;
}
var effectiveMax = explicitMax ?? CreatePermissionsFromPolicy(context, permissionsPolicy, includeIdToken: isTrusted, includeModels: context.GetFeatures().AllowModelsPermission);
var effectiveMax = explicitMax ?? CreatePermissionsFromPolicy(context, permissionsPolicy, includeIdToken: isTrusted, includeModels: context.GetFeatures().AllowModelsPermission, includeVulnerabilityAlerts: context.GetFeatures().AllowVulnerabilityAlertsPermission);
if (requested.ViolatesMaxPermissions(effectiveMax, out var permissionLevelViolations))
{
@@ -59,18 +59,19 @@ namespace GitHub.Actions.WorkflowParser.Conversion
TemplateContext context,
string permissionsPolicy,
bool includeIdToken,
bool includeModels)
bool includeModels,
bool includeVulnerabilityAlerts)
{
switch (permissionsPolicy)
{
case WorkflowConstants.PermissionsPolicy.LimitedRead:
return new Permissions(PermissionLevel.NoAccess, includeIdToken: false, includeAttestations: false, includeModels: false)
return new Permissions(PermissionLevel.NoAccess, includeIdToken: false, includeAttestations: false, includeModels: false, includeVulnerabilityAlerts: false)
{
Contents = PermissionLevel.Read,
Packages = PermissionLevel.Read,
};
case WorkflowConstants.PermissionsPolicy.Write:
return new Permissions(PermissionLevel.Write, includeIdToken: includeIdToken, includeAttestations: true, includeModels: includeModels);
return new Permissions(PermissionLevel.Write, includeIdToken: includeIdToken, includeAttestations: true, includeModels: includeModels, includeVulnerabilityAlerts: includeVulnerabilityAlerts);
default:
throw new ArgumentException($"Unexpected permission policy: '{permissionsPolicy}'");
}

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));
}
@@ -1780,7 +1828,7 @@ namespace GitHub.Actions.WorkflowParser.Conversion
var node = default(ExpressionNode);
try
{
node = expressionParser.CreateTree(condition, null, namedValues, functions, allowCaseFunction: context.AllowCaseFunction) as ExpressionNode;
node = expressionParser.CreateTree(condition, null, namedValues, functions) as ExpressionNode;
}
catch (Exception ex)
{
@@ -1829,7 +1877,7 @@ namespace GitHub.Actions.WorkflowParser.Conversion
permissionsStr.AssertUnexpectedValue(permissionsStr.Value);
break;
}
return new Permissions(permissionLevel, includeIdToken: true, includeAttestations: true, includeModels: context.GetFeatures().AllowModelsPermission);
return new Permissions(permissionLevel, includeIdToken: true, includeAttestations: true, includeModels: context.GetFeatures().AllowModelsPermission, includeVulnerabilityAlerts: context.GetFeatures().AllowVulnerabilityAlertsPermission);
}
var mapping = token.AssertMapping("permissions");
@@ -1909,6 +1957,23 @@ namespace GitHub.Actions.WorkflowParser.Conversion
context.Error(key, $"The permission 'models' is not allowed");
}
break;
case "vulnerability-alerts":
if (context.GetFeatures().AllowVulnerabilityAlertsPermission)
{
if (permissionLevel == PermissionLevel.Write)
{
permissions.VulnerabilityAlerts = PermissionLevel.Read;
}
else
{
permissions.VulnerabilityAlerts = permissionLevel;
}
}
else
{
context.Error(key, $"The permission 'vulnerability-alerts' is not allowed");
}
break;
default:
break;
}

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

@@ -1,4 +1,4 @@
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
using System;
using System.Collections.Generic;
@@ -113,12 +113,6 @@ namespace GitHub.Actions.WorkflowParser.ObjectTemplating
/// </summary>
internal Boolean StrictJsonParsing { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the case expression function is allowed.
/// Defaults to true. Set to false to disable the case function.
/// </summary>
internal Boolean AllowCaseFunction { get; set; } = true;
internal ITraceWriter TraceWriter { get; set; }
private IDictionary<String, Int32> FileIds

View File

@@ -1,4 +1,4 @@
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
using System;
using System.Collections.Generic;
@@ -55,7 +55,7 @@ namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens
var originalBytes = context.Memory.CurrentBytes;
try
{
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction);
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions);
var options = new EvaluationOptions
{
MaxMemory = context.Memory.MaxBytes,
@@ -93,7 +93,7 @@ namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens
var originalBytes = context.Memory.CurrentBytes;
try
{
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction);
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions);
var options = new EvaluationOptions
{
MaxMemory = context.Memory.MaxBytes,
@@ -123,7 +123,7 @@ namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens
var originalBytes = context.Memory.CurrentBytes;
try
{
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction);
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions);
var options = new EvaluationOptions
{
MaxMemory = context.Memory.MaxBytes,
@@ -153,7 +153,7 @@ namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens
var originalBytes = context.Memory.CurrentBytes;
try
{
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction);
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions);
var options = new EvaluationOptions
{
MaxMemory = context.Memory.MaxBytes,
@@ -289,4 +289,4 @@ namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens
return result;
}
}
}
}

View File

@@ -32,6 +32,7 @@ namespace GitHub.Actions.WorkflowParser
SecurityEvents = copy.SecurityEvents;
IdToken = copy.IdToken;
Models = copy.Models;
VulnerabilityAlerts = copy.VulnerabilityAlerts;
}
public Permissions(
@@ -61,6 +62,19 @@ namespace GitHub.Actions.WorkflowParser
: PermissionLevel.NoAccess;
}
public Permissions(
PermissionLevel permissionLevel,
bool includeIdToken,
bool includeAttestations,
bool includeModels,
bool includeVulnerabilityAlerts)
: this(permissionLevel, includeIdToken, includeAttestations, includeModels)
{
VulnerabilityAlerts = includeVulnerabilityAlerts
? (permissionLevel == PermissionLevel.Write ? PermissionLevel.Read : permissionLevel)
: PermissionLevel.NoAccess;
}
private static KeyValuePair<string, (PermissionLevel, PermissionLevel)>[] ComparisonKeyMapping(Permissions left, Permissions right)
{
return new[]
@@ -81,6 +95,7 @@ namespace GitHub.Actions.WorkflowParser
new KeyValuePair<string, (PermissionLevel, PermissionLevel)>("security-events", (left.SecurityEvents, right.SecurityEvents)),
new KeyValuePair<string, (PermissionLevel, PermissionLevel)>("id-token", (left.IdToken, right.IdToken)),
new KeyValuePair<string, (PermissionLevel, PermissionLevel)>("models", (left.Models, right.Models)),
new KeyValuePair<string, (PermissionLevel, PermissionLevel)>("vulnerability-alerts", (left.VulnerabilityAlerts, right.VulnerabilityAlerts)),
};
}
@@ -154,6 +169,13 @@ namespace GitHub.Actions.WorkflowParser
set;
}
[DataMember(Name = "vulnerability-alerts", EmitDefaultValue = false)]
public PermissionLevel VulnerabilityAlerts
{
get;
set;
}
[DataMember(Name = "packages", EmitDefaultValue = false)]
public PermissionLevel Packages
{

View File

@@ -41,6 +41,13 @@ namespace GitHub.Actions.WorkflowParser
[DataMember(EmitDefaultValue = false)]
public bool AllowModelsPermission { get; set; }
/// <summary>
/// Gets or sets a value indicating whether users may use the "vulnerability-alerts" permission.
/// Used during parsing only.
/// </summary>
[DataMember(EmitDefaultValue = false)]
public bool AllowVulnerabilityAlertsPermission { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the expression function fromJson performs strict JSON parsing.
/// Used during evaluation only.
@@ -48,6 +55,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 +74,8 @@ 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
AllowVulnerabilityAlertsPermission = 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

@@ -496,8 +496,8 @@
"check-suite-activity": {
"description": "The types of check suite activity that trigger the workflow. Supported activity types: `completed`.",
"one-of": [
"check-suite-activity-type",
"check-suite-activity-types"
"check-suite-activity-type",
"check-suite-activity-types"
]
},
"check-suite-activity-types": {
@@ -1865,11 +1865,15 @@
},
"security-events": {
"type": "permission-level-any",
"description": "Code scanning and Dependabot alerts."
"description": "Code scanning alerts."
},
"statuses": {
"type": "permission-level-any",
"description": "Commit statuses."
},
"vulnerability-alerts": {
"type": "permission-level-read-or-no-access",
"description": "Dependabot alerts."
}
}
}
@@ -2589,21 +2593,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 +2670,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 +2691,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

@@ -24,7 +24,10 @@ namespace GitHub.Runner.Common.Tests
"osx-arm64"
};
Assert.Equal(40, BuildConstants.Source.CommitHash.Length);
Assert.True(
BuildConstants.Source.CommitHash.Length == 40 || BuildConstants.Source.CommitHash.Length == 64,
"CommitHash should be a 40-char SHA-1 or 64-char SHA-256 hex string");
Assert.Matches("^[0-9a-f]+$", BuildConstants.Source.CommitHash);
Assert.True(validPackageNames.Contains(BuildConstants.RunnerPackage.PackageName), $"PackageName should be one of the following '{string.Join(", ", validPackageNames)}', current PackageName is '{BuildConstants.RunnerPackage.PackageName}'");
}
}

View File

@@ -14,7 +14,7 @@ using Pipelines = GitHub.DistributedTask.Pipelines;
namespace GitHub.Runner.Common.Tests.Listener
{
public sealed class RunnerL0
public sealed class RunnerL0 : IDisposable
{
private Mock<IConfigurationManager> _configurationManager;
private Mock<IJobNotification> _jobNotification;
@@ -29,6 +29,7 @@ namespace GitHub.Runner.Common.Tests.Listener
private Mock<ICredentialManager> _credentialManager;
private Mock<IActionsRunServer> _actionsRunServer;
private Mock<IRunServer> _runServer;
private readonly string _returnJobResultForHosted;
public RunnerL0()
{
@@ -45,6 +46,14 @@ namespace GitHub.Runner.Common.Tests.Listener
_credentialManager = new Mock<ICredentialManager>();
_actionsRunServer = new Mock<IActionsRunServer>();
_runServer = new Mock<IRunServer>();
_returnJobResultForHosted = Environment.GetEnvironmentVariable("ACTIONS_RUNNER_RETURN_JOB_RESULT_FOR_HOSTED");
Environment.SetEnvironmentVariable("ACTIONS_RUNNER_RETURN_JOB_RESULT_FOR_HOSTED", null);
}
public void Dispose()
{
Environment.SetEnvironmentVariable("ACTIONS_RUNNER_RETURN_JOB_RESULT_FOR_HOSTED", _returnJobResultForHosted);
}
private Pipelines.AgentJobRequestMessage CreateJobRequestMessage(string jobName)

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,86 @@
using GitHub.DistributedTask.Expressions2;
using GitHub.DistributedTask.Expressions2.Sdk;
using GitHub.DistributedTask.ObjectTemplating;
using System;
using System.Collections.Generic;
using Xunit;
namespace GitHub.Runner.Common.Tests.Sdk
{
/// <summary>
/// Regression tests for ExpressionParser.CreateTree to verify that
/// the case function does not accidentally set allowUnknownKeywords.
/// </summary>
public sealed class ExpressionParserL0
{
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Sdk")]
public void CreateTree_RejectsUnrecognizedNamedValue()
{
// Regression: the case function parameter was passed positionally into
// the allowUnknownKeywords parameter, causing all named values
// to be silently accepted.
var parser = new ExpressionParser();
var namedValues = new List<INamedValueInfo>
{
new NamedValueInfo<ContextValueNode>("inputs"),
};
var ex = Assert.Throws<ParseException>(() =>
parser.CreateTree("github.event.repository.private", null, namedValues, null));
Assert.Contains("Unrecognized named-value", ex.Message);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Sdk")]
public void CreateTree_AcceptsRecognizedNamedValue()
{
var parser = new ExpressionParser();
var namedValues = new List<INamedValueInfo>
{
new NamedValueInfo<ContextValueNode>("inputs"),
};
var node = parser.CreateTree("inputs.foo", null, namedValues, null);
Assert.NotNull(node);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Sdk")]
public void CreateTree_CaseFunctionWorks()
{
var parser = new ExpressionParser();
var namedValues = new List<INamedValueInfo>
{
new NamedValueInfo<ContextValueNode>("github"),
};
var node = parser.CreateTree("case(github.event_name, 'push', 'Push Event')", null, namedValues, null);
Assert.NotNull(node);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Sdk")]
public void CreateTree_CaseFunctionDoesNotAffectUnknownKeywords()
{
// The key regression test: unrecognized named values must still be rejected.
var parser = new ExpressionParser();
var namedValues = new List<INamedValueInfo>
{
new NamedValueInfo<ContextValueNode>("inputs"),
};
var ex = Assert.Throws<ParseException>(() =>
parser.CreateTree("github.ref", null, namedValues, null));
Assert.Contains("Unrecognized named-value", ex.Message);
}
}
}

View File

@@ -0,0 +1,168 @@
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");
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Common")]
public void VerifyDebuggerTunnelDeserialization_WithTunnel()
{
// Arrange
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage), new DataContractJsonSerializerSettings
{
KnownTypes = new[] { typeof(DebuggerTunnelInfo) }
});
string json = DoubleQuotify(
"{'EnableDebugger': true, 'DebuggerTunnel': {'TunnelId': 'tun-123', 'ClusterId': 'use2', 'HostToken': 'tok-abc', 'Port': 4711}}");
// Act
using var stream = new MemoryStream();
stream.Write(Encoding.UTF8.GetBytes(json));
stream.Position = 0;
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
// Assert
Assert.NotNull(recoveredMessage);
Assert.True(recoveredMessage.EnableDebugger);
Assert.NotNull(recoveredMessage.DebuggerTunnel);
Assert.Equal("tun-123", recoveredMessage.DebuggerTunnel.TunnelId);
Assert.Equal("use2", recoveredMessage.DebuggerTunnel.ClusterId);
Assert.Equal("tok-abc", recoveredMessage.DebuggerTunnel.HostToken);
Assert.Equal(4711, recoveredMessage.DebuggerTunnel.Port);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Common")]
public void VerifyDebuggerTunnelDeserialization_WithoutTunnel()
{
// Arrange
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage));
string json = DoubleQuotify("{'EnableDebugger': true}");
// Act
using var stream = new MemoryStream();
stream.Write(Encoding.UTF8.GetBytes(json));
stream.Position = 0;
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
// Assert
Assert.NotNull(recoveredMessage);
Assert.True(recoveredMessage.EnableDebugger);
Assert.Null(recoveredMessage.DebuggerTunnel);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Common")]
public void VerifyActionsDependenciesDeserialization_WithDependencies()
{
// Arrange
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage));
string json = DoubleQuotify("{'dependencies': ['actions/checkout@v4:sha256-abc123', 'actions/setup-node@v4:sha256-def456']}");
// Act
using var stream = new MemoryStream();
stream.Write(Encoding.UTF8.GetBytes(json));
stream.Position = 0;
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
// Assert
Assert.NotNull(recoveredMessage);
Assert.Equal(2, recoveredMessage.ActionsDependencies.Count);
Assert.Equal("actions/checkout@v4:sha256-abc123", recoveredMessage.ActionsDependencies[0]);
Assert.Equal("actions/setup-node@v4:sha256-def456", recoveredMessage.ActionsDependencies[1]);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Common")]
public void VerifyActionsDependenciesDeserialization_DefaultsToEmpty()
{
// Arrange
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage));
string json = DoubleQuotify("{'messageType': 'PipelineAgentJobRequest'}");
// Act
using var stream = new MemoryStream();
stream.Write(Encoding.UTF8.GetBytes(json));
stream.Position = 0;
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
// Assert
Assert.NotNull(recoveredMessage);
Assert.Empty(recoveredMessage.ActionsDependencies);
}
private static string DoubleQuotify(string text)
{
return text.Replace('\'', '"');
}
}

View File

@@ -0,0 +1,100 @@
using GitHub.DistributedTask.Pipelines.Expressions;
using Xunit;
namespace GitHub.Runner.Common.Tests.Sdk
{
public sealed class WellKnownRegularExpressionsL0
{
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Sdk")]
public void SHA1_Key_Returns_CommitHash_Regex()
{
var regex = WellKnownRegularExpressions.GetRegex(WellKnownRegularExpressions.SHA1);
Assert.NotNull(regex);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Sdk")]
public void CommitHash_Key_Returns_CommitHash_Regex()
{
var regex = WellKnownRegularExpressions.GetRegex(WellKnownRegularExpressions.CommitHash);
Assert.NotNull(regex);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Sdk")]
public void SHA1_And_CommitHash_Return_Same_Regex()
{
var sha1Regex = WellKnownRegularExpressions.GetRegex(WellKnownRegularExpressions.SHA1);
var commitHashRegex = WellKnownRegularExpressions.GetRegex(WellKnownRegularExpressions.CommitHash);
Assert.Same(sha1Regex, commitHashRegex);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Sdk")]
public void Matches_40_Char_Hex()
{
var regex = WellKnownRegularExpressions.GetRegex(WellKnownRegularExpressions.CommitHash);
Assert.Matches(regex.Value, new string('a', 40));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Sdk")]
public void Matches_64_Char_Hex()
{
var regex = WellKnownRegularExpressions.GetRegex(WellKnownRegularExpressions.CommitHash);
Assert.Matches(regex.Value, new string('a', 64));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Sdk")]
public void Does_Not_Match_63_Char_Hex()
{
var regex = WellKnownRegularExpressions.GetRegex(WellKnownRegularExpressions.CommitHash);
Assert.DoesNotMatch(regex.Value, new string('a', 63));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Sdk")]
public void Does_Not_Match_65_Char_Hex()
{
var regex = WellKnownRegularExpressions.GetRegex(WellKnownRegularExpressions.CommitHash);
Assert.DoesNotMatch(regex.Value, new string('a', 65));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Sdk")]
public void Matches_Mixed_Case_64_Char()
{
var regex = WellKnownRegularExpressions.GetRegex(WellKnownRegularExpressions.CommitHash);
var value = new string('A', 32) + new string('b', 32);
Assert.Matches(regex.Value, value);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Sdk")]
public void Unknown_Key_Returns_Null()
{
var regex = WellKnownRegularExpressions.GetRegex("UnknownType");
Assert.Null(regex);
}
}
}

View File

@@ -2,6 +2,7 @@
using GitHub.Runner.Listener.Check;
using GitHub.Runner.Listener.Configuration;
using GitHub.Runner.Worker;
using GitHub.Runner.Worker.Dap;
using GitHub.Runner.Worker.Container.ContainerHooks;
using GitHub.Runner.Worker.Handlers;
using System;

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.IO;
using System.IO.Compression;
using System.Net;
@@ -24,6 +25,7 @@ namespace GitHub.Runner.Common.Tests.Worker
public sealed class ActionManagerL0
{
private const string TestDataFolderName = "TestData";
private const string Sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
private CancellationTokenSource _ecTokenSource;
private Mock<IConfigurationStore> _configurationStore;
private Mock<IDockerCommandManager> _dockerManager;
@@ -198,7 +200,8 @@ namespace GitHub.Runner.Common.Tests.Worker
Func<Task> action = async () => await _actionManager.PrepareActionsAsync(_ec.Object, actions);
//Assert
await Assert.ThrowsAsync<ActionNotFoundException>(action);
var ex = await Assert.ThrowsAsync<FailedToDownloadActionException>(action);
Assert.IsType<ActionNotFoundException>(ex.InnerException);
var watermarkFile = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), ActionName, "main.completed");
Assert.False(File.Exists(watermarkFile));
@@ -332,7 +335,7 @@ runs:
await File.WriteAllTextAsync(Path.Combine(_hc.GetDirectory(WellKnownDirectory.Temp), "actions-download-artifact", "action.yml"), Content);
#if OS_WINDOWS
ZipFile.CreateFromDirectory(Path.Combine(_hc.GetDirectory(WellKnownDirectory.Temp), "actions-download-artifact"), Path.Combine(_hc.GetDirectory(WellKnownDirectory.Temp), "action_cache", "actions_download-artifact", "master-sha.zip"), CompressionLevel.Fastest, true);
ZipFile.CreateFromDirectory(Path.Combine(_hc.GetDirectory(WellKnownDirectory.Temp), "actions-download-artifact"), Path.Combine(_hc.GetDirectory(WellKnownDirectory.Temp), "action_cache", "actions_download-artifact", $"{Sha256}.zip"), CompressionLevel.Fastest, true);
#else
string tar = WhichUtil.Which("tar", require: true, trace: _hc.GetTrace());
@@ -358,7 +361,7 @@ runs:
string cwd = Path.GetDirectoryName(Path.Combine(_hc.GetDirectory(WellKnownDirectory.Temp), "actions-download-artifact"));
string inputDirectory = Path.GetFileName(Path.Combine(_hc.GetDirectory(WellKnownDirectory.Temp), "actions-download-artifact"));
string archiveFile = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Temp), "action_cache", "actions_download-artifact", "master-sha.tar.gz");
string archiveFile = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Temp), "action_cache", "actions_download-artifact", $"{Sha256}.tar.gz");
int exitCode = await processInvoker.ExecuteAsync(_hc.GetDirectory(WellKnownDirectory.Bin), tar, $"-czf \"{archiveFile}\" -C \"{cwd}\" \"{inputDirectory}\"", null, CancellationToken.None);
if (exitCode != 0)
{
@@ -366,6 +369,8 @@ runs:
}
}
#endif
MockResolvedSha("actions/download-artifact", "master", Sha256);
var actionId = Guid.NewGuid();
var actions = new List<Pipelines.ActionStep>
{
@@ -514,9 +519,10 @@ runs:
string actionsArchive = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Temp), "actions_archive", "action_checkout");
Directory.CreateDirectory(actionsArchive);
Directory.CreateDirectory(Path.Combine(actionsArchive, "actions_checkout", "master-sha"));
Directory.CreateDirectory(Path.Combine(actionsArchive, "actions_checkout", "master-sha", "content"));
await File.WriteAllTextAsync(Path.Combine(actionsArchive, "actions_checkout", "master-sha", "content", "action.yml"), Content);
Directory.CreateDirectory(Path.Combine(actionsArchive, "actions_checkout", Sha256));
Directory.CreateDirectory(Path.Combine(actionsArchive, "actions_checkout", Sha256, "content"));
await File.WriteAllTextAsync(Path.Combine(actionsArchive, "actions_checkout", Sha256, "content", "action.yml"), Content);
MockResolvedSha("actions/checkout", "master", Sha256);
Environment.SetEnvironmentVariable(Constants.Variables.Agent.ActionArchiveCacheDirectory, actionsArchive);
//Act
@@ -1253,6 +1259,659 @@ runs:
}
#endif
// =================================================================
// Tests for batched action resolution optimization
// =================================================================
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async void PrepareActions_BatchesResolutionAcrossCompositeActions()
{
// Verifies that when multiple composite actions at the same depth
// reference sub-actions, those sub-actions are resolved in a single
// batched API call rather than one call per composite.
//
// Action tree:
// CompositePrestep (composite) → [Node action, CompositePrestep2 (composite)]
// CompositePrestep2 (composite) → [Node action, Docker action]
//
// Without batching: 3 API calls (depth 0, depth 1 for CompositePrestep, depth 2 for CompositePrestep2)
// With batching: still 3 calls at most, but the key is that depth-1
// sub-actions from all composites at depth 0 are batched into 1 call.
// And the same action appearing at multiple depths triggers only 1 resolve.
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", "true");
try
{
//Arrange
Setup();
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
var resolveCallCount = 0;
var resolvedActions = new List<ActionReferenceList>();
_jobServer.Setup(x => x.ResolveActionDownloadInfoAsync(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<Guid>(), It.IsAny<Guid>(), It.IsAny<ActionReferenceList>(), It.IsAny<CancellationToken>()))
.Returns((Guid scopeIdentifier, string hubName, Guid planId, Guid jobId, ActionReferenceList actions, CancellationToken cancellationToken) =>
{
resolveCallCount++;
resolvedActions.Add(actions);
var result = new ActionDownloadInfoCollection { Actions = new Dictionary<string, ActionDownloadInfo>() };
foreach (var action in actions.Actions)
{
var key = $"{action.NameWithOwner}@{action.Ref}";
result.Actions[key] = new ActionDownloadInfo
{
NameWithOwner = action.NameWithOwner,
Ref = action.Ref,
ResolvedNameWithOwner = action.NameWithOwner,
ResolvedSha = $"{action.Ref}-sha",
TarballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/tarball/{action.Ref}",
ZipballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/zipball/{action.Ref}",
};
}
return Task.FromResult(result);
});
var actionId = Guid.NewGuid();
var actions = new List<Pipelines.ActionStep>
{
new Pipelines.ActionStep()
{
Name = "action",
Id = actionId,
Reference = new Pipelines.RepositoryPathReference()
{
Name = "TingluoHuang/runner_L0",
Ref = "CompositePrestep",
RepositoryType = "GitHub"
}
}
};
//Act
var result = await _actionManager.PrepareActionsAsync(_ec.Object, actions);
//Assert
// The composite tree is:
// depth 0: CompositePrestep
// depth 1: Node@RepositoryActionWithWrapperActionfile_Node + CompositePrestep2
// depth 2: Node@RepositoryActionWithWrapperActionfile_Node + Docker@RepositoryActionWithWrapperActionfile_Docker
//
// With batching:
// Call 1 (depth 0, resolve): CompositePrestep
// Call 2 (depth 0→1, pre-resolve): Node + CompositePrestep2 in one batch
// Call 3 (depth 1→2, pre-resolve): Docker only (Node already cached from call 2)
Assert.Equal(3, resolveCallCount);
// Call 1: depth 0 resolve — just the top-level composite
var call1Keys = resolvedActions[0].Actions.Select(a => $"{a.NameWithOwner}@{a.Ref}").OrderBy(k => k).ToList();
Assert.Equal(new[] { "TingluoHuang/runner_L0@CompositePrestep" }, call1Keys);
// Call 2: depth 0→1 pre-resolve — batch both children of CompositePrestep
var call2Keys = resolvedActions[1].Actions.Select(a => $"{a.NameWithOwner}@{a.Ref}").OrderBy(k => k).ToList();
Assert.Equal(new[] { "TingluoHuang/runner_L0@CompositePrestep2", "TingluoHuang/runner_L0@RepositoryActionWithWrapperActionfile_Node" }, call2Keys);
// Call 3: depth 1→2 pre-resolve — only Docker (Node was cached in call 2)
var call3Keys = resolvedActions[2].Actions.Select(a => $"{a.NameWithOwner}@{a.Ref}").OrderBy(k => k).ToList();
Assert.Equal(new[] { "TingluoHuang/runner_L0@RepositoryActionWithWrapperActionfile_Docker" }, call3Keys);
// Verify all actions were downloaded
Assert.True(File.Exists(Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), "TingluoHuang/runner_L0", "CompositePrestep.completed")));
Assert.True(File.Exists(Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), "TingluoHuang/runner_L0", "RepositoryActionWithWrapperActionfile_Node.completed")));
Assert.True(File.Exists(Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), "TingluoHuang/runner_L0", "CompositePrestep2.completed")));
Assert.True(File.Exists(Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), "TingluoHuang/runner_L0", "RepositoryActionWithWrapperActionfile_Docker.completed")));
// Verify pre-step tracking still works correctly
Assert.Equal(1, result.PreStepTracker.Count);
}
finally
{
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", null);
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async void PrepareActions_DeduplicatesResolutionAcrossDepthLevels()
{
// Verifies that an action appearing at multiple depths in the
// composite tree is only resolved once (not re-resolved at each level).
//
// CompositePrestep uses Node action at depth 1.
// CompositePrestep2 (also at depth 1) uses the SAME Node action at depth 2.
// The Node action should only be resolved once total.
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", "true");
try
{
//Arrange
Setup();
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
var allResolvedKeys = new List<string>();
_jobServer.Setup(x => x.ResolveActionDownloadInfoAsync(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<Guid>(), It.IsAny<Guid>(), It.IsAny<ActionReferenceList>(), It.IsAny<CancellationToken>()))
.Returns((Guid scopeIdentifier, string hubName, Guid planId, Guid jobId, ActionReferenceList actions, CancellationToken cancellationToken) =>
{
var result = new ActionDownloadInfoCollection { Actions = new Dictionary<string, ActionDownloadInfo>() };
foreach (var action in actions.Actions)
{
var key = $"{action.NameWithOwner}@{action.Ref}";
allResolvedKeys.Add(key);
result.Actions[key] = new ActionDownloadInfo
{
NameWithOwner = action.NameWithOwner,
Ref = action.Ref,
ResolvedNameWithOwner = action.NameWithOwner,
ResolvedSha = $"{action.Ref}-sha",
TarballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/tarball/{action.Ref}",
ZipballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/zipball/{action.Ref}",
};
}
return Task.FromResult(result);
});
var actionId = Guid.NewGuid();
var actions = new List<Pipelines.ActionStep>
{
new Pipelines.ActionStep()
{
Name = "action",
Id = actionId,
Reference = new Pipelines.RepositoryPathReference()
{
Name = "TingluoHuang/runner_L0",
Ref = "CompositePrestep",
RepositoryType = "GitHub"
}
}
};
//Act
await _actionManager.PrepareActionsAsync(_ec.Object, actions);
//Assert
// TingluoHuang/runner_L0@RepositoryActionWithWrapperActionfile_Node appears
// at both depth 1 (sub-step of CompositePrestep) and depth 2 (sub-step of
// CompositePrestep2). With deduplication it should only be resolved once.
var nodeActionKey = "TingluoHuang/runner_L0@RepositoryActionWithWrapperActionfile_Node";
var nodeResolveCount = allResolvedKeys.FindAll(k => k == nodeActionKey).Count;
Assert.Equal(1, nodeResolveCount);
// Verify the total number of unique actions resolved matches the tree
var uniqueKeys = new HashSet<string>(allResolvedKeys);
// Expected unique actions: CompositePrestep, Node, CompositePrestep2, Docker = 4
Assert.Equal(4, uniqueKeys.Count);
}
finally
{
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", null);
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async void PrepareActions_MultipleTopLevelActions_BatchesResolution()
{
// Verifies that multiple independent actions at depth 0 are
// resolved in a single API call.
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", "true");
try
{
//Arrange
Setup();
// Node action has pre+post, needs IActionRunner instances
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
var resolveCallCount = 0;
var firstCallActionCount = 0;
_jobServer.Setup(x => x.ResolveActionDownloadInfoAsync(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<Guid>(), It.IsAny<Guid>(), It.IsAny<ActionReferenceList>(), It.IsAny<CancellationToken>()))
.Returns((Guid scopeIdentifier, string hubName, Guid planId, Guid jobId, ActionReferenceList actions, CancellationToken cancellationToken) =>
{
resolveCallCount++;
if (resolveCallCount == 1)
{
firstCallActionCount = actions.Actions.Count;
}
var result = new ActionDownloadInfoCollection { Actions = new Dictionary<string, ActionDownloadInfo>() };
foreach (var action in actions.Actions)
{
var key = $"{action.NameWithOwner}@{action.Ref}";
result.Actions[key] = new ActionDownloadInfo
{
NameWithOwner = action.NameWithOwner,
Ref = action.Ref,
ResolvedNameWithOwner = action.NameWithOwner,
ResolvedSha = $"{action.Ref}-sha",
TarballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/tarball/{action.Ref}",
ZipballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/zipball/{action.Ref}",
};
}
return Task.FromResult(result);
});
var actions = new List<Pipelines.ActionStep>
{
new Pipelines.ActionStep()
{
Name = "action1",
Id = Guid.NewGuid(),
Reference = new Pipelines.RepositoryPathReference()
{
Name = "TingluoHuang/runner_L0",
Ref = "RepositoryActionWithWrapperActionfile_Node",
RepositoryType = "GitHub"
}
},
new Pipelines.ActionStep()
{
Name = "action2",
Id = Guid.NewGuid(),
Reference = new Pipelines.RepositoryPathReference()
{
Name = "TingluoHuang/runner_L0",
Ref = "RepositoryActionWithWrapperActionfile_Docker",
RepositoryType = "GitHub"
}
}
};
//Act
await _actionManager.PrepareActionsAsync(_ec.Object, actions);
//Assert
// Both actions are at depth 0 — should be resolved in a single batch call
Assert.Equal(1, resolveCallCount);
Assert.Equal(2, firstCallActionCount);
// Verify both were downloaded
Assert.True(File.Exists(Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), "TingluoHuang/runner_L0", "RepositoryActionWithWrapperActionfile_Node.completed")));
Assert.True(File.Exists(Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), "TingluoHuang/runner_L0", "RepositoryActionWithWrapperActionfile_Docker.completed")));
}
finally
{
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", null);
Teardown();
}
}
#if OS_LINUX
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async void PrepareActions_NestedCompositeContainers_BatchedResolution()
{
// Verifies batching with nested composite actions that reference
// container actions (Linux-only since containers require Linux).
//
// CompositeContainerNested (composite):
// → repositoryactionwithdockerfile (Dockerfile)
// → CompositeContainerNested2 (composite):
// → repositoryactionwithdockerfile (Dockerfile, same as above)
// → notpullorbuildimagesmultipletimes1 (Dockerfile)
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", "true");
try
{
//Arrange
Setup();
var resolveCallCount = 0;
_jobServer.Setup(x => x.ResolveActionDownloadInfoAsync(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<Guid>(), It.IsAny<Guid>(), It.IsAny<ActionReferenceList>(), It.IsAny<CancellationToken>()))
.Returns((Guid scopeIdentifier, string hubName, Guid planId, Guid jobId, ActionReferenceList actions, CancellationToken cancellationToken) =>
{
resolveCallCount++;
var result = new ActionDownloadInfoCollection { Actions = new Dictionary<string, ActionDownloadInfo>() };
foreach (var action in actions.Actions)
{
var key = $"{action.NameWithOwner}@{action.Ref}";
result.Actions[key] = new ActionDownloadInfo
{
NameWithOwner = action.NameWithOwner,
Ref = action.Ref,
ResolvedNameWithOwner = action.NameWithOwner,
ResolvedSha = $"{action.Ref}-sha",
TarballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/tarball/{action.Ref}",
ZipballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/zipball/{action.Ref}",
};
}
return Task.FromResult(result);
});
var actionId = Guid.NewGuid();
var actions = new List<Pipelines.ActionStep>
{
new Pipelines.ActionStep()
{
Name = "action",
Id = actionId,
Reference = new Pipelines.RepositoryPathReference()
{
Name = "TingluoHuang/runner_L0",
Ref = "CompositeContainerNested",
RepositoryType = "GitHub"
}
}
};
//Act
var result = await _actionManager.PrepareActionsAsync(_ec.Object, actions);
//Assert
// Tree has 3 depth levels with 5 unique actions.
// With batching, should need at most 3 resolve calls (one per depth level).
Assert.True(resolveCallCount <= 3, $"Expected at most 3 resolve calls but got {resolveCallCount}");
// repositoryactionwithdockerfile appears at both depth 1 and depth 2.
// Container setup should still work correctly — 2 unique Docker images.
Assert.Equal(2, result.ContainerSetupSteps.Count);
}
finally
{
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", null);
Teardown();
}
}
#endif
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async void PrepareActions_ParallelDownloads_MultipleUniqueActions()
{
// Verifies that multiple unique top-level actions are downloaded via
// DownloadActionsInParallelAsync (the parallel code path), and that
// all actions are correctly resolved and downloaded.
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", "true");
try
{
//Arrange
Setup();
// Node action has pre step, and CompositePrestep recurses into
// sub-actions that also need IActionRunner instances
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
var resolveCallCount = 0;
_jobServer.Setup(x => x.ResolveActionDownloadInfoAsync(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<Guid>(), It.IsAny<Guid>(), It.IsAny<ActionReferenceList>(), It.IsAny<CancellationToken>()))
.Returns((Guid scopeIdentifier, string hubName, Guid planId, Guid jobId, ActionReferenceList actions, CancellationToken cancellationToken) =>
{
Interlocked.Increment(ref resolveCallCount);
var result = new ActionDownloadInfoCollection { Actions = new Dictionary<string, ActionDownloadInfo>() };
foreach (var action in actions.Actions)
{
var key = $"{action.NameWithOwner}@{action.Ref}";
result.Actions[key] = new ActionDownloadInfo
{
NameWithOwner = action.NameWithOwner,
Ref = action.Ref,
ResolvedNameWithOwner = action.NameWithOwner,
ResolvedSha = $"{action.Ref}-sha",
TarballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/tarball/{action.Ref}",
ZipballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/zipball/{action.Ref}",
};
}
return Task.FromResult(result);
});
var actions = new List<Pipelines.ActionStep>
{
new Pipelines.ActionStep()
{
Name = "action1",
Id = Guid.NewGuid(),
Reference = new Pipelines.RepositoryPathReference()
{
Name = "TingluoHuang/runner_L0",
Ref = "RepositoryActionWithWrapperActionfile_Node",
RepositoryType = "GitHub"
}
},
new Pipelines.ActionStep()
{
Name = "action2",
Id = Guid.NewGuid(),
Reference = new Pipelines.RepositoryPathReference()
{
Name = "TingluoHuang/runner_L0",
Ref = "RepositoryActionWithWrapperActionfile_Docker",
RepositoryType = "GitHub"
}
},
new Pipelines.ActionStep()
{
Name = "action3",
Id = Guid.NewGuid(),
Reference = new Pipelines.RepositoryPathReference()
{
Name = "TingluoHuang/runner_L0",
Ref = "CompositePrestep",
RepositoryType = "GitHub"
}
}
};
//Act
await _actionManager.PrepareActionsAsync(_ec.Object, actions);
//Assert
// 3 unique actions at depth 0 → triggers DownloadActionsInParallelAsync
// (parallel path used when uniqueDownloads.Count > 1)
var nodeCompleted = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), "TingluoHuang/runner_L0", "RepositoryActionWithWrapperActionfile_Node.completed");
var dockerCompleted = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), "TingluoHuang/runner_L0", "RepositoryActionWithWrapperActionfile_Docker.completed");
var compositeCompleted = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), "TingluoHuang/runner_L0", "CompositePrestep.completed");
Assert.True(File.Exists(nodeCompleted), $"Expected watermark at {nodeCompleted}");
Assert.True(File.Exists(dockerCompleted), $"Expected watermark at {dockerCompleted}");
Assert.True(File.Exists(compositeCompleted), $"Expected watermark at {compositeCompleted}");
// All depth-0 actions resolved in a single batch call.
// Composite sub-actions may add 1-2 more calls.
Assert.True(resolveCallCount >= 1, "Expected at least 1 resolve call");
}
finally
{
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", null);
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async void PrepareActions_DownloadsNextLevelActionsBeforeRecursing()
{
// Verifies that depth-1 actions are downloaded before the depth-2
// pre-resolve fires. We detect this by snapshotting watermark state
// inside the 3rd ResolveActionDownloadInfoAsync callback (which is
// the depth-2 pre-resolve). If pre-download works, depth-1 watermarks
// already exist at that point.
//
// Action tree:
// CompositePrestep (composite) → [Node, CompositePrestep2 (composite)]
// CompositePrestep2 (composite) → [Node, Docker]
//
// Without pre-download: downloads happen during recursion (serial per depth)
// With pre-download: depth 1 actions (Node + CompositePrestep2) are
// downloaded in parallel before recursing, so recursion is a no-op
// for downloads.
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", "true");
try
{
//Arrange
Setup();
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
// Track watermark state at the time of each resolve call.
// If pre-download works, when the 3rd resolve fires (depth 2
// pre-resolve for Docker), the depth-1 actions (Node +
// CompositePrestep2) should already have watermarks on disk.
var resolveCallCount = 0;
var watermarksAtResolve3 = new Dictionary<string, bool>();
_jobServer.Setup(x => x.ResolveActionDownloadInfoAsync(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<Guid>(), It.IsAny<Guid>(), It.IsAny<ActionReferenceList>(), It.IsAny<CancellationToken>()))
.Returns((Guid scopeIdentifier, string hubName, Guid planId, Guid jobId, ActionReferenceList actions, CancellationToken cancellationToken) =>
{
resolveCallCount++;
if (resolveCallCount == 3)
{
// At the time of the 3rd resolve, check if depth-1 actions
// are already downloaded (pre-download should have done this)
var actionsDir2 = _hc.GetDirectory(WellKnownDirectory.Actions);
watermarksAtResolve3["Node"] = File.Exists(Path.Combine(actionsDir2, "TingluoHuang/runner_L0", "RepositoryActionWithWrapperActionfile_Node.completed"));
watermarksAtResolve3["CompositePrestep2"] = File.Exists(Path.Combine(actionsDir2, "TingluoHuang/runner_L0", "CompositePrestep2.completed"));
}
var result = new ActionDownloadInfoCollection { Actions = new Dictionary<string, ActionDownloadInfo>() };
foreach (var action in actions.Actions)
{
var key = $"{action.NameWithOwner}@{action.Ref}";
result.Actions[key] = new ActionDownloadInfo
{
NameWithOwner = action.NameWithOwner,
Ref = action.Ref,
ResolvedNameWithOwner = action.NameWithOwner,
ResolvedSha = $"{action.Ref}-sha",
TarballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/tarball/{action.Ref}",
ZipballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/zipball/{action.Ref}",
};
}
return Task.FromResult(result);
});
var actionId = Guid.NewGuid();
var actions = new List<Pipelines.ActionStep>
{
new Pipelines.ActionStep()
{
Name = "action",
Id = actionId,
Reference = new Pipelines.RepositoryPathReference()
{
Name = "TingluoHuang/runner_L0",
Ref = "CompositePrestep",
RepositoryType = "GitHub"
}
}
};
//Act
var result = await _actionManager.PrepareActionsAsync(_ec.Object, actions);
//Assert
// All actions should be downloaded (watermarks exist)
var actionsDir = _hc.GetDirectory(WellKnownDirectory.Actions);
Assert.True(File.Exists(Path.Combine(actionsDir, "TingluoHuang/runner_L0", "CompositePrestep.completed")));
Assert.True(File.Exists(Path.Combine(actionsDir, "TingluoHuang/runner_L0", "RepositoryActionWithWrapperActionfile_Node.completed")));
Assert.True(File.Exists(Path.Combine(actionsDir, "TingluoHuang/runner_L0", "CompositePrestep2.completed")));
Assert.True(File.Exists(Path.Combine(actionsDir, "TingluoHuang/runner_L0", "RepositoryActionWithWrapperActionfile_Docker.completed")));
// 3 resolve calls total
Assert.Equal(3, resolveCallCount);
// The key assertion: at the time of the 3rd resolve call
// (pre-resolve for depth 2), the depth-1 actions should
// ALREADY be downloaded thanks to pre-download.
// Without pre-download, these watermarks wouldn't exist yet
// because depth-1 downloads would only happen during recursion.
Assert.True(watermarksAtResolve3["Node"],
"Node action should be pre-downloaded before depth 2 pre-resolve");
Assert.True(watermarksAtResolve3["CompositePrestep2"],
"CompositePrestep2 should be pre-downloaded before depth 2 pre-resolve");
}
finally
{
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", null);
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async void PrepareActions_ParallelDownloadsAtSameDepth()
{
// Verifies that multiple unique actions at the same depth are
// downloaded concurrently (Task.WhenAll) rather than sequentially.
// We detect this by checking that all watermarks exist after a
// single PrepareActionsAsync call with multiple top-level actions.
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", "true");
try
{
//Arrange
Setup();
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
_jobServer.Setup(x => x.ResolveActionDownloadInfoAsync(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<Guid>(), It.IsAny<Guid>(), It.IsAny<ActionReferenceList>(), It.IsAny<CancellationToken>()))
.Returns((Guid scopeIdentifier, string hubName, Guid planId, Guid jobId, ActionReferenceList actions, CancellationToken cancellationToken) =>
{
var result = new ActionDownloadInfoCollection { Actions = new Dictionary<string, ActionDownloadInfo>() };
foreach (var action in actions.Actions)
{
var key = $"{action.NameWithOwner}@{action.Ref}";
result.Actions[key] = new ActionDownloadInfo
{
NameWithOwner = action.NameWithOwner,
Ref = action.Ref,
ResolvedNameWithOwner = action.NameWithOwner,
ResolvedSha = $"{action.Ref}-sha",
TarballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/tarball/{action.Ref}",
ZipballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/zipball/{action.Ref}",
};
}
return Task.FromResult(result);
});
var actions = new List<Pipelines.ActionStep>
{
new Pipelines.ActionStep()
{
Name = "action1",
Id = Guid.NewGuid(),
Reference = new Pipelines.RepositoryPathReference()
{
Name = "TingluoHuang/runner_L0",
Ref = "RepositoryActionWithWrapperActionfile_Node",
RepositoryType = "GitHub"
}
},
new Pipelines.ActionStep()
{
Name = "action2",
Id = Guid.NewGuid(),
Reference = new Pipelines.RepositoryPathReference()
{
Name = "TingluoHuang/runner_L0",
Ref = "RepositoryActionWithWrapperActionfile_Docker",
RepositoryType = "GitHub"
}
}
};
//Act
await _actionManager.PrepareActionsAsync(_ec.Object, actions);
//Assert - both downloaded (parallel path used when > 1 unique download)
var actionsDir = _hc.GetDirectory(WellKnownDirectory.Actions);
Assert.True(File.Exists(Path.Combine(actionsDir, "TingluoHuang/runner_L0", "RepositoryActionWithWrapperActionfile_Node.completed")));
Assert.True(File.Exists(Path.Combine(actionsDir, "TingluoHuang/runner_L0", "RepositoryActionWithWrapperActionfile_Docker.completed")));
}
finally
{
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", null);
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
@@ -2494,6 +3153,51 @@ runs:
#endif
}
private void MockResolvedSha(string nameWithOwner, string reference, string resolvedSha)
{
_jobServer.Setup(x => x.ResolveActionDownloadInfoAsync(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<Guid>(), It.IsAny<Guid>(), It.Is<ActionReferenceList>(actions => actions.Actions.Any(action => action.NameWithOwner == nameWithOwner && action.Ref == reference)), It.IsAny<CancellationToken>()))
.Returns((Guid scopeIdentifier, string hubName, Guid planId, Guid jobId, ActionReferenceList actions, CancellationToken cancellationToken) =>
{
var result = new ActionDownloadInfoCollection { Actions = new Dictionary<string, ActionDownloadInfo>() };
foreach (var action in actions.Actions)
{
var key = $"{action.NameWithOwner}@{action.Ref}";
result.Actions[key] = new ActionDownloadInfo
{
NameWithOwner = action.NameWithOwner,
Ref = action.Ref,
ResolvedNameWithOwner = action.NameWithOwner,
ResolvedSha = resolvedSha,
TarballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/tarball/{action.Ref}",
ZipballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/zipball/{action.Ref}",
};
}
return Task.FromResult(result);
});
_launchServer.Setup(x => x.ResolveActionsDownloadInfoAsync(It.IsAny<Guid>(), It.IsAny<Guid>(), It.Is<ActionReferenceList>(actions => actions.Actions.Any(action => action.NameWithOwner == nameWithOwner && action.Ref == reference)), It.IsAny<CancellationToken>(), It.IsAny<bool>()))
.Returns((Guid planId, Guid jobId, ActionReferenceList actions, CancellationToken cancellationToken, bool displayHelpfulActionsDownloadErrors) =>
{
var result = new ActionDownloadInfoCollection { Actions = new Dictionary<string, ActionDownloadInfo>() };
foreach (var action in actions.Actions)
{
var key = $"{action.NameWithOwner}@{action.Ref}";
result.Actions[key] = new ActionDownloadInfo
{
NameWithOwner = action.NameWithOwner,
Ref = action.Ref,
ResolvedNameWithOwner = action.NameWithOwner,
ResolvedSha = resolvedSha,
TarballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/tarball/{action.Ref}",
ZipballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/zipball/{action.Ref}",
};
}
return Task.FromResult(result);
});
}
private void Setup([CallerMemberName] string name = "", bool enableComposite = true)
{
_ecTokenSource?.Dispose();
@@ -2628,5 +3332,141 @@ runs:
Directory.Delete(_workFolder, recursive: true);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task GetDownloadInfoAsync_PropagatesDependencies_WhenPresent()
{
try
{
// Arrange
Setup();
// Set RunServiceJob so we hit the Launch path
_ec.Object.Global.Variables.Set(Constants.Variables.System.JobRequestType, "RunnerJobRequest");
// Populate lockfile dependencies
_ec.Object.Global.ActionsDependencies = new List<string>
{
"github.com/actions/checkout@v4:sha256-abc123",
"github.com/actions/setup-node@v4:sha256-def456"
};
// Capture the ActionReferenceList passed to Launch
ActionReferenceList capturedList = null;
_launchServer
.Setup(x => x.ResolveActionsDownloadInfoAsync(It.IsAny<Guid>(), It.IsAny<Guid>(), It.IsAny<ActionReferenceList>(), It.IsAny<CancellationToken>(), It.IsAny<bool>()))
.Callback<Guid, Guid, ActionReferenceList, CancellationToken, bool>((planId, jobId, list, ct, display) => capturedList = list)
.Returns((Guid planId, Guid jobId, ActionReferenceList actions, CancellationToken ct, bool display) =>
{
var result = new ActionDownloadInfoCollection { Actions = new Dictionary<string, ActionDownloadInfo>() };
foreach (var action in actions.Actions)
{
var key = $"{action.NameWithOwner}@{action.Ref}";
result.Actions[key] = new ActionDownloadInfo
{
NameWithOwner = action.NameWithOwner,
Ref = action.Ref,
ResolvedNameWithOwner = action.NameWithOwner,
ResolvedSha = $"{action.Ref}-sha",
TarballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/tarball/{action.Ref}",
ZipballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/zipball/{action.Ref}",
};
}
return Task.FromResult(result);
});
var actionStep = new Pipelines.ActionStep()
{
Name = "action",
Id = Guid.NewGuid(),
Reference = new Pipelines.RepositoryPathReference()
{
Name = "actions/checkout",
Ref = "v4",
RepositoryType = "GitHub"
}
};
// Act
var result = await _actionManager.PrepareActionsAsync(_ec.Object, new List<Pipelines.JobStep> { actionStep }, default);
// Assert
Assert.NotNull(capturedList);
Assert.NotNull(capturedList.Dependencies);
Assert.Equal(2, capturedList.Dependencies.Count);
Assert.Equal("github.com/actions/checkout@v4:sha256-abc123", capturedList.Dependencies[0]);
Assert.Equal("github.com/actions/setup-node@v4:sha256-def456", capturedList.Dependencies[1]);
}
finally
{
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task GetDownloadInfoAsync_OmitsDependencies_WhenEmpty()
{
try
{
// Arrange
Setup();
// Set RunServiceJob so we hit the Launch path
_ec.Object.Global.Variables.Set(Constants.Variables.System.JobRequestType, "RunnerJobRequest");
// No dependencies set (default empty list from GlobalContext)
// Capture the ActionReferenceList passed to Launch
ActionReferenceList capturedList = null;
_launchServer
.Setup(x => x.ResolveActionsDownloadInfoAsync(It.IsAny<Guid>(), It.IsAny<Guid>(), It.IsAny<ActionReferenceList>(), It.IsAny<CancellationToken>(), It.IsAny<bool>()))
.Callback<Guid, Guid, ActionReferenceList, CancellationToken, bool>((planId, jobId, list, ct, display) => capturedList = list)
.Returns((Guid planId, Guid jobId, ActionReferenceList actions, CancellationToken ct, bool display) =>
{
var result = new ActionDownloadInfoCollection { Actions = new Dictionary<string, ActionDownloadInfo>() };
foreach (var action in actions.Actions)
{
var key = $"{action.NameWithOwner}@{action.Ref}";
result.Actions[key] = new ActionDownloadInfo
{
NameWithOwner = action.NameWithOwner,
Ref = action.Ref,
ResolvedNameWithOwner = action.NameWithOwner,
ResolvedSha = $"{action.Ref}-sha",
TarballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/tarball/{action.Ref}",
ZipballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/zipball/{action.Ref}",
};
}
return Task.FromResult(result);
});
var actionStep = new Pipelines.ActionStep()
{
Name = "action",
Id = Guid.NewGuid(),
Reference = new Pipelines.RepositoryPathReference()
{
Name = "actions/checkout",
Ref = "v4",
RepositoryType = "GitHub"
}
};
// Act
var result = await _actionManager.PrepareActionsAsync(_ec.Object, new List<Pipelines.JobStep> { actionStep }, default);
// Assert
Assert.NotNull(capturedList);
Assert.Null(capturedList.Dependencies);
}
finally
{
Teardown();
}
}
}
}

View File

@@ -504,7 +504,7 @@ namespace GitHub.Runner.Common.Tests.Worker
}
}
[Fact]
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Load_Node24Action()
@@ -928,6 +928,58 @@ namespace GitHub.Runner.Common.Tests.Worker
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Load_ContainerAction_RejectsInvalidExpressionContext()
{
try
{
// Arrange
Setup();
var actionManifest = new ActionManifestManager();
actionManifest.Initialize(_hc);
// Act & Assert — github is not a valid context for container-runs-env (only inputs is allowed)
var ex = Assert.Throws<ArgumentException>(() =>
actionManifest.Load(_ec.Object, Path.Combine(TestUtil.GetTestDataPath(), "dockerfileaction_env_invalid_context.yml")));
Assert.Contains("Failed to load", ex.Message);
}
finally
{
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Load_ContainerAction_AcceptsValidExpressionContext()
{
try
{
// Arrange
Setup();
var actionManifest = new ActionManifestManager();
actionManifest.Initialize(_hc);
// Act — inputs is a valid context for container-runs-env
var result = actionManifest.Load(_ec.Object, Path.Combine(TestUtil.GetTestDataPath(), "dockerfileaction_arg_env_expression.yml"));
// Assert
var containerAction = result.Execution as ContainerActionExecutionDataNew;
Assert.NotNull(containerAction);
Assert.Equal("${{ inputs.entryPoint }}", containerAction.Environment[1].Value.ToString());
}
finally
{
Teardown();
}
}
private void Setup([CallerMemberName] string name = "")
{
_ecTokenSource?.Dispose();
@@ -954,6 +1006,45 @@ namespace GitHub.Runner.Common.Tests.Worker
_ec.Setup(x => x.AddIssue(It.IsAny<Issue>(), It.IsAny<ExecutionContextLogOptions>())).Callback((Issue issue, ExecutionContextLogOptions logOptions) => { _hc.GetTrace().Info($"[{issue.Type}]{logOptions.LogMessageOverride ?? issue.Message}"); });
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Evaluate_Default_Input_Case_Function()
{
try
{
//Arrange
Setup();
var actionManifest = new ActionManifestManager();
actionManifest.Initialize(_hc);
_ec.Object.ExpressionValues["github"] = new LegacyContextData.DictionaryContextData
{
{ "ref", new LegacyContextData.StringContextData("refs/heads/main") },
};
_ec.Object.ExpressionValues["strategy"] = new LegacyContextData.DictionaryContextData();
_ec.Object.ExpressionValues["matrix"] = new LegacyContextData.DictionaryContextData();
_ec.Object.ExpressionValues["steps"] = new LegacyContextData.DictionaryContextData();
_ec.Object.ExpressionValues["job"] = new LegacyContextData.DictionaryContextData();
_ec.Object.ExpressionValues["runner"] = new LegacyContextData.DictionaryContextData();
_ec.Object.ExpressionValues["env"] = new LegacyContextData.DictionaryContextData();
_ec.Object.ExpressionFunctions.Add(new LegacyExpressions.FunctionInfo<GitHub.Runner.Worker.Expressions.HashFilesFunction>("hashFiles", 1, 255));
// Act — evaluate a case() expression as a default input value.
// The feature flag is set, so this should succeed.
var token = new BasicExpressionToken(null, null, null, "case(true, 'matched', 'default')");
var result = actionManifest.EvaluateDefaultInput(_ec.Object, "testInput", token);
// Assert — case() should evaluate successfully
Assert.Equal("matched", result);
}
finally
{
Teardown();
}
}
private void Teardown()
{
_hc?.Dispose();

View File

@@ -926,6 +926,58 @@ namespace GitHub.Runner.Common.Tests.Worker
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Load_ContainerAction_RejectsInvalidExpressionContext()
{
try
{
// Arrange
Setup();
var actionManifest = new ActionManifestManagerLegacy();
actionManifest.Initialize(_hc);
// Act & Assert — github is not a valid context for container-runs-env (only inputs is allowed)
var ex = Assert.Throws<ArgumentException>(() =>
actionManifest.Load(_ec.Object, Path.Combine(TestUtil.GetTestDataPath(), "dockerfileaction_env_invalid_context.yml")));
Assert.Contains("Failed to load", ex.Message);
}
finally
{
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Load_ContainerAction_AcceptsValidExpressionContext()
{
try
{
// Arrange
Setup();
var actionManifest = new ActionManifestManagerLegacy();
actionManifest.Initialize(_hc);
// Act — inputs is a valid context for container-runs-env
var result = actionManifest.Load(_ec.Object, Path.Combine(TestUtil.GetTestDataPath(), "dockerfileaction_arg_env_expression.yml"));
// Assert
var containerAction = result.Execution as ContainerActionExecutionData;
Assert.NotNull(containerAction);
Assert.Equal("${{ inputs.entryPoint }}", containerAction.Environment[1].Value.ToString());
}
finally
{
Teardown();
}
}
private void Setup([CallerMemberName] string name = "")
{
_ecTokenSource?.Dispose();

View File

@@ -379,6 +379,40 @@ namespace GitHub.Runner.Common.Tests.Worker
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Load_BothParsersRejectInvalidExpressionContext()
{
try
{
// Arrange — regression test: both parsers must reject github context
// in container-runs-env (only inputs is allowed per schema)
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var legacyManager = new ActionManifestManagerLegacy();
legacyManager.Initialize(_hc);
_hc.SetSingleton<IActionManifestManagerLegacy>(legacyManager);
var newManager = new ActionManifestManager();
newManager.Initialize(_hc);
_hc.SetSingleton<IActionManifestManager>(newManager);
var wrapper = new ActionManifestManagerWrapper();
wrapper.Initialize(_hc);
var manifestPath = Path.Combine(TestUtil.GetTestDataPath(), "dockerfileaction_env_invalid_context.yml");
// Act & Assert — both parsers should reject, wrapper should throw
Assert.Throws<ArgumentException>(() => wrapper.Load(_ec.Object, manifestPath));
}
finally
{
Teardown();
}
}
private string GetFullExceptionMessage(Exception ex)
{
var messages = new List<string>();

File diff suppressed because it is too large Load Diff

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,237 @@
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using GitHub.DistributedTask.Expressions2;
using GitHub.DistributedTask.Pipelines.ContextData;
using GitHub.Runner.Common.Tests;
using GitHub.Runner.Worker;
using GitHub.Runner.Worker.Dap;
using Moq;
using Xunit;
namespace GitHub.Runner.Common.Tests.Worker
{
public sealed class DapReplExecutorL0
{
private TestHostContext _hc;
private DapReplExecutor _executor;
private List<Event> _sentEvents;
private TestHostContext CreateTestContext([CallerMemberName] string testName = "")
{
_hc = new TestHostContext(this, testName);
_sentEvents = new List<Event>();
_executor = new DapReplExecutor(_hc, (category, text) =>
{
_sentEvents.Add(new Event
{
EventType = "output",
Body = new OutputEventBody
{
Category = category,
Output = text
}
});
});
return _hc;
}
private Mock<IExecutionContext> CreateMockContext(
DictionaryContextData exprValues = null,
IDictionary<string, IDictionary<string, string>> jobDefaults = null)
{
var mock = new Mock<IExecutionContext>();
mock.Setup(x => x.ExpressionValues).Returns(exprValues ?? new DictionaryContextData());
mock.Setup(x => x.ExpressionFunctions).Returns(new List<IFunctionInfo>());
var global = new GlobalContext
{
PrependPath = new List<string>(),
JobDefaults = jobDefaults
?? new Dictionary<string, IDictionary<string, string>>(StringComparer.OrdinalIgnoreCase),
};
mock.Setup(x => x.Global).Returns(global);
return mock;
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task ExecuteRunCommand_NullContext_ReturnsError()
{
using (CreateTestContext())
{
var command = new RunCommand { Script = "echo hello" };
var result = await _executor.ExecuteRunCommandAsync(command, null, CancellationToken.None);
Assert.Equal("error", result.Type);
Assert.Contains("No execution context available", result.Result);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ExpandExpressions_NoExpressions_ReturnsInput()
{
using (CreateTestContext())
{
var context = CreateMockContext();
var result = _executor.ExpandExpressions("echo hello", context.Object);
Assert.Equal("echo hello", result);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ExpandExpressions_NullInput_ReturnsEmpty()
{
using (CreateTestContext())
{
var context = CreateMockContext();
var result = _executor.ExpandExpressions(null, context.Object);
Assert.Equal(string.Empty, result);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ExpandExpressions_EmptyInput_ReturnsEmpty()
{
using (CreateTestContext())
{
var context = CreateMockContext();
var result = _executor.ExpandExpressions("", context.Object);
Assert.Equal(string.Empty, result);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ExpandExpressions_UnterminatedExpression_KeepsLiteral()
{
using (CreateTestContext())
{
var context = CreateMockContext();
var result = _executor.ExpandExpressions("echo ${{ github.repo", context.Object);
Assert.Equal("echo ${{ github.repo", result);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ResolveDefaultShell_NoJobDefaults_ReturnsPlatformDefault()
{
using (CreateTestContext())
{
var context = CreateMockContext();
var result = _executor.ResolveDefaultShell(context.Object);
#if OS_WINDOWS
Assert.True(result == "pwsh" || result == "powershell");
#else
Assert.Equal("sh", result);
#endif
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ResolveDefaultShell_WithJobDefault_ReturnsJobDefault()
{
using (CreateTestContext())
{
var jobDefaults = new Dictionary<string, IDictionary<string, string>>(StringComparer.OrdinalIgnoreCase)
{
["run"] = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["shell"] = "bash"
}
};
var context = CreateMockContext(jobDefaults: jobDefaults);
var result = _executor.ResolveDefaultShell(context.Object);
Assert.Equal("bash", result);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void BuildEnvironment_MergesEnvContextAndReplOverrides()
{
using (CreateTestContext())
{
var exprValues = new DictionaryContextData();
var envData = new DictionaryContextData
{
["FOO"] = new StringContextData("bar"),
};
exprValues["env"] = envData;
var context = CreateMockContext(exprValues);
var replEnv = new Dictionary<string, string> { { "BAZ", "qux" } };
var result = _executor.BuildEnvironment(context.Object, replEnv);
Assert.Equal("bar", result["FOO"]);
Assert.Equal("qux", result["BAZ"]);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void BuildEnvironment_ReplOverridesWin()
{
using (CreateTestContext())
{
var exprValues = new DictionaryContextData();
var envData = new DictionaryContextData
{
["FOO"] = new StringContextData("original"),
};
exprValues["env"] = envData;
var context = CreateMockContext(exprValues);
var replEnv = new Dictionary<string, string> { { "FOO", "override" } };
var result = _executor.BuildEnvironment(context.Object, replEnv);
Assert.Equal("override", result["FOO"]);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void BuildEnvironment_NullReplEnv_ReturnsContextEnvOnly()
{
using (CreateTestContext())
{
var exprValues = new DictionaryContextData();
var envData = new DictionaryContextData
{
["FOO"] = new StringContextData("bar"),
};
exprValues["env"] = envData;
var context = CreateMockContext(exprValues);
var result = _executor.BuildEnvironment(context.Object, null);
Assert.Equal("bar", result["FOO"]);
Assert.False(result.ContainsKey("BAZ"));
}
}
}
}

View File

@@ -0,0 +1,314 @@
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using GitHub.Runner.Common.Tests;
using GitHub.Runner.Worker.Dap;
using Xunit;
namespace GitHub.Runner.Common.Tests.Worker
{
public sealed class DapReplParserL0
{
#region help command
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Parse_HelpReturnsHelpCommand()
{
var cmd = DapReplParser.TryParse("help", out var error);
Assert.Null(error);
var help = Assert.IsType<HelpCommand>(cmd);
Assert.Null(help.Topic);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Parse_HelpCaseInsensitive()
{
var cmd = DapReplParser.TryParse("Help", out var error);
Assert.Null(error);
Assert.IsType<HelpCommand>(cmd);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Parse_HelpWithTopic()
{
var cmd = DapReplParser.TryParse("help(\"run\")", out var error);
Assert.Null(error);
var help = Assert.IsType<HelpCommand>(cmd);
Assert.Equal("run", help.Topic);
}
#endregion
#region run command basic
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Parse_RunSimpleScript()
{
var cmd = DapReplParser.TryParse("run(\"echo hello\")", out var error);
Assert.Null(error);
var run = Assert.IsType<RunCommand>(cmd);
Assert.Equal("echo hello", run.Script);
Assert.Null(run.Shell);
Assert.Null(run.Env);
Assert.Null(run.WorkingDirectory);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Parse_RunWithShell()
{
var cmd = DapReplParser.TryParse("run(\"echo hello\", shell: \"bash\")", out var error);
Assert.Null(error);
var run = Assert.IsType<RunCommand>(cmd);
Assert.Equal("echo hello", run.Script);
Assert.Equal("bash", run.Shell);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Parse_RunWithWorkingDirectory()
{
var cmd = DapReplParser.TryParse("run(\"ls\", working_directory: \"/tmp\")", out var error);
Assert.Null(error);
var run = Assert.IsType<RunCommand>(cmd);
Assert.Equal("ls", run.Script);
Assert.Equal("/tmp", run.WorkingDirectory);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Parse_RunWithEnv()
{
var cmd = DapReplParser.TryParse("run(\"echo $FOO\", env: { FOO: \"bar\" })", out var error);
Assert.Null(error);
var run = Assert.IsType<RunCommand>(cmd);
Assert.Equal("echo $FOO", run.Script);
Assert.NotNull(run.Env);
Assert.Equal("bar", run.Env["FOO"]);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Parse_RunWithMultipleEnvVars()
{
var cmd = DapReplParser.TryParse("run(\"echo\", env: { A: \"1\", B: \"2\" })", out var error);
Assert.Null(error);
var run = Assert.IsType<RunCommand>(cmd);
Assert.Equal(2, run.Env.Count);
Assert.Equal("1", run.Env["A"]);
Assert.Equal("2", run.Env["B"]);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Parse_RunWithAllOptions()
{
var input = "run(\"echo $X\", shell: \"zsh\", env: { X: \"1\" }, working_directory: \"/tmp\")";
var cmd = DapReplParser.TryParse(input, out var error);
Assert.Null(error);
var run = Assert.IsType<RunCommand>(cmd);
Assert.Equal("echo $X", run.Script);
Assert.Equal("zsh", run.Shell);
Assert.Equal("1", run.Env["X"]);
Assert.Equal("/tmp", run.WorkingDirectory);
}
#endregion
#region run command edge cases
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Parse_RunWithEscapedQuotes()
{
var cmd = DapReplParser.TryParse("run(\"echo \\\"hello\\\"\")", out var error);
Assert.Null(error);
var run = Assert.IsType<RunCommand>(cmd);
Assert.Equal("echo \"hello\"", run.Script);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Parse_RunWithCommaInEnvValue()
{
var cmd = DapReplParser.TryParse("run(\"echo\", env: { CSV: \"a,b,c\" })", out var error);
Assert.Null(error);
var run = Assert.IsType<RunCommand>(cmd);
Assert.Equal("a,b,c", run.Env["CSV"]);
}
#endregion
#region error cases
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Parse_RunEmptyArgsReturnsError()
{
var cmd = DapReplParser.TryParse("run()", out var error);
Assert.NotNull(error);
Assert.Null(cmd);
Assert.Contains("requires a script argument", error);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Parse_RunUnquotedArgReturnsError()
{
var cmd = DapReplParser.TryParse("run(echo hello)", out var error);
Assert.NotNull(error);
Assert.Null(cmd);
Assert.Contains("quoted string", error);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Parse_RunUnknownOptionReturnsError()
{
var cmd = DapReplParser.TryParse("run(\"echo\", timeout: \"10\")", out var error);
Assert.NotNull(error);
Assert.Null(cmd);
Assert.Contains("Unknown option", error);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Parse_RunMissingClosingParenReturnsError()
{
var cmd = DapReplParser.TryParse("run(\"echo\"", out var error);
Assert.NotNull(error);
Assert.Null(cmd);
}
#endregion
#region non-DSL input falls through
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Parse_ExpressionReturnsNull()
{
var cmd = DapReplParser.TryParse("github.repository", out var error);
Assert.Null(error);
Assert.Null(cmd);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Parse_WrappedExpressionReturnsNull()
{
var cmd = DapReplParser.TryParse("${{ github.event_name }}", out var error);
Assert.Null(error);
Assert.Null(cmd);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Parse_EmptyInputReturnsNull()
{
var cmd = DapReplParser.TryParse("", out var error);
Assert.Null(error);
Assert.Null(cmd);
cmd = DapReplParser.TryParse(null, out error);
Assert.Null(error);
Assert.Null(cmd);
}
#endregion
#region help text
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void GetGeneralHelp_ContainsCommands()
{
var help = DapReplParser.GetGeneralHelp();
Assert.Contains("help", help);
Assert.Contains("run", help);
Assert.Contains("expression", help, System.StringComparison.OrdinalIgnoreCase);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void GetRunHelp_ContainsOptions()
{
var help = DapReplParser.GetRunHelp();
Assert.Contains("shell", help);
Assert.Contains("env", help);
Assert.Contains("working_directory", help);
}
#endregion
#region internal parser helpers
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void SplitArguments_HandlesNestedBraces()
{
var args = DapReplParser.SplitArguments("\"hello\", env: { A: \"1\", B: \"2\" }", out var error);
Assert.Null(error);
Assert.Equal(2, args.Count);
Assert.Equal("\"hello\"", args[0].Trim());
Assert.Contains("A:", args[1]);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ParseEnvBlock_HandlesEmptyBlock()
{
var result = DapReplParser.ParseEnvBlock("{ }", out var error);
Assert.Null(error);
Assert.NotNull(result);
Assert.Empty(result);
}
#endregion
}
}

View File

@@ -0,0 +1,728 @@
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using GitHub.DistributedTask.Pipelines.ContextData;
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common;
using GitHub.Runner.Common.Tests;
using GitHub.Runner.Worker;
using GitHub.Runner.Worker.Dap;
using Xunit;
namespace GitHub.Runner.Common.Tests.Worker
{
public sealed class DapVariableProviderL0
{
private TestHostContext _hc;
private DapVariableProvider _provider;
private TestHostContext CreateTestContext([CallerMemberName] string testName = "")
{
_hc = new TestHostContext(this, testName);
_provider = new DapVariableProvider(_hc.SecretMasker);
return _hc;
}
private Moq.Mock<GitHub.Runner.Worker.IExecutionContext> CreateMockContext(DictionaryContextData expressionValues)
{
var mock = new Moq.Mock<GitHub.Runner.Worker.IExecutionContext>();
mock.Setup(x => x.ExpressionValues).Returns(expressionValues);
return mock;
}
#region GetScopes tests
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void GetScopes_ReturnsEmptyWhenContextIsNull()
{
using (CreateTestContext())
{
var scopes = _provider.GetScopes(null);
Assert.Empty(scopes);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void GetScopes_ReturnsOnlyPopulatedScopes()
{
using (CreateTestContext())
{
var exprValues = new DictionaryContextData();
exprValues["github"] = new DictionaryContextData
{
{ "repository", new StringContextData("owner/repo") }
};
exprValues["env"] = new DictionaryContextData
{
{ "CI", new StringContextData("true") },
{ "HOME", new StringContextData("/home/runner") }
};
// "runner" is not set — should not appear in scopes
var ctx = CreateMockContext(exprValues);
var scopes = _provider.GetScopes(ctx.Object);
Assert.Equal(2, scopes.Count);
Assert.Equal("github", scopes[0].Name);
Assert.Equal("env", scopes[1].Name);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void GetScopes_ReportsNamedVariableCount()
{
using (CreateTestContext())
{
var exprValues = new DictionaryContextData();
exprValues["env"] = new DictionaryContextData
{
{ "A", new StringContextData("1") },
{ "B", new StringContextData("2") },
{ "C", new StringContextData("3") }
};
var ctx = CreateMockContext(exprValues);
var scopes = _provider.GetScopes(ctx.Object);
Assert.Single(scopes);
Assert.Equal(3, scopes[0].NamedVariables);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void GetScopes_SecretsGetSpecialPresentationHint()
{
using (CreateTestContext())
{
var exprValues = new DictionaryContextData();
exprValues["secrets"] = new DictionaryContextData
{
{ "MY_SECRET", new StringContextData("super-secret") }
};
exprValues["env"] = new DictionaryContextData
{
{ "CI", new StringContextData("true") }
};
var ctx = CreateMockContext(exprValues);
var scopes = _provider.GetScopes(ctx.Object);
var envScope = scopes.Find(s => s.Name == "env");
var secretsScope = scopes.Find(s => s.Name == "secrets");
Assert.NotNull(envScope);
Assert.Null(envScope.PresentationHint);
Assert.NotNull(secretsScope);
Assert.Equal("registers", secretsScope.PresentationHint);
}
}
#endregion
#region GetVariables basic types
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void GetVariables_ReturnsEmptyWhenContextIsNull()
{
using (CreateTestContext())
{
var variables = _provider.GetVariables(null, 1);
Assert.Empty(variables);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void GetVariables_ReturnsStringVariables()
{
using (CreateTestContext())
{
var exprValues = new DictionaryContextData();
exprValues["env"] = new DictionaryContextData
{
{ "CI", new StringContextData("true") },
{ "HOME", new StringContextData("/home/runner") }
};
var ctx = CreateMockContext(exprValues);
// "env" is at ScopeNames index 1 → variablesReference = 2
var variables = _provider.GetVariables(ctx.Object, 2);
Assert.Equal(2, variables.Count);
var ciVar = variables.Find(v => v.Name == "CI");
Assert.NotNull(ciVar);
Assert.Equal("true", ciVar.Value);
Assert.Equal("string", ciVar.Type);
Assert.Equal(0, ciVar.VariablesReference);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void GetVariables_ReturnsBooleanVariables()
{
using (CreateTestContext())
{
var exprValues = new DictionaryContextData();
exprValues["github"] = new DictionaryContextData
{
{ "event_name", new StringContextData("push") },
};
// Use a nested dict with boolean to test
var jobDict = new DictionaryContextData();
// BooleanContextData is a valid PipelineContextData type
// but job context typically has strings. Use env scope instead.
exprValues["env"] = new DictionaryContextData
{
{ "flag", new BooleanContextData(true) }
};
var ctx = CreateMockContext(exprValues);
// "env" is at index 1 → ref 2
var variables = _provider.GetVariables(ctx.Object, 2);
var flagVar = variables.Find(v => v.Name == "flag");
Assert.NotNull(flagVar);
Assert.Equal("true", flagVar.Value);
Assert.Equal("boolean", flagVar.Type);
Assert.Equal(0, flagVar.VariablesReference);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void GetVariables_ReturnsNumberVariables()
{
using (CreateTestContext())
{
var exprValues = new DictionaryContextData();
exprValues["env"] = new DictionaryContextData
{
{ "count", new NumberContextData(42) }
};
var ctx = CreateMockContext(exprValues);
var variables = _provider.GetVariables(ctx.Object, 2);
var countVar = variables.Find(v => v.Name == "count");
Assert.NotNull(countVar);
Assert.Equal("42", countVar.Value);
Assert.Equal("number", countVar.Type);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void GetVariables_HandlesNullValues()
{
using (CreateTestContext())
{
var exprValues = new DictionaryContextData();
var dict = new DictionaryContextData();
dict["present"] = new StringContextData("yes");
dict["missing"] = null;
exprValues["env"] = dict;
var ctx = CreateMockContext(exprValues);
var variables = _provider.GetVariables(ctx.Object, 2);
var nullVar = variables.Find(v => v.Name == "missing");
Assert.NotNull(nullVar);
Assert.Equal("null", nullVar.Value);
Assert.Equal("null", nullVar.Type);
Assert.Equal(0, nullVar.VariablesReference);
}
}
#endregion
#region GetVariables nested expansion
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void GetVariables_NestedDictionaryIsExpandable()
{
using (CreateTestContext())
{
var innerDict = new DictionaryContextData
{
{ "name", new StringContextData("push") },
{ "ref", new StringContextData("refs/heads/main") }
};
var exprValues = new DictionaryContextData();
exprValues["github"] = new DictionaryContextData
{
{ "event", innerDict }
};
var ctx = CreateMockContext(exprValues);
// "github" is at index 0 → ref 1
var variables = _provider.GetVariables(ctx.Object, 1);
var eventVar = variables.Find(v => v.Name == "event");
Assert.NotNull(eventVar);
Assert.Equal("object", eventVar.Type);
Assert.True(eventVar.VariablesReference > 0, "Nested dict should have a non-zero variablesReference");
Assert.Equal(2, eventVar.NamedVariables);
// Now expand it
var children = _provider.GetVariables(ctx.Object, eventVar.VariablesReference);
Assert.Equal(2, children.Count);
var nameVar = children.Find(v => v.Name == "name");
Assert.NotNull(nameVar);
Assert.Equal("push", nameVar.Value);
Assert.Equal("${{ github.event.name }}", nameVar.EvaluateName);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void GetVariables_NestedArrayIsExpandable()
{
using (CreateTestContext())
{
var array = new ArrayContextData();
array.Add(new StringContextData("item0"));
array.Add(new StringContextData("item1"));
var exprValues = new DictionaryContextData();
exprValues["env"] = new DictionaryContextData
{
{ "list", array }
};
var ctx = CreateMockContext(exprValues);
var variables = _provider.GetVariables(ctx.Object, 2);
var listVar = variables.Find(v => v.Name == "list");
Assert.NotNull(listVar);
Assert.Equal("array", listVar.Type);
Assert.True(listVar.VariablesReference > 0);
Assert.Equal(2, listVar.IndexedVariables);
// Expand the array
var items = _provider.GetVariables(ctx.Object, listVar.VariablesReference);
Assert.Equal(2, items.Count);
Assert.Equal("[0]", items[0].Name);
Assert.Equal("item0", items[0].Value);
Assert.Equal("[1]", items[1].Name);
Assert.Equal("item1", items[1].Value);
}
}
#endregion
#region Secret masking
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void GetVariables_SecretsScopeValuesAreRedacted()
{
using (CreateTestContext())
{
var exprValues = new DictionaryContextData();
exprValues["secrets"] = new DictionaryContextData
{
{ "MY_TOKEN", new StringContextData("ghp_abc123secret") },
{ "DB_PASSWORD", new StringContextData("p@ssword!") }
};
var ctx = CreateMockContext(exprValues);
// "secrets" is at index 5 → ref 6
var variables = _provider.GetVariables(ctx.Object, 6);
Assert.Equal(2, variables.Count);
foreach (var v in variables)
{
Assert.Equal("***", v.Value);
Assert.Equal("string", v.Type);
}
// Keys should still be visible
Assert.Contains(variables, v => v.Name == "MY_TOKEN");
Assert.Contains(variables, v => v.Name == "DB_PASSWORD");
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void GetVariables_NonSecretScopeValuesMaskedBySecretMasker()
{
using (var hc = CreateTestContext())
{
// Register a known secret value with the masker
hc.SecretMasker.AddValue("super-secret-token");
var exprValues = new DictionaryContextData();
exprValues["env"] = new DictionaryContextData
{
{ "SAFE", new StringContextData("hello world") },
{ "LEAKED", new StringContextData("prefix-super-secret-token-suffix") }
};
var ctx = CreateMockContext(exprValues);
var variables = _provider.GetVariables(ctx.Object, 2);
var safeVar = variables.Find(v => v.Name == "SAFE");
Assert.NotNull(safeVar);
Assert.Equal("hello world", safeVar.Value);
var leakedVar = variables.Find(v => v.Name == "LEAKED");
Assert.NotNull(leakedVar);
Assert.DoesNotContain("super-secret-token", leakedVar.Value);
Assert.Contains("***", leakedVar.Value);
}
}
#endregion
#region Reset
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Reset_InvalidatesNestedReferences()
{
using (CreateTestContext())
{
var innerDict = new DictionaryContextData
{
{ "name", new StringContextData("push") }
};
var exprValues = new DictionaryContextData();
exprValues["github"] = new DictionaryContextData
{
{ "event", innerDict }
};
var ctx = CreateMockContext(exprValues);
var variables = _provider.GetVariables(ctx.Object, 1);
var eventVar = variables.Find(v => v.Name == "event");
Assert.True(eventVar.VariablesReference > 0);
var savedRef = eventVar.VariablesReference;
// Reset should clear all dynamic references
_provider.Reset();
var children = _provider.GetVariables(ctx.Object, savedRef);
Assert.Empty(children);
}
}
#endregion
#region EvaluateName
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void GetVariables_SetsEvaluateNameWithDotPath()
{
using (CreateTestContext())
{
var exprValues = new DictionaryContextData();
exprValues["github"] = new DictionaryContextData
{
{ "repository", new StringContextData("owner/repo") }
};
var ctx = CreateMockContext(exprValues);
var variables = _provider.GetVariables(ctx.Object, 1);
var repoVar = variables.Find(v => v.Name == "repository");
Assert.NotNull(repoVar);
Assert.Equal("${{ github.repository }}", repoVar.EvaluateName);
}
}
#endregion
#region EvaluateExpression
/// <summary>
/// Creates a mock execution context with Global set up so that
/// ToPipelineTemplateEvaluator() works for real expression evaluation.
/// </summary>
private Moq.Mock<IExecutionContext> CreateEvaluatableContext(
TestHostContext hc,
DictionaryContextData expressionValues)
{
var mock = new Moq.Mock<IExecutionContext>();
mock.Setup(x => x.ExpressionValues).Returns(expressionValues);
mock.Setup(x => x.ExpressionFunctions)
.Returns(new List<GitHub.DistributedTask.Expressions2.IFunctionInfo>());
mock.Setup(x => x.Global).Returns(new GlobalContext
{
FileTable = new List<string>(),
Variables = new Variables(hc, new Dictionary<string, VariableValue>()),
});
// ToPipelineTemplateEvaluator uses ToTemplateTraceWriter which calls
// context.Write — provide a no-op so it doesn't NRE.
mock.Setup(x => x.Write(Moq.It.IsAny<string>(), Moq.It.IsAny<string>()));
return mock;
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateExpression_ReturnsValueForSimpleExpression()
{
using (var hc = CreateTestContext())
{
var exprValues = new DictionaryContextData();
exprValues["github"] = new DictionaryContextData
{
{ "repository", new StringContextData("owner/repo") }
};
var ctx = CreateEvaluatableContext(hc, exprValues);
var result = _provider.EvaluateExpression("github.repository", ctx.Object);
Assert.Equal("owner/repo", result.Result);
Assert.Equal("string", result.Type);
Assert.Equal(0, result.VariablesReference);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateExpression_StripsWrapperSyntax()
{
using (var hc = CreateTestContext())
{
var exprValues = new DictionaryContextData();
exprValues["github"] = new DictionaryContextData
{
{ "event_name", new StringContextData("push") }
};
var ctx = CreateEvaluatableContext(hc, exprValues);
var result = _provider.EvaluateExpression("${{ github.event_name }}", ctx.Object);
Assert.Equal("push", result.Result);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateExpression_MasksSecretInResult()
{
using (var hc = CreateTestContext())
{
hc.SecretMasker.AddValue("super-secret");
var exprValues = new DictionaryContextData();
exprValues["env"] = new DictionaryContextData
{
{ "TOKEN", new StringContextData("super-secret") }
};
var ctx = CreateEvaluatableContext(hc, exprValues);
var result = _provider.EvaluateExpression("env.TOKEN", ctx.Object);
Assert.DoesNotContain("super-secret", result.Result);
Assert.Contains("***", result.Result);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateExpression_ReturnsErrorForInvalidExpression()
{
using (var hc = CreateTestContext())
{
var exprValues = new DictionaryContextData();
exprValues["github"] = new DictionaryContextData();
var ctx = CreateEvaluatableContext(hc, exprValues);
// An invalid expression syntax should not throw — it should
// return an error result.
var result = _provider.EvaluateExpression("!!!invalid[[", ctx.Object);
Assert.Contains("error", result.Result, StringComparison.OrdinalIgnoreCase);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateExpression_ReturnsMessageWhenNoContext()
{
using (CreateTestContext())
{
var result = _provider.EvaluateExpression("github.repository", null);
Assert.Contains("no execution context", result.Result, StringComparison.OrdinalIgnoreCase);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateExpression_ReturnsEmptyForEmptyExpression()
{
using (var hc = CreateTestContext())
{
var exprValues = new DictionaryContextData();
var ctx = CreateEvaluatableContext(hc, exprValues);
var result = _provider.EvaluateExpression("", ctx.Object);
Assert.Equal(string.Empty, result.Result);
}
}
#endregion
#region InferResultType
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void InferResultType_ClassifiesCorrectly()
{
using (CreateTestContext())
{
Assert.Equal("null", DapVariableProvider.InferResultType(null));
Assert.Equal("null", DapVariableProvider.InferResultType("null"));
Assert.Equal("boolean", DapVariableProvider.InferResultType("true"));
Assert.Equal("boolean", DapVariableProvider.InferResultType("false"));
Assert.Equal("number", DapVariableProvider.InferResultType("42"));
Assert.Equal("number", DapVariableProvider.InferResultType("3.14"));
Assert.Equal("object", DapVariableProvider.InferResultType("{\"key\":\"val\"}"));
Assert.Equal("object", DapVariableProvider.InferResultType("[1,2,3]"));
Assert.Equal("string", DapVariableProvider.InferResultType("hello world"));
Assert.Equal("string", DapVariableProvider.InferResultType("owner/repo"));
}
}
#endregion
#region Non-string secret type redaction
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void GetVariables_SecretsScopeRedactsNumberContextData()
{
using (CreateTestContext())
{
var exprValues = new DictionaryContextData();
exprValues["secrets"] = new DictionaryContextData
{
{ "NUMERIC_SECRET", new NumberContextData(12345) }
};
var ctx = CreateMockContext(exprValues);
var variables = _provider.GetVariables(ctx.Object, 6);
Assert.Single(variables);
Assert.Equal("NUMERIC_SECRET", variables[0].Name);
Assert.Equal("***", variables[0].Value);
Assert.Equal("string", variables[0].Type);
Assert.Equal(0, variables[0].VariablesReference);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void GetVariables_SecretsScopeRedactsBooleanContextData()
{
using (CreateTestContext())
{
var exprValues = new DictionaryContextData();
exprValues["secrets"] = new DictionaryContextData
{
{ "BOOL_SECRET", new BooleanContextData(true) }
};
var ctx = CreateMockContext(exprValues);
var variables = _provider.GetVariables(ctx.Object, 6);
Assert.Single(variables);
Assert.Equal("BOOL_SECRET", variables[0].Name);
Assert.Equal("***", variables[0].Value);
Assert.Equal("string", variables[0].Type);
Assert.Equal(0, variables[0].VariablesReference);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void GetVariables_SecretsScopeRedactsNestedDictionary()
{
using (CreateTestContext())
{
var exprValues = new DictionaryContextData();
exprValues["secrets"] = new DictionaryContextData
{
{ "NESTED_SECRET", new DictionaryContextData
{
{ "inner_key", new StringContextData("inner_value") }
}
}
};
var ctx = CreateMockContext(exprValues);
var variables = _provider.GetVariables(ctx.Object, 6);
Assert.Single(variables);
Assert.Equal("NESTED_SECRET", variables[0].Name);
Assert.Equal("***", variables[0].Value);
Assert.Equal("string", variables[0].Type);
// Nested container should NOT be drillable under secrets
Assert.Equal(0, variables[0].VariablesReference);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void GetVariables_SecretsScopeRedactsNullValue()
{
using (CreateTestContext())
{
var exprValues = new DictionaryContextData();
var secrets = new DictionaryContextData();
secrets["NULL_SECRET"] = null;
exprValues["secrets"] = secrets;
var ctx = CreateMockContext(exprValues);
var variables = _provider.GetVariables(ctx.Object, 6);
Assert.Single(variables);
Assert.Equal("NULL_SECRET", variables[0].Name);
Assert.Equal("***", variables[0].Value);
Assert.Equal(0, variables[0].VariablesReference);
}
}
#endregion
}
}

View File

@@ -7,6 +7,7 @@ using GitHub.DistributedTask.Pipelines.ContextData;
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Worker;
using GitHub.Runner.Worker.Container;
using GitHub.Runner.Worker.Dap;
using GitHub.Runner.Worker.Handlers;
using Moq;
using Xunit;
@@ -405,6 +406,7 @@ namespace GitHub.Runner.Common.Tests.Worker
hc.EnqueueInstance(pagingLogger5.Object);
hc.EnqueueInstance(actionRunner1 as IActionRunner);
hc.EnqueueInstance(actionRunner2 as IActionRunner);
hc.SetSingleton(new Mock<IDapDebugger>().Object);
hc.SetSingleton(jobServerQueue.Object);
var jobContext = new Runner.Worker.ExecutionContext();
@@ -503,6 +505,7 @@ namespace GitHub.Runner.Common.Tests.Worker
hc.EnqueueInstance(pagingLogger5.Object);
hc.EnqueueInstance(actionRunner1 as IActionRunner);
hc.EnqueueInstance(actionRunner2 as IActionRunner);
hc.SetSingleton(new Mock<IDapDebugger>().Object);
hc.SetSingleton(jobServerQueue.Object);
var jobContext = new Runner.Worker.ExecutionContext();
@@ -544,6 +547,75 @@ namespace GitHub.Runner.Common.Tests.Worker
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void RegisterPostJobAction_DebuggerDisabled_DoesNotInvokeDapDebugger()
{
using (TestHostContext hc = CreateTestContext())
{
// Arrange: Create a job request message with EnableDebugger left at the default (false).
TaskOrchestrationPlanReference plan = new();
TimelineReference timeline = new();
Guid jobId = Guid.NewGuid();
string jobName = "some job name";
var jobRequest = new Pipelines.AgentJobRequestMessage(plan, timeline, jobId, jobName, jobName, null, null, null, new Dictionary<string, VariableValue>(), new List<MaskHint>(), new Pipelines.JobResources(), new Pipelines.ContextData.DictionaryContextData(), new Pipelines.WorkspaceOptions(), new List<Pipelines.ActionStep>(), null, null, null, null, null);
jobRequest.Resources.Repositories.Add(new Pipelines.RepositoryResource()
{
Alias = Pipelines.PipelineConstants.SelfAlias,
Id = "github",
Version = "sha1"
});
jobRequest.ContextData["github"] = new Pipelines.ContextData.DictionaryContextData();
var pagingLogger = new Mock<IPagingLogger>();
var jobServerQueue = new Mock<IJobServerQueue>();
jobServerQueue.Setup(x => x.QueueTimelineRecordUpdate(It.IsAny<Guid>(), It.IsAny<TimelineRecord>()));
jobServerQueue.Setup(x => x.QueueWebConsoleLine(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<long?>()));
var actionRunner = new ActionRunner();
actionRunner.Initialize(hc);
hc.EnqueueInstance(pagingLogger.Object);
hc.EnqueueInstance(pagingLogger.Object);
hc.EnqueueInstance(pagingLogger.Object);
hc.EnqueueInstance(pagingLogger.Object);
hc.EnqueueInstance(pagingLogger.Object);
hc.EnqueueInstance(pagingLogger.Object);
hc.EnqueueInstance(pagingLogger.Object);
hc.EnqueueInstance(actionRunner as IActionRunner);
// Register a strict mock IDapDebugger. If the production code calls
// ANY method on it, the test fails — proving the containment guard
// short-circuited before HostContext.GetService<IDapDebugger>().
var dapMock = new Mock<IDapDebugger>(MockBehavior.Strict);
hc.SetSingleton(dapMock.Object);
hc.SetSingleton(jobServerQueue.Object);
var jobContext = new Runner.Worker.ExecutionContext();
jobContext.Initialize(hc);
jobContext.InitializeJob(jobRequest, CancellationToken.None);
var action = jobContext.CreateChild(Guid.NewGuid(), "action_1", "action_1", null, null, 0);
var postRunner = hc.CreateService<IActionRunner>();
postRunner.Action = new Pipelines.ActionStep() { Id = Guid.NewGuid(), Name = "post", DisplayName = "Post", Reference = new Pipelines.RepositoryPathReference() { Name = "actions/action" } };
postRunner.Stage = ActionRunStage.Post;
postRunner.Condition = "always()";
postRunner.DisplayName = "post";
// Sanity: ensure the production code path actually believes the debugger is disabled.
Assert.True(jobContext.Global.Debugger == null || jobContext.Global.Debugger.Enabled == false);
// Act.
action.RegisterPostJobStep(postRunner);
// Assert: the debugger was never consulted on the non-debug path.
dapMock.VerifyNoOtherCalls();
Assert.Equal(1, jobContext.PostJobSteps.Count);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
@@ -1203,19 +1275,19 @@ namespace GitHub.Runner.Common.Tests.Worker
}
}
// TODO: this test can be deleted when `AddCheckRunIdToJobContext` is fully rolled out
// AddCheckRunIdToJobContext is now permanently enabled server-side (hardcoded to "true"
// in acquirejobhandler.go). The runner always copies ContextData["job"] entries, so the
// flag-disabled test is no longer applicable. Replaced with a test that verifies
// check_run_id is always hydrated regardless of the flag value.
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void InitializeJob_HydratesJobContextWithCheckRunId_FeatureFlagDisabled()
public void InitializeJob_HydratesJobContextWithCheckRunId_AlwaysCopied()
{
using (TestHostContext hc = CreateTestContext())
{
// Arrange: Create a job request message and make sure the feature flag is disabled
var variables = new Dictionary<string, VariableValue>()
{
[Constants.Runner.Features.AddCheckRunIdToJobContext] = new VariableValue("false"),
};
// Arrange: No feature flag set at all
var variables = new Dictionary<string, VariableValue>();
var jobRequest = new Pipelines.AgentJobRequestMessage(new TaskOrchestrationPlanReference(), new TimelineReference(), Guid.NewGuid(), "some job name", "some job name", null, null, null, variables, new List<MaskHint>(), new Pipelines.JobResources(), new Pipelines.ContextData.DictionaryContextData(), new Pipelines.WorkspaceOptions(), new List<Pipelines.ActionStep>(), null, null, null, null, null);
var pagingLogger = new Moq.Mock<IPagingLogger>();
var jobServerQueue = new Moq.Mock<IJobServerQueue>();
@@ -1233,9 +1305,80 @@ namespace GitHub.Runner.Common.Tests.Worker
// Act
ec.InitializeJob(jobRequest, CancellationToken.None);
// Assert
// Assert: check_run_id is always copied regardless of flag
Assert.NotNull(ec.JobContext);
Assert.Null(ec.JobContext.CheckRunId); // with the feature flag disabled we should not have added a CheckRunId to the JobContext
Assert.Equal(123456, ec.JobContext.CheckRunId);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void InitializeJob_HydratesJobContextWithWorkflowIdentity()
{
using (TestHostContext hc = CreateTestContext())
{
// Arrange
var variables = new Dictionary<string, VariableValue>();
var jobRequest = new Pipelines.AgentJobRequestMessage(new TaskOrchestrationPlanReference(), new TimelineReference(), Guid.NewGuid(), "some job name", "some job name", null, null, null, variables, new List<MaskHint>(), new Pipelines.JobResources(), new Pipelines.ContextData.DictionaryContextData(), new Pipelines.WorkspaceOptions(), new List<Pipelines.ActionStep>(), null, null, null, null, null);
var pagingLogger = new Moq.Mock<IPagingLogger>();
var jobServerQueue = new Moq.Mock<IJobServerQueue>();
hc.EnqueueInstance(pagingLogger.Object);
hc.SetSingleton(jobServerQueue.Object);
var ec = new Runner.Worker.ExecutionContext();
ec.Initialize(hc);
// Arrange: Server sends all 4 workflow identity fields
var jobContext = new Pipelines.ContextData.DictionaryContextData();
jobContext["workflow_ref"] = new StringContextData("my-org/my-repo/.github/workflows/reusable.yml@refs/heads/main");
jobContext["workflow_sha"] = new StringContextData("abc123def456");
jobContext["workflow_repository"] = new StringContextData("my-org/my-repo");
jobContext["workflow_file_path"] = new StringContextData(".github/workflows/reusable.yml");
jobRequest.ContextData["job"] = jobContext;
jobRequest.ContextData["github"] = new Pipelines.ContextData.DictionaryContextData();
// Act
ec.InitializeJob(jobRequest, CancellationToken.None);
// Assert: all properties hydrated from server
Assert.NotNull(ec.JobContext);
Assert.Equal("my-org/my-repo/.github/workflows/reusable.yml@refs/heads/main", ec.JobContext.WorkflowRef);
Assert.Equal("abc123def456", ec.JobContext.WorkflowSha);
Assert.Equal("my-org/my-repo", ec.JobContext.WorkflowRepository);
Assert.Equal(".github/workflows/reusable.yml", ec.JobContext.WorkflowFilePath);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void InitializeJob_WorkflowIdentityNotSet_WhenServerSendsNoData()
{
using (TestHostContext hc = CreateTestContext())
{
// Arrange: Server sends no workflow identity in job context
var variables = new Dictionary<string, VariableValue>();
var jobRequest = new Pipelines.AgentJobRequestMessage(new TaskOrchestrationPlanReference(), new TimelineReference(), Guid.NewGuid(), "some job name", "some job name", null, null, null, variables, new List<MaskHint>(), new Pipelines.JobResources(), new Pipelines.ContextData.DictionaryContextData(), new Pipelines.WorkspaceOptions(), new List<Pipelines.ActionStep>(), null, null, null, null, null);
var pagingLogger = new Moq.Mock<IPagingLogger>();
var jobServerQueue = new Moq.Mock<IJobServerQueue>();
hc.EnqueueInstance(pagingLogger.Object);
hc.SetSingleton(jobServerQueue.Object);
var ec = new Runner.Worker.ExecutionContext();
ec.Initialize(hc);
// Arrange: empty job context
jobRequest.ContextData["job"] = new Pipelines.ContextData.DictionaryContextData();
jobRequest.ContextData["github"] = new Pipelines.ContextData.DictionaryContextData();
// Act
ec.InitializeJob(jobRequest, CancellationToken.None);
// Assert: no workflow identity
Assert.NotNull(ec.JobContext);
Assert.Null(ec.JobContext.WorkflowRef);
Assert.Null(ec.JobContext.WorkflowSha);
Assert.Null(ec.JobContext.WorkflowRepository);
Assert.Null(ec.JobContext.WorkflowFilePath);
}
}

View File

@@ -370,5 +370,504 @@ namespace GitHub.Runner.Common.Tests.Worker
Assert.Contains("./.github/actions/my-action", deprecatedActions);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Node20Action_TrackedAsUpgradedWhenUseNode24ByDefaultEnabled()
{
using (TestHostContext hc = CreateTestContext())
{
// Arrange.
var hf = new HandlerFactory();
hf.Initialize(hc);
var variables = new Dictionary<string, VariableValue>
{
{ Constants.Runner.NodeMigration.WarnOnNode20Flag, new VariableValue("true") },
{ Constants.Runner.NodeMigration.UseNode24ByDefaultFlag, new VariableValue("true") }
};
Variables serverVariables = new(hc, variables);
var deprecatedActions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var upgradedActions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
_ec.Setup(x => x.Global).Returns(new GlobalContext()
{
Variables = serverVariables,
EnvironmentVariables = new Dictionary<string, string>(),
DeprecatedNode20Actions = deprecatedActions,
UpgradedToNode24Actions = upgradedActions
});
var actionRef = new RepositoryPathReference
{
Name = "actions/checkout",
Ref = "v4"
};
// Act.
var data = new NodeJSActionExecutionData();
data.NodeVersion = "node20";
var handler = hf.Create(
_ec.Object,
actionRef,
new Mock<IStepHost>().Object,
data,
new Dictionary<string, string>(),
new Dictionary<string, string>(),
new Variables(hc, new Dictionary<string, VariableValue>()),
"",
new List<JobExtensionRunner>()
) as INodeScriptActionHandler;
// On non-ARM32 platforms, action should be upgraded to node24
// and tracked in UpgradedToNode24Actions, NOT in DeprecatedNode20Actions
bool isArm32Linux = System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture == System.Runtime.InteropServices.Architecture.Arm &&
System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Linux);
if (!isArm32Linux)
{
Assert.Equal("node24", handler.Data.NodeVersion);
Assert.Contains("actions/checkout@v4", upgradedActions);
Assert.DoesNotContain("actions/checkout@v4", deprecatedActions);
}
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Node20Action_NotUpgradedWhenPhase1Only()
{
using (TestHostContext hc = CreateTestContext())
{
// Arrange.
var hf = new HandlerFactory();
hf.Initialize(hc);
var variables = new Dictionary<string, VariableValue>
{
{ Constants.Runner.NodeMigration.WarnOnNode20Flag, new VariableValue("true") }
};
Variables serverVariables = new(hc, variables);
var deprecatedActions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var upgradedActions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
_ec.Setup(x => x.Global).Returns(new GlobalContext()
{
Variables = serverVariables,
EnvironmentVariables = new Dictionary<string, string>(),
DeprecatedNode20Actions = deprecatedActions,
UpgradedToNode24Actions = upgradedActions
});
var actionRef = new RepositoryPathReference
{
Name = "actions/checkout",
Ref = "v4"
};
// Act.
var data = new NodeJSActionExecutionData();
data.NodeVersion = "node20";
var handler = hf.Create(
_ec.Object,
actionRef,
new Mock<IStepHost>().Object,
data,
new Dictionary<string, string>(),
new Dictionary<string, string>(),
new Variables(hc, new Dictionary<string, VariableValue>()),
"",
new List<JobExtensionRunner>()
) as INodeScriptActionHandler;
// In Phase 1 (no UseNode24ByDefault), action stays on node20
// and should be in DeprecatedNode20Actions
Assert.Equal("node20", handler.Data.NodeVersion);
Assert.Contains("actions/checkout@v4", deprecatedActions);
Assert.Empty(upgradedActions);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ExplicitNode24Action_KillArm32Flag_ThrowsOnArm32()
{
using (TestHostContext hc = CreateTestContext())
{
// Arrange.
var hf = new HandlerFactory();
hf.Initialize(hc);
var variables = new Dictionary<string, VariableValue>
{
{ Constants.Runner.NodeMigration.KillLinuxArm32Flag, new VariableValue("true") }
};
Variables serverVariables = new(hc, variables);
_ec.Setup(x => x.Global).Returns(new GlobalContext()
{
Variables = serverVariables,
EnvironmentVariables = new Dictionary<string, string>()
});
var actionRef = new RepositoryPathReference
{
Name = "actions/checkout",
Ref = "v5"
};
// Act - action explicitly declares node24
var data = new NodeJSActionExecutionData();
data.NodeVersion = "node24";
bool isArm32Linux = System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture == System.Runtime.InteropServices.Architecture.Arm &&
System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Linux);
if (isArm32Linux)
{
// On ARM32 Linux, kill flag should cause the handler to throw
Assert.Throws<InvalidOperationException>(() => hf.Create(
_ec.Object,
actionRef,
new Mock<IStepHost>().Object,
data,
new Dictionary<string, string>(),
new Dictionary<string, string>(),
new Variables(hc, new Dictionary<string, VariableValue>()),
"",
new List<JobExtensionRunner>()
));
}
else
{
// On other platforms, should proceed normally
var handler = hf.Create(
_ec.Object,
actionRef,
new Mock<IStepHost>().Object,
data,
new Dictionary<string, string>(),
new Dictionary<string, string>(),
new Variables(hc, new Dictionary<string, VariableValue>()),
"",
new List<JobExtensionRunner>()
) as INodeScriptActionHandler;
Assert.Equal("node24", handler.Data.NodeVersion);
}
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ExplicitNode24Action_DeprecateArm32Flag_DowngradesToNode20OnArm32()
{
using (TestHostContext hc = CreateTestContext())
{
// Arrange.
var hf = new HandlerFactory();
hf.Initialize(hc);
var variables = new Dictionary<string, VariableValue>
{
{ Constants.Runner.NodeMigration.DeprecateLinuxArm32Flag, new VariableValue("true") }
};
Variables serverVariables = new(hc, variables);
var arm32Actions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
_ec.Setup(x => x.Global).Returns(new GlobalContext()
{
Variables = serverVariables,
EnvironmentVariables = new Dictionary<string, string>(),
Arm32Node20Actions = arm32Actions
});
var actionRef = new RepositoryPathReference
{
Name = "actions/checkout",
Ref = "v5"
};
// Act - action explicitly declares node24
var data = new NodeJSActionExecutionData();
data.NodeVersion = "node24";
var handler = hf.Create(
_ec.Object,
actionRef,
new Mock<IStepHost>().Object,
data,
new Dictionary<string, string>(),
new Dictionary<string, string>(),
new Variables(hc, new Dictionary<string, VariableValue>()),
"",
new List<JobExtensionRunner>()
) as INodeScriptActionHandler;
bool isArm32Linux = System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture == System.Runtime.InteropServices.Architecture.Arm &&
System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Linux);
if (isArm32Linux)
{
// On ARM32 Linux, should downgrade to node20 and track
Assert.Equal("node20", handler.Data.NodeVersion);
Assert.Contains("actions/checkout@v5", arm32Actions);
}
else
{
// On other platforms, should remain node24
Assert.Equal("node24", handler.Data.NodeVersion);
Assert.Empty(arm32Actions);
}
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ExplicitNode24Action_NoArm32Flags_StaysNode24()
{
using (TestHostContext hc = CreateTestContext())
{
// Arrange.
var hf = new HandlerFactory();
hf.Initialize(hc);
var variables = new Dictionary<string, VariableValue>();
Variables serverVariables = new(hc, variables);
var arm32Actions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var deprecatedActions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
_ec.Setup(x => x.Global).Returns(new GlobalContext()
{
Variables = serverVariables,
EnvironmentVariables = new Dictionary<string, string>(),
Arm32Node20Actions = arm32Actions,
DeprecatedNode20Actions = deprecatedActions
});
var actionRef = new RepositoryPathReference
{
Name = "actions/checkout",
Ref = "v5"
};
// Act - action explicitly declares node24, no ARM32 flags
var data = new NodeJSActionExecutionData();
data.NodeVersion = "node24";
var handler = hf.Create(
_ec.Object,
actionRef,
new Mock<IStepHost>().Object,
data,
new Dictionary<string, string>(),
new Dictionary<string, string>(),
new Variables(hc, new Dictionary<string, VariableValue>()),
"",
new List<JobExtensionRunner>()
) as INodeScriptActionHandler;
// On non-ARM32 platforms, should stay node24 and not be tracked in any list
bool isArm32Linux = System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture == System.Runtime.InteropServices.Architecture.Arm &&
System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Linux);
if (!isArm32Linux)
{
Assert.Equal("node24", handler.Data.NodeVersion);
Assert.Empty(arm32Actions);
Assert.Empty(deprecatedActions);
}
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Node20Action_RequireNode24_ForcesNode24()
{
using (TestHostContext hc = CreateTestContext())
{
// Arrange.
var hf = new HandlerFactory();
hf.Initialize(hc);
var variables = new Dictionary<string, VariableValue>
{
{ Constants.Runner.NodeMigration.RequireNode24Flag, new VariableValue("true") },
{ Constants.Runner.NodeMigration.WarnOnNode20Flag, new VariableValue("true") }
};
Variables serverVariables = new(hc, variables);
var upgradedActions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var deprecatedActions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
_ec.Setup(x => x.Global).Returns(new GlobalContext()
{
Variables = serverVariables,
EnvironmentVariables = new Dictionary<string, string>(),
UpgradedToNode24Actions = upgradedActions,
DeprecatedNode20Actions = deprecatedActions
});
var actionRef = new RepositoryPathReference
{
Name = "actions/checkout",
Ref = "v4"
};
// Act.
var data = new NodeJSActionExecutionData();
data.NodeVersion = "node20";
bool isArm32Linux = System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture == System.Runtime.InteropServices.Architecture.Arm &&
System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Linux);
if (!isArm32Linux)
{
var handler = hf.Create(
_ec.Object,
actionRef,
new Mock<IStepHost>().Object,
data,
new Dictionary<string, string>(),
new Dictionary<string, string>(),
new Variables(hc, new Dictionary<string, VariableValue>()),
"",
new List<JobExtensionRunner>()
) as INodeScriptActionHandler;
// Phase 3: RequireNode24 forces node24, ignoring env vars
Assert.Equal("node24", handler.Data.NodeVersion);
Assert.Contains("actions/checkout@v4", upgradedActions);
Assert.Empty(deprecatedActions);
}
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Node20Action_KillArm32Flag_ThrowsOnArm32()
{
using (TestHostContext hc = CreateTestContext())
{
// Arrange.
var hf = new HandlerFactory();
hf.Initialize(hc);
var variables = new Dictionary<string, VariableValue>
{
{ Constants.Runner.NodeMigration.KillLinuxArm32Flag, new VariableValue("true") }
};
Variables serverVariables = new(hc, variables);
_ec.Setup(x => x.Global).Returns(new GlobalContext()
{
Variables = serverVariables,
EnvironmentVariables = new Dictionary<string, string>()
});
var actionRef = new RepositoryPathReference
{
Name = "actions/checkout",
Ref = "v4"
};
var data = new NodeJSActionExecutionData();
data.NodeVersion = "node20";
bool isArm32Linux = System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture == System.Runtime.InteropServices.Architecture.Arm &&
System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Linux);
if (isArm32Linux)
{
Assert.Throws<InvalidOperationException>(() => hf.Create(
_ec.Object,
actionRef,
new Mock<IStepHost>().Object,
data,
new Dictionary<string, string>(),
new Dictionary<string, string>(),
new Variables(hc, new Dictionary<string, VariableValue>()),
"",
new List<JobExtensionRunner>()
));
}
else
{
// On non-ARM32, should proceed normally (node20 stays)
var handler = hf.Create(
_ec.Object,
actionRef,
new Mock<IStepHost>().Object,
data,
new Dictionary<string, string>(),
new Dictionary<string, string>(),
new Variables(hc, new Dictionary<string, VariableValue>()),
"",
new List<JobExtensionRunner>()
) as INodeScriptActionHandler;
Assert.Equal("node20", handler.Data.NodeVersion);
}
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ExplicitNode24Action_DeprecateArm32_UsesOriginalVersionForTracking()
{
// Regression test: verifies that when an action explicitly declares node24
// and ARM32 deprecation downgrades it to node20, the tracking call uses
// the original preferred version ("node24"), not the already-overwritten
// nodeData.NodeVersion ("node20"). Without this fix, ShouldTrackAsArm32Node20
// would receive (preferred="node20", final="node20") and never return true.
string originalPreferred = "node24";
string finalAfterArm32Downgrade = "node20";
string deprecationWarning = "Linux ARM32 runners are deprecated and will no longer be supported after September 16th, 2026. Please migrate to a supported platform.";
// Correct: use the original preferred version before assignment
bool correctTracking = HandlerFactory.ShouldTrackAsArm32Node20(
deprecateArm32: true,
preferredNodeVersion: originalPreferred,
finalNodeVersion: finalAfterArm32Downgrade,
platformWarningMessage: deprecationWarning);
Assert.True(correctTracking);
// Bug scenario: if nodeData.NodeVersion was already overwritten to finalNodeVersion
bool buggyTracking = HandlerFactory.ShouldTrackAsArm32Node20(
deprecateArm32: true,
preferredNodeVersion: finalAfterArm32Downgrade,
finalNodeVersion: finalAfterArm32Downgrade,
platformWarningMessage: deprecationWarning);
Assert.False(buggyTracking);
}
[Theory]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
[InlineData(true, "node24", "node20", "Linux ARM32 runners are deprecated", true)]
[InlineData(true, "node20", "node20", "Linux ARM32 runners are deprecated", false)]
[InlineData(true, "node24", "node24", "Linux ARM32 runners are deprecated", false)]
[InlineData(true, "node24", "node20", null, false)]
[InlineData(false, "node24", "node20", "Linux ARM32 runners are deprecated", false)]
public void ShouldTrackAsArm32Node20_ClassifiesOnlyPlatformDowngrades(
bool deprecateArm32,
string preferredNodeVersion,
string finalNodeVersion,
string platformWarningMessage,
bool expected)
{
bool actual = HandlerFactory.ShouldTrackAsArm32Node20(
deprecateArm32,
preferredNodeVersion,
finalNodeVersion,
platformWarningMessage);
Assert.Equal(expected, actual);
}
}
}

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

Some files were not shown because too many files have changed in this diff Show More