Compare commits

..

35 Commits

Author SHA1 Message Date
dependabot[bot]
ec940a333e Bump System.Security.Cryptography.ProtectedData from 10.0.3 to 10.0.10
---
updated-dependencies:
- dependency-name: System.Security.Cryptography.ProtectedData
  dependency-version: 10.0.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-29 12:18:14 +00:00
dependabot[bot]
b8dd9a3e3c Bump System.Formats.Asn1 and System.Security.Cryptography.Pkcs (#4584)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Salman Chishti <salmanmkc@GitHub.com>
2026-07-29 12:14:51 +00:00
dependabot[bot]
cc01a0d090 Bump undici from 6.24.1 to 6.27.0 in /src/Misc/expressionFunc/hashFiles (#4522)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Salman Chishti <salmanmkc@GitHub.com>
2026-07-29 13:06:16 +01:00
dependabot[bot]
03a0707cbe Bump @typescript-eslint/parser from 8.63.0 to 8.65.0 in /src/Misc/expressionFunc/hashFiles (#4582)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-29 11:23:54 +01:00
Francesco Renzi
e1d1844725 Mask and sanitize debugger welcome message before DAP console output (#4577) 2026-07-28 11:31:59 +00:00
dependabot[bot]
44b796f3c8 Bump @typescript-eslint/parser from 8.59.0 to 8.63.0 in /src/Misc/expressionFunc/hashFiles (#4543)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Salman Chishti <salmanmkc@GitHub.com>
2026-07-28 12:01:20 +01:00
dependabot[bot]
6bb3178b59 Bump eslint-plugin-github from 4.10.2 to 6.1.0 in /src/Misc/expressionFunc/hashFiles (#4549)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Salman Chishti <salmanmkc@GitHub.com>
2026-07-28 11:27:28 +01:00
dependabot[bot]
148a3bb616 Bump Microsoft.DevTunnels.Connections from 1.3.48 to 1.3.50 (#4566)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Salman Chishti <salmanmkc@GitHub.com>
2026-07-28 11:22:36 +01:00
dependabot[bot]
e18d1a9845 Bump actions/stale from 10 to 11 (#4576)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 11:21:36 +01:00
dependabot[bot]
14de40d73a Bump Azure.Storage.Blobs from 12.27.0 to 12.29.1 (#4545)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Salman Chishti <salmanmkc@GitHub.com>
2026-07-22 13:05:20 -04:00
dependabot[bot]
8bd0a334d7 Bump actions/setup-node from 6 to 7 (#4554)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Salman Chishti <salmanmkc@GitHub.com>
2026-07-22 09:39:46 +01:00
github-actions[bot]
f898ef14a5 Update dotnet sdk to latest version @8.0.423 (#4561)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-21 14:33:13 +00:00
Luke Tomlinson
de4c2885af Preparing runner release 2.336.0 (#4563)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-07-20 13:33:01 -04:00
Luke Tomlinson
c5480235c4 Recreate session on RunnerSessionInvalid from broker (#4556)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-16 16:00:37 -04:00
Nikola Jokic
67cbf96ce6 Cleanup session files on get message or session deleted error (#4551) 2026-07-16 11:10:25 -04:00
Francesco Renzi
802fd9c0bf Exit ephemeral runners on broker acknowledge job-not-found (#4540)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-07-15 14:50:32 +00:00
Tingluo Huang
9d9b3c3373 Allow checking DNS with api.gihub.com. (#4547) 2026-07-15 10:36:37 -04:00
Aiqiao Yan
1d0011cb67 do not cap migrated setting retry is exception is session conflict (#4557) 2026-07-15 10:00:41 -04:00
Tingluo Huang
32e89e2afd Wait for worker to finish during cancel (#4553) 2026-07-13 13:53:36 -04:00
Jeff Martin
8efad23e6e Setup Job: reword locked-dependencies log line to use lockfile language (#4550) 2026-07-10 10:04:49 -05:00
Jeff Martin
9cd4f29390 Setup Job: announce when running with locked dependencies (#4546) 2026-07-09 17:41:26 +00:00
Philip Gai
6b19888d3b feat: expose effective cache-mode to steps via ACTIONS_CACHE_MODE (#4538) 2026-07-09 17:16:17 +00:00
Brian DeHamer
35e45850b5 Add support for $GITHUB_ARTIFACTS environment files (#4527)
Signed-off-by: Brian DeHamer <bdehamer@github.com>
Co-authored-by: Luke Tomlinson <luketomlinson@github.com>
2026-07-07 16:29:00 -04:00
Stefan Kuhn
5c45757098 Link config.sh and installdependencies.sh in docs (#4526) 2026-07-06 22:30:15 -04:00
github-actions[bot]
a0a67d3939 Update dotnet sdk to latest version @8.0.422 (#4504)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-06 22:01:29 -04:00
Jeff Martin
dde968bf57 feat: add dollar-self action reference syntax (#4457) 2026-07-02 21:35:54 +00:00
Allan Guigou
0e31cd5ff7 Update Docker version to 29.6.1 (#4539) 2026-07-02 15:52:34 -04:00
Tingluo Huang
4c6d85cfc0 feat: enhance telemetry for action download resolution and failures (#4536) 2026-07-01 17:29:02 -04:00
github-actions[bot]
1ed4f70ee9 chore: update Node versions (#4530)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-29 10:27:28 -04:00
github-actions[bot]
c814d7ca46 chore: update Node versions (#4519)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-22 13:48:51 +00:00
github-actions[bot]
302ff10861 Update Docker to v29.6.0 and Buildx to v0.35.0 (#4516)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-22 13:35:29 +00:00
dependabot[bot]
74aa458a12 Bump actions/checkout from 6 to 7 (#4511)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-19 16:20:11 +08:00
Tingluo Huang
c057cc3886 Report actions archive size in telemetry. (#4509) 2026-06-17 11:49:15 -04:00
Lokesh Gopu
16c52e389d Canceled background steps should not impact job result (#4482) 2026-06-08 17:18:11 -04:00
Francesco Renzi
060eeda6e0 Preparing runner release 2.335.0 (#4481)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-08 17:57:10 +01:00
64 changed files with 6562 additions and 2716 deletions

View File

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

View File

@@ -53,7 +53,7 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
# Build runner layout
- name: Build & Layout Release
@@ -95,7 +95,7 @@ jobs:
docker_platform: linux/arm64
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Get latest runner version
id: latest_runner

View File

@@ -7,7 +7,7 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v10
- uses: actions/stale@v11
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"

View File

@@ -7,7 +7,7 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v10
- uses: actions/stale@v11
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"

View File

@@ -23,7 +23,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL

View File

@@ -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@v6
- uses: actions/checkout@v7
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: "20"

View File

@@ -17,7 +17,7 @@ jobs:
BUILDX_CURRENT_VERSION: ${{ steps.check_buildx_version.outputs.CURRENT_VERSION }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Check Docker version
id: check_docker_version
@@ -89,7 +89,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Update Docker version
shell: bash

View File

@@ -20,7 +20,7 @@ jobs:
IMAGE_NAME: ${{ github.repository_owner }}/actions-runner
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
ref: ${{ github.event.inputs.releaseBranch }}

View File

@@ -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@v6
uses: actions/checkout@v7
- 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@v6
- uses: actions/checkout@v7
with:
ref: feature/dotnetsdk-upgrade/${{ needs.dotnet-update.outputs.DOTNET_LATEST_MAJOR_MINOR_PATCH_VERSION }}
- name: Create Pull Request

View File

@@ -9,7 +9,7 @@ jobs:
update-node:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Get latest Node versions
id: node-versions
run: |

View File

@@ -7,9 +7,9 @@ jobs:
npm-audit-with-ts-fix:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: "20"
- name: NPM install and audit fix with TypeScript auto-repair

View File

@@ -9,10 +9,10 @@ jobs:
npm-audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: "20"

View File

@@ -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@v6
- uses: actions/checkout@v7
# 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@v6
- uses: actions/checkout@v7
# Build runner layout
- name: Build & Layout Release
@@ -129,7 +129,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
# 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@v6
uses: actions/checkout@v7
- name: Compute image version
id: image

View File

@@ -7,7 +7,7 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v10
- uses: actions/stale@v11
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."

View File

@@ -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` will check .Net Core 3.x dependencies during runner configuration.
The [config.sh](../../src/Misc/layoutroot/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 `./bin/installdependencies.sh`.
You can easily correct the problem by executing [installdependencies.sh](../../src/Misc/layoutbin/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`).

View File

@@ -5,8 +5,8 @@ ARG TARGETOS
ARG TARGETARCH
ARG RUNNER_VERSION
ARG RUNNER_CONTAINER_HOOKS_VERSION=0.7.0
ARG DOCKER_VERSION=29.5.3
ARG BUILDX_VERSION=0.34.1
ARG DOCKER_VERSION=29.6.1
ARG BUILDX_VERSION=0.35.0
RUN apt update -y && apt install curl unzip -y

View File

@@ -1,40 +1,32 @@
## What's Changed
* 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
* 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
## New Contributors
* @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
* @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
**Full Changelog**: https://github.com/actions/runner/compare/v2.334.0...v2.335.0
**Full Changelog**: https://github.com/actions/runner/compare/v2.335.0...v2.336.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.

View File

@@ -1 +1 @@
2.335.1
<Update to ./src/runnerversion when creating release>

File diff suppressed because it is too large Load Diff

View File

@@ -38,10 +38,10 @@
"@stylistic/eslint-plugin": "^5.10.0",
"@types/node": "^22.0.0",
"@typescript-eslint/eslint-plugin": "^8.59.0",
"@typescript-eslint/parser": "^8.59.0",
"@typescript-eslint/parser": "^8.65.0",
"@vercel/ncc": "^0.38.3",
"eslint": "^8.47.0",
"eslint-plugin-github": "^4.10.2",
"eslint-plugin-github": "^6.1.0",
"eslint-plugin-prettier": "^5.0.0",
"husky": "^9.1.7",
"lint-staged": "^16.4.0",

View File

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

View File

@@ -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)
if (ex is AccessDeniedException || ex is VssUnauthorizedException || ex is RunnerNotFoundException || ex is HostedRunnerDeprovisionedException || ex is TaskAgentSessionExpiredException)
{
return false;
}

View File

@@ -180,6 +180,8 @@ 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
@@ -227,6 +229,12 @@ 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

View File

@@ -62,6 +62,8 @@ 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");

View File

@@ -167,12 +167,15 @@ namespace GitHub.Runner.Listener
Trace.Error("Catch exception during create session.");
Trace.Error(ex);
// If using migrated settings, limit the number of retries before returning failure
if (_isMigratedSettings)
// 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)
{
_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.");
@@ -338,7 +341,14 @@ 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);

View File

@@ -254,6 +254,8 @@ 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;
}

View File

@@ -498,6 +498,7 @@ 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>();
@@ -687,7 +688,7 @@ namespace GitHub.Runner.Listener
else
{
var messageRef = StringUtil.ConvertFromJson<RunnerJobRequestRef>(message.Body);
// Acknowledge (best-effort)
if (messageRef.ShouldAcknowledge) // Temporary feature flag
{
@@ -695,6 +696,12 @@ 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}'");
@@ -813,6 +820,14 @@ 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)
@@ -859,7 +874,7 @@ namespace GitHub.Runner.Listener
messageQueueLoopTokenSource.Dispose();
if (settings.Ephemeral && runOnceJobCompleted)
if ((settings.Ephemeral && runOnceJobCompleted) || cleanupLocalConfigAfter404)
{
configManager.DeleteLocalRunnerConfig();
}

View File

@@ -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))
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)
{
ArgUtil.NotNull(executionContext, nameof(executionContext));
if (depth > Constants.CompositeActionsMaxDepth)
@@ -186,6 +186,21 @@ 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)
@@ -228,7 +243,30 @@ namespace GitHub.Runner.Worker
{
throw new Exception($"Missing download info for {lookupKey}");
}
await DownloadRepositoryActionAsync(executionContext, downloadInfo);
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)}"
});
}
}
// Parse action.yml and collect composite sub-actions for batched
@@ -278,16 +316,53 @@ namespace GitHub.Runner.Worker
// then recurse per parent (which hits the cache, not the API).
if (nextLevel.Count > 0)
{
var nextLevelRepoActions = nextLevel
.Where(x => x.action.Reference.Type == Pipelines.ActionSourceType.Repository)
.Select(x => x.action)
.ToList();
await ResolveNewActionsAsync(executionContext, nextLevelRepoActions, resolvedDownloadInfos);
foreach (var group in nextLevel.GroupBy(x => x.parentId))
if (executionContext.Global.Variables.GetBoolean(Constants.Runner.Features.SelfRepository) == true)
{
var groupActions = group.Select(x => x.action).ToList();
state = await PrepareActionsRecursiveAsync(executionContext, state, groupActions, resolvedDownloadInfos, depth + 1, group.Key);
// 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);
}
}
}
@@ -363,13 +438,25 @@ 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))
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)
{
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)
@@ -398,7 +485,30 @@ namespace GitHub.Runner.Worker
if (repositoryActions.Count > 0)
{
// Get the download info
var downloadInfos = await GetDownloadInfoAsync(executionContext, repositoryActions);
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)}"
});
}
// Download each action
foreach (var action in repositoryActions)
@@ -414,7 +524,29 @@ namespace GitHub.Runner.Worker
throw new Exception($"Missing download info for {lookupKey}");
}
await DownloadRepositoryActionAsync(executionContext, downloadInfo);
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)}"
});
}
}
// More preparation based on content in the repository (action.yml)
@@ -449,7 +581,17 @@ namespace GitHub.Runner.Worker
}
else if (setupInfo != null && setupInfo.Steps != null && setupInfo.Steps.Count > 0)
{
state = await PrepareActionsRecursiveLegacyAsync(executionContext, state, setupInfo.Steps, depth + 1, action.Id);
// 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);
}
var repoAction = action.Reference as Pipelines.RepositoryPathReference;
if (repoAction.RepositoryType != Pipelines.PipelineConstants.SelfAlias)
@@ -562,6 +704,12 @@ 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);
@@ -697,6 +845,27 @@ 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
{
@@ -980,10 +1149,33 @@ namespace GitHub.Runner.Worker
if (actionsToResolve.Count > 0)
{
var downloadInfos = await GetDownloadInfoAsync(executionContext, actionsToResolve);
foreach (var kvp in downloadInfos)
IDictionary<string, WebApi.ActionDownloadInfo> downloadInfos = null;
Exception resolveFailure = null;
try
{
resolvedDownloadInfos[kvp.Key] = kvp.Value;
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)}"
});
}
}
}
@@ -1108,12 +1300,6 @@ 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);
@@ -1122,6 +1308,13 @@ 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
{
@@ -1159,7 +1352,6 @@ 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}.");
}
@@ -1209,6 +1401,12 @@ 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;
@@ -1347,6 +1545,47 @@ 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)
@@ -1362,6 +1601,11 @@ 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);

View File

@@ -0,0 +1,52 @@
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}";
}
}

View File

@@ -0,0 +1,92 @@
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.
}
}
}

View File

@@ -0,0 +1,384 @@
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) { }
}
}
}

View File

@@ -1026,7 +1026,9 @@ namespace GitHub.Runner.Worker.Dap
{
if (!string.IsNullOrEmpty(debuggerConfig.WelcomeMessage))
{
SendOutput("console", debuggerConfig.WelcomeMessage);
// The welcome message is server-supplied and never rendered verbatim:
// mask secrets and strip control characters before it reaches the console.
SendOutput("console", SanitizeConsoleText(MaskUserVisibleText(debuggerConfig.WelcomeMessage)));
Trace.Info("Sent custom welcome message");
}
else
@@ -1772,6 +1774,30 @@ 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>

View File

@@ -36,6 +36,11 @@ 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>

View File

@@ -973,6 +973,9 @@ 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);

View File

@@ -55,6 +55,18 @@ 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);
}
@@ -102,6 +114,14 @@ 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

View File

@@ -15,6 +15,8 @@ namespace GitHub.Runner.Worker
"actor",
"actor_id",
"api_url",
"artifacts",
"artifacts_list",
"base_ref",
"env",
"event_name",

View File

@@ -39,5 +39,9 @@ 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; }
}
}

View File

@@ -239,6 +239,11 @@ 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))

View File

@@ -78,6 +78,11 @@ 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))

View File

@@ -4,6 +4,7 @@ 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;
@@ -45,6 +46,7 @@ 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();
@@ -171,6 +173,12 @@ 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}");
@@ -185,6 +193,13 @@ 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>();
@@ -602,7 +617,21 @@ namespace GitHub.Runner.Worker
{
foreach (var checkUrl in checkUrls)
{
_connectivityCheckTasks.Add(CheckConnectivity(checkUrl, accessToken: string.Empty, timeoutInSeconds: 5, token: CancellationToken.None));
_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));
}
}
}
@@ -901,7 +930,7 @@ namespace GitHub.Runner.Worker
foreach (var check in _connectivityCheckTasks)
{
var result = await check;
Trace.Info($"Connectivity check result: {result}");
Trace.Info($"Connectivity check result: {StringUtil.ConvertToJson(result)}");
context.Global.JobTelemetry.Add(new JobTelemetry() { Type = JobTelemetryType.ConnectivityCheck, Message = $"{result.EndpointUrl}: {result.StatusCode}" });
}
}
@@ -913,6 +942,27 @@ 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)
{
@@ -1004,16 +1054,43 @@ namespace GitHub.Runner.Worker
}
}
private async Task<CheckResult> CheckConnectivity(string endpointUrl, string accessToken, int timeoutInSeconds, CancellationToken token)
private async Task<CheckResult> CheckConnectivity(string endpointUrl, string accessToken, int timeoutInSeconds, bool checkDNS = false, CancellationToken token = default)
{
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))
{
@@ -1024,7 +1101,7 @@ namespace GitHub.Runner.Worker
}
var response = await httpClient.GetAsync(endpointUrl, linkedTokenSource.Token);
result.StatusCode = $"{response.StatusCode}";
result.StatusCode = $"http_{response.StatusCode}";
var githubRequestId = UrlUtil.GetGitHubRequestId(response.Headers);
var vssRequestId = UrlUtil.GetVssRequestId(response.Headers);
@@ -1036,26 +1113,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 = "canceled";
result.StatusCode = "http_canceled";
}
catch (Exception ex) when (ex is OperationCanceledException && timeoutTokenSource.IsCancellationRequested)
{
Trace.Error($"Request timeout during connectivity check: {ex}");
result.StatusCode = "timeout";
result.StatusCode = "http_timeout";
}
catch (Exception ex)
{
Trace.Error($"Catch exception during connectivity check: {ex}");
result.StatusCode = $"{ex.Message}";
result.StatusCode = $"http_{ex.Message}";
}
}
stopwatch.Stop();
result.DurationInMs = (int)stopwatch.ElapsedMilliseconds;
return result;
}
@@ -1140,8 +1217,8 @@ namespace GitHub.Runner.Worker
try
{
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");
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");
if (!testResult.HasFailure &&
result.StatusCode != "OK" &&
result.StatusCode != "canceled")
@@ -1212,13 +1289,19 @@ 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 DurationInMs { get; set; }
public int HttpRequestDurationInMs { get; set; }
public int DNSResolutionDurationInMs { get; set; }
public string DNSError { get; set; }
}
}
}

View File

@@ -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.48" />
<PackageReference Include="Microsoft.DevTunnels.Connections" Version="1.3.50" />
</ItemGroup>
<ItemGroup>

View File

@@ -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 as-is and a null or empty value suppresses the
/// a non-empty value is shown and a null or empty value suppresses the
/// default welcome message. When the flag is not set, the runner shows its
/// built-in help text and this field is ignored.
/// </summary>

View File

@@ -55,7 +55,18 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
break;
case ActionSourceType.Repository:
var repositoryReference = step.Reference as RepositoryPathReference;
name = !String.IsNullOrEmpty(repositoryReference.Name) ? repositoryReference.Name : PipelineConstants.SelfAlias;
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;
}
break;
}
@@ -600,6 +611,14 @@ 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('@');

View File

@@ -38,10 +38,43 @@ namespace GitHub.DistributedTask.Pipelines
public static readonly Int32 MaxNodeNameLength = 100;
/// <summary>
/// Alias for the self repository.
/// Alias for the self local-workspace repository type (./ syntax).
/// Resolves to the local checkout on the runner.
/// </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>

View File

@@ -5,8 +5,10 @@ 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";
}
}

View File

@@ -18,19 +18,19 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.Storage.Blobs" Version="12.27.0" />
<PackageReference Include="Azure.Storage.Blobs" Version="12.29.1" />
<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.7" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.3" />
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="10.0.10" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.10" />
<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.7" />
<PackageReference Include="System.Formats.Asn1" Version="10.0.10" />
</ItemGroup>
<ItemGroup>

View File

@@ -125,6 +125,8 @@ namespace GitHub.Actions.RunService.WebApi
};
case BrokerErrorKind.HostedRunnerDeprovisioned:
throw new HostedRunnerDeprovisionedException(brokerError.Message);
case BrokerErrorKind.RunnerSessionInvalid:
throw new TaskAgentSessionExpiredException(brokerError.Message);
default:
break;
}
@@ -251,10 +253,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;
}
}

View File

@@ -0,0 +1,23 @@
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)
{
}
}
}

View File

@@ -1605,6 +1605,10 @@ 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('@');

View File

@@ -26,10 +26,15 @@ namespace GitHub.Actions.WorkflowParser
internal const Int32 MaxNodeNameLength = 100;
/// <summary>
/// Alias for the self repository.
/// Alias for the self local-workspace repository type (./ syntax).
/// </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";

View File

@@ -363,6 +363,80 @@ 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")]
@@ -400,7 +474,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());
}

View File

@@ -29,6 +29,7 @@ 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()
@@ -46,6 +47,7 @@ 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);
@@ -175,6 +177,96 @@ 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
@@ -871,6 +963,202 @@ 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")]

View File

@@ -0,0 +1,76 @@
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);
}
}
}

View File

@@ -90,6 +90,11 @@ 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
@@ -148,6 +153,11 @@ 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
{
@@ -215,6 +225,51 @@ 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")]
@@ -530,9 +585,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
{
@@ -2386,7 +2441,7 @@ runs:
}
}
[Fact]
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void LoadsNode24ActionDefinition()
@@ -2454,7 +2509,7 @@ runs:
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
@@ -3333,6 +3388,16 @@ 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")]
@@ -3468,5 +3533,604 @@ 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();
}
}
}
}

View File

@@ -0,0 +1,214 @@
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;
}
}
}

View File

@@ -141,6 +141,8 @@ 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>();

View File

@@ -0,0 +1,627 @@
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;
}
}
}

View File

@@ -1422,6 +1422,96 @@ 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")]

View File

@@ -0,0 +1,105 @@
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)
{
}
}
}
}

View File

@@ -1,10 +1,19 @@
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;
@@ -85,5 +94,260 @@ 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;
}
}
}

View File

@@ -199,6 +199,54 @@ 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")]
@@ -238,21 +286,76 @@ 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 JobExtensionBuildFailsWithoutContainerIfRequired()
public async Task InitializeJob_DoesNotLogCacheMode_WhenVariableAbsent()
{
Environment.SetEnvironmentVariable(Constants.Variables.Actions.RequireJobContainer, "true");
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 JobExtensionRunner(null, "", "prepare1", null), new JobExtensionRunner(null, "", "prepare2", null) }, new Dictionary<Guid, IActionRunner>())));
.Returns(Task.FromResult(new PrepareResult(new List<JobExtensionRunner>(), new Dictionary<Guid, IActionRunner>())));
await Assert.ThrowsAsync<ArgumentException>(() => jobExtension.InitializeJob(_jobEc, _message));
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())
{
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>())));
await Assert.ThrowsAsync<ArgumentException>(() => jobExtension.InitializeJob(_jobEc, _message));
}
}
finally
{
Environment.SetEnvironmentVariable(Constants.Variables.Actions.RequireJobContainer, null);
}
}

View File

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

View File

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

View File

@@ -1 +1 @@
2.335.1
2.336.0