mirror of
https://github.com/actions/runner.git
synced 2026-08-03 09:52:46 +08:00
Compare commits
3 Commits
feature/do
...
v2.335.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d737449ef | ||
|
|
0d310567ae | ||
|
|
1ccca7c073 |
@@ -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.421"
|
||||
},
|
||||
"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."
|
||||
|
||||
@@ -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`).
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
ARG RUNNER_VERSION
|
||||
ARG RUNNER_CONTAINER_HOOKS_VERSION=0.7.0
|
||||
ARG DOCKER_VERSION=29.7.1
|
||||
ARG BUILDX_VERSION=0.36.0
|
||||
ARG DOCKER_VERSION=29.5.3
|
||||
ARG BUILDX_VERSION=0.34.1
|
||||
|
||||
RUN apt update -y && apt install curl unzip -y
|
||||
|
||||
|
||||
@@ -1,32 +1,40 @@
|
||||
## 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
|
||||
* Bump System.ServiceProcess.ServiceController from 10.0.6 to 10.0.7 by @dependabot[bot] in https://github.com/actions/runner/pull/4370
|
||||
* Bump @actions/glob from 0.6.1 to 0.7.0 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4367
|
||||
* feat: propagate actions dependencies by @nodeselector in https://github.com/actions/runner/pull/4372
|
||||
* Not retry and report action download 403. by @TingluoHuang in https://github.com/actions/runner/pull/4391
|
||||
* Update setup job starting logs by @GitPaulo in https://github.com/actions/runner/pull/4383
|
||||
* fix: expand commit hash regex to support SHA-256 (64-char) hashes by @yaananth in https://github.com/actions/runner/pull/4347
|
||||
* Move dap setup to setup job step by @rentziass in https://github.com/actions/runner/pull/4403
|
||||
* Add support for Ubuntu 26.04 (liblttng-ust1t64, libicu77-80) by @dvaldivia in https://github.com/actions/runner/pull/4394
|
||||
* Update dotnet sdk to latest version @8.0.421 by @github-actions[bot] in https://github.com/actions/runner/pull/4428
|
||||
* Update Docker to v29.5.0 and Buildx to v0.34.0 by @github-actions[bot] in https://github.com/actions/runner/pull/4425
|
||||
* Execute debugger REPL commands inside job container by @rentziass in https://github.com/actions/runner/pull/4420
|
||||
* Send welcome message in debugger console on connect by @rentziass in https://github.com/actions/runner/pull/4419
|
||||
* Update snapshot-if context and functions by @drielenr in https://github.com/actions/runner/pull/4443
|
||||
* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4452
|
||||
* Allow disable node v8 maglev jit compiler on node24. by @TingluoHuang in https://github.com/actions/runner/pull/4447
|
||||
* Update Node 24 default date to June 16th, 2026 by @salmanmkc in https://github.com/actions/runner/pull/4462
|
||||
* Populate telemetry for non-action post-job steps by @drielenr in https://github.com/actions/runner/pull/4463
|
||||
* Add SDK types and results plumbing for background step control by @lokesh755 in https://github.com/actions/runner/pull/4472
|
||||
* Add job execution view model by @rentziass in https://github.com/actions/runner/pull/4470
|
||||
* Add thread-safety locks to StepsContext by @lokesh755 in https://github.com/actions/runner/pull/4475
|
||||
* Add background step deferral infrastructure and metadata plumbing by @lokesh755 in https://github.com/actions/runner/pull/4479
|
||||
* Wire job execution view into DAP by @rentziass in https://github.com/actions/runner/pull/4471
|
||||
* Background steps execution engine by @lokesh755 in https://github.com/actions/runner/pull/4476
|
||||
* Update Docker to v29.5.2 and Buildx to v0.34.1 by @github-actions[bot] in https://github.com/actions/runner/pull/4451
|
||||
* BrokerServer should not retry on 401. by @TingluoHuang in https://github.com/actions/runner/pull/4445
|
||||
* Add new env var to allow single-prefix multiline logs on stdout by @nuclearpidgeon in https://github.com/actions/runner/pull/4424
|
||||
* Bump Microsoft.DevTunnels.Connections from 1.3.39 to 1.3.48 by @dependabot[bot] in https://github.com/actions/runner/pull/4441
|
||||
* Bump System.Formats.Asn1 and System.Security.Cryptography.Pkcs by @dependabot[bot] in https://github.com/actions/runner/pull/4369
|
||||
|
||||
## 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
|
||||
* @GitPaulo made their first contribution in https://github.com/actions/runner/pull/4383
|
||||
* @dvaldivia made their first contribution in https://github.com/actions/runner/pull/4394
|
||||
* @drielenr made their first contribution in https://github.com/actions/runner/pull/4443
|
||||
* @nuclearpidgeon made their first contribution in https://github.com/actions/runner/pull/4424
|
||||
|
||||
**Full Changelog**: https://github.com/actions/runner/compare/v2.335.0...v2.336.0
|
||||
**Full Changelog**: https://github.com/actions/runner/compare/v2.334.0...v2.335.0
|
||||
|
||||
_Note: Actions Runner follows a progressive release policy, so the latest release might not be available to your enterprise, organization, or repository yet.
|
||||
To confirm which version of the Actions Runner you should expect, please view the download instructions for your enterprise, organization, or repository.
|
||||
|
||||
@@ -1 +1 @@
|
||||
<Update to ./src/runnerversion when creating release>
|
||||
2.335.1
|
||||
|
||||
6092
src/Misc/expressionFunc/hashFiles/package-lock.json
generated
6092
src/Misc/expressionFunc/hashFiles/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -38,13 +38,13 @@
|
||||
"@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/parser": "^8.59.0",
|
||||
"@vercel/ncc": "^0.38.3",
|
||||
"eslint": "^8.47.0",
|
||||
"eslint-plugin-github": "^6.1.2",
|
||||
"eslint-plugin-github": "^4.10.2",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^17.2.0",
|
||||
"lint-staged": "^16.4.0",
|
||||
"prettier": "^3.0.3",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
|
||||
@@ -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.16.0"
|
||||
|
||||
get_abs_path() {
|
||||
# exploits the fact that pwd will print abs path when no args
|
||||
|
||||
@@ -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 VssUnauthorizedException || ex is RunnerNotFoundException || ex is HostedRunnerDeprovisionedException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -180,8 +180,6 @@ namespace GitHub.Runner.Common
|
||||
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
|
||||
@@ -229,12 +227,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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -61,7 +61,7 @@ namespace GitHub.Runner.Common
|
||||
if (!string.IsNullOrEmpty(liveConsoleFeedUrl))
|
||||
{
|
||||
_liveConsoleFeedUrl = liveConsoleFeedUrl;
|
||||
InitializeWebsocketClient(liveConsoleFeedUrl, TimeSpan.Zero, retryConnection: true);
|
||||
InitializeWebsocketClient(liveConsoleFeedUrl, token, TimeSpan.Zero, retryConnection: true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,9 +164,9 @@ namespace GitHub.Runner.Common
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private void InitializeWebsocketClient(string liveConsoleFeedUrl, TimeSpan delay, bool retryConnection = false)
|
||||
private void InitializeWebsocketClient(string liveConsoleFeedUrl, string accessToken, TimeSpan delay, bool retryConnection = false)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_token))
|
||||
if (string.IsNullOrEmpty(accessToken))
|
||||
{
|
||||
Trace.Info($"No access token from server");
|
||||
return;
|
||||
@@ -179,7 +179,12 @@ namespace GitHub.Runner.Common
|
||||
}
|
||||
|
||||
Trace.Info($"Creating websocket client ..." + liveConsoleFeedUrl);
|
||||
this._websocketClient = CreateWebSocketClient();
|
||||
this._websocketClient = new ClientWebSocket();
|
||||
this._websocketClient.Options.SetRequestHeader("Authorization", $"Bearer {accessToken}");
|
||||
var userAgentValues = new List<ProductInfoHeaderValue>();
|
||||
userAgentValues.AddRange(UserAgentUtility.GetDefaultRestUserAgent());
|
||||
userAgentValues.AddRange(HostContext.UserAgents);
|
||||
this._websocketClient.Options.SetRequestHeader("User-Agent", string.Join(" ", userAgentValues.Select(x => x.ToString())));
|
||||
|
||||
// during initialization, retry upto 3 times to setup connection
|
||||
this._websocketConnectTask = ConnectWebSocketClient(liveConsoleFeedUrl, delay, retryConnection);
|
||||
@@ -196,15 +201,8 @@ namespace GitHub.Runner.Common
|
||||
{
|
||||
Trace.Info($"Attempting to start websocket client with delay {delay}.");
|
||||
await Task.Delay(delay);
|
||||
using (var connectTimeoutTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(30)))
|
||||
{
|
||||
if (this._websocketClient == null)
|
||||
{
|
||||
this._websocketClient = CreateWebSocketClient();
|
||||
}
|
||||
|
||||
await this._websocketClient.ConnectAsync(new Uri(feedStreamUrl), connectTimeoutTokenSource.Token);
|
||||
}
|
||||
using var connectTimeoutTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
await this._websocketClient.ConnectAsync(new Uri(feedStreamUrl), connectTimeoutTokenSource.Token);
|
||||
Trace.Info($"Successfully started websocket client.");
|
||||
connected = true;
|
||||
}
|
||||
@@ -213,7 +211,6 @@ namespace GitHub.Runner.Common
|
||||
Trace.Info("Exception caught during websocket client connect, retry connection.");
|
||||
Trace.Error(ex);
|
||||
retries++;
|
||||
this._websocketClient?.Dispose();
|
||||
this._websocketClient = null;
|
||||
_lastConnectionFailure = DateTime.Now;
|
||||
}
|
||||
@@ -262,7 +259,7 @@ namespace GitHub.Runner.Common
|
||||
Trace.Info($"Websocket is not open, let's attempt to connect back again with random backoff {delay} ms.");
|
||||
Trace.Verbose(ex.ToString());
|
||||
retries++;
|
||||
InitializeWebsocketClient(_liveConsoleFeedUrl, delay);
|
||||
InitializeWebsocketClient(_liveConsoleFeedUrl, _token, delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -277,24 +274,13 @@ namespace GitHub.Runner.Common
|
||||
if (_lastConnectionFailure.HasValue && DateTime.Now > _lastConnectionFailure.Value.AddMinutes(10))
|
||||
{
|
||||
// Some minutes passed since we retried last time, try connection again
|
||||
InitializeWebsocketClient(_liveConsoleFeedUrl, TimeSpan.Zero);
|
||||
InitializeWebsocketClient(_liveConsoleFeedUrl, _token, TimeSpan.Zero);
|
||||
}
|
||||
}
|
||||
|
||||
return delivered;
|
||||
}
|
||||
|
||||
private ClientWebSocket CreateWebSocketClient()
|
||||
{
|
||||
var client = new ClientWebSocket();
|
||||
client.Options.SetRequestHeader("Authorization", $"Bearer {_token}");
|
||||
var userAgentValues = new List<ProductInfoHeaderValue>();
|
||||
userAgentValues.AddRange(UserAgentUtility.GetDefaultRestUserAgent());
|
||||
userAgentValues.AddRange(HostContext.UserAgents);
|
||||
client.Options.SetRequestHeader("User-Agent", string.Join(" ", userAgentValues.Select(x => x.ToString())));
|
||||
return client;
|
||||
}
|
||||
|
||||
private void CloseWebSocket(WebSocketCloseStatus closeStatus, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.DistributedTask.Logging;
|
||||
using GitHub.Runner.Sdk;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace GitHub.Runner.Common
|
||||
{
|
||||
|
||||
[ServiceLocator(Default = typeof(VSockSecretNotifier))]
|
||||
public interface IVSockSecretNotifier : IRunnerService, IAsyncDisposable
|
||||
{
|
||||
bool TryStartNotifier();
|
||||
|
||||
void NotifyNewSecret(NewSecretEventArgs newSecret);
|
||||
}
|
||||
|
||||
public sealed class VSockSecretNotifier : RunnerService, IVSockSecretNotifier
|
||||
{
|
||||
private Socket _vsock = null;
|
||||
|
||||
private CancellationTokenSource _cancellationTokenSource = null;
|
||||
|
||||
private Task _secretNotificationTask = null;
|
||||
|
||||
private Channel<byte[]> _channel = Channel.CreateUnbounded<byte[]>(new UnboundedChannelOptions() { SingleReader = true });
|
||||
|
||||
public bool TryStartNotifier()
|
||||
{
|
||||
if (_vsock != null)
|
||||
{
|
||||
Trace.Verbose("VSocket is already connected.");
|
||||
return true;
|
||||
}
|
||||
|
||||
// `GITHUB_ACTIONS_RUNNER_VSOCK_CID_PORT` is expected to be in the format "CID:PORT", e.g. "2:9999".
|
||||
string vsockCidPort = Environment.GetEnvironmentVariable("GITHUB_ACTIONS_RUNNER_VSOCK_CID_PORT");
|
||||
if (string.IsNullOrEmpty(vsockCidPort))
|
||||
{
|
||||
Trace.Verbose("VSocket CID/Port environment variable is not set.");
|
||||
return false;
|
||||
}
|
||||
|
||||
string[] parts = vsockCidPort.Split(':', 2, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length != 2)
|
||||
{
|
||||
Trace.Verbose("VSocket CID/Port environment variable is not in the correct format.");
|
||||
return false;
|
||||
}
|
||||
|
||||
uint cid, port;
|
||||
if (!uint.TryParse(parts[0], out cid) || !uint.TryParse(parts[1], out port))
|
||||
{
|
||||
Trace.Verbose("VSocket CID/Port environment variable contains invalid numbers.");
|
||||
return false;
|
||||
}
|
||||
|
||||
Trace.Info($"Attempting to start VSocket secret notifier with CID: {cid}, Port: {port}.");
|
||||
try
|
||||
{
|
||||
SafeSocketHandle nativeSocket = NativeSocket((int)(AddressFamily)40, (int)SocketType.Stream, 0);
|
||||
if (nativeSocket.IsInvalid)
|
||||
{
|
||||
int error = Marshal.GetLastPInvokeError();
|
||||
nativeSocket.Dispose();
|
||||
throw new SocketException(error);
|
||||
}
|
||||
|
||||
_vsock = new Socket(nativeSocket);
|
||||
_vsock.Connect(new HostVsockEndPoint(cid, port));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error($"Failed to create and connect VSocket: {ex}");
|
||||
_vsock?.Dispose();
|
||||
_vsock = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
_cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(HostContext.RunnerShutdownToken);
|
||||
_secretNotificationTask = ProcessSecretChannel();
|
||||
Trace.Info($"VSocket secret notifier started successfully.");
|
||||
return true;
|
||||
}
|
||||
|
||||
public void NotifyNewSecret(NewSecretEventArgs newSecret)
|
||||
{
|
||||
if (_vsock == null)
|
||||
{
|
||||
Trace.Verbose("VSocket is not connected, skipping secret notification.");
|
||||
return;
|
||||
}
|
||||
|
||||
byte[] payloadBytes = Encoding.UTF8.GetBytes(StringUtil.ConvertToJson(new { RunnerSecrets = newSecret }, Formatting.None));
|
||||
byte[] lengthPrefix = BitConverter.GetBytes(IPAddress.HostToNetworkOrder(payloadBytes.Length));
|
||||
byte[] fullPayload = new byte[lengthPrefix.Length + payloadBytes.Length];
|
||||
Buffer.BlockCopy(lengthPrefix, 0, fullPayload, 0, lengthPrefix.Length);
|
||||
Buffer.BlockCopy(payloadBytes, 0, fullPayload, lengthPrefix.Length, payloadBytes.Length);
|
||||
|
||||
// we don't need to check return since unbounded channel will always accept the item.
|
||||
_channel.Writer.TryWrite(fullPayload);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_vsock != null && _secretNotificationTask != null)
|
||||
{
|
||||
_cancellationTokenSource?.Cancel();
|
||||
try
|
||||
{
|
||||
await _secretNotificationTask;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error($"Secret notification task finished with error: {ex}");
|
||||
}
|
||||
|
||||
_cancellationTokenSource?.Dispose();
|
||||
_cancellationTokenSource = null;
|
||||
_vsock?.Dispose();
|
||||
_vsock = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessSecretChannel()
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!_cancellationTokenSource.Token.IsCancellationRequested &&
|
||||
await _channel.Reader.WaitToReadAsync(_cancellationTokenSource.Token))
|
||||
{
|
||||
while (_channel.Reader.TryRead(out var payload))
|
||||
{
|
||||
try
|
||||
{
|
||||
// Socket.SendAsync on a stream socket may send fewer bytes than requested,
|
||||
// so keep sending until the entire payload has been written.
|
||||
int totalSent = 0;
|
||||
while (totalSent < payload.Length)
|
||||
{
|
||||
int bytesSent = await _vsock.SendAsync(payload.AsMemory(totalSent), SocketFlags.None, _cancellationTokenSource.Token);
|
||||
if (bytesSent == 0)
|
||||
{
|
||||
throw new SocketException((int)SocketError.ConnectionReset);
|
||||
}
|
||||
|
||||
totalSent += bytesSent;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Trace.Info("Secret notification task was canceled.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error($"Failed to notify new secret over VSocket: {ex}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Trace.Info("Secret notification task was canceled.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error($"Failed to process secret channel: {ex}");
|
||||
}
|
||||
|
||||
_channel.Writer.TryComplete();
|
||||
}
|
||||
|
||||
[DllImport("libc", SetLastError = true, EntryPoint = "socket")]
|
||||
private static extern SafeSocketHandle NativeSocket(int domain, int type, int protocol);
|
||||
}
|
||||
|
||||
internal sealed class HostVsockEndPoint : EndPoint
|
||||
{
|
||||
private const int SocketAddressSize = 16;
|
||||
private readonly uint _cid;
|
||||
private readonly uint _port;
|
||||
|
||||
public HostVsockEndPoint(uint cid, uint port)
|
||||
{
|
||||
_cid = cid;
|
||||
_port = port;
|
||||
}
|
||||
|
||||
public override AddressFamily AddressFamily => (AddressFamily)40;
|
||||
|
||||
public override SocketAddress Serialize()
|
||||
{
|
||||
SocketAddress socketAddress = new SocketAddress(AddressFamily.Unspecified, SocketAddressSize);
|
||||
// sockaddr_vm layout: family(0-1), reserved1(2-3), port(4-7), cid(8-11)
|
||||
ushort family = (ushort)AddressFamily;
|
||||
socketAddress[0] = (byte)(family & 0xFF);
|
||||
socketAddress[1] = (byte)((family >> 8) & 0xFF);
|
||||
socketAddress[2] = 0;
|
||||
socketAddress[3] = 0;
|
||||
socketAddress[4] = (byte)(_port & 0xFF);
|
||||
socketAddress[5] = (byte)((_port >> 8) & 0xFF);
|
||||
socketAddress[6] = (byte)((_port >> 16) & 0xFF);
|
||||
socketAddress[7] = (byte)((_port >> 24) & 0xFF);
|
||||
socketAddress[8] = (byte)(_cid & 0xFF);
|
||||
socketAddress[9] = (byte)((_cid >> 8) & 0xFF);
|
||||
socketAddress[10] = (byte)((_cid >> 16) & 0xFF);
|
||||
socketAddress[11] = (byte)((_cid >> 24) & 0xFF);
|
||||
return socketAddress;
|
||||
}
|
||||
|
||||
public override EndPoint Create(SocketAddress socketAddress)
|
||||
{
|
||||
uint port = (uint)socketAddress[4]
|
||||
| ((uint)socketAddress[5] << 8)
|
||||
| ((uint)socketAddress[6] << 16)
|
||||
| ((uint)socketAddress[7] << 24);
|
||||
|
||||
uint cid = (uint)socketAddress[8]
|
||||
| ((uint)socketAddress[9] << 8)
|
||||
| ((uint)socketAddress[10] << 16)
|
||||
| ((uint)socketAddress[11] << 24);
|
||||
|
||||
return new HostVsockEndPoint(cid, port);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
@@ -1149,33 +980,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 +1108,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 +1122,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 +1159,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 +1209,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 +1347,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 +1362,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);
|
||||
|
||||
@@ -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,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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1026,9 +1026,7 @@ namespace GitHub.Runner.Worker.Dap
|
||||
{
|
||||
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)));
|
||||
SendOutput("console", debuggerConfig.WelcomeMessage);
|
||||
Trace.Info("Sent custom welcome message");
|
||||
}
|
||||
else
|
||||
@@ -1774,30 +1772,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>
|
||||
|
||||
@@ -36,11 +36,6 @@ namespace GitHub.Runner.Worker.Dap
|
||||
/// 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>
|
||||
|
||||
@@ -973,9 +973,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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,8 +15,6 @@ namespace GitHub.Runner.Worker
|
||||
"actor",
|
||||
"actor_id",
|
||||
"api_url",
|
||||
"artifacts",
|
||||
"artifacts_list",
|
||||
"base_ref",
|
||||
"env",
|
||||
"event_name",
|
||||
|
||||
@@ -39,9 +39,5 @@ namespace GitHub.Runner.Worker
|
||||
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))
|
||||
|
||||
@@ -78,11 +78,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))
|
||||
|
||||
@@ -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;
|
||||
@@ -46,7 +45,6 @@ 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();
|
||||
@@ -173,12 +171,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 +185,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>();
|
||||
@@ -617,21 +602,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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -930,7 +901,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 +913,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)
|
||||
{
|
||||
@@ -1054,43 +1004,16 @@ namespace GitHub.Runner.Worker
|
||||
}
|
||||
}
|
||||
|
||||
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 +1024,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 +1036,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 +1140,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 +1212,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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.48" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
using System;
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using Pipelines = GitHub.DistributedTask.Pipelines;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Services.WebApi;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using GitHub.Runner.Sdk;
|
||||
using Newtonsoft.Json;
|
||||
using Pipelines = GitHub.DistributedTask.Pipelines;
|
||||
using System.Text;
|
||||
|
||||
namespace GitHub.Runner.Worker
|
||||
{
|
||||
@@ -45,7 +46,6 @@ namespace GitHub.Runner.Worker
|
||||
var jobRunner = HostContext.CreateService<IJobRunner>();
|
||||
var terminal = HostContext.GetService<ITerminal>();
|
||||
|
||||
await using (var secretNotifier = HostContext.GetService<IVSockSecretNotifier>())
|
||||
using (var channel = HostContext.CreateService<IProcessChannel>())
|
||||
using (var jobRequestCancellationToken = CancellationTokenSource.CreateLinkedTokenSource(HostContext.RunnerShutdownToken))
|
||||
using (var channelTokenSource = new CancellationTokenSource())
|
||||
@@ -86,14 +86,6 @@ namespace GitHub.Runner.Worker
|
||||
HostContext.WritePerfCounter($"WorkerJobMessageReceived_{jobMessage.RequestId.ToString()}");
|
||||
|
||||
// Initialize the secret masker and set the thread culture.
|
||||
if (Constants.Runner.Platform == Constants.OSPlatform.Linux &&
|
||||
secretNotifier.TryStartNotifier())
|
||||
{
|
||||
HostContext.SecretMasker.NewSecretAdded += (sender, e) =>
|
||||
{
|
||||
secretNotifier.NotifyNewSecret(e);
|
||||
};
|
||||
}
|
||||
InitializeSecretMasker(jobMessage);
|
||||
SetCulture(jobMessage);
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.Serialization;
|
||||
|
||||
namespace GitHub.DistributedTask.Logging
|
||||
{
|
||||
@@ -13,41 +11,5 @@ namespace GitHub.DistributedTask.Logging
|
||||
void AddValueEncoder(ValueEncoder encoder);
|
||||
ISecretMasker Clone();
|
||||
String MaskSecrets(String input);
|
||||
public event EventHandler<NewSecretEventArgs> NewSecretAdded;
|
||||
}
|
||||
|
||||
public abstract class NewSecretEventArgs : EventArgs
|
||||
{
|
||||
public abstract String Type { get; }
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
public sealed class NewRegexSecretEventArgs : NewSecretEventArgs
|
||||
{
|
||||
[DataMember]
|
||||
public override String Type => "regex";
|
||||
|
||||
public NewRegexSecretEventArgs(String pattern)
|
||||
{
|
||||
Pattern = pattern;
|
||||
}
|
||||
|
||||
[DataMember]
|
||||
public String Pattern { get; private set; }
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
public sealed class NewVariableSecretEventArgs : NewSecretEventArgs
|
||||
{
|
||||
[DataMember]
|
||||
public override String Type => "variable";
|
||||
|
||||
public NewVariableSecretEventArgs(List<string> values)
|
||||
{
|
||||
Values.AddRange(values);
|
||||
}
|
||||
|
||||
[DataMember]
|
||||
public List<string> Values { get; private set; } = new List<string>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,6 @@ namespace GitHub.DistributedTask.Logging
|
||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public sealed class SecretMasker : ISecretMasker, IDisposable
|
||||
{
|
||||
public event EventHandler<NewSecretEventArgs> NewSecretAdded;
|
||||
|
||||
public SecretMasker()
|
||||
{
|
||||
m_originalValueSecrets = new HashSet<ValueSecret>();
|
||||
@@ -68,8 +66,6 @@ namespace GitHub.DistributedTask.Logging
|
||||
m_lock.ExitWriteLock();
|
||||
}
|
||||
}
|
||||
|
||||
NewSecretAdded?.Invoke(this, new NewRegexSecretEventArgs(pattern));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -137,9 +133,6 @@ namespace GitHub.DistributedTask.Logging
|
||||
m_lock.ExitWriteLock();
|
||||
}
|
||||
}
|
||||
|
||||
// valueSecrets contains all the values run through the encoders.
|
||||
NewSecretAdded?.Invoke(this, new NewVariableSecretEventArgs(valueSecrets.Select(x => x.m_value).ToList()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -271,7 +271,7 @@ namespace GitHub.DistributedTask.Pipelines
|
||||
/// 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
|
||||
/// a non-empty value is shown as-is and a null or empty value suppresses the
|
||||
/// default welcome message. When the flag is not set, the runner shows its
|
||||
/// built-in help text and this field is ignored.
|
||||
/// </summary>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.7" />
|
||||
<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.7" />
|
||||
</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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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('@');
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -29,7 +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,7 +46,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);
|
||||
@@ -177,96 +175,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 +871,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,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,11 +90,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 +148,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 +215,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")]
|
||||
@@ -585,9 +530,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 +2386,7 @@ runs:
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void LoadsNode24ActionDefinition()
|
||||
@@ -2509,7 +2454,7 @@ runs:
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
@@ -3388,16 +3333,6 @@ runs:
|
||||
}
|
||||
}
|
||||
|
||||
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")]
|
||||
@@ -3533,604 +3468,5 @@ runs:
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1422,96 +1422,6 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task WelcomeMessageMasksSecrets()
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
hc.SecretMasker.AddValue("super-secret-token");
|
||||
|
||||
var port = GetFreePort();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var jobContext = CreateJobContextWithTunnel(cts.Token, port,
|
||||
overrideWelcomeMessage: true,
|
||||
welcomeMessage: "Welcome! Token: super-secret-token");
|
||||
await _debugger.StartAsync(jobContext.Object);
|
||||
|
||||
using var client = await ConnectClientAsync(port);
|
||||
var stream = client.GetStream();
|
||||
|
||||
await SendRequestAsync(stream, new Request
|
||||
{
|
||||
Seq = 1,
|
||||
Type = "request",
|
||||
Command = "configurationDone"
|
||||
});
|
||||
|
||||
var configDoneResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("\"command\":\"configurationDone\"", configDoneResponse);
|
||||
|
||||
var welcomeMsg = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("\"event\":\"output\"", welcomeMsg);
|
||||
Assert.DoesNotContain("super-secret-token", welcomeMsg);
|
||||
Assert.Contains("***", welcomeMsg);
|
||||
|
||||
await _debugger.StopAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task WelcomeMessageStripsControlCharacters()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var port = GetFreePort();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var jobContext = CreateJobContextWithTunnel(cts.Token, port,
|
||||
overrideWelcomeMessage: true,
|
||||
welcomeMessage: "Wel\u001b[31mcome\u0007\u0000\u009bhere\nnext\tline");
|
||||
await _debugger.StartAsync(jobContext.Object);
|
||||
|
||||
using var client = await ConnectClientAsync(port);
|
||||
var stream = client.GetStream();
|
||||
|
||||
await SendRequestAsync(stream, new Request
|
||||
{
|
||||
Seq = 1,
|
||||
Type = "request",
|
||||
Command = "configurationDone"
|
||||
});
|
||||
|
||||
var configDoneResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("\"command\":\"configurationDone\"", configDoneResponse);
|
||||
|
||||
var welcomeMsg = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("\"event\":\"output\"", welcomeMsg);
|
||||
|
||||
var output = JObject.Parse(welcomeMsg)["body"]["output"].ToString();
|
||||
Assert.Equal("Wel[31mcomehere\nnext\tline", output);
|
||||
|
||||
await _debugger.StopAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
[InlineData(null, "")]
|
||||
[InlineData("", "")]
|
||||
[InlineData("plain text", "plain text")]
|
||||
[InlineData("keep\r\nnewlines\tand tabs", "keep\r\nnewlines\tand tabs")]
|
||||
[InlineData("esc\u001bape", "escape")]
|
||||
[InlineData("bell\u0007null\u0000del\u007f", "bellnulldel")]
|
||||
[InlineData("c1\u0080\u009fchars", "c1chars")]
|
||||
public void SanitizeConsoleTextRemovesControlCharacters(string input, string expected)
|
||||
{
|
||||
Assert.Equal(expected, DapDebugger.SanitizeConsoleText(input));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Runtime.CompilerServices;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Sdk;
|
||||
using GitHub.Runner.Worker;
|
||||
using GitHub.Runner.Worker.Container;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace GitHub.Runner.Common.Tests.Worker
|
||||
{
|
||||
public sealed class FileCommandManagerL0
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void InitializeFiles_InvokesPopulateInitialContents_OncePerExtension()
|
||||
{
|
||||
using (var hostContext = Setup(out var executionContext, out var ext))
|
||||
{
|
||||
var manager = new FileCommandManager();
|
||||
manager.Initialize(hostContext);
|
||||
|
||||
manager.InitializeFiles(executionContext, null);
|
||||
|
||||
Assert.Equal(1, ext.PopulateCallCount);
|
||||
Assert.True(File.Exists(ext.LastPopulatedPath));
|
||||
|
||||
// A second invocation should populate again with the new
|
||||
// per-step file (file path rotates between calls).
|
||||
var firstPath = ext.LastPopulatedPath;
|
||||
manager.InitializeFiles(executionContext, null);
|
||||
Assert.Equal(2, ext.PopulateCallCount);
|
||||
Assert.NotEqual(firstPath, ext.LastPopulatedPath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void InitializeFiles_PopulateException_DoesNotAbortInitialization()
|
||||
{
|
||||
using (var hostContext = Setup(out var executionContext, out var ext))
|
||||
{
|
||||
ext.ThrowOnPopulate = true;
|
||||
|
||||
var manager = new FileCommandManager();
|
||||
manager.Initialize(hostContext);
|
||||
|
||||
// Must not throw — failures during populate should be
|
||||
// swallowed so a misbehaving extension cannot block step
|
||||
// setup.
|
||||
manager.InitializeFiles(executionContext, null);
|
||||
|
||||
Assert.Equal(1, ext.PopulateCallCount);
|
||||
}
|
||||
}
|
||||
|
||||
private TestHostContext Setup(out IExecutionContext executionContext, out RecordingFileCommand recordingExtension, [CallerMemberName] string name = "")
|
||||
{
|
||||
var hostContext = new TestHostContext(this, name);
|
||||
|
||||
recordingExtension = new RecordingFileCommand();
|
||||
recordingExtension.Initialize(hostContext);
|
||||
|
||||
var extensionManager = new Mock<IExtensionManager>();
|
||||
extensionManager.Setup(x => x.GetExtensions<IFileCommandExtension>())
|
||||
.Returns(new List<IFileCommandExtension> { recordingExtension });
|
||||
hostContext.SetSingleton<IExtensionManager>(extensionManager.Object);
|
||||
|
||||
var ec = new Mock<IExecutionContext>();
|
||||
ec.Setup(x => x.SetGitHubContext(It.IsAny<string>(), It.IsAny<string>()));
|
||||
executionContext = ec.Object;
|
||||
|
||||
return hostContext;
|
||||
}
|
||||
|
||||
private sealed class RecordingFileCommand : RunnerService, IFileCommandExtension
|
||||
{
|
||||
public string ContextName => "recording";
|
||||
public string FilePrefix => "recording_";
|
||||
public Type ExtensionType => typeof(IFileCommandExtension);
|
||||
|
||||
public int PopulateCallCount { get; private set; }
|
||||
public string LastPopulatedPath { get; private set; }
|
||||
public bool ThrowOnPopulate { get; set; }
|
||||
|
||||
public void PopulateInitialContents(IExecutionContext context, string filePath, ContainerInfo container)
|
||||
{
|
||||
PopulateCallCount++;
|
||||
LastPopulatedPath = filePath;
|
||||
if (ThrowOnPopulate)
|
||||
{
|
||||
throw new InvalidOperationException("intentional");
|
||||
}
|
||||
}
|
||||
|
||||
public void ProcessCommand(IExecutionContext context, string filePath, ContainerInfo container)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,10 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Actions.RunService.WebApi;
|
||||
using GitHub.DistributedTask.Pipelines;
|
||||
using GitHub.DistributedTask.Pipelines.ContextData;
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Sdk;
|
||||
using GitHub.Runner.Worker;
|
||||
using GitHub.Runner.Worker.Container;
|
||||
using GitHub.Runner.Worker.Container.ContainerHooks;
|
||||
using GitHub.Runner.Worker.Handlers;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
@@ -94,260 +85,5 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
Assert.Equal("ubuntu:20.04", _stepTelemetry.Action);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
[InlineData("read")]
|
||||
[InlineData("none")]
|
||||
[InlineData("write")]
|
||||
[InlineData("write-only")]
|
||||
public async Task RunAsync_ExportsCacheModeEnv_WhenVariableSet(string mode)
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
var environment = await RunNodeScriptActionHandlerAsync(hc, new Dictionary<string, VariableValue>
|
||||
{
|
||||
{ "actions_cache_mode", mode }
|
||||
});
|
||||
|
||||
Assert.True(environment.TryGetValue("ACTIONS_CACHE_MODE", out var value));
|
||||
Assert.Equal(mode, value);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task RunAsync_DoesNotExportCacheModeEnv_WhenVariableAbsent()
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
var environment = await RunNodeScriptActionHandlerAsync(hc, new Dictionary<string, VariableValue>());
|
||||
|
||||
Assert.False(environment.ContainsKey("ACTIONS_CACHE_MODE"));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task RunAsync_DoesNotExportCacheModeEnv_WhenVariableEmpty()
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
var environment = await RunNodeScriptActionHandlerAsync(hc, new Dictionary<string, VariableValue>
|
||||
{
|
||||
{ "actions_cache_mode", "" }
|
||||
});
|
||||
|
||||
Assert.False(environment.ContainsKey("ACTIONS_CACHE_MODE"));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task RunAsync_CacheModeCoexistsWithCacheServiceV2()
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
var environment = await RunNodeScriptActionHandlerAsync(hc, new Dictionary<string, VariableValue>
|
||||
{
|
||||
{ "actions_uses_cache_service_v2", "true" },
|
||||
{ "actions_cache_mode", "read" }
|
||||
});
|
||||
|
||||
Assert.Equal(bool.TrueString, environment["ACTIONS_CACHE_SERVICE_V2"]);
|
||||
Assert.Equal("read", environment["ACTIONS_CACHE_MODE"]);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task RunAsync_DoesNotAffectRuntimeEnv_WhenCacheModeAbsent()
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
var environment = await RunNodeScriptActionHandlerAsync(hc, new Dictionary<string, VariableValue>());
|
||||
|
||||
// Baseline runtime env is still exported and cache-mode adds nothing.
|
||||
Assert.Equal("https://pipelines.actions.githubusercontent.com/", environment["ACTIONS_RUNTIME_URL"]);
|
||||
Assert.Equal("token", environment["ACTIONS_RUNTIME_TOKEN"]);
|
||||
Assert.False(environment.ContainsKey("ACTIONS_CACHE_MODE"));
|
||||
Assert.False(environment.ContainsKey("ACTIONS_CACHE_SERVICE_V2"));
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
[InlineData("read")]
|
||||
[InlineData("none")]
|
||||
public async Task ContainerRunAsync_ExportsCacheModeEnv_WhenVariableSet(string mode)
|
||||
{
|
||||
// Container actions only run on Linux; RunAsync throws on other platforms.
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
var container = await RunContainerActionHandlerAsync(hc, new Dictionary<string, VariableValue>
|
||||
{
|
||||
{ "actions_cache_mode", mode }
|
||||
});
|
||||
|
||||
Assert.True(container.ContainerEnvironmentVariables.TryGetValue("ACTIONS_CACHE_MODE", out var value));
|
||||
Assert.Equal(mode, value);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task ContainerRunAsync_DoesNotExportCacheModeEnv_WhenVariableAbsent()
|
||||
{
|
||||
// Container actions only run on Linux; RunAsync throws on other platforms.
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
var container = await RunContainerActionHandlerAsync(hc, new Dictionary<string, VariableValue>());
|
||||
|
||||
Assert.False(container.ContainerEnvironmentVariables.ContainsKey("ACTIONS_CACHE_MODE"));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ContainerInfo> RunContainerActionHandlerAsync(TestHostContext hc, IDictionary<string, VariableValue> variables)
|
||||
{
|
||||
// Route through the container-hooks path so the handler skips docker build/run.
|
||||
variables[Constants.Runner.Features.AllowRunnerContainerHooks] = "true";
|
||||
Environment.SetEnvironmentVariable(Constants.Hooks.ContainerHooksPath, Path.Combine(hc.GetDirectory(WellKnownDirectory.Root), "hooks.js"));
|
||||
|
||||
var tempDirectory = hc.GetDirectory(WellKnownDirectory.Temp);
|
||||
Directory.CreateDirectory(Path.Combine(tempDirectory, "_runner_file_commands"));
|
||||
Directory.CreateDirectory(Path.Combine(tempDirectory, "_github_workflow"));
|
||||
var workspace = Path.Combine(hc.GetDirectory(WellKnownDirectory.Work), "workspace");
|
||||
Directory.CreateDirectory(workspace);
|
||||
|
||||
var serverVariables = new Variables(hc, variables);
|
||||
var endpoints = new List<ServiceEndpoint>
|
||||
{
|
||||
new ServiceEndpoint()
|
||||
{
|
||||
Name = WellKnownServiceEndpointNames.SystemVssConnection,
|
||||
Url = new Uri("https://pipelines.actions.githubusercontent.com"),
|
||||
Authorization = new EndpointAuthorization()
|
||||
{
|
||||
Scheme = "Test",
|
||||
Parameters = { { "AccessToken", "token" } }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_ec.Setup(x => x.Global).Returns(new GlobalContext()
|
||||
{
|
||||
Variables = serverVariables,
|
||||
Endpoints = endpoints,
|
||||
PrependPath = new List<string>(),
|
||||
EnvironmentVariables = new Dictionary<string, string>()
|
||||
});
|
||||
_ec.Setup(x => x.ExpressionValues).Returns(new DictionaryContextData());
|
||||
_ec.Setup(x => x.JobContext).Returns(new JobContext());
|
||||
_ec.Setup(x => x.GetGitHubContext("workspace")).Returns(workspace);
|
||||
|
||||
ContainerInfo captured = null;
|
||||
var hookManager = new Mock<IContainerHookManager>();
|
||||
hookManager.Setup(x => x.RunContainerStepAsync(It.IsAny<IExecutionContext>(), It.IsAny<ContainerInfo>(), It.IsAny<string>()))
|
||||
.Callback((IExecutionContext ec, ContainerInfo container, string dockerFile) => { captured = container; })
|
||||
.Returns(Task.CompletedTask);
|
||||
hc.SetSingleton(hookManager.Object);
|
||||
hc.SetSingleton(new Mock<IActionManifestManagerWrapper>().Object);
|
||||
|
||||
var handler = new ContainerActionHandler();
|
||||
handler.Initialize(hc);
|
||||
handler.ExecutionContext = _ec.Object;
|
||||
handler.Environment = new Dictionary<string, string>();
|
||||
handler.Inputs = new Dictionary<string, string>();
|
||||
handler.Action = new ContainerRegistryReference() { Image = "alpine:latest" };
|
||||
handler.Data = new ContainerActionExecutionData() { Image = "docker://alpine:latest" };
|
||||
|
||||
await handler.RunAsync(ActionRunStage.Main);
|
||||
|
||||
return captured;
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, string>> RunNodeScriptActionHandlerAsync(TestHostContext hc, IDictionary<string, VariableValue> variables)
|
||||
{
|
||||
var actionDirectory = Path.Combine(hc.GetDirectory(WellKnownDirectory.Work), Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(actionDirectory);
|
||||
var scriptFile = "main.js";
|
||||
File.WriteAllText(Path.Combine(actionDirectory, scriptFile), "// noop");
|
||||
|
||||
var serverVariables = new Variables(hc, variables);
|
||||
var endpoints = new List<ServiceEndpoint>
|
||||
{
|
||||
new ServiceEndpoint()
|
||||
{
|
||||
Name = WellKnownServiceEndpointNames.SystemVssConnection,
|
||||
Url = new Uri("https://pipelines.actions.githubusercontent.com"),
|
||||
Authorization = new EndpointAuthorization()
|
||||
{
|
||||
Scheme = "Test",
|
||||
Parameters = { { "AccessToken", "token" } }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_ec.Setup(x => x.Global).Returns(new GlobalContext()
|
||||
{
|
||||
Variables = serverVariables,
|
||||
Endpoints = endpoints,
|
||||
PrependPath = new List<string>(),
|
||||
EnvironmentVariables = new Dictionary<string, string>()
|
||||
});
|
||||
_ec.Setup(x => x.ExpressionValues).Returns(new DictionaryContextData());
|
||||
_ec.Setup(x => x.GetGitHubContext("workspace")).Returns(actionDirectory);
|
||||
_ec.Setup(x => x.GetMatchers()).Returns(new List<IssueMatcherConfig>());
|
||||
_ec.Setup(x => x.ForceCompleted).Returns(new TaskCompletionSource<int>().Task);
|
||||
_ec.Setup(x => x.CancellationToken).Returns(CancellationToken.None);
|
||||
|
||||
var stepHost = new Mock<IStepHost>();
|
||||
stepHost.Setup(x => x.DetermineNodeRuntimeVersion(It.IsAny<IExecutionContext>(), It.IsAny<string>())).ReturnsAsync("node20");
|
||||
stepHost.Setup(x => x.ResolvePathForStepHost(It.IsAny<IExecutionContext>(), It.IsAny<string>())).Returns((IExecutionContext ec, string path) => path);
|
||||
stepHost.Setup(x => x.ExecuteAsync(
|
||||
It.IsAny<IExecutionContext>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<IDictionary<string, string>>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<System.Text.Encoding>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>())).ReturnsAsync(0);
|
||||
|
||||
var handler = new NodeScriptActionHandler();
|
||||
handler.Initialize(hc);
|
||||
handler.ExecutionContext = _ec.Object;
|
||||
handler.StepHost = stepHost.Object;
|
||||
handler.Environment = new Dictionary<string, string>();
|
||||
handler.Inputs = new Dictionary<string, string>();
|
||||
handler.RuntimeVariables = serverVariables;
|
||||
handler.ActionDirectory = actionDirectory;
|
||||
handler.Action = new RepositoryPathReference() { Name = "actions/checkout", Ref = "v2" };
|
||||
handler.Data = new NodeJSActionExecutionData() { Script = scriptFile, NodeVersion = "node20" };
|
||||
|
||||
await handler.RunAsync(ActionRunStage.Main);
|
||||
|
||||
return handler.Environment;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,54 +199,6 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task JobExtensionOutputsLockedDependenciesWhenPresent()
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
var consoleLines = new List<string>();
|
||||
_jobServerQueue.Setup(x => x.QueueWebConsoleLine(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<long?>()))
|
||||
.Callback((Guid _, string line, long? __) => consoleLines.Add(line));
|
||||
|
||||
_message.ActionsDependencies.Add("actions/checkout@v4:sha256-abc123");
|
||||
|
||||
var jobExtension = new JobExtension();
|
||||
jobExtension.Initialize(hc);
|
||||
|
||||
_actionManager.Setup(x => x.PrepareActionsAsync(It.IsAny<IExecutionContext>(), It.IsAny<IEnumerable<Pipelines.JobStep>>(), It.IsAny<Guid>()))
|
||||
.Returns(Task.FromResult(new PrepareResult(new List<JobExtensionRunner>(), new Dictionary<Guid, IActionRunner>())));
|
||||
|
||||
await jobExtension.InitializeJob(_jobEc, _message);
|
||||
|
||||
Assert.Contains(consoleLines, line => line.Contains("Using locked action versions from the workflow's lockfile"));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task JobExtensionDoesNotOutputLockedDependenciesWhenAbsent()
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
var consoleLines = new List<string>();
|
||||
_jobServerQueue.Setup(x => x.QueueWebConsoleLine(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<long?>()))
|
||||
.Callback((Guid _, string line, long? __) => consoleLines.Add(line));
|
||||
|
||||
var jobExtension = new JobExtension();
|
||||
jobExtension.Initialize(hc);
|
||||
|
||||
_actionManager.Setup(x => x.PrepareActionsAsync(It.IsAny<IExecutionContext>(), It.IsAny<IEnumerable<Pipelines.JobStep>>(), It.IsAny<Guid>()))
|
||||
.Returns(Task.FromResult(new PrepareResult(new List<JobExtensionRunner>(), new Dictionary<Guid, IActionRunner>())));
|
||||
|
||||
await jobExtension.InitializeJob(_jobEc, _message);
|
||||
|
||||
Assert.DoesNotContain(consoleLines, line => line.Contains("Using locked action versions from the workflow's lockfile"));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
@@ -286,76 +238,21 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
[InlineData("read")]
|
||||
[InlineData("none")]
|
||||
[InlineData("write")]
|
||||
[InlineData("write-only")]
|
||||
public async Task InitializeJob_LogsCacheMode_WhenVariableSet(string mode)
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
_jobEc.Global.Variables.Set("actions_cache_mode", mode);
|
||||
|
||||
var jobExtension = new JobExtension();
|
||||
jobExtension.Initialize(hc);
|
||||
|
||||
_actionManager.Setup(x => x.PrepareActionsAsync(It.IsAny<IExecutionContext>(), It.IsAny<IEnumerable<Pipelines.JobStep>>(), It.IsAny<Guid>()))
|
||||
.Returns(Task.FromResult(new PrepareResult(new List<JobExtensionRunner>(), new Dictionary<Guid, IActionRunner>())));
|
||||
|
||||
await jobExtension.InitializeJob(_jobEc, _message);
|
||||
|
||||
_jobServerQueue.Verify(
|
||||
x => x.QueueWebConsoleLine(It.IsAny<Guid>(), It.Is<string>(m => m.Contains($"Cache mode: {mode}")), It.IsAny<long?>()),
|
||||
Times.Once);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task InitializeJob_DoesNotLogCacheMode_WhenVariableAbsent()
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
var jobExtension = new JobExtension();
|
||||
jobExtension.Initialize(hc);
|
||||
|
||||
_actionManager.Setup(x => x.PrepareActionsAsync(It.IsAny<IExecutionContext>(), It.IsAny<IEnumerable<Pipelines.JobStep>>(), It.IsAny<Guid>()))
|
||||
.Returns(Task.FromResult(new PrepareResult(new List<JobExtensionRunner>(), new Dictionary<Guid, IActionRunner>())));
|
||||
|
||||
await jobExtension.InitializeJob(_jobEc, _message);
|
||||
|
||||
_jobServerQueue.Verify(
|
||||
x => x.QueueWebConsoleLine(It.IsAny<Guid>(), It.Is<string>(m => m.Contains("Cache mode:")), It.IsAny<long?>()),
|
||||
Times.Never);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task JobExtensionBuildFailsWithoutContainerIfRequired()
|
||||
{
|
||||
Environment.SetEnvironmentVariable(Constants.Variables.Actions.RequireJobContainer, "true");
|
||||
try
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
var jobExtension = new JobExtension();
|
||||
jobExtension.Initialize(hc);
|
||||
var jobExtension = new JobExtension();
|
||||
jobExtension.Initialize(hc);
|
||||
|
||||
_actionManager.Setup(x => x.PrepareActionsAsync(It.IsAny<IExecutionContext>(), It.IsAny<IEnumerable<Pipelines.JobStep>>(), It.IsAny<Guid>()))
|
||||
.Returns(Task.FromResult(new PrepareResult(new List<JobExtensionRunner>() { new JobExtensionRunner(null, "", "prepare1", null), new JobExtensionRunner(null, "", "prepare2", null) }, new Dictionary<Guid, IActionRunner>())));
|
||||
_actionManager.Setup(x => x.PrepareActionsAsync(It.IsAny<IExecutionContext>(), It.IsAny<IEnumerable<Pipelines.JobStep>>(), It.IsAny<Guid>()))
|
||||
.Returns(Task.FromResult(new PrepareResult(new List<JobExtensionRunner>() { new JobExtensionRunner(null, "", "prepare1", null), new JobExtensionRunner(null, "", "prepare2", null) }, new Dictionary<Guid, IActionRunner>())));
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => jobExtension.InitializeJob(_jobEc, _message));
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable(Constants.Variables.Actions.RequireJobContainer, null);
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => jobExtension.InitializeJob(_jobEc, _message));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,13 +16,11 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
{
|
||||
private Mock<IProcessChannel> _processChannel;
|
||||
private Mock<IJobRunner> _jobRunner;
|
||||
private Mock<IVSockSecretNotifier> _vsockSecretNotifier;
|
||||
|
||||
public WorkerL0()
|
||||
{
|
||||
_processChannel = new Mock<IProcessChannel>();
|
||||
_jobRunner = new Mock<IJobRunner>();
|
||||
_vsockSecretNotifier = new Mock<IVSockSecretNotifier>();
|
||||
}
|
||||
|
||||
private Pipelines.AgentJobRequestMessage CreateJobRequestMessage(string jobName)
|
||||
@@ -90,7 +88,6 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
var worker = new GitHub.Runner.Worker.Worker();
|
||||
hc.EnqueueInstance<IProcessChannel>(_processChannel.Object);
|
||||
hc.EnqueueInstance<IJobRunner>(_jobRunner.Object);
|
||||
hc.SetSingleton<IVSockSecretNotifier>(_vsockSecretNotifier.Object);
|
||||
worker.Initialize(hc);
|
||||
var jobMessage = CreateJobRequestMessage("job1");
|
||||
var arWorkerMessages = new WorkerMessage[]
|
||||
@@ -142,7 +139,6 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
var worker = new GitHub.Runner.Worker.Worker();
|
||||
hc.EnqueueInstance<IProcessChannel>(_processChannel.Object);
|
||||
hc.EnqueueInstance<IJobRunner>(_jobRunner.Object);
|
||||
hc.SetSingleton<IVSockSecretNotifier>(_vsockSecretNotifier.Object);
|
||||
worker.Initialize(hc);
|
||||
var jobMessage = CreateJobRequestMessage("job1");
|
||||
var cancelMessage = CreateJobCancelMessage(jobMessage.JobId);
|
||||
|
||||
@@ -17,7 +17,7 @@ LAYOUT_DIR="$SCRIPT_DIR/../_layout"
|
||||
DOWNLOAD_DIR="$SCRIPT_DIR/../_downloads/netcore2x"
|
||||
PACKAGE_DIR="$SCRIPT_DIR/../_package"
|
||||
DOTNETSDK_ROOT="$SCRIPT_DIR/../_dotnetsdk"
|
||||
DOTNETSDK_VERSION="8.0.423"
|
||||
DOTNETSDK_VERSION="8.0.421"
|
||||
DOTNETSDK_INSTALLDIR="$DOTNETSDK_ROOT/$DOTNETSDK_VERSION"
|
||||
RUNNER_VERSION=$(cat runnerversion)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "8.0.423"
|
||||
"version": "8.0.421"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
2.336.0
|
||||
2.335.1
|
||||
|
||||
Reference in New Issue
Block a user