Compare commits

...

30 Commits

Author SHA1 Message Date
Francesco Renzi
4dbc4349d6 Add JobExecutionView state container
The DAP debugger needs to map a runtime IStep back to a source line
when answering `stackTrace` requests. The renderer (#PR1b) produces
the YAML and per-entry start lines from an immutable list, but the
debugger's view grows over the job's lifetime: post steps register
lazily, and the integration layer needs O(1) IStep -> line lookup
at every pause.

This commit adds JobExecutionView, a stateful append-only wrapper
around the renderer. It maintains:
  - the current entry list,
  - the most recent rendered YAML,
  - a Dictionary<IStep, int> for fast line lookup.

Each Append can register an entry in one of three modes:
  - with a stepIdentity: registers the IStep -> line mapping
    immediately;
  - with a matchKey: registers an unclaimed placeholder that a
    later TryClaim binds to a real IStep (used when an entry is
    predicted before the runner materializes its IStep, e.g. a
    Post-step placeholder synthesized at job-init from an action's
    metadata);
  - with neither: a static informational entry that needs no line
    lookup.

This is part 3 of 5. The DAP-integration PR that consumes this
container is the final follow-up.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-19 02:49:32 -07:00
Francesco Renzi
c23ac2969d Add JobExecutionViewRenderer for DAP execution view
The DAP debugger serves a synthesized YAML document as the job's
`source`. That document is a 1:1 representation of how the runner
sees the job — not the workflow file — so pre and post action steps
appear as their own 'lines' that the user can pause on (and
eventually breakpoint, set in a follow-up PR).

This commit adds the core rendering algorithm: given a list of
phase-tagged entries (`JobExecutionViewEntry`), produce the
phase-keyed YAML plus a parallel array of 1-based line numbers
pointing at each entry's `- step:` key. The line numbers are what
later powers the DAP `stackTrace` handler.

Why hand-emit the skeleton instead of serializing a DTO?
Per-entry line offsets must be tracked at emission time. Using a
generic YAML serializer would force a second pass to scan the
output for `- step:` lines, which is fragile and breaks the moment
indentation conventions shift. Scalar values still go through the
library (via YamlScalarFormatter from #PR1a), so we don't carry
quoting rules.

Example output for a typical job (build, build, post step):

    # Job: build
    # Runner execution plan — read-only.

    setup:
      - step: Setup job

    main:
      - step: Run actions/checkout@v6
        uses: actions/checkout@v6
        if: success()
      - step: Cache Primes
        id: cache-primes
        uses: actions/cache@v5
        if: success()
        with:
          path: prime-numbers
          key: ${{ runner.os }}-primes

    post:
      - step: Post Cache Primes
        action: actions/cache@v5

    cleanup:
      - step: Complete job

This is part 2 of 5 splitting the previously-monolithic foundation
for review tractability. The wiring that turns runner state into
these entries lives in the next PRs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-19 02:49:32 -07:00
Francesco Renzi
1ec6749d4b Address Copilot review feedback
- Remove the redundant second `TrimEnd('\n')` from the return path.
  The earlier trim already removes any trailing newline before the
  `\n...` doc-end check; the marker-removal substring does not
  re-introduce one, so the second trim was dead code.
- Surface full exception (`ex.ToString()`) in the test round-trip
  helper so YAML parse failures show stack + inner exception, not
  just the top-level message.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-19 02:49:32 -07:00
Francesco Renzi
699901f072 Add YamlScalarFormatter for quote-safe YAML scalars
The upcoming DAP execution-view renderer serves a synthesized YAML
document as the job's debugger source. The skeleton is hand-emitted
so we can track per-step line offsets, but scalar values (step names,
action refs, etc.) need quote-safe formatting that respects YAML's
reserved chars, leading/trailing whitespace, and embedded `: `/`#`
sequences. Doing this by hand is bug-prone and easy to get wrong on
edge cases (empty strings, expressions, multiline content).

This commit adds a thin wrapper around YamlDotNet's `Emitter` that
emits a single scalar, strips the surrounding document markers, and
forces LF line breaks (`StringWriter` otherwise picks up Windows's
CRLF via `Environment.NewLine` and corrupts the document-end
stripping).

No caller yet — the renderer that uses it lands in a follow-up PR.
This is part 1 of 5 splitting the previously-monolithic foundation
for review tractability.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-19 02:49:32 -07:00
Francesco Renzi
ae2896c551 Send welcome message in debugger console on connect (#4419)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-18 16:14:37 +00:00
Francesco Renzi
ebf33710e8 Execute debugger REPL commands inside job container (#4420)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-18 16:07:25 +00:00
github-actions[bot]
a1ccd22030 Update Docker to v29.5.0 and Buildx to v0.34.0 (#4425)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-18 09:57:42 -04:00
github-actions[bot]
b549247bee Update dotnet sdk to latest version @8.0.421 (#4428)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-18 13:31:42 +00:00
Daniel Valdivia
d36839b001 Add support for Ubuntu 26.04 (liblttng-ust1t64, libicu77-80) (#4394)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 10:51:46 -04:00
Francesco Renzi
0cdaa36d07 Move dap setup to setup job step (#4403) 2026-05-06 18:11:23 +01: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
58 changed files with 5430 additions and 784 deletions

View File

@@ -4,7 +4,7 @@
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": {},
"ghcr.io/devcontainers/features/dotnet": {
"version": "8.0.419"
"version": "8.0.421"
},
"ghcr.io/devcontainers/features/node:1": {
"version": "20"

View File

@@ -25,11 +25,11 @@ The `installdependencies.sh` script should install all required dependencies on
Debian based OS (Debian, Ubuntu, Linux Mint)
- liblttng-ust1 or liblttng-ust0
- liblttng-ust1t64, liblttng-ust1 or liblttng-ust0
- libkrb5-3
- zlib1g
- libssl3t64, libssl3, libssl1.1, libssl1.0.2 or libssl1.0.0
- libicu76, libicu75, ..., libicu66, libicu65, libicu63, libicu60, libicu57, libicu55, or libicu52
- libicu80, libicu79, ..., libicu66, libicu65, libicu63, libicu60, libicu57, libicu55, or libicu52
Fedora based OS (Fedora, Red Hat Enterprise Linux, CentOS, Oracle Linux 7)

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.3.1
ARG BUILDX_VERSION=0.33.0
ARG DOCKER_VERSION=29.5.0
ARG BUILDX_VERSION=0.34.0
RUN apt update -y && apt install curl unzip -y

View File

@@ -1,33 +1,36 @@
## What's Changed
* Log inner exception message. by @TingluoHuang in https://github.com/actions/runner/pull/4265
* Fix composite post-step marker display names by @ericsciple in https://github.com/actions/runner/pull/4267
* Bump actions/download-artifact from 7 to 8 by @dependabot[bot] in https://github.com/actions/runner/pull/4269
* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4272
* Avoid throw in SelfUpdaters. by @TingluoHuang in https://github.com/actions/runner/pull/4274
* Fix parser comparison mismatches by @ericsciple in https://github.com/actions/runner/pull/4273
* Devcontainer: bump base image Ubuntu version by @MaxHorstmann in https://github.com/actions/runner/pull/4277
* Support `entrypoint` and `command` for service containers by @ericsciple in https://github.com/actions/runner/pull/4276
* Bump actions/upload-artifact from 6 to 7 by @dependabot[bot] in https://github.com/actions/runner/pull/4270
* Bump docker/login-action from 3 to 4 by @dependabot[bot] in https://github.com/actions/runner/pull/4278
* Fix positional arg bug in ExpressionParser.CreateTree by @ericsciple in https://github.com/actions/runner/pull/4279
* Bump docker/build-push-action from 6 to 7 by @dependabot[bot] in https://github.com/actions/runner/pull/4283
* Bump docker/setup-buildx-action from 3 to 4 by @dependabot[bot] in https://github.com/actions/runner/pull/4282
* Bump actions/attest-build-provenance from 3 to 4 by @dependabot[bot] in https://github.com/actions/runner/pull/4266
* Bump @stylistic/eslint-plugin from 5.9.0 to 5.10.0 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4281
* Update Docker to v29.3.0 and Buildx to v0.32.1 by @github-actions[bot] in https://github.com/actions/runner/pull/4286
* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4287
* Fix cancellation token race during parser comparison by @ericsciple in https://github.com/actions/runner/pull/4280
* Bump @typescript-eslint/eslint-plugin from 8.47.0 to 8.54.0 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4230
* Exit with specified exit code when runner is outdated by @nikola-jokic in https://github.com/actions/runner/pull/4285
* Report infra_error for action download failures. by @TingluoHuang in https://github.com/actions/runner/pull/4294
* Update dotnet sdk to latest version @8.0.419 by @github-actions[bot] in https://github.com/actions/runner/pull/4301
* Node 24 enforcement + Linux ARM32 deprecation support by @salmanmkc in https://github.com/actions/runner/pull/4303
* Bump @typescript-eslint/eslint-plugin from 8.54.0 to 8.57.1 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4304
* 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
* @MaxHorstmann made their first contribution in https://github.com/actions/runner/pull/4277
* @stefanpenner made their first contribution in https://github.com/actions/runner/pull/4296
**Full Changelog**: https://github.com/actions/runner/compare/v2.332.0...v2.333.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": "^5.10.0",
"@types/node": "^22.0.0",
"@typescript-eslint/eslint-plugin": "^8.58.1",
"@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": "^6.0.2"
"typescript": "^6.0.3"
}
}

View File

@@ -7,7 +7,7 @@ NODE_ALPINE_URL=https://github.com/actions/alpine_nodejs/releases/download
# When you update Node versions you must also create a new release of alpine_nodejs at that updated version.
# Follow the instructions here: https://github.com/actions/alpine_nodejs?tab=readme-ov-file#getting-started
NODE20_VERSION="20.20.2"
NODE24_VERSION="24.14.1"
NODE24_VERSION="24.15.0"
get_abs_path() {
# exploits the fact that pwd will print abs path when no args

View File

@@ -94,7 +94,7 @@ then
fi
}
apt_get_with_fallbacks liblttng-ust1 liblttng-ust0
apt_get_with_fallbacks liblttng-ust1t64 liblttng-ust1 liblttng-ust0
if [ $? -ne 0 ]
then
echo "'$apt_get' failed with exit code '$?'"
@@ -110,7 +110,7 @@ then
exit 1
fi
apt_get_with_fallbacks libicu76 libicu75 libicu74 libicu73 libicu72 libicu71 libicu70 libicu69 libicu68 libicu67 libicu66 libicu65 libicu63 libicu60 libicu57 libicu55 libicu52
apt_get_with_fallbacks libicu80 libicu79 libicu78 libicu77 libicu76 libicu75 libicu74 libicu73 libicu72 libicu71 libicu70 libicu69 libicu68 libicu67 libicu66 libicu65 libicu63 libicu60 libicu57 libicu55 libicu52
if [ $? -ne 0 ]
then
echo "'$apt_get' failed with exit code '$?'"

View File

@@ -179,6 +179,7 @@ namespace GitHub.Runner.Common
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";
public static readonly string OverrideDebuggerWelcomeMessage = "actions_runner_override_debugger_welcome_message";
}
// Node version migration related constants

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

@@ -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="8.0.1" />
<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

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

@@ -880,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>();
@@ -891,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
{
@@ -1441,6 +1446,11 @@ namespace GitHub.Runner.Worker
// 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
@@ -1464,6 +1474,11 @@ namespace GitHub.Runner.Worker
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++;
@@ -1489,7 +1504,7 @@ namespace GitHub.Runner.Worker
}
}
}
catch (Exception ex) when (!(ex is OperationCanceledException) && !executionContext.CancellationToken.IsCancellationRequested)
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);

View File

@@ -63,9 +63,11 @@ namespace GitHub.Runner.Worker.Dap
private volatile DapSessionState _state = DapSessionState.NotStarted;
private CancellationTokenRegistration? _cancellationRegistration;
private bool _isFirstStep = true;
private bool _welcomeMessageSent;
// Dev Tunnel relay host for remote debugging
private TunnelRelayTunnelHost _tunnelRelayHost;
private IWebSocketDapBridge _webSocketBridge;
// Cancellation source for the connection loop, cancelled in StopAsync
// so AcceptTcpClientAsync unblocks cleanly without relying on listener disposal.
@@ -74,6 +76,10 @@ namespace GitHub.Runner.Worker.Dap
// When true, skip tunnel relay startup (unit tests only)
internal bool SkipTunnelRelay { get; set; }
// When true, skip the public websocket bridge and expose the raw DAP
// listener directly on the configured tunnel port (unit tests only).
internal bool SkipWebSocketBridge { get; set; }
// Synchronization for step execution
private TaskCompletionSource<DapCommand> _commandTcs;
private readonly object _stateLock = new object();
@@ -108,6 +114,7 @@ namespace GitHub.Runner.Worker.Dap
_state == DapSessionState.Running;
internal DapSessionState State => _state;
internal int InternalDapPort => (_listener?.LocalEndpoint as IPEndPoint)?.Port ?? 0;
public override void Initialize(IHostContext hostContext)
{
@@ -133,9 +140,19 @@ namespace GitHub.Runner.Worker.Dap
_jobContext = jobContext;
_readyTcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
_listener = new TcpListener(IPAddress.Loopback, debuggerConfig.Tunnel.Port);
var dapPort = SkipWebSocketBridge ? debuggerConfig.Tunnel.Port : 0;
_listener = new TcpListener(IPAddress.Loopback, dapPort);
_listener.Start();
Trace.Info($"DAP debugger listening on {_listener.LocalEndpoint}");
if (SkipWebSocketBridge)
{
Trace.Info($"DAP debugger listening on {_listener.LocalEndpoint}");
}
else
{
Trace.Info($"Internal DAP debugger listening on {_listener.LocalEndpoint}");
_webSocketBridge = HostContext.CreateService<IWebSocketDapBridge>();
_webSocketBridge.Start(debuggerConfig.Tunnel.Port, InternalDapPort);
}
// Start Dev Tunnel relay so remote clients reach the local DAP port.
// The relay is torn down explicitly in StopAsync (after the DAP session
@@ -227,6 +244,26 @@ namespace GitHub.Runner.Worker.Dap
{
if (_state != DapSessionState.NotStarted)
{
// Pause so the user can inspect final job state before we tear down,
// but only if the user was stepping through (not if they hit continue).
if (IsActive && _pauseOnNextStep)
{
try
{
if (_jobContext != null)
{
Trace.Info("Job completed — pausing for inspection");
SendStoppedEvent("completed", "Job completed — inspect variables before the session ends.");
await WaitForCommandAsync(_jobContext.CancellationToken);
}
}
catch (Exception ex)
{
Trace.Warning($"DAP job-completed pause error: {ex.Message}");
}
}
try
{
OnJobCompleted();
@@ -236,8 +273,6 @@ namespace GitHub.Runner.Worker.Dap
Trace.Warning($"DAP OnJobCompleted error: {ex.Message}");
}
}
await StopAsync();
}
public async Task StopAsync()
@@ -274,6 +309,25 @@ namespace GitHub.Runner.Worker.Dap
_tunnelRelayHost = null;
}
if (_webSocketBridge != null)
{
Trace.Info("Stopping WebSocket DAP bridge");
var shutdownTask = _webSocketBridge.ShutdownAsync();
if (await Task.WhenAny(shutdownTask, Task.Delay(5_000)) != shutdownTask)
{
Trace.Warning("WebSocket DAP bridge shutdown timed out after 5s");
_ = shutdownTask.ContinueWith(
t => Trace.Error($"WebSocket DAP bridge shutdown faulted: {t.Exception?.GetBaseException().Message}"),
TaskContinuationOptions.OnlyOnFaulted);
}
else
{
Trace.Info("WebSocket DAP bridge stopped");
}
_webSocketBridge = null;
}
CleanupConnection();
// Cancel the connection loop first so AcceptTcpClientAsync unblocks
@@ -315,6 +369,7 @@ namespace GitHub.Runner.Worker.Dap
_connectionLoopTask = null;
_loopCts?.Dispose();
_loopCts = null;
_webSocketBridge = null;
}
public async Task OnStepStartingAsync(IStep step)
@@ -436,6 +491,11 @@ namespace GitHub.Runner.Worker.Dap
});
Trace.Info("Sent initialized event");
}
if (request.Command == "configurationDone")
{
SendWelcomeMessage();
}
}
catch (Exception ex)
{
@@ -454,6 +514,7 @@ namespace GitHub.Runner.Worker.Dap
internal void HandleClientConnected()
{
_isClientConnected = true;
_welcomeMessageSent = false;
Trace.Info("Client connected to debug session");
// If we're paused, re-send the stopped event so the new client
@@ -764,6 +825,34 @@ namespace GitHub.Runner.Worker.Dap
});
}
internal void SendWelcomeMessage()
{
if (_welcomeMessageSent)
{
return;
}
_welcomeMessageSent = true;
var debuggerConfig = _jobContext?.Global?.Debugger;
if (debuggerConfig?.OverrideWelcomeMessage == true)
{
if (!string.IsNullOrEmpty(debuggerConfig.WelcomeMessage))
{
SendOutput("console", debuggerConfig.WelcomeMessage);
Trace.Info("Sent custom welcome message");
}
else
{
Trace.Info("Welcome message suppressed by override");
}
}
else
{
SendOutput("console", DapReplParser.GetGeneralHelp());
Trace.Info("Sent default welcome message");
}
}
internal async Task OnStepStartingAsync(IStep step, bool isFirstStep)
{
bool pauseOnNextStep;
@@ -806,6 +895,9 @@ namespace GitHub.Runner.Worker.Dap
// Send stopped event to debugger (only if client is connected)
SendStoppedEvent(reason, description);
// Emit a banner so the user knows where REPL commands will execute
SendExecutionContextBanner();
// Wait for debugger command
await WaitForCommandAsync(cancellationToken);
}
@@ -1141,7 +1233,12 @@ namespace GitHub.Runner.Worker.Dap
case RunCommand run:
var context = GetExecutionContextForFrame(frameId);
return await _replExecutor.ExecuteRunCommandAsync(run, context, cancellationToken);
bool isActionStep;
lock (_stateLock)
{
isActionStep = _currentStep is IActionRunner;
}
return await _replExecutor.ExecuteRunCommandAsync(run, context, isActionStep, cancellationToken);
default:
return new EvaluateResponseBody
@@ -1266,6 +1363,13 @@ namespace GitHub.Runner.Worker.Dap
_commandTcs = new TaskCompletionSource<DapCommand>(TaskCreationOptions.RunContinuationsAsynchronously);
}
// If cancellation already fired before we created the new TCS,
// the registration callback targeted the old one. Unblock now.
if (cancellationToken.IsCancellationRequested)
{
_commandTcs.TrySetResult(DapCommand.Disconnect);
}
Trace.Info("Waiting for debugger command...");
var command = await _commandTcs.Task;
@@ -1346,6 +1450,40 @@ namespace GitHub.Runner.Worker.Dap
});
}
/// <summary>
/// Emits a console output banner telling the user whether REPL
/// commands will execute on the host or inside the job container.
/// </summary>
private void SendExecutionContextBanner()
{
if (!_isClientConnected)
{
return;
}
bool isActionStep = _currentStep is IActionRunner;
var container = _jobContext?.Global?.Container;
string target;
if (isActionStep && container != null &&
(!string.IsNullOrEmpty(container.ContainerId) ||
FeatureManager.IsContainerHooksEnabled(_jobContext?.Global?.Variables)))
{
var image = container.ContainerImage ?? "container";
var shortId = !string.IsNullOrEmpty(container.ContainerId) && container.ContainerId.Length >= 12
? container.ContainerId.Substring(0, 12)
: container.ContainerId ?? "";
var idSuffix = !string.IsNullOrEmpty(shortId) ? $" ({shortId})" : "";
target = $"job container: {image}{idSuffix}";
}
else
{
target = "runner host";
}
SendOutput("console", $"\nCommands will run on {target}\n");
}
private string MaskUserVisibleText(string value)
{
if (string.IsNullOrEmpty(value))

View File

@@ -9,6 +9,7 @@ using GitHub.DistributedTask.Pipelines.ContextData;
using GitHub.Runner.Common;
using GitHub.Runner.Common.Util;
using GitHub.Runner.Sdk;
using GitHub.Runner.Worker.Container;
using GitHub.Runner.Worker.Handlers;
namespace GitHub.Runner.Worker.Dap
@@ -43,6 +44,7 @@ namespace GitHub.Runner.Worker.Dap
public async Task<EvaluateResponseBody> ExecuteRunCommandAsync(
RunCommand command,
IExecutionContext context,
bool isActionStep,
CancellationToken cancellationToken)
{
if (context == null)
@@ -52,7 +54,7 @@ namespace GitHub.Runner.Worker.Dap
try
{
return await ExecuteScriptAsync(command, context, cancellationToken);
return await ExecuteScriptAsync(command, context, isActionStep, cancellationToken);
}
catch (Exception ex)
{
@@ -65,9 +67,17 @@ namespace GitHub.Runner.Worker.Dap
private async Task<EvaluateResponseBody> ExecuteScriptAsync(
RunCommand command,
IExecutionContext context,
bool isActionStep,
CancellationToken cancellationToken)
{
// 1. Resolve shell — same logic as ScriptHandler
// 1. Resolve step host — container or host, same as ActionRunner.
// Only action steps (user-defined run:/uses:) execute inside the
// container. Infrastructure steps (Set up job, Initialize
// containers, Complete job, etc.) always run on the host.
var stepHost = CreateStepHost(context, isActionStep);
var isContainerStepHost = stepHost is IContainerStepHost;
// 2. Resolve shell — same logic as ScriptHandler
string shellCommand;
string argFormat;
@@ -87,9 +97,9 @@ namespace GitHub.Runner.Worker.Dap
argFormat = ScriptHandlerHelpers.GetScriptArgumentsFormat(shellCommand);
}
_trace.Info("Resolved REPL shell");
_trace.Info($"Resolved REPL shell (container={isContainerStepHost})");
// 2. Expand ${{ }} expressions in the script body, just like
// 3. 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);
@@ -111,25 +121,47 @@ namespace GitHub.Runner.Worker.Dap
try
{
// 3. Format arguments with script path
var resolvedPath = scriptFilePath.Replace("\"", "\\\"");
// 4. Resolve script path — translate for container if needed
var resolvedPath = stepHost.ResolvePathForStepHost(context, 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
// 5. Resolve shell command path — for containers, use the shell
// name directly (it will be resolved inside the container);
// for host execution, resolve the full path on the host.
string prependPath = string.Join(
Path.PathSeparator.ToString(),
Enumerable.Reverse(context.Global.PrependPath));
var commandPath = WhichUtil.Which(shellCommand, false, _trace, prependPath)
?? shellCommand;
var fileName = isContainerStepHost
? shellCommand
: WhichUtil.Which(shellCommand, false, _trace, prependPath) ?? shellCommand;
// 5. Build environment — merge from execution context like a real step
// 6. Build environment — merge from execution context like a real step
var environment = BuildEnvironment(context, command.Env);
// 6. Resolve working directory
// 7. Handle PrependPath — mirrors Handler.AddPrependPathToEnvironment
if (context.Global.PrependPath.Count > 0)
{
if (stepHost is IContainerStepHost containerHost)
{
containerHost.PrependPath = prependPath;
}
else
{
string taskEnvPATH;
environment.TryGetValue(Constants.PathVariable, out taskEnvPATH);
string originalPath = context.Global.Variables?.Get(Constants.PathVariable) ?? // Prefer a job variable.
taskEnvPATH ?? // Then a task-environment variable.
System.Environment.GetEnvironmentVariable(Constants.PathVariable) ?? // Then an environment variable.
string.Empty;
environment[Constants.PathVariable] = PathUtil.PrependPath(prependPath, originalPath);
}
}
// 8. Resolve working directory — translate for container
var workingDirectory = command.WorkingDirectory;
if (string.IsNullOrEmpty(workingDirectory))
{
@@ -141,48 +173,60 @@ namespace GitHub.Runner.Worker.Dap
: null;
workingDirectory = workspace ?? _hostContext.GetDirectory(WellKnownDirectory.Work);
}
workingDirectory = stepHost.ResolvePathForStepHost(context, workingDirectory);
_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>())
// NOTE: When container hooks are enabled, ContainerStepHost routes
// execution through IContainerHookManager which does not raise
// OutputDataReceived/ErrorDataReceived events. Output will not be
// streamed to the debug console in that mode.
if (isContainerStepHost && FeatureManager.IsContainerHooksEnabled(context.Global?.Variables))
{
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);
const string hookWarning = "Container hooks are enabled. REPL output will not be streamed to the debug console for this command.";
_trace.Warning(hookWarning);
SendOutput("stderr", hookWarning + "\n");
}
// 9. Execute via IStepHost — handles docker exec for containers,
// direct process execution for host, and container hooks
stepHost.OutputDataReceived += (sender, args) =>
{
if (!string.IsNullOrEmpty(args.Data))
{
var masked = _hostContext.SecretMasker.MaskSecrets(args.Data);
SendOutput("stdout", masked + "\n");
}
};
stepHost.ErrorDataReceived += (sender, args) =>
{
if (!string.IsNullOrEmpty(args.Data))
{
var masked = _hostContext.SecretMasker.MaskSecrets(args.Data);
SendOutput("stderr", masked + "\n");
}
};
int exitCode = await stepHost.ExecuteAsync(
context: context,
workingDirectory: workingDirectory,
fileName: fileName,
arguments: arguments,
environment: environment,
requireExitCodeZero: false,
outputEncoding: null,
killProcessOnCancel: true,
inheritConsoleHandler: false,
standardInInput: null,
cancellationToken: cancellationToken);
_trace.Info($"REPL command exited with code {exitCode}");
// 8. Return only the exit code summary (output was already streamed)
// 10. 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}.",
@@ -198,6 +242,43 @@ namespace GitHub.Runner.Worker.Dap
}
}
/// <summary>
/// Creates the appropriate <see cref="IStepHost"/> for the current
/// execution context, mirroring how <see cref="ActionRunner"/> decides
/// between host and container execution.
///
/// Only action steps (user-defined run:/uses: steps) run inside the
/// job container. Infrastructure steps like "Set up job", "Initialize
/// containers", "Stop containers", and "Complete job" always execute
/// on the host regardless of whether a container is configured.
/// </summary>
internal IStepHost CreateStepHost(IExecutionContext context, bool isActionStep)
{
if (!isActionStep)
{
_trace.Info("Creating DefaultStepHost for REPL execution (infrastructure step)");
return _hostContext.CreateService<IDefaultStepHost>();
}
var container = context?.Global?.Container;
if (container != null)
{
// Container hooks don't always set ContainerId, but the container
// step host handles that internally
var hooksEnabled = FeatureManager.IsContainerHooksEnabled(context.Global?.Variables);
if (hooksEnabled || !string.IsNullOrEmpty(container.ContainerId))
{
_trace.Info("Creating ContainerStepHost for REPL execution");
var containerStepHost = _hostContext.CreateService<IContainerStepHost>();
containerStepHost.Container = container;
return containerStepHost;
}
}
_trace.Info("Creating DefaultStepHost for REPL execution");
return _hostContext.CreateService<IDefaultStepHost>();
}
/// <summary>
/// Expands <c>${{ }}</c> expressions in the input string using the
/// runner's template evaluator — the same evaluation path that processes

View File

@@ -1,4 +1,4 @@
using GitHub.DistributedTask.Pipelines;
using GitHub.DistributedTask.Pipelines;
namespace GitHub.Runner.Worker.Dap
{
@@ -8,10 +8,12 @@ namespace GitHub.Runner.Worker.Dap
/// </summary>
public sealed class DebuggerConfig
{
public DebuggerConfig(bool enabled, DebuggerTunnelInfo tunnel)
public DebuggerConfig(bool enabled, DebuggerTunnelInfo tunnel, bool overrideWelcomeMessage = false, string welcomeMessage = null)
{
Enabled = enabled;
Tunnel = tunnel;
OverrideWelcomeMessage = overrideWelcomeMessage;
WelcomeMessage = welcomeMessage;
}
/// <summary>Whether the debugger is enabled for this job.</summary>
@@ -23,6 +25,19 @@ namespace GitHub.Runner.Worker.Dap
/// </summary>
public DebuggerTunnelInfo Tunnel { get; }
/// <summary>
/// When true, the runner overrides the default welcome message with
/// <see cref="WelcomeMessage"/>. A null or empty <see cref="WelcomeMessage"/>
/// suppresses the message entirely. When false, the default help text is shown.
/// </summary>
public bool OverrideWelcomeMessage { get; }
/// <summary>
/// Optional welcome message content for the debugger console. Only used when
/// <see cref="OverrideWelcomeMessage"/> is true.
/// </summary>
public string WelcomeMessage { get; }
/// <summary>Whether the tunnel configuration is complete and valid.</summary>
public bool HasValidTunnel => Tunnel != null
&& !string.IsNullOrEmpty(Tunnel.TunnelId)

View File

@@ -22,5 +22,6 @@ namespace GitHub.Runner.Worker.Dap
Task OnStepStartingAsync(IStep step);
void OnStepCompleted(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,276 @@
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.
///
/// Each <see cref="Append"/> can register the entry in one of three modes:
/// - With a non-null <c>stepIdentity</c>: registers the IStep→line mapping
/// immediately. Used for entries whose real <see cref="IStep"/> is already
/// known at append time.
/// - With a non-null <c>matchKey</c>: registers an unclaimed placeholder
/// that a later <see cref="TryClaim"/> binds to a real <see cref="IStep"/>.
/// Used for entries whose <see cref="IStep"/> is materialized later. A
/// placeholder that is never claimed simply stays in the view and is never
/// paused on — the IStep→line mapping is only populated on claim.
/// - With neither: a static entry that needs no line lookup.
///
/// <see cref="Append"/> and <see cref="AppendRange"/> never remove or reorder
/// existing entries. <see cref="TryClaim"/> does not re-render. The IStep→line
/// mapping is rebuilt on every render, so lookups stay accurate even if a later
/// Append happens to shift previously-emitted entries.
/// </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. Exactly one of <paramref name="stepIdentity"/>
/// or <paramref name="matchKey"/> may be non-null (or both may be
/// null for a static entry that needs no line lookup):
/// - <paramref name="stepIdentity"/> non-null: registers the
/// IStep→line mapping immediately. Use when the real
/// <see cref="IStep"/> is known at append time.
/// - <paramref name="matchKey"/> non-null: registers an unclaimed
/// placeholder that a later <see cref="TryClaim"/> binds to a
/// real <see cref="IStep"/>.
/// 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)
{
ArgUtil.NotNull(entry, nameof(entry));
if (stepIdentity != null && matchKey != null)
{
throw new ArgumentException(
"Append cannot register both a step identity and a placeholder match key on the same entry; pass at most one.");
}
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>
/// 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,336 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using GitHub.Runner.Sdk;
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>
/// 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(YamlScalarFormatter.Format(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(YamlScalarFormatter.Format(entry.DisplayName));
sb.Append('\n');
newlinesEmitted++;
switch (phase)
{
case JobExecutionPhase.Pre:
case JobExecutionPhase.Post:
if (!string.IsNullOrEmpty(entry.Uses))
{
sb.Append(" action: ").Append(YamlScalarFormatter.Format(entry.Uses)).Append('\n');
newlinesEmitted++;
}
// No source: annotation for pre/post.
break;
case JobExecutionPhase.Main:
if (!string.IsNullOrEmpty(entry.Id))
{
sb.Append(" id: ").Append(YamlScalarFormatter.Format(entry.Id)).Append('\n');
newlinesEmitted++;
}
if (!string.IsNullOrEmpty(entry.Uses))
{
sb.Append(" uses: ").Append(YamlScalarFormatter.Format(entry.Uses)).Append('\n');
newlinesEmitted++;
}
if (!string.IsNullOrEmpty(entry.Run))
{
if (entry.Run.IndexOf('\n') < 0)
{
sb.Append(" run: ").Append(YamlScalarFormatter.Format(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(YamlScalarFormatter.Format(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(YamlScalarFormatter.Format(entry.Shell)).Append('\n');
newlinesEmitted++;
}
if (!string.IsNullOrEmpty(entry.WorkingDirectory))
{
sb.Append(" working-directory: ").Append(YamlScalarFormatter.Format(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;
}
}
}

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

@@ -0,0 +1,63 @@
using System;
using System.Globalization;
using System.IO;
using GitHub.Runner.Sdk;
using YamlDotNet.Core;
using YamlDotNet.Core.Events;
namespace GitHub.Runner.Worker.Dap
{
/// <summary>
/// Formats a single string as a quote-safe YAML scalar by routing it
/// through YamlDotNet's <see cref="Emitter"/>. The returned text is
/// safe to splice into a hand-emitted YAML document fragment.
///
/// Caller responsibility: this only handles the scalar value; it does
/// not emit a key, indent, or trailing newline.
/// </summary>
internal static class YamlScalarFormatter
{
/// <summary>
/// Return <paramref name="value"/> formatted as a YAML scalar:
/// plain, single-quoted, or double-quoted as the emitter chooses,
/// with no surrounding document markers or trailing newline.
/// </summary>
public static string Format(string value)
{
ArgUtil.NotNull(value, nameof(value));
using var sw = new StringWriter(CultureInfo.InvariantCulture);
// Force LF line breaks; YamlDotNet's Emitter calls WriteLine,
// which would otherwise produce CRLF on Windows and break
// both our document-end stripping below and downstream
// consumers that assume a single line-break convention.
sw.NewLine = "\n";
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();
// Strip YAML document markers. Emitter elides these for most
// scalars but emits "--- " (with space) for some edge cases
// (e.g. empty strings). Defensively handle "---\n" too.
if (raw.StartsWith("--- ", StringComparison.Ordinal))
{
raw = raw.Substring(4);
}
else if (raw.StartsWith("---\n", 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;
}
}
}

View File

@@ -875,6 +875,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);
@@ -892,15 +895,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;
@@ -970,7 +970,8 @@ namespace GitHub.Runner.Worker
Global.WriteDebug = Global.Variables.Step_Debug ?? false;
// Debugger enabled flag (from acquire response).
Global.Debugger = new Dap.DebuggerConfig(message.EnableDebugger, message.DebuggerTunnel);
var overrideDebuggerWelcomeMessage = Global.Variables.GetBoolean(Constants.Runner.Features.OverrideDebuggerWelcomeMessage) ?? false;
Global.Debugger = new Dap.DebuggerConfig(message.EnableDebugger, message.DebuggerTunnel, overrideDebuggerWelcomeMessage, message.DebuggerWelcomeMessage);
// Hook up JobServerQueueThrottling event, we will log warning on server tarpit.
_jobServerQueue.JobServerQueueThrottling += JobServerQueueThrottling_EventReceived;

View File

@@ -38,5 +38,6 @@ namespace GitHub.Runner.Worker
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

@@ -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 = "";
@@ -777,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

@@ -13,7 +13,6 @@ using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common;
using GitHub.Runner.Common.Util;
using GitHub.Runner.Sdk;
using GitHub.Runner.Worker.Dap;
using GitHub.Services.Common;
using GitHub.Services.WebApi;
using Sdk.RSWebApi.Contracts;
@@ -29,7 +28,6 @@ namespace GitHub.Runner.Worker
public sealed class JobRunner : RunnerService, IJobRunner
{
private const string DebuggerConnectionTelemetryPrefix = "DebuggerConnectionResult";
private IJobServerQueue _jobServerQueue;
private RunnerSettings _runnerSettings;
private ITempDirectoryManager _tempDirectoryManager;
@@ -114,7 +112,6 @@ namespace GitHub.Runner.Worker
IExecutionContext jobContext = null;
CancellationTokenRegistration? runnerShutdownRegistration = null;
IDapDebugger dapDebugger = null;
try
{
// Create the job execution context.
@@ -181,25 +178,6 @@ namespace GitHub.Runner.Worker
_tempDirectoryManager = HostContext.GetService<ITempDirectoryManager>();
_tempDirectoryManager.InitializeTempDirectory(jobContext);
// Setup the debugger
if (jobContext.Global.Debugger?.Enabled == true)
{
Trace.Info("Debugger enabled for this job run");
try
{
dapDebugger = HostContext.GetService<IDapDebugger>();
await dapDebugger.StartAsync(jobContext);
}
catch (Exception ex)
{
Trace.Error($"Failed to start DAP debugger: {ex.Message}");
AddDebuggerConnectionTelemetry(jobContext, $"Failed: {ex.Message}");
jobContext.Error("Failed to start debugger.");
return await CompleteJobAsync(server, jobContext, message, TaskResult.Failed);
}
}
// Get the job extension.
Trace.Info("Getting job extension.");
@@ -242,33 +220,6 @@ namespace GitHub.Runner.Worker
await Task.WhenAny(_jobServerQueue.JobRecordUpdated.Task, Task.Delay(1000));
}
// Wait for DAP debugger client connection and handshake after "Set up job"
// so the job page shows the setup step before we block on the debugger
if (dapDebugger != null)
{
try
{
await dapDebugger.WaitUntilReadyAsync();
AddDebuggerConnectionTelemetry(jobContext, "Connected");
}
catch (OperationCanceledException) when (jobRequestCancellationToken.IsCancellationRequested)
{
Trace.Info("Job was cancelled before debugger client connected.");
AddDebuggerConnectionTelemetry(jobContext, "Canceled");
jobContext.Error("Job was cancelled before debugger client connected.");
return await CompleteJobAsync(server, jobContext, message, TaskResult.Canceled);
}
catch (Exception ex)
{
Trace.Error($"DAP debugger failed to become ready: {ex.Message}");
AddDebuggerConnectionTelemetry(jobContext, $"Failed: {ex.Message}");
// If debugging was requested but the debugger is not available, fail the job
jobContext.Error("The debugger failed to start or no debugger client connected in time.");
return await CompleteJobAsync(server, jobContext, message, TaskResult.Failed);
}
}
// Run all job steps
Trace.Info("Run all job steps.");
var stepsRunner = HostContext.GetService<IStepsRunner>();
@@ -309,11 +260,6 @@ namespace GitHub.Runner.Worker
runnerShutdownRegistration = null;
}
if (dapDebugger != null)
{
await dapDebugger.OnJobCompletedAsync();
}
await ShutdownQueue(throwOnFailure: false);
}
}
@@ -495,15 +441,6 @@ namespace GitHub.Runner.Worker
throw new AggregateException(exceptions);
}
private static void AddDebuggerConnectionTelemetry(IExecutionContext jobContext, string result)
{
jobContext.Global.JobTelemetry.Add(new JobTelemetry
{
Type = JobTelemetryType.General,
Message = $"{DebuggerConnectionTelemetryPrefix}: {result}"
});
}
private void MaskTelemetrySecrets(List<JobTelemetry> jobTelemetry)
{
foreach (var telemetryItem in jobTelemetry)

View File

@@ -19,11 +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.16" />
<PackageReference Include="Microsoft.DevTunnels.Connections" Version="1.3.39" />
</ItemGroup>
<ItemGroup>

View File

@@ -267,6 +267,36 @@ namespace GitHub.DistributedTask.Pipelines
set;
}
/// <summary>
/// Optional welcome message shown in the debugger console when a client connects.
/// Only used when the <c>actions_runner_override_debugger_welcome_message</c>
/// feature flag is set to <c>true</c> in the job variables. With the flag set,
/// a non-empty value is shown as-is and a null or empty value suppresses the
/// default welcome message. When the flag is not set, the runner shows its
/// built-in help text and this field is ignored.
/// </summary>
[DataMember(EmitDefaultValue = false)]
public string DebuggerWelcomeMessage
{
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>
@@ -441,6 +471,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))
{
@@ -466,6 +501,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

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

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

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

@@ -1877,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");
@@ -1957,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

@@ -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.
@@ -67,6 +74,7 @@ namespace GitHub.Actions.WorkflowParser
Snapshot = false, // Default to false since this feature is still in an experimental phase
StrictJsonParsing = false, // Default to false since this is temporary for telemetry purposes only
AllowModelsPermission = false, // Default to false since we want this to be disabled for all non-production environments
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."
}
}
}

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

@@ -1,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
using System.IO;
using System.Runtime.Serialization.Json;
using System.Text;
@@ -17,13 +17,13 @@ public sealed class AgentJobRequestMessageL0
// 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");
@@ -37,13 +37,13 @@ public sealed class AgentJobRequestMessageL0
// 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");
@@ -57,13 +57,13 @@ public sealed class AgentJobRequestMessageL0
// 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");
@@ -119,6 +119,68 @@ public sealed class AgentJobRequestMessageL0
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);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Common")]
public void VerifyDebuggerWelcomeMessageRoundTrips()
{
// Arrange
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage));
string json = DoubleQuotify("{'DebuggerWelcomeMessage': 'Welcome to debugging!'}");
// 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("Welcome to debugging!", recoveredMessage.DebuggerWelcomeMessage);
}
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

@@ -25,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;
@@ -334,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());
@@ -360,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)
{
@@ -368,6 +369,8 @@ runs:
}
}
#endif
MockResolvedSha("actions/download-artifact", "master", Sha256);
var actionId = Guid.NewGuid();
var actions = new List<Pipelines.ActionStep>
{
@@ -516,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
@@ -3149,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();
@@ -3283,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

@@ -2,6 +2,7 @@
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Net.WebSockets;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading;
@@ -19,13 +20,45 @@ namespace GitHub.Runner.Common.Tests.Worker
private const string TimeoutEnvironmentVariable = "ACTIONS_RUNNER_DAP_CONNECTION_TIMEOUT";
private const string TunnelConnectTimeoutVariable = "ACTIONS_RUNNER_DAP_TUNNEL_CONNECT_TIMEOUT_SECONDS";
private DapDebugger _debugger;
private TestWebSocketDapBridge _testWebSocketBridge;
private TestHostContext CreateTestContext([CallerMemberName] string testName = "")
private sealed class TestWebSocketDapBridge : RunnerService, IWebSocketDapBridge
{
private readonly WebSocketDapBridge _inner = new WebSocketDapBridge();
public int ListenPort => _inner.ListenPort;
public override void Initialize(IHostContext hostContext)
{
base.Initialize(hostContext);
_inner.Initialize(hostContext);
}
public void Start(int listenPort, int targetPort)
{
_inner.Start(0, targetPort);
}
public Task ShutdownAsync()
{
return _inner.ShutdownAsync();
}
}
private TestHostContext CreateTestContext(bool enableWebSocketBridge = false, [CallerMemberName] string testName = "")
{
var hc = new TestHostContext(this, testName);
_debugger = new DapDebugger();
_testWebSocketBridge = null;
_debugger.Initialize(hc);
_debugger.SkipTunnelRelay = true;
_debugger.SkipWebSocketBridge = !enableWebSocketBridge;
if (enableWebSocketBridge)
{
_testWebSocketBridge = new TestWebSocketDapBridge();
hc.EnqueueInstance<IWebSocketDapBridge>(_testWebSocketBridge);
}
return hc;
}
@@ -71,6 +104,14 @@ namespace GitHub.Runner.Common.Tests.Worker
return client;
}
private static async Task<ClientWebSocket> ConnectWebSocketClientAsync(int port)
{
var client = new ClientWebSocket();
client.Options.Proxy = null;
await client.ConnectAsync(new Uri($"ws://127.0.0.1:{port}/"), CancellationToken.None);
return client;
}
private static async Task SendRequestAsync(NetworkStream stream, Request request)
{
var json = JsonConvert.SerializeObject(request);
@@ -83,6 +124,14 @@ namespace GitHub.Runner.Common.Tests.Worker
await stream.FlushAsync();
}
private static async Task SendRequestAsync(WebSocket client, Request request)
{
var json = JsonConvert.SerializeObject(request);
var body = Encoding.UTF8.GetBytes(json);
await client.SendAsync(new ArraySegment<byte>(body), WebSocketMessageType.Text, endOfMessage: true, CancellationToken.None);
}
/// <summary>
/// Reads a single DAP-framed message from a stream with a timeout.
/// Parses the Content-Length header, reads exactly that many bytes,
@@ -141,7 +190,53 @@ namespace GitHub.Runner.Common.Tests.Worker
return Encoding.UTF8.GetString(body);
}
private static Mock<IExecutionContext> CreateJobContextWithTunnel(CancellationToken cancellationToken, ushort port, string jobName = null)
private static async Task<string> ReadWebSocketDataUntilAsync(WebSocket client, TimeSpan timeout, params string[] expectedFragments)
{
using var cts = new CancellationTokenSource(timeout);
var buffer = new byte[4096];
var allMessages = new StringBuilder();
while (true)
{
using var messageStream = new MemoryStream();
WebSocketReceiveResult result;
do
{
result = await client.ReceiveAsync(new ArraySegment<byte>(buffer), cts.Token);
if (result.MessageType == WebSocketMessageType.Close)
{
throw new EndOfStreamException("WebSocket closed before expected DAP messages were received.");
}
if (result.Count > 0)
{
messageStream.Write(buffer, 0, result.Count);
}
}
while (!result.EndOfMessage);
var messageText = Encoding.UTF8.GetString(messageStream.ToArray());
allMessages.Append(messageText);
var text = allMessages.ToString();
var containsAllFragments = true;
foreach (var fragment in expectedFragments)
{
if (!text.Contains(fragment, StringComparison.Ordinal))
{
containsAllFragments = false;
break;
}
}
if (containsAllFragments)
{
return text;
}
}
}
private static Mock<IExecutionContext> CreateJobContextWithTunnel(CancellationToken cancellationToken, ushort port, string jobName = null, bool overrideWelcomeMessage = false, string welcomeMessage = null)
{
var tunnel = new GitHub.DistributedTask.Pipelines.DebuggerTunnelInfo
{
@@ -150,7 +245,7 @@ namespace GitHub.Runner.Common.Tests.Worker
HostToken = "test-token",
Port = port
};
var debuggerConfig = new DebuggerConfig(true, tunnel);
var debuggerConfig = new DebuggerConfig(true, tunnel, overrideWelcomeMessage, welcomeMessage);
var jobContext = new Mock<IExecutionContext>();
jobContext.Setup(x => x.CancellationToken).Returns(cancellationToken);
jobContext.Setup(x => x.Global).Returns(new GlobalContext { Debugger = debuggerConfig });
@@ -208,6 +303,84 @@ namespace GitHub.Runner.Common.Tests.Worker
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task StartAsyncWithWebSocketBridgeAcceptsInitializeOverWebSocket()
{
using (CreateTestContext(enableWebSocketBridge: true))
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, GetFreePort());
await _debugger.StartAsync(jobContext.Object);
var bridgePort = _testWebSocketBridge.ListenPort;
Assert.NotEqual(0, _debugger.InternalDapPort);
Assert.NotEqual(0, bridgePort);
Assert.NotEqual(bridgePort, _debugger.InternalDapPort);
using var client = await ConnectWebSocketClientAsync(bridgePort);
await SendRequestAsync(client, new Request
{
Seq = 1,
Type = "request",
Command = "initialize"
});
var response = await ReadWebSocketDataUntilAsync(
client,
TimeSpan.FromSeconds(5),
"\"type\":\"response\"",
"\"command\":\"initialize\"",
"\"event\":\"initialized\"");
Assert.Contains("\"success\":true", response);
await _debugger.StopAsync();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task StartAsyncWithWebSocketBridgeAcceptsPreUpgradedWebSocketStream()
{
using (CreateTestContext(enableWebSocketBridge: true))
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, GetFreePort());
await _debugger.StartAsync(jobContext.Object);
var bridgePort = _testWebSocketBridge.ListenPort;
Assert.NotEqual(0, _debugger.InternalDapPort);
Assert.NotEqual(0, bridgePort);
Assert.NotEqual(bridgePort, _debugger.InternalDapPort);
using var tcpClient = await ConnectClientAsync(bridgePort);
using var webSocket = WebSocket.CreateFromStream(
tcpClient.GetStream(),
isServer: false,
subProtocol: null,
keepAliveInterval: TimeSpan.FromSeconds(30));
await SendRequestAsync(webSocket, new Request
{
Seq = 1,
Type = "request",
Command = "initialize"
});
var response = await ReadWebSocketDataUntilAsync(
webSocket,
TimeSpan.FromSeconds(5),
"\"type\":\"response\"",
"\"command\":\"initialize\"",
"\"event\":\"initialized\"");
Assert.Contains("\"success\":true", response);
await _debugger.StopAsync();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
@@ -569,16 +742,36 @@ namespace GitHub.Runner.Common.Tests.Worker
// Read the configurationDone response
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
// Read the welcome message output event
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await waitTask;
// Complete the job — events are sent via OnJobCompletedAsync
await _debugger.OnJobCompletedAsync();
// Complete the job — OnJobCompletedAsync pauses when stepping,
// so run it in the background and send continue to unblock.
var completedTask = _debugger.OnJobCompletedAsync();
var msg1 = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
var msg2 = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
// Read the stopped event from the pause
var stoppedMsg = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"event\":\"stopped\"", stoppedMsg);
// Both events should arrive (order may vary)
var combined = msg1 + msg2;
// Send continue to unblock the pause
await SendRequestAsync(stream, new Request
{
Seq = 2,
Type = "request",
Command = "continue"
});
await completedTask;
// Read remaining messages — continue response + continued event + terminated + exited
var allMessages = new System.Text.StringBuilder();
for (int i = 0; i < 4; i++)
{
allMessages.Append(await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)));
}
var combined = allMessages.ToString();
Assert.Contains("\"event\":\"terminated\"", combined);
Assert.Contains("\"event\":\"exited\"", combined);
}
@@ -636,5 +829,266 @@ namespace GitHub.Runner.Common.Tests.Worker
});
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task WaitForCommandAsyncUnblocksOnCancellationDuringWait()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
var waitTask = _debugger.WaitUntilReadyAsync();
using var client = await ConnectClientAsync(port);
var stream = client.GetStream();
await SendRequestAsync(stream, new Request
{
Seq = 1,
Type = "request",
Command = "configurationDone"
});
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
// Read the welcome message output event
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await waitTask;
// Start OnJobCompletedAsync — it will pause because _pauseOnNextStep is true
var completedTask = _debugger.OnJobCompletedAsync();
// Read the stopped event
var stoppedMsg = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"event\":\"stopped\"", stoppedMsg);
// Cancel the job while waiting — should unblock the pause
cts.Cancel();
// OnJobCompletedAsync should complete without hanging
var finished = await Task.WhenAny(completedTask, Task.Delay(TimeSpan.FromSeconds(5)));
Assert.Equal(completedTask, finished);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task WelcomeMessageSendsDefaultHelpWhenOverrideDisabled()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
using var client = await ConnectClientAsync(port);
var stream = client.GetStream();
await SendRequestAsync(stream, new Request
{
Seq = 1,
Type = "request",
Command = "configurationDone"
});
// First message: configurationDone response
var configDoneResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"command\":\"configurationDone\"", configDoneResponse);
// Second message: welcome output event with default help text
var welcomeMsg = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"event\":\"output\"", welcomeMsg);
Assert.Contains("\"category\":\"console\"", welcomeMsg);
Assert.Contains("Actions Debug Console", welcomeMsg);
Assert.Contains("help", welcomeMsg);
await _debugger.StopAsync();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task WelcomeMessageShowsCustomMessageWhenOverrideEnabled()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port,
overrideWelcomeMessage: true,
welcomeMessage: "Welcome to debugging!");
await _debugger.StartAsync(jobContext.Object);
using var client = await ConnectClientAsync(port);
var stream = client.GetStream();
await SendRequestAsync(stream, new Request
{
Seq = 1,
Type = "request",
Command = "configurationDone"
});
// First: configurationDone response
var configDoneResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"command\":\"configurationDone\"", configDoneResponse);
// Second: custom welcome message
var welcomeMsg = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"event\":\"output\"", welcomeMsg);
Assert.Contains("Welcome to debugging!", welcomeMsg);
await _debugger.StopAsync();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task WelcomeMessageSuppressedWhenOverrideEnabledWithEmptyMessage()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port,
overrideWelcomeMessage: true,
welcomeMessage: "");
await _debugger.StartAsync(jobContext.Object);
using var client = await ConnectClientAsync(port);
var stream = client.GetStream();
await SendRequestAsync(stream, new Request
{
Seq = 1,
Type = "request",
Command = "configurationDone"
});
// Read configurationDone response
var configDoneResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"command\":\"configurationDone\"", configDoneResponse);
// Send threads request — if welcome message was suppressed, this
// should be the next response (no output event in between)
await SendRequestAsync(stream, new Request
{
Seq = 2,
Type = "request",
Command = "threads"
});
var threadsResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"command\":\"threads\"", threadsResponse);
await _debugger.StopAsync();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task WelcomeMessageSuppressedWhenOverrideEnabledWithNullMessage()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port,
overrideWelcomeMessage: true,
welcomeMessage: null);
await _debugger.StartAsync(jobContext.Object);
using var client = await ConnectClientAsync(port);
var stream = client.GetStream();
await SendRequestAsync(stream, new Request
{
Seq = 1,
Type = "request",
Command = "configurationDone"
});
// Read configurationDone response
var configDoneResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"command\":\"configurationDone\"", configDoneResponse);
// Send threads request — if welcome message was suppressed, this
// should be the next response (no output event in between)
await SendRequestAsync(stream, new Request
{
Seq = 2,
Type = "request",
Command = "threads"
});
var threadsResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"command\":\"threads\"", threadsResponse);
await _debugger.StopAsync();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task WelcomeMessageSentOnlyOnce()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
using var client = await ConnectClientAsync(port);
var stream = client.GetStream();
// First configurationDone
await SendRequestAsync(stream, new Request
{
Seq = 1,
Type = "request",
Command = "configurationDone"
});
var configDoneResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"command\":\"configurationDone\"", configDoneResponse);
// Welcome message should appear
var welcomeMsg = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"event\":\"output\"", welcomeMsg);
Assert.Contains("Actions Debug Console", welcomeMsg);
// Second configurationDone — should NOT produce another welcome message
await SendRequestAsync(stream, new Request
{
Seq = 2,
Type = "request",
Command = "configurationDone"
});
var secondResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"command\":\"configurationDone\"", secondResponse);
// Next message should be threads response, not another welcome output
await SendRequestAsync(stream, new Request
{
Seq = 3,
Type = "request",
Command = "threads"
});
var threadsResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"command\":\"threads\"", threadsResponse);
await _debugger.StopAsync();
}
}
}
}

View File

@@ -5,9 +5,12 @@ using System.Threading;
using System.Threading.Tasks;
using GitHub.DistributedTask.Expressions2;
using GitHub.DistributedTask.Pipelines.ContextData;
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common.Tests;
using GitHub.Runner.Worker;
using GitHub.Runner.Worker.Container;
using GitHub.Runner.Worker.Dap;
using GitHub.Runner.Worker.Handlers;
using Moq;
using Xunit;
@@ -40,7 +43,8 @@ namespace GitHub.Runner.Common.Tests.Worker
private Mock<IExecutionContext> CreateMockContext(
DictionaryContextData exprValues = null,
IDictionary<string, IDictionary<string, string>> jobDefaults = null)
IDictionary<string, IDictionary<string, string>> jobDefaults = null,
ContainerInfo container = null)
{
var mock = new Mock<IExecutionContext>();
mock.Setup(x => x.ExpressionValues).Returns(exprValues ?? new DictionaryContextData());
@@ -51,6 +55,7 @@ namespace GitHub.Runner.Common.Tests.Worker
PrependPath = new List<string>(),
JobDefaults = jobDefaults
?? new Dictionary<string, IDictionary<string, string>>(StringComparer.OrdinalIgnoreCase),
Container = container,
};
mock.Setup(x => x.Global).Returns(global);
@@ -65,7 +70,7 @@ namespace GitHub.Runner.Common.Tests.Worker
using (CreateTestContext())
{
var command = new RunCommand { Script = "echo hello" };
var result = await _executor.ExecuteRunCommandAsync(command, null, CancellationToken.None);
var result = await _executor.ExecuteRunCommandAsync(command, null, false, CancellationToken.None);
Assert.Equal("error", result.Type);
Assert.Contains("No execution context available", result.Result);
@@ -233,5 +238,101 @@ namespace GitHub.Runner.Common.Tests.Worker
Assert.False(result.ContainsKey("BAZ"));
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void CreateStepHost_NoContainer_ReturnsDefaultStepHost()
{
using (var hc = CreateTestContext())
{
hc.EnqueueInstance<IDefaultStepHost>(new DefaultStepHost());
var context = CreateMockContext();
var result = _executor.CreateStepHost(context.Object, isActionStep: true);
Assert.IsType<DefaultStepHost>(result);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void CreateStepHost_WithContainer_ActionStep_ReturnsContainerStepHost()
{
using (var hc = CreateTestContext())
{
hc.EnqueueInstance<IContainerStepHost>(new ContainerStepHost());
var container = new ContainerInfo { ContainerId = "abc123" };
var context = CreateMockContext(container: container);
var result = _executor.CreateStepHost(context.Object, isActionStep: true);
Assert.IsType<ContainerStepHost>(result);
var containerHost = (ContainerStepHost)result;
Assert.Same(container, containerHost.Container);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void CreateStepHost_WithContainer_InfrastructureStep_ReturnsDefaultStepHost()
{
using (var hc = CreateTestContext())
{
hc.EnqueueInstance<IDefaultStepHost>(new DefaultStepHost());
var container = new ContainerInfo { ContainerId = "abc123" };
var context = CreateMockContext(container: container);
var result = _executor.CreateStepHost(context.Object, isActionStep: false);
Assert.IsType<DefaultStepHost>(result);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void CreateStepHost_ContainerWithoutId_NoHooks_ReturnsDefaultStepHost()
{
using (var hc = CreateTestContext())
{
hc.EnqueueInstance<IDefaultStepHost>(new DefaultStepHost());
// Container exists but hasn't been started yet (no ContainerId)
var container = new ContainerInfo();
var context = CreateMockContext(container: container);
var result = _executor.CreateStepHost(context.Object, isActionStep: true);
Assert.IsType<DefaultStepHost>(result);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void CreateStepHost_ContainerWithoutId_HooksEnabled_ReturnsContainerStepHost()
{
using (var hc = CreateTestContext())
{
hc.EnqueueInstance<IContainerStepHost>(new ContainerStepHost());
// Container hooks need both the feature flag and the env var
Environment.SetEnvironmentVariable("ACTIONS_RUNNER_CONTAINER_HOOKS", "/some/hook/path");
try
{
var container = new ContainerInfo();
var context = CreateMockContext(container: container);
context.Object.Global.Variables = new Variables(
hc,
new Dictionary<string, VariableValue>
{
{ Constants.Runner.Features.AllowRunnerContainerHooks, new VariableValue("true") }
});
var result = _executor.CreateStepHost(context.Object, isActionStep: true);
Assert.IsAssignableFrom<IContainerStepHost>(result);
}
finally
{
Environment.SetEnvironmentVariable("ACTIONS_RUNNER_CONTAINER_HOOKS", null);
}
}
}
}
}

View File

@@ -1203,19 +1203,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 +1233,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

@@ -34,5 +34,109 @@ namespace GitHub.Runner.Common.Tests.Worker
ctx.CheckRunId = null;
Assert.Null(ctx.CheckRunId);
}
[Fact]
public void WorkflowRef_SetAndGet_WorksCorrectly()
{
var ctx = new JobContext();
ctx.WorkflowRef = "owner/repo/.github/workflows/ci.yml@refs/heads/main";
Assert.Equal("owner/repo/.github/workflows/ci.yml@refs/heads/main", ctx.WorkflowRef);
Assert.True(ctx.TryGetValue("workflow_ref", out var value));
Assert.IsType<StringContextData>(value);
}
[Fact]
public void WorkflowRef_NotSet_ReturnsNull()
{
var ctx = new JobContext();
Assert.Null(ctx.WorkflowRef);
}
[Fact]
public void WorkflowRef_SetNull_ClearsValue()
{
var ctx = new JobContext();
ctx.WorkflowRef = "owner/repo/.github/workflows/ci.yml@refs/heads/main";
ctx.WorkflowRef = null;
Assert.Null(ctx.WorkflowRef);
}
[Fact]
public void WorkflowSha_SetAndGet_WorksCorrectly()
{
var ctx = new JobContext();
ctx.WorkflowSha = "abc123def456";
Assert.Equal("abc123def456", ctx.WorkflowSha);
Assert.True(ctx.TryGetValue("workflow_sha", out var value));
Assert.IsType<StringContextData>(value);
}
[Fact]
public void WorkflowSha_NotSet_ReturnsNull()
{
var ctx = new JobContext();
Assert.Null(ctx.WorkflowSha);
}
[Fact]
public void WorkflowSha_SetNull_ClearsValue()
{
var ctx = new JobContext();
ctx.WorkflowSha = "abc123def456";
ctx.WorkflowSha = null;
Assert.Null(ctx.WorkflowSha);
}
[Fact]
public void WorkflowRepository_SetAndGet_WorksCorrectly()
{
var ctx = new JobContext();
ctx.WorkflowRepository = "owner/repo";
Assert.Equal("owner/repo", ctx.WorkflowRepository);
Assert.True(ctx.TryGetValue("workflow_repository", out var value));
Assert.IsType<StringContextData>(value);
}
[Fact]
public void WorkflowRepository_NotSet_ReturnsNull()
{
var ctx = new JobContext();
Assert.Null(ctx.WorkflowRepository);
}
[Fact]
public void WorkflowRepository_SetNull_ClearsValue()
{
var ctx = new JobContext();
ctx.WorkflowRepository = "owner/repo";
ctx.WorkflowRepository = null;
Assert.Null(ctx.WorkflowRepository);
}
[Fact]
public void WorkflowFilePath_SetAndGet_WorksCorrectly()
{
var ctx = new JobContext();
ctx.WorkflowFilePath = ".github/workflows/ci.yml";
Assert.Equal(".github/workflows/ci.yml", ctx.WorkflowFilePath);
Assert.True(ctx.TryGetValue("workflow_file_path", out var value));
Assert.IsType<StringContextData>(value);
}
[Fact]
public void WorkflowFilePath_NotSet_ReturnsNull()
{
var ctx = new JobContext();
Assert.Null(ctx.WorkflowFilePath);
}
[Fact]
public void WorkflowFilePath_SetNull_ClearsValue()
{
var ctx = new JobContext();
ctx.WorkflowFilePath = ".github/workflows/ci.yml";
ctx.WorkflowFilePath = null;
Assert.Null(ctx.WorkflowFilePath);
}
}
}

View File

@@ -0,0 +1,442 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using GitHub.Runner.Worker;
using GitHub.Runner.Worker.Dap;
using Moq;
using Xunit;
namespace GitHub.Runner.Common.Tests.Worker
{
public sealed class JobExecutionViewL0
{
private static JobExecutionViewEntry MainEntry(string name)
{
return new JobExecutionViewEntry(JobExecutionPhase.Main, name, run: name);
}
private static IStep NewStep(string displayName = "step")
{
var mock = new Mock<IStep>();
mock.Setup(s => s.DisplayName).Returns(displayName);
return mock.Object;
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Constructor_RendersEmptyView()
{
var view = new JobExecutionView("my-job");
Assert.Equal(0, view.EntryCount);
Assert.Contains("# Job: my-job", view.Yaml);
Assert.Contains("- step: Setup job", view.Yaml);
Assert.Contains("- step: Complete job", view.Yaml);
// Only the two synthetic boundaries appear.
int stepCount = view.Yaml.Split("- step: ").Length - 1;
Assert.Equal(2, stepCount);
}
[Theory]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Constructor_ThrowsOnInvalidJobId(string jobId)
{
Assert.Throws<ArgumentException>(() => new JobExecutionView(jobId));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Append_IncrementsEntryCount()
{
var view = new JobExecutionView("j");
int line0 = view.Append(MainEntry("a"));
int line1 = view.Append(MainEntry("b"));
int line2 = view.Append(MainEntry("c"));
Assert.Equal(3, view.EntryCount);
Assert.True(line0 < line1);
Assert.True(line1 < line2);
Assert.Equal(line0, view.GetLine(0));
Assert.Equal(line1, view.GetLine(1));
Assert.Equal(line2, view.GetLine(2));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Append_PreservesPriorEntryLines()
{
var view = new JobExecutionView("j");
int l0 = view.Append(MainEntry("a"));
int l1 = view.Append(MainEntry("b"));
int l2 = view.Append(MainEntry("c"));
view.Append(MainEntry("d"));
Assert.Equal(l0, view.GetLine(0));
Assert.Equal(l1, view.GetLine(1));
Assert.Equal(l2, view.GetLine(2));
view.Append(MainEntry("e"));
Assert.Equal(l0, view.GetLine(0));
Assert.Equal(l1, view.GetLine(1));
Assert.Equal(l2, view.GetLine(2));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Append_RegistersStepIdentity()
{
var view = new JobExecutionView("j");
var step = NewStep();
int line = view.Append(MainEntry("a"), step);
Assert.Equal(line, view.GetLine(0));
Assert.Equal(line, view.TryGetLineForStep(step));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Append_NullStepIdentity_StillAppends()
{
var view = new JobExecutionView("j");
view.Append(MainEntry("a"), stepIdentity: null);
Assert.Equal(1, view.EntryCount);
Assert.Null(view.TryGetLineForStep(null));
Assert.Contains("- step: a", view.Yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Append_DuplicateStepIdentity_Throws()
{
var view = new JobExecutionView("j");
var step = NewStep();
view.Append(MainEntry("a"), step);
Assert.Throws<InvalidOperationException>(() => view.Append(MainEntry("b"), step));
// State preserved: only the first entry is present.
Assert.Equal(1, view.EntryCount);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Append_NullEntry_Throws()
{
var view = new JobExecutionView("j");
Assert.Throws<ArgumentNullException>(() => view.Append(null));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void AppendRange_AppendsAllAndRendersOnce()
{
var view = new JobExecutionView("j");
var steps = Enumerable.Range(0, 5).Select(i => NewStep("s" + i)).ToList();
var items = steps
.Select((s, i) => (entry: MainEntry("e" + i), stepIdentity: s))
.ToList();
view.AppendRange(items);
Assert.Equal(5, view.EntryCount);
for (int i = 0; i < 5; i++)
{
int line = view.GetLine(i);
Assert.Equal(line, view.TryGetLineForStep(steps[i]));
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void AppendRange_RejectsDuplicateInInput()
{
var view = new JobExecutionView("j");
var dup = NewStep();
var items = new List<(JobExecutionViewEntry, IStep)>
{
(MainEntry("a"), dup),
(MainEntry("b"), dup),
};
Assert.Throws<InvalidOperationException>(() => view.AppendRange(items));
Assert.Equal(0, view.EntryCount);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void AppendRange_RejectsOverlapWithExisting()
{
var view = new JobExecutionView("j");
var step = NewStep();
view.Append(MainEntry("a"), step);
var items = new List<(JobExecutionViewEntry, IStep)>
{
(MainEntry("b"), step),
};
Assert.Throws<InvalidOperationException>(() => view.AppendRange(items));
Assert.Equal(1, view.EntryCount);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void AppendRange_NullItems_Throws()
{
var view = new JobExecutionView("j");
Assert.Throws<ArgumentNullException>(() => view.AppendRange(null));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void TryGetLineForStep_NullStep_ReturnsNull()
{
var view = new JobExecutionView("j");
Assert.Null(view.TryGetLineForStep(null));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void TryGetLineForStep_UnknownStep_ReturnsNull()
{
var view = new JobExecutionView("j");
var step = NewStep();
Assert.Null(view.TryGetLineForStep(step));
}
[Theory]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
[InlineData(-1)]
[InlineData(2)]
public void GetLine_OutOfRange_Throws(int index)
{
var view = new JobExecutionView("j");
view.Append(MainEntry("a"));
view.Append(MainEntry("b"));
Assert.Throws<ArgumentOutOfRangeException>(() => view.GetLine(index));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Yaml_UpdatesAfterAppend()
{
var view = new JobExecutionView("j");
view.Append(MainEntry("first"));
string before = view.Yaml;
Assert.Contains("- step: first", before);
view.Append(MainEntry("second"));
string after = view.Yaml;
Assert.Contains("- step: first", after);
Assert.Contains("- step: second", after);
Assert.NotEqual(before, after);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Yaml_AlwaysEndsWithCleanupBoundary()
{
var view = new JobExecutionView("j");
Assert.EndsWith("cleanup:\n - step: Complete job\n", view.Yaml);
view.Append(MainEntry("a"));
Assert.EndsWith("cleanup:\n - step: Complete job\n", view.Yaml);
view.Append(MainEntry("b"));
view.Append(MainEntry("c"));
Assert.EndsWith("cleanup:\n - step: Complete job\n", view.Yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Append_WithMatchKey_TracksUnclaimed()
{
var view = new JobExecutionView("j");
int line = view.Append(MainEntry("placeholder"), stepIdentity: null, matchKey: "k1");
var step = NewStep("real");
int? claimed = view.TryClaim("k1", step);
Assert.Equal(line, claimed);
Assert.Equal(line, view.TryGetLineForStep(step));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void TryClaim_UnknownKey_ReturnsNull()
{
var view = new JobExecutionView("j");
view.Append(MainEntry("a"), stepIdentity: null, matchKey: "k1");
Assert.Null(view.TryClaim("nope", NewStep()));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void TryClaim_AlreadyClaimed_ReturnsNull()
{
var view = new JobExecutionView("j");
view.Append(MainEntry("a"), stepIdentity: null, matchKey: "k1");
var first = NewStep("first");
Assert.NotNull(view.TryClaim("k1", first));
var second = NewStep("second");
Assert.Null(view.TryClaim("k1", second));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void TryClaim_StepAlreadyRegistered_ReturnsNull()
{
var view = new JobExecutionView("j");
var step = NewStep();
// Step is registered for the first entry.
view.Append(MainEntry("a"), step);
// A placeholder is registered for the second entry.
view.Append(MainEntry("b"), stepIdentity: null, matchKey: "k1");
// Trying to claim the placeholder with the already-registered
// step must return null (defensive — would otherwise double-bind).
Assert.Null(view.TryClaim("k1", step));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Append_DuplicateMatchKey_Throws()
{
var view = new JobExecutionView("j");
view.Append(MainEntry("a"), stepIdentity: null, matchKey: "k1");
Assert.Throws<InvalidOperationException>(
() => view.Append(MainEntry("b"), stepIdentity: null, matchKey: "k1"));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Append_MatchKeyNull_BehavesLikeOldOverload()
{
var view = new JobExecutionView("j");
var step = NewStep();
int line = view.Append(MainEntry("a"), step);
Assert.Equal(line, view.GetLine(0));
Assert.Equal(line, view.TryGetLineForStep(step));
// TryClaim with any key must return null since no matchKey was registered.
Assert.Null(view.TryClaim("anything", NewStep()));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void TryClaim_AfterClaim_TryGetLineForStepResolves()
{
var view = new JobExecutionView("j");
int line = view.Append(MainEntry("placeholder"), stepIdentity: null, matchKey: "k1");
var step = NewStep();
Assert.Equal(line, view.TryClaim("k1", step));
Assert.Equal(line, view.TryGetLineForStep(step));
// And a later Append doesn't lose the claim (Render rebuilds
// the IStep -> line map from the persisted identities).
view.Append(MainEntry("b"));
Assert.Equal(line, view.TryGetLineForStep(step));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void TryClaim_NullArgs_Throws()
{
var view = new JobExecutionView("j");
Assert.Throws<ArgumentNullException>(() => view.TryClaim(null, NewStep()));
Assert.Throws<ArgumentNullException>(() => view.TryClaim("k", null));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task ConcurrentAppends_DontCorruptState()
{
var view = new JobExecutionView("j");
const int N = 50;
var steps = Enumerable.Range(0, N).Select(i => NewStep("s" + i)).ToList();
var returnedLines = new ConcurrentBag<int>();
var tasks = Enumerable.Range(0, N).Select(i => Task.Run(() =>
{
int line = view.Append(MainEntry("e" + i), steps[i]);
returnedLines.Add(line);
})).ToArray();
await Task.WhenAll(tasks);
Assert.Equal(N, view.EntryCount);
Assert.Equal(N, returnedLines.Distinct().Count());
// Every step identity resolves to some line in [0, N).
var entryLines = Enumerable.Range(0, N).Select(view.GetLine).ToHashSet();
Assert.Equal(N, entryLines.Count);
foreach (var step in steps)
{
int? line = view.TryGetLineForStep(step);
Assert.NotNull(line);
Assert.Contains(line.Value, entryLines);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Append_RejectsBothStepIdentityAndMatchKey()
{
// Allowing both would orphan the IStep→line mapping the moment
// TryClaim overwrites _stepIdentities[index] for a different
// step, so the API rejects the combination at append time.
var view = new JobExecutionView("j");
var entry = new JobExecutionViewEntry(JobExecutionPhase.Post, "Post X", uses: "actions/x@v1");
Assert.Throws<ArgumentException>(() =>
view.Append(entry, stepIdentity: NewStep("real"), matchKey: "k1"));
// State unchanged.
Assert.Equal(0, view.EntryCount);
}
}
}

View File

@@ -0,0 +1,598 @@
using System;
using System.Collections.Generic;
using GitHub.Runner.Worker.Dap;
using Xunit;
namespace GitHub.Runner.Common.Tests.Worker
{
public sealed class JobExecutionViewRendererL0
{
// Verbatim expected YAML for the design doc's "Worked example".
// The render output is structured as phase-keyed top-level sections;
// there is no per-entry `phase:` field. The setup: and cleanup:
// sections always render; pre:/main:/post: render only when
// they contain at least one entry. The Main entries surface
// user-authored step parameters pre-evaluation (no expression
// substitution); Pre/Post entries stay minimal.
private const string ExpectedWorkedExampleYaml =
"# Job: build\n" +
"# Runner execution plan — read-only.\n" +
"\n" +
"setup:\n" +
" - step: Setup job\n" +
"\n" +
"pre:\n" +
" - step: Pre actions/checkout@v4\n" +
" action: actions/checkout@v4\n" +
" - step: Pre actions/cache@v5\n" +
" action: actions/cache@v5\n" +
"\n" +
"main:\n" +
" - step: actions/checkout@v4\n" +
" uses: actions/checkout@v4\n" +
" source: .github/workflows/ci.yml:10\n" +
" - step: Cache Primes\n" +
" id: cache-primes\n" +
" uses: actions/cache@v5\n" +
" with:\n" +
" path: prime-numbers\n" +
" key: ${{ runner.os }}-primes\n" +
" source: .github/workflows/ci.yml:12\n" +
" - step: Run tests\n" +
" id: test\n" +
" run: |\n" +
" echo starting\n" +
" npm test\n" +
" if: ${{ github.event_name == 'push' }}\n" +
" env:\n" +
" NODE_ENV: production\n" +
" shell: bash\n" +
" working-directory: ./api\n" +
" source: .github/workflows/ci.yml:18\n" +
" - step: npm ci\n" +
" run: npm ci\n" +
" source: .github/workflows/ci.yml:28\n" +
"\n" +
"post:\n" +
" - step: Post actions/cache@v5\n" +
" action: actions/cache@v5\n" +
" - step: Post actions/checkout@v4\n" +
" action: actions/checkout@v4\n" +
"\n" +
"cleanup:\n" +
" - step: Complete job\n";
private static List<JobExecutionViewEntry> WorkedExampleEntries()
{
return new List<JobExecutionViewEntry>
{
new JobExecutionViewEntry(JobExecutionPhase.Pre, "Pre actions/checkout@v4", uses: "actions/checkout@v4"),
new JobExecutionViewEntry(JobExecutionPhase.Pre, "Pre actions/cache@v5", uses: "actions/cache@v5"),
new JobExecutionViewEntry(JobExecutionPhase.Main, "actions/checkout@v4", uses: "actions/checkout@v4", sourcePath: ".github/workflows/ci.yml", sourceLine: 10),
new JobExecutionViewEntry(
JobExecutionPhase.Main,
"Cache Primes",
uses: "actions/cache@v5",
id: "cache-primes",
withYaml: " path: prime-numbers\n key: ${{ runner.os }}-primes",
sourcePath: ".github/workflows/ci.yml",
sourceLine: 12),
new JobExecutionViewEntry(
JobExecutionPhase.Main,
"Run tests",
run: "echo starting\nnpm test",
id: "test",
@if: "${{ github.event_name == 'push' }}",
envYaml: " NODE_ENV: production",
shell: "bash",
workingDirectory: "./api",
sourcePath: ".github/workflows/ci.yml",
sourceLine: 18),
new JobExecutionViewEntry(JobExecutionPhase.Main, "npm ci", run: "npm ci", sourcePath: ".github/workflows/ci.yml", sourceLine: 28),
new JobExecutionViewEntry(JobExecutionPhase.Post, "Post actions/cache@v5", uses: "actions/cache@v5"),
new JobExecutionViewEntry(JobExecutionPhase.Post, "Post actions/checkout@v4", uses: "actions/checkout@v4"),
};
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_MatchesDesignDocWorkedExample()
{
var entries = WorkedExampleEntries();
var result = JobExecutionViewRenderer.Render("build", entries);
Assert.Equal(ExpectedWorkedExampleYaml, result.Yaml);
Assert.Equal(8, result.EntryStartLines.Count);
var lines = result.Yaml.Split('\n');
for (int i = 0; i < entries.Count; i++)
{
Assert.StartsWith(" - step: ", lines[result.EntryStartLines[i] - 1]);
Assert.Contains(entries[i].DisplayName, lines[result.EntryStartLines[i] - 1]);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_AlwaysEmitsSetupAndCleanup()
{
var result = JobExecutionViewRenderer.Render("job-1", new List<JobExecutionViewEntry>());
const string expected =
"# Job: job-1\n" +
"# Runner execution plan — read-only.\n" +
"\n" +
"setup:\n" +
" - step: Setup job\n" +
"\n" +
"cleanup:\n" +
" - step: Complete job\n";
Assert.Equal(expected, result.Yaml);
Assert.Empty(result.EntryStartLines);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_OmitsEmptyOptionalSections()
{
// Only a Main entry — pre:/post: must not appear.
var result = JobExecutionViewRenderer.Render("j", new[]
{
new JobExecutionViewEntry(JobExecutionPhase.Main, "echo", run: "echo hello"),
});
Assert.Contains("setup:\n", result.Yaml);
Assert.Contains("main:\n", result.Yaml);
Assert.Contains("cleanup:\n", result.Yaml);
Assert.DoesNotContain("\npre:\n", result.Yaml);
Assert.DoesNotContain("\npost:\n", result.Yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_EmitsPhaseSectionsInFixedOrder()
{
// Input order [Post, Pre, Main] should still render as setup → pre → main → post → cleanup.
var entries = new[]
{
new JobExecutionViewEntry(JobExecutionPhase.Post, "post-a", uses: "a/b@v1"),
new JobExecutionViewEntry(JobExecutionPhase.Pre, "pre-a", uses: "a/b@v1"),
new JobExecutionViewEntry(JobExecutionPhase.Main, "main-a", uses: "a/b@v1"),
};
var result = JobExecutionViewRenderer.Render("j", entries);
string yaml = result.Yaml;
int setupIdx = yaml.IndexOf("setup:\n", StringComparison.Ordinal);
int preIdx = yaml.IndexOf("\npre:\n", StringComparison.Ordinal);
int mainIdx = yaml.IndexOf("\nmain:\n", StringComparison.Ordinal);
int postIdx = yaml.IndexOf("\npost:\n", StringComparison.Ordinal);
int cleanupIdx = yaml.IndexOf("\ncleanup:\n", StringComparison.Ordinal);
Assert.True(setupIdx >= 0 && preIdx > setupIdx && mainIdx > preIdx && postIdx > mainIdx && cleanupIdx > postIdx,
$"section ordering wrong: setup={setupIdx} pre={preIdx} main={mainIdx} post={postIdx} cleanup={cleanupIdx}");
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_StartLinesAlignWithInputOrder()
{
// Input order is [Pre, Main, Post]; output order is also pre/main/post,
// but startLines must be indexed by INPUT position, not by section.
var entries = new[]
{
new JobExecutionViewEntry(JobExecutionPhase.Pre, "pre-x", uses: "x/y@v1"), // index 0
new JobExecutionViewEntry(JobExecutionPhase.Main, "main-x", uses: "x/y@v1"), // index 1
new JobExecutionViewEntry(JobExecutionPhase.Post, "post-x", uses: "x/y@v1"), // index 2
};
var result = JobExecutionViewRenderer.Render("j", entries);
var lines = result.Yaml.Split('\n');
Assert.StartsWith(" - step: pre-x", lines[result.EntryStartLines[0] - 1]);
Assert.StartsWith(" - step: main-x", lines[result.EntryStartLines[1] - 1]);
Assert.StartsWith(" - step: post-x", lines[result.EntryStartLines[2] - 1]);
// And input-order ordering of start lines is strictly increasing
// when phases are in declaration order matching the section order.
Assert.True(result.EntryStartLines[0] < result.EntryStartLines[1]);
Assert.True(result.EntryStartLines[1] < result.EntryStartLines[2]);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_StartLinesFollowInputOrderEvenWhenPhasesAreInterleaved()
{
// Input order is [Main A, Pre B, Main C]: pre section will render
// first (Pre B) and main second (Main A then Main C). startLines
// must still be indexed by input order.
var entries = new[]
{
new JobExecutionViewEntry(JobExecutionPhase.Main, "main-a", uses: "a@v1"), // index 0 — renders in main section
new JobExecutionViewEntry(JobExecutionPhase.Pre, "pre-b", uses: "b@v1"), // index 1 — renders in pre section
new JobExecutionViewEntry(JobExecutionPhase.Main, "main-c", uses: "c@v1"), // index 2 — renders in main section
};
var result = JobExecutionViewRenderer.Render("j", entries);
var lines = result.Yaml.Split('\n');
Assert.StartsWith(" - step: main-a", lines[result.EntryStartLines[0] - 1]);
Assert.StartsWith(" - step: pre-b", lines[result.EntryStartLines[1] - 1]);
Assert.StartsWith(" - step: main-c", lines[result.EntryStartLines[2] - 1]);
// The pre section comes before main: input-index-1 entry's line is
// before input-index-0 entry's line.
Assert.True(result.EntryStartLines[1] < result.EntryStartLines[0]);
Assert.True(result.EntryStartLines[0] < result.EntryStartLines[2]);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_EntryStartLinesPointAtStepKeys()
{
var entries = WorkedExampleEntries();
var result = JobExecutionViewRenderer.Render("build", entries);
var lines = result.Yaml.Split('\n');
for (int i = 0; i < result.EntryStartLines.Count; i++)
{
int oneBased = result.EntryStartLines[i];
Assert.True(oneBased >= 1 && oneBased <= lines.Length, $"start line {oneBased} out of range");
Assert.StartsWith(" - step: ", lines[oneBased - 1]);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_EntryStartLinesExcludeSetupAndCleanup()
{
var entries = WorkedExampleEntries();
var result = JobExecutionViewRenderer.Render("build", entries);
var lines = result.Yaml.Split('\n');
int setupLine = -1, cleanupLine = -1;
for (int i = 0; i < lines.Length; i++)
{
if (lines[i] == " - step: Setup job") setupLine = i + 1;
if (lines[i] == " - step: Complete job") cleanupLine = i + 1;
}
Assert.True(setupLine > 0 && cleanupLine > 0, "Setup/Cleanup lines must exist");
Assert.DoesNotContain(setupLine, result.EntryStartLines);
Assert.DoesNotContain(cleanupLine, result.EntryStartLines);
}
[Theory]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
[InlineData("hello")]
[InlineData("with: colon")]
[InlineData("with#hash")]
[InlineData(" leading")]
[InlineData("trailing ")]
[InlineData("a\"b")]
[InlineData("a\\b")]
[InlineData("@at")]
[InlineData("*star")]
public void Render_QuotesSpecialChars(string displayName)
{
// Round-trip the rendered YAML through YamlDotNet's deserializer
// and assert the parsed step's display name matches the input.
// This decouples the test from any specific quoting style.
var entry = new JobExecutionViewEntry(JobExecutionPhase.Main, displayName);
var result = JobExecutionViewRenderer.Render("j", new[] { entry });
var deserializer = new YamlDotNet.Serialization.DeserializerBuilder().Build();
var doc = deserializer.Deserialize<Dictionary<string, List<Dictionary<string, object>>>>(result.Yaml);
Assert.NotNull(doc);
Assert.True(doc.ContainsKey("main"), "rendered YAML missing top-level 'main' key");
var mainSteps = doc["main"];
Assert.Single(mainSteps);
Assert.Equal(displayName, mainSteps[0]["step"] as string);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_EmitsSourceAnnotationForMainStep()
{
var entry = new JobExecutionViewEntry(
JobExecutionPhase.Main,
"npm ci",
run: "npm ci",
sourcePath: ".github/workflows/ci.yml",
sourceLine: 42);
var result = JobExecutionViewRenderer.Render("j", new[] { entry });
Assert.Contains(" source: .github/workflows/ci.yml:42\n", result.Yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_OmitsSourceAnnotationForPreAndPost()
{
var pre = new JobExecutionViewEntry(
JobExecutionPhase.Pre,
"Pre actions/checkout@v4",
uses: "actions/checkout@v4",
sourcePath: ".github/workflows/ci.yml",
sourceLine: 9);
var post = new JobExecutionViewEntry(
JobExecutionPhase.Post,
"Post actions/checkout@v4",
uses: "actions/checkout@v4",
sourcePath: ".github/workflows/ci.yml",
sourceLine: 9);
var result = JobExecutionViewRenderer.Render("j", new[] { pre, post });
Assert.DoesNotContain("source:", result.Yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_EmitsMultilineRunAsBlockScalar()
{
var entry = new JobExecutionViewEntry(
JobExecutionPhase.Main,
"multi",
run: "echo a\necho b\necho c");
var result = JobExecutionViewRenderer.Render("j", new[] { entry });
Assert.Contains(" run: |\n", result.Yaml);
Assert.Contains(" echo a\n", result.Yaml);
Assert.Contains(" echo b\n", result.Yaml);
Assert.Contains(" echo c\n", result.Yaml);
Assert.DoesNotContain("truncated", result.Yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_EmitsAllUserAuthoredParamsForActionStep()
{
var entry = new JobExecutionViewEntry(
JobExecutionPhase.Main,
"Run action",
uses: "actions/cache@v5",
id: "cache-primes",
@if: "${{ github.event_name == 'push' }}",
continueOnError: "true",
timeoutMinutes: "10",
envYaml: " NODE_ENV: production",
withYaml: " path: prime-numbers\n key: ${{ runner.os }}-primes",
sourcePath: "ci.yml",
sourceLine: 5);
var result = JobExecutionViewRenderer.Render("j", new[] { entry });
Assert.Contains(" id: cache-primes\n", result.Yaml);
Assert.Contains(" uses: actions/cache@v5\n", result.Yaml);
Assert.Contains(" continue-on-error: true\n", result.Yaml);
Assert.Contains(" timeout-minutes: 10\n", result.Yaml);
Assert.Contains(" env:\n NODE_ENV: production\n", result.Yaml);
Assert.Contains(" with:\n path: prime-numbers\n key: ${{ runner.os }}-primes\n", result.Yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_EmitsRunStepWithShellAndWorkingDirectory()
{
var entry = new JobExecutionViewEntry(
JobExecutionPhase.Main,
"Run tests",
run: "echo starting\nnpm test",
id: "test",
shell: "bash",
workingDirectory: "./api");
var result = JobExecutionViewRenderer.Render("j", new[] { entry });
Assert.Contains(" run: |\n echo starting\n npm test\n", result.Yaml);
Assert.Contains(" shell: bash\n", result.Yaml);
Assert.Contains(" working-directory: ./api\n", result.Yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_PreservesExpressionsInRenderedYaml()
{
var entry = new JobExecutionViewEntry(
JobExecutionPhase.Main,
"Cache",
uses: "actions/cache@v5",
withYaml: " key: ${{ runner.os }}-primes");
var result = JobExecutionViewRenderer.Render("j", new[] { entry });
// Expressions render exactly as authored — no evaluation.
Assert.Contains("${{ runner.os }}-primes", result.Yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_PrePostStepsRemainMinimal()
{
// Even if a pre/post entry carries user-param fields (it shouldn't
// in production, but the renderer must defensively drop them),
// only step: + action: render for these phases.
var pre = new JobExecutionViewEntry(
JobExecutionPhase.Pre,
"Pre actions/cache@v5",
uses: "actions/cache@v5",
id: "should-not-appear",
envYaml: " X: y",
withYaml: " key: nope");
var post = new JobExecutionViewEntry(
JobExecutionPhase.Post,
"Post actions/cache@v5",
uses: "actions/cache@v5",
id: "should-not-appear",
envYaml: " X: y");
var result = JobExecutionViewRenderer.Render("j", new[] { pre, post });
Assert.DoesNotContain("id:", result.Yaml);
Assert.DoesNotContain("env:", result.Yaml);
Assert.DoesNotContain("with:", result.Yaml);
Assert.DoesNotContain("should-not-appear", result.Yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_FieldOrderIsStable()
{
var entry = new JobExecutionViewEntry(
JobExecutionPhase.Main,
"Everything",
uses: "actions/cache@v5",
id: "x",
@if: "always()",
continueOnError: "false",
timeoutMinutes: "5",
envYaml: " A: 1",
withYaml: " key: k",
sourcePath: "ci.yml",
sourceLine: 1);
var result = JobExecutionViewRenderer.Render("j", new[] { entry });
var y = result.Yaml;
int iStep = y.IndexOf(" - step: ", StringComparison.Ordinal) >= 0
? y.IndexOf("- step:", StringComparison.Ordinal) : y.IndexOf("- step:", StringComparison.Ordinal);
int iId = y.IndexOf(" id:", StringComparison.Ordinal);
int iUses = y.IndexOf(" uses:", StringComparison.Ordinal);
int iIf = y.IndexOf(" if:", StringComparison.Ordinal);
int iCoe = y.IndexOf(" continue-on-error:", StringComparison.Ordinal);
int iTm = y.IndexOf(" timeout-minutes:", StringComparison.Ordinal);
int iEnv = y.IndexOf(" env:", StringComparison.Ordinal);
int iWith = y.IndexOf(" with:", StringComparison.Ordinal);
int iSrc = y.IndexOf(" source:", StringComparison.Ordinal);
Assert.True(iId < iUses && iUses < iIf && iIf < iCoe && iCoe < iTm && iTm < iEnv && iEnv < iWith && iWith < iSrc,
$"order wrong: id={iId} uses={iUses} if={iIf} coe={iCoe} tm={iTm} env={iEnv} with={iWith} src={iSrc}");
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_OmitsEmptyOptionalFields()
{
var entry = new JobExecutionViewEntry(
JobExecutionPhase.Main,
"bare",
uses: "a/b@v1");
var result = JobExecutionViewRenderer.Render("j", new[] { entry });
Assert.DoesNotContain(" id:", result.Yaml);
Assert.DoesNotContain(" if:", result.Yaml);
Assert.DoesNotContain(" continue-on-error:", result.Yaml);
Assert.DoesNotContain(" timeout-minutes:", result.Yaml);
Assert.DoesNotContain(" env:", result.Yaml);
Assert.DoesNotContain(" with:", result.Yaml);
Assert.DoesNotContain(" shell:", result.Yaml);
Assert.DoesNotContain(" working-directory:", result.Yaml);
Assert.DoesNotContain(" source:", result.Yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_HandlesEmptyEntries()
{
var result = JobExecutionViewRenderer.Render("j", new List<JobExecutionViewEntry>());
Assert.Empty(result.EntryStartLines);
Assert.Contains(" - step: Setup job\n", result.Yaml);
Assert.Contains(" - step: Complete job\n", result.Yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_NoPerEntryPhaseField()
{
// The phase: <value> per-entry field is gone — the section
// header is the phase indicator. Guard against accidental
// regressions.
var result = JobExecutionViewRenderer.Render("build", WorkedExampleEntries());
Assert.DoesNotContain("phase:", result.Yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_ThrowsOnNullJobId()
{
Assert.Throws<ArgumentException>(
() => JobExecutionViewRenderer.Render(null, new List<JobExecutionViewEntry>()));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_ThrowsOnWhitespaceJobId()
{
Assert.Throws<ArgumentException>(
() => JobExecutionViewRenderer.Render(" ", new List<JobExecutionViewEntry>()));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_ThrowsOnNullEntries()
{
Assert.Throws<ArgumentNullException>(
() => JobExecutionViewRenderer.Render("j", null));
}
[Theory]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
[InlineData(null, 1)]
[InlineData("", 1)]
[InlineData(" ", 1)]
public void Entry_Constructor_RejectsBadDisplayName(string displayName, int sourceLine)
{
Assert.Throws<ArgumentException>(
() => new JobExecutionViewEntry(JobExecutionPhase.Main, displayName, sourceLine: sourceLine));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Entry_Constructor_RejectsZeroLineWhenSourcePathSet()
{
Assert.Throws<ArgumentException>(
() => new JobExecutionViewEntry(
JobExecutionPhase.Main,
"ok",
sourcePath: "ci.yml",
sourceLine: 0));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_AlwaysUsesLfLineBreaks()
{
// Regression: YamlDotNet's Emitter calls WriteLine, which on
// Windows produces CRLF (the host's Environment.NewLine).
// The renderer's hand-emitted skeleton always uses '\n'; this
// test asserts the scalar formatter doesn't sneak CRLF in.
var entry = new JobExecutionViewEntry(JobExecutionPhase.Main, "with: colon", id: "step-1", uses: "actions/checkout@v4");
var result = JobExecutionViewRenderer.Render("job-1", new[] { entry });
Assert.DoesNotContain("\r", result.Yaml);
}
}
}

View File

@@ -760,5 +760,171 @@ namespace GitHub.Runner.Common.Tests.Worker
Environment.SetEnvironmentVariable("RUNNER_ENVIRONMENT", null);
Environment.SetEnvironmentVariable("GITHUB_ACTIONS_IMAGE_GEN_ENABLED", null);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task DebuggerStartedInSetupJobWhenEnabled()
{
using (TestHostContext hc = CreateTestContext())
{
var jobExtension = new JobExtension();
jobExtension.Initialize(hc);
// Enable debugger on the message
_message.EnableDebugger = true;
_message.DebuggerTunnel = new Pipelines.DebuggerTunnelInfo
{
TunnelId = "test-tunnel",
ClusterId = "test-cluster",
HostToken = "test-token",
Port = 9229
};
// Re-initialize the execution context so it picks up debugger config
_jobEc = new Runner.Worker.ExecutionContext();
_jobEc.Initialize(hc);
_jobEc.InitializeJob(_message, _tokenSource.Token);
// Set up mock debugger
var mockDebugger = new Mock<IDapDebugger>();
mockDebugger.Setup(x => x.StartAsync(It.IsAny<IExecutionContext>())).Returns(Task.CompletedTask);
mockDebugger.Setup(x => x.WaitUntilReadyAsync()).Returns(Task.CompletedTask);
hc.SetSingleton(mockDebugger.Object);
_actionManager.Setup(x => x.PrepareActionsAsync(It.IsAny<IExecutionContext>(), It.IsAny<IEnumerable<Pipelines.JobStep>>(), It.IsAny<Guid>()))
.Returns(Task.FromResult(new PrepareResult(new List<JobExtensionRunner>(), new Dictionary<Guid, IActionRunner>())));
List<IStep> result = await jobExtension.InitializeJob(_jobEc, _message);
// Verify DAP debugger was started and waited on
mockDebugger.Verify(x => x.StartAsync(It.IsAny<IExecutionContext>()), Times.Once);
mockDebugger.Verify(x => x.WaitUntilReadyAsync(), Times.Once);
// Verify steps are still returned correctly
Assert.Equal(5, result.Count);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task DebuggerNotStartedInSetupJobWhenDisabled()
{
using (TestHostContext hc = CreateTestContext())
{
var jobExtension = new JobExtension();
jobExtension.Initialize(hc);
// Debugger NOT enabled on the message — should not be started
// Set up mock debugger (should NOT be called)
var mockDebugger = new Mock<IDapDebugger>();
hc.SetSingleton(mockDebugger.Object);
_actionManager.Setup(x => x.PrepareActionsAsync(It.IsAny<IExecutionContext>(), It.IsAny<IEnumerable<Pipelines.JobStep>>(), It.IsAny<Guid>()))
.Returns(Task.FromResult(new PrepareResult(new List<JobExtensionRunner>(), new Dictionary<Guid, IActionRunner>())));
List<IStep> result = await jobExtension.InitializeJob(_jobEc, _message);
// Verify DAP debugger was NOT started during setup job
mockDebugger.Verify(x => x.StartAsync(It.IsAny<IExecutionContext>()), Times.Never);
mockDebugger.Verify(x => x.WaitUntilReadyAsync(), Times.Never);
Assert.Equal(5, result.Count);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task DebuggerCleanedUpInFinalizeJob()
{
using (TestHostContext hc = CreateTestContext())
{
var jobExtension = new JobExtension();
jobExtension.Initialize(hc);
// Enable debugger on the message
_message.EnableDebugger = true;
_message.DebuggerTunnel = new Pipelines.DebuggerTunnelInfo
{
TunnelId = "test-tunnel",
ClusterId = "test-cluster",
HostToken = "test-token",
Port = 9229
};
// Re-initialize the execution context so it picks up debugger config
_jobEc = new Runner.Worker.ExecutionContext();
_jobEc.Initialize(hc);
_jobEc.InitializeJob(_message, _tokenSource.Token);
// Set up mock debugger
var mockDebugger = new Mock<IDapDebugger>();
mockDebugger.Setup(x => x.StartAsync(It.IsAny<IExecutionContext>())).Returns(Task.CompletedTask);
mockDebugger.Setup(x => x.WaitUntilReadyAsync()).Returns(Task.CompletedTask);
mockDebugger.Setup(x => x.OnJobCompletedAsync()).Returns(Task.CompletedTask);
hc.SetSingleton(mockDebugger.Object);
_actionManager.Setup(x => x.PrepareActionsAsync(It.IsAny<IExecutionContext>(), It.IsAny<IEnumerable<Pipelines.JobStep>>(), It.IsAny<Guid>()))
.Returns(Task.FromResult(new PrepareResult(new List<JobExtensionRunner>(), new Dictionary<Guid, IActionRunner>())));
// Run InitializeJob to start the debugger
await jobExtension.InitializeJob(_jobEc, _message);
// Run FinalizeJob — should pause (inside OnJobCompletedAsync) then clean up
await jobExtension.FinalizeJob(_jobEc, _message, DateTime.UtcNow);
// Verify OnJobCompletedAsync was called (it handles pause + cleanup)
mockDebugger.Verify(x => x.OnJobCompletedAsync(), Times.Once);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task FinalizeJobHandlesDebuggerCleanupException()
{
using (TestHostContext hc = CreateTestContext())
{
var jobExtension = new JobExtension();
jobExtension.Initialize(hc);
// Enable debugger on the message
_message.EnableDebugger = true;
_message.DebuggerTunnel = new Pipelines.DebuggerTunnelInfo
{
TunnelId = "test-tunnel",
ClusterId = "test-cluster",
HostToken = "test-token",
Port = 9229
};
// Re-initialize the execution context so it picks up debugger config
_jobEc = new Runner.Worker.ExecutionContext();
_jobEc.Initialize(hc);
_jobEc.InitializeJob(_message, _tokenSource.Token);
// Set up mock debugger — OnJobCompletedAsync throws
var mockDebugger = new Mock<IDapDebugger>();
mockDebugger.Setup(x => x.StartAsync(It.IsAny<IExecutionContext>())).Returns(Task.CompletedTask);
mockDebugger.Setup(x => x.WaitUntilReadyAsync()).Returns(Task.CompletedTask);
mockDebugger.Setup(x => x.OnJobCompletedAsync()).ThrowsAsync(new InvalidOperationException("tunnel disposed"));
mockDebugger.Setup(x => x.StopAsync()).Returns(Task.CompletedTask);
hc.SetSingleton(mockDebugger.Object);
_actionManager.Setup(x => x.PrepareActionsAsync(It.IsAny<IExecutionContext>(), It.IsAny<IEnumerable<Pipelines.JobStep>>(), It.IsAny<Guid>()))
.Returns(Task.FromResult(new PrepareResult(new List<JobExtensionRunner>(), new Dictionary<Guid, IActionRunner>())));
await jobExtension.InitializeJob(_jobEc, _message);
// FinalizeJob should not throw even when OnJobCompletedAsync throws
await jobExtension.FinalizeJob(_jobEc, _message, DateTime.UtcNow);
mockDebugger.Verify(x => x.OnJobCompletedAsync(), Times.Once);
mockDebugger.Verify(x => x.StopAsync(), Times.Once);
}
}
}
}

View File

@@ -0,0 +1,266 @@
using System;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Net.WebSockets;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using GitHub.Runner.Common;
using GitHub.Runner.Worker.Dap;
using Xunit;
namespace GitHub.Runner.Common.Tests.Worker
{
public sealed class WebSocketDapBridgeL0
{
private TestHostContext CreateTestContext([CallerMemberName] string testName = "")
{
return new TestHostContext(this, testName);
}
private static async Task<byte[]> ReadWebSocketMessageAsync(ClientWebSocket client, TimeSpan timeout)
{
using var cts = new CancellationTokenSource(timeout);
using var buffer = new MemoryStream();
var receiveBuffer = new byte[1024];
while (true)
{
var result = await client.ReceiveAsync(new ArraySegment<byte>(receiveBuffer), cts.Token);
if (result.MessageType == WebSocketMessageType.Close)
{
throw new EndOfStreamException("WebSocket closed unexpectedly.");
}
if (result.Count > 0)
{
buffer.Write(receiveBuffer, 0, result.Count);
}
if (result.EndOfMessage)
{
return buffer.ToArray();
}
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task BridgeForwardsWebSocketFramesToTcpAndBack()
{
using var hc = CreateTestContext();
using var targetListener = new TcpListener(IPAddress.Loopback, 0);
targetListener.Start();
var targetPort = ((IPEndPoint)targetListener.LocalEndpoint).Port;
var bridge = new WebSocketDapBridge();
bridge.Initialize(hc);
bridge.Start(0, targetPort);
var bridgePort = bridge.ListenPort;
try
{
var echoTask = Task.Run(async () =>
{
using var targetClient = await targetListener.AcceptTcpClientAsync();
using var stream = targetClient.GetStream();
var headerBuilder = new StringBuilder();
var buffer = new byte[1];
var contentLength = -1;
while (true)
{
var bytesRead = await stream.ReadAsync(buffer, 0, 1);
if (bytesRead == 0) break;
headerBuilder.Append((char)buffer[0]);
var headers = headerBuilder.ToString();
if (headers.EndsWith("\r\n\r\n", StringComparison.Ordinal))
{
foreach (var line in headers.Split(new[] { "\r\n" }, StringSplitOptions.RemoveEmptyEntries))
{
if (line.StartsWith("Content-Length: ", StringComparison.OrdinalIgnoreCase))
{
contentLength = int.Parse(line.Substring("Content-Length: ".Length).Trim());
}
}
break;
}
}
var body = new byte[contentLength];
var totalRead = 0;
while (totalRead < contentLength)
{
var bytesRead = await stream.ReadAsync(body, totalRead, contentLength - totalRead);
if (bytesRead == 0) break;
totalRead += bytesRead;
}
var header = $"Content-Length: {body.Length}\r\n\r\n";
var headerBytes = Encoding.ASCII.GetBytes(header);
await stream.WriteAsync(headerBytes, 0, headerBytes.Length);
await stream.WriteAsync(body, 0, body.Length);
await stream.FlushAsync();
});
using var client = new ClientWebSocket();
client.Options.Proxy = null;
await client.ConnectAsync(new Uri($"ws://127.0.0.1:{bridgePort}/"), CancellationToken.None);
var dapMessage = "{\"type\":\"request\",\"seq\":1,\"command\":\"initialize\"}";
var payload = Encoding.UTF8.GetBytes(dapMessage);
await client.SendAsync(new ArraySegment<byte>(payload), WebSocketMessageType.Text, endOfMessage: true, CancellationToken.None);
var echoed = await ReadWebSocketMessageAsync(client, TimeSpan.FromSeconds(5));
Assert.Equal(payload, echoed);
await echoTask;
}
finally
{
await bridge.ShutdownAsync();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task BridgeRejectsNonWebSocketRequests()
{
using var hc = CreateTestContext();
var bridge = new WebSocketDapBridge();
bridge.Initialize(hc);
bridge.Start(0, 0);
var bridgePort = bridge.ListenPort;
try
{
using var client = new TcpClient();
await client.ConnectAsync(IPAddress.Loopback, bridgePort);
using var stream = client.GetStream();
var request = Encoding.ASCII.GetBytes(
"GET / HTTP/1.1\r\n" +
"Host: localhost\r\n" +
"\r\n");
await stream.WriteAsync(request, 0, request.Length);
await stream.FlushAsync();
// Read until the server closes the connection (Connection: close).
// A single ReadAsync may return a partial response on some platforms.
using var ms = new MemoryStream();
var responseBuffer = new byte[1024];
int bytesRead;
while ((bytesRead = await stream.ReadAsync(responseBuffer, 0, responseBuffer.Length)) > 0)
{
ms.Write(responseBuffer, 0, bytesRead);
}
var response = Encoding.ASCII.GetString(ms.ToArray());
Assert.Contains("400 BadRequest", response);
Assert.Contains("Expected a websocket upgrade request.", response);
}
finally
{
await bridge.ShutdownAsync();
}
}
[Theory]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
[InlineData(new byte[] { (byte)'G', (byte)'E', (byte)'T', (byte)' ' }, 1)]
[InlineData(new byte[] { 0x81, 0x85, 0x00, 0x00 }, 2)]
[InlineData(new byte[] { 0xC1, 0x85, 0x00, 0x00 }, 3)]
[InlineData(new byte[] { (byte)'P', (byte)'R', (byte)'I', (byte)' ' }, 4)]
[InlineData(new byte[] { 0x16, 0x03, 0x03, 0x01 }, 5)]
[InlineData(new byte[] { (byte)'B', (byte)'A', (byte)'D', (byte)'!' }, 0)]
public void ClassifyIncomingStreamPrefixDetectsExpectedProtocols(byte[] initialBytes, int expectedKind)
{
var actualKind = WebSocketDapBridge.ClassifyIncomingStreamPrefix(initialBytes);
Assert.Equal((WebSocketDapBridge.IncomingStreamPrefixKind)expectedKind, actualKind);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task BridgeRejectsOversizedWebSocketMessage()
{
using var hc = CreateTestContext();
using var targetListener = new TcpListener(IPAddress.Loopback, 0);
targetListener.Start();
var targetPort = ((IPEndPoint)targetListener.LocalEndpoint).Port;
var bridge = new WebSocketDapBridge();
bridge.Initialize(hc);
bridge.MaxInboundMessageSize = 64; // artificially small limit for testing
bridge.Start(0, targetPort);
var bridgePort = bridge.ListenPort;
try
{
using var client = new ClientWebSocket();
client.Options.Proxy = null;
await client.ConnectAsync(new Uri($"ws://127.0.0.1:{bridgePort}/"), CancellationToken.None);
// Send a message that exceeds the 64-byte limit
var oversizedPayload = new byte[128];
Array.Fill(oversizedPayload, (byte)'X');
await client.SendAsync(
new ArraySegment<byte>(oversizedPayload),
WebSocketMessageType.Text,
endOfMessage: true,
CancellationToken.None);
// The bridge should close the connection with MessageTooBig
var receiveBuffer = new byte[256];
using var receiveCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var result = await client.ReceiveAsync(
new ArraySegment<byte>(receiveBuffer),
receiveCts.Token);
Assert.Equal(WebSocketMessageType.Close, result.MessageType);
Assert.Equal(WebSocketCloseStatus.MessageTooBig, client.CloseStatus);
}
finally
{
await bridge.ShutdownAsync();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task BridgeShutdownCompletesWhenPeerDoesNotCloseGracefully()
{
using var hc = CreateTestContext();
using var targetListener = new TcpListener(IPAddress.Loopback, 0);
targetListener.Start();
var targetPort = ((IPEndPoint)targetListener.LocalEndpoint).Port;
var bridge = new WebSocketDapBridge();
bridge.Initialize(hc);
bridge.Start(0, targetPort);
var bridgePort = bridge.ListenPort;
// Connect a raw TCP client but never perform WebSocket close handshake
using var rawClient = new TcpClient();
await rawClient.ConnectAsync(IPAddress.Loopback, bridgePort);
// Shutdown should complete within a bounded time, not hang
var shutdownTask = bridge.ShutdownAsync();
var completed = await Task.WhenAny(shutdownTask, Task.Delay(TimeSpan.FromSeconds(15)));
Assert.True(completed == shutdownTask, "Bridge shutdown should complete within the timeout, not hang on a non-cooperative peer");
}
}
}

View File

@@ -0,0 +1,119 @@
using System;
using System.Collections.Generic;
using GitHub.Runner.Worker.Dap;
using Xunit;
using YamlDotNet.Serialization;
namespace GitHub.Runner.Common.Tests.Worker
{
public sealed class YamlScalarFormatterL0
{
private static readonly IDeserializer Deserializer = new DeserializerBuilder().Build();
// Embed the formatter output inside a minimal YAML mapping and
// round-trip through YamlDotNet, asserting the parsed value equals
// the original input. Decouples assertions from the emitter's
// quoting choices (plain vs single- vs double-quoted).
private static void AssertRoundTrips(string value)
{
string scalar = YamlScalarFormatter.Format(value);
string yaml = $"k: {scalar}\n";
Dictionary<string, object> doc;
try
{
doc = Deserializer.Deserialize<Dictionary<string, object>>(yaml);
}
catch (Exception ex)
{
throw new Xunit.Sdk.XunitException(
$"Formatted scalar did not round-trip as valid YAML.\nInput: '{value}'\nFormatted: '{scalar}'\nFull YAML:\n{yaml}\nError: {ex}");
}
Assert.NotNull(doc);
Assert.True(doc.ContainsKey("k"), $"missing key in parsed doc. Formatted: '{scalar}'");
Assert.Equal(value, doc["k"] as string);
}
[Theory]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
[InlineData("hello")]
[InlineData("with: colon")]
[InlineData("with#hash")]
[InlineData(" leading")]
[InlineData("trailing ")]
[InlineData("a\"b")]
[InlineData("a\\b")]
[InlineData("@at")]
[InlineData("*star")]
[InlineData("&amp")]
[InlineData("?question")]
[InlineData("!exclaim")]
[InlineData("- dash")]
[InlineData("{brace}")]
[InlineData("[bracket]")]
public void Format_RoundTripsThroughYamlDeserializer(string value)
{
// The formatter must produce output that, embedded under a key,
// parses back to exactly the input. The emitter is free to
// pick plain, single-quoted, or double-quoted style.
AssertRoundTrips(value);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Format_PlainAscii_NoQuotingNeeded()
{
// Sanity check that the simple case stays plain.
Assert.Equal("hello", YamlScalarFormatter.Format("hello"));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Format_NoTrailingNewline()
{
Assert.False(YamlScalarFormatter.Format("hello").EndsWith("\n"));
Assert.False(YamlScalarFormatter.Format("with: colon").EndsWith("\n"));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Format_NoDocumentMarkers()
{
// The emitter wraps the scalar in a document; the formatter
// must strip both `--- ` (with space) and `---\n` (on its
// own line) prefixes plus the `\n...` suffix.
Assert.DoesNotContain("---", YamlScalarFormatter.Format("hello"));
Assert.DoesNotContain("...", YamlScalarFormatter.Format("hello"));
// Empty string is one of the cases where the emitter does
// produce a document marker by default.
Assert.DoesNotContain("---", YamlScalarFormatter.Format(""));
Assert.DoesNotContain("...", YamlScalarFormatter.Format(""));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Format_AlwaysUsesLfLineBreaks()
{
// Regression: YamlDotNet's Emitter calls WriteLine, which on
// Windows produces CRLF (the host's Environment.NewLine).
// Format must force LF so the output round-trips regardless
// of platform.
Assert.DoesNotContain('\r', YamlScalarFormatter.Format("hello"));
Assert.DoesNotContain('\r', YamlScalarFormatter.Format("with: colon"));
Assert.DoesNotContain('\r', YamlScalarFormatter.Format(""));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Format_NullValue_Throws()
{
Assert.Throws<ArgumentNullException>(() => YamlScalarFormatter.Format(null));
}
}
}

View File

@@ -17,7 +17,7 @@ LAYOUT_DIR="$SCRIPT_DIR/../_layout"
DOWNLOAD_DIR="$SCRIPT_DIR/../_downloads/netcore2x"
PACKAGE_DIR="$SCRIPT_DIR/../_package"
DOTNETSDK_ROOT="$SCRIPT_DIR/../_dotnetsdk"
DOTNETSDK_VERSION="8.0.419"
DOTNETSDK_VERSION="8.0.421"
DOTNETSDK_INSTALLDIR="$DOTNETSDK_ROOT/$DOTNETSDK_VERSION"
RUNNER_VERSION=$(cat runnerversion)

View File

@@ -1,5 +1,5 @@
{
"sdk": {
"version": "8.0.419"
"version": "8.0.421"
}
}

View File

@@ -1 +1 @@
2.333.0
2.334.0