mirror of
https://github.com/actions/runner.git
synced 2026-07-04 19:45:31 +08:00
Compare commits
1 Commits
dap-execut
...
v2.333.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d231aaf86 |
@@ -4,7 +4,7 @@
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {},
|
||||
"ghcr.io/devcontainers/features/dotnet": {
|
||||
"version": "8.0.421"
|
||||
"version": "8.0.419"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"version": "20"
|
||||
|
||||
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -99,7 +99,7 @@ jobs:
|
||||
|
||||
- name: Get latest runner version
|
||||
id: latest_runner
|
||||
uses: actions/github-script@v9
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
github-token: ${{secrets.GITHUB_TOKEN}}
|
||||
script: |
|
||||
|
||||
2
.github/workflows/docker-publish.yml
vendored
2
.github/workflows/docker-publish.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
- name: Compute image version
|
||||
id: image
|
||||
uses: actions/github-script@v9
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
|
||||
24
.github/workflows/node-upgrade.yml
vendored
24
.github/workflows/node-upgrade.yml
vendored
@@ -159,36 +159,18 @@ jobs:
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "<41898282+github-actions[bot]@users.noreply.github.com>"
|
||||
|
||||
# Build version summary for commit message and PR body (only include changed versions)
|
||||
COMMIT_VERSIONS=""
|
||||
PR_VERSION_LINES=""
|
||||
|
||||
if [ "${{ steps.node-versions.outputs.needs_update20 }}" == "true" ]; then
|
||||
COMMIT_VERSIONS="20: $NODE20_VERSION"
|
||||
PR_VERSION_LINES="- Node 20: ${{ steps.node-versions.outputs.current_node20 }} → $NODE20_VERSION"
|
||||
fi
|
||||
|
||||
if [ "${{ steps.node-versions.outputs.needs_update24 }}" == "true" ]; then
|
||||
if [ -n "$COMMIT_VERSIONS" ]; then
|
||||
COMMIT_VERSIONS="$COMMIT_VERSIONS, 24: $NODE24_VERSION"
|
||||
else
|
||||
COMMIT_VERSIONS="24: $NODE24_VERSION"
|
||||
fi
|
||||
PR_VERSION_LINES="${PR_VERSION_LINES:+$PR_VERSION_LINES
|
||||
}- Node 24: ${{ steps.node-versions.outputs.current_node24 }} → $NODE24_VERSION"
|
||||
fi
|
||||
|
||||
# Create branch and commit changes
|
||||
branch_name="chore/update-node"
|
||||
git checkout -b "$branch_name"
|
||||
git commit -a -m "chore: update Node versions ($COMMIT_VERSIONS)"
|
||||
git commit -a -m "chore: update Node versions (20: $NODE20_VERSION, 24: $NODE24_VERSION)"
|
||||
git push --force origin "$branch_name"
|
||||
|
||||
# Create PR body using here-doc for proper formatting
|
||||
cat > pr_body.txt << EOF
|
||||
Automated Node.js version update:
|
||||
|
||||
$PR_VERSION_LINES
|
||||
- Node 20: ${{ steps.node-versions.outputs.current_node20 }} → $NODE20_VERSION
|
||||
- Node 24: ${{ steps.node-versions.outputs.current_node24 }} → $NODE24_VERSION
|
||||
|
||||
This update ensures we're using the latest stable Node.js versions for security and performance improvements.
|
||||
|
||||
|
||||
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
# Make sure ./releaseVersion match ./src/runnerversion
|
||||
# Query GitHub release ensure version is not used
|
||||
- name: Check version
|
||||
uses: actions/github-script@v9
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
github-token: ${{secrets.GITHUB_TOKEN}}
|
||||
script: |
|
||||
@@ -171,7 +171,7 @@ jobs:
|
||||
# Create ReleaseNote file
|
||||
- name: Create ReleaseNote
|
||||
id: releaseNote
|
||||
uses: actions/github-script@v9
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
github-token: ${{secrets.GITHUB_TOKEN}}
|
||||
script: |
|
||||
@@ -300,7 +300,7 @@ jobs:
|
||||
|
||||
- name: Compute image version
|
||||
id: image
|
||||
uses: actions/github-script@v9
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -27,5 +27,4 @@ TestResults
|
||||
TestLogs
|
||||
.DS_Store
|
||||
.mono
|
||||
**/*.DotSettings.user
|
||||
**/*.lscache
|
||||
**/*.DotSettings.user
|
||||
@@ -25,11 +25,11 @@ The `installdependencies.sh` script should install all required dependencies on
|
||||
|
||||
Debian based OS (Debian, Ubuntu, Linux Mint)
|
||||
|
||||
- liblttng-ust1t64, liblttng-ust1 or liblttng-ust0
|
||||
- liblttng-ust1 or liblttng-ust0
|
||||
- libkrb5-3
|
||||
- zlib1g
|
||||
- libssl3t64, libssl3, libssl1.1, libssl1.0.2 or libssl1.0.0
|
||||
- libicu80, libicu79, ..., libicu66, libicu65, libicu63, libicu60, libicu57, libicu55, or libicu52
|
||||
- libicu76, libicu75, ..., libicu66, libicu65, libicu63, libicu60, libicu57, libicu55, or libicu52
|
||||
|
||||
Fedora based OS (Fedora, Red Hat Enterprise Linux, CentOS, Oracle Linux 7)
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
ARG RUNNER_VERSION
|
||||
ARG RUNNER_CONTAINER_HOOKS_VERSION=0.7.0
|
||||
ARG DOCKER_VERSION=29.5.0
|
||||
ARG BUILDX_VERSION=0.34.0
|
||||
ARG DOCKER_VERSION=29.3.0
|
||||
ARG BUILDX_VERSION=0.32.1
|
||||
|
||||
RUN apt update -y && apt install curl unzip -y
|
||||
|
||||
|
||||
@@ -1,36 +1,33 @@
|
||||
## What's Changed
|
||||
* 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
|
||||
* Log inner exception message. by @TingluoHuang in https://github.com/actions/runner/pull/4265
|
||||
* Fix composite post-step marker display names by @ericsciple in https://github.com/actions/runner/pull/4267
|
||||
* Bump actions/download-artifact from 7 to 8 by @dependabot[bot] in https://github.com/actions/runner/pull/4269
|
||||
* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4272
|
||||
* Avoid throw in SelfUpdaters. by @TingluoHuang in https://github.com/actions/runner/pull/4274
|
||||
* Fix parser comparison mismatches by @ericsciple in https://github.com/actions/runner/pull/4273
|
||||
* Devcontainer: bump base image Ubuntu version by @MaxHorstmann in https://github.com/actions/runner/pull/4277
|
||||
* Support `entrypoint` and `command` for service containers by @ericsciple in https://github.com/actions/runner/pull/4276
|
||||
* Bump actions/upload-artifact from 6 to 7 by @dependabot[bot] in https://github.com/actions/runner/pull/4270
|
||||
* Bump docker/login-action from 3 to 4 by @dependabot[bot] in https://github.com/actions/runner/pull/4278
|
||||
* Fix positional arg bug in ExpressionParser.CreateTree by @ericsciple in https://github.com/actions/runner/pull/4279
|
||||
* Bump docker/build-push-action from 6 to 7 by @dependabot[bot] in https://github.com/actions/runner/pull/4283
|
||||
* Bump docker/setup-buildx-action from 3 to 4 by @dependabot[bot] in https://github.com/actions/runner/pull/4282
|
||||
* Bump actions/attest-build-provenance from 3 to 4 by @dependabot[bot] in https://github.com/actions/runner/pull/4266
|
||||
* Bump @stylistic/eslint-plugin from 5.9.0 to 5.10.0 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4281
|
||||
* Update Docker to v29.3.0 and Buildx to v0.32.1 by @github-actions[bot] in https://github.com/actions/runner/pull/4286
|
||||
* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4287
|
||||
* Fix cancellation token race during parser comparison by @ericsciple in https://github.com/actions/runner/pull/4280
|
||||
* Bump @typescript-eslint/eslint-plugin from 8.47.0 to 8.54.0 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4230
|
||||
* Exit with specified exit code when runner is outdated by @nikola-jokic in https://github.com/actions/runner/pull/4285
|
||||
* Report infra_error for action download failures. by @TingluoHuang in https://github.com/actions/runner/pull/4294
|
||||
* Update dotnet sdk to latest version @8.0.419 by @github-actions[bot] in https://github.com/actions/runner/pull/4301
|
||||
* Node 24 enforcement + Linux ARM32 deprecation support by @salmanmkc in https://github.com/actions/runner/pull/4303
|
||||
* Bump @typescript-eslint/eslint-plugin from 8.54.0 to 8.57.1 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4304
|
||||
|
||||
## New Contributors
|
||||
* @stefanpenner made their first contribution in https://github.com/actions/runner/pull/4296
|
||||
* @MaxHorstmann made their first contribution in https://github.com/actions/runner/pull/4277
|
||||
|
||||
**Full Changelog**: https://github.com/actions/runner/compare/v2.333.1...v2.334.0
|
||||
**Full Changelog**: https://github.com/actions/runner/compare/v2.332.0...v2.333.0
|
||||
|
||||
_Note: Actions Runner follows a progressive release policy, so the latest release might not be available to your enterprise, organization, or repository yet.
|
||||
To confirm which version of the Actions Runner you should expect, please view the download instructions for your enterprise, organization, or repository.
|
||||
|
||||
@@ -1 +1 @@
|
||||
<Update to ./src/runnerversion when creating release>
|
||||
2.333.0
|
||||
|
||||
1228
src/Misc/expressionFunc/hashFiles/package-lock.json
generated
1228
src/Misc/expressionFunc/hashFiles/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -32,20 +32,20 @@
|
||||
"author": "GitHub Actions",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/glob": "^0.7.0"
|
||||
"@actions/glob": "^0.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@stylistic/eslint-plugin": "^5.10.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.0",
|
||||
"@typescript-eslint/parser": "^8.59.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.57.1",
|
||||
"@typescript-eslint/parser": "^8.0.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": "^16.4.0",
|
||||
"lint-staged": "^15.5.0",
|
||||
"prettier": "^3.0.3",
|
||||
"typescript": "^6.0.3"
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ NODE_URL=https://nodejs.org/dist
|
||||
NODE_ALPINE_URL=https://github.com/actions/alpine_nodejs/releases/download
|
||||
# When you update Node versions you must also create a new release of alpine_nodejs at that updated version.
|
||||
# Follow the instructions here: https://github.com/actions/alpine_nodejs?tab=readme-ov-file#getting-started
|
||||
NODE20_VERSION="20.20.2"
|
||||
NODE24_VERSION="24.16.0"
|
||||
NODE20_VERSION="20.20.1"
|
||||
NODE24_VERSION="24.14.0"
|
||||
|
||||
get_abs_path() {
|
||||
# exploits the fact that pwd will print abs path when no args
|
||||
|
||||
@@ -94,7 +94,7 @@ then
|
||||
fi
|
||||
}
|
||||
|
||||
apt_get_with_fallbacks liblttng-ust1t64 liblttng-ust1 liblttng-ust0
|
||||
apt_get_with_fallbacks 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 libicu80 libicu79 libicu78 libicu77 libicu76 libicu75 libicu74 libicu73 libicu72 libicu71 libicu70 libicu69 libicu68 libicu67 libicu66 libicu65 libicu63 libicu60 libicu57 libicu55 libicu52
|
||||
apt_get_with_fallbacks 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 '$?'"
|
||||
|
||||
@@ -177,9 +177,6 @@ namespace GitHub.Runner.Common
|
||||
public static readonly string SetOrchestrationIdEnvForActions = "actions_set_orchestration_id_env_for_actions";
|
||||
public static readonly string SendJobLevelAnnotations = "actions_send_job_level_annotations";
|
||||
public static readonly string EmitCompositeMarkers = "actions_runner_emit_composite_markers";
|
||||
public static readonly string BatchActionResolution = "actions_batch_action_resolution";
|
||||
public static readonly string UseBearerTokenForCodeload = "actions_use_bearer_token_for_codeload";
|
||||
public static readonly string OverrideDebuggerWelcomeMessage = "actions_runner_override_debugger_welcome_message";
|
||||
}
|
||||
|
||||
// Node version migration related constants
|
||||
|
||||
@@ -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="10.0.3" />
|
||||
<PackageReference Include="System.Text.Encoding.CodePages" Version="10.0.3" />
|
||||
<PackageReference Include="System.Threading.Channels" Version="10.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" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
|
||||
|
||||
@@ -12,13 +12,6 @@ namespace GitHub.Runner.Common
|
||||
private ISecretMasker _secretMasker;
|
||||
private TraceSource _traceSource;
|
||||
|
||||
/// <summary>
|
||||
/// The underlying <see cref="System.Diagnostics.TraceSource"/> for this instance.
|
||||
/// Useful when third-party libraries require a <see cref="System.Diagnostics.TraceSource"/>
|
||||
/// to route their diagnostics into the runner's log infrastructure.
|
||||
/// </summary>
|
||||
public TraceSource Source => _traceSource;
|
||||
|
||||
public Tracing(string name, ISecretMasker secretMasker, SourceSwitch sourceSwitch, HostTraceListener traceListener, StdoutTraceListener stdoutTraceListener = null)
|
||||
{
|
||||
ArgUtil.NotNull(secretMasker, nameof(secretMasker));
|
||||
|
||||
@@ -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="10.0.3" />
|
||||
<PackageReference Include="System.ServiceProcess.ServiceController" Version="10.0.7" />
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
|
||||
<PackageReference Include="System.ServiceProcess.ServiceController" Version="8.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
|
||||
|
||||
@@ -12,6 +12,8 @@ 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");
|
||||
@@ -97,7 +99,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.CommitHash))
|
||||
if (string.IsNullOrEmpty(sourceVersion) && RegexUtility.IsMatch(sourceBranch, WellKnownRegularExpressions.SHA1))
|
||||
{
|
||||
sourceVersion = sourceBranch;
|
||||
|
||||
|
||||
@@ -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.CommitHash))
|
||||
if (string.IsNullOrEmpty(sourceVersion) && RegexUtility.IsMatch(sourceBranch, WellKnownRegularExpressions.SHA1))
|
||||
{
|
||||
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/*
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Text.Encoding.CodePages" Version="10.0.3" />
|
||||
<PackageReference Include="System.Text.Encoding.CodePages" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
|
||||
<PackageReference Include="System.Threading.Channels" Version="10.0.3" />
|
||||
<PackageReference Include="System.Threading.Channels" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
|
||||
|
||||
@@ -79,13 +79,6 @@ namespace GitHub.Runner.Worker
|
||||
PreStepTracker = new Dictionary<Guid, IActionRunner>()
|
||||
};
|
||||
var containerSetupSteps = new List<JobExtensionRunner>();
|
||||
var batchActionResolution = (executionContext.Global.Variables.GetBoolean(Constants.Runner.Features.BatchActionResolution) ?? false)
|
||||
|| StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION"));
|
||||
// Stack-local cache: same action (owner/repo@ref) is resolved only once,
|
||||
// even if it appears at multiple depths in a composite tree.
|
||||
var resolvedDownloadInfos = batchActionResolution
|
||||
? new Dictionary<string, WebApi.ActionDownloadInfo>(StringComparer.Ordinal)
|
||||
: null;
|
||||
var depth = 0;
|
||||
// We are running at the start of a job
|
||||
if (rootStepId == default(Guid))
|
||||
@@ -112,9 +105,7 @@ namespace GitHub.Runner.Worker
|
||||
PrepareActionsState result = new PrepareActionsState();
|
||||
try
|
||||
{
|
||||
result = batchActionResolution
|
||||
? await PrepareActionsRecursiveAsync(executionContext, state, actions, resolvedDownloadInfos, depth, rootStepId)
|
||||
: await PrepareActionsRecursiveLegacyAsync(executionContext, state, actions, depth, rootStepId);
|
||||
result = await PrepareActionsRecursiveAsync(executionContext, state, actions, depth, rootStepId);
|
||||
}
|
||||
catch (FailedToResolveActionDownloadInfoException ex)
|
||||
{
|
||||
@@ -178,192 +169,7 @@ namespace GitHub.Runner.Worker
|
||||
return new PrepareResult(containerSetupSteps, result.PreStepTracker);
|
||||
}
|
||||
|
||||
private async Task<PrepareActionsState> PrepareActionsRecursiveAsync(IExecutionContext executionContext, PrepareActionsState state, IEnumerable<Pipelines.ActionStep> actions, Dictionary<string, WebApi.ActionDownloadInfo> resolvedDownloadInfos, Int32 depth = 0, Guid parentStepId = default(Guid))
|
||||
{
|
||||
ArgUtil.NotNull(executionContext, nameof(executionContext));
|
||||
if (depth > Constants.CompositeActionsMaxDepth)
|
||||
{
|
||||
throw new Exception($"Composite action depth exceeded max depth {Constants.CompositeActionsMaxDepth}");
|
||||
}
|
||||
|
||||
var repositoryActions = new List<Pipelines.ActionStep>();
|
||||
|
||||
foreach (var action in actions)
|
||||
{
|
||||
if (action.Reference.Type == Pipelines.ActionSourceType.ContainerRegistry)
|
||||
{
|
||||
ArgUtil.NotNull(action, nameof(action));
|
||||
var containerReference = action.Reference as Pipelines.ContainerRegistryReference;
|
||||
ArgUtil.NotNull(containerReference, nameof(containerReference));
|
||||
ArgUtil.NotNullOrEmpty(containerReference.Image, nameof(containerReference.Image));
|
||||
|
||||
if (!state.ImagesToPull.ContainsKey(containerReference.Image))
|
||||
{
|
||||
state.ImagesToPull[containerReference.Image] = new List<Guid>();
|
||||
}
|
||||
|
||||
Trace.Info($"Action {action.Name} ({action.Id}) needs to pull image '{containerReference.Image}'");
|
||||
state.ImagesToPull[containerReference.Image].Add(action.Id);
|
||||
}
|
||||
else if (action.Reference.Type == Pipelines.ActionSourceType.Repository)
|
||||
{
|
||||
repositoryActions.Add(action);
|
||||
}
|
||||
}
|
||||
|
||||
if (repositoryActions.Count > 0)
|
||||
{
|
||||
// Resolve download info, skipping any actions already cached.
|
||||
await ResolveNewActionsAsync(executionContext, repositoryActions, resolvedDownloadInfos);
|
||||
|
||||
// Download each action.
|
||||
foreach (var action in repositoryActions)
|
||||
{
|
||||
var lookupKey = GetDownloadInfoLookupKey(action);
|
||||
if (string.IsNullOrEmpty(lookupKey))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (!resolvedDownloadInfos.TryGetValue(lookupKey, out var downloadInfo))
|
||||
{
|
||||
throw new Exception($"Missing download info for {lookupKey}");
|
||||
}
|
||||
await DownloadRepositoryActionAsync(executionContext, downloadInfo);
|
||||
}
|
||||
|
||||
// Parse action.yml and collect composite sub-actions for batched
|
||||
// resolution below. Pre/post step registration is deferred until
|
||||
// after recursion so that HasPre/HasPost reflect the full subtree.
|
||||
var nextLevel = new List<(Pipelines.ActionStep action, Guid parentId)>();
|
||||
|
||||
foreach (var action in repositoryActions)
|
||||
{
|
||||
var setupInfo = PrepareRepositoryActionAsync(executionContext, action);
|
||||
if (setupInfo != null && setupInfo.Container != null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(setupInfo.Container.Image))
|
||||
{
|
||||
if (!state.ImagesToPull.ContainsKey(setupInfo.Container.Image))
|
||||
{
|
||||
state.ImagesToPull[setupInfo.Container.Image] = new List<Guid>();
|
||||
}
|
||||
|
||||
Trace.Info($"Action {action.Name} ({action.Id}) from repository '{setupInfo.Container.ActionRepository}' needs to pull image '{setupInfo.Container.Image}'");
|
||||
state.ImagesToPull[setupInfo.Container.Image].Add(action.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
ArgUtil.NotNullOrEmpty(setupInfo.Container.ActionRepository, nameof(setupInfo.Container.ActionRepository));
|
||||
|
||||
if (!state.ImagesToBuild.ContainsKey(setupInfo.Container.ActionRepository))
|
||||
{
|
||||
state.ImagesToBuild[setupInfo.Container.ActionRepository] = new List<Guid>();
|
||||
}
|
||||
|
||||
Trace.Info($"Action {action.Name} ({action.Id}) from repository '{setupInfo.Container.ActionRepository}' needs to build image '{setupInfo.Container.Dockerfile}'");
|
||||
state.ImagesToBuild[setupInfo.Container.ActionRepository].Add(action.Id);
|
||||
state.ImagesToBuildInfo[setupInfo.Container.ActionRepository] = setupInfo.Container;
|
||||
}
|
||||
}
|
||||
else if (setupInfo != null && setupInfo.Steps != null && setupInfo.Steps.Count > 0)
|
||||
{
|
||||
foreach (var step in setupInfo.Steps)
|
||||
{
|
||||
nextLevel.Add((step, action.Id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve all next-level sub-actions in one batch API call,
|
||||
// then recurse per parent (which hits the cache, not the API).
|
||||
if (nextLevel.Count > 0)
|
||||
{
|
||||
var nextLevelRepoActions = nextLevel
|
||||
.Where(x => x.action.Reference.Type == Pipelines.ActionSourceType.Repository)
|
||||
.Select(x => x.action)
|
||||
.ToList();
|
||||
await ResolveNewActionsAsync(executionContext, nextLevelRepoActions, resolvedDownloadInfos);
|
||||
|
||||
foreach (var group in nextLevel.GroupBy(x => x.parentId))
|
||||
{
|
||||
var groupActions = group.Select(x => x.action).ToList();
|
||||
state = await PrepareActionsRecursiveAsync(executionContext, state, groupActions, resolvedDownloadInfos, depth + 1, group.Key);
|
||||
}
|
||||
}
|
||||
|
||||
// Register pre/post steps after recursion so that HasPre/HasPost
|
||||
// are correct (they depend on _cachedEmbeddedPreSteps/PostSteps
|
||||
// being populated by the recursive calls above).
|
||||
foreach (var action in repositoryActions)
|
||||
{
|
||||
var repoAction = action.Reference as Pipelines.RepositoryPathReference;
|
||||
if (repoAction.RepositoryType != Pipelines.PipelineConstants.SelfAlias)
|
||||
{
|
||||
var definition = LoadAction(executionContext, action);
|
||||
if (definition.Data.Execution.HasPre)
|
||||
{
|
||||
Trace.Info($"Add 'pre' execution for {action.Id}");
|
||||
// Root Step
|
||||
if (depth < 1)
|
||||
{
|
||||
var actionRunner = HostContext.CreateService<IActionRunner>();
|
||||
actionRunner.Action = action;
|
||||
actionRunner.Stage = ActionRunStage.Pre;
|
||||
actionRunner.Condition = definition.Data.Execution.InitCondition;
|
||||
state.PreStepTracker[action.Id] = actionRunner;
|
||||
}
|
||||
// Embedded Step
|
||||
else
|
||||
{
|
||||
if (!_cachedEmbeddedPreSteps.ContainsKey(parentStepId))
|
||||
{
|
||||
_cachedEmbeddedPreSteps[parentStepId] = new List<Pipelines.ActionStep>();
|
||||
}
|
||||
// Clone action so we can modify the condition without affecting the original
|
||||
var clonedAction = action.Clone() as Pipelines.ActionStep;
|
||||
clonedAction.Condition = definition.Data.Execution.InitCondition;
|
||||
_cachedEmbeddedPreSteps[parentStepId].Add(clonedAction);
|
||||
}
|
||||
}
|
||||
|
||||
if (definition.Data.Execution.HasPost && depth > 0)
|
||||
{
|
||||
if (!_cachedEmbeddedPostSteps.ContainsKey(parentStepId))
|
||||
{
|
||||
// If we haven't done so already, add the parent to the post steps
|
||||
_cachedEmbeddedPostSteps[parentStepId] = new Stack<Pipelines.ActionStep>();
|
||||
}
|
||||
// Clone action so we can modify the condition without affecting the original
|
||||
var clonedAction = action.Clone() as Pipelines.ActionStep;
|
||||
clonedAction.Condition = definition.Data.Execution.CleanupCondition;
|
||||
_cachedEmbeddedPostSteps[parentStepId].Push(clonedAction);
|
||||
}
|
||||
}
|
||||
else if (depth > 0)
|
||||
{
|
||||
// if we're in a composite action and haven't loaded the local action yet
|
||||
// we assume it has a post step
|
||||
if (!_cachedEmbeddedPostSteps.ContainsKey(parentStepId))
|
||||
{
|
||||
// If we haven't done so already, add the parent to the post steps
|
||||
_cachedEmbeddedPostSteps[parentStepId] = new Stack<Pipelines.ActionStep>();
|
||||
}
|
||||
// Clone action so we can modify the condition without affecting the original
|
||||
var clonedAction = action.Clone() as Pipelines.ActionStep;
|
||||
_cachedEmbeddedPostSteps[parentStepId].Push(clonedAction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Legacy (non-batched) action resolution. Each composite resolves its
|
||||
/// sub-actions individually, with no cross-depth deduplication.
|
||||
/// Used when the BatchActionResolution feature flag is disabled.
|
||||
/// </summary>
|
||||
private async Task<PrepareActionsState> PrepareActionsRecursiveLegacyAsync(IExecutionContext executionContext, PrepareActionsState state, IEnumerable<Pipelines.ActionStep> actions, Int32 depth = 0, Guid parentStepId = default(Guid))
|
||||
private async Task<PrepareActionsState> PrepareActionsRecursiveAsync(IExecutionContext executionContext, PrepareActionsState state, IEnumerable<Pipelines.ActionStep> actions, Int32 depth = 0, Guid parentStepId = default(Guid))
|
||||
{
|
||||
ArgUtil.NotNull(executionContext, nameof(executionContext));
|
||||
if (depth > Constants.CompositeActionsMaxDepth)
|
||||
@@ -449,7 +255,7 @@ namespace GitHub.Runner.Worker
|
||||
}
|
||||
else if (setupInfo != null && setupInfo.Steps != null && setupInfo.Steps.Count > 0)
|
||||
{
|
||||
state = await PrepareActionsRecursiveLegacyAsync(executionContext, state, setupInfo.Steps, depth + 1, action.Id);
|
||||
state = await PrepareActionsRecursiveAsync(executionContext, state, setupInfo.Steps, depth + 1, action.Id);
|
||||
}
|
||||
var repoAction = action.Reference as Pipelines.RepositoryPathReference;
|
||||
if (repoAction.RepositoryType != Pipelines.PipelineConstants.SelfAlias)
|
||||
@@ -880,11 +686,6 @@ 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>();
|
||||
@@ -896,7 +697,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, Dependencies = dependencies }, executionContext.CancellationToken, displayHelpfulActionsDownloadErrors);
|
||||
actionDownloadInfos = await launchServer.ResolveActionsDownloadInfoAsync(executionContext.Global.Plan.PlanId, executionContext.Root.Id, new WebApi.ActionReferenceList { Actions = actionReferences }, executionContext.CancellationToken, displayHelpfulActionsDownloadErrors);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -961,33 +762,6 @@ namespace GitHub.Runner.Worker
|
||||
return actionDownloadInfos.Actions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Only resolves actions not already in resolvedDownloadInfos.
|
||||
/// Results are cached for reuse at deeper recursion levels.
|
||||
/// </summary>
|
||||
private async Task ResolveNewActionsAsync(IExecutionContext executionContext, List<Pipelines.ActionStep> actions, Dictionary<string, WebApi.ActionDownloadInfo> resolvedDownloadInfos)
|
||||
{
|
||||
var actionsToResolve = new List<Pipelines.ActionStep>();
|
||||
var pendingKeys = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var action in actions)
|
||||
{
|
||||
var lookupKey = GetDownloadInfoLookupKey(action);
|
||||
if (!string.IsNullOrEmpty(lookupKey) && !resolvedDownloadInfos.ContainsKey(lookupKey) && pendingKeys.Add(lookupKey))
|
||||
{
|
||||
actionsToResolve.Add(action);
|
||||
}
|
||||
}
|
||||
|
||||
if (actionsToResolve.Count > 0)
|
||||
{
|
||||
var downloadInfos = await GetDownloadInfoAsync(executionContext, actionsToResolve);
|
||||
foreach (var kvp in downloadInfos)
|
||||
{
|
||||
resolvedDownloadInfos[kvp.Key] = kvp.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DownloadRepositoryActionAsync(IExecutionContext executionContext, WebApi.ActionDownloadInfo downloadInfo)
|
||||
{
|
||||
Trace.Entering();
|
||||
@@ -1372,29 +1146,16 @@ namespace GitHub.Runner.Worker
|
||||
return $"{repositoryReference.Name}@{repositoryReference.Ref}";
|
||||
}
|
||||
|
||||
private AuthenticationHeaderValue CreateAuthHeader(IExecutionContext executionContext, string downloadUrl, string token)
|
||||
private AuthenticationHeaderValue CreateAuthHeader(string token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(token))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (executionContext.Global.Variables.GetBoolean(Constants.Runner.Features.UseBearerTokenForCodeload) == true &&
|
||||
Uri.TryCreate(downloadUrl, UriKind.Absolute, out var parsedUrl) &&
|
||||
!string.IsNullOrEmpty(parsedUrl?.Host) &&
|
||||
!string.IsNullOrEmpty(parsedUrl?.PathAndQuery) &&
|
||||
(parsedUrl.Host.StartsWith("codeload.", StringComparison.OrdinalIgnoreCase) || parsedUrl.PathAndQuery.StartsWith("/_codeload/", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
Trace.Info("Using Bearer token for action archive download directly to codeload.");
|
||||
return new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
else
|
||||
{
|
||||
Trace.Info("Using Basic token for action archive download.");
|
||||
var base64EncodingToken = Convert.ToBase64String(Encoding.UTF8.GetBytes($"x-access-token:{token}"));
|
||||
HostContext.SecretMasker.AddValue(base64EncodingToken);
|
||||
return new AuthenticationHeaderValue("Basic", base64EncodingToken);
|
||||
}
|
||||
var base64EncodingToken = Convert.ToBase64String(Encoding.UTF8.GetBytes($"x-access-token:{token}"));
|
||||
HostContext.SecretMasker.AddValue(base64EncodingToken);
|
||||
return new AuthenticationHeaderValue("Basic", base64EncodingToken);
|
||||
}
|
||||
|
||||
private async Task DownloadRepositoryArchive(IExecutionContext executionContext, string downloadUrl, string downloadAuthToken, string archiveFile)
|
||||
@@ -1419,7 +1180,7 @@ namespace GitHub.Runner.Worker
|
||||
using (var httpClientHandler = HostContext.CreateHttpClientHandler())
|
||||
using (var httpClient = new HttpClient(httpClientHandler))
|
||||
{
|
||||
httpClient.DefaultRequestHeaders.Authorization = CreateAuthHeader(executionContext, downloadUrl, downloadAuthToken);
|
||||
httpClient.DefaultRequestHeaders.Authorization = CreateAuthHeader(downloadAuthToken);
|
||||
|
||||
httpClient.DefaultRequestHeaders.UserAgent.AddRange(HostContext.UserAgents);
|
||||
using (var response = await httpClient.GetAsync(downloadUrl))
|
||||
@@ -1446,11 +1207,6 @@ 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
|
||||
@@ -1474,11 +1230,6 @@ 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++;
|
||||
@@ -1504,7 +1255,7 @@ namespace GitHub.Runner.Worker
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (!(ex is AccessDeniedException) && !(ex is OperationCanceledException) && !executionContext.CancellationToken.IsCancellationRequested)
|
||||
catch (Exception ex) when (!(ex is OperationCanceledException) && !executionContext.CancellationToken.IsCancellationRequested)
|
||||
{
|
||||
Trace.Error($"Failed to download archive '{downloadUrl}' after {retryCount + 1} attempts.");
|
||||
Trace.Error(ex);
|
||||
|
||||
@@ -316,6 +316,7 @@ namespace GitHub.Runner.Worker
|
||||
Schema = _actionManifestSchema,
|
||||
// TODO: Switch to real tracewriter for cutover
|
||||
TraceWriter = new GitHub.Actions.WorkflowParser.ObjectTemplating.EmptyTraceWriter(),
|
||||
AllowCaseFunction = false,
|
||||
};
|
||||
|
||||
// Expression values from execution context
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
@@ -315,6 +315,7 @@ namespace GitHub.Runner.Worker
|
||||
maxBytes: 10 * 1024 * 1024),
|
||||
Schema = _actionManifestSchema,
|
||||
TraceWriter = executionContext.ToTemplateTraceWriter(),
|
||||
AllowCaseFunction = false,
|
||||
};
|
||||
|
||||
// Expression values from execution context
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,450 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.DistributedTask.Pipelines.ContextData;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using GitHub.Runner.Sdk;
|
||||
using GitHub.Runner.Worker.Container;
|
||||
using GitHub.Runner.Worker.Handlers;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap
|
||||
{
|
||||
/// <summary>
|
||||
/// Executes <see cref="RunCommand"/> objects in the job's runtime context.
|
||||
///
|
||||
/// Mirrors the behavior of a normal workflow <c>run:</c> step as closely
|
||||
/// as possible by reusing the runner's existing shell-resolution logic,
|
||||
/// script fixup helpers, and process execution infrastructure.
|
||||
///
|
||||
/// Output is streamed to the debugger via DAP <c>output</c> events with
|
||||
/// secrets masked before emission.
|
||||
/// </summary>
|
||||
internal sealed class DapReplExecutor
|
||||
{
|
||||
private readonly IHostContext _hostContext;
|
||||
private readonly Action<string, string> _sendOutput;
|
||||
private readonly Tracing _trace;
|
||||
|
||||
public DapReplExecutor(IHostContext hostContext, Action<string, string> sendOutput)
|
||||
{
|
||||
_hostContext = hostContext ?? throw new ArgumentNullException(nameof(hostContext));
|
||||
_sendOutput = sendOutput ?? throw new ArgumentNullException(nameof(sendOutput));
|
||||
_trace = hostContext.GetTrace(nameof(DapReplExecutor));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a <see cref="RunCommand"/> and returns the exit code as a
|
||||
/// formatted <see cref="EvaluateResponseBody"/>.
|
||||
/// </summary>
|
||||
public async Task<EvaluateResponseBody> ExecuteRunCommandAsync(
|
||||
RunCommand command,
|
||||
IExecutionContext context,
|
||||
bool isActionStep,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (context == null)
|
||||
{
|
||||
return ErrorResult("No execution context available. The debugger must be paused at a step to run commands.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await ExecuteScriptAsync(command, context, isActionStep, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_trace.Error($"REPL run command failed ({ex.GetType().Name})");
|
||||
var maskedError = _hostContext.SecretMasker.MaskSecrets(ex.Message);
|
||||
return ErrorResult($"Command failed: {maskedError}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<EvaluateResponseBody> ExecuteScriptAsync(
|
||||
RunCommand command,
|
||||
IExecutionContext context,
|
||||
bool isActionStep,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 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;
|
||||
|
||||
if (!string.IsNullOrEmpty(command.Shell))
|
||||
{
|
||||
// Explicit shell from the DSL
|
||||
var parsed = ScriptHandlerHelpers.ParseShellOptionString(command.Shell);
|
||||
shellCommand = parsed.shellCommand;
|
||||
argFormat = string.IsNullOrEmpty(parsed.shellArgs)
|
||||
? ScriptHandlerHelpers.GetScriptArgumentsFormat(shellCommand)
|
||||
: parsed.shellArgs;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Default shell — mirrors ScriptHandler platform defaults
|
||||
shellCommand = ResolveDefaultShell(context);
|
||||
argFormat = ScriptHandlerHelpers.GetScriptArgumentsFormat(shellCommand);
|
||||
}
|
||||
|
||||
_trace.Info($"Resolved REPL shell (container={isContainerStepHost})");
|
||||
|
||||
// 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);
|
||||
|
||||
// Write to a temp file (same pattern as ScriptHandler)
|
||||
var extension = ScriptHandlerHelpers.GetScriptFileExtension(shellCommand);
|
||||
var scriptFilePath = Path.Combine(
|
||||
_hostContext.GetDirectory(WellKnownDirectory.Temp),
|
||||
$"dap_repl_{Guid.NewGuid()}{extension}");
|
||||
|
||||
Encoding encoding = new UTF8Encoding(false);
|
||||
#if OS_WINDOWS
|
||||
contents = contents.Replace("\r\n", "\n").Replace("\n", "\r\n");
|
||||
encoding = Console.InputEncoding.CodePage != 65001
|
||||
? Console.InputEncoding
|
||||
: encoding;
|
||||
#endif
|
||||
File.WriteAllText(scriptFilePath, contents, encoding);
|
||||
|
||||
try
|
||||
{
|
||||
// 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);
|
||||
|
||||
// 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 fileName = isContainerStepHost
|
||||
? shellCommand
|
||||
: WhichUtil.Which(shellCommand, false, _trace, prependPath) ?? shellCommand;
|
||||
|
||||
// 6. Build environment — merge from execution context like a real step
|
||||
var environment = BuildEnvironment(context, command.Env);
|
||||
|
||||
// 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))
|
||||
{
|
||||
var githubContext = context.ExpressionValues.TryGetValue("github", out var gh)
|
||||
? gh as DictionaryContextData
|
||||
: null;
|
||||
var workspace = githubContext?.TryGetValue("workspace", out var ws) == true
|
||||
? (ws as StringContextData)?.Value
|
||||
: null;
|
||||
workingDirectory = workspace ?? _hostContext.GetDirectory(WellKnownDirectory.Work);
|
||||
}
|
||||
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");
|
||||
|
||||
// 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))
|
||||
{
|
||||
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}");
|
||||
|
||||
// 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}.",
|
||||
Type = exitCode == 0 ? "string" : "error",
|
||||
VariablesReference = 0
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Clean up temp script file
|
||||
try { File.Delete(scriptFilePath); }
|
||||
catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// step inputs before <see cref="ScriptHandler"/> runs them.
|
||||
///
|
||||
/// Each <c>${{ expr }}</c> occurrence is individually evaluated and
|
||||
/// replaced with its masked string result, mirroring the semantics of
|
||||
/// expression interpolation in a workflow <c>run:</c> step body.
|
||||
/// </summary>
|
||||
internal string ExpandExpressions(string input, IExecutionContext context)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input) || !input.Contains("${{"))
|
||||
{
|
||||
return input ?? string.Empty;
|
||||
}
|
||||
|
||||
var result = new StringBuilder();
|
||||
int pos = 0;
|
||||
|
||||
while (pos < input.Length)
|
||||
{
|
||||
var start = input.IndexOf("${{", pos, StringComparison.Ordinal);
|
||||
if (start < 0)
|
||||
{
|
||||
result.Append(input, pos, input.Length - pos);
|
||||
break;
|
||||
}
|
||||
|
||||
// Append the literal text before the expression
|
||||
result.Append(input, pos, start - pos);
|
||||
|
||||
var end = input.IndexOf("}}", start + 3, StringComparison.Ordinal);
|
||||
if (end < 0)
|
||||
{
|
||||
// Unterminated expression — keep literal
|
||||
result.Append(input, start, input.Length - start);
|
||||
break;
|
||||
}
|
||||
|
||||
var expr = input.Substring(start + 3, end - start - 3).Trim();
|
||||
end += 2; // skip past "}}"
|
||||
|
||||
// Evaluate the expression
|
||||
try
|
||||
{
|
||||
var templateEvaluator = context.ToPipelineTemplateEvaluator();
|
||||
var token = new GitHub.DistributedTask.ObjectTemplating.Tokens.BasicExpressionToken(
|
||||
null, null, null, expr);
|
||||
var evaluated = templateEvaluator.EvaluateStepDisplayName(
|
||||
token,
|
||||
context.ExpressionValues,
|
||||
context.ExpressionFunctions);
|
||||
result.Append(_hostContext.SecretMasker.MaskSecrets(evaluated ?? string.Empty));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_trace.Warning($"Expression expansion failed ({ex.GetType().Name})");
|
||||
// Keep the original expression literal on failure
|
||||
result.Append(input, start, end - start);
|
||||
}
|
||||
|
||||
pos = end;
|
||||
}
|
||||
|
||||
return result.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the default shell the same way <see cref="ScriptHandler"/>
|
||||
/// does: check job defaults, then fall back to platform default.
|
||||
/// </summary>
|
||||
internal string ResolveDefaultShell(IExecutionContext context)
|
||||
{
|
||||
// Check job defaults
|
||||
if (context.Global?.JobDefaults != null &&
|
||||
context.Global.JobDefaults.TryGetValue("run", out var runDefaults) &&
|
||||
runDefaults.TryGetValue("shell", out var defaultShell) &&
|
||||
!string.IsNullOrEmpty(defaultShell))
|
||||
{
|
||||
_trace.Info("Using job default shell");
|
||||
return defaultShell;
|
||||
}
|
||||
|
||||
#if OS_WINDOWS
|
||||
string prependPath = string.Join(
|
||||
Path.PathSeparator.ToString(),
|
||||
context.Global?.PrependPath != null ? Enumerable.Reverse(context.Global.PrependPath) : Array.Empty<string>());
|
||||
var pwshPath = WhichUtil.Which("pwsh", false, _trace, prependPath);
|
||||
return !string.IsNullOrEmpty(pwshPath) ? "pwsh" : "powershell";
|
||||
#else
|
||||
return "sh";
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges the job context environment with any REPL-specific overrides.
|
||||
/// </summary>
|
||||
internal Dictionary<string, string> BuildEnvironment(
|
||||
IExecutionContext context,
|
||||
Dictionary<string, string> replEnv)
|
||||
{
|
||||
var env = new Dictionary<string, string>(VarUtil.EnvironmentVariableKeyComparer);
|
||||
|
||||
// Pull environment from the execution context (same as ActionRunner)
|
||||
if (context.ExpressionValues.TryGetValue("env", out var envData))
|
||||
{
|
||||
if (envData is DictionaryContextData dictEnv)
|
||||
{
|
||||
foreach (var pair in dictEnv)
|
||||
{
|
||||
if (pair.Value is StringContextData str)
|
||||
{
|
||||
env[pair.Key] = str.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (envData is CaseSensitiveDictionaryContextData csEnv)
|
||||
{
|
||||
foreach (var pair in csEnv)
|
||||
{
|
||||
if (pair.Value is StringContextData str)
|
||||
{
|
||||
env[pair.Key] = str.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Expose runtime context variables to the environment (GITHUB_*, RUNNER_*, etc.)
|
||||
foreach (var ctxPair in context.ExpressionValues)
|
||||
{
|
||||
if (ctxPair.Value is IEnvironmentContextData runtimeContext && runtimeContext != null)
|
||||
{
|
||||
foreach (var rtEnv in runtimeContext.GetRuntimeEnvironmentVariables())
|
||||
{
|
||||
env[rtEnv.Key] = rtEnv.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply REPL-specific overrides last (so they win),
|
||||
// expanding any ${{ }} expressions in the values
|
||||
if (replEnv != null)
|
||||
{
|
||||
foreach (var pair in replEnv)
|
||||
{
|
||||
env[pair.Key] = ExpandExpressions(pair.Value, context);
|
||||
}
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
private void SendOutput(string category, string text)
|
||||
{
|
||||
_sendOutput(category, text);
|
||||
}
|
||||
|
||||
private static EvaluateResponseBody ErrorResult(string message)
|
||||
{
|
||||
return new EvaluateResponseBody
|
||||
{
|
||||
Result = message,
|
||||
Type = "error",
|
||||
VariablesReference = 0
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,411 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap
|
||||
{
|
||||
/// <summary>
|
||||
/// Base type for all REPL DSL commands.
|
||||
/// </summary>
|
||||
internal abstract class DapReplCommand
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>help</c> or <c>help("run")</c>
|
||||
/// </summary>
|
||||
internal sealed class HelpCommand : DapReplCommand
|
||||
{
|
||||
public string Topic { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>run("echo hello")</c> or
|
||||
/// <c>run("echo hello", shell: "bash", env: { FOO: "bar" }, working_directory: "/tmp")</c>
|
||||
/// </summary>
|
||||
internal sealed class RunCommand : DapReplCommand
|
||||
{
|
||||
public string Script { get; set; }
|
||||
public string Shell { get; set; }
|
||||
public Dictionary<string, string> Env { get; set; }
|
||||
public string WorkingDirectory { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses REPL input into typed <see cref="DapReplCommand"/> objects.
|
||||
///
|
||||
/// Grammar (intentionally minimal — extend as the DSL grows):
|
||||
/// <code>
|
||||
/// help → HelpCommand { Topic = null }
|
||||
/// help("run") → HelpCommand { Topic = "run" }
|
||||
/// run("script body") → RunCommand { Script = "script body" }
|
||||
/// run("script", shell: "bash") → RunCommand { Shell = "bash" }
|
||||
/// run("script", env: { K: "V" }) → RunCommand { Env = { K → V } }
|
||||
/// run("script", working_directory: "p")→ RunCommand { WorkingDirectory = "p" }
|
||||
/// </code>
|
||||
///
|
||||
/// Parsing is intentionally hand-rolled rather than regex-based so it can
|
||||
/// handle nested braces, quoted strings with escapes, and grow to support
|
||||
/// future commands without accumulating regex complexity.
|
||||
/// </summary>
|
||||
internal static class DapReplParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Attempts to parse REPL input into a command. Returns null if the
|
||||
/// input does not match any known DSL command (i.e. it should be
|
||||
/// treated as an expression instead).
|
||||
/// </summary>
|
||||
internal static DapReplCommand TryParse(string input, out string error)
|
||||
{
|
||||
error = null;
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = input.Trim();
|
||||
|
||||
// help / help("topic")
|
||||
if (trimmed.Equals("help", StringComparison.OrdinalIgnoreCase) ||
|
||||
trimmed.StartsWith("help(", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ParseHelp(trimmed, out error);
|
||||
}
|
||||
|
||||
// run("...")
|
||||
if (trimmed.StartsWith("run(", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ParseRun(trimmed, out error);
|
||||
}
|
||||
|
||||
// Not a DSL command
|
||||
return null;
|
||||
}
|
||||
|
||||
internal static string GetGeneralHelp()
|
||||
{
|
||||
return """
|
||||
Actions Debug Console
|
||||
|
||||
Commands:
|
||||
help Show this help
|
||||
help("run") Show help for the run command
|
||||
run("script") Execute a script (like a workflow run step)
|
||||
|
||||
Anything else is evaluated as a GitHub Actions expression.
|
||||
Example: github.repository
|
||||
Example: ${{ github.event_name }}
|
||||
|
||||
""";
|
||||
}
|
||||
|
||||
internal static string GetRunHelp()
|
||||
{
|
||||
return """
|
||||
run command — execute a script in the job context
|
||||
|
||||
Usage:
|
||||
run("echo hello")
|
||||
run("echo $FOO", shell: "bash")
|
||||
run("echo $FOO", env: { FOO: "bar" })
|
||||
run("ls", working_directory: "/tmp")
|
||||
run("echo $X", shell: "bash", env: { X: "1" }, working_directory: "/tmp")
|
||||
|
||||
Options:
|
||||
shell: Shell to use (default: job default, e.g. bash)
|
||||
env: Extra environment variables as { KEY: "value" }
|
||||
working_directory: Working directory for the command
|
||||
|
||||
Behavior:
|
||||
- Equivalent to a workflow `run:` step
|
||||
- Expressions in the script body are expanded (${{ ... }})
|
||||
- Output is streamed in real time and secrets are masked
|
||||
|
||||
""";
|
||||
}
|
||||
|
||||
#region Parsers
|
||||
|
||||
private static HelpCommand ParseHelp(string input, out string error)
|
||||
{
|
||||
error = null;
|
||||
if (input.Equals("help", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new HelpCommand();
|
||||
}
|
||||
|
||||
// help("topic")
|
||||
var inner = ExtractParenthesizedArgs(input, "help", out error);
|
||||
if (error != null) return null;
|
||||
|
||||
var topic = ExtractQuotedString(inner.Trim(), out error);
|
||||
if (error != null) return null;
|
||||
|
||||
return new HelpCommand { Topic = topic };
|
||||
}
|
||||
|
||||
private static RunCommand ParseRun(string input, out string error)
|
||||
{
|
||||
error = null;
|
||||
|
||||
var inner = ExtractParenthesizedArgs(input, "run", out error);
|
||||
if (error != null) return null;
|
||||
|
||||
// Split into argument list respecting quotes and braces
|
||||
var args = SplitArguments(inner, out error);
|
||||
if (error != null) return null;
|
||||
if (args.Count == 0)
|
||||
{
|
||||
error = "run() requires a script argument. Example: run(\"echo hello\")";
|
||||
return null;
|
||||
}
|
||||
|
||||
// First arg must be the script body (a quoted string)
|
||||
var script = ExtractQuotedString(args[0].Trim(), out error);
|
||||
if (error != null)
|
||||
{
|
||||
error = $"First argument to run() must be a quoted string. {error}";
|
||||
return null;
|
||||
}
|
||||
|
||||
var cmd = new RunCommand { Script = script };
|
||||
|
||||
// Parse remaining keyword arguments
|
||||
for (int i = 1; i < args.Count; i++)
|
||||
{
|
||||
var kv = args[i].Trim();
|
||||
var colonIdx = kv.IndexOf(':');
|
||||
if (colonIdx <= 0)
|
||||
{
|
||||
error = $"Expected keyword argument (e.g. shell: \"bash\"), got: {kv}";
|
||||
return null;
|
||||
}
|
||||
|
||||
var key = kv.Substring(0, colonIdx).Trim();
|
||||
var value = kv.Substring(colonIdx + 1).Trim();
|
||||
|
||||
switch (key.ToLowerInvariant())
|
||||
{
|
||||
case "shell":
|
||||
cmd.Shell = ExtractQuotedString(value, out error);
|
||||
if (error != null) { error = $"shell: {error}"; return null; }
|
||||
break;
|
||||
|
||||
case "working_directory":
|
||||
cmd.WorkingDirectory = ExtractQuotedString(value, out error);
|
||||
if (error != null) { error = $"working_directory: {error}"; return null; }
|
||||
break;
|
||||
|
||||
case "env":
|
||||
cmd.Env = ParseEnvBlock(value, out error);
|
||||
if (error != null) { error = $"env: {error}"; return null; }
|
||||
break;
|
||||
|
||||
default:
|
||||
error = $"Unknown option: {key}. Valid options: shell, env, working_directory";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Low-level parsing helpers
|
||||
|
||||
/// <summary>
|
||||
/// Given "cmd(...)" returns the inner content between the outer parens.
|
||||
/// </summary>
|
||||
private static string ExtractParenthesizedArgs(string input, string prefix, out string error)
|
||||
{
|
||||
error = null;
|
||||
var start = prefix.Length; // skip "cmd"
|
||||
if (start >= input.Length || input[start] != '(')
|
||||
{
|
||||
error = $"Expected '(' after {prefix}";
|
||||
return null;
|
||||
}
|
||||
|
||||
if (input[input.Length - 1] != ')')
|
||||
{
|
||||
error = $"Expected ')' at end of {prefix}(...)";
|
||||
return null;
|
||||
}
|
||||
|
||||
return input.Substring(start + 1, input.Length - start - 2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts a double-quoted string value, handling escaped quotes.
|
||||
/// </summary>
|
||||
internal static string ExtractQuotedString(string input, out string error)
|
||||
{
|
||||
error = null;
|
||||
if (string.IsNullOrEmpty(input))
|
||||
{
|
||||
error = "Expected a quoted string, got empty input";
|
||||
return null;
|
||||
}
|
||||
|
||||
if (input[0] != '"')
|
||||
{
|
||||
error = $"Expected a quoted string starting with \", got: {Truncate(input, 40)}";
|
||||
return null;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
for (int i = 1; i < input.Length; i++)
|
||||
{
|
||||
if (input[i] == '\\' && i + 1 < input.Length)
|
||||
{
|
||||
sb.Append(input[i + 1]);
|
||||
i++;
|
||||
}
|
||||
else if (input[i] == '"')
|
||||
{
|
||||
// Check nothing meaningful follows the closing quote
|
||||
var rest = input.Substring(i + 1).Trim();
|
||||
if (rest.Length > 0)
|
||||
{
|
||||
error = $"Unexpected content after closing quote: {Truncate(rest, 40)}";
|
||||
return null;
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(input[i]);
|
||||
}
|
||||
}
|
||||
|
||||
error = "Unterminated string (missing closing \")";
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splits a comma-separated argument list, respecting quoted strings
|
||||
/// and nested braces so that <c>"a, b", env: { K: "V, W" }</c> is
|
||||
/// correctly split into two arguments.
|
||||
/// </summary>
|
||||
internal static List<string> SplitArguments(string input, out string error)
|
||||
{
|
||||
error = null;
|
||||
var result = new List<string>();
|
||||
var current = new StringBuilder();
|
||||
int depth = 0;
|
||||
bool inQuote = false;
|
||||
|
||||
for (int i = 0; i < input.Length; i++)
|
||||
{
|
||||
var ch = input[i];
|
||||
|
||||
if (ch == '\\' && inQuote && i + 1 < input.Length)
|
||||
{
|
||||
current.Append(ch);
|
||||
current.Append(input[++i]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch == '"')
|
||||
{
|
||||
inQuote = !inQuote;
|
||||
current.Append(ch);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inQuote)
|
||||
{
|
||||
if (ch == '{')
|
||||
{
|
||||
depth++;
|
||||
current.Append(ch);
|
||||
continue;
|
||||
}
|
||||
if (ch == '}')
|
||||
{
|
||||
depth--;
|
||||
current.Append(ch);
|
||||
continue;
|
||||
}
|
||||
if (ch == ',' && depth == 0)
|
||||
{
|
||||
result.Add(current.ToString());
|
||||
current.Clear();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
current.Append(ch);
|
||||
}
|
||||
|
||||
if (inQuote)
|
||||
{
|
||||
error = "Unterminated string in arguments";
|
||||
return null;
|
||||
}
|
||||
if (depth != 0)
|
||||
{
|
||||
error = "Unmatched braces in arguments";
|
||||
return null;
|
||||
}
|
||||
|
||||
if (current.Length > 0)
|
||||
{
|
||||
result.Add(current.ToString());
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses <c>{ KEY: "value", KEY2: "value2" }</c> into a dictionary.
|
||||
/// </summary>
|
||||
internal static Dictionary<string, string> ParseEnvBlock(string input, out string error)
|
||||
{
|
||||
error = null;
|
||||
var trimmed = input.Trim();
|
||||
if (!trimmed.StartsWith("{") || !trimmed.EndsWith("}"))
|
||||
{
|
||||
error = "Expected env block in the form { KEY: \"value\" }";
|
||||
return null;
|
||||
}
|
||||
|
||||
var inner = trimmed.Substring(1, trimmed.Length - 2).Trim();
|
||||
if (string.IsNullOrEmpty(inner))
|
||||
{
|
||||
return new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
var pairs = SplitArguments(inner, out error);
|
||||
if (error != null) return null;
|
||||
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in pairs)
|
||||
{
|
||||
var colonIdx = pair.IndexOf(':');
|
||||
if (colonIdx <= 0)
|
||||
{
|
||||
error = $"Expected KEY: \"value\" pair, got: {Truncate(pair.Trim(), 40)}";
|
||||
return null;
|
||||
}
|
||||
|
||||
var key = pair.Substring(0, colonIdx).Trim();
|
||||
var val = ExtractQuotedString(pair.Substring(colonIdx + 1).Trim(), out error);
|
||||
if (error != null) return null;
|
||||
|
||||
result[key] = val;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string Truncate(string value, int maxLength)
|
||||
{
|
||||
if (value == null) return "(null)";
|
||||
return value.Length <= maxLength ? value : value.Substring(0, maxLength) + "...";
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -1,373 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using GitHub.DistributedTask.Logging;
|
||||
using GitHub.DistributedTask.ObjectTemplating.Tokens;
|
||||
using GitHub.DistributedTask.Pipelines.ContextData;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps runner execution context data to DAP scopes and variables.
|
||||
///
|
||||
/// This is the single point where runner context values are materialized
|
||||
/// for the debugger. All values pass through the runner's existing
|
||||
/// <see cref="GitHub.DistributedTask.Logging.ISecretMasker"/> so the DAP
|
||||
/// surface never exposes anything beyond what a normal CI log would show.
|
||||
///
|
||||
/// The secrets scope is intentionally opaque: keys are visible but every
|
||||
/// value is replaced with a constant redaction marker.
|
||||
///
|
||||
/// Designed to be reusable by future DAP features (evaluate, hover, REPL)
|
||||
/// so that masking policy is never duplicated.
|
||||
/// </summary>
|
||||
internal sealed class DapVariableProvider
|
||||
{
|
||||
// Well-known scope names that map to top-level expression contexts.
|
||||
// Order matters: the index determines the stable variablesReference ID.
|
||||
private static readonly string[] _scopeNames =
|
||||
{
|
||||
"github", "env", "runner", "job", "steps",
|
||||
"secrets", "inputs", "vars", "matrix", "needs"
|
||||
};
|
||||
|
||||
// Scope references occupy the range [1, ScopeReferenceMax].
|
||||
private const int _scopeReferenceBase = 1;
|
||||
private const int _scopeReferenceMax = 100;
|
||||
|
||||
// Dynamic (nested) variable references start above the scope range.
|
||||
private const int _dynamicReferenceBase = 101;
|
||||
|
||||
private const string _redactedValue = "***";
|
||||
|
||||
private readonly ISecretMasker _secretMasker;
|
||||
|
||||
// Maps dynamic variable reference IDs to the backing data and its
|
||||
// dot-separated path (e.g. "github.event.pull_request").
|
||||
private readonly Dictionary<int, (PipelineContextData Data, string Path)> _variableReferences = new();
|
||||
private int _nextVariableReference = _dynamicReferenceBase;
|
||||
|
||||
public DapVariableProvider(ISecretMasker secretMasker)
|
||||
{
|
||||
_secretMasker = secretMasker ?? throw new ArgumentNullException(nameof(secretMasker));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all dynamic variable references.
|
||||
/// Call this whenever the paused execution context changes (e.g. new step)
|
||||
/// so that stale nested references are not served to the client.
|
||||
/// </summary>
|
||||
public void Reset()
|
||||
{
|
||||
_variableReferences.Clear();
|
||||
_nextVariableReference = _dynamicReferenceBase;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the list of DAP scopes for the given execution context.
|
||||
/// Each scope corresponds to a well-known runner expression context
|
||||
/// (github, env, secrets, …) and carries a stable variablesReference
|
||||
/// that the client can use to drill into variables.
|
||||
/// </summary>
|
||||
public List<Scope> GetScopes(IExecutionContext context)
|
||||
{
|
||||
var scopes = new List<Scope>();
|
||||
|
||||
if (context?.ExpressionValues == null)
|
||||
{
|
||||
return scopes;
|
||||
}
|
||||
|
||||
for (int i = 0; i < _scopeNames.Length; i++)
|
||||
{
|
||||
var scopeName = _scopeNames[i];
|
||||
if (!context.ExpressionValues.TryGetValue(scopeName, out var value) || value == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var scope = new Scope
|
||||
{
|
||||
Name = scopeName,
|
||||
VariablesReference = _scopeReferenceBase + i,
|
||||
Expensive = false,
|
||||
PresentationHint = scopeName == "secrets" ? "registers" : null
|
||||
};
|
||||
|
||||
if (value is DictionaryContextData dict)
|
||||
{
|
||||
scope.NamedVariables = dict.Count;
|
||||
}
|
||||
else if (value is CaseSensitiveDictionaryContextData csDict)
|
||||
{
|
||||
scope.NamedVariables = csDict.Count;
|
||||
}
|
||||
|
||||
scopes.Add(scope);
|
||||
}
|
||||
|
||||
return scopes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the child variables for a given variablesReference.
|
||||
/// The reference may point at a top-level scope (1–100) or a
|
||||
/// dynamically registered nested container (101+).
|
||||
/// </summary>
|
||||
public List<Variable> GetVariables(IExecutionContext context, int variablesReference)
|
||||
{
|
||||
var variables = new List<Variable>();
|
||||
|
||||
if (context?.ExpressionValues == null)
|
||||
{
|
||||
return variables;
|
||||
}
|
||||
|
||||
PipelineContextData data = null;
|
||||
string basePath = null;
|
||||
bool isSecretsScope = false;
|
||||
|
||||
if (variablesReference >= _scopeReferenceBase && variablesReference <= _scopeReferenceMax)
|
||||
{
|
||||
var scopeIndex = variablesReference - _scopeReferenceBase;
|
||||
if (scopeIndex < _scopeNames.Length)
|
||||
{
|
||||
var scopeName = _scopeNames[scopeIndex];
|
||||
isSecretsScope = scopeName == "secrets";
|
||||
if (context.ExpressionValues.TryGetValue(scopeName, out data))
|
||||
{
|
||||
basePath = scopeName;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (_variableReferences.TryGetValue(variablesReference, out var refData))
|
||||
{
|
||||
data = refData.Data;
|
||||
basePath = refData.Path;
|
||||
isSecretsScope = basePath?.StartsWith("secrets", StringComparison.OrdinalIgnoreCase) == true;
|
||||
}
|
||||
|
||||
if (data == null)
|
||||
{
|
||||
return variables;
|
||||
}
|
||||
|
||||
ConvertToVariables(data, basePath, isSecretsScope, variables);
|
||||
return variables;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates a GitHub Actions expression (e.g. "github.repository",
|
||||
/// "${{ github.event_name }}") in the context of the current step and
|
||||
/// returns a masked result suitable for the DAP evaluate response.
|
||||
///
|
||||
/// Uses the runner's standard <see cref="GitHub.DistributedTask.Pipelines.ObjectTemplating.IPipelineTemplateEvaluator"/>
|
||||
/// so the full expression language is available (functions, operators,
|
||||
/// context access).
|
||||
/// </summary>
|
||||
public EvaluateResponseBody EvaluateExpression(string expression, IExecutionContext context)
|
||||
{
|
||||
if (context?.ExpressionValues == null)
|
||||
{
|
||||
return new EvaluateResponseBody
|
||||
{
|
||||
Result = "(no execution context available)",
|
||||
Type = "string",
|
||||
VariablesReference = 0
|
||||
};
|
||||
}
|
||||
|
||||
// Strip ${{ }} wrapper if present
|
||||
var expr = expression?.Trim() ?? string.Empty;
|
||||
if (expr.StartsWith("${{") && expr.EndsWith("}}"))
|
||||
{
|
||||
expr = expr.Substring(3, expr.Length - 5).Trim();
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(expr))
|
||||
{
|
||||
return new EvaluateResponseBody
|
||||
{
|
||||
Result = string.Empty,
|
||||
Type = "string",
|
||||
VariablesReference = 0
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var templateEvaluator = context.ToPipelineTemplateEvaluator();
|
||||
var token = new BasicExpressionToken(null, null, null, expr);
|
||||
|
||||
var result = templateEvaluator.EvaluateStepDisplayName(
|
||||
token,
|
||||
context.ExpressionValues,
|
||||
context.ExpressionFunctions);
|
||||
|
||||
result = _secretMasker.MaskSecrets(result ?? "null");
|
||||
|
||||
return new EvaluateResponseBody
|
||||
{
|
||||
Result = result,
|
||||
Type = InferResultType(result),
|
||||
VariablesReference = 0
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var errorMessage = _secretMasker.MaskSecrets($"Evaluation error: {ex.Message}");
|
||||
return new EvaluateResponseBody
|
||||
{
|
||||
Result = errorMessage,
|
||||
Type = "string",
|
||||
VariablesReference = 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Infers a simple DAP type hint from the string representation of a result.
|
||||
/// </summary>
|
||||
internal static string InferResultType(string value)
|
||||
{
|
||||
value = value?.ToLower();
|
||||
if (value == null || value == "null")
|
||||
return "null";
|
||||
if (value == "true" || value == "false")
|
||||
return "boolean";
|
||||
if (double.TryParse(value, NumberStyles.Any,
|
||||
CultureInfo.InvariantCulture, out _))
|
||||
return "number";
|
||||
if (value.StartsWith("{") || value.StartsWith("["))
|
||||
return "object";
|
||||
return "string";
|
||||
}
|
||||
|
||||
#region Private helpers
|
||||
|
||||
private void ConvertToVariables(
|
||||
PipelineContextData data,
|
||||
string basePath,
|
||||
bool isSecretsScope,
|
||||
List<Variable> variables)
|
||||
{
|
||||
switch (data)
|
||||
{
|
||||
case DictionaryContextData dict:
|
||||
foreach (var pair in dict)
|
||||
{
|
||||
variables.Add(CreateVariable(pair.Key, pair.Value, basePath, isSecretsScope));
|
||||
}
|
||||
break;
|
||||
|
||||
case CaseSensitiveDictionaryContextData csDict:
|
||||
foreach (var pair in csDict)
|
||||
{
|
||||
variables.Add(CreateVariable(pair.Key, pair.Value, basePath, isSecretsScope));
|
||||
}
|
||||
break;
|
||||
|
||||
case ArrayContextData array:
|
||||
for (int i = 0; i < array.Count; i++)
|
||||
{
|
||||
var variable = CreateVariable($"[{i}]", array[i], basePath, isSecretsScope);
|
||||
variables.Add(variable);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private Variable CreateVariable(
|
||||
string name,
|
||||
PipelineContextData value,
|
||||
string basePath,
|
||||
bool isSecretsScope)
|
||||
{
|
||||
var childPath = string.IsNullOrEmpty(basePath) ? name : $"{basePath}.{name}";
|
||||
var variable = new Variable
|
||||
{
|
||||
Name = name,
|
||||
EvaluateName = $"${{{{ {childPath} }}}}"
|
||||
};
|
||||
|
||||
// Secrets scope: redact ALL values regardless of underlying type.
|
||||
// Keys are visible but values are always replaced with the
|
||||
// redaction marker, and nested containers are not drillable.
|
||||
if (isSecretsScope)
|
||||
{
|
||||
variable.Value = _redactedValue;
|
||||
variable.Type = "string";
|
||||
variable.VariablesReference = 0;
|
||||
return variable;
|
||||
}
|
||||
|
||||
if (value == null)
|
||||
{
|
||||
variable.Value = "null";
|
||||
variable.Type = "null";
|
||||
variable.VariablesReference = 0;
|
||||
return variable;
|
||||
}
|
||||
|
||||
switch (value)
|
||||
{
|
||||
case StringContextData str:
|
||||
variable.Value = _secretMasker.MaskSecrets(str.Value);
|
||||
variable.Type = "string";
|
||||
variable.VariablesReference = 0;
|
||||
break;
|
||||
|
||||
case NumberContextData num:
|
||||
variable.Value = _secretMasker.MaskSecrets(num.Value.ToString("G15", CultureInfo.InvariantCulture));
|
||||
variable.Type = "number";
|
||||
variable.VariablesReference = 0;
|
||||
break;
|
||||
|
||||
case BooleanContextData boolVal:
|
||||
variable.Value = boolVal.Value ? "true" : "false";
|
||||
variable.Type = "boolean";
|
||||
variable.VariablesReference = 0;
|
||||
break;
|
||||
|
||||
case DictionaryContextData dict:
|
||||
variable.Value = $"Object ({dict.Count} properties)";
|
||||
variable.Type = "object";
|
||||
variable.VariablesReference = RegisterVariableReference(dict, childPath);
|
||||
variable.NamedVariables = dict.Count;
|
||||
break;
|
||||
|
||||
case CaseSensitiveDictionaryContextData csDict:
|
||||
variable.Value = $"Object ({csDict.Count} properties)";
|
||||
variable.Type = "object";
|
||||
variable.VariablesReference = RegisterVariableReference(csDict, childPath);
|
||||
variable.NamedVariables = csDict.Count;
|
||||
break;
|
||||
|
||||
case ArrayContextData array:
|
||||
variable.Value = $"Array ({array.Count} items)";
|
||||
variable.Type = "array";
|
||||
variable.VariablesReference = RegisterVariableReference(array, childPath);
|
||||
variable.IndexedVariables = array.Count;
|
||||
break;
|
||||
|
||||
default:
|
||||
var rawValue = value.ToJToken()?.ToString() ?? "unknown";
|
||||
variable.Value = _secretMasker.MaskSecrets(rawValue);
|
||||
variable.Type = value.GetType().Name;
|
||||
variable.VariablesReference = 0;
|
||||
break;
|
||||
}
|
||||
|
||||
return variable;
|
||||
}
|
||||
|
||||
private int RegisterVariableReference(PipelineContextData data, string path)
|
||||
{
|
||||
var reference = _nextVariableReference++;
|
||||
_variableReferences[reference] = (data, path);
|
||||
return reference;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
using GitHub.DistributedTask.Pipelines;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap
|
||||
{
|
||||
/// <summary>
|
||||
/// Consolidated runtime configuration for the job debugger.
|
||||
/// Populated once from the acquire response and owned by <see cref="GlobalContext"/>.
|
||||
/// </summary>
|
||||
public sealed class DebuggerConfig
|
||||
{
|
||||
public DebuggerConfig(bool enabled, DebuggerTunnelInfo tunnel, bool overrideWelcomeMessage = false, string welcomeMessage = null)
|
||||
{
|
||||
Enabled = enabled;
|
||||
Tunnel = tunnel;
|
||||
OverrideWelcomeMessage = overrideWelcomeMessage;
|
||||
WelcomeMessage = welcomeMessage;
|
||||
}
|
||||
|
||||
/// <summary>Whether the debugger is enabled for this job.</summary>
|
||||
public bool Enabled { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Dev Tunnel details for remote debugging.
|
||||
/// Required when <see cref="Enabled"/> is true.
|
||||
/// </summary>
|
||||
public DebuggerTunnelInfo Tunnel { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 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)
|
||||
&& !string.IsNullOrEmpty(Tunnel.ClusterId)
|
||||
&& !string.IsNullOrEmpty(Tunnel.HostToken)
|
||||
&& Tunnel.Port >= 1024 && Tunnel.Port <= 65535;
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Runner.Common;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap
|
||||
{
|
||||
public enum DapSessionState
|
||||
{
|
||||
NotStarted,
|
||||
WaitingForConnection,
|
||||
Initializing,
|
||||
Ready,
|
||||
Paused,
|
||||
Running,
|
||||
Terminated
|
||||
}
|
||||
|
||||
[ServiceLocator(Default = typeof(DapDebugger))]
|
||||
public interface IDapDebugger : IRunnerService
|
||||
{
|
||||
Task StartAsync(IExecutionContext jobContext);
|
||||
Task WaitUntilReadyAsync();
|
||||
Task OnStepStartingAsync(IStep step);
|
||||
void OnStepCompleted(IStep step);
|
||||
|
||||
/// <summary>
|
||||
/// Called after JobExtension.InitializeJob has returned and the initial
|
||||
/// step queue + post-step stack have been populated. The debugger uses
|
||||
/// these snapshots to build the synthesized job execution view served
|
||||
/// via the DAP source request.
|
||||
/// </summary>
|
||||
Task OnJobStepsInitializedAsync(IEnumerable<IStep> mainQueue, IEnumerable<IStep> initialPostStack);
|
||||
|
||||
/// <summary>
|
||||
/// Called from ExecutionContext.RegisterPostJobStep after a post-step
|
||||
/// is pushed onto the post-job stack. The debugger appends the step
|
||||
/// to the running execution view so the rendered YAML reflects the
|
||||
/// newly-known post-step.
|
||||
/// </summary>
|
||||
void OnPostStepRegistered(IStep step);
|
||||
|
||||
Task OnJobCompletedAsync();
|
||||
Task StopAsync();
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
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>();
|
||||
private int _completeJobLine;
|
||||
|
||||
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>
|
||||
/// 1-based line where the synthetic <c>- step: Complete job</c> entry
|
||||
/// appears in <see cref="Yaml"/>. Always non-zero — Cleanup is always emitted.
|
||||
/// </summary>
|
||||
public int CompleteJobLine
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _completeJobLine;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <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;
|
||||
_completeJobLine = result.CompleteJobLine;
|
||||
|
||||
_lineByStep.Clear();
|
||||
for (int i = 0; i < _stepIdentities.Count; i++)
|
||||
{
|
||||
var step = _stepIdentities[i];
|
||||
if (step != null)
|
||||
{
|
||||
_lineByStep[step] = _entryStartLines[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,345 +0,0 @@
|
||||
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, int completeJobLine)
|
||||
{
|
||||
Yaml = yaml;
|
||||
EntryStartLines = entryStartLines;
|
||||
CompleteJobLine = completeJobLine;
|
||||
}
|
||||
|
||||
public string Yaml { get; }
|
||||
public IReadOnlyList<int> EntryStartLines { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 1-based line where the synthetic <c>- step: Complete job</c> entry
|
||||
/// appears in <see cref="Yaml"/>. Always non-zero — Cleanup is always emitted.
|
||||
/// </summary>
|
||||
public int CompleteJobLine { 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");
|
||||
newlinesEmitted += 2;
|
||||
int completeJobLine = newlinesEmitted + 1;
|
||||
sb.Append(" - step: Complete job\n");
|
||||
|
||||
return new RenderResult(sb.ToString(), Array.AsReadOnly(startLines), completeJobLine);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using GitHub.DistributedTask.ObjectTemplating.Tokens;
|
||||
using GitHub.DistributedTask.Pipelines;
|
||||
using GitHub.Runner.Sdk;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap
|
||||
{
|
||||
/// <summary>
|
||||
/// Translates runner <see cref="IStep"/> instances into pure-data
|
||||
/// <see cref="JobExecutionViewEntry"/> records used by the DAP debugger
|
||||
/// execution view. Filters out runner-internal steps (e.g.
|
||||
/// <see cref="JobExtensionRunner"/>) so the rendered view only shows
|
||||
/// user-visible workflow steps.
|
||||
/// </summary>
|
||||
internal static class StepEntryTranslator
|
||||
{
|
||||
// Run-step internals carried on ActionStep.Inputs that are NOT
|
||||
// user-authored `with:` entries. The runner stores these under
|
||||
// the keys defined in PipelineConstants.ScriptStepInputs, NOT
|
||||
// their kebab-case workflow-YAML spellings.
|
||||
private static readonly HashSet<string> RunStepInternalKeys = new(StringComparer.Ordinal)
|
||||
{
|
||||
PipelineConstants.ScriptStepInputs.Script,
|
||||
PipelineConstants.ScriptStepInputs.Shell,
|
||||
PipelineConstants.ScriptStepInputs.WorkingDirectory,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Translate an IStep into a JobExecutionViewEntry.
|
||||
/// </summary>
|
||||
/// <param name="step">The IStep to translate. Must not be null.</param>
|
||||
/// <returns>
|
||||
/// A JobExecutionViewEntry, or null if the step is not user-visible
|
||||
/// (JobExtensionRunner and any other non-IActionRunner IStep impls).
|
||||
/// </returns>
|
||||
public static JobExecutionViewEntry TryTranslate(IStep step)
|
||||
{
|
||||
ArgUtil.NotNull(step, nameof(step));
|
||||
|
||||
if (step is JobExtensionRunner)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (step is not IActionRunner actionRunner)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var phase = actionRunner.Stage switch
|
||||
{
|
||||
ActionRunStage.Pre => JobExecutionPhase.Pre,
|
||||
ActionRunStage.Post => JobExecutionPhase.Post,
|
||||
_ => JobExecutionPhase.Main,
|
||||
};
|
||||
|
||||
string displayName = actionRunner.DisplayName;
|
||||
if (string.IsNullOrWhiteSpace(displayName))
|
||||
{
|
||||
displayName = "run";
|
||||
}
|
||||
|
||||
string uses = null;
|
||||
string run = null;
|
||||
string id = null;
|
||||
string ifCond = null;
|
||||
string continueOnError = null;
|
||||
string timeoutMinutes = null;
|
||||
string envYaml = null;
|
||||
string withYaml = null;
|
||||
string shell = null;
|
||||
string workingDirectory = null;
|
||||
|
||||
var action = actionRunner.Action;
|
||||
var reference = action?.Reference;
|
||||
bool isScript = reference?.Type == ActionSourceType.Script;
|
||||
|
||||
if (reference != null && !isScript)
|
||||
{
|
||||
uses = FormatActionReference(reference);
|
||||
}
|
||||
|
||||
// Only the user-visible Main entry surfaces authored params.
|
||||
// Pre/Post stay minimal (step + action) — they reference the
|
||||
// same Action as the Main entry, and duplicating params adds
|
||||
// noise without information.
|
||||
if (phase == JobExecutionPhase.Main && action != null)
|
||||
{
|
||||
id = FilterAuthoredId(action.ContextName);
|
||||
|
||||
if (!string.IsNullOrEmpty(action.Condition))
|
||||
{
|
||||
ifCond = action.Condition;
|
||||
}
|
||||
|
||||
if (action.ContinueOnError != null)
|
||||
{
|
||||
continueOnError = TemplateTokenYamlAdapter.Serialize(action.ContinueOnError, indentSpaces: 0);
|
||||
}
|
||||
if (action.TimeoutInMinutes != null)
|
||||
{
|
||||
timeoutMinutes = TemplateTokenYamlAdapter.Serialize(action.TimeoutInMinutes, indentSpaces: 0);
|
||||
}
|
||||
|
||||
if (action.Environment is MappingToken envMap && envMap.Count > 0)
|
||||
{
|
||||
envYaml = TemplateTokenYamlAdapter.Serialize(envMap, indentSpaces: 6);
|
||||
}
|
||||
else if (action.Environment != null && !(action.Environment is MappingToken))
|
||||
{
|
||||
// Unusual but possible: env: ${{ ... }} expression form.
|
||||
envYaml = TemplateTokenYamlAdapter.Serialize(action.Environment, indentSpaces: 6);
|
||||
}
|
||||
|
||||
if (isScript)
|
||||
{
|
||||
var inputs = action.Inputs as MappingToken;
|
||||
if (inputs != null)
|
||||
{
|
||||
if (TryGetMapValue(inputs, PipelineConstants.ScriptStepInputs.Script, out var scriptTok) && scriptTok != null)
|
||||
{
|
||||
run = scriptTok.ToString();
|
||||
}
|
||||
if (TryGetMapValue(inputs, PipelineConstants.ScriptStepInputs.Shell, out var shellTok) && shellTok != null)
|
||||
{
|
||||
string shellText = shellTok.ToString();
|
||||
if (!string.IsNullOrEmpty(shellText))
|
||||
{
|
||||
shell = shellText;
|
||||
}
|
||||
}
|
||||
if (TryGetMapValue(inputs, PipelineConstants.ScriptStepInputs.WorkingDirectory, out var wdTok) && wdTok != null)
|
||||
{
|
||||
string wdText = wdTok.ToString();
|
||||
if (!string.IsNullOrEmpty(wdText))
|
||||
{
|
||||
workingDirectory = wdText;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Action step: surface `with:` entries, filtering any
|
||||
// run-step internal keys defensively.
|
||||
if (action.Inputs is MappingToken withMap && withMap.Count > 0)
|
||||
{
|
||||
var filtered = FilterMapping(withMap, RunStepInternalKeys);
|
||||
if (filtered != null && filtered.Count > 0)
|
||||
{
|
||||
withYaml = TemplateTokenYamlAdapter.Serialize(filtered, indentSpaces: 6);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Source annotation (SourcePath/SourceLine) requires a public
|
||||
// seam onto TemplateToken position info — not wired yet.
|
||||
return new JobExecutionViewEntry(
|
||||
phase: phase,
|
||||
displayName: displayName,
|
||||
uses: uses,
|
||||
run: run,
|
||||
sourcePath: null,
|
||||
sourceLine: 0,
|
||||
id: id,
|
||||
@if: ifCond,
|
||||
continueOnError: continueOnError,
|
||||
timeoutMinutes: timeoutMinutes,
|
||||
envYaml: envYaml,
|
||||
withYaml: withYaml,
|
||||
shell: shell,
|
||||
workingDirectory: workingDirectory);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Auto-generated step IDs are noise in the view: filter them out.
|
||||
/// The runner's convention (see ExecutionContext) is that auto-
|
||||
/// generated context names start with <c>__</c>. Only user-authored
|
||||
/// IDs survive the filter.
|
||||
/// </summary>
|
||||
internal static string FilterAuthoredId(string contextName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(contextName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (contextName.StartsWith("__", StringComparison.Ordinal))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return contextName;
|
||||
}
|
||||
|
||||
private static bool TryGetMapValue(MappingToken map, string key, out TemplateToken value)
|
||||
{
|
||||
foreach (var pair in map)
|
||||
{
|
||||
if (pair.Key is StringToken s && string.Equals(s.Value, key, StringComparison.Ordinal))
|
||||
{
|
||||
value = pair.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
value = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static MappingToken FilterMapping(MappingToken source, HashSet<string> excludeKeys)
|
||||
{
|
||||
var copy = new MappingToken(source.FileId, source.Line, source.Column);
|
||||
foreach (var pair in source)
|
||||
{
|
||||
if (pair.Key is StringToken sk && excludeKeys.Contains(sk.Value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
copy.Add(pair);
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
internal static string FormatActionReference(ActionStepDefinitionReference reference)
|
||||
{
|
||||
switch (reference)
|
||||
{
|
||||
case RepositoryPathReference repo:
|
||||
var path = string.IsNullOrEmpty(repo.Path) ? string.Empty : $"/{repo.Path}";
|
||||
return string.IsNullOrEmpty(repo.Ref)
|
||||
? $"{repo.Name}{path}"
|
||||
: $"{repo.Name}{path}@{repo.Ref}";
|
||||
case ContainerRegistryReference container:
|
||||
return container.Image;
|
||||
default:
|
||||
return reference.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using GitHub.DistributedTask.ObjectTemplating;
|
||||
using GitHub.DistributedTask.ObjectTemplating.Tokens;
|
||||
using GitHub.Runner.Sdk;
|
||||
using YamlDotNet.Core;
|
||||
using YamlDotNet.Core.Events;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap
|
||||
{
|
||||
/// <summary>
|
||||
/// Adapts a YamlDotNet <see cref="IEmitter"/> as a DT
|
||||
/// <see cref="IObjectWriter"/> so a <see cref="TemplateToken"/> DOM
|
||||
/// can be serialized back to YAML preserving its pre-evaluation form
|
||||
/// (basic <c>${{ }}</c> expressions are written through verbatim).
|
||||
///
|
||||
/// Used by the DAP execution view to surface user-authored step
|
||||
/// parameters (<c>env:</c>, <c>with:</c>, <c>run:</c>, ...) without
|
||||
/// any expression substitution.
|
||||
/// </summary>
|
||||
internal sealed class TemplateTokenYamlAdapter : IObjectWriter
|
||||
{
|
||||
private readonly IEmitter _emitter;
|
||||
|
||||
public TemplateTokenYamlAdapter(IEmitter emitter)
|
||||
{
|
||||
ArgUtil.NotNull(emitter, nameof(emitter));
|
||||
_emitter = emitter;
|
||||
}
|
||||
|
||||
public void WriteStart()
|
||||
{
|
||||
_emitter.Emit(new StreamStart());
|
||||
_emitter.Emit(new DocumentStart(null, null, true));
|
||||
}
|
||||
|
||||
public void WriteEnd()
|
||||
{
|
||||
_emitter.Emit(new DocumentEnd(true));
|
||||
_emitter.Emit(new StreamEnd());
|
||||
}
|
||||
|
||||
public void WriteNull() =>
|
||||
_emitter.Emit(new Scalar(null, null, "null", ScalarStyle.Plain, true, false));
|
||||
|
||||
public void WriteBoolean(bool value) =>
|
||||
_emitter.Emit(new Scalar(null, null, value ? "true" : "false", ScalarStyle.Plain, true, false));
|
||||
|
||||
public void WriteNumber(double value) =>
|
||||
_emitter.Emit(new Scalar(null, null, value.ToString("R", CultureInfo.InvariantCulture), ScalarStyle.Plain, true, false));
|
||||
|
||||
public void WriteString(string value)
|
||||
{
|
||||
if (value == null)
|
||||
{
|
||||
WriteNull();
|
||||
return;
|
||||
}
|
||||
// Multi-line strings render as block literal so embedded
|
||||
// newlines survive the YAML round trip.
|
||||
var style = value.IndexOf('\n') >= 0 ? ScalarStyle.Literal : ScalarStyle.Any;
|
||||
_emitter.Emit(new Scalar(null, null, value, style, true, true));
|
||||
}
|
||||
|
||||
public void WriteSequenceStart() =>
|
||||
_emitter.Emit(new SequenceStart(null, null, true, SequenceStyle.Any));
|
||||
|
||||
public void WriteSequenceEnd() =>
|
||||
_emitter.Emit(new SequenceEnd());
|
||||
|
||||
public void WriteMappingStart() =>
|
||||
_emitter.Emit(new MappingStart(null, null, true, MappingStyle.Any));
|
||||
|
||||
public void WriteMappingEnd() =>
|
||||
_emitter.Emit(new MappingEnd());
|
||||
|
||||
/// <summary>
|
||||
/// Serialize a TemplateToken to a YAML fragment ready to embed
|
||||
/// under a parent key. Each non-empty line is prefixed by
|
||||
/// <paramref name="indentSpaces"/> spaces. Trailing newlines and
|
||||
/// the YAML stream start/document markers are stripped, so the
|
||||
/// caller controls line breaks.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Empty mappings render as <c>{}</c> and empty sequences as
|
||||
/// <c>[]</c> via YamlDotNet's flow style fallback for empty
|
||||
/// collections.
|
||||
/// </remarks>
|
||||
internal static string Serialize(TemplateToken token, int indentSpaces)
|
||||
{
|
||||
if (indentSpaces < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(indentSpaces));
|
||||
}
|
||||
|
||||
using var sw = new StringWriter(CultureInfo.InvariantCulture);
|
||||
// Force LF line breaks; YamlDotNet's Emitter calls WriteLine,
|
||||
// which would otherwise produce CRLF on Windows and corrupt
|
||||
// both the document-end stripping below and the per-line
|
||||
// indentation pass that follows.
|
||||
sw.NewLine = "\n";
|
||||
var emitter = new Emitter(sw);
|
||||
var adapter = new TemplateTokenYamlAdapter(emitter);
|
||||
adapter.WriteStart();
|
||||
WriteToken(adapter, token);
|
||||
adapter.WriteEnd();
|
||||
|
||||
string raw = sw.ToString();
|
||||
// Strip YAML document markers. The Emitter most commonly elides
|
||||
// these for our use (DocumentStart isImplicit=true), but emits
|
||||
// them for some scalar edge cases (e.g. empty strings) and may
|
||||
// emit them on their own line for collection roots under some
|
||||
// settings. Strip both shapes defensively so callers never see
|
||||
// a leaked marker leak into the embedded fragment.
|
||||
if (raw.StartsWith("--- ", StringComparison.Ordinal))
|
||||
{
|
||||
raw = raw.Substring(4);
|
||||
}
|
||||
else if (raw.StartsWith("---\n", StringComparison.Ordinal))
|
||||
{
|
||||
raw = raw.Substring(4);
|
||||
}
|
||||
const string DocEndMarker = "\n...";
|
||||
if (raw.EndsWith(DocEndMarker + "\n", StringComparison.Ordinal))
|
||||
{
|
||||
raw = raw.Substring(0, raw.Length - DocEndMarker.Length - 1);
|
||||
}
|
||||
else if (raw.EndsWith(DocEndMarker, StringComparison.Ordinal))
|
||||
{
|
||||
raw = raw.Substring(0, raw.Length - DocEndMarker.Length);
|
||||
}
|
||||
raw = raw.TrimEnd('\n');
|
||||
|
||||
if (indentSpaces == 0)
|
||||
{
|
||||
return raw;
|
||||
}
|
||||
|
||||
// Re-indent every non-empty line. Empty lines remain empty
|
||||
// so YAML block-literal blank lines stay valid.
|
||||
var pad = new string(' ', indentSpaces);
|
||||
var sb = new System.Text.StringBuilder(raw.Length + indentSpaces * 4);
|
||||
int i = 0;
|
||||
while (i < raw.Length)
|
||||
{
|
||||
int end = raw.IndexOf('\n', i);
|
||||
int lineEnd = end < 0 ? raw.Length : end;
|
||||
if (lineEnd > i)
|
||||
{
|
||||
sb.Append(pad);
|
||||
sb.Append(raw, i, lineEnd - i);
|
||||
}
|
||||
if (end < 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
sb.Append('\n');
|
||||
i = end + 1;
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors <see cref="TemplateWriter"/>'s recursive walk, with one
|
||||
/// behavioural change: <see cref="BasicExpressionToken"/> is emitted
|
||||
/// via <c>ToDisplayString()</c> instead of <c>ToString()</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The workflow parser tokenizes a mixed scalar like
|
||||
/// <c>${{ runner.os }}-primes</c> as a single
|
||||
/// <see cref="BasicExpressionToken"/> whose internal expression is
|
||||
/// <c>format('{0}-primes', runner.os)</c>. <c>ToString()</c> emits
|
||||
/// the normalized form verbatim; <c>ToDisplayString()</c> reverses
|
||||
/// the <c>format(...)</c> rewrite so the user sees the original
|
||||
/// authored form. Other token kinds delegate to the same writer
|
||||
/// calls <see cref="TemplateWriter"/> would make.
|
||||
/// </remarks>
|
||||
private static void WriteToken(IObjectWriter writer, TemplateToken token)
|
||||
{
|
||||
switch (token?.Type ?? TokenType.Null)
|
||||
{
|
||||
case TokenType.Null:
|
||||
writer.WriteNull();
|
||||
break;
|
||||
case TokenType.Boolean:
|
||||
writer.WriteBoolean(((BooleanToken)token).Value);
|
||||
break;
|
||||
case TokenType.Number:
|
||||
writer.WriteNumber(((NumberToken)token).Value);
|
||||
break;
|
||||
case TokenType.String:
|
||||
writer.WriteString(token.ToString());
|
||||
break;
|
||||
case TokenType.BasicExpression:
|
||||
writer.WriteString(((BasicExpressionToken)token).ToDisplayString());
|
||||
break;
|
||||
case TokenType.InsertExpression:
|
||||
writer.WriteString(token.ToString());
|
||||
break;
|
||||
case TokenType.Mapping:
|
||||
writer.WriteMappingStart();
|
||||
foreach (var pair in (MappingToken)token)
|
||||
{
|
||||
WriteToken(writer, pair.Key);
|
||||
WriteToken(writer, pair.Value);
|
||||
}
|
||||
writer.WriteMappingEnd();
|
||||
break;
|
||||
case TokenType.Sequence:
|
||||
writer.WriteSequenceStart();
|
||||
foreach (var item in (SequenceToken)token)
|
||||
{
|
||||
WriteToken(writer, item);
|
||||
}
|
||||
writer.WriteSequenceEnd();
|
||||
break;
|
||||
default:
|
||||
throw new NotSupportedException($"Unexpected token type '{token.GetType()}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,839 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -338,14 +338,6 @@ namespace GitHub.Runner.Worker
|
||||
|
||||
step.ExecutionContext = Root.CreatePostChild(step.DisplayName, IntraActionState, siblingScopeName);
|
||||
Root.PostJobSteps.Push(step);
|
||||
// Only consult the DAP debugger when it was actually enabled for this job.
|
||||
// Without this guard, HostContext.GetService<IDapDebugger>() would auto-
|
||||
// instantiate the default singleton for every non-debug job, violating the
|
||||
// "no debugger, no risk" containment property.
|
||||
if (Global.Debugger?.Enabled == true)
|
||||
{
|
||||
HostContext.GetService<Dap.IDapDebugger>().OnPostStepRegistered(step);
|
||||
}
|
||||
}
|
||||
|
||||
public IExecutionContext CreateChild(
|
||||
@@ -883,9 +875,6 @@ 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);
|
||||
|
||||
@@ -903,12 +892,15 @@ namespace GitHub.Runner.Worker
|
||||
|
||||
Trace.Info("Initializing Job context");
|
||||
var jobContext = new JobContext();
|
||||
ExpressionValues.TryGetValue("job", out var jobDictionary);
|
||||
if (jobDictionary != null)
|
||||
if (Global.Variables.GetBoolean(Constants.Runner.Features.AddCheckRunIdToJobContext) ?? false)
|
||||
{
|
||||
foreach (var pair in jobDictionary.AssertDictionary("job"))
|
||||
ExpressionValues.TryGetValue("job", out var jobDictionary);
|
||||
if (jobDictionary != null)
|
||||
{
|
||||
jobContext[pair.Key] = pair.Value;
|
||||
foreach (var pair in jobDictionary.AssertDictionary("job"))
|
||||
{
|
||||
jobContext[pair.Key] = pair.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
ExpressionValues["job"] = jobContext;
|
||||
@@ -977,10 +969,6 @@ namespace GitHub.Runner.Worker
|
||||
// Verbosity (from GitHub.Step_Debug).
|
||||
Global.WriteDebug = Global.Variables.Step_Debug ?? false;
|
||||
|
||||
// Debugger enabled flag (from acquire response).
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ using GitHub.Actions.RunService.WebApi;
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using GitHub.Runner.Worker.Container;
|
||||
using GitHub.Runner.Worker.Dap;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Sdk.RSWebApi.Contracts;
|
||||
|
||||
@@ -28,7 +27,6 @@ namespace GitHub.Runner.Worker
|
||||
public StepsContext StepsContext { get; set; }
|
||||
public Variables Variables { get; set; }
|
||||
public bool WriteDebug { get; set; }
|
||||
public DebuggerConfig Debugger { get; set; }
|
||||
public string InfrastructureFailureCategory { get; set; }
|
||||
public JObject ContainerHookState { get; set; }
|
||||
public bool HasTemplateEvaluatorMismatch { get; set; }
|
||||
@@ -38,6 +36,5 @@ 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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ using GitHub.Runner.Common.Util;
|
||||
using GitHub.Runner.Sdk;
|
||||
using GitHub.Runner.Worker.Container;
|
||||
using GitHub.Runner.Worker.Container.ContainerHooks;
|
||||
using GitHub.Services.Common;
|
||||
|
||||
namespace GitHub.Runner.Worker.Handlers
|
||||
{
|
||||
@@ -129,15 +128,6 @@ namespace GitHub.Runner.Worker.Handlers
|
||||
// file name character on Linux.
|
||||
string arguments = StepHost.ResolvePathForStepHost(ExecutionContext, StringUtil.Format(@"""{0}""", target.Replace(@"""", @"\""")));
|
||||
|
||||
// Disable maglev jit compiler in node.js 24.x.x on x64 Windows until the node.js bug is fixed.
|
||||
// https://github.com/nodejs/node/issues/62260
|
||||
if (nodeRuntimeVersion.StartsWith("node24", StringComparison.OrdinalIgnoreCase) &&
|
||||
(StringUtil.ConvertToBoolean(System.Environment.GetEnvironmentVariable("ACTIONS_RUNNER_DISABLE_NODE_MAGLEV")) || StringUtil.ConvertToBoolean(Environment.GetValueOrDefault("ACTIONS_RUNNER_DISABLE_NODE_MAGLEV"))))
|
||||
{
|
||||
Trace.Info("Disable maglev jit compiler in node.js");
|
||||
arguments = $"--no-maglev {arguments}";
|
||||
}
|
||||
|
||||
#if OS_WINDOWS
|
||||
// It appears that node.exe outputs UTF8 when not in TTY mode.
|
||||
Encoding outputEncoding = Encoding.UTF8;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("Test")]
|
||||
[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")]
|
||||
|
||||
@@ -82,69 +82,5 @@ 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,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 Newtonsoft.Json;
|
||||
using Pipelines = GitHub.DistributedTask.Pipelines;
|
||||
@@ -51,7 +50,6 @@ 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.
|
||||
@@ -69,7 +67,6 @@ namespace GitHub.Runner.Worker
|
||||
|
||||
List<IStep> preJobSteps = new();
|
||||
List<IStep> jobSteps = new();
|
||||
var initSucceeded = false;
|
||||
using (var register = jobContext.CancellationToken.Register(() => { context.CancelToken(); }))
|
||||
{
|
||||
try
|
||||
@@ -80,25 +77,20 @@ namespace GitHub.Runner.Worker
|
||||
|
||||
var setting = HostContext.GetService<IConfigurationStore>().GetSettings();
|
||||
var credFile = HostContext.GetConfigFile(WellKnownConfigFile.Credentials);
|
||||
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 _))
|
||||
if (File.Exists(credFile))
|
||||
{
|
||||
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))
|
||||
var credData = IOUtil.LoadObject<CredentialData>(credFile);
|
||||
if (credData != null &&
|
||||
credData.Data.TryGetValue("clientId", out var clientId))
|
||||
{
|
||||
context.Output($"Runner group name: '{runnerGroupName.Value}'");
|
||||
// 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}'");
|
||||
}
|
||||
// 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);
|
||||
@@ -484,41 +476,6 @@ 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)
|
||||
@@ -539,36 +496,12 @@ 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 = "";
|
||||
@@ -844,34 +777,6 @@ 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();
|
||||
}
|
||||
|
||||
@@ -178,7 +178,6 @@ namespace GitHub.Runner.Worker
|
||||
_tempDirectoryManager = HostContext.GetService<ITempDirectoryManager>();
|
||||
_tempDirectoryManager.InitializeTempDirectory(jobContext);
|
||||
|
||||
|
||||
// Get the job extension.
|
||||
Trace.Info("Getting job extension.");
|
||||
IJobExtension jobExtension = HostContext.CreateService<IJobExtension>();
|
||||
@@ -230,24 +229,6 @@ namespace GitHub.Runner.Worker
|
||||
jobContext.JobSteps.Enqueue(step);
|
||||
}
|
||||
|
||||
if (jobContext.Global.Debugger?.Enabled == true)
|
||||
{
|
||||
// Only consult the DAP debugger when it was actually enabled for this job.
|
||||
// Without this guard, HostContext.GetService<IDapDebugger>() would auto-
|
||||
// instantiate the default singleton for every non-debug job, violating the
|
||||
// "no debugger, no risk" containment property.
|
||||
var dapDebugger = HostContext.GetService<Dap.IDapDebugger>();
|
||||
try
|
||||
{
|
||||
await dapDebugger.OnJobStepsInitializedAsync(jobContext.JobSteps, jobContext.PostJobSteps);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Warning("DAP OnJobStepsInitialized error; continuing without DAP view.");
|
||||
Trace.Error(ex);
|
||||
}
|
||||
}
|
||||
|
||||
await stepsRunner.RunAsync(jobContext);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -19,11 +19,10 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<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="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="YamlDotNet.Signed" Version="5.3.0" />
|
||||
<PackageReference Include="Microsoft.DevTunnels.Connections" Version="1.3.39" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -10,7 +10,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.Runner.Worker.Expressions;
|
||||
|
||||
namespace GitHub.Runner.Worker
|
||||
@@ -51,7 +50,6 @@ namespace GitHub.Runner.Worker
|
||||
jobContext.JobContext.Status = (jobContext.Result ?? TaskResult.Succeeded).ToActionResult();
|
||||
var scopeInputs = new Dictionary<string, PipelineContextData>(StringComparer.OrdinalIgnoreCase);
|
||||
bool checkPostJobActions = false;
|
||||
var dapDebugger = HostContext.GetService<IDapDebugger>();
|
||||
while (jobContext.JobSteps.Count > 0 || !checkPostJobActions)
|
||||
{
|
||||
if (jobContext.JobSteps.Count == 0 && !checkPostJobActions)
|
||||
@@ -228,14 +226,9 @@ namespace GitHub.Runner.Worker
|
||||
}
|
||||
else
|
||||
{
|
||||
// Pause for DAP debugger before step execution
|
||||
await dapDebugger?.OnStepStartingAsync(step);
|
||||
|
||||
// Run the step
|
||||
await RunStepAsync(step, jobContext.CancellationToken);
|
||||
CompleteStep(step);
|
||||
|
||||
dapDebugger?.OnStepCompleted(step);
|
||||
}
|
||||
}
|
||||
finally
|
||||
@@ -262,7 +255,6 @@ namespace GitHub.Runner.Worker
|
||||
|
||||
Trace.Info($"Current state: job state = '{jobContext.Result}'");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private async Task RunStepAsync(IStep step, CancellationToken jobCancellationToken)
|
||||
|
||||
@@ -17,9 +17,10 @@ namespace GitHub.DistributedTask.Expressions2
|
||||
String expression,
|
||||
ITraceWriter trace,
|
||||
IEnumerable<INamedValueInfo> namedValues,
|
||||
IEnumerable<IFunctionInfo> functions)
|
||||
IEnumerable<IFunctionInfo> functions,
|
||||
Boolean allowCaseFunction = true)
|
||||
{
|
||||
var context = new ParseContext(expression, trace, namedValues, functions);
|
||||
var context = new ParseContext(expression, trace, namedValues, functions, allowCaseFunction: allowCaseFunction);
|
||||
context.Trace.Info($"Parsing expression: <{expression}>");
|
||||
return CreateTree(context);
|
||||
}
|
||||
@@ -415,6 +416,12 @@ namespace GitHub.DistributedTask.Expressions2
|
||||
String name,
|
||||
out IFunctionInfo functionInfo)
|
||||
{
|
||||
if (String.Equals(name, "case", StringComparison.OrdinalIgnoreCase) && !context.AllowCaseFunction)
|
||||
{
|
||||
functionInfo = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
return ExpressionConstants.WellKnownFunctions.TryGetValue(name, out functionInfo) ||
|
||||
context.ExtensionFunctions.TryGetValue(name, out functionInfo);
|
||||
}
|
||||
@@ -422,6 +429,7 @@ namespace GitHub.DistributedTask.Expressions2
|
||||
private sealed class ParseContext
|
||||
{
|
||||
public Boolean AllowUnknownKeywords;
|
||||
public Boolean AllowCaseFunction;
|
||||
public readonly String Expression;
|
||||
public readonly Dictionary<String, IFunctionInfo> ExtensionFunctions = new Dictionary<String, IFunctionInfo>(StringComparer.OrdinalIgnoreCase);
|
||||
public readonly Dictionary<String, INamedValueInfo> ExtensionNamedValues = new Dictionary<String, INamedValueInfo>(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -437,7 +445,8 @@ namespace GitHub.DistributedTask.Expressions2
|
||||
ITraceWriter trace,
|
||||
IEnumerable<INamedValueInfo> namedValues,
|
||||
IEnumerable<IFunctionInfo> functions,
|
||||
Boolean allowUnknownKeywords = false)
|
||||
Boolean allowUnknownKeywords = false,
|
||||
Boolean allowCaseFunction = true)
|
||||
{
|
||||
Expression = expression ?? String.Empty;
|
||||
if (Expression.Length > ExpressionConstants.MaxLength)
|
||||
@@ -458,6 +467,7 @@ namespace GitHub.DistributedTask.Expressions2
|
||||
|
||||
LexicalAnalyzer = new LexicalAnalyzer(Expression);
|
||||
AllowUnknownKeywords = allowUnknownKeywords;
|
||||
AllowCaseFunction = allowCaseFunction;
|
||||
}
|
||||
|
||||
private class NoOperationTraceWriter : ITraceWriter
|
||||
|
||||
@@ -86,6 +86,12 @@ namespace GitHub.DistributedTask.ObjectTemplating
|
||||
|
||||
internal ITraceWriter TraceWriter { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the case expression function is allowed.
|
||||
/// Defaults to true. Set to false to disable the case function.
|
||||
/// </summary>
|
||||
internal Boolean AllowCaseFunction { get; set; } = true;
|
||||
|
||||
private IDictionary<String, Int32> FileIds
|
||||
{
|
||||
get
|
||||
|
||||
@@ -57,7 +57,7 @@ namespace GitHub.DistributedTask.ObjectTemplating.Tokens
|
||||
var originalBytes = context.Memory.CurrentBytes;
|
||||
try
|
||||
{
|
||||
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions);
|
||||
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction);
|
||||
var options = new EvaluationOptions
|
||||
{
|
||||
MaxMemory = context.Memory.MaxBytes,
|
||||
@@ -94,7 +94,7 @@ namespace GitHub.DistributedTask.ObjectTemplating.Tokens
|
||||
var originalBytes = context.Memory.CurrentBytes;
|
||||
try
|
||||
{
|
||||
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions);
|
||||
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction);
|
||||
var options = new EvaluationOptions
|
||||
{
|
||||
MaxMemory = context.Memory.MaxBytes,
|
||||
@@ -123,7 +123,7 @@ namespace GitHub.DistributedTask.ObjectTemplating.Tokens
|
||||
var originalBytes = context.Memory.CurrentBytes;
|
||||
try
|
||||
{
|
||||
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions);
|
||||
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction);
|
||||
var options = new EvaluationOptions
|
||||
{
|
||||
MaxMemory = context.Memory.MaxBytes,
|
||||
@@ -152,7 +152,7 @@ namespace GitHub.DistributedTask.ObjectTemplating.Tokens
|
||||
var originalBytes = context.Memory.CurrentBytes;
|
||||
try
|
||||
{
|
||||
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions);
|
||||
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction);
|
||||
var options = new EvaluationOptions
|
||||
{
|
||||
MaxMemory = context.Memory.MaxBytes,
|
||||
|
||||
@@ -253,50 +253,6 @@ namespace GitHub.DistributedTask.Pipelines
|
||||
set;
|
||||
}
|
||||
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public bool EnableDebugger
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public DebuggerTunnelInfo DebuggerTunnel
|
||||
{
|
||||
get;
|
||||
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>
|
||||
@@ -471,11 +427,6 @@ 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))
|
||||
{
|
||||
@@ -501,9 +452,6 @@ 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;
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
using System.Runtime.Serialization;
|
||||
|
||||
namespace GitHub.DistributedTask.Pipelines
|
||||
{
|
||||
/// <summary>
|
||||
/// Dev Tunnel information the runner needs to host the debugger tunnel.
|
||||
/// Matches the run-service <c>DebuggerTunnel</c> contract.
|
||||
/// </summary>
|
||||
[DataContract]
|
||||
public sealed class DebuggerTunnelInfo
|
||||
{
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public string TunnelId { get; set; }
|
||||
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public string ClusterId { get; set; }
|
||||
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public string HostToken { get; set; }
|
||||
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public ushort Port { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ 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>
|
||||
@@ -25,8 +24,7 @@ namespace GitHub.DistributedTask.Pipelines.Expressions
|
||||
case IPv4Address:
|
||||
return s_validIPv4Address;
|
||||
case SHA1:
|
||||
case CommitHash:
|
||||
return s_validCommitHash;
|
||||
return s_validSha1;
|
||||
case Url:
|
||||
return s_validUrl;
|
||||
default:
|
||||
@@ -48,9 +46,9 @@ namespace GitHub.DistributedTask.Pipelines.Expressions
|
||||
)
|
||||
);
|
||||
|
||||
// 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",
|
||||
// 40 hex characters
|
||||
private static readonly Lazy<Regex> s_validSha1 = new Lazy<Regex>(() => new Regex(
|
||||
@"\b[0-9a-f]{40}\b",
|
||||
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled, RegexUtility.GetRegexTimeOut()
|
||||
)
|
||||
);
|
||||
|
||||
@@ -681,7 +681,7 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
|
||||
var node = default(ExpressionNode);
|
||||
try
|
||||
{
|
||||
node = expressionParser.CreateTree(condition, null, namedValues, functions) as ExpressionNode;
|
||||
node = expressionParser.CreateTree(condition, null, namedValues, functions, allowCaseFunction: context.AllowCaseFunction) as ExpressionNode;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -186,16 +186,7 @@
|
||||
"vars",
|
||||
"needs",
|
||||
"strategy",
|
||||
"matrix",
|
||||
"steps",
|
||||
"job",
|
||||
"runner",
|
||||
"env",
|
||||
"always(0,0)",
|
||||
"failure(0,0)",
|
||||
"cancelled(0,0)",
|
||||
"success(0,0)",
|
||||
"hashFiles(1,255)"
|
||||
"matrix"
|
||||
],
|
||||
"string": {}
|
||||
},
|
||||
|
||||
@@ -12,12 +12,5 @@ namespace GitHub.DistributedTask.WebApi
|
||||
get;
|
||||
set;
|
||||
}
|
||||
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public IList<string> Dependencies
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
@@ -17,9 +17,10 @@ namespace GitHub.Actions.Expressions
|
||||
String expression,
|
||||
ITraceWriter trace,
|
||||
IEnumerable<INamedValueInfo> namedValues,
|
||||
IEnumerable<IFunctionInfo> functions)
|
||||
IEnumerable<IFunctionInfo> functions,
|
||||
Boolean allowCaseFunction = true)
|
||||
{
|
||||
var context = new ParseContext(expression, trace, namedValues, functions);
|
||||
var context = new ParseContext(expression, trace, namedValues, functions, allowCaseFunction: allowCaseFunction);
|
||||
context.Trace.Info($"Parsing expression: <{expression}>");
|
||||
return CreateTree(context);
|
||||
}
|
||||
@@ -321,7 +322,7 @@ namespace GitHub.Actions.Expressions
|
||||
context.Operators.Pop();
|
||||
}
|
||||
var functionOperands = PopOperands(context, parameterCount);
|
||||
|
||||
|
||||
// Node already exists on the operand stack
|
||||
function = (Function)context.Operands.Peek();
|
||||
|
||||
@@ -415,6 +416,12 @@ namespace GitHub.Actions.Expressions
|
||||
String name,
|
||||
out IFunctionInfo functionInfo)
|
||||
{
|
||||
if (String.Equals(name, "case", StringComparison.OrdinalIgnoreCase) && !context.AllowCaseFunction)
|
||||
{
|
||||
functionInfo = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
return ExpressionConstants.WellKnownFunctions.TryGetValue(name, out functionInfo) ||
|
||||
context.ExtensionFunctions.TryGetValue(name, out functionInfo);
|
||||
}
|
||||
@@ -422,6 +429,7 @@ namespace GitHub.Actions.Expressions
|
||||
private sealed class ParseContext
|
||||
{
|
||||
public Boolean AllowUnknownKeywords;
|
||||
public Boolean AllowCaseFunction;
|
||||
public readonly String Expression;
|
||||
public readonly Dictionary<String, IFunctionInfo> ExtensionFunctions = new Dictionary<String, IFunctionInfo>(StringComparer.OrdinalIgnoreCase);
|
||||
public readonly Dictionary<String, INamedValueInfo> ExtensionNamedValues = new Dictionary<String, INamedValueInfo>(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -437,7 +445,8 @@ namespace GitHub.Actions.Expressions
|
||||
ITraceWriter trace,
|
||||
IEnumerable<INamedValueInfo> namedValues,
|
||||
IEnumerable<IFunctionInfo> functions,
|
||||
Boolean allowUnknownKeywords = false)
|
||||
Boolean allowUnknownKeywords = false,
|
||||
Boolean allowCaseFunction = true)
|
||||
{
|
||||
Expression = expression ?? String.Empty;
|
||||
if (Expression.Length > ExpressionConstants.MaxLength)
|
||||
@@ -458,6 +467,7 @@ namespace GitHub.Actions.Expressions
|
||||
|
||||
LexicalAnalyzer = new LexicalAnalyzer(Expression);
|
||||
AllowUnknownKeywords = allowUnknownKeywords;
|
||||
AllowCaseFunction = allowCaseFunction;
|
||||
}
|
||||
|
||||
private class NoOperationTraceWriter : ITraceWriter
|
||||
|
||||
@@ -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.6" />
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.3" />
|
||||
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="10.0.2" />
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
|
||||
<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.6" />
|
||||
<PackageReference Include="System.Formats.Asn1" Version="10.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -22,9 +22,6 @@ 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]
|
||||
|
||||
@@ -97,8 +97,7 @@ namespace GitHub.Services.Launch.Client
|
||||
{
|
||||
return new ActionReferenceRequestList
|
||||
{
|
||||
Actions = actionReferenceList.Actions?.Select(ToGitHubData).ToList(),
|
||||
ActionsDependencies = actionReferenceList.Dependencies
|
||||
Actions = actionReferenceList.Actions?.Select(ToGitHubData).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
||||
return;
|
||||
}
|
||||
|
||||
var effectiveMax = explicitMax ?? CreatePermissionsFromPolicy(context, permissionsPolicy, includeIdToken: isTrusted, includeModels: context.GetFeatures().AllowModelsPermission, includeVulnerabilityAlerts: context.GetFeatures().AllowVulnerabilityAlertsPermission);
|
||||
var effectiveMax = explicitMax ?? CreatePermissionsFromPolicy(context, permissionsPolicy, includeIdToken: isTrusted, includeModels: context.GetFeatures().AllowModelsPermission);
|
||||
|
||||
if (requested.ViolatesMaxPermissions(effectiveMax, out var permissionLevelViolations))
|
||||
{
|
||||
@@ -59,19 +59,18 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
||||
TemplateContext context,
|
||||
string permissionsPolicy,
|
||||
bool includeIdToken,
|
||||
bool includeModels,
|
||||
bool includeVulnerabilityAlerts)
|
||||
bool includeModels)
|
||||
{
|
||||
switch (permissionsPolicy)
|
||||
{
|
||||
case WorkflowConstants.PermissionsPolicy.LimitedRead:
|
||||
return new Permissions(PermissionLevel.NoAccess, includeIdToken: false, includeAttestations: false, includeModels: false, includeVulnerabilityAlerts: false)
|
||||
return new Permissions(PermissionLevel.NoAccess, includeIdToken: false, includeAttestations: false, includeModels: false)
|
||||
{
|
||||
Contents = PermissionLevel.Read,
|
||||
Packages = PermissionLevel.Read,
|
||||
};
|
||||
case WorkflowConstants.PermissionsPolicy.Write:
|
||||
return new Permissions(PermissionLevel.Write, includeIdToken: includeIdToken, includeAttestations: true, includeModels: includeModels, includeVulnerabilityAlerts: includeVulnerabilityAlerts);
|
||||
return new Permissions(PermissionLevel.Write, includeIdToken: includeIdToken, includeAttestations: true, includeModels: includeModels);
|
||||
default:
|
||||
throw new ArgumentException($"Unexpected permission policy: '{permissionsPolicy}'");
|
||||
}
|
||||
|
||||
@@ -1828,7 +1828,7 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
||||
var node = default(ExpressionNode);
|
||||
try
|
||||
{
|
||||
node = expressionParser.CreateTree(condition, null, namedValues, functions) as ExpressionNode;
|
||||
node = expressionParser.CreateTree(condition, null, namedValues, functions, allowCaseFunction: context.AllowCaseFunction) as ExpressionNode;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -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, includeVulnerabilityAlerts: context.GetFeatures().AllowVulnerabilityAlertsPermission);
|
||||
return new Permissions(permissionLevel, includeIdToken: true, includeAttestations: true, includeModels: context.GetFeatures().AllowModelsPermission);
|
||||
}
|
||||
|
||||
var mapping = token.AssertMapping("permissions");
|
||||
@@ -1957,23 +1957,6 @@ 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;
|
||||
}
|
||||
@@ -2291,10 +2274,6 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
||||
new NamedValueInfo<NoOperationNamedValue>(WorkflowTemplateConstants.Needs),
|
||||
new NamedValueInfo<NoOperationNamedValue>(WorkflowTemplateConstants.Strategy),
|
||||
new NamedValueInfo<NoOperationNamedValue>(WorkflowTemplateConstants.Matrix),
|
||||
new NamedValueInfo<NoOperationNamedValue>(WorkflowTemplateConstants.Steps),
|
||||
new NamedValueInfo<NoOperationNamedValue>(WorkflowTemplateConstants.Job),
|
||||
new NamedValueInfo<NoOperationNamedValue>(WorkflowTemplateConstants.Runner),
|
||||
new NamedValueInfo<NoOperationNamedValue>(WorkflowTemplateConstants.Env),
|
||||
};
|
||||
private static readonly IFunctionInfo[] s_jobConditionFunctions = new IFunctionInfo[]
|
||||
{
|
||||
@@ -2311,13 +2290,6 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
||||
new FunctionInfo<NoOperation>(WorkflowTemplateConstants.Success, 0, 0),
|
||||
new FunctionInfo<NoOperation>(WorkflowTemplateConstants.HashFiles, 1, Byte.MaxValue),
|
||||
};
|
||||
private static readonly IFunctionInfo[] s_snapshotConditionFunctions = new IFunctionInfo[]
|
||||
{
|
||||
new FunctionInfo<NoOperation>(WorkflowTemplateConstants.Always, 0, 0),
|
||||
new FunctionInfo<NoOperation>(WorkflowTemplateConstants.Cancelled, 0, 0),
|
||||
new FunctionInfo<NoOperation>(WorkflowTemplateConstants.Failure, 0, 0),
|
||||
new FunctionInfo<NoOperation>(WorkflowTemplateConstants.Success, 0, 0),
|
||||
new FunctionInfo<NoOperation>(WorkflowTemplateConstants.HashFiles, 1, Byte.MaxValue),
|
||||
};
|
||||
private static readonly IFunctionInfo[] s_snapshotConditionFunctions = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
@@ -113,6 +113,12 @@ namespace GitHub.Actions.WorkflowParser.ObjectTemplating
|
||||
/// </summary>
|
||||
internal Boolean StrictJsonParsing { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the case expression function is allowed.
|
||||
/// Defaults to true. Set to false to disable the case function.
|
||||
/// </summary>
|
||||
internal Boolean AllowCaseFunction { get; set; } = true;
|
||||
|
||||
internal ITraceWriter TraceWriter { get; set; }
|
||||
|
||||
private IDictionary<String, Int32> FileIds
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
@@ -55,7 +55,7 @@ namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens
|
||||
var originalBytes = context.Memory.CurrentBytes;
|
||||
try
|
||||
{
|
||||
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions);
|
||||
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction);
|
||||
var options = new EvaluationOptions
|
||||
{
|
||||
MaxMemory = context.Memory.MaxBytes,
|
||||
@@ -93,7 +93,7 @@ namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens
|
||||
var originalBytes = context.Memory.CurrentBytes;
|
||||
try
|
||||
{
|
||||
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions);
|
||||
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction);
|
||||
var options = new EvaluationOptions
|
||||
{
|
||||
MaxMemory = context.Memory.MaxBytes,
|
||||
@@ -123,7 +123,7 @@ namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens
|
||||
var originalBytes = context.Memory.CurrentBytes;
|
||||
try
|
||||
{
|
||||
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions);
|
||||
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction);
|
||||
var options = new EvaluationOptions
|
||||
{
|
||||
MaxMemory = context.Memory.MaxBytes,
|
||||
@@ -153,7 +153,7 @@ namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens
|
||||
var originalBytes = context.Memory.CurrentBytes;
|
||||
try
|
||||
{
|
||||
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions);
|
||||
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction);
|
||||
var options = new EvaluationOptions
|
||||
{
|
||||
MaxMemory = context.Memory.MaxBytes,
|
||||
@@ -289,4 +289,4 @@ namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,6 @@ namespace GitHub.Actions.WorkflowParser
|
||||
SecurityEvents = copy.SecurityEvents;
|
||||
IdToken = copy.IdToken;
|
||||
Models = copy.Models;
|
||||
VulnerabilityAlerts = copy.VulnerabilityAlerts;
|
||||
}
|
||||
|
||||
public Permissions(
|
||||
@@ -62,19 +61,6 @@ 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[]
|
||||
@@ -95,7 +81,6 @@ 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)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -169,13 +154,6 @@ namespace GitHub.Actions.WorkflowParser
|
||||
set;
|
||||
}
|
||||
|
||||
[DataMember(Name = "vulnerability-alerts", EmitDefaultValue = false)]
|
||||
public PermissionLevel VulnerabilityAlerts
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
|
||||
[DataMember(Name = "packages", EmitDefaultValue = false)]
|
||||
public PermissionLevel Packages
|
||||
{
|
||||
|
||||
@@ -41,13 +41,6 @@ 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.
|
||||
@@ -74,7 +67,6 @@ 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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,15 +1865,11 @@
|
||||
},
|
||||
"security-events": {
|
||||
"type": "permission-level-any",
|
||||
"description": "Code scanning alerts."
|
||||
"description": "Code scanning and Dependabot alerts."
|
||||
},
|
||||
"statuses": {
|
||||
"type": "permission-level-any",
|
||||
"description": "Commit statuses."
|
||||
},
|
||||
"vulnerability-alerts": {
|
||||
"type": "permission-level-read-or-no-access",
|
||||
"description": "Dependabot alerts."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2196,16 +2192,7 @@
|
||||
"vars",
|
||||
"needs",
|
||||
"strategy",
|
||||
"matrix",
|
||||
"steps",
|
||||
"job",
|
||||
"runner",
|
||||
"env",
|
||||
"always(0,0)",
|
||||
"failure(0,0)",
|
||||
"cancelled(0,0)",
|
||||
"success(0,0)",
|
||||
"hashFiles(1,255)"
|
||||
"matrix"
|
||||
],
|
||||
"description": "Use the if conditional to prevent a snapshot from being taken unless a condition is met. Any supported context and expression can be used to create a conditional. Expressions in an `if` conditional do not require the bracketed expression syntax. When you use expressions in an `if` conditional, you may omit the expression syntax because GitHub automatically evaluates the `if` conditional as an expression.",
|
||||
"string": {
|
||||
|
||||
@@ -24,10 +24,7 @@ namespace GitHub.Runner.Common.Tests
|
||||
"osx-arm64"
|
||||
};
|
||||
|
||||
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.Equal(40, BuildConstants.Source.CommitHash.Length);
|
||||
Assert.True(validPackageNames.Contains(BuildConstants.RunnerPackage.PackageName), $"PackageName should be one of the following '{string.Join(", ", validPackageNames)}', current PackageName is '{BuildConstants.RunnerPackage.PackageName}'");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ using Pipelines = GitHub.DistributedTask.Pipelines;
|
||||
|
||||
namespace GitHub.Runner.Common.Tests.Listener
|
||||
{
|
||||
public sealed class RunnerL0 : IDisposable
|
||||
public sealed class RunnerL0
|
||||
{
|
||||
private Mock<IConfigurationManager> _configurationManager;
|
||||
private Mock<IJobNotification> _jobNotification;
|
||||
@@ -29,7 +29,6 @@ namespace GitHub.Runner.Common.Tests.Listener
|
||||
private Mock<ICredentialManager> _credentialManager;
|
||||
private Mock<IActionsRunServer> _actionsRunServer;
|
||||
private Mock<IRunServer> _runServer;
|
||||
private readonly string _returnJobResultForHosted;
|
||||
|
||||
public RunnerL0()
|
||||
{
|
||||
@@ -46,14 +45,6 @@ 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)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using GitHub.DistributedTask.Expressions2;
|
||||
using GitHub.DistributedTask.Expressions2;
|
||||
using GitHub.DistributedTask.Expressions2.Sdk;
|
||||
using GitHub.DistributedTask.ObjectTemplating;
|
||||
using System;
|
||||
@@ -9,7 +9,7 @@ namespace GitHub.Runner.Common.Tests.Sdk
|
||||
{
|
||||
/// <summary>
|
||||
/// Regression tests for ExpressionParser.CreateTree to verify that
|
||||
/// the case function does not accidentally set allowUnknownKeywords.
|
||||
/// allowCaseFunction does not accidentally set allowUnknownKeywords.
|
||||
/// </summary>
|
||||
public sealed class ExpressionParserL0
|
||||
{
|
||||
@@ -18,7 +18,7 @@ namespace GitHub.Runner.Common.Tests.Sdk
|
||||
[Trait("Category", "Sdk")]
|
||||
public void CreateTree_RejectsUnrecognizedNamedValue()
|
||||
{
|
||||
// Regression: the case function parameter was passed positionally into
|
||||
// Regression: allowCaseFunction was passed positionally into
|
||||
// the allowUnknownKeywords parameter, causing all named values
|
||||
// to be silently accepted.
|
||||
var parser = new ExpressionParser();
|
||||
@@ -52,7 +52,7 @@ namespace GitHub.Runner.Common.Tests.Sdk
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Sdk")]
|
||||
public void CreateTree_CaseFunctionWorks()
|
||||
public void CreateTree_CaseFunctionWorks_WhenAllowed()
|
||||
{
|
||||
var parser = new ExpressionParser();
|
||||
var namedValues = new List<INamedValueInfo>
|
||||
@@ -60,17 +60,35 @@ namespace GitHub.Runner.Common.Tests.Sdk
|
||||
new NamedValueInfo<ContextValueNode>("github"),
|
||||
};
|
||||
|
||||
var node = parser.CreateTree("case(github.event_name, 'push', 'Push Event')", null, namedValues, null);
|
||||
var node = parser.CreateTree("case(github.event_name, 'push', 'Push Event')", null, namedValues, null, allowCaseFunction: true);
|
||||
|
||||
Assert.NotNull(node);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Sdk")]
|
||||
public void CreateTree_CaseFunctionRejected_WhenDisallowed()
|
||||
{
|
||||
var parser = new ExpressionParser();
|
||||
var namedValues = new List<INamedValueInfo>
|
||||
{
|
||||
new NamedValueInfo<ContextValueNode>("github"),
|
||||
};
|
||||
|
||||
var ex = Assert.Throws<ParseException>(() =>
|
||||
parser.CreateTree("case(github.event_name, 'push', 'Push Event')", null, namedValues, null, allowCaseFunction: false));
|
||||
|
||||
Assert.Contains("Unrecognized function", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Sdk")]
|
||||
public void CreateTree_CaseFunctionDoesNotAffectUnknownKeywords()
|
||||
{
|
||||
// The key regression test: unrecognized named values must still be rejected.
|
||||
// The key regression test: with allowCaseFunction=true (default),
|
||||
// unrecognized named values must still be rejected.
|
||||
var parser = new ExpressionParser();
|
||||
var namedValues = new List<INamedValueInfo>
|
||||
{
|
||||
@@ -78,7 +96,7 @@ namespace GitHub.Runner.Common.Tests.Sdk
|
||||
};
|
||||
|
||||
var ex = Assert.Throws<ParseException>(() =>
|
||||
parser.CreateTree("github.ref", null, namedValues, null));
|
||||
parser.CreateTree("github.ref", null, namedValues, null, allowCaseFunction: true));
|
||||
|
||||
Assert.Contains("Unrecognized named-value", ex.Message);
|
||||
}
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Runtime.Serialization.Json;
|
||||
using System.Text;
|
||||
using Xunit;
|
||||
using GitHub.DistributedTask.Pipelines;
|
||||
|
||||
namespace GitHub.Actions.RunService.WebApi.Tests;
|
||||
|
||||
public sealed class AgentJobRequestMessageL0
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Common")]
|
||||
public void VerifyEnableDebuggerDeserialization_WithTrue()
|
||||
{
|
||||
// Arrange
|
||||
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage));
|
||||
string jsonWithEnabledDebugger = DoubleQuotify("{'EnableDebugger': true}");
|
||||
|
||||
// Act
|
||||
using var stream = new MemoryStream();
|
||||
stream.Write(Encoding.UTF8.GetBytes(jsonWithEnabledDebugger));
|
||||
stream.Position = 0;
|
||||
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(recoveredMessage);
|
||||
Assert.True(recoveredMessage.EnableDebugger, "EnableDebugger should be true when JSON contains 'EnableDebugger': true");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Common")]
|
||||
public void VerifyEnableDebuggerDeserialization_DefaultToFalse()
|
||||
{
|
||||
// Arrange
|
||||
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage));
|
||||
string jsonWithoutDebugger = DoubleQuotify("{'messageType': 'PipelineAgentJobRequest'}");
|
||||
|
||||
// Act
|
||||
using var stream = new MemoryStream();
|
||||
stream.Write(Encoding.UTF8.GetBytes(jsonWithoutDebugger));
|
||||
stream.Position = 0;
|
||||
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(recoveredMessage);
|
||||
Assert.False(recoveredMessage.EnableDebugger, "EnableDebugger should default to false when JSON field is absent");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Common")]
|
||||
public void VerifyEnableDebuggerDeserialization_WithFalse()
|
||||
{
|
||||
// Arrange
|
||||
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage));
|
||||
string jsonWithDisabledDebugger = DoubleQuotify("{'EnableDebugger': false}");
|
||||
|
||||
// Act
|
||||
using var stream = new MemoryStream();
|
||||
stream.Write(Encoding.UTF8.GetBytes(jsonWithDisabledDebugger));
|
||||
stream.Position = 0;
|
||||
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(recoveredMessage);
|
||||
Assert.False(recoveredMessage.EnableDebugger, "EnableDebugger should be false when JSON contains 'EnableDebugger': false");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Common")]
|
||||
public void VerifyDebuggerTunnelDeserialization_WithTunnel()
|
||||
{
|
||||
// Arrange
|
||||
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage), new DataContractJsonSerializerSettings
|
||||
{
|
||||
KnownTypes = new[] { typeof(DebuggerTunnelInfo) }
|
||||
});
|
||||
string json = DoubleQuotify(
|
||||
"{'EnableDebugger': true, 'DebuggerTunnel': {'TunnelId': 'tun-123', 'ClusterId': 'use2', 'HostToken': 'tok-abc', 'Port': 4711}}");
|
||||
|
||||
// Act
|
||||
using var stream = new MemoryStream();
|
||||
stream.Write(Encoding.UTF8.GetBytes(json));
|
||||
stream.Position = 0;
|
||||
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(recoveredMessage);
|
||||
Assert.True(recoveredMessage.EnableDebugger);
|
||||
Assert.NotNull(recoveredMessage.DebuggerTunnel);
|
||||
Assert.Equal("tun-123", recoveredMessage.DebuggerTunnel.TunnelId);
|
||||
Assert.Equal("use2", recoveredMessage.DebuggerTunnel.ClusterId);
|
||||
Assert.Equal("tok-abc", recoveredMessage.DebuggerTunnel.HostToken);
|
||||
Assert.Equal(4711, recoveredMessage.DebuggerTunnel.Port);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Common")]
|
||||
public void VerifyDebuggerTunnelDeserialization_WithoutTunnel()
|
||||
{
|
||||
// Arrange
|
||||
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage));
|
||||
string json = DoubleQuotify("{'EnableDebugger': true}");
|
||||
|
||||
// Act
|
||||
using var stream = new MemoryStream();
|
||||
stream.Write(Encoding.UTF8.GetBytes(json));
|
||||
stream.Position = 0;
|
||||
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(recoveredMessage);
|
||||
Assert.True(recoveredMessage.EnableDebugger);
|
||||
Assert.Null(recoveredMessage.DebuggerTunnel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Common")]
|
||||
public void VerifyActionsDependenciesDeserialization_WithDependencies()
|
||||
{
|
||||
// Arrange
|
||||
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage));
|
||||
string json = DoubleQuotify("{'dependencies': ['actions/checkout@v4:sha256-abc123', 'actions/setup-node@v4:sha256-def456']}");
|
||||
|
||||
// Act
|
||||
using var stream = new MemoryStream();
|
||||
stream.Write(Encoding.UTF8.GetBytes(json));
|
||||
stream.Position = 0;
|
||||
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(recoveredMessage);
|
||||
Assert.Equal(2, recoveredMessage.ActionsDependencies.Count);
|
||||
Assert.Equal("actions/checkout@v4:sha256-abc123", recoveredMessage.ActionsDependencies[0]);
|
||||
Assert.Equal("actions/setup-node@v4:sha256-def456", recoveredMessage.ActionsDependencies[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Common")]
|
||||
public void VerifyActionsDependenciesDeserialization_DefaultsToEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage));
|
||||
string json = DoubleQuotify("{'messageType': 'PipelineAgentJobRequest'}");
|
||||
|
||||
// Act
|
||||
using var stream = new MemoryStream();
|
||||
stream.Write(Encoding.UTF8.GetBytes(json));
|
||||
stream.Position = 0;
|
||||
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(recoveredMessage);
|
||||
Assert.Empty(recoveredMessage.ActionsDependencies);
|
||||
}
|
||||
|
||||
[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('\'', '"');
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
using GitHub.Runner.Listener.Check;
|
||||
using GitHub.Runner.Listener.Configuration;
|
||||
using GitHub.Runner.Worker;
|
||||
using GitHub.Runner.Worker.Dap;
|
||||
using GitHub.Runner.Worker.Container.ContainerHooks;
|
||||
using GitHub.Runner.Worker.Handlers;
|
||||
using System;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Net;
|
||||
@@ -25,7 +24,6 @@ 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;
|
||||
@@ -335,7 +333,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", $"{Sha256}.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", "master-sha.zip"), CompressionLevel.Fastest, true);
|
||||
#else
|
||||
string tar = WhichUtil.Which("tar", require: true, trace: _hc.GetTrace());
|
||||
|
||||
@@ -361,7 +359,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", $"{Sha256}.tar.gz");
|
||||
string archiveFile = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Temp), "action_cache", "actions_download-artifact", "master-sha.tar.gz");
|
||||
int exitCode = await processInvoker.ExecuteAsync(_hc.GetDirectory(WellKnownDirectory.Bin), tar, $"-czf \"{archiveFile}\" -C \"{cwd}\" \"{inputDirectory}\"", null, CancellationToken.None);
|
||||
if (exitCode != 0)
|
||||
{
|
||||
@@ -369,8 +367,6 @@ runs:
|
||||
}
|
||||
}
|
||||
#endif
|
||||
MockResolvedSha("actions/download-artifact", "master", Sha256);
|
||||
|
||||
var actionId = Guid.NewGuid();
|
||||
var actions = new List<Pipelines.ActionStep>
|
||||
{
|
||||
@@ -519,10 +515,9 @@ runs:
|
||||
|
||||
string actionsArchive = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Temp), "actions_archive", "action_checkout");
|
||||
Directory.CreateDirectory(actionsArchive);
|
||||
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);
|
||||
Directory.CreateDirectory(Path.Combine(actionsArchive, "actions_checkout", "master-sha"));
|
||||
Directory.CreateDirectory(Path.Combine(actionsArchive, "actions_checkout", "master-sha", "content"));
|
||||
await File.WriteAllTextAsync(Path.Combine(actionsArchive, "actions_checkout", "master-sha", "content", "action.yml"), Content);
|
||||
Environment.SetEnvironmentVariable(Constants.Variables.Agent.ActionArchiveCacheDirectory, actionsArchive);
|
||||
|
||||
//Act
|
||||
@@ -1259,659 +1254,6 @@ runs:
|
||||
}
|
||||
#endif
|
||||
|
||||
// =================================================================
|
||||
// Tests for batched action resolution optimization
|
||||
// =================================================================
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async void PrepareActions_BatchesResolutionAcrossCompositeActions()
|
||||
{
|
||||
// Verifies that when multiple composite actions at the same depth
|
||||
// reference sub-actions, those sub-actions are resolved in a single
|
||||
// batched API call rather than one call per composite.
|
||||
//
|
||||
// Action tree:
|
||||
// CompositePrestep (composite) → [Node action, CompositePrestep2 (composite)]
|
||||
// CompositePrestep2 (composite) → [Node action, Docker action]
|
||||
//
|
||||
// Without batching: 3 API calls (depth 0, depth 1 for CompositePrestep, depth 2 for CompositePrestep2)
|
||||
// With batching: still 3 calls at most, but the key is that depth-1
|
||||
// sub-actions from all composites at depth 0 are batched into 1 call.
|
||||
// And the same action appearing at multiple depths triggers only 1 resolve.
|
||||
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", "true");
|
||||
try
|
||||
{
|
||||
//Arrange
|
||||
Setup();
|
||||
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
|
||||
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
|
||||
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
|
||||
|
||||
var resolveCallCount = 0;
|
||||
var resolvedActions = new List<ActionReferenceList>();
|
||||
_jobServer.Setup(x => x.ResolveActionDownloadInfoAsync(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<Guid>(), It.IsAny<Guid>(), It.IsAny<ActionReferenceList>(), It.IsAny<CancellationToken>()))
|
||||
.Returns((Guid scopeIdentifier, string hubName, Guid planId, Guid jobId, ActionReferenceList actions, CancellationToken cancellationToken) =>
|
||||
{
|
||||
resolveCallCount++;
|
||||
resolvedActions.Add(actions);
|
||||
var result = new ActionDownloadInfoCollection { Actions = new Dictionary<string, ActionDownloadInfo>() };
|
||||
foreach (var action in actions.Actions)
|
||||
{
|
||||
var key = $"{action.NameWithOwner}@{action.Ref}";
|
||||
result.Actions[key] = new ActionDownloadInfo
|
||||
{
|
||||
NameWithOwner = action.NameWithOwner,
|
||||
Ref = action.Ref,
|
||||
ResolvedNameWithOwner = action.NameWithOwner,
|
||||
ResolvedSha = $"{action.Ref}-sha",
|
||||
TarballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/tarball/{action.Ref}",
|
||||
ZipballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/zipball/{action.Ref}",
|
||||
};
|
||||
}
|
||||
return Task.FromResult(result);
|
||||
});
|
||||
|
||||
var actionId = Guid.NewGuid();
|
||||
var actions = new List<Pipelines.ActionStep>
|
||||
{
|
||||
new Pipelines.ActionStep()
|
||||
{
|
||||
Name = "action",
|
||||
Id = actionId,
|
||||
Reference = new Pipelines.RepositoryPathReference()
|
||||
{
|
||||
Name = "TingluoHuang/runner_L0",
|
||||
Ref = "CompositePrestep",
|
||||
RepositoryType = "GitHub"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
//Act
|
||||
var result = await _actionManager.PrepareActionsAsync(_ec.Object, actions);
|
||||
|
||||
//Assert
|
||||
// The composite tree is:
|
||||
// depth 0: CompositePrestep
|
||||
// depth 1: Node@RepositoryActionWithWrapperActionfile_Node + CompositePrestep2
|
||||
// depth 2: Node@RepositoryActionWithWrapperActionfile_Node + Docker@RepositoryActionWithWrapperActionfile_Docker
|
||||
//
|
||||
// With batching:
|
||||
// Call 1 (depth 0, resolve): CompositePrestep
|
||||
// Call 2 (depth 0→1, pre-resolve): Node + CompositePrestep2 in one batch
|
||||
// Call 3 (depth 1→2, pre-resolve): Docker only (Node already cached from call 2)
|
||||
Assert.Equal(3, resolveCallCount);
|
||||
|
||||
// Call 1: depth 0 resolve — just the top-level composite
|
||||
var call1Keys = resolvedActions[0].Actions.Select(a => $"{a.NameWithOwner}@{a.Ref}").OrderBy(k => k).ToList();
|
||||
Assert.Equal(new[] { "TingluoHuang/runner_L0@CompositePrestep" }, call1Keys);
|
||||
|
||||
// Call 2: depth 0→1 pre-resolve — batch both children of CompositePrestep
|
||||
var call2Keys = resolvedActions[1].Actions.Select(a => $"{a.NameWithOwner}@{a.Ref}").OrderBy(k => k).ToList();
|
||||
Assert.Equal(new[] { "TingluoHuang/runner_L0@CompositePrestep2", "TingluoHuang/runner_L0@RepositoryActionWithWrapperActionfile_Node" }, call2Keys);
|
||||
|
||||
// Call 3: depth 1→2 pre-resolve — only Docker (Node was cached in call 2)
|
||||
var call3Keys = resolvedActions[2].Actions.Select(a => $"{a.NameWithOwner}@{a.Ref}").OrderBy(k => k).ToList();
|
||||
Assert.Equal(new[] { "TingluoHuang/runner_L0@RepositoryActionWithWrapperActionfile_Docker" }, call3Keys);
|
||||
|
||||
// Verify all actions were downloaded
|
||||
Assert.True(File.Exists(Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), "TingluoHuang/runner_L0", "CompositePrestep.completed")));
|
||||
Assert.True(File.Exists(Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), "TingluoHuang/runner_L0", "RepositoryActionWithWrapperActionfile_Node.completed")));
|
||||
Assert.True(File.Exists(Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), "TingluoHuang/runner_L0", "CompositePrestep2.completed")));
|
||||
Assert.True(File.Exists(Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), "TingluoHuang/runner_L0", "RepositoryActionWithWrapperActionfile_Docker.completed")));
|
||||
|
||||
// Verify pre-step tracking still works correctly
|
||||
Assert.Equal(1, result.PreStepTracker.Count);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", null);
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async void PrepareActions_DeduplicatesResolutionAcrossDepthLevels()
|
||||
{
|
||||
// Verifies that an action appearing at multiple depths in the
|
||||
// composite tree is only resolved once (not re-resolved at each level).
|
||||
//
|
||||
// CompositePrestep uses Node action at depth 1.
|
||||
// CompositePrestep2 (also at depth 1) uses the SAME Node action at depth 2.
|
||||
// The Node action should only be resolved once total.
|
||||
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", "true");
|
||||
try
|
||||
{
|
||||
//Arrange
|
||||
Setup();
|
||||
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
|
||||
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
|
||||
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
|
||||
|
||||
var allResolvedKeys = new List<string>();
|
||||
_jobServer.Setup(x => x.ResolveActionDownloadInfoAsync(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<Guid>(), It.IsAny<Guid>(), It.IsAny<ActionReferenceList>(), It.IsAny<CancellationToken>()))
|
||||
.Returns((Guid scopeIdentifier, string hubName, Guid planId, Guid jobId, ActionReferenceList actions, CancellationToken cancellationToken) =>
|
||||
{
|
||||
var result = new ActionDownloadInfoCollection { Actions = new Dictionary<string, ActionDownloadInfo>() };
|
||||
foreach (var action in actions.Actions)
|
||||
{
|
||||
var key = $"{action.NameWithOwner}@{action.Ref}";
|
||||
allResolvedKeys.Add(key);
|
||||
result.Actions[key] = new ActionDownloadInfo
|
||||
{
|
||||
NameWithOwner = action.NameWithOwner,
|
||||
Ref = action.Ref,
|
||||
ResolvedNameWithOwner = action.NameWithOwner,
|
||||
ResolvedSha = $"{action.Ref}-sha",
|
||||
TarballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/tarball/{action.Ref}",
|
||||
ZipballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/zipball/{action.Ref}",
|
||||
};
|
||||
}
|
||||
return Task.FromResult(result);
|
||||
});
|
||||
|
||||
var actionId = Guid.NewGuid();
|
||||
var actions = new List<Pipelines.ActionStep>
|
||||
{
|
||||
new Pipelines.ActionStep()
|
||||
{
|
||||
Name = "action",
|
||||
Id = actionId,
|
||||
Reference = new Pipelines.RepositoryPathReference()
|
||||
{
|
||||
Name = "TingluoHuang/runner_L0",
|
||||
Ref = "CompositePrestep",
|
||||
RepositoryType = "GitHub"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
//Act
|
||||
await _actionManager.PrepareActionsAsync(_ec.Object, actions);
|
||||
|
||||
//Assert
|
||||
// TingluoHuang/runner_L0@RepositoryActionWithWrapperActionfile_Node appears
|
||||
// at both depth 1 (sub-step of CompositePrestep) and depth 2 (sub-step of
|
||||
// CompositePrestep2). With deduplication it should only be resolved once.
|
||||
var nodeActionKey = "TingluoHuang/runner_L0@RepositoryActionWithWrapperActionfile_Node";
|
||||
var nodeResolveCount = allResolvedKeys.FindAll(k => k == nodeActionKey).Count;
|
||||
Assert.Equal(1, nodeResolveCount);
|
||||
|
||||
// Verify the total number of unique actions resolved matches the tree
|
||||
var uniqueKeys = new HashSet<string>(allResolvedKeys);
|
||||
// Expected unique actions: CompositePrestep, Node, CompositePrestep2, Docker = 4
|
||||
Assert.Equal(4, uniqueKeys.Count);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", null);
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async void PrepareActions_MultipleTopLevelActions_BatchesResolution()
|
||||
{
|
||||
// Verifies that multiple independent actions at depth 0 are
|
||||
// resolved in a single API call.
|
||||
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", "true");
|
||||
try
|
||||
{
|
||||
//Arrange
|
||||
Setup();
|
||||
// Node action has pre+post, needs IActionRunner instances
|
||||
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
|
||||
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
|
||||
|
||||
var resolveCallCount = 0;
|
||||
var firstCallActionCount = 0;
|
||||
_jobServer.Setup(x => x.ResolveActionDownloadInfoAsync(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<Guid>(), It.IsAny<Guid>(), It.IsAny<ActionReferenceList>(), It.IsAny<CancellationToken>()))
|
||||
.Returns((Guid scopeIdentifier, string hubName, Guid planId, Guid jobId, ActionReferenceList actions, CancellationToken cancellationToken) =>
|
||||
{
|
||||
resolveCallCount++;
|
||||
if (resolveCallCount == 1)
|
||||
{
|
||||
firstCallActionCount = actions.Actions.Count;
|
||||
}
|
||||
var result = new ActionDownloadInfoCollection { Actions = new Dictionary<string, ActionDownloadInfo>() };
|
||||
foreach (var action in actions.Actions)
|
||||
{
|
||||
var key = $"{action.NameWithOwner}@{action.Ref}";
|
||||
result.Actions[key] = new ActionDownloadInfo
|
||||
{
|
||||
NameWithOwner = action.NameWithOwner,
|
||||
Ref = action.Ref,
|
||||
ResolvedNameWithOwner = action.NameWithOwner,
|
||||
ResolvedSha = $"{action.Ref}-sha",
|
||||
TarballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/tarball/{action.Ref}",
|
||||
ZipballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/zipball/{action.Ref}",
|
||||
};
|
||||
}
|
||||
return Task.FromResult(result);
|
||||
});
|
||||
|
||||
var actions = new List<Pipelines.ActionStep>
|
||||
{
|
||||
new Pipelines.ActionStep()
|
||||
{
|
||||
Name = "action1",
|
||||
Id = Guid.NewGuid(),
|
||||
Reference = new Pipelines.RepositoryPathReference()
|
||||
{
|
||||
Name = "TingluoHuang/runner_L0",
|
||||
Ref = "RepositoryActionWithWrapperActionfile_Node",
|
||||
RepositoryType = "GitHub"
|
||||
}
|
||||
},
|
||||
new Pipelines.ActionStep()
|
||||
{
|
||||
Name = "action2",
|
||||
Id = Guid.NewGuid(),
|
||||
Reference = new Pipelines.RepositoryPathReference()
|
||||
{
|
||||
Name = "TingluoHuang/runner_L0",
|
||||
Ref = "RepositoryActionWithWrapperActionfile_Docker",
|
||||
RepositoryType = "GitHub"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
//Act
|
||||
await _actionManager.PrepareActionsAsync(_ec.Object, actions);
|
||||
|
||||
//Assert
|
||||
// Both actions are at depth 0 — should be resolved in a single batch call
|
||||
Assert.Equal(1, resolveCallCount);
|
||||
Assert.Equal(2, firstCallActionCount);
|
||||
|
||||
// Verify both were downloaded
|
||||
Assert.True(File.Exists(Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), "TingluoHuang/runner_L0", "RepositoryActionWithWrapperActionfile_Node.completed")));
|
||||
Assert.True(File.Exists(Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), "TingluoHuang/runner_L0", "RepositoryActionWithWrapperActionfile_Docker.completed")));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", null);
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
#if OS_LINUX
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async void PrepareActions_NestedCompositeContainers_BatchedResolution()
|
||||
{
|
||||
// Verifies batching with nested composite actions that reference
|
||||
// container actions (Linux-only since containers require Linux).
|
||||
//
|
||||
// CompositeContainerNested (composite):
|
||||
// → repositoryactionwithdockerfile (Dockerfile)
|
||||
// → CompositeContainerNested2 (composite):
|
||||
// → repositoryactionwithdockerfile (Dockerfile, same as above)
|
||||
// → notpullorbuildimagesmultipletimes1 (Dockerfile)
|
||||
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", "true");
|
||||
try
|
||||
{
|
||||
//Arrange
|
||||
Setup();
|
||||
|
||||
var resolveCallCount = 0;
|
||||
_jobServer.Setup(x => x.ResolveActionDownloadInfoAsync(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<Guid>(), It.IsAny<Guid>(), It.IsAny<ActionReferenceList>(), It.IsAny<CancellationToken>()))
|
||||
.Returns((Guid scopeIdentifier, string hubName, Guid planId, Guid jobId, ActionReferenceList actions, CancellationToken cancellationToken) =>
|
||||
{
|
||||
resolveCallCount++;
|
||||
var result = new ActionDownloadInfoCollection { Actions = new Dictionary<string, ActionDownloadInfo>() };
|
||||
foreach (var action in actions.Actions)
|
||||
{
|
||||
var key = $"{action.NameWithOwner}@{action.Ref}";
|
||||
result.Actions[key] = new ActionDownloadInfo
|
||||
{
|
||||
NameWithOwner = action.NameWithOwner,
|
||||
Ref = action.Ref,
|
||||
ResolvedNameWithOwner = action.NameWithOwner,
|
||||
ResolvedSha = $"{action.Ref}-sha",
|
||||
TarballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/tarball/{action.Ref}",
|
||||
ZipballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/zipball/{action.Ref}",
|
||||
};
|
||||
}
|
||||
return Task.FromResult(result);
|
||||
});
|
||||
|
||||
var actionId = Guid.NewGuid();
|
||||
var actions = new List<Pipelines.ActionStep>
|
||||
{
|
||||
new Pipelines.ActionStep()
|
||||
{
|
||||
Name = "action",
|
||||
Id = actionId,
|
||||
Reference = new Pipelines.RepositoryPathReference()
|
||||
{
|
||||
Name = "TingluoHuang/runner_L0",
|
||||
Ref = "CompositeContainerNested",
|
||||
RepositoryType = "GitHub"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
//Act
|
||||
var result = await _actionManager.PrepareActionsAsync(_ec.Object, actions);
|
||||
|
||||
//Assert
|
||||
// Tree has 3 depth levels with 5 unique actions.
|
||||
// With batching, should need at most 3 resolve calls (one per depth level).
|
||||
Assert.True(resolveCallCount <= 3, $"Expected at most 3 resolve calls but got {resolveCallCount}");
|
||||
|
||||
// repositoryactionwithdockerfile appears at both depth 1 and depth 2.
|
||||
// Container setup should still work correctly — 2 unique Docker images.
|
||||
Assert.Equal(2, result.ContainerSetupSteps.Count);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", null);
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async void PrepareActions_ParallelDownloads_MultipleUniqueActions()
|
||||
{
|
||||
// Verifies that multiple unique top-level actions are downloaded via
|
||||
// DownloadActionsInParallelAsync (the parallel code path), and that
|
||||
// all actions are correctly resolved and downloaded.
|
||||
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", "true");
|
||||
try
|
||||
{
|
||||
//Arrange
|
||||
Setup();
|
||||
// Node action has pre step, and CompositePrestep recurses into
|
||||
// sub-actions that also need IActionRunner instances
|
||||
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
|
||||
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
|
||||
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
|
||||
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
|
||||
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
|
||||
|
||||
var resolveCallCount = 0;
|
||||
_jobServer.Setup(x => x.ResolveActionDownloadInfoAsync(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<Guid>(), It.IsAny<Guid>(), It.IsAny<ActionReferenceList>(), It.IsAny<CancellationToken>()))
|
||||
.Returns((Guid scopeIdentifier, string hubName, Guid planId, Guid jobId, ActionReferenceList actions, CancellationToken cancellationToken) =>
|
||||
{
|
||||
Interlocked.Increment(ref resolveCallCount);
|
||||
var result = new ActionDownloadInfoCollection { Actions = new Dictionary<string, ActionDownloadInfo>() };
|
||||
foreach (var action in actions.Actions)
|
||||
{
|
||||
var key = $"{action.NameWithOwner}@{action.Ref}";
|
||||
result.Actions[key] = new ActionDownloadInfo
|
||||
{
|
||||
NameWithOwner = action.NameWithOwner,
|
||||
Ref = action.Ref,
|
||||
ResolvedNameWithOwner = action.NameWithOwner,
|
||||
ResolvedSha = $"{action.Ref}-sha",
|
||||
TarballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/tarball/{action.Ref}",
|
||||
ZipballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/zipball/{action.Ref}",
|
||||
};
|
||||
}
|
||||
return Task.FromResult(result);
|
||||
});
|
||||
|
||||
var actions = new List<Pipelines.ActionStep>
|
||||
{
|
||||
new Pipelines.ActionStep()
|
||||
{
|
||||
Name = "action1",
|
||||
Id = Guid.NewGuid(),
|
||||
Reference = new Pipelines.RepositoryPathReference()
|
||||
{
|
||||
Name = "TingluoHuang/runner_L0",
|
||||
Ref = "RepositoryActionWithWrapperActionfile_Node",
|
||||
RepositoryType = "GitHub"
|
||||
}
|
||||
},
|
||||
new Pipelines.ActionStep()
|
||||
{
|
||||
Name = "action2",
|
||||
Id = Guid.NewGuid(),
|
||||
Reference = new Pipelines.RepositoryPathReference()
|
||||
{
|
||||
Name = "TingluoHuang/runner_L0",
|
||||
Ref = "RepositoryActionWithWrapperActionfile_Docker",
|
||||
RepositoryType = "GitHub"
|
||||
}
|
||||
},
|
||||
new Pipelines.ActionStep()
|
||||
{
|
||||
Name = "action3",
|
||||
Id = Guid.NewGuid(),
|
||||
Reference = new Pipelines.RepositoryPathReference()
|
||||
{
|
||||
Name = "TingluoHuang/runner_L0",
|
||||
Ref = "CompositePrestep",
|
||||
RepositoryType = "GitHub"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
//Act
|
||||
await _actionManager.PrepareActionsAsync(_ec.Object, actions);
|
||||
|
||||
//Assert
|
||||
// 3 unique actions at depth 0 → triggers DownloadActionsInParallelAsync
|
||||
// (parallel path used when uniqueDownloads.Count > 1)
|
||||
var nodeCompleted = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), "TingluoHuang/runner_L0", "RepositoryActionWithWrapperActionfile_Node.completed");
|
||||
var dockerCompleted = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), "TingluoHuang/runner_L0", "RepositoryActionWithWrapperActionfile_Docker.completed");
|
||||
var compositeCompleted = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), "TingluoHuang/runner_L0", "CompositePrestep.completed");
|
||||
|
||||
Assert.True(File.Exists(nodeCompleted), $"Expected watermark at {nodeCompleted}");
|
||||
Assert.True(File.Exists(dockerCompleted), $"Expected watermark at {dockerCompleted}");
|
||||
Assert.True(File.Exists(compositeCompleted), $"Expected watermark at {compositeCompleted}");
|
||||
|
||||
// All depth-0 actions resolved in a single batch call.
|
||||
// Composite sub-actions may add 1-2 more calls.
|
||||
Assert.True(resolveCallCount >= 1, "Expected at least 1 resolve call");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", null);
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async void PrepareActions_DownloadsNextLevelActionsBeforeRecursing()
|
||||
{
|
||||
// Verifies that depth-1 actions are downloaded before the depth-2
|
||||
// pre-resolve fires. We detect this by snapshotting watermark state
|
||||
// inside the 3rd ResolveActionDownloadInfoAsync callback (which is
|
||||
// the depth-2 pre-resolve). If pre-download works, depth-1 watermarks
|
||||
// already exist at that point.
|
||||
//
|
||||
// Action tree:
|
||||
// CompositePrestep (composite) → [Node, CompositePrestep2 (composite)]
|
||||
// CompositePrestep2 (composite) → [Node, Docker]
|
||||
//
|
||||
// Without pre-download: downloads happen during recursion (serial per depth)
|
||||
// With pre-download: depth 1 actions (Node + CompositePrestep2) are
|
||||
// downloaded in parallel before recursing, so recursion is a no-op
|
||||
// for downloads.
|
||||
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", "true");
|
||||
try
|
||||
{
|
||||
//Arrange
|
||||
Setup();
|
||||
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
|
||||
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
|
||||
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
|
||||
|
||||
// Track watermark state at the time of each resolve call.
|
||||
// If pre-download works, when the 3rd resolve fires (depth 2
|
||||
// pre-resolve for Docker), the depth-1 actions (Node +
|
||||
// CompositePrestep2) should already have watermarks on disk.
|
||||
var resolveCallCount = 0;
|
||||
var watermarksAtResolve3 = new Dictionary<string, bool>();
|
||||
_jobServer.Setup(x => x.ResolveActionDownloadInfoAsync(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<Guid>(), It.IsAny<Guid>(), It.IsAny<ActionReferenceList>(), It.IsAny<CancellationToken>()))
|
||||
.Returns((Guid scopeIdentifier, string hubName, Guid planId, Guid jobId, ActionReferenceList actions, CancellationToken cancellationToken) =>
|
||||
{
|
||||
resolveCallCount++;
|
||||
if (resolveCallCount == 3)
|
||||
{
|
||||
// At the time of the 3rd resolve, check if depth-1 actions
|
||||
// are already downloaded (pre-download should have done this)
|
||||
var actionsDir2 = _hc.GetDirectory(WellKnownDirectory.Actions);
|
||||
watermarksAtResolve3["Node"] = File.Exists(Path.Combine(actionsDir2, "TingluoHuang/runner_L0", "RepositoryActionWithWrapperActionfile_Node.completed"));
|
||||
watermarksAtResolve3["CompositePrestep2"] = File.Exists(Path.Combine(actionsDir2, "TingluoHuang/runner_L0", "CompositePrestep2.completed"));
|
||||
}
|
||||
var result = new ActionDownloadInfoCollection { Actions = new Dictionary<string, ActionDownloadInfo>() };
|
||||
foreach (var action in actions.Actions)
|
||||
{
|
||||
var key = $"{action.NameWithOwner}@{action.Ref}";
|
||||
result.Actions[key] = new ActionDownloadInfo
|
||||
{
|
||||
NameWithOwner = action.NameWithOwner,
|
||||
Ref = action.Ref,
|
||||
ResolvedNameWithOwner = action.NameWithOwner,
|
||||
ResolvedSha = $"{action.Ref}-sha",
|
||||
TarballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/tarball/{action.Ref}",
|
||||
ZipballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/zipball/{action.Ref}",
|
||||
};
|
||||
}
|
||||
return Task.FromResult(result);
|
||||
});
|
||||
|
||||
var actionId = Guid.NewGuid();
|
||||
var actions = new List<Pipelines.ActionStep>
|
||||
{
|
||||
new Pipelines.ActionStep()
|
||||
{
|
||||
Name = "action",
|
||||
Id = actionId,
|
||||
Reference = new Pipelines.RepositoryPathReference()
|
||||
{
|
||||
Name = "TingluoHuang/runner_L0",
|
||||
Ref = "CompositePrestep",
|
||||
RepositoryType = "GitHub"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
//Act
|
||||
var result = await _actionManager.PrepareActionsAsync(_ec.Object, actions);
|
||||
|
||||
//Assert
|
||||
// All actions should be downloaded (watermarks exist)
|
||||
var actionsDir = _hc.GetDirectory(WellKnownDirectory.Actions);
|
||||
Assert.True(File.Exists(Path.Combine(actionsDir, "TingluoHuang/runner_L0", "CompositePrestep.completed")));
|
||||
Assert.True(File.Exists(Path.Combine(actionsDir, "TingluoHuang/runner_L0", "RepositoryActionWithWrapperActionfile_Node.completed")));
|
||||
Assert.True(File.Exists(Path.Combine(actionsDir, "TingluoHuang/runner_L0", "CompositePrestep2.completed")));
|
||||
Assert.True(File.Exists(Path.Combine(actionsDir, "TingluoHuang/runner_L0", "RepositoryActionWithWrapperActionfile_Docker.completed")));
|
||||
|
||||
// 3 resolve calls total
|
||||
Assert.Equal(3, resolveCallCount);
|
||||
|
||||
// The key assertion: at the time of the 3rd resolve call
|
||||
// (pre-resolve for depth 2), the depth-1 actions should
|
||||
// ALREADY be downloaded thanks to pre-download.
|
||||
// Without pre-download, these watermarks wouldn't exist yet
|
||||
// because depth-1 downloads would only happen during recursion.
|
||||
Assert.True(watermarksAtResolve3["Node"],
|
||||
"Node action should be pre-downloaded before depth 2 pre-resolve");
|
||||
Assert.True(watermarksAtResolve3["CompositePrestep2"],
|
||||
"CompositePrestep2 should be pre-downloaded before depth 2 pre-resolve");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", null);
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async void PrepareActions_ParallelDownloadsAtSameDepth()
|
||||
{
|
||||
// Verifies that multiple unique actions at the same depth are
|
||||
// downloaded concurrently (Task.WhenAll) rather than sequentially.
|
||||
// We detect this by checking that all watermarks exist after a
|
||||
// single PrepareActionsAsync call with multiple top-level actions.
|
||||
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", "true");
|
||||
try
|
||||
{
|
||||
//Arrange
|
||||
Setup();
|
||||
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
|
||||
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
|
||||
|
||||
_jobServer.Setup(x => x.ResolveActionDownloadInfoAsync(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<Guid>(), It.IsAny<Guid>(), It.IsAny<ActionReferenceList>(), It.IsAny<CancellationToken>()))
|
||||
.Returns((Guid scopeIdentifier, string hubName, Guid planId, Guid jobId, ActionReferenceList actions, CancellationToken cancellationToken) =>
|
||||
{
|
||||
var result = new ActionDownloadInfoCollection { Actions = new Dictionary<string, ActionDownloadInfo>() };
|
||||
foreach (var action in actions.Actions)
|
||||
{
|
||||
var key = $"{action.NameWithOwner}@{action.Ref}";
|
||||
result.Actions[key] = new ActionDownloadInfo
|
||||
{
|
||||
NameWithOwner = action.NameWithOwner,
|
||||
Ref = action.Ref,
|
||||
ResolvedNameWithOwner = action.NameWithOwner,
|
||||
ResolvedSha = $"{action.Ref}-sha",
|
||||
TarballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/tarball/{action.Ref}",
|
||||
ZipballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/zipball/{action.Ref}",
|
||||
};
|
||||
}
|
||||
return Task.FromResult(result);
|
||||
});
|
||||
|
||||
var actions = new List<Pipelines.ActionStep>
|
||||
{
|
||||
new Pipelines.ActionStep()
|
||||
{
|
||||
Name = "action1",
|
||||
Id = Guid.NewGuid(),
|
||||
Reference = new Pipelines.RepositoryPathReference()
|
||||
{
|
||||
Name = "TingluoHuang/runner_L0",
|
||||
Ref = "RepositoryActionWithWrapperActionfile_Node",
|
||||
RepositoryType = "GitHub"
|
||||
}
|
||||
},
|
||||
new Pipelines.ActionStep()
|
||||
{
|
||||
Name = "action2",
|
||||
Id = Guid.NewGuid(),
|
||||
Reference = new Pipelines.RepositoryPathReference()
|
||||
{
|
||||
Name = "TingluoHuang/runner_L0",
|
||||
Ref = "RepositoryActionWithWrapperActionfile_Docker",
|
||||
RepositoryType = "GitHub"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
//Act
|
||||
await _actionManager.PrepareActionsAsync(_ec.Object, actions);
|
||||
|
||||
//Assert - both downloaded (parallel path used when > 1 unique download)
|
||||
var actionsDir = _hc.GetDirectory(WellKnownDirectory.Actions);
|
||||
Assert.True(File.Exists(Path.Combine(actionsDir, "TingluoHuang/runner_L0", "RepositoryActionWithWrapperActionfile_Node.completed")));
|
||||
Assert.True(File.Exists(Path.Combine(actionsDir, "TingluoHuang/runner_L0", "RepositoryActionWithWrapperActionfile_Docker.completed")));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", null);
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
@@ -3153,51 +2495,6 @@ 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();
|
||||
@@ -3332,141 +2629,5 @@ 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -504,7 +504,7 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Load_Node24Action()
|
||||
@@ -1006,45 +1006,6 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
_ec.Setup(x => x.AddIssue(It.IsAny<Issue>(), It.IsAny<ExecutionContextLogOptions>())).Callback((Issue issue, ExecutionContextLogOptions logOptions) => { _hc.GetTrace().Info($"[{issue.Type}]{logOptions.LogMessageOverride ?? issue.Message}"); });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Evaluate_Default_Input_Case_Function()
|
||||
{
|
||||
try
|
||||
{
|
||||
//Arrange
|
||||
Setup();
|
||||
|
||||
var actionManifest = new ActionManifestManager();
|
||||
actionManifest.Initialize(_hc);
|
||||
|
||||
_ec.Object.ExpressionValues["github"] = new LegacyContextData.DictionaryContextData
|
||||
{
|
||||
{ "ref", new LegacyContextData.StringContextData("refs/heads/main") },
|
||||
};
|
||||
_ec.Object.ExpressionValues["strategy"] = new LegacyContextData.DictionaryContextData();
|
||||
_ec.Object.ExpressionValues["matrix"] = new LegacyContextData.DictionaryContextData();
|
||||
_ec.Object.ExpressionValues["steps"] = new LegacyContextData.DictionaryContextData();
|
||||
_ec.Object.ExpressionValues["job"] = new LegacyContextData.DictionaryContextData();
|
||||
_ec.Object.ExpressionValues["runner"] = new LegacyContextData.DictionaryContextData();
|
||||
_ec.Object.ExpressionValues["env"] = new LegacyContextData.DictionaryContextData();
|
||||
_ec.Object.ExpressionFunctions.Add(new LegacyExpressions.FunctionInfo<GitHub.Runner.Worker.Expressions.HashFilesFunction>("hashFiles", 1, 255));
|
||||
|
||||
// Act — evaluate a case() expression as a default input value.
|
||||
// The feature flag is set, so this should succeed.
|
||||
var token = new BasicExpressionToken(null, null, null, "case(true, 'matched', 'default')");
|
||||
var result = actionManifest.EvaluateDefaultInput(_ec.Object, "testInput", token);
|
||||
|
||||
// Assert — case() should evaluate successfully
|
||||
Assert.Equal("matched", result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
private void Teardown()
|
||||
{
|
||||
_hc?.Dispose();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,233 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
using GitHub.Runner.Worker.Dap;
|
||||
|
||||
namespace GitHub.Runner.Common.Tests.Worker
|
||||
{
|
||||
public sealed class DapMessagesL0
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void RequestSerializesCorrectly()
|
||||
{
|
||||
var request = new Request
|
||||
{
|
||||
Seq = 1,
|
||||
Type = "request",
|
||||
Command = "initialize",
|
||||
Arguments = JObject.FromObject(new { clientID = "test-client" })
|
||||
};
|
||||
|
||||
var json = JsonConvert.SerializeObject(request);
|
||||
var deserialized = JsonConvert.DeserializeObject<Request>(json);
|
||||
|
||||
Assert.Equal(1, deserialized.Seq);
|
||||
Assert.Equal("request", deserialized.Type);
|
||||
Assert.Equal("initialize", deserialized.Command);
|
||||
Assert.Equal("test-client", deserialized.Arguments["clientID"].ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ResponseSerializesCorrectly()
|
||||
{
|
||||
var response = new Response
|
||||
{
|
||||
Seq = 2,
|
||||
Type = "response",
|
||||
RequestSeq = 1,
|
||||
Success = true,
|
||||
Command = "initialize",
|
||||
Body = new Capabilities { SupportsConfigurationDoneRequest = true }
|
||||
};
|
||||
|
||||
var json = JsonConvert.SerializeObject(response);
|
||||
var deserialized = JsonConvert.DeserializeObject<Response>(json);
|
||||
|
||||
Assert.Equal(2, deserialized.Seq);
|
||||
Assert.Equal("response", deserialized.Type);
|
||||
Assert.Equal(1, deserialized.RequestSeq);
|
||||
Assert.True(deserialized.Success);
|
||||
Assert.Equal("initialize", deserialized.Command);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EventSerializesWithCorrectType()
|
||||
{
|
||||
var evt = new Event
|
||||
{
|
||||
EventType = "stopped",
|
||||
Body = new StoppedEventBody
|
||||
{
|
||||
Reason = "entry",
|
||||
Description = "Stopped at entry",
|
||||
ThreadId = 1,
|
||||
AllThreadsStopped = true
|
||||
}
|
||||
};
|
||||
|
||||
Assert.Equal("event", evt.Type);
|
||||
|
||||
var json = JsonConvert.SerializeObject(evt);
|
||||
Assert.Contains("\"type\":\"event\"", json);
|
||||
Assert.Contains("\"event\":\"stopped\"", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void StoppedEventBodyOmitsNullFields()
|
||||
{
|
||||
var body = new StoppedEventBody
|
||||
{
|
||||
Reason = "step"
|
||||
};
|
||||
|
||||
var json = JsonConvert.SerializeObject(body);
|
||||
Assert.Contains("\"reason\":\"step\"", json);
|
||||
Assert.DoesNotContain("\"threadId\"", json);
|
||||
Assert.DoesNotContain("\"allThreadsStopped\"", json);
|
||||
Assert.DoesNotContain("\"description\"", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void CapabilitiesMvpDefaults()
|
||||
{
|
||||
var caps = new Capabilities
|
||||
{
|
||||
SupportsConfigurationDoneRequest = true,
|
||||
SupportsFunctionBreakpoints = false,
|
||||
SupportsStepBack = false
|
||||
};
|
||||
|
||||
var json = JsonConvert.SerializeObject(caps);
|
||||
var deserialized = JsonConvert.DeserializeObject<Capabilities>(json);
|
||||
|
||||
Assert.True(deserialized.SupportsConfigurationDoneRequest);
|
||||
Assert.False(deserialized.SupportsFunctionBreakpoints);
|
||||
Assert.False(deserialized.SupportsStepBack);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ContinueResponseBodySerialization()
|
||||
{
|
||||
var body = new ContinueResponseBody { AllThreadsContinued = true };
|
||||
var json = JsonConvert.SerializeObject(body);
|
||||
var deserialized = JsonConvert.DeserializeObject<ContinueResponseBody>(json);
|
||||
|
||||
Assert.True(deserialized.AllThreadsContinued);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ThreadsResponseBodySerialization()
|
||||
{
|
||||
var body = new ThreadsResponseBody
|
||||
{
|
||||
Threads = new List<Thread>
|
||||
{
|
||||
new Thread { Id = 1, Name = "Job Thread" }
|
||||
}
|
||||
};
|
||||
|
||||
var json = JsonConvert.SerializeObject(body);
|
||||
var deserialized = JsonConvert.DeserializeObject<ThreadsResponseBody>(json);
|
||||
|
||||
Assert.Single(deserialized.Threads);
|
||||
Assert.Equal(1, deserialized.Threads[0].Id);
|
||||
Assert.Equal("Job Thread", deserialized.Threads[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void StackFrameSerialization()
|
||||
{
|
||||
var frame = new StackFrame
|
||||
{
|
||||
Id = 1,
|
||||
Name = "Step: Checkout",
|
||||
Line = 1,
|
||||
Column = 1,
|
||||
PresentationHint = "normal"
|
||||
};
|
||||
|
||||
var json = JsonConvert.SerializeObject(frame);
|
||||
var deserialized = JsonConvert.DeserializeObject<StackFrame>(json);
|
||||
|
||||
Assert.Equal(1, deserialized.Id);
|
||||
Assert.Equal("Step: Checkout", deserialized.Name);
|
||||
Assert.Equal("normal", deserialized.PresentationHint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ExitedEventBodySerialization()
|
||||
{
|
||||
var body = new ExitedEventBody { ExitCode = 130 };
|
||||
var json = JsonConvert.SerializeObject(body);
|
||||
var deserialized = JsonConvert.DeserializeObject<ExitedEventBody>(json);
|
||||
|
||||
Assert.Equal(130, deserialized.ExitCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void DapCommandEnumValues()
|
||||
{
|
||||
Assert.Equal(0, (int)DapCommand.Continue);
|
||||
Assert.Equal(1, (int)DapCommand.Next);
|
||||
Assert.Equal(4, (int)DapCommand.Disconnect);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void RequestDeserializesFromRawJson()
|
||||
{
|
||||
var json = @"{""seq"":5,""type"":""request"",""command"":""continue"",""arguments"":{""threadId"":1}}";
|
||||
var request = JsonConvert.DeserializeObject<Request>(json);
|
||||
|
||||
Assert.Equal(5, request.Seq);
|
||||
Assert.Equal("request", request.Type);
|
||||
Assert.Equal("continue", request.Command);
|
||||
Assert.Equal(1, request.Arguments["threadId"].Value<int>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ErrorResponseBodySerialization()
|
||||
{
|
||||
var body = new ErrorResponseBody
|
||||
{
|
||||
Error = new Message
|
||||
{
|
||||
Id = 1,
|
||||
Format = "Something went wrong",
|
||||
ShowUser = true
|
||||
}
|
||||
};
|
||||
|
||||
var json = JsonConvert.SerializeObject(body);
|
||||
var deserialized = JsonConvert.DeserializeObject<ErrorResponseBody>(json);
|
||||
|
||||
Assert.Equal(1, deserialized.Error.Id);
|
||||
Assert.Equal("Something went wrong", deserialized.Error.Format);
|
||||
Assert.True(deserialized.Error.ShowUser);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,338 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.DistributedTask.Expressions2;
|
||||
using GitHub.DistributedTask.Pipelines.ContextData;
|
||||
using GitHub.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;
|
||||
|
||||
namespace GitHub.Runner.Common.Tests.Worker
|
||||
{
|
||||
public sealed class DapReplExecutorL0
|
||||
{
|
||||
private TestHostContext _hc;
|
||||
private DapReplExecutor _executor;
|
||||
private List<Event> _sentEvents;
|
||||
|
||||
private TestHostContext CreateTestContext([CallerMemberName] string testName = "")
|
||||
{
|
||||
_hc = new TestHostContext(this, testName);
|
||||
_sentEvents = new List<Event>();
|
||||
_executor = new DapReplExecutor(_hc, (category, text) =>
|
||||
{
|
||||
_sentEvents.Add(new Event
|
||||
{
|
||||
EventType = "output",
|
||||
Body = new OutputEventBody
|
||||
{
|
||||
Category = category,
|
||||
Output = text
|
||||
}
|
||||
});
|
||||
});
|
||||
return _hc;
|
||||
}
|
||||
|
||||
private Mock<IExecutionContext> CreateMockContext(
|
||||
DictionaryContextData exprValues = null,
|
||||
IDictionary<string, IDictionary<string, string>> jobDefaults = null,
|
||||
ContainerInfo container = null)
|
||||
{
|
||||
var mock = new Mock<IExecutionContext>();
|
||||
mock.Setup(x => x.ExpressionValues).Returns(exprValues ?? new DictionaryContextData());
|
||||
mock.Setup(x => x.ExpressionFunctions).Returns(new List<IFunctionInfo>());
|
||||
|
||||
var global = new GlobalContext
|
||||
{
|
||||
PrependPath = new List<string>(),
|
||||
JobDefaults = jobDefaults
|
||||
?? new Dictionary<string, IDictionary<string, string>>(StringComparer.OrdinalIgnoreCase),
|
||||
Container = container,
|
||||
};
|
||||
mock.Setup(x => x.Global).Returns(global);
|
||||
|
||||
return mock;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task ExecuteRunCommand_NullContext_ReturnsError()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var command = new RunCommand { Script = "echo hello" };
|
||||
var result = await _executor.ExecuteRunCommandAsync(command, null, false, CancellationToken.None);
|
||||
|
||||
Assert.Equal("error", result.Type);
|
||||
Assert.Contains("No execution context available", result.Result);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ExpandExpressions_NoExpressions_ReturnsInput()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var context = CreateMockContext();
|
||||
var result = _executor.ExpandExpressions("echo hello", context.Object);
|
||||
|
||||
Assert.Equal("echo hello", result);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ExpandExpressions_NullInput_ReturnsEmpty()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var context = CreateMockContext();
|
||||
var result = _executor.ExpandExpressions(null, context.Object);
|
||||
|
||||
Assert.Equal(string.Empty, result);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ExpandExpressions_EmptyInput_ReturnsEmpty()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var context = CreateMockContext();
|
||||
var result = _executor.ExpandExpressions("", context.Object);
|
||||
|
||||
Assert.Equal(string.Empty, result);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ExpandExpressions_UnterminatedExpression_KeepsLiteral()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var context = CreateMockContext();
|
||||
var result = _executor.ExpandExpressions("echo ${{ github.repo", context.Object);
|
||||
|
||||
Assert.Equal("echo ${{ github.repo", result);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ResolveDefaultShell_NoJobDefaults_ReturnsPlatformDefault()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var context = CreateMockContext();
|
||||
var result = _executor.ResolveDefaultShell(context.Object);
|
||||
|
||||
#if OS_WINDOWS
|
||||
Assert.True(result == "pwsh" || result == "powershell");
|
||||
#else
|
||||
Assert.Equal("sh", result);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ResolveDefaultShell_WithJobDefault_ReturnsJobDefault()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var jobDefaults = new Dictionary<string, IDictionary<string, string>>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["run"] = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["shell"] = "bash"
|
||||
}
|
||||
};
|
||||
var context = CreateMockContext(jobDefaults: jobDefaults);
|
||||
var result = _executor.ResolveDefaultShell(context.Object);
|
||||
|
||||
Assert.Equal("bash", result);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void BuildEnvironment_MergesEnvContextAndReplOverrides()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var exprValues = new DictionaryContextData();
|
||||
var envData = new DictionaryContextData
|
||||
{
|
||||
["FOO"] = new StringContextData("bar"),
|
||||
};
|
||||
exprValues["env"] = envData;
|
||||
|
||||
var context = CreateMockContext(exprValues);
|
||||
var replEnv = new Dictionary<string, string> { { "BAZ", "qux" } };
|
||||
var result = _executor.BuildEnvironment(context.Object, replEnv);
|
||||
|
||||
Assert.Equal("bar", result["FOO"]);
|
||||
Assert.Equal("qux", result["BAZ"]);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void BuildEnvironment_ReplOverridesWin()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var exprValues = new DictionaryContextData();
|
||||
var envData = new DictionaryContextData
|
||||
{
|
||||
["FOO"] = new StringContextData("original"),
|
||||
};
|
||||
exprValues["env"] = envData;
|
||||
|
||||
var context = CreateMockContext(exprValues);
|
||||
var replEnv = new Dictionary<string, string> { { "FOO", "override" } };
|
||||
var result = _executor.BuildEnvironment(context.Object, replEnv);
|
||||
|
||||
Assert.Equal("override", result["FOO"]);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void BuildEnvironment_NullReplEnv_ReturnsContextEnvOnly()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var exprValues = new DictionaryContextData();
|
||||
var envData = new DictionaryContextData
|
||||
{
|
||||
["FOO"] = new StringContextData("bar"),
|
||||
};
|
||||
exprValues["env"] = envData;
|
||||
|
||||
var context = CreateMockContext(exprValues);
|
||||
var result = _executor.BuildEnvironment(context.Object, null);
|
||||
|
||||
Assert.Equal("bar", result["FOO"]);
|
||||
Assert.False(result.ContainsKey("BAZ"));
|
||||
}
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,314 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.CompilerServices;
|
||||
using GitHub.Runner.Common.Tests;
|
||||
using GitHub.Runner.Worker.Dap;
|
||||
using Xunit;
|
||||
|
||||
namespace GitHub.Runner.Common.Tests.Worker
|
||||
{
|
||||
public sealed class DapReplParserL0
|
||||
{
|
||||
#region help command
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_HelpReturnsHelpCommand()
|
||||
{
|
||||
var cmd = DapReplParser.TryParse("help", out var error);
|
||||
|
||||
Assert.Null(error);
|
||||
var help = Assert.IsType<HelpCommand>(cmd);
|
||||
Assert.Null(help.Topic);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_HelpCaseInsensitive()
|
||||
{
|
||||
var cmd = DapReplParser.TryParse("Help", out var error);
|
||||
Assert.Null(error);
|
||||
Assert.IsType<HelpCommand>(cmd);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_HelpWithTopic()
|
||||
{
|
||||
var cmd = DapReplParser.TryParse("help(\"run\")", out var error);
|
||||
|
||||
Assert.Null(error);
|
||||
var help = Assert.IsType<HelpCommand>(cmd);
|
||||
Assert.Equal("run", help.Topic);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region run command — basic
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_RunSimpleScript()
|
||||
{
|
||||
var cmd = DapReplParser.TryParse("run(\"echo hello\")", out var error);
|
||||
|
||||
Assert.Null(error);
|
||||
var run = Assert.IsType<RunCommand>(cmd);
|
||||
Assert.Equal("echo hello", run.Script);
|
||||
Assert.Null(run.Shell);
|
||||
Assert.Null(run.Env);
|
||||
Assert.Null(run.WorkingDirectory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_RunWithShell()
|
||||
{
|
||||
var cmd = DapReplParser.TryParse("run(\"echo hello\", shell: \"bash\")", out var error);
|
||||
|
||||
Assert.Null(error);
|
||||
var run = Assert.IsType<RunCommand>(cmd);
|
||||
Assert.Equal("echo hello", run.Script);
|
||||
Assert.Equal("bash", run.Shell);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_RunWithWorkingDirectory()
|
||||
{
|
||||
var cmd = DapReplParser.TryParse("run(\"ls\", working_directory: \"/tmp\")", out var error);
|
||||
|
||||
Assert.Null(error);
|
||||
var run = Assert.IsType<RunCommand>(cmd);
|
||||
Assert.Equal("ls", run.Script);
|
||||
Assert.Equal("/tmp", run.WorkingDirectory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_RunWithEnv()
|
||||
{
|
||||
var cmd = DapReplParser.TryParse("run(\"echo $FOO\", env: { FOO: \"bar\" })", out var error);
|
||||
|
||||
Assert.Null(error);
|
||||
var run = Assert.IsType<RunCommand>(cmd);
|
||||
Assert.Equal("echo $FOO", run.Script);
|
||||
Assert.NotNull(run.Env);
|
||||
Assert.Equal("bar", run.Env["FOO"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_RunWithMultipleEnvVars()
|
||||
{
|
||||
var cmd = DapReplParser.TryParse("run(\"echo\", env: { A: \"1\", B: \"2\" })", out var error);
|
||||
|
||||
Assert.Null(error);
|
||||
var run = Assert.IsType<RunCommand>(cmd);
|
||||
Assert.Equal(2, run.Env.Count);
|
||||
Assert.Equal("1", run.Env["A"]);
|
||||
Assert.Equal("2", run.Env["B"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_RunWithAllOptions()
|
||||
{
|
||||
var input = "run(\"echo $X\", shell: \"zsh\", env: { X: \"1\" }, working_directory: \"/tmp\")";
|
||||
var cmd = DapReplParser.TryParse(input, out var error);
|
||||
|
||||
Assert.Null(error);
|
||||
var run = Assert.IsType<RunCommand>(cmd);
|
||||
Assert.Equal("echo $X", run.Script);
|
||||
Assert.Equal("zsh", run.Shell);
|
||||
Assert.Equal("1", run.Env["X"]);
|
||||
Assert.Equal("/tmp", run.WorkingDirectory);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region run command — edge cases
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_RunWithEscapedQuotes()
|
||||
{
|
||||
var cmd = DapReplParser.TryParse("run(\"echo \\\"hello\\\"\")", out var error);
|
||||
|
||||
Assert.Null(error);
|
||||
var run = Assert.IsType<RunCommand>(cmd);
|
||||
Assert.Equal("echo \"hello\"", run.Script);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_RunWithCommaInEnvValue()
|
||||
{
|
||||
var cmd = DapReplParser.TryParse("run(\"echo\", env: { CSV: \"a,b,c\" })", out var error);
|
||||
|
||||
Assert.Null(error);
|
||||
var run = Assert.IsType<RunCommand>(cmd);
|
||||
Assert.Equal("a,b,c", run.Env["CSV"]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region error cases
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_RunEmptyArgsReturnsError()
|
||||
{
|
||||
var cmd = DapReplParser.TryParse("run()", out var error);
|
||||
|
||||
Assert.NotNull(error);
|
||||
Assert.Null(cmd);
|
||||
Assert.Contains("requires a script argument", error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_RunUnquotedArgReturnsError()
|
||||
{
|
||||
var cmd = DapReplParser.TryParse("run(echo hello)", out var error);
|
||||
|
||||
Assert.NotNull(error);
|
||||
Assert.Null(cmd);
|
||||
Assert.Contains("quoted string", error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_RunUnknownOptionReturnsError()
|
||||
{
|
||||
var cmd = DapReplParser.TryParse("run(\"echo\", timeout: \"10\")", out var error);
|
||||
|
||||
Assert.NotNull(error);
|
||||
Assert.Null(cmd);
|
||||
Assert.Contains("Unknown option", error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_RunMissingClosingParenReturnsError()
|
||||
{
|
||||
var cmd = DapReplParser.TryParse("run(\"echo\"", out var error);
|
||||
|
||||
Assert.NotNull(error);
|
||||
Assert.Null(cmd);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region non-DSL input falls through
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_ExpressionReturnsNull()
|
||||
{
|
||||
var cmd = DapReplParser.TryParse("github.repository", out var error);
|
||||
|
||||
Assert.Null(error);
|
||||
Assert.Null(cmd);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_WrappedExpressionReturnsNull()
|
||||
{
|
||||
var cmd = DapReplParser.TryParse("${{ github.event_name }}", out var error);
|
||||
|
||||
Assert.Null(error);
|
||||
Assert.Null(cmd);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_EmptyInputReturnsNull()
|
||||
{
|
||||
var cmd = DapReplParser.TryParse("", out var error);
|
||||
Assert.Null(error);
|
||||
Assert.Null(cmd);
|
||||
|
||||
cmd = DapReplParser.TryParse(null, out error);
|
||||
Assert.Null(error);
|
||||
Assert.Null(cmd);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region help text
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetGeneralHelp_ContainsCommands()
|
||||
{
|
||||
var help = DapReplParser.GetGeneralHelp();
|
||||
|
||||
Assert.Contains("help", help);
|
||||
Assert.Contains("run", help);
|
||||
Assert.Contains("expression", help, System.StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetRunHelp_ContainsOptions()
|
||||
{
|
||||
var help = DapReplParser.GetRunHelp();
|
||||
|
||||
Assert.Contains("shell", help);
|
||||
Assert.Contains("env", help);
|
||||
Assert.Contains("working_directory", help);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region internal parser helpers
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void SplitArguments_HandlesNestedBraces()
|
||||
{
|
||||
var args = DapReplParser.SplitArguments("\"hello\", env: { A: \"1\", B: \"2\" }", out var error);
|
||||
|
||||
Assert.Null(error);
|
||||
Assert.Equal(2, args.Count);
|
||||
Assert.Equal("\"hello\"", args[0].Trim());
|
||||
Assert.Contains("A:", args[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseEnvBlock_HandlesEmptyBlock()
|
||||
{
|
||||
var result = DapReplParser.ParseEnvBlock("{ }", out var error);
|
||||
|
||||
Assert.Null(error);
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -1,728 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.CompilerServices;
|
||||
using GitHub.DistributedTask.Pipelines.ContextData;
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Common.Tests;
|
||||
using GitHub.Runner.Worker;
|
||||
using GitHub.Runner.Worker.Dap;
|
||||
using Xunit;
|
||||
|
||||
namespace GitHub.Runner.Common.Tests.Worker
|
||||
{
|
||||
public sealed class DapVariableProviderL0
|
||||
{
|
||||
private TestHostContext _hc;
|
||||
private DapVariableProvider _provider;
|
||||
|
||||
private TestHostContext CreateTestContext([CallerMemberName] string testName = "")
|
||||
{
|
||||
_hc = new TestHostContext(this, testName);
|
||||
_provider = new DapVariableProvider(_hc.SecretMasker);
|
||||
return _hc;
|
||||
}
|
||||
|
||||
private Moq.Mock<GitHub.Runner.Worker.IExecutionContext> CreateMockContext(DictionaryContextData expressionValues)
|
||||
{
|
||||
var mock = new Moq.Mock<GitHub.Runner.Worker.IExecutionContext>();
|
||||
mock.Setup(x => x.ExpressionValues).Returns(expressionValues);
|
||||
return mock;
|
||||
}
|
||||
|
||||
#region GetScopes tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetScopes_ReturnsEmptyWhenContextIsNull()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var scopes = _provider.GetScopes(null);
|
||||
Assert.Empty(scopes);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetScopes_ReturnsOnlyPopulatedScopes()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var exprValues = new DictionaryContextData();
|
||||
exprValues["github"] = new DictionaryContextData
|
||||
{
|
||||
{ "repository", new StringContextData("owner/repo") }
|
||||
};
|
||||
exprValues["env"] = new DictionaryContextData
|
||||
{
|
||||
{ "CI", new StringContextData("true") },
|
||||
{ "HOME", new StringContextData("/home/runner") }
|
||||
};
|
||||
// "runner" is not set — should not appear in scopes
|
||||
|
||||
var ctx = CreateMockContext(exprValues);
|
||||
var scopes = _provider.GetScopes(ctx.Object);
|
||||
|
||||
Assert.Equal(2, scopes.Count);
|
||||
Assert.Equal("github", scopes[0].Name);
|
||||
Assert.Equal("env", scopes[1].Name);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetScopes_ReportsNamedVariableCount()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var exprValues = new DictionaryContextData();
|
||||
exprValues["env"] = new DictionaryContextData
|
||||
{
|
||||
{ "A", new StringContextData("1") },
|
||||
{ "B", new StringContextData("2") },
|
||||
{ "C", new StringContextData("3") }
|
||||
};
|
||||
|
||||
var ctx = CreateMockContext(exprValues);
|
||||
var scopes = _provider.GetScopes(ctx.Object);
|
||||
|
||||
Assert.Single(scopes);
|
||||
Assert.Equal(3, scopes[0].NamedVariables);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetScopes_SecretsGetSpecialPresentationHint()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var exprValues = new DictionaryContextData();
|
||||
exprValues["secrets"] = new DictionaryContextData
|
||||
{
|
||||
{ "MY_SECRET", new StringContextData("super-secret") }
|
||||
};
|
||||
exprValues["env"] = new DictionaryContextData
|
||||
{
|
||||
{ "CI", new StringContextData("true") }
|
||||
};
|
||||
|
||||
var ctx = CreateMockContext(exprValues);
|
||||
var scopes = _provider.GetScopes(ctx.Object);
|
||||
|
||||
var envScope = scopes.Find(s => s.Name == "env");
|
||||
var secretsScope = scopes.Find(s => s.Name == "secrets");
|
||||
|
||||
Assert.NotNull(envScope);
|
||||
Assert.Null(envScope.PresentationHint);
|
||||
|
||||
Assert.NotNull(secretsScope);
|
||||
Assert.Equal("registers", secretsScope.PresentationHint);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetVariables — basic types
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetVariables_ReturnsEmptyWhenContextIsNull()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var variables = _provider.GetVariables(null, 1);
|
||||
Assert.Empty(variables);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetVariables_ReturnsStringVariables()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var exprValues = new DictionaryContextData();
|
||||
exprValues["env"] = new DictionaryContextData
|
||||
{
|
||||
{ "CI", new StringContextData("true") },
|
||||
{ "HOME", new StringContextData("/home/runner") }
|
||||
};
|
||||
|
||||
var ctx = CreateMockContext(exprValues);
|
||||
// "env" is at ScopeNames index 1 → variablesReference = 2
|
||||
var variables = _provider.GetVariables(ctx.Object, 2);
|
||||
|
||||
Assert.Equal(2, variables.Count);
|
||||
|
||||
var ciVar = variables.Find(v => v.Name == "CI");
|
||||
Assert.NotNull(ciVar);
|
||||
Assert.Equal("true", ciVar.Value);
|
||||
Assert.Equal("string", ciVar.Type);
|
||||
Assert.Equal(0, ciVar.VariablesReference);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetVariables_ReturnsBooleanVariables()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var exprValues = new DictionaryContextData();
|
||||
exprValues["github"] = new DictionaryContextData
|
||||
{
|
||||
{ "event_name", new StringContextData("push") },
|
||||
};
|
||||
// Use a nested dict with boolean to test
|
||||
var jobDict = new DictionaryContextData();
|
||||
// BooleanContextData is a valid PipelineContextData type
|
||||
// but job context typically has strings. Use env scope instead.
|
||||
exprValues["env"] = new DictionaryContextData
|
||||
{
|
||||
{ "flag", new BooleanContextData(true) }
|
||||
};
|
||||
|
||||
var ctx = CreateMockContext(exprValues);
|
||||
// "env" is at index 1 → ref 2
|
||||
var variables = _provider.GetVariables(ctx.Object, 2);
|
||||
|
||||
var flagVar = variables.Find(v => v.Name == "flag");
|
||||
Assert.NotNull(flagVar);
|
||||
Assert.Equal("true", flagVar.Value);
|
||||
Assert.Equal("boolean", flagVar.Type);
|
||||
Assert.Equal(0, flagVar.VariablesReference);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetVariables_ReturnsNumberVariables()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var exprValues = new DictionaryContextData();
|
||||
exprValues["env"] = new DictionaryContextData
|
||||
{
|
||||
{ "count", new NumberContextData(42) }
|
||||
};
|
||||
|
||||
var ctx = CreateMockContext(exprValues);
|
||||
var variables = _provider.GetVariables(ctx.Object, 2);
|
||||
|
||||
var countVar = variables.Find(v => v.Name == "count");
|
||||
Assert.NotNull(countVar);
|
||||
Assert.Equal("42", countVar.Value);
|
||||
Assert.Equal("number", countVar.Type);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetVariables_HandlesNullValues()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var exprValues = new DictionaryContextData();
|
||||
var dict = new DictionaryContextData();
|
||||
dict["present"] = new StringContextData("yes");
|
||||
dict["missing"] = null;
|
||||
exprValues["env"] = dict;
|
||||
|
||||
var ctx = CreateMockContext(exprValues);
|
||||
var variables = _provider.GetVariables(ctx.Object, 2);
|
||||
|
||||
var nullVar = variables.Find(v => v.Name == "missing");
|
||||
Assert.NotNull(nullVar);
|
||||
Assert.Equal("null", nullVar.Value);
|
||||
Assert.Equal("null", nullVar.Type);
|
||||
Assert.Equal(0, nullVar.VariablesReference);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetVariables — nested expansion
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetVariables_NestedDictionaryIsExpandable()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var innerDict = new DictionaryContextData
|
||||
{
|
||||
{ "name", new StringContextData("push") },
|
||||
{ "ref", new StringContextData("refs/heads/main") }
|
||||
};
|
||||
var exprValues = new DictionaryContextData();
|
||||
exprValues["github"] = new DictionaryContextData
|
||||
{
|
||||
{ "event", innerDict }
|
||||
};
|
||||
|
||||
var ctx = CreateMockContext(exprValues);
|
||||
// "github" is at index 0 → ref 1
|
||||
var variables = _provider.GetVariables(ctx.Object, 1);
|
||||
|
||||
var eventVar = variables.Find(v => v.Name == "event");
|
||||
Assert.NotNull(eventVar);
|
||||
Assert.Equal("object", eventVar.Type);
|
||||
Assert.True(eventVar.VariablesReference > 0, "Nested dict should have a non-zero variablesReference");
|
||||
Assert.Equal(2, eventVar.NamedVariables);
|
||||
|
||||
// Now expand it
|
||||
var children = _provider.GetVariables(ctx.Object, eventVar.VariablesReference);
|
||||
Assert.Equal(2, children.Count);
|
||||
|
||||
var nameVar = children.Find(v => v.Name == "name");
|
||||
Assert.NotNull(nameVar);
|
||||
Assert.Equal("push", nameVar.Value);
|
||||
Assert.Equal("${{ github.event.name }}", nameVar.EvaluateName);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetVariables_NestedArrayIsExpandable()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var array = new ArrayContextData();
|
||||
array.Add(new StringContextData("item0"));
|
||||
array.Add(new StringContextData("item1"));
|
||||
|
||||
var exprValues = new DictionaryContextData();
|
||||
exprValues["env"] = new DictionaryContextData
|
||||
{
|
||||
{ "list", array }
|
||||
};
|
||||
|
||||
var ctx = CreateMockContext(exprValues);
|
||||
var variables = _provider.GetVariables(ctx.Object, 2);
|
||||
|
||||
var listVar = variables.Find(v => v.Name == "list");
|
||||
Assert.NotNull(listVar);
|
||||
Assert.Equal("array", listVar.Type);
|
||||
Assert.True(listVar.VariablesReference > 0);
|
||||
Assert.Equal(2, listVar.IndexedVariables);
|
||||
|
||||
// Expand the array
|
||||
var items = _provider.GetVariables(ctx.Object, listVar.VariablesReference);
|
||||
Assert.Equal(2, items.Count);
|
||||
Assert.Equal("[0]", items[0].Name);
|
||||
Assert.Equal("item0", items[0].Value);
|
||||
Assert.Equal("[1]", items[1].Name);
|
||||
Assert.Equal("item1", items[1].Value);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Secret masking
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetVariables_SecretsScopeValuesAreRedacted()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var exprValues = new DictionaryContextData();
|
||||
exprValues["secrets"] = new DictionaryContextData
|
||||
{
|
||||
{ "MY_TOKEN", new StringContextData("ghp_abc123secret") },
|
||||
{ "DB_PASSWORD", new StringContextData("p@ssword!") }
|
||||
};
|
||||
|
||||
var ctx = CreateMockContext(exprValues);
|
||||
// "secrets" is at index 5 → ref 6
|
||||
var variables = _provider.GetVariables(ctx.Object, 6);
|
||||
|
||||
Assert.Equal(2, variables.Count);
|
||||
foreach (var v in variables)
|
||||
{
|
||||
Assert.Equal("***", v.Value);
|
||||
Assert.Equal("string", v.Type);
|
||||
}
|
||||
|
||||
// Keys should still be visible
|
||||
Assert.Contains(variables, v => v.Name == "MY_TOKEN");
|
||||
Assert.Contains(variables, v => v.Name == "DB_PASSWORD");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetVariables_NonSecretScopeValuesMaskedBySecretMasker()
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
// Register a known secret value with the masker
|
||||
hc.SecretMasker.AddValue("super-secret-token");
|
||||
|
||||
var exprValues = new DictionaryContextData();
|
||||
exprValues["env"] = new DictionaryContextData
|
||||
{
|
||||
{ "SAFE", new StringContextData("hello world") },
|
||||
{ "LEAKED", new StringContextData("prefix-super-secret-token-suffix") }
|
||||
};
|
||||
|
||||
var ctx = CreateMockContext(exprValues);
|
||||
var variables = _provider.GetVariables(ctx.Object, 2);
|
||||
|
||||
var safeVar = variables.Find(v => v.Name == "SAFE");
|
||||
Assert.NotNull(safeVar);
|
||||
Assert.Equal("hello world", safeVar.Value);
|
||||
|
||||
var leakedVar = variables.Find(v => v.Name == "LEAKED");
|
||||
Assert.NotNull(leakedVar);
|
||||
Assert.DoesNotContain("super-secret-token", leakedVar.Value);
|
||||
Assert.Contains("***", leakedVar.Value);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Reset
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Reset_InvalidatesNestedReferences()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var innerDict = new DictionaryContextData
|
||||
{
|
||||
{ "name", new StringContextData("push") }
|
||||
};
|
||||
var exprValues = new DictionaryContextData();
|
||||
exprValues["github"] = new DictionaryContextData
|
||||
{
|
||||
{ "event", innerDict }
|
||||
};
|
||||
|
||||
var ctx = CreateMockContext(exprValues);
|
||||
var variables = _provider.GetVariables(ctx.Object, 1);
|
||||
var eventVar = variables.Find(v => v.Name == "event");
|
||||
Assert.True(eventVar.VariablesReference > 0);
|
||||
|
||||
var savedRef = eventVar.VariablesReference;
|
||||
|
||||
// Reset should clear all dynamic references
|
||||
_provider.Reset();
|
||||
|
||||
var children = _provider.GetVariables(ctx.Object, savedRef);
|
||||
Assert.Empty(children);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region EvaluateName
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetVariables_SetsEvaluateNameWithDotPath()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var exprValues = new DictionaryContextData();
|
||||
exprValues["github"] = new DictionaryContextData
|
||||
{
|
||||
{ "repository", new StringContextData("owner/repo") }
|
||||
};
|
||||
|
||||
var ctx = CreateMockContext(exprValues);
|
||||
var variables = _provider.GetVariables(ctx.Object, 1);
|
||||
|
||||
var repoVar = variables.Find(v => v.Name == "repository");
|
||||
Assert.NotNull(repoVar);
|
||||
Assert.Equal("${{ github.repository }}", repoVar.EvaluateName);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region EvaluateExpression
|
||||
|
||||
/// <summary>
|
||||
/// Creates a mock execution context with Global set up so that
|
||||
/// ToPipelineTemplateEvaluator() works for real expression evaluation.
|
||||
/// </summary>
|
||||
private Moq.Mock<IExecutionContext> CreateEvaluatableContext(
|
||||
TestHostContext hc,
|
||||
DictionaryContextData expressionValues)
|
||||
{
|
||||
var mock = new Moq.Mock<IExecutionContext>();
|
||||
mock.Setup(x => x.ExpressionValues).Returns(expressionValues);
|
||||
mock.Setup(x => x.ExpressionFunctions)
|
||||
.Returns(new List<GitHub.DistributedTask.Expressions2.IFunctionInfo>());
|
||||
mock.Setup(x => x.Global).Returns(new GlobalContext
|
||||
{
|
||||
FileTable = new List<string>(),
|
||||
Variables = new Variables(hc, new Dictionary<string, VariableValue>()),
|
||||
});
|
||||
// ToPipelineTemplateEvaluator uses ToTemplateTraceWriter which calls
|
||||
// context.Write — provide a no-op so it doesn't NRE.
|
||||
mock.Setup(x => x.Write(Moq.It.IsAny<string>(), Moq.It.IsAny<string>()));
|
||||
return mock;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateExpression_ReturnsValueForSimpleExpression()
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
var exprValues = new DictionaryContextData();
|
||||
exprValues["github"] = new DictionaryContextData
|
||||
{
|
||||
{ "repository", new StringContextData("owner/repo") }
|
||||
};
|
||||
|
||||
var ctx = CreateEvaluatableContext(hc, exprValues);
|
||||
var result = _provider.EvaluateExpression("github.repository", ctx.Object);
|
||||
|
||||
Assert.Equal("owner/repo", result.Result);
|
||||
Assert.Equal("string", result.Type);
|
||||
Assert.Equal(0, result.VariablesReference);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateExpression_StripsWrapperSyntax()
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
var exprValues = new DictionaryContextData();
|
||||
exprValues["github"] = new DictionaryContextData
|
||||
{
|
||||
{ "event_name", new StringContextData("push") }
|
||||
};
|
||||
|
||||
var ctx = CreateEvaluatableContext(hc, exprValues);
|
||||
var result = _provider.EvaluateExpression("${{ github.event_name }}", ctx.Object);
|
||||
|
||||
Assert.Equal("push", result.Result);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateExpression_MasksSecretInResult()
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
hc.SecretMasker.AddValue("super-secret");
|
||||
|
||||
var exprValues = new DictionaryContextData();
|
||||
exprValues["env"] = new DictionaryContextData
|
||||
{
|
||||
{ "TOKEN", new StringContextData("super-secret") }
|
||||
};
|
||||
|
||||
var ctx = CreateEvaluatableContext(hc, exprValues);
|
||||
var result = _provider.EvaluateExpression("env.TOKEN", ctx.Object);
|
||||
|
||||
Assert.DoesNotContain("super-secret", result.Result);
|
||||
Assert.Contains("***", result.Result);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateExpression_ReturnsErrorForInvalidExpression()
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
var exprValues = new DictionaryContextData();
|
||||
exprValues["github"] = new DictionaryContextData();
|
||||
|
||||
var ctx = CreateEvaluatableContext(hc, exprValues);
|
||||
// An invalid expression syntax should not throw — it should
|
||||
// return an error result.
|
||||
var result = _provider.EvaluateExpression("!!!invalid[[", ctx.Object);
|
||||
|
||||
Assert.Contains("error", result.Result, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateExpression_ReturnsMessageWhenNoContext()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var result = _provider.EvaluateExpression("github.repository", null);
|
||||
|
||||
Assert.Contains("no execution context", result.Result, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateExpression_ReturnsEmptyForEmptyExpression()
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
var exprValues = new DictionaryContextData();
|
||||
var ctx = CreateEvaluatableContext(hc, exprValues);
|
||||
var result = _provider.EvaluateExpression("", ctx.Object);
|
||||
|
||||
Assert.Equal(string.Empty, result.Result);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region InferResultType
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void InferResultType_ClassifiesCorrectly()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
Assert.Equal("null", DapVariableProvider.InferResultType(null));
|
||||
Assert.Equal("null", DapVariableProvider.InferResultType("null"));
|
||||
Assert.Equal("boolean", DapVariableProvider.InferResultType("true"));
|
||||
Assert.Equal("boolean", DapVariableProvider.InferResultType("false"));
|
||||
Assert.Equal("number", DapVariableProvider.InferResultType("42"));
|
||||
Assert.Equal("number", DapVariableProvider.InferResultType("3.14"));
|
||||
Assert.Equal("object", DapVariableProvider.InferResultType("{\"key\":\"val\"}"));
|
||||
Assert.Equal("object", DapVariableProvider.InferResultType("[1,2,3]"));
|
||||
Assert.Equal("string", DapVariableProvider.InferResultType("hello world"));
|
||||
Assert.Equal("string", DapVariableProvider.InferResultType("owner/repo"));
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Non-string secret type redaction
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetVariables_SecretsScopeRedactsNumberContextData()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var exprValues = new DictionaryContextData();
|
||||
exprValues["secrets"] = new DictionaryContextData
|
||||
{
|
||||
{ "NUMERIC_SECRET", new NumberContextData(12345) }
|
||||
};
|
||||
|
||||
var ctx = CreateMockContext(exprValues);
|
||||
var variables = _provider.GetVariables(ctx.Object, 6);
|
||||
|
||||
Assert.Single(variables);
|
||||
Assert.Equal("NUMERIC_SECRET", variables[0].Name);
|
||||
Assert.Equal("***", variables[0].Value);
|
||||
Assert.Equal("string", variables[0].Type);
|
||||
Assert.Equal(0, variables[0].VariablesReference);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetVariables_SecretsScopeRedactsBooleanContextData()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var exprValues = new DictionaryContextData();
|
||||
exprValues["secrets"] = new DictionaryContextData
|
||||
{
|
||||
{ "BOOL_SECRET", new BooleanContextData(true) }
|
||||
};
|
||||
|
||||
var ctx = CreateMockContext(exprValues);
|
||||
var variables = _provider.GetVariables(ctx.Object, 6);
|
||||
|
||||
Assert.Single(variables);
|
||||
Assert.Equal("BOOL_SECRET", variables[0].Name);
|
||||
Assert.Equal("***", variables[0].Value);
|
||||
Assert.Equal("string", variables[0].Type);
|
||||
Assert.Equal(0, variables[0].VariablesReference);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetVariables_SecretsScopeRedactsNestedDictionary()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var exprValues = new DictionaryContextData();
|
||||
exprValues["secrets"] = new DictionaryContextData
|
||||
{
|
||||
{ "NESTED_SECRET", new DictionaryContextData
|
||||
{
|
||||
{ "inner_key", new StringContextData("inner_value") }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var ctx = CreateMockContext(exprValues);
|
||||
var variables = _provider.GetVariables(ctx.Object, 6);
|
||||
|
||||
Assert.Single(variables);
|
||||
Assert.Equal("NESTED_SECRET", variables[0].Name);
|
||||
Assert.Equal("***", variables[0].Value);
|
||||
Assert.Equal("string", variables[0].Type);
|
||||
// Nested container should NOT be drillable under secrets
|
||||
Assert.Equal(0, variables[0].VariablesReference);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetVariables_SecretsScopeRedactsNullValue()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var exprValues = new DictionaryContextData();
|
||||
var secrets = new DictionaryContextData();
|
||||
secrets["NULL_SECRET"] = null;
|
||||
exprValues["secrets"] = secrets;
|
||||
|
||||
var ctx = CreateMockContext(exprValues);
|
||||
var variables = _provider.GetVariables(ctx.Object, 6);
|
||||
|
||||
Assert.Single(variables);
|
||||
Assert.Equal("NULL_SECRET", variables[0].Name);
|
||||
Assert.Equal("***", variables[0].Value);
|
||||
Assert.Equal(0, variables[0].VariablesReference);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ using GitHub.DistributedTask.Pipelines.ContextData;
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Worker;
|
||||
using GitHub.Runner.Worker.Container;
|
||||
using GitHub.Runner.Worker.Dap;
|
||||
using GitHub.Runner.Worker.Handlers;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
@@ -406,7 +405,6 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
hc.EnqueueInstance(pagingLogger5.Object);
|
||||
hc.EnqueueInstance(actionRunner1 as IActionRunner);
|
||||
hc.EnqueueInstance(actionRunner2 as IActionRunner);
|
||||
hc.SetSingleton(new Mock<IDapDebugger>().Object);
|
||||
hc.SetSingleton(jobServerQueue.Object);
|
||||
|
||||
var jobContext = new Runner.Worker.ExecutionContext();
|
||||
@@ -505,7 +503,6 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
hc.EnqueueInstance(pagingLogger5.Object);
|
||||
hc.EnqueueInstance(actionRunner1 as IActionRunner);
|
||||
hc.EnqueueInstance(actionRunner2 as IActionRunner);
|
||||
hc.SetSingleton(new Mock<IDapDebugger>().Object);
|
||||
hc.SetSingleton(jobServerQueue.Object);
|
||||
|
||||
var jobContext = new Runner.Worker.ExecutionContext();
|
||||
@@ -547,75 +544,6 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void RegisterPostJobAction_DebuggerDisabled_DoesNotInvokeDapDebugger()
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
// Arrange: Create a job request message with EnableDebugger left at the default (false).
|
||||
TaskOrchestrationPlanReference plan = new();
|
||||
TimelineReference timeline = new();
|
||||
Guid jobId = Guid.NewGuid();
|
||||
string jobName = "some job name";
|
||||
var jobRequest = new Pipelines.AgentJobRequestMessage(plan, timeline, jobId, jobName, jobName, null, null, null, new Dictionary<string, VariableValue>(), new List<MaskHint>(), new Pipelines.JobResources(), new Pipelines.ContextData.DictionaryContextData(), new Pipelines.WorkspaceOptions(), new List<Pipelines.ActionStep>(), null, null, null, null, null);
|
||||
jobRequest.Resources.Repositories.Add(new Pipelines.RepositoryResource()
|
||||
{
|
||||
Alias = Pipelines.PipelineConstants.SelfAlias,
|
||||
Id = "github",
|
||||
Version = "sha1"
|
||||
});
|
||||
jobRequest.ContextData["github"] = new Pipelines.ContextData.DictionaryContextData();
|
||||
|
||||
var pagingLogger = new Mock<IPagingLogger>();
|
||||
var jobServerQueue = new Mock<IJobServerQueue>();
|
||||
jobServerQueue.Setup(x => x.QueueTimelineRecordUpdate(It.IsAny<Guid>(), It.IsAny<TimelineRecord>()));
|
||||
jobServerQueue.Setup(x => x.QueueWebConsoleLine(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<long?>()));
|
||||
|
||||
var actionRunner = new ActionRunner();
|
||||
actionRunner.Initialize(hc);
|
||||
|
||||
hc.EnqueueInstance(pagingLogger.Object);
|
||||
hc.EnqueueInstance(pagingLogger.Object);
|
||||
hc.EnqueueInstance(pagingLogger.Object);
|
||||
hc.EnqueueInstance(pagingLogger.Object);
|
||||
hc.EnqueueInstance(pagingLogger.Object);
|
||||
hc.EnqueueInstance(pagingLogger.Object);
|
||||
hc.EnqueueInstance(pagingLogger.Object);
|
||||
hc.EnqueueInstance(actionRunner as IActionRunner);
|
||||
|
||||
// Register a strict mock IDapDebugger. If the production code calls
|
||||
// ANY method on it, the test fails — proving the containment guard
|
||||
// short-circuited before HostContext.GetService<IDapDebugger>().
|
||||
var dapMock = new Mock<IDapDebugger>(MockBehavior.Strict);
|
||||
hc.SetSingleton(dapMock.Object);
|
||||
hc.SetSingleton(jobServerQueue.Object);
|
||||
|
||||
var jobContext = new Runner.Worker.ExecutionContext();
|
||||
jobContext.Initialize(hc);
|
||||
jobContext.InitializeJob(jobRequest, CancellationToken.None);
|
||||
|
||||
var action = jobContext.CreateChild(Guid.NewGuid(), "action_1", "action_1", null, null, 0);
|
||||
|
||||
var postRunner = hc.CreateService<IActionRunner>();
|
||||
postRunner.Action = new Pipelines.ActionStep() { Id = Guid.NewGuid(), Name = "post", DisplayName = "Post", Reference = new Pipelines.RepositoryPathReference() { Name = "actions/action" } };
|
||||
postRunner.Stage = ActionRunStage.Post;
|
||||
postRunner.Condition = "always()";
|
||||
postRunner.DisplayName = "post";
|
||||
|
||||
// Sanity: ensure the production code path actually believes the debugger is disabled.
|
||||
Assert.True(jobContext.Global.Debugger == null || jobContext.Global.Debugger.Enabled == false);
|
||||
|
||||
// Act.
|
||||
action.RegisterPostJobStep(postRunner);
|
||||
|
||||
// Assert: the debugger was never consulted on the non-debug path.
|
||||
dapMock.VerifyNoOtherCalls();
|
||||
Assert.Equal(1, jobContext.PostJobSteps.Count);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
@@ -1275,19 +1203,19 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// TODO: this test can be deleted when `AddCheckRunIdToJobContext` is fully rolled out
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void InitializeJob_HydratesJobContextWithCheckRunId_AlwaysCopied()
|
||||
public void InitializeJob_HydratesJobContextWithCheckRunId_FeatureFlagDisabled()
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
// Arrange: No feature flag set at all
|
||||
var variables = new Dictionary<string, VariableValue>();
|
||||
// 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"),
|
||||
};
|
||||
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>();
|
||||
@@ -1305,80 +1233,9 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
// Act
|
||||
ec.InitializeJob(jobRequest, CancellationToken.None);
|
||||
|
||||
// Assert: check_run_id is always copied regardless of flag
|
||||
// Assert
|
||||
Assert.NotNull(ec.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);
|
||||
Assert.Null(ec.JobContext.CheckRunId); // with the feature flag disabled we should not have added a CheckRunId to the JobContext
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,109 +34,5 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,442 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,660 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.DistributedTask.Pipelines;
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Worker;
|
||||
using GitHub.Runner.Worker.Dap;
|
||||
using Moq;
|
||||
using Newtonsoft.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace GitHub.Runner.Common.Tests.Worker
|
||||
{
|
||||
public sealed class JobExecutionViewLifecycleL0
|
||||
{
|
||||
private DapDebugger _debugger;
|
||||
|
||||
private TestHostContext CreateTestContext([CallerMemberName] string testName = "")
|
||||
{
|
||||
var hc = new TestHostContext(this, testName);
|
||||
_debugger = new DapDebugger();
|
||||
_debugger.Initialize(hc);
|
||||
_debugger.SkipTunnelRelay = true;
|
||||
_debugger.SkipWebSocketBridge = true;
|
||||
return hc;
|
||||
}
|
||||
|
||||
private static ushort GetFreePort()
|
||||
{
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
return (ushort)((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
}
|
||||
|
||||
private static Mock<IExecutionContext> CreateJobContextWithTunnel(CancellationToken cancellationToken, ushort port, string jobName = "ci-job")
|
||||
{
|
||||
var tunnel = new GitHub.DistributedTask.Pipelines.DebuggerTunnelInfo
|
||||
{
|
||||
TunnelId = "test-tunnel",
|
||||
ClusterId = "test-cluster",
|
||||
HostToken = "test-token",
|
||||
Port = port
|
||||
};
|
||||
var debuggerConfig = new DebuggerConfig(true, tunnel);
|
||||
var jobContext = new Mock<IExecutionContext>();
|
||||
jobContext.Setup(x => x.CancellationToken).Returns(cancellationToken);
|
||||
jobContext.Setup(x => x.Global).Returns(new GlobalContext { Debugger = debuggerConfig });
|
||||
jobContext
|
||||
.Setup(x => x.GetGitHubContext(It.IsAny<string>()))
|
||||
.Returns((string contextName) => string.Equals(contextName, "job", StringComparison.Ordinal) ? jobName : null);
|
||||
return jobContext;
|
||||
}
|
||||
|
||||
private static async Task DriveToReadyAsync(DapDebugger debugger, int port)
|
||||
{
|
||||
var waitTask = debugger.WaitUntilReadyAsync();
|
||||
var client = new TcpClient();
|
||||
await client.ConnectAsync(IPAddress.Loopback, port);
|
||||
var stream = client.GetStream();
|
||||
var request = new Request { Seq = 1, Type = "request", Command = "configurationDone" };
|
||||
var json = JsonConvert.SerializeObject(request);
|
||||
var body = Encoding.UTF8.GetBytes(json);
|
||||
var header = Encoding.ASCII.GetBytes($"Content-Length: {body.Length}\r\n\r\n");
|
||||
await stream.WriteAsync(header, 0, header.Length);
|
||||
await stream.WriteAsync(body, 0, body.Length);
|
||||
await stream.FlushAsync();
|
||||
await waitTask;
|
||||
// Keep client alive by holding a reference via GC root in caller scope.
|
||||
// We deliberately don't dispose here; tests dispose the context.
|
||||
_ = client;
|
||||
}
|
||||
|
||||
private static Mock<IActionRunner> NewActionRunner(ActionRunStage stage, string displayName, string actionName = "actions/checkout", string actionRef = "v4", Guid actionId = default)
|
||||
{
|
||||
var mock = new Mock<IActionRunner>();
|
||||
mock.SetupGet(x => x.Stage).Returns(stage);
|
||||
mock.SetupGet(x => x.DisplayName).Returns(displayName);
|
||||
mock.SetupGet(x => x.Action).Returns(new ActionStep
|
||||
{
|
||||
Id = actionId,
|
||||
Reference = new RepositoryPathReference { Name = actionName, Ref = actionRef },
|
||||
});
|
||||
return mock;
|
||||
}
|
||||
|
||||
private static Mock<IActionRunner> NewSelfActionRunner(ActionRunStage stage, string displayName, Guid actionId = default)
|
||||
{
|
||||
// RepositoryType = "self" — the predictor must skip these.
|
||||
var mock = new Mock<IActionRunner>();
|
||||
mock.SetupGet(x => x.Stage).Returns(stage);
|
||||
mock.SetupGet(x => x.DisplayName).Returns(displayName);
|
||||
mock.SetupGet(x => x.Action).Returns(new ActionStep
|
||||
{
|
||||
Id = actionId,
|
||||
Reference = new RepositoryPathReference
|
||||
{
|
||||
RepositoryType = GitHub.DistributedTask.Pipelines.PipelineConstants.SelfAlias,
|
||||
Path = "./.github/actions/local",
|
||||
},
|
||||
});
|
||||
return mock;
|
||||
}
|
||||
|
||||
private static Mock<IActionRunner> NewScriptActionRunner(ActionRunStage stage, string displayName, Guid actionId = default)
|
||||
{
|
||||
// ScriptReference — a `run:` step. Not a RepositoryPathReference,
|
||||
// so the predictor's pattern match falls through.
|
||||
var mock = new Mock<IActionRunner>();
|
||||
mock.SetupGet(x => x.Stage).Returns(stage);
|
||||
mock.SetupGet(x => x.DisplayName).Returns(displayName);
|
||||
mock.SetupGet(x => x.Action).Returns(new ActionStep
|
||||
{
|
||||
Id = actionId,
|
||||
Reference = new ScriptReference(),
|
||||
});
|
||||
return mock;
|
||||
}
|
||||
|
||||
// IActionManager mock that returns specific Definitions per action by
|
||||
// matching on the action's reference Name. Actions whose name is not
|
||||
// in the map get a Definition with HasPost = false.
|
||||
private static Mock<IActionManager> NewActionManagerWithPost(params string[] actionNamesWithPost)
|
||||
{
|
||||
var withPost = new HashSet<string>(actionNamesWithPost, StringComparer.Ordinal);
|
||||
var mock = new Mock<IActionManager>();
|
||||
mock.Setup(x => x.LoadAction(It.IsAny<IExecutionContext>(), It.IsAny<ActionStep>()))
|
||||
.Returns((IExecutionContext _, ActionStep step) =>
|
||||
{
|
||||
var name = (step.Reference as RepositoryPathReference)?.Name ?? "";
|
||||
return new Definition
|
||||
{
|
||||
Data = new ActionDefinitionData
|
||||
{
|
||||
Execution = withPost.Contains(name)
|
||||
? new NodeJSActionExecutionData { Post = "post.js" }
|
||||
: new NodeJSActionExecutionData(),
|
||||
},
|
||||
};
|
||||
});
|
||||
return mock;
|
||||
}
|
||||
|
||||
private static IStep NewJobExtensionRunner(string displayName)
|
||||
{
|
||||
return new JobExtensionRunner(
|
||||
runAsync: (_, __) => Task.CompletedTask,
|
||||
condition: null,
|
||||
displayName: displayName,
|
||||
data: null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task OnJobStepsInitialized_NotActive_NoOps()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var step = NewActionRunner(ActionRunStage.Main, "Run").Object;
|
||||
|
||||
await _debugger.OnJobStepsInitializedAsync(new[] { step }, Array.Empty<IStep>());
|
||||
|
||||
Assert.Null(_debugger.ExecutionView);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task OnPostStepRegistered_NotActive_NoOps()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var step = NewActionRunner(ActionRunStage.Post, "Post Run").Object;
|
||||
_debugger.OnPostStepRegistered(step); // must not throw
|
||||
Assert.Null(_debugger.ExecutionView);
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task OnJobStepsInitialized_Active_BuildsView()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var port = GetFreePort();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
|
||||
await _debugger.StartAsync(jobContext.Object);
|
||||
try
|
||||
{
|
||||
await DriveToReadyAsync(_debugger, port);
|
||||
|
||||
var main1 = NewActionRunner(ActionRunStage.Main, "Run actions/checkout@v4").Object;
|
||||
var main2 = NewActionRunner(ActionRunStage.Main, "Run actions/setup-node@v3", "actions/setup-node", "v3").Object;
|
||||
var jobExt = NewJobExtensionRunner("Set up job");
|
||||
var post1 = NewActionRunner(ActionRunStage.Post, "Post Run actions/checkout@v4").Object;
|
||||
|
||||
await _debugger.OnJobStepsInitializedAsync(
|
||||
new IStep[] { main1, jobExt, main2 },
|
||||
new IStep[] { post1 });
|
||||
|
||||
var view = _debugger.ExecutionView;
|
||||
Assert.NotNull(view);
|
||||
Assert.Equal(3, view.EntryCount); // jobExt filtered out
|
||||
Assert.Contains("Run actions/checkout@v4", view.Yaml);
|
||||
Assert.Contains("Run actions/setup-node@v3", view.Yaml);
|
||||
Assert.Contains("Post Run actions/checkout@v4", view.Yaml);
|
||||
Assert.NotNull(view.TryGetLineForStep(main1));
|
||||
Assert.NotNull(view.TryGetLineForStep(main2));
|
||||
Assert.NotNull(view.TryGetLineForStep(post1));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _debugger.StopAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task OnJobStepsInitialized_PreservesQueueOrder()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var port = GetFreePort();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
|
||||
await _debugger.StartAsync(jobContext.Object);
|
||||
try
|
||||
{
|
||||
await DriveToReadyAsync(_debugger, port);
|
||||
|
||||
var s1 = NewActionRunner(ActionRunStage.Main, "Step 1", "a/b", "v1").Object;
|
||||
var s2 = NewActionRunner(ActionRunStage.Main, "Step 2", "c/d", "v2").Object;
|
||||
var s3 = NewActionRunner(ActionRunStage.Main, "Step 3", "e/f", "v3").Object;
|
||||
|
||||
await _debugger.OnJobStepsInitializedAsync(new[] { s1, s2, s3 }, Array.Empty<IStep>());
|
||||
|
||||
var view = _debugger.ExecutionView;
|
||||
Assert.Equal(3, view.EntryCount);
|
||||
var l1 = view.TryGetLineForStep(s1);
|
||||
var l2 = view.TryGetLineForStep(s2);
|
||||
var l3 = view.TryGetLineForStep(s3);
|
||||
Assert.NotNull(l1);
|
||||
Assert.NotNull(l2);
|
||||
Assert.NotNull(l3);
|
||||
Assert.True(l1 < l2);
|
||||
Assert.True(l2 < l3);
|
||||
Assert.Equal(view.GetLine(0), l1);
|
||||
Assert.Equal(view.GetLine(1), l2);
|
||||
Assert.Equal(view.GetLine(2), l3);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _debugger.StopAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task OnPostStepRegistered_AppendsToView()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var port = GetFreePort();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
|
||||
await _debugger.StartAsync(jobContext.Object);
|
||||
try
|
||||
{
|
||||
await DriveToReadyAsync(_debugger, port);
|
||||
|
||||
var main1 = NewActionRunner(ActionRunStage.Main, "Run actions/checkout@v4").Object;
|
||||
await _debugger.OnJobStepsInitializedAsync(new[] { main1 }, Array.Empty<IStep>());
|
||||
Assert.Equal(1, _debugger.ExecutionView.EntryCount);
|
||||
|
||||
var post1 = NewActionRunner(ActionRunStage.Post, "Post Run actions/cache@v3", "actions/cache", "v3").Object;
|
||||
_debugger.OnPostStepRegistered(post1);
|
||||
|
||||
var view = _debugger.ExecutionView;
|
||||
Assert.Equal(2, view.EntryCount);
|
||||
Assert.Contains("Post Run actions/cache@v3", view.Yaml);
|
||||
Assert.NotNull(view.TryGetLineForStep(post1));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _debugger.StopAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task OnPostStepRegistered_BeforeViewBuilt_NoOps()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var port = GetFreePort();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
|
||||
await _debugger.StartAsync(jobContext.Object);
|
||||
try
|
||||
{
|
||||
await DriveToReadyAsync(_debugger, port);
|
||||
|
||||
var post = NewActionRunner(ActionRunStage.Post, "Post Run").Object;
|
||||
_debugger.OnPostStepRegistered(post); // must not throw
|
||||
|
||||
Assert.Null(_debugger.ExecutionView);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _debugger.StopAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task OnPostStepRegistered_DuplicateStep_DoesNotThrow()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var port = GetFreePort();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
|
||||
await _debugger.StartAsync(jobContext.Object);
|
||||
try
|
||||
{
|
||||
await DriveToReadyAsync(_debugger, port);
|
||||
await _debugger.OnJobStepsInitializedAsync(Array.Empty<IStep>(), Array.Empty<IStep>());
|
||||
|
||||
var post = NewActionRunner(ActionRunStage.Post, "Post Run").Object;
|
||||
_debugger.OnPostStepRegistered(post);
|
||||
_debugger.OnPostStepRegistered(post); // duplicate, must be silently ignored
|
||||
|
||||
Assert.Equal(1, _debugger.ExecutionView.EntryCount);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _debugger.StopAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task OnPostStepRegistered_FilteredStep_NoOps()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var port = GetFreePort();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
|
||||
await _debugger.StartAsync(jobContext.Object);
|
||||
try
|
||||
{
|
||||
await DriveToReadyAsync(_debugger, port);
|
||||
await _debugger.OnJobStepsInitializedAsync(Array.Empty<IStep>(), Array.Empty<IStep>());
|
||||
|
||||
var before = _debugger.ExecutionView.EntryCount;
|
||||
_debugger.OnPostStepRegistered(NewJobExtensionRunner("Cleanup"));
|
||||
Assert.Equal(before, _debugger.ExecutionView.EntryCount);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _debugger.StopAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Predictive Post-step synthesis ----
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task OnJobStepsInitialized_PredictsPostForActionsWithHasPost()
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
hc.SetSingleton<IActionManager>(NewActionManagerWithPost("actions/has-post").Object);
|
||||
|
||||
var port = GetFreePort();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
|
||||
await _debugger.StartAsync(jobContext.Object);
|
||||
try
|
||||
{
|
||||
await DriveToReadyAsync(_debugger, port);
|
||||
|
||||
var withPost = NewActionRunner(ActionRunStage.Main, "Run actions/has-post@v1", "actions/has-post", "v1", actionId: Guid.NewGuid()).Object;
|
||||
var noPost = NewActionRunner(ActionRunStage.Main, "Run actions/no-post@v1", "actions/no-post", "v1", actionId: Guid.NewGuid()).Object;
|
||||
|
||||
await _debugger.OnJobStepsInitializedAsync(new[] { withPost, noPost }, Array.Empty<IStep>());
|
||||
|
||||
var view = _debugger.ExecutionView;
|
||||
Assert.NotNull(view);
|
||||
// 2 main entries + 1 predicted post placeholder.
|
||||
Assert.Equal(3, view.EntryCount);
|
||||
Assert.Contains("post:\n", view.Yaml);
|
||||
Assert.Contains("Post Run actions/has-post@v1", view.Yaml);
|
||||
Assert.DoesNotContain("Post Run actions/no-post@v1", view.Yaml);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _debugger.StopAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task OnJobStepsInitialized_PostPredictionsInReverseOrder()
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
// Both actions have post — predictions must render in
|
||||
// reverse declaration order to mirror the runner's LIFO
|
||||
// post-execution order.
|
||||
hc.SetSingleton<IActionManager>(NewActionManagerWithPost("actions/a", "actions/b").Object);
|
||||
|
||||
var port = GetFreePort();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
|
||||
await _debugger.StartAsync(jobContext.Object);
|
||||
try
|
||||
{
|
||||
await DriveToReadyAsync(_debugger, port);
|
||||
|
||||
var aMain = NewActionRunner(ActionRunStage.Main, "Run actions/a@v1", "actions/a", "v1", actionId: Guid.NewGuid()).Object;
|
||||
var bMain = NewActionRunner(ActionRunStage.Main, "Run actions/b@v1", "actions/b", "v1", actionId: Guid.NewGuid()).Object;
|
||||
|
||||
await _debugger.OnJobStepsInitializedAsync(new[] { aMain, bMain }, Array.Empty<IStep>());
|
||||
|
||||
string yaml = _debugger.ExecutionView.Yaml;
|
||||
int idxPostB = yaml.IndexOf("Post Run actions/b@v1", StringComparison.Ordinal);
|
||||
int idxPostA = yaml.IndexOf("Post Run actions/a@v1", StringComparison.Ordinal);
|
||||
Assert.True(idxPostB > 0 && idxPostA > 0, "both post placeholders expected");
|
||||
// Reverse declaration order: Post B appears BEFORE Post A.
|
||||
Assert.True(idxPostB < idxPostA, $"expected Post B before Post A (b={idxPostB} a={idxPostA})");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _debugger.StopAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task OnJobStepsInitialized_SkipsScriptSteps()
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
// Even if the action manager would say HasPost, the predictor
|
||||
// must skip script run-steps because their reference is not
|
||||
// a RepositoryPathReference.
|
||||
hc.SetSingleton<IActionManager>(NewActionManagerWithPost(/* nothing */).Object);
|
||||
|
||||
var port = GetFreePort();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
|
||||
await _debugger.StartAsync(jobContext.Object);
|
||||
try
|
||||
{
|
||||
await DriveToReadyAsync(_debugger, port);
|
||||
|
||||
var script = NewScriptActionRunner(ActionRunStage.Main, "Run script", Guid.NewGuid()).Object;
|
||||
await _debugger.OnJobStepsInitializedAsync(new[] { script }, Array.Empty<IStep>());
|
||||
|
||||
var view = _debugger.ExecutionView;
|
||||
Assert.NotNull(view);
|
||||
Assert.DoesNotContain("post:\n", view.Yaml);
|
||||
Assert.DoesNotContain("Post ", view.Yaml);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _debugger.StopAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task OnJobStepsInitialized_SkipsSelfActions()
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
// Self-action: ActionRunner.cs:106 guards against creating a
|
||||
// Post for self-repository references. The predictor mirrors
|
||||
// that, regardless of what the manifest reports.
|
||||
hc.SetSingleton<IActionManager>(NewActionManagerWithPost("anything").Object);
|
||||
|
||||
var port = GetFreePort();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
|
||||
await _debugger.StartAsync(jobContext.Object);
|
||||
try
|
||||
{
|
||||
await DriveToReadyAsync(_debugger, port);
|
||||
|
||||
var selfRunner = NewSelfActionRunner(ActionRunStage.Main, "Run ./local-action", Guid.NewGuid()).Object;
|
||||
await _debugger.OnJobStepsInitializedAsync(new[] { selfRunner }, Array.Empty<IStep>());
|
||||
|
||||
Assert.DoesNotContain("post:\n", _debugger.ExecutionView.Yaml);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _debugger.StopAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task OnPostStepRegistered_ClaimsExistingPlaceholder()
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
hc.SetSingleton<IActionManager>(NewActionManagerWithPost("actions/has-post").Object);
|
||||
|
||||
var port = GetFreePort();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
|
||||
await _debugger.StartAsync(jobContext.Object);
|
||||
try
|
||||
{
|
||||
await DriveToReadyAsync(_debugger, port);
|
||||
|
||||
var actionId = Guid.NewGuid();
|
||||
var mainRunner = NewActionRunner(ActionRunStage.Main, "Run actions/has-post@v1", "actions/has-post", "v1", actionId: actionId).Object;
|
||||
await _debugger.OnJobStepsInitializedAsync(new[] { mainRunner }, Array.Empty<IStep>());
|
||||
|
||||
var view = _debugger.ExecutionView;
|
||||
int before = view.EntryCount;
|
||||
Assert.Equal(2, before); // main + predicted post placeholder
|
||||
|
||||
// The real Post IActionRunner shares the same Action.Id
|
||||
// as the Main runner (ActionRunner.cs:131).
|
||||
var postRunner = NewActionRunner(ActionRunStage.Post, "Post actions/has-post@v1", "actions/has-post", "v1", actionId: actionId).Object;
|
||||
_debugger.OnPostStepRegistered(postRunner);
|
||||
|
||||
// No new entry: the placeholder was claimed.
|
||||
Assert.Equal(before, view.EntryCount);
|
||||
Assert.NotNull(view.TryGetLineForStep(postRunner));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _debugger.StopAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task OnPostStepRegistered_UnpredictedFallsBackToAppend()
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
// Manager returns no HasPost — no predictions made.
|
||||
hc.SetSingleton<IActionManager>(NewActionManagerWithPost(/* nothing */).Object);
|
||||
|
||||
var port = GetFreePort();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
|
||||
await _debugger.StartAsync(jobContext.Object);
|
||||
try
|
||||
{
|
||||
await DriveToReadyAsync(_debugger, port);
|
||||
|
||||
var mainRunner = NewActionRunner(ActionRunStage.Main, "Run actions/a@v1", "actions/a", "v1", actionId: Guid.NewGuid()).Object;
|
||||
await _debugger.OnJobStepsInitializedAsync(new[] { mainRunner }, Array.Empty<IStep>());
|
||||
|
||||
var view = _debugger.ExecutionView;
|
||||
int before = view.EntryCount;
|
||||
Assert.Equal(1, before); // just main, no predicted post
|
||||
|
||||
var unpredictedPost = NewActionRunner(ActionRunStage.Post, "Post Surprise", "actions/surprise", "v1", actionId: Guid.NewGuid()).Object;
|
||||
_debugger.OnPostStepRegistered(unpredictedPost);
|
||||
|
||||
// Falls back to Append.
|
||||
Assert.Equal(before + 1, view.EntryCount);
|
||||
Assert.NotNull(view.TryGetLineForStep(unpredictedPost));
|
||||
Assert.Contains("Post Surprise", view.Yaml);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _debugger.StopAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task OnPostStepRegistered_DuplicateClaim_NoDoubleEntry()
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
hc.SetSingleton<IActionManager>(NewActionManagerWithPost("actions/has-post").Object);
|
||||
|
||||
var port = GetFreePort();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
|
||||
await _debugger.StartAsync(jobContext.Object);
|
||||
try
|
||||
{
|
||||
await DriveToReadyAsync(_debugger, port);
|
||||
|
||||
var actionId = Guid.NewGuid();
|
||||
var mainRunner = NewActionRunner(ActionRunStage.Main, "Run actions/has-post@v1", "actions/has-post", "v1", actionId: actionId).Object;
|
||||
await _debugger.OnJobStepsInitializedAsync(new[] { mainRunner }, Array.Empty<IStep>());
|
||||
Assert.Equal(2, _debugger.ExecutionView.EntryCount);
|
||||
|
||||
// First registration claims the placeholder.
|
||||
var post1 = NewActionRunner(ActionRunStage.Post, "Post actions/has-post@v1", "actions/has-post", "v1", actionId: actionId).Object;
|
||||
_debugger.OnPostStepRegistered(post1);
|
||||
Assert.Equal(2, _debugger.ExecutionView.EntryCount);
|
||||
|
||||
// Second registration with the same Action.Id but a
|
||||
// different IStep: TryClaim returns null (already
|
||||
// claimed). Falls through to Append. But the entry
|
||||
// it builds matches no existing step, so a new entry
|
||||
// would be added — UNLESS we constructed the second
|
||||
// post as a duplicate IStep registration of the same
|
||||
// step. Here we intentionally pass the same `post1`
|
||||
// step a second time — Append will reject the
|
||||
// already-registered step, the handler swallows it.
|
||||
_debugger.OnPostStepRegistered(post1);
|
||||
|
||||
Assert.Equal(2, _debugger.ExecutionView.EntryCount);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _debugger.StopAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,628 +0,0 @@
|
||||
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_ReportsCompleteJobLineMatchingYaml()
|
||||
{
|
||||
// Empty entries — Cleanup still emitted.
|
||||
var emptyResult = JobExecutionViewRenderer.Render("j", new List<JobExecutionViewEntry>());
|
||||
AssertCompleteJobLineMatchesYaml(emptyResult);
|
||||
|
||||
// Non-empty entries across phases.
|
||||
var populatedResult = JobExecutionViewRenderer.Render("build", WorkedExampleEntries());
|
||||
AssertCompleteJobLineMatchesYaml(populatedResult);
|
||||
}
|
||||
|
||||
private static void AssertCompleteJobLineMatchesYaml(RenderResult result)
|
||||
{
|
||||
var lines = result.Yaml.Split('\n');
|
||||
int? actual = null;
|
||||
for (int i = 0; i < lines.Length; i++)
|
||||
{
|
||||
if (lines[i] == " - step: Complete job")
|
||||
{
|
||||
actual = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Assert.NotNull(actual);
|
||||
Assert.Equal(actual.Value, result.CompleteJobLine);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ using GitHub.DistributedTask.ObjectTemplating.Tokens;
|
||||
using GitHub.DistributedTask.Pipelines.ObjectTemplating;
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Worker;
|
||||
using GitHub.Runner.Worker.Dap;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
using Pipelines = GitHub.DistributedTask.Pipelines;
|
||||
@@ -141,7 +140,6 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
hc.SetSingleton(_diagnosticLogManager.Object);
|
||||
hc.SetSingleton(_jobHookProvider.Object);
|
||||
hc.SetSingleton(_snapshotOperationProvider.Object);
|
||||
hc.SetSingleton(new Mock<IDapDebugger>().Object);
|
||||
hc.EnqueueInstance<IPagingLogger>(_logger.Object); // JobExecutionContext
|
||||
hc.EnqueueInstance<IPagingLogger>(_logger.Object); // job start hook
|
||||
hc.EnqueueInstance<IPagingLogger>(_logger.Object); // Initial Job
|
||||
@@ -549,10 +547,6 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
|
||||
var _stepsRunner = new StepsRunner();
|
||||
_stepsRunner.Initialize(hc);
|
||||
|
||||
var mockDapDebugger = new Mock<IDapDebugger>();
|
||||
hc.SetSingleton(mockDapDebugger.Object);
|
||||
|
||||
await _stepsRunner.RunAsync(_jobEc);
|
||||
|
||||
Assert.Equal("Create custom image", snapshotStep.DisplayName);
|
||||
@@ -761,171 +755,5 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Worker;
|
||||
using GitHub.Runner.Worker.Dap;
|
||||
using Moq;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
@@ -84,7 +83,6 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
hc.SetSingleton(_extensions.Object);
|
||||
hc.SetSingleton(_temp.Object);
|
||||
hc.SetSingleton(_diagnosticLogManager.Object);
|
||||
hc.SetSingleton(new Mock<IDapDebugger>().Object);
|
||||
hc.EnqueueInstance<IExecutionContext>(_jobEc);
|
||||
hc.EnqueueInstance<IPagingLogger>(_logger.Object);
|
||||
hc.EnqueueInstance<IJobExtension>(_jobExtension.Object);
|
||||
@@ -177,29 +175,5 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
Assert.Equal(TaskResult.Succeeded, _jobEc.Result);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task DebuggerDisabled_DoesNotInvokeDapDebugger()
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
// Override the lenient IDapDebugger singleton from CreateTestContext
|
||||
// with a strict mock. If the containment guard fails, the production
|
||||
// code will call OnJobStepsInitializedAsync and the strict mock will throw.
|
||||
var dapMock = new Mock<IDapDebugger>(MockBehavior.Strict);
|
||||
hc.SetSingleton(dapMock.Object);
|
||||
|
||||
var message = GetMessage();
|
||||
// EnableDebugger defaults to false on AgentJobRequestMessage.
|
||||
Assert.False(message.EnableDebugger);
|
||||
|
||||
await _jobRunner.RunAsync(message, _tokenSource.Token);
|
||||
|
||||
Assert.Equal(TaskResult.Succeeded, _jobEc.Result);
|
||||
dapMock.VerifyNoOtherCalls();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,428 +0,0 @@
|
||||
using System;
|
||||
using GitHub.DistributedTask.ObjectTemplating.Tokens;
|
||||
using GitHub.DistributedTask.Pipelines;
|
||||
using GitHub.Runner.Worker;
|
||||
using GitHub.Runner.Worker.Dap;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace GitHub.Runner.Common.Tests.Worker
|
||||
{
|
||||
public sealed class StepEntryTranslatorL0
|
||||
{
|
||||
private static StringToken Str(string s) => new(null, null, null, s);
|
||||
|
||||
private static MappingToken Map(params (string Key, TemplateToken Value)[] pairs)
|
||||
{
|
||||
var m = new MappingToken(null, null, null);
|
||||
foreach (var (k, v) in pairs)
|
||||
{
|
||||
m.Add(Str(k), v);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
private static Mock<IActionRunner> NewActionRunnerMock(
|
||||
ActionRunStage stage,
|
||||
string displayName,
|
||||
ActionStepDefinitionReference reference,
|
||||
ActionStep actionOverride = null)
|
||||
{
|
||||
var mock = new Mock<IActionRunner>();
|
||||
mock.SetupGet(x => x.Stage).Returns(stage);
|
||||
mock.SetupGet(x => x.DisplayName).Returns(displayName);
|
||||
mock.SetupGet(x => x.Action).Returns(actionOverride ?? new ActionStep
|
||||
{
|
||||
Reference = reference,
|
||||
});
|
||||
return mock;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Translate_NullStep_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
StepEntryTranslator.TryTranslate(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Translate_JobExtensionRunner_ReturnsNull()
|
||||
{
|
||||
var step = new JobExtensionRunner(
|
||||
runAsync: (_, __) => System.Threading.Tasks.Task.CompletedTask,
|
||||
condition: null,
|
||||
displayName: "Set up job",
|
||||
data: null);
|
||||
|
||||
Assert.Null(StepEntryTranslator.TryTranslate(step));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Translate_OtherIStepType_ReturnsNull()
|
||||
{
|
||||
var mock = new Mock<IStep>();
|
||||
mock.SetupGet(x => x.DisplayName).Returns("custom");
|
||||
|
||||
Assert.Null(StepEntryTranslator.TryTranslate(mock.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Translate_ActionRunnerPre_ReturnsPreEntry()
|
||||
{
|
||||
var reference = new RepositoryPathReference
|
||||
{
|
||||
Name = "actions/checkout",
|
||||
Ref = "v4",
|
||||
};
|
||||
var mock = NewActionRunnerMock(ActionRunStage.Pre, "Pre Run actions/checkout@v4", reference);
|
||||
|
||||
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.Equal(JobExecutionPhase.Pre, entry.Phase);
|
||||
Assert.Equal("Pre Run actions/checkout@v4", entry.DisplayName);
|
||||
Assert.Equal("actions/checkout@v4", entry.Uses);
|
||||
Assert.Null(entry.Run);
|
||||
Assert.Null(entry.SourcePath);
|
||||
Assert.Equal(0, entry.SourceLine);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Translate_ActionRunnerMain_ReturnsMainEntryWithUses()
|
||||
{
|
||||
var reference = new RepositoryPathReference
|
||||
{
|
||||
Name = "actions/setup-node",
|
||||
Path = "subdir",
|
||||
Ref = "v3",
|
||||
};
|
||||
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run actions/setup-node@v3", reference);
|
||||
|
||||
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.Equal(JobExecutionPhase.Main, entry.Phase);
|
||||
Assert.Equal("actions/setup-node/subdir@v3", entry.Uses);
|
||||
Assert.Null(entry.Run);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Translate_ActionRunnerMain_ScriptReference_LeavesUsesNull()
|
||||
{
|
||||
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run echo hi", new ScriptReference());
|
||||
|
||||
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.Equal(JobExecutionPhase.Main, entry.Phase);
|
||||
Assert.Null(entry.Uses);
|
||||
Assert.Null(entry.Run);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Translate_ActionRunnerMain_ContainerReference_UsesImage()
|
||||
{
|
||||
var reference = new ContainerRegistryReference { Image = "alpine:3.18" };
|
||||
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run alpine", reference);
|
||||
|
||||
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.Equal("alpine:3.18", entry.Uses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Translate_ActionRunnerPost_ReturnsPostEntry()
|
||||
{
|
||||
var reference = new RepositoryPathReference { Name = "actions/cache", Ref = "v3" };
|
||||
var mock = NewActionRunnerMock(ActionRunStage.Post, "Post Run actions/cache@v3", reference);
|
||||
|
||||
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.Equal(JobExecutionPhase.Post, entry.Phase);
|
||||
Assert.Equal("Post Run actions/cache@v3", entry.DisplayName);
|
||||
Assert.Equal("actions/cache@v3", entry.Uses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Translate_ActionRunner_NullAction_LeavesUsesNull()
|
||||
{
|
||||
var mock = new Mock<IActionRunner>();
|
||||
mock.SetupGet(x => x.Stage).Returns(ActionRunStage.Main);
|
||||
mock.SetupGet(x => x.DisplayName).Returns("anonymous");
|
||||
mock.SetupGet(x => x.Action).Returns((ActionStep)null);
|
||||
|
||||
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.Equal("anonymous", entry.DisplayName);
|
||||
Assert.Null(entry.Uses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Translate_ActionStep_ExtractsWith()
|
||||
{
|
||||
var reference = new RepositoryPathReference { Name = "actions/cache", Ref = "v5" };
|
||||
var action = new ActionStep
|
||||
{
|
||||
Reference = reference,
|
||||
Inputs = Map(("path", Str("prime-numbers")), ("key", Str("k"))),
|
||||
};
|
||||
var mock = NewActionRunnerMock(ActionRunStage.Main, "Cache", reference, action);
|
||||
|
||||
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.NotNull(entry.WithYaml);
|
||||
Assert.Contains("path: prime-numbers", entry.WithYaml);
|
||||
Assert.Contains("key: k", entry.WithYaml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Translate_ActionStep_PreservesExpressionInWith()
|
||||
{
|
||||
var reference = new RepositoryPathReference { Name = "actions/cache", Ref = "v5" };
|
||||
var action = new ActionStep
|
||||
{
|
||||
Reference = reference,
|
||||
Inputs = Map(("key", Str("${{ runner.os }}-primes"))),
|
||||
};
|
||||
var mock = NewActionRunnerMock(ActionRunStage.Main, "Cache", reference, action);
|
||||
|
||||
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.Contains("${{ runner.os }}-primes", entry.WithYaml);
|
||||
Assert.DoesNotContain("Linux", entry.WithYaml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Translate_RunStep_ExtractsScript()
|
||||
{
|
||||
var action = new ActionStep
|
||||
{
|
||||
Reference = new ScriptReference(),
|
||||
Inputs = Map(("script", Str("echo hi"))),
|
||||
};
|
||||
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run echo", new ScriptReference(), action);
|
||||
|
||||
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.Null(entry.Uses);
|
||||
Assert.Equal("echo hi", entry.Run);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Translate_RunStep_ExtractsShellAndWorkingDirectory()
|
||||
{
|
||||
// The runner stores run-step inputs under the keys defined in
|
||||
// PipelineConstants.ScriptStepInputs (camelCase), NOT their
|
||||
// kebab-case workflow-YAML spellings — see
|
||||
// ActionManifestManagerWrapper:244.
|
||||
var action = new ActionStep
|
||||
{
|
||||
Reference = new ScriptReference(),
|
||||
Inputs = Map(
|
||||
(PipelineConstants.ScriptStepInputs.Script, Str("npm test")),
|
||||
(PipelineConstants.ScriptStepInputs.Shell, Str("bash")),
|
||||
(PipelineConstants.ScriptStepInputs.WorkingDirectory, Str("./api"))),
|
||||
};
|
||||
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run", new ScriptReference(), action);
|
||||
|
||||
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.Equal("npm test", entry.Run);
|
||||
Assert.Equal("bash", entry.Shell);
|
||||
Assert.Equal("./api", entry.WorkingDirectory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Translate_ActionStep_FiltersRunStepKeysFromWith()
|
||||
{
|
||||
// Defensive: an action step's Inputs should not contain
|
||||
// run-step internal keys, but if it did, they must not
|
||||
// surface in the with: rendering.
|
||||
var reference = new RepositoryPathReference { Name = "a/b", Ref = "v1" };
|
||||
var action = new ActionStep
|
||||
{
|
||||
Reference = reference,
|
||||
Inputs = Map(
|
||||
("mode", Str("ci")),
|
||||
(PipelineConstants.ScriptStepInputs.Script, Str("leak")),
|
||||
(PipelineConstants.ScriptStepInputs.Shell, Str("leak")),
|
||||
(PipelineConstants.ScriptStepInputs.WorkingDirectory, Str("leak"))),
|
||||
};
|
||||
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run", reference, action);
|
||||
|
||||
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.NotNull(entry.WithYaml);
|
||||
Assert.Contains("mode: ci", entry.WithYaml);
|
||||
Assert.DoesNotContain("leak", entry.WithYaml);
|
||||
Assert.DoesNotContain(PipelineConstants.ScriptStepInputs.Script, entry.WithYaml);
|
||||
Assert.DoesNotContain(PipelineConstants.ScriptStepInputs.Shell, entry.WithYaml);
|
||||
Assert.DoesNotContain(PipelineConstants.ScriptStepInputs.WorkingDirectory, entry.WithYaml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Translate_ActionStep_OmitsEmptyEnv()
|
||||
{
|
||||
var reference = new RepositoryPathReference { Name = "a/b", Ref = "v1" };
|
||||
var action = new ActionStep
|
||||
{
|
||||
Reference = reference,
|
||||
Environment = new MappingToken(null, null, null),
|
||||
};
|
||||
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run", reference, action);
|
||||
|
||||
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.Null(entry.EnvYaml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Translate_ActionStep_ExtractsEnv()
|
||||
{
|
||||
var reference = new RepositoryPathReference { Name = "a/b", Ref = "v1" };
|
||||
var action = new ActionStep
|
||||
{
|
||||
Reference = reference,
|
||||
Environment = Map(("NODE_ENV", Str("production"))),
|
||||
};
|
||||
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run", reference, action);
|
||||
|
||||
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.NotNull(entry.EnvYaml);
|
||||
Assert.Contains("NODE_ENV: production", entry.EnvYaml);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("__1")]
|
||||
[InlineData("__123")]
|
||||
public void Translate_FiltersAutoGeneratedId(string contextName)
|
||||
{
|
||||
var reference = new RepositoryPathReference { Name = "a/b", Ref = "v1" };
|
||||
var action = new ActionStep
|
||||
{
|
||||
Reference = reference,
|
||||
ContextName = contextName,
|
||||
};
|
||||
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run", reference, action);
|
||||
|
||||
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.Null(entry.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Translate_PreservesUserId()
|
||||
{
|
||||
var reference = new RepositoryPathReference { Name = "a/b", Ref = "v1" };
|
||||
var action = new ActionStep
|
||||
{
|
||||
Reference = reference,
|
||||
ContextName = "cache-primes",
|
||||
};
|
||||
var mock = NewActionRunnerMock(ActionRunStage.Main, "Cache", reference, action);
|
||||
|
||||
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.Equal("cache-primes", entry.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Translate_ActionStep_ExtractsCondition()
|
||||
{
|
||||
var reference = new RepositoryPathReference { Name = "a/b", Ref = "v1" };
|
||||
var action = new ActionStep
|
||||
{
|
||||
Reference = reference,
|
||||
Condition = "always()",
|
||||
};
|
||||
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run", reference, action);
|
||||
|
||||
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.Equal("always()", entry.If);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Translate_PreEntry_OmitsUserParams()
|
||||
{
|
||||
// Pre entries stay minimal — they reference the same Action as
|
||||
// Main, and duplicating params adds noise.
|
||||
var reference = new RepositoryPathReference { Name = "a/b", Ref = "v1" };
|
||||
var action = new ActionStep
|
||||
{
|
||||
Reference = reference,
|
||||
ContextName = "user-id",
|
||||
Condition = "always()",
|
||||
Environment = Map(("X", Str("y"))),
|
||||
Inputs = Map(("k", Str("v"))),
|
||||
};
|
||||
var mock = NewActionRunnerMock(ActionRunStage.Pre, "Pre a/b@v1", reference, action);
|
||||
|
||||
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.Equal(JobExecutionPhase.Pre, entry.Phase);
|
||||
Assert.Null(entry.Id);
|
||||
Assert.Null(entry.If);
|
||||
Assert.Null(entry.EnvYaml);
|
||||
Assert.Null(entry.WithYaml);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ using GitHub.DistributedTask.ObjectTemplating.Tokens;
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using GitHub.Runner.Worker;
|
||||
using GitHub.Runner.Worker.Dap;
|
||||
|
||||
namespace GitHub.Runner.Common.Tests.Worker
|
||||
{
|
||||
@@ -62,10 +61,6 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
|
||||
_stepsRunner = new StepsRunner();
|
||||
_stepsRunner.Initialize(hc);
|
||||
|
||||
var mockDapDebugger = new Mock<IDapDebugger>();
|
||||
hc.SetSingleton(mockDapDebugger.Object);
|
||||
|
||||
return hc;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
using GitHub.DistributedTask.ObjectTemplating.Tokens;
|
||||
using GitHub.Runner.Worker.Dap;
|
||||
using Xunit;
|
||||
|
||||
namespace GitHub.Runner.Common.Tests.Worker
|
||||
{
|
||||
public sealed class TemplateTokenYamlAdapterL0
|
||||
{
|
||||
private static StringToken Str(string s) => new(null, null, null, s);
|
||||
private static BooleanToken Bool(bool b) => new(null, null, null, b);
|
||||
private static NumberToken Num(double n) => new(null, null, null, n);
|
||||
private static BasicExpressionToken Expr(string s) => new(null, null, null, s);
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Serialize_StringScalar()
|
||||
{
|
||||
Assert.Equal("hello", TemplateTokenYamlAdapter.Serialize(Str("hello"), 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Serialize_BooleanScalar()
|
||||
{
|
||||
Assert.Equal("true", TemplateTokenYamlAdapter.Serialize(Bool(true), 0));
|
||||
Assert.Equal("false", TemplateTokenYamlAdapter.Serialize(Bool(false), 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Serialize_NumberScalar()
|
||||
{
|
||||
Assert.Equal("10", TemplateTokenYamlAdapter.Serialize(Num(10), 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Serialize_NullToken_RendersAsNull()
|
||||
{
|
||||
Assert.Equal("null", TemplateTokenYamlAdapter.Serialize(null, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Serialize_PreservesBasicExpression()
|
||||
{
|
||||
var token = Expr("runner.os");
|
||||
string yaml = TemplateTokenYamlAdapter.Serialize(token, 0);
|
||||
Assert.Contains("${{ runner.os }}", yaml);
|
||||
Assert.DoesNotContain("Linux", yaml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Serialize_PreservesCompositeExpressionInStringToken()
|
||||
{
|
||||
// A StringToken constructed directly with the literal text
|
||||
// round-trips unchanged. (The workflow parser does NOT produce
|
||||
// a StringToken for this input — see
|
||||
// Serialize_ReversesFormatRewriteForCompositeExpression — but
|
||||
// direct StringToken construction must still preserve the
|
||||
// literal verbatim.)
|
||||
var token = Str("${{ runner.os }}-primes");
|
||||
string yaml = TemplateTokenYamlAdapter.Serialize(token, 0);
|
||||
Assert.Contains("${{ runner.os }}-primes", yaml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Serialize_ReversesFormatRewriteForCompositeExpression()
|
||||
{
|
||||
// The workflow parser tokenizes a mixed scalar like
|
||||
// `${{ runner.os }}-primes` as a single BasicExpressionToken
|
||||
// whose internal expression is `format('{0}-primes', runner.os)`.
|
||||
// The adapter must surface the author-facing form, not the
|
||||
// parser's normalized rewrite.
|
||||
var token = Expr("format('{0}-primes', runner.os)");
|
||||
string yaml = TemplateTokenYamlAdapter.Serialize(token, 0);
|
||||
Assert.Contains("${{ runner.os }}-primes", yaml);
|
||||
Assert.DoesNotContain("format(", yaml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Serialize_NestedMapping()
|
||||
{
|
||||
var inner = new MappingToken(null, null, null);
|
||||
inner.Add(Str("b"), Num(1));
|
||||
inner.Add(Str("c"), Expr("x"));
|
||||
var outer = new MappingToken(null, null, null);
|
||||
outer.Add(Str("a"), inner);
|
||||
|
||||
string yaml = TemplateTokenYamlAdapter.Serialize(outer, 0);
|
||||
|
||||
Assert.Contains("a:", yaml);
|
||||
Assert.Contains("b: 1", yaml);
|
||||
Assert.Contains("c: ${{ x }}", yaml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Serialize_EmptyMapping()
|
||||
{
|
||||
var token = new MappingToken(null, null, null);
|
||||
string yaml = TemplateTokenYamlAdapter.Serialize(token, 0);
|
||||
Assert.Equal("{}", yaml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Serialize_EmptySequence()
|
||||
{
|
||||
var token = new SequenceToken(null, null, null);
|
||||
string yaml = TemplateTokenYamlAdapter.Serialize(token, 0);
|
||||
Assert.Equal("[]", yaml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Serialize_MultilineString_UsesBlockScalar()
|
||||
{
|
||||
var token = Str("line1\nline2\nline3");
|
||||
string yaml = TemplateTokenYamlAdapter.Serialize(token, 0);
|
||||
// Block-literal indicator `|` appears for multi-line scalars.
|
||||
Assert.Contains("|", yaml);
|
||||
Assert.Contains("line1", yaml);
|
||||
Assert.Contains("line2", yaml);
|
||||
Assert.Contains("line3", yaml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Serialize_IndentLevel_PrefixesNonEmptyLines()
|
||||
{
|
||||
var map = new MappingToken(null, null, null);
|
||||
map.Add(Str("k1"), Str("v1"));
|
||||
map.Add(Str("k2"), Str("v2"));
|
||||
|
||||
string yaml = TemplateTokenYamlAdapter.Serialize(map, indentSpaces: 4);
|
||||
|
||||
foreach (var line in yaml.Split('\n'))
|
||||
{
|
||||
if (line.Length > 0)
|
||||
{
|
||||
Assert.StartsWith(" ", line);
|
||||
}
|
||||
}
|
||||
Assert.Contains("k1: v1", yaml);
|
||||
Assert.Contains("k2: v2", yaml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Serialize_NoTrailingNewline()
|
||||
{
|
||||
var token = Str("hello");
|
||||
string yaml = TemplateTokenYamlAdapter.Serialize(token, 0);
|
||||
Assert.False(yaml.EndsWith("\n"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Serialize_AlwaysUsesLfLineBreaks()
|
||||
{
|
||||
// Regression: YamlDotNet's Emitter calls WriteLine, which on
|
||||
// Windows produces CRLF (the host's Environment.NewLine).
|
||||
// Serialize must force LF so the rendered view round-trips
|
||||
// regardless of platform.
|
||||
var map = new MappingToken(null, null, null);
|
||||
map.Add(Str("k1"), Str("v1"));
|
||||
map.Add(Str("k2"), Num(2));
|
||||
map.Add(Str("k3"), Bool(true));
|
||||
string yaml = TemplateTokenYamlAdapter.Serialize(map, indentSpaces: 2);
|
||||
Assert.DoesNotContain("\r", yaml);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
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("&")]
|
||||
[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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.421"
|
||||
DOTNETSDK_VERSION="8.0.419"
|
||||
DOTNETSDK_INSTALLDIR="$DOTNETSDK_ROOT/$DOTNETSDK_VERSION"
|
||||
RUNNER_VERSION=$(cat runnerversion)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "8.0.421"
|
||||
"version": "8.0.419"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
2.334.0
|
||||
2.333.0
|
||||
|
||||
Reference in New Issue
Block a user