mirror of
https://github.com/actions/runner.git
synced 2026-08-03 09:52:46 +08:00
Compare commits
4 Commits
dependabot
...
adr-10024/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04ee71a5d1 | ||
|
|
4342a1b8f0 | ||
|
|
5806ab55db | ||
|
|
cb8124fc4a |
@@ -4,7 +4,7 @@
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {},
|
||||
"ghcr.io/devcontainers/features/dotnet": {
|
||||
"version": "8.0.423"
|
||||
"version": "8.0.419"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"version": "20"
|
||||
|
||||
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
@@ -53,7 +53,7 @@ jobs:
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
# Build runner layout
|
||||
- name: Build & Layout Release
|
||||
@@ -95,7 +95,7 @@ jobs:
|
||||
docker_platform: linux/arm64
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Get latest runner version
|
||||
id: latest_runner
|
||||
|
||||
2
.github/workflows/close-bugs-bot.yml
vendored
2
.github/workflows/close-bugs-bot.yml
vendored
@@ -7,7 +7,7 @@ jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v11
|
||||
- uses: actions/stale@v10
|
||||
with:
|
||||
close-issue-message: "This issue does not seem to be a problem with the runner application, it concerns the GitHub actions platform more generally. Could you please post your feedback on the [GitHub Community Support Forum](https://github.com/orgs/community/discussions/categories/actions) which is actively monitored. Using the forum ensures that we route your problem to the correct team. 😃"
|
||||
exempt-issue-labels: "keep"
|
||||
|
||||
2
.github/workflows/close-features-bot.yml
vendored
2
.github/workflows/close-features-bot.yml
vendored
@@ -7,7 +7,7 @@ jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v11
|
||||
- uses: actions/stale@v10
|
||||
with:
|
||||
close-issue-message: "Thank you for your interest in the runner application and taking the time to provide your valuable feedback. We kindly ask you to redirect this feedback to the [GitHub Community Support Forum](https://github.com/orgs/community/discussions/categories/actions) which our team actively monitors and would be a better place to start a discussion for new feature requests in GitHub Actions. For more information on this policy please [read our contribution guidelines](https://github.com/actions/runner#contribute). 😃"
|
||||
exempt-issue-labels: "keep"
|
||||
|
||||
2
.github/workflows/codeql.yml
vendored
2
.github/workflows/codeql.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
|
||||
4
.github/workflows/dependency-check.yml
vendored
4
.github/workflows/dependency-check.yml
vendored
@@ -29,9 +29,9 @@ jobs:
|
||||
npm-vulnerabilities: ${{ steps.check-versions.outputs.npm-vulnerabilities }}
|
||||
open-dependency-prs: ${{ steps.check-prs.outputs.open-dependency-prs }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
|
||||
4
.github/workflows/docker-buildx-upgrade.yml
vendored
4
.github/workflows/docker-buildx-upgrade.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
BUILDX_CURRENT_VERSION: ${{ steps.check_buildx_version.outputs.CURRENT_VERSION }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Check Docker version
|
||||
id: check_docker_version
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Update Docker version
|
||||
shell: bash
|
||||
|
||||
2
.github/workflows/docker-publish.yml
vendored
2
.github/workflows/docker-publish.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
||||
IMAGE_NAME: ${{ github.repository_owner }}/actions-runner
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.inputs.releaseBranch }}
|
||||
|
||||
|
||||
4
.github/workflows/dotnet-upgrade.yml
vendored
4
.github/workflows/dotnet-upgrade.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
DOTNET_CURRENT_MAJOR_MINOR_VERSION: ${{ steps.fetch_current_version.outputs.DOTNET_CURRENT_MAJOR_MINOR_VERSION }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
- name: Get current major minor version
|
||||
id: fetch_current_version
|
||||
shell: bash
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
if: ${{ needs.dotnet-update.outputs.SHOULD_UPDATE == 1 && needs.dotnet-update.outputs.BRANCH_EXISTS == 0 }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: feature/dotnetsdk-upgrade/${{ needs.dotnet-update.outputs.DOTNET_LATEST_MAJOR_MINOR_PATCH_VERSION }}
|
||||
- name: Create Pull Request
|
||||
|
||||
2
.github/workflows/node-upgrade.yml
vendored
2
.github/workflows/node-upgrade.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
||||
update-node:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
- name: Get latest Node versions
|
||||
id: node-versions
|
||||
run: |
|
||||
|
||||
4
.github/workflows/npm-audit-typescript.yml
vendored
4
.github/workflows/npm-audit-typescript.yml
vendored
@@ -7,9 +7,9 @@ jobs:
|
||||
npm-audit-with-ts-fix:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
- name: NPM install and audit fix with TypeScript auto-repair
|
||||
|
||||
4
.github/workflows/npm-audit.yml
vendored
4
.github/workflows/npm-audit.yml
vendored
@@ -9,10 +9,10 @@ jobs:
|
||||
npm-audit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
|
||||
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
if: startsWith(github.ref, 'refs/heads/releases/') || github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
# Make sure ./releaseVersion match ./src/runnerversion
|
||||
# Query GitHub release ensure version is not used
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
# Build runner layout
|
||||
- name: Build & Layout Release
|
||||
@@ -129,7 +129,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
# Download runner package tar.gz/zip produced by 'build' job
|
||||
- name: Download Artifact (win-x64)
|
||||
@@ -296,7 +296,7 @@ jobs:
|
||||
IMAGE_NAME: ${{ github.repository_owner }}/actions-runner
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Compute image version
|
||||
id: image
|
||||
|
||||
2
.github/workflows/stale-bot.yml
vendored
2
.github/workflows/stale-bot.yml
vendored
@@ -7,7 +7,7 @@ jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v11
|
||||
- uses: actions/stale@v10
|
||||
with:
|
||||
stale-issue-message: "This issue is stale because it has been open 365 days with no activity. Remove stale label or comment or this will be closed in 15 days."
|
||||
close-issue-message: "This issue was closed because it has been stalled for 15 days with no activity."
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -27,5 +27,4 @@ TestResults
|
||||
TestLogs
|
||||
.DS_Store
|
||||
.mono
|
||||
**/*.DotSettings.user
|
||||
**/*.lscache
|
||||
**/*.DotSettings.user
|
||||
@@ -8,7 +8,7 @@ Please see "[Supported architectures and operating systems for self-hosted runne
|
||||
|
||||
## Install .Net Core 3.x Linux Dependencies
|
||||
|
||||
The [config.sh](../../src/Misc/layoutroot/config.sh) will check .Net Core 3.x dependencies during runner configuration.
|
||||
The `./config.sh` will check .Net Core 3.x dependencies during runner configuration.
|
||||
You might see something like this which indicate a dependency's missing.
|
||||
```bash
|
||||
./config.sh
|
||||
@@ -17,7 +17,7 @@ You might see something like this which indicate a dependency's missing.
|
||||
Dependencies is missing for Dotnet Core 6.0
|
||||
Execute ./bin/installdependencies.sh to install any missing Dotnet Core 6.0 dependencies.
|
||||
```
|
||||
You can easily correct the problem by executing [installdependencies.sh](../../src/Misc/layoutbin/installdependencies.sh).
|
||||
You can easily correct the problem by executing `./bin/installdependencies.sh`.
|
||||
The `installdependencies.sh` script should install all required dependencies on all supported Linux versions
|
||||
> Note: The `installdependencies.sh` script will try to use the default package management mechanism on your Linux flavor (ex. `yum`/`apt-get`/`apt`).
|
||||
|
||||
@@ -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.6.1
|
||||
ARG BUILDX_VERSION=0.35.0
|
||||
ARG DOCKER_VERSION=29.3.1
|
||||
ARG BUILDX_VERSION=0.33.0
|
||||
|
||||
RUN apt update -y && apt install curl unzip -y
|
||||
|
||||
|
||||
@@ -1,32 +1,33 @@
|
||||
## What's Changed
|
||||
* Canceled background steps should not impact job result by @lokesh755 in https://github.com/actions/runner/pull/4482
|
||||
* Report actions archive size in telemetry. by @TingluoHuang in https://github.com/actions/runner/pull/4509
|
||||
* Bump actions/checkout from 6 to 7 by @dependabot[bot] in https://github.com/actions/runner/pull/4511
|
||||
* Update Docker to v29.6.0 and Buildx to v0.35.0 by @github-actions[bot] in https://github.com/actions/runner/pull/4516
|
||||
* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4519
|
||||
* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4530
|
||||
* feat: enhance telemetry for action download resolution and failures by @TingluoHuang in https://github.com/actions/runner/pull/4536
|
||||
* Update Docker version to 29.6.1 by @AllanGuigou in https://github.com/actions/runner/pull/4539
|
||||
* feat: add self-repository action reference syntax by @nodeselector in https://github.com/actions/runner/pull/4457
|
||||
* Update dotnet sdk to latest version @8.0.422 by @github-actions[bot] in https://github.com/actions/runner/pull/4504
|
||||
* Link config.sh and installdependencies.sh in docs by @Wuodan in https://github.com/actions/runner/pull/4526
|
||||
* Add support for $GITHUB_ARTIFACTS environment files by @bdehamer in https://github.com/actions/runner/pull/4527
|
||||
* feat: expose effective cache-mode to steps via ACTIONS_CACHE_MODE by @philip-gai in https://github.com/actions/runner/pull/4538
|
||||
* Setup Job: announce when running with locked dependencies by @nodeselector in https://github.com/actions/runner/pull/4546
|
||||
* Setup Job: reword locked-dependencies log line to use lockfile language by @nodeselector in https://github.com/actions/runner/pull/4550
|
||||
* Wait for worker to finish during cancel by @TingluoHuang in https://github.com/actions/runner/pull/4553
|
||||
* do not cap migrated setting retry is exception is session conflict by @aiqiaoy in https://github.com/actions/runner/pull/4557
|
||||
* Allow checking DNS with api.gihub.com. by @TingluoHuang in https://github.com/actions/runner/pull/4547
|
||||
* Exit ephemeral runners on broker acknowledge job-not-found by @rentziass in https://github.com/actions/runner/pull/4540
|
||||
* Cleanup session files on get message or session deleted error by @nikola-jokic in https://github.com/actions/runner/pull/4551
|
||||
* Recreate session on RunnerSessionInvalid from broker by @luketomlinson in https://github.com/actions/runner/pull/4556
|
||||
* 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
|
||||
* @Wuodan made their first contribution in https://github.com/actions/runner/pull/4526
|
||||
* @bdehamer made their first contribution in https://github.com/actions/runner/pull/4527
|
||||
* @philip-gai made their first contribution in https://github.com/actions/runner/pull/4538
|
||||
* @MaxHorstmann made their first contribution in https://github.com/actions/runner/pull/4277
|
||||
|
||||
**Full Changelog**: https://github.com/actions/runner/compare/v2.335.0...v2.336.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.
|
||||
|
||||
5513
src/Misc/expressionFunc/hashFiles/package-lock.json
generated
5513
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.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@stylistic/eslint-plugin": "^5.10.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.0",
|
||||
"@typescript-eslint/parser": "^8.65.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.58.1",
|
||||
"@typescript-eslint/parser": "^8.58.1",
|
||||
"@vercel/ncc": "^0.38.3",
|
||||
"eslint": "^8.47.0",
|
||||
"eslint-plugin-github": "^6.1.0",
|
||||
"eslint-plugin-github": "^4.10.2",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.4.0",
|
||||
"prettier": "^3.0.3",
|
||||
"typescript": "^6.0.3"
|
||||
"typescript": "^6.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ NODE_ALPINE_URL=https://github.com/actions/alpine_nodejs/releases/download
|
||||
# When you update Node versions you must also create a new release of alpine_nodejs at that updated version.
|
||||
# Follow the instructions here: https://github.com/actions/alpine_nodejs?tab=readme-ov-file#getting-started
|
||||
NODE20_VERSION="20.20.2"
|
||||
NODE24_VERSION="24.18.0"
|
||||
NODE24_VERSION="24.14.1"
|
||||
|
||||
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 '$?'"
|
||||
|
||||
@@ -108,7 +108,7 @@ namespace GitHub.Runner.Common
|
||||
|
||||
public bool ShouldRetryException(Exception ex)
|
||||
{
|
||||
if (ex is AccessDeniedException || ex is VssUnauthorizedException || ex is RunnerNotFoundException || ex is HostedRunnerDeprovisionedException || ex is TaskAgentSessionExpiredException)
|
||||
if (ex is AccessDeniedException || ex is RunnerNotFoundException || ex is HostedRunnerDeprovisionedException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -179,9 +179,6 @@ namespace GitHub.Runner.Common
|
||||
public static readonly string EmitCompositeMarkers = "actions_runner_emit_composite_markers";
|
||||
public static readonly string BatchActionResolution = "actions_batch_action_resolution";
|
||||
public static readonly string UseBearerTokenForCodeload = "actions_use_bearer_token_for_codeload";
|
||||
public static readonly string OverrideDebuggerWelcomeMessage = "actions_runner_override_debugger_welcome_message";
|
||||
public static readonly string AllowArtifactsFile = "actions_runner_allow_artifacts_file";
|
||||
public static readonly string SelfRepository = "actions_self_repository";
|
||||
}
|
||||
|
||||
// Node version migration related constants
|
||||
@@ -208,7 +205,7 @@ namespace GitHub.Runner.Common
|
||||
public static readonly string Node20DeprecationUrl = "https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/";
|
||||
|
||||
// Node 20 migration dates (hardcoded fallbacks, can be overridden via job variables)
|
||||
public static readonly string Node24DefaultDate = "June 16th, 2026";
|
||||
public static readonly string Node24DefaultDate = "June 2nd, 2026";
|
||||
public static readonly string Node20RemovalDate = "September 16th, 2026";
|
||||
|
||||
// Variable keys for server-overridable dates
|
||||
@@ -229,12 +226,6 @@ namespace GitHub.Runner.Common
|
||||
public static readonly string UnsupportedStopCommandTokenDisabled = "You cannot use a endToken that is an empty string, the string 'pause-logging', or another workflow command. For more information see: https://docs.github.com/actions/learn-github-actions/workflow-commands-for-github-actions#example-stopping-and-starting-workflow-commands or opt into insecure command execution by setting the `ACTIONS_ALLOW_UNSECURE_STOPCOMMAND_TOKENS` environment variable to `true`.";
|
||||
public static readonly string UnsupportedSummarySize = "$GITHUB_STEP_SUMMARY upload aborted, supports content up to a size of {0}k, got {1}k. For more information see: https://docs.github.com/actions/using-workflows/workflow-commands-for-github-actions#adding-a-markdown-summary";
|
||||
public static readonly string SummaryUploadError = "$GITHUB_STEP_SUMMARY upload aborted, an error occurred when uploading the summary. For more information see: https://docs.github.com/actions/using-workflows/workflow-commands-for-github-actions#adding-a-markdown-summary";
|
||||
|
||||
// $GITHUB_ARTIFACTS file command
|
||||
public static readonly string ArtifactsFileSizeExceeded = "$GITHUB_ARTIFACTS file exceeds the maximum size of {0} KiB (got {1} KiB).";
|
||||
public static readonly string ArtifactsAggregateLimitExceeded = "The job has exceeded the maximum of {0} declared artifacts.";
|
||||
public static readonly string ArtifactsInvalidLine = "Invalid $GITHUB_ARTIFACTS entry on line {0}: {1}";
|
||||
public static readonly string ArtifactsConflictingDigest = "Conflicting digest for artifact '{0}': previously declared as '{1}', now declared as '{2}'.";
|
||||
}
|
||||
|
||||
public static class RunnerEvent
|
||||
@@ -316,7 +307,6 @@ namespace GitHub.Runner.Common
|
||||
public static readonly string ForcedInternalNodeVersion = "ACTIONS_RUNNER_FORCED_INTERNAL_NODE_VERSION";
|
||||
public static readonly string ForcedActionsNodeVersion = "ACTIONS_RUNNER_FORCE_ACTIONS_NODE_VERSION";
|
||||
public static readonly string PrintLogToStdout = "ACTIONS_RUNNER_PRINT_LOG_TO_STDOUT";
|
||||
public static readonly string DisableStdoutMultilineLogPrefixing = "ACTIONS_RUNNER_DISABLE_STDOUT_MULTILINE_LOG_PREFIXING";
|
||||
public static readonly string ActionArchiveCacheDirectory = "ACTIONS_RUNNER_ACTION_ARCHIVE_CACHE";
|
||||
public static readonly string SymlinkCachedActions = "ACTIONS_RUNNER_SYMLINK_CACHED_ACTIONS";
|
||||
public static readonly string EmitCompositeMarkers = "ACTIONS_RUNNER_EMIT_COMPOSITE_MARKERS";
|
||||
|
||||
@@ -62,8 +62,6 @@ namespace GitHub.Runner.Common
|
||||
Add<T>(extensions, "GitHub.Runner.Worker.CreateStepSummaryCommand, Runner.Worker");
|
||||
Add<T>(extensions, "GitHub.Runner.Worker.SaveStateFileCommand, Runner.Worker");
|
||||
Add<T>(extensions, "GitHub.Runner.Worker.SetOutputFileCommand, Runner.Worker");
|
||||
Add<T>(extensions, "GitHub.Runner.Worker.CreateArtifactsFileCommand, Runner.Worker");
|
||||
Add<T>(extensions, "GitHub.Runner.Worker.ArtifactsListFileCommand, Runner.Worker");
|
||||
break;
|
||||
case "GitHub.Runner.Listener.Check.ICheckExtension":
|
||||
Add<T>(extensions, "GitHub.Runner.Listener.Check.InternetCheck, Runner.Listener");
|
||||
|
||||
@@ -837,15 +837,6 @@ namespace GitHub.Runner.Common
|
||||
timelineRecord.Variables[variable.Key] = variable.Value.Clone();
|
||||
}
|
||||
}
|
||||
|
||||
// Merge background step metadata
|
||||
if (rec.IsBackground)
|
||||
{
|
||||
timelineRecord.IsBackground = rec.IsBackground;
|
||||
}
|
||||
timelineRecord.BackgroundControlType = rec.BackgroundControlType ?? timelineRecord.BackgroundControlType;
|
||||
timelineRecord.BackgroundControlStepIds = rec.BackgroundControlStepIds ?? timelineRecord.BackgroundControlStepIds;
|
||||
timelineRecord.ParallelGroupId = rec.ParallelGroupId ?? timelineRecord.ParallelGroupId;
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
@@ -9,12 +9,10 @@ namespace GitHub.Runner.Common
|
||||
public sealed class StdoutTraceListener : ConsoleTraceListener
|
||||
{
|
||||
private readonly string _hostType;
|
||||
private readonly bool _disablePrefixMultilineLogs = false;
|
||||
|
||||
public StdoutTraceListener(string hostType)
|
||||
{
|
||||
this._hostType = hostType;
|
||||
this._disablePrefixMultilineLogs = StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable(Constants.Variables.Agent.DisableStdoutMultilineLogPrefixing));
|
||||
}
|
||||
|
||||
// Copied and modified slightly from .Net Core source code. Modification was required to make it compile.
|
||||
@@ -28,20 +26,11 @@ namespace GitHub.Runner.Common
|
||||
|
||||
if (!string.IsNullOrEmpty(message))
|
||||
{
|
||||
if (!this._disablePrefixMultilineLogs)
|
||||
{
|
||||
var messageLines = message.Split(Environment.NewLine);
|
||||
foreach (var messageLine in messageLines)
|
||||
{
|
||||
WriteHeader(source, eventType, id);
|
||||
WriteLine(messageLine);
|
||||
WriteFooter(eventCache);
|
||||
}
|
||||
}
|
||||
else
|
||||
var messageLines = message.Split(Environment.NewLine);
|
||||
foreach (var messageLine in messageLines)
|
||||
{
|
||||
WriteHeader(source, eventType, id);
|
||||
WriteLine(message);
|
||||
WriteLine(messageLine);
|
||||
WriteFooter(eventCache);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,15 +167,12 @@ namespace GitHub.Runner.Listener
|
||||
Trace.Error("Catch exception during create session.");
|
||||
Trace.Error(ex);
|
||||
|
||||
// When using migrated settings, cap retries for generic transient/retriable errors so we can
|
||||
// fall back to the original .runner settings instead of retrying the migrated settings forever.
|
||||
// Session conflict (4 min) has its own bounded retry limits and are
|
||||
// excluded here so they keep their v1-consistent behavior.
|
||||
if (_isMigratedSettings &&
|
||||
ex is not TaskAgentSessionConflictException)
|
||||
// If using migrated settings, limit the number of retries before returning failure
|
||||
if (_isMigratedSettings)
|
||||
{
|
||||
_migratedSettingsRetryCount++;
|
||||
Trace.Warning($"Migrated settings retry {_migratedSettingsRetryCount} of {_maxMigratedSettingsRetries}");
|
||||
|
||||
if (_migratedSettingsRetryCount >= _maxMigratedSettingsRetries)
|
||||
{
|
||||
Trace.Warning("Reached maximum retry attempts for migrated settings. Returning failure to try default settings.");
|
||||
@@ -341,14 +338,7 @@ namespace GitHub.Runner.Listener
|
||||
Trace.Error("Catch exception during get next message.");
|
||||
Trace.Error(ex);
|
||||
|
||||
// don't retry if SkipSessionRecover = true, the service will delete the runner session to stop the runner from taking more jobs.
|
||||
if (!HostContext.AllowAuthMigration &&
|
||||
ex is TaskAgentSessionExpiredException &&
|
||||
!_settings.SkipSessionRecover && (await CreateSessionAsync(token) == CreateSessionResult.Success))
|
||||
{
|
||||
Trace.Info($"{nameof(TaskAgentSessionExpiredException)} received, recovered by recreate session.");
|
||||
}
|
||||
else if (!HostContext.AllowAuthMigration &&
|
||||
!IsGetNextMessageExceptionRetriable(ex))
|
||||
{
|
||||
throw new NonRetryableException("Get next message failed with non-retryable error.", ex);
|
||||
|
||||
@@ -254,8 +254,6 @@ namespace GitHub.Runner.Listener
|
||||
{
|
||||
Trace.Error($"We are not yet checking the state of jobrequest {jobDispatch.JobId} status. Cancel running worker right away.");
|
||||
jobDispatch.WorkerCancellationTokenSource.Cancel();
|
||||
// wait for worker process exit then return.
|
||||
await jobDispatch.WorkerDispatch;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="System.IO.FileSystem.AccessControl" Version="5.0.0" />
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.3" />
|
||||
<PackageReference Include="System.ServiceProcess.ServiceController" Version="10.0.7" />
|
||||
<PackageReference Include="System.ServiceProcess.ServiceController" Version="10.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
|
||||
|
||||
@@ -498,7 +498,6 @@ namespace GitHub.Runner.Listener
|
||||
bool skipSessionDeletion = false;
|
||||
bool restartSession = false; // Flag to indicate session restart
|
||||
bool restartSessionPending = false;
|
||||
bool cleanupLocalConfigAfter404 = false;
|
||||
try
|
||||
{
|
||||
var notification = HostContext.GetService<IJobNotification>();
|
||||
@@ -688,7 +687,7 @@ namespace GitHub.Runner.Listener
|
||||
else
|
||||
{
|
||||
var messageRef = StringUtil.ConvertFromJson<RunnerJobRequestRef>(message.Body);
|
||||
|
||||
|
||||
// Acknowledge (best-effort)
|
||||
if (messageRef.ShouldAcknowledge) // Temporary feature flag
|
||||
{
|
||||
@@ -696,12 +695,6 @@ namespace GitHub.Runner.Listener
|
||||
{
|
||||
await _listener.AcknowledgeMessageAsync(messageRef.RunnerRequestId, messageQueueLoopTokenSource.Token);
|
||||
}
|
||||
catch (RunnerRequestJobNotFoundException) when (settings.Ephemeral)
|
||||
{
|
||||
Trace.Info($"Acknowledge returned job-not-found for ephemeral runner request '{messageRef.RunnerRequestId}'. Exiting runner.");
|
||||
runOnceJobCompleted = true;
|
||||
return Constants.Runner.ReturnCode.Success;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error($"Best-effort acknowledge failed for request '{messageRef.RunnerRequestId}'");
|
||||
@@ -820,14 +813,6 @@ namespace GitHub.Runner.Listener
|
||||
Trace.Error($"Received message {message.MessageId} with unsupported message type {message.MessageType}.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is TaskAgentNotFoundException || ex is RunnerNotFoundException)
|
||||
{
|
||||
Trace.Info($"Runner registration no longer exists while retrieving messages. {ex.Message}");
|
||||
_term.WriteError("The runner no longer exists on the server. Cleaning up local configuration.");
|
||||
skipSessionDeletion = true;
|
||||
cleanupLocalConfigAfter404 = true;
|
||||
break;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!skipMessageDeletion && message != null)
|
||||
@@ -874,7 +859,7 @@ namespace GitHub.Runner.Listener
|
||||
|
||||
messageQueueLoopTokenSource.Dispose();
|
||||
|
||||
if ((settings.Ephemeral && runOnceJobCompleted) || cleanupLocalConfigAfter404)
|
||||
if (settings.Ephemeral && runOnceJobCompleted)
|
||||
{
|
||||
configManager.DeleteLocalRunnerConfig();
|
||||
}
|
||||
|
||||
@@ -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,7 +15,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Text.Encoding.CodePages" Version="10.0.10" />
|
||||
<PackageReference Include="System.Text.Encoding.CodePages" Version="10.0.3" />
|
||||
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
|
||||
<PackageReference Include="System.Threading.Channels" Version="10.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -282,15 +282,8 @@ namespace GitHub.Runner.Worker
|
||||
}
|
||||
}
|
||||
|
||||
if (context.DeferredEnvironmentVariables != null)
|
||||
{
|
||||
context.DeferredEnvironmentVariables[envName] = command.Data;
|
||||
}
|
||||
else
|
||||
{
|
||||
context.Global.EnvironmentVariables[envName] = command.Data;
|
||||
context.SetEnvContext(envName, command.Data);
|
||||
}
|
||||
context.Global.EnvironmentVariables[envName] = command.Data;
|
||||
context.SetEnvContext(envName, command.Data);
|
||||
context.Debug($"{envName}='{command.Data}'");
|
||||
}
|
||||
|
||||
@@ -341,15 +334,8 @@ namespace GitHub.Runner.Worker
|
||||
throw new Exception("Required field 'name' is missing in ##[set-output] command.");
|
||||
}
|
||||
|
||||
if (context.DeferredOutputs != null)
|
||||
{
|
||||
context.DeferredOutputs[outputName] = command.Data;
|
||||
}
|
||||
else
|
||||
{
|
||||
context.SetOutput(outputName, command.Data, out var reference);
|
||||
context.Debug($"{reference}='{command.Data}'");
|
||||
}
|
||||
context.SetOutput(outputName, command.Data, out var reference);
|
||||
context.Debug($"{reference}='{command.Data}'");
|
||||
}
|
||||
|
||||
private static class SetOutputCommandProperties
|
||||
@@ -479,16 +465,8 @@ namespace GitHub.Runner.Worker
|
||||
}
|
||||
|
||||
ArgUtil.NotNullOrEmpty(command.Data, "path");
|
||||
if (context.DeferredPrependPath != null)
|
||||
{
|
||||
context.DeferredPrependPath.RemoveAll(x => string.Equals(x, command.Data, StringComparison.CurrentCulture));
|
||||
context.DeferredPrependPath.Add(command.Data);
|
||||
}
|
||||
else
|
||||
{
|
||||
context.Global.PrependPath.RemoveAll(x => string.Equals(x, command.Data, StringComparison.CurrentCulture));
|
||||
context.Global.PrependPath.Add(command.Data);
|
||||
}
|
||||
context.Global.PrependPath.RemoveAll(x => string.Equals(x, command.Data, StringComparison.CurrentCulture));
|
||||
context.Global.PrependPath.Add(command.Data);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -178,7 +178,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), string selfRepoName = null, string selfRepoRef = null)
|
||||
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)
|
||||
@@ -186,21 +186,6 @@ namespace GitHub.Runner.Worker
|
||||
throw new Exception($"Composite action depth exceeded max depth {Constants.CompositeActionsMaxDepth}");
|
||||
}
|
||||
|
||||
// Resolve self-repository ($/) references before processing
|
||||
if (executionContext.Global.Variables.GetBoolean(Constants.Runner.Features.SelfRepository) == true)
|
||||
{
|
||||
if (string.IsNullOrEmpty(selfRepoName))
|
||||
{
|
||||
// job.workflow_repository/workflow_sha point to the repo
|
||||
// containing the workflow file — correct for both regular
|
||||
// and reusable workflows. Always present when the server
|
||||
// supports $/. See: https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/accessing-contextual-information-about-workflow-runs#github-context
|
||||
selfRepoName = executionContext.JobContext?.WorkflowRepository;
|
||||
selfRepoRef = executionContext.JobContext?.WorkflowSha;
|
||||
}
|
||||
ResolveSelfRepositoryReferences(executionContext, actions, selfRepoName, selfRepoRef);
|
||||
}
|
||||
|
||||
var repositoryActions = new List<Pipelines.ActionStep>();
|
||||
|
||||
foreach (var action in actions)
|
||||
@@ -243,30 +228,7 @@ namespace GitHub.Runner.Worker
|
||||
{
|
||||
throw new Exception($"Missing download info for {lookupKey}");
|
||||
}
|
||||
|
||||
Exception downloadFailure = null;
|
||||
try
|
||||
{
|
||||
await DownloadRepositoryActionAsync(executionContext, downloadInfo);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// record the exception for telemetry, and rethrow the original exception to fail the step.
|
||||
downloadFailure = ex;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
executionContext.Global.JobTelemetry.Add(new JobTelemetry()
|
||||
{
|
||||
Type = JobTelemetryType.General,
|
||||
Message = $"resolve_download_actions_telemetry:{StringUtil.ConvertToJson(new ActionTelemetryPayload
|
||||
{
|
||||
Operation = "download_action",
|
||||
Result = downloadFailure == null ? "succeeded" : downloadFailure.GetType().Name
|
||||
}, Newtonsoft.Json.Formatting.None)}"
|
||||
});
|
||||
}
|
||||
await DownloadRepositoryActionAsync(executionContext, downloadInfo);
|
||||
}
|
||||
|
||||
// Parse action.yml and collect composite sub-actions for batched
|
||||
@@ -316,53 +278,16 @@ namespace GitHub.Runner.Worker
|
||||
// then recurse per parent (which hits the cache, not the API).
|
||||
if (nextLevel.Count > 0)
|
||||
{
|
||||
if (executionContext.Global.Variables.GetBoolean(Constants.Runner.Features.SelfRepository) == true)
|
||||
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))
|
||||
{
|
||||
// Self-repository path: group by parent so each group's
|
||||
// $/ refs resolve against the correct parent repo context.
|
||||
var groups = nextLevel.GroupBy(x => x.parentId).Select(group =>
|
||||
{
|
||||
string childRepoName = selfRepoName;
|
||||
string childRepoRef = selfRepoRef;
|
||||
var parentAction = repositoryActions.FirstOrDefault(a => a.Id == group.Key);
|
||||
if (parentAction?.Reference is Pipelines.RepositoryPathReference parentRef &&
|
||||
string.Equals(parentRef.RepositoryType, Pipelines.RepositoryTypes.GitHub, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
childRepoName = parentRef.Name;
|
||||
childRepoRef = parentRef.Ref;
|
||||
}
|
||||
return new { ParentId = group.Key, Actions = group.Select(x => x.action).ToList(), RepoName = childRepoName, RepoRef = childRepoRef };
|
||||
}).ToList();
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
ResolveSelfRepositoryReferences(executionContext, group.Actions, group.RepoName, group.RepoRef);
|
||||
}
|
||||
|
||||
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 groups)
|
||||
{
|
||||
state = await PrepareActionsRecursiveAsync(executionContext, state, group.Actions, resolvedDownloadInfos, depth + 1, group.ParentId, group.RepoName, group.RepoRef);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Original path: no self-repository resolution needed.
|
||||
var nextLevelActions = nextLevel.Select(x => x.action).ToList();
|
||||
var nextLevelRepoActions = nextLevelActions
|
||||
.Where(x => x.Reference.Type == Pipelines.ActionSourceType.Repository)
|
||||
.ToList();
|
||||
await ResolveNewActionsAsync(executionContext, nextLevelRepoActions, resolvedDownloadInfos);
|
||||
|
||||
foreach (var grp in nextLevel.GroupBy(x => x.parentId))
|
||||
{
|
||||
state = await PrepareActionsRecursiveAsync(executionContext, state, grp.Select(x => x.action).ToList(), resolvedDownloadInfos, depth + 1, grp.Key);
|
||||
}
|
||||
var groupActions = group.Select(x => x.action).ToList();
|
||||
state = await PrepareActionsRecursiveAsync(executionContext, state, groupActions, resolvedDownloadInfos, depth + 1, group.Key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -438,25 +363,13 @@ namespace GitHub.Runner.Worker
|
||||
/// 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), string selfRepoName = null, string selfRepoRef = null)
|
||||
private async Task<PrepareActionsState> PrepareActionsRecursiveLegacyAsync(IExecutionContext executionContext, PrepareActionsState state, IEnumerable<Pipelines.ActionStep> actions, Int32 depth = 0, Guid parentStepId = default(Guid))
|
||||
{
|
||||
ArgUtil.NotNull(executionContext, nameof(executionContext));
|
||||
if (depth > Constants.CompositeActionsMaxDepth)
|
||||
{
|
||||
throw new Exception($"Composite action depth exceeded max depth {Constants.CompositeActionsMaxDepth}");
|
||||
}
|
||||
|
||||
// Resolve self-repository ($/) references before processing
|
||||
if (executionContext.Global.Variables.GetBoolean(Constants.Runner.Features.SelfRepository) == true)
|
||||
{
|
||||
if (string.IsNullOrEmpty(selfRepoName))
|
||||
{
|
||||
selfRepoName = executionContext.JobContext?.WorkflowRepository;
|
||||
selfRepoRef = executionContext.JobContext?.WorkflowSha;
|
||||
}
|
||||
ResolveSelfRepositoryReferences(executionContext, actions, selfRepoName, selfRepoRef);
|
||||
}
|
||||
|
||||
var repositoryActions = new List<Pipelines.ActionStep>();
|
||||
|
||||
foreach (var action in actions)
|
||||
@@ -485,30 +398,7 @@ namespace GitHub.Runner.Worker
|
||||
if (repositoryActions.Count > 0)
|
||||
{
|
||||
// Get the download info
|
||||
IDictionary<string, WebApi.ActionDownloadInfo> downloadInfos = null;
|
||||
Exception resolveFailure = null;
|
||||
try
|
||||
{
|
||||
downloadInfos = await GetDownloadInfoAsync(executionContext, repositoryActions);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// record the exception for telemetry, and rethrow the original exception to fail the step.
|
||||
resolveFailure = ex;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
executionContext.Global.JobTelemetry.Add(new JobTelemetry()
|
||||
{
|
||||
Type = JobTelemetryType.General,
|
||||
Message = $"resolve_download_actions_telemetry:{StringUtil.ConvertToJson(new ActionTelemetryPayload
|
||||
{
|
||||
Operation = "resolve_actions",
|
||||
Result = resolveFailure == null ? "succeeded" : resolveFailure.GetType().Name
|
||||
}, Newtonsoft.Json.Formatting.None)}"
|
||||
});
|
||||
}
|
||||
var downloadInfos = await GetDownloadInfoAsync(executionContext, repositoryActions);
|
||||
|
||||
// Download each action
|
||||
foreach (var action in repositoryActions)
|
||||
@@ -524,29 +414,7 @@ namespace GitHub.Runner.Worker
|
||||
throw new Exception($"Missing download info for {lookupKey}");
|
||||
}
|
||||
|
||||
Exception downloadFailure = null;
|
||||
try
|
||||
{
|
||||
await DownloadRepositoryActionAsync(executionContext, downloadInfo);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// record the exception for telemetry, and rethrow the original exception to fail the step.
|
||||
downloadFailure = ex;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
executionContext.Global.JobTelemetry.Add(new JobTelemetry()
|
||||
{
|
||||
Type = JobTelemetryType.General,
|
||||
Message = $"resolve_download_actions_telemetry:{StringUtil.ConvertToJson(new ActionTelemetryPayload
|
||||
{
|
||||
Operation = "download_action",
|
||||
Result = downloadFailure == null ? "succeeded" : downloadFailure.GetType().Name
|
||||
}, Newtonsoft.Json.Formatting.None)}"
|
||||
});
|
||||
}
|
||||
await DownloadRepositoryActionAsync(executionContext, downloadInfo);
|
||||
}
|
||||
|
||||
// More preparation based on content in the repository (action.yml)
|
||||
@@ -581,17 +449,7 @@ namespace GitHub.Runner.Worker
|
||||
}
|
||||
else if (setupInfo != null && setupInfo.Steps != null && setupInfo.Steps.Count > 0)
|
||||
{
|
||||
// Propagate parent's repo context for nested self-repository resolution
|
||||
var parentRef = action.Reference as Pipelines.RepositoryPathReference;
|
||||
var childRepoName = selfRepoName;
|
||||
var childRepoRef = selfRepoRef;
|
||||
if (parentRef != null &&
|
||||
string.Equals(parentRef.RepositoryType, Pipelines.RepositoryTypes.GitHub, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
childRepoName = parentRef.Name;
|
||||
childRepoRef = parentRef.Ref;
|
||||
}
|
||||
state = await PrepareActionsRecursiveLegacyAsync(executionContext, state, setupInfo.Steps, depth + 1, action.Id, childRepoName, childRepoRef);
|
||||
state = await PrepareActionsRecursiveLegacyAsync(executionContext, state, setupInfo.Steps, depth + 1, action.Id);
|
||||
}
|
||||
var repoAction = action.Reference as Pipelines.RepositoryPathReference;
|
||||
if (repoAction.RepositoryType != Pipelines.PipelineConstants.SelfAlias)
|
||||
@@ -704,12 +562,6 @@ namespace GitHub.Runner.Worker
|
||||
actionDirectory = Path.Combine(actionDirectory, repoAction.Path);
|
||||
}
|
||||
}
|
||||
else if (string.Equals(repoAction.RepositoryType, Pipelines.PipelineConstants.SelfRepositoryAlias, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Unresolved self-repository reference at load time — this
|
||||
// shouldn't happen but guard against NRE if it does.
|
||||
throw new InvalidOperationException($"Self-repository reference '$/{repoAction.Path}' was not resolved before LoadAction. Ensure the '{Constants.Runner.Features.SelfRepository}' feature flag is enabled.");
|
||||
}
|
||||
else
|
||||
{
|
||||
actionDirectory = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Actions), repoAction.Name.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar), repoAction.Ref);
|
||||
@@ -845,27 +697,6 @@ namespace GitHub.Runner.Worker
|
||||
_cachedEmbeddedStepIds[action.Id].Add(guid);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve self-repository refs in composite steps at load time.
|
||||
// During setup, resolution happens on a separate copy of these
|
||||
// step objects. At runtime, action.yml is re-parsed, producing
|
||||
// fresh self-repository refs that need resolution here.
|
||||
// When the parent is a dot-slash (self local-workspace) action,
|
||||
// repoAction.Name/Ref are null — fall back to workflow context.
|
||||
if (executionContext.Global.Variables.GetBoolean(Constants.Runner.Features.SelfRepository) == true)
|
||||
{
|
||||
var parentName = repoAction.Name ?? executionContext.JobContext?.WorkflowRepository;
|
||||
var parentRef = repoAction.Ref ?? executionContext.JobContext?.WorkflowSha;
|
||||
ResolveSelfRepositoryReferences(executionContext, compositeAction.Steps, parentName, parentRef);
|
||||
if (compositeAction.PreSteps != null)
|
||||
{
|
||||
ResolveSelfRepositoryReferences(executionContext, compositeAction.PreSteps, parentName, parentRef);
|
||||
}
|
||||
if (compositeAction.PostSteps != null)
|
||||
{
|
||||
ResolveSelfRepositoryReferences(executionContext, compositeAction.PostSteps, parentName, parentRef);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -1049,11 +880,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>();
|
||||
@@ -1065,7 +891,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
|
||||
{
|
||||
@@ -1149,33 +975,10 @@ namespace GitHub.Runner.Worker
|
||||
|
||||
if (actionsToResolve.Count > 0)
|
||||
{
|
||||
IDictionary<string, WebApi.ActionDownloadInfo> downloadInfos = null;
|
||||
Exception resolveFailure = null;
|
||||
try
|
||||
var downloadInfos = await GetDownloadInfoAsync(executionContext, actionsToResolve);
|
||||
foreach (var kvp in downloadInfos)
|
||||
{
|
||||
downloadInfos = await GetDownloadInfoAsync(executionContext, actionsToResolve);
|
||||
foreach (var kvp in downloadInfos)
|
||||
{
|
||||
resolvedDownloadInfos[kvp.Key] = kvp.Value;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// record the exception for telemetry, and rethrow the original exception to fail the step.
|
||||
resolveFailure = ex;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
executionContext.Global.JobTelemetry.Add(new JobTelemetry()
|
||||
{
|
||||
Type = JobTelemetryType.General,
|
||||
Message = $"resolve_download_actions_telemetry:{StringUtil.ConvertToJson(new ActionTelemetryPayload
|
||||
{
|
||||
Operation = "resolve_actions",
|
||||
Result = resolveFailure == null ? "succeeded" : resolveFailure.GetType().Name
|
||||
}, Newtonsoft.Json.Formatting.None)}"
|
||||
});
|
||||
resolvedDownloadInfos[kvp.Key] = kvp.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1300,6 +1103,12 @@ namespace GitHub.Runner.Worker
|
||||
}
|
||||
}
|
||||
|
||||
executionContext.Global.JobTelemetry.Add(new JobTelemetry()
|
||||
{
|
||||
Type = JobTelemetryType.General,
|
||||
Message = $"Action archive cache usage: {downloadInfo.ResolvedNameWithOwner}@{downloadInfo.ResolvedSha} use cache {useActionArchiveCache} has cache {hasActionArchiveCache}"
|
||||
});
|
||||
|
||||
if (!useActionArchiveCache)
|
||||
{
|
||||
await DownloadRepositoryArchive(executionContext, link, downloadInfo.Authentication?.Token, archiveFile);
|
||||
@@ -1308,13 +1117,6 @@ namespace GitHub.Runner.Worker
|
||||
var stagingDirectory = Path.Combine(tempDirectory, "_staging");
|
||||
Directory.CreateDirectory(stagingDirectory);
|
||||
|
||||
var fileInfo = new FileInfo(archiveFile);
|
||||
executionContext.Global.JobTelemetry.Add(new JobTelemetry()
|
||||
{
|
||||
Type = JobTelemetryType.General,
|
||||
Message = $"Action archive cache usage: {downloadInfo.ResolvedNameWithOwner}@{downloadInfo.ResolvedSha} use cache {useActionArchiveCache} has cache {hasActionArchiveCache} size {fileInfo.Length} bytes"
|
||||
});
|
||||
|
||||
#if OS_WINDOWS
|
||||
try
|
||||
{
|
||||
@@ -1352,6 +1154,7 @@ namespace GitHub.Runner.Worker
|
||||
int exitCode = await processInvoker.ExecuteAsync(stagingDirectory, tar, $"-xzf \"{archiveFile}\"", null, executionContext.CancellationToken);
|
||||
if (exitCode != 0)
|
||||
{
|
||||
var fileInfo = new FileInfo(archiveFile);
|
||||
var sha256hash = await IOUtil.GetFileContentSha256HashAsync(archiveFile);
|
||||
throw new InvalidActionArchiveException($"Can't use 'tar -xzf' extract archive file: {archiveFile} (SHA256 '{sha256hash}', size '{fileInfo.Length}' bytes, tar outputs '{string.Join(' ', tarOutputs)}'). Action being checked out: {downloadInfo.NameWithOwner}@{downloadInfo.Ref}. return code: {exitCode}.");
|
||||
}
|
||||
@@ -1401,12 +1204,6 @@ namespace GitHub.Runner.Worker
|
||||
|
||||
private string GetWatermarkFilePath(string directory) => directory + ".completed";
|
||||
|
||||
private sealed class ActionTelemetryPayload
|
||||
{
|
||||
public string Operation { get; set; }
|
||||
public string Result { get; set; }
|
||||
}
|
||||
|
||||
private ActionSetupInfo PrepareRepositoryActionAsync(IExecutionContext executionContext, Pipelines.ActionStep repositoryAction)
|
||||
{
|
||||
var repositoryReference = repositoryAction.Reference as Pipelines.RepositoryPathReference;
|
||||
@@ -1545,47 +1342,6 @@ namespace GitHub.Runner.Worker
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves self-reference ($/) references by mutating them in-place
|
||||
/// to standard GitHub repository references with the containing repo's
|
||||
/// name and ref.
|
||||
/// </summary>
|
||||
private void ResolveSelfRepositoryReferences(IExecutionContext executionContext, IEnumerable<Pipelines.ActionStep> actions, string repoName, string repoRef)
|
||||
{
|
||||
if (string.IsNullOrEmpty(repoName) || string.IsNullOrEmpty(repoRef))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var action in actions)
|
||||
{
|
||||
if (action.Reference.Type != Pipelines.ActionSourceType.Repository)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var repoAction = action.Reference as Pipelines.RepositoryPathReference;
|
||||
if (!string.Equals(repoAction.RepositoryType, Pipelines.PipelineConstants.SelfRepositoryAlias, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Trace.Info($"Resolving self-repository reference reference '$/{repoAction.Path}' to '{repoName}/{repoAction.Path}@{repoRef}'");
|
||||
executionContext.Debug($"Resolving $/{repoAction.Path} → {repoName}/{repoAction.Path}@{repoRef}");
|
||||
|
||||
repoAction.RepositoryType = Pipelines.RepositoryTypes.GitHub;
|
||||
repoAction.Name = repoName;
|
||||
repoAction.Ref = repoRef;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// If this is a reusable workflow job, ensure the workflow repo tarball
|
||||
/// is downloaded so self.workspace resolves to a real path on disk.
|
||||
/// Always downloads for reusable workflows when the feature flag is on,
|
||||
/// since step expressions are already expanded by the server and can't
|
||||
/// be scanned for self.* usage.
|
||||
/// </summary>
|
||||
private static string GetDownloadInfoLookupKey(Pipelines.ActionStep action)
|
||||
{
|
||||
if (action.Reference.Type != Pipelines.ActionSourceType.Repository)
|
||||
@@ -1601,11 +1357,6 @@ namespace GitHub.Runner.Worker
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.Equals(repositoryReference.RepositoryType, Pipelines.PipelineConstants.SelfRepositoryAlias, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"Unable to resolve self-reference '$/'. This can occur when the server does not support this syntax, the feature flag is disabled, or the workflow context (repository/SHA) is unavailable.");
|
||||
}
|
||||
|
||||
if (!string.Equals(repositoryReference.RepositoryType, Pipelines.RepositoryTypes.GitHub, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new NotSupportedException(repositoryReference.RepositoryType);
|
||||
@@ -1690,11 +1441,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
|
||||
@@ -1718,11 +1464,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++;
|
||||
@@ -1748,7 +1489,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);
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace GitHub.Runner.Worker
|
||||
{
|
||||
public enum ArtifactSubjectKind
|
||||
{
|
||||
File,
|
||||
OciSubject,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single artifact subject declared via the
|
||||
/// <c>GITHUB_ARTIFACTS</c> per-step environment file.
|
||||
/// </summary>
|
||||
public sealed class ArtifactSubject : IEquatable<ArtifactSubject>
|
||||
{
|
||||
public ArtifactSubject(string name, string digest, ArtifactSubjectKind kind)
|
||||
{
|
||||
if (string.IsNullOrEmpty(name))
|
||||
{
|
||||
throw new ArgumentException("Name must not be null or empty.", nameof(name));
|
||||
}
|
||||
if (string.IsNullOrEmpty(digest))
|
||||
{
|
||||
throw new ArgumentException("Digest must not be null or empty.", nameof(digest));
|
||||
}
|
||||
Name = name;
|
||||
Digest = digest;
|
||||
Kind = kind;
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
public string Digest { get; }
|
||||
public ArtifactSubjectKind Kind { get; }
|
||||
|
||||
public bool Equals(ArtifactSubject other)
|
||||
{
|
||||
if (other is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return string.Equals(Name, other.Name, StringComparison.Ordinal)
|
||||
&& string.Equals(Digest, other.Digest, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public override bool Equals(object obj) => Equals(obj as ArtifactSubject);
|
||||
|
||||
public override int GetHashCode() => HashCode.Combine(Name, Digest);
|
||||
|
||||
public override string ToString() => $"{Name}@{Digest}";
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Sdk;
|
||||
using GitHub.Runner.Worker.Container;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace GitHub.Runner.Worker
|
||||
{
|
||||
/// <summary>
|
||||
/// File command extension that exposes the job-scoped aggregate of
|
||||
/// <see cref="GlobalContext.ArtifactSubjects"/> as a read-only JSON
|
||||
/// file. Subsequent steps in the same job read the file via the
|
||||
/// <c>GITHUB_ARTIFACTS_LIST</c> environment variable, getting a
|
||||
/// running view of every artifact declared via
|
||||
/// <c>$GITHUB_ARTIFACTS</c> in earlier steps.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The file uses the existing per-step file-command lifecycle:
|
||||
/// <see cref="FileCommandManager.InitializeFiles"/> creates a fresh
|
||||
/// file, invokes <see cref="PopulateInitialContents"/> here, exposes
|
||||
/// the (translated) path to the step's environment, and (for the
|
||||
/// read-only file) ignores anything the step writes back.
|
||||
///
|
||||
/// The file is always written when the feature is enabled, so
|
||||
/// consumers never need to branch on "did the runner inject this?".
|
||||
/// An empty aggregate produces <c>{"version":1,"subjects":[]}</c>.
|
||||
/// </remarks>
|
||||
public sealed class ArtifactsListFileCommand : RunnerService, IFileCommandExtension
|
||||
{
|
||||
public const int FormatVersion = 1;
|
||||
|
||||
public string ContextName => "artifacts_list";
|
||||
public string FilePrefix => "artifacts_list_";
|
||||
|
||||
public Type ExtensionType => typeof(IFileCommandExtension);
|
||||
|
||||
public void PopulateInitialContents(IExecutionContext context, string filePath, ContainerInfo container)
|
||||
{
|
||||
ArgUtil.NotNull(context, nameof(context));
|
||||
|
||||
// Feature flag gate. Mirrors CreateArtifactsFileCommand so the
|
||||
// write side and the read side are toggled together.
|
||||
var enabled = (context.Global.Variables.GetBoolean(Constants.Runner.Features.AllowArtifactsFile) ?? false)
|
||||
|| StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable(CreateArtifactsFileCommand.EnableEnvVar));
|
||||
if (!enabled)
|
||||
{
|
||||
Trace.Verbose("$GITHUB_ARTIFACTS_LIST publishing is disabled (feature flag and env-var fallback are both off).");
|
||||
return;
|
||||
}
|
||||
|
||||
var aggregate = context.Global.ArtifactSubjects
|
||||
?? new Dictionary<string, ArtifactSubject>(StringComparer.Ordinal);
|
||||
|
||||
var subjects = new JArray();
|
||||
// Emit subjects sorted by name so the output is deterministic
|
||||
// regardless of the backing dictionary's enumeration order
|
||||
// (which is not contractually guaranteed).
|
||||
foreach (var entry in aggregate.Values.OrderBy(v => v.Name, StringComparer.Ordinal))
|
||||
{
|
||||
subjects.Add(new JObject
|
||||
{
|
||||
["name"] = entry.Name,
|
||||
["digest"] = entry.Digest,
|
||||
["kind"] = entry.Kind == ArtifactSubjectKind.OciSubject ? "oci" : "file",
|
||||
});
|
||||
}
|
||||
|
||||
var payload = new JObject
|
||||
{
|
||||
["version"] = FormatVersion,
|
||||
["subjects"] = subjects,
|
||||
};
|
||||
|
||||
// UTF-8 without BOM; consumers in other languages should not
|
||||
// have to special-case a leading BOM.
|
||||
File.WriteAllText(filePath, payload.ToString(Formatting.None), new UTF8Encoding(false));
|
||||
Trace.Info($"Wrote $GITHUB_ARTIFACTS_LIST with {aggregate.Count} subject(s) to '{filePath}'");
|
||||
}
|
||||
|
||||
public void ProcessCommand(IExecutionContext context, string filePath, ContainerInfo container)
|
||||
{
|
||||
// Read-only file: anything the step writes here is ignored.
|
||||
// The aggregate is fed only by the write-side $GITHUB_ARTIFACTS
|
||||
// file processed by CreateArtifactsFileCommand.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace GitHub.Runner.Worker
|
||||
{
|
||||
/// <summary>
|
||||
/// Pure data for control-flow steps (wait, wait-all, cancel).
|
||||
/// Type uses Pipelines.BackgroundControlTypes string constants.
|
||||
/// </summary>
|
||||
public sealed class BackgroundStepControlFlowData
|
||||
{
|
||||
public string Type { get; set; }
|
||||
public Guid StepId { get; set; }
|
||||
public string StepName { get; set; }
|
||||
|
||||
// Target step IDs (for wait: steps to wait for; for cancel: steps to cancel)
|
||||
public string[] StepIds { get; set; }
|
||||
|
||||
// Parallel group ID for grouping steps in the UI
|
||||
public string ParallelGroupId { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,394 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using GitHub.Runner.Sdk;
|
||||
using Pipelines = GitHub.DistributedTask.Pipelines;
|
||||
|
||||
namespace GitHub.Runner.Worker
|
||||
{
|
||||
[ServiceLocator(Default = typeof(BackgroundStepCoordinator))]
|
||||
public interface IBackgroundStepCoordinator : IRunnerService
|
||||
{
|
||||
void InitializeCoordinator(int maxConcurrent);
|
||||
void StartBackgroundStep(IStep step, CancellationToken jobCancellationToken);
|
||||
Task<TaskResult> WaitForUnwaitedStepsAsync(CancellationToken cancellationToken);
|
||||
Task RunControlFlowAsync(IExecutionContext stepContext, object data);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Coordinates background step execution, waiting, cancellation, and deferred state.
|
||||
/// Extracted from StepsRunner so the main step loop stays clean.
|
||||
/// </summary>
|
||||
public sealed class BackgroundStepCoordinator : RunnerService, IBackgroundStepCoordinator
|
||||
{
|
||||
private const int DefaultMaxBackgroundSteps = 10;
|
||||
private readonly Dictionary<string, (IStep Step, Task Task, CancellationTokenSource Cts)> _backgroundSteps = new();
|
||||
|
||||
// IDs of background steps that have already been completed (waited on or canceled).
|
||||
// Used to avoid waiting on or flushing the same step more than once.
|
||||
private readonly HashSet<string> _completedStepIds = new();
|
||||
|
||||
// IDs of background steps that were explicitly canceled via a `cancel` control step.
|
||||
// These steps are expected to be canceled, so their (Canceled) result must not be
|
||||
// merged into the overall job result.
|
||||
private readonly HashSet<string> _explicitlyCanceledStepIds = new();
|
||||
private SemaphoreSlim _backgroundSlotSemaphore = new SemaphoreSlim(DefaultMaxBackgroundSteps);
|
||||
|
||||
/// <summary>
|
||||
/// Reset per-job state. Call at the start of each job.
|
||||
/// </summary>
|
||||
public void InitializeCoordinator(int maxConcurrent)
|
||||
{
|
||||
_backgroundSteps.Clear();
|
||||
_completedStepIds.Clear();
|
||||
_explicitlyCanceledStepIds.Clear();
|
||||
var max = maxConcurrent > 0 ? maxConcurrent : DefaultMaxBackgroundSteps;
|
||||
_backgroundSlotSemaphore = new SemaphoreSlim(max);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Starting background steps
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Prepare and launch a background step. Does not block the caller.
|
||||
/// </summary>
|
||||
public void StartBackgroundStep(IStep step, CancellationToken jobCancellationToken)
|
||||
{
|
||||
var stepId = step.ExecutionContext?.ContextName ?? step.DisplayName;
|
||||
|
||||
// Isolate GitHubContext so concurrent steps don't overwrite each other's GITHUB_OUTPUT paths
|
||||
if (step.ExecutionContext.ExpressionValues.TryGetValue("github", out var ghCtx) && ghCtx is GitHubContext sharedGitHub)
|
||||
{
|
||||
step.ExecutionContext.ExpressionValues["github"] = sharedGitHub.ShallowCopy();
|
||||
}
|
||||
|
||||
var bgCts = CancellationTokenSource.CreateLinkedTokenSource(jobCancellationToken);
|
||||
|
||||
// Evaluate timeout on the main thread (needs expression context)
|
||||
var timeoutMinutes = 0;
|
||||
try
|
||||
{
|
||||
var templateEvaluator = step.ExecutionContext.ToPipelineTemplateEvaluator();
|
||||
timeoutMinutes = templateEvaluator.EvaluateStepTimeout(step.Timeout, step.ExecutionContext.ExpressionValues, step.ExecutionContext.ExpressionFunctions);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Info($"Error determining timeout for background step '{stepId}': {ex.Message}");
|
||||
}
|
||||
|
||||
var task = ExecuteBackgroundStepCoreAsync(step, bgCts, stepId, timeoutMinutes);
|
||||
_backgroundSteps[stepId] = (step, task, bgCts);
|
||||
Trace.Info($"Background step '{stepId}' queued (slot will be acquired asynchronously).");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Safety net
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
// Drain any background steps that weren't already waited on by an explicit wait/cancel
|
||||
// control step, then merge the final results of all background steps into a single result
|
||||
// for the caller to fold into the job result.
|
||||
public async Task<TaskResult> WaitForUnwaitedStepsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var unwaitedIds = _backgroundSteps.Keys.Where(id => !_completedStepIds.Contains(id)).ToList();
|
||||
if (unwaitedIds.Count > 0)
|
||||
{
|
||||
Trace.Info($"Safety net: {unwaitedIds.Count} unwaited background step(s) at post-job boundary: {string.Join(", ", unwaitedIds)}");
|
||||
await WaitForStepTasksAsync(unwaitedIds, cancellationToken);
|
||||
CompleteWaitedSteps(unwaitedIds);
|
||||
}
|
||||
|
||||
var result = TaskResult.Succeeded;
|
||||
foreach (var (stepId, (step, _, _)) in _backgroundSteps)
|
||||
{
|
||||
// A step that succeeded does not set a Result by default, so a missing
|
||||
// value means the step succeeded and there is nothing to merge.
|
||||
if (!step.ExecutionContext.Result.HasValue)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// A step explicitly canceled via a `cancel` control step is expected to be canceled,
|
||||
// so a Canceled result must not influence the overall job result. However, if the step
|
||||
// failed (e.g. before the cancellation took effect), that failure should still count.
|
||||
if (_explicitlyCanceledStepIds.Contains(stepId) &&
|
||||
step.ExecutionContext.Result.Value == TaskResult.Canceled)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
result = TaskResultUtil.MergeTaskResults(result, step.ExecutionContext.Result.Value);
|
||||
}
|
||||
|
||||
if (result != TaskResult.Succeeded)
|
||||
{
|
||||
Trace.Info($"Background steps reported result '{result}' to caller.");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Control-flow step dispatch
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Execute a control-flow step (wait, wait-all, cancel) and propagate results.
|
||||
/// </summary>
|
||||
public async Task RunControlFlowAsync(IExecutionContext stepContext, object data)
|
||||
{
|
||||
var controlFlow = data as BackgroundStepControlFlowData;
|
||||
switch (controlFlow.Type)
|
||||
{
|
||||
case Pipelines.BackgroundControlTypes.Wait:
|
||||
{
|
||||
var ids = controlFlow.StepIds ?? Array.Empty<string>();
|
||||
stepContext.Output($"Waiting for background step(s) to complete: {DescribeSteps(ids)}");
|
||||
await WaitForStepTasksAsync(ids, stepContext.CancellationToken);
|
||||
stepContext.Result = CompleteWaitedSteps(ids);
|
||||
ReportCompletedSteps(stepContext, "Finished waiting for background step(s).", ids);
|
||||
break;
|
||||
}
|
||||
|
||||
case Pipelines.BackgroundControlTypes.WaitAll:
|
||||
{
|
||||
var remaining = _backgroundSteps.Keys.Where(id => !_completedStepIds.Contains(id)).ToList();
|
||||
stepContext.Output(remaining.Count > 0
|
||||
? $"Waiting for all background step(s) to complete: {DescribeSteps(remaining)}"
|
||||
: "No background steps remaining to wait for.");
|
||||
await WaitForStepTasksAsync(remaining, stepContext.CancellationToken);
|
||||
stepContext.Result = CompleteWaitedSteps(remaining);
|
||||
ReportCompletedSteps(stepContext, "Finished waiting for all background step(s).", remaining);
|
||||
break;
|
||||
}
|
||||
|
||||
case Pipelines.BackgroundControlTypes.Cancel:
|
||||
{
|
||||
var cancelIds = controlFlow.StepIds ?? Array.Empty<string>();
|
||||
stepContext.Output($"Cancelling background step(s): {DescribeSteps(cancelIds)}");
|
||||
await CancelStepsAsync(controlFlow.StepIds);
|
||||
stepContext.Result = TaskResult.Succeeded;
|
||||
ReportCompletedSteps(stepContext, "Finished cancelling background step(s).", cancelIds);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new ArgumentException($"Unknown background step control type '{controlFlow.Type}'.");
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Private helpers
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
// Resolve background step IDs to their display names for customer-facing output.
|
||||
private string DescribeSteps(IEnumerable<string> stepIds)
|
||||
{
|
||||
var names = stepIds
|
||||
.Select(id => _backgroundSteps.TryGetValue(id, out var entry) ? entry.Step.DisplayName : id)
|
||||
.ToList();
|
||||
return names.Count > 0 ? string.Join(", ", names) : "(none)";
|
||||
}
|
||||
|
||||
// Emit a completion summary plus the final result of each affected background step.
|
||||
private void ReportCompletedSteps(IExecutionContext stepContext, string summary, IEnumerable<string> stepIds)
|
||||
{
|
||||
stepContext.Output(summary);
|
||||
foreach (var id in stepIds)
|
||||
{
|
||||
if (_backgroundSteps.TryGetValue(id, out var entry))
|
||||
{
|
||||
var result = entry.Step.ExecutionContext.Result?.ToString() ?? "Unknown";
|
||||
stepContext.Output($" {entry.Step.DisplayName}: {result}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExecuteBackgroundStepCoreAsync(
|
||||
IStep step, CancellationTokenSource bgCts,
|
||||
string stepId, int timeoutMinutes)
|
||||
{
|
||||
Trace.Info($"Background step '{stepId}' waiting for slot.");
|
||||
await _backgroundSlotSemaphore.WaitAsync(bgCts.Token);
|
||||
Trace.Info($"Background step '{stepId}' acquired slot.");
|
||||
|
||||
step.ExecutionContext.Start();
|
||||
|
||||
if (timeoutMinutes > 0)
|
||||
{
|
||||
step.ExecutionContext.SetTimeout(TimeSpan.FromMinutes(timeoutMinutes));
|
||||
}
|
||||
|
||||
using var cancelReg = bgCts.Token.Register(() =>
|
||||
{
|
||||
Trace.Info($"Background step '{stepId}': cancellation signalled, sending CancelToken to process.");
|
||||
step.ExecutionContext.CancelToken();
|
||||
});
|
||||
|
||||
TaskResult? result = null;
|
||||
try
|
||||
{
|
||||
await step.RunAsync();
|
||||
result = step.ExecutionContext.Result ?? TaskResult.Succeeded;
|
||||
}
|
||||
catch (OperationCanceledException) when (bgCts.Token.IsCancellationRequested)
|
||||
{
|
||||
result = TaskResult.Canceled;
|
||||
}
|
||||
catch (OperationCanceledException) when (step.ExecutionContext.CancellationToken.IsCancellationRequested)
|
||||
{
|
||||
Trace.Info($"Background step '{stepId}' timed out after {timeoutMinutes} minutes.");
|
||||
step.ExecutionContext.Error($"The background step '{step.DisplayName}' has timed out after {timeoutMinutes} minutes.");
|
||||
result = TaskResult.Failed;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Info($"Background step '{stepId}' failed: {ex.Message}");
|
||||
step.ExecutionContext.Error(ex);
|
||||
result = TaskResult.Failed;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_backgroundSlotSemaphore.Release();
|
||||
|
||||
if (step.ExecutionContext.CommandResult != null)
|
||||
{
|
||||
result = TaskResultUtil.MergeTaskResults(result, step.ExecutionContext.CommandResult.Value);
|
||||
}
|
||||
|
||||
step.ExecutionContext.Result = result;
|
||||
step.ExecutionContext.ApplyContinueOnError(step.ContinueOnError);
|
||||
|
||||
step.ExecutionContext.Complete(step.ExecutionContext.Result);
|
||||
Trace.Info($"Background step '{stepId}' completed with result: {step.ExecutionContext.Result}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CancelStepsAsync(string[] cancelStepIds)
|
||||
{
|
||||
if (cancelStepIds == null || cancelStepIds.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark these steps as expected-to-be-canceled so their result does not
|
||||
// affect the overall job result.
|
||||
foreach (var id in cancelStepIds)
|
||||
{
|
||||
_explicitlyCanceledStepIds.Add(id);
|
||||
}
|
||||
|
||||
var idsToCancel = cancelStepIds
|
||||
.Where(id => _backgroundSteps.ContainsKey(id) && !_backgroundSteps[id].Task.IsCompleted)
|
||||
.ToArray();
|
||||
|
||||
if (idsToCancel.Length > 0)
|
||||
{
|
||||
Trace.Info($"Cancelling {idsToCancel.Length} background step(s): {string.Join(", ", idsToCancel)}");
|
||||
await CancelWithGracePeriodAsync(idsToCancel);
|
||||
}
|
||||
|
||||
// Flush deferred state and mark canceled steps as completed.
|
||||
CompleteWaitedSteps(cancelStepIds);
|
||||
}
|
||||
|
||||
private async Task WaitForStepTasksAsync(IEnumerable<string> stepIds, CancellationToken cancellationToken)
|
||||
{
|
||||
var ids = stepIds.ToList();
|
||||
var tasks = new List<Task>();
|
||||
|
||||
foreach (var stepId in ids)
|
||||
{
|
||||
if (_backgroundSteps.TryGetValue(stepId, out var entry) && !entry.Task.IsCompleted)
|
||||
{
|
||||
tasks.Add(entry.Task);
|
||||
}
|
||||
else if (!_backgroundSteps.ContainsKey(stepId))
|
||||
{
|
||||
Trace.Info($"Wait references unknown background step: {stepId}");
|
||||
}
|
||||
}
|
||||
|
||||
if (tasks.Count > 0)
|
||||
{
|
||||
Trace.Info($"Waiting for {tasks.Count} background step(s)...");
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(tasks).WaitAsync(cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
Trace.Info("Wait interrupted by job cancellation — cancelling background steps.");
|
||||
await CancelWithGracePeriodAsync(ids);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CancelWithGracePeriodAsync(IEnumerable<string> stepIds, double graceSeconds = 7.5)
|
||||
{
|
||||
var cancelledSteps = new List<(string StepId, Task Task, IStep Step)>();
|
||||
foreach (var stepId in stepIds)
|
||||
{
|
||||
if (_backgroundSteps.TryGetValue(stepId, out var entry) && !entry.Task.IsCompleted)
|
||||
{
|
||||
entry.Step.ExecutionContext.CancelToken();
|
||||
entry.Cts.Cancel();
|
||||
cancelledSteps.Add((stepId, entry.Task, entry.Step));
|
||||
}
|
||||
}
|
||||
|
||||
if (cancelledSteps.Count > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(cancelledSteps.Select(s => s.Task)).WaitAsync(TimeSpan.FromSeconds(graceSeconds));
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
Trace.Info($"Some background steps did not terminate within {graceSeconds}s grace period.");
|
||||
|
||||
// The step tasks above never completed, so their finally block never ran and
|
||||
// their result was never set. Force-mark them as canceled so the abandoned
|
||||
// steps still report a terminal result.
|
||||
foreach (var (stepId, task, step) in cancelledSteps)
|
||||
{
|
||||
if (!task.IsCompleted && !step.ExecutionContext.Result.HasValue)
|
||||
{
|
||||
step.ExecutionContext.Result = TaskResult.Canceled;
|
||||
Trace.Info($"Background step '{stepId}' did not terminate within grace period; marking as canceled.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private TaskResult CompleteWaitedSteps(IEnumerable<string> stepIds)
|
||||
{
|
||||
var result = TaskResult.Succeeded;
|
||||
foreach (var id in stepIds)
|
||||
{
|
||||
_completedStepIds.Add(id);
|
||||
if (_backgroundSteps.TryGetValue(id, out var entry))
|
||||
{
|
||||
// Flush deferred state for the completed step.
|
||||
entry.Step.ExecutionContext.FlushDeferredOutputs();
|
||||
entry.Step.ExecutionContext.FlushDeferredEnvironment();
|
||||
entry.Step.ExecutionContext.FlushDeferredOutcomeConclusion();
|
||||
Trace.Info($"Flushed deferred state for background step '{id}'.");
|
||||
|
||||
if (entry.Step.ExecutionContext.Result.HasValue)
|
||||
{
|
||||
result = TaskResultUtil.MergeTaskResults(result, entry.Step.ExecutionContext.Result.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,384 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Sdk;
|
||||
using GitHub.Runner.Worker.Container;
|
||||
|
||||
namespace GitHub.Runner.Worker
|
||||
{
|
||||
/// <summary>
|
||||
/// File command extension that implements the <c>GITHUB_ARTIFACTS</c>
|
||||
/// per-step environment file contract.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Lifecycle is identical to the other per-step file commands:
|
||||
/// <see cref="FileCommandManager"/> creates an empty file before each
|
||||
/// step runs and invokes <see cref="ProcessCommand"/> after the step
|
||||
/// completes. This class is responsible for parsing the file's
|
||||
/// contents, validating each entry, and aggregating the resulting
|
||||
/// (name, digest) pairs onto <see cref="GlobalContext.ArtifactSubjects"/>
|
||||
/// at job scope.
|
||||
///
|
||||
/// The feature is gated by the <c>actions_runner_allow_artifacts_file</c>
|
||||
/// feature flag. When the flag is disabled, the env var is still
|
||||
/// exposed but writes are silently ignored.
|
||||
/// </remarks>
|
||||
public sealed class CreateArtifactsFileCommand : RunnerService, IFileCommandExtension
|
||||
{
|
||||
// Each per-step file may contain at most 1 MiB.
|
||||
public const int MaxFileSizeBytes = 1024 * 1024;
|
||||
|
||||
// A job may declare at most 500 artifacts in aggregate.
|
||||
public const int MaxAggregateArtifacts = 500;
|
||||
|
||||
public string ContextName => "artifacts";
|
||||
public string FilePrefix => "artifacts_";
|
||||
|
||||
// Runner-side environment variable that enables the feature on
|
||||
// self-hosted runners where the server-side feature flag is not
|
||||
// configurable. Mirrors patterns like
|
||||
// ACTIONS_RUNNER_COMPARE_WORKFLOW_PARSER elsewhere in the runner.
|
||||
public const string EnableEnvVar = "ACTIONS_RUNNER_ALLOW_ARTIFACTS_FILE";
|
||||
|
||||
public Type ExtensionType => typeof(IFileCommandExtension);
|
||||
|
||||
// Recognized scheme prefixes (case-insensitive).
|
||||
private const string FileScheme = "file://";
|
||||
private const string OciScheme = "oci://";
|
||||
|
||||
// Matches "<scheme>://...". Used to detect unsupported URI schemes.
|
||||
// Scheme grammar per RFC 3986: ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )
|
||||
private static readonly Regex s_schemeRegex = new(
|
||||
@"^[A-Za-z][A-Za-z0-9+.\-]*://",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
// Matches "<ref>@<algo>:<hex>" where <algo> is sha256/sha384/sha512.
|
||||
// Hex length is validated separately so we can produce a precise error.
|
||||
private static readonly Regex s_ociDigestSuffixRegex = new(
|
||||
@"^(?<ref>.+)@(?<algo>sha(?:256|384|512)):(?<hex>[0-9a-fA-F]+)$",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
public void ProcessCommand(IExecutionContext context, string filePath, ContainerInfo container)
|
||||
{
|
||||
ArgUtil.NotNull(context, nameof(context));
|
||||
|
||||
// Feature flag gate. Enabled when either the server-side
|
||||
// feature flag is set, or the runner is started with the
|
||||
// ACTIONS_RUNNER_ALLOW_ARTIFACTS_FILE env var set to true
|
||||
// (the env-var fallback exists so self-hosted runners can
|
||||
// opt in locally). Silently no-op when disabled.
|
||||
var enabled = (context.Global.Variables.GetBoolean(Constants.Runner.Features.AllowArtifactsFile) ?? false)
|
||||
|| StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable(EnableEnvVar));
|
||||
if (!enabled)
|
||||
{
|
||||
Trace.Verbose("$GITHUB_ARTIFACTS processing is disabled (feature flag and env-var fallback are both off).");
|
||||
return;
|
||||
}
|
||||
|
||||
Trace.Info($"Processing $GITHUB_ARTIFACTS file '{filePath}'");
|
||||
|
||||
if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath))
|
||||
{
|
||||
Trace.Info("$GITHUB_ARTIFACTS file does not exist; nothing to process.");
|
||||
return;
|
||||
}
|
||||
|
||||
var fileSize = new FileInfo(filePath).Length;
|
||||
if (fileSize == 0)
|
||||
{
|
||||
Trace.Info("$GITHUB_ARTIFACTS file is empty; nothing to process.");
|
||||
return;
|
||||
}
|
||||
if (fileSize > MaxFileSizeBytes)
|
||||
{
|
||||
throw new Exception(StringUtil.Format(
|
||||
Constants.Runner.ArtifactsFileSizeExceeded,
|
||||
MaxFileSizeBytes / 1024,
|
||||
fileSize / 1024));
|
||||
}
|
||||
|
||||
// Per-step subjects parsed from this file; aggregated into the
|
||||
// job-level set at the end so a single malformed line fails the
|
||||
// step without partially polluting the aggregate.
|
||||
var parsed = new List<(int LineNumber, ArtifactSubject Subject)>();
|
||||
|
||||
// Relative artifact paths are resolved against the workspace
|
||||
// root (GITHUB_WORKSPACE), not the step's working directory.
|
||||
// This matches the established runner precedent set by
|
||||
// hashFiles() which always resolve relative paths against
|
||||
// the workspace root regardless of any step-level
|
||||
// `working-directory:`.
|
||||
var workspaceRoot = ResolveWorkspaceRoot(context);
|
||||
|
||||
var lines = File.ReadAllLines(filePath, Encoding.UTF8);
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var lineNumber = i + 1;
|
||||
var raw = lines[i];
|
||||
var trimmed = raw.Trim();
|
||||
if (trimmed.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (trimmed[0] == '#')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
ArtifactSubject subject;
|
||||
try
|
||||
{
|
||||
subject = ParseLine(trimmed, workspaceRoot, container);
|
||||
}
|
||||
catch (ArtifactsParseException ex)
|
||||
{
|
||||
throw new Exception(StringUtil.Format(
|
||||
Constants.Runner.ArtifactsInvalidLine,
|
||||
lineNumber,
|
||||
ex.Message));
|
||||
}
|
||||
|
||||
parsed.Add((lineNumber, subject));
|
||||
}
|
||||
|
||||
// Aggregate at job scope: dedup identical, reject conflicts,
|
||||
// enforce the 500-artifact cap (after dedup so identical
|
||||
// duplicates above the cap do not fail).
|
||||
var aggregate = context.Global.ArtifactSubjects;
|
||||
if (aggregate == null)
|
||||
{
|
||||
throw new InvalidOperationException("Global.ArtifactSubjects is not initialized.");
|
||||
}
|
||||
|
||||
var addedThisStep = 0;
|
||||
foreach (var (lineNumber, subject) in parsed)
|
||||
{
|
||||
if (aggregate.TryGetValue(subject.Name, out var existing))
|
||||
{
|
||||
if (string.Equals(existing.Digest, subject.Digest, StringComparison.Ordinal))
|
||||
{
|
||||
// Identical declaration — silently deduplicate.
|
||||
Trace.Info($"Skipped duplicate artifact subject '{subject.Name}' (digest={subject.Digest})");
|
||||
continue;
|
||||
}
|
||||
throw new Exception(StringUtil.Format(
|
||||
Constants.Runner.ArtifactsInvalidLine,
|
||||
lineNumber,
|
||||
StringUtil.Format(
|
||||
Constants.Runner.ArtifactsConflictingDigest,
|
||||
subject.Name,
|
||||
existing.Digest,
|
||||
subject.Digest)));
|
||||
}
|
||||
|
||||
if (aggregate.Count >= MaxAggregateArtifacts)
|
||||
{
|
||||
throw new Exception(StringUtil.Format(
|
||||
Constants.Runner.ArtifactsInvalidLine,
|
||||
lineNumber,
|
||||
StringUtil.Format(
|
||||
Constants.Runner.ArtifactsAggregateLimitExceeded,
|
||||
MaxAggregateArtifacts)));
|
||||
}
|
||||
|
||||
aggregate[subject.Name] = subject;
|
||||
addedThisStep++;
|
||||
Trace.Info($"Declared artifact subject '{subject.Name}' (kind={subject.Kind}, digest={subject.Digest})");
|
||||
context.Debug($"Declared artifact subject '{subject.Name}' (kind={subject.Kind}, digest={subject.Digest})");
|
||||
}
|
||||
|
||||
if (addedThisStep > 0)
|
||||
{
|
||||
// Mirror the existing file-command UX: a single, terse
|
||||
// user-visible line that confirms the declarations landed.
|
||||
context.Output($"Captured {addedThisStep} artifact subject(s) from this step (job total: {aggregate.Count}).");
|
||||
}
|
||||
}
|
||||
|
||||
private ArtifactSubject ParseLine(string trimmed, string workspaceRoot, ContainerInfo container)
|
||||
{
|
||||
// Reject lines containing '=' — reserved for a future v2
|
||||
// key/value extension to the format.
|
||||
if (trimmed.IndexOf('=') >= 0)
|
||||
{
|
||||
throw new ArtifactsParseException("entries containing '=' are reserved and not permitted");
|
||||
}
|
||||
|
||||
// Handle the explicit escape-hatch schemes first
|
||||
// (case-insensitive).
|
||||
if (StartsWithIgnoreCase(trimmed, FileScheme))
|
||||
{
|
||||
var path = trimmed.Substring(FileScheme.Length);
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
throw new ArtifactsParseException("file:// entries must include a path");
|
||||
}
|
||||
return MakeFileSubject(path, workspaceRoot, container);
|
||||
}
|
||||
if (StartsWithIgnoreCase(trimmed, OciScheme))
|
||||
{
|
||||
var rest = trimmed.Substring(OciScheme.Length);
|
||||
var match = s_ociDigestSuffixRegex.Match(rest);
|
||||
if (!match.Success)
|
||||
{
|
||||
throw new ArtifactsParseException("oci:// entries must include an @sha{256,384,512}:<hex> digest");
|
||||
}
|
||||
return MakeOciSubject(match);
|
||||
}
|
||||
|
||||
// Reject any other URI scheme up-front.
|
||||
if (s_schemeRegex.IsMatch(trimmed))
|
||||
{
|
||||
throw new ArtifactsParseException("unsupported URI scheme");
|
||||
}
|
||||
|
||||
// Otherwise discriminate syntactically: an entry that matches
|
||||
// the OCI digest suffix shape (with the right hex length for
|
||||
// its algorithm) is an OCI subject; everything else is a path.
|
||||
var ociMatch = s_ociDigestSuffixRegex.Match(trimmed);
|
||||
if (ociMatch.Success && IsExpectedHexLength(ociMatch.Groups["algo"].Value, ociMatch.Groups["hex"].Value))
|
||||
{
|
||||
return MakeOciSubject(ociMatch);
|
||||
}
|
||||
|
||||
return MakeFileSubject(trimmed, workspaceRoot, container);
|
||||
}
|
||||
|
||||
private static ArtifactSubject MakeOciSubject(Match match)
|
||||
{
|
||||
var refName = match.Groups["ref"].Value;
|
||||
var algo = match.Groups["algo"].Value.ToLowerInvariant();
|
||||
var hex = match.Groups["hex"].Value.ToLowerInvariant();
|
||||
|
||||
if (!IsExpectedHexLength(algo, hex))
|
||||
{
|
||||
throw new ArtifactsParseException(
|
||||
$"digest '{algo}' must be {ExpectedHexLength(algo)} hex characters, got {hex.Length}");
|
||||
}
|
||||
if (string.IsNullOrEmpty(refName))
|
||||
{
|
||||
throw new ArtifactsParseException("oci subject must include a reference");
|
||||
}
|
||||
|
||||
return new ArtifactSubject(refName, $"{algo}:{hex}", ArtifactSubjectKind.OciSubject);
|
||||
}
|
||||
|
||||
private static ArtifactSubject MakeFileSubject(string declaredPath, string workspaceRoot, ContainerInfo container)
|
||||
{
|
||||
var hostPath = ResolveFilePath(declaredPath, workspaceRoot, container);
|
||||
|
||||
if (!File.Exists(hostPath))
|
||||
{
|
||||
if (Directory.Exists(hostPath))
|
||||
{
|
||||
throw new ArtifactsParseException($"'{declaredPath}' is a directory, not a regular file");
|
||||
}
|
||||
// For relative paths, surface where we looked so authors
|
||||
// aren't surprised that resolution is workspace-relative.
|
||||
if (!Path.IsPathRooted(declaredPath))
|
||||
{
|
||||
throw new ArtifactsParseException(
|
||||
$"file '{declaredPath}' does not exist (relative paths are resolved against the workspace root '{workspaceRoot}')");
|
||||
}
|
||||
throw new ArtifactsParseException($"file '{declaredPath}' does not exist");
|
||||
}
|
||||
|
||||
// FileInfo + File.GetAttributes guards against named pipes,
|
||||
// device files, etc. We accept regular files and symlinks
|
||||
// resolved to regular files.
|
||||
var attrs = File.GetAttributes(hostPath);
|
||||
if ((attrs & FileAttributes.Directory) == FileAttributes.Directory)
|
||||
{
|
||||
throw new ArtifactsParseException($"'{declaredPath}' is a directory, not a regular file");
|
||||
}
|
||||
|
||||
string hex;
|
||||
using (var stream = File.OpenRead(hostPath))
|
||||
using (var sha = SHA256.Create())
|
||||
{
|
||||
var hash = sha.ComputeHash(stream);
|
||||
var sb = new StringBuilder(hash.Length * 2);
|
||||
foreach (var b in hash)
|
||||
{
|
||||
sb.Append(b.ToString("x2", CultureInfo.InvariantCulture));
|
||||
}
|
||||
hex = sb.ToString();
|
||||
}
|
||||
|
||||
var name = Path.GetFileName(hostPath);
|
||||
return new ArtifactSubject(name, $"sha256:{hex}", ArtifactSubjectKind.File);
|
||||
}
|
||||
|
||||
private static string ResolveFilePath(string declaredPath, string workspaceRoot, ContainerInfo container)
|
||||
{
|
||||
if (Path.IsPathRooted(declaredPath))
|
||||
{
|
||||
if (container == null)
|
||||
{
|
||||
return declaredPath;
|
||||
}
|
||||
|
||||
// Absolute path from a container step: it lives in the
|
||||
// container's filesystem namespace, so translate it to the
|
||||
// host path via the container's volume mounts.
|
||||
// TranslateToHostPath returns the input unchanged when the
|
||||
// path is not under any mount. We must NOT fall back to the
|
||||
// host file at that same path -- that would hash an arbitrary
|
||||
// host file the container step never referenced -- so reject
|
||||
// it instead.
|
||||
var hostPath = container.TranslateToHostPath(declaredPath);
|
||||
if (string.Equals(hostPath, declaredPath, StringComparison.Ordinal))
|
||||
{
|
||||
throw new ArtifactsParseException(
|
||||
$"absolute path '{declaredPath}' is not inside a volume mounted into the container and cannot be resolved");
|
||||
}
|
||||
return hostPath;
|
||||
}
|
||||
|
||||
// Relative path: resolve against the workspace root
|
||||
// (GITHUB_WORKSPACE).
|
||||
var baseDir = workspaceRoot ?? string.Empty;
|
||||
return Path.GetFullPath(Path.Combine(baseDir, declaredPath));
|
||||
}
|
||||
|
||||
private static string ResolveWorkspaceRoot(IExecutionContext context)
|
||||
{
|
||||
// The workspace root (GITHUB_WORKSPACE) is the resolution base
|
||||
// for all relative artifact paths.
|
||||
var workspace = context.GetGitHubContext("workspace");
|
||||
return string.IsNullOrEmpty(workspace) ? null : workspace;
|
||||
}
|
||||
|
||||
private static bool StartsWithIgnoreCase(string s, string prefix)
|
||||
{
|
||||
return s.StartsWith(prefix, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static int ExpectedHexLength(string algo)
|
||||
{
|
||||
return algo.ToLowerInvariant() switch
|
||||
{
|
||||
"sha256" => 64,
|
||||
"sha384" => 96,
|
||||
"sha512" => 128,
|
||||
_ => -1,
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsExpectedHexLength(string algo, string hex)
|
||||
{
|
||||
var expected = ExpectedHexLength(algo);
|
||||
return expected > 0 && hex.Length == expected;
|
||||
}
|
||||
|
||||
private sealed class ArtifactsParseException : Exception
|
||||
{
|
||||
public ArtifactsParseException(string message) : base(message) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,6 @@ using Microsoft.DevTunnels.Connections;
|
||||
using Microsoft.DevTunnels.Contracts;
|
||||
using Microsoft.DevTunnels.Management;
|
||||
using Newtonsoft.Json;
|
||||
using Pipelines = GitHub.DistributedTask.Pipelines;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap
|
||||
{
|
||||
@@ -28,7 +27,6 @@ namespace GitHub.Runner.Worker.Dap
|
||||
public string DisplayName { get; set; }
|
||||
public TaskResult? Result { get; set; }
|
||||
public int FrameId { get; set; }
|
||||
public int? SourceLine { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -56,9 +54,6 @@ namespace GitHub.Runner.Worker.Dap
|
||||
// Frame IDs for completed steps start at 1000
|
||||
private const int _completedFrameIdBase = 1000;
|
||||
|
||||
// Stable session-scoped source reference for the synthesized job step list.
|
||||
private const int _jobStepsSourceReference = 1;
|
||||
|
||||
private TcpListener _listener;
|
||||
private TcpClient _client;
|
||||
private NetworkStream _stream;
|
||||
@@ -68,11 +63,9 @@ namespace GitHub.Runner.Worker.Dap
|
||||
private volatile DapSessionState _state = DapSessionState.NotStarted;
|
||||
private CancellationTokenRegistration? _cancellationRegistration;
|
||||
private bool _isFirstStep = true;
|
||||
private bool _welcomeMessageSent;
|
||||
|
||||
// Dev Tunnel relay host for remote debugging
|
||||
private TunnelRelayTunnelHost _tunnelRelayHost;
|
||||
private IWebSocketDapBridge _webSocketBridge;
|
||||
|
||||
// Cancellation source for the connection loop, cancelled in StopAsync
|
||||
// so AcceptTcpClientAsync unblocks cleanly without relying on listener disposal.
|
||||
@@ -81,10 +74,6 @@ namespace GitHub.Runner.Worker.Dap
|
||||
// When true, skip tunnel relay startup (unit tests only)
|
||||
internal bool SkipTunnelRelay { get; set; }
|
||||
|
||||
// When true, skip the public websocket bridge and expose the raw DAP
|
||||
// listener directly on the configured tunnel port (unit tests only).
|
||||
internal bool SkipWebSocketBridge { get; set; }
|
||||
|
||||
// Synchronization for step execution
|
||||
private TaskCompletionSource<DapCommand> _commandTcs;
|
||||
private readonly object _stateLock = new object();
|
||||
@@ -103,8 +92,6 @@ namespace GitHub.Runner.Worker.Dap
|
||||
// Track completed steps for stack trace
|
||||
private readonly List<CompletedStepInfo> _completedSteps = new List<CompletedStepInfo>();
|
||||
private int _nextCompletedFrameId = _completedFrameIdBase;
|
||||
private JobExecutionView _jobStepsSource;
|
||||
private bool _jobCompleted;
|
||||
|
||||
// Client connection tracking for reconnection support
|
||||
private volatile bool _isClientConnected;
|
||||
@@ -121,7 +108,6 @@ namespace GitHub.Runner.Worker.Dap
|
||||
_state == DapSessionState.Running;
|
||||
|
||||
internal DapSessionState State => _state;
|
||||
internal int InternalDapPort => (_listener?.LocalEndpoint as IPEndPoint)?.Port ?? 0;
|
||||
|
||||
public override void Initialize(IHostContext hostContext)
|
||||
{
|
||||
@@ -147,19 +133,9 @@ namespace GitHub.Runner.Worker.Dap
|
||||
_jobContext = jobContext;
|
||||
_readyTcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
var dapPort = SkipWebSocketBridge ? debuggerConfig.Tunnel.Port : 0;
|
||||
_listener = new TcpListener(IPAddress.Loopback, dapPort);
|
||||
_listener = new TcpListener(IPAddress.Loopback, debuggerConfig.Tunnel.Port);
|
||||
_listener.Start();
|
||||
if (SkipWebSocketBridge)
|
||||
{
|
||||
Trace.Info($"DAP debugger listening on {_listener.LocalEndpoint}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Trace.Info($"Internal DAP debugger listening on {_listener.LocalEndpoint}");
|
||||
_webSocketBridge = HostContext.CreateService<IWebSocketDapBridge>();
|
||||
_webSocketBridge.Start(debuggerConfig.Tunnel.Port, InternalDapPort);
|
||||
}
|
||||
Trace.Info($"DAP debugger listening on {_listener.LocalEndpoint}");
|
||||
|
||||
// Start Dev Tunnel relay so remote clients reach the local DAP port.
|
||||
// The relay is torn down explicitly in StopAsync (after the DAP session
|
||||
@@ -247,208 +223,10 @@ namespace GitHub.Runner.Worker.Dap
|
||||
}
|
||||
}
|
||||
|
||||
public Task OnJobStepsInitializedAsync(IEnumerable<IStep> steps, IEnumerable<IStep> initialPostSteps)
|
||||
{
|
||||
if (!IsActive)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
IExecutionContext jobContext;
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_state != DapSessionState.Ready &&
|
||||
_state != DapSessionState.Paused &&
|
||||
_state != DapSessionState.Running)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
jobContext = _jobContext;
|
||||
}
|
||||
|
||||
var stepList = steps?.Where(step => step != null).ToList() ?? new List<IStep>();
|
||||
var initialPostStepList = initialPostSteps?.Where(step => step != null).ToList() ?? new List<IStep>();
|
||||
var jobId = jobContext?.GetGitHubContext("job");
|
||||
var snapshot = new JobExecutionView(
|
||||
jobId,
|
||||
stepList,
|
||||
initialPostStepList,
|
||||
PredictPostSteps(jobContext, stepList, initialPostStepList));
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
_jobStepsSource = snapshot;
|
||||
_jobCompleted = false;
|
||||
}
|
||||
Trace.Info("DAP job steps source initialized");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Warning("DAP OnJobStepsInitialized error.");
|
||||
Trace.Error(ex);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void OnPostStepRegistered(IStep step)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (step is IActionRunner postRunner && postRunner.Action != null)
|
||||
{
|
||||
JobExecutionView snapshot;
|
||||
lock (_stateLock)
|
||||
{
|
||||
snapshot = _jobStepsSource;
|
||||
}
|
||||
|
||||
var line = snapshot?.TryClaimPredictedStep(MatchKeyFor(postRunner.Action.Id), step);
|
||||
if (line.HasValue)
|
||||
{
|
||||
Trace.Info($"DAP job steps source claimed predicted post step '{step.DisplayName}' at line {line.Value}.");
|
||||
}
|
||||
else
|
||||
{
|
||||
Trace.Info($"DAP job steps source had no predicted line for post step '{step.DisplayName}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Warning("DAP OnPostStepRegistered error.");
|
||||
Trace.Error(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<JobExecutionView.PredictedPostStep> PredictPostSteps(
|
||||
IExecutionContext jobContext,
|
||||
IReadOnlyList<IStep> steps,
|
||||
IReadOnlyList<IStep> initialPostSteps)
|
||||
{
|
||||
if (jobContext == null || steps == null || steps.Count == 0)
|
||||
{
|
||||
return Array.Empty<JobExecutionView.PredictedPostStep>();
|
||||
}
|
||||
|
||||
IActionManager actionManager;
|
||||
try
|
||||
{
|
||||
actionManager = HostContext.GetService<IActionManager>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Info($"DAP post-step predictor skipped because IActionManager is unavailable ({ex.Message}).");
|
||||
return Array.Empty<JobExecutionView.PredictedPostStep>();
|
||||
}
|
||||
|
||||
var predictions = new List<JobExecutionView.PredictedPostStep>();
|
||||
var seenActionIds = new HashSet<Guid>();
|
||||
if (initialPostSteps != null)
|
||||
{
|
||||
foreach (var postStep in initialPostSteps)
|
||||
{
|
||||
if (postStep is IActionRunner postRunner && postRunner.Action != null)
|
||||
{
|
||||
seenActionIds.Add(postRunner.Action.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var step in steps)
|
||||
{
|
||||
if (step is not IActionRunner runner ||
|
||||
runner.Stage == ActionRunStage.Post ||
|
||||
runner.Action == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var action = runner.Action;
|
||||
if (action.Reference is not Pipelines.RepositoryPathReference repoRef)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!seenActionIds.Add(action.Id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Definition definition;
|
||||
try
|
||||
{
|
||||
definition = actionManager.LoadAction(jobContext, action);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Info($"DAP post-step predictor could not load action '{repoRef.Name}' ({ex.Message}).");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (definition?.Data?.Execution?.HasPost != true)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
predictions.Add(new JobExecutionView.PredictedPostStep(
|
||||
GetPostDisplayName(runner),
|
||||
MatchKeyFor(action.Id)));
|
||||
}
|
||||
|
||||
predictions.Reverse();
|
||||
return predictions;
|
||||
}
|
||||
|
||||
private static string GetPostDisplayName(IActionRunner runner)
|
||||
{
|
||||
var displayName = string.IsNullOrEmpty(runner.DisplayName) ? "step" : runner.DisplayName;
|
||||
if (runner.Stage == ActionRunStage.Pre &&
|
||||
displayName.StartsWith("Pre ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
displayName = displayName.Substring("Pre ".Length);
|
||||
}
|
||||
|
||||
return $"Post {displayName}";
|
||||
}
|
||||
|
||||
private static string MatchKeyFor(Guid actionId)
|
||||
{
|
||||
return $"post:{actionId:N}";
|
||||
}
|
||||
|
||||
public async Task OnJobCompletedAsync()
|
||||
{
|
||||
if (_state != DapSessionState.NotStarted)
|
||||
{
|
||||
// Pause so the user can inspect final job state before we tear down,
|
||||
// but only if the user was stepping through (not if they hit continue).
|
||||
if (IsActive && _pauseOnNextStep)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_jobContext != null)
|
||||
{
|
||||
Trace.Info("Job completed — pausing for inspection");
|
||||
lock (_stateLock)
|
||||
{
|
||||
_jobCompleted = true;
|
||||
}
|
||||
|
||||
SendStoppedEvent("completed", "Job completed — inspect variables before the session ends.");
|
||||
|
||||
await WaitForCommandAsync(_jobContext.CancellationToken);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Warning($"DAP job-completed pause error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
OnJobCompleted();
|
||||
@@ -458,6 +236,8 @@ namespace GitHub.Runner.Worker.Dap
|
||||
Trace.Warning($"DAP OnJobCompleted error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
await StopAsync();
|
||||
}
|
||||
|
||||
public async Task StopAsync()
|
||||
@@ -494,25 +274,6 @@ namespace GitHub.Runner.Worker.Dap
|
||||
_tunnelRelayHost = null;
|
||||
}
|
||||
|
||||
if (_webSocketBridge != null)
|
||||
{
|
||||
Trace.Info("Stopping WebSocket DAP bridge");
|
||||
var shutdownTask = _webSocketBridge.ShutdownAsync();
|
||||
if (await Task.WhenAny(shutdownTask, Task.Delay(5_000)) != shutdownTask)
|
||||
{
|
||||
Trace.Warning("WebSocket DAP bridge shutdown timed out after 5s");
|
||||
_ = shutdownTask.ContinueWith(
|
||||
t => Trace.Error($"WebSocket DAP bridge shutdown faulted: {t.Exception?.GetBaseException().Message}"),
|
||||
TaskContinuationOptions.OnlyOnFaulted);
|
||||
}
|
||||
else
|
||||
{
|
||||
Trace.Info("WebSocket DAP bridge stopped");
|
||||
}
|
||||
|
||||
_webSocketBridge = null;
|
||||
}
|
||||
|
||||
CleanupConnection();
|
||||
|
||||
// Cancel the connection loop first so AcceptTcpClientAsync unblocks
|
||||
@@ -544,7 +305,6 @@ namespace GitHub.Runner.Worker.Dap
|
||||
{
|
||||
_state = DapSessionState.Terminated;
|
||||
}
|
||||
_jobStepsSource = null;
|
||||
}
|
||||
|
||||
_isClientConnected = false;
|
||||
@@ -555,7 +315,6 @@ namespace GitHub.Runner.Worker.Dap
|
||||
_connectionLoopTask = null;
|
||||
_loopCts?.Dispose();
|
||||
_loopCts = null;
|
||||
_webSocketBridge = null;
|
||||
}
|
||||
|
||||
public async Task OnStepStartingAsync(IStep step)
|
||||
@@ -603,8 +362,7 @@ namespace GitHub.Runner.Worker.Dap
|
||||
{
|
||||
DisplayName = step.DisplayName,
|
||||
Result = result,
|
||||
FrameId = _nextCompletedFrameId++,
|
||||
SourceLine = _jobStepsSource?.TryGetLineForStep(step)
|
||||
FrameId = _nextCompletedFrameId++
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -655,7 +413,6 @@ namespace GitHub.Runner.Worker.Dap
|
||||
"next" => HandleNext(request),
|
||||
"setBreakpoints" => HandleSetBreakpoints(request),
|
||||
"setExceptionBreakpoints" => HandleSetExceptionBreakpoints(request),
|
||||
"source" => HandleSource(request),
|
||||
"completions" => HandleCompletions(request),
|
||||
"stepIn" => CreateResponse(request, false, "Step In is not supported. Actions jobs debug at the step level - use 'next' to advance to the next step.", body: null),
|
||||
"stepOut" => CreateResponse(request, false, "Step Out is not supported. Actions jobs debug at the step level - use 'continue' to resume.", body: null),
|
||||
@@ -679,11 +436,6 @@ namespace GitHub.Runner.Worker.Dap
|
||||
});
|
||||
Trace.Info("Sent initialized event");
|
||||
}
|
||||
|
||||
if (request.Command == "configurationDone")
|
||||
{
|
||||
SendWelcomeMessage();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -702,7 +454,6 @@ namespace GitHub.Runner.Worker.Dap
|
||||
internal void HandleClientConnected()
|
||||
{
|
||||
_isClientConnected = true;
|
||||
_welcomeMessageSent = false;
|
||||
Trace.Info("Client connected to debug session");
|
||||
|
||||
// If we're paused, re-send the stopped event so the new client
|
||||
@@ -1013,41 +764,10 @@ namespace GitHub.Runner.Worker.Dap
|
||||
});
|
||||
}
|
||||
|
||||
internal void SendWelcomeMessage()
|
||||
{
|
||||
if (_welcomeMessageSent)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_welcomeMessageSent = true;
|
||||
|
||||
var debuggerConfig = _jobContext?.Global?.Debugger;
|
||||
if (debuggerConfig?.OverrideWelcomeMessage == true)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(debuggerConfig.WelcomeMessage))
|
||||
{
|
||||
// The welcome message is server-supplied and never rendered verbatim:
|
||||
// mask secrets and strip control characters before it reaches the console.
|
||||
SendOutput("console", SanitizeConsoleText(MaskUserVisibleText(debuggerConfig.WelcomeMessage)));
|
||||
Trace.Info("Sent custom welcome message");
|
||||
}
|
||||
else
|
||||
{
|
||||
Trace.Info("Welcome message suppressed by override");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
SendOutput("console", DapReplParser.GetGeneralHelp());
|
||||
Trace.Info("Sent default welcome message");
|
||||
}
|
||||
}
|
||||
|
||||
internal async Task OnStepStartingAsync(IStep step, bool isFirstStep)
|
||||
{
|
||||
bool pauseOnNextStep;
|
||||
CancellationToken cancellationToken;
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_state != DapSessionState.Ready &&
|
||||
@@ -1059,7 +779,6 @@ namespace GitHub.Runner.Worker.Dap
|
||||
|
||||
_currentStep = step;
|
||||
_currentStepIndex = _completedSteps.Count;
|
||||
_jobCompleted = false;
|
||||
pauseOnNextStep = _pauseOnNextStep;
|
||||
cancellationToken = _jobContext?.CancellationToken ?? CancellationToken.None;
|
||||
}
|
||||
@@ -1087,9 +806,6 @@ namespace GitHub.Runner.Worker.Dap
|
||||
// Send stopped event to debugger (only if client is connected)
|
||||
SendStoppedEvent(reason, description);
|
||||
|
||||
// Emit a banner so the user knows where REPL commands will execute
|
||||
SendExecutionContextBanner();
|
||||
|
||||
// Wait for debugger command
|
||||
await WaitForCommandAsync(cancellationToken);
|
||||
}
|
||||
@@ -1242,46 +958,29 @@ namespace GitHub.Runner.Worker.Dap
|
||||
private Response HandleStackTrace(Request request)
|
||||
{
|
||||
IStep currentStep;
|
||||
int currentStepIndex;
|
||||
CompletedStepInfo[] completedSteps;
|
||||
JobExecutionView jobStepsSource;
|
||||
bool jobCompleted;
|
||||
lock (_stateLock)
|
||||
{
|
||||
currentStep = _currentStep;
|
||||
currentStepIndex = _currentStepIndex;
|
||||
completedSteps = _completedSteps.ToArray();
|
||||
jobStepsSource = _jobStepsSource;
|
||||
jobCompleted = _jobCompleted;
|
||||
}
|
||||
|
||||
var frames = new List<StackFrame>();
|
||||
var source = jobStepsSource != null ? BuildJobStepsSource(jobStepsSource) : null;
|
||||
|
||||
// Add current step as the top frame
|
||||
if (jobCompleted && jobStepsSource != null)
|
||||
{
|
||||
frames.Add(new StackFrame
|
||||
{
|
||||
Id = _currentFrameId,
|
||||
Name = "Complete job [completed]",
|
||||
Source = source,
|
||||
Line = jobStepsSource.CompleteJobLine,
|
||||
Column = 1,
|
||||
PresentationHint = "normal"
|
||||
});
|
||||
}
|
||||
else if (currentStep != null)
|
||||
if (currentStep != null)
|
||||
{
|
||||
var resultIndicator = currentStep.ExecutionContext?.Result != null
|
||||
? $" [{currentStep.ExecutionContext.Result}]"
|
||||
: " [running]";
|
||||
var currentSourceLine = jobStepsSource?.TryGetLineForStep(currentStep);
|
||||
|
||||
frames.Add(new StackFrame
|
||||
{
|
||||
Id = _currentFrameId,
|
||||
Name = MaskUserVisibleText($"{currentStep.DisplayName ?? "Current Step"}{resultIndicator}"),
|
||||
Source = currentSourceLine.HasValue ? source : null,
|
||||
Line = currentSourceLine ?? 0,
|
||||
Line = currentStepIndex + 1,
|
||||
Column = 1,
|
||||
PresentationHint = "normal"
|
||||
});
|
||||
@@ -1307,8 +1006,7 @@ namespace GitHub.Runner.Worker.Dap
|
||||
{
|
||||
Id = completedStep.FrameId,
|
||||
Name = MaskUserVisibleText($"{completedStep.DisplayName}{resultStr}"),
|
||||
Source = completedStep.SourceLine.HasValue ? source : null,
|
||||
Line = completedStep.SourceLine ?? 0,
|
||||
Line = 1,
|
||||
Column = 1,
|
||||
PresentationHint = "subtle"
|
||||
});
|
||||
@@ -1323,76 +1021,6 @@ namespace GitHub.Runner.Worker.Dap
|
||||
return CreateResponse(request, true, body: body);
|
||||
}
|
||||
|
||||
private Source BuildJobStepsSource(JobExecutionView snapshot)
|
||||
{
|
||||
return new Source
|
||||
{
|
||||
Name = MaskUserVisibleText(snapshot.SourceFileName),
|
||||
Path = MaskUserVisibleText($"{SanitizeSourcePathSegment(snapshot.JobId)}/{snapshot.SourceFileName}"),
|
||||
SourceReference = _jobStepsSourceReference,
|
||||
PresentationHint = "normal"
|
||||
};
|
||||
}
|
||||
|
||||
private static string SanitizeSourcePathSegment(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return "job";
|
||||
}
|
||||
|
||||
var builder = new StringBuilder(value.Length);
|
||||
foreach (var character in value)
|
||||
{
|
||||
builder.Append(char.IsControl(character) || character == '/' || character == '\\'
|
||||
? '_'
|
||||
: character);
|
||||
}
|
||||
|
||||
return builder.Length == 0 ? "job" : builder.ToString();
|
||||
}
|
||||
|
||||
internal Response HandleSource(Request request)
|
||||
{
|
||||
SourceArguments args;
|
||||
try
|
||||
{
|
||||
args = request.Arguments?.ToObject<SourceArguments>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Warning($"Failed to parse source arguments: {ex.GetType().Name}");
|
||||
return CreateResponse(request, false, "Invalid source arguments.", body: null);
|
||||
}
|
||||
|
||||
var sourceReference = args?.Source?.SourceReference ?? args?.SourceReference;
|
||||
if (!sourceReference.HasValue)
|
||||
{
|
||||
return CreateResponse(request, false, "Missing source reference.", body: null);
|
||||
}
|
||||
|
||||
JobExecutionView snapshot;
|
||||
lock (_stateLock)
|
||||
{
|
||||
snapshot = _jobStepsSource;
|
||||
}
|
||||
|
||||
if (snapshot == null)
|
||||
{
|
||||
return CreateResponse(request, false, "Job steps source not yet available.", body: null);
|
||||
}
|
||||
|
||||
if (sourceReference.Value != _jobStepsSourceReference)
|
||||
{
|
||||
return CreateResponse(request, false, $"Unknown source reference: {sourceReference.Value}.", body: null);
|
||||
}
|
||||
|
||||
return CreateResponse(request, true, body: new SourceResponseBody
|
||||
{
|
||||
Content = MaskUserVisibleText(snapshot.Content)
|
||||
});
|
||||
}
|
||||
|
||||
private Response HandleScopes(Request request)
|
||||
{
|
||||
var args = request.Arguments?.ToObject<ScopesArguments>();
|
||||
@@ -1513,12 +1141,7 @@ namespace GitHub.Runner.Worker.Dap
|
||||
|
||||
case RunCommand run:
|
||||
var context = GetExecutionContextForFrame(frameId);
|
||||
bool isActionStep;
|
||||
lock (_stateLock)
|
||||
{
|
||||
isActionStep = _currentStep is IActionRunner;
|
||||
}
|
||||
return await _replExecutor.ExecuteRunCommandAsync(run, context, isActionStep, cancellationToken);
|
||||
return await _replExecutor.ExecuteRunCommandAsync(run, context, cancellationToken);
|
||||
|
||||
default:
|
||||
return new EvaluateResponseBody
|
||||
@@ -1643,13 +1266,6 @@ namespace GitHub.Runner.Worker.Dap
|
||||
_commandTcs = new TaskCompletionSource<DapCommand>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
}
|
||||
|
||||
// If cancellation already fired before we created the new TCS,
|
||||
// the registration callback targeted the old one. Unblock now.
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_commandTcs.TrySetResult(DapCommand.Disconnect);
|
||||
}
|
||||
|
||||
Trace.Info("Waiting for debugger command...");
|
||||
|
||||
var command = await _commandTcs.Task;
|
||||
@@ -1730,40 +1346,6 @@ namespace GitHub.Runner.Worker.Dap
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits a console output banner telling the user whether REPL
|
||||
/// commands will execute on the host or inside the job container.
|
||||
/// </summary>
|
||||
private void SendExecutionContextBanner()
|
||||
{
|
||||
if (!_isClientConnected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool isActionStep = _currentStep is IActionRunner;
|
||||
var container = _jobContext?.Global?.Container;
|
||||
|
||||
string target;
|
||||
if (isActionStep && container != null &&
|
||||
(!string.IsNullOrEmpty(container.ContainerId) ||
|
||||
FeatureManager.IsContainerHooksEnabled(_jobContext?.Global?.Variables)))
|
||||
{
|
||||
var image = container.ContainerImage ?? "container";
|
||||
var shortId = !string.IsNullOrEmpty(container.ContainerId) && container.ContainerId.Length >= 12
|
||||
? container.ContainerId.Substring(0, 12)
|
||||
: container.ContainerId ?? "";
|
||||
var idSuffix = !string.IsNullOrEmpty(shortId) ? $" ({shortId})" : "";
|
||||
target = $"job container: {image}{idSuffix}";
|
||||
}
|
||||
else
|
||||
{
|
||||
target = "runner host";
|
||||
}
|
||||
|
||||
SendOutput("console", $"\nCommands will run on {target}\n");
|
||||
}
|
||||
|
||||
private string MaskUserVisibleText(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
@@ -1774,30 +1356,6 @@ namespace GitHub.Runner.Worker.Dap
|
||||
return HostContext?.SecretMasker?.MaskSecrets(value) ?? value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes C0/C1 control characters (except tab, carriage return and line feed) so
|
||||
/// server-supplied text cannot inject ANSI escape sequences or terminal control codes
|
||||
/// into the DAP console.
|
||||
/// </summary>
|
||||
internal static string SanitizeConsoleText(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return value ?? string.Empty;
|
||||
}
|
||||
|
||||
var builder = new StringBuilder(value.Length);
|
||||
foreach (var character in value)
|
||||
{
|
||||
if (!char.IsControl(character) || character == '\t' || character == '\r' || character == '\n')
|
||||
{
|
||||
builder.Append(character);
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a DAP response with common fields pre-populated.
|
||||
/// </summary>
|
||||
|
||||
@@ -537,46 +537,6 @@ namespace GitHub.Runner.Worker.Dap
|
||||
|
||||
#endregion
|
||||
|
||||
#region Source Request/Response
|
||||
|
||||
/// <summary>
|
||||
/// Arguments for 'source' request.
|
||||
/// </summary>
|
||||
public class SourceArguments
|
||||
{
|
||||
/// <summary>
|
||||
/// Source descriptor. Some clients send sourceReference only here.
|
||||
/// </summary>
|
||||
[JsonProperty("source", NullValueHandling = NullValueHandling.Ignore)]
|
||||
public Source Source { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The reference to the source.
|
||||
/// </summary>
|
||||
[JsonProperty("sourceReference", NullValueHandling = NullValueHandling.Ignore)]
|
||||
public int? SourceReference { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response body for 'source' request.
|
||||
/// </summary>
|
||||
public class SourceResponseBody
|
||||
{
|
||||
/// <summary>
|
||||
/// Content of the source as a string.
|
||||
/// </summary>
|
||||
[JsonProperty("content")]
|
||||
public string Content { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional content type / mime type of the source.
|
||||
/// </summary>
|
||||
[JsonProperty("mimeType", NullValueHandling = NullValueHandling.Ignore)]
|
||||
public string MimeType { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Scopes Request/Response
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -9,7 +9,6 @@ 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
|
||||
@@ -44,7 +43,6 @@ namespace GitHub.Runner.Worker.Dap
|
||||
public async Task<EvaluateResponseBody> ExecuteRunCommandAsync(
|
||||
RunCommand command,
|
||||
IExecutionContext context,
|
||||
bool isActionStep,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (context == null)
|
||||
@@ -54,7 +52,7 @@ namespace GitHub.Runner.Worker.Dap
|
||||
|
||||
try
|
||||
{
|
||||
return await ExecuteScriptAsync(command, context, isActionStep, cancellationToken);
|
||||
return await ExecuteScriptAsync(command, context, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -67,17 +65,9 @@ namespace GitHub.Runner.Worker.Dap
|
||||
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
|
||||
// 1. Resolve shell — same logic as ScriptHandler
|
||||
string shellCommand;
|
||||
string argFormat;
|
||||
|
||||
@@ -97,9 +87,9 @@ namespace GitHub.Runner.Worker.Dap
|
||||
argFormat = ScriptHandlerHelpers.GetScriptArgumentsFormat(shellCommand);
|
||||
}
|
||||
|
||||
_trace.Info($"Resolved REPL shell (container={isContainerStepHost})");
|
||||
_trace.Info("Resolved REPL shell");
|
||||
|
||||
// 3. Expand ${{ }} expressions in the script body, just like
|
||||
// 2. Expand ${{ }} expressions in the script body, just like
|
||||
// ActionRunner evaluates step inputs before ScriptHandler sees them
|
||||
var contents = ExpandExpressions(command.Script, context);
|
||||
contents = ScriptHandlerHelpers.FixUpScriptContents(shellCommand, contents);
|
||||
@@ -121,47 +111,25 @@ namespace GitHub.Runner.Worker.Dap
|
||||
|
||||
try
|
||||
{
|
||||
// 4. Resolve script path — translate for container if needed
|
||||
var resolvedPath = stepHost.ResolvePathForStepHost(context, scriptFilePath).Replace("\"", "\\\"");
|
||||
// 3. Format arguments with script path
|
||||
var resolvedPath = scriptFilePath.Replace("\"", "\\\"");
|
||||
if (string.IsNullOrEmpty(argFormat) || !argFormat.Contains("{0}"))
|
||||
{
|
||||
return ErrorResult($"Invalid shell option '{shellCommand}'. Shell must be a valid built-in (bash, sh, cmd, powershell, pwsh) or a format string containing '{{0}}'");
|
||||
}
|
||||
var arguments = string.Format(argFormat, resolvedPath);
|
||||
|
||||
// 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.
|
||||
// 4. Resolve shell command path
|
||||
string prependPath = string.Join(
|
||||
Path.PathSeparator.ToString(),
|
||||
Enumerable.Reverse(context.Global.PrependPath));
|
||||
var fileName = isContainerStepHost
|
||||
? shellCommand
|
||||
: WhichUtil.Which(shellCommand, false, _trace, prependPath) ?? shellCommand;
|
||||
var commandPath = WhichUtil.Which(shellCommand, false, _trace, prependPath)
|
||||
?? shellCommand;
|
||||
|
||||
// 6. Build environment — merge from execution context like a real step
|
||||
// 5. 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
|
||||
// 6. Resolve working directory
|
||||
var workingDirectory = command.WorkingDirectory;
|
||||
if (string.IsNullOrEmpty(workingDirectory))
|
||||
{
|
||||
@@ -173,60 +141,48 @@ namespace GitHub.Runner.Worker.Dap
|
||||
: null;
|
||||
workingDirectory = workspace ?? _hostContext.GetDirectory(WellKnownDirectory.Work);
|
||||
}
|
||||
workingDirectory = stepHost.ResolvePathForStepHost(context, workingDirectory);
|
||||
|
||||
_trace.Info("Executing REPL command");
|
||||
|
||||
// Stream execution info to debugger
|
||||
SendOutput("console", $"$ {shellCommand} {command.Script.Substring(0, Math.Min(command.Script.Length, 80))}{(command.Script.Length > 80 ? "..." : "")}\n");
|
||||
|
||||
// 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))
|
||||
// 7. Execute via IProcessInvoker (same as DefaultStepHost)
|
||||
int exitCode;
|
||||
using (var processInvoker = _hostContext.CreateService<IProcessInvoker>())
|
||||
{
|
||||
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");
|
||||
processInvoker.OutputDataReceived += (sender, args) =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(args.Data))
|
||||
{
|
||||
var masked = _hostContext.SecretMasker.MaskSecrets(args.Data);
|
||||
SendOutput("stdout", masked + "\n");
|
||||
}
|
||||
};
|
||||
|
||||
processInvoker.ErrorDataReceived += (sender, args) =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(args.Data))
|
||||
{
|
||||
var masked = _hostContext.SecretMasker.MaskSecrets(args.Data);
|
||||
SendOutput("stderr", masked + "\n");
|
||||
}
|
||||
};
|
||||
|
||||
exitCode = await processInvoker.ExecuteAsync(
|
||||
workingDirectory: workingDirectory,
|
||||
fileName: commandPath,
|
||||
arguments: arguments,
|
||||
environment: environment,
|
||||
requireExitCodeZero: false,
|
||||
outputEncoding: null,
|
||||
killProcessOnCancel: true,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
// 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)
|
||||
// 8. Return only the exit code summary (output was already streamed)
|
||||
return new EvaluateResponseBody
|
||||
{
|
||||
Result = exitCode == 0 ? $"(exit code: {exitCode})" : $"Process completed with exit code {exitCode}.",
|
||||
@@ -242,43 +198,6 @@ namespace GitHub.Runner.Worker.Dap
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the appropriate <see cref="IStepHost"/> for the current
|
||||
/// execution context, mirroring how <see cref="ActionRunner"/> decides
|
||||
/// between host and container execution.
|
||||
///
|
||||
/// Only action steps (user-defined run:/uses: steps) run inside the
|
||||
/// job container. Infrastructure steps like "Set up job", "Initialize
|
||||
/// containers", "Stop containers", and "Complete job" always execute
|
||||
/// on the host regardless of whether a container is configured.
|
||||
/// </summary>
|
||||
internal IStepHost CreateStepHost(IExecutionContext context, bool isActionStep)
|
||||
{
|
||||
if (!isActionStep)
|
||||
{
|
||||
_trace.Info("Creating DefaultStepHost for REPL execution (infrastructure step)");
|
||||
return _hostContext.CreateService<IDefaultStepHost>();
|
||||
}
|
||||
|
||||
var container = context?.Global?.Container;
|
||||
if (container != null)
|
||||
{
|
||||
// Container hooks don't always set ContainerId, but the container
|
||||
// step host handles that internally
|
||||
var hooksEnabled = FeatureManager.IsContainerHooksEnabled(context.Global?.Variables);
|
||||
if (hooksEnabled || !string.IsNullOrEmpty(container.ContainerId))
|
||||
{
|
||||
_trace.Info("Creating ContainerStepHost for REPL execution");
|
||||
var containerStepHost = _hostContext.CreateService<IContainerStepHost>();
|
||||
containerStepHost.Container = container;
|
||||
return containerStepHost;
|
||||
}
|
||||
}
|
||||
|
||||
_trace.Info("Creating DefaultStepHost for REPL execution");
|
||||
return _hostContext.CreateService<IDefaultStepHost>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Expands <c>${{ }}</c> expressions in the input string using the
|
||||
/// runner's template evaluator — the same evaluation path that processes
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using GitHub.DistributedTask.Pipelines;
|
||||
using GitHub.DistributedTask.Pipelines;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap
|
||||
{
|
||||
@@ -8,12 +8,10 @@ namespace GitHub.Runner.Worker.Dap
|
||||
/// </summary>
|
||||
public sealed class DebuggerConfig
|
||||
{
|
||||
public DebuggerConfig(bool enabled, DebuggerTunnelInfo tunnel, bool overrideWelcomeMessage = false, string welcomeMessage = null)
|
||||
public DebuggerConfig(bool enabled, DebuggerTunnelInfo tunnel)
|
||||
{
|
||||
Enabled = enabled;
|
||||
Tunnel = tunnel;
|
||||
OverrideWelcomeMessage = overrideWelcomeMessage;
|
||||
WelcomeMessage = welcomeMessage;
|
||||
}
|
||||
|
||||
/// <summary>Whether the debugger is enabled for this job.</summary>
|
||||
@@ -25,24 +23,6 @@ namespace GitHub.Runner.Worker.Dap
|
||||
/// </summary>
|
||||
public DebuggerTunnelInfo Tunnel { get; }
|
||||
|
||||
/// <summary>
|
||||
/// When true, the runner overrides the default welcome message with
|
||||
/// <see cref="WelcomeMessage"/>. A null or empty <see cref="WelcomeMessage"/>
|
||||
/// suppresses the message entirely. When false, the default help text is shown.
|
||||
/// </summary>
|
||||
public bool OverrideWelcomeMessage { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional welcome message content for the debugger console. Only used when
|
||||
/// <see cref="OverrideWelcomeMessage"/> is true.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Server-supplied and treated as untrusted: it is masked with the runner's
|
||||
/// secret masker and stripped of control characters before being written to
|
||||
/// the debugger console.
|
||||
/// </remarks>
|
||||
public string WelcomeMessage { get; }
|
||||
|
||||
/// <summary>Whether the tunnel configuration is complete and valid.</summary>
|
||||
public bool HasValidTunnel => Tunnel != null
|
||||
&& !string.IsNullOrEmpty(Tunnel.TunnelId)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Runner.Common;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap
|
||||
@@ -20,11 +19,8 @@ namespace GitHub.Runner.Worker.Dap
|
||||
{
|
||||
Task StartAsync(IExecutionContext jobContext);
|
||||
Task WaitUntilReadyAsync();
|
||||
Task OnJobStepsInitializedAsync(IEnumerable<IStep> steps, IEnumerable<IStep> initialPostSteps);
|
||||
void OnPostStepRegistered(IStep step);
|
||||
Task OnStepStartingAsync(IStep step);
|
||||
void OnStepCompleted(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,358 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap
|
||||
{
|
||||
internal sealed class JobExecutionView
|
||||
{
|
||||
private const string _sourceFileName = "execution.yml";
|
||||
|
||||
private readonly object _lock = new object();
|
||||
private readonly List<SourceEntry> _preEntries = new List<SourceEntry>();
|
||||
private readonly List<SourceEntry> _mainEntries = new List<SourceEntry>();
|
||||
private readonly List<SourceEntry> _postEntries = new List<SourceEntry>();
|
||||
private readonly List<StepLine> _lineByStep = new List<StepLine>();
|
||||
private string _content;
|
||||
private int _completeJobLine;
|
||||
|
||||
public JobExecutionView(
|
||||
string jobId,
|
||||
IEnumerable<IStep> steps,
|
||||
IEnumerable<IStep> initialPostSteps,
|
||||
IEnumerable<PredictedPostStep> predictedPostSteps = null)
|
||||
{
|
||||
JobId = string.IsNullOrWhiteSpace(jobId) ? "job" : jobId;
|
||||
|
||||
_preEntries.Add(new SourceEntry("Set up job"));
|
||||
AddSteps(steps);
|
||||
AddPredictedPostSteps(predictedPostSteps);
|
||||
AddSteps(initialPostSteps);
|
||||
_postEntries.Add(SourceEntry.CreateSyntheticCompleteJob());
|
||||
Render();
|
||||
}
|
||||
|
||||
public string JobId { get; }
|
||||
public string SourceFileName => _sourceFileName;
|
||||
|
||||
public string Content
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int CompleteJobLine
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _completeJobLine;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int? TryClaimPredictedStep(string matchKey, IStep step)
|
||||
{
|
||||
if (string.IsNullOrEmpty(matchKey) || step == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
var existingLine = TryGetLineForStepNoLock(step);
|
||||
if (existingLine.HasValue)
|
||||
{
|
||||
return existingLine;
|
||||
}
|
||||
|
||||
foreach (var entry in _postEntries)
|
||||
{
|
||||
if (!string.Equals(entry.MatchKey, matchKey, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.Step != null && !ReferenceEquals(entry.Step, step))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
entry.Step = step;
|
||||
Render();
|
||||
return TryGetLineForStepNoLock(step);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public int? TryGetLineForStep(IStep step)
|
||||
{
|
||||
if (step == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
return TryGetLineForStepNoLock(step);
|
||||
}
|
||||
}
|
||||
|
||||
private int? TryGetLineForStepNoLock(IStep step)
|
||||
{
|
||||
foreach (var stepLine in _lineByStep)
|
||||
{
|
||||
if (ReferenceEquals(stepLine.Step, step))
|
||||
{
|
||||
return stepLine.Line;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void AddSteps(IEnumerable<IStep> steps)
|
||||
{
|
||||
if (steps == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var step in steps)
|
||||
{
|
||||
if (step == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
GetEntries(GetSection(step)).Add(new SourceEntry(step));
|
||||
}
|
||||
}
|
||||
|
||||
private void AddPredictedPostSteps(IEnumerable<PredictedPostStep> steps)
|
||||
{
|
||||
if (steps == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var step in steps)
|
||||
{
|
||||
if (step == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_postEntries.Add(new SourceEntry(step.DisplayName, step.MatchKey));
|
||||
}
|
||||
}
|
||||
|
||||
private List<SourceEntry> GetEntries(SourceSection section)
|
||||
{
|
||||
switch (section)
|
||||
{
|
||||
case SourceSection.Pre:
|
||||
return _preEntries;
|
||||
case SourceSection.Post:
|
||||
return _postEntries;
|
||||
default:
|
||||
return _mainEntries;
|
||||
}
|
||||
}
|
||||
|
||||
private static SourceSection GetSection(IStep step)
|
||||
{
|
||||
if (step is IActionRunner actionRunner)
|
||||
{
|
||||
return GetSection(actionRunner.Stage);
|
||||
}
|
||||
|
||||
if (step.ExecutionContext != null)
|
||||
{
|
||||
return GetSection(step.ExecutionContext.Stage);
|
||||
}
|
||||
|
||||
return SourceSection.Main;
|
||||
}
|
||||
|
||||
private static SourceSection GetSection(ActionRunStage stage)
|
||||
{
|
||||
switch (stage)
|
||||
{
|
||||
case ActionRunStage.Pre:
|
||||
return SourceSection.Pre;
|
||||
case ActionRunStage.Post:
|
||||
return SourceSection.Post;
|
||||
default:
|
||||
return SourceSection.Main;
|
||||
}
|
||||
}
|
||||
|
||||
private void Render()
|
||||
{
|
||||
_lineByStep.Clear();
|
||||
_completeJobLine = 0;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
var line = 1;
|
||||
|
||||
AppendSection(sb, "pre", _preEntries, ref line, appendSeparatorLine: true);
|
||||
AppendSection(sb, "main", _mainEntries, ref line, appendSeparatorLine: true);
|
||||
AppendSection(sb, "post", _postEntries, ref line, appendSeparatorLine: false);
|
||||
|
||||
_content = sb.ToString();
|
||||
}
|
||||
|
||||
private void AppendSection(
|
||||
StringBuilder sb,
|
||||
string sectionName,
|
||||
IReadOnlyList<SourceEntry> entries,
|
||||
ref int line,
|
||||
bool appendSeparatorLine)
|
||||
{
|
||||
sb.Append(sectionName).Append(":\n");
|
||||
line++;
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
if (entry.Step != null && TryGetLineForStepNoLock(entry.Step) == null)
|
||||
{
|
||||
_lineByStep.Add(new StepLine(entry.Step, line));
|
||||
}
|
||||
|
||||
sb.Append(" - step: ");
|
||||
sb.Append(FormatYamlString(entry.DisplayName));
|
||||
sb.Append('\n');
|
||||
if (entry.IsSyntheticCompleteJob)
|
||||
{
|
||||
_completeJobLine = line;
|
||||
}
|
||||
|
||||
line++;
|
||||
}
|
||||
|
||||
if (appendSeparatorLine)
|
||||
{
|
||||
sb.Append('\n');
|
||||
line++;
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatYamlString(string value)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append('"');
|
||||
foreach (var c in value)
|
||||
{
|
||||
switch (c)
|
||||
{
|
||||
case '\\':
|
||||
sb.Append(@"\\");
|
||||
break;
|
||||
case '"':
|
||||
sb.Append("\\\"");
|
||||
break;
|
||||
case '\r':
|
||||
sb.Append(@"\r");
|
||||
break;
|
||||
case '\n':
|
||||
sb.Append(@"\n");
|
||||
break;
|
||||
case '\t':
|
||||
sb.Append(@"\t");
|
||||
break;
|
||||
default:
|
||||
if (char.IsControl(c))
|
||||
{
|
||||
sb.Append(@"\u");
|
||||
sb.Append(((int)c).ToString("x4", CultureInfo.InvariantCulture));
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(c);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
sb.Append('"');
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
internal sealed class PredictedPostStep
|
||||
{
|
||||
public PredictedPostStep(string displayName, string matchKey)
|
||||
{
|
||||
DisplayName = string.IsNullOrEmpty(displayName) ? "step" : displayName;
|
||||
MatchKey = matchKey;
|
||||
}
|
||||
|
||||
public string DisplayName { get; }
|
||||
public string MatchKey { get; }
|
||||
}
|
||||
|
||||
private sealed class StepLine
|
||||
{
|
||||
public StepLine(IStep step, int line)
|
||||
{
|
||||
Step = step;
|
||||
Line = line;
|
||||
}
|
||||
|
||||
public IStep Step { get; }
|
||||
public int Line { get; }
|
||||
}
|
||||
|
||||
private sealed class SourceEntry
|
||||
{
|
||||
public SourceEntry(string displayName)
|
||||
{
|
||||
DisplayName = string.IsNullOrEmpty(displayName) ? "step" : displayName;
|
||||
}
|
||||
|
||||
public SourceEntry(string displayName, string matchKey)
|
||||
: this(displayName)
|
||||
{
|
||||
MatchKey = matchKey;
|
||||
}
|
||||
|
||||
public SourceEntry(IStep step)
|
||||
{
|
||||
Step = step;
|
||||
DisplayName = string.IsNullOrEmpty(step.DisplayName) ? "step" : step.DisplayName;
|
||||
}
|
||||
|
||||
private SourceEntry(string displayName, bool isSyntheticCompleteJob)
|
||||
: this(displayName)
|
||||
{
|
||||
IsSyntheticCompleteJob = isSyntheticCompleteJob;
|
||||
}
|
||||
|
||||
public static SourceEntry CreateSyntheticCompleteJob()
|
||||
{
|
||||
return new SourceEntry("Complete job", isSyntheticCompleteJob: true);
|
||||
}
|
||||
|
||||
public IStep Step { get; set; }
|
||||
public string DisplayName { get; }
|
||||
public string MatchKey { get; }
|
||||
public bool IsSyntheticCompleteJob { get; }
|
||||
}
|
||||
|
||||
private enum SourceSection
|
||||
{
|
||||
Pre,
|
||||
Main,
|
||||
Post
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,23 +77,14 @@ namespace GitHub.Runner.Worker
|
||||
|
||||
List<string> StepEnvironmentOverrides { get; }
|
||||
|
||||
bool IsBackground { get; }
|
||||
|
||||
IExecutionContext Root { get; }
|
||||
|
||||
// Initialize
|
||||
void InitializeJob(Pipelines.AgentJobRequestMessage message, CancellationToken token);
|
||||
void CancelToken();
|
||||
IExecutionContext CreateChild(Guid recordId, string displayName, string refName, string scopeName, string contextName, ActionRunStage stage, Dictionary<string, string> intraActionState = null, int? recordOrder = null, IPagingLogger logger = null, bool isEmbedded = false, List<Issue> embeddedIssueCollector = null, CancellationTokenSource cancellationTokenSource = null, Guid embeddedId = default(Guid), string siblingScopeName = null, TimeSpan? timeout = null, bool isBackground = false, string backgroundControlType = null, string[] backgroundControlStepIds = null, string parallelGroupId = null);
|
||||
IExecutionContext CreateChild(Guid recordId, string displayName, string refName, string scopeName, string contextName, ActionRunStage stage, Dictionary<string, string> intraActionState = null, int? recordOrder = null, IPagingLogger logger = null, bool isEmbedded = false, List<Issue> embeddedIssueCollector = null, CancellationTokenSource cancellationTokenSource = null, Guid embeddedId = default(Guid), string siblingScopeName = null, TimeSpan? timeout = null);
|
||||
IExecutionContext CreateEmbeddedChild(string scopeName, string contextName, Guid embeddedId, ActionRunStage stage, Dictionary<string, string> intraActionState = null, string siblingScopeName = null);
|
||||
|
||||
|
||||
// Background step deferral properties
|
||||
Dictionary<string, string> DeferredOutputs { get; set; }
|
||||
Dictionary<string, string> DeferredEnvironmentVariables { get; set; }
|
||||
List<string> DeferredPrependPath { get; set; }
|
||||
bool DeferOutcomeConclusion { get; set; }
|
||||
|
||||
// logging
|
||||
long Write(string tag, string message);
|
||||
void QueueAttachFile(string type, string name, string filePath);
|
||||
@@ -109,12 +100,6 @@ namespace GitHub.Runner.Worker
|
||||
void SetGitHubContext(string name, string value);
|
||||
void SetOutput(string name, string value, out string reference);
|
||||
void SetTimeout(TimeSpan? timeout);
|
||||
|
||||
// Background step deferral flush methods
|
||||
void FlushDeferredOutputs();
|
||||
void FlushDeferredEnvironment();
|
||||
void FlushDeferredOutcomeConclusion();
|
||||
|
||||
void AddIssue(Issue issue, ExecutionContextLogOptions logOptions);
|
||||
void Progress(int percentage, string currentOperation = null);
|
||||
void UpdateDetailTimelineRecord(TimelineRecord record);
|
||||
@@ -231,9 +216,6 @@ namespace GitHub.Runner.Worker
|
||||
|
||||
public bool EchoOnActionCommand { get; set; }
|
||||
|
||||
// Whether this step runs in the background
|
||||
public bool IsBackground => _record.IsBackground;
|
||||
|
||||
// An embedded execution context shares the same record ID, record name, and logger
|
||||
// as its enclosing execution context.
|
||||
public bool IsEmbedded { get; private init; }
|
||||
@@ -297,12 +279,6 @@ namespace GitHub.Runner.Worker
|
||||
|
||||
public List<string> StepEnvironmentOverrides { get; } = new List<string>();
|
||||
|
||||
// Background step deferral properties
|
||||
public Dictionary<string, string> DeferredOutputs { get; set; }
|
||||
public Dictionary<string, string> DeferredEnvironmentVariables { get; set; }
|
||||
public List<string> DeferredPrependPath { get; set; }
|
||||
public bool DeferOutcomeConclusion { get; set; }
|
||||
|
||||
public override void Initialize(IHostContext hostContext)
|
||||
{
|
||||
base.Initialize(hostContext);
|
||||
@@ -361,25 +337,7 @@ namespace GitHub.Runner.Worker
|
||||
}
|
||||
|
||||
step.ExecutionContext = Root.CreatePostChild(step.DisplayName, IntraActionState, siblingScopeName);
|
||||
if (step is JobExtensionRunner)
|
||||
{
|
||||
step.ExecutionContext.StepTelemetry.Type = "runner";
|
||||
step.ExecutionContext.StepTelemetry.Action = step.DisplayName.ToLowerInvariant().Replace(' ', '_');
|
||||
}
|
||||
Root.PostJobSteps.Push(step);
|
||||
|
||||
if (Root.Global.Debugger?.Enabled == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
HostContext.GetService<Dap.IDapDebugger>().OnPostStepRegistered(step);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Warning("Failed to notify DAP debugger about registered post job step.");
|
||||
Trace.Error(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public IExecutionContext CreateChild(
|
||||
@@ -397,11 +355,7 @@ namespace GitHub.Runner.Worker
|
||||
CancellationTokenSource cancellationTokenSource = null,
|
||||
Guid embeddedId = default(Guid),
|
||||
string siblingScopeName = null,
|
||||
TimeSpan? timeout = null,
|
||||
bool isBackground = false,
|
||||
string backgroundControlType = null,
|
||||
string[] backgroundControlStepIds = null,
|
||||
string parallelGroupId = null)
|
||||
TimeSpan? timeout = null)
|
||||
{
|
||||
Trace.Entering();
|
||||
|
||||
@@ -442,24 +396,6 @@ namespace GitHub.Runner.Worker
|
||||
|
||||
child.EchoOnActionCommand = EchoOnActionCommand;
|
||||
|
||||
// Set background step metadata before InitializeTimelineRecord so it's included in the first update
|
||||
if (isBackground || backgroundControlType != null || parallelGroupId != null)
|
||||
{
|
||||
child._record.IsBackground = isBackground;
|
||||
child._record.BackgroundControlType = backgroundControlType;
|
||||
child._record.BackgroundControlStepIds = backgroundControlStepIds;
|
||||
child._record.ParallelGroupId = parallelGroupId;
|
||||
|
||||
// Initialize deferred state for background steps — flushed at wait/wait-all
|
||||
if (isBackground)
|
||||
{
|
||||
child.DeferredOutputs = new Dictionary<string, string>();
|
||||
child.DeferredEnvironmentVariables = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
child.DeferredPrependPath = new List<string>();
|
||||
child.DeferOutcomeConclusion = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (recordOrder != null)
|
||||
{
|
||||
child.InitializeTimelineRecord(_mainTimelineId, recordId, _record.Id, ExecutionContextType.Task, displayName, refName, recordOrder, embedded: isEmbedded);
|
||||
@@ -572,11 +508,7 @@ namespace GitHub.Runner.Worker
|
||||
Type = StepTelemetry?.Type,
|
||||
StartedAt = _record.StartTime,
|
||||
CompletedAt = _record.FinishTime,
|
||||
Annotations = new List<Annotation>(),
|
||||
// Populate background step metadata from timeline record fields
|
||||
IsBackground = _record.IsBackground,
|
||||
BackgroundControlType = _record.BackgroundControlType,
|
||||
BackgroundControlStepIds = _record.BackgroundControlStepIds
|
||||
Annotations = new List<Annotation>()
|
||||
};
|
||||
|
||||
_record.Issues?.ForEach(issue =>
|
||||
@@ -622,22 +554,11 @@ namespace GitHub.Runner.Worker
|
||||
|
||||
_logger.End();
|
||||
|
||||
if (!DeferOutcomeConclusion)
|
||||
{
|
||||
UpdateGlobalStepsContext();
|
||||
}
|
||||
UpdateGlobalStepsContext();
|
||||
|
||||
return Result.Value;
|
||||
}
|
||||
|
||||
public void FlushDeferredOutcomeConclusion()
|
||||
{
|
||||
if (DeferOutcomeConclusion)
|
||||
{
|
||||
UpdateGlobalStepsContext();
|
||||
}
|
||||
}
|
||||
|
||||
public void UpdateGlobalStepsContext()
|
||||
{
|
||||
// Skip if generated context name. Generated context names start with "__". After 3.2 the server will never send an empty context name.
|
||||
@@ -713,40 +634,6 @@ namespace GitHub.Runner.Worker
|
||||
Global.StepsContext.SetOutput(ScopeName, ContextName, name, value, out reference);
|
||||
}
|
||||
|
||||
public void FlushDeferredOutputs()
|
||||
{
|
||||
if (DeferredOutputs == null || DeferredOutputs.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var kvp in DeferredOutputs)
|
||||
{
|
||||
Global.StepsContext.SetOutput(ScopeName, ContextName, kvp.Key, kvp.Value, out _);
|
||||
}
|
||||
}
|
||||
|
||||
public void FlushDeferredEnvironment()
|
||||
{
|
||||
if (DeferredEnvironmentVariables != null)
|
||||
{
|
||||
foreach (var kvp in DeferredEnvironmentVariables)
|
||||
{
|
||||
Global.EnvironmentVariables[kvp.Key] = kvp.Value;
|
||||
SetEnvContext(kvp.Key, kvp.Value);
|
||||
}
|
||||
}
|
||||
|
||||
if (DeferredPrependPath != null)
|
||||
{
|
||||
foreach (var path in DeferredPrependPath)
|
||||
{
|
||||
Global.PrependPath.RemoveAll(x => string.Equals(x, path, StringComparison.CurrentCulture));
|
||||
Global.PrependPath.Add(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void SetTimeout(TimeSpan? timeout)
|
||||
{
|
||||
if (timeout != null)
|
||||
@@ -973,9 +860,6 @@ namespace GitHub.Runner.Worker
|
||||
// Track actions stuck on Node.js 20 due to ARM32 (separate from general deprecation)
|
||||
Global.Arm32Node20Actions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Job-scoped aggregate of artifact subjects declared via $GITHUB_ARTIFACTS.
|
||||
Global.ArtifactSubjects = new Dictionary<string, ArtifactSubject>(StringComparer.Ordinal);
|
||||
|
||||
// Job Outputs
|
||||
JobOutputs = new Dictionary<string, VariableValue>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -991,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);
|
||||
|
||||
@@ -1019,6 +900,10 @@ namespace GitHub.Runner.Worker
|
||||
jobContext[pair.Key] = pair.Value;
|
||||
}
|
||||
}
|
||||
|
||||
// Derive workflow_repository and workflow_file_path from workflow_ref
|
||||
// if the server sent workflow_ref but not the decomposed fields
|
||||
jobContext.DeriveWorkflowRefComponents();
|
||||
ExpressionValues["job"] = jobContext;
|
||||
|
||||
Trace.Info("Initialize GitHub context");
|
||||
@@ -1086,8 +971,7 @@ namespace GitHub.Runner.Worker
|
||||
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);
|
||||
Global.Debugger = new Dap.DebuggerConfig(message.EnableDebugger, message.DebuggerTunnel);
|
||||
|
||||
// Hook up JobServerQueueThrottling event, we will log warning on server tarpit.
|
||||
_jobServerQueue.JobServerQueueThrottling += JobServerQueueThrottling_EventReceived;
|
||||
@@ -1446,10 +1330,7 @@ namespace GitHub.Runner.Worker
|
||||
Trace.Info($"Updated step result (continue on error)");
|
||||
}
|
||||
|
||||
if (!DeferOutcomeConclusion)
|
||||
{
|
||||
UpdateGlobalStepsContext();
|
||||
}
|
||||
UpdateGlobalStepsContext();
|
||||
}
|
||||
|
||||
internal IPipelineTemplateEvaluator ToPipelineTemplateEvaluatorInternal(bool allowServiceContainerCommand, ObjectTemplating.ITraceWriter traceWriter = null)
|
||||
|
||||
@@ -55,18 +55,6 @@ namespace GitHub.Runner.Worker
|
||||
TryDeleteFile(newPath);
|
||||
File.Create(newPath).Dispose();
|
||||
|
||||
// Give extensions a chance to populate the file before
|
||||
// the step starts (e.g., read-only views of job state).
|
||||
// Errors are logged but must not fail step setup.
|
||||
try
|
||||
{
|
||||
fileCommand.PopulateInitialContents(context, newPath, container);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_trace.Warning($"Failed to populate initial contents for file command '{fileCommand.ContextName}': {ex}");
|
||||
}
|
||||
|
||||
var pathToSet = container != null ? container.TranslateToContainerPath(newPath) : newPath;
|
||||
context.SetGitHubContext(fileCommand.ContextName, pathToSet);
|
||||
}
|
||||
@@ -114,14 +102,6 @@ namespace GitHub.Runner.Worker
|
||||
string FilePrefix { get; }
|
||||
|
||||
void ProcessCommand(IExecutionContext context, string filePath, ContainerInfo container);
|
||||
|
||||
// Optional hook invoked by FileCommandManager.InitializeFiles
|
||||
// after creating the empty per-step file. Extensions that need to
|
||||
// pre-populate the file (e.g., a read-only view of job-scoped
|
||||
// state) override this; the default no-op preserves the existing
|
||||
// "empty file at start of step" behavior for write-only file
|
||||
// commands such as GITHUB_ENV, GITHUB_OUTPUT, GITHUB_PATH, etc.
|
||||
void PopulateInitialContents(IExecutionContext context, string filePath, ContainerInfo container) { }
|
||||
}
|
||||
|
||||
public sealed class AddPathFileCommand : RunnerService, IFileCommandExtension
|
||||
@@ -142,16 +122,8 @@ namespace GitHub.Runner.Worker
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (context.DeferredPrependPath != null)
|
||||
{
|
||||
context.DeferredPrependPath.RemoveAll(x => string.Equals(x, line, StringComparison.CurrentCulture));
|
||||
context.DeferredPrependPath.Add(line);
|
||||
}
|
||||
else
|
||||
{
|
||||
context.Global.PrependPath.RemoveAll(x => string.Equals(x, line, StringComparison.CurrentCulture));
|
||||
context.Global.PrependPath.Add(line);
|
||||
}
|
||||
context.Global.PrependPath.RemoveAll(x => string.Equals(x, line, StringComparison.CurrentCulture));
|
||||
context.Global.PrependPath.Add(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -200,15 +172,8 @@ namespace GitHub.Runner.Worker
|
||||
string name,
|
||||
string value)
|
||||
{
|
||||
if (context.DeferredEnvironmentVariables != null)
|
||||
{
|
||||
context.DeferredEnvironmentVariables[name] = value;
|
||||
}
|
||||
else
|
||||
{
|
||||
context.Global.EnvironmentVariables[name] = value;
|
||||
context.SetEnvContext(name, value);
|
||||
}
|
||||
context.Global.EnvironmentVariables[name] = value;
|
||||
context.SetEnvContext(name, value);
|
||||
context.Debug($"{name}='{value}'");
|
||||
}
|
||||
|
||||
@@ -337,14 +302,7 @@ namespace GitHub.Runner.Worker
|
||||
var pairs = new EnvFileKeyValuePairs(context, filePath);
|
||||
foreach (var pair in pairs)
|
||||
{
|
||||
if (context.DeferredOutputs != null)
|
||||
{
|
||||
context.DeferredOutputs[pair.Key] = pair.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
context.SetOutput(pair.Key, pair.Value, out var reference);
|
||||
}
|
||||
context.SetOutput(pair.Key, pair.Value, out var reference);
|
||||
context.Debug($"Set output {pair.Key} = {pair.Value}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,8 +15,6 @@ namespace GitHub.Runner.Worker
|
||||
"actor",
|
||||
"actor_id",
|
||||
"api_url",
|
||||
"artifacts",
|
||||
"artifacts_list",
|
||||
"base_ref",
|
||||
"env",
|
||||
"event_name",
|
||||
|
||||
@@ -38,10 +38,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; }
|
||||
|
||||
// Job-scoped aggregate of artifact subjects declared via $GITHUB_ARTIFACTS.
|
||||
// Keyed by canonical subject name (OCI ref without digest, or file basename).
|
||||
public IDictionary<string, ArtifactSubject> ArtifactSubjects { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,11 +239,6 @@ namespace GitHub.Runner.Worker.Handlers
|
||||
Environment["ACTIONS_RESULTS_URL"] = resultsUrl;
|
||||
}
|
||||
|
||||
if (ExecutionContext.Global.Variables.TryGetValue("actions_cache_mode", out var cacheMode) && !string.IsNullOrEmpty(cacheMode))
|
||||
{
|
||||
Environment["ACTIONS_CACHE_MODE"] = cacheMode;
|
||||
}
|
||||
|
||||
if (ExecutionContext.Global.Variables.GetBoolean(Constants.Runner.Features.SetOrchestrationIdEnvForActions) ?? false)
|
||||
{
|
||||
if (ExecutionContext.Global.Variables.TryGetValue(Constants.Variables.System.OrchestrationId, out var orchestrationId) && !string.IsNullOrEmpty(orchestrationId))
|
||||
|
||||
@@ -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
|
||||
{
|
||||
@@ -78,11 +77,6 @@ namespace GitHub.Runner.Worker.Handlers
|
||||
Environment["ACTIONS_CACHE_SERVICE_V2"] = bool.TrueString;
|
||||
}
|
||||
|
||||
if (ExecutionContext.Global.Variables.TryGetValue("actions_cache_mode", out var cacheMode) && !string.IsNullOrEmpty(cacheMode))
|
||||
{
|
||||
Environment["ACTIONS_CACHE_MODE"] = cacheMode;
|
||||
}
|
||||
|
||||
if (ExecutionContext.Global.Variables.GetBoolean(Constants.Runner.Features.SetOrchestrationIdEnvForActions) ?? false)
|
||||
{
|
||||
if (ExecutionContext.Global.Variables.TryGetValue(Constants.Variables.System.OrchestrationId, out var orchestrationId) && !string.IsNullOrEmpty(orchestrationId))
|
||||
@@ -134,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;
|
||||
|
||||
@@ -146,5 +146,51 @@ namespace GitHub.Runner.Worker
|
||||
this["workflow_file_path"] = value != null ? new StringContextData(value) : null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a composite workflow_ref (e.g. "owner/repo/.github/workflows/file.yml@refs/heads/main")
|
||||
/// and populates workflow_repository and workflow_file_path if they are not already set.
|
||||
/// </summary>
|
||||
public void DeriveWorkflowRefComponents()
|
||||
{
|
||||
var workflowRef = WorkflowRef;
|
||||
if (string.IsNullOrEmpty(workflowRef))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Format: owner/repo/.github/workflows/file.yml@ref
|
||||
var atIndex = workflowRef.IndexOf('@');
|
||||
var pathPart = atIndex >= 0 ? workflowRef.Substring(0, atIndex) : workflowRef;
|
||||
|
||||
// Split at /.github/workflows/ to correctly handle repos named ".github"
|
||||
// e.g. "octo-org/.github/.github/workflows/ci.yml" → repo="octo-org/.github"
|
||||
var marker = "/.github/workflows/";
|
||||
var markerIndex = pathPart.IndexOf(marker);
|
||||
if (markerIndex < 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var repo = pathPart.Substring(0, markerIndex);
|
||||
var filePath = pathPart.Substring(markerIndex + 1); // skip leading '/'
|
||||
|
||||
// Validate repo is owner/repo format (must have at least one slash with non-empty segments)
|
||||
var slashIndex = repo.IndexOf('/');
|
||||
if (slashIndex <= 0 || slashIndex >= repo.Length - 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (WorkflowRepository == null || WorkflowRepository == "")
|
||||
{
|
||||
WorkflowRepository = repo;
|
||||
}
|
||||
|
||||
if (WorkflowFilePath == null || WorkflowFilePath == "")
|
||||
{
|
||||
WorkflowFilePath = filePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Runtime.Serialization;
|
||||
using System.Threading;
|
||||
@@ -17,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;
|
||||
@@ -46,14 +44,12 @@ namespace GitHub.Runner.Worker
|
||||
{
|
||||
private readonly HashSet<string> _existingProcesses = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly List<Task<CheckResult>> _connectivityCheckTasks = new();
|
||||
private readonly List<Task<CheckResult>> _connectivityAndDNSCheckTasks = new();
|
||||
private bool _processCleanup;
|
||||
private string _processLookupId = $"github_{Guid.NewGuid()}";
|
||||
private CancellationTokenSource _diskSpaceCheckToken = new();
|
||||
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.
|
||||
@@ -71,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
|
||||
@@ -82,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);
|
||||
@@ -173,12 +163,6 @@ namespace GitHub.Runner.Worker
|
||||
context.Output($"Secret source: {secretSource}");
|
||||
}
|
||||
|
||||
var cacheMode = jobContext.Global.Variables.Get("actions_cache_mode");
|
||||
if (!string.IsNullOrEmpty(cacheMode))
|
||||
{
|
||||
context.Output($"Cache mode: {cacheMode}");
|
||||
}
|
||||
|
||||
var repoFullName = context.GetGitHubContext("repository");
|
||||
ArgUtil.NotNull(repoFullName, nameof(repoFullName));
|
||||
context.Debug($"Primary repository: {repoFullName}");
|
||||
@@ -193,13 +177,6 @@ namespace GitHub.Runner.Worker
|
||||
context.Output($"Runner is running behind proxy server '{HostContext.WebProxy.HttpsProxyAddress}' for all HTTPS requests.");
|
||||
}
|
||||
|
||||
// Signal to the user that the job is using locked action
|
||||
// versions from the workflow's lockfile.
|
||||
if (message.ActionsDependencies != null && message.ActionsDependencies.Count > 0)
|
||||
{
|
||||
context.Output("Using locked action versions from the workflow's lockfile");
|
||||
}
|
||||
|
||||
// Prepare the workflow directory
|
||||
context.Output("Prepare workflow directory");
|
||||
var directoryManager = HostContext.GetService<IPipelineDirectoryManager>();
|
||||
@@ -360,38 +337,6 @@ namespace GitHub.Runner.Worker
|
||||
preJobSteps.Add(preStep);
|
||||
}
|
||||
}
|
||||
else if (step.Type == Pipelines.StepType.BackgroundStepControl)
|
||||
{
|
||||
var ctrl = step as Pipelines.BackgroundStepControl;
|
||||
Trace.Info($"Adding {ctrl.ControlType} step for: {string.Join(", ", ctrl.StepIds ?? Array.Empty<string>())}");
|
||||
var controlType = ctrl.ControlType;
|
||||
if (string.IsNullOrEmpty(controlType))
|
||||
{
|
||||
throw new ArgumentException($"Background step control '{step.Name}' has no control type.");
|
||||
}
|
||||
if (controlType != Pipelines.BackgroundControlTypes.Wait &&
|
||||
controlType != Pipelines.BackgroundControlTypes.WaitAll &&
|
||||
controlType != Pipelines.BackgroundControlTypes.Cancel)
|
||||
{
|
||||
throw new ArgumentException($"Unknown background step control type '{controlType}' for step '{step.Name}'.");
|
||||
}
|
||||
var displayName = (ctrl.DisplayNameToken as GitHub.DistributedTask.ObjectTemplating.Tokens.StringToken)?.Value
|
||||
?? step.DisplayName ?? step.Name ?? ctrl.ControlType;
|
||||
var data = new BackgroundStepControlFlowData
|
||||
{
|
||||
Type = controlType,
|
||||
StepId = step.Id,
|
||||
StepName = step.Name,
|
||||
StepIds = ctrl.StepIds,
|
||||
ParallelGroupId = ctrl.ParallelGroupId,
|
||||
};
|
||||
var bgCoord = HostContext.GetService<IBackgroundStepCoordinator>();
|
||||
jobSteps.Add(new JobExtensionRunner(
|
||||
runAsync: bgCoord.RunControlFlowAsync,
|
||||
condition: $"{PipelineTemplateConstants.Always}()",
|
||||
displayName: displayName,
|
||||
data: data));
|
||||
}
|
||||
}
|
||||
|
||||
if (message.Variables.TryGetValue("system.workflowFileFullPath", out VariableValue workflowFileFullPath))
|
||||
@@ -447,107 +392,13 @@ namespace GitHub.Runner.Worker
|
||||
}
|
||||
|
||||
// Create execution context for job steps
|
||||
// Build mapping of logical step ID (ContextName) → external ID (timeline record GUID)
|
||||
// so wait/cancel steps can reference background steps by external ID.
|
||||
var contextNameToExternalId = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var hasBackgroundSteps = false;
|
||||
var backgroundStepExternalIds = new List<string>();
|
||||
|
||||
// Track which background steps are explicitly covered by wait/wait-all/cancel
|
||||
var coveredBackgroundIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var step in jobSteps)
|
||||
{
|
||||
if (step is IActionRunner actionStep)
|
||||
{
|
||||
ArgUtil.NotNull(actionStep, step.DisplayName);
|
||||
intraActionStates.TryGetValue(actionStep.Action.Id, out var intraActionState);
|
||||
|
||||
var isBg = actionStep.Action?.Background == true;
|
||||
actionStep.ExecutionContext = jobContext.CreateChild(
|
||||
actionStep.Action.Id, actionStep.DisplayName, actionStep.Action.Name,
|
||||
null, actionStep.Action.ContextName, ActionRunStage.Main, intraActionState,
|
||||
isBackground: isBg,
|
||||
parallelGroupId: isBg ? actionStep.Action.ParallelGroupId : null);
|
||||
|
||||
if (isBg)
|
||||
{
|
||||
hasBackgroundSteps = true;
|
||||
var externalId = actionStep.Action.Id.ToString("N");
|
||||
contextNameToExternalId[actionStep.Action.ContextName] = externalId;
|
||||
backgroundStepExternalIds.Add(externalId);
|
||||
}
|
||||
}
|
||||
else if (step is JobExtensionRunner runnerStep && runnerStep.Data is BackgroundStepControlFlowData cf)
|
||||
{
|
||||
// Resolve step IDs to external IDs and track coverage
|
||||
string[] externalIds = null;
|
||||
if (cf.StepIds != null && cf.StepIds.Length > 0)
|
||||
{
|
||||
foreach (var id in cf.StepIds)
|
||||
{
|
||||
coveredBackgroundIds.Add(id);
|
||||
}
|
||||
externalIds = cf.StepIds
|
||||
.Where(id => contextNameToExternalId.ContainsKey(id))
|
||||
.Select(id => contextNameToExternalId[id])
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
if (cf.Type == Pipelines.BackgroundControlTypes.WaitAll)
|
||||
{
|
||||
externalIds = backgroundStepExternalIds.Count > 0 ? backgroundStepExternalIds.ToArray() : null;
|
||||
foreach (var id in contextNameToExternalId.Keys)
|
||||
{
|
||||
coveredBackgroundIds.Add(id);
|
||||
}
|
||||
}
|
||||
|
||||
step.ExecutionContext = jobContext.CreateChild(
|
||||
cf.StepId, step.DisplayName, cf.StepName,
|
||||
null, cf.StepName, ActionRunStage.Main,
|
||||
backgroundControlType: cf.Type,
|
||||
backgroundControlStepIds: externalIds,
|
||||
parallelGroupId: cf.ParallelGroupId);
|
||||
}
|
||||
}
|
||||
|
||||
// Add implicit wait-all only if there are background steps not covered by any wait/wait-all/cancel
|
||||
var allBackgroundIds = contextNameToExternalId.Keys;
|
||||
var hasUncoveredBackgroundSteps = allBackgroundIds.Any(id => !coveredBackgroundIds.Contains(id));
|
||||
if (hasBackgroundSteps)
|
||||
{
|
||||
// Initialize coordinator only when there are background steps
|
||||
var bgCoordinator = HostContext.GetService<IBackgroundStepCoordinator>();
|
||||
var maxBgSteps = jobContext.Global.Variables.GetInt("system.runner.maxbackgroundsteps");
|
||||
var maxConcurrent = (maxBgSteps.HasValue && maxBgSteps.Value > 0) ? maxBgSteps.Value : 10;
|
||||
bgCoordinator.InitializeCoordinator(maxConcurrent);
|
||||
|
||||
// Add implicit wait-all only if there are uncovered background steps
|
||||
if (hasUncoveredBackgroundSteps)
|
||||
{
|
||||
var implicitStepId = Guid.NewGuid();
|
||||
var implicitWaitAllData = new BackgroundStepControlFlowData
|
||||
{
|
||||
Type = Pipelines.BackgroundControlTypes.WaitAll,
|
||||
StepId = implicitStepId,
|
||||
StepName = "__implicit_wait_all",
|
||||
};
|
||||
var implicitWaitAll = new JobExtensionRunner(
|
||||
runAsync: bgCoordinator.RunControlFlowAsync,
|
||||
condition: $"{PipelineTemplateConstants.Always}()",
|
||||
displayName: "Wait for all background steps",
|
||||
data: implicitWaitAllData);
|
||||
var uncoveredExternalIds = contextNameToExternalId
|
||||
.Where(kvp => !coveredBackgroundIds.Contains(kvp.Key))
|
||||
.Select(kvp => kvp.Value)
|
||||
.ToArray();
|
||||
implicitWaitAll.ExecutionContext = jobContext.CreateChild(
|
||||
implicitStepId, implicitWaitAll.DisplayName, "__implicit_wait_all",
|
||||
null, "__implicit_wait_all", ActionRunStage.Main,
|
||||
backgroundControlType: Pipelines.BackgroundControlTypes.WaitAll,
|
||||
backgroundControlStepIds: uncoveredExternalIds.Length > 0 ? uncoveredExternalIds : null);
|
||||
jobSteps.Add(implicitWaitAll);
|
||||
actionStep.ExecutionContext = jobContext.CreateChild(actionStep.Action.Id, actionStep.DisplayName, actionStep.Action.Name, null, actionStep.Action.ContextName, ActionRunStage.Main, intraActionState);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -617,21 +468,7 @@ namespace GitHub.Runner.Worker
|
||||
{
|
||||
foreach (var checkUrl in checkUrls)
|
||||
{
|
||||
_connectivityCheckTasks.Add(CheckConnectivity(checkUrl, accessToken: string.Empty, timeoutInSeconds: 5));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (systemConnection.Data.TryGetValue("ConnectivityAndDNSChecks", out var connectivityAndDNSChecksPayload) &&
|
||||
!string.IsNullOrEmpty(connectivityAndDNSChecksPayload))
|
||||
{
|
||||
Trace.Info($"Start checking server connectivity and DNS.");
|
||||
var checkUrls = StringUtil.ConvertFromJson<List<string>>(connectivityAndDNSChecksPayload);
|
||||
if (checkUrls?.Count > 0)
|
||||
{
|
||||
foreach (var checkUrl in checkUrls)
|
||||
{
|
||||
_connectivityAndDNSCheckTasks.Add(CheckConnectivity(checkUrl, accessToken: string.Empty, timeoutInSeconds: 5, checkDNS: true));
|
||||
_connectivityCheckTasks.Add(CheckConnectivity(checkUrl, accessToken: string.Empty, timeoutInSeconds: 5, token: CancellationToken.None));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -639,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)
|
||||
@@ -694,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 = "";
|
||||
@@ -930,7 +708,7 @@ namespace GitHub.Runner.Worker
|
||||
foreach (var check in _connectivityCheckTasks)
|
||||
{
|
||||
var result = await check;
|
||||
Trace.Info($"Connectivity check result: {StringUtil.ConvertToJson(result)}");
|
||||
Trace.Info($"Connectivity check result: {result}");
|
||||
context.Global.JobTelemetry.Add(new JobTelemetry() { Type = JobTelemetryType.ConnectivityCheck, Message = $"{result.EndpointUrl}: {result.StatusCode}" });
|
||||
}
|
||||
}
|
||||
@@ -942,27 +720,6 @@ namespace GitHub.Runner.Worker
|
||||
}
|
||||
}
|
||||
|
||||
if (_connectivityAndDNSCheckTasks.Count > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
Trace.Info($"Wait for all connectivity and DNS checks to finish.");
|
||||
await Task.WhenAll(_connectivityAndDNSCheckTasks);
|
||||
foreach (var check in _connectivityAndDNSCheckTasks)
|
||||
{
|
||||
var result = await check;
|
||||
Trace.Info($"Connectivity and DNS check result: {StringUtil.ConvertToJson(result)}");
|
||||
context.Global.JobTelemetry.Add(new JobTelemetry() { Type = JobTelemetryType.ConnectivityCheck, Message = $"connectivity_dns_telemetry:{StringUtil.ConvertToJson(result, Formatting.None)}" });
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error($"Fail to check server connectivity and DNS.");
|
||||
Trace.Error(ex);
|
||||
context.Global.JobTelemetry.Add(new JobTelemetry() { Type = JobTelemetryType.ConnectivityCheck, Message = $"Fail to check server connectivity and DNS. {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
// Collect service connectivity check result
|
||||
if (_serviceConnectivityCheckTask != null)
|
||||
{
|
||||
@@ -1020,77 +777,22 @@ 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<CheckResult> CheckConnectivity(string endpointUrl, string accessToken, int timeoutInSeconds, bool checkDNS = false, CancellationToken token = default)
|
||||
private async Task<CheckResult> CheckConnectivity(string endpointUrl, string accessToken, int timeoutInSeconds, CancellationToken token)
|
||||
{
|
||||
Trace.Info($"Check server connectivity for {endpointUrl}.");
|
||||
CheckResult result = new CheckResult() { EndpointUrl = endpointUrl };
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
using (var timeoutTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutInSeconds)))
|
||||
using (var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(token, timeoutTokenSource.Token))
|
||||
{
|
||||
if (checkDNS)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dnsStopwatch = Stopwatch.StartNew();
|
||||
var addresses = await Dns.GetHostAddressesAsync(new Uri(endpointUrl).Host, linkedTokenSource.Token);
|
||||
dnsStopwatch.Stop();
|
||||
result.DNSResolutionDurationInMs = (int)dnsStopwatch.ElapsedMilliseconds;
|
||||
result.EndpointIPs = addresses.Select(a => a.ToString()).ToArray();
|
||||
}
|
||||
catch (Exception ex) when (ex is OperationCanceledException && token.IsCancellationRequested)
|
||||
{
|
||||
Trace.Error($"DNS resolution canceled: {ex}");
|
||||
result.DNSError = "dns_canceled";
|
||||
}
|
||||
catch (Exception ex) when (ex is OperationCanceledException && timeoutTokenSource.IsCancellationRequested)
|
||||
{
|
||||
Trace.Error($"DNS resolution timeout: {ex}");
|
||||
result.DNSError = "dns_timeout";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error($"Catch exception during DNS resolution: {ex}");
|
||||
result.DNSError = $"dns_{ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var httpStopwatch = Stopwatch.StartNew();
|
||||
using (var httpClientHandler = HostContext.CreateHttpClientHandler())
|
||||
using (var httpClient = new HttpClient(httpClientHandler))
|
||||
{
|
||||
@@ -1101,7 +803,7 @@ namespace GitHub.Runner.Worker
|
||||
}
|
||||
|
||||
var response = await httpClient.GetAsync(endpointUrl, linkedTokenSource.Token);
|
||||
result.StatusCode = $"http_{response.StatusCode}";
|
||||
result.StatusCode = $"{response.StatusCode}";
|
||||
|
||||
var githubRequestId = UrlUtil.GetGitHubRequestId(response.Headers);
|
||||
var vssRequestId = UrlUtil.GetVssRequestId(response.Headers);
|
||||
@@ -1113,26 +815,26 @@ namespace GitHub.Runner.Worker
|
||||
{
|
||||
result.RequestId = vssRequestId;
|
||||
}
|
||||
httpStopwatch.Stop();
|
||||
result.HttpRequestDurationInMs = (int)httpStopwatch.ElapsedMilliseconds;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is OperationCanceledException && token.IsCancellationRequested)
|
||||
{
|
||||
Trace.Error($"Request canceled during connectivity check: {ex}");
|
||||
result.StatusCode = "http_canceled";
|
||||
result.StatusCode = "canceled";
|
||||
}
|
||||
catch (Exception ex) when (ex is OperationCanceledException && timeoutTokenSource.IsCancellationRequested)
|
||||
{
|
||||
Trace.Error($"Request timeout during connectivity check: {ex}");
|
||||
result.StatusCode = "http_timeout";
|
||||
result.StatusCode = "timeout";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error($"Catch exception during connectivity check: {ex}");
|
||||
result.StatusCode = $"http_{ex.Message}";
|
||||
result.StatusCode = $"{ex.Message}";
|
||||
}
|
||||
}
|
||||
stopwatch.Stop();
|
||||
result.DurationInMs = (int)stopwatch.ElapsedMilliseconds;
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1217,8 +919,8 @@ namespace GitHub.Runner.Worker
|
||||
|
||||
try
|
||||
{
|
||||
var result = await CheckConnectivity(endpoint.Value, accessToken: accessToken, timeoutInSeconds: checkConnectivityInfo.RequestTimeoutInSecond, token: token);
|
||||
testResult.EndpointsResult[endpoint.Key].Add($"{result.StartTime:s}: {result.StatusCode} - {result.RequestId} - {result.HttpRequestDurationInMs}ms");
|
||||
var result = await CheckConnectivity(endpoint.Value, accessToken: accessToken, timeoutInSeconds: checkConnectivityInfo.RequestTimeoutInSecond, token);
|
||||
testResult.EndpointsResult[endpoint.Key].Add($"{result.StartTime:s}: {result.StatusCode} - {result.RequestId} - {result.DurationInMs}ms");
|
||||
if (!testResult.HasFailure &&
|
||||
result.StatusCode != "OK" &&
|
||||
result.StatusCode != "canceled")
|
||||
@@ -1289,19 +991,13 @@ namespace GitHub.Runner.Worker
|
||||
|
||||
public string EndpointUrl { get; set; }
|
||||
|
||||
public string[] EndpointIPs { get; set; }
|
||||
|
||||
public DateTime StartTime { get; set; }
|
||||
|
||||
public string StatusCode { get; set; }
|
||||
|
||||
public string RequestId { get; set; }
|
||||
|
||||
public int HttpRequestDurationInMs { get; set; }
|
||||
|
||||
public int DNSResolutionDurationInMs { get; set; }
|
||||
|
||||
public string DNSError { get; set; }
|
||||
public int DurationInMs { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ namespace GitHub.Runner.Worker
|
||||
|
||||
public sealed class JobRunner : RunnerService, IJobRunner
|
||||
{
|
||||
private const string DebuggerConnectionTelemetryPrefix = "DebuggerConnectionResult";
|
||||
private IJobServerQueue _jobServerQueue;
|
||||
private RunnerSettings _runnerSettings;
|
||||
private ITempDirectoryManager _tempDirectoryManager;
|
||||
@@ -113,6 +114,7 @@ namespace GitHub.Runner.Worker
|
||||
|
||||
IExecutionContext jobContext = null;
|
||||
CancellationTokenRegistration? runnerShutdownRegistration = null;
|
||||
IDapDebugger dapDebugger = null;
|
||||
try
|
||||
{
|
||||
// Create the job execution context.
|
||||
@@ -179,6 +181,25 @@ namespace GitHub.Runner.Worker
|
||||
_tempDirectoryManager = HostContext.GetService<ITempDirectoryManager>();
|
||||
_tempDirectoryManager.InitializeTempDirectory(jobContext);
|
||||
|
||||
// Setup the debugger
|
||||
if (jobContext.Global.Debugger?.Enabled == true)
|
||||
{
|
||||
Trace.Info("Debugger enabled for this job run");
|
||||
|
||||
try
|
||||
{
|
||||
dapDebugger = HostContext.GetService<IDapDebugger>();
|
||||
await dapDebugger.StartAsync(jobContext);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error($"Failed to start DAP debugger: {ex.Message}");
|
||||
AddDebuggerConnectionTelemetry(jobContext, $"Failed: {ex.Message}");
|
||||
jobContext.Error("Failed to start debugger.");
|
||||
return await CompleteJobAsync(server, jobContext, message, TaskResult.Failed);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Get the job extension.
|
||||
Trace.Info("Getting job extension.");
|
||||
@@ -221,6 +242,33 @@ namespace GitHub.Runner.Worker
|
||||
await Task.WhenAny(_jobServerQueue.JobRecordUpdated.Task, Task.Delay(1000));
|
||||
}
|
||||
|
||||
// Wait for DAP debugger client connection and handshake after "Set up job"
|
||||
// so the job page shows the setup step before we block on the debugger
|
||||
if (dapDebugger != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await dapDebugger.WaitUntilReadyAsync();
|
||||
AddDebuggerConnectionTelemetry(jobContext, "Connected");
|
||||
}
|
||||
catch (OperationCanceledException) when (jobRequestCancellationToken.IsCancellationRequested)
|
||||
{
|
||||
Trace.Info("Job was cancelled before debugger client connected.");
|
||||
AddDebuggerConnectionTelemetry(jobContext, "Canceled");
|
||||
jobContext.Error("Job was cancelled before debugger client connected.");
|
||||
return await CompleteJobAsync(server, jobContext, message, TaskResult.Canceled);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error($"DAP debugger failed to become ready: {ex.Message}");
|
||||
AddDebuggerConnectionTelemetry(jobContext, $"Failed: {ex.Message}");
|
||||
|
||||
// If debugging was requested but the debugger is not available, fail the job
|
||||
jobContext.Error("The debugger failed to start or no debugger client connected in time.");
|
||||
return await CompleteJobAsync(server, jobContext, message, TaskResult.Failed);
|
||||
}
|
||||
}
|
||||
|
||||
// Run all job steps
|
||||
Trace.Info("Run all job steps.");
|
||||
var stepsRunner = HostContext.GetService<IStepsRunner>();
|
||||
@@ -231,12 +279,6 @@ namespace GitHub.Runner.Worker
|
||||
jobContext.JobSteps.Enqueue(step);
|
||||
}
|
||||
|
||||
if (jobContext.Global.Debugger?.Enabled == true)
|
||||
{
|
||||
var dapDebugger = HostContext.GetService<IDapDebugger>();
|
||||
await dapDebugger.OnJobStepsInitializedAsync(jobContext.JobSteps, jobContext.PostJobSteps);
|
||||
}
|
||||
|
||||
await stepsRunner.RunAsync(jobContext);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -267,6 +309,11 @@ namespace GitHub.Runner.Worker
|
||||
runnerShutdownRegistration = null;
|
||||
}
|
||||
|
||||
if (dapDebugger != null)
|
||||
{
|
||||
await dapDebugger.OnJobCompletedAsync();
|
||||
}
|
||||
|
||||
await ShutdownQueue(throwOnFailure: false);
|
||||
}
|
||||
}
|
||||
@@ -448,6 +495,15 @@ namespace GitHub.Runner.Worker
|
||||
throw new AggregateException(exceptions);
|
||||
}
|
||||
|
||||
private static void AddDebuggerConnectionTelemetry(IExecutionContext jobContext, string result)
|
||||
{
|
||||
jobContext.Global.JobTelemetry.Add(new JobTelemetry
|
||||
{
|
||||
Type = JobTelemetryType.General,
|
||||
Message = $"{DebuggerConnectionTelemetryPrefix}: {result}"
|
||||
});
|
||||
}
|
||||
|
||||
private void MaskTelemetrySecrets(List<JobTelemetry> jobTelemetry)
|
||||
{
|
||||
foreach (var telemetryItem in jobTelemetry)
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<PackageReference Include="System.ServiceProcess.ServiceController" Version="10.0.3" />
|
||||
<PackageReference Include="System.Threading.Channels" Version="10.0.3" />
|
||||
<PackageReference Include="YamlDotNet.Signed" Version="5.3.0" />
|
||||
<PackageReference Include="Microsoft.DevTunnels.Connections" Version="1.3.50" />
|
||||
<PackageReference Include="Microsoft.DevTunnels.Connections" Version="1.3.16" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -18,7 +18,6 @@ namespace GitHub.Runner.Worker
|
||||
{
|
||||
private static readonly Regex _propertyRegex = new("^[a-zA-Z_][a-zA-Z0-9_]*$", RegexOptions.Compiled);
|
||||
private readonly DictionaryContextData _contextData = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Clears memory for a composite action's isolated "steps" context, after the action
|
||||
@@ -26,12 +25,9 @@ namespace GitHub.Runner.Worker
|
||||
/// </summary>
|
||||
public void ClearScope(string scopeName)
|
||||
{
|
||||
lock (_lock)
|
||||
if (_contextData.TryGetValue(scopeName, out _))
|
||||
{
|
||||
if (_contextData.TryGetValue(scopeName, out _))
|
||||
{
|
||||
_contextData[scopeName] = new DictionaryContextData();
|
||||
}
|
||||
_contextData[scopeName] = new DictionaryContextData();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,26 +41,23 @@ namespace GitHub.Runner.Worker
|
||||
/// </summary>
|
||||
public DictionaryContextData GetScope(string scopeName)
|
||||
{
|
||||
lock (_lock)
|
||||
if (scopeName == null)
|
||||
{
|
||||
if (scopeName == null)
|
||||
{
|
||||
scopeName = string.Empty;
|
||||
}
|
||||
|
||||
var scope = default(DictionaryContextData);
|
||||
if (_contextData.TryGetValue(scopeName, out var scopeValue))
|
||||
{
|
||||
scope = scopeValue.AssertDictionary("scope");
|
||||
}
|
||||
else
|
||||
{
|
||||
scope = new DictionaryContextData();
|
||||
_contextData.Add(scopeName, scope);
|
||||
}
|
||||
|
||||
return scope;
|
||||
scopeName = string.Empty;
|
||||
}
|
||||
|
||||
var scope = default(DictionaryContextData);
|
||||
if (_contextData.TryGetValue(scopeName, out var scopeValue))
|
||||
{
|
||||
scope = scopeValue.AssertDictionary("scope");
|
||||
}
|
||||
else
|
||||
{
|
||||
scope = new DictionaryContextData();
|
||||
_contextData.Add(scopeName, scope);
|
||||
}
|
||||
|
||||
return scope;
|
||||
}
|
||||
|
||||
public void SetOutput(
|
||||
@@ -74,19 +67,16 @@ namespace GitHub.Runner.Worker
|
||||
string value,
|
||||
out string reference)
|
||||
{
|
||||
lock (_lock)
|
||||
var step = GetStep(scopeName, stepName);
|
||||
var outputs = step["outputs"].AssertDictionary("outputs");
|
||||
outputs[outputName] = new StringContextData(value);
|
||||
if (_propertyRegex.IsMatch(outputName))
|
||||
{
|
||||
var step = GetStep(scopeName, stepName);
|
||||
var outputs = step["outputs"].AssertDictionary("outputs");
|
||||
outputs[outputName] = new StringContextData(value);
|
||||
if (_propertyRegex.IsMatch(outputName))
|
||||
{
|
||||
reference = $"steps.{stepName}.outputs.{outputName}";
|
||||
}
|
||||
else
|
||||
{
|
||||
reference = $"steps['{stepName}']['outputs']['{outputName}']";
|
||||
}
|
||||
reference = $"steps.{stepName}.outputs.{outputName}";
|
||||
}
|
||||
else
|
||||
{
|
||||
reference = $"steps['{stepName}']['outputs']['{outputName}']";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,11 +85,8 @@ namespace GitHub.Runner.Worker
|
||||
string stepName,
|
||||
ActionResult conclusion)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var step = GetStep(scopeName, stepName);
|
||||
step["conclusion"] = new StringContextData(conclusion.ToString().ToLowerInvariant());
|
||||
}
|
||||
var step = GetStep(scopeName, stepName);
|
||||
step["conclusion"] = new StringContextData(conclusion.ToString().ToLowerInvariant());
|
||||
}
|
||||
|
||||
public void SetOutcome(
|
||||
@@ -107,11 +94,8 @@ namespace GitHub.Runner.Worker
|
||||
string stepName,
|
||||
ActionResult outcome)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var step = GetStep(scopeName, stepName);
|
||||
step["outcome"] = new StringContextData(outcome.ToString().ToLowerInvariant());
|
||||
}
|
||||
var step = GetStep(scopeName, stepName);
|
||||
step["outcome"] = new StringContextData(outcome.ToString().ToLowerInvariant());
|
||||
}
|
||||
|
||||
private DictionaryContextData GetStep(string scopeName, string stepName)
|
||||
|
||||
@@ -41,8 +41,6 @@ namespace GitHub.Runner.Worker
|
||||
ArgUtil.NotNull(jobContext, nameof(jobContext));
|
||||
ArgUtil.NotNull(jobContext.JobSteps, nameof(jobContext.JobSteps));
|
||||
|
||||
var _bgCoordinator = HostContext.GetService<IBackgroundStepCoordinator>();
|
||||
|
||||
// TaskResult:
|
||||
// Abandoned (Server set this.)
|
||||
// Canceled
|
||||
@@ -59,15 +57,6 @@ namespace GitHub.Runner.Worker
|
||||
if (jobContext.JobSteps.Count == 0 && !checkPostJobActions)
|
||||
{
|
||||
checkPostJobActions = true;
|
||||
|
||||
// Safety net: wait for any unwaited background steps before post-hooks
|
||||
var backgroundResult = await _bgCoordinator.WaitForUnwaitedStepsAsync(jobContext.CancellationToken);
|
||||
if (backgroundResult != TaskResult.Succeeded)
|
||||
{
|
||||
jobContext.Result = TaskResultUtil.MergeTaskResults(jobContext.Result, backgroundResult);
|
||||
jobContext.JobContext.Status = jobContext.Result?.ToActionResult();
|
||||
}
|
||||
|
||||
while (jobContext.PostJobSteps.TryPop(out var postStep))
|
||||
{
|
||||
jobContext.JobSteps.Enqueue(postStep);
|
||||
@@ -83,11 +72,8 @@ namespace GitHub.Runner.Worker
|
||||
ArgUtil.NotNull(step.ExecutionContext.Global, nameof(step.ExecutionContext.Global));
|
||||
ArgUtil.NotNull(step.ExecutionContext.Global.Variables, nameof(step.ExecutionContext.Global.Variables));
|
||||
|
||||
// Start — defer for background steps until the slot is acquired
|
||||
if (!step.ExecutionContext.IsBackground)
|
||||
{
|
||||
step.ExecutionContext.Start();
|
||||
}
|
||||
// Start
|
||||
step.ExecutionContext.Start();
|
||||
|
||||
// Expression functions
|
||||
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<AlwaysFunction>(PipelineTemplateConstants.Always, 0, 0));
|
||||
@@ -242,22 +228,14 @@ namespace GitHub.Runner.Worker
|
||||
}
|
||||
else
|
||||
{
|
||||
if (step.ExecutionContext.IsBackground)
|
||||
{
|
||||
// Queue the background step via coordinator
|
||||
_bgCoordinator.StartBackgroundStep(step, jobContext.CancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Pause for DAP debugger before step execution
|
||||
await dapDebugger?.OnStepStartingAsync(step);
|
||||
// Pause for DAP debugger before step execution
|
||||
await dapDebugger?.OnStepStartingAsync(step);
|
||||
|
||||
// Run the step synchronously (normal behavior)
|
||||
await RunStepAsync(step, jobContext.CancellationToken);
|
||||
CompleteStep(step);
|
||||
// Run the step
|
||||
await RunStepAsync(step, jobContext.CancellationToken);
|
||||
CompleteStep(step);
|
||||
|
||||
dapDebugger?.OnStepCompleted(step);
|
||||
}
|
||||
dapDebugger?.OnStepCompleted(step);
|
||||
}
|
||||
}
|
||||
finally
|
||||
|
||||
@@ -25,7 +25,6 @@ namespace GitHub.DistributedTask.Pipelines
|
||||
Inputs = actionToClone.Inputs?.Clone();
|
||||
ContextName = actionToClone?.ContextName;
|
||||
DisplayNameToken = actionToClone.DisplayNameToken?.Clone();
|
||||
Background = actionToClone.Background;
|
||||
}
|
||||
|
||||
public override StepType Type => StepType.Action;
|
||||
@@ -50,9 +49,6 @@ namespace GitHub.DistributedTask.Pipelines
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public TemplateToken Inputs { get; set; }
|
||||
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public bool Background { get; set; }
|
||||
|
||||
public override Step Clone()
|
||||
{
|
||||
return new ActionStep(this);
|
||||
|
||||
@@ -267,36 +267,6 @@ namespace GitHub.DistributedTask.Pipelines
|
||||
set;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optional welcome message shown in the debugger console when a client connects.
|
||||
/// Only used when the <c>actions_runner_override_debugger_welcome_message</c>
|
||||
/// feature flag is set to <c>true</c> in the job variables. With the flag set,
|
||||
/// a non-empty value is shown 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 +441,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 +466,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,57 +0,0 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.Serialization;
|
||||
using GitHub.DistributedTask.ObjectTemplating.Tokens;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace GitHub.DistributedTask.Pipelines
|
||||
{
|
||||
/// <summary>
|
||||
/// Known control-flow types for background step control steps.
|
||||
/// Wire values must match run-service constants (wait, wait-all, cancel).
|
||||
/// </summary>
|
||||
public static class BackgroundControlTypes
|
||||
{
|
||||
public const string Wait = "wait";
|
||||
public const string WaitAll = "wait-all";
|
||||
public const string Cancel = "cancel";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a unified background step control-flow step (wait, wait-all, cancel).
|
||||
/// </summary>
|
||||
[DataContract]
|
||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public class BackgroundStepControl : JobStep
|
||||
{
|
||||
[JsonConstructor]
|
||||
public BackgroundStepControl()
|
||||
{
|
||||
}
|
||||
|
||||
private BackgroundStepControl(BackgroundStepControl stepToClone)
|
||||
: base(stepToClone)
|
||||
{
|
||||
this.ControlType = stepToClone.ControlType;
|
||||
this.StepIds = stepToClone.StepIds != null
|
||||
? (string[])stepToClone.StepIds.Clone()
|
||||
: null;
|
||||
this.DisplayNameToken = stepToClone.DisplayNameToken?.Clone();
|
||||
}
|
||||
|
||||
public override StepType Type => StepType.BackgroundStepControl;
|
||||
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public string ControlType { get; set; }
|
||||
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public string[] StepIds { get; set; }
|
||||
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public TemplateToken DisplayNameToken { get; set; }
|
||||
|
||||
public override Step Clone()
|
||||
{
|
||||
return new BackgroundStepControl(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
);
|
||||
|
||||
@@ -22,7 +22,6 @@ namespace GitHub.DistributedTask.Pipelines
|
||||
this.Condition = stepToClone.Condition;
|
||||
this.ContinueOnError = stepToClone.ContinueOnError?.Clone();
|
||||
this.TimeoutInMinutes = stepToClone.TimeoutInMinutes?.Clone();
|
||||
this.ParallelGroupId = stepToClone.ParallelGroupId;
|
||||
}
|
||||
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
@@ -45,8 +44,5 @@ namespace GitHub.DistributedTask.Pipelines
|
||||
get;
|
||||
set;
|
||||
}
|
||||
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public string ParallelGroupId { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,18 +55,7 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
|
||||
break;
|
||||
case ActionSourceType.Repository:
|
||||
var repositoryReference = step.Reference as RepositoryPathReference;
|
||||
if (!String.IsNullOrEmpty(repositoryReference.Name))
|
||||
{
|
||||
name = repositoryReference.Name;
|
||||
}
|
||||
else if (String.Equals(repositoryReference.RepositoryType, PipelineConstants.SelfRepositoryAlias, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
name = PipelineConstants.SelfRepositoryAlias;
|
||||
}
|
||||
else
|
||||
{
|
||||
name = PipelineConstants.SelfAlias;
|
||||
}
|
||||
name = !String.IsNullOrEmpty(repositoryReference.Name) ? repositoryReference.Name : PipelineConstants.SelfAlias;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -611,14 +600,6 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
|
||||
Path = uses.Value
|
||||
};
|
||||
}
|
||||
else if (PipelineConstants.TryParseSelfRepository(uses.Value, out var selfPath))
|
||||
{
|
||||
result.Reference = new RepositoryPathReference
|
||||
{
|
||||
RepositoryType = PipelineConstants.SelfRepositoryAlias,
|
||||
Path = selfPath
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
var usesSegments = uses.Value.Split('@');
|
||||
|
||||
@@ -38,43 +38,10 @@ namespace GitHub.DistributedTask.Pipelines
|
||||
public static readonly Int32 MaxNodeNameLength = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Alias for the self local-workspace repository type (./ syntax).
|
||||
/// Resolves to the local checkout on the runner.
|
||||
/// Alias for the self repository.
|
||||
/// </summary>
|
||||
public static readonly String SelfAlias = "self";
|
||||
|
||||
/// <summary>
|
||||
/// RepositoryType for self-repository references ($/ syntax).
|
||||
/// Resolves to "this repo, at this SHA" based on the containing YAML file.
|
||||
/// </summary>
|
||||
public static readonly String SelfRepositoryAlias = "selfRepository";
|
||||
|
||||
/// <summary>
|
||||
/// The prefix for self-repository references in uses: values.
|
||||
/// </summary>
|
||||
public const String SelfRepositoryPrefix = "$/";
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the uses value is a self-repository reference (starts with $/),
|
||||
/// and outputs the subpath after the prefix.
|
||||
/// </summary>
|
||||
public static bool TryParseSelfRepository(string usesValue, out string path)
|
||||
{
|
||||
if (usesValue != null && usesValue.StartsWith(SelfRepositoryPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
path = usesValue.Substring(SelfRepositoryPrefix.Length).TrimStart('/');
|
||||
if (string.IsNullOrEmpty(path))
|
||||
{
|
||||
path = null;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
path = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Error code during graph validation.
|
||||
/// </summary>
|
||||
|
||||
@@ -7,7 +7,6 @@ namespace GitHub.DistributedTask.Pipelines
|
||||
{
|
||||
[DataContract]
|
||||
[KnownType(typeof(ActionStep))]
|
||||
[KnownType(typeof(BackgroundStepControl))]
|
||||
[JsonConverter(typeof(StepConverter))]
|
||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public abstract class Step
|
||||
@@ -69,7 +68,5 @@ namespace GitHub.DistributedTask.Pipelines
|
||||
{
|
||||
[DataMember]
|
||||
Action = 4,
|
||||
[DataMember]
|
||||
BackgroundStepControl = 5,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,9 +51,6 @@ namespace GitHub.DistributedTask.Pipelines
|
||||
case StepType.Action:
|
||||
stepObject = new ActionStep();
|
||||
break;
|
||||
case StepType.BackgroundStepControl:
|
||||
stepObject = new BackgroundStepControl();
|
||||
break;
|
||||
}
|
||||
|
||||
using (var objectReader = value.CreateReader())
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,10 +43,6 @@ namespace GitHub.DistributedTask.WebApi
|
||||
this.WarningCount = recordToBeCloned.WarningCount;
|
||||
this.NoticeCount = recordToBeCloned.NoticeCount;
|
||||
this.AgentPlatform = recordToBeCloned.AgentPlatform;
|
||||
this.IsBackground = recordToBeCloned.IsBackground;
|
||||
this.BackgroundControlType = recordToBeCloned.BackgroundControlType;
|
||||
this.BackgroundControlStepIds = recordToBeCloned.BackgroundControlStepIds;
|
||||
this.ParallelGroupId = recordToBeCloned.ParallelGroupId;
|
||||
|
||||
if (recordToBeCloned.Log != null)
|
||||
{
|
||||
@@ -293,34 +289,6 @@ namespace GitHub.DistributedTask.WebApi
|
||||
set;
|
||||
}
|
||||
|
||||
[DataMember(Order = 140, EmitDefaultValue = false)]
|
||||
public bool IsBackground
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
|
||||
[DataMember(Order = 141, EmitDefaultValue = false)]
|
||||
public string BackgroundControlType
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
|
||||
[DataMember(Order = 142, EmitDefaultValue = false)]
|
||||
public string[] BackgroundControlStepIds
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
|
||||
[DataMember(Order = 144, EmitDefaultValue = false)]
|
||||
public string ParallelGroupId
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
|
||||
public IList<TimelineAttempt> PreviousAttempts
|
||||
{
|
||||
get
|
||||
|
||||
@@ -5,10 +5,8 @@ namespace GitHub.Actions.RunService.WebApi
|
||||
[DataContract]
|
||||
public class BrokerErrorKind
|
||||
{
|
||||
public const string AcknowledgeJobNotFound = "AcknowledgeJobNotFound";
|
||||
public const string RunnerNotFound = "RunnerNotFound";
|
||||
public const string RunnerVersionTooOld = "RunnerVersionTooOld";
|
||||
public const string HostedRunnerDeprovisioned = "HostedRunnerDeprovisioned";
|
||||
public const string RunnerSessionInvalid = "RunnerSessionInvalid";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,14 +50,5 @@ namespace GitHub.Actions.RunService.WebApi
|
||||
|
||||
[DataMember(Name = "annotations", EmitDefaultValue = false)]
|
||||
public List<Annotation> Annotations { get; set; }
|
||||
|
||||
[DataMember(Name = "is_background", EmitDefaultValue = false)]
|
||||
public bool IsBackground { get; set; }
|
||||
|
||||
[DataMember(Name = "background_control_type", EmitDefaultValue = false)]
|
||||
public string BackgroundControlType { get; set; }
|
||||
|
||||
[DataMember(Name = "background_control_step_ids", EmitDefaultValue = false)]
|
||||
public string[] BackgroundControlStepIds { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,19 +18,19 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Storage.Blobs" Version="12.29.1" />
|
||||
<PackageReference Include="Azure.Storage.Blobs" Version="12.27.0" />
|
||||
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
|
||||
<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.10" />
|
||||
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="10.0.3" />
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.3" />
|
||||
<PackageReference Include="Minimatch" Version="2.0.0" />
|
||||
<PackageReference Include="YamlDotNet.Signed" Version="5.3.0" />
|
||||
<PackageReference Include="System.Net.Http" Version="4.3.4" />
|
||||
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1" />
|
||||
<PackageReference Include="System.Private.Uri" Version="4.3.2" />
|
||||
<PackageReference Include="System.Formats.Asn1" Version="10.0.10" />
|
||||
<PackageReference Include="System.Formats.Asn1" Version="10.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -125,8 +125,6 @@ namespace GitHub.Actions.RunService.WebApi
|
||||
};
|
||||
case BrokerErrorKind.HostedRunnerDeprovisioned:
|
||||
throw new HostedRunnerDeprovisionedException(brokerError.Message);
|
||||
case BrokerErrorKind.RunnerSessionInvalid:
|
||||
throw new TaskAgentSessionExpiredException(brokerError.Message);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -253,10 +251,10 @@ namespace GitHub.Actions.RunService.WebApi
|
||||
{
|
||||
switch (brokerError.ErrorKind)
|
||||
{
|
||||
case BrokerErrorKind.AcknowledgeJobNotFound:
|
||||
throw new RunnerRequestJobNotFoundException(brokerError.Message);
|
||||
case BrokerErrorKind.RunnerNotFound:
|
||||
throw new RunnerNotFoundException(brokerError.Message);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -179,14 +179,6 @@ namespace GitHub.Services.Results.Contracts
|
||||
public string CompletedAt;
|
||||
[DataMember]
|
||||
public Conclusion Conclusion;
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public bool IsBackground;
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public string BackgroundControlType;
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public string[] BackgroundControlStepIds;
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public string ParallelGroupId;
|
||||
}
|
||||
|
||||
public enum Status
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace GitHub.Services.WebApi
|
||||
{
|
||||
[Serializable]
|
||||
public sealed class RunnerRequestJobNotFoundException : Exception
|
||||
{
|
||||
public RunnerRequestJobNotFoundException()
|
||||
: base()
|
||||
{
|
||||
}
|
||||
|
||||
public RunnerRequestJobNotFoundException(String message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public RunnerRequestJobNotFoundException(String message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -514,7 +514,7 @@ namespace GitHub.Services.Results.Client
|
||||
|
||||
private Step ConvertTimelineRecordToStep(TimelineRecord r)
|
||||
{
|
||||
var step = new Step()
|
||||
return new Step()
|
||||
{
|
||||
ExternalId = r.Id.ToString(),
|
||||
Number = r.Order.GetValueOrDefault(),
|
||||
@@ -522,25 +522,8 @@ namespace GitHub.Services.Results.Client
|
||||
Status = ConvertStateToStatus(r.State.GetValueOrDefault()),
|
||||
StartedAt = r.StartTime?.ToString(Constants.TimestampFormat, CultureInfo.InvariantCulture),
|
||||
CompletedAt = r.FinishTime?.ToString(Constants.TimestampFormat, CultureInfo.InvariantCulture),
|
||||
Conclusion = ConvertResultToConclusion(r.Result),
|
||||
IsBackground = r.IsBackground,
|
||||
Conclusion = ConvertResultToConclusion(r.Result)
|
||||
};
|
||||
|
||||
// Set background control type directly (no enum mapping needed)
|
||||
if (!string.IsNullOrEmpty(r.BackgroundControlType))
|
||||
{
|
||||
step.BackgroundControlType = r.BackgroundControlType;
|
||||
}
|
||||
if (r.BackgroundControlStepIds != null)
|
||||
{
|
||||
step.BackgroundControlStepIds = r.BackgroundControlStepIds;
|
||||
}
|
||||
if (!string.IsNullOrEmpty(r.ParallelGroupId))
|
||||
{
|
||||
step.ParallelGroupId = r.ParallelGroupId;
|
||||
}
|
||||
|
||||
return step;
|
||||
}
|
||||
|
||||
private Status ConvertStateToStatus(TimelineRecordState s)
|
||||
|
||||
@@ -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}'");
|
||||
}
|
||||
|
||||
@@ -1605,10 +1605,6 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
||||
{
|
||||
id = WorkflowConstants.SelfAlias;
|
||||
}
|
||||
else if (GitHub.DistributedTask.Pipelines.PipelineConstants.TryParseSelfRepository(action.Uses!.Value, out _))
|
||||
{
|
||||
id = WorkflowConstants.SelfRepositoryAlias;
|
||||
}
|
||||
else
|
||||
{
|
||||
var usesSegments = action.Uses!.Value.Split('@');
|
||||
@@ -1881,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");
|
||||
@@ -1961,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;
|
||||
}
|
||||
@@ -2295,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[]
|
||||
{
|
||||
@@ -2315,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -26,15 +26,10 @@ namespace GitHub.Actions.WorkflowParser
|
||||
internal const Int32 MaxNodeNameLength = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Alias for the self local-workspace repository type (./ syntax).
|
||||
/// Alias for the self repository.
|
||||
/// </summary>
|
||||
internal const String SelfAlias = "self";
|
||||
|
||||
/// <summary>
|
||||
/// RepositoryType for self-repository references ($/ syntax).
|
||||
/// </summary>
|
||||
internal const String SelfRepositoryAlias = "selfRepository";
|
||||
|
||||
public static class PermissionsPolicy
|
||||
{
|
||||
public const string LimitedRead = "LimitedRead";
|
||||
|
||||
@@ -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}'");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,80 +363,6 @@ namespace GitHub.Runner.Common.Tests.Listener
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Runner")]
|
||||
public async Task GetNextMessage_RecreatesSessionOnSessionExpired()
|
||||
{
|
||||
using (TestHostContext tc = CreateTestContext())
|
||||
using (var tokenSource = new CancellationTokenSource())
|
||||
{
|
||||
Tracing trace = tc.GetTrace();
|
||||
|
||||
// Arrange.
|
||||
_credMgr.Setup(x => x.LoadCredentials(true)).Returns(new VssCredentials());
|
||||
|
||||
var expectedSession = new TaskAgentSession();
|
||||
_brokerServer
|
||||
.Setup(x => x.CreateSessionAsync(
|
||||
It.Is<TaskAgentSession>(y => y != null),
|
||||
tokenSource.Token))
|
||||
.Returns(Task.FromResult(expectedSession));
|
||||
|
||||
var expectedMessage = new TaskAgentMessage();
|
||||
var throwSessionExpired = true;
|
||||
_brokerServer
|
||||
.Setup(x => x.GetRunnerMessageAsync(
|
||||
It.IsAny<Guid?>(),
|
||||
It.IsAny<TaskAgentStatus>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(async (Guid? sessionId, TaskAgentStatus status, string version, string os, string architecture, bool disableUpdate, CancellationToken token) =>
|
||||
{
|
||||
await Task.Yield();
|
||||
if (throwSessionExpired)
|
||||
{
|
||||
throwSessionExpired = false;
|
||||
throw new TaskAgentSessionExpiredException("Runner session is invalid");
|
||||
}
|
||||
|
||||
return expectedMessage;
|
||||
});
|
||||
|
||||
// Act.
|
||||
BrokerMessageListener listener = new();
|
||||
listener.Initialize(tc);
|
||||
|
||||
CreateSessionResult result = await listener.CreateSessionAsync(tokenSource.Token);
|
||||
trace.Info("result: {0}", result);
|
||||
Assert.Equal(CreateSessionResult.Success, result);
|
||||
|
||||
TaskAgentMessage message = await listener.GetNextMessageAsync(tokenSource.Token);
|
||||
trace.Info("message: {0}", message);
|
||||
|
||||
// Assert.
|
||||
Assert.Equal(expectedMessage, message);
|
||||
_brokerServer
|
||||
.Verify(x => x.GetRunnerMessageAsync(
|
||||
It.IsAny<Guid?>(),
|
||||
It.IsAny<TaskAgentStatus>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<CancellationToken>()), Times.Exactly(2));
|
||||
|
||||
// Session recreated once on the expired exception (plus the initial create above).
|
||||
_brokerServer
|
||||
.Verify(x => x.CreateSessionAsync(
|
||||
It.Is<TaskAgentSession>(y => y != null),
|
||||
tokenSource.Token), Times.Exactly(2));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Runner")]
|
||||
@@ -474,7 +400,7 @@ namespace GitHub.Runner.Common.Tests.Listener
|
||||
.Verify(x => x.CreateSessionAsync(
|
||||
It.Is<TaskAgentSession>(y => y != null),
|
||||
tokenSource.Token), Times.Once());
|
||||
|
||||
|
||||
// Verify LoadSettings was never called
|
||||
_config.Verify(x => x.LoadSettings(), Times.Never());
|
||||
}
|
||||
|
||||
@@ -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,8 +29,6 @@ namespace GitHub.Runner.Common.Tests.Listener
|
||||
private Mock<ICredentialManager> _credentialManager;
|
||||
private Mock<IActionsRunServer> _actionsRunServer;
|
||||
private Mock<IRunServer> _runServer;
|
||||
private Mock<IBrokerServer> _brokerServer;
|
||||
private readonly string _returnJobResultForHosted;
|
||||
|
||||
public RunnerL0()
|
||||
{
|
||||
@@ -47,15 +45,6 @@ namespace GitHub.Runner.Common.Tests.Listener
|
||||
_credentialManager = new Mock<ICredentialManager>();
|
||||
_actionsRunServer = new Mock<IActionsRunServer>();
|
||||
_runServer = new Mock<IRunServer>();
|
||||
_brokerServer = new Mock<IBrokerServer>();
|
||||
|
||||
_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)
|
||||
@@ -177,96 +166,6 @@ namespace GitHub.Runner.Common.Tests.Listener
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Runner")]
|
||||
public async Task TestRunAsyncCleanupLocalConfigWhenGetNextMessageReturnsNotFound()
|
||||
{
|
||||
using (var hc = new TestHostContext(this))
|
||||
{
|
||||
//Arrange
|
||||
var runner = new Runner.Listener.Runner();
|
||||
hc.SetSingleton<IConfigurationManager>(_configurationManager.Object);
|
||||
hc.SetSingleton<IJobNotification>(_jobNotification.Object);
|
||||
hc.SetSingleton<IPromptManager>(_promptManager.Object);
|
||||
hc.SetSingleton<IRunnerServer>(_runnerServer.Object);
|
||||
hc.SetSingleton<IBrokerServer>(_brokerServer.Object);
|
||||
hc.SetSingleton<ICredentialManager>(_credentialManager.Object);
|
||||
hc.SetSingleton<IConfigurationStore>(_configStore.Object);
|
||||
hc.EnqueueInstance<IErrorThrottler>(_acquireJobThrottler.Object);
|
||||
hc.EnqueueInstance<IJobDispatcher>(_jobDispatcher.Object);
|
||||
|
||||
var messageListener = new MessageListener();
|
||||
messageListener.Initialize(hc);
|
||||
hc.SetSingleton<IMessageListener>(messageListener);
|
||||
|
||||
runner.Initialize(hc);
|
||||
|
||||
var settings = new RunnerSettings
|
||||
{
|
||||
AgentId = 1,
|
||||
AgentName = "myagent",
|
||||
PoolId = 43242,
|
||||
PoolName = "default",
|
||||
ServerUrl = "http://myserver",
|
||||
WorkFolder = "_work",
|
||||
Ephemeral = false,
|
||||
};
|
||||
|
||||
_configurationManager.Setup(x => x.LoadSettings())
|
||||
.Returns(settings);
|
||||
_configurationManager.Setup(x => x.IsConfigured())
|
||||
.Returns(true);
|
||||
_credentialManager.Setup(x => x.LoadCredentials(false)).Returns(new VssCredentials());
|
||||
_runnerServer.Setup(x => x.ConnectAsync(It.IsAny<Uri>(), It.IsAny<VssCredentials>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
_runnerServer.Setup(x => x.CreateAgentSessionAsync(
|
||||
settings.PoolId,
|
||||
It.Is<TaskAgentSession>(x => x != null),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.FromResult(new TaskAgentSession()));
|
||||
_runnerServer.Setup(x => x.GetAgentMessageAsync(
|
||||
settings.PoolId,
|
||||
It.IsAny<Guid>(),
|
||||
It.IsAny<long?>(),
|
||||
TaskAgentStatus.Online,
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Throws(new TaskAgentNotFoundException("runner not found"));
|
||||
_jobNotification.Setup(x => x.StartClient(It.IsAny<string>()));
|
||||
_configStore.Setup(x => x.IsServiceConfigured()).Returns(false);
|
||||
|
||||
//Act
|
||||
var command = new CommandSettings(hc, new string[] { "run" });
|
||||
var result = await runner.ExecuteCommand(command);
|
||||
|
||||
//Assert
|
||||
Assert.Equal(Constants.Runner.ReturnCode.Success, result);
|
||||
_runnerServer.Verify(x => x.CreateAgentSessionAsync(
|
||||
settings.PoolId,
|
||||
It.Is<TaskAgentSession>(x => x != null),
|
||||
It.IsAny<CancellationToken>()), Times.Once());
|
||||
_runnerServer.Verify(x => x.GetAgentMessageAsync(
|
||||
settings.PoolId,
|
||||
It.IsAny<Guid>(),
|
||||
It.IsAny<long?>(),
|
||||
TaskAgentStatus.Online,
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<CancellationToken>()), Times.Once());
|
||||
_runnerServer.Verify(x => x.DeleteAgentSessionAsync(
|
||||
It.IsAny<int>(),
|
||||
It.IsAny<Guid>(),
|
||||
It.IsAny<CancellationToken>()), Times.Never());
|
||||
_configurationManager.Verify(x => x.DeleteLocalRunnerConfig(), Times.Once());
|
||||
}
|
||||
}
|
||||
|
||||
public static TheoryData<string[], bool, Times> RunAsServiceTestData = new TheoryData<string[], bool, Times>()
|
||||
{
|
||||
// staring with run command, configured as run as service, should start the runner
|
||||
@@ -963,202 +862,6 @@ namespace GitHub.Runner.Common.Tests.Listener
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Runner")]
|
||||
public async Task TestEphemeralRunnerJobRequestMessageFromRunServiceExitsOnAcknowledgeJobNotFound()
|
||||
{
|
||||
using (var hc = new TestHostContext(this))
|
||||
{
|
||||
//Arrange
|
||||
var runner = new Runner.Listener.Runner();
|
||||
hc.SetSingleton<IConfigurationManager>(_configurationManager.Object);
|
||||
hc.SetSingleton<IJobNotification>(_jobNotification.Object);
|
||||
hc.SetSingleton<IMessageListener>(_messageListener.Object);
|
||||
hc.SetSingleton<IPromptManager>(_promptManager.Object);
|
||||
hc.SetSingleton<IRunnerServer>(_runnerServer.Object);
|
||||
hc.SetSingleton<IConfigurationStore>(_configStore.Object);
|
||||
hc.SetSingleton<ISelfUpdater>(_updater.Object);
|
||||
hc.SetSingleton<ICredentialManager>(_credentialManager.Object);
|
||||
hc.EnqueueInstance<IErrorThrottler>(_acquireJobThrottler.Object);
|
||||
hc.EnqueueInstance<IRunServer>(_runServer.Object);
|
||||
hc.EnqueueInstance<IJobDispatcher>(_jobDispatcher.Object);
|
||||
|
||||
runner.Initialize(hc);
|
||||
var settings = new RunnerSettings
|
||||
{
|
||||
PoolId = 43242,
|
||||
AgentId = 5678,
|
||||
Ephemeral = true,
|
||||
ServerUrl = "https://github.com",
|
||||
};
|
||||
|
||||
var message = new TaskAgentMessage()
|
||||
{
|
||||
Body = JsonUtility.ToString(new RunnerJobRequestRef() { BillingOwnerId = "github", RunnerRequestId = "999", RunServiceUrl = "https://run-service.com", ShouldAcknowledge = true }),
|
||||
MessageId = 4234,
|
||||
MessageType = JobRequestMessageTypes.RunnerJobRequest
|
||||
};
|
||||
|
||||
var messages = new Queue<TaskAgentMessage>();
|
||||
messages.Enqueue(message);
|
||||
_configurationManager.Setup(x => x.LoadSettings())
|
||||
.Returns(settings);
|
||||
_configurationManager.Setup(x => x.IsConfigured())
|
||||
.Returns(true);
|
||||
_messageListener.Setup(x => x.CreateSessionAsync(It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.FromResult<CreateSessionResult>(CreateSessionResult.Success));
|
||||
_messageListener.Setup(x => x.GetNextMessageAsync(It.IsAny<CancellationToken>()))
|
||||
.Returns(async (CancellationToken token) =>
|
||||
{
|
||||
if (0 == messages.Count)
|
||||
{
|
||||
await Task.Delay(2000, token);
|
||||
}
|
||||
|
||||
return messages.Dequeue();
|
||||
});
|
||||
_messageListener.Setup(x => x.AcknowledgeMessageAsync("999", It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new RunnerRequestJobNotFoundException("Job not found"));
|
||||
_messageListener.Setup(x => x.DeleteSessionAsync())
|
||||
.Returns(Task.CompletedTask);
|
||||
_messageListener.Setup(x => x.DeleteMessageAsync(It.IsAny<TaskAgentMessage>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
_jobNotification.Setup(x => x.StartClient(It.IsAny<String>()))
|
||||
.Callback(() =>
|
||||
{
|
||||
|
||||
});
|
||||
|
||||
_configStore.Setup(x => x.IsServiceConfigured()).Returns(false);
|
||||
|
||||
//Act
|
||||
var command = new CommandSettings(hc, new string[] { "run" });
|
||||
Task<int> runnerTask = runner.ExecuteCommand(command);
|
||||
|
||||
//Assert
|
||||
await Task.WhenAny(runnerTask, Task.Delay(30000));
|
||||
|
||||
Assert.True(runnerTask.IsCompleted, $"{nameof(runner.ExecuteCommand)} timed out.");
|
||||
Assert.True(!runnerTask.IsFaulted, runnerTask.Exception?.ToString());
|
||||
if (runnerTask.IsCompleted)
|
||||
{
|
||||
Assert.Equal(Constants.Runner.ReturnCode.Success, await runnerTask);
|
||||
}
|
||||
|
||||
_messageListener.Verify(x => x.AcknowledgeMessageAsync("999", It.IsAny<CancellationToken>()), Times.Once());
|
||||
_jobDispatcher.Verify(x => x.Run(It.IsAny<Pipelines.AgentJobRequestMessage>(), It.IsAny<bool>()), Times.Never());
|
||||
_runServer.Verify(x => x.GetJobMessageAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never());
|
||||
_credentialManager.Verify(x => x.LoadCredentials(true), Times.Never());
|
||||
_messageListener.Verify(x => x.DeleteSessionAsync(), Times.Once());
|
||||
_messageListener.Verify(x => x.DeleteMessageAsync(It.IsAny<TaskAgentMessage>()), Times.Once());
|
||||
_configurationManager.Verify(x => x.DeleteLocalRunnerConfig(), Times.Once());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Runner")]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Runner")]
|
||||
public async Task TestRunnerJobRequestMessageFromRunServiceContinuesOnAcknowledgeJobNotFoundForPersistentRunner()
|
||||
{
|
||||
using (var hc = new TestHostContext(this))
|
||||
{
|
||||
//Arrange
|
||||
var runner = new Runner.Listener.Runner();
|
||||
hc.SetSingleton<IConfigurationManager>(_configurationManager.Object);
|
||||
hc.SetSingleton<IJobNotification>(_jobNotification.Object);
|
||||
hc.SetSingleton<IMessageListener>(_messageListener.Object);
|
||||
hc.SetSingleton<IPromptManager>(_promptManager.Object);
|
||||
hc.SetSingleton<IRunnerServer>(_runnerServer.Object);
|
||||
hc.SetSingleton<IConfigurationStore>(_configStore.Object);
|
||||
hc.SetSingleton<ISelfUpdater>(_updater.Object);
|
||||
hc.SetSingleton<ICredentialManager>(_credentialManager.Object);
|
||||
hc.EnqueueInstance<IErrorThrottler>(_acquireJobThrottler.Object);
|
||||
hc.EnqueueInstance<IRunServer>(_runServer.Object);
|
||||
hc.EnqueueInstance<IJobDispatcher>(_jobDispatcher.Object);
|
||||
|
||||
runner.Initialize(hc);
|
||||
var settings = new RunnerSettings
|
||||
{
|
||||
PoolId = 43242,
|
||||
AgentId = 5678,
|
||||
Ephemeral = false,
|
||||
ServerUrl = "https://github.com",
|
||||
};
|
||||
|
||||
var message = new TaskAgentMessage()
|
||||
{
|
||||
Body = JsonUtility.ToString(new RunnerJobRequestRef() { BillingOwnerId = "github", RunnerRequestId = "999", RunServiceUrl = "https://run-service.com", ShouldAcknowledge = true }),
|
||||
MessageId = 4234,
|
||||
MessageType = JobRequestMessageTypes.RunnerJobRequest
|
||||
};
|
||||
|
||||
var messages = new Queue<TaskAgentMessage>();
|
||||
messages.Enqueue(message);
|
||||
var signalWorkerStarted = new SemaphoreSlim(0, 1);
|
||||
_configurationManager.Setup(x => x.LoadSettings())
|
||||
.Returns(settings);
|
||||
_configurationManager.Setup(x => x.IsConfigured())
|
||||
.Returns(true);
|
||||
_messageListener.Setup(x => x.CreateSessionAsync(It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.FromResult<CreateSessionResult>(CreateSessionResult.Success));
|
||||
_messageListener.Setup(x => x.GetNextMessageAsync(It.IsAny<CancellationToken>()))
|
||||
.Returns(async (CancellationToken token) =>
|
||||
{
|
||||
if (0 == messages.Count)
|
||||
{
|
||||
await Task.Delay(2000, token);
|
||||
}
|
||||
|
||||
return messages.Dequeue();
|
||||
});
|
||||
_messageListener.Setup(x => x.AcknowledgeMessageAsync("999", It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new RunnerRequestJobNotFoundException("Job not found"));
|
||||
_messageListener.Setup(x => x.DeleteSessionAsync())
|
||||
.Returns(Task.CompletedTask);
|
||||
_messageListener.Setup(x => x.DeleteMessageAsync(It.IsAny<TaskAgentMessage>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
_jobNotification.Setup(x => x.StartClient(It.IsAny<String>()))
|
||||
.Callback(() =>
|
||||
{
|
||||
|
||||
});
|
||||
_runServer.Setup(x => x.GetJobMessageAsync("999", "github", It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.FromResult(CreateJobRequestMessage("test")));
|
||||
_credentialManager.Setup(x => x.LoadCredentials(true)).Returns(new VssCredentials());
|
||||
_jobDispatcher.Setup(x => x.Run(It.IsAny<Pipelines.AgentJobRequestMessage>(), false))
|
||||
.Callback(() =>
|
||||
{
|
||||
signalWorkerStarted.Release();
|
||||
});
|
||||
|
||||
_configStore.Setup(x => x.IsServiceConfigured()).Returns(false);
|
||||
|
||||
//Act
|
||||
var command = new CommandSettings(hc, new string[] { "run" });
|
||||
Task<int> runnerTask = runner.ExecuteCommand(command);
|
||||
|
||||
//Assert
|
||||
if (!await signalWorkerStarted.WaitAsync(2000))
|
||||
{
|
||||
Assert.Fail($"{nameof(_jobDispatcher.Object.Run)} was not invoked.");
|
||||
}
|
||||
|
||||
hc.ShutdownRunner(ShutdownReason.UserCancelled);
|
||||
await Task.WhenAny(runnerTask, Task.Delay(2000));
|
||||
|
||||
Assert.True(runnerTask.IsCompleted, $"{nameof(runner.ExecuteCommand)} timed out.");
|
||||
Assert.True(runnerTask.IsCanceled);
|
||||
_messageListener.Verify(x => x.AcknowledgeMessageAsync("999", It.IsAny<CancellationToken>()), Times.Once());
|
||||
_jobDispatcher.Verify(x => x.Run(It.IsAny<Pipelines.AgentJobRequestMessage>(), false), Times.Once());
|
||||
_runServer.Verify(x => x.GetJobMessageAsync("999", "github", It.IsAny<CancellationToken>()), Times.Once());
|
||||
_credentialManager.Verify(x => x.LoadCredentials(true), Times.Once());
|
||||
_configurationManager.Verify(x => x.DeleteLocalRunnerConfig(), Times.Never());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Runner")]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Runtime.Serialization.Json;
|
||||
using System.Text;
|
||||
@@ -17,13 +17,13 @@ public sealed class AgentJobRequestMessageL0
|
||||
// Arrange
|
||||
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage));
|
||||
string jsonWithEnabledDebugger = DoubleQuotify("{'EnableDebugger': true}");
|
||||
|
||||
|
||||
// Act
|
||||
using var stream = new MemoryStream();
|
||||
stream.Write(Encoding.UTF8.GetBytes(jsonWithEnabledDebugger));
|
||||
stream.Position = 0;
|
||||
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
|
||||
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(recoveredMessage);
|
||||
Assert.True(recoveredMessage.EnableDebugger, "EnableDebugger should be true when JSON contains 'EnableDebugger': true");
|
||||
@@ -37,13 +37,13 @@ public sealed class AgentJobRequestMessageL0
|
||||
// Arrange
|
||||
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage));
|
||||
string jsonWithoutDebugger = DoubleQuotify("{'messageType': 'PipelineAgentJobRequest'}");
|
||||
|
||||
|
||||
// Act
|
||||
using var stream = new MemoryStream();
|
||||
stream.Write(Encoding.UTF8.GetBytes(jsonWithoutDebugger));
|
||||
stream.Position = 0;
|
||||
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
|
||||
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(recoveredMessage);
|
||||
Assert.False(recoveredMessage.EnableDebugger, "EnableDebugger should default to false when JSON field is absent");
|
||||
@@ -57,13 +57,13 @@ public sealed class AgentJobRequestMessageL0
|
||||
// Arrange
|
||||
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage));
|
||||
string jsonWithDisabledDebugger = DoubleQuotify("{'EnableDebugger': false}");
|
||||
|
||||
|
||||
// Act
|
||||
using var stream = new MemoryStream();
|
||||
stream.Write(Encoding.UTF8.GetBytes(jsonWithDisabledDebugger));
|
||||
stream.Position = 0;
|
||||
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
|
||||
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(recoveredMessage);
|
||||
Assert.False(recoveredMessage.EnableDebugger, "EnableDebugger should be false when JSON contains 'EnableDebugger': false");
|
||||
@@ -119,68 +119,6 @@ public sealed class AgentJobRequestMessageL0
|
||||
Assert.Null(recoveredMessage.DebuggerTunnel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Common")]
|
||||
public void VerifyActionsDependenciesDeserialization_WithDependencies()
|
||||
{
|
||||
// Arrange
|
||||
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage));
|
||||
string json = DoubleQuotify("{'dependencies': ['actions/checkout@v4:sha256-abc123', 'actions/setup-node@v4:sha256-def456']}");
|
||||
|
||||
// Act
|
||||
using var stream = new MemoryStream();
|
||||
stream.Write(Encoding.UTF8.GetBytes(json));
|
||||
stream.Position = 0;
|
||||
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(recoveredMessage);
|
||||
Assert.Equal(2, recoveredMessage.ActionsDependencies.Count);
|
||||
Assert.Equal("actions/checkout@v4:sha256-abc123", recoveredMessage.ActionsDependencies[0]);
|
||||
Assert.Equal("actions/setup-node@v4:sha256-def456", recoveredMessage.ActionsDependencies[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Common")]
|
||||
public void VerifyActionsDependenciesDeserialization_DefaultsToEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage));
|
||||
string json = DoubleQuotify("{'messageType': 'PipelineAgentJobRequest'}");
|
||||
|
||||
// Act
|
||||
using var stream = new MemoryStream();
|
||||
stream.Write(Encoding.UTF8.GetBytes(json));
|
||||
stream.Position = 0;
|
||||
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(recoveredMessage);
|
||||
Assert.Empty(recoveredMessage.ActionsDependencies);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Common")]
|
||||
public void VerifyDebuggerWelcomeMessageRoundTrips()
|
||||
{
|
||||
// Arrange
|
||||
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage));
|
||||
string json = DoubleQuotify("{'DebuggerWelcomeMessage': 'Welcome to debugging!'}");
|
||||
|
||||
// Act
|
||||
using var stream = new MemoryStream();
|
||||
stream.Write(Encoding.UTF8.GetBytes(json));
|
||||
stream.Position = 0;
|
||||
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(recoveredMessage);
|
||||
Assert.Equal("Welcome to debugging!", recoveredMessage.DebuggerWelcomeMessage);
|
||||
}
|
||||
|
||||
private static string DoubleQuotify(string text)
|
||||
{
|
||||
return text.Replace('\'', '"');
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Sdk;
|
||||
using GitHub.Services.WebApi;
|
||||
using Xunit;
|
||||
|
||||
namespace GitHub.Actions.RunService.WebApi.Tests;
|
||||
|
||||
public sealed class BrokerHttpClientL0
|
||||
{
|
||||
[Fact]
|
||||
public async Task AcknowledgeRunnerRequestAsyncThrowsRunnerRequestJobNotFoundException()
|
||||
{
|
||||
using var client = CreateClient(
|
||||
HttpStatusCode.NotFound,
|
||||
new BrokerError
|
||||
{
|
||||
Source = "actions-broker-listener",
|
||||
ErrorKind = BrokerErrorKind.AcknowledgeJobNotFound,
|
||||
StatusCode = (int)HttpStatusCode.NotFound,
|
||||
Message = "Assigned job no longer exists",
|
||||
});
|
||||
|
||||
await Assert.ThrowsAsync<RunnerRequestJobNotFoundException>(() =>
|
||||
client.AcknowledgeRunnerRequestAsync("runner-request", Guid.NewGuid(), "2.0.0", TaskAgentStatus.Online, cancellationToken: CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcknowledgeRunnerRequestAsyncKeepsRunnerNotFoundClassification()
|
||||
{
|
||||
using var client = CreateClient(
|
||||
HttpStatusCode.NotFound,
|
||||
new BrokerError
|
||||
{
|
||||
Source = "actions-broker-listener",
|
||||
ErrorKind = BrokerErrorKind.RunnerNotFound,
|
||||
StatusCode = (int)HttpStatusCode.NotFound,
|
||||
Message = "Runner not found",
|
||||
});
|
||||
|
||||
await Assert.ThrowsAsync<RunnerNotFoundException>(() =>
|
||||
client.AcknowledgeRunnerRequestAsync("runner-request", Guid.NewGuid(), "2.0.0", TaskAgentStatus.Online, cancellationToken: CancellationToken.None));
|
||||
}
|
||||
|
||||
private static BrokerHttpClient CreateClient(HttpStatusCode statusCode, BrokerError brokerError)
|
||||
{
|
||||
return new BrokerHttpClient(
|
||||
new Uri("https://broker.actions.githubusercontent.com/"),
|
||||
new StaticResponseHandler(new HttpResponseMessage(statusCode)
|
||||
{
|
||||
Content = new StringContent(JsonUtility.ToString(brokerError), Encoding.UTF8, "application/json"),
|
||||
}),
|
||||
disposeHandler: true);
|
||||
}
|
||||
|
||||
private sealed class StaticResponseHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly HttpResponseMessage _response;
|
||||
|
||||
public StaticResponseHandler(HttpResponseMessage response)
|
||||
{
|
||||
_response = response;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
_response.RequestMessage = request;
|
||||
return Task.FromResult(_response);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,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;
|
||||
@@ -90,11 +89,6 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
|
||||
var actionYamlFile = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), ActionName, "main", "action.yml");
|
||||
Assert.True(File.Exists(actionYamlFile));
|
||||
|
||||
var telemetryMessages = GetTelemetryMessages();
|
||||
Assert.True(ContainsTelemetry(telemetryMessages, "resolve_actions"));
|
||||
Assert.True(ContainsTelemetry(telemetryMessages, "succeeded"));
|
||||
Assert.True(ContainsTelemetry(telemetryMessages, "download_action"));
|
||||
_hc.GetTrace().Info(File.ReadAllText(actionYamlFile));
|
||||
}
|
||||
finally
|
||||
@@ -153,11 +147,6 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
|
||||
// Act + Assert
|
||||
await Assert.ThrowsAsync<InvalidActionArchiveException>(async () => await _actionManager.PrepareActionsAsync(_ec.Object, actions));
|
||||
|
||||
var telemetryMessages = GetTelemetryMessages();
|
||||
Assert.True(ContainsTelemetry(telemetryMessages, "resolve_actions"));
|
||||
Assert.True(ContainsTelemetry(telemetryMessages, "download_action"));
|
||||
Assert.True(ContainsTelemetry(telemetryMessages, "InvalidActionArchiveException"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -225,51 +214,6 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task PrepareActions_ResolveActionDownloadInfo_RecordsTelemetry_OnFailure()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Arrange
|
||||
Setup();
|
||||
_ec.Object.Global.Variables.Set(Constants.Variables.System.JobRequestType, "RunnerJobRequest");
|
||||
|
||||
_launchServer
|
||||
.Setup(x => x.ResolveActionsDownloadInfoAsync(It.IsAny<Guid>(), It.IsAny<Guid>(), It.IsAny<ActionReferenceList>(), It.IsAny<CancellationToken>(), It.IsAny<bool>()))
|
||||
.ThrowsAsync(new Exception("resolve failed"));
|
||||
|
||||
var actions = new List<Pipelines.JobStep>
|
||||
{
|
||||
new Pipelines.ActionStep()
|
||||
{
|
||||
Name = "action",
|
||||
Id = Guid.NewGuid(),
|
||||
Reference = new Pipelines.RepositoryPathReference()
|
||||
{
|
||||
Name = "actions/checkout",
|
||||
Ref = "v4",
|
||||
RepositoryType = "GitHub"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Act + Assert
|
||||
await Assert.ThrowsAsync<FailedToResolveActionDownloadInfoException>(async () => await _actionManager.PrepareActionsAsync(_ec.Object, actions));
|
||||
|
||||
var telemetryMessages = GetTelemetryMessages();
|
||||
Assert.Equal(1, telemetryMessages.Count(message =>
|
||||
message.Contains("resolve_actions", StringComparison.OrdinalIgnoreCase)
|
||||
&& !message.Contains("\"result\":\"succeeded\"", StringComparison.OrdinalIgnoreCase)));
|
||||
Assert.False(ContainsTelemetry(telemetryMessages, "resolve_actions\",\"result\":\"succeeded"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
#if OS_LINUX
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
@@ -390,7 +334,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());
|
||||
|
||||
@@ -416,7 +360,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)
|
||||
{
|
||||
@@ -424,8 +368,6 @@ runs:
|
||||
}
|
||||
}
|
||||
#endif
|
||||
MockResolvedSha("actions/download-artifact", "master", Sha256);
|
||||
|
||||
var actionId = Guid.NewGuid();
|
||||
var actions = new List<Pipelines.ActionStep>
|
||||
{
|
||||
@@ -574,10 +516,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
|
||||
@@ -585,9 +526,9 @@ runs:
|
||||
|
||||
//Assert
|
||||
string destDirectory = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), "actions", "checkout", "master");
|
||||
Assert.True(Directory.Exists(destDirectory), "Destination directory does not exist");
|
||||
var di = new DirectoryInfo(destDirectory);
|
||||
Assert.NotNull(di.LinkTarget);
|
||||
Assert.True(Directory.Exists(destDirectory), "Destination directory does not exist");
|
||||
var di = new DirectoryInfo(destDirectory);
|
||||
Assert.NotNull(di.LinkTarget);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -2441,7 +2382,7 @@ runs:
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void LoadsNode24ActionDefinition()
|
||||
@@ -2509,7 +2450,7 @@ runs:
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
@@ -3208,51 +3149,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();
|
||||
@@ -3387,750 +3283,5 @@ runs:
|
||||
Directory.Delete(_workFolder, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private IList<string> GetTelemetryMessages()
|
||||
{
|
||||
return _ec.Object.Global.JobTelemetry.Select(x => x.Message).ToList();
|
||||
}
|
||||
|
||||
private static bool ContainsTelemetry(IList<string> telemetryMessages, string expectedFragment)
|
||||
{
|
||||
return telemetryMessages.Any(message => message.Contains(expectedFragment, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[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();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async void PrepareActions_SelfRepository_ResolvesAtDepthZero()
|
||||
{
|
||||
// Self-references are only supported via run service (batch resolution path)
|
||||
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", "true");
|
||||
try
|
||||
{
|
||||
// Arrange
|
||||
Setup();
|
||||
const string RepoName = "my-org/my-repo";
|
||||
const string RepoSha = "abc123def456";
|
||||
_ec.Setup(x => x.GetGitHubContext("repository")).Returns(RepoName);
|
||||
_ec.Setup(x => x.GetGitHubContext("sha")).Returns(RepoSha);
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.SelfRepository, "true");
|
||||
var jobContext = new JobContext();
|
||||
jobContext.WorkflowRepository = RepoName;
|
||||
jobContext.WorkflowSha = RepoSha;
|
||||
_ec.Setup(x => x.JobContext).Returns(jobContext);
|
||||
|
||||
var actionId = Guid.NewGuid();
|
||||
var actions = new List<Pipelines.ActionStep>
|
||||
{
|
||||
new Pipelines.ActionStep()
|
||||
{
|
||||
Name = "action",
|
||||
Id = actionId,
|
||||
Reference = new Pipelines.RepositoryPathReference()
|
||||
{
|
||||
RepositoryType = Pipelines.PipelineConstants.SelfRepositoryAlias,
|
||||
Path = "actions/my-action"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
string archiveFile = await CreateRepoArchive();
|
||||
using var stream = File.OpenRead(archiveFile);
|
||||
string archiveLink = GetLinkToActionArchive("https://api.github.com", RepoName, RepoSha);
|
||||
var mockClientHandler = new Mock<HttpClientHandler>();
|
||||
mockClientHandler.Protected().Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.Is<HttpRequestMessage>(m => m.RequestUri == new Uri(archiveLink)), ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(stream) });
|
||||
var mockHandlerFactory = new Mock<IHttpClientHandlerFactory>();
|
||||
mockHandlerFactory.Setup(p => p.CreateClientHandler(It.IsAny<RunnerWebProxy>())).Returns(mockClientHandler.Object);
|
||||
_hc.SetSingleton(mockHandlerFactory.Object);
|
||||
|
||||
_ec.Setup(x => x.GetGitHubContext("api_url")).Returns("https://api.github.com");
|
||||
|
||||
// Act — resolution mutates the reference in-place before download/prepare.
|
||||
// The archive doesn't contain the subpath, so prepare will fail, but the
|
||||
// reference is already resolved by that point.
|
||||
try
|
||||
{
|
||||
await _actionManager.PrepareActionsAsync(_ec.Object, actions);
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("Can't find"))
|
||||
{
|
||||
// Expected: test archive lacks the action.yml at the resolved subpath
|
||||
}
|
||||
|
||||
// Assert — the reference should be resolved to a GitHub repo reference
|
||||
var repoRef = actions[0].Reference as Pipelines.RepositoryPathReference;
|
||||
Assert.Equal(Pipelines.RepositoryTypes.GitHub, repoRef.RepositoryType);
|
||||
Assert.Equal(RepoName, repoRef.Name);
|
||||
Assert.Equal(RepoSha, repoRef.Ref);
|
||||
Assert.Equal("actions/my-action", repoRef.Path);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", null);
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async void PrepareActions_SelfRepository_NotResolvedWhenFeatureFlagDisabled()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Arrange
|
||||
Setup();
|
||||
_ec.Setup(x => x.GetGitHubContext("repository")).Returns("my-org/my-repo");
|
||||
_ec.Setup(x => x.GetGitHubContext("sha")).Returns("abc123");
|
||||
// Feature flag NOT set
|
||||
|
||||
var actionId = Guid.NewGuid();
|
||||
var actions = new List<Pipelines.ActionStep>
|
||||
{
|
||||
new Pipelines.ActionStep()
|
||||
{
|
||||
Name = "action",
|
||||
Id = actionId,
|
||||
Reference = new Pipelines.RepositoryPathReference()
|
||||
{
|
||||
RepositoryType = Pipelines.PipelineConstants.SelfRepositoryAlias,
|
||||
Path = "actions/my-action"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Act & Assert — should throw because unresolved self-reference hits GetDownloadInfoLookupKey
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
||||
await _actionManager.PrepareActionsAsync(_ec.Object, actions));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async void PrepareActions_SelfRepository_ResolvesNestedInComposite()
|
||||
{
|
||||
// Composite action at $/actions/parent uses $/actions/child (same repo).
|
||||
// This tests the batch path fix: $/ refs in nextLevel must be resolved
|
||||
// BEFORE ResolveNewActionsAsync, otherwise GetDownloadInfoLookupKey throws.
|
||||
// We pre-stage only the parent action.yml on disk so the composite steps
|
||||
// are discovered, but we DON'T stage the child — a download failure for
|
||||
// the child is fine; the important thing is that $/ was resolved
|
||||
// (no InvalidOperationException from GetDownloadInfoLookupKey).
|
||||
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", "true");
|
||||
try
|
||||
{
|
||||
// Arrange
|
||||
Setup();
|
||||
const string RepoName = "my-org/my-repo";
|
||||
const string RepoSha = "abc123def456";
|
||||
_ec.Setup(x => x.GetGitHubContext("repository")).Returns(RepoName);
|
||||
_ec.Setup(x => x.GetGitHubContext("sha")).Returns(RepoSha);
|
||||
_ec.Setup(x => x.GetGitHubContext("api_url")).Returns("https://api.github.com");
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.SelfRepository, "true");
|
||||
var jobContext = new JobContext();
|
||||
jobContext.WorkflowRepository = RepoName;
|
||||
jobContext.WorkflowSha = RepoSha;
|
||||
_ec.Setup(x => x.JobContext).Returns(jobContext);
|
||||
|
||||
// Stage parent action on disk as a composite that uses $/actions/child.
|
||||
// We use rootStepId != default to avoid directory deletion,
|
||||
// and create the watermark + action.yml in the expected location.
|
||||
string actionsDir = Path.Combine(_workFolder, Constants.Path.ActionsDirectory);
|
||||
string destDir = Path.Combine(actionsDir, RepoName.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar), RepoSha);
|
||||
Directory.CreateDirectory(Path.Combine(destDir, "actions", "parent"));
|
||||
File.WriteAllText(Path.Combine(destDir, "actions", "parent", Constants.Path.ActionManifestYmlFile), @"
|
||||
name: 'Parent'
|
||||
description: 'Composite parent'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: $/actions/child
|
||||
");
|
||||
// Stage child action too (as a leaf node action)
|
||||
Directory.CreateDirectory(Path.Combine(destDir, "actions", "child"));
|
||||
File.WriteAllText(Path.Combine(destDir, "actions", "child", Constants.Path.ActionManifestYmlFile), @"
|
||||
name: 'Child'
|
||||
description: 'Node child'
|
||||
runs:
|
||||
using: 'node20'
|
||||
main: 'index.js'
|
||||
");
|
||||
// Write watermark
|
||||
File.WriteAllText($"{destDir}.completed", string.Empty);
|
||||
|
||||
var rootStepId = Guid.NewGuid();
|
||||
var actions = new List<Pipelines.ActionStep>
|
||||
{
|
||||
new Pipelines.ActionStep()
|
||||
{
|
||||
Name = "action",
|
||||
Id = Guid.NewGuid(),
|
||||
Reference = new Pipelines.RepositoryPathReference()
|
||||
{
|
||||
RepositoryType = Pipelines.PipelineConstants.SelfRepositoryAlias,
|
||||
Path = "actions/parent"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Act — should resolve $/ and not throw InvalidOperationException
|
||||
await _actionManager.PrepareActionsAsync(_ec.Object, actions, rootStepId);
|
||||
|
||||
// Assert — top-level $/ resolved
|
||||
var topRef = actions[0].Reference as Pipelines.RepositoryPathReference;
|
||||
Assert.Equal(Pipelines.RepositoryTypes.GitHub, topRef.RepositoryType);
|
||||
Assert.Equal(RepoName, topRef.Name);
|
||||
Assert.Equal(RepoSha, topRef.Ref);
|
||||
Assert.Equal("actions/parent", topRef.Path);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", null);
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async void PrepareActions_SelfRepository_CrossRepoCompositeResolvesToParentRepo()
|
||||
{
|
||||
// External composite (external/foo@v1) uses $/lib/bar.
|
||||
// $/lib/bar should resolve to external/foo@v1 (the parent's repo),
|
||||
// NOT to the workflow's root repo.
|
||||
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", "true");
|
||||
try
|
||||
{
|
||||
// Arrange
|
||||
Setup();
|
||||
const string RootRepoName = "my-org/my-repo";
|
||||
const string RootRepoSha = "root-sha-111";
|
||||
const string ExtRepoName = "external/foo";
|
||||
const string ExtRepoRef = "v1";
|
||||
_ec.Setup(x => x.GetGitHubContext("repository")).Returns(RootRepoName);
|
||||
_ec.Setup(x => x.GetGitHubContext("sha")).Returns(RootRepoSha);
|
||||
_ec.Setup(x => x.GetGitHubContext("api_url")).Returns("https://api.github.com");
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.SelfRepository, "true");
|
||||
var jobContext = new JobContext();
|
||||
jobContext.WorkflowRepository = RootRepoName;
|
||||
jobContext.WorkflowSha = RootRepoSha;
|
||||
_ec.Setup(x => x.JobContext).Returns(jobContext);
|
||||
|
||||
string actionsDir = Path.Combine(_workFolder, Constants.Path.ActionsDirectory);
|
||||
string destDir = Path.Combine(actionsDir, ExtRepoName.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar), ExtRepoRef);
|
||||
Directory.CreateDirectory(destDir);
|
||||
File.WriteAllText(Path.Combine(destDir, Constants.Path.ActionManifestYmlFile), @"
|
||||
name: 'External Foo'
|
||||
description: 'External composite'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: $/lib/bar
|
||||
");
|
||||
Directory.CreateDirectory(Path.Combine(destDir, "lib", "bar"));
|
||||
File.WriteAllText(Path.Combine(destDir, "lib", "bar", Constants.Path.ActionManifestYmlFile), @"
|
||||
name: 'Bar'
|
||||
description: 'Node action in external repo'
|
||||
runs:
|
||||
using: 'node20'
|
||||
main: 'index.js'
|
||||
");
|
||||
File.WriteAllText($"{destDir}.completed", string.Empty);
|
||||
|
||||
var rootStepId = Guid.NewGuid();
|
||||
var actions = new List<Pipelines.ActionStep>
|
||||
{
|
||||
new Pipelines.ActionStep()
|
||||
{
|
||||
Name = "action",
|
||||
Id = Guid.NewGuid(),
|
||||
Reference = new Pipelines.RepositoryPathReference()
|
||||
{
|
||||
Name = ExtRepoName,
|
||||
Ref = ExtRepoRef,
|
||||
RepositoryType = Pipelines.RepositoryTypes.GitHub
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Act — should resolve $/lib/bar to external/foo@v1/lib/bar
|
||||
await _actionManager.PrepareActionsAsync(_ec.Object, actions, rootStepId);
|
||||
|
||||
// Assert — the top-level ref is unchanged (it was already concrete)
|
||||
var topRef = actions[0].Reference as Pipelines.RepositoryPathReference;
|
||||
Assert.Equal(ExtRepoName, topRef.Name);
|
||||
Assert.Equal(ExtRepoRef, topRef.Ref);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", null);
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async void PrepareActions_SelfRepository_MultiLevelChain()
|
||||
{
|
||||
// $/a → composite → $/b → composite → $/c (three levels, same repo)
|
||||
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", "true");
|
||||
try
|
||||
{
|
||||
// Arrange
|
||||
Setup();
|
||||
const string RepoName = "my-org/my-repo";
|
||||
const string RepoSha = "chain-sha-222";
|
||||
_ec.Setup(x => x.GetGitHubContext("repository")).Returns(RepoName);
|
||||
_ec.Setup(x => x.GetGitHubContext("sha")).Returns(RepoSha);
|
||||
_ec.Setup(x => x.GetGitHubContext("api_url")).Returns("https://api.github.com");
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.SelfRepository, "true");
|
||||
var jobContext = new JobContext();
|
||||
jobContext.WorkflowRepository = RepoName;
|
||||
jobContext.WorkflowSha = RepoSha;
|
||||
_ec.Setup(x => x.JobContext).Returns(jobContext);
|
||||
|
||||
string actionsDir = Path.Combine(_workFolder, Constants.Path.ActionsDirectory);
|
||||
string destDir = Path.Combine(actionsDir, RepoName.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar), RepoSha);
|
||||
Directory.CreateDirectory(Path.Combine(destDir, "a"));
|
||||
File.WriteAllText(Path.Combine(destDir, "a", Constants.Path.ActionManifestYmlFile), @"
|
||||
name: 'A'
|
||||
description: 'Level 0 composite'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: $/b
|
||||
");
|
||||
Directory.CreateDirectory(Path.Combine(destDir, "b"));
|
||||
File.WriteAllText(Path.Combine(destDir, "b", Constants.Path.ActionManifestYmlFile), @"
|
||||
name: 'B'
|
||||
description: 'Level 1 composite'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: $/c
|
||||
");
|
||||
Directory.CreateDirectory(Path.Combine(destDir, "c"));
|
||||
File.WriteAllText(Path.Combine(destDir, "c", Constants.Path.ActionManifestYmlFile), @"
|
||||
name: 'C'
|
||||
description: 'Level 2 node leaf'
|
||||
runs:
|
||||
using: 'node20'
|
||||
main: 'index.js'
|
||||
");
|
||||
File.WriteAllText($"{destDir}.completed", string.Empty);
|
||||
|
||||
var rootStepId = Guid.NewGuid();
|
||||
var actions = new List<Pipelines.ActionStep>
|
||||
{
|
||||
new Pipelines.ActionStep()
|
||||
{
|
||||
Name = "action",
|
||||
Id = Guid.NewGuid(),
|
||||
Reference = new Pipelines.RepositoryPathReference()
|
||||
{
|
||||
RepositoryType = Pipelines.PipelineConstants.SelfRepositoryAlias,
|
||||
Path = "a"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Act — three-level $/ chain should resolve without error
|
||||
await _actionManager.PrepareActionsAsync(_ec.Object, actions, rootStepId);
|
||||
|
||||
// Assert — top-level ref resolved
|
||||
var topRef = actions[0].Reference as Pipelines.RepositoryPathReference;
|
||||
Assert.Equal(Pipelines.RepositoryTypes.GitHub, topRef.RepositoryType);
|
||||
Assert.Equal(RepoName, topRef.Name);
|
||||
Assert.Equal(RepoSha, topRef.Ref);
|
||||
Assert.Equal("a", topRef.Path);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", null);
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async void PrepareActions_SelfRepository_ResolvesAtDepthZero_LegacyPath()
|
||||
{
|
||||
// Same as ResolvesAtDepthZero but on the legacy (non-batch) path
|
||||
try
|
||||
{
|
||||
// Arrange
|
||||
Setup();
|
||||
const string RepoName = "my-org/my-repo";
|
||||
const string RepoSha = "abc123def456";
|
||||
_ec.Setup(x => x.GetGitHubContext("repository")).Returns(RepoName);
|
||||
_ec.Setup(x => x.GetGitHubContext("sha")).Returns(RepoSha);
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.SelfRepository, "true");
|
||||
var jobContext = new JobContext();
|
||||
jobContext.WorkflowRepository = RepoName;
|
||||
jobContext.WorkflowSha = RepoSha;
|
||||
_ec.Setup(x => x.JobContext).Returns(jobContext);
|
||||
|
||||
var actionId = Guid.NewGuid();
|
||||
var actions = new List<Pipelines.ActionStep>
|
||||
{
|
||||
new Pipelines.ActionStep()
|
||||
{
|
||||
Name = "action",
|
||||
Id = actionId,
|
||||
Reference = new Pipelines.RepositoryPathReference()
|
||||
{
|
||||
RepositoryType = Pipelines.PipelineConstants.SelfRepositoryAlias,
|
||||
Path = "actions/my-action"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
string archiveFile = await CreateRepoArchive();
|
||||
using var stream = File.OpenRead(archiveFile);
|
||||
string archiveLink = GetLinkToActionArchive("https://api.github.com", RepoName, RepoSha);
|
||||
var mockClientHandler = new Mock<HttpClientHandler>();
|
||||
mockClientHandler.Protected().Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.Is<HttpRequestMessage>(m => m.RequestUri == new Uri(archiveLink)), ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(stream) });
|
||||
var mockHandlerFactory = new Mock<IHttpClientHandlerFactory>();
|
||||
mockHandlerFactory.Setup(p => p.CreateClientHandler(It.IsAny<RunnerWebProxy>())).Returns(mockClientHandler.Object);
|
||||
_hc.SetSingleton(mockHandlerFactory.Object);
|
||||
|
||||
_ec.Setup(x => x.GetGitHubContext("api_url")).Returns("https://api.github.com");
|
||||
|
||||
// Act
|
||||
try
|
||||
{
|
||||
await _actionManager.PrepareActionsAsync(_ec.Object, actions);
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("Can't find"))
|
||||
{
|
||||
// Expected: test archive lacks the action.yml at the resolved subpath
|
||||
}
|
||||
|
||||
// Assert — the reference should be resolved to a GitHub repo reference
|
||||
var repoRef = actions[0].Reference as Pipelines.RepositoryPathReference;
|
||||
Assert.Equal(Pipelines.RepositoryTypes.GitHub, repoRef.RepositoryType);
|
||||
Assert.Equal(RepoName, repoRef.Name);
|
||||
Assert.Equal(RepoSha, repoRef.Ref);
|
||||
Assert.Equal("actions/my-action", repoRef.Path);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async void PrepareActions_SelfRepository_ResolvesNestedInComposite_LegacyPath()
|
||||
{
|
||||
// Same as ResolvesNestedInComposite but on the legacy (non-batch) path.
|
||||
// Verifies that $/ resolution works when batch action resolution is disabled.
|
||||
try
|
||||
{
|
||||
// Arrange
|
||||
Setup();
|
||||
const string RepoName = "my-org/my-repo";
|
||||
const string RepoSha = "abc123def456";
|
||||
_ec.Setup(x => x.GetGitHubContext("repository")).Returns(RepoName);
|
||||
_ec.Setup(x => x.GetGitHubContext("sha")).Returns(RepoSha);
|
||||
_ec.Setup(x => x.GetGitHubContext("api_url")).Returns("https://api.github.com");
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.SelfRepository, "true");
|
||||
var jobContext = new JobContext();
|
||||
jobContext.WorkflowRepository = RepoName;
|
||||
jobContext.WorkflowSha = RepoSha;
|
||||
_ec.Setup(x => x.JobContext).Returns(jobContext);
|
||||
|
||||
// Stage parent action on disk as a composite that uses $/actions/child.
|
||||
string actionsDir = Path.Combine(_workFolder, Constants.Path.ActionsDirectory);
|
||||
string destDir = Path.Combine(actionsDir, RepoName.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar), RepoSha);
|
||||
Directory.CreateDirectory(Path.Combine(destDir, "actions", "parent"));
|
||||
File.WriteAllText(Path.Combine(destDir, "actions", "parent", Constants.Path.ActionManifestYmlFile), @"
|
||||
name: 'Parent'
|
||||
description: 'Composite parent'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: $/actions/child
|
||||
");
|
||||
// Stage child action too (as a leaf node action)
|
||||
Directory.CreateDirectory(Path.Combine(destDir, "actions", "child"));
|
||||
File.WriteAllText(Path.Combine(destDir, "actions", "child", Constants.Path.ActionManifestYmlFile), @"
|
||||
name: 'Child'
|
||||
description: 'Node child'
|
||||
runs:
|
||||
using: 'node20'
|
||||
main: 'index.js'
|
||||
");
|
||||
// Write watermark
|
||||
File.WriteAllText($"{destDir}.completed", string.Empty);
|
||||
|
||||
var rootStepId = Guid.NewGuid();
|
||||
var actions = new List<Pipelines.ActionStep>
|
||||
{
|
||||
new Pipelines.ActionStep()
|
||||
{
|
||||
Name = "action",
|
||||
Id = Guid.NewGuid(),
|
||||
Reference = new Pipelines.RepositoryPathReference()
|
||||
{
|
||||
RepositoryType = Pipelines.PipelineConstants.SelfRepositoryAlias,
|
||||
Path = "actions/parent"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Act — should resolve $/ and not throw InvalidOperationException
|
||||
await _actionManager.PrepareActionsAsync(_ec.Object, actions, rootStepId);
|
||||
|
||||
// Assert — top-level $/ resolved
|
||||
var topRef = actions[0].Reference as Pipelines.RepositoryPathReference;
|
||||
Assert.Equal(Pipelines.RepositoryTypes.GitHub, topRef.RepositoryType);
|
||||
Assert.Equal(RepoName, topRef.Name);
|
||||
Assert.Equal(RepoSha, topRef.Ref);
|
||||
Assert.Equal("actions/parent", topRef.Path);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void LoadAction_DotSlashCompositeWithNestedSelfRepository_ResolvesViaWorkflowContext()
|
||||
{
|
||||
// Regression test: when a dot-slash (./) composite action contains a
|
||||
// nested $/actions/child step, LoadAction re-parses the action.yml at
|
||||
// runtime and must resolve the $/ ref. The parent is repositoryType "self"
|
||||
// so its Name and Ref are null — resolution must fall back to
|
||||
// WorkflowRepository/WorkflowSha from the job context. Before the fix,
|
||||
// this path hit a NullReferenceException at repoAction.Name.Replace().
|
||||
try
|
||||
{
|
||||
// Arrange
|
||||
Setup();
|
||||
const string WorkflowRepo = "my-org/my-repo";
|
||||
const string WorkflowSha = "abc123def456";
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.SelfRepository, "true");
|
||||
var jobContext = new JobContext();
|
||||
jobContext.WorkflowRepository = WorkflowRepo;
|
||||
jobContext.WorkflowSha = WorkflowSha;
|
||||
_ec.Setup(x => x.JobContext).Returns(jobContext);
|
||||
|
||||
// Stage the dot-slash composite in the workspace directory.
|
||||
// It contains a nested $/actions/child step.
|
||||
string workspaceDir = Path.Combine(_workFolder, "actions", "actions");
|
||||
string compositeDir = Path.Combine(workspaceDir, "my-composite");
|
||||
Directory.CreateDirectory(compositeDir);
|
||||
File.WriteAllText(Path.Combine(compositeDir, Constants.Path.ActionManifestYmlFile), @"
|
||||
name: 'DotSlash Parent'
|
||||
description: 'Composite loaded via ./ that nests a $/ ref'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- run: echo 'hello'
|
||||
shell: bash
|
||||
- uses: $/actions/child
|
||||
");
|
||||
|
||||
// Stage the child action in the actions cache under the workflow repo.
|
||||
string actionsDir = Path.Combine(_workFolder, Constants.Path.ActionsDirectory);
|
||||
string childDir = Path.Combine(actionsDir, WorkflowRepo.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar), WorkflowSha, "actions", "child");
|
||||
Directory.CreateDirectory(childDir);
|
||||
File.WriteAllText(Path.Combine(childDir, Constants.Path.ActionManifestYmlFile), @"
|
||||
name: 'Child'
|
||||
description: 'Leaf action'
|
||||
runs:
|
||||
using: 'node20'
|
||||
main: 'index.js'
|
||||
");
|
||||
|
||||
// Create dot-slash step with Name = null (the real scenario).
|
||||
var instance = new Pipelines.ActionStep()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Reference = new Pipelines.RepositoryPathReference()
|
||||
{
|
||||
Name = null,
|
||||
Ref = null,
|
||||
RepositoryType = Pipelines.PipelineConstants.SelfAlias,
|
||||
Path = "my-composite"
|
||||
}
|
||||
};
|
||||
|
||||
// Act — should NOT throw NullReferenceException
|
||||
Definition definition = _actionManager.LoadAction(_ec.Object, instance);
|
||||
|
||||
// Assert — loaded the composite successfully
|
||||
Assert.NotNull(definition);
|
||||
Assert.NotNull(definition.Data);
|
||||
Assert.Equal(ActionExecutionType.Composite, definition.Data.Execution.ExecutionType);
|
||||
|
||||
// Assert — the nested $/ step was resolved to the workflow repo
|
||||
var compositeData = definition.Data.Execution as CompositeActionExecutionData;
|
||||
Assert.NotNull(compositeData);
|
||||
var childStep = compositeData.Steps
|
||||
.OfType<Pipelines.ActionStep>()
|
||||
.FirstOrDefault(s => s.Reference is Pipelines.RepositoryPathReference r
|
||||
&& r.Path == "actions/child");
|
||||
Assert.NotNull(childStep);
|
||||
var childRef = childStep.Reference as Pipelines.RepositoryPathReference;
|
||||
Assert.Equal(Pipelines.RepositoryTypes.GitHub, childRef.RepositoryType);
|
||||
Assert.Equal(WorkflowRepo, childRef.Name);
|
||||
Assert.Equal(WorkflowSha, childRef.Ref);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Runtime.CompilerServices;
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using GitHub.Runner.Sdk;
|
||||
using GitHub.Runner.Worker;
|
||||
using Moq;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace GitHub.Runner.Common.Tests.Worker
|
||||
{
|
||||
public sealed class ArtifactsListFileCommandL0
|
||||
{
|
||||
private Mock<IExecutionContext> _executionContext;
|
||||
private string _rootDirectory;
|
||||
private string _outputFile;
|
||||
private ArtifactsListFileCommand _command;
|
||||
private GlobalContext _global;
|
||||
private ITraceWriter _trace;
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EmptyAggregate_WritesVersionedJsonWithEmptySubjects()
|
||||
{
|
||||
using (var hostContext = Setup())
|
||||
{
|
||||
_command.PopulateInitialContents(_executionContext.Object, _outputFile, null);
|
||||
var json = JObject.Parse(File.ReadAllText(_outputFile));
|
||||
Assert.Equal(ArtifactsListFileCommand.FormatVersion, json["version"].Value<int>());
|
||||
Assert.Empty((JArray)json["subjects"]);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void SingleSubject_SerializedCorrectly()
|
||||
{
|
||||
using (var hostContext = Setup())
|
||||
{
|
||||
_global.ArtifactSubjects["myapp"] = new ArtifactSubject(
|
||||
"myapp",
|
||||
"sha256:" + new string('a', 64),
|
||||
ArtifactSubjectKind.File);
|
||||
|
||||
_command.PopulateInitialContents(_executionContext.Object, _outputFile, null);
|
||||
|
||||
var json = JObject.Parse(File.ReadAllText(_outputFile));
|
||||
var subjects = (JArray)json["subjects"];
|
||||
Assert.Single(subjects);
|
||||
Assert.Equal("myapp", subjects[0]["name"].Value<string>());
|
||||
Assert.Equal("sha256:" + new string('a', 64), subjects[0]["digest"].Value<string>());
|
||||
Assert.Equal("file", subjects[0]["kind"].Value<string>());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void OciSubject_KindIsLowercaseOci()
|
||||
{
|
||||
using (var hostContext = Setup())
|
||||
{
|
||||
_global.ArtifactSubjects["ghcr.io/x:1"] = new ArtifactSubject(
|
||||
"ghcr.io/x:1",
|
||||
"sha256:" + new string('b', 64),
|
||||
ArtifactSubjectKind.OciSubject);
|
||||
|
||||
_command.PopulateInitialContents(_executionContext.Object, _outputFile, null);
|
||||
|
||||
var json = JObject.Parse(File.ReadAllText(_outputFile));
|
||||
Assert.Equal("oci", json["subjects"][0]["kind"].Value<string>());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void MultipleSubjects_SortedByName()
|
||||
{
|
||||
using (var hostContext = Setup())
|
||||
{
|
||||
// Insert deliberately out of alphabetical order to prove the
|
||||
// output is sorted by name rather than by insertion order.
|
||||
_global.ArtifactSubjects["two"] = new ArtifactSubject("two", "sha256:" + new string('2', 64), ArtifactSubjectKind.File);
|
||||
_global.ArtifactSubjects["one"] = new ArtifactSubject("one", "sha256:" + new string('1', 64), ArtifactSubjectKind.File);
|
||||
_global.ArtifactSubjects["three"] = new ArtifactSubject("three", "sha256:" + new string('3', 64), ArtifactSubjectKind.OciSubject);
|
||||
|
||||
_command.PopulateInitialContents(_executionContext.Object, _outputFile, null);
|
||||
|
||||
var subjects = (JArray)JObject.Parse(File.ReadAllText(_outputFile))["subjects"];
|
||||
Assert.Equal(3, subjects.Count);
|
||||
Assert.Equal("one", subjects[0]["name"].Value<string>());
|
||||
Assert.Equal("three", subjects[1]["name"].Value<string>());
|
||||
Assert.Equal("two", subjects[2]["name"].Value<string>());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void FeatureFlagOff_LeavesFileEmpty()
|
||||
{
|
||||
using (var hostContext = Setup(featureFlag: false))
|
||||
{
|
||||
_global.ArtifactSubjects["myapp"] = new ArtifactSubject("myapp", "sha256:" + new string('a', 64), ArtifactSubjectKind.File);
|
||||
|
||||
_command.PopulateInitialContents(_executionContext.Object, _outputFile, null);
|
||||
|
||||
Assert.Equal(string.Empty, File.ReadAllText(_outputFile));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EnvVarFallback_EnablesPublishing()
|
||||
{
|
||||
using (var hostContext = Setup(featureFlag: false, envVarOverride: "true"))
|
||||
{
|
||||
_global.ArtifactSubjects["myapp"] = new ArtifactSubject("myapp", "sha256:" + new string('a', 64), ArtifactSubjectKind.File);
|
||||
|
||||
_command.PopulateInitialContents(_executionContext.Object, _outputFile, null);
|
||||
|
||||
var json = JObject.Parse(File.ReadAllText(_outputFile));
|
||||
Assert.Single((JArray)json["subjects"]);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void OutputFileIsUtf8WithoutBom()
|
||||
{
|
||||
using (var hostContext = Setup())
|
||||
{
|
||||
_command.PopulateInitialContents(_executionContext.Object, _outputFile, null);
|
||||
|
||||
var bytes = File.ReadAllBytes(_outputFile);
|
||||
// UTF-8 BOM is EF BB BF
|
||||
Assert.False(bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF,
|
||||
"File should not begin with a UTF-8 BOM.");
|
||||
// Sanity check that the file is valid JSON.
|
||||
Assert.NotNull(JObject.Parse(System.Text.Encoding.UTF8.GetString(bytes)));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ProcessCommand_IsNoOp()
|
||||
{
|
||||
using (var hostContext = Setup())
|
||||
{
|
||||
// Even if the step writes garbage, ProcessCommand must not touch the aggregate.
|
||||
File.WriteAllText(_outputFile, "anything the step wrote");
|
||||
_command.ProcessCommand(_executionContext.Object, _outputFile, null);
|
||||
Assert.Empty(_global.ArtifactSubjects);
|
||||
}
|
||||
}
|
||||
|
||||
private TestHostContext Setup(bool featureFlag = true, string envVarOverride = null, [CallerMemberName] string name = "")
|
||||
{
|
||||
// Reset env-var state across test runs in the same process.
|
||||
Environment.SetEnvironmentVariable(CreateArtifactsFileCommand.EnableEnvVar, envVarOverride);
|
||||
|
||||
var hostContext = new TestHostContext(this, name);
|
||||
_trace = hostContext.GetTrace();
|
||||
|
||||
var workDirectory = hostContext.GetDirectory(WellKnownDirectory.Work);
|
||||
Directory.CreateDirectory(workDirectory);
|
||||
_rootDirectory = Path.Combine(workDirectory, nameof(ArtifactsListFileCommandL0), name);
|
||||
if (Directory.Exists(_rootDirectory))
|
||||
{
|
||||
Directory.Delete(_rootDirectory, recursive: true);
|
||||
}
|
||||
Directory.CreateDirectory(_rootDirectory);
|
||||
_outputFile = Path.Combine(_rootDirectory, "artifacts_list");
|
||||
File.WriteAllText(_outputFile, string.Empty);
|
||||
|
||||
var variableValues = new Dictionary<string, VariableValue>(StringComparer.OrdinalIgnoreCase);
|
||||
if (featureFlag)
|
||||
{
|
||||
variableValues[Common.Constants.Runner.Features.AllowArtifactsFile] = new VariableValue("true");
|
||||
}
|
||||
var variables = new Variables(hostContext, variableValues);
|
||||
|
||||
_global = new GlobalContext
|
||||
{
|
||||
EnvironmentVariables = new Dictionary<string, string>(VarUtil.EnvironmentVariableKeyComparer),
|
||||
Variables = variables,
|
||||
WriteDebug = true,
|
||||
ArtifactSubjects = new Dictionary<string, ArtifactSubject>(StringComparer.Ordinal),
|
||||
};
|
||||
|
||||
_executionContext = new Mock<IExecutionContext>();
|
||||
_executionContext.Setup(x => x.Global).Returns(_global);
|
||||
_executionContext.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Callback((string tag, string message) =>
|
||||
{
|
||||
_trace.Info($"{tag}{message}");
|
||||
});
|
||||
|
||||
_command = new ArtifactsListFileCommand();
|
||||
_command.Initialize(hostContext);
|
||||
|
||||
return hostContext;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,702 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
using GitHub.DistributedTask.Expressions2;
|
||||
using GitHub.DistributedTask.Pipelines.ContextData;
|
||||
using GitHub.DistributedTask.ObjectTemplating.Tokens;
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using GitHub.Runner.Worker;
|
||||
using GitHub.Runner.Worker.Dap;
|
||||
using Pipelines = GitHub.DistributedTask.Pipelines;
|
||||
|
||||
namespace GitHub.Runner.Common.Tests.Worker
|
||||
{
|
||||
public sealed class BackgroundStepsL0
|
||||
{
|
||||
private Mock<IExecutionContext> _ec;
|
||||
private StepsRunner _stepsRunner;
|
||||
private Variables _variables;
|
||||
private Dictionary<string, string> _env;
|
||||
private DictionaryContextData _contexts;
|
||||
private JobContext _jobContext;
|
||||
private StepsContext _stepContext;
|
||||
|
||||
private TestHostContext CreateTestContext([CallerMemberName] String testName = "")
|
||||
{
|
||||
var hc = new TestHostContext(this, testName);
|
||||
Dictionary<string, VariableValue> variablesToCopy = new();
|
||||
_variables = new Variables(
|
||||
hostContext: hc,
|
||||
copy: variablesToCopy);
|
||||
_env = new Dictionary<string, string>()
|
||||
{
|
||||
{"env1", "1"},
|
||||
{"test", "github_actions"}
|
||||
};
|
||||
_ec = new Mock<IExecutionContext>();
|
||||
_ec.SetupAllProperties();
|
||||
_ec.Setup(x => x.Global).Returns(new GlobalContext { WriteDebug = true });
|
||||
_ec.Object.Global.Variables = _variables;
|
||||
_ec.Object.Global.EnvironmentVariables = _env;
|
||||
_ec.Object.Global.FileTable = new List<string>();
|
||||
|
||||
_contexts = new DictionaryContextData();
|
||||
_jobContext = new JobContext();
|
||||
_contexts["github"] = new GitHubContext();
|
||||
_contexts["runner"] = new DictionaryContextData();
|
||||
_contexts["job"] = _jobContext;
|
||||
_ec.Setup(x => x.ExpressionValues).Returns(_contexts);
|
||||
_ec.Setup(x => x.ExpressionFunctions).Returns(new List<IFunctionInfo>());
|
||||
_ec.Setup(x => x.JobContext).Returns(_jobContext);
|
||||
_ec.Setup(x => x.CancellationToken).Returns(CancellationToken.None);
|
||||
|
||||
_stepContext = new StepsContext();
|
||||
_ec.Object.Global.StepsContext = _stepContext;
|
||||
|
||||
_ec.Setup(x => x.PostJobSteps).Returns(new Stack<IStep>());
|
||||
|
||||
var trace = hc.GetTrace();
|
||||
|
||||
// Mock CreateChild for implicit wait-all step injection
|
||||
_ec.Setup(x => x.CreateChild(
|
||||
It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ActionRunStage>(),
|
||||
It.IsAny<Dictionary<string, string>>(), It.IsAny<int?>(), It.IsAny<IPagingLogger>(),
|
||||
It.IsAny<bool>(), It.IsAny<List<Issue>>(), It.IsAny<CancellationTokenSource>(),
|
||||
It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<TimeSpan?>(),
|
||||
It.IsAny<bool>(), It.IsAny<string>(), It.IsAny<string[]>(), It.IsAny<string>()))
|
||||
.Returns((Guid recordId, string displayName, string refName, string scopeName, string contextName,
|
||||
ActionRunStage stage, Dictionary<string, string> intraActionState, int? recordOrder, IPagingLogger logger,
|
||||
bool isEmbedded, List<Issue> issues, CancellationTokenSource cts, Guid embeddedId, string siblingScopeName, TimeSpan? timeout,
|
||||
bool isBackground, string backgroundControlType, string[] backgroundControlStepIds, string parallelGroupId) =>
|
||||
{
|
||||
var childEc = new Mock<IExecutionContext>();
|
||||
childEc.SetupAllProperties();
|
||||
childEc.Setup(x => x.Global).Returns(() => _ec.Object.Global);
|
||||
childEc.Setup(x => x.ExpressionValues).Returns(new DictionaryContextData());
|
||||
childEc.Setup(x => x.ExpressionFunctions).Returns(new List<IFunctionInfo>());
|
||||
childEc.Setup(x => x.ContextName).Returns(contextName);
|
||||
childEc.Setup(x => x.CancellationToken).Returns(CancellationToken.None);
|
||||
childEc.Setup(x => x.Complete(It.IsAny<TaskResult?>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Callback((TaskResult? r, string currentOperation, string resultCode) =>
|
||||
{
|
||||
if (r != null) childEc.Object.Result = r;
|
||||
});
|
||||
childEc.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>())).Callback((string tag, string message) => { trace.Info($"[{tag}]{message}"); });
|
||||
return childEc.Object;
|
||||
});
|
||||
|
||||
_ec.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>())).Callback((string tag, string message) => { trace.Info($"[{tag}]{message}"); });
|
||||
|
||||
_stepsRunner = new StepsRunner();
|
||||
_stepsRunner.Initialize(hc);
|
||||
|
||||
var bgCoordinator = new BackgroundStepCoordinator();
|
||||
bgCoordinator.Initialize(hc);
|
||||
hc.SetSingleton<IBackgroundStepCoordinator>(bgCoordinator);
|
||||
|
||||
var mockDapDebugger = new Mock<IDapDebugger>();
|
||||
hc.SetSingleton(mockDapDebugger.Object);
|
||||
|
||||
return hc;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task BackgroundStepRunsConcurrentlyWithForeground()
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
// Arrange: background step that takes time, followed by a foreground step
|
||||
var executionOrder = new List<string>();
|
||||
|
||||
var bgStep = CreateStep(hc, TaskResult.Succeeded, "success()", name: "bg-step", contextName: "bg", isBackground: true);
|
||||
bgStep.Setup(x => x.RunAsync()).Returns(async () =>
|
||||
{
|
||||
executionOrder.Add("bg-start");
|
||||
await Task.Delay(2000);
|
||||
executionOrder.Add("bg-end");
|
||||
});
|
||||
bgStep.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
|
||||
{
|
||||
Name = "bg-step",
|
||||
Id = Guid.NewGuid(),
|
||||
ContextName = "bg",
|
||||
Background = true,
|
||||
});
|
||||
|
||||
var fgStep = CreateStep(hc, TaskResult.Succeeded, "success()", name: "fg-step", contextName: "fg");
|
||||
fgStep.Setup(x => x.RunAsync()).Returns(() =>
|
||||
{
|
||||
executionOrder.Add("fg-run");
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
var waitAllStep = CreateWaitAllStep(hc);
|
||||
|
||||
_ec.Object.Result = null;
|
||||
_ec.Setup(x => x.JobSteps).Returns(new Queue<IStep>(new IStep[]
|
||||
{
|
||||
bgStep.Object, fgStep.Object, waitAllStep
|
||||
}));
|
||||
|
||||
// Act
|
||||
await _stepsRunner.RunAsync(jobContext: _ec.Object);
|
||||
|
||||
// Assert: foreground step should start before background step finishes
|
||||
Assert.Contains("bg-start", executionOrder);
|
||||
Assert.Contains("fg-run", executionOrder);
|
||||
Assert.Contains("bg-end", executionOrder);
|
||||
var fgIndex = executionOrder.IndexOf("fg-run");
|
||||
var bgEndIndex = executionOrder.IndexOf("bg-end");
|
||||
Assert.True(fgIndex < bgEndIndex, "Foreground step should run before background step completes");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task WaitStepBlocksUntilBackgroundCompletes()
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
// Arrange
|
||||
var bgCompleted = false;
|
||||
|
||||
var bgStep = CreateStep(hc, TaskResult.Succeeded, "success()", name: "db", contextName: "db", isBackground: true);
|
||||
bgStep.Setup(x => x.RunAsync()).Returns(async () =>
|
||||
{
|
||||
await Task.Delay(100);
|
||||
bgCompleted = true;
|
||||
});
|
||||
bgStep.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
|
||||
{
|
||||
Name = "db",
|
||||
Id = Guid.NewGuid(),
|
||||
ContextName = "db",
|
||||
Background = true,
|
||||
});
|
||||
|
||||
var waitStep = CreateWaitStep(hc, new[] { "db" });
|
||||
|
||||
_ec.Object.Result = null;
|
||||
_ec.Setup(x => x.JobSteps).Returns(new Queue<IStep>(new IStep[]
|
||||
{
|
||||
bgStep.Object, waitStep
|
||||
}));
|
||||
|
||||
// Act
|
||||
await _stepsRunner.RunAsync(jobContext: _ec.Object);
|
||||
|
||||
// Assert: background step must have completed after wait
|
||||
Assert.True(bgCompleted, "Background step should have completed after wait");
|
||||
Assert.Equal(TaskResult.Succeeded, _ec.Object.Result ?? TaskResult.Succeeded);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task BackgroundStepFailurePropagatesAtWait()
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
// Arrange: background step that fails
|
||||
var bgStep = CreateStep(hc, TaskResult.Failed, "success()", name: "flaky", contextName: "flaky", isBackground: true);
|
||||
bgStep.Setup(x => x.RunAsync()).Returns(() =>
|
||||
{
|
||||
throw new Exception("Service crashed");
|
||||
});
|
||||
bgStep.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
|
||||
{
|
||||
Name = "flaky",
|
||||
Id = Guid.NewGuid(),
|
||||
ContextName = "flaky",
|
||||
Background = true,
|
||||
});
|
||||
|
||||
var waitStep = CreateWaitStep(hc, new[] { "flaky" });
|
||||
|
||||
_ec.Object.Result = null;
|
||||
_ec.Setup(x => x.JobSteps).Returns(new Queue<IStep>(new IStep[]
|
||||
{
|
||||
bgStep.Object, waitStep
|
||||
}));
|
||||
|
||||
// Act
|
||||
await _stepsRunner.RunAsync(jobContext: _ec.Object);
|
||||
|
||||
// Assert: job should fail because background step failed
|
||||
Assert.Equal(TaskResult.Failed, _ec.Object.Result);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task CancelStepTerminatesBackgroundStep()
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
// Arrange: background step that runs until cancelled via ExecutionContext.CancellationToken
|
||||
var stepCts = new CancellationTokenSource();
|
||||
|
||||
var bgStep = CreateStep(hc, TaskResult.Succeeded, "success()", name: "server", contextName: "server");
|
||||
// Wire CancellationToken to our CTS so the cancel path can trigger it
|
||||
var bgStepContext = Mock.Get(bgStep.Object.ExecutionContext);
|
||||
bgStepContext.Setup(x => x.CancellationToken).Returns(stepCts.Token);
|
||||
bgStepContext.Setup(x => x.CancelToken()).Callback(() => stepCts.Cancel());
|
||||
bgStep.Setup(x => x.RunAsync()).Returns(async () =>
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), stepCts.Token);
|
||||
});
|
||||
bgStep.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
|
||||
{
|
||||
Name = "server",
|
||||
Id = Guid.NewGuid(),
|
||||
ContextName = "server",
|
||||
Background = true,
|
||||
});
|
||||
|
||||
var cancelStep = CreateCancelStep(hc, "server");
|
||||
|
||||
_ec.Object.Result = null;
|
||||
_ec.Setup(x => x.JobSteps).Returns(new Queue<IStep>(new IStep[]
|
||||
{
|
||||
bgStep.Object, cancelStep
|
||||
}));
|
||||
|
||||
// Act
|
||||
await _stepsRunner.RunAsync(jobContext: _ec.Object);
|
||||
|
||||
// Assert: background step should have been cancelled
|
||||
// Note: the cancel mechanism uses the BackgroundStepContext.Cts, not bgCts
|
||||
// so wasCancelled may not be true in this mock, but the step should complete
|
||||
Assert.Equal(TaskResult.Succeeded, _ec.Object.Result ?? TaskResult.Succeeded);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task WaitAllWaitsForAllBackgroundSteps()
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
// Arrange: two background steps
|
||||
var step1Done = false;
|
||||
var step2Done = false;
|
||||
|
||||
var bgStep1 = CreateStep(hc, TaskResult.Succeeded, "success()", name: "svc1", contextName: "svc1", isBackground: true);
|
||||
bgStep1.Setup(x => x.RunAsync()).Returns(async () =>
|
||||
{
|
||||
await Task.Delay(50);
|
||||
step1Done = true;
|
||||
});
|
||||
bgStep1.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
|
||||
{
|
||||
Name = "svc1",
|
||||
Id = Guid.NewGuid(),
|
||||
ContextName = "svc1",
|
||||
Background = true,
|
||||
});
|
||||
|
||||
var bgStep2 = CreateStep(hc, TaskResult.Succeeded, "success()", name: "svc2", contextName: "svc2", isBackground: true);
|
||||
bgStep2.Setup(x => x.RunAsync()).Returns(async () =>
|
||||
{
|
||||
await Task.Delay(100);
|
||||
step2Done = true;
|
||||
});
|
||||
bgStep2.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
|
||||
{
|
||||
Name = "svc2",
|
||||
Id = Guid.NewGuid(),
|
||||
ContextName = "svc2",
|
||||
Background = true,
|
||||
});
|
||||
|
||||
var waitAllStep = CreateWaitAllStep(hc);
|
||||
|
||||
_ec.Object.Result = null;
|
||||
_ec.Setup(x => x.JobSteps).Returns(new Queue<IStep>(new IStep[]
|
||||
{
|
||||
bgStep1.Object, bgStep2.Object, waitAllStep
|
||||
}));
|
||||
|
||||
// Act
|
||||
await _stepsRunner.RunAsync(jobContext: _ec.Object);
|
||||
|
||||
// Assert
|
||||
Assert.True(step1Done, "Background step 1 should have completed");
|
||||
Assert.True(step2Done, "Background step 2 should have completed");
|
||||
Assert.Equal(TaskResult.Succeeded, _ec.Object.Result ?? TaskResult.Succeeded);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task CancelStepPublishesCanceledBackgroundExternalId()
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
var bgStep = CreateStep(hc, TaskResult.Succeeded, "success()", name: "server", contextName: "server", isBackground: true);
|
||||
bgStep.Setup(x => x.RunAsync()).Returns(Task.CompletedTask);
|
||||
bgStep.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
|
||||
{
|
||||
Name = "server",
|
||||
Id = Guid.NewGuid(),
|
||||
ContextName = "server",
|
||||
Background = true,
|
||||
});
|
||||
|
||||
var cancelStep = CreateCancelStep(hc, "server");
|
||||
|
||||
_ec.Object.Result = null;
|
||||
_ec.Setup(x => x.JobSteps).Returns(new Queue<IStep>(new IStep[]
|
||||
{
|
||||
bgStep.Object, cancelStep
|
||||
}));
|
||||
|
||||
await _stepsRunner.RunAsync(jobContext: _ec.Object);
|
||||
|
||||
// Assert: cancel step completed without error
|
||||
Assert.Equal(TaskResult.Succeeded, _ec.Object.Result ?? TaskResult.Succeeded);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task CanceledBackgroundStepDoesNotAffectJobResult()
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
// Arrange: a background step that runs until explicitly canceled. When canceled it
|
||||
// reports TaskResult.Canceled, but since the cancellation is expected (driven by a
|
||||
// cancel control step), it must not impact the overall job result.
|
||||
using var stepCts = new CancellationTokenSource();
|
||||
|
||||
var bgStep = CreateStep(hc, TaskResult.Succeeded, "success()", name: "server", contextName: "server", isBackground: true);
|
||||
var bgStepContext = Mock.Get(bgStep.Object.ExecutionContext);
|
||||
bgStepContext.Setup(x => x.CancellationToken).Returns(stepCts.Token);
|
||||
bgStepContext.Setup(x => x.CancelToken()).Callback(() => stepCts.Cancel());
|
||||
bgStep.Setup(x => x.RunAsync()).Returns(async () =>
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(2), stepCts.Token);
|
||||
});
|
||||
bgStep.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
|
||||
{
|
||||
Name = "server",
|
||||
Id = Guid.NewGuid(),
|
||||
ContextName = "server",
|
||||
Background = true,
|
||||
});
|
||||
|
||||
var cancelStep = CreateCancelStep(hc, "server");
|
||||
|
||||
_ec.Object.Result = null;
|
||||
_ec.Setup(x => x.JobSteps).Returns(new Queue<IStep>(new IStep[]
|
||||
{
|
||||
bgStep.Object, cancelStep
|
||||
}));
|
||||
|
||||
// Act
|
||||
await _stepsRunner.RunAsync(jobContext: _ec.Object);
|
||||
|
||||
// Assert: the canceled background step reported Canceled, but the job result is unaffected.
|
||||
Assert.Equal(TaskResult.Canceled, bgStep.Object.ExecutionContext.Result);
|
||||
Assert.Equal(TaskResult.Succeeded, _ec.Object.Result ?? TaskResult.Succeeded);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task FailedBackgroundStepTargetedByCancelStillAffectsJobResult()
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
// Arrange: a background step that fails (e.g. before the cancel takes effect). Even
|
||||
// though a cancel control step targets it, its Failed result must still propagate to
|
||||
// the overall job result.
|
||||
var bgStep = CreateStep(hc, TaskResult.Failed, "success()", name: "server", contextName: "server", isBackground: true);
|
||||
bgStep.Setup(x => x.RunAsync()).Returns(Task.CompletedTask);
|
||||
bgStep.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
|
||||
{
|
||||
Name = "server",
|
||||
Id = Guid.NewGuid(),
|
||||
ContextName = "server",
|
||||
Background = true,
|
||||
});
|
||||
|
||||
var cancelStep = CreateCancelStep(hc, "server");
|
||||
|
||||
_ec.Object.Result = null;
|
||||
_ec.Setup(x => x.JobSteps).Returns(new Queue<IStep>(new IStep[]
|
||||
{
|
||||
bgStep.Object, cancelStep
|
||||
}));
|
||||
|
||||
// Act
|
||||
await _stepsRunner.RunAsync(jobContext: _ec.Object);
|
||||
|
||||
// Assert: the background step failed, so the job result reflects that failure.
|
||||
Assert.Equal(TaskResult.Failed, bgStep.Object.ExecutionContext.Result);
|
||||
Assert.Equal(TaskResult.Failed, _ec.Object.Result);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task StepsContextThreadSafety()
|
||||
{
|
||||
// Test that concurrent SetOutput/SetConclusion doesn't throw
|
||||
var stepsContext = new StepsContext();
|
||||
var tasks = new List<Task>();
|
||||
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
var index = i;
|
||||
tasks.Add(Task.Run(() =>
|
||||
{
|
||||
stepsContext.SetOutput("", $"step{index}", "out", $"value{index}", out _);
|
||||
stepsContext.SetConclusion("", $"step{index}", ActionResult.Success);
|
||||
stepsContext.SetOutcome("", $"step{index}", ActionResult.Success);
|
||||
}));
|
||||
}
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
// Assert: all 100 steps should have their data set
|
||||
var scope = stepsContext.GetScope("");
|
||||
Assert.Equal(100, scope.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task ControlFlowStepsRunEvenAfterFailure()
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
// Arrange: a background step, a foreground step that fails, then a wait step
|
||||
var bgStep = CreateStep(hc, TaskResult.Succeeded, "success()", name: "bg", contextName: "bg", isBackground: true);
|
||||
bgStep.Setup(x => x.RunAsync()).Returns(Task.CompletedTask);
|
||||
bgStep.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
|
||||
{
|
||||
Name = "bg",
|
||||
Id = Guid.NewGuid(),
|
||||
ContextName = "bg",
|
||||
Background = true,
|
||||
});
|
||||
|
||||
var failStep = CreateStep(hc, TaskResult.Failed, "success()", name: "fail", contextName: "fail");
|
||||
|
||||
// Wait step uses always() condition — should run even after failure
|
||||
var waitStep = CreateWaitStep(hc, new[] { "bg" });
|
||||
waitStep.Condition = $"{GitHub.DistributedTask.Pipelines.ObjectTemplating.PipelineTemplateConstants.Always}()";
|
||||
|
||||
_ec.Object.Result = null;
|
||||
_ec.Setup(x => x.JobSteps).Returns(new Queue<IStep>(new IStep[]
|
||||
{
|
||||
bgStep.Object, failStep.Object, waitStep
|
||||
}));
|
||||
|
||||
// Act
|
||||
await _stepsRunner.RunAsync(jobContext: _ec.Object);
|
||||
|
||||
// Assert: wait step should have run (not skipped) because it has always() condition
|
||||
Assert.NotNull(waitStep.ExecutionContext.Result);
|
||||
Assert.NotEqual(TaskResult.Skipped, waitStep.ExecutionContext.Result);
|
||||
}
|
||||
}
|
||||
|
||||
#region Helpers
|
||||
|
||||
private Mock<IActionRunner> CreateStep(TestHostContext hc, TaskResult result, string condition, string name = "Test", string contextName = null, Guid? recordId = null, bool isBackground = false)
|
||||
{
|
||||
var stepRecordId = recordId ?? Guid.NewGuid();
|
||||
var step = new Mock<IActionRunner>();
|
||||
step.Setup(x => x.Condition).Returns(condition);
|
||||
step.Setup(x => x.ContinueOnError).Returns(new BooleanToken(null, null, null, false));
|
||||
step.Setup(x => x.Stage).Returns(ActionRunStage.Main);
|
||||
step.Setup(x => x.Action)
|
||||
.Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
|
||||
{
|
||||
Name = name,
|
||||
Id = stepRecordId,
|
||||
ContextName = contextName ?? name,
|
||||
});
|
||||
|
||||
var stepContext = new Mock<IExecutionContext>();
|
||||
stepContext.SetupAllProperties();
|
||||
stepContext.Setup(x => x.Global).Returns(() => _ec.Object.Global);
|
||||
stepContext.Setup(x => x.IsBackground).Returns(isBackground);
|
||||
var expressionValues = new DictionaryContextData();
|
||||
foreach (var pair in _ec.Object.ExpressionValues)
|
||||
{
|
||||
expressionValues[pair.Key] = pair.Value;
|
||||
}
|
||||
stepContext.Setup(x => x.ExpressionValues).Returns(expressionValues);
|
||||
stepContext.Setup(x => x.ExpressionFunctions).Returns(new List<IFunctionInfo>());
|
||||
stepContext.Setup(x => x.JobContext).Returns(_jobContext);
|
||||
stepContext.Setup(x => x.Id).Returns(stepRecordId);
|
||||
stepContext.Setup(x => x.ContextName).Returns(step.Object.Action.ContextName);
|
||||
stepContext.Setup(x => x.CancellationToken).Returns(CancellationToken.None);
|
||||
stepContext.Setup(x => x.Complete(It.IsAny<TaskResult?>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Callback((TaskResult? r, string currentOperation, string resultCode) =>
|
||||
{
|
||||
if (r != null)
|
||||
{
|
||||
stepContext.Object.Result = r;
|
||||
}
|
||||
_stepContext.SetOutcome("", stepContext.Object.ContextName, (stepContext.Object.Outcome ?? stepContext.Object.Result ?? TaskResult.Succeeded).ToActionResult());
|
||||
_stepContext.SetConclusion("", stepContext.Object.ContextName, (stepContext.Object.Result ?? TaskResult.Succeeded).ToActionResult());
|
||||
});
|
||||
stepContext.Setup(x => x.StepEnvironmentOverrides).Returns(new List<string>());
|
||||
stepContext.Setup(x => x.ApplyContinueOnError(It.IsAny<TemplateToken>()));
|
||||
stepContext.Setup(x => x.FlushDeferredOutputs()).Callback(() =>
|
||||
{
|
||||
if (stepContext.Object.DeferredOutputs != null)
|
||||
{
|
||||
foreach (var kvp in stepContext.Object.DeferredOutputs)
|
||||
{
|
||||
_stepContext.SetOutput("", stepContext.Object.ContextName, kvp.Key, kvp.Value, out _);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var trace = hc.GetTrace();
|
||||
stepContext.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>())).Callback((string tag, string message) => { trace.Info($"[{tag}]{message}"); });
|
||||
stepContext.Object.Result = result;
|
||||
step.Setup(x => x.ExecutionContext).Returns(stepContext.Object);
|
||||
step.Setup(x => x.RunAsync()).Returns(Task.CompletedTask);
|
||||
|
||||
return step;
|
||||
}
|
||||
|
||||
private JobExtensionRunner CreateWaitStep(TestHostContext hc, string[] stepIds, Dictionary<string, string> timelineVariables = null)
|
||||
{
|
||||
var waitData = new BackgroundStepControlFlowData
|
||||
{
|
||||
Type = Pipelines.BackgroundControlTypes.Wait,
|
||||
StepIds = stepIds,
|
||||
};
|
||||
var bgCoordinator = hc.GetService<IBackgroundStepCoordinator>();
|
||||
var waitRunner = new JobExtensionRunner(
|
||||
runAsync: bgCoordinator.RunControlFlowAsync,
|
||||
condition: "success()",
|
||||
displayName: "Wait",
|
||||
data: waitData);
|
||||
|
||||
var stepContext = new Mock<IExecutionContext>();
|
||||
stepContext.SetupAllProperties();
|
||||
stepContext.Setup(x => x.Global).Returns(() => _ec.Object.Global);
|
||||
var waitExprValues = new DictionaryContextData();
|
||||
foreach (var pair in _ec.Object.ExpressionValues) { waitExprValues[pair.Key] = pair.Value; }
|
||||
stepContext.Setup(x => x.ExpressionValues).Returns(waitExprValues);
|
||||
stepContext.Setup(x => x.ExpressionFunctions).Returns(new List<IFunctionInfo>());
|
||||
stepContext.Setup(x => x.ContextName).Returns("__wait");
|
||||
stepContext.Setup(x => x.JobContext).Returns(_jobContext);
|
||||
stepContext.Setup(x => x.ScopeName).Returns((string)null);
|
||||
stepContext.Setup(x => x.CancellationToken).Returns(CancellationToken.None);
|
||||
stepContext.Setup(x => x.StepEnvironmentOverrides).Returns(new List<string>());
|
||||
stepContext.Setup(x => x.Complete(It.IsAny<TaskResult?>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Callback((TaskResult? r, string currentOperation, string resultCode) =>
|
||||
{
|
||||
if (r != null) stepContext.Object.Result = r;
|
||||
});
|
||||
var trace = hc.GetTrace();
|
||||
stepContext.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>())).Callback((string tag, string message) => { trace.Info($"[{tag}]{message}"); });
|
||||
|
||||
waitRunner.ExecutionContext = stepContext.Object;
|
||||
return waitRunner;
|
||||
}
|
||||
|
||||
private JobExtensionRunner CreateWaitAllStep(TestHostContext hc, Dictionary<string, string> timelineVariables = null)
|
||||
{
|
||||
var waitAllData = new BackgroundStepControlFlowData
|
||||
{
|
||||
Type = Pipelines.BackgroundControlTypes.WaitAll,
|
||||
};
|
||||
var bgCoordinator2 = hc.GetService<IBackgroundStepCoordinator>();
|
||||
var waitAllRunner = new JobExtensionRunner(
|
||||
runAsync: bgCoordinator2.RunControlFlowAsync,
|
||||
condition: "success()",
|
||||
displayName: "Wait All",
|
||||
data: waitAllData);
|
||||
|
||||
var stepContext = new Mock<IExecutionContext>();
|
||||
stepContext.SetupAllProperties();
|
||||
stepContext.Setup(x => x.Global).Returns(() => _ec.Object.Global);
|
||||
var waitAllExprValues = new DictionaryContextData();
|
||||
foreach (var pair in _ec.Object.ExpressionValues) { waitAllExprValues[pair.Key] = pair.Value; }
|
||||
stepContext.Setup(x => x.ExpressionValues).Returns(waitAllExprValues);
|
||||
stepContext.Setup(x => x.ExpressionFunctions).Returns(new List<IFunctionInfo>());
|
||||
stepContext.Setup(x => x.ContextName).Returns("__wait-all");
|
||||
stepContext.Setup(x => x.JobContext).Returns(_jobContext);
|
||||
stepContext.Setup(x => x.ScopeName).Returns((string)null);
|
||||
stepContext.Setup(x => x.CancellationToken).Returns(CancellationToken.None);
|
||||
stepContext.Setup(x => x.StepEnvironmentOverrides).Returns(new List<string>());
|
||||
stepContext.Setup(x => x.Complete(It.IsAny<TaskResult?>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Callback((TaskResult? r, string currentOperation, string resultCode) =>
|
||||
{
|
||||
if (r != null) stepContext.Object.Result = r;
|
||||
});
|
||||
var trace = hc.GetTrace();
|
||||
stepContext.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>())).Callback((string tag, string message) => { trace.Info($"[{tag}]{message}"); });
|
||||
|
||||
waitAllRunner.ExecutionContext = stepContext.Object;
|
||||
return waitAllRunner;
|
||||
}
|
||||
|
||||
private JobExtensionRunner CreateCancelStep(TestHostContext hc, string cancelStepId, Dictionary<string, string> timelineVariables = null)
|
||||
{
|
||||
var cancelData = new BackgroundStepControlFlowData
|
||||
{
|
||||
Type = Pipelines.BackgroundControlTypes.Cancel,
|
||||
StepIds = new[] { cancelStepId },
|
||||
};
|
||||
var bgCoordinator3 = hc.GetService<IBackgroundStepCoordinator>();
|
||||
var cancelRunner = new JobExtensionRunner(
|
||||
runAsync: bgCoordinator3.RunControlFlowAsync,
|
||||
condition: "success()",
|
||||
displayName: "Cancel",
|
||||
data: cancelData);
|
||||
|
||||
var stepContext = new Mock<IExecutionContext>();
|
||||
stepContext.SetupAllProperties();
|
||||
stepContext.Setup(x => x.Global).Returns(() => _ec.Object.Global);
|
||||
var cancelExprValues = new DictionaryContextData();
|
||||
foreach (var pair in _ec.Object.ExpressionValues) { cancelExprValues[pair.Key] = pair.Value; }
|
||||
stepContext.Setup(x => x.ExpressionValues).Returns(cancelExprValues);
|
||||
stepContext.Setup(x => x.ExpressionFunctions).Returns(new List<IFunctionInfo>());
|
||||
stepContext.Setup(x => x.ContextName).Returns("__cancel");
|
||||
stepContext.Setup(x => x.JobContext).Returns(_jobContext);
|
||||
stepContext.Setup(x => x.ScopeName).Returns((string)null);
|
||||
stepContext.Setup(x => x.CancellationToken).Returns(CancellationToken.None);
|
||||
stepContext.Setup(x => x.StepEnvironmentOverrides).Returns(new List<string>());
|
||||
stepContext.Setup(x => x.Complete(It.IsAny<TaskResult?>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Callback((TaskResult? r, string currentOperation, string resultCode) =>
|
||||
{
|
||||
if (r != null) stepContext.Object.Result = r;
|
||||
});
|
||||
var trace = hc.GetTrace();
|
||||
stepContext.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>())).Callback((string tag, string message) => { trace.Info($"[{tag}]{message}"); });
|
||||
|
||||
cancelRunner.ExecutionContext = stepContext.Object;
|
||||
return cancelRunner;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -141,8 +141,6 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
|
||||
private void Setup([CallerMemberName] string testName = "")
|
||||
{
|
||||
// Ensure no leaked container hooks path from other tests so the docker manager is initialized.
|
||||
Environment.SetEnvironmentVariable(Constants.Hooks.ContainerHooksPath, null);
|
||||
containers.Add(new ContainerInfo() { ContainerImage = "ubuntu:16.04" });
|
||||
_hc = new TestHostContext(this, testName);
|
||||
_ec = new Mock<IExecutionContext>();
|
||||
|
||||
@@ -1,627 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using GitHub.Runner.Sdk;
|
||||
using GitHub.Runner.Worker;
|
||||
using GitHub.Runner.Worker.Container;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
using DTWebApi = GitHub.DistributedTask.WebApi;
|
||||
|
||||
namespace GitHub.Runner.Common.Tests.Worker
|
||||
{
|
||||
public sealed class CreateArtifactsFileCommandL0
|
||||
{
|
||||
private const string FlagOn = "true";
|
||||
|
||||
private Mock<IExecutionContext> _executionContext;
|
||||
private List<DTWebApi.Issue> _issues;
|
||||
private string _rootDirectory;
|
||||
private string _workspaceDirectory;
|
||||
private CreateArtifactsFileCommand _command;
|
||||
private GlobalContext _global;
|
||||
private ITraceWriter _trace;
|
||||
|
||||
// ---------- Feature flag ----------
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void FeatureFlagOff_NoOp()
|
||||
{
|
||||
using (var hostContext = Setup(featureFlag: false))
|
||||
{
|
||||
var artifactsFile = WriteArtifactsFile("ghcr.io/octocat/myapp:1.0@sha256:" + new string('a', 64));
|
||||
_command.ProcessCommand(_executionContext.Object, artifactsFile, null);
|
||||
Assert.Empty(_global.ArtifactSubjects);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EnvVarOverride_EnablesFeature()
|
||||
{
|
||||
using (var hostContext = Setup(featureFlag: false, envVarOverride: "true"))
|
||||
{
|
||||
var hex = new string('a', 64);
|
||||
var artifactsFile = WriteArtifactsFile($"ghcr.io/octocat/myapp:1.0@sha256:{hex}");
|
||||
_command.ProcessCommand(_executionContext.Object, artifactsFile, null);
|
||||
Assert.Single(_global.ArtifactSubjects);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EnvVarFalse_DoesNotEnable()
|
||||
{
|
||||
using (var hostContext = Setup(featureFlag: false, envVarOverride: "false"))
|
||||
{
|
||||
var artifactsFile = WriteArtifactsFile("ghcr.io/x@sha256:" + new string('a', 64));
|
||||
_command.ProcessCommand(_executionContext.Object, artifactsFile, null);
|
||||
Assert.Empty(_global.ArtifactSubjects);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Trivial cases ----------
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void FileMissing_NoOp()
|
||||
{
|
||||
using (var hostContext = Setup())
|
||||
{
|
||||
var artifactsFile = Path.Combine(_rootDirectory, "does-not-exist");
|
||||
_command.ProcessCommand(_executionContext.Object, artifactsFile, null);
|
||||
Assert.Empty(_global.ArtifactSubjects);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EmptyFile_NoOp()
|
||||
{
|
||||
using (var hostContext = Setup())
|
||||
{
|
||||
var artifactsFile = WriteArtifactsFile(string.Empty);
|
||||
_command.ProcessCommand(_executionContext.Object, artifactsFile, null);
|
||||
Assert.Empty(_global.ArtifactSubjects);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void BlankAndCommentLines_Skipped()
|
||||
{
|
||||
using (var hostContext = Setup())
|
||||
{
|
||||
var artifactsFile = WriteArtifactsFile(
|
||||
"",
|
||||
"# this is a comment",
|
||||
" # leading-whitespace comment",
|
||||
"",
|
||||
" ");
|
||||
_command.ProcessCommand(_executionContext.Object, artifactsFile, null);
|
||||
Assert.Empty(_global.ArtifactSubjects);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- OCI subjects ----------
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void OciSubject_Sha256_HappyPath()
|
||||
{
|
||||
using (var hostContext = Setup())
|
||||
{
|
||||
var hex = new string('a', 64);
|
||||
var artifactsFile = WriteArtifactsFile($"ghcr.io/octocat/myapp:1.0.0@sha256:{hex}");
|
||||
_command.ProcessCommand(_executionContext.Object, artifactsFile, null);
|
||||
Assert.Single(_global.ArtifactSubjects);
|
||||
var subject = _global.ArtifactSubjects["ghcr.io/octocat/myapp:1.0.0"];
|
||||
Assert.Equal($"sha256:{hex}", subject.Digest);
|
||||
Assert.Equal(ArtifactSubjectKind.OciSubject, subject.Kind);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void OciSubject_Sha384_HappyPath()
|
||||
{
|
||||
using (var hostContext = Setup())
|
||||
{
|
||||
var hex = new string('b', 96);
|
||||
var artifactsFile = WriteArtifactsFile($"ghcr.io/x/y@sha384:{hex}");
|
||||
_command.ProcessCommand(_executionContext.Object, artifactsFile, null);
|
||||
Assert.Single(_global.ArtifactSubjects);
|
||||
Assert.Equal($"sha384:{hex}", _global.ArtifactSubjects["ghcr.io/x/y"].Digest);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void OciSubject_Sha512_HappyPath()
|
||||
{
|
||||
using (var hostContext = Setup())
|
||||
{
|
||||
var hex = new string('c', 128);
|
||||
var artifactsFile = WriteArtifactsFile($"ghcr.io/x/y@sha512:{hex}");
|
||||
_command.ProcessCommand(_executionContext.Object, artifactsFile, null);
|
||||
Assert.Single(_global.ArtifactSubjects);
|
||||
Assert.Equal($"sha512:{hex}", _global.ArtifactSubjects["ghcr.io/x/y"].Digest);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void OciSubject_DigestLowercased()
|
||||
{
|
||||
using (var hostContext = Setup())
|
||||
{
|
||||
var hex = new string('A', 64);
|
||||
var artifactsFile = WriteArtifactsFile($"ghcr.io/x@sha256:{hex}");
|
||||
_command.ProcessCommand(_executionContext.Object, artifactsFile, null);
|
||||
Assert.Equal($"sha256:{hex.ToLowerInvariant()}", _global.ArtifactSubjects["ghcr.io/x"].Digest);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void OciSubject_PreservesTagAndRegistryPort()
|
||||
{
|
||||
using (var hostContext = Setup())
|
||||
{
|
||||
var hex = new string('d', 64);
|
||||
var artifactsFile = WriteArtifactsFile($"localhost:5000/repo/img:v1@sha256:{hex}");
|
||||
_command.ProcessCommand(_executionContext.Object, artifactsFile, null);
|
||||
Assert.Single(_global.ArtifactSubjects);
|
||||
Assert.True(_global.ArtifactSubjects.ContainsKey("localhost:5000/repo/img:v1"));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void OciSubject_WrongHexLength_Throws()
|
||||
{
|
||||
using (var hostContext = Setup())
|
||||
{
|
||||
// 63 hex chars instead of 64 → falls back to file path parse → file missing → throws
|
||||
var artifactsFile = WriteArtifactsFile("ghcr.io/x@sha256:" + new string('a', 63));
|
||||
var ex = Assert.Throws<Exception>(() => _command.ProcessCommand(_executionContext.Object, artifactsFile, null));
|
||||
Assert.Contains("line 1", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void OciSubject_NonHexChars_TreatedAsFile_Throws()
|
||||
{
|
||||
using (var hostContext = Setup())
|
||||
{
|
||||
// Non-hex character: digest regex doesn't match → treated as file path → file missing
|
||||
var artifactsFile = WriteArtifactsFile("ghcr.io/x@sha256:" + new string('z', 64));
|
||||
var ex = Assert.Throws<Exception>(() => _command.ProcessCommand(_executionContext.Object, artifactsFile, null));
|
||||
Assert.Contains("line 1", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void OciScheme_RejectsWhenDigestMissing()
|
||||
{
|
||||
using (var hostContext = Setup())
|
||||
{
|
||||
var artifactsFile = WriteArtifactsFile("oci://ghcr.io/x:1.0");
|
||||
var ex = Assert.Throws<Exception>(() => _command.ProcessCommand(_executionContext.Object, artifactsFile, null));
|
||||
Assert.Contains("line 1", ex.Message);
|
||||
Assert.Contains("digest", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void OciScheme_HappyPath()
|
||||
{
|
||||
using (var hostContext = Setup())
|
||||
{
|
||||
var hex = new string('e', 64);
|
||||
var artifactsFile = WriteArtifactsFile($"OCI://ghcr.io/x:1.0@sha256:{hex}");
|
||||
_command.ProcessCommand(_executionContext.Object, artifactsFile, null);
|
||||
Assert.Single(_global.ArtifactSubjects);
|
||||
Assert.True(_global.ArtifactSubjects.ContainsKey("ghcr.io/x:1.0"));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- File subjects ----------
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void FileSubject_Absolute_HappyPath()
|
||||
{
|
||||
using (var hostContext = Setup())
|
||||
{
|
||||
var artifactPath = Path.Combine(_rootDirectory, "binary.bin");
|
||||
File.WriteAllBytes(artifactPath, new byte[] { 1, 2, 3, 4 });
|
||||
var artifactsFile = WriteArtifactsFile(artifactPath);
|
||||
|
||||
_command.ProcessCommand(_executionContext.Object, artifactsFile, null);
|
||||
|
||||
Assert.Single(_global.ArtifactSubjects);
|
||||
var subject = _global.ArtifactSubjects["binary.bin"];
|
||||
Assert.Equal(ArtifactSubjectKind.File, subject.Kind);
|
||||
// sha256("\x01\x02\x03\x04") = 9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a
|
||||
Assert.Equal("sha256:9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a", subject.Digest);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void FileSubject_RelativeToWorkspace()
|
||||
{
|
||||
using (var hostContext = Setup())
|
||||
{
|
||||
Directory.CreateDirectory(Path.Combine(_workspaceDirectory, "dist"));
|
||||
File.WriteAllBytes(Path.Combine(_workspaceDirectory, "dist", "myapp"), new byte[] { 9 });
|
||||
var artifactsFile = WriteArtifactsFile("dist/myapp");
|
||||
|
||||
_command.ProcessCommand(_executionContext.Object, artifactsFile, null);
|
||||
|
||||
Assert.Single(_global.ArtifactSubjects);
|
||||
Assert.True(_global.ArtifactSubjects.ContainsKey("myapp"));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void FileScheme_TreatsAsFilePathEvenIfLooksLikeOci()
|
||||
{
|
||||
using (var hostContext = Setup())
|
||||
{
|
||||
// File literally named "image@sha256:deadbeef..." — force file path via file:// prefix.
|
||||
var quirkyName = "image@sha256:" + new string('f', 64);
|
||||
var artifactPath = Path.Combine(_rootDirectory, quirkyName);
|
||||
File.WriteAllBytes(artifactPath, new byte[] { 1 });
|
||||
var artifactsFile = WriteArtifactsFile("file://" + artifactPath);
|
||||
|
||||
_command.ProcessCommand(_executionContext.Object, artifactsFile, null);
|
||||
|
||||
Assert.Single(_global.ArtifactSubjects);
|
||||
var subject = _global.ArtifactSubjects[quirkyName];
|
||||
Assert.Equal(ArtifactSubjectKind.File, subject.Kind);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void FileSubject_Missing_Throws()
|
||||
{
|
||||
using (var hostContext = Setup())
|
||||
{
|
||||
var artifactsFile = WriteArtifactsFile(Path.Combine(_rootDirectory, "does-not-exist"));
|
||||
var ex = Assert.Throws<Exception>(() => _command.ProcessCommand(_executionContext.Object, artifactsFile, null));
|
||||
Assert.Contains("line 1", ex.Message);
|
||||
Assert.Contains("does not exist", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void FileSubject_Directory_Throws()
|
||||
{
|
||||
using (var hostContext = Setup())
|
||||
{
|
||||
var dir = Path.Combine(_rootDirectory, "a-directory");
|
||||
Directory.CreateDirectory(dir);
|
||||
var artifactsFile = WriteArtifactsFile(dir);
|
||||
var ex = Assert.Throws<Exception>(() => _command.ProcessCommand(_executionContext.Object, artifactsFile, null));
|
||||
Assert.Contains("line 1", ex.Message);
|
||||
Assert.Contains("not a regular file", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void FileSubject_ContainerAbsolute_InMount_ResolvesToHostFile()
|
||||
{
|
||||
using (var hostContext = Setup())
|
||||
{
|
||||
// The host file lives under a directory that is mounted into
|
||||
// the container. The step declares the file using its
|
||||
// container-namespace path, which must translate back to the
|
||||
// host file so the digest is computed over the right bytes.
|
||||
var hostDirectory = Path.Combine(_rootDirectory, "mounted");
|
||||
Directory.CreateDirectory(hostDirectory);
|
||||
File.WriteAllBytes(Path.Combine(hostDirectory, "app.bin"), new byte[] { 1, 2, 3, 4 });
|
||||
|
||||
var container = new ContainerInfo();
|
||||
var containerDirectory = "/container-workspace";
|
||||
container.AddPathTranslateMapping(hostDirectory, containerDirectory);
|
||||
|
||||
var artifactsFile = WriteArtifactsFile(Path.Combine(containerDirectory, "app.bin"));
|
||||
_command.ProcessCommand(_executionContext.Object, artifactsFile, container);
|
||||
|
||||
Assert.Single(_global.ArtifactSubjects);
|
||||
var subject = _global.ArtifactSubjects["app.bin"];
|
||||
Assert.Equal(ArtifactSubjectKind.File, subject.Kind);
|
||||
// sha256("\x01\x02\x03\x04")
|
||||
Assert.Equal("sha256:9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a", subject.Digest);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void FileSubject_ContainerAbsolute_OutsideMount_Throws()
|
||||
{
|
||||
using (var hostContext = Setup())
|
||||
{
|
||||
// An absolute path that does not resolve into any mounted
|
||||
// volume must be rejected rather than silently hashing the
|
||||
// host file that happens to live at that same path.
|
||||
var container = new ContainerInfo();
|
||||
container.AddPathTranslateMapping(Path.Combine(_rootDirectory, "mounted"), "/container-workspace");
|
||||
|
||||
var artifactsFile = WriteArtifactsFile(Path.Combine("/unmapped-container-dir", "secret.bin"));
|
||||
var ex = Assert.Throws<Exception>(() => _command.ProcessCommand(_executionContext.Object, artifactsFile, container));
|
||||
Assert.Contains("line 1", ex.Message);
|
||||
Assert.Contains("not inside a volume mounted", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Format / scheme rules ----------
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EqualsSign_Rejected()
|
||||
{
|
||||
using (var hostContext = Setup())
|
||||
{
|
||||
var artifactsFile = WriteArtifactsFile("name=ghcr.io/x@sha256:" + new string('a', 64));
|
||||
var ex = Assert.Throws<Exception>(() => _command.ProcessCommand(_executionContext.Object, artifactsFile, null));
|
||||
Assert.Contains("line 1", ex.Message);
|
||||
Assert.Contains("'='", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void UnsupportedScheme_Rejected()
|
||||
{
|
||||
using (var hostContext = Setup())
|
||||
{
|
||||
var artifactsFile = WriteArtifactsFile("https://example.com/artifact");
|
||||
var ex = Assert.Throws<Exception>(() => _command.ProcessCommand(_executionContext.Object, artifactsFile, null));
|
||||
Assert.Contains("line 1", ex.Message);
|
||||
Assert.Contains("unsupported URI scheme", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void LineNumberInError_PointsAtRightLine()
|
||||
{
|
||||
using (var hostContext = Setup())
|
||||
{
|
||||
var hex = new string('a', 64);
|
||||
var artifactsFile = WriteArtifactsFile(
|
||||
"# comment",
|
||||
$"ghcr.io/ok@sha256:{hex}",
|
||||
"",
|
||||
"name=bogus");
|
||||
var ex = Assert.Throws<Exception>(() => _command.ProcessCommand(_executionContext.Object, artifactsFile, null));
|
||||
Assert.Contains("line 4", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Size and aggregate limits ----------
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void FileTooLarge_Throws()
|
||||
{
|
||||
using (var hostContext = Setup())
|
||||
{
|
||||
var artifactsFile = Path.Combine(_rootDirectory, "huge");
|
||||
// Slightly larger than 1 MiB.
|
||||
File.WriteAllBytes(artifactsFile, new byte[CreateArtifactsFileCommand.MaxFileSizeBytes + 1]);
|
||||
var ex = Assert.Throws<Exception>(() => _command.ProcessCommand(_executionContext.Object, artifactsFile, null));
|
||||
Assert.Contains("$GITHUB_ARTIFACTS", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void AggregateCap_AllowsDuplicatesAtCap()
|
||||
{
|
||||
using (var hostContext = Setup())
|
||||
{
|
||||
// Pre-fill the aggregate to exactly the cap.
|
||||
var hex = new string('a', 64);
|
||||
for (var i = 0; i < CreateArtifactsFileCommand.MaxAggregateArtifacts; i++)
|
||||
{
|
||||
var name = $"ghcr.io/x{i}";
|
||||
_global.ArtifactSubjects[name] = new ArtifactSubject(name, $"sha256:{hex}", ArtifactSubjectKind.OciSubject);
|
||||
}
|
||||
|
||||
// A new step redeclares one of the existing artifacts identically — should NOT throw.
|
||||
var artifactsFile = WriteArtifactsFile($"ghcr.io/x0@sha256:{hex}");
|
||||
_command.ProcessCommand(_executionContext.Object, artifactsFile, null);
|
||||
Assert.Equal(CreateArtifactsFileCommand.MaxAggregateArtifacts, _global.ArtifactSubjects.Count);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void AggregateCap_FailsOnFirstDistinctOverflow()
|
||||
{
|
||||
using (var hostContext = Setup())
|
||||
{
|
||||
var hex = new string('a', 64);
|
||||
for (var i = 0; i < CreateArtifactsFileCommand.MaxAggregateArtifacts; i++)
|
||||
{
|
||||
var name = $"ghcr.io/x{i}";
|
||||
_global.ArtifactSubjects[name] = new ArtifactSubject(name, $"sha256:{hex}", ArtifactSubjectKind.OciSubject);
|
||||
}
|
||||
|
||||
var artifactsFile = WriteArtifactsFile($"ghcr.io/new@sha256:{hex}");
|
||||
var ex = Assert.Throws<Exception>(() => _command.ProcessCommand(_executionContext.Object, artifactsFile, null));
|
||||
Assert.Contains("500", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Aggregation rules ----------
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Aggregation_DedupsIdentical()
|
||||
{
|
||||
using (var hostContext = Setup())
|
||||
{
|
||||
var hex = new string('a', 64);
|
||||
_global.ArtifactSubjects["ghcr.io/x"] = new ArtifactSubject("ghcr.io/x", $"sha256:{hex}", ArtifactSubjectKind.OciSubject);
|
||||
var artifactsFile = WriteArtifactsFile($"ghcr.io/x@sha256:{hex}");
|
||||
_command.ProcessCommand(_executionContext.Object, artifactsFile, null);
|
||||
Assert.Single(_global.ArtifactSubjects);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Aggregation_FailsOnConflict()
|
||||
{
|
||||
using (var hostContext = Setup())
|
||||
{
|
||||
var hexA = new string('a', 64);
|
||||
var hexB = new string('b', 64);
|
||||
_global.ArtifactSubjects["ghcr.io/x"] = new ArtifactSubject("ghcr.io/x", $"sha256:{hexA}", ArtifactSubjectKind.OciSubject);
|
||||
var artifactsFile = WriteArtifactsFile($"ghcr.io/x@sha256:{hexB}");
|
||||
var ex = Assert.Throws<Exception>(() => _command.ProcessCommand(_executionContext.Object, artifactsFile, null));
|
||||
Assert.Contains("Conflicting digest", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void MultipleEntries_AllProcessed()
|
||||
{
|
||||
using (var hostContext = Setup())
|
||||
{
|
||||
Directory.CreateDirectory(Path.Combine(_workspaceDirectory, "dist"));
|
||||
File.WriteAllBytes(Path.Combine(_workspaceDirectory, "dist", "myapp-linux-amd64"), new byte[] { 7 });
|
||||
|
||||
var hex = new string('a', 64);
|
||||
var artifactsFile = WriteArtifactsFile(
|
||||
"# Release binary",
|
||||
"dist/myapp-linux-amd64",
|
||||
"",
|
||||
"# Published container image",
|
||||
$"ghcr.io/octocat/myapp:1.0.0@sha256:{hex}");
|
||||
|
||||
_command.ProcessCommand(_executionContext.Object, artifactsFile, null);
|
||||
|
||||
Assert.Equal(2, _global.ArtifactSubjects.Count);
|
||||
Assert.True(_global.ArtifactSubjects.ContainsKey("myapp-linux-amd64"));
|
||||
Assert.True(_global.ArtifactSubjects.ContainsKey("ghcr.io/octocat/myapp:1.0.0"));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Setup helpers ----------
|
||||
|
||||
private string WriteArtifactsFile(params string[] lines)
|
||||
{
|
||||
var path = Path.Combine(_rootDirectory, "artifacts");
|
||||
File.WriteAllText(path, string.Join("\n", lines), new UTF8Encoding(false));
|
||||
return path;
|
||||
}
|
||||
|
||||
private TestHostContext Setup(bool featureFlag = true, string envVarOverride = null, [CallerMemberName] string name = "")
|
||||
{
|
||||
_issues = new List<DTWebApi.Issue>();
|
||||
|
||||
// Ensure no leaked state from prior tests in the same process.
|
||||
Environment.SetEnvironmentVariable(CreateArtifactsFileCommand.EnableEnvVar, envVarOverride);
|
||||
|
||||
var hostContext = new TestHostContext(this, name);
|
||||
_trace = hostContext.GetTrace();
|
||||
|
||||
var workDirectory = hostContext.GetDirectory(WellKnownDirectory.Work);
|
||||
Directory.CreateDirectory(workDirectory);
|
||||
_rootDirectory = Path.Combine(workDirectory, nameof(CreateArtifactsFileCommandL0), name);
|
||||
if (Directory.Exists(_rootDirectory))
|
||||
{
|
||||
Directory.Delete(_rootDirectory, recursive: true);
|
||||
}
|
||||
Directory.CreateDirectory(_rootDirectory);
|
||||
|
||||
_workspaceDirectory = Path.Combine(_rootDirectory, "workspace");
|
||||
Directory.CreateDirectory(_workspaceDirectory);
|
||||
|
||||
var variableValues = new Dictionary<string, VariableValue>(StringComparer.OrdinalIgnoreCase);
|
||||
if (featureFlag)
|
||||
{
|
||||
variableValues[Common.Constants.Runner.Features.AllowArtifactsFile] = new VariableValue(FlagOn);
|
||||
}
|
||||
var variables = new Variables(hostContext, variableValues);
|
||||
|
||||
_global = new GlobalContext
|
||||
{
|
||||
EnvironmentVariables = new Dictionary<string, string>(VarUtil.EnvironmentVariableKeyComparer),
|
||||
Variables = variables,
|
||||
WriteDebug = true,
|
||||
ArtifactSubjects = new Dictionary<string, ArtifactSubject>(StringComparer.Ordinal),
|
||||
};
|
||||
|
||||
_executionContext = new Mock<IExecutionContext>();
|
||||
_executionContext.Setup(x => x.Global).Returns(_global);
|
||||
_executionContext.Setup(x => x.GetGitHubContext("workspace")).Returns(_workspaceDirectory);
|
||||
_executionContext.Setup(x => x.AddIssue(It.IsAny<DTWebApi.Issue>(), It.IsAny<ExecutionContextLogOptions>()))
|
||||
.Callback((DTWebApi.Issue issue, ExecutionContextLogOptions logOptions) =>
|
||||
{
|
||||
_issues.Add(issue);
|
||||
_trace.Info($"Issue '{issue.Type}': {issue.Message}");
|
||||
});
|
||||
_executionContext.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Callback((string tag, string message) =>
|
||||
{
|
||||
_trace.Info($"{tag}{message}");
|
||||
});
|
||||
|
||||
_command = new CreateArtifactsFileCommand();
|
||||
_command.Initialize(hostContext);
|
||||
|
||||
return hostContext;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user