mirror of
https://github.com/actions/runner.git
synced 2026-07-08 06:14:20 +08:00
Compare commits
26 Commits
dap-execut
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35e45850b5 | ||
|
|
5c45757098 | ||
|
|
a0a67d3939 | ||
|
|
dde968bf57 | ||
|
|
0e31cd5ff7 | ||
|
|
4c6d85cfc0 | ||
|
|
1ed4f70ee9 | ||
|
|
c814d7ca46 | ||
|
|
302ff10861 | ||
|
|
74aa458a12 | ||
|
|
c057cc3886 | ||
|
|
16c52e389d | ||
|
|
060eeda6e0 | ||
|
|
cbaeeb89ea | ||
|
|
4e51e7980c | ||
|
|
39108f22e4 | ||
|
|
7e0ff4d3e4 | ||
|
|
4864bb5778 | ||
|
|
a3df03d35a | ||
|
|
e6c5af75be | ||
|
|
fb78489197 | ||
|
|
77d6014f58 | ||
|
|
9c2a004d07 | ||
|
|
5053d17b4e | ||
|
|
c6a124e184 | ||
|
|
1a6560294e |
@@ -4,7 +4,7 @@
|
|||||||
"features": {
|
"features": {
|
||||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {},
|
"ghcr.io/devcontainers/features/docker-in-docker:2": {},
|
||||||
"ghcr.io/devcontainers/features/dotnet": {
|
"ghcr.io/devcontainers/features/dotnet": {
|
||||||
"version": "8.0.421"
|
"version": "8.0.422"
|
||||||
},
|
},
|
||||||
"ghcr.io/devcontainers/features/node:1": {
|
"ghcr.io/devcontainers/features/node:1": {
|
||||||
"version": "20"
|
"version": "20"
|
||||||
|
|||||||
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
@@ -53,7 +53,7 @@ jobs:
|
|||||||
|
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
# Build runner layout
|
# Build runner layout
|
||||||
- name: Build & Layout Release
|
- name: Build & Layout Release
|
||||||
@@ -95,7 +95,7 @@ jobs:
|
|||||||
docker_platform: linux/arm64
|
docker_platform: linux/arm64
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Get latest runner version
|
- name: Get latest runner version
|
||||||
id: latest_runner
|
id: latest_runner
|
||||||
|
|||||||
2
.github/workflows/codeql.yml
vendored
2
.github/workflows/codeql.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
|
|||||||
2
.github/workflows/dependency-check.yml
vendored
2
.github/workflows/dependency-check.yml
vendored
@@ -29,7 +29,7 @@ jobs:
|
|||||||
npm-vulnerabilities: ${{ steps.check-versions.outputs.npm-vulnerabilities }}
|
npm-vulnerabilities: ${{ steps.check-versions.outputs.npm-vulnerabilities }}
|
||||||
open-dependency-prs: ${{ steps.check-prs.outputs.open-dependency-prs }}
|
open-dependency-prs: ${{ steps.check-prs.outputs.open-dependency-prs }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
|
|||||||
4
.github/workflows/docker-buildx-upgrade.yml
vendored
4
.github/workflows/docker-buildx-upgrade.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
|||||||
BUILDX_CURRENT_VERSION: ${{ steps.check_buildx_version.outputs.CURRENT_VERSION }}
|
BUILDX_CURRENT_VERSION: ${{ steps.check_buildx_version.outputs.CURRENT_VERSION }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Check Docker version
|
- name: Check Docker version
|
||||||
id: check_docker_version
|
id: check_docker_version
|
||||||
@@ -89,7 +89,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Update Docker version
|
- name: Update Docker version
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|||||||
2
.github/workflows/docker-publish.yml
vendored
2
.github/workflows/docker-publish.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
|||||||
IMAGE_NAME: ${{ github.repository_owner }}/actions-runner
|
IMAGE_NAME: ${{ github.repository_owner }}/actions-runner
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.inputs.releaseBranch }}
|
ref: ${{ github.event.inputs.releaseBranch }}
|
||||||
|
|
||||||
|
|||||||
4
.github/workflows/dotnet-upgrade.yml
vendored
4
.github/workflows/dotnet-upgrade.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
|||||||
DOTNET_CURRENT_MAJOR_MINOR_VERSION: ${{ steps.fetch_current_version.outputs.DOTNET_CURRENT_MAJOR_MINOR_VERSION }}
|
DOTNET_CURRENT_MAJOR_MINOR_VERSION: ${{ steps.fetch_current_version.outputs.DOTNET_CURRENT_MAJOR_MINOR_VERSION }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
- name: Get current major minor version
|
- name: Get current major minor version
|
||||||
id: fetch_current_version
|
id: fetch_current_version
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -89,7 +89,7 @@ jobs:
|
|||||||
if: ${{ needs.dotnet-update.outputs.SHOULD_UPDATE == 1 && needs.dotnet-update.outputs.BRANCH_EXISTS == 0 }}
|
if: ${{ needs.dotnet-update.outputs.SHOULD_UPDATE == 1 && needs.dotnet-update.outputs.BRANCH_EXISTS == 0 }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
ref: feature/dotnetsdk-upgrade/${{ needs.dotnet-update.outputs.DOTNET_LATEST_MAJOR_MINOR_PATCH_VERSION }}
|
ref: feature/dotnetsdk-upgrade/${{ needs.dotnet-update.outputs.DOTNET_LATEST_MAJOR_MINOR_PATCH_VERSION }}
|
||||||
- name: Create Pull Request
|
- name: Create Pull Request
|
||||||
|
|||||||
2
.github/workflows/node-upgrade.yml
vendored
2
.github/workflows/node-upgrade.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
|||||||
update-node:
|
update-node:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
- name: Get latest Node versions
|
- name: Get latest Node versions
|
||||||
id: node-versions
|
id: node-versions
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
2
.github/workflows/npm-audit-typescript.yml
vendored
2
.github/workflows/npm-audit-typescript.yml
vendored
@@ -7,7 +7,7 @@ jobs:
|
|||||||
npm-audit-with-ts-fix:
|
npm-audit-with-ts-fix:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
|
|||||||
2
.github/workflows/npm-audit.yml
vendored
2
.github/workflows/npm-audit.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
|||||||
npm-audit:
|
npm-audit:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
|
|||||||
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
|||||||
if: startsWith(github.ref, 'refs/heads/releases/') || github.ref == 'refs/heads/main'
|
if: startsWith(github.ref, 'refs/heads/releases/') || github.ref == 'refs/heads/main'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
# Make sure ./releaseVersion match ./src/runnerversion
|
# Make sure ./releaseVersion match ./src/runnerversion
|
||||||
# Query GitHub release ensure version is not used
|
# Query GitHub release ensure version is not used
|
||||||
@@ -86,7 +86,7 @@ jobs:
|
|||||||
|
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
# Build runner layout
|
# Build runner layout
|
||||||
- name: Build & Layout Release
|
- name: Build & Layout Release
|
||||||
@@ -129,7 +129,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
# Download runner package tar.gz/zip produced by 'build' job
|
# Download runner package tar.gz/zip produced by 'build' job
|
||||||
- name: Download Artifact (win-x64)
|
- name: Download Artifact (win-x64)
|
||||||
@@ -296,7 +296,7 @@ jobs:
|
|||||||
IMAGE_NAME: ${{ github.repository_owner }}/actions-runner
|
IMAGE_NAME: ${{ github.repository_owner }}/actions-runner
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Compute image version
|
- name: Compute image version
|
||||||
id: image
|
id: image
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Please see "[Supported architectures and operating systems for self-hosted runne
|
|||||||
|
|
||||||
## Install .Net Core 3.x Linux Dependencies
|
## 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.
|
You might see something like this which indicate a dependency's missing.
|
||||||
```bash
|
```bash
|
||||||
./config.sh
|
./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
|
Dependencies is missing for Dotnet Core 6.0
|
||||||
Execute ./bin/installdependencies.sh to install any missing Dotnet Core 6.0 dependencies.
|
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
|
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`).
|
> Note: The `installdependencies.sh` script will try to use the default package management mechanism on your Linux flavor (ex. `yum`/`apt-get`/`apt`).
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ ARG TARGETOS
|
|||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
ARG RUNNER_VERSION
|
ARG RUNNER_VERSION
|
||||||
ARG RUNNER_CONTAINER_HOOKS_VERSION=0.7.0
|
ARG RUNNER_CONTAINER_HOOKS_VERSION=0.7.0
|
||||||
ARG DOCKER_VERSION=29.5.0
|
ARG DOCKER_VERSION=29.6.1
|
||||||
ARG BUILDX_VERSION=0.34.0
|
ARG BUILDX_VERSION=0.35.0
|
||||||
|
|
||||||
RUN apt update -y && apt install curl unzip -y
|
RUN apt update -y && apt install curl unzip -y
|
||||||
|
|
||||||
|
|||||||
@@ -1,36 +1,40 @@
|
|||||||
## What's Changed
|
## What's Changed
|
||||||
* Bump flatted from 3.2.7 to 3.4.2 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4307
|
* Bump System.ServiceProcess.ServiceController from 10.0.6 to 10.0.7 by @dependabot[bot] in https://github.com/actions/runner/pull/4370
|
||||||
* Add DAP server by @rentziass in https://github.com/actions/runner/pull/4298
|
* 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
|
||||||
* Bump @typescript-eslint/eslint-plugin from 8.57.1 to 8.57.2 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4310
|
* feat: propagate actions dependencies by @nodeselector in https://github.com/actions/runner/pull/4372
|
||||||
* Remove AllowCaseFunction feature flag by @ericsciple in https://github.com/actions/runner/pull/4316
|
* Not retry and report action download 403. by @TingluoHuang in https://github.com/actions/runner/pull/4391
|
||||||
* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4319
|
* Update setup job starting logs by @GitPaulo in https://github.com/actions/runner/pull/4383
|
||||||
* Batch and deduplicate action resolution across composite depths by @stefanpenner in https://github.com/actions/runner/pull/4296
|
* fix: expand commit hash regex to support SHA-256 (64-char) hashes by @yaananth in https://github.com/actions/runner/pull/4347
|
||||||
* Add support for Bearer token in action archive downloads by @TingluoHuang in https://github.com/actions/runner/pull/4321
|
* Move dap setup to setup job step by @rentziass in https://github.com/actions/runner/pull/4403
|
||||||
* Bump brace-expansion in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4318
|
* Add support for Ubuntu 26.04 (liblttng-ust1t64, libicu77-80) by @dvaldivia in https://github.com/actions/runner/pull/4394
|
||||||
* Add devtunnel connection for debugger jobs by @rentziass in https://github.com/actions/runner/pull/4317
|
* 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.3.1 and Buildx to v0.33.0 by @github-actions[bot] in https://github.com/actions/runner/pull/4324
|
* Update Docker to v29.5.0 and Buildx to v0.34.0 by @github-actions[bot] in https://github.com/actions/runner/pull/4425
|
||||||
* Bump @typescript-eslint/eslint-plugin from 8.57.2 to 8.58.1 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4327
|
* Execute debugger REPL commands inside job container by @rentziass in https://github.com/actions/runner/pull/4420
|
||||||
* Bump actions/github-script from 8 to 9 by @dependabot[bot] in https://github.com/actions/runner/pull/4331
|
* Send welcome message in debugger console on connect by @rentziass in https://github.com/actions/runner/pull/4419
|
||||||
* Bump typescript from 5.9.3 to 6.0.2 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4329
|
* Update snapshot-if context and functions by @drielenr in https://github.com/actions/runner/pull/4443
|
||||||
* fix: only show changed versions in node upgrade PR description by @salmanmkc in https://github.com/actions/runner/pull/4332
|
* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4452
|
||||||
* Bump System.Formats.Asn1, Cryptography.Pkcs, ProtectedData, ServiceController, CodePages, Threading.Channels, @actions/glob, @typescript-eslint/parser, lint-staged, picomatch by @Copilot in https://github.com/actions/runner/pull/4333
|
* Allow disable node v8 maglev jit compiler on node24. by @TingluoHuang in https://github.com/actions/runner/pull/4447
|
||||||
* feat: add `job.workflow_*` typed accessors to JobContext by @salmanmkc in https://github.com/actions/runner/pull/4335
|
* Update Node 24 default date to June 16th, 2026 by @salmanmkc in https://github.com/actions/runner/pull/4462
|
||||||
* Add WS bridge over DAP TCP server by @rentziass in https://github.com/actions/runner/pull/4328
|
* Populate telemetry for non-action post-job steps by @drielenr in https://github.com/actions/runner/pull/4463
|
||||||
* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4355
|
* Add SDK types and results plumbing for background step control by @lokesh755 in https://github.com/actions/runner/pull/4472
|
||||||
* Bump Docker version to 29.4.0 by @Copilot in https://github.com/actions/runner/pull/4352
|
* Add job execution view model by @rentziass in https://github.com/actions/runner/pull/4470
|
||||||
* Update dotnet sdk to latest version @8.0.420 by @github-actions[bot] in https://github.com/actions/runner/pull/4356
|
* Add thread-safety locks to StepsContext by @lokesh755 in https://github.com/actions/runner/pull/4475
|
||||||
* Bump @typescript-eslint/parser from 8.58.1 to 8.59.0 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4360
|
* Add background step deferral infrastructure and metadata plumbing by @lokesh755 in https://github.com/actions/runner/pull/4479
|
||||||
* Bump System.Formats.Asn1 and System.Security.Cryptography.Pkcs by @dependabot[bot] in https://github.com/actions/runner/pull/4362
|
* Wire job execution view into DAP by @rentziass in https://github.com/actions/runner/pull/4471
|
||||||
* Add vulnerability-alerts permission by @salmanmkc in https://github.com/actions/runner/pull/4350
|
* Background steps execution engine by @lokesh755 in https://github.com/actions/runner/pull/4476
|
||||||
* Bump @typescript-eslint/eslint-plugin from 8.58.1 to 8.59.0 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4359
|
* Update Docker to v29.5.2 and Buildx to v0.34.1 by @github-actions[bot] in https://github.com/actions/runner/pull/4451
|
||||||
* Bump System.ServiceProcess.ServiceController from 10.0.3 to 10.0.6 by @dependabot[bot] in https://github.com/actions/runner/pull/4358
|
* BrokerServer should not retry on 401. by @TingluoHuang in https://github.com/actions/runner/pull/4445
|
||||||
* Bump typescript from 6.0.2 to 6.0.3 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4353
|
* 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.16 to 1.3.39 by @dependabot[bot] in https://github.com/actions/runner/pull/4339
|
* Bump Microsoft.DevTunnels.Connections from 1.3.39 to 1.3.48 by @dependabot[bot] in https://github.com/actions/runner/pull/4441
|
||||||
|
* Bump System.Formats.Asn1 and System.Security.Cryptography.Pkcs by @dependabot[bot] in https://github.com/actions/runner/pull/4369
|
||||||
|
|
||||||
## New Contributors
|
## New Contributors
|
||||||
* @stefanpenner made their first contribution in https://github.com/actions/runner/pull/4296
|
* @GitPaulo made their first contribution in https://github.com/actions/runner/pull/4383
|
||||||
|
* @dvaldivia made their first contribution in https://github.com/actions/runner/pull/4394
|
||||||
|
* @drielenr made their first contribution in https://github.com/actions/runner/pull/4443
|
||||||
|
* @nuclearpidgeon made their first contribution in https://github.com/actions/runner/pull/4424
|
||||||
|
|
||||||
**Full Changelog**: https://github.com/actions/runner/compare/v2.333.1...v2.334.0
|
**Full Changelog**: https://github.com/actions/runner/compare/v2.334.0...v2.335.0
|
||||||
|
|
||||||
_Note: Actions Runner follows a progressive release policy, so the latest release might not be available to your enterprise, organization, or repository yet.
|
_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.
|
To confirm which version of the Actions Runner you should expect, please view the download instructions for your enterprise, organization, or repository.
|
||||||
|
|||||||
@@ -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.
|
# 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
|
# Follow the instructions here: https://github.com/actions/alpine_nodejs?tab=readme-ov-file#getting-started
|
||||||
NODE20_VERSION="20.20.2"
|
NODE20_VERSION="20.20.2"
|
||||||
NODE24_VERSION="24.16.0"
|
NODE24_VERSION="24.18.0"
|
||||||
|
|
||||||
get_abs_path() {
|
get_abs_path() {
|
||||||
# exploits the fact that pwd will print abs path when no args
|
# exploits the fact that pwd will print abs path when no args
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ namespace GitHub.Runner.Common
|
|||||||
|
|
||||||
public bool ShouldRetryException(Exception ex)
|
public bool ShouldRetryException(Exception ex)
|
||||||
{
|
{
|
||||||
if (ex is AccessDeniedException || ex is RunnerNotFoundException || ex is HostedRunnerDeprovisionedException)
|
if (ex is AccessDeniedException || ex is VssUnauthorizedException || ex is RunnerNotFoundException || ex is HostedRunnerDeprovisionedException)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,6 +180,8 @@ namespace GitHub.Runner.Common
|
|||||||
public static readonly string BatchActionResolution = "actions_batch_action_resolution";
|
public static readonly string BatchActionResolution = "actions_batch_action_resolution";
|
||||||
public static readonly string UseBearerTokenForCodeload = "actions_use_bearer_token_for_codeload";
|
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 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
|
// Node version migration related constants
|
||||||
@@ -206,7 +208,7 @@ namespace GitHub.Runner.Common
|
|||||||
public static readonly string Node20DeprecationUrl = "https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/";
|
public static readonly string Node20DeprecationUrl = "https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/";
|
||||||
|
|
||||||
// Node 20 migration dates (hardcoded fallbacks, can be overridden via job variables)
|
// Node 20 migration dates (hardcoded fallbacks, can be overridden via job variables)
|
||||||
public static readonly string Node24DefaultDate = "June 2nd, 2026";
|
public static readonly string Node24DefaultDate = "June 16th, 2026";
|
||||||
public static readonly string Node20RemovalDate = "September 16th, 2026";
|
public static readonly string Node20RemovalDate = "September 16th, 2026";
|
||||||
|
|
||||||
// Variable keys for server-overridable dates
|
// Variable keys for server-overridable dates
|
||||||
@@ -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 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 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";
|
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
|
public static class RunnerEvent
|
||||||
@@ -308,6 +316,7 @@ namespace GitHub.Runner.Common
|
|||||||
public static readonly string ForcedInternalNodeVersion = "ACTIONS_RUNNER_FORCED_INTERNAL_NODE_VERSION";
|
public static readonly string ForcedInternalNodeVersion = "ACTIONS_RUNNER_FORCED_INTERNAL_NODE_VERSION";
|
||||||
public static readonly string ForcedActionsNodeVersion = "ACTIONS_RUNNER_FORCE_ACTIONS_NODE_VERSION";
|
public static readonly string ForcedActionsNodeVersion = "ACTIONS_RUNNER_FORCE_ACTIONS_NODE_VERSION";
|
||||||
public static readonly string PrintLogToStdout = "ACTIONS_RUNNER_PRINT_LOG_TO_STDOUT";
|
public static readonly string PrintLogToStdout = "ACTIONS_RUNNER_PRINT_LOG_TO_STDOUT";
|
||||||
|
public static readonly string DisableStdoutMultilineLogPrefixing = "ACTIONS_RUNNER_DISABLE_STDOUT_MULTILINE_LOG_PREFIXING";
|
||||||
public static readonly string ActionArchiveCacheDirectory = "ACTIONS_RUNNER_ACTION_ARCHIVE_CACHE";
|
public static readonly string ActionArchiveCacheDirectory = "ACTIONS_RUNNER_ACTION_ARCHIVE_CACHE";
|
||||||
public static readonly string SymlinkCachedActions = "ACTIONS_RUNNER_SYMLINK_CACHED_ACTIONS";
|
public static readonly string SymlinkCachedActions = "ACTIONS_RUNNER_SYMLINK_CACHED_ACTIONS";
|
||||||
public static readonly string EmitCompositeMarkers = "ACTIONS_RUNNER_EMIT_COMPOSITE_MARKERS";
|
public static readonly string EmitCompositeMarkers = "ACTIONS_RUNNER_EMIT_COMPOSITE_MARKERS";
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ namespace GitHub.Runner.Common
|
|||||||
Add<T>(extensions, "GitHub.Runner.Worker.CreateStepSummaryCommand, Runner.Worker");
|
Add<T>(extensions, "GitHub.Runner.Worker.CreateStepSummaryCommand, Runner.Worker");
|
||||||
Add<T>(extensions, "GitHub.Runner.Worker.SaveStateFileCommand, 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.SetOutputFileCommand, Runner.Worker");
|
||||||
|
Add<T>(extensions, "GitHub.Runner.Worker.CreateArtifactsFileCommand, Runner.Worker");
|
||||||
|
Add<T>(extensions, "GitHub.Runner.Worker.ArtifactsListFileCommand, Runner.Worker");
|
||||||
break;
|
break;
|
||||||
case "GitHub.Runner.Listener.Check.ICheckExtension":
|
case "GitHub.Runner.Listener.Check.ICheckExtension":
|
||||||
Add<T>(extensions, "GitHub.Runner.Listener.Check.InternetCheck, Runner.Listener");
|
Add<T>(extensions, "GitHub.Runner.Listener.Check.InternetCheck, Runner.Listener");
|
||||||
|
|||||||
@@ -837,6 +837,15 @@ namespace GitHub.Runner.Common
|
|||||||
timelineRecord.Variables[variable.Key] = variable.Value.Clone();
|
timelineRecord.Variables[variable.Key] = variable.Value.Clone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Merge background step metadata
|
||||||
|
if (rec.IsBackground)
|
||||||
|
{
|
||||||
|
timelineRecord.IsBackground = rec.IsBackground;
|
||||||
|
}
|
||||||
|
timelineRecord.BackgroundControlType = rec.BackgroundControlType ?? timelineRecord.BackgroundControlType;
|
||||||
|
timelineRecord.BackgroundControlStepIds = rec.BackgroundControlStepIds ?? timelineRecord.BackgroundControlStepIds;
|
||||||
|
timelineRecord.ParallelGroupId = rec.ParallelGroupId ?? timelineRecord.ParallelGroupId;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
@@ -9,10 +9,12 @@ namespace GitHub.Runner.Common
|
|||||||
public sealed class StdoutTraceListener : ConsoleTraceListener
|
public sealed class StdoutTraceListener : ConsoleTraceListener
|
||||||
{
|
{
|
||||||
private readonly string _hostType;
|
private readonly string _hostType;
|
||||||
|
private readonly bool _disablePrefixMultilineLogs = false;
|
||||||
|
|
||||||
public StdoutTraceListener(string hostType)
|
public StdoutTraceListener(string hostType)
|
||||||
{
|
{
|
||||||
this._hostType = hostType;
|
this._hostType = hostType;
|
||||||
|
this._disablePrefixMultilineLogs = StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable(Constants.Variables.Agent.DisableStdoutMultilineLogPrefixing));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copied and modified slightly from .Net Core source code. Modification was required to make it compile.
|
// Copied and modified slightly from .Net Core source code. Modification was required to make it compile.
|
||||||
@@ -26,11 +28,20 @@ namespace GitHub.Runner.Common
|
|||||||
|
|
||||||
if (!string.IsNullOrEmpty(message))
|
if (!string.IsNullOrEmpty(message))
|
||||||
{
|
{
|
||||||
var messageLines = message.Split(Environment.NewLine);
|
if (!this._disablePrefixMultilineLogs)
|
||||||
foreach (var messageLine in messageLines)
|
{
|
||||||
|
var messageLines = message.Split(Environment.NewLine);
|
||||||
|
foreach (var messageLine in messageLines)
|
||||||
|
{
|
||||||
|
WriteHeader(source, eventType, id);
|
||||||
|
WriteLine(messageLine);
|
||||||
|
WriteFooter(eventCache);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
{
|
{
|
||||||
WriteHeader(source, eventType, id);
|
WriteHeader(source, eventType, id);
|
||||||
WriteLine(messageLine);
|
WriteLine(message);
|
||||||
WriteFooter(eventCache);
|
WriteFooter(eventCache);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -282,8 +282,15 @@ namespace GitHub.Runner.Worker
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
context.Global.EnvironmentVariables[envName] = command.Data;
|
if (context.DeferredEnvironmentVariables != null)
|
||||||
context.SetEnvContext(envName, command.Data);
|
{
|
||||||
|
context.DeferredEnvironmentVariables[envName] = command.Data;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
context.Global.EnvironmentVariables[envName] = command.Data;
|
||||||
|
context.SetEnvContext(envName, command.Data);
|
||||||
|
}
|
||||||
context.Debug($"{envName}='{command.Data}'");
|
context.Debug($"{envName}='{command.Data}'");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,8 +341,15 @@ namespace GitHub.Runner.Worker
|
|||||||
throw new Exception("Required field 'name' is missing in ##[set-output] command.");
|
throw new Exception("Required field 'name' is missing in ##[set-output] command.");
|
||||||
}
|
}
|
||||||
|
|
||||||
context.SetOutput(outputName, command.Data, out var reference);
|
if (context.DeferredOutputs != null)
|
||||||
context.Debug($"{reference}='{command.Data}'");
|
{
|
||||||
|
context.DeferredOutputs[outputName] = command.Data;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
context.SetOutput(outputName, command.Data, out var reference);
|
||||||
|
context.Debug($"{reference}='{command.Data}'");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static class SetOutputCommandProperties
|
private static class SetOutputCommandProperties
|
||||||
@@ -465,8 +479,16 @@ namespace GitHub.Runner.Worker
|
|||||||
}
|
}
|
||||||
|
|
||||||
ArgUtil.NotNullOrEmpty(command.Data, "path");
|
ArgUtil.NotNullOrEmpty(command.Data, "path");
|
||||||
context.Global.PrependPath.RemoveAll(x => string.Equals(x, command.Data, StringComparison.CurrentCulture));
|
if (context.DeferredPrependPath != null)
|
||||||
context.Global.PrependPath.Add(command.Data);
|
{
|
||||||
|
context.DeferredPrependPath.RemoveAll(x => string.Equals(x, command.Data, StringComparison.CurrentCulture));
|
||||||
|
context.DeferredPrependPath.Add(command.Data);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
context.Global.PrependPath.RemoveAll(x => string.Equals(x, command.Data, StringComparison.CurrentCulture));
|
||||||
|
context.Global.PrependPath.Add(command.Data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ namespace GitHub.Runner.Worker
|
|||||||
return new PrepareResult(containerSetupSteps, result.PreStepTracker);
|
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));
|
ArgUtil.NotNull(executionContext, nameof(executionContext));
|
||||||
if (depth > Constants.CompositeActionsMaxDepth)
|
if (depth > Constants.CompositeActionsMaxDepth)
|
||||||
@@ -186,6 +186,21 @@ namespace GitHub.Runner.Worker
|
|||||||
throw new Exception($"Composite action depth exceeded max 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))
|
||||||
|
{
|
||||||
|
// 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>();
|
var repositoryActions = new List<Pipelines.ActionStep>();
|
||||||
|
|
||||||
foreach (var action in actions)
|
foreach (var action in actions)
|
||||||
@@ -228,7 +243,30 @@ namespace GitHub.Runner.Worker
|
|||||||
{
|
{
|
||||||
throw new Exception($"Missing download info for {lookupKey}");
|
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
|
// 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).
|
// then recurse per parent (which hits the cache, not the API).
|
||||||
if (nextLevel.Count > 0)
|
if (nextLevel.Count > 0)
|
||||||
{
|
{
|
||||||
var nextLevelRepoActions = nextLevel
|
if (executionContext.Global.Variables.GetBoolean(Constants.Runner.Features.SelfRepository) == true)
|
||||||
.Where(x => x.action.Reference.Type == Pipelines.ActionSourceType.Repository)
|
|
||||||
.Select(x => x.action)
|
|
||||||
.ToList();
|
|
||||||
await ResolveNewActionsAsync(executionContext, nextLevelRepoActions, resolvedDownloadInfos);
|
|
||||||
|
|
||||||
foreach (var group in nextLevel.GroupBy(x => x.parentId))
|
|
||||||
{
|
{
|
||||||
var groupActions = group.Select(x => x.action).ToList();
|
// Self-repository path: group by parent so each group's
|
||||||
state = await PrepareActionsRecursiveAsync(executionContext, state, groupActions, resolvedDownloadInfos, depth + 1, group.Key);
|
// $/ 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.
|
/// sub-actions individually, with no cross-depth deduplication.
|
||||||
/// Used when the BatchActionResolution feature flag is disabled.
|
/// Used when the BatchActionResolution feature flag is disabled.
|
||||||
/// </summary>
|
/// </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));
|
ArgUtil.NotNull(executionContext, nameof(executionContext));
|
||||||
if (depth > Constants.CompositeActionsMaxDepth)
|
if (depth > Constants.CompositeActionsMaxDepth)
|
||||||
{
|
{
|
||||||
throw new Exception($"Composite action depth exceeded max 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>();
|
var repositoryActions = new List<Pipelines.ActionStep>();
|
||||||
|
|
||||||
foreach (var action in actions)
|
foreach (var action in actions)
|
||||||
@@ -398,7 +485,30 @@ namespace GitHub.Runner.Worker
|
|||||||
if (repositoryActions.Count > 0)
|
if (repositoryActions.Count > 0)
|
||||||
{
|
{
|
||||||
// Get the download info
|
// 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
|
// Download each action
|
||||||
foreach (var action in repositoryActions)
|
foreach (var action in repositoryActions)
|
||||||
@@ -414,7 +524,29 @@ namespace GitHub.Runner.Worker
|
|||||||
throw new Exception($"Missing download info for {lookupKey}");
|
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)
|
// 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)
|
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;
|
var repoAction = action.Reference as Pipelines.RepositoryPathReference;
|
||||||
if (repoAction.RepositoryType != Pipelines.PipelineConstants.SelfAlias)
|
if (repoAction.RepositoryType != Pipelines.PipelineConstants.SelfAlias)
|
||||||
@@ -562,6 +704,12 @@ namespace GitHub.Runner.Worker
|
|||||||
actionDirectory = Path.Combine(actionDirectory, repoAction.Path);
|
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
|
else
|
||||||
{
|
{
|
||||||
actionDirectory = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Actions), repoAction.Name.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar), repoAction.Ref);
|
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);
|
_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
|
else
|
||||||
{
|
{
|
||||||
@@ -980,10 +1149,33 @@ namespace GitHub.Runner.Worker
|
|||||||
|
|
||||||
if (actionsToResolve.Count > 0)
|
if (actionsToResolve.Count > 0)
|
||||||
{
|
{
|
||||||
var downloadInfos = await GetDownloadInfoAsync(executionContext, actionsToResolve);
|
IDictionary<string, WebApi.ActionDownloadInfo> downloadInfos = null;
|
||||||
foreach (var kvp in downloadInfos)
|
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)
|
if (!useActionArchiveCache)
|
||||||
{
|
{
|
||||||
await DownloadRepositoryArchive(executionContext, link, downloadInfo.Authentication?.Token, archiveFile);
|
await DownloadRepositoryArchive(executionContext, link, downloadInfo.Authentication?.Token, archiveFile);
|
||||||
@@ -1122,6 +1308,13 @@ namespace GitHub.Runner.Worker
|
|||||||
var stagingDirectory = Path.Combine(tempDirectory, "_staging");
|
var stagingDirectory = Path.Combine(tempDirectory, "_staging");
|
||||||
Directory.CreateDirectory(stagingDirectory);
|
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
|
#if OS_WINDOWS
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -1159,7 +1352,6 @@ namespace GitHub.Runner.Worker
|
|||||||
int exitCode = await processInvoker.ExecuteAsync(stagingDirectory, tar, $"-xzf \"{archiveFile}\"", null, executionContext.CancellationToken);
|
int exitCode = await processInvoker.ExecuteAsync(stagingDirectory, tar, $"-xzf \"{archiveFile}\"", null, executionContext.CancellationToken);
|
||||||
if (exitCode != 0)
|
if (exitCode != 0)
|
||||||
{
|
{
|
||||||
var fileInfo = new FileInfo(archiveFile);
|
|
||||||
var sha256hash = await IOUtil.GetFileContentSha256HashAsync(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}.");
|
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 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)
|
private ActionSetupInfo PrepareRepositoryActionAsync(IExecutionContext executionContext, Pipelines.ActionStep repositoryAction)
|
||||||
{
|
{
|
||||||
var repositoryReference = repositoryAction.Reference as Pipelines.RepositoryPathReference;
|
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)
|
private static string GetDownloadInfoLookupKey(Pipelines.ActionStep action)
|
||||||
{
|
{
|
||||||
if (action.Reference.Type != Pipelines.ActionSourceType.Repository)
|
if (action.Reference.Type != Pipelines.ActionSourceType.Repository)
|
||||||
@@ -1362,6 +1601,11 @@ namespace GitHub.Runner.Worker
|
|||||||
return null;
|
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))
|
if (!string.Equals(repositoryReference.RepositoryType, Pipelines.RepositoryTypes.GitHub, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
throw new NotSupportedException(repositoryReference.RepositoryType);
|
throw new NotSupportedException(repositoryReference.RepositoryType);
|
||||||
|
|||||||
52
src/Runner.Worker/ArtifactSubject.cs
Normal file
52
src/Runner.Worker/ArtifactSubject.cs
Normal 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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
92
src/Runner.Worker/ArtifactsListFileCommand.cs
Normal file
92
src/Runner.Worker/ArtifactsListFileCommand.cs
Normal 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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/Runner.Worker/BackgroundStepControlFlowData.cs
Normal file
21
src/Runner.Worker/BackgroundStepControlFlowData.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace GitHub.Runner.Worker
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Pure data for control-flow steps (wait, wait-all, cancel).
|
||||||
|
/// Type uses Pipelines.BackgroundControlTypes string constants.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class BackgroundStepControlFlowData
|
||||||
|
{
|
||||||
|
public string Type { get; set; }
|
||||||
|
public Guid StepId { get; set; }
|
||||||
|
public string StepName { get; set; }
|
||||||
|
|
||||||
|
// Target step IDs (for wait: steps to wait for; for cancel: steps to cancel)
|
||||||
|
public string[] StepIds { get; set; }
|
||||||
|
|
||||||
|
// Parallel group ID for grouping steps in the UI
|
||||||
|
public string ParallelGroupId { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
394
src/Runner.Worker/BackgroundStepCoordinator.cs
Normal file
394
src/Runner.Worker/BackgroundStepCoordinator.cs
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using GitHub.DistributedTask.WebApi;
|
||||||
|
using GitHub.Runner.Common;
|
||||||
|
using GitHub.Runner.Common.Util;
|
||||||
|
using GitHub.Runner.Sdk;
|
||||||
|
using Pipelines = GitHub.DistributedTask.Pipelines;
|
||||||
|
|
||||||
|
namespace GitHub.Runner.Worker
|
||||||
|
{
|
||||||
|
[ServiceLocator(Default = typeof(BackgroundStepCoordinator))]
|
||||||
|
public interface IBackgroundStepCoordinator : IRunnerService
|
||||||
|
{
|
||||||
|
void InitializeCoordinator(int maxConcurrent);
|
||||||
|
void StartBackgroundStep(IStep step, CancellationToken jobCancellationToken);
|
||||||
|
Task<TaskResult> WaitForUnwaitedStepsAsync(CancellationToken cancellationToken);
|
||||||
|
Task RunControlFlowAsync(IExecutionContext stepContext, object data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Coordinates background step execution, waiting, cancellation, and deferred state.
|
||||||
|
/// Extracted from StepsRunner so the main step loop stays clean.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class BackgroundStepCoordinator : RunnerService, IBackgroundStepCoordinator
|
||||||
|
{
|
||||||
|
private const int DefaultMaxBackgroundSteps = 10;
|
||||||
|
private readonly Dictionary<string, (IStep Step, Task Task, CancellationTokenSource Cts)> _backgroundSteps = new();
|
||||||
|
|
||||||
|
// IDs of background steps that have already been completed (waited on or canceled).
|
||||||
|
// Used to avoid waiting on or flushing the same step more than once.
|
||||||
|
private readonly HashSet<string> _completedStepIds = new();
|
||||||
|
|
||||||
|
// IDs of background steps that were explicitly canceled via a `cancel` control step.
|
||||||
|
// These steps are expected to be canceled, so their (Canceled) result must not be
|
||||||
|
// merged into the overall job result.
|
||||||
|
private readonly HashSet<string> _explicitlyCanceledStepIds = new();
|
||||||
|
private SemaphoreSlim _backgroundSlotSemaphore = new SemaphoreSlim(DefaultMaxBackgroundSteps);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reset per-job state. Call at the start of each job.
|
||||||
|
/// </summary>
|
||||||
|
public void InitializeCoordinator(int maxConcurrent)
|
||||||
|
{
|
||||||
|
_backgroundSteps.Clear();
|
||||||
|
_completedStepIds.Clear();
|
||||||
|
_explicitlyCanceledStepIds.Clear();
|
||||||
|
var max = maxConcurrent > 0 ? maxConcurrent : DefaultMaxBackgroundSteps;
|
||||||
|
_backgroundSlotSemaphore = new SemaphoreSlim(max);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// Starting background steps
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Prepare and launch a background step. Does not block the caller.
|
||||||
|
/// </summary>
|
||||||
|
public void StartBackgroundStep(IStep step, CancellationToken jobCancellationToken)
|
||||||
|
{
|
||||||
|
var stepId = step.ExecutionContext?.ContextName ?? step.DisplayName;
|
||||||
|
|
||||||
|
// Isolate GitHubContext so concurrent steps don't overwrite each other's GITHUB_OUTPUT paths
|
||||||
|
if (step.ExecutionContext.ExpressionValues.TryGetValue("github", out var ghCtx) && ghCtx is GitHubContext sharedGitHub)
|
||||||
|
{
|
||||||
|
step.ExecutionContext.ExpressionValues["github"] = sharedGitHub.ShallowCopy();
|
||||||
|
}
|
||||||
|
|
||||||
|
var bgCts = CancellationTokenSource.CreateLinkedTokenSource(jobCancellationToken);
|
||||||
|
|
||||||
|
// Evaluate timeout on the main thread (needs expression context)
|
||||||
|
var timeoutMinutes = 0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var templateEvaluator = step.ExecutionContext.ToPipelineTemplateEvaluator();
|
||||||
|
timeoutMinutes = templateEvaluator.EvaluateStepTimeout(step.Timeout, step.ExecutionContext.ExpressionValues, step.ExecutionContext.ExpressionFunctions);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Trace.Info($"Error determining timeout for background step '{stepId}': {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var task = ExecuteBackgroundStepCoreAsync(step, bgCts, stepId, timeoutMinutes);
|
||||||
|
_backgroundSteps[stepId] = (step, task, bgCts);
|
||||||
|
Trace.Info($"Background step '{stepId}' queued (slot will be acquired asynchronously).");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// Safety net
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
// Drain any background steps that weren't already waited on by an explicit wait/cancel
|
||||||
|
// control step, then merge the final results of all background steps into a single result
|
||||||
|
// for the caller to fold into the job result.
|
||||||
|
public async Task<TaskResult> WaitForUnwaitedStepsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var unwaitedIds = _backgroundSteps.Keys.Where(id => !_completedStepIds.Contains(id)).ToList();
|
||||||
|
if (unwaitedIds.Count > 0)
|
||||||
|
{
|
||||||
|
Trace.Info($"Safety net: {unwaitedIds.Count} unwaited background step(s) at post-job boundary: {string.Join(", ", unwaitedIds)}");
|
||||||
|
await WaitForStepTasksAsync(unwaitedIds, cancellationToken);
|
||||||
|
CompleteWaitedSteps(unwaitedIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = TaskResult.Succeeded;
|
||||||
|
foreach (var (stepId, (step, _, _)) in _backgroundSteps)
|
||||||
|
{
|
||||||
|
// A step that succeeded does not set a Result by default, so a missing
|
||||||
|
// value means the step succeeded and there is nothing to merge.
|
||||||
|
if (!step.ExecutionContext.Result.HasValue)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A step explicitly canceled via a `cancel` control step is expected to be canceled,
|
||||||
|
// so a Canceled result must not influence the overall job result. However, if the step
|
||||||
|
// failed (e.g. before the cancellation took effect), that failure should still count.
|
||||||
|
if (_explicitlyCanceledStepIds.Contains(stepId) &&
|
||||||
|
step.ExecutionContext.Result.Value == TaskResult.Canceled)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
result = TaskResultUtil.MergeTaskResults(result, step.ExecutionContext.Result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result != TaskResult.Succeeded)
|
||||||
|
{
|
||||||
|
Trace.Info($"Background steps reported result '{result}' to caller.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// Control-flow step dispatch
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Execute a control-flow step (wait, wait-all, cancel) and propagate results.
|
||||||
|
/// </summary>
|
||||||
|
public async Task RunControlFlowAsync(IExecutionContext stepContext, object data)
|
||||||
|
{
|
||||||
|
var controlFlow = data as BackgroundStepControlFlowData;
|
||||||
|
switch (controlFlow.Type)
|
||||||
|
{
|
||||||
|
case Pipelines.BackgroundControlTypes.Wait:
|
||||||
|
{
|
||||||
|
var ids = controlFlow.StepIds ?? Array.Empty<string>();
|
||||||
|
stepContext.Output($"Waiting for background step(s) to complete: {DescribeSteps(ids)}");
|
||||||
|
await WaitForStepTasksAsync(ids, stepContext.CancellationToken);
|
||||||
|
stepContext.Result = CompleteWaitedSteps(ids);
|
||||||
|
ReportCompletedSteps(stepContext, "Finished waiting for background step(s).", ids);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case Pipelines.BackgroundControlTypes.WaitAll:
|
||||||
|
{
|
||||||
|
var remaining = _backgroundSteps.Keys.Where(id => !_completedStepIds.Contains(id)).ToList();
|
||||||
|
stepContext.Output(remaining.Count > 0
|
||||||
|
? $"Waiting for all background step(s) to complete: {DescribeSteps(remaining)}"
|
||||||
|
: "No background steps remaining to wait for.");
|
||||||
|
await WaitForStepTasksAsync(remaining, stepContext.CancellationToken);
|
||||||
|
stepContext.Result = CompleteWaitedSteps(remaining);
|
||||||
|
ReportCompletedSteps(stepContext, "Finished waiting for all background step(s).", remaining);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case Pipelines.BackgroundControlTypes.Cancel:
|
||||||
|
{
|
||||||
|
var cancelIds = controlFlow.StepIds ?? Array.Empty<string>();
|
||||||
|
stepContext.Output($"Cancelling background step(s): {DescribeSteps(cancelIds)}");
|
||||||
|
await CancelStepsAsync(controlFlow.StepIds);
|
||||||
|
stepContext.Result = TaskResult.Succeeded;
|
||||||
|
ReportCompletedSteps(stepContext, "Finished cancelling background step(s).", cancelIds);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new ArgumentException($"Unknown background step control type '{controlFlow.Type}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// Private helpers
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
// Resolve background step IDs to their display names for customer-facing output.
|
||||||
|
private string DescribeSteps(IEnumerable<string> stepIds)
|
||||||
|
{
|
||||||
|
var names = stepIds
|
||||||
|
.Select(id => _backgroundSteps.TryGetValue(id, out var entry) ? entry.Step.DisplayName : id)
|
||||||
|
.ToList();
|
||||||
|
return names.Count > 0 ? string.Join(", ", names) : "(none)";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit a completion summary plus the final result of each affected background step.
|
||||||
|
private void ReportCompletedSteps(IExecutionContext stepContext, string summary, IEnumerable<string> stepIds)
|
||||||
|
{
|
||||||
|
stepContext.Output(summary);
|
||||||
|
foreach (var id in stepIds)
|
||||||
|
{
|
||||||
|
if (_backgroundSteps.TryGetValue(id, out var entry))
|
||||||
|
{
|
||||||
|
var result = entry.Step.ExecutionContext.Result?.ToString() ?? "Unknown";
|
||||||
|
stepContext.Output($" {entry.Step.DisplayName}: {result}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ExecuteBackgroundStepCoreAsync(
|
||||||
|
IStep step, CancellationTokenSource bgCts,
|
||||||
|
string stepId, int timeoutMinutes)
|
||||||
|
{
|
||||||
|
Trace.Info($"Background step '{stepId}' waiting for slot.");
|
||||||
|
await _backgroundSlotSemaphore.WaitAsync(bgCts.Token);
|
||||||
|
Trace.Info($"Background step '{stepId}' acquired slot.");
|
||||||
|
|
||||||
|
step.ExecutionContext.Start();
|
||||||
|
|
||||||
|
if (timeoutMinutes > 0)
|
||||||
|
{
|
||||||
|
step.ExecutionContext.SetTimeout(TimeSpan.FromMinutes(timeoutMinutes));
|
||||||
|
}
|
||||||
|
|
||||||
|
using var cancelReg = bgCts.Token.Register(() =>
|
||||||
|
{
|
||||||
|
Trace.Info($"Background step '{stepId}': cancellation signalled, sending CancelToken to process.");
|
||||||
|
step.ExecutionContext.CancelToken();
|
||||||
|
});
|
||||||
|
|
||||||
|
TaskResult? result = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await step.RunAsync();
|
||||||
|
result = step.ExecutionContext.Result ?? TaskResult.Succeeded;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (bgCts.Token.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
result = TaskResult.Canceled;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (step.ExecutionContext.CancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
Trace.Info($"Background step '{stepId}' timed out after {timeoutMinutes} minutes.");
|
||||||
|
step.ExecutionContext.Error($"The background step '{step.DisplayName}' has timed out after {timeoutMinutes} minutes.");
|
||||||
|
result = TaskResult.Failed;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Trace.Info($"Background step '{stepId}' failed: {ex.Message}");
|
||||||
|
step.ExecutionContext.Error(ex);
|
||||||
|
result = TaskResult.Failed;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_backgroundSlotSemaphore.Release();
|
||||||
|
|
||||||
|
if (step.ExecutionContext.CommandResult != null)
|
||||||
|
{
|
||||||
|
result = TaskResultUtil.MergeTaskResults(result, step.ExecutionContext.CommandResult.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
step.ExecutionContext.Result = result;
|
||||||
|
step.ExecutionContext.ApplyContinueOnError(step.ContinueOnError);
|
||||||
|
|
||||||
|
step.ExecutionContext.Complete(step.ExecutionContext.Result);
|
||||||
|
Trace.Info($"Background step '{stepId}' completed with result: {step.ExecutionContext.Result}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CancelStepsAsync(string[] cancelStepIds)
|
||||||
|
{
|
||||||
|
if (cancelStepIds == null || cancelStepIds.Length == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark these steps as expected-to-be-canceled so their result does not
|
||||||
|
// affect the overall job result.
|
||||||
|
foreach (var id in cancelStepIds)
|
||||||
|
{
|
||||||
|
_explicitlyCanceledStepIds.Add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
var idsToCancel = cancelStepIds
|
||||||
|
.Where(id => _backgroundSteps.ContainsKey(id) && !_backgroundSteps[id].Task.IsCompleted)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (idsToCancel.Length > 0)
|
||||||
|
{
|
||||||
|
Trace.Info($"Cancelling {idsToCancel.Length} background step(s): {string.Join(", ", idsToCancel)}");
|
||||||
|
await CancelWithGracePeriodAsync(idsToCancel);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush deferred state and mark canceled steps as completed.
|
||||||
|
CompleteWaitedSteps(cancelStepIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task WaitForStepTasksAsync(IEnumerable<string> stepIds, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var ids = stepIds.ToList();
|
||||||
|
var tasks = new List<Task>();
|
||||||
|
|
||||||
|
foreach (var stepId in ids)
|
||||||
|
{
|
||||||
|
if (_backgroundSteps.TryGetValue(stepId, out var entry) && !entry.Task.IsCompleted)
|
||||||
|
{
|
||||||
|
tasks.Add(entry.Task);
|
||||||
|
}
|
||||||
|
else if (!_backgroundSteps.ContainsKey(stepId))
|
||||||
|
{
|
||||||
|
Trace.Info($"Wait references unknown background step: {stepId}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tasks.Count > 0)
|
||||||
|
{
|
||||||
|
Trace.Info($"Waiting for {tasks.Count} background step(s)...");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.WhenAll(tasks).WaitAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
Trace.Info("Wait interrupted by job cancellation — cancelling background steps.");
|
||||||
|
await CancelWithGracePeriodAsync(ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CancelWithGracePeriodAsync(IEnumerable<string> stepIds, double graceSeconds = 7.5)
|
||||||
|
{
|
||||||
|
var cancelledSteps = new List<(string StepId, Task Task, IStep Step)>();
|
||||||
|
foreach (var stepId in stepIds)
|
||||||
|
{
|
||||||
|
if (_backgroundSteps.TryGetValue(stepId, out var entry) && !entry.Task.IsCompleted)
|
||||||
|
{
|
||||||
|
entry.Step.ExecutionContext.CancelToken();
|
||||||
|
entry.Cts.Cancel();
|
||||||
|
cancelledSteps.Add((stepId, entry.Task, entry.Step));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelledSteps.Count > 0)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.WhenAll(cancelledSteps.Select(s => s.Task)).WaitAsync(TimeSpan.FromSeconds(graceSeconds));
|
||||||
|
}
|
||||||
|
catch (TimeoutException)
|
||||||
|
{
|
||||||
|
Trace.Info($"Some background steps did not terminate within {graceSeconds}s grace period.");
|
||||||
|
|
||||||
|
// The step tasks above never completed, so their finally block never ran and
|
||||||
|
// their result was never set. Force-mark them as canceled so the abandoned
|
||||||
|
// steps still report a terminal result.
|
||||||
|
foreach (var (stepId, task, step) in cancelledSteps)
|
||||||
|
{
|
||||||
|
if (!task.IsCompleted && !step.ExecutionContext.Result.HasValue)
|
||||||
|
{
|
||||||
|
step.ExecutionContext.Result = TaskResult.Canceled;
|
||||||
|
Trace.Info($"Background step '{stepId}' did not terminate within grace period; marking as canceled.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private TaskResult CompleteWaitedSteps(IEnumerable<string> stepIds)
|
||||||
|
{
|
||||||
|
var result = TaskResult.Succeeded;
|
||||||
|
foreach (var id in stepIds)
|
||||||
|
{
|
||||||
|
_completedStepIds.Add(id);
|
||||||
|
if (_backgroundSteps.TryGetValue(id, out var entry))
|
||||||
|
{
|
||||||
|
// Flush deferred state for the completed step.
|
||||||
|
entry.Step.ExecutionContext.FlushDeferredOutputs();
|
||||||
|
entry.Step.ExecutionContext.FlushDeferredEnvironment();
|
||||||
|
entry.Step.ExecutionContext.FlushDeferredOutcomeConclusion();
|
||||||
|
Trace.Info($"Flushed deferred state for background step '{id}'.");
|
||||||
|
|
||||||
|
if (entry.Step.ExecutionContext.Result.HasValue)
|
||||||
|
{
|
||||||
|
result = TaskResultUtil.MergeTaskResults(result, entry.Step.ExecutionContext.Result.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
384
src/Runner.Worker/CreateArtifactsFileCommand.cs
Normal file
384
src/Runner.Worker/CreateArtifactsFileCommand.cs
Normal 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) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ using Microsoft.DevTunnels.Connections;
|
|||||||
using Microsoft.DevTunnels.Contracts;
|
using Microsoft.DevTunnels.Contracts;
|
||||||
using Microsoft.DevTunnels.Management;
|
using Microsoft.DevTunnels.Management;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
using Pipelines = GitHub.DistributedTask.Pipelines;
|
||||||
|
|
||||||
namespace GitHub.Runner.Worker.Dap
|
namespace GitHub.Runner.Worker.Dap
|
||||||
{
|
{
|
||||||
@@ -27,6 +28,7 @@ namespace GitHub.Runner.Worker.Dap
|
|||||||
public string DisplayName { get; set; }
|
public string DisplayName { get; set; }
|
||||||
public TaskResult? Result { get; set; }
|
public TaskResult? Result { get; set; }
|
||||||
public int FrameId { get; set; }
|
public int FrameId { get; set; }
|
||||||
|
public int? SourceLine { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -54,6 +56,9 @@ namespace GitHub.Runner.Worker.Dap
|
|||||||
// Frame IDs for completed steps start at 1000
|
// Frame IDs for completed steps start at 1000
|
||||||
private const int _completedFrameIdBase = 1000;
|
private const int _completedFrameIdBase = 1000;
|
||||||
|
|
||||||
|
// Stable session-scoped source reference for the synthesized job step list.
|
||||||
|
private const int _jobStepsSourceReference = 1;
|
||||||
|
|
||||||
private TcpListener _listener;
|
private TcpListener _listener;
|
||||||
private TcpClient _client;
|
private TcpClient _client;
|
||||||
private NetworkStream _stream;
|
private NetworkStream _stream;
|
||||||
@@ -98,6 +103,8 @@ namespace GitHub.Runner.Worker.Dap
|
|||||||
// Track completed steps for stack trace
|
// Track completed steps for stack trace
|
||||||
private readonly List<CompletedStepInfo> _completedSteps = new List<CompletedStepInfo>();
|
private readonly List<CompletedStepInfo> _completedSteps = new List<CompletedStepInfo>();
|
||||||
private int _nextCompletedFrameId = _completedFrameIdBase;
|
private int _nextCompletedFrameId = _completedFrameIdBase;
|
||||||
|
private JobExecutionView _jobStepsSource;
|
||||||
|
private bool _jobCompleted;
|
||||||
|
|
||||||
// Client connection tracking for reconnection support
|
// Client connection tracking for reconnection support
|
||||||
private volatile bool _isClientConnected;
|
private volatile bool _isClientConnected;
|
||||||
@@ -240,6 +247,179 @@ namespace GitHub.Runner.Worker.Dap
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task OnJobStepsInitializedAsync(IEnumerable<IStep> steps, IEnumerable<IStep> initialPostSteps)
|
||||||
|
{
|
||||||
|
if (!IsActive)
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
IExecutionContext jobContext;
|
||||||
|
lock (_stateLock)
|
||||||
|
{
|
||||||
|
if (_state != DapSessionState.Ready &&
|
||||||
|
_state != DapSessionState.Paused &&
|
||||||
|
_state != DapSessionState.Running)
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
jobContext = _jobContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
var stepList = steps?.Where(step => step != null).ToList() ?? new List<IStep>();
|
||||||
|
var initialPostStepList = initialPostSteps?.Where(step => step != null).ToList() ?? new List<IStep>();
|
||||||
|
var jobId = jobContext?.GetGitHubContext("job");
|
||||||
|
var snapshot = new JobExecutionView(
|
||||||
|
jobId,
|
||||||
|
stepList,
|
||||||
|
initialPostStepList,
|
||||||
|
PredictPostSteps(jobContext, stepList, initialPostStepList));
|
||||||
|
|
||||||
|
lock (_stateLock)
|
||||||
|
{
|
||||||
|
_jobStepsSource = snapshot;
|
||||||
|
_jobCompleted = false;
|
||||||
|
}
|
||||||
|
Trace.Info("DAP job steps source initialized");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Trace.Warning("DAP OnJobStepsInitialized error.");
|
||||||
|
Trace.Error(ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnPostStepRegistered(IStep step)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (step is IActionRunner postRunner && postRunner.Action != null)
|
||||||
|
{
|
||||||
|
JobExecutionView snapshot;
|
||||||
|
lock (_stateLock)
|
||||||
|
{
|
||||||
|
snapshot = _jobStepsSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
var line = snapshot?.TryClaimPredictedStep(MatchKeyFor(postRunner.Action.Id), step);
|
||||||
|
if (line.HasValue)
|
||||||
|
{
|
||||||
|
Trace.Info($"DAP job steps source claimed predicted post step '{step.DisplayName}' at line {line.Value}.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Trace.Info($"DAP job steps source had no predicted line for post step '{step.DisplayName}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Trace.Warning("DAP OnPostStepRegistered error.");
|
||||||
|
Trace.Error(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private IReadOnlyList<JobExecutionView.PredictedPostStep> PredictPostSteps(
|
||||||
|
IExecutionContext jobContext,
|
||||||
|
IReadOnlyList<IStep> steps,
|
||||||
|
IReadOnlyList<IStep> initialPostSteps)
|
||||||
|
{
|
||||||
|
if (jobContext == null || steps == null || steps.Count == 0)
|
||||||
|
{
|
||||||
|
return Array.Empty<JobExecutionView.PredictedPostStep>();
|
||||||
|
}
|
||||||
|
|
||||||
|
IActionManager actionManager;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
actionManager = HostContext.GetService<IActionManager>();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Trace.Info($"DAP post-step predictor skipped because IActionManager is unavailable ({ex.Message}).");
|
||||||
|
return Array.Empty<JobExecutionView.PredictedPostStep>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var predictions = new List<JobExecutionView.PredictedPostStep>();
|
||||||
|
var seenActionIds = new HashSet<Guid>();
|
||||||
|
if (initialPostSteps != null)
|
||||||
|
{
|
||||||
|
foreach (var postStep in initialPostSteps)
|
||||||
|
{
|
||||||
|
if (postStep is IActionRunner postRunner && postRunner.Action != null)
|
||||||
|
{
|
||||||
|
seenActionIds.Add(postRunner.Action.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var step in steps)
|
||||||
|
{
|
||||||
|
if (step is not IActionRunner runner ||
|
||||||
|
runner.Stage == ActionRunStage.Post ||
|
||||||
|
runner.Action == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var action = runner.Action;
|
||||||
|
if (action.Reference is not Pipelines.RepositoryPathReference repoRef)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!seenActionIds.Add(action.Id))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Definition definition;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
definition = actionManager.LoadAction(jobContext, action);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Trace.Info($"DAP post-step predictor could not load action '{repoRef.Name}' ({ex.Message}).");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (definition?.Data?.Execution?.HasPost != true)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
predictions.Add(new JobExecutionView.PredictedPostStep(
|
||||||
|
GetPostDisplayName(runner),
|
||||||
|
MatchKeyFor(action.Id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
predictions.Reverse();
|
||||||
|
return predictions;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetPostDisplayName(IActionRunner runner)
|
||||||
|
{
|
||||||
|
var displayName = string.IsNullOrEmpty(runner.DisplayName) ? "step" : runner.DisplayName;
|
||||||
|
if (runner.Stage == ActionRunStage.Pre &&
|
||||||
|
displayName.StartsWith("Pre ", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
displayName = displayName.Substring("Pre ".Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"Post {displayName}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string MatchKeyFor(Guid actionId)
|
||||||
|
{
|
||||||
|
return $"post:{actionId:N}";
|
||||||
|
}
|
||||||
|
|
||||||
public async Task OnJobCompletedAsync()
|
public async Task OnJobCompletedAsync()
|
||||||
{
|
{
|
||||||
if (_state != DapSessionState.NotStarted)
|
if (_state != DapSessionState.NotStarted)
|
||||||
@@ -253,6 +433,11 @@ namespace GitHub.Runner.Worker.Dap
|
|||||||
if (_jobContext != null)
|
if (_jobContext != null)
|
||||||
{
|
{
|
||||||
Trace.Info("Job completed — pausing for inspection");
|
Trace.Info("Job completed — pausing for inspection");
|
||||||
|
lock (_stateLock)
|
||||||
|
{
|
||||||
|
_jobCompleted = true;
|
||||||
|
}
|
||||||
|
|
||||||
SendStoppedEvent("completed", "Job completed — inspect variables before the session ends.");
|
SendStoppedEvent("completed", "Job completed — inspect variables before the session ends.");
|
||||||
|
|
||||||
await WaitForCommandAsync(_jobContext.CancellationToken);
|
await WaitForCommandAsync(_jobContext.CancellationToken);
|
||||||
@@ -359,6 +544,7 @@ namespace GitHub.Runner.Worker.Dap
|
|||||||
{
|
{
|
||||||
_state = DapSessionState.Terminated;
|
_state = DapSessionState.Terminated;
|
||||||
}
|
}
|
||||||
|
_jobStepsSource = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
_isClientConnected = false;
|
_isClientConnected = false;
|
||||||
@@ -417,7 +603,8 @@ namespace GitHub.Runner.Worker.Dap
|
|||||||
{
|
{
|
||||||
DisplayName = step.DisplayName,
|
DisplayName = step.DisplayName,
|
||||||
Result = result,
|
Result = result,
|
||||||
FrameId = _nextCompletedFrameId++
|
FrameId = _nextCompletedFrameId++,
|
||||||
|
SourceLine = _jobStepsSource?.TryGetLineForStep(step)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -468,6 +655,7 @@ namespace GitHub.Runner.Worker.Dap
|
|||||||
"next" => HandleNext(request),
|
"next" => HandleNext(request),
|
||||||
"setBreakpoints" => HandleSetBreakpoints(request),
|
"setBreakpoints" => HandleSetBreakpoints(request),
|
||||||
"setExceptionBreakpoints" => HandleSetExceptionBreakpoints(request),
|
"setExceptionBreakpoints" => HandleSetExceptionBreakpoints(request),
|
||||||
|
"source" => HandleSource(request),
|
||||||
"completions" => HandleCompletions(request),
|
"completions" => HandleCompletions(request),
|
||||||
"stepIn" => CreateResponse(request, false, "Step In is not supported. Actions jobs debug at the step level - use 'next' to advance to the next step.", body: null),
|
"stepIn" => CreateResponse(request, false, "Step In is not supported. Actions jobs debug at the step level - use 'next' to advance to the next step.", body: null),
|
||||||
"stepOut" => CreateResponse(request, false, "Step Out is not supported. Actions jobs debug at the step level - use 'continue' to resume.", body: null),
|
"stepOut" => CreateResponse(request, false, "Step Out is not supported. Actions jobs debug at the step level - use 'continue' to resume.", body: null),
|
||||||
@@ -857,6 +1045,7 @@ namespace GitHub.Runner.Worker.Dap
|
|||||||
{
|
{
|
||||||
bool pauseOnNextStep;
|
bool pauseOnNextStep;
|
||||||
CancellationToken cancellationToken;
|
CancellationToken cancellationToken;
|
||||||
|
|
||||||
lock (_stateLock)
|
lock (_stateLock)
|
||||||
{
|
{
|
||||||
if (_state != DapSessionState.Ready &&
|
if (_state != DapSessionState.Ready &&
|
||||||
@@ -868,6 +1057,7 @@ namespace GitHub.Runner.Worker.Dap
|
|||||||
|
|
||||||
_currentStep = step;
|
_currentStep = step;
|
||||||
_currentStepIndex = _completedSteps.Count;
|
_currentStepIndex = _completedSteps.Count;
|
||||||
|
_jobCompleted = false;
|
||||||
pauseOnNextStep = _pauseOnNextStep;
|
pauseOnNextStep = _pauseOnNextStep;
|
||||||
cancellationToken = _jobContext?.CancellationToken ?? CancellationToken.None;
|
cancellationToken = _jobContext?.CancellationToken ?? CancellationToken.None;
|
||||||
}
|
}
|
||||||
@@ -1050,29 +1240,46 @@ namespace GitHub.Runner.Worker.Dap
|
|||||||
private Response HandleStackTrace(Request request)
|
private Response HandleStackTrace(Request request)
|
||||||
{
|
{
|
||||||
IStep currentStep;
|
IStep currentStep;
|
||||||
int currentStepIndex;
|
|
||||||
CompletedStepInfo[] completedSteps;
|
CompletedStepInfo[] completedSteps;
|
||||||
|
JobExecutionView jobStepsSource;
|
||||||
|
bool jobCompleted;
|
||||||
lock (_stateLock)
|
lock (_stateLock)
|
||||||
{
|
{
|
||||||
currentStep = _currentStep;
|
currentStep = _currentStep;
|
||||||
currentStepIndex = _currentStepIndex;
|
|
||||||
completedSteps = _completedSteps.ToArray();
|
completedSteps = _completedSteps.ToArray();
|
||||||
|
jobStepsSource = _jobStepsSource;
|
||||||
|
jobCompleted = _jobCompleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
var frames = new List<StackFrame>();
|
var frames = new List<StackFrame>();
|
||||||
|
var source = jobStepsSource != null ? BuildJobStepsSource(jobStepsSource) : null;
|
||||||
|
|
||||||
// Add current step as the top frame
|
// Add current step as the top frame
|
||||||
if (currentStep != null)
|
if (jobCompleted && jobStepsSource != null)
|
||||||
|
{
|
||||||
|
frames.Add(new StackFrame
|
||||||
|
{
|
||||||
|
Id = _currentFrameId,
|
||||||
|
Name = "Complete job [completed]",
|
||||||
|
Source = source,
|
||||||
|
Line = jobStepsSource.CompleteJobLine,
|
||||||
|
Column = 1,
|
||||||
|
PresentationHint = "normal"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (currentStep != null)
|
||||||
{
|
{
|
||||||
var resultIndicator = currentStep.ExecutionContext?.Result != null
|
var resultIndicator = currentStep.ExecutionContext?.Result != null
|
||||||
? $" [{currentStep.ExecutionContext.Result}]"
|
? $" [{currentStep.ExecutionContext.Result}]"
|
||||||
: " [running]";
|
: " [running]";
|
||||||
|
var currentSourceLine = jobStepsSource?.TryGetLineForStep(currentStep);
|
||||||
|
|
||||||
frames.Add(new StackFrame
|
frames.Add(new StackFrame
|
||||||
{
|
{
|
||||||
Id = _currentFrameId,
|
Id = _currentFrameId,
|
||||||
Name = MaskUserVisibleText($"{currentStep.DisplayName ?? "Current Step"}{resultIndicator}"),
|
Name = MaskUserVisibleText($"{currentStep.DisplayName ?? "Current Step"}{resultIndicator}"),
|
||||||
Line = currentStepIndex + 1,
|
Source = currentSourceLine.HasValue ? source : null,
|
||||||
|
Line = currentSourceLine ?? 0,
|
||||||
Column = 1,
|
Column = 1,
|
||||||
PresentationHint = "normal"
|
PresentationHint = "normal"
|
||||||
});
|
});
|
||||||
@@ -1098,7 +1305,8 @@ namespace GitHub.Runner.Worker.Dap
|
|||||||
{
|
{
|
||||||
Id = completedStep.FrameId,
|
Id = completedStep.FrameId,
|
||||||
Name = MaskUserVisibleText($"{completedStep.DisplayName}{resultStr}"),
|
Name = MaskUserVisibleText($"{completedStep.DisplayName}{resultStr}"),
|
||||||
Line = 1,
|
Source = completedStep.SourceLine.HasValue ? source : null,
|
||||||
|
Line = completedStep.SourceLine ?? 0,
|
||||||
Column = 1,
|
Column = 1,
|
||||||
PresentationHint = "subtle"
|
PresentationHint = "subtle"
|
||||||
});
|
});
|
||||||
@@ -1113,6 +1321,76 @@ namespace GitHub.Runner.Worker.Dap
|
|||||||
return CreateResponse(request, true, body: body);
|
return CreateResponse(request, true, body: body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Source BuildJobStepsSource(JobExecutionView snapshot)
|
||||||
|
{
|
||||||
|
return new Source
|
||||||
|
{
|
||||||
|
Name = MaskUserVisibleText(snapshot.SourceFileName),
|
||||||
|
Path = MaskUserVisibleText($"{SanitizeSourcePathSegment(snapshot.JobId)}/{snapshot.SourceFileName}"),
|
||||||
|
SourceReference = _jobStepsSourceReference,
|
||||||
|
PresentationHint = "normal"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SanitizeSourcePathSegment(string value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return "job";
|
||||||
|
}
|
||||||
|
|
||||||
|
var builder = new StringBuilder(value.Length);
|
||||||
|
foreach (var character in value)
|
||||||
|
{
|
||||||
|
builder.Append(char.IsControl(character) || character == '/' || character == '\\'
|
||||||
|
? '_'
|
||||||
|
: character);
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.Length == 0 ? "job" : builder.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal Response HandleSource(Request request)
|
||||||
|
{
|
||||||
|
SourceArguments args;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
args = request.Arguments?.ToObject<SourceArguments>();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Trace.Warning($"Failed to parse source arguments: {ex.GetType().Name}");
|
||||||
|
return CreateResponse(request, false, "Invalid source arguments.", body: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var sourceReference = args?.Source?.SourceReference ?? args?.SourceReference;
|
||||||
|
if (!sourceReference.HasValue)
|
||||||
|
{
|
||||||
|
return CreateResponse(request, false, "Missing source reference.", body: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
JobExecutionView snapshot;
|
||||||
|
lock (_stateLock)
|
||||||
|
{
|
||||||
|
snapshot = _jobStepsSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot == null)
|
||||||
|
{
|
||||||
|
return CreateResponse(request, false, "Job steps source not yet available.", body: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceReference.Value != _jobStepsSourceReference)
|
||||||
|
{
|
||||||
|
return CreateResponse(request, false, $"Unknown source reference: {sourceReference.Value}.", body: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return CreateResponse(request, true, body: new SourceResponseBody
|
||||||
|
{
|
||||||
|
Content = MaskUserVisibleText(snapshot.Content)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private Response HandleScopes(Request request)
|
private Response HandleScopes(Request request)
|
||||||
{
|
{
|
||||||
var args = request.Arguments?.ToObject<ScopesArguments>();
|
var args = request.Arguments?.ToObject<ScopesArguments>();
|
||||||
|
|||||||
@@ -537,6 +537,46 @@ namespace GitHub.Runner.Worker.Dap
|
|||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
#region Source Request/Response
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Arguments for 'source' request.
|
||||||
|
/// </summary>
|
||||||
|
public class SourceArguments
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Source descriptor. Some clients send sourceReference only here.
|
||||||
|
/// </summary>
|
||||||
|
[JsonProperty("source", NullValueHandling = NullValueHandling.Ignore)]
|
||||||
|
public Source Source { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The reference to the source.
|
||||||
|
/// </summary>
|
||||||
|
[JsonProperty("sourceReference", NullValueHandling = NullValueHandling.Ignore)]
|
||||||
|
public int? SourceReference { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Response body for 'source' request.
|
||||||
|
/// </summary>
|
||||||
|
public class SourceResponseBody
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Content of the source as a string.
|
||||||
|
/// </summary>
|
||||||
|
[JsonProperty("content")]
|
||||||
|
public string Content { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional content type / mime type of the source.
|
||||||
|
/// </summary>
|
||||||
|
[JsonProperty("mimeType", NullValueHandling = NullValueHandling.Ignore)]
|
||||||
|
public string MimeType { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
#region Scopes Request/Response
|
#region Scopes Request/Response
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Threading.Tasks;
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using GitHub.Runner.Common;
|
using GitHub.Runner.Common;
|
||||||
|
|
||||||
namespace GitHub.Runner.Worker.Dap
|
namespace GitHub.Runner.Worker.Dap
|
||||||
@@ -19,6 +20,8 @@ namespace GitHub.Runner.Worker.Dap
|
|||||||
{
|
{
|
||||||
Task StartAsync(IExecutionContext jobContext);
|
Task StartAsync(IExecutionContext jobContext);
|
||||||
Task WaitUntilReadyAsync();
|
Task WaitUntilReadyAsync();
|
||||||
|
Task OnJobStepsInitializedAsync(IEnumerable<IStep> steps, IEnumerable<IStep> initialPostSteps);
|
||||||
|
void OnPostStepRegistered(IStep step);
|
||||||
Task OnStepStartingAsync(IStep step);
|
Task OnStepStartingAsync(IStep step);
|
||||||
void OnStepCompleted(IStep step);
|
void OnStepCompleted(IStep step);
|
||||||
Task OnJobCompletedAsync();
|
Task OnJobCompletedAsync();
|
||||||
|
|||||||
358
src/Runner.Worker/Dap/JobExecutionView.cs
Normal file
358
src/Runner.Worker/Dap/JobExecutionView.cs
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace GitHub.Runner.Worker.Dap
|
||||||
|
{
|
||||||
|
internal sealed class JobExecutionView
|
||||||
|
{
|
||||||
|
private const string _sourceFileName = "execution.yml";
|
||||||
|
|
||||||
|
private readonly object _lock = new object();
|
||||||
|
private readonly List<SourceEntry> _preEntries = new List<SourceEntry>();
|
||||||
|
private readonly List<SourceEntry> _mainEntries = new List<SourceEntry>();
|
||||||
|
private readonly List<SourceEntry> _postEntries = new List<SourceEntry>();
|
||||||
|
private readonly List<StepLine> _lineByStep = new List<StepLine>();
|
||||||
|
private string _content;
|
||||||
|
private int _completeJobLine;
|
||||||
|
|
||||||
|
public JobExecutionView(
|
||||||
|
string jobId,
|
||||||
|
IEnumerable<IStep> steps,
|
||||||
|
IEnumerable<IStep> initialPostSteps,
|
||||||
|
IEnumerable<PredictedPostStep> predictedPostSteps = null)
|
||||||
|
{
|
||||||
|
JobId = string.IsNullOrWhiteSpace(jobId) ? "job" : jobId;
|
||||||
|
|
||||||
|
_preEntries.Add(new SourceEntry("Set up job"));
|
||||||
|
AddSteps(steps);
|
||||||
|
AddPredictedPostSteps(predictedPostSteps);
|
||||||
|
AddSteps(initialPostSteps);
|
||||||
|
_postEntries.Add(SourceEntry.CreateSyntheticCompleteJob());
|
||||||
|
Render();
|
||||||
|
}
|
||||||
|
|
||||||
|
public string JobId { get; }
|
||||||
|
public string SourceFileName => _sourceFileName;
|
||||||
|
|
||||||
|
public string Content
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
return _content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int CompleteJobLine
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
return _completeJobLine;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int? TryClaimPredictedStep(string matchKey, IStep step)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(matchKey) || step == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
var existingLine = TryGetLineForStepNoLock(step);
|
||||||
|
if (existingLine.HasValue)
|
||||||
|
{
|
||||||
|
return existingLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var entry in _postEntries)
|
||||||
|
{
|
||||||
|
if (!string.Equals(entry.MatchKey, matchKey, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.Step != null && !ReferenceEquals(entry.Step, step))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.Step = step;
|
||||||
|
Render();
|
||||||
|
return TryGetLineForStepNoLock(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int? TryGetLineForStep(IStep step)
|
||||||
|
{
|
||||||
|
if (step == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
return TryGetLineForStepNoLock(step);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int? TryGetLineForStepNoLock(IStep step)
|
||||||
|
{
|
||||||
|
foreach (var stepLine in _lineByStep)
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(stepLine.Step, step))
|
||||||
|
{
|
||||||
|
return stepLine.Line;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddSteps(IEnumerable<IStep> steps)
|
||||||
|
{
|
||||||
|
if (steps == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var step in steps)
|
||||||
|
{
|
||||||
|
if (step == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
GetEntries(GetSection(step)).Add(new SourceEntry(step));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddPredictedPostSteps(IEnumerable<PredictedPostStep> steps)
|
||||||
|
{
|
||||||
|
if (steps == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var step in steps)
|
||||||
|
{
|
||||||
|
if (step == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
_postEntries.Add(new SourceEntry(step.DisplayName, step.MatchKey));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<SourceEntry> GetEntries(SourceSection section)
|
||||||
|
{
|
||||||
|
switch (section)
|
||||||
|
{
|
||||||
|
case SourceSection.Pre:
|
||||||
|
return _preEntries;
|
||||||
|
case SourceSection.Post:
|
||||||
|
return _postEntries;
|
||||||
|
default:
|
||||||
|
return _mainEntries;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SourceSection GetSection(IStep step)
|
||||||
|
{
|
||||||
|
if (step is IActionRunner actionRunner)
|
||||||
|
{
|
||||||
|
return GetSection(actionRunner.Stage);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step.ExecutionContext != null)
|
||||||
|
{
|
||||||
|
return GetSection(step.ExecutionContext.Stage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SourceSection.Main;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SourceSection GetSection(ActionRunStage stage)
|
||||||
|
{
|
||||||
|
switch (stage)
|
||||||
|
{
|
||||||
|
case ActionRunStage.Pre:
|
||||||
|
return SourceSection.Pre;
|
||||||
|
case ActionRunStage.Post:
|
||||||
|
return SourceSection.Post;
|
||||||
|
default:
|
||||||
|
return SourceSection.Main;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Render()
|
||||||
|
{
|
||||||
|
_lineByStep.Clear();
|
||||||
|
_completeJobLine = 0;
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
var line = 1;
|
||||||
|
|
||||||
|
AppendSection(sb, "pre", _preEntries, ref line, appendSeparatorLine: true);
|
||||||
|
AppendSection(sb, "main", _mainEntries, ref line, appendSeparatorLine: true);
|
||||||
|
AppendSection(sb, "post", _postEntries, ref line, appendSeparatorLine: false);
|
||||||
|
|
||||||
|
_content = sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AppendSection(
|
||||||
|
StringBuilder sb,
|
||||||
|
string sectionName,
|
||||||
|
IReadOnlyList<SourceEntry> entries,
|
||||||
|
ref int line,
|
||||||
|
bool appendSeparatorLine)
|
||||||
|
{
|
||||||
|
sb.Append(sectionName).Append(":\n");
|
||||||
|
line++;
|
||||||
|
|
||||||
|
foreach (var entry in entries)
|
||||||
|
{
|
||||||
|
if (entry.Step != null && TryGetLineForStepNoLock(entry.Step) == null)
|
||||||
|
{
|
||||||
|
_lineByStep.Add(new StepLine(entry.Step, line));
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.Append(" - step: ");
|
||||||
|
sb.Append(FormatYamlString(entry.DisplayName));
|
||||||
|
sb.Append('\n');
|
||||||
|
if (entry.IsSyntheticCompleteJob)
|
||||||
|
{
|
||||||
|
_completeJobLine = line;
|
||||||
|
}
|
||||||
|
|
||||||
|
line++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appendSeparatorLine)
|
||||||
|
{
|
||||||
|
sb.Append('\n');
|
||||||
|
line++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatYamlString(string value)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.Append('"');
|
||||||
|
foreach (var c in value)
|
||||||
|
{
|
||||||
|
switch (c)
|
||||||
|
{
|
||||||
|
case '\\':
|
||||||
|
sb.Append(@"\\");
|
||||||
|
break;
|
||||||
|
case '"':
|
||||||
|
sb.Append("\\\"");
|
||||||
|
break;
|
||||||
|
case '\r':
|
||||||
|
sb.Append(@"\r");
|
||||||
|
break;
|
||||||
|
case '\n':
|
||||||
|
sb.Append(@"\n");
|
||||||
|
break;
|
||||||
|
case '\t':
|
||||||
|
sb.Append(@"\t");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
if (char.IsControl(c))
|
||||||
|
{
|
||||||
|
sb.Append(@"\u");
|
||||||
|
sb.Append(((int)c).ToString("x4", CultureInfo.InvariantCulture));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sb.Append(c);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.Append('"');
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class PredictedPostStep
|
||||||
|
{
|
||||||
|
public PredictedPostStep(string displayName, string matchKey)
|
||||||
|
{
|
||||||
|
DisplayName = string.IsNullOrEmpty(displayName) ? "step" : displayName;
|
||||||
|
MatchKey = matchKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string DisplayName { get; }
|
||||||
|
public string MatchKey { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StepLine
|
||||||
|
{
|
||||||
|
public StepLine(IStep step, int line)
|
||||||
|
{
|
||||||
|
Step = step;
|
||||||
|
Line = line;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IStep Step { get; }
|
||||||
|
public int Line { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SourceEntry
|
||||||
|
{
|
||||||
|
public SourceEntry(string displayName)
|
||||||
|
{
|
||||||
|
DisplayName = string.IsNullOrEmpty(displayName) ? "step" : displayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SourceEntry(string displayName, string matchKey)
|
||||||
|
: this(displayName)
|
||||||
|
{
|
||||||
|
MatchKey = matchKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SourceEntry(IStep step)
|
||||||
|
{
|
||||||
|
Step = step;
|
||||||
|
DisplayName = string.IsNullOrEmpty(step.DisplayName) ? "step" : step.DisplayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SourceEntry(string displayName, bool isSyntheticCompleteJob)
|
||||||
|
: this(displayName)
|
||||||
|
{
|
||||||
|
IsSyntheticCompleteJob = isSyntheticCompleteJob;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static SourceEntry CreateSyntheticCompleteJob()
|
||||||
|
{
|
||||||
|
return new SourceEntry("Complete job", isSyntheticCompleteJob: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IStep Step { get; set; }
|
||||||
|
public string DisplayName { get; }
|
||||||
|
public string MatchKey { get; }
|
||||||
|
public bool IsSyntheticCompleteJob { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum SourceSection
|
||||||
|
{
|
||||||
|
Pre,
|
||||||
|
Main,
|
||||||
|
Post
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,14 +77,23 @@ namespace GitHub.Runner.Worker
|
|||||||
|
|
||||||
List<string> StepEnvironmentOverrides { get; }
|
List<string> StepEnvironmentOverrides { get; }
|
||||||
|
|
||||||
|
bool IsBackground { get; }
|
||||||
|
|
||||||
IExecutionContext Root { get; }
|
IExecutionContext Root { get; }
|
||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
void InitializeJob(Pipelines.AgentJobRequestMessage message, CancellationToken token);
|
void InitializeJob(Pipelines.AgentJobRequestMessage message, CancellationToken token);
|
||||||
void CancelToken();
|
void CancelToken();
|
||||||
IExecutionContext CreateChild(Guid recordId, string displayName, string refName, string scopeName, string contextName, ActionRunStage stage, Dictionary<string, string> intraActionState = null, int? recordOrder = null, IPagingLogger logger = null, bool isEmbedded = false, List<Issue> embeddedIssueCollector = null, CancellationTokenSource cancellationTokenSource = null, Guid embeddedId = default(Guid), string siblingScopeName = null, TimeSpan? timeout = null);
|
IExecutionContext CreateChild(Guid recordId, string displayName, string refName, string scopeName, string contextName, ActionRunStage stage, Dictionary<string, string> intraActionState = null, int? recordOrder = null, IPagingLogger logger = null, bool isEmbedded = false, List<Issue> embeddedIssueCollector = null, CancellationTokenSource cancellationTokenSource = null, Guid embeddedId = default(Guid), string siblingScopeName = null, TimeSpan? timeout = null, bool isBackground = false, string backgroundControlType = null, string[] backgroundControlStepIds = null, string parallelGroupId = null);
|
||||||
IExecutionContext CreateEmbeddedChild(string scopeName, string contextName, Guid embeddedId, ActionRunStage stage, Dictionary<string, string> intraActionState = null, string siblingScopeName = null);
|
IExecutionContext CreateEmbeddedChild(string scopeName, string contextName, Guid embeddedId, ActionRunStage stage, Dictionary<string, string> intraActionState = null, string siblingScopeName = null);
|
||||||
|
|
||||||
|
|
||||||
|
// Background step deferral properties
|
||||||
|
Dictionary<string, string> DeferredOutputs { get; set; }
|
||||||
|
Dictionary<string, string> DeferredEnvironmentVariables { get; set; }
|
||||||
|
List<string> DeferredPrependPath { get; set; }
|
||||||
|
bool DeferOutcomeConclusion { get; set; }
|
||||||
|
|
||||||
// logging
|
// logging
|
||||||
long Write(string tag, string message);
|
long Write(string tag, string message);
|
||||||
void QueueAttachFile(string type, string name, string filePath);
|
void QueueAttachFile(string type, string name, string filePath);
|
||||||
@@ -100,6 +109,12 @@ namespace GitHub.Runner.Worker
|
|||||||
void SetGitHubContext(string name, string value);
|
void SetGitHubContext(string name, string value);
|
||||||
void SetOutput(string name, string value, out string reference);
|
void SetOutput(string name, string value, out string reference);
|
||||||
void SetTimeout(TimeSpan? timeout);
|
void SetTimeout(TimeSpan? timeout);
|
||||||
|
|
||||||
|
// Background step deferral flush methods
|
||||||
|
void FlushDeferredOutputs();
|
||||||
|
void FlushDeferredEnvironment();
|
||||||
|
void FlushDeferredOutcomeConclusion();
|
||||||
|
|
||||||
void AddIssue(Issue issue, ExecutionContextLogOptions logOptions);
|
void AddIssue(Issue issue, ExecutionContextLogOptions logOptions);
|
||||||
void Progress(int percentage, string currentOperation = null);
|
void Progress(int percentage, string currentOperation = null);
|
||||||
void UpdateDetailTimelineRecord(TimelineRecord record);
|
void UpdateDetailTimelineRecord(TimelineRecord record);
|
||||||
@@ -216,6 +231,9 @@ namespace GitHub.Runner.Worker
|
|||||||
|
|
||||||
public bool EchoOnActionCommand { get; set; }
|
public bool EchoOnActionCommand { get; set; }
|
||||||
|
|
||||||
|
// Whether this step runs in the background
|
||||||
|
public bool IsBackground => _record.IsBackground;
|
||||||
|
|
||||||
// An embedded execution context shares the same record ID, record name, and logger
|
// An embedded execution context shares the same record ID, record name, and logger
|
||||||
// as its enclosing execution context.
|
// as its enclosing execution context.
|
||||||
public bool IsEmbedded { get; private init; }
|
public bool IsEmbedded { get; private init; }
|
||||||
@@ -279,6 +297,12 @@ namespace GitHub.Runner.Worker
|
|||||||
|
|
||||||
public List<string> StepEnvironmentOverrides { get; } = new List<string>();
|
public List<string> StepEnvironmentOverrides { get; } = new List<string>();
|
||||||
|
|
||||||
|
// Background step deferral properties
|
||||||
|
public Dictionary<string, string> DeferredOutputs { get; set; }
|
||||||
|
public Dictionary<string, string> DeferredEnvironmentVariables { get; set; }
|
||||||
|
public List<string> DeferredPrependPath { get; set; }
|
||||||
|
public bool DeferOutcomeConclusion { get; set; }
|
||||||
|
|
||||||
public override void Initialize(IHostContext hostContext)
|
public override void Initialize(IHostContext hostContext)
|
||||||
{
|
{
|
||||||
base.Initialize(hostContext);
|
base.Initialize(hostContext);
|
||||||
@@ -337,7 +361,25 @@ namespace GitHub.Runner.Worker
|
|||||||
}
|
}
|
||||||
|
|
||||||
step.ExecutionContext = Root.CreatePostChild(step.DisplayName, IntraActionState, siblingScopeName);
|
step.ExecutionContext = Root.CreatePostChild(step.DisplayName, IntraActionState, siblingScopeName);
|
||||||
|
if (step is JobExtensionRunner)
|
||||||
|
{
|
||||||
|
step.ExecutionContext.StepTelemetry.Type = "runner";
|
||||||
|
step.ExecutionContext.StepTelemetry.Action = step.DisplayName.ToLowerInvariant().Replace(' ', '_');
|
||||||
|
}
|
||||||
Root.PostJobSteps.Push(step);
|
Root.PostJobSteps.Push(step);
|
||||||
|
|
||||||
|
if (Root.Global.Debugger?.Enabled == true)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
HostContext.GetService<Dap.IDapDebugger>().OnPostStepRegistered(step);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Trace.Warning("Failed to notify DAP debugger about registered post job step.");
|
||||||
|
Trace.Error(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public IExecutionContext CreateChild(
|
public IExecutionContext CreateChild(
|
||||||
@@ -355,7 +397,11 @@ namespace GitHub.Runner.Worker
|
|||||||
CancellationTokenSource cancellationTokenSource = null,
|
CancellationTokenSource cancellationTokenSource = null,
|
||||||
Guid embeddedId = default(Guid),
|
Guid embeddedId = default(Guid),
|
||||||
string siblingScopeName = null,
|
string siblingScopeName = null,
|
||||||
TimeSpan? timeout = null)
|
TimeSpan? timeout = null,
|
||||||
|
bool isBackground = false,
|
||||||
|
string backgroundControlType = null,
|
||||||
|
string[] backgroundControlStepIds = null,
|
||||||
|
string parallelGroupId = null)
|
||||||
{
|
{
|
||||||
Trace.Entering();
|
Trace.Entering();
|
||||||
|
|
||||||
@@ -396,6 +442,24 @@ namespace GitHub.Runner.Worker
|
|||||||
|
|
||||||
child.EchoOnActionCommand = EchoOnActionCommand;
|
child.EchoOnActionCommand = EchoOnActionCommand;
|
||||||
|
|
||||||
|
// Set background step metadata before InitializeTimelineRecord so it's included in the first update
|
||||||
|
if (isBackground || backgroundControlType != null || parallelGroupId != null)
|
||||||
|
{
|
||||||
|
child._record.IsBackground = isBackground;
|
||||||
|
child._record.BackgroundControlType = backgroundControlType;
|
||||||
|
child._record.BackgroundControlStepIds = backgroundControlStepIds;
|
||||||
|
child._record.ParallelGroupId = parallelGroupId;
|
||||||
|
|
||||||
|
// Initialize deferred state for background steps — flushed at wait/wait-all
|
||||||
|
if (isBackground)
|
||||||
|
{
|
||||||
|
child.DeferredOutputs = new Dictionary<string, string>();
|
||||||
|
child.DeferredEnvironmentVariables = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
child.DeferredPrependPath = new List<string>();
|
||||||
|
child.DeferOutcomeConclusion = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (recordOrder != null)
|
if (recordOrder != null)
|
||||||
{
|
{
|
||||||
child.InitializeTimelineRecord(_mainTimelineId, recordId, _record.Id, ExecutionContextType.Task, displayName, refName, recordOrder, embedded: isEmbedded);
|
child.InitializeTimelineRecord(_mainTimelineId, recordId, _record.Id, ExecutionContextType.Task, displayName, refName, recordOrder, embedded: isEmbedded);
|
||||||
@@ -508,7 +572,11 @@ namespace GitHub.Runner.Worker
|
|||||||
Type = StepTelemetry?.Type,
|
Type = StepTelemetry?.Type,
|
||||||
StartedAt = _record.StartTime,
|
StartedAt = _record.StartTime,
|
||||||
CompletedAt = _record.FinishTime,
|
CompletedAt = _record.FinishTime,
|
||||||
Annotations = new List<Annotation>()
|
Annotations = new List<Annotation>(),
|
||||||
|
// Populate background step metadata from timeline record fields
|
||||||
|
IsBackground = _record.IsBackground,
|
||||||
|
BackgroundControlType = _record.BackgroundControlType,
|
||||||
|
BackgroundControlStepIds = _record.BackgroundControlStepIds
|
||||||
};
|
};
|
||||||
|
|
||||||
_record.Issues?.ForEach(issue =>
|
_record.Issues?.ForEach(issue =>
|
||||||
@@ -554,11 +622,22 @@ namespace GitHub.Runner.Worker
|
|||||||
|
|
||||||
_logger.End();
|
_logger.End();
|
||||||
|
|
||||||
UpdateGlobalStepsContext();
|
if (!DeferOutcomeConclusion)
|
||||||
|
{
|
||||||
|
UpdateGlobalStepsContext();
|
||||||
|
}
|
||||||
|
|
||||||
return Result.Value;
|
return Result.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void FlushDeferredOutcomeConclusion()
|
||||||
|
{
|
||||||
|
if (DeferOutcomeConclusion)
|
||||||
|
{
|
||||||
|
UpdateGlobalStepsContext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void UpdateGlobalStepsContext()
|
public void UpdateGlobalStepsContext()
|
||||||
{
|
{
|
||||||
// Skip if generated context name. Generated context names start with "__". After 3.2 the server will never send an empty context name.
|
// Skip if generated context name. Generated context names start with "__". After 3.2 the server will never send an empty context name.
|
||||||
@@ -634,6 +713,40 @@ namespace GitHub.Runner.Worker
|
|||||||
Global.StepsContext.SetOutput(ScopeName, ContextName, name, value, out reference);
|
Global.StepsContext.SetOutput(ScopeName, ContextName, name, value, out reference);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void FlushDeferredOutputs()
|
||||||
|
{
|
||||||
|
if (DeferredOutputs == null || DeferredOutputs.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var kvp in DeferredOutputs)
|
||||||
|
{
|
||||||
|
Global.StepsContext.SetOutput(ScopeName, ContextName, kvp.Key, kvp.Value, out _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void FlushDeferredEnvironment()
|
||||||
|
{
|
||||||
|
if (DeferredEnvironmentVariables != null)
|
||||||
|
{
|
||||||
|
foreach (var kvp in DeferredEnvironmentVariables)
|
||||||
|
{
|
||||||
|
Global.EnvironmentVariables[kvp.Key] = kvp.Value;
|
||||||
|
SetEnvContext(kvp.Key, kvp.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DeferredPrependPath != null)
|
||||||
|
{
|
||||||
|
foreach (var path in DeferredPrependPath)
|
||||||
|
{
|
||||||
|
Global.PrependPath.RemoveAll(x => string.Equals(x, path, StringComparison.CurrentCulture));
|
||||||
|
Global.PrependPath.Add(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void SetTimeout(TimeSpan? timeout)
|
public void SetTimeout(TimeSpan? timeout)
|
||||||
{
|
{
|
||||||
if (timeout != null)
|
if (timeout != null)
|
||||||
@@ -860,6 +973,9 @@ namespace GitHub.Runner.Worker
|
|||||||
// Track actions stuck on Node.js 20 due to ARM32 (separate from general deprecation)
|
// Track actions stuck on Node.js 20 due to ARM32 (separate from general deprecation)
|
||||||
Global.Arm32Node20Actions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
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
|
// Job Outputs
|
||||||
JobOutputs = new Dictionary<string, VariableValue>(StringComparer.OrdinalIgnoreCase);
|
JobOutputs = new Dictionary<string, VariableValue>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
@@ -1330,7 +1446,10 @@ namespace GitHub.Runner.Worker
|
|||||||
Trace.Info($"Updated step result (continue on error)");
|
Trace.Info($"Updated step result (continue on error)");
|
||||||
}
|
}
|
||||||
|
|
||||||
UpdateGlobalStepsContext();
|
if (!DeferOutcomeConclusion)
|
||||||
|
{
|
||||||
|
UpdateGlobalStepsContext();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal IPipelineTemplateEvaluator ToPipelineTemplateEvaluatorInternal(bool allowServiceContainerCommand, ObjectTemplating.ITraceWriter traceWriter = null)
|
internal IPipelineTemplateEvaluator ToPipelineTemplateEvaluatorInternal(bool allowServiceContainerCommand, ObjectTemplating.ITraceWriter traceWriter = null)
|
||||||
|
|||||||
@@ -55,6 +55,18 @@ namespace GitHub.Runner.Worker
|
|||||||
TryDeleteFile(newPath);
|
TryDeleteFile(newPath);
|
||||||
File.Create(newPath).Dispose();
|
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;
|
var pathToSet = container != null ? container.TranslateToContainerPath(newPath) : newPath;
|
||||||
context.SetGitHubContext(fileCommand.ContextName, pathToSet);
|
context.SetGitHubContext(fileCommand.ContextName, pathToSet);
|
||||||
}
|
}
|
||||||
@@ -102,6 +114,14 @@ namespace GitHub.Runner.Worker
|
|||||||
string FilePrefix { get; }
|
string FilePrefix { get; }
|
||||||
|
|
||||||
void ProcessCommand(IExecutionContext context, string filePath, ContainerInfo container);
|
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
|
public sealed class AddPathFileCommand : RunnerService, IFileCommandExtension
|
||||||
@@ -122,8 +142,16 @@ namespace GitHub.Runner.Worker
|
|||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
context.Global.PrependPath.RemoveAll(x => string.Equals(x, line, StringComparison.CurrentCulture));
|
if (context.DeferredPrependPath != null)
|
||||||
context.Global.PrependPath.Add(line);
|
{
|
||||||
|
context.DeferredPrependPath.RemoveAll(x => string.Equals(x, line, StringComparison.CurrentCulture));
|
||||||
|
context.DeferredPrependPath.Add(line);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
context.Global.PrependPath.RemoveAll(x => string.Equals(x, line, StringComparison.CurrentCulture));
|
||||||
|
context.Global.PrependPath.Add(line);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,8 +200,15 @@ namespace GitHub.Runner.Worker
|
|||||||
string name,
|
string name,
|
||||||
string value)
|
string value)
|
||||||
{
|
{
|
||||||
context.Global.EnvironmentVariables[name] = value;
|
if (context.DeferredEnvironmentVariables != null)
|
||||||
context.SetEnvContext(name, value);
|
{
|
||||||
|
context.DeferredEnvironmentVariables[name] = value;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
context.Global.EnvironmentVariables[name] = value;
|
||||||
|
context.SetEnvContext(name, value);
|
||||||
|
}
|
||||||
context.Debug($"{name}='{value}'");
|
context.Debug($"{name}='{value}'");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,7 +337,14 @@ namespace GitHub.Runner.Worker
|
|||||||
var pairs = new EnvFileKeyValuePairs(context, filePath);
|
var pairs = new EnvFileKeyValuePairs(context, filePath);
|
||||||
foreach (var pair in pairs)
|
foreach (var pair in pairs)
|
||||||
{
|
{
|
||||||
context.SetOutput(pair.Key, pair.Value, out var reference);
|
if (context.DeferredOutputs != null)
|
||||||
|
{
|
||||||
|
context.DeferredOutputs[pair.Key] = pair.Value;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
context.SetOutput(pair.Key, pair.Value, out var reference);
|
||||||
|
}
|
||||||
context.Debug($"Set output {pair.Key} = {pair.Value}");
|
context.Debug($"Set output {pair.Key} = {pair.Value}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ namespace GitHub.Runner.Worker
|
|||||||
"actor",
|
"actor",
|
||||||
"actor_id",
|
"actor_id",
|
||||||
"api_url",
|
"api_url",
|
||||||
|
"artifacts",
|
||||||
|
"artifacts_list",
|
||||||
"base_ref",
|
"base_ref",
|
||||||
"env",
|
"env",
|
||||||
"event_name",
|
"event_name",
|
||||||
|
|||||||
@@ -39,5 +39,9 @@ namespace GitHub.Runner.Worker
|
|||||||
public HashSet<string> UpgradedToNode24Actions { get; set; }
|
public HashSet<string> UpgradedToNode24Actions { get; set; }
|
||||||
public HashSet<string> Arm32Node20Actions { get; set; }
|
public HashSet<string> Arm32Node20Actions { get; set; }
|
||||||
public IList<String> ActionsDependencies { 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; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -345,6 +345,38 @@ namespace GitHub.Runner.Worker
|
|||||||
preJobSteps.Add(preStep);
|
preJobSteps.Add(preStep);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else if (step.Type == Pipelines.StepType.BackgroundStepControl)
|
||||||
|
{
|
||||||
|
var ctrl = step as Pipelines.BackgroundStepControl;
|
||||||
|
Trace.Info($"Adding {ctrl.ControlType} step for: {string.Join(", ", ctrl.StepIds ?? Array.Empty<string>())}");
|
||||||
|
var controlType = ctrl.ControlType;
|
||||||
|
if (string.IsNullOrEmpty(controlType))
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"Background step control '{step.Name}' has no control type.");
|
||||||
|
}
|
||||||
|
if (controlType != Pipelines.BackgroundControlTypes.Wait &&
|
||||||
|
controlType != Pipelines.BackgroundControlTypes.WaitAll &&
|
||||||
|
controlType != Pipelines.BackgroundControlTypes.Cancel)
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"Unknown background step control type '{controlType}' for step '{step.Name}'.");
|
||||||
|
}
|
||||||
|
var displayName = (ctrl.DisplayNameToken as GitHub.DistributedTask.ObjectTemplating.Tokens.StringToken)?.Value
|
||||||
|
?? step.DisplayName ?? step.Name ?? ctrl.ControlType;
|
||||||
|
var data = new BackgroundStepControlFlowData
|
||||||
|
{
|
||||||
|
Type = controlType,
|
||||||
|
StepId = step.Id,
|
||||||
|
StepName = step.Name,
|
||||||
|
StepIds = ctrl.StepIds,
|
||||||
|
ParallelGroupId = ctrl.ParallelGroupId,
|
||||||
|
};
|
||||||
|
var bgCoord = HostContext.GetService<IBackgroundStepCoordinator>();
|
||||||
|
jobSteps.Add(new JobExtensionRunner(
|
||||||
|
runAsync: bgCoord.RunControlFlowAsync,
|
||||||
|
condition: $"{PipelineTemplateConstants.Always}()",
|
||||||
|
displayName: displayName,
|
||||||
|
data: data));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.Variables.TryGetValue("system.workflowFileFullPath", out VariableValue workflowFileFullPath))
|
if (message.Variables.TryGetValue("system.workflowFileFullPath", out VariableValue workflowFileFullPath))
|
||||||
@@ -400,13 +432,107 @@ namespace GitHub.Runner.Worker
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create execution context for job steps
|
// Create execution context for job steps
|
||||||
|
// Build mapping of logical step ID (ContextName) → external ID (timeline record GUID)
|
||||||
|
// so wait/cancel steps can reference background steps by external ID.
|
||||||
|
var contextNameToExternalId = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var hasBackgroundSteps = false;
|
||||||
|
var backgroundStepExternalIds = new List<string>();
|
||||||
|
|
||||||
|
// Track which background steps are explicitly covered by wait/wait-all/cancel
|
||||||
|
var coveredBackgroundIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
foreach (var step in jobSteps)
|
foreach (var step in jobSteps)
|
||||||
{
|
{
|
||||||
if (step is IActionRunner actionStep)
|
if (step is IActionRunner actionStep)
|
||||||
{
|
{
|
||||||
ArgUtil.NotNull(actionStep, step.DisplayName);
|
ArgUtil.NotNull(actionStep, step.DisplayName);
|
||||||
intraActionStates.TryGetValue(actionStep.Action.Id, out var intraActionState);
|
intraActionStates.TryGetValue(actionStep.Action.Id, out var intraActionState);
|
||||||
actionStep.ExecutionContext = jobContext.CreateChild(actionStep.Action.Id, actionStep.DisplayName, actionStep.Action.Name, null, actionStep.Action.ContextName, ActionRunStage.Main, intraActionState);
|
|
||||||
|
var isBg = actionStep.Action?.Background == true;
|
||||||
|
actionStep.ExecutionContext = jobContext.CreateChild(
|
||||||
|
actionStep.Action.Id, actionStep.DisplayName, actionStep.Action.Name,
|
||||||
|
null, actionStep.Action.ContextName, ActionRunStage.Main, intraActionState,
|
||||||
|
isBackground: isBg,
|
||||||
|
parallelGroupId: isBg ? actionStep.Action.ParallelGroupId : null);
|
||||||
|
|
||||||
|
if (isBg)
|
||||||
|
{
|
||||||
|
hasBackgroundSteps = true;
|
||||||
|
var externalId = actionStep.Action.Id.ToString("N");
|
||||||
|
contextNameToExternalId[actionStep.Action.ContextName] = externalId;
|
||||||
|
backgroundStepExternalIds.Add(externalId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (step is JobExtensionRunner runnerStep && runnerStep.Data is BackgroundStepControlFlowData cf)
|
||||||
|
{
|
||||||
|
// Resolve step IDs to external IDs and track coverage
|
||||||
|
string[] externalIds = null;
|
||||||
|
if (cf.StepIds != null && cf.StepIds.Length > 0)
|
||||||
|
{
|
||||||
|
foreach (var id in cf.StepIds)
|
||||||
|
{
|
||||||
|
coveredBackgroundIds.Add(id);
|
||||||
|
}
|
||||||
|
externalIds = cf.StepIds
|
||||||
|
.Where(id => contextNameToExternalId.ContainsKey(id))
|
||||||
|
.Select(id => contextNameToExternalId[id])
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cf.Type == Pipelines.BackgroundControlTypes.WaitAll)
|
||||||
|
{
|
||||||
|
externalIds = backgroundStepExternalIds.Count > 0 ? backgroundStepExternalIds.ToArray() : null;
|
||||||
|
foreach (var id in contextNameToExternalId.Keys)
|
||||||
|
{
|
||||||
|
coveredBackgroundIds.Add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
step.ExecutionContext = jobContext.CreateChild(
|
||||||
|
cf.StepId, step.DisplayName, cf.StepName,
|
||||||
|
null, cf.StepName, ActionRunStage.Main,
|
||||||
|
backgroundControlType: cf.Type,
|
||||||
|
backgroundControlStepIds: externalIds,
|
||||||
|
parallelGroupId: cf.ParallelGroupId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add implicit wait-all only if there are background steps not covered by any wait/wait-all/cancel
|
||||||
|
var allBackgroundIds = contextNameToExternalId.Keys;
|
||||||
|
var hasUncoveredBackgroundSteps = allBackgroundIds.Any(id => !coveredBackgroundIds.Contains(id));
|
||||||
|
if (hasBackgroundSteps)
|
||||||
|
{
|
||||||
|
// Initialize coordinator only when there are background steps
|
||||||
|
var bgCoordinator = HostContext.GetService<IBackgroundStepCoordinator>();
|
||||||
|
var maxBgSteps = jobContext.Global.Variables.GetInt("system.runner.maxbackgroundsteps");
|
||||||
|
var maxConcurrent = (maxBgSteps.HasValue && maxBgSteps.Value > 0) ? maxBgSteps.Value : 10;
|
||||||
|
bgCoordinator.InitializeCoordinator(maxConcurrent);
|
||||||
|
|
||||||
|
// Add implicit wait-all only if there are uncovered background steps
|
||||||
|
if (hasUncoveredBackgroundSteps)
|
||||||
|
{
|
||||||
|
var implicitStepId = Guid.NewGuid();
|
||||||
|
var implicitWaitAllData = new BackgroundStepControlFlowData
|
||||||
|
{
|
||||||
|
Type = Pipelines.BackgroundControlTypes.WaitAll,
|
||||||
|
StepId = implicitStepId,
|
||||||
|
StepName = "__implicit_wait_all",
|
||||||
|
};
|
||||||
|
var implicitWaitAll = new JobExtensionRunner(
|
||||||
|
runAsync: bgCoordinator.RunControlFlowAsync,
|
||||||
|
condition: $"{PipelineTemplateConstants.Always}()",
|
||||||
|
displayName: "Wait for all background steps",
|
||||||
|
data: implicitWaitAllData);
|
||||||
|
var uncoveredExternalIds = contextNameToExternalId
|
||||||
|
.Where(kvp => !coveredBackgroundIds.Contains(kvp.Key))
|
||||||
|
.Select(kvp => kvp.Value)
|
||||||
|
.ToArray();
|
||||||
|
implicitWaitAll.ExecutionContext = jobContext.CreateChild(
|
||||||
|
implicitStepId, implicitWaitAll.DisplayName, "__implicit_wait_all",
|
||||||
|
null, "__implicit_wait_all", ActionRunStage.Main,
|
||||||
|
backgroundControlType: Pipelines.BackgroundControlTypes.WaitAll,
|
||||||
|
backgroundControlStepIds: uncoveredExternalIds.Length > 0 ? uncoveredExternalIds : null);
|
||||||
|
jobSteps.Add(implicitWaitAll);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ using GitHub.DistributedTask.WebApi;
|
|||||||
using GitHub.Runner.Common;
|
using GitHub.Runner.Common;
|
||||||
using GitHub.Runner.Common.Util;
|
using GitHub.Runner.Common.Util;
|
||||||
using GitHub.Runner.Sdk;
|
using GitHub.Runner.Sdk;
|
||||||
|
using GitHub.Runner.Worker.Dap;
|
||||||
using GitHub.Services.Common;
|
using GitHub.Services.Common;
|
||||||
using GitHub.Services.WebApi;
|
using GitHub.Services.WebApi;
|
||||||
using Sdk.RSWebApi.Contracts;
|
using Sdk.RSWebApi.Contracts;
|
||||||
@@ -230,6 +231,12 @@ namespace GitHub.Runner.Worker
|
|||||||
jobContext.JobSteps.Enqueue(step);
|
jobContext.JobSteps.Enqueue(step);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (jobContext.Global.Debugger?.Enabled == true)
|
||||||
|
{
|
||||||
|
var dapDebugger = HostContext.GetService<IDapDebugger>();
|
||||||
|
await dapDebugger.OnJobStepsInitializedAsync(jobContext.JobSteps, jobContext.PostJobSteps);
|
||||||
|
}
|
||||||
|
|
||||||
await stepsRunner.RunAsync(jobContext);
|
await stepsRunner.RunAsync(jobContext);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
<PackageReference Include="System.ServiceProcess.ServiceController" Version="10.0.3" />
|
<PackageReference Include="System.ServiceProcess.ServiceController" Version="10.0.3" />
|
||||||
<PackageReference Include="System.Threading.Channels" Version="10.0.3" />
|
<PackageReference Include="System.Threading.Channels" Version="10.0.3" />
|
||||||
<PackageReference Include="YamlDotNet.Signed" Version="5.3.0" />
|
<PackageReference Include="YamlDotNet.Signed" Version="5.3.0" />
|
||||||
<PackageReference Include="Microsoft.DevTunnels.Connections" Version="1.3.39" />
|
<PackageReference Include="Microsoft.DevTunnels.Connections" Version="1.3.48" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ namespace GitHub.Runner.Worker
|
|||||||
{
|
{
|
||||||
private static readonly Regex _propertyRegex = new("^[a-zA-Z_][a-zA-Z0-9_]*$", RegexOptions.Compiled);
|
private static readonly Regex _propertyRegex = new("^[a-zA-Z_][a-zA-Z0-9_]*$", RegexOptions.Compiled);
|
||||||
private readonly DictionaryContextData _contextData = new();
|
private readonly DictionaryContextData _contextData = new();
|
||||||
|
private readonly object _lock = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Clears memory for a composite action's isolated "steps" context, after the action
|
/// Clears memory for a composite action's isolated "steps" context, after the action
|
||||||
@@ -25,9 +26,12 @@ namespace GitHub.Runner.Worker
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public void ClearScope(string scopeName)
|
public void ClearScope(string scopeName)
|
||||||
{
|
{
|
||||||
if (_contextData.TryGetValue(scopeName, out _))
|
lock (_lock)
|
||||||
{
|
{
|
||||||
_contextData[scopeName] = new DictionaryContextData();
|
if (_contextData.TryGetValue(scopeName, out _))
|
||||||
|
{
|
||||||
|
_contextData[scopeName] = new DictionaryContextData();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,23 +45,26 @@ namespace GitHub.Runner.Worker
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public DictionaryContextData GetScope(string scopeName)
|
public DictionaryContextData GetScope(string scopeName)
|
||||||
{
|
{
|
||||||
if (scopeName == null)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
scopeName = string.Empty;
|
if (scopeName == null)
|
||||||
}
|
{
|
||||||
|
scopeName = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
var scope = default(DictionaryContextData);
|
var scope = default(DictionaryContextData);
|
||||||
if (_contextData.TryGetValue(scopeName, out var scopeValue))
|
if (_contextData.TryGetValue(scopeName, out var scopeValue))
|
||||||
{
|
{
|
||||||
scope = scopeValue.AssertDictionary("scope");
|
scope = scopeValue.AssertDictionary("scope");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
scope = new DictionaryContextData();
|
scope = new DictionaryContextData();
|
||||||
_contextData.Add(scopeName, scope);
|
_contextData.Add(scopeName, scope);
|
||||||
}
|
}
|
||||||
|
|
||||||
return scope;
|
return scope;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SetOutput(
|
public void SetOutput(
|
||||||
@@ -67,16 +74,19 @@ namespace GitHub.Runner.Worker
|
|||||||
string value,
|
string value,
|
||||||
out string reference)
|
out string reference)
|
||||||
{
|
{
|
||||||
var step = GetStep(scopeName, stepName);
|
lock (_lock)
|
||||||
var outputs = step["outputs"].AssertDictionary("outputs");
|
|
||||||
outputs[outputName] = new StringContextData(value);
|
|
||||||
if (_propertyRegex.IsMatch(outputName))
|
|
||||||
{
|
{
|
||||||
reference = $"steps.{stepName}.outputs.{outputName}";
|
var step = GetStep(scopeName, stepName);
|
||||||
}
|
var outputs = step["outputs"].AssertDictionary("outputs");
|
||||||
else
|
outputs[outputName] = new StringContextData(value);
|
||||||
{
|
if (_propertyRegex.IsMatch(outputName))
|
||||||
reference = $"steps['{stepName}']['outputs']['{outputName}']";
|
{
|
||||||
|
reference = $"steps.{stepName}.outputs.{outputName}";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
reference = $"steps['{stepName}']['outputs']['{outputName}']";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,8 +95,11 @@ namespace GitHub.Runner.Worker
|
|||||||
string stepName,
|
string stepName,
|
||||||
ActionResult conclusion)
|
ActionResult conclusion)
|
||||||
{
|
{
|
||||||
var step = GetStep(scopeName, stepName);
|
lock (_lock)
|
||||||
step["conclusion"] = new StringContextData(conclusion.ToString().ToLowerInvariant());
|
{
|
||||||
|
var step = GetStep(scopeName, stepName);
|
||||||
|
step["conclusion"] = new StringContextData(conclusion.ToString().ToLowerInvariant());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SetOutcome(
|
public void SetOutcome(
|
||||||
@@ -94,8 +107,11 @@ namespace GitHub.Runner.Worker
|
|||||||
string stepName,
|
string stepName,
|
||||||
ActionResult outcome)
|
ActionResult outcome)
|
||||||
{
|
{
|
||||||
var step = GetStep(scopeName, stepName);
|
lock (_lock)
|
||||||
step["outcome"] = new StringContextData(outcome.ToString().ToLowerInvariant());
|
{
|
||||||
|
var step = GetStep(scopeName, stepName);
|
||||||
|
step["outcome"] = new StringContextData(outcome.ToString().ToLowerInvariant());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private DictionaryContextData GetStep(string scopeName, string stepName)
|
private DictionaryContextData GetStep(string scopeName, string stepName)
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ namespace GitHub.Runner.Worker
|
|||||||
ArgUtil.NotNull(jobContext, nameof(jobContext));
|
ArgUtil.NotNull(jobContext, nameof(jobContext));
|
||||||
ArgUtil.NotNull(jobContext.JobSteps, nameof(jobContext.JobSteps));
|
ArgUtil.NotNull(jobContext.JobSteps, nameof(jobContext.JobSteps));
|
||||||
|
|
||||||
|
var _bgCoordinator = HostContext.GetService<IBackgroundStepCoordinator>();
|
||||||
|
|
||||||
// TaskResult:
|
// TaskResult:
|
||||||
// Abandoned (Server set this.)
|
// Abandoned (Server set this.)
|
||||||
// Canceled
|
// Canceled
|
||||||
@@ -57,6 +59,15 @@ namespace GitHub.Runner.Worker
|
|||||||
if (jobContext.JobSteps.Count == 0 && !checkPostJobActions)
|
if (jobContext.JobSteps.Count == 0 && !checkPostJobActions)
|
||||||
{
|
{
|
||||||
checkPostJobActions = true;
|
checkPostJobActions = true;
|
||||||
|
|
||||||
|
// Safety net: wait for any unwaited background steps before post-hooks
|
||||||
|
var backgroundResult = await _bgCoordinator.WaitForUnwaitedStepsAsync(jobContext.CancellationToken);
|
||||||
|
if (backgroundResult != TaskResult.Succeeded)
|
||||||
|
{
|
||||||
|
jobContext.Result = TaskResultUtil.MergeTaskResults(jobContext.Result, backgroundResult);
|
||||||
|
jobContext.JobContext.Status = jobContext.Result?.ToActionResult();
|
||||||
|
}
|
||||||
|
|
||||||
while (jobContext.PostJobSteps.TryPop(out var postStep))
|
while (jobContext.PostJobSteps.TryPop(out var postStep))
|
||||||
{
|
{
|
||||||
jobContext.JobSteps.Enqueue(postStep);
|
jobContext.JobSteps.Enqueue(postStep);
|
||||||
@@ -72,8 +83,11 @@ namespace GitHub.Runner.Worker
|
|||||||
ArgUtil.NotNull(step.ExecutionContext.Global, nameof(step.ExecutionContext.Global));
|
ArgUtil.NotNull(step.ExecutionContext.Global, nameof(step.ExecutionContext.Global));
|
||||||
ArgUtil.NotNull(step.ExecutionContext.Global.Variables, nameof(step.ExecutionContext.Global.Variables));
|
ArgUtil.NotNull(step.ExecutionContext.Global.Variables, nameof(step.ExecutionContext.Global.Variables));
|
||||||
|
|
||||||
// Start
|
// Start — defer for background steps until the slot is acquired
|
||||||
step.ExecutionContext.Start();
|
if (!step.ExecutionContext.IsBackground)
|
||||||
|
{
|
||||||
|
step.ExecutionContext.Start();
|
||||||
|
}
|
||||||
|
|
||||||
// Expression functions
|
// Expression functions
|
||||||
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<AlwaysFunction>(PipelineTemplateConstants.Always, 0, 0));
|
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<AlwaysFunction>(PipelineTemplateConstants.Always, 0, 0));
|
||||||
@@ -228,14 +242,22 @@ namespace GitHub.Runner.Worker
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Pause for DAP debugger before step execution
|
if (step.ExecutionContext.IsBackground)
|
||||||
await dapDebugger?.OnStepStartingAsync(step);
|
{
|
||||||
|
// Queue the background step via coordinator
|
||||||
|
_bgCoordinator.StartBackgroundStep(step, jobContext.CancellationToken);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Pause for DAP debugger before step execution
|
||||||
|
await dapDebugger?.OnStepStartingAsync(step);
|
||||||
|
|
||||||
// Run the step
|
// Run the step synchronously (normal behavior)
|
||||||
await RunStepAsync(step, jobContext.CancellationToken);
|
await RunStepAsync(step, jobContext.CancellationToken);
|
||||||
CompleteStep(step);
|
CompleteStep(step);
|
||||||
|
|
||||||
dapDebugger?.OnStepCompleted(step);
|
dapDebugger?.OnStepCompleted(step);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ namespace GitHub.DistributedTask.Pipelines
|
|||||||
Inputs = actionToClone.Inputs?.Clone();
|
Inputs = actionToClone.Inputs?.Clone();
|
||||||
ContextName = actionToClone?.ContextName;
|
ContextName = actionToClone?.ContextName;
|
||||||
DisplayNameToken = actionToClone.DisplayNameToken?.Clone();
|
DisplayNameToken = actionToClone.DisplayNameToken?.Clone();
|
||||||
|
Background = actionToClone.Background;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override StepType Type => StepType.Action;
|
public override StepType Type => StepType.Action;
|
||||||
@@ -49,6 +50,9 @@ namespace GitHub.DistributedTask.Pipelines
|
|||||||
[DataMember(EmitDefaultValue = false)]
|
[DataMember(EmitDefaultValue = false)]
|
||||||
public TemplateToken Inputs { get; set; }
|
public TemplateToken Inputs { get; set; }
|
||||||
|
|
||||||
|
[DataMember(EmitDefaultValue = false)]
|
||||||
|
public bool Background { get; set; }
|
||||||
|
|
||||||
public override Step Clone()
|
public override Step Clone()
|
||||||
{
|
{
|
||||||
return new ActionStep(this);
|
return new ActionStep(this);
|
||||||
|
|||||||
57
src/Sdk/DTPipelines/Pipelines/BackgroundStepControl.cs
Normal file
57
src/Sdk/DTPipelines/Pipelines/BackgroundStepControl.cs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Runtime.Serialization;
|
||||||
|
using GitHub.DistributedTask.ObjectTemplating.Tokens;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace GitHub.DistributedTask.Pipelines
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Known control-flow types for background step control steps.
|
||||||
|
/// Wire values must match run-service constants (wait, wait-all, cancel).
|
||||||
|
/// </summary>
|
||||||
|
public static class BackgroundControlTypes
|
||||||
|
{
|
||||||
|
public const string Wait = "wait";
|
||||||
|
public const string WaitAll = "wait-all";
|
||||||
|
public const string Cancel = "cancel";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a unified background step control-flow step (wait, wait-all, cancel).
|
||||||
|
/// </summary>
|
||||||
|
[DataContract]
|
||||||
|
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||||
|
public class BackgroundStepControl : JobStep
|
||||||
|
{
|
||||||
|
[JsonConstructor]
|
||||||
|
public BackgroundStepControl()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
private BackgroundStepControl(BackgroundStepControl stepToClone)
|
||||||
|
: base(stepToClone)
|
||||||
|
{
|
||||||
|
this.ControlType = stepToClone.ControlType;
|
||||||
|
this.StepIds = stepToClone.StepIds != null
|
||||||
|
? (string[])stepToClone.StepIds.Clone()
|
||||||
|
: null;
|
||||||
|
this.DisplayNameToken = stepToClone.DisplayNameToken?.Clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override StepType Type => StepType.BackgroundStepControl;
|
||||||
|
|
||||||
|
[DataMember(EmitDefaultValue = false)]
|
||||||
|
public string ControlType { get; set; }
|
||||||
|
|
||||||
|
[DataMember(EmitDefaultValue = false)]
|
||||||
|
public string[] StepIds { get; set; }
|
||||||
|
|
||||||
|
[DataMember(EmitDefaultValue = false)]
|
||||||
|
public TemplateToken DisplayNameToken { get; set; }
|
||||||
|
|
||||||
|
public override Step Clone()
|
||||||
|
{
|
||||||
|
return new BackgroundStepControl(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ namespace GitHub.DistributedTask.Pipelines
|
|||||||
this.Condition = stepToClone.Condition;
|
this.Condition = stepToClone.Condition;
|
||||||
this.ContinueOnError = stepToClone.ContinueOnError?.Clone();
|
this.ContinueOnError = stepToClone.ContinueOnError?.Clone();
|
||||||
this.TimeoutInMinutes = stepToClone.TimeoutInMinutes?.Clone();
|
this.TimeoutInMinutes = stepToClone.TimeoutInMinutes?.Clone();
|
||||||
|
this.ParallelGroupId = stepToClone.ParallelGroupId;
|
||||||
}
|
}
|
||||||
|
|
||||||
[DataMember(EmitDefaultValue = false)]
|
[DataMember(EmitDefaultValue = false)]
|
||||||
@@ -44,5 +45,8 @@ namespace GitHub.DistributedTask.Pipelines
|
|||||||
get;
|
get;
|
||||||
set;
|
set;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[DataMember(EmitDefaultValue = false)]
|
||||||
|
public string ParallelGroupId { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,18 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
|
|||||||
break;
|
break;
|
||||||
case ActionSourceType.Repository:
|
case ActionSourceType.Repository:
|
||||||
var repositoryReference = step.Reference as RepositoryPathReference;
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -600,6 +611,14 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
|
|||||||
Path = uses.Value
|
Path = uses.Value
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
else if (PipelineConstants.TryParseSelfRepository(uses.Value, out var selfPath))
|
||||||
|
{
|
||||||
|
result.Reference = new RepositoryPathReference
|
||||||
|
{
|
||||||
|
RepositoryType = PipelineConstants.SelfRepositoryAlias,
|
||||||
|
Path = selfPath
|
||||||
|
};
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var usesSegments = uses.Value.Split('@');
|
var usesSegments = uses.Value.Split('@');
|
||||||
|
|||||||
@@ -38,10 +38,43 @@ namespace GitHub.DistributedTask.Pipelines
|
|||||||
public static readonly Int32 MaxNodeNameLength = 100;
|
public static readonly Int32 MaxNodeNameLength = 100;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Alias for the self repository.
|
/// Alias for the self local-workspace repository type (./ syntax).
|
||||||
|
/// Resolves to the local checkout on the runner.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static readonly String SelfAlias = "self";
|
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>
|
/// <summary>
|
||||||
/// Error code during graph validation.
|
/// Error code during graph validation.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace GitHub.DistributedTask.Pipelines
|
|||||||
{
|
{
|
||||||
[DataContract]
|
[DataContract]
|
||||||
[KnownType(typeof(ActionStep))]
|
[KnownType(typeof(ActionStep))]
|
||||||
|
[KnownType(typeof(BackgroundStepControl))]
|
||||||
[JsonConverter(typeof(StepConverter))]
|
[JsonConverter(typeof(StepConverter))]
|
||||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||||
public abstract class Step
|
public abstract class Step
|
||||||
@@ -68,5 +69,7 @@ namespace GitHub.DistributedTask.Pipelines
|
|||||||
{
|
{
|
||||||
[DataMember]
|
[DataMember]
|
||||||
Action = 4,
|
Action = 4,
|
||||||
|
[DataMember]
|
||||||
|
BackgroundStepControl = 5,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ namespace GitHub.DistributedTask.Pipelines
|
|||||||
case StepType.Action:
|
case StepType.Action:
|
||||||
stepObject = new ActionStep();
|
stepObject = new ActionStep();
|
||||||
break;
|
break;
|
||||||
|
case StepType.BackgroundStepControl:
|
||||||
|
stepObject = new BackgroundStepControl();
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
using (var objectReader = value.CreateReader())
|
using (var objectReader = value.CreateReader())
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ namespace GitHub.DistributedTask.WebApi
|
|||||||
this.WarningCount = recordToBeCloned.WarningCount;
|
this.WarningCount = recordToBeCloned.WarningCount;
|
||||||
this.NoticeCount = recordToBeCloned.NoticeCount;
|
this.NoticeCount = recordToBeCloned.NoticeCount;
|
||||||
this.AgentPlatform = recordToBeCloned.AgentPlatform;
|
this.AgentPlatform = recordToBeCloned.AgentPlatform;
|
||||||
|
this.IsBackground = recordToBeCloned.IsBackground;
|
||||||
|
this.BackgroundControlType = recordToBeCloned.BackgroundControlType;
|
||||||
|
this.BackgroundControlStepIds = recordToBeCloned.BackgroundControlStepIds;
|
||||||
|
this.ParallelGroupId = recordToBeCloned.ParallelGroupId;
|
||||||
|
|
||||||
if (recordToBeCloned.Log != null)
|
if (recordToBeCloned.Log != null)
|
||||||
{
|
{
|
||||||
@@ -289,6 +293,34 @@ namespace GitHub.DistributedTask.WebApi
|
|||||||
set;
|
set;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[DataMember(Order = 140, EmitDefaultValue = false)]
|
||||||
|
public bool IsBackground
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
set;
|
||||||
|
}
|
||||||
|
|
||||||
|
[DataMember(Order = 141, EmitDefaultValue = false)]
|
||||||
|
public string BackgroundControlType
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
set;
|
||||||
|
}
|
||||||
|
|
||||||
|
[DataMember(Order = 142, EmitDefaultValue = false)]
|
||||||
|
public string[] BackgroundControlStepIds
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
set;
|
||||||
|
}
|
||||||
|
|
||||||
|
[DataMember(Order = 144, EmitDefaultValue = false)]
|
||||||
|
public string ParallelGroupId
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
set;
|
||||||
|
}
|
||||||
|
|
||||||
public IList<TimelineAttempt> PreviousAttempts
|
public IList<TimelineAttempt> PreviousAttempts
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
|
|||||||
@@ -50,5 +50,14 @@ namespace GitHub.Actions.RunService.WebApi
|
|||||||
|
|
||||||
[DataMember(Name = "annotations", EmitDefaultValue = false)]
|
[DataMember(Name = "annotations", EmitDefaultValue = false)]
|
||||||
public List<Annotation> Annotations { get; set; }
|
public List<Annotation> Annotations { get; set; }
|
||||||
|
|
||||||
|
[DataMember(Name = "is_background", EmitDefaultValue = false)]
|
||||||
|
public bool IsBackground { get; set; }
|
||||||
|
|
||||||
|
[DataMember(Name = "background_control_type", EmitDefaultValue = false)]
|
||||||
|
public string BackgroundControlType { get; set; }
|
||||||
|
|
||||||
|
[DataMember(Name = "background_control_step_ids", EmitDefaultValue = false)]
|
||||||
|
public string[] BackgroundControlStepIds { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,14 +23,14 @@
|
|||||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||||
<PackageReference Include="Microsoft.AspNet.WebApi.Client" Version="6.0.0" />
|
<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.Cng" Version="5.0.0" />
|
||||||
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="10.0.6" />
|
<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.ProtectedData" Version="10.0.3" />
|
||||||
<PackageReference Include="Minimatch" Version="2.0.0" />
|
<PackageReference Include="Minimatch" Version="2.0.0" />
|
||||||
<PackageReference Include="YamlDotNet.Signed" Version="5.3.0" />
|
<PackageReference Include="YamlDotNet.Signed" Version="5.3.0" />
|
||||||
<PackageReference Include="System.Net.Http" Version="4.3.4" />
|
<PackageReference Include="System.Net.Http" Version="4.3.4" />
|
||||||
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1" />
|
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1" />
|
||||||
<PackageReference Include="System.Private.Uri" Version="4.3.2" />
|
<PackageReference Include="System.Private.Uri" Version="4.3.2" />
|
||||||
<PackageReference Include="System.Formats.Asn1" Version="10.0.6" />
|
<PackageReference Include="System.Formats.Asn1" Version="10.0.7" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -179,6 +179,14 @@ namespace GitHub.Services.Results.Contracts
|
|||||||
public string CompletedAt;
|
public string CompletedAt;
|
||||||
[DataMember]
|
[DataMember]
|
||||||
public Conclusion Conclusion;
|
public Conclusion Conclusion;
|
||||||
|
[DataMember(EmitDefaultValue = false)]
|
||||||
|
public bool IsBackground;
|
||||||
|
[DataMember(EmitDefaultValue = false)]
|
||||||
|
public string BackgroundControlType;
|
||||||
|
[DataMember(EmitDefaultValue = false)]
|
||||||
|
public string[] BackgroundControlStepIds;
|
||||||
|
[DataMember(EmitDefaultValue = false)]
|
||||||
|
public string ParallelGroupId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum Status
|
public enum Status
|
||||||
|
|||||||
@@ -514,7 +514,7 @@ namespace GitHub.Services.Results.Client
|
|||||||
|
|
||||||
private Step ConvertTimelineRecordToStep(TimelineRecord r)
|
private Step ConvertTimelineRecordToStep(TimelineRecord r)
|
||||||
{
|
{
|
||||||
return new Step()
|
var step = new Step()
|
||||||
{
|
{
|
||||||
ExternalId = r.Id.ToString(),
|
ExternalId = r.Id.ToString(),
|
||||||
Number = r.Order.GetValueOrDefault(),
|
Number = r.Order.GetValueOrDefault(),
|
||||||
@@ -522,8 +522,25 @@ namespace GitHub.Services.Results.Client
|
|||||||
Status = ConvertStateToStatus(r.State.GetValueOrDefault()),
|
Status = ConvertStateToStatus(r.State.GetValueOrDefault()),
|
||||||
StartedAt = r.StartTime?.ToString(Constants.TimestampFormat, CultureInfo.InvariantCulture),
|
StartedAt = r.StartTime?.ToString(Constants.TimestampFormat, CultureInfo.InvariantCulture),
|
||||||
CompletedAt = r.FinishTime?.ToString(Constants.TimestampFormat, CultureInfo.InvariantCulture),
|
CompletedAt = r.FinishTime?.ToString(Constants.TimestampFormat, CultureInfo.InvariantCulture),
|
||||||
Conclusion = ConvertResultToConclusion(r.Result)
|
Conclusion = ConvertResultToConclusion(r.Result),
|
||||||
|
IsBackground = r.IsBackground,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Set background control type directly (no enum mapping needed)
|
||||||
|
if (!string.IsNullOrEmpty(r.BackgroundControlType))
|
||||||
|
{
|
||||||
|
step.BackgroundControlType = r.BackgroundControlType;
|
||||||
|
}
|
||||||
|
if (r.BackgroundControlStepIds != null)
|
||||||
|
{
|
||||||
|
step.BackgroundControlStepIds = r.BackgroundControlStepIds;
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrEmpty(r.ParallelGroupId))
|
||||||
|
{
|
||||||
|
step.ParallelGroupId = r.ParallelGroupId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return step;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Status ConvertStateToStatus(TimelineRecordState s)
|
private Status ConvertStateToStatus(TimelineRecordState s)
|
||||||
|
|||||||
@@ -1605,6 +1605,10 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
|||||||
{
|
{
|
||||||
id = WorkflowConstants.SelfAlias;
|
id = WorkflowConstants.SelfAlias;
|
||||||
}
|
}
|
||||||
|
else if (GitHub.DistributedTask.Pipelines.PipelineConstants.TryParseSelfRepository(action.Uses!.Value, out _))
|
||||||
|
{
|
||||||
|
id = WorkflowConstants.SelfRepositoryAlias;
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var usesSegments = action.Uses!.Value.Split('@');
|
var usesSegments = action.Uses!.Value.Split('@');
|
||||||
|
|||||||
@@ -26,10 +26,15 @@ namespace GitHub.Actions.WorkflowParser
|
|||||||
internal const Int32 MaxNodeNameLength = 100;
|
internal const Int32 MaxNodeNameLength = 100;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Alias for the self repository.
|
/// Alias for the self local-workspace repository type (./ syntax).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal const String SelfAlias = "self";
|
internal const String SelfAlias = "self";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// RepositoryType for self-repository references ($/ syntax).
|
||||||
|
/// </summary>
|
||||||
|
internal const String SelfRepositoryAlias = "selfRepository";
|
||||||
|
|
||||||
public static class PermissionsPolicy
|
public static class PermissionsPolicy
|
||||||
{
|
{
|
||||||
public const string LimitedRead = "LimitedRead";
|
public const string LimitedRead = "LimitedRead";
|
||||||
|
|||||||
@@ -90,6 +90,11 @@ namespace GitHub.Runner.Common.Tests.Worker
|
|||||||
|
|
||||||
var actionYamlFile = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), ActionName, "main", "action.yml");
|
var actionYamlFile = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), ActionName, "main", "action.yml");
|
||||||
Assert.True(File.Exists(actionYamlFile));
|
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));
|
_hc.GetTrace().Info(File.ReadAllText(actionYamlFile));
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -148,6 +153,11 @@ namespace GitHub.Runner.Common.Tests.Worker
|
|||||||
|
|
||||||
// Act + Assert
|
// Act + Assert
|
||||||
await Assert.ThrowsAsync<InvalidActionArchiveException>(async () => await _actionManager.PrepareActionsAsync(_ec.Object, actions));
|
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
|
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
|
#if OS_LINUX
|
||||||
[Fact]
|
[Fact]
|
||||||
[Trait("Level", "L0")]
|
[Trait("Level", "L0")]
|
||||||
@@ -2386,7 +2441,7 @@ runs:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
[Trait("Level", "L0")]
|
[Trait("Level", "L0")]
|
||||||
[Trait("Category", "Worker")]
|
[Trait("Category", "Worker")]
|
||||||
public void LoadsNode24ActionDefinition()
|
public void LoadsNode24ActionDefinition()
|
||||||
@@ -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]
|
[Fact]
|
||||||
[Trait("Level", "L0")]
|
[Trait("Level", "L0")]
|
||||||
[Trait("Category", "Worker")]
|
[Trait("Category", "Worker")]
|
||||||
@@ -3468,5 +3533,604 @@ runs:
|
|||||||
Teardown();
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
214
src/Test/L0/Worker/ArtifactsListFileCommandL0.cs
Normal file
214
src/Test/L0/Worker/ArtifactsListFileCommandL0.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
702
src/Test/L0/Worker/BackgroundStepsL0.cs
Normal file
702
src/Test/L0/Worker/BackgroundStepsL0.cs
Normal file
@@ -0,0 +1,702 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Moq;
|
||||||
|
using Xunit;
|
||||||
|
using GitHub.DistributedTask.Expressions2;
|
||||||
|
using GitHub.DistributedTask.Pipelines.ContextData;
|
||||||
|
using GitHub.DistributedTask.ObjectTemplating.Tokens;
|
||||||
|
using GitHub.DistributedTask.WebApi;
|
||||||
|
using GitHub.Runner.Common.Util;
|
||||||
|
using GitHub.Runner.Worker;
|
||||||
|
using GitHub.Runner.Worker.Dap;
|
||||||
|
using Pipelines = GitHub.DistributedTask.Pipelines;
|
||||||
|
|
||||||
|
namespace GitHub.Runner.Common.Tests.Worker
|
||||||
|
{
|
||||||
|
public sealed class BackgroundStepsL0
|
||||||
|
{
|
||||||
|
private Mock<IExecutionContext> _ec;
|
||||||
|
private StepsRunner _stepsRunner;
|
||||||
|
private Variables _variables;
|
||||||
|
private Dictionary<string, string> _env;
|
||||||
|
private DictionaryContextData _contexts;
|
||||||
|
private JobContext _jobContext;
|
||||||
|
private StepsContext _stepContext;
|
||||||
|
|
||||||
|
private TestHostContext CreateTestContext([CallerMemberName] String testName = "")
|
||||||
|
{
|
||||||
|
var hc = new TestHostContext(this, testName);
|
||||||
|
Dictionary<string, VariableValue> variablesToCopy = new();
|
||||||
|
_variables = new Variables(
|
||||||
|
hostContext: hc,
|
||||||
|
copy: variablesToCopy);
|
||||||
|
_env = new Dictionary<string, string>()
|
||||||
|
{
|
||||||
|
{"env1", "1"},
|
||||||
|
{"test", "github_actions"}
|
||||||
|
};
|
||||||
|
_ec = new Mock<IExecutionContext>();
|
||||||
|
_ec.SetupAllProperties();
|
||||||
|
_ec.Setup(x => x.Global).Returns(new GlobalContext { WriteDebug = true });
|
||||||
|
_ec.Object.Global.Variables = _variables;
|
||||||
|
_ec.Object.Global.EnvironmentVariables = _env;
|
||||||
|
_ec.Object.Global.FileTable = new List<string>();
|
||||||
|
|
||||||
|
_contexts = new DictionaryContextData();
|
||||||
|
_jobContext = new JobContext();
|
||||||
|
_contexts["github"] = new GitHubContext();
|
||||||
|
_contexts["runner"] = new DictionaryContextData();
|
||||||
|
_contexts["job"] = _jobContext;
|
||||||
|
_ec.Setup(x => x.ExpressionValues).Returns(_contexts);
|
||||||
|
_ec.Setup(x => x.ExpressionFunctions).Returns(new List<IFunctionInfo>());
|
||||||
|
_ec.Setup(x => x.JobContext).Returns(_jobContext);
|
||||||
|
_ec.Setup(x => x.CancellationToken).Returns(CancellationToken.None);
|
||||||
|
|
||||||
|
_stepContext = new StepsContext();
|
||||||
|
_ec.Object.Global.StepsContext = _stepContext;
|
||||||
|
|
||||||
|
_ec.Setup(x => x.PostJobSteps).Returns(new Stack<IStep>());
|
||||||
|
|
||||||
|
var trace = hc.GetTrace();
|
||||||
|
|
||||||
|
// Mock CreateChild for implicit wait-all step injection
|
||||||
|
_ec.Setup(x => x.CreateChild(
|
||||||
|
It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||||
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ActionRunStage>(),
|
||||||
|
It.IsAny<Dictionary<string, string>>(), It.IsAny<int?>(), It.IsAny<IPagingLogger>(),
|
||||||
|
It.IsAny<bool>(), It.IsAny<List<Issue>>(), It.IsAny<CancellationTokenSource>(),
|
||||||
|
It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<TimeSpan?>(),
|
||||||
|
It.IsAny<bool>(), It.IsAny<string>(), It.IsAny<string[]>(), It.IsAny<string>()))
|
||||||
|
.Returns((Guid recordId, string displayName, string refName, string scopeName, string contextName,
|
||||||
|
ActionRunStage stage, Dictionary<string, string> intraActionState, int? recordOrder, IPagingLogger logger,
|
||||||
|
bool isEmbedded, List<Issue> issues, CancellationTokenSource cts, Guid embeddedId, string siblingScopeName, TimeSpan? timeout,
|
||||||
|
bool isBackground, string backgroundControlType, string[] backgroundControlStepIds, string parallelGroupId) =>
|
||||||
|
{
|
||||||
|
var childEc = new Mock<IExecutionContext>();
|
||||||
|
childEc.SetupAllProperties();
|
||||||
|
childEc.Setup(x => x.Global).Returns(() => _ec.Object.Global);
|
||||||
|
childEc.Setup(x => x.ExpressionValues).Returns(new DictionaryContextData());
|
||||||
|
childEc.Setup(x => x.ExpressionFunctions).Returns(new List<IFunctionInfo>());
|
||||||
|
childEc.Setup(x => x.ContextName).Returns(contextName);
|
||||||
|
childEc.Setup(x => x.CancellationToken).Returns(CancellationToken.None);
|
||||||
|
childEc.Setup(x => x.Complete(It.IsAny<TaskResult?>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||||
|
.Callback((TaskResult? r, string currentOperation, string resultCode) =>
|
||||||
|
{
|
||||||
|
if (r != null) childEc.Object.Result = r;
|
||||||
|
});
|
||||||
|
childEc.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>())).Callback((string tag, string message) => { trace.Info($"[{tag}]{message}"); });
|
||||||
|
return childEc.Object;
|
||||||
|
});
|
||||||
|
|
||||||
|
_ec.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>())).Callback((string tag, string message) => { trace.Info($"[{tag}]{message}"); });
|
||||||
|
|
||||||
|
_stepsRunner = new StepsRunner();
|
||||||
|
_stepsRunner.Initialize(hc);
|
||||||
|
|
||||||
|
var bgCoordinator = new BackgroundStepCoordinator();
|
||||||
|
bgCoordinator.Initialize(hc);
|
||||||
|
hc.SetSingleton<IBackgroundStepCoordinator>(bgCoordinator);
|
||||||
|
|
||||||
|
var mockDapDebugger = new Mock<IDapDebugger>();
|
||||||
|
hc.SetSingleton(mockDapDebugger.Object);
|
||||||
|
|
||||||
|
return hc;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public async Task BackgroundStepRunsConcurrentlyWithForeground()
|
||||||
|
{
|
||||||
|
using (TestHostContext hc = CreateTestContext())
|
||||||
|
{
|
||||||
|
// Arrange: background step that takes time, followed by a foreground step
|
||||||
|
var executionOrder = new List<string>();
|
||||||
|
|
||||||
|
var bgStep = CreateStep(hc, TaskResult.Succeeded, "success()", name: "bg-step", contextName: "bg", isBackground: true);
|
||||||
|
bgStep.Setup(x => x.RunAsync()).Returns(async () =>
|
||||||
|
{
|
||||||
|
executionOrder.Add("bg-start");
|
||||||
|
await Task.Delay(2000);
|
||||||
|
executionOrder.Add("bg-end");
|
||||||
|
});
|
||||||
|
bgStep.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
|
||||||
|
{
|
||||||
|
Name = "bg-step",
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ContextName = "bg",
|
||||||
|
Background = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
var fgStep = CreateStep(hc, TaskResult.Succeeded, "success()", name: "fg-step", contextName: "fg");
|
||||||
|
fgStep.Setup(x => x.RunAsync()).Returns(() =>
|
||||||
|
{
|
||||||
|
executionOrder.Add("fg-run");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
|
||||||
|
var waitAllStep = CreateWaitAllStep(hc);
|
||||||
|
|
||||||
|
_ec.Object.Result = null;
|
||||||
|
_ec.Setup(x => x.JobSteps).Returns(new Queue<IStep>(new IStep[]
|
||||||
|
{
|
||||||
|
bgStep.Object, fgStep.Object, waitAllStep
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await _stepsRunner.RunAsync(jobContext: _ec.Object);
|
||||||
|
|
||||||
|
// Assert: foreground step should start before background step finishes
|
||||||
|
Assert.Contains("bg-start", executionOrder);
|
||||||
|
Assert.Contains("fg-run", executionOrder);
|
||||||
|
Assert.Contains("bg-end", executionOrder);
|
||||||
|
var fgIndex = executionOrder.IndexOf("fg-run");
|
||||||
|
var bgEndIndex = executionOrder.IndexOf("bg-end");
|
||||||
|
Assert.True(fgIndex < bgEndIndex, "Foreground step should run before background step completes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public async Task WaitStepBlocksUntilBackgroundCompletes()
|
||||||
|
{
|
||||||
|
using (TestHostContext hc = CreateTestContext())
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var bgCompleted = false;
|
||||||
|
|
||||||
|
var bgStep = CreateStep(hc, TaskResult.Succeeded, "success()", name: "db", contextName: "db", isBackground: true);
|
||||||
|
bgStep.Setup(x => x.RunAsync()).Returns(async () =>
|
||||||
|
{
|
||||||
|
await Task.Delay(100);
|
||||||
|
bgCompleted = true;
|
||||||
|
});
|
||||||
|
bgStep.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
|
||||||
|
{
|
||||||
|
Name = "db",
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ContextName = "db",
|
||||||
|
Background = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
var waitStep = CreateWaitStep(hc, new[] { "db" });
|
||||||
|
|
||||||
|
_ec.Object.Result = null;
|
||||||
|
_ec.Setup(x => x.JobSteps).Returns(new Queue<IStep>(new IStep[]
|
||||||
|
{
|
||||||
|
bgStep.Object, waitStep
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await _stepsRunner.RunAsync(jobContext: _ec.Object);
|
||||||
|
|
||||||
|
// Assert: background step must have completed after wait
|
||||||
|
Assert.True(bgCompleted, "Background step should have completed after wait");
|
||||||
|
Assert.Equal(TaskResult.Succeeded, _ec.Object.Result ?? TaskResult.Succeeded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public async Task BackgroundStepFailurePropagatesAtWait()
|
||||||
|
{
|
||||||
|
using (TestHostContext hc = CreateTestContext())
|
||||||
|
{
|
||||||
|
// Arrange: background step that fails
|
||||||
|
var bgStep = CreateStep(hc, TaskResult.Failed, "success()", name: "flaky", contextName: "flaky", isBackground: true);
|
||||||
|
bgStep.Setup(x => x.RunAsync()).Returns(() =>
|
||||||
|
{
|
||||||
|
throw new Exception("Service crashed");
|
||||||
|
});
|
||||||
|
bgStep.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
|
||||||
|
{
|
||||||
|
Name = "flaky",
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ContextName = "flaky",
|
||||||
|
Background = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
var waitStep = CreateWaitStep(hc, new[] { "flaky" });
|
||||||
|
|
||||||
|
_ec.Object.Result = null;
|
||||||
|
_ec.Setup(x => x.JobSteps).Returns(new Queue<IStep>(new IStep[]
|
||||||
|
{
|
||||||
|
bgStep.Object, waitStep
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await _stepsRunner.RunAsync(jobContext: _ec.Object);
|
||||||
|
|
||||||
|
// Assert: job should fail because background step failed
|
||||||
|
Assert.Equal(TaskResult.Failed, _ec.Object.Result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public async Task CancelStepTerminatesBackgroundStep()
|
||||||
|
{
|
||||||
|
using (TestHostContext hc = CreateTestContext())
|
||||||
|
{
|
||||||
|
// Arrange: background step that runs until cancelled via ExecutionContext.CancellationToken
|
||||||
|
var stepCts = new CancellationTokenSource();
|
||||||
|
|
||||||
|
var bgStep = CreateStep(hc, TaskResult.Succeeded, "success()", name: "server", contextName: "server");
|
||||||
|
// Wire CancellationToken to our CTS so the cancel path can trigger it
|
||||||
|
var bgStepContext = Mock.Get(bgStep.Object.ExecutionContext);
|
||||||
|
bgStepContext.Setup(x => x.CancellationToken).Returns(stepCts.Token);
|
||||||
|
bgStepContext.Setup(x => x.CancelToken()).Callback(() => stepCts.Cancel());
|
||||||
|
bgStep.Setup(x => x.RunAsync()).Returns(async () =>
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(5), stepCts.Token);
|
||||||
|
});
|
||||||
|
bgStep.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
|
||||||
|
{
|
||||||
|
Name = "server",
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ContextName = "server",
|
||||||
|
Background = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
var cancelStep = CreateCancelStep(hc, "server");
|
||||||
|
|
||||||
|
_ec.Object.Result = null;
|
||||||
|
_ec.Setup(x => x.JobSteps).Returns(new Queue<IStep>(new IStep[]
|
||||||
|
{
|
||||||
|
bgStep.Object, cancelStep
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await _stepsRunner.RunAsync(jobContext: _ec.Object);
|
||||||
|
|
||||||
|
// Assert: background step should have been cancelled
|
||||||
|
// Note: the cancel mechanism uses the BackgroundStepContext.Cts, not bgCts
|
||||||
|
// so wasCancelled may not be true in this mock, but the step should complete
|
||||||
|
Assert.Equal(TaskResult.Succeeded, _ec.Object.Result ?? TaskResult.Succeeded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public async Task WaitAllWaitsForAllBackgroundSteps()
|
||||||
|
{
|
||||||
|
using (TestHostContext hc = CreateTestContext())
|
||||||
|
{
|
||||||
|
// Arrange: two background steps
|
||||||
|
var step1Done = false;
|
||||||
|
var step2Done = false;
|
||||||
|
|
||||||
|
var bgStep1 = CreateStep(hc, TaskResult.Succeeded, "success()", name: "svc1", contextName: "svc1", isBackground: true);
|
||||||
|
bgStep1.Setup(x => x.RunAsync()).Returns(async () =>
|
||||||
|
{
|
||||||
|
await Task.Delay(50);
|
||||||
|
step1Done = true;
|
||||||
|
});
|
||||||
|
bgStep1.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
|
||||||
|
{
|
||||||
|
Name = "svc1",
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ContextName = "svc1",
|
||||||
|
Background = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
var bgStep2 = CreateStep(hc, TaskResult.Succeeded, "success()", name: "svc2", contextName: "svc2", isBackground: true);
|
||||||
|
bgStep2.Setup(x => x.RunAsync()).Returns(async () =>
|
||||||
|
{
|
||||||
|
await Task.Delay(100);
|
||||||
|
step2Done = true;
|
||||||
|
});
|
||||||
|
bgStep2.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
|
||||||
|
{
|
||||||
|
Name = "svc2",
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ContextName = "svc2",
|
||||||
|
Background = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
var waitAllStep = CreateWaitAllStep(hc);
|
||||||
|
|
||||||
|
_ec.Object.Result = null;
|
||||||
|
_ec.Setup(x => x.JobSteps).Returns(new Queue<IStep>(new IStep[]
|
||||||
|
{
|
||||||
|
bgStep1.Object, bgStep2.Object, waitAllStep
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await _stepsRunner.RunAsync(jobContext: _ec.Object);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(step1Done, "Background step 1 should have completed");
|
||||||
|
Assert.True(step2Done, "Background step 2 should have completed");
|
||||||
|
Assert.Equal(TaskResult.Succeeded, _ec.Object.Result ?? TaskResult.Succeeded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public async Task CancelStepPublishesCanceledBackgroundExternalId()
|
||||||
|
{
|
||||||
|
using (TestHostContext hc = CreateTestContext())
|
||||||
|
{
|
||||||
|
var bgStep = CreateStep(hc, TaskResult.Succeeded, "success()", name: "server", contextName: "server", isBackground: true);
|
||||||
|
bgStep.Setup(x => x.RunAsync()).Returns(Task.CompletedTask);
|
||||||
|
bgStep.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
|
||||||
|
{
|
||||||
|
Name = "server",
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ContextName = "server",
|
||||||
|
Background = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
var cancelStep = CreateCancelStep(hc, "server");
|
||||||
|
|
||||||
|
_ec.Object.Result = null;
|
||||||
|
_ec.Setup(x => x.JobSteps).Returns(new Queue<IStep>(new IStep[]
|
||||||
|
{
|
||||||
|
bgStep.Object, cancelStep
|
||||||
|
}));
|
||||||
|
|
||||||
|
await _stepsRunner.RunAsync(jobContext: _ec.Object);
|
||||||
|
|
||||||
|
// Assert: cancel step completed without error
|
||||||
|
Assert.Equal(TaskResult.Succeeded, _ec.Object.Result ?? TaskResult.Succeeded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public async Task CanceledBackgroundStepDoesNotAffectJobResult()
|
||||||
|
{
|
||||||
|
using (TestHostContext hc = CreateTestContext())
|
||||||
|
{
|
||||||
|
// Arrange: a background step that runs until explicitly canceled. When canceled it
|
||||||
|
// reports TaskResult.Canceled, but since the cancellation is expected (driven by a
|
||||||
|
// cancel control step), it must not impact the overall job result.
|
||||||
|
using var stepCts = new CancellationTokenSource();
|
||||||
|
|
||||||
|
var bgStep = CreateStep(hc, TaskResult.Succeeded, "success()", name: "server", contextName: "server", isBackground: true);
|
||||||
|
var bgStepContext = Mock.Get(bgStep.Object.ExecutionContext);
|
||||||
|
bgStepContext.Setup(x => x.CancellationToken).Returns(stepCts.Token);
|
||||||
|
bgStepContext.Setup(x => x.CancelToken()).Callback(() => stepCts.Cancel());
|
||||||
|
bgStep.Setup(x => x.RunAsync()).Returns(async () =>
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(2), stepCts.Token);
|
||||||
|
});
|
||||||
|
bgStep.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
|
||||||
|
{
|
||||||
|
Name = "server",
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ContextName = "server",
|
||||||
|
Background = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
var cancelStep = CreateCancelStep(hc, "server");
|
||||||
|
|
||||||
|
_ec.Object.Result = null;
|
||||||
|
_ec.Setup(x => x.JobSteps).Returns(new Queue<IStep>(new IStep[]
|
||||||
|
{
|
||||||
|
bgStep.Object, cancelStep
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await _stepsRunner.RunAsync(jobContext: _ec.Object);
|
||||||
|
|
||||||
|
// Assert: the canceled background step reported Canceled, but the job result is unaffected.
|
||||||
|
Assert.Equal(TaskResult.Canceled, bgStep.Object.ExecutionContext.Result);
|
||||||
|
Assert.Equal(TaskResult.Succeeded, _ec.Object.Result ?? TaskResult.Succeeded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public async Task FailedBackgroundStepTargetedByCancelStillAffectsJobResult()
|
||||||
|
{
|
||||||
|
using (TestHostContext hc = CreateTestContext())
|
||||||
|
{
|
||||||
|
// Arrange: a background step that fails (e.g. before the cancel takes effect). Even
|
||||||
|
// though a cancel control step targets it, its Failed result must still propagate to
|
||||||
|
// the overall job result.
|
||||||
|
var bgStep = CreateStep(hc, TaskResult.Failed, "success()", name: "server", contextName: "server", isBackground: true);
|
||||||
|
bgStep.Setup(x => x.RunAsync()).Returns(Task.CompletedTask);
|
||||||
|
bgStep.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
|
||||||
|
{
|
||||||
|
Name = "server",
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ContextName = "server",
|
||||||
|
Background = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
var cancelStep = CreateCancelStep(hc, "server");
|
||||||
|
|
||||||
|
_ec.Object.Result = null;
|
||||||
|
_ec.Setup(x => x.JobSteps).Returns(new Queue<IStep>(new IStep[]
|
||||||
|
{
|
||||||
|
bgStep.Object, cancelStep
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await _stepsRunner.RunAsync(jobContext: _ec.Object);
|
||||||
|
|
||||||
|
// Assert: the background step failed, so the job result reflects that failure.
|
||||||
|
Assert.Equal(TaskResult.Failed, bgStep.Object.ExecutionContext.Result);
|
||||||
|
Assert.Equal(TaskResult.Failed, _ec.Object.Result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public async Task StepsContextThreadSafety()
|
||||||
|
{
|
||||||
|
// Test that concurrent SetOutput/SetConclusion doesn't throw
|
||||||
|
var stepsContext = new StepsContext();
|
||||||
|
var tasks = new List<Task>();
|
||||||
|
|
||||||
|
for (int i = 0; i < 100; i++)
|
||||||
|
{
|
||||||
|
var index = i;
|
||||||
|
tasks.Add(Task.Run(() =>
|
||||||
|
{
|
||||||
|
stepsContext.SetOutput("", $"step{index}", "out", $"value{index}", out _);
|
||||||
|
stepsContext.SetConclusion("", $"step{index}", ActionResult.Success);
|
||||||
|
stepsContext.SetOutcome("", $"step{index}", ActionResult.Success);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.WhenAll(tasks);
|
||||||
|
|
||||||
|
// Assert: all 100 steps should have their data set
|
||||||
|
var scope = stepsContext.GetScope("");
|
||||||
|
Assert.Equal(100, scope.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public async Task ControlFlowStepsRunEvenAfterFailure()
|
||||||
|
{
|
||||||
|
using (TestHostContext hc = CreateTestContext())
|
||||||
|
{
|
||||||
|
// Arrange: a background step, a foreground step that fails, then a wait step
|
||||||
|
var bgStep = CreateStep(hc, TaskResult.Succeeded, "success()", name: "bg", contextName: "bg", isBackground: true);
|
||||||
|
bgStep.Setup(x => x.RunAsync()).Returns(Task.CompletedTask);
|
||||||
|
bgStep.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
|
||||||
|
{
|
||||||
|
Name = "bg",
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ContextName = "bg",
|
||||||
|
Background = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
var failStep = CreateStep(hc, TaskResult.Failed, "success()", name: "fail", contextName: "fail");
|
||||||
|
|
||||||
|
// Wait step uses always() condition — should run even after failure
|
||||||
|
var waitStep = CreateWaitStep(hc, new[] { "bg" });
|
||||||
|
waitStep.Condition = $"{GitHub.DistributedTask.Pipelines.ObjectTemplating.PipelineTemplateConstants.Always}()";
|
||||||
|
|
||||||
|
_ec.Object.Result = null;
|
||||||
|
_ec.Setup(x => x.JobSteps).Returns(new Queue<IStep>(new IStep[]
|
||||||
|
{
|
||||||
|
bgStep.Object, failStep.Object, waitStep
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await _stepsRunner.RunAsync(jobContext: _ec.Object);
|
||||||
|
|
||||||
|
// Assert: wait step should have run (not skipped) because it has always() condition
|
||||||
|
Assert.NotNull(waitStep.ExecutionContext.Result);
|
||||||
|
Assert.NotEqual(TaskResult.Skipped, waitStep.ExecutionContext.Result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#region Helpers
|
||||||
|
|
||||||
|
private Mock<IActionRunner> CreateStep(TestHostContext hc, TaskResult result, string condition, string name = "Test", string contextName = null, Guid? recordId = null, bool isBackground = false)
|
||||||
|
{
|
||||||
|
var stepRecordId = recordId ?? Guid.NewGuid();
|
||||||
|
var step = new Mock<IActionRunner>();
|
||||||
|
step.Setup(x => x.Condition).Returns(condition);
|
||||||
|
step.Setup(x => x.ContinueOnError).Returns(new BooleanToken(null, null, null, false));
|
||||||
|
step.Setup(x => x.Stage).Returns(ActionRunStage.Main);
|
||||||
|
step.Setup(x => x.Action)
|
||||||
|
.Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
|
||||||
|
{
|
||||||
|
Name = name,
|
||||||
|
Id = stepRecordId,
|
||||||
|
ContextName = contextName ?? name,
|
||||||
|
});
|
||||||
|
|
||||||
|
var stepContext = new Mock<IExecutionContext>();
|
||||||
|
stepContext.SetupAllProperties();
|
||||||
|
stepContext.Setup(x => x.Global).Returns(() => _ec.Object.Global);
|
||||||
|
stepContext.Setup(x => x.IsBackground).Returns(isBackground);
|
||||||
|
var expressionValues = new DictionaryContextData();
|
||||||
|
foreach (var pair in _ec.Object.ExpressionValues)
|
||||||
|
{
|
||||||
|
expressionValues[pair.Key] = pair.Value;
|
||||||
|
}
|
||||||
|
stepContext.Setup(x => x.ExpressionValues).Returns(expressionValues);
|
||||||
|
stepContext.Setup(x => x.ExpressionFunctions).Returns(new List<IFunctionInfo>());
|
||||||
|
stepContext.Setup(x => x.JobContext).Returns(_jobContext);
|
||||||
|
stepContext.Setup(x => x.Id).Returns(stepRecordId);
|
||||||
|
stepContext.Setup(x => x.ContextName).Returns(step.Object.Action.ContextName);
|
||||||
|
stepContext.Setup(x => x.CancellationToken).Returns(CancellationToken.None);
|
||||||
|
stepContext.Setup(x => x.Complete(It.IsAny<TaskResult?>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||||
|
.Callback((TaskResult? r, string currentOperation, string resultCode) =>
|
||||||
|
{
|
||||||
|
if (r != null)
|
||||||
|
{
|
||||||
|
stepContext.Object.Result = r;
|
||||||
|
}
|
||||||
|
_stepContext.SetOutcome("", stepContext.Object.ContextName, (stepContext.Object.Outcome ?? stepContext.Object.Result ?? TaskResult.Succeeded).ToActionResult());
|
||||||
|
_stepContext.SetConclusion("", stepContext.Object.ContextName, (stepContext.Object.Result ?? TaskResult.Succeeded).ToActionResult());
|
||||||
|
});
|
||||||
|
stepContext.Setup(x => x.StepEnvironmentOverrides).Returns(new List<string>());
|
||||||
|
stepContext.Setup(x => x.ApplyContinueOnError(It.IsAny<TemplateToken>()));
|
||||||
|
stepContext.Setup(x => x.FlushDeferredOutputs()).Callback(() =>
|
||||||
|
{
|
||||||
|
if (stepContext.Object.DeferredOutputs != null)
|
||||||
|
{
|
||||||
|
foreach (var kvp in stepContext.Object.DeferredOutputs)
|
||||||
|
{
|
||||||
|
_stepContext.SetOutput("", stepContext.Object.ContextName, kvp.Key, kvp.Value, out _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var trace = hc.GetTrace();
|
||||||
|
stepContext.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>())).Callback((string tag, string message) => { trace.Info($"[{tag}]{message}"); });
|
||||||
|
stepContext.Object.Result = result;
|
||||||
|
step.Setup(x => x.ExecutionContext).Returns(stepContext.Object);
|
||||||
|
step.Setup(x => x.RunAsync()).Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
return step;
|
||||||
|
}
|
||||||
|
|
||||||
|
private JobExtensionRunner CreateWaitStep(TestHostContext hc, string[] stepIds, Dictionary<string, string> timelineVariables = null)
|
||||||
|
{
|
||||||
|
var waitData = new BackgroundStepControlFlowData
|
||||||
|
{
|
||||||
|
Type = Pipelines.BackgroundControlTypes.Wait,
|
||||||
|
StepIds = stepIds,
|
||||||
|
};
|
||||||
|
var bgCoordinator = hc.GetService<IBackgroundStepCoordinator>();
|
||||||
|
var waitRunner = new JobExtensionRunner(
|
||||||
|
runAsync: bgCoordinator.RunControlFlowAsync,
|
||||||
|
condition: "success()",
|
||||||
|
displayName: "Wait",
|
||||||
|
data: waitData);
|
||||||
|
|
||||||
|
var stepContext = new Mock<IExecutionContext>();
|
||||||
|
stepContext.SetupAllProperties();
|
||||||
|
stepContext.Setup(x => x.Global).Returns(() => _ec.Object.Global);
|
||||||
|
var waitExprValues = new DictionaryContextData();
|
||||||
|
foreach (var pair in _ec.Object.ExpressionValues) { waitExprValues[pair.Key] = pair.Value; }
|
||||||
|
stepContext.Setup(x => x.ExpressionValues).Returns(waitExprValues);
|
||||||
|
stepContext.Setup(x => x.ExpressionFunctions).Returns(new List<IFunctionInfo>());
|
||||||
|
stepContext.Setup(x => x.ContextName).Returns("__wait");
|
||||||
|
stepContext.Setup(x => x.JobContext).Returns(_jobContext);
|
||||||
|
stepContext.Setup(x => x.ScopeName).Returns((string)null);
|
||||||
|
stepContext.Setup(x => x.CancellationToken).Returns(CancellationToken.None);
|
||||||
|
stepContext.Setup(x => x.StepEnvironmentOverrides).Returns(new List<string>());
|
||||||
|
stepContext.Setup(x => x.Complete(It.IsAny<TaskResult?>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||||
|
.Callback((TaskResult? r, string currentOperation, string resultCode) =>
|
||||||
|
{
|
||||||
|
if (r != null) stepContext.Object.Result = r;
|
||||||
|
});
|
||||||
|
var trace = hc.GetTrace();
|
||||||
|
stepContext.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>())).Callback((string tag, string message) => { trace.Info($"[{tag}]{message}"); });
|
||||||
|
|
||||||
|
waitRunner.ExecutionContext = stepContext.Object;
|
||||||
|
return waitRunner;
|
||||||
|
}
|
||||||
|
|
||||||
|
private JobExtensionRunner CreateWaitAllStep(TestHostContext hc, Dictionary<string, string> timelineVariables = null)
|
||||||
|
{
|
||||||
|
var waitAllData = new BackgroundStepControlFlowData
|
||||||
|
{
|
||||||
|
Type = Pipelines.BackgroundControlTypes.WaitAll,
|
||||||
|
};
|
||||||
|
var bgCoordinator2 = hc.GetService<IBackgroundStepCoordinator>();
|
||||||
|
var waitAllRunner = new JobExtensionRunner(
|
||||||
|
runAsync: bgCoordinator2.RunControlFlowAsync,
|
||||||
|
condition: "success()",
|
||||||
|
displayName: "Wait All",
|
||||||
|
data: waitAllData);
|
||||||
|
|
||||||
|
var stepContext = new Mock<IExecutionContext>();
|
||||||
|
stepContext.SetupAllProperties();
|
||||||
|
stepContext.Setup(x => x.Global).Returns(() => _ec.Object.Global);
|
||||||
|
var waitAllExprValues = new DictionaryContextData();
|
||||||
|
foreach (var pair in _ec.Object.ExpressionValues) { waitAllExprValues[pair.Key] = pair.Value; }
|
||||||
|
stepContext.Setup(x => x.ExpressionValues).Returns(waitAllExprValues);
|
||||||
|
stepContext.Setup(x => x.ExpressionFunctions).Returns(new List<IFunctionInfo>());
|
||||||
|
stepContext.Setup(x => x.ContextName).Returns("__wait-all");
|
||||||
|
stepContext.Setup(x => x.JobContext).Returns(_jobContext);
|
||||||
|
stepContext.Setup(x => x.ScopeName).Returns((string)null);
|
||||||
|
stepContext.Setup(x => x.CancellationToken).Returns(CancellationToken.None);
|
||||||
|
stepContext.Setup(x => x.StepEnvironmentOverrides).Returns(new List<string>());
|
||||||
|
stepContext.Setup(x => x.Complete(It.IsAny<TaskResult?>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||||
|
.Callback((TaskResult? r, string currentOperation, string resultCode) =>
|
||||||
|
{
|
||||||
|
if (r != null) stepContext.Object.Result = r;
|
||||||
|
});
|
||||||
|
var trace = hc.GetTrace();
|
||||||
|
stepContext.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>())).Callback((string tag, string message) => { trace.Info($"[{tag}]{message}"); });
|
||||||
|
|
||||||
|
waitAllRunner.ExecutionContext = stepContext.Object;
|
||||||
|
return waitAllRunner;
|
||||||
|
}
|
||||||
|
|
||||||
|
private JobExtensionRunner CreateCancelStep(TestHostContext hc, string cancelStepId, Dictionary<string, string> timelineVariables = null)
|
||||||
|
{
|
||||||
|
var cancelData = new BackgroundStepControlFlowData
|
||||||
|
{
|
||||||
|
Type = Pipelines.BackgroundControlTypes.Cancel,
|
||||||
|
StepIds = new[] { cancelStepId },
|
||||||
|
};
|
||||||
|
var bgCoordinator3 = hc.GetService<IBackgroundStepCoordinator>();
|
||||||
|
var cancelRunner = new JobExtensionRunner(
|
||||||
|
runAsync: bgCoordinator3.RunControlFlowAsync,
|
||||||
|
condition: "success()",
|
||||||
|
displayName: "Cancel",
|
||||||
|
data: cancelData);
|
||||||
|
|
||||||
|
var stepContext = new Mock<IExecutionContext>();
|
||||||
|
stepContext.SetupAllProperties();
|
||||||
|
stepContext.Setup(x => x.Global).Returns(() => _ec.Object.Global);
|
||||||
|
var cancelExprValues = new DictionaryContextData();
|
||||||
|
foreach (var pair in _ec.Object.ExpressionValues) { cancelExprValues[pair.Key] = pair.Value; }
|
||||||
|
stepContext.Setup(x => x.ExpressionValues).Returns(cancelExprValues);
|
||||||
|
stepContext.Setup(x => x.ExpressionFunctions).Returns(new List<IFunctionInfo>());
|
||||||
|
stepContext.Setup(x => x.ContextName).Returns("__cancel");
|
||||||
|
stepContext.Setup(x => x.JobContext).Returns(_jobContext);
|
||||||
|
stepContext.Setup(x => x.ScopeName).Returns((string)null);
|
||||||
|
stepContext.Setup(x => x.CancellationToken).Returns(CancellationToken.None);
|
||||||
|
stepContext.Setup(x => x.StepEnvironmentOverrides).Returns(new List<string>());
|
||||||
|
stepContext.Setup(x => x.Complete(It.IsAny<TaskResult?>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||||
|
.Callback((TaskResult? r, string currentOperation, string resultCode) =>
|
||||||
|
{
|
||||||
|
if (r != null) stepContext.Object.Result = r;
|
||||||
|
});
|
||||||
|
var trace = hc.GetTrace();
|
||||||
|
stepContext.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>())).Callback((string tag, string message) => { trace.Info($"[{tag}]{message}"); });
|
||||||
|
|
||||||
|
cancelRunner.ExecutionContext = stepContext.Object;
|
||||||
|
return cancelRunner;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
|
}
|
||||||
627
src/Test/L0/Worker/CreateArtifactsFileCommandL0.cs
Normal file
627
src/Test/L0/Worker/CreateArtifactsFileCommandL0.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,9 @@ using Moq;
|
|||||||
using GitHub.Runner.Worker;
|
using GitHub.Runner.Worker;
|
||||||
using GitHub.Runner.Worker.Dap;
|
using GitHub.Runner.Worker.Dap;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
using Pipelines = GitHub.DistributedTask.Pipelines;
|
||||||
|
|
||||||
namespace GitHub.Runner.Common.Tests.Worker
|
namespace GitHub.Runner.Common.Tests.Worker
|
||||||
{
|
{
|
||||||
@@ -255,6 +257,78 @@ namespace GitHub.Runner.Common.Tests.Worker
|
|||||||
return jobContext;
|
return jobContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Mock<IStep> CreateStep(string displayName, ActionRunStage? stage = null)
|
||||||
|
{
|
||||||
|
var step = new Mock<IStep>();
|
||||||
|
step.Setup(s => s.DisplayName).Returns(displayName);
|
||||||
|
if (stage.HasValue)
|
||||||
|
{
|
||||||
|
var executionContext = new Mock<IExecutionContext>();
|
||||||
|
executionContext.Setup(x => x.Stage).Returns(stage.Value);
|
||||||
|
step.Setup(s => s.ExecutionContext).Returns(executionContext.Object);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
step.Setup(s => s.ExecutionContext).Returns((IExecutionContext)null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return step;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Mock<IActionRunner> CreateActionRunner(string displayName, ActionRunStage stage, Pipelines.ActionStep action)
|
||||||
|
{
|
||||||
|
var executionContext = new Mock<IExecutionContext>();
|
||||||
|
executionContext.Setup(x => x.Stage).Returns(stage);
|
||||||
|
|
||||||
|
var runner = new Mock<IActionRunner>();
|
||||||
|
runner.Setup(s => s.DisplayName).Returns(displayName);
|
||||||
|
runner.Setup(s => s.ExecutionContext).Returns(executionContext.Object);
|
||||||
|
runner.Setup(s => s.Stage).Returns(stage);
|
||||||
|
runner.Setup(s => s.Action).Returns(action);
|
||||||
|
return runner;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Pipelines.ActionStep CreateRepositoryActionStep(string name)
|
||||||
|
{
|
||||||
|
return new Pipelines.ActionStep
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Name = name,
|
||||||
|
Reference = new Pipelines.RepositoryPathReference
|
||||||
|
{
|
||||||
|
Name = name,
|
||||||
|
Ref = "v1",
|
||||||
|
RepositoryType = Pipelines.RepositoryTypes.GitHub
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Definition CreateActionDefinitionWithPost()
|
||||||
|
{
|
||||||
|
return new Definition
|
||||||
|
{
|
||||||
|
Data = new ActionDefinitionData
|
||||||
|
{
|
||||||
|
Execution = new NodeJSActionExecutionData
|
||||||
|
{
|
||||||
|
Script = "main.js",
|
||||||
|
Post = "post.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Request MakeRequest(string command, object arguments)
|
||||||
|
{
|
||||||
|
return new Request
|
||||||
|
{
|
||||||
|
Seq = 1,
|
||||||
|
Type = "request",
|
||||||
|
Command = command,
|
||||||
|
Arguments = JObject.FromObject(arguments)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
[Trait("Level", "L0")]
|
[Trait("Level", "L0")]
|
||||||
[Trait("Category", "Worker")]
|
[Trait("Category", "Worker")]
|
||||||
@@ -718,6 +792,325 @@ namespace GitHub.Runner.Common.Tests.Worker
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public async Task HandleSourceReturnsJobStepsSource()
|
||||||
|
{
|
||||||
|
using (var hc = CreateTestContext())
|
||||||
|
{
|
||||||
|
hc.SecretMasker.AddValue("secret-step");
|
||||||
|
var port = GetFreePort();
|
||||||
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||||
|
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
|
||||||
|
await _debugger.StartAsync(jobContext.Object);
|
||||||
|
|
||||||
|
var waitTask = _debugger.WaitUntilReadyAsync();
|
||||||
|
using var client = await ConnectClientAsync(port);
|
||||||
|
var stream = client.GetStream();
|
||||||
|
await SendRequestAsync(stream, new Request
|
||||||
|
{
|
||||||
|
Seq = 1,
|
||||||
|
Type = "request",
|
||||||
|
Command = "configurationDone"
|
||||||
|
});
|
||||||
|
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||||
|
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||||
|
await waitTask;
|
||||||
|
|
||||||
|
var pre = CreateStep("Pre cache", ActionRunStage.Pre);
|
||||||
|
var checkout = CreateStep("Checkout");
|
||||||
|
var secret = CreateStep("secret-step");
|
||||||
|
var post = CreateStep("Post cache", ActionRunStage.Post);
|
||||||
|
await _debugger.OnJobStepsInitializedAsync(
|
||||||
|
new[] { pre.Object, checkout.Object, secret.Object },
|
||||||
|
new[] { post.Object });
|
||||||
|
|
||||||
|
var response = _debugger.HandleSource(MakeRequest(
|
||||||
|
"source",
|
||||||
|
new SourceArguments { SourceReference = 1 }));
|
||||||
|
|
||||||
|
Assert.True(response.Success);
|
||||||
|
var body = Assert.IsType<SourceResponseBody>(response.Body);
|
||||||
|
Assert.Equal(
|
||||||
|
"pre:\n - step: \"Set up job\"\n - step: \"Pre cache\"\n\nmain:\n - step: \"Checkout\"\n - step: \"***\"\n\npost:\n - step: \"Post cache\"\n - step: \"Complete job\"\n",
|
||||||
|
body.Content);
|
||||||
|
Assert.Null(body.MimeType);
|
||||||
|
|
||||||
|
await _debugger.StopAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public async Task StackTraceUsesJobStepsSourceLine()
|
||||||
|
{
|
||||||
|
using (CreateTestContext())
|
||||||
|
{
|
||||||
|
var port = GetFreePort();
|
||||||
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||||
|
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
|
||||||
|
await _debugger.StartAsync(jobContext.Object);
|
||||||
|
|
||||||
|
var waitTask = _debugger.WaitUntilReadyAsync();
|
||||||
|
using var client = await ConnectClientAsync(port);
|
||||||
|
var stream = client.GetStream();
|
||||||
|
await SendRequestAsync(stream, new Request
|
||||||
|
{
|
||||||
|
Seq = 1,
|
||||||
|
Type = "request",
|
||||||
|
Command = "configurationDone"
|
||||||
|
});
|
||||||
|
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||||
|
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||||
|
await waitTask;
|
||||||
|
|
||||||
|
var checkout = CreateStep("Checkout");
|
||||||
|
var build = CreateStep("Build");
|
||||||
|
await _debugger.OnJobStepsInitializedAsync(
|
||||||
|
new[] { checkout.Object, build.Object },
|
||||||
|
Array.Empty<IStep>());
|
||||||
|
|
||||||
|
var stepTask = _debugger.OnStepStartingAsync(build.Object);
|
||||||
|
var stoppedEvent = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||||
|
Assert.Contains("\"event\":\"stopped\"", stoppedEvent);
|
||||||
|
|
||||||
|
var bannerEvent = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||||
|
Assert.Contains("\"event\":\"output\"", bannerEvent);
|
||||||
|
|
||||||
|
await SendRequestAsync(stream, new Request
|
||||||
|
{
|
||||||
|
Seq = 2,
|
||||||
|
Type = "request",
|
||||||
|
Command = "stackTrace"
|
||||||
|
});
|
||||||
|
|
||||||
|
var stackTraceJson = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||||
|
var stackTrace = JObject.Parse(stackTraceJson);
|
||||||
|
var frame = stackTrace["body"]?["stackFrames"]?[0];
|
||||||
|
|
||||||
|
Assert.NotNull(frame);
|
||||||
|
Assert.Equal(6, frame["line"].Value<int>());
|
||||||
|
Assert.Equal(1, frame["source"]["sourceReference"].Value<int>());
|
||||||
|
Assert.Equal("execution.yml", frame["source"]["name"].Value<string>());
|
||||||
|
|
||||||
|
await SendRequestAsync(stream, new Request
|
||||||
|
{
|
||||||
|
Seq = 3,
|
||||||
|
Type = "request",
|
||||||
|
Command = "continue"
|
||||||
|
});
|
||||||
|
await stepTask;
|
||||||
|
|
||||||
|
await _debugger.StopAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public async Task StackTraceOmitsSourceForUnmappedCurrentStep()
|
||||||
|
{
|
||||||
|
using (CreateTestContext())
|
||||||
|
{
|
||||||
|
var port = GetFreePort();
|
||||||
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||||
|
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
|
||||||
|
await _debugger.StartAsync(jobContext.Object);
|
||||||
|
|
||||||
|
var waitTask = _debugger.WaitUntilReadyAsync();
|
||||||
|
using var client = await ConnectClientAsync(port);
|
||||||
|
var stream = client.GetStream();
|
||||||
|
await SendRequestAsync(stream, new Request
|
||||||
|
{
|
||||||
|
Seq = 1,
|
||||||
|
Type = "request",
|
||||||
|
Command = "configurationDone"
|
||||||
|
});
|
||||||
|
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||||
|
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||||
|
await waitTask;
|
||||||
|
|
||||||
|
var checkout = CreateStep("Checkout");
|
||||||
|
var build = CreateStep("Build");
|
||||||
|
await _debugger.OnJobStepsInitializedAsync(
|
||||||
|
new[] { checkout.Object },
|
||||||
|
Array.Empty<IStep>());
|
||||||
|
|
||||||
|
var stepTask = _debugger.OnStepStartingAsync(build.Object);
|
||||||
|
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||||
|
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
await SendRequestAsync(stream, new Request
|
||||||
|
{
|
||||||
|
Seq = 2,
|
||||||
|
Type = "request",
|
||||||
|
Command = "stackTrace"
|
||||||
|
});
|
||||||
|
|
||||||
|
var stackTraceJson = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||||
|
var stackTrace = JObject.Parse(stackTraceJson);
|
||||||
|
var frame = stackTrace["body"]?["stackFrames"]?[0];
|
||||||
|
|
||||||
|
Assert.NotNull(frame);
|
||||||
|
Assert.Equal(0, frame["line"].Value<int>());
|
||||||
|
Assert.Null(frame["source"]);
|
||||||
|
|
||||||
|
await SendRequestAsync(stream, new Request
|
||||||
|
{
|
||||||
|
Seq = 3,
|
||||||
|
Type = "request",
|
||||||
|
Command = "continue"
|
||||||
|
});
|
||||||
|
await stepTask;
|
||||||
|
|
||||||
|
await _debugger.StopAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public async Task PredictedPostStepIsServedAtInitializationAndClaimedAtRegistration()
|
||||||
|
{
|
||||||
|
using (var hc = CreateTestContext())
|
||||||
|
{
|
||||||
|
var action = CreateRepositoryActionStep("actions/cache");
|
||||||
|
var actionManager = new Mock<IActionManager>();
|
||||||
|
actionManager
|
||||||
|
.Setup(x => x.LoadAction(It.IsAny<IExecutionContext>(), action))
|
||||||
|
.Returns(CreateActionDefinitionWithPost());
|
||||||
|
hc.SetSingleton<IActionManager>(actionManager.Object);
|
||||||
|
|
||||||
|
var port = GetFreePort();
|
||||||
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||||
|
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
|
||||||
|
await _debugger.StartAsync(jobContext.Object);
|
||||||
|
|
||||||
|
var waitTask = _debugger.WaitUntilReadyAsync();
|
||||||
|
using var client = await ConnectClientAsync(port);
|
||||||
|
var stream = client.GetStream();
|
||||||
|
await SendRequestAsync(stream, new Request
|
||||||
|
{
|
||||||
|
Seq = 1,
|
||||||
|
Type = "request",
|
||||||
|
Command = "configurationDone"
|
||||||
|
});
|
||||||
|
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||||
|
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||||
|
await waitTask;
|
||||||
|
|
||||||
|
var checkout = CreateActionRunner("Checkout", ActionRunStage.Main, action);
|
||||||
|
await _debugger.OnJobStepsInitializedAsync(
|
||||||
|
new[] { checkout.Object },
|
||||||
|
Array.Empty<IStep>());
|
||||||
|
|
||||||
|
var sourceResponse = _debugger.HandleSource(MakeRequest(
|
||||||
|
"source",
|
||||||
|
new SourceArguments { SourceReference = 1 }));
|
||||||
|
var sourceBody = Assert.IsType<SourceResponseBody>(sourceResponse.Body);
|
||||||
|
Assert.Equal(
|
||||||
|
"pre:\n - step: \"Set up job\"\n\nmain:\n - step: \"Checkout\"\n\npost:\n - step: \"Post Checkout\"\n - step: \"Complete job\"\n",
|
||||||
|
sourceBody.Content);
|
||||||
|
|
||||||
|
var post = CreateActionRunner("Post Checkout", ActionRunStage.Post, action);
|
||||||
|
_debugger.OnPostStepRegistered(post.Object);
|
||||||
|
|
||||||
|
var stepTask = _debugger.OnStepStartingAsync(post.Object);
|
||||||
|
var stoppedEvent = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||||
|
Assert.Contains("\"event\":\"stopped\"", stoppedEvent);
|
||||||
|
|
||||||
|
var bannerEvent = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||||
|
Assert.Contains("\"event\":\"output\"", bannerEvent);
|
||||||
|
|
||||||
|
await SendRequestAsync(stream, new Request
|
||||||
|
{
|
||||||
|
Seq = 2,
|
||||||
|
Type = "request",
|
||||||
|
Command = "stackTrace"
|
||||||
|
});
|
||||||
|
|
||||||
|
var stackTraceJson = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||||
|
var stackTrace = JObject.Parse(stackTraceJson);
|
||||||
|
var frame = stackTrace["body"]?["stackFrames"]?[0];
|
||||||
|
|
||||||
|
Assert.NotNull(frame);
|
||||||
|
Assert.Equal(8, frame["line"].Value<int>());
|
||||||
|
Assert.Equal(1, frame["source"]["sourceReference"].Value<int>());
|
||||||
|
|
||||||
|
await SendRequestAsync(stream, new Request
|
||||||
|
{
|
||||||
|
Seq = 3,
|
||||||
|
Type = "request",
|
||||||
|
Command = "continue"
|
||||||
|
});
|
||||||
|
await stepTask;
|
||||||
|
|
||||||
|
await _debugger.StopAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public async Task StackTraceSanitizesSyntheticSourcePath()
|
||||||
|
{
|
||||||
|
using (CreateTestContext())
|
||||||
|
{
|
||||||
|
var port = GetFreePort();
|
||||||
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||||
|
var jobContext = CreateJobContextWithTunnel(cts.Token, port, jobName: "my/job\\name");
|
||||||
|
await _debugger.StartAsync(jobContext.Object);
|
||||||
|
|
||||||
|
var waitTask = _debugger.WaitUntilReadyAsync();
|
||||||
|
using var client = await ConnectClientAsync(port);
|
||||||
|
var stream = client.GetStream();
|
||||||
|
await SendRequestAsync(stream, new Request
|
||||||
|
{
|
||||||
|
Seq = 1,
|
||||||
|
Type = "request",
|
||||||
|
Command = "configurationDone"
|
||||||
|
});
|
||||||
|
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||||
|
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||||
|
await waitTask;
|
||||||
|
|
||||||
|
var checkout = CreateStep("Checkout");
|
||||||
|
await _debugger.OnJobStepsInitializedAsync(
|
||||||
|
new[] { checkout.Object },
|
||||||
|
Array.Empty<IStep>());
|
||||||
|
|
||||||
|
var stepTask = _debugger.OnStepStartingAsync(checkout.Object);
|
||||||
|
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||||
|
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
await SendRequestAsync(stream, new Request
|
||||||
|
{
|
||||||
|
Seq = 2,
|
||||||
|
Type = "request",
|
||||||
|
Command = "stackTrace"
|
||||||
|
});
|
||||||
|
|
||||||
|
var stackTraceJson = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||||
|
var stackTrace = JObject.Parse(stackTraceJson);
|
||||||
|
var frame = stackTrace["body"]?["stackFrames"]?[0];
|
||||||
|
|
||||||
|
Assert.NotNull(frame);
|
||||||
|
Assert.Equal("my_job_name/execution.yml", frame["source"]["path"].Value<string>());
|
||||||
|
|
||||||
|
await SendRequestAsync(stream, new Request
|
||||||
|
{
|
||||||
|
Seq = 3,
|
||||||
|
Type = "request",
|
||||||
|
Command = "continue"
|
||||||
|
});
|
||||||
|
await stepTask;
|
||||||
|
|
||||||
|
await _debugger.StopAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
[Trait("Level", "L0")]
|
[Trait("Level", "L0")]
|
||||||
[Trait("Category", "Worker")]
|
[Trait("Category", "Worker")]
|
||||||
@@ -746,6 +1139,11 @@ namespace GitHub.Runner.Common.Tests.Worker
|
|||||||
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||||
await waitTask;
|
await waitTask;
|
||||||
|
|
||||||
|
var checkout = CreateStep("Checkout");
|
||||||
|
await _debugger.OnJobStepsInitializedAsync(
|
||||||
|
new[] { checkout.Object },
|
||||||
|
Array.Empty<IStep>());
|
||||||
|
|
||||||
// Complete the job — OnJobCompletedAsync pauses when stepping,
|
// Complete the job — OnJobCompletedAsync pauses when stepping,
|
||||||
// so run it in the background and send continue to unblock.
|
// so run it in the background and send continue to unblock.
|
||||||
var completedTask = _debugger.OnJobCompletedAsync();
|
var completedTask = _debugger.OnJobCompletedAsync();
|
||||||
@@ -754,11 +1152,27 @@ namespace GitHub.Runner.Common.Tests.Worker
|
|||||||
var stoppedMsg = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
var stoppedMsg = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||||
Assert.Contains("\"event\":\"stopped\"", stoppedMsg);
|
Assert.Contains("\"event\":\"stopped\"", stoppedMsg);
|
||||||
|
|
||||||
// Send continue to unblock the pause
|
|
||||||
await SendRequestAsync(stream, new Request
|
await SendRequestAsync(stream, new Request
|
||||||
{
|
{
|
||||||
Seq = 2,
|
Seq = 2,
|
||||||
Type = "request",
|
Type = "request",
|
||||||
|
Command = "stackTrace"
|
||||||
|
});
|
||||||
|
|
||||||
|
var stackTraceJson = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||||
|
var stackTrace = JObject.Parse(stackTraceJson);
|
||||||
|
var frame = stackTrace["body"]?["stackFrames"]?[0];
|
||||||
|
|
||||||
|
Assert.NotNull(frame);
|
||||||
|
Assert.Equal("Complete job [completed]", frame["name"].Value<string>());
|
||||||
|
Assert.Equal(8, frame["line"].Value<int>());
|
||||||
|
Assert.Equal(1, frame["source"]["sourceReference"].Value<int>());
|
||||||
|
|
||||||
|
// Send continue to unblock the pause
|
||||||
|
await SendRequestAsync(stream, new Request
|
||||||
|
{
|
||||||
|
Seq = 3,
|
||||||
|
Type = "request",
|
||||||
Command = "continue"
|
Command = "continue"
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -777,6 +1191,68 @@ namespace GitHub.Runner.Common.Tests.Worker
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public async Task OnJobCompletedUsesSyntheticCompleteJobLineWhenPostStepSharesName()
|
||||||
|
{
|
||||||
|
using (CreateTestContext())
|
||||||
|
{
|
||||||
|
var port = GetFreePort();
|
||||||
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||||
|
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
|
||||||
|
await _debugger.StartAsync(jobContext.Object);
|
||||||
|
|
||||||
|
var waitTask = _debugger.WaitUntilReadyAsync();
|
||||||
|
using var client = await ConnectClientAsync(port);
|
||||||
|
var stream = client.GetStream();
|
||||||
|
await SendRequestAsync(stream, new Request
|
||||||
|
{
|
||||||
|
Seq = 1,
|
||||||
|
Type = "request",
|
||||||
|
Command = "configurationDone"
|
||||||
|
});
|
||||||
|
|
||||||
|
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||||
|
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||||
|
await waitTask;
|
||||||
|
|
||||||
|
var checkout = CreateStep("Checkout");
|
||||||
|
var realPost = CreateStep("Complete job", ActionRunStage.Post);
|
||||||
|
await _debugger.OnJobStepsInitializedAsync(
|
||||||
|
new[] { checkout.Object },
|
||||||
|
new[] { realPost.Object });
|
||||||
|
|
||||||
|
var completedTask = _debugger.OnJobCompletedAsync();
|
||||||
|
|
||||||
|
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
await SendRequestAsync(stream, new Request
|
||||||
|
{
|
||||||
|
Seq = 2,
|
||||||
|
Type = "request",
|
||||||
|
Command = "stackTrace"
|
||||||
|
});
|
||||||
|
|
||||||
|
var stackTraceJson = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||||
|
var stackTrace = JObject.Parse(stackTraceJson);
|
||||||
|
var frame = stackTrace["body"]?["stackFrames"]?[0];
|
||||||
|
|
||||||
|
Assert.NotNull(frame);
|
||||||
|
Assert.Equal("Complete job [completed]", frame["name"].Value<string>());
|
||||||
|
Assert.Equal(9, frame["line"].Value<int>());
|
||||||
|
|
||||||
|
await SendRequestAsync(stream, new Request
|
||||||
|
{
|
||||||
|
Seq = 3,
|
||||||
|
Type = "request",
|
||||||
|
Command = "continue"
|
||||||
|
});
|
||||||
|
|
||||||
|
await completedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
[Trait("Level", "L0")]
|
[Trait("Level", "L0")]
|
||||||
[Trait("Category", "Worker")]
|
[Trait("Category", "Worker")]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@@ -171,6 +171,36 @@ namespace GitHub.Runner.Common.Tests.Worker
|
|||||||
Assert.Equal("normal", deserialized.PresentationHint);
|
Assert.Equal("normal", deserialized.PresentationHint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void SourceRequestAndResponseSerialization()
|
||||||
|
{
|
||||||
|
var args = new SourceArguments
|
||||||
|
{
|
||||||
|
Source = new Source
|
||||||
|
{
|
||||||
|
SourceReference = 1
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var argsJson = JsonConvert.SerializeObject(args);
|
||||||
|
var deserializedArgs = JsonConvert.DeserializeObject<SourceArguments>(argsJson);
|
||||||
|
|
||||||
|
Assert.Equal(1, deserializedArgs.Source.SourceReference);
|
||||||
|
|
||||||
|
var body = new SourceResponseBody
|
||||||
|
{
|
||||||
|
Content = "pre:\n - step: \"Setup job\"\n\nmain:\n - step: \"Checkout\"\n\npost:\n - step: \"Complete job\"\n"
|
||||||
|
};
|
||||||
|
|
||||||
|
var bodyJson = JsonConvert.SerializeObject(body);
|
||||||
|
var deserializedBody = JsonConvert.DeserializeObject<SourceResponseBody>(bodyJson);
|
||||||
|
|
||||||
|
Assert.Equal("pre:\n - step: \"Setup job\"\n\nmain:\n - step: \"Checkout\"\n\npost:\n - step: \"Complete job\"\n", deserializedBody.Content);
|
||||||
|
Assert.Null(deserializedBody.MimeType);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
[Trait("Level", "L0")]
|
[Trait("Level", "L0")]
|
||||||
[Trait("Category", "Worker")]
|
[Trait("Category", "Worker")]
|
||||||
|
|||||||
@@ -361,6 +361,119 @@ namespace GitHub.Runner.Common.Tests.Worker
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void RegisterPostJobStep_JobExtensionRunner_DefaultsRunnerTelemetry()
|
||||||
|
{
|
||||||
|
using (TestHostContext hc = CreateTestContext())
|
||||||
|
{
|
||||||
|
// Arrange: Create a job request message.
|
||||||
|
TaskOrchestrationPlanReference plan = new();
|
||||||
|
TimelineReference timeline = new();
|
||||||
|
Guid jobId = Guid.NewGuid();
|
||||||
|
string jobName = "some job name";
|
||||||
|
var jobRequest = new Pipelines.AgentJobRequestMessage(plan, timeline, jobId, jobName, jobName, null, null, null, new Dictionary<string, VariableValue>(), new List<MaskHint>(), new Pipelines.JobResources(), new Pipelines.ContextData.DictionaryContextData(), new Pipelines.WorkspaceOptions(), new List<Pipelines.ActionStep>(), null, null, null, null, null);
|
||||||
|
jobRequest.Resources.Repositories.Add(new Pipelines.RepositoryResource()
|
||||||
|
{
|
||||||
|
Alias = Pipelines.PipelineConstants.SelfAlias,
|
||||||
|
Id = "github",
|
||||||
|
Version = "sha1"
|
||||||
|
});
|
||||||
|
jobRequest.ContextData["github"] = new Pipelines.ContextData.DictionaryContextData();
|
||||||
|
|
||||||
|
var pagingLogger1 = new Mock<IPagingLogger>();
|
||||||
|
var pagingLogger2 = new Mock<IPagingLogger>();
|
||||||
|
var jobServerQueue = new Mock<IJobServerQueue>();
|
||||||
|
jobServerQueue.Setup(x => x.QueueTimelineRecordUpdate(It.IsAny<Guid>(), It.IsAny<TimelineRecord>()));
|
||||||
|
|
||||||
|
hc.EnqueueInstance(pagingLogger1.Object);
|
||||||
|
hc.EnqueueInstance(pagingLogger2.Object);
|
||||||
|
hc.SetSingleton(jobServerQueue.Object);
|
||||||
|
|
||||||
|
var jobContext = new Runner.Worker.ExecutionContext();
|
||||||
|
jobContext.Initialize(hc);
|
||||||
|
|
||||||
|
// Act.
|
||||||
|
jobContext.InitializeJob(jobRequest, CancellationToken.None);
|
||||||
|
|
||||||
|
var extensionStep = new JobExtensionRunner(
|
||||||
|
runAsync: (_, _) => System.Threading.Tasks.Task.CompletedTask,
|
||||||
|
condition: "always()",
|
||||||
|
displayName: "Create Custom Image",
|
||||||
|
data: null);
|
||||||
|
|
||||||
|
jobContext.RegisterPostJobStep(extensionStep);
|
||||||
|
|
||||||
|
// Assert: telemetry defaults are populated for non-action post-job steps.
|
||||||
|
Assert.NotNull(extensionStep.ExecutionContext);
|
||||||
|
Assert.Equal("runner", extensionStep.ExecutionContext.StepTelemetry.Type);
|
||||||
|
Assert.Equal("create_custom_image", extensionStep.ExecutionContext.StepTelemetry.Action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void RegisterPostJobStep_ActionRunner_DoesNotOverrideTelemetry()
|
||||||
|
{
|
||||||
|
using (TestHostContext hc = CreateTestContext())
|
||||||
|
{
|
||||||
|
// Arrange: Create a job request message.
|
||||||
|
TaskOrchestrationPlanReference plan = new();
|
||||||
|
TimelineReference timeline = new();
|
||||||
|
Guid jobId = Guid.NewGuid();
|
||||||
|
string jobName = "some job name";
|
||||||
|
var jobRequest = new Pipelines.AgentJobRequestMessage(plan, timeline, jobId, jobName, jobName, null, null, null, new Dictionary<string, VariableValue>(), new List<MaskHint>(), new Pipelines.JobResources(), new Pipelines.ContextData.DictionaryContextData(), new Pipelines.WorkspaceOptions(), new List<Pipelines.ActionStep>(), null, null, null, null, null);
|
||||||
|
jobRequest.Resources.Repositories.Add(new Pipelines.RepositoryResource()
|
||||||
|
{
|
||||||
|
Alias = Pipelines.PipelineConstants.SelfAlias,
|
||||||
|
Id = "github",
|
||||||
|
Version = "sha1"
|
||||||
|
});
|
||||||
|
jobRequest.ContextData["github"] = new Pipelines.ContextData.DictionaryContextData();
|
||||||
|
|
||||||
|
var pagingLogger1 = new Mock<IPagingLogger>();
|
||||||
|
var pagingLogger2 = new Mock<IPagingLogger>();
|
||||||
|
var pagingLogger3 = new Mock<IPagingLogger>();
|
||||||
|
var pagingLogger4 = new Mock<IPagingLogger>();
|
||||||
|
var jobServerQueue = new Mock<IJobServerQueue>();
|
||||||
|
jobServerQueue.Setup(x => x.QueueTimelineRecordUpdate(It.IsAny<Guid>(), It.IsAny<TimelineRecord>()));
|
||||||
|
|
||||||
|
var actionRunner = new ActionRunner();
|
||||||
|
actionRunner.Initialize(hc);
|
||||||
|
|
||||||
|
hc.EnqueueInstance(pagingLogger1.Object);
|
||||||
|
hc.EnqueueInstance(pagingLogger2.Object);
|
||||||
|
hc.EnqueueInstance(pagingLogger3.Object);
|
||||||
|
hc.EnqueueInstance(pagingLogger4.Object);
|
||||||
|
hc.EnqueueInstance(actionRunner as IActionRunner);
|
||||||
|
hc.SetSingleton(jobServerQueue.Object);
|
||||||
|
|
||||||
|
var jobContext = new Runner.Worker.ExecutionContext();
|
||||||
|
jobContext.Initialize(hc);
|
||||||
|
|
||||||
|
// Act.
|
||||||
|
jobContext.InitializeJob(jobRequest, CancellationToken.None);
|
||||||
|
|
||||||
|
var action = jobContext.CreateChild(Guid.NewGuid(), "action", "action", null, null, 0);
|
||||||
|
|
||||||
|
var postRunner = hc.CreateService<IActionRunner>();
|
||||||
|
postRunner.Action = new Pipelines.ActionStep() { Id = Guid.NewGuid(), Name = "post", DisplayName = "Post Action", Reference = new Pipelines.RepositoryPathReference() { Name = "actions/action" } };
|
||||||
|
postRunner.Stage = ActionRunStage.Post;
|
||||||
|
postRunner.Condition = "always()";
|
||||||
|
postRunner.DisplayName = "Post Action";
|
||||||
|
|
||||||
|
action.RegisterPostJobStep(postRunner);
|
||||||
|
|
||||||
|
// Assert: action post-step telemetry is left for the handler to fill in,
|
||||||
|
// so RegisterPostJobStep should NOT pre-populate runner-owned defaults.
|
||||||
|
Assert.NotNull(postRunner.ExecutionContext);
|
||||||
|
Assert.Null(postRunner.ExecutionContext.StepTelemetry.Type);
|
||||||
|
Assert.Null(postRunner.ExecutionContext.StepTelemetry.Action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
[Trait("Level", "L0")]
|
[Trait("Level", "L0")]
|
||||||
[Trait("Category", "Worker")]
|
[Trait("Category", "Worker")]
|
||||||
|
|||||||
105
src/Test/L0/Worker/FileCommandManagerL0.cs
Normal file
105
src/Test/L0/Worker/FileCommandManagerL0.cs
Normal 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)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
130
src/Test/L0/Worker/JobExecutionViewL0.cs
Normal file
130
src/Test/L0/Worker/JobExecutionViewL0.cs
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
using System;
|
||||||
|
using GitHub.DistributedTask.Pipelines;
|
||||||
|
using GitHub.Runner.Worker;
|
||||||
|
using GitHub.Runner.Worker.Dap;
|
||||||
|
using Moq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace GitHub.Runner.Common.Tests.Worker
|
||||||
|
{
|
||||||
|
public sealed class JobExecutionViewL0
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void RendersPreMainAndPostSections()
|
||||||
|
{
|
||||||
|
var pre = CreateStep("Pre cache", ActionRunStage.Pre);
|
||||||
|
var checkout = CreateStep("Checkout");
|
||||||
|
var post = CreateStep("Post cache", ActionRunStage.Post);
|
||||||
|
|
||||||
|
var view = new JobExecutionView(
|
||||||
|
"job",
|
||||||
|
new[] { pre.Object, checkout.Object },
|
||||||
|
new[] { post.Object });
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
"pre:\n - step: \"Set up job\"\n - step: \"Pre cache\"\n\nmain:\n - step: \"Checkout\"\n\npost:\n - step: \"Post cache\"\n - step: \"Complete job\"\n",
|
||||||
|
view.Content);
|
||||||
|
Assert.Equal(3, view.TryGetLineForStep(pre.Object));
|
||||||
|
Assert.Equal(6, view.TryGetLineForStep(checkout.Object));
|
||||||
|
Assert.Equal(9, view.TryGetLineForStep(post.Object));
|
||||||
|
Assert.Equal(10, view.CompleteJobLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void ClaimsPredictedPostStepWithoutChangingLine()
|
||||||
|
{
|
||||||
|
var action = CreateRepositoryActionStep("actions/cache");
|
||||||
|
var checkout = CreateActionRunner("Checkout", ActionRunStage.Main, action);
|
||||||
|
var predicted = new JobExecutionView.PredictedPostStep(
|
||||||
|
"Post Checkout",
|
||||||
|
MatchKeyFor(action.Id));
|
||||||
|
|
||||||
|
var view = new JobExecutionView(
|
||||||
|
"job",
|
||||||
|
new[] { checkout.Object },
|
||||||
|
Array.Empty<IStep>(),
|
||||||
|
new[] { predicted });
|
||||||
|
|
||||||
|
var post = CreateActionRunner("Post Checkout", ActionRunStage.Post, action);
|
||||||
|
var line = view.TryClaimPredictedStep(MatchKeyFor(action.Id), post.Object);
|
||||||
|
|
||||||
|
Assert.Equal(8, line);
|
||||||
|
Assert.Equal(8, view.TryGetLineForStep(post.Object));
|
||||||
|
Assert.Equal(
|
||||||
|
"pre:\n - step: \"Set up job\"\n\nmain:\n - step: \"Checkout\"\n\npost:\n - step: \"Post Checkout\"\n - step: \"Complete job\"\n",
|
||||||
|
view.Content);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Worker")]
|
||||||
|
public void UsesSyntheticCompleteJobLineWhenPostStepSharesName()
|
||||||
|
{
|
||||||
|
var checkout = CreateStep("Checkout");
|
||||||
|
var realPost = CreateStep("Complete job", ActionRunStage.Post);
|
||||||
|
|
||||||
|
var view = new JobExecutionView(
|
||||||
|
"job",
|
||||||
|
new[] { checkout.Object },
|
||||||
|
new[] { realPost.Object });
|
||||||
|
|
||||||
|
Assert.Equal(8, view.TryGetLineForStep(realPost.Object));
|
||||||
|
Assert.Equal(9, view.CompleteJobLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Mock<IStep> CreateStep(string displayName, ActionRunStage? stage = null)
|
||||||
|
{
|
||||||
|
var step = new Mock<IStep>();
|
||||||
|
step.Setup(s => s.DisplayName).Returns(displayName);
|
||||||
|
if (stage.HasValue)
|
||||||
|
{
|
||||||
|
var executionContext = new Mock<IExecutionContext>();
|
||||||
|
executionContext.Setup(x => x.Stage).Returns(stage.Value);
|
||||||
|
step.Setup(s => s.ExecutionContext).Returns(executionContext.Object);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
step.Setup(s => s.ExecutionContext).Returns((IExecutionContext)null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return step;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Mock<IActionRunner> CreateActionRunner(string displayName, ActionRunStage stage, ActionStep action)
|
||||||
|
{
|
||||||
|
var executionContext = new Mock<IExecutionContext>();
|
||||||
|
executionContext.Setup(x => x.Stage).Returns(stage);
|
||||||
|
|
||||||
|
var runner = new Mock<IActionRunner>();
|
||||||
|
runner.Setup(s => s.DisplayName).Returns(displayName);
|
||||||
|
runner.Setup(s => s.ExecutionContext).Returns(executionContext.Object);
|
||||||
|
runner.Setup(s => s.Stage).Returns(stage);
|
||||||
|
runner.Setup(s => s.Action).Returns(action);
|
||||||
|
return runner;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ActionStep CreateRepositoryActionStep(string name)
|
||||||
|
{
|
||||||
|
return new ActionStep
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Name = name,
|
||||||
|
Reference = new RepositoryPathReference
|
||||||
|
{
|
||||||
|
Name = name,
|
||||||
|
Ref = "v1",
|
||||||
|
RepositoryType = RepositoryTypes.GitHub
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string MatchKeyFor(Guid actionId)
|
||||||
|
{
|
||||||
|
return $"post:{actionId:N}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -549,6 +549,10 @@ namespace GitHub.Runner.Common.Tests.Worker
|
|||||||
var _stepsRunner = new StepsRunner();
|
var _stepsRunner = new StepsRunner();
|
||||||
_stepsRunner.Initialize(hc);
|
_stepsRunner.Initialize(hc);
|
||||||
|
|
||||||
|
var bgCoordinator = new BackgroundStepCoordinator();
|
||||||
|
bgCoordinator.Initialize(hc);
|
||||||
|
hc.SetSingleton<IBackgroundStepCoordinator>(bgCoordinator);
|
||||||
|
|
||||||
var mockDapDebugger = new Mock<IDapDebugger>();
|
var mockDapDebugger = new Mock<IDapDebugger>();
|
||||||
hc.SetSingleton(mockDapDebugger.Object);
|
hc.SetSingleton(mockDapDebugger.Object);
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ namespace GitHub.Runner.Common.Tests.Worker
|
|||||||
_stepsRunner = new StepsRunner();
|
_stepsRunner = new StepsRunner();
|
||||||
_stepsRunner.Initialize(hc);
|
_stepsRunner.Initialize(hc);
|
||||||
|
|
||||||
|
var bgCoordinator = new BackgroundStepCoordinator();
|
||||||
|
bgCoordinator.Initialize(hc);
|
||||||
|
hc.SetSingleton<IBackgroundStepCoordinator>(bgCoordinator);
|
||||||
|
|
||||||
var mockDapDebugger = new Mock<IDapDebugger>();
|
var mockDapDebugger = new Mock<IDapDebugger>();
|
||||||
hc.SetSingleton(mockDapDebugger.Object);
|
hc.SetSingleton(mockDapDebugger.Object);
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ LAYOUT_DIR="$SCRIPT_DIR/../_layout"
|
|||||||
DOWNLOAD_DIR="$SCRIPT_DIR/../_downloads/netcore2x"
|
DOWNLOAD_DIR="$SCRIPT_DIR/../_downloads/netcore2x"
|
||||||
PACKAGE_DIR="$SCRIPT_DIR/../_package"
|
PACKAGE_DIR="$SCRIPT_DIR/../_package"
|
||||||
DOTNETSDK_ROOT="$SCRIPT_DIR/../_dotnetsdk"
|
DOTNETSDK_ROOT="$SCRIPT_DIR/../_dotnetsdk"
|
||||||
DOTNETSDK_VERSION="8.0.421"
|
DOTNETSDK_VERSION="8.0.422"
|
||||||
DOTNETSDK_INSTALLDIR="$DOTNETSDK_ROOT/$DOTNETSDK_VERSION"
|
DOTNETSDK_INSTALLDIR="$DOTNETSDK_ROOT/$DOTNETSDK_VERSION"
|
||||||
RUNNER_VERSION=$(cat runnerversion)
|
RUNNER_VERSION=$(cat runnerversion)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"sdk": {
|
"sdk": {
|
||||||
"version": "8.0.421"
|
"version": "8.0.422"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
2.334.0
|
2.335.0
|
||||||
|
|||||||
Reference in New Issue
Block a user