mirror of
https://github.com/actions/runner.git
synced 2026-07-04 19:45:31 +08:00
Compare commits
91 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dde968bf57 | ||
|
|
0e31cd5ff7 | ||
|
|
4c6d85cfc0 | ||
|
|
1ed4f70ee9 | ||
|
|
c814d7ca46 | ||
|
|
302ff10861 | ||
|
|
74aa458a12 | ||
|
|
c057cc3886 | ||
|
|
16c52e389d | ||
|
|
060eeda6e0 | ||
|
|
cbaeeb89ea | ||
|
|
4e51e7980c | ||
|
|
39108f22e4 | ||
|
|
7e0ff4d3e4 | ||
|
|
4864bb5778 | ||
|
|
a3df03d35a | ||
|
|
e6c5af75be | ||
|
|
fb78489197 | ||
|
|
77d6014f58 | ||
|
|
9c2a004d07 | ||
|
|
5053d17b4e | ||
|
|
c6a124e184 | ||
|
|
1a6560294e | ||
|
|
3ff2186ec0 | ||
|
|
7c0b271d2e | ||
|
|
0b3b8e0ba7 | ||
|
|
ae2896c551 | ||
|
|
ebf33710e8 | ||
|
|
a1ccd22030 | ||
|
|
b549247bee | ||
|
|
d36839b001 | ||
|
|
0cdaa36d07 | ||
|
|
5ed0c52e21 | ||
|
|
16c8a91b21 | ||
|
|
4550db3c89 | ||
|
|
b06c585762 | ||
|
|
c6f978fd5f | ||
|
|
d1690af497 | ||
|
|
c87d955bad | ||
|
|
7407189cf5 | ||
|
|
a84fb3602d | ||
|
|
84598e03fa | ||
|
|
8fa7457bbf | ||
|
|
00af8379a2 | ||
|
|
6692e6a590 | ||
|
|
cacb25d2ed | ||
|
|
c6ca9f6edb | ||
|
|
fad1253513 | ||
|
|
45debbd528 | ||
|
|
43e5211996 | ||
|
|
4a587ada27 | ||
|
|
182a433782 | ||
|
|
8d35e710da | ||
|
|
2bcc65e864 | ||
|
|
1ba5fdfd88 | ||
|
|
580116c18b | ||
|
|
c9a1751d87 | ||
|
|
7711dc53e2 | ||
|
|
df507886cb | ||
|
|
5c6dd47e76 | ||
|
|
7ff994b932 | ||
|
|
b9275b59cf | ||
|
|
f0c228635e | ||
|
|
9728019b24 | ||
|
|
e17e7aabbf | ||
|
|
4259ffb6dc | ||
|
|
4e8e1ff020 | ||
|
|
b6cca8fb99 | ||
|
|
18d0789c74 | ||
|
|
c985a9ff03 | ||
|
|
45ed15ddf3 | ||
|
|
c5dcf59d26 | ||
|
|
c7f6c49ba0 | ||
|
|
40dd583def | ||
|
|
68f2e9adb7 | ||
|
|
2b98d42113 | ||
|
|
ce8ce410b0 | ||
|
|
5310e90af2 | ||
|
|
98323280e8 | ||
|
|
5ef3270368 | ||
|
|
1138dd80f7 | ||
|
|
99910ca83e | ||
|
|
bcd04cfbf0 | ||
|
|
20111cbfda | ||
|
|
8f01257663 | ||
|
|
8a73bccebb | ||
|
|
a9a07a6553 | ||
|
|
60a9422599 | ||
|
|
985a06fcca | ||
|
|
ae09a9d7b5 | ||
|
|
7650fc432e |
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "Actions Runner Devcontainer",
|
||||
"image": "mcr.microsoft.com/devcontainers/base:focal",
|
||||
"image": "mcr.microsoft.com/devcontainers/base:noble",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:1": {},
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {},
|
||||
"ghcr.io/devcontainers/features/dotnet": {
|
||||
"version": "8.0.418"
|
||||
"version": "8.0.421"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"version": "20"
|
||||
|
||||
12
.github/workflows/build.yml
vendored
12
.github/workflows/build.yml
vendored
@@ -53,7 +53,7 @@ jobs:
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
# Build runner layout
|
||||
- name: Build & Layout Release
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
# Upload runner package tar.gz/zip as artifact
|
||||
- name: Publish Artifact
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: runner-package-${{ matrix.runtime }}
|
||||
path: |
|
||||
@@ -95,11 +95,11 @@ jobs:
|
||||
docker_platform: linux/arm64
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Get latest runner version
|
||||
id: latest_runner
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
github-token: ${{secrets.GITHUB_TOKEN}}
|
||||
script: |
|
||||
@@ -111,10 +111,10 @@ jobs:
|
||||
core.setOutput('version', version);
|
||||
|
||||
- name: Setup Docker buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ./images
|
||||
load: true
|
||||
|
||||
2
.github/workflows/codeql.yml
vendored
2
.github/workflows/codeql.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
|
||||
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 }}
|
||||
open-dependency-prs: ${{ steps.check-prs.outputs.open-dependency-prs }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
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 }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Check Docker version
|
||||
id: check_docker_version
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Update Docker version
|
||||
shell: bash
|
||||
|
||||
12
.github/workflows/docker-publish.yml
vendored
12
.github/workflows/docker-publish.yml
vendored
@@ -20,13 +20,13 @@ jobs:
|
||||
IMAGE_NAME: ${{ github.repository_owner }}/actions-runner
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event.inputs.releaseBranch }}
|
||||
|
||||
- name: Compute image version
|
||||
id: image
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
@@ -38,10 +38,10 @@ jobs:
|
||||
core.setOutput('version', runnerVersion);
|
||||
|
||||
- name: Setup Docker buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ./images
|
||||
platforms: |
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
org.opencontainers.image.description=https://github.com/actions/runner/releases/tag/v${{ steps.image.outputs.version }}
|
||||
|
||||
- name: Generate attestation
|
||||
uses: actions/attest-build-provenance@v3
|
||||
uses: actions/attest-build-provenance@v4
|
||||
with:
|
||||
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
subject-digest: ${{ steps.build-and-push.outputs.digest }}
|
||||
|
||||
4
.github/workflows/dotnet-upgrade.yml
vendored
4
.github/workflows/dotnet-upgrade.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
DOTNET_CURRENT_MAJOR_MINOR_VERSION: ${{ steps.fetch_current_version.outputs.DOTNET_CURRENT_MAJOR_MINOR_VERSION }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- name: Get current major minor version
|
||||
id: fetch_current_version
|
||||
shell: bash
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
if: ${{ needs.dotnet-update.outputs.SHOULD_UPDATE == 1 && needs.dotnet-update.outputs.BRANCH_EXISTS == 0 }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: feature/dotnetsdk-upgrade/${{ needs.dotnet-update.outputs.DOTNET_LATEST_MAJOR_MINOR_PATCH_VERSION }}
|
||||
- name: Create Pull Request
|
||||
|
||||
26
.github/workflows/node-upgrade.yml
vendored
26
.github/workflows/node-upgrade.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
||||
update-node:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Get latest Node versions
|
||||
id: node-versions
|
||||
run: |
|
||||
@@ -159,18 +159,36 @@ jobs:
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "<41898282+github-actions[bot]@users.noreply.github.com>"
|
||||
|
||||
# Build version summary for commit message and PR body (only include changed versions)
|
||||
COMMIT_VERSIONS=""
|
||||
PR_VERSION_LINES=""
|
||||
|
||||
if [ "${{ steps.node-versions.outputs.needs_update20 }}" == "true" ]; then
|
||||
COMMIT_VERSIONS="20: $NODE20_VERSION"
|
||||
PR_VERSION_LINES="- Node 20: ${{ steps.node-versions.outputs.current_node20 }} → $NODE20_VERSION"
|
||||
fi
|
||||
|
||||
if [ "${{ steps.node-versions.outputs.needs_update24 }}" == "true" ]; then
|
||||
if [ -n "$COMMIT_VERSIONS" ]; then
|
||||
COMMIT_VERSIONS="$COMMIT_VERSIONS, 24: $NODE24_VERSION"
|
||||
else
|
||||
COMMIT_VERSIONS="24: $NODE24_VERSION"
|
||||
fi
|
||||
PR_VERSION_LINES="${PR_VERSION_LINES:+$PR_VERSION_LINES
|
||||
}- Node 24: ${{ steps.node-versions.outputs.current_node24 }} → $NODE24_VERSION"
|
||||
fi
|
||||
|
||||
# Create branch and commit changes
|
||||
branch_name="chore/update-node"
|
||||
git checkout -b "$branch_name"
|
||||
git commit -a -m "chore: update Node versions (20: $NODE20_VERSION, 24: $NODE24_VERSION)"
|
||||
git commit -a -m "chore: update Node versions ($COMMIT_VERSIONS)"
|
||||
git push --force origin "$branch_name"
|
||||
|
||||
# Create PR body using here-doc for proper formatting
|
||||
cat > pr_body.txt << EOF
|
||||
Automated Node.js version update:
|
||||
|
||||
- Node 20: ${{ steps.node-versions.outputs.current_node20 }} → $NODE20_VERSION
|
||||
- Node 24: ${{ steps.node-versions.outputs.current_node24 }} → $NODE24_VERSION
|
||||
$PR_VERSION_LINES
|
||||
|
||||
This update ensures we're using the latest stable Node.js versions for security and performance improvements.
|
||||
|
||||
|
||||
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:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
|
||||
2
.github/workflows/npm-audit.yml
vendored
2
.github/workflows/npm-audit.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
||||
npm-audit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
38
.github/workflows/release.yml
vendored
38
.github/workflows/release.yml
vendored
@@ -11,12 +11,12 @@ jobs:
|
||||
if: startsWith(github.ref, 'refs/heads/releases/') || github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
# Make sure ./releaseVersion match ./src/runnerversion
|
||||
# Query GitHub release ensure version is not used
|
||||
- name: Check version
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
github-token: ${{secrets.GITHUB_TOKEN}}
|
||||
script: |
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
# Build runner layout
|
||||
- name: Build & Layout Release
|
||||
@@ -118,7 +118,7 @@ jobs:
|
||||
# Upload runner package tar.gz/zip as artifact.
|
||||
- name: Publish Artifact
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: runner-packages-${{ matrix.runtime }}
|
||||
path: |
|
||||
@@ -129,41 +129,41 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
# Download runner package tar.gz/zip produced by 'build' job
|
||||
- name: Download Artifact (win-x64)
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: runner-packages-win-x64
|
||||
path: ./
|
||||
- name: Download Artifact (win-arm64)
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: runner-packages-win-arm64
|
||||
path: ./
|
||||
- name: Download Artifact (osx-x64)
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: runner-packages-osx-x64
|
||||
path: ./
|
||||
- name: Download Artifact (osx-arm64)
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: runner-packages-osx-arm64
|
||||
path: ./
|
||||
- name: Download Artifact (linux-x64)
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: runner-packages-linux-x64
|
||||
path: ./
|
||||
- name: Download Artifact (linux-arm)
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: runner-packages-linux-arm
|
||||
path: ./
|
||||
- name: Download Artifact (linux-arm64)
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: runner-packages-linux-arm64
|
||||
path: ./
|
||||
@@ -171,7 +171,7 @@ jobs:
|
||||
# Create ReleaseNote file
|
||||
- name: Create ReleaseNote
|
||||
id: releaseNote
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
github-token: ${{secrets.GITHUB_TOKEN}}
|
||||
script: |
|
||||
@@ -296,11 +296,11 @@ jobs:
|
||||
IMAGE_NAME: ${{ github.repository_owner }}/actions-runner
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Compute image version
|
||||
id: image
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
@@ -309,10 +309,10 @@ jobs:
|
||||
core.setOutput('version', runnerVersion);
|
||||
|
||||
- name: Setup Docker buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -320,7 +320,7 @@ jobs:
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ./images
|
||||
platforms: |
|
||||
@@ -339,7 +339,7 @@ jobs:
|
||||
org.opencontainers.image.description=https://github.com/actions/runner/releases/tag/v${{ steps.image.outputs.version }}
|
||||
|
||||
- name: Generate attestation
|
||||
uses: actions/attest-build-provenance@v3
|
||||
uses: actions/attest-build-provenance@v4
|
||||
with:
|
||||
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
subject-digest: ${{ steps.build-and-push.outputs.digest }}
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -27,4 +27,5 @@ TestResults
|
||||
TestLogs
|
||||
.DS_Store
|
||||
.mono
|
||||
**/*.DotSettings.user
|
||||
**/*.DotSettings.user
|
||||
**/*.lscache
|
||||
@@ -25,11 +25,11 @@ The `installdependencies.sh` script should install all required dependencies on
|
||||
|
||||
Debian based OS (Debian, Ubuntu, Linux Mint)
|
||||
|
||||
- liblttng-ust1 or liblttng-ust0
|
||||
- liblttng-ust1t64, liblttng-ust1 or liblttng-ust0
|
||||
- libkrb5-3
|
||||
- zlib1g
|
||||
- libssl3t64, libssl3, libssl1.1, libssl1.0.2 or libssl1.0.0
|
||||
- libicu76, libicu75, ..., libicu66, libicu65, libicu63, libicu60, libicu57, libicu55, or libicu52
|
||||
- libicu80, libicu79, ..., libicu66, libicu65, libicu63, libicu60, libicu57, libicu55, or libicu52
|
||||
|
||||
Fedora based OS (Fedora, Red Hat Enterprise Linux, CentOS, Oracle Linux 7)
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
ARG RUNNER_VERSION
|
||||
ARG RUNNER_CONTAINER_HOOKS_VERSION=0.7.0
|
||||
ARG DOCKER_VERSION=29.2.0
|
||||
ARG BUILDX_VERSION=0.31.1
|
||||
ARG DOCKER_VERSION=29.6.1
|
||||
ARG BUILDX_VERSION=0.35.0
|
||||
|
||||
RUN apt update -y && apt install curl unzip -y
|
||||
|
||||
|
||||
@@ -1,35 +1,40 @@
|
||||
## What's Changed
|
||||
* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4200
|
||||
* Update dotnet sdk to latest version @8.0.417 by @github-actions[bot] in https://github.com/actions/runner/pull/4201
|
||||
* Bump System.Formats.Asn1 and System.Security.Cryptography.Pkcs by @dependabot[bot] in https://github.com/actions/runner/pull/4202
|
||||
* Allow empty container options by @ericsciple in https://github.com/actions/runner/pull/4208
|
||||
* Update Docker to v29.1.5 and Buildx to v0.31.0 by @github-actions[bot] in https://github.com/actions/runner/pull/4212
|
||||
* Report job level annotations by @TingluoHuang in https://github.com/actions/runner/pull/4216
|
||||
* Fix local action display name showing `Run /./` instead of `Run ./` by @ericsciple in https://github.com/actions/runner/pull/4218
|
||||
* Update Docker to v29.2.0 and Buildx to v0.31.1 by @github-actions[bot] in https://github.com/actions/runner/pull/4219
|
||||
* Add support for libssl3 and libssl3t64 for newer Debian/Ubuntu versions by @nekketsuuu in https://github.com/actions/runner/pull/4213
|
||||
* Validate work dir during runner start up. by @TingluoHuang in https://github.com/actions/runner/pull/4227
|
||||
* Bump hook to 0.8.1 by @nikola-jokic in https://github.com/actions/runner/pull/4222
|
||||
* Support return job result as exitcode in hosted runner. by @TingluoHuang in https://github.com/actions/runner/pull/4233
|
||||
* Add telemetry tracking for deprecated set-output and save-state commands by @ericsciple in https://github.com/actions/runner/pull/4221
|
||||
* Fix parser comparison mismatches by @ericsciple in https://github.com/actions/runner/pull/4220
|
||||
* Remove unnecessary connection test during some registration flows by @zarenner in https://github.com/actions/runner/pull/4244
|
||||
* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4249
|
||||
* Update dotnet sdk to latest version @8.0.418 by @github-actions[bot] in https://github.com/actions/runner/pull/4250
|
||||
* Fix link to SECURITY.md in README by @TingluoHuang in https://github.com/actions/runner/pull/4253
|
||||
* Try to infer runner is on hosted/ghes when githuburl is empty. by @TingluoHuang in https://github.com/actions/runner/pull/4254
|
||||
* Add Node.js 20 deprecation warning annotation (Phase 1) by @salmanmkc in https://github.com/actions/runner/pull/4242
|
||||
* Update Node.js 20 deprecation date to June 2nd, 2026 by @salmanmkc in https://github.com/actions/runner/pull/4258
|
||||
* Composite Action Step Markers by @ericsciple in https://github.com/actions/runner/pull/4243
|
||||
* Symlink actions cache by @paveliak in https://github.com/actions/runner/pull/4260
|
||||
* Bump minimatch in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4261
|
||||
* Bump @stylistic/eslint-plugin from 3.1.0 to 5.9.0 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4257
|
||||
* Bump System.ServiceProcess.ServiceController from 10.0.6 to 10.0.7 by @dependabot[bot] in https://github.com/actions/runner/pull/4370
|
||||
* Bump @actions/glob from 0.6.1 to 0.7.0 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4367
|
||||
* feat: propagate actions dependencies by @nodeselector in https://github.com/actions/runner/pull/4372
|
||||
* Not retry and report action download 403. by @TingluoHuang in https://github.com/actions/runner/pull/4391
|
||||
* Update setup job starting logs by @GitPaulo in https://github.com/actions/runner/pull/4383
|
||||
* fix: expand commit hash regex to support SHA-256 (64-char) hashes by @yaananth in https://github.com/actions/runner/pull/4347
|
||||
* Move dap setup to setup job step by @rentziass in https://github.com/actions/runner/pull/4403
|
||||
* Add support for Ubuntu 26.04 (liblttng-ust1t64, libicu77-80) by @dvaldivia in https://github.com/actions/runner/pull/4394
|
||||
* Update dotnet sdk to latest version @8.0.421 by @github-actions[bot] in https://github.com/actions/runner/pull/4428
|
||||
* Update Docker to v29.5.0 and Buildx to v0.34.0 by @github-actions[bot] in https://github.com/actions/runner/pull/4425
|
||||
* Execute debugger REPL commands inside job container by @rentziass in https://github.com/actions/runner/pull/4420
|
||||
* Send welcome message in debugger console on connect by @rentziass in https://github.com/actions/runner/pull/4419
|
||||
* Update snapshot-if context and functions by @drielenr in https://github.com/actions/runner/pull/4443
|
||||
* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4452
|
||||
* Allow disable node v8 maglev jit compiler on node24. by @TingluoHuang in https://github.com/actions/runner/pull/4447
|
||||
* Update Node 24 default date to June 16th, 2026 by @salmanmkc in https://github.com/actions/runner/pull/4462
|
||||
* Populate telemetry for non-action post-job steps by @drielenr in https://github.com/actions/runner/pull/4463
|
||||
* Add SDK types and results plumbing for background step control by @lokesh755 in https://github.com/actions/runner/pull/4472
|
||||
* Add job execution view model by @rentziass in https://github.com/actions/runner/pull/4470
|
||||
* Add thread-safety locks to StepsContext by @lokesh755 in https://github.com/actions/runner/pull/4475
|
||||
* Add background step deferral infrastructure and metadata plumbing by @lokesh755 in https://github.com/actions/runner/pull/4479
|
||||
* Wire job execution view into DAP by @rentziass in https://github.com/actions/runner/pull/4471
|
||||
* Background steps execution engine by @lokesh755 in https://github.com/actions/runner/pull/4476
|
||||
* Update Docker to v29.5.2 and Buildx to v0.34.1 by @github-actions[bot] in https://github.com/actions/runner/pull/4451
|
||||
* BrokerServer should not retry on 401. by @TingluoHuang in https://github.com/actions/runner/pull/4445
|
||||
* Add new env var to allow single-prefix multiline logs on stdout by @nuclearpidgeon in https://github.com/actions/runner/pull/4424
|
||||
* Bump Microsoft.DevTunnels.Connections from 1.3.39 to 1.3.48 by @dependabot[bot] in https://github.com/actions/runner/pull/4441
|
||||
* Bump System.Formats.Asn1 and System.Security.Cryptography.Pkcs by @dependabot[bot] in https://github.com/actions/runner/pull/4369
|
||||
|
||||
## New Contributors
|
||||
* @nekketsuuu made their first contribution in https://github.com/actions/runner/pull/4213
|
||||
* @zarenner made their first contribution in https://github.com/actions/runner/pull/4244
|
||||
* @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.331.0...v2.332.0
|
||||
**Full Changelog**: https://github.com/actions/runner/compare/v2.334.0...v2.335.0
|
||||
|
||||
_Note: Actions Runner follows a progressive release policy, so the latest release might not be available to your enterprise, organization, or repository yet.
|
||||
To confirm which version of the Actions Runner you should expect, please view the download instructions for your enterprise, organization, or repository.
|
||||
|
||||
1428
src/Misc/expressionFunc/hashFiles/package-lock.json
generated
1428
src/Misc/expressionFunc/hashFiles/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -32,20 +32,20 @@
|
||||
"author": "GitHub Actions",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/glob": "^0.4.0"
|
||||
"@actions/glob": "^0.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@stylistic/eslint-plugin": "^5.9.0",
|
||||
"@stylistic/eslint-plugin": "^5.10.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||
"@typescript-eslint/parser": "^8.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.0",
|
||||
"@typescript-eslint/parser": "^8.59.0",
|
||||
"@vercel/ncc": "^0.38.3",
|
||||
"eslint": "^8.47.0",
|
||||
"eslint-plugin-github": "^4.10.2",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^15.5.0",
|
||||
"lint-staged": "^16.4.0",
|
||||
"prettier": "^3.0.3",
|
||||
"typescript": "^5.9.3"
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ NODE_URL=https://nodejs.org/dist
|
||||
NODE_ALPINE_URL=https://github.com/actions/alpine_nodejs/releases/download
|
||||
# When you update Node versions you must also create a new release of alpine_nodejs at that updated version.
|
||||
# Follow the instructions here: https://github.com/actions/alpine_nodejs?tab=readme-ov-file#getting-started
|
||||
NODE20_VERSION="20.20.0"
|
||||
NODE24_VERSION="24.13.1"
|
||||
NODE20_VERSION="20.20.2"
|
||||
NODE24_VERSION="24.18.0"
|
||||
|
||||
get_abs_path() {
|
||||
# exploits the fact that pwd will print abs path when no args
|
||||
|
||||
@@ -94,7 +94,7 @@ then
|
||||
fi
|
||||
}
|
||||
|
||||
apt_get_with_fallbacks liblttng-ust1 liblttng-ust0
|
||||
apt_get_with_fallbacks liblttng-ust1t64 liblttng-ust1 liblttng-ust0
|
||||
if [ $? -ne 0 ]
|
||||
then
|
||||
echo "'$apt_get' failed with exit code '$?'"
|
||||
@@ -110,7 +110,7 @@ then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
apt_get_with_fallbacks libicu76 libicu75 libicu74 libicu73 libicu72 libicu71 libicu70 libicu69 libicu68 libicu67 libicu66 libicu65 libicu63 libicu60 libicu57 libicu55 libicu52
|
||||
apt_get_with_fallbacks libicu80 libicu79 libicu78 libicu77 libicu76 libicu75 libicu74 libicu73 libicu72 libicu71 libicu70 libicu69 libicu68 libicu67 libicu66 libicu65 libicu63 libicu60 libicu57 libicu55 libicu52
|
||||
if [ $? -ne 0 ]
|
||||
then
|
||||
echo "'$apt_get' failed with exit code '$?'"
|
||||
|
||||
@@ -10,6 +10,13 @@ if %ERRORLEVEL% EQU 0 (
|
||||
exit /b 0
|
||||
)
|
||||
|
||||
if "%ACTIONS_RUNNER_RETURN_VERSION_DEPRECATED_EXIT_CODE%"=="1" (
|
||||
if %ERRORLEVEL% EQU 7 (
|
||||
echo "Runner listener exit with deprecated version error code: %ERRORLEVEL%."
|
||||
exit /b %ERRORLEVEL%
|
||||
)
|
||||
)
|
||||
|
||||
if %ERRORLEVEL% EQU 1 (
|
||||
echo "Runner listener exit with terminated error, stop the service, no retry needed."
|
||||
exit /b 0
|
||||
|
||||
@@ -34,11 +34,13 @@ fi
|
||||
|
||||
updateFile="update.finished"
|
||||
"$DIR"/bin/Runner.Listener run $*
|
||||
|
||||
returnCode=$?
|
||||
if [[ $returnCode == 0 ]]; then
|
||||
echo "Runner listener exit with 0 return code, stop the service, no retry needed."
|
||||
exit 0
|
||||
elif [[ "$ACTIONS_RUNNER_RETURN_VERSION_DEPRECATED_EXIT_CODE" == "1" && $returnCode -eq 7 ]]; then
|
||||
echo "Runner listener exit with deprecated version exit code: ${returnCode}."
|
||||
exit "$returnCode"
|
||||
elif [[ $returnCode == 1 ]]; then
|
||||
echo "Runner listener exit with terminated error, stop the service, no retry needed."
|
||||
exit 0
|
||||
|
||||
@@ -25,7 +25,14 @@ call "%~dp0run-helper.cmd" %*
|
||||
if %ERRORLEVEL% EQU 1 (
|
||||
echo "Restarting runner..."
|
||||
goto :launch_helper
|
||||
) else (
|
||||
echo "Exiting runner..."
|
||||
exit /b 0
|
||||
)
|
||||
|
||||
if "%ACTIONS_RUNNER_RETURN_VERSION_DEPRECATED_EXIT_CODE%"=="1" (
|
||||
if %ERRORLEVEL% EQU 7 (
|
||||
echo "Exiting runner with deprecated version error code: %ERRORLEVEL%"
|
||||
exit /b %ERRORLEVEL%
|
||||
)
|
||||
)
|
||||
|
||||
echo "Exiting runner..."
|
||||
exit /b 0
|
||||
|
||||
@@ -19,6 +19,9 @@ run() {
|
||||
returnCode=$?
|
||||
if [[ $returnCode -eq 2 ]]; then
|
||||
echo "Restarting runner..."
|
||||
elif [[ "$ACTIONS_RUNNER_RETURN_VERSION_DEPRECATED_EXIT_CODE" == "1" && $returnCode -eq 7 ]]; then
|
||||
echo "Exiting runner..."
|
||||
exit "$returnCode"
|
||||
else
|
||||
echo "Exiting runner..."
|
||||
exit 0
|
||||
@@ -42,6 +45,9 @@ runWithManualTrap() {
|
||||
returnCode=$?
|
||||
if [[ $returnCode -eq 2 ]]; then
|
||||
echo "Restarting runner..."
|
||||
elif [[ "$ACTIONS_RUNNER_RETURN_VERSION_DEPRECATED_EXIT_CODE" == "1" && $returnCode -eq 7 ]]; then
|
||||
echo "Exiting runner..."
|
||||
exit "$returnCode"
|
||||
else
|
||||
echo "Exiting runner..."
|
||||
# Unregister signal handling before exit
|
||||
|
||||
@@ -108,7 +108,7 @@ namespace GitHub.Runner.Common
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -159,6 +159,7 @@ namespace GitHub.Runner.Common
|
||||
// and the runner should be restarted. This is a temporary code and will be removed in the future after
|
||||
// the runner is migrated to runner admin.
|
||||
public const int RunnerConfigurationRefreshed = 6;
|
||||
public const int RunnerVersionDeprecated = 7;
|
||||
}
|
||||
|
||||
public static class Features
|
||||
@@ -172,9 +173,14 @@ namespace GitHub.Runner.Common
|
||||
public static readonly string SnapshotPreflightHostedRunnerCheck = "actions_snapshot_preflight_hosted_runner_check";
|
||||
public static readonly string SnapshotPreflightImageGenPoolCheck = "actions_snapshot_preflight_image_gen_pool_check";
|
||||
public static readonly string CompareWorkflowParser = "actions_runner_compare_workflow_parser";
|
||||
public static readonly string ServiceContainerCommand = "actions_service_container_command";
|
||||
public static readonly string SetOrchestrationIdEnvForActions = "actions_set_orchestration_id_env_for_actions";
|
||||
public static readonly string SendJobLevelAnnotations = "actions_send_job_level_annotations";
|
||||
public static readonly string EmitCompositeMarkers = "actions_runner_emit_composite_markers";
|
||||
public static readonly string BatchActionResolution = "actions_batch_action_resolution";
|
||||
public static readonly string UseBearerTokenForCodeload = "actions_use_bearer_token_for_codeload";
|
||||
public static readonly string OverrideDebuggerWelcomeMessage = "actions_runner_override_debugger_welcome_message";
|
||||
public static readonly string SelfRepository = "actions_self_repository";
|
||||
}
|
||||
|
||||
// Node version migration related constants
|
||||
@@ -193,8 +199,22 @@ namespace GitHub.Runner.Common
|
||||
public static readonly string RequireNode24Flag = "actions.runner.requirenode24";
|
||||
public static readonly string WarnOnNode20Flag = "actions.runner.warnonnode20";
|
||||
|
||||
// Feature flags for Linux ARM32 deprecation
|
||||
public static readonly string DeprecateLinuxArm32Flag = "actions_runner_deprecate_linux_arm32";
|
||||
public static readonly string KillLinuxArm32Flag = "actions_runner_kill_linux_arm32";
|
||||
|
||||
// Blog post URL for Node 20 deprecation
|
||||
public static readonly string Node20DeprecationUrl = "https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/";
|
||||
|
||||
// Node 20 migration dates (hardcoded fallbacks, can be overridden via job variables)
|
||||
public static readonly string Node24DefaultDate = "June 16th, 2026";
|
||||
public static readonly string Node20RemovalDate = "September 16th, 2026";
|
||||
|
||||
// Variable keys for server-overridable dates
|
||||
public static readonly string Node24DefaultDateVariable = "actions_runner_node24_default_date";
|
||||
public static readonly string Node20RemovalDateVariable = "actions_runner_node20_removal_date";
|
||||
|
||||
public static readonly string LinuxArm32DeprecationMessage = "Linux ARM32 runners are deprecated and will no longer be supported after {0}. Please migrate to a supported platform.";
|
||||
}
|
||||
|
||||
public static readonly string InternalTelemetryIssueDataKey = "_internal_telemetry";
|
||||
@@ -276,6 +296,7 @@ namespace GitHub.Runner.Common
|
||||
public static readonly string AllowUnsupportedCommands = "ACTIONS_ALLOW_UNSECURE_COMMANDS";
|
||||
public static readonly string AllowUnsupportedStopCommandTokens = "ACTIONS_ALLOW_UNSECURE_STOPCOMMAND_TOKENS";
|
||||
public static readonly string RequireJobContainer = "ACTIONS_RUNNER_REQUIRE_JOB_CONTAINER";
|
||||
public static readonly string ReturnVersionDeprecatedExitCode = "ACTIONS_RUNNER_RETURN_VERSION_DEPRECATED_EXIT_CODE";
|
||||
public static readonly string RunnerDebug = "ACTIONS_RUNNER_DEBUG";
|
||||
public static readonly string StepDebug = "ACTIONS_STEP_DEBUG";
|
||||
}
|
||||
@@ -288,6 +309,7 @@ namespace GitHub.Runner.Common
|
||||
public static readonly string ForcedInternalNodeVersion = "ACTIONS_RUNNER_FORCED_INTERNAL_NODE_VERSION";
|
||||
public static readonly string ForcedActionsNodeVersion = "ACTIONS_RUNNER_FORCE_ACTIONS_NODE_VERSION";
|
||||
public static readonly string PrintLogToStdout = "ACTIONS_RUNNER_PRINT_LOG_TO_STDOUT";
|
||||
public static readonly string DisableStdoutMultilineLogPrefixing = "ACTIONS_RUNNER_DISABLE_STDOUT_MULTILINE_LOG_PREFIXING";
|
||||
public static readonly string ActionArchiveCacheDirectory = "ACTIONS_RUNNER_ACTION_ARCHIVE_CACHE";
|
||||
public static readonly string SymlinkCachedActions = "ACTIONS_RUNNER_SYMLINK_CACHED_ACTIONS";
|
||||
public static readonly string EmitCompositeMarkers = "ACTIONS_RUNNER_EMIT_COMPOSITE_MARKERS";
|
||||
|
||||
@@ -837,6 +837,15 @@ namespace GitHub.Runner.Common
|
||||
timelineRecord.Variables[variable.Key] = variable.Value.Clone();
|
||||
}
|
||||
}
|
||||
|
||||
// Merge background step metadata
|
||||
if (rec.IsBackground)
|
||||
{
|
||||
timelineRecord.IsBackground = rec.IsBackground;
|
||||
}
|
||||
timelineRecord.BackgroundControlType = rec.BackgroundControlType ?? timelineRecord.BackgroundControlType;
|
||||
timelineRecord.BackgroundControlStepIds = rec.BackgroundControlStepIds ?? timelineRecord.BackgroundControlStepIds;
|
||||
timelineRecord.ParallelGroupId = rec.ParallelGroupId ?? timelineRecord.ParallelGroupId;
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
|
||||
<PackageReference Include="System.Text.Encoding.CodePages" Version="8.0.0" />
|
||||
<PackageReference Include="System.Threading.Channels" Version="8.0.0" />
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.3" />
|
||||
<PackageReference Include="System.Text.Encoding.CodePages" Version="10.0.3" />
|
||||
<PackageReference Include="System.Threading.Channels" Version="10.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
@@ -9,10 +9,12 @@ namespace GitHub.Runner.Common
|
||||
public sealed class StdoutTraceListener : ConsoleTraceListener
|
||||
{
|
||||
private readonly string _hostType;
|
||||
private readonly bool _disablePrefixMultilineLogs = false;
|
||||
|
||||
public StdoutTraceListener(string hostType)
|
||||
{
|
||||
this._hostType = hostType;
|
||||
this._disablePrefixMultilineLogs = StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable(Constants.Variables.Agent.DisableStdoutMultilineLogPrefixing));
|
||||
}
|
||||
|
||||
// Copied and modified slightly from .Net Core source code. Modification was required to make it compile.
|
||||
@@ -26,11 +28,20 @@ namespace GitHub.Runner.Common
|
||||
|
||||
if (!string.IsNullOrEmpty(message))
|
||||
{
|
||||
var messageLines = message.Split(Environment.NewLine);
|
||||
foreach (var messageLine in messageLines)
|
||||
if (!this._disablePrefixMultilineLogs)
|
||||
{
|
||||
var messageLines = message.Split(Environment.NewLine);
|
||||
foreach (var messageLine in messageLines)
|
||||
{
|
||||
WriteHeader(source, eventType, id);
|
||||
WriteLine(messageLine);
|
||||
WriteFooter(eventCache);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
WriteHeader(source, eventType, id);
|
||||
WriteLine(messageLine);
|
||||
WriteLine(message);
|
||||
WriteFooter(eventCache);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,13 @@ namespace GitHub.Runner.Common
|
||||
private ISecretMasker _secretMasker;
|
||||
private TraceSource _traceSource;
|
||||
|
||||
/// <summary>
|
||||
/// The underlying <see cref="System.Diagnostics.TraceSource"/> for this instance.
|
||||
/// Useful when third-party libraries require a <see cref="System.Diagnostics.TraceSource"/>
|
||||
/// to route their diagnostics into the runner's log infrastructure.
|
||||
/// </summary>
|
||||
public TraceSource Source => _traceSource;
|
||||
|
||||
public Tracing(string name, ISecretMasker secretMasker, SourceSwitch sourceSwitch, HostTraceListener traceListener, StdoutTraceListener stdoutTraceListener = null)
|
||||
{
|
||||
ArgUtil.NotNull(secretMasker, nameof(secretMasker));
|
||||
|
||||
@@ -58,7 +58,7 @@ namespace GitHub.Runner.Common.Util
|
||||
{
|
||||
return (Constants.Runner.NodeMigration.Node24, null);
|
||||
}
|
||||
|
||||
|
||||
// Get environment variable details with source information
|
||||
var forceNode24Details = GetEnvironmentVariableDetails(
|
||||
Constants.Runner.NodeMigration.ForceNode24Variable, workflowEnvironment);
|
||||
@@ -108,14 +108,50 @@ namespace GitHub.Runner.Common.Util
|
||||
|
||||
/// <summary>
|
||||
/// Checks if Node24 is requested but running on ARM32 Linux, and determines if fallback is needed.
|
||||
/// Also handles ARM32 deprecation and kill switch phases.
|
||||
/// </summary>
|
||||
/// <param name="preferredVersion">The preferred Node version</param>
|
||||
/// <param name="deprecateArm32">Feature flag indicating ARM32 Linux is deprecated</param>
|
||||
/// <param name="killArm32">Feature flag indicating ARM32 Linux should no longer work</param>
|
||||
/// <returns>A tuple containing the adjusted node version and an optional warning message</returns>
|
||||
public static (string nodeVersion, string warningMessage) CheckNodeVersionForLinuxArm32(string preferredVersion)
|
||||
public static (string nodeVersion, string warningMessage) CheckNodeVersionForLinuxArm32(
|
||||
string preferredVersion,
|
||||
bool deprecateArm32 = false,
|
||||
bool killArm32 = false,
|
||||
string node20RemovalDate = null)
|
||||
{
|
||||
if (string.Equals(preferredVersion, Constants.Runner.NodeMigration.Node24, StringComparison.OrdinalIgnoreCase) &&
|
||||
Constants.Runner.PlatformArchitecture.Equals(Constants.Architecture.Arm) &&
|
||||
Constants.Runner.Platform.Equals(Constants.OSPlatform.Linux))
|
||||
bool isArm32Linux = Constants.Runner.PlatformArchitecture.Equals(Constants.Architecture.Arm) &&
|
||||
Constants.Runner.Platform.Equals(Constants.OSPlatform.Linux);
|
||||
|
||||
if (!isArm32Linux)
|
||||
{
|
||||
return (preferredVersion, null);
|
||||
}
|
||||
|
||||
// ARM32 kill switch: runner should no longer work on this platform
|
||||
if (killArm32)
|
||||
{
|
||||
return (null, "Linux ARM32 runners are no longer supported. Please migrate to a supported platform.");
|
||||
}
|
||||
|
||||
// ARM32 deprecation warning: continue using node20 but warn about upcoming end of support
|
||||
if (deprecateArm32)
|
||||
{
|
||||
string effectiveDate = string.IsNullOrEmpty(node20RemovalDate) ? Constants.Runner.NodeMigration.Node20RemovalDate : node20RemovalDate;
|
||||
string deprecationWarning = string.Format(
|
||||
Constants.Runner.NodeMigration.LinuxArm32DeprecationMessage,
|
||||
effectiveDate);
|
||||
|
||||
if (string.Equals(preferredVersion, Constants.Runner.NodeMigration.Node24, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return (Constants.Runner.NodeMigration.Node20, deprecationWarning);
|
||||
}
|
||||
|
||||
return (preferredVersion, deprecationWarning);
|
||||
}
|
||||
|
||||
// Legacy behavior: fall back to node20 if node24 was requested on ARM32
|
||||
if (string.Equals(preferredVersion, Constants.Runner.NodeMigration.Node24, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return (Constants.Runner.NodeMigration.Node20, "Node 24 is not supported on Linux ARM32 platforms. Falling back to Node 20.");
|
||||
}
|
||||
|
||||
@@ -141,9 +141,9 @@ namespace GitHub.Runner.Listener
|
||||
}
|
||||
catch (AccessDeniedException e) when (e.ErrorCode == 1)
|
||||
{
|
||||
terminal.WriteError($"An error occured: {e.Message}");
|
||||
terminal.WriteError($"An error occurred: {e.Message}");
|
||||
trace.Error(e);
|
||||
return Constants.Runner.ReturnCode.TerminatedError;
|
||||
return GetRunnerVersionDeprecatedExitCode();
|
||||
}
|
||||
catch (RunnerNotFoundException e)
|
||||
{
|
||||
@@ -159,6 +159,16 @@ namespace GitHub.Runner.Listener
|
||||
}
|
||||
}
|
||||
|
||||
private static int GetRunnerVersionDeprecatedExitCode()
|
||||
{
|
||||
if (StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable(Constants.Variables.Actions.ReturnVersionDeprecatedExitCode)))
|
||||
{
|
||||
return Constants.Runner.ReturnCode.RunnerVersionDeprecated;
|
||||
}
|
||||
|
||||
return Constants.Runner.ReturnCode.TerminatedError;
|
||||
}
|
||||
|
||||
private static void LoadAndSetEnv()
|
||||
{
|
||||
var binDir = Path.GetDirectoryName(Assembly.GetEntryAssembly().Location);
|
||||
|
||||
@@ -22,8 +22,8 @@
|
||||
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="System.IO.FileSystem.AccessControl" Version="5.0.0" />
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
|
||||
<PackageReference Include="System.ServiceProcess.ServiceController" Version="8.0.1" />
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.3" />
|
||||
<PackageReference Include="System.ServiceProcess.ServiceController" Version="10.0.7" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
|
||||
|
||||
@@ -120,8 +120,10 @@ namespace GitHub.Runner.Listener
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error(ex);
|
||||
_terminal.WriteError($"Runner update failed: {ex.Message}");
|
||||
_updateTrace.Enqueue(ex.ToString());
|
||||
throw;
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -120,8 +120,10 @@ namespace GitHub.Runner.Listener
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error(ex);
|
||||
_terminal.WriteError($"Runner update failed: {ex.Message}");
|
||||
_updateTrace.Enqueue(ex.ToString());
|
||||
throw;
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -12,8 +12,6 @@ namespace GitHub.Runner.Plugins.Repository.v1_0
|
||||
{
|
||||
public class CheckoutTask : IRunnerActionPlugin
|
||||
{
|
||||
private readonly Regex _validSha1 = new(@"\b[0-9a-f]{40}\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled, TimeSpan.FromSeconds(2));
|
||||
|
||||
public async Task RunAsync(RunnerActionPluginExecutionContext executionContext, CancellationToken token)
|
||||
{
|
||||
string runnerWorkspace = executionContext.GetRunnerContext("workspace");
|
||||
@@ -99,7 +97,7 @@ namespace GitHub.Runner.Plugins.Repository.v1_0
|
||||
{
|
||||
sourceBranch = refInput;
|
||||
sourceVersion = executionContext.GetInput(Pipelines.PipelineConstants.CheckoutTaskInputs.Version); // version get removed when checkout move to repo in the graph
|
||||
if (string.IsNullOrEmpty(sourceVersion) && RegexUtility.IsMatch(sourceBranch, WellKnownRegularExpressions.SHA1))
|
||||
if (string.IsNullOrEmpty(sourceVersion) && RegexUtility.IsMatch(sourceBranch, WellKnownRegularExpressions.CommitHash))
|
||||
{
|
||||
sourceVersion = sourceBranch;
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ namespace GitHub.Runner.Plugins.Repository.v1_1
|
||||
{
|
||||
sourceBranch = refInput;
|
||||
sourceVersion = executionContext.GetInput(Pipelines.PipelineConstants.CheckoutTaskInputs.Version); // version get removed when checkout move to repo in the graph
|
||||
if (string.IsNullOrEmpty(sourceVersion) && RegexUtility.IsMatch(sourceBranch, WellKnownRegularExpressions.SHA1))
|
||||
if (string.IsNullOrEmpty(sourceVersion) && RegexUtility.IsMatch(sourceBranch, WellKnownRegularExpressions.CommitHash))
|
||||
{
|
||||
sourceVersion = sourceBranch;
|
||||
// If Ref is a SHA and the repo is self, we need to use github.ref as source branch since it might be refs/pull/*
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Text.Encoding.CodePages" Version="8.0.0" />
|
||||
<PackageReference Include="System.Text.Encoding.CodePages" Version="10.0.3" />
|
||||
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
|
||||
<PackageReference Include="System.Threading.Channels" Version="8.0.0" />
|
||||
<PackageReference Include="System.Threading.Channels" Version="10.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
|
||||
|
||||
@@ -282,8 +282,15 @@ namespace GitHub.Runner.Worker
|
||||
}
|
||||
}
|
||||
|
||||
context.Global.EnvironmentVariables[envName] = command.Data;
|
||||
context.SetEnvContext(envName, command.Data);
|
||||
if (context.DeferredEnvironmentVariables != null)
|
||||
{
|
||||
context.DeferredEnvironmentVariables[envName] = command.Data;
|
||||
}
|
||||
else
|
||||
{
|
||||
context.Global.EnvironmentVariables[envName] = command.Data;
|
||||
context.SetEnvContext(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.");
|
||||
}
|
||||
|
||||
context.SetOutput(outputName, command.Data, out var reference);
|
||||
context.Debug($"{reference}='{command.Data}'");
|
||||
if (context.DeferredOutputs != null)
|
||||
{
|
||||
context.DeferredOutputs[outputName] = command.Data;
|
||||
}
|
||||
else
|
||||
{
|
||||
context.SetOutput(outputName, command.Data, out var reference);
|
||||
context.Debug($"{reference}='{command.Data}'");
|
||||
}
|
||||
}
|
||||
|
||||
private static class SetOutputCommandProperties
|
||||
@@ -465,8 +479,16 @@ namespace GitHub.Runner.Worker
|
||||
}
|
||||
|
||||
ArgUtil.NotNullOrEmpty(command.Data, "path");
|
||||
context.Global.PrependPath.RemoveAll(x => string.Equals(x, command.Data, StringComparison.CurrentCulture));
|
||||
context.Global.PrependPath.Add(command.Data);
|
||||
if (context.DeferredPrependPath != null)
|
||||
{
|
||||
context.DeferredPrependPath.RemoveAll(x => string.Equals(x, command.Data, StringComparison.CurrentCulture));
|
||||
context.DeferredPrependPath.Add(command.Data);
|
||||
}
|
||||
else
|
||||
{
|
||||
context.Global.PrependPath.RemoveAll(x => string.Equals(x, command.Data, StringComparison.CurrentCulture));
|
||||
context.Global.PrependPath.Add(command.Data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -79,6 +79,13 @@ namespace GitHub.Runner.Worker
|
||||
PreStepTracker = new Dictionary<Guid, IActionRunner>()
|
||||
};
|
||||
var containerSetupSteps = new List<JobExtensionRunner>();
|
||||
var batchActionResolution = (executionContext.Global.Variables.GetBoolean(Constants.Runner.Features.BatchActionResolution) ?? false)
|
||||
|| StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION"));
|
||||
// Stack-local cache: same action (owner/repo@ref) is resolved only once,
|
||||
// even if it appears at multiple depths in a composite tree.
|
||||
var resolvedDownloadInfos = batchActionResolution
|
||||
? new Dictionary<string, WebApi.ActionDownloadInfo>(StringComparer.Ordinal)
|
||||
: null;
|
||||
var depth = 0;
|
||||
// We are running at the start of a job
|
||||
if (rootStepId == default(Guid))
|
||||
@@ -105,13 +112,23 @@ namespace GitHub.Runner.Worker
|
||||
PrepareActionsState result = new PrepareActionsState();
|
||||
try
|
||||
{
|
||||
result = await PrepareActionsRecursiveAsync(executionContext, state, actions, depth, rootStepId);
|
||||
result = batchActionResolution
|
||||
? await PrepareActionsRecursiveAsync(executionContext, state, actions, resolvedDownloadInfos, depth, rootStepId)
|
||||
: await PrepareActionsRecursiveLegacyAsync(executionContext, state, actions, depth, rootStepId);
|
||||
}
|
||||
catch (FailedToResolveActionDownloadInfoException ex)
|
||||
{
|
||||
// Log the error and fail the PrepareActionsAsync Initialization.
|
||||
Trace.Error($"Caught exception from PrepareActionsAsync Initialization: {ex}");
|
||||
executionContext.InfrastructureError(ex.Message, category: "resolve_action");
|
||||
executionContext.InfrastructureError(ex.InnerException?.Message ?? ex.Message, category: "resolve_action");
|
||||
executionContext.Result = TaskResult.Failed;
|
||||
throw;
|
||||
}
|
||||
catch (FailedToDownloadActionException ex)
|
||||
{
|
||||
// Log the error and fail the PrepareActionsAsync Initialization.
|
||||
Trace.Error($"Caught exception from PrepareActionsAsync Initialization: {ex}");
|
||||
executionContext.InfrastructureError(ex.InnerException?.Message ?? ex.Message, category: "error_download_action");
|
||||
executionContext.Result = TaskResult.Failed;
|
||||
throw;
|
||||
}
|
||||
@@ -161,13 +178,285 @@ namespace GitHub.Runner.Worker
|
||||
return new PrepareResult(containerSetupSteps, result.PreStepTracker);
|
||||
}
|
||||
|
||||
private async Task<PrepareActionsState> PrepareActionsRecursiveAsync(IExecutionContext executionContext, PrepareActionsState state, IEnumerable<Pipelines.ActionStep> actions, Int32 depth = 0, Guid parentStepId = default(Guid))
|
||||
private async Task<PrepareActionsState> PrepareActionsRecursiveAsync(IExecutionContext executionContext, PrepareActionsState state, IEnumerable<Pipelines.ActionStep> actions, Dictionary<string, WebApi.ActionDownloadInfo> resolvedDownloadInfos, Int32 depth = 0, Guid parentStepId = default(Guid), string selfRepoName = null, string selfRepoRef = null)
|
||||
{
|
||||
ArgUtil.NotNull(executionContext, nameof(executionContext));
|
||||
if (depth > Constants.CompositeActionsMaxDepth)
|
||||
{
|
||||
throw new Exception($"Composite action depth exceeded max depth {Constants.CompositeActionsMaxDepth}");
|
||||
}
|
||||
|
||||
// Resolve self-repository ($/) references before processing
|
||||
if (executionContext.Global.Variables.GetBoolean(Constants.Runner.Features.SelfRepository) == true)
|
||||
{
|
||||
if (string.IsNullOrEmpty(selfRepoName))
|
||||
{
|
||||
// job.workflow_repository/workflow_sha point to the repo
|
||||
// containing the workflow file — correct for both regular
|
||||
// and reusable workflows. Always present when the server
|
||||
// supports $/. See: https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/accessing-contextual-information-about-workflow-runs#github-context
|
||||
selfRepoName = executionContext.JobContext?.WorkflowRepository;
|
||||
selfRepoRef = executionContext.JobContext?.WorkflowSha;
|
||||
}
|
||||
ResolveSelfRepositoryReferences(executionContext, actions, selfRepoName, selfRepoRef);
|
||||
}
|
||||
|
||||
var repositoryActions = new List<Pipelines.ActionStep>();
|
||||
|
||||
foreach (var action in actions)
|
||||
{
|
||||
if (action.Reference.Type == Pipelines.ActionSourceType.ContainerRegistry)
|
||||
{
|
||||
ArgUtil.NotNull(action, nameof(action));
|
||||
var containerReference = action.Reference as Pipelines.ContainerRegistryReference;
|
||||
ArgUtil.NotNull(containerReference, nameof(containerReference));
|
||||
ArgUtil.NotNullOrEmpty(containerReference.Image, nameof(containerReference.Image));
|
||||
|
||||
if (!state.ImagesToPull.ContainsKey(containerReference.Image))
|
||||
{
|
||||
state.ImagesToPull[containerReference.Image] = new List<Guid>();
|
||||
}
|
||||
|
||||
Trace.Info($"Action {action.Name} ({action.Id}) needs to pull image '{containerReference.Image}'");
|
||||
state.ImagesToPull[containerReference.Image].Add(action.Id);
|
||||
}
|
||||
else if (action.Reference.Type == Pipelines.ActionSourceType.Repository)
|
||||
{
|
||||
repositoryActions.Add(action);
|
||||
}
|
||||
}
|
||||
|
||||
if (repositoryActions.Count > 0)
|
||||
{
|
||||
// Resolve download info, skipping any actions already cached.
|
||||
await ResolveNewActionsAsync(executionContext, repositoryActions, resolvedDownloadInfos);
|
||||
|
||||
// Download each action.
|
||||
foreach (var action in repositoryActions)
|
||||
{
|
||||
var lookupKey = GetDownloadInfoLookupKey(action);
|
||||
if (string.IsNullOrEmpty(lookupKey))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (!resolvedDownloadInfos.TryGetValue(lookupKey, out var downloadInfo))
|
||||
{
|
||||
throw new Exception($"Missing download info for {lookupKey}");
|
||||
}
|
||||
|
||||
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
|
||||
// resolution below. Pre/post step registration is deferred until
|
||||
// after recursion so that HasPre/HasPost reflect the full subtree.
|
||||
var nextLevel = new List<(Pipelines.ActionStep action, Guid parentId)>();
|
||||
|
||||
foreach (var action in repositoryActions)
|
||||
{
|
||||
var setupInfo = PrepareRepositoryActionAsync(executionContext, action);
|
||||
if (setupInfo != null && setupInfo.Container != null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(setupInfo.Container.Image))
|
||||
{
|
||||
if (!state.ImagesToPull.ContainsKey(setupInfo.Container.Image))
|
||||
{
|
||||
state.ImagesToPull[setupInfo.Container.Image] = new List<Guid>();
|
||||
}
|
||||
|
||||
Trace.Info($"Action {action.Name} ({action.Id}) from repository '{setupInfo.Container.ActionRepository}' needs to pull image '{setupInfo.Container.Image}'");
|
||||
state.ImagesToPull[setupInfo.Container.Image].Add(action.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
ArgUtil.NotNullOrEmpty(setupInfo.Container.ActionRepository, nameof(setupInfo.Container.ActionRepository));
|
||||
|
||||
if (!state.ImagesToBuild.ContainsKey(setupInfo.Container.ActionRepository))
|
||||
{
|
||||
state.ImagesToBuild[setupInfo.Container.ActionRepository] = new List<Guid>();
|
||||
}
|
||||
|
||||
Trace.Info($"Action {action.Name} ({action.Id}) from repository '{setupInfo.Container.ActionRepository}' needs to build image '{setupInfo.Container.Dockerfile}'");
|
||||
state.ImagesToBuild[setupInfo.Container.ActionRepository].Add(action.Id);
|
||||
state.ImagesToBuildInfo[setupInfo.Container.ActionRepository] = setupInfo.Container;
|
||||
}
|
||||
}
|
||||
else if (setupInfo != null && setupInfo.Steps != null && setupInfo.Steps.Count > 0)
|
||||
{
|
||||
foreach (var step in setupInfo.Steps)
|
||||
{
|
||||
nextLevel.Add((step, action.Id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve all next-level sub-actions in one batch API call,
|
||||
// then recurse per parent (which hits the cache, not the API).
|
||||
if (nextLevel.Count > 0)
|
||||
{
|
||||
if (executionContext.Global.Variables.GetBoolean(Constants.Runner.Features.SelfRepository) == true)
|
||||
{
|
||||
// Self-repository path: group by parent so each group's
|
||||
// $/ refs resolve against the correct parent repo context.
|
||||
var groups = nextLevel.GroupBy(x => x.parentId).Select(group =>
|
||||
{
|
||||
string childRepoName = selfRepoName;
|
||||
string childRepoRef = selfRepoRef;
|
||||
var parentAction = repositoryActions.FirstOrDefault(a => a.Id == group.Key);
|
||||
if (parentAction?.Reference is Pipelines.RepositoryPathReference parentRef &&
|
||||
string.Equals(parentRef.RepositoryType, Pipelines.RepositoryTypes.GitHub, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
childRepoName = parentRef.Name;
|
||||
childRepoRef = parentRef.Ref;
|
||||
}
|
||||
return new { ParentId = group.Key, Actions = group.Select(x => x.action).ToList(), RepoName = childRepoName, RepoRef = childRepoRef };
|
||||
}).ToList();
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
ResolveSelfRepositoryReferences(executionContext, group.Actions, group.RepoName, group.RepoRef);
|
||||
}
|
||||
|
||||
var nextLevelRepoActions = nextLevel
|
||||
.Where(x => x.action.Reference.Type == Pipelines.ActionSourceType.Repository)
|
||||
.Select(x => x.action)
|
||||
.ToList();
|
||||
await ResolveNewActionsAsync(executionContext, nextLevelRepoActions, resolvedDownloadInfos);
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
state = await PrepareActionsRecursiveAsync(executionContext, state, group.Actions, resolvedDownloadInfos, depth + 1, group.ParentId, group.RepoName, group.RepoRef);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Original path: no self-repository resolution needed.
|
||||
var nextLevelActions = nextLevel.Select(x => x.action).ToList();
|
||||
var nextLevelRepoActions = nextLevelActions
|
||||
.Where(x => x.Reference.Type == Pipelines.ActionSourceType.Repository)
|
||||
.ToList();
|
||||
await ResolveNewActionsAsync(executionContext, nextLevelRepoActions, resolvedDownloadInfos);
|
||||
|
||||
foreach (var grp in nextLevel.GroupBy(x => x.parentId))
|
||||
{
|
||||
state = await PrepareActionsRecursiveAsync(executionContext, state, grp.Select(x => x.action).ToList(), resolvedDownloadInfos, depth + 1, grp.Key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register pre/post steps after recursion so that HasPre/HasPost
|
||||
// are correct (they depend on _cachedEmbeddedPreSteps/PostSteps
|
||||
// being populated by the recursive calls above).
|
||||
foreach (var action in repositoryActions)
|
||||
{
|
||||
var repoAction = action.Reference as Pipelines.RepositoryPathReference;
|
||||
if (repoAction.RepositoryType != Pipelines.PipelineConstants.SelfAlias)
|
||||
{
|
||||
var definition = LoadAction(executionContext, action);
|
||||
if (definition.Data.Execution.HasPre)
|
||||
{
|
||||
Trace.Info($"Add 'pre' execution for {action.Id}");
|
||||
// Root Step
|
||||
if (depth < 1)
|
||||
{
|
||||
var actionRunner = HostContext.CreateService<IActionRunner>();
|
||||
actionRunner.Action = action;
|
||||
actionRunner.Stage = ActionRunStage.Pre;
|
||||
actionRunner.Condition = definition.Data.Execution.InitCondition;
|
||||
state.PreStepTracker[action.Id] = actionRunner;
|
||||
}
|
||||
// Embedded Step
|
||||
else
|
||||
{
|
||||
if (!_cachedEmbeddedPreSteps.ContainsKey(parentStepId))
|
||||
{
|
||||
_cachedEmbeddedPreSteps[parentStepId] = new List<Pipelines.ActionStep>();
|
||||
}
|
||||
// Clone action so we can modify the condition without affecting the original
|
||||
var clonedAction = action.Clone() as Pipelines.ActionStep;
|
||||
clonedAction.Condition = definition.Data.Execution.InitCondition;
|
||||
_cachedEmbeddedPreSteps[parentStepId].Add(clonedAction);
|
||||
}
|
||||
}
|
||||
|
||||
if (definition.Data.Execution.HasPost && depth > 0)
|
||||
{
|
||||
if (!_cachedEmbeddedPostSteps.ContainsKey(parentStepId))
|
||||
{
|
||||
// If we haven't done so already, add the parent to the post steps
|
||||
_cachedEmbeddedPostSteps[parentStepId] = new Stack<Pipelines.ActionStep>();
|
||||
}
|
||||
// Clone action so we can modify the condition without affecting the original
|
||||
var clonedAction = action.Clone() as Pipelines.ActionStep;
|
||||
clonedAction.Condition = definition.Data.Execution.CleanupCondition;
|
||||
_cachedEmbeddedPostSteps[parentStepId].Push(clonedAction);
|
||||
}
|
||||
}
|
||||
else if (depth > 0)
|
||||
{
|
||||
// if we're in a composite action and haven't loaded the local action yet
|
||||
// we assume it has a post step
|
||||
if (!_cachedEmbeddedPostSteps.ContainsKey(parentStepId))
|
||||
{
|
||||
// If we haven't done so already, add the parent to the post steps
|
||||
_cachedEmbeddedPostSteps[parentStepId] = new Stack<Pipelines.ActionStep>();
|
||||
}
|
||||
// Clone action so we can modify the condition without affecting the original
|
||||
var clonedAction = action.Clone() as Pipelines.ActionStep;
|
||||
_cachedEmbeddedPostSteps[parentStepId].Push(clonedAction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Legacy (non-batched) action resolution. Each composite resolves its
|
||||
/// sub-actions individually, with no cross-depth deduplication.
|
||||
/// Used when the BatchActionResolution feature flag is disabled.
|
||||
/// </summary>
|
||||
private async Task<PrepareActionsState> PrepareActionsRecursiveLegacyAsync(IExecutionContext executionContext, PrepareActionsState state, IEnumerable<Pipelines.ActionStep> actions, Int32 depth = 0, Guid parentStepId = default(Guid), string selfRepoName = null, string selfRepoRef = null)
|
||||
{
|
||||
ArgUtil.NotNull(executionContext, nameof(executionContext));
|
||||
if (depth > Constants.CompositeActionsMaxDepth)
|
||||
{
|
||||
throw new Exception($"Composite action depth exceeded max depth {Constants.CompositeActionsMaxDepth}");
|
||||
}
|
||||
|
||||
// Resolve self-repository ($/) references before processing
|
||||
if (executionContext.Global.Variables.GetBoolean(Constants.Runner.Features.SelfRepository) == true)
|
||||
{
|
||||
if (string.IsNullOrEmpty(selfRepoName))
|
||||
{
|
||||
selfRepoName = executionContext.JobContext?.WorkflowRepository;
|
||||
selfRepoRef = executionContext.JobContext?.WorkflowSha;
|
||||
}
|
||||
ResolveSelfRepositoryReferences(executionContext, actions, selfRepoName, selfRepoRef);
|
||||
}
|
||||
|
||||
var repositoryActions = new List<Pipelines.ActionStep>();
|
||||
|
||||
foreach (var action in actions)
|
||||
@@ -196,7 +485,30 @@ namespace GitHub.Runner.Worker
|
||||
if (repositoryActions.Count > 0)
|
||||
{
|
||||
// Get the download info
|
||||
var downloadInfos = await GetDownloadInfoAsync(executionContext, repositoryActions);
|
||||
IDictionary<string, WebApi.ActionDownloadInfo> downloadInfos = null;
|
||||
Exception resolveFailure = null;
|
||||
try
|
||||
{
|
||||
downloadInfos = await GetDownloadInfoAsync(executionContext, repositoryActions);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// record the exception for telemetry, and rethrow the original exception to fail the step.
|
||||
resolveFailure = ex;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
executionContext.Global.JobTelemetry.Add(new JobTelemetry()
|
||||
{
|
||||
Type = JobTelemetryType.General,
|
||||
Message = $"resolve_download_actions_telemetry:{StringUtil.ConvertToJson(new ActionTelemetryPayload
|
||||
{
|
||||
Operation = "resolve_actions",
|
||||
Result = resolveFailure == null ? "succeeded" : resolveFailure.GetType().Name
|
||||
}, Newtonsoft.Json.Formatting.None)}"
|
||||
});
|
||||
}
|
||||
|
||||
// Download each action
|
||||
foreach (var action in repositoryActions)
|
||||
@@ -212,7 +524,29 @@ namespace GitHub.Runner.Worker
|
||||
throw new Exception($"Missing download info for {lookupKey}");
|
||||
}
|
||||
|
||||
await DownloadRepositoryActionAsync(executionContext, downloadInfo);
|
||||
Exception downloadFailure = null;
|
||||
try
|
||||
{
|
||||
await DownloadRepositoryActionAsync(executionContext, downloadInfo);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// record the exception for telemetry, and rethrow the original exception to fail the step.
|
||||
downloadFailure = ex;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
executionContext.Global.JobTelemetry.Add(new JobTelemetry()
|
||||
{
|
||||
Type = JobTelemetryType.General,
|
||||
Message = $"resolve_download_actions_telemetry:{StringUtil.ConvertToJson(new ActionTelemetryPayload
|
||||
{
|
||||
Operation = "download_action",
|
||||
Result = downloadFailure == null ? "succeeded" : downloadFailure.GetType().Name
|
||||
}, Newtonsoft.Json.Formatting.None)}"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// More preparation based on content in the repository (action.yml)
|
||||
@@ -247,7 +581,17 @@ namespace GitHub.Runner.Worker
|
||||
}
|
||||
else if (setupInfo != null && setupInfo.Steps != null && setupInfo.Steps.Count > 0)
|
||||
{
|
||||
state = await PrepareActionsRecursiveAsync(executionContext, state, setupInfo.Steps, depth + 1, action.Id);
|
||||
// Propagate parent's repo context for nested self-repository resolution
|
||||
var parentRef = action.Reference as Pipelines.RepositoryPathReference;
|
||||
var childRepoName = selfRepoName;
|
||||
var childRepoRef = selfRepoRef;
|
||||
if (parentRef != null &&
|
||||
string.Equals(parentRef.RepositoryType, Pipelines.RepositoryTypes.GitHub, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
childRepoName = parentRef.Name;
|
||||
childRepoRef = parentRef.Ref;
|
||||
}
|
||||
state = await PrepareActionsRecursiveLegacyAsync(executionContext, state, setupInfo.Steps, depth + 1, action.Id, childRepoName, childRepoRef);
|
||||
}
|
||||
var repoAction = action.Reference as Pipelines.RepositoryPathReference;
|
||||
if (repoAction.RepositoryType != Pipelines.PipelineConstants.SelfAlias)
|
||||
@@ -360,6 +704,12 @@ namespace GitHub.Runner.Worker
|
||||
actionDirectory = Path.Combine(actionDirectory, repoAction.Path);
|
||||
}
|
||||
}
|
||||
else if (string.Equals(repoAction.RepositoryType, Pipelines.PipelineConstants.SelfRepositoryAlias, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Unresolved self-repository reference at load time — this
|
||||
// shouldn't happen but guard against NRE if it does.
|
||||
throw new InvalidOperationException($"Self-repository reference '$/{repoAction.Path}' was not resolved before LoadAction. Ensure the '{Constants.Runner.Features.SelfRepository}' feature flag is enabled.");
|
||||
}
|
||||
else
|
||||
{
|
||||
actionDirectory = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Actions), repoAction.Name.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar), repoAction.Ref);
|
||||
@@ -495,6 +845,27 @@ namespace GitHub.Runner.Worker
|
||||
_cachedEmbeddedStepIds[action.Id].Add(guid);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve self-repository refs in composite steps at load time.
|
||||
// During setup, resolution happens on a separate copy of these
|
||||
// step objects. At runtime, action.yml is re-parsed, producing
|
||||
// fresh self-repository refs that need resolution here.
|
||||
// When the parent is a dot-slash (self local-workspace) action,
|
||||
// repoAction.Name/Ref are null — fall back to workflow context.
|
||||
if (executionContext.Global.Variables.GetBoolean(Constants.Runner.Features.SelfRepository) == true)
|
||||
{
|
||||
var parentName = repoAction.Name ?? executionContext.JobContext?.WorkflowRepository;
|
||||
var parentRef = repoAction.Ref ?? executionContext.JobContext?.WorkflowSha;
|
||||
ResolveSelfRepositoryReferences(executionContext, compositeAction.Steps, parentName, parentRef);
|
||||
if (compositeAction.PreSteps != null)
|
||||
{
|
||||
ResolveSelfRepositoryReferences(executionContext, compositeAction.PreSteps, parentName, parentRef);
|
||||
}
|
||||
if (compositeAction.PostSteps != null)
|
||||
{
|
||||
ResolveSelfRepositoryReferences(executionContext, compositeAction.PostSteps, parentName, parentRef);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -678,6 +1049,11 @@ namespace GitHub.Runner.Worker
|
||||
return new Dictionary<string, WebApi.ActionDownloadInfo>();
|
||||
}
|
||||
|
||||
// Pass lockfile dependencies to Launch when present, so it can
|
||||
// perform ref-scoped policy matching with the original refs.
|
||||
var deps = executionContext.Global.ActionsDependencies;
|
||||
IList<string> dependencies = (deps != null && deps.Count > 0) ? deps : null;
|
||||
|
||||
// Resolve download info
|
||||
var launchServer = HostContext.GetService<ILaunchServer>();
|
||||
var jobServer = HostContext.GetService<IJobServer>();
|
||||
@@ -689,7 +1065,7 @@ namespace GitHub.Runner.Worker
|
||||
if (MessageUtil.IsRunServiceJob(executionContext.Global.Variables.Get(Constants.Variables.System.JobRequestType)))
|
||||
{
|
||||
var displayHelpfulActionsDownloadErrors = executionContext.Global.Variables.GetBoolean(Constants.Runner.Features.DisplayHelpfulActionsDownloadErrors) ?? false;
|
||||
actionDownloadInfos = await launchServer.ResolveActionsDownloadInfoAsync(executionContext.Global.Plan.PlanId, executionContext.Root.Id, new WebApi.ActionReferenceList { Actions = actionReferences }, executionContext.CancellationToken, displayHelpfulActionsDownloadErrors);
|
||||
actionDownloadInfos = await launchServer.ResolveActionsDownloadInfoAsync(executionContext.Global.Plan.PlanId, executionContext.Root.Id, new WebApi.ActionReferenceList { Actions = actionReferences, Dependencies = dependencies }, executionContext.CancellationToken, displayHelpfulActionsDownloadErrors);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -754,6 +1130,56 @@ namespace GitHub.Runner.Worker
|
||||
return actionDownloadInfos.Actions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Only resolves actions not already in resolvedDownloadInfos.
|
||||
/// Results are cached for reuse at deeper recursion levels.
|
||||
/// </summary>
|
||||
private async Task ResolveNewActionsAsync(IExecutionContext executionContext, List<Pipelines.ActionStep> actions, Dictionary<string, WebApi.ActionDownloadInfo> resolvedDownloadInfos)
|
||||
{
|
||||
var actionsToResolve = new List<Pipelines.ActionStep>();
|
||||
var pendingKeys = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var action in actions)
|
||||
{
|
||||
var lookupKey = GetDownloadInfoLookupKey(action);
|
||||
if (!string.IsNullOrEmpty(lookupKey) && !resolvedDownloadInfos.ContainsKey(lookupKey) && pendingKeys.Add(lookupKey))
|
||||
{
|
||||
actionsToResolve.Add(action);
|
||||
}
|
||||
}
|
||||
|
||||
if (actionsToResolve.Count > 0)
|
||||
{
|
||||
IDictionary<string, WebApi.ActionDownloadInfo> downloadInfos = null;
|
||||
Exception resolveFailure = null;
|
||||
try
|
||||
{
|
||||
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)}"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DownloadRepositoryActionAsync(IExecutionContext executionContext, WebApi.ActionDownloadInfo downloadInfo)
|
||||
{
|
||||
Trace.Entering();
|
||||
@@ -818,7 +1244,7 @@ namespace GitHub.Runner.Worker
|
||||
try
|
||||
{
|
||||
Trace.Info($"Found unpacked action directory '{cacheDirectory}' in cache directory '{actionArchiveCacheDir}'");
|
||||
|
||||
|
||||
// repository archive from github always contains a nested folder
|
||||
var nestedDirectories = new DirectoryInfo(cacheDirectory).GetDirectories();
|
||||
if (nestedDirectories.Length != 1)
|
||||
@@ -832,14 +1258,14 @@ namespace GitHub.Runner.Worker
|
||||
IOUtil.DeleteDirectory(destDirectory, executionContext.CancellationToken);
|
||||
IOUtil.CreateSymbolicLink(destDirectory, nestedDirectories[0].FullName);
|
||||
}
|
||||
|
||||
|
||||
executionContext.Debug($"Created symlink from cached directory '{cacheDirectory}' to '{destDirectory}'");
|
||||
executionContext.Global.JobTelemetry.Add(new JobTelemetry()
|
||||
{
|
||||
Type = JobTelemetryType.General,
|
||||
Message = $"Action archive cache usage: {downloadInfo.ResolvedNameWithOwner}@{downloadInfo.ResolvedSha} use cache {useActionArchiveCache} has cache {hasActionArchiveCache} via symlink"
|
||||
});
|
||||
|
||||
|
||||
Trace.Info("Finished getting action repository.");
|
||||
return;
|
||||
}
|
||||
@@ -874,12 +1300,6 @@ namespace GitHub.Runner.Worker
|
||||
}
|
||||
}
|
||||
|
||||
executionContext.Global.JobTelemetry.Add(new JobTelemetry()
|
||||
{
|
||||
Type = JobTelemetryType.General,
|
||||
Message = $"Action archive cache usage: {downloadInfo.ResolvedNameWithOwner}@{downloadInfo.ResolvedSha} use cache {useActionArchiveCache} has cache {hasActionArchiveCache}"
|
||||
});
|
||||
|
||||
if (!useActionArchiveCache)
|
||||
{
|
||||
await DownloadRepositoryArchive(executionContext, link, downloadInfo.Authentication?.Token, archiveFile);
|
||||
@@ -888,6 +1308,13 @@ namespace GitHub.Runner.Worker
|
||||
var stagingDirectory = Path.Combine(tempDirectory, "_staging");
|
||||
Directory.CreateDirectory(stagingDirectory);
|
||||
|
||||
var fileInfo = new FileInfo(archiveFile);
|
||||
executionContext.Global.JobTelemetry.Add(new JobTelemetry()
|
||||
{
|
||||
Type = JobTelemetryType.General,
|
||||
Message = $"Action archive cache usage: {downloadInfo.ResolvedNameWithOwner}@{downloadInfo.ResolvedSha} use cache {useActionArchiveCache} has cache {hasActionArchiveCache} size {fileInfo.Length} bytes"
|
||||
});
|
||||
|
||||
#if OS_WINDOWS
|
||||
try
|
||||
{
|
||||
@@ -925,7 +1352,6 @@ namespace GitHub.Runner.Worker
|
||||
int exitCode = await processInvoker.ExecuteAsync(stagingDirectory, tar, $"-xzf \"{archiveFile}\"", null, executionContext.CancellationToken);
|
||||
if (exitCode != 0)
|
||||
{
|
||||
var fileInfo = new FileInfo(archiveFile);
|
||||
var sha256hash = await IOUtil.GetFileContentSha256HashAsync(archiveFile);
|
||||
throw new InvalidActionArchiveException($"Can't use 'tar -xzf' extract archive file: {archiveFile} (SHA256 '{sha256hash}', size '{fileInfo.Length}' bytes, tar outputs '{string.Join(' ', tarOutputs)}'). Action being checked out: {downloadInfo.NameWithOwner}@{downloadInfo.Ref}. return code: {exitCode}.");
|
||||
}
|
||||
@@ -975,6 +1401,12 @@ namespace GitHub.Runner.Worker
|
||||
|
||||
private string GetWatermarkFilePath(string directory) => directory + ".completed";
|
||||
|
||||
private sealed class ActionTelemetryPayload
|
||||
{
|
||||
public string Operation { get; set; }
|
||||
public string Result { get; set; }
|
||||
}
|
||||
|
||||
private ActionSetupInfo PrepareRepositoryActionAsync(IExecutionContext executionContext, Pipelines.ActionStep repositoryAction)
|
||||
{
|
||||
var repositoryReference = repositoryAction.Reference as Pipelines.RepositoryPathReference;
|
||||
@@ -1113,6 +1545,47 @@ namespace GitHub.Runner.Worker
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves self-reference ($/) references by mutating them in-place
|
||||
/// to standard GitHub repository references with the containing repo's
|
||||
/// name and ref.
|
||||
/// </summary>
|
||||
private void ResolveSelfRepositoryReferences(IExecutionContext executionContext, IEnumerable<Pipelines.ActionStep> actions, string repoName, string repoRef)
|
||||
{
|
||||
if (string.IsNullOrEmpty(repoName) || string.IsNullOrEmpty(repoRef))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var action in actions)
|
||||
{
|
||||
if (action.Reference.Type != Pipelines.ActionSourceType.Repository)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var repoAction = action.Reference as Pipelines.RepositoryPathReference;
|
||||
if (!string.Equals(repoAction.RepositoryType, Pipelines.PipelineConstants.SelfRepositoryAlias, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Trace.Info($"Resolving self-repository reference reference '$/{repoAction.Path}' to '{repoName}/{repoAction.Path}@{repoRef}'");
|
||||
executionContext.Debug($"Resolving $/{repoAction.Path} → {repoName}/{repoAction.Path}@{repoRef}");
|
||||
|
||||
repoAction.RepositoryType = Pipelines.RepositoryTypes.GitHub;
|
||||
repoAction.Name = repoName;
|
||||
repoAction.Ref = repoRef;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// If this is a reusable workflow job, ensure the workflow repo tarball
|
||||
/// is downloaded so self.workspace resolves to a real path on disk.
|
||||
/// Always downloads for reusable workflows when the feature flag is on,
|
||||
/// since step expressions are already expanded by the server and can't
|
||||
/// be scanned for self.* usage.
|
||||
/// </summary>
|
||||
private static string GetDownloadInfoLookupKey(Pipelines.ActionStep action)
|
||||
{
|
||||
if (action.Reference.Type != Pipelines.ActionSourceType.Repository)
|
||||
@@ -1128,6 +1601,11 @@ namespace GitHub.Runner.Worker
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.Equals(repositoryReference.RepositoryType, Pipelines.PipelineConstants.SelfRepositoryAlias, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"Unable to resolve self-reference '$/'. This can occur when the server does not support this syntax, the feature flag is disabled, or the workflow context (repository/SHA) is unavailable.");
|
||||
}
|
||||
|
||||
if (!string.Equals(repositoryReference.RepositoryType, Pipelines.RepositoryTypes.GitHub, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new NotSupportedException(repositoryReference.RepositoryType);
|
||||
@@ -1138,16 +1616,29 @@ namespace GitHub.Runner.Worker
|
||||
return $"{repositoryReference.Name}@{repositoryReference.Ref}";
|
||||
}
|
||||
|
||||
private AuthenticationHeaderValue CreateAuthHeader(string token)
|
||||
private AuthenticationHeaderValue CreateAuthHeader(IExecutionContext executionContext, string downloadUrl, string token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(token))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var base64EncodingToken = Convert.ToBase64String(Encoding.UTF8.GetBytes($"x-access-token:{token}"));
|
||||
HostContext.SecretMasker.AddValue(base64EncodingToken);
|
||||
return new AuthenticationHeaderValue("Basic", base64EncodingToken);
|
||||
if (executionContext.Global.Variables.GetBoolean(Constants.Runner.Features.UseBearerTokenForCodeload) == true &&
|
||||
Uri.TryCreate(downloadUrl, UriKind.Absolute, out var parsedUrl) &&
|
||||
!string.IsNullOrEmpty(parsedUrl?.Host) &&
|
||||
!string.IsNullOrEmpty(parsedUrl?.PathAndQuery) &&
|
||||
(parsedUrl.Host.StartsWith("codeload.", StringComparison.OrdinalIgnoreCase) || parsedUrl.PathAndQuery.StartsWith("/_codeload/", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
Trace.Info("Using Bearer token for action archive download directly to codeload.");
|
||||
return new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
else
|
||||
{
|
||||
Trace.Info("Using Basic token for action archive download.");
|
||||
var base64EncodingToken = Convert.ToBase64String(Encoding.UTF8.GetBytes($"x-access-token:{token}"));
|
||||
HostContext.SecretMasker.AddValue(base64EncodingToken);
|
||||
return new AuthenticationHeaderValue("Basic", base64EncodingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DownloadRepositoryArchive(IExecutionContext executionContext, string downloadUrl, string downloadAuthToken, string archiveFile)
|
||||
@@ -1157,93 +1648,112 @@ namespace GitHub.Runner.Worker
|
||||
|
||||
// Allow up to 20 * 60s for any action to be downloaded from github graph.
|
||||
int timeoutSeconds = 20 * 60;
|
||||
while (retryCount < 3)
|
||||
try
|
||||
{
|
||||
string requestId = string.Empty;
|
||||
using (var actionDownloadTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds)))
|
||||
using (var actionDownloadCancellation = CancellationTokenSource.CreateLinkedTokenSource(actionDownloadTimeout.Token, executionContext.CancellationToken))
|
||||
while (retryCount < 3)
|
||||
{
|
||||
try
|
||||
string requestId = string.Empty;
|
||||
using (var actionDownloadTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds)))
|
||||
using (var actionDownloadCancellation = CancellationTokenSource.CreateLinkedTokenSource(actionDownloadTimeout.Token, executionContext.CancellationToken))
|
||||
{
|
||||
//open zip stream in async mode
|
||||
using (FileStream fs = new(archiveFile, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: _defaultFileStreamBufferSize, useAsync: true))
|
||||
using (var httpClientHandler = HostContext.CreateHttpClientHandler())
|
||||
using (var httpClient = new HttpClient(httpClientHandler))
|
||||
try
|
||||
{
|
||||
httpClient.DefaultRequestHeaders.Authorization = CreateAuthHeader(downloadAuthToken);
|
||||
|
||||
httpClient.DefaultRequestHeaders.UserAgent.AddRange(HostContext.UserAgents);
|
||||
using (var response = await httpClient.GetAsync(downloadUrl))
|
||||
//open zip stream in async mode
|
||||
using (FileStream fs = new(archiveFile, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: _defaultFileStreamBufferSize, useAsync: true))
|
||||
using (var httpClientHandler = HostContext.CreateHttpClientHandler())
|
||||
using (var httpClient = new HttpClient(httpClientHandler))
|
||||
{
|
||||
requestId = UrlUtil.GetGitHubRequestId(response.Headers);
|
||||
if (!string.IsNullOrEmpty(requestId))
|
||||
{
|
||||
Trace.Info($"Request URL: {downloadUrl} X-GitHub-Request-Id: {requestId} Http Status: {response.StatusCode}");
|
||||
}
|
||||
httpClient.DefaultRequestHeaders.Authorization = CreateAuthHeader(executionContext, downloadUrl, downloadAuthToken);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
httpClient.DefaultRequestHeaders.UserAgent.AddRange(HostContext.UserAgents);
|
||||
using (var response = await httpClient.GetAsync(downloadUrl))
|
||||
{
|
||||
using (var result = await response.Content.ReadAsStreamAsync())
|
||||
requestId = UrlUtil.GetGitHubRequestId(response.Headers);
|
||||
if (!string.IsNullOrEmpty(requestId))
|
||||
{
|
||||
await result.CopyToAsync(fs, _defaultCopyBufferSize, actionDownloadCancellation.Token);
|
||||
await fs.FlushAsync(actionDownloadCancellation.Token);
|
||||
|
||||
// download succeed, break out the retry loop.
|
||||
break;
|
||||
Trace.Info($"Request URL: {downloadUrl} X-GitHub-Request-Id: {requestId} Http Status: {response.StatusCode}");
|
||||
}
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
using (var result = await response.Content.ReadAsStreamAsync())
|
||||
{
|
||||
await result.CopyToAsync(fs, _defaultCopyBufferSize, actionDownloadCancellation.Token);
|
||||
await fs.FlushAsync(actionDownloadCancellation.Token);
|
||||
|
||||
// download succeed, break out the retry loop.
|
||||
break;
|
||||
}
|
||||
}
|
||||
else if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
// It doesn't make sense to retry in this case, so just stop
|
||||
throw new ActionNotFoundException(new Uri(downloadUrl), requestId);
|
||||
}
|
||||
else if (response.StatusCode == HttpStatusCode.Forbidden)
|
||||
{
|
||||
// It doesn't make sense to retry in this case, so just stop
|
||||
throw new AccessDeniedException($"Access denied to '{downloadUrl}' ({requestId})");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Something else bad happened, let's go to our retry logic
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
}
|
||||
else if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
// It doesn't make sense to retry in this case, so just stop
|
||||
throw new ActionNotFoundException(new Uri(downloadUrl), requestId);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Something else bad happened, let's go to our retry logic
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (executionContext.CancellationToken.IsCancellationRequested)
|
||||
{
|
||||
Trace.Info("Action download has been cancelled.");
|
||||
throw;
|
||||
}
|
||||
catch (OperationCanceledException ex) when (!executionContext.CancellationToken.IsCancellationRequested && retryCount >= 2)
|
||||
{
|
||||
Trace.Info($"Action download final retry timeout after {timeoutSeconds} seconds.");
|
||||
throw new TimeoutException($"Action '{downloadUrl}' download has timed out. Error: {ex.Message} {requestId}");
|
||||
}
|
||||
catch (ActionNotFoundException)
|
||||
{
|
||||
Trace.Info($"The action at '{downloadUrl}' does not exist");
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) when (retryCount < 2)
|
||||
{
|
||||
retryCount++;
|
||||
Trace.Error($"Fail to download archive '{downloadUrl}' -- Attempt: {retryCount}");
|
||||
Trace.Error(ex);
|
||||
if (actionDownloadTimeout.Token.IsCancellationRequested)
|
||||
catch (OperationCanceledException) when (executionContext.CancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// action download didn't finish within timeout
|
||||
executionContext.Warning($"Action '{downloadUrl}' didn't finish download within {timeoutSeconds} seconds. {requestId}");
|
||||
Trace.Info("Action download has been cancelled.");
|
||||
throw;
|
||||
}
|
||||
else
|
||||
catch (OperationCanceledException ex) when (!executionContext.CancellationToken.IsCancellationRequested && retryCount >= 2)
|
||||
{
|
||||
executionContext.Warning($"Failed to download action '{downloadUrl}'. Error: {ex.Message} {requestId}");
|
||||
Trace.Info($"Action download final retry timeout after {timeoutSeconds} seconds.");
|
||||
throw new TimeoutException($"Action '{downloadUrl}' download has timed out. Error: {ex.Message} {requestId}");
|
||||
}
|
||||
catch (ActionNotFoundException)
|
||||
{
|
||||
Trace.Info($"The action at '{downloadUrl}' does not exist");
|
||||
throw;
|
||||
}
|
||||
catch (AccessDeniedException)
|
||||
{
|
||||
Trace.Info($"Access denied to '{downloadUrl}'");
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) when (retryCount < 2)
|
||||
{
|
||||
retryCount++;
|
||||
Trace.Error($"Fail to download archive '{downloadUrl}' -- Attempt: {retryCount}");
|
||||
Trace.Error(ex);
|
||||
if (actionDownloadTimeout.Token.IsCancellationRequested)
|
||||
{
|
||||
// action download didn't finish within timeout
|
||||
executionContext.Warning($"Action '{downloadUrl}' didn't finish download within {timeoutSeconds} seconds. {requestId}");
|
||||
}
|
||||
else
|
||||
{
|
||||
executionContext.Warning($"Failed to download action '{downloadUrl}'. Error: {ex.Message} {requestId}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (String.IsNullOrEmpty(Environment.GetEnvironmentVariable("_GITHUB_ACTION_DOWNLOAD_NO_BACKOFF")))
|
||||
{
|
||||
var backOff = BackoffTimerHelper.GetRandomBackoff(TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30));
|
||||
executionContext.Warning($"Back off {backOff.TotalSeconds} seconds before retry.");
|
||||
await Task.Delay(backOff);
|
||||
if (String.IsNullOrEmpty(Environment.GetEnvironmentVariable("_GITHUB_ACTION_DOWNLOAD_NO_BACKOFF")))
|
||||
{
|
||||
var backOff = BackoffTimerHelper.GetRandomBackoff(TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30));
|
||||
executionContext.Warning($"Back off {backOff.TotalSeconds} seconds before retry.");
|
||||
await Task.Delay(backOff);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (!(ex is AccessDeniedException) && !(ex is OperationCanceledException) && !executionContext.CancellationToken.IsCancellationRequested)
|
||||
{
|
||||
Trace.Error($"Failed to download archive '{downloadUrl}' after {retryCount + 1} attempts.");
|
||||
Trace.Error(ex);
|
||||
throw new FailedToDownloadActionException($"Failed to download archive '{downloadUrl}' after {retryCount + 1} attempts.", ex);
|
||||
}
|
||||
|
||||
ArgUtil.NotNullOrEmpty(archiveFile, nameof(archiveFile));
|
||||
executionContext.Debug($"Download '{downloadUrl}' to '{archiveFile}'");
|
||||
|
||||
@@ -316,7 +316,6 @@ namespace GitHub.Runner.Worker
|
||||
Schema = _actionManifestSchema,
|
||||
// TODO: Switch to real tracewriter for cutover
|
||||
TraceWriter = new GitHub.Actions.WorkflowParser.ObjectTemplating.EmptyTraceWriter(),
|
||||
AllowCaseFunction = false,
|
||||
};
|
||||
|
||||
// Expression values from execution context
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
@@ -315,7 +315,6 @@ namespace GitHub.Runner.Worker
|
||||
maxBytes: 10 * 1024 * 1024),
|
||||
Schema = _actionManifestSchema,
|
||||
TraceWriter = executionContext.ToTemplateTraceWriter(),
|
||||
AllowCaseFunction = false,
|
||||
};
|
||||
|
||||
// Expression values from execution context
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,8 @@ namespace GitHub.Runner.Worker.Container
|
||||
this.ContainerImage = containerImage;
|
||||
this.ContainerDisplayName = $"{container.Alias}_{Pipelines.Validation.NameValidation.Sanitize(containerImage)}_{Guid.NewGuid().ToString("N").Substring(0, 6)}";
|
||||
this.ContainerCreateOptions = container.Options;
|
||||
this.ContainerEntryPoint = container.Entrypoint;
|
||||
this.ContainerEntryPointArgs = container.Command;
|
||||
_environmentVariables = container.Environment;
|
||||
this.IsJobContainer = isJobContainer;
|
||||
this.ContainerNetworkAlias = networkAlias;
|
||||
|
||||
1815
src/Runner.Worker/Dap/DapDebugger.cs
Normal file
1815
src/Runner.Worker/Dap/DapDebugger.cs
Normal file
File diff suppressed because it is too large
Load Diff
1271
src/Runner.Worker/Dap/DapMessages.cs
Normal file
1271
src/Runner.Worker/Dap/DapMessages.cs
Normal file
File diff suppressed because it is too large
Load Diff
450
src/Runner.Worker/Dap/DapReplExecutor.cs
Normal file
450
src/Runner.Worker/Dap/DapReplExecutor.cs
Normal file
@@ -0,0 +1,450 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.DistributedTask.Pipelines.ContextData;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using GitHub.Runner.Sdk;
|
||||
using GitHub.Runner.Worker.Container;
|
||||
using GitHub.Runner.Worker.Handlers;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap
|
||||
{
|
||||
/// <summary>
|
||||
/// Executes <see cref="RunCommand"/> objects in the job's runtime context.
|
||||
///
|
||||
/// Mirrors the behavior of a normal workflow <c>run:</c> step as closely
|
||||
/// as possible by reusing the runner's existing shell-resolution logic,
|
||||
/// script fixup helpers, and process execution infrastructure.
|
||||
///
|
||||
/// Output is streamed to the debugger via DAP <c>output</c> events with
|
||||
/// secrets masked before emission.
|
||||
/// </summary>
|
||||
internal sealed class DapReplExecutor
|
||||
{
|
||||
private readonly IHostContext _hostContext;
|
||||
private readonly Action<string, string> _sendOutput;
|
||||
private readonly Tracing _trace;
|
||||
|
||||
public DapReplExecutor(IHostContext hostContext, Action<string, string> sendOutput)
|
||||
{
|
||||
_hostContext = hostContext ?? throw new ArgumentNullException(nameof(hostContext));
|
||||
_sendOutput = sendOutput ?? throw new ArgumentNullException(nameof(sendOutput));
|
||||
_trace = hostContext.GetTrace(nameof(DapReplExecutor));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a <see cref="RunCommand"/> and returns the exit code as a
|
||||
/// formatted <see cref="EvaluateResponseBody"/>.
|
||||
/// </summary>
|
||||
public async Task<EvaluateResponseBody> ExecuteRunCommandAsync(
|
||||
RunCommand command,
|
||||
IExecutionContext context,
|
||||
bool isActionStep,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (context == null)
|
||||
{
|
||||
return ErrorResult("No execution context available. The debugger must be paused at a step to run commands.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await ExecuteScriptAsync(command, context, isActionStep, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_trace.Error($"REPL run command failed ({ex.GetType().Name})");
|
||||
var maskedError = _hostContext.SecretMasker.MaskSecrets(ex.Message);
|
||||
return ErrorResult($"Command failed: {maskedError}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<EvaluateResponseBody> ExecuteScriptAsync(
|
||||
RunCommand command,
|
||||
IExecutionContext context,
|
||||
bool isActionStep,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. Resolve step host — container or host, same as ActionRunner.
|
||||
// Only action steps (user-defined run:/uses:) execute inside the
|
||||
// container. Infrastructure steps (Set up job, Initialize
|
||||
// containers, Complete job, etc.) always run on the host.
|
||||
var stepHost = CreateStepHost(context, isActionStep);
|
||||
var isContainerStepHost = stepHost is IContainerStepHost;
|
||||
|
||||
// 2. Resolve shell — same logic as ScriptHandler
|
||||
string shellCommand;
|
||||
string argFormat;
|
||||
|
||||
if (!string.IsNullOrEmpty(command.Shell))
|
||||
{
|
||||
// Explicit shell from the DSL
|
||||
var parsed = ScriptHandlerHelpers.ParseShellOptionString(command.Shell);
|
||||
shellCommand = parsed.shellCommand;
|
||||
argFormat = string.IsNullOrEmpty(parsed.shellArgs)
|
||||
? ScriptHandlerHelpers.GetScriptArgumentsFormat(shellCommand)
|
||||
: parsed.shellArgs;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Default shell — mirrors ScriptHandler platform defaults
|
||||
shellCommand = ResolveDefaultShell(context);
|
||||
argFormat = ScriptHandlerHelpers.GetScriptArgumentsFormat(shellCommand);
|
||||
}
|
||||
|
||||
_trace.Info($"Resolved REPL shell (container={isContainerStepHost})");
|
||||
|
||||
// 3. Expand ${{ }} expressions in the script body, just like
|
||||
// ActionRunner evaluates step inputs before ScriptHandler sees them
|
||||
var contents = ExpandExpressions(command.Script, context);
|
||||
contents = ScriptHandlerHelpers.FixUpScriptContents(shellCommand, contents);
|
||||
|
||||
// Write to a temp file (same pattern as ScriptHandler)
|
||||
var extension = ScriptHandlerHelpers.GetScriptFileExtension(shellCommand);
|
||||
var scriptFilePath = Path.Combine(
|
||||
_hostContext.GetDirectory(WellKnownDirectory.Temp),
|
||||
$"dap_repl_{Guid.NewGuid()}{extension}");
|
||||
|
||||
Encoding encoding = new UTF8Encoding(false);
|
||||
#if OS_WINDOWS
|
||||
contents = contents.Replace("\r\n", "\n").Replace("\n", "\r\n");
|
||||
encoding = Console.InputEncoding.CodePage != 65001
|
||||
? Console.InputEncoding
|
||||
: encoding;
|
||||
#endif
|
||||
File.WriteAllText(scriptFilePath, contents, encoding);
|
||||
|
||||
try
|
||||
{
|
||||
// 4. Resolve script path — translate for container if needed
|
||||
var resolvedPath = stepHost.ResolvePathForStepHost(context, scriptFilePath).Replace("\"", "\\\"");
|
||||
if (string.IsNullOrEmpty(argFormat) || !argFormat.Contains("{0}"))
|
||||
{
|
||||
return ErrorResult($"Invalid shell option '{shellCommand}'. Shell must be a valid built-in (bash, sh, cmd, powershell, pwsh) or a format string containing '{{0}}'");
|
||||
}
|
||||
var arguments = string.Format(argFormat, resolvedPath);
|
||||
|
||||
// 5. Resolve shell command path — for containers, use the shell
|
||||
// name directly (it will be resolved inside the container);
|
||||
// for host execution, resolve the full path on the host.
|
||||
string prependPath = string.Join(
|
||||
Path.PathSeparator.ToString(),
|
||||
Enumerable.Reverse(context.Global.PrependPath));
|
||||
var fileName = isContainerStepHost
|
||||
? shellCommand
|
||||
: WhichUtil.Which(shellCommand, false, _trace, prependPath) ?? shellCommand;
|
||||
|
||||
// 6. Build environment — merge from execution context like a real step
|
||||
var environment = BuildEnvironment(context, command.Env);
|
||||
|
||||
// 7. Handle PrependPath — mirrors Handler.AddPrependPathToEnvironment
|
||||
if (context.Global.PrependPath.Count > 0)
|
||||
{
|
||||
if (stepHost is IContainerStepHost containerHost)
|
||||
{
|
||||
containerHost.PrependPath = prependPath;
|
||||
}
|
||||
else
|
||||
{
|
||||
string taskEnvPATH;
|
||||
environment.TryGetValue(Constants.PathVariable, out taskEnvPATH);
|
||||
string originalPath = context.Global.Variables?.Get(Constants.PathVariable) ?? // Prefer a job variable.
|
||||
taskEnvPATH ?? // Then a task-environment variable.
|
||||
System.Environment.GetEnvironmentVariable(Constants.PathVariable) ?? // Then an environment variable.
|
||||
string.Empty;
|
||||
environment[Constants.PathVariable] = PathUtil.PrependPath(prependPath, originalPath);
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Resolve working directory — translate for container
|
||||
var workingDirectory = command.WorkingDirectory;
|
||||
if (string.IsNullOrEmpty(workingDirectory))
|
||||
{
|
||||
var githubContext = context.ExpressionValues.TryGetValue("github", out var gh)
|
||||
? gh as DictionaryContextData
|
||||
: null;
|
||||
var workspace = githubContext?.TryGetValue("workspace", out var ws) == true
|
||||
? (ws as StringContextData)?.Value
|
||||
: null;
|
||||
workingDirectory = workspace ?? _hostContext.GetDirectory(WellKnownDirectory.Work);
|
||||
}
|
||||
workingDirectory = stepHost.ResolvePathForStepHost(context, workingDirectory);
|
||||
|
||||
_trace.Info("Executing REPL command");
|
||||
|
||||
// Stream execution info to debugger
|
||||
SendOutput("console", $"$ {shellCommand} {command.Script.Substring(0, Math.Min(command.Script.Length, 80))}{(command.Script.Length > 80 ? "..." : "")}\n");
|
||||
|
||||
// NOTE: When container hooks are enabled, ContainerStepHost routes
|
||||
// execution through IContainerHookManager which does not raise
|
||||
// OutputDataReceived/ErrorDataReceived events. Output will not be
|
||||
// streamed to the debug console in that mode.
|
||||
if (isContainerStepHost && FeatureManager.IsContainerHooksEnabled(context.Global?.Variables))
|
||||
{
|
||||
const string hookWarning = "Container hooks are enabled. REPL output will not be streamed to the debug console for this command.";
|
||||
_trace.Warning(hookWarning);
|
||||
SendOutput("stderr", hookWarning + "\n");
|
||||
}
|
||||
|
||||
// 9. Execute via IStepHost — handles docker exec for containers,
|
||||
// direct process execution for host, and container hooks
|
||||
stepHost.OutputDataReceived += (sender, args) =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(args.Data))
|
||||
{
|
||||
var masked = _hostContext.SecretMasker.MaskSecrets(args.Data);
|
||||
SendOutput("stdout", masked + "\n");
|
||||
}
|
||||
};
|
||||
|
||||
stepHost.ErrorDataReceived += (sender, args) =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(args.Data))
|
||||
{
|
||||
var masked = _hostContext.SecretMasker.MaskSecrets(args.Data);
|
||||
SendOutput("stderr", masked + "\n");
|
||||
}
|
||||
};
|
||||
|
||||
int exitCode = await stepHost.ExecuteAsync(
|
||||
context: context,
|
||||
workingDirectory: workingDirectory,
|
||||
fileName: fileName,
|
||||
arguments: arguments,
|
||||
environment: environment,
|
||||
requireExitCodeZero: false,
|
||||
outputEncoding: null,
|
||||
killProcessOnCancel: true,
|
||||
inheritConsoleHandler: false,
|
||||
standardInInput: null,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
_trace.Info($"REPL command exited with code {exitCode}");
|
||||
|
||||
// 10. Return only the exit code summary (output was already streamed)
|
||||
return new EvaluateResponseBody
|
||||
{
|
||||
Result = exitCode == 0 ? $"(exit code: {exitCode})" : $"Process completed with exit code {exitCode}.",
|
||||
Type = exitCode == 0 ? "string" : "error",
|
||||
VariablesReference = 0
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Clean up temp script file
|
||||
try { File.Delete(scriptFilePath); }
|
||||
catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the appropriate <see cref="IStepHost"/> for the current
|
||||
/// execution context, mirroring how <see cref="ActionRunner"/> decides
|
||||
/// between host and container execution.
|
||||
///
|
||||
/// Only action steps (user-defined run:/uses: steps) run inside the
|
||||
/// job container. Infrastructure steps like "Set up job", "Initialize
|
||||
/// containers", "Stop containers", and "Complete job" always execute
|
||||
/// on the host regardless of whether a container is configured.
|
||||
/// </summary>
|
||||
internal IStepHost CreateStepHost(IExecutionContext context, bool isActionStep)
|
||||
{
|
||||
if (!isActionStep)
|
||||
{
|
||||
_trace.Info("Creating DefaultStepHost for REPL execution (infrastructure step)");
|
||||
return _hostContext.CreateService<IDefaultStepHost>();
|
||||
}
|
||||
|
||||
var container = context?.Global?.Container;
|
||||
if (container != null)
|
||||
{
|
||||
// Container hooks don't always set ContainerId, but the container
|
||||
// step host handles that internally
|
||||
var hooksEnabled = FeatureManager.IsContainerHooksEnabled(context.Global?.Variables);
|
||||
if (hooksEnabled || !string.IsNullOrEmpty(container.ContainerId))
|
||||
{
|
||||
_trace.Info("Creating ContainerStepHost for REPL execution");
|
||||
var containerStepHost = _hostContext.CreateService<IContainerStepHost>();
|
||||
containerStepHost.Container = container;
|
||||
return containerStepHost;
|
||||
}
|
||||
}
|
||||
|
||||
_trace.Info("Creating DefaultStepHost for REPL execution");
|
||||
return _hostContext.CreateService<IDefaultStepHost>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Expands <c>${{ }}</c> expressions in the input string using the
|
||||
/// runner's template evaluator — the same evaluation path that processes
|
||||
/// step inputs before <see cref="ScriptHandler"/> runs them.
|
||||
///
|
||||
/// Each <c>${{ expr }}</c> occurrence is individually evaluated and
|
||||
/// replaced with its masked string result, mirroring the semantics of
|
||||
/// expression interpolation in a workflow <c>run:</c> step body.
|
||||
/// </summary>
|
||||
internal string ExpandExpressions(string input, IExecutionContext context)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input) || !input.Contains("${{"))
|
||||
{
|
||||
return input ?? string.Empty;
|
||||
}
|
||||
|
||||
var result = new StringBuilder();
|
||||
int pos = 0;
|
||||
|
||||
while (pos < input.Length)
|
||||
{
|
||||
var start = input.IndexOf("${{", pos, StringComparison.Ordinal);
|
||||
if (start < 0)
|
||||
{
|
||||
result.Append(input, pos, input.Length - pos);
|
||||
break;
|
||||
}
|
||||
|
||||
// Append the literal text before the expression
|
||||
result.Append(input, pos, start - pos);
|
||||
|
||||
var end = input.IndexOf("}}", start + 3, StringComparison.Ordinal);
|
||||
if (end < 0)
|
||||
{
|
||||
// Unterminated expression — keep literal
|
||||
result.Append(input, start, input.Length - start);
|
||||
break;
|
||||
}
|
||||
|
||||
var expr = input.Substring(start + 3, end - start - 3).Trim();
|
||||
end += 2; // skip past "}}"
|
||||
|
||||
// Evaluate the expression
|
||||
try
|
||||
{
|
||||
var templateEvaluator = context.ToPipelineTemplateEvaluator();
|
||||
var token = new GitHub.DistributedTask.ObjectTemplating.Tokens.BasicExpressionToken(
|
||||
null, null, null, expr);
|
||||
var evaluated = templateEvaluator.EvaluateStepDisplayName(
|
||||
token,
|
||||
context.ExpressionValues,
|
||||
context.ExpressionFunctions);
|
||||
result.Append(_hostContext.SecretMasker.MaskSecrets(evaluated ?? string.Empty));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_trace.Warning($"Expression expansion failed ({ex.GetType().Name})");
|
||||
// Keep the original expression literal on failure
|
||||
result.Append(input, start, end - start);
|
||||
}
|
||||
|
||||
pos = end;
|
||||
}
|
||||
|
||||
return result.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the default shell the same way <see cref="ScriptHandler"/>
|
||||
/// does: check job defaults, then fall back to platform default.
|
||||
/// </summary>
|
||||
internal string ResolveDefaultShell(IExecutionContext context)
|
||||
{
|
||||
// Check job defaults
|
||||
if (context.Global?.JobDefaults != null &&
|
||||
context.Global.JobDefaults.TryGetValue("run", out var runDefaults) &&
|
||||
runDefaults.TryGetValue("shell", out var defaultShell) &&
|
||||
!string.IsNullOrEmpty(defaultShell))
|
||||
{
|
||||
_trace.Info("Using job default shell");
|
||||
return defaultShell;
|
||||
}
|
||||
|
||||
#if OS_WINDOWS
|
||||
string prependPath = string.Join(
|
||||
Path.PathSeparator.ToString(),
|
||||
context.Global?.PrependPath != null ? Enumerable.Reverse(context.Global.PrependPath) : Array.Empty<string>());
|
||||
var pwshPath = WhichUtil.Which("pwsh", false, _trace, prependPath);
|
||||
return !string.IsNullOrEmpty(pwshPath) ? "pwsh" : "powershell";
|
||||
#else
|
||||
return "sh";
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges the job context environment with any REPL-specific overrides.
|
||||
/// </summary>
|
||||
internal Dictionary<string, string> BuildEnvironment(
|
||||
IExecutionContext context,
|
||||
Dictionary<string, string> replEnv)
|
||||
{
|
||||
var env = new Dictionary<string, string>(VarUtil.EnvironmentVariableKeyComparer);
|
||||
|
||||
// Pull environment from the execution context (same as ActionRunner)
|
||||
if (context.ExpressionValues.TryGetValue("env", out var envData))
|
||||
{
|
||||
if (envData is DictionaryContextData dictEnv)
|
||||
{
|
||||
foreach (var pair in dictEnv)
|
||||
{
|
||||
if (pair.Value is StringContextData str)
|
||||
{
|
||||
env[pair.Key] = str.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (envData is CaseSensitiveDictionaryContextData csEnv)
|
||||
{
|
||||
foreach (var pair in csEnv)
|
||||
{
|
||||
if (pair.Value is StringContextData str)
|
||||
{
|
||||
env[pair.Key] = str.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Expose runtime context variables to the environment (GITHUB_*, RUNNER_*, etc.)
|
||||
foreach (var ctxPair in context.ExpressionValues)
|
||||
{
|
||||
if (ctxPair.Value is IEnvironmentContextData runtimeContext && runtimeContext != null)
|
||||
{
|
||||
foreach (var rtEnv in runtimeContext.GetRuntimeEnvironmentVariables())
|
||||
{
|
||||
env[rtEnv.Key] = rtEnv.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply REPL-specific overrides last (so they win),
|
||||
// expanding any ${{ }} expressions in the values
|
||||
if (replEnv != null)
|
||||
{
|
||||
foreach (var pair in replEnv)
|
||||
{
|
||||
env[pair.Key] = ExpandExpressions(pair.Value, context);
|
||||
}
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
private void SendOutput(string category, string text)
|
||||
{
|
||||
_sendOutput(category, text);
|
||||
}
|
||||
|
||||
private static EvaluateResponseBody ErrorResult(string message)
|
||||
{
|
||||
return new EvaluateResponseBody
|
||||
{
|
||||
Result = message,
|
||||
Type = "error",
|
||||
VariablesReference = 0
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
411
src/Runner.Worker/Dap/DapReplParser.cs
Normal file
411
src/Runner.Worker/Dap/DapReplParser.cs
Normal file
@@ -0,0 +1,411 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap
|
||||
{
|
||||
/// <summary>
|
||||
/// Base type for all REPL DSL commands.
|
||||
/// </summary>
|
||||
internal abstract class DapReplCommand
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>help</c> or <c>help("run")</c>
|
||||
/// </summary>
|
||||
internal sealed class HelpCommand : DapReplCommand
|
||||
{
|
||||
public string Topic { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>run("echo hello")</c> or
|
||||
/// <c>run("echo hello", shell: "bash", env: { FOO: "bar" }, working_directory: "/tmp")</c>
|
||||
/// </summary>
|
||||
internal sealed class RunCommand : DapReplCommand
|
||||
{
|
||||
public string Script { get; set; }
|
||||
public string Shell { get; set; }
|
||||
public Dictionary<string, string> Env { get; set; }
|
||||
public string WorkingDirectory { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses REPL input into typed <see cref="DapReplCommand"/> objects.
|
||||
///
|
||||
/// Grammar (intentionally minimal — extend as the DSL grows):
|
||||
/// <code>
|
||||
/// help → HelpCommand { Topic = null }
|
||||
/// help("run") → HelpCommand { Topic = "run" }
|
||||
/// run("script body") → RunCommand { Script = "script body" }
|
||||
/// run("script", shell: "bash") → RunCommand { Shell = "bash" }
|
||||
/// run("script", env: { K: "V" }) → RunCommand { Env = { K → V } }
|
||||
/// run("script", working_directory: "p")→ RunCommand { WorkingDirectory = "p" }
|
||||
/// </code>
|
||||
///
|
||||
/// Parsing is intentionally hand-rolled rather than regex-based so it can
|
||||
/// handle nested braces, quoted strings with escapes, and grow to support
|
||||
/// future commands without accumulating regex complexity.
|
||||
/// </summary>
|
||||
internal static class DapReplParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Attempts to parse REPL input into a command. Returns null if the
|
||||
/// input does not match any known DSL command (i.e. it should be
|
||||
/// treated as an expression instead).
|
||||
/// </summary>
|
||||
internal static DapReplCommand TryParse(string input, out string error)
|
||||
{
|
||||
error = null;
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = input.Trim();
|
||||
|
||||
// help / help("topic")
|
||||
if (trimmed.Equals("help", StringComparison.OrdinalIgnoreCase) ||
|
||||
trimmed.StartsWith("help(", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ParseHelp(trimmed, out error);
|
||||
}
|
||||
|
||||
// run("...")
|
||||
if (trimmed.StartsWith("run(", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ParseRun(trimmed, out error);
|
||||
}
|
||||
|
||||
// Not a DSL command
|
||||
return null;
|
||||
}
|
||||
|
||||
internal static string GetGeneralHelp()
|
||||
{
|
||||
return """
|
||||
Actions Debug Console
|
||||
|
||||
Commands:
|
||||
help Show this help
|
||||
help("run") Show help for the run command
|
||||
run("script") Execute a script (like a workflow run step)
|
||||
|
||||
Anything else is evaluated as a GitHub Actions expression.
|
||||
Example: github.repository
|
||||
Example: ${{ github.event_name }}
|
||||
|
||||
""";
|
||||
}
|
||||
|
||||
internal static string GetRunHelp()
|
||||
{
|
||||
return """
|
||||
run command — execute a script in the job context
|
||||
|
||||
Usage:
|
||||
run("echo hello")
|
||||
run("echo $FOO", shell: "bash")
|
||||
run("echo $FOO", env: { FOO: "bar" })
|
||||
run("ls", working_directory: "/tmp")
|
||||
run("echo $X", shell: "bash", env: { X: "1" }, working_directory: "/tmp")
|
||||
|
||||
Options:
|
||||
shell: Shell to use (default: job default, e.g. bash)
|
||||
env: Extra environment variables as { KEY: "value" }
|
||||
working_directory: Working directory for the command
|
||||
|
||||
Behavior:
|
||||
- Equivalent to a workflow `run:` step
|
||||
- Expressions in the script body are expanded (${{ ... }})
|
||||
- Output is streamed in real time and secrets are masked
|
||||
|
||||
""";
|
||||
}
|
||||
|
||||
#region Parsers
|
||||
|
||||
private static HelpCommand ParseHelp(string input, out string error)
|
||||
{
|
||||
error = null;
|
||||
if (input.Equals("help", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new HelpCommand();
|
||||
}
|
||||
|
||||
// help("topic")
|
||||
var inner = ExtractParenthesizedArgs(input, "help", out error);
|
||||
if (error != null) return null;
|
||||
|
||||
var topic = ExtractQuotedString(inner.Trim(), out error);
|
||||
if (error != null) return null;
|
||||
|
||||
return new HelpCommand { Topic = topic };
|
||||
}
|
||||
|
||||
private static RunCommand ParseRun(string input, out string error)
|
||||
{
|
||||
error = null;
|
||||
|
||||
var inner = ExtractParenthesizedArgs(input, "run", out error);
|
||||
if (error != null) return null;
|
||||
|
||||
// Split into argument list respecting quotes and braces
|
||||
var args = SplitArguments(inner, out error);
|
||||
if (error != null) return null;
|
||||
if (args.Count == 0)
|
||||
{
|
||||
error = "run() requires a script argument. Example: run(\"echo hello\")";
|
||||
return null;
|
||||
}
|
||||
|
||||
// First arg must be the script body (a quoted string)
|
||||
var script = ExtractQuotedString(args[0].Trim(), out error);
|
||||
if (error != null)
|
||||
{
|
||||
error = $"First argument to run() must be a quoted string. {error}";
|
||||
return null;
|
||||
}
|
||||
|
||||
var cmd = new RunCommand { Script = script };
|
||||
|
||||
// Parse remaining keyword arguments
|
||||
for (int i = 1; i < args.Count; i++)
|
||||
{
|
||||
var kv = args[i].Trim();
|
||||
var colonIdx = kv.IndexOf(':');
|
||||
if (colonIdx <= 0)
|
||||
{
|
||||
error = $"Expected keyword argument (e.g. shell: \"bash\"), got: {kv}";
|
||||
return null;
|
||||
}
|
||||
|
||||
var key = kv.Substring(0, colonIdx).Trim();
|
||||
var value = kv.Substring(colonIdx + 1).Trim();
|
||||
|
||||
switch (key.ToLowerInvariant())
|
||||
{
|
||||
case "shell":
|
||||
cmd.Shell = ExtractQuotedString(value, out error);
|
||||
if (error != null) { error = $"shell: {error}"; return null; }
|
||||
break;
|
||||
|
||||
case "working_directory":
|
||||
cmd.WorkingDirectory = ExtractQuotedString(value, out error);
|
||||
if (error != null) { error = $"working_directory: {error}"; return null; }
|
||||
break;
|
||||
|
||||
case "env":
|
||||
cmd.Env = ParseEnvBlock(value, out error);
|
||||
if (error != null) { error = $"env: {error}"; return null; }
|
||||
break;
|
||||
|
||||
default:
|
||||
error = $"Unknown option: {key}. Valid options: shell, env, working_directory";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Low-level parsing helpers
|
||||
|
||||
/// <summary>
|
||||
/// Given "cmd(...)" returns the inner content between the outer parens.
|
||||
/// </summary>
|
||||
private static string ExtractParenthesizedArgs(string input, string prefix, out string error)
|
||||
{
|
||||
error = null;
|
||||
var start = prefix.Length; // skip "cmd"
|
||||
if (start >= input.Length || input[start] != '(')
|
||||
{
|
||||
error = $"Expected '(' after {prefix}";
|
||||
return null;
|
||||
}
|
||||
|
||||
if (input[input.Length - 1] != ')')
|
||||
{
|
||||
error = $"Expected ')' at end of {prefix}(...)";
|
||||
return null;
|
||||
}
|
||||
|
||||
return input.Substring(start + 1, input.Length - start - 2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts a double-quoted string value, handling escaped quotes.
|
||||
/// </summary>
|
||||
internal static string ExtractQuotedString(string input, out string error)
|
||||
{
|
||||
error = null;
|
||||
if (string.IsNullOrEmpty(input))
|
||||
{
|
||||
error = "Expected a quoted string, got empty input";
|
||||
return null;
|
||||
}
|
||||
|
||||
if (input[0] != '"')
|
||||
{
|
||||
error = $"Expected a quoted string starting with \", got: {Truncate(input, 40)}";
|
||||
return null;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
for (int i = 1; i < input.Length; i++)
|
||||
{
|
||||
if (input[i] == '\\' && i + 1 < input.Length)
|
||||
{
|
||||
sb.Append(input[i + 1]);
|
||||
i++;
|
||||
}
|
||||
else if (input[i] == '"')
|
||||
{
|
||||
// Check nothing meaningful follows the closing quote
|
||||
var rest = input.Substring(i + 1).Trim();
|
||||
if (rest.Length > 0)
|
||||
{
|
||||
error = $"Unexpected content after closing quote: {Truncate(rest, 40)}";
|
||||
return null;
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(input[i]);
|
||||
}
|
||||
}
|
||||
|
||||
error = "Unterminated string (missing closing \")";
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splits a comma-separated argument list, respecting quoted strings
|
||||
/// and nested braces so that <c>"a, b", env: { K: "V, W" }</c> is
|
||||
/// correctly split into two arguments.
|
||||
/// </summary>
|
||||
internal static List<string> SplitArguments(string input, out string error)
|
||||
{
|
||||
error = null;
|
||||
var result = new List<string>();
|
||||
var current = new StringBuilder();
|
||||
int depth = 0;
|
||||
bool inQuote = false;
|
||||
|
||||
for (int i = 0; i < input.Length; i++)
|
||||
{
|
||||
var ch = input[i];
|
||||
|
||||
if (ch == '\\' && inQuote && i + 1 < input.Length)
|
||||
{
|
||||
current.Append(ch);
|
||||
current.Append(input[++i]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch == '"')
|
||||
{
|
||||
inQuote = !inQuote;
|
||||
current.Append(ch);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inQuote)
|
||||
{
|
||||
if (ch == '{')
|
||||
{
|
||||
depth++;
|
||||
current.Append(ch);
|
||||
continue;
|
||||
}
|
||||
if (ch == '}')
|
||||
{
|
||||
depth--;
|
||||
current.Append(ch);
|
||||
continue;
|
||||
}
|
||||
if (ch == ',' && depth == 0)
|
||||
{
|
||||
result.Add(current.ToString());
|
||||
current.Clear();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
current.Append(ch);
|
||||
}
|
||||
|
||||
if (inQuote)
|
||||
{
|
||||
error = "Unterminated string in arguments";
|
||||
return null;
|
||||
}
|
||||
if (depth != 0)
|
||||
{
|
||||
error = "Unmatched braces in arguments";
|
||||
return null;
|
||||
}
|
||||
|
||||
if (current.Length > 0)
|
||||
{
|
||||
result.Add(current.ToString());
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses <c>{ KEY: "value", KEY2: "value2" }</c> into a dictionary.
|
||||
/// </summary>
|
||||
internal static Dictionary<string, string> ParseEnvBlock(string input, out string error)
|
||||
{
|
||||
error = null;
|
||||
var trimmed = input.Trim();
|
||||
if (!trimmed.StartsWith("{") || !trimmed.EndsWith("}"))
|
||||
{
|
||||
error = "Expected env block in the form { KEY: \"value\" }";
|
||||
return null;
|
||||
}
|
||||
|
||||
var inner = trimmed.Substring(1, trimmed.Length - 2).Trim();
|
||||
if (string.IsNullOrEmpty(inner))
|
||||
{
|
||||
return new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
var pairs = SplitArguments(inner, out error);
|
||||
if (error != null) return null;
|
||||
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in pairs)
|
||||
{
|
||||
var colonIdx = pair.IndexOf(':');
|
||||
if (colonIdx <= 0)
|
||||
{
|
||||
error = $"Expected KEY: \"value\" pair, got: {Truncate(pair.Trim(), 40)}";
|
||||
return null;
|
||||
}
|
||||
|
||||
var key = pair.Substring(0, colonIdx).Trim();
|
||||
var val = ExtractQuotedString(pair.Substring(colonIdx + 1).Trim(), out error);
|
||||
if (error != null) return null;
|
||||
|
||||
result[key] = val;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string Truncate(string value, int maxLength)
|
||||
{
|
||||
if (value == null) return "(null)";
|
||||
return value.Length <= maxLength ? value : value.Substring(0, maxLength) + "...";
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
373
src/Runner.Worker/Dap/DapVariableProvider.cs
Normal file
373
src/Runner.Worker/Dap/DapVariableProvider.cs
Normal file
@@ -0,0 +1,373 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using GitHub.DistributedTask.Logging;
|
||||
using GitHub.DistributedTask.ObjectTemplating.Tokens;
|
||||
using GitHub.DistributedTask.Pipelines.ContextData;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps runner execution context data to DAP scopes and variables.
|
||||
///
|
||||
/// This is the single point where runner context values are materialized
|
||||
/// for the debugger. All values pass through the runner's existing
|
||||
/// <see cref="GitHub.DistributedTask.Logging.ISecretMasker"/> so the DAP
|
||||
/// surface never exposes anything beyond what a normal CI log would show.
|
||||
///
|
||||
/// The secrets scope is intentionally opaque: keys are visible but every
|
||||
/// value is replaced with a constant redaction marker.
|
||||
///
|
||||
/// Designed to be reusable by future DAP features (evaluate, hover, REPL)
|
||||
/// so that masking policy is never duplicated.
|
||||
/// </summary>
|
||||
internal sealed class DapVariableProvider
|
||||
{
|
||||
// Well-known scope names that map to top-level expression contexts.
|
||||
// Order matters: the index determines the stable variablesReference ID.
|
||||
private static readonly string[] _scopeNames =
|
||||
{
|
||||
"github", "env", "runner", "job", "steps",
|
||||
"secrets", "inputs", "vars", "matrix", "needs"
|
||||
};
|
||||
|
||||
// Scope references occupy the range [1, ScopeReferenceMax].
|
||||
private const int _scopeReferenceBase = 1;
|
||||
private const int _scopeReferenceMax = 100;
|
||||
|
||||
// Dynamic (nested) variable references start above the scope range.
|
||||
private const int _dynamicReferenceBase = 101;
|
||||
|
||||
private const string _redactedValue = "***";
|
||||
|
||||
private readonly ISecretMasker _secretMasker;
|
||||
|
||||
// Maps dynamic variable reference IDs to the backing data and its
|
||||
// dot-separated path (e.g. "github.event.pull_request").
|
||||
private readonly Dictionary<int, (PipelineContextData Data, string Path)> _variableReferences = new();
|
||||
private int _nextVariableReference = _dynamicReferenceBase;
|
||||
|
||||
public DapVariableProvider(ISecretMasker secretMasker)
|
||||
{
|
||||
_secretMasker = secretMasker ?? throw new ArgumentNullException(nameof(secretMasker));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all dynamic variable references.
|
||||
/// Call this whenever the paused execution context changes (e.g. new step)
|
||||
/// so that stale nested references are not served to the client.
|
||||
/// </summary>
|
||||
public void Reset()
|
||||
{
|
||||
_variableReferences.Clear();
|
||||
_nextVariableReference = _dynamicReferenceBase;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the list of DAP scopes for the given execution context.
|
||||
/// Each scope corresponds to a well-known runner expression context
|
||||
/// (github, env, secrets, …) and carries a stable variablesReference
|
||||
/// that the client can use to drill into variables.
|
||||
/// </summary>
|
||||
public List<Scope> GetScopes(IExecutionContext context)
|
||||
{
|
||||
var scopes = new List<Scope>();
|
||||
|
||||
if (context?.ExpressionValues == null)
|
||||
{
|
||||
return scopes;
|
||||
}
|
||||
|
||||
for (int i = 0; i < _scopeNames.Length; i++)
|
||||
{
|
||||
var scopeName = _scopeNames[i];
|
||||
if (!context.ExpressionValues.TryGetValue(scopeName, out var value) || value == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var scope = new Scope
|
||||
{
|
||||
Name = scopeName,
|
||||
VariablesReference = _scopeReferenceBase + i,
|
||||
Expensive = false,
|
||||
PresentationHint = scopeName == "secrets" ? "registers" : null
|
||||
};
|
||||
|
||||
if (value is DictionaryContextData dict)
|
||||
{
|
||||
scope.NamedVariables = dict.Count;
|
||||
}
|
||||
else if (value is CaseSensitiveDictionaryContextData csDict)
|
||||
{
|
||||
scope.NamedVariables = csDict.Count;
|
||||
}
|
||||
|
||||
scopes.Add(scope);
|
||||
}
|
||||
|
||||
return scopes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the child variables for a given variablesReference.
|
||||
/// The reference may point at a top-level scope (1–100) or a
|
||||
/// dynamically registered nested container (101+).
|
||||
/// </summary>
|
||||
public List<Variable> GetVariables(IExecutionContext context, int variablesReference)
|
||||
{
|
||||
var variables = new List<Variable>();
|
||||
|
||||
if (context?.ExpressionValues == null)
|
||||
{
|
||||
return variables;
|
||||
}
|
||||
|
||||
PipelineContextData data = null;
|
||||
string basePath = null;
|
||||
bool isSecretsScope = false;
|
||||
|
||||
if (variablesReference >= _scopeReferenceBase && variablesReference <= _scopeReferenceMax)
|
||||
{
|
||||
var scopeIndex = variablesReference - _scopeReferenceBase;
|
||||
if (scopeIndex < _scopeNames.Length)
|
||||
{
|
||||
var scopeName = _scopeNames[scopeIndex];
|
||||
isSecretsScope = scopeName == "secrets";
|
||||
if (context.ExpressionValues.TryGetValue(scopeName, out data))
|
||||
{
|
||||
basePath = scopeName;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (_variableReferences.TryGetValue(variablesReference, out var refData))
|
||||
{
|
||||
data = refData.Data;
|
||||
basePath = refData.Path;
|
||||
isSecretsScope = basePath?.StartsWith("secrets", StringComparison.OrdinalIgnoreCase) == true;
|
||||
}
|
||||
|
||||
if (data == null)
|
||||
{
|
||||
return variables;
|
||||
}
|
||||
|
||||
ConvertToVariables(data, basePath, isSecretsScope, variables);
|
||||
return variables;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates a GitHub Actions expression (e.g. "github.repository",
|
||||
/// "${{ github.event_name }}") in the context of the current step and
|
||||
/// returns a masked result suitable for the DAP evaluate response.
|
||||
///
|
||||
/// Uses the runner's standard <see cref="GitHub.DistributedTask.Pipelines.ObjectTemplating.IPipelineTemplateEvaluator"/>
|
||||
/// so the full expression language is available (functions, operators,
|
||||
/// context access).
|
||||
/// </summary>
|
||||
public EvaluateResponseBody EvaluateExpression(string expression, IExecutionContext context)
|
||||
{
|
||||
if (context?.ExpressionValues == null)
|
||||
{
|
||||
return new EvaluateResponseBody
|
||||
{
|
||||
Result = "(no execution context available)",
|
||||
Type = "string",
|
||||
VariablesReference = 0
|
||||
};
|
||||
}
|
||||
|
||||
// Strip ${{ }} wrapper if present
|
||||
var expr = expression?.Trim() ?? string.Empty;
|
||||
if (expr.StartsWith("${{") && expr.EndsWith("}}"))
|
||||
{
|
||||
expr = expr.Substring(3, expr.Length - 5).Trim();
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(expr))
|
||||
{
|
||||
return new EvaluateResponseBody
|
||||
{
|
||||
Result = string.Empty,
|
||||
Type = "string",
|
||||
VariablesReference = 0
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var templateEvaluator = context.ToPipelineTemplateEvaluator();
|
||||
var token = new BasicExpressionToken(null, null, null, expr);
|
||||
|
||||
var result = templateEvaluator.EvaluateStepDisplayName(
|
||||
token,
|
||||
context.ExpressionValues,
|
||||
context.ExpressionFunctions);
|
||||
|
||||
result = _secretMasker.MaskSecrets(result ?? "null");
|
||||
|
||||
return new EvaluateResponseBody
|
||||
{
|
||||
Result = result,
|
||||
Type = InferResultType(result),
|
||||
VariablesReference = 0
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var errorMessage = _secretMasker.MaskSecrets($"Evaluation error: {ex.Message}");
|
||||
return new EvaluateResponseBody
|
||||
{
|
||||
Result = errorMessage,
|
||||
Type = "string",
|
||||
VariablesReference = 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Infers a simple DAP type hint from the string representation of a result.
|
||||
/// </summary>
|
||||
internal static string InferResultType(string value)
|
||||
{
|
||||
value = value?.ToLower();
|
||||
if (value == null || value == "null")
|
||||
return "null";
|
||||
if (value == "true" || value == "false")
|
||||
return "boolean";
|
||||
if (double.TryParse(value, NumberStyles.Any,
|
||||
CultureInfo.InvariantCulture, out _))
|
||||
return "number";
|
||||
if (value.StartsWith("{") || value.StartsWith("["))
|
||||
return "object";
|
||||
return "string";
|
||||
}
|
||||
|
||||
#region Private helpers
|
||||
|
||||
private void ConvertToVariables(
|
||||
PipelineContextData data,
|
||||
string basePath,
|
||||
bool isSecretsScope,
|
||||
List<Variable> variables)
|
||||
{
|
||||
switch (data)
|
||||
{
|
||||
case DictionaryContextData dict:
|
||||
foreach (var pair in dict)
|
||||
{
|
||||
variables.Add(CreateVariable(pair.Key, pair.Value, basePath, isSecretsScope));
|
||||
}
|
||||
break;
|
||||
|
||||
case CaseSensitiveDictionaryContextData csDict:
|
||||
foreach (var pair in csDict)
|
||||
{
|
||||
variables.Add(CreateVariable(pair.Key, pair.Value, basePath, isSecretsScope));
|
||||
}
|
||||
break;
|
||||
|
||||
case ArrayContextData array:
|
||||
for (int i = 0; i < array.Count; i++)
|
||||
{
|
||||
var variable = CreateVariable($"[{i}]", array[i], basePath, isSecretsScope);
|
||||
variables.Add(variable);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private Variable CreateVariable(
|
||||
string name,
|
||||
PipelineContextData value,
|
||||
string basePath,
|
||||
bool isSecretsScope)
|
||||
{
|
||||
var childPath = string.IsNullOrEmpty(basePath) ? name : $"{basePath}.{name}";
|
||||
var variable = new Variable
|
||||
{
|
||||
Name = name,
|
||||
EvaluateName = $"${{{{ {childPath} }}}}"
|
||||
};
|
||||
|
||||
// Secrets scope: redact ALL values regardless of underlying type.
|
||||
// Keys are visible but values are always replaced with the
|
||||
// redaction marker, and nested containers are not drillable.
|
||||
if (isSecretsScope)
|
||||
{
|
||||
variable.Value = _redactedValue;
|
||||
variable.Type = "string";
|
||||
variable.VariablesReference = 0;
|
||||
return variable;
|
||||
}
|
||||
|
||||
if (value == null)
|
||||
{
|
||||
variable.Value = "null";
|
||||
variable.Type = "null";
|
||||
variable.VariablesReference = 0;
|
||||
return variable;
|
||||
}
|
||||
|
||||
switch (value)
|
||||
{
|
||||
case StringContextData str:
|
||||
variable.Value = _secretMasker.MaskSecrets(str.Value);
|
||||
variable.Type = "string";
|
||||
variable.VariablesReference = 0;
|
||||
break;
|
||||
|
||||
case NumberContextData num:
|
||||
variable.Value = _secretMasker.MaskSecrets(num.Value.ToString("G15", CultureInfo.InvariantCulture));
|
||||
variable.Type = "number";
|
||||
variable.VariablesReference = 0;
|
||||
break;
|
||||
|
||||
case BooleanContextData boolVal:
|
||||
variable.Value = boolVal.Value ? "true" : "false";
|
||||
variable.Type = "boolean";
|
||||
variable.VariablesReference = 0;
|
||||
break;
|
||||
|
||||
case DictionaryContextData dict:
|
||||
variable.Value = $"Object ({dict.Count} properties)";
|
||||
variable.Type = "object";
|
||||
variable.VariablesReference = RegisterVariableReference(dict, childPath);
|
||||
variable.NamedVariables = dict.Count;
|
||||
break;
|
||||
|
||||
case CaseSensitiveDictionaryContextData csDict:
|
||||
variable.Value = $"Object ({csDict.Count} properties)";
|
||||
variable.Type = "object";
|
||||
variable.VariablesReference = RegisterVariableReference(csDict, childPath);
|
||||
variable.NamedVariables = csDict.Count;
|
||||
break;
|
||||
|
||||
case ArrayContextData array:
|
||||
variable.Value = $"Array ({array.Count} items)";
|
||||
variable.Type = "array";
|
||||
variable.VariablesReference = RegisterVariableReference(array, childPath);
|
||||
variable.IndexedVariables = array.Count;
|
||||
break;
|
||||
|
||||
default:
|
||||
var rawValue = value.ToJToken()?.ToString() ?? "unknown";
|
||||
variable.Value = _secretMasker.MaskSecrets(rawValue);
|
||||
variable.Type = value.GetType().Name;
|
||||
variable.VariablesReference = 0;
|
||||
break;
|
||||
}
|
||||
|
||||
return variable;
|
||||
}
|
||||
|
||||
private int RegisterVariableReference(PipelineContextData data, string path)
|
||||
{
|
||||
var reference = _nextVariableReference++;
|
||||
_variableReferences[reference] = (data, path);
|
||||
return reference;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
48
src/Runner.Worker/Dap/DebuggerConfig.cs
Normal file
48
src/Runner.Worker/Dap/DebuggerConfig.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using GitHub.DistributedTask.Pipelines;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap
|
||||
{
|
||||
/// <summary>
|
||||
/// Consolidated runtime configuration for the job debugger.
|
||||
/// Populated once from the acquire response and owned by <see cref="GlobalContext"/>.
|
||||
/// </summary>
|
||||
public sealed class DebuggerConfig
|
||||
{
|
||||
public DebuggerConfig(bool enabled, DebuggerTunnelInfo tunnel, bool overrideWelcomeMessage = false, string welcomeMessage = null)
|
||||
{
|
||||
Enabled = enabled;
|
||||
Tunnel = tunnel;
|
||||
OverrideWelcomeMessage = overrideWelcomeMessage;
|
||||
WelcomeMessage = welcomeMessage;
|
||||
}
|
||||
|
||||
/// <summary>Whether the debugger is enabled for this job.</summary>
|
||||
public bool Enabled { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Dev Tunnel details for remote debugging.
|
||||
/// Required when <see cref="Enabled"/> is true.
|
||||
/// </summary>
|
||||
public DebuggerTunnelInfo Tunnel { get; }
|
||||
|
||||
/// <summary>
|
||||
/// When true, the runner overrides the default welcome message with
|
||||
/// <see cref="WelcomeMessage"/>. A null or empty <see cref="WelcomeMessage"/>
|
||||
/// suppresses the message entirely. When false, the default help text is shown.
|
||||
/// </summary>
|
||||
public bool OverrideWelcomeMessage { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional welcome message content for the debugger console. Only used when
|
||||
/// <see cref="OverrideWelcomeMessage"/> is true.
|
||||
/// </summary>
|
||||
public string WelcomeMessage { get; }
|
||||
|
||||
/// <summary>Whether the tunnel configuration is complete and valid.</summary>
|
||||
public bool HasValidTunnel => Tunnel != null
|
||||
&& !string.IsNullOrEmpty(Tunnel.TunnelId)
|
||||
&& !string.IsNullOrEmpty(Tunnel.ClusterId)
|
||||
&& !string.IsNullOrEmpty(Tunnel.HostToken)
|
||||
&& Tunnel.Port >= 1024 && Tunnel.Port <= 65535;
|
||||
}
|
||||
}
|
||||
30
src/Runner.Worker/Dap/IDapDebugger.cs
Normal file
30
src/Runner.Worker/Dap/IDapDebugger.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Runner.Common;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap
|
||||
{
|
||||
public enum DapSessionState
|
||||
{
|
||||
NotStarted,
|
||||
WaitingForConnection,
|
||||
Initializing,
|
||||
Ready,
|
||||
Paused,
|
||||
Running,
|
||||
Terminated
|
||||
}
|
||||
|
||||
[ServiceLocator(Default = typeof(DapDebugger))]
|
||||
public interface IDapDebugger : IRunnerService
|
||||
{
|
||||
Task StartAsync(IExecutionContext jobContext);
|
||||
Task WaitUntilReadyAsync();
|
||||
Task OnJobStepsInitializedAsync(IEnumerable<IStep> steps, IEnumerable<IStep> initialPostSteps);
|
||||
void OnPostStepRegistered(IStep step);
|
||||
Task OnStepStartingAsync(IStep step);
|
||||
void OnStepCompleted(IStep step);
|
||||
Task OnJobCompletedAsync();
|
||||
Task StopAsync();
|
||||
}
|
||||
}
|
||||
12
src/Runner.Worker/Dap/IWebSocketDapBridge.cs
Normal file
12
src/Runner.Worker/Dap/IWebSocketDapBridge.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Runner.Common;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap
|
||||
{
|
||||
[ServiceLocator(Default = typeof(WebSocketDapBridge))]
|
||||
public interface IWebSocketDapBridge : IRunnerService
|
||||
{
|
||||
void Start(int listenPort, int targetPort);
|
||||
Task ShutdownAsync();
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
839
src/Runner.Worker/Dap/WebSocketDapBridge.cs
Normal file
839
src/Runner.Worker/Dap/WebSocketDapBridge.cs
Normal file
@@ -0,0 +1,839 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Net.WebSockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Runner.Common;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap
|
||||
{
|
||||
internal sealed class WebSocketDapBridge : RunnerService, IWebSocketDapBridge
|
||||
{
|
||||
internal enum IncomingStreamPrefixKind
|
||||
{
|
||||
Unknown,
|
||||
HttpWebSocketUpgrade,
|
||||
PreUpgradedWebSocket,
|
||||
WebSocketReservedBits,
|
||||
Http2Preface,
|
||||
TlsClientHello,
|
||||
}
|
||||
|
||||
private const int _bufferSize = 32 * 1024;
|
||||
private const int _maxHeaderLineLength = 8 * 1024;
|
||||
private const int _defaultMaxInboundMessageSize = 10 * 1024 * 1024; // 10 MB
|
||||
private static readonly TimeSpan _keepAliveInterval = TimeSpan.FromSeconds(30);
|
||||
private static readonly TimeSpan _closeTimeout = TimeSpan.FromSeconds(5);
|
||||
private static readonly TimeSpan _handshakeTimeout = TimeSpan.FromSeconds(10);
|
||||
private const string _webSocketAcceptMagic = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
||||
private const int _maxHeaderCount = 64;
|
||||
private static readonly byte[] _headerEndMarker = new byte[] { (byte)'\r', (byte)'\n', (byte)'\r', (byte)'\n' };
|
||||
|
||||
private int _listenPort;
|
||||
private int _targetPort;
|
||||
|
||||
private TcpListener _listener;
|
||||
private CancellationTokenSource _loopCts;
|
||||
private Task _acceptLoopTask;
|
||||
|
||||
public int MaxInboundMessageSize { get; set; } = _defaultMaxInboundMessageSize;
|
||||
|
||||
internal int ListenPort => (_listener?.LocalEndpoint as IPEndPoint)?.Port ?? 0;
|
||||
|
||||
public void Start(int listenPort, int targetPort)
|
||||
{
|
||||
if (_listener != null)
|
||||
{
|
||||
throw new InvalidOperationException("WebSocket DAP bridge already started.");
|
||||
}
|
||||
|
||||
_listenPort = listenPort;
|
||||
_targetPort = targetPort;
|
||||
|
||||
_listener = new TcpListener(IPAddress.Loopback, _listenPort);
|
||||
_listener.Start();
|
||||
_loopCts = new CancellationTokenSource();
|
||||
_acceptLoopTask = AcceptLoopAsync(_loopCts.Token);
|
||||
|
||||
Trace.Info($"WebSocket DAP bridge listening on {_listener.LocalEndpoint} -> 127.0.0.1:{_targetPort}");
|
||||
}
|
||||
|
||||
public async Task ShutdownAsync()
|
||||
{
|
||||
_loopCts?.Cancel();
|
||||
|
||||
try
|
||||
{
|
||||
_listener?.Stop();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Warning($"Error stopping listener during shutdown ({ex.GetType().Name})");
|
||||
}
|
||||
|
||||
if (_acceptLoopTask != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _acceptLoopTask;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// expected on shutdown
|
||||
}
|
||||
}
|
||||
|
||||
_loopCts?.Dispose();
|
||||
_loopCts = null;
|
||||
_listener = null;
|
||||
_acceptLoopTask = null;
|
||||
}
|
||||
|
||||
private async Task AcceptLoopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
TcpClient client = null;
|
||||
try
|
||||
{
|
||||
client = await _listener.AcceptTcpClientAsync(cancellationToken);
|
||||
client.NoDelay = true;
|
||||
await HandleClientAsync(client, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
client?.Dispose();
|
||||
Trace.Error($"WebSocket DAP bridge connection error");
|
||||
Trace.Error(ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
client?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
Trace.Info("WebSocket DAP bridge accept loop ended");
|
||||
}
|
||||
|
||||
private async Task HandleClientAsync(TcpClient incomingClient, CancellationToken cancellationToken)
|
||||
{
|
||||
using (var incomingStream = incomingClient.GetStream())
|
||||
{
|
||||
Trace.Info($"WebSocket DAP bridge accepted client {incomingClient.Client.RemoteEndPoint}");
|
||||
|
||||
WebSocket webSocket;
|
||||
using (var handshakeCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken))
|
||||
{
|
||||
handshakeCts.CancelAfter(_handshakeTimeout);
|
||||
try
|
||||
{
|
||||
webSocket = await AcceptWebSocketAsync(incomingStream, handshakeCts.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
Trace.Warning("WebSocket handshake timed out");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (webSocket == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using (webSocket)
|
||||
using (var dapClient = new TcpClient())
|
||||
{
|
||||
dapClient.NoDelay = true;
|
||||
await dapClient.ConnectAsync(IPAddress.Loopback, _targetPort, cancellationToken);
|
||||
|
||||
using (var dapStream = dapClient.GetStream())
|
||||
using (var sessionCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken))
|
||||
{
|
||||
var proxyToken = sessionCts.Token;
|
||||
var wsToTcpTask = PumpWebSocketToTcpAsync(webSocket, dapStream, proxyToken);
|
||||
var tcpToWsTask = PumpTcpToWebSocketAsync(dapStream, webSocket, proxyToken);
|
||||
|
||||
await Task.WhenAny(wsToTcpTask, tcpToWsTask);
|
||||
sessionCts.Cancel();
|
||||
|
||||
await CloseWebSocketAsync(webSocket);
|
||||
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(wsToTcpTask, tcpToWsTask);
|
||||
}
|
||||
catch (OperationCanceledException) when (proxyToken.IsCancellationRequested)
|
||||
{
|
||||
// expected during shutdown
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Warning($"DAP protocol error: {ex}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<WebSocket> AcceptWebSocketAsync(NetworkStream stream, CancellationToken cancellationToken)
|
||||
{
|
||||
var initialBytes = await ReadInitialBytesAsync(stream, cancellationToken);
|
||||
if (initialBytes == null || initialBytes.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var prefixKind = ClassifyIncomingStreamPrefix(initialBytes);
|
||||
if (prefixKind == IncomingStreamPrefixKind.PreUpgradedWebSocket)
|
||||
{
|
||||
Trace.Info($"Treating incoming tunnel stream as an already-upgraded websocket connection ({DescribeInitialBytes(initialBytes)})");
|
||||
return WebSocket.CreateFromStream(
|
||||
new ReplayableStream(stream, initialBytes),
|
||||
isServer: true,
|
||||
subProtocol: null,
|
||||
keepAliveInterval: _keepAliveInterval);
|
||||
}
|
||||
|
||||
if (prefixKind != IncomingStreamPrefixKind.HttpWebSocketUpgrade)
|
||||
{
|
||||
Trace.Warning($"Unsupported debugger tunnel stream prefix ({prefixKind}): {DescribeInitialBytes(initialBytes)}");
|
||||
return null;
|
||||
}
|
||||
|
||||
var handshakeStream = new ReplayableStream(stream, initialBytes);
|
||||
var requestLine = await ReadLineAsync(handshakeStream, cancellationToken);
|
||||
if (string.IsNullOrEmpty(requestLine))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
if (headers.Count >= _maxHeaderCount)
|
||||
{
|
||||
Trace.Warning($"Rejected WebSocket request with too many headers (>{_maxHeaderCount})");
|
||||
await WriteHttpErrorAsync(stream, HttpStatusCode.BadRequest, "Too many headers.", cancellationToken);
|
||||
return null;
|
||||
}
|
||||
|
||||
var line = await ReadLineAsync(handshakeStream, cancellationToken);
|
||||
if (line == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (line.Length == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var separatorIndex = line.IndexOf(':');
|
||||
if (separatorIndex <= 0)
|
||||
{
|
||||
await WriteHttpErrorAsync(stream, HttpStatusCode.BadRequest, "Invalid HTTP header.", cancellationToken);
|
||||
return null;
|
||||
}
|
||||
|
||||
var headerName = line.Substring(0, separatorIndex).Trim();
|
||||
var headerValue = line.Substring(separatorIndex + 1).Trim();
|
||||
|
||||
if (headers.TryGetValue(headerName, out var existingValue))
|
||||
{
|
||||
headers[headerName] = $"{existingValue}, {headerValue}";
|
||||
}
|
||||
else
|
||||
{
|
||||
headers[headerName] = headerValue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!IsValidWebSocketRequest(requestLine, headers))
|
||||
{
|
||||
var method = requestLine.Split(' ')[0];
|
||||
Trace.Info($"Rejected non-websocket request (method={method})");
|
||||
await WriteHttpErrorAsync(stream, HttpStatusCode.BadRequest, "Expected a websocket upgrade request.", cancellationToken);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!headers.TryGetValue("Sec-WebSocket-Version", out var webSocketVersion) ||
|
||||
!string.Equals(webSocketVersion.Trim(), "13", StringComparison.Ordinal))
|
||||
{
|
||||
Trace.Warning("Rejected WebSocket request with unsupported version");
|
||||
await WriteHttpErrorAsync(stream, (HttpStatusCode)426, "Unsupported WebSocket version. Expected: 13.", cancellationToken);
|
||||
return null;
|
||||
}
|
||||
|
||||
var webSocketKey = headers["Sec-WebSocket-Key"];
|
||||
if (!IsValidWebSocketKey(webSocketKey))
|
||||
{
|
||||
Trace.Warning("Rejected WebSocket request with invalid Sec-WebSocket-Key");
|
||||
await WriteHttpErrorAsync(stream, HttpStatusCode.BadRequest, "Invalid Sec-WebSocket-Key.", cancellationToken);
|
||||
return null;
|
||||
}
|
||||
|
||||
var acceptValue = ComputeAcceptValue(webSocketKey);
|
||||
var responseBytes = Encoding.ASCII.GetBytes(
|
||||
"HTTP/1.1 101 Switching Protocols\r\n" +
|
||||
"Connection: Upgrade\r\n" +
|
||||
"Upgrade: websocket\r\n" +
|
||||
$"Sec-WebSocket-Accept: {acceptValue}\r\n" +
|
||||
"\r\n");
|
||||
|
||||
await handshakeStream.WriteAsync(responseBytes, 0, responseBytes.Length, cancellationToken);
|
||||
await handshakeStream.FlushAsync(cancellationToken);
|
||||
|
||||
Trace.Info("WebSocket DAP bridge completed websocket handshake");
|
||||
return WebSocket.CreateFromStream(handshakeStream, isServer: true, subProtocol: null, keepAliveInterval: _keepAliveInterval);
|
||||
}
|
||||
|
||||
private async Task PumpWebSocketToTcpAsync(WebSocket source, NetworkStream destination, CancellationToken cancellationToken)
|
||||
{
|
||||
var buffer = new byte[_bufferSize];
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
using (var messageStream = new MemoryStream())
|
||||
{
|
||||
WebSocketReceiveResult result;
|
||||
do
|
||||
{
|
||||
result = await source.ReceiveAsync(new ArraySegment<byte>(buffer), cancellationToken);
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.MessageType != WebSocketMessageType.Binary &&
|
||||
result.MessageType != WebSocketMessageType.Text)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (result.Count > 0)
|
||||
{
|
||||
if (messageStream.Length + result.Count > MaxInboundMessageSize)
|
||||
{
|
||||
Trace.Warning($"WebSocket message exceeds maximum allowed size of {MaxInboundMessageSize} bytes, closing connection");
|
||||
await source.CloseAsync(
|
||||
WebSocketCloseStatus.MessageTooBig,
|
||||
$"Message exceeds {MaxInboundMessageSize} byte limit",
|
||||
CancellationToken.None);
|
||||
return;
|
||||
}
|
||||
|
||||
messageStream.Write(buffer, 0, result.Count);
|
||||
}
|
||||
}
|
||||
while (!result.EndOfMessage && !cancellationToken.IsCancellationRequested);
|
||||
|
||||
if (result.MessageType != WebSocketMessageType.Binary &&
|
||||
result.MessageType != WebSocketMessageType.Text)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var messageBytes = messageStream.ToArray();
|
||||
if (messageBytes.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var contentLengthHeader = Encoding.ASCII.GetBytes($"Content-Length: {messageBytes.Length}\r\n\r\n");
|
||||
await destination.WriteAsync(contentLengthHeader, 0, contentLengthHeader.Length, cancellationToken);
|
||||
await destination.WriteAsync(messageBytes, 0, messageBytes.Length, cancellationToken);
|
||||
await destination.FlushAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task PumpTcpToWebSocketAsync(NetworkStream source, WebSocket destination, CancellationToken cancellationToken)
|
||||
{
|
||||
var readBuffer = new byte[_bufferSize];
|
||||
var dapBuffer = new List<byte>();
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
var bytesRead = await source.ReadAsync(readBuffer, 0, readBuffer.Length, cancellationToken);
|
||||
if (bytesRead == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
dapBuffer.AddRange(new ArraySegment<byte>(readBuffer, 0, bytesRead));
|
||||
|
||||
while (TryParseDapMessage(dapBuffer, out var messageBody))
|
||||
{
|
||||
await destination.SendAsync(
|
||||
new ArraySegment<byte>(messageBody),
|
||||
WebSocketMessageType.Text,
|
||||
endOfMessage: true,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParseDapMessage(List<byte> buffer, out byte[] messageBody)
|
||||
{
|
||||
messageBody = null;
|
||||
|
||||
var headerEndIndex = FindSequence(buffer, _headerEndMarker);
|
||||
if (headerEndIndex == -1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var headerBytes = buffer.GetRange(0, headerEndIndex).ToArray();
|
||||
var headerText = Encoding.ASCII.GetString(headerBytes);
|
||||
|
||||
var contentLength = -1;
|
||||
foreach (var line in headerText.Split(new[] { "\r\n" }, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
if (line.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var valueStart = line.IndexOf(':') + 1;
|
||||
if (int.TryParse(line.Substring(valueStart).Trim(), out var parsedLength))
|
||||
{
|
||||
contentLength = parsedLength;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (contentLength < 0)
|
||||
{
|
||||
throw new InvalidOperationException("DAP message missing or unparseable Content-Length header; tearing down session.");
|
||||
}
|
||||
|
||||
var messageStart = headerEndIndex + 4;
|
||||
var messageEnd = messageStart + contentLength;
|
||||
|
||||
if (buffer.Count < messageEnd)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
messageBody = buffer.GetRange(messageStart, contentLength).ToArray();
|
||||
buffer.RemoveRange(0, messageEnd);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static int FindSequence(List<byte> buffer, byte[] sequence)
|
||||
{
|
||||
if (buffer.Count < sequence.Length)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
for (int i = 0; i <= buffer.Count - sequence.Length; i++)
|
||||
{
|
||||
var match = true;
|
||||
for (int j = 0; j < sequence.Length; j++)
|
||||
{
|
||||
if (buffer[i + j] != sequence[j])
|
||||
{
|
||||
match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (match)
|
||||
{
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static bool IsValidWebSocketRequest(string requestLine, IDictionary<string, string> headers)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(requestLine))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var requestLineParts = requestLine.Split(' ');
|
||||
if (requestLineParts.Length < 3 || !string.Equals(requestLineParts[0], "GET", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return HeaderContainsToken(headers, "Connection", "Upgrade") &&
|
||||
HeaderContainsToken(headers, "Upgrade", "websocket") &&
|
||||
headers.ContainsKey("Sec-WebSocket-Key");
|
||||
}
|
||||
|
||||
private static bool HeaderContainsToken(IDictionary<string, string> headers, string headerName, string expectedToken)
|
||||
{
|
||||
if (!headers.TryGetValue(headerName, out var headerValue) || string.IsNullOrWhiteSpace(headerValue))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return headerValue
|
||||
.Split(',')
|
||||
.Select(token => token.Trim())
|
||||
.Any(token => string.Equals(token, expectedToken, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static string ComputeAcceptValue(string webSocketKey)
|
||||
{
|
||||
using (var sha1 = SHA1.Create())
|
||||
{
|
||||
var inputBytes = Encoding.ASCII.GetBytes($"{webSocketKey}{_webSocketAcceptMagic}");
|
||||
var hashBytes = sha1.ComputeHash(inputBytes);
|
||||
return Convert.ToBase64String(hashBytes);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsValidWebSocketKey(string key)
|
||||
{
|
||||
if (string.IsNullOrEmpty(key) || key.IndexOfAny(new[] { '\r', '\n' }) >= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var decoded = Convert.FromBase64String(key);
|
||||
return decoded.Length == 16;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string> ReadLineAsync(Stream stream, CancellationToken cancellationToken)
|
||||
{
|
||||
var lineBuilder = new StringBuilder();
|
||||
var buffer = new byte[1];
|
||||
var previousWasCarriageReturn = false;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var bytesRead = await stream.ReadAsync(buffer, 0, 1, cancellationToken);
|
||||
if (bytesRead == 0)
|
||||
{
|
||||
return lineBuilder.Length > 0 ? lineBuilder.ToString() : null;
|
||||
}
|
||||
|
||||
var currentChar = (char)buffer[0];
|
||||
if (currentChar == '\n' && previousWasCarriageReturn)
|
||||
{
|
||||
if (lineBuilder.Length > 0 && lineBuilder[lineBuilder.Length - 1] == '\r')
|
||||
{
|
||||
lineBuilder.Length--;
|
||||
}
|
||||
|
||||
return lineBuilder.ToString();
|
||||
}
|
||||
|
||||
previousWasCarriageReturn = currentChar == '\r';
|
||||
lineBuilder.Append(currentChar);
|
||||
|
||||
if (lineBuilder.Length > _maxHeaderLineLength)
|
||||
{
|
||||
throw new InvalidDataException($"HTTP header line exceeds maximum length of {_maxHeaderLineLength}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<byte[]> ReadInitialBytesAsync(NetworkStream stream, CancellationToken cancellationToken)
|
||||
{
|
||||
var buffer = new byte[4];
|
||||
var totalRead = 0;
|
||||
|
||||
while (totalRead < buffer.Length)
|
||||
{
|
||||
var bytesRead = await stream.ReadAsync(buffer, totalRead, buffer.Length - totalRead, cancellationToken);
|
||||
if (bytesRead == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
totalRead += bytesRead;
|
||||
}
|
||||
|
||||
if (totalRead == 0)
|
||||
{
|
||||
return Array.Empty<byte>();
|
||||
}
|
||||
|
||||
if (totalRead == buffer.Length)
|
||||
{
|
||||
return buffer;
|
||||
}
|
||||
|
||||
var initialBytes = new byte[totalRead];
|
||||
Array.Copy(buffer, initialBytes, totalRead);
|
||||
return initialBytes;
|
||||
}
|
||||
|
||||
internal static IncomingStreamPrefixKind ClassifyIncomingStreamPrefix(byte[] initialBytes)
|
||||
{
|
||||
if (LooksLikeHttpUpgrade(initialBytes))
|
||||
{
|
||||
return IncomingStreamPrefixKind.HttpWebSocketUpgrade;
|
||||
}
|
||||
|
||||
if (LooksLikeHttp2Preface(initialBytes))
|
||||
{
|
||||
return IncomingStreamPrefixKind.Http2Preface;
|
||||
}
|
||||
|
||||
if (LooksLikeTlsClientHello(initialBytes))
|
||||
{
|
||||
return IncomingStreamPrefixKind.TlsClientHello;
|
||||
}
|
||||
|
||||
if (LooksLikeWebSocketFramePrefix(initialBytes, requireReservedBitsClear: false))
|
||||
{
|
||||
return HasReservedBitsSet(initialBytes[0])
|
||||
? IncomingStreamPrefixKind.WebSocketReservedBits
|
||||
: IncomingStreamPrefixKind.PreUpgradedWebSocket;
|
||||
}
|
||||
|
||||
return IncomingStreamPrefixKind.Unknown;
|
||||
}
|
||||
|
||||
internal static string DescribeInitialBytes(byte[] initialBytes)
|
||||
{
|
||||
if (initialBytes == null || initialBytes.Length == 0)
|
||||
{
|
||||
return "no bytes read";
|
||||
}
|
||||
|
||||
var hex = BitConverter.ToString(initialBytes);
|
||||
var ascii = new string(initialBytes.Select(value => value >= 32 && value <= 126 ? (char)value : '.').ToArray());
|
||||
return $"hex={hex}, ascii=\"{ascii}\"";
|
||||
}
|
||||
|
||||
private static bool LooksLikeHttpUpgrade(byte[] initialBytes)
|
||||
{
|
||||
if (initialBytes == null || initialBytes.Length < 4)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return initialBytes[0] == (byte)'G' &&
|
||||
initialBytes[1] == (byte)'E' &&
|
||||
initialBytes[2] == (byte)'T' &&
|
||||
initialBytes[3] == (byte)' ';
|
||||
}
|
||||
|
||||
private static bool LooksLikeHttp2Preface(byte[] initialBytes)
|
||||
{
|
||||
if (initialBytes == null || initialBytes.Length < 4)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return initialBytes[0] == (byte)'P' &&
|
||||
initialBytes[1] == (byte)'R' &&
|
||||
initialBytes[2] == (byte)'I' &&
|
||||
initialBytes[3] == (byte)' ';
|
||||
}
|
||||
|
||||
private static bool LooksLikeTlsClientHello(byte[] initialBytes)
|
||||
{
|
||||
if (initialBytes == null || initialBytes.Length < 3)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return initialBytes[0] == 0x16 &&
|
||||
initialBytes[1] == 0x03 &&
|
||||
initialBytes[2] >= 0x00 &&
|
||||
initialBytes[2] <= 0x04;
|
||||
}
|
||||
|
||||
private static bool LooksLikeWebSocketFramePrefix(byte[] initialBytes, bool requireReservedBitsClear)
|
||||
{
|
||||
if (initialBytes == null || initialBytes.Length < 2)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var firstByte = initialBytes[0];
|
||||
var secondByte = initialBytes[1];
|
||||
var opcode = firstByte & 0x0F;
|
||||
var isMasked = (secondByte & 0x80) != 0;
|
||||
|
||||
if (!isMasked || !IsSupportedWebSocketOpcode(opcode))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return !requireReservedBitsClear || !HasReservedBitsSet(firstByte);
|
||||
}
|
||||
|
||||
private static bool HasReservedBitsSet(byte firstByte)
|
||||
{
|
||||
return (firstByte & 0x70) != 0;
|
||||
}
|
||||
|
||||
private static bool IsSupportedWebSocketOpcode(int opcode)
|
||||
{
|
||||
switch (opcode)
|
||||
{
|
||||
case 0x0:
|
||||
case 0x1:
|
||||
case 0x2:
|
||||
case 0x8:
|
||||
case 0x9:
|
||||
case 0xA:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WriteHttpErrorAsync(
|
||||
NetworkStream stream,
|
||||
HttpStatusCode statusCode,
|
||||
string message,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var bodyBytes = Encoding.UTF8.GetBytes(message);
|
||||
var responseBytes = Encoding.ASCII.GetBytes(
|
||||
$"HTTP/1.1 {(int)statusCode} {statusCode}\r\n" +
|
||||
"Connection: close\r\n" +
|
||||
"Content-Type: text/plain; charset=utf-8\r\n" +
|
||||
$"Content-Length: {bodyBytes.Length}\r\n" +
|
||||
"Sec-WebSocket-Version: 13\r\n" +
|
||||
"\r\n");
|
||||
|
||||
await stream.WriteAsync(responseBytes, 0, responseBytes.Length, cancellationToken);
|
||||
await stream.WriteAsync(bodyBytes, 0, bodyBytes.Length, cancellationToken);
|
||||
await stream.FlushAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task CloseWebSocketAsync(WebSocket webSocket)
|
||||
{
|
||||
if (webSocket == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (webSocket.State != WebSocketState.Open &&
|
||||
webSocket.State != WebSocketState.CloseReceived)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(_closeTimeout);
|
||||
await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, string.Empty, cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Graceful close timed out, abort the connection.
|
||||
webSocket.Abort();
|
||||
}
|
||||
catch (WebSocketException)
|
||||
{
|
||||
// Peer already disconnected.
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ReplayableStream : Stream
|
||||
{
|
||||
private readonly Stream _innerStream;
|
||||
private readonly byte[] _prefixBytes;
|
||||
private int _prefixOffset;
|
||||
|
||||
public ReplayableStream(Stream innerStream, byte[] prefixBytes)
|
||||
{
|
||||
_innerStream = innerStream ?? throw new ArgumentNullException(nameof(innerStream));
|
||||
_prefixBytes = prefixBytes ?? Array.Empty<byte>();
|
||||
}
|
||||
|
||||
public override bool CanRead => _innerStream.CanRead;
|
||||
public override bool CanSeek => false;
|
||||
public override bool CanWrite => _innerStream.CanWrite;
|
||||
public override long Length => throw new NotSupportedException();
|
||||
|
||||
public override long Position
|
||||
{
|
||||
get => throw new NotSupportedException();
|
||||
set => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public override void Flush() => _innerStream.Flush();
|
||||
|
||||
public override Task FlushAsync(CancellationToken cancellationToken) => _innerStream.FlushAsync(cancellationToken);
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count)
|
||||
{
|
||||
if (TryReadPrefix(buffer, offset, count, out var bytesRead))
|
||||
{
|
||||
return bytesRead;
|
||||
}
|
||||
|
||||
return _innerStream.Read(buffer, offset, count);
|
||||
}
|
||||
|
||||
public override async Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
|
||||
{
|
||||
if (TryReadPrefix(buffer, offset, count, out var bytesRead))
|
||||
{
|
||||
return bytesRead;
|
||||
}
|
||||
|
||||
return await _innerStream.ReadAsync(buffer, offset, count, cancellationToken);
|
||||
}
|
||||
|
||||
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_prefixOffset < _prefixBytes.Length)
|
||||
{
|
||||
var bytesToCopy = Math.Min(buffer.Length, _prefixBytes.Length - _prefixOffset);
|
||||
new ReadOnlySpan<byte>(_prefixBytes, _prefixOffset, bytesToCopy).CopyTo(buffer.Span);
|
||||
_prefixOffset += bytesToCopy;
|
||||
return bytesToCopy;
|
||||
}
|
||||
|
||||
return await _innerStream.ReadAsync(buffer, cancellationToken);
|
||||
}
|
||||
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
|
||||
public override void Write(byte[] buffer, int offset, int count) => _innerStream.Write(buffer, offset, count);
|
||||
|
||||
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) =>
|
||||
_innerStream.WriteAsync(buffer, offset, count, cancellationToken);
|
||||
|
||||
public override ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default) =>
|
||||
_innerStream.WriteAsync(buffer, cancellationToken);
|
||||
|
||||
private bool TryReadPrefix(byte[] buffer, int offset, int count, out int bytesRead)
|
||||
{
|
||||
if (_prefixOffset >= _prefixBytes.Length)
|
||||
{
|
||||
bytesRead = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
bytesRead = Math.Min(count, _prefixBytes.Length - _prefixOffset);
|
||||
Array.Copy(_prefixBytes, _prefixOffset, buffer, offset, bytesRead);
|
||||
_prefixOffset += bytesRead;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,15 +77,23 @@ namespace GitHub.Runner.Worker
|
||||
|
||||
List<string> StepEnvironmentOverrides { get; }
|
||||
|
||||
ExecutionContext Root { get; }
|
||||
ExecutionContext Parent { get; }
|
||||
bool IsBackground { get; }
|
||||
|
||||
IExecutionContext Root { get; }
|
||||
|
||||
// Initialize
|
||||
void InitializeJob(Pipelines.AgentJobRequestMessage message, CancellationToken token);
|
||||
void CancelToken();
|
||||
IExecutionContext CreateChild(Guid recordId, string displayName, string refName, string scopeName, string contextName, ActionRunStage stage, Dictionary<string, string> intraActionState = null, int? recordOrder = null, IPagingLogger logger = null, bool isEmbedded = false, List<Issue> embeddedIssueCollector = null, CancellationTokenSource cancellationTokenSource = null, Guid embeddedId = default(Guid), string siblingScopeName = null, TimeSpan? timeout = null);
|
||||
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);
|
||||
|
||||
|
||||
// Background step deferral properties
|
||||
Dictionary<string, string> DeferredOutputs { get; set; }
|
||||
Dictionary<string, string> DeferredEnvironmentVariables { get; set; }
|
||||
List<string> DeferredPrependPath { get; set; }
|
||||
bool DeferOutcomeConclusion { get; set; }
|
||||
|
||||
// logging
|
||||
long Write(string tag, string message);
|
||||
void QueueAttachFile(string type, string name, string filePath);
|
||||
@@ -101,6 +109,12 @@ namespace GitHub.Runner.Worker
|
||||
void SetGitHubContext(string name, string value);
|
||||
void SetOutput(string name, string value, out string reference);
|
||||
void SetTimeout(TimeSpan? timeout);
|
||||
|
||||
// Background step deferral flush methods
|
||||
void FlushDeferredOutputs();
|
||||
void FlushDeferredEnvironment();
|
||||
void FlushDeferredOutcomeConclusion();
|
||||
|
||||
void AddIssue(Issue issue, ExecutionContextLogOptions logOptions);
|
||||
void Progress(int percentage, string currentOperation = null);
|
||||
void UpdateDetailTimelineRecord(TimelineRecord record);
|
||||
@@ -217,6 +231,9 @@ namespace GitHub.Runner.Worker
|
||||
|
||||
public bool EchoOnActionCommand { get; set; }
|
||||
|
||||
// Whether this step runs in the background
|
||||
public bool IsBackground => _record.IsBackground;
|
||||
|
||||
// An embedded execution context shares the same record ID, record name, and logger
|
||||
// as its enclosing execution context.
|
||||
public bool IsEmbedded { get; private init; }
|
||||
@@ -251,7 +268,9 @@ namespace GitHub.Runner.Worker
|
||||
}
|
||||
}
|
||||
|
||||
public ExecutionContext Root
|
||||
IExecutionContext IExecutionContext.Root => Root;
|
||||
|
||||
private ExecutionContext Root
|
||||
{
|
||||
get
|
||||
{
|
||||
@@ -266,13 +285,7 @@ namespace GitHub.Runner.Worker
|
||||
}
|
||||
}
|
||||
|
||||
public ExecutionContext Parent
|
||||
{
|
||||
get
|
||||
{
|
||||
return _parentExecutionContext;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public JobContext JobContext
|
||||
{
|
||||
@@ -284,6 +297,12 @@ namespace GitHub.Runner.Worker
|
||||
|
||||
public List<string> StepEnvironmentOverrides { get; } = new List<string>();
|
||||
|
||||
// Background step deferral properties
|
||||
public Dictionary<string, string> DeferredOutputs { get; set; }
|
||||
public Dictionary<string, string> DeferredEnvironmentVariables { get; set; }
|
||||
public List<string> DeferredPrependPath { get; set; }
|
||||
public bool DeferOutcomeConclusion { get; set; }
|
||||
|
||||
public override void Initialize(IHostContext hostContext)
|
||||
{
|
||||
base.Initialize(hostContext);
|
||||
@@ -342,7 +361,25 @@ namespace GitHub.Runner.Worker
|
||||
}
|
||||
|
||||
step.ExecutionContext = Root.CreatePostChild(step.DisplayName, IntraActionState, siblingScopeName);
|
||||
if (step is JobExtensionRunner)
|
||||
{
|
||||
step.ExecutionContext.StepTelemetry.Type = "runner";
|
||||
step.ExecutionContext.StepTelemetry.Action = step.DisplayName.ToLowerInvariant().Replace(' ', '_');
|
||||
}
|
||||
Root.PostJobSteps.Push(step);
|
||||
|
||||
if (Root.Global.Debugger?.Enabled == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
HostContext.GetService<Dap.IDapDebugger>().OnPostStepRegistered(step);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Warning("Failed to notify DAP debugger about registered post job step.");
|
||||
Trace.Error(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public IExecutionContext CreateChild(
|
||||
@@ -360,7 +397,11 @@ namespace GitHub.Runner.Worker
|
||||
CancellationTokenSource cancellationTokenSource = null,
|
||||
Guid embeddedId = default(Guid),
|
||||
string siblingScopeName = null,
|
||||
TimeSpan? timeout = null)
|
||||
TimeSpan? timeout = null,
|
||||
bool isBackground = false,
|
||||
string backgroundControlType = null,
|
||||
string[] backgroundControlStepIds = null,
|
||||
string parallelGroupId = null)
|
||||
{
|
||||
Trace.Entering();
|
||||
|
||||
@@ -401,6 +442,24 @@ namespace GitHub.Runner.Worker
|
||||
|
||||
child.EchoOnActionCommand = EchoOnActionCommand;
|
||||
|
||||
// Set background step metadata before InitializeTimelineRecord so it's included in the first update
|
||||
if (isBackground || backgroundControlType != null || parallelGroupId != null)
|
||||
{
|
||||
child._record.IsBackground = isBackground;
|
||||
child._record.BackgroundControlType = backgroundControlType;
|
||||
child._record.BackgroundControlStepIds = backgroundControlStepIds;
|
||||
child._record.ParallelGroupId = parallelGroupId;
|
||||
|
||||
// Initialize deferred state for background steps — flushed at wait/wait-all
|
||||
if (isBackground)
|
||||
{
|
||||
child.DeferredOutputs = new Dictionary<string, string>();
|
||||
child.DeferredEnvironmentVariables = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
child.DeferredPrependPath = new List<string>();
|
||||
child.DeferOutcomeConclusion = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (recordOrder != null)
|
||||
{
|
||||
child.InitializeTimelineRecord(_mainTimelineId, recordId, _record.Id, ExecutionContextType.Task, displayName, refName, recordOrder, embedded: isEmbedded);
|
||||
@@ -513,7 +572,11 @@ namespace GitHub.Runner.Worker
|
||||
Type = StepTelemetry?.Type,
|
||||
StartedAt = _record.StartTime,
|
||||
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 =>
|
||||
@@ -559,11 +622,22 @@ namespace GitHub.Runner.Worker
|
||||
|
||||
_logger.End();
|
||||
|
||||
UpdateGlobalStepsContext();
|
||||
if (!DeferOutcomeConclusion)
|
||||
{
|
||||
UpdateGlobalStepsContext();
|
||||
}
|
||||
|
||||
return Result.Value;
|
||||
}
|
||||
|
||||
public void FlushDeferredOutcomeConclusion()
|
||||
{
|
||||
if (DeferOutcomeConclusion)
|
||||
{
|
||||
UpdateGlobalStepsContext();
|
||||
}
|
||||
}
|
||||
|
||||
public void UpdateGlobalStepsContext()
|
||||
{
|
||||
// Skip if generated context name. Generated context names start with "__". After 3.2 the server will never send an empty context name.
|
||||
@@ -639,6 +713,40 @@ namespace GitHub.Runner.Worker
|
||||
Global.StepsContext.SetOutput(ScopeName, ContextName, name, value, out reference);
|
||||
}
|
||||
|
||||
public void FlushDeferredOutputs()
|
||||
{
|
||||
if (DeferredOutputs == null || DeferredOutputs.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var kvp in DeferredOutputs)
|
||||
{
|
||||
Global.StepsContext.SetOutput(ScopeName, ContextName, kvp.Key, kvp.Value, out _);
|
||||
}
|
||||
}
|
||||
|
||||
public void FlushDeferredEnvironment()
|
||||
{
|
||||
if (DeferredEnvironmentVariables != null)
|
||||
{
|
||||
foreach (var kvp in DeferredEnvironmentVariables)
|
||||
{
|
||||
Global.EnvironmentVariables[kvp.Key] = kvp.Value;
|
||||
SetEnvContext(kvp.Key, kvp.Value);
|
||||
}
|
||||
}
|
||||
|
||||
if (DeferredPrependPath != null)
|
||||
{
|
||||
foreach (var path in DeferredPrependPath)
|
||||
{
|
||||
Global.PrependPath.RemoveAll(x => string.Equals(x, path, StringComparison.CurrentCulture));
|
||||
Global.PrependPath.Add(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void SetTimeout(TimeSpan? timeout)
|
||||
{
|
||||
if (timeout != null)
|
||||
@@ -859,6 +967,12 @@ namespace GitHub.Runner.Worker
|
||||
// Track Node.js 20 actions for deprecation warning
|
||||
Global.DeprecatedNode20Actions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Track actions upgraded from Node.js 20 to Node.js 24
|
||||
Global.UpgradedToNode24Actions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Track actions stuck on Node.js 20 due to ARM32 (separate from general deprecation)
|
||||
Global.Arm32Node20Actions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Job Outputs
|
||||
JobOutputs = new Dictionary<string, VariableValue>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -874,6 +988,9 @@ namespace GitHub.Runner.Worker
|
||||
// File table
|
||||
Global.FileTable = new List<String>(message.FileTable ?? new string[0]);
|
||||
|
||||
// Workflow dependencies (lockfile pins)
|
||||
Global.ActionsDependencies = message.ActionsDependencies;
|
||||
|
||||
// What type of job request is running (i.e. Run Service vs. pipelines)
|
||||
Global.Variables.Set(Constants.Variables.System.JobRequestType, message.MessageType);
|
||||
|
||||
@@ -891,15 +1008,12 @@ namespace GitHub.Runner.Worker
|
||||
|
||||
Trace.Info("Initializing Job context");
|
||||
var jobContext = new JobContext();
|
||||
if (Global.Variables.GetBoolean(Constants.Runner.Features.AddCheckRunIdToJobContext) ?? false)
|
||||
ExpressionValues.TryGetValue("job", out var jobDictionary);
|
||||
if (jobDictionary != null)
|
||||
{
|
||||
ExpressionValues.TryGetValue("job", out var jobDictionary);
|
||||
if (jobDictionary != null)
|
||||
foreach (var pair in jobDictionary.AssertDictionary("job"))
|
||||
{
|
||||
foreach (var pair in jobDictionary.AssertDictionary("job"))
|
||||
{
|
||||
jobContext[pair.Key] = pair.Value;
|
||||
}
|
||||
jobContext[pair.Key] = pair.Value;
|
||||
}
|
||||
}
|
||||
ExpressionValues["job"] = jobContext;
|
||||
@@ -968,6 +1082,10 @@ namespace GitHub.Runner.Worker
|
||||
// Verbosity (from GitHub.Step_Debug).
|
||||
Global.WriteDebug = Global.Variables.Step_Debug ?? false;
|
||||
|
||||
// Debugger enabled flag (from acquire response).
|
||||
var overrideDebuggerWelcomeMessage = Global.Variables.GetBoolean(Constants.Runner.Features.OverrideDebuggerWelcomeMessage) ?? false;
|
||||
Global.Debugger = new Dap.DebuggerConfig(message.EnableDebugger, message.DebuggerTunnel, overrideDebuggerWelcomeMessage, message.DebuggerWelcomeMessage);
|
||||
|
||||
// Hook up JobServerQueueThrottling event, we will log warning on server tarpit.
|
||||
_jobServerQueue.JobServerQueueThrottling += JobServerQueueThrottling_EventReceived;
|
||||
}
|
||||
@@ -1325,12 +1443,15 @@ namespace GitHub.Runner.Worker
|
||||
Trace.Info($"Updated step result (continue on error)");
|
||||
}
|
||||
|
||||
UpdateGlobalStepsContext();
|
||||
if (!DeferOutcomeConclusion)
|
||||
{
|
||||
UpdateGlobalStepsContext();
|
||||
}
|
||||
}
|
||||
|
||||
internal IPipelineTemplateEvaluator ToPipelineTemplateEvaluatorInternal(ObjectTemplating.ITraceWriter traceWriter = null)
|
||||
internal IPipelineTemplateEvaluator ToPipelineTemplateEvaluatorInternal(bool allowServiceContainerCommand, ObjectTemplating.ITraceWriter traceWriter = null)
|
||||
{
|
||||
return new PipelineTemplateEvaluatorWrapper(HostContext, this, traceWriter);
|
||||
return new PipelineTemplateEvaluatorWrapper(HostContext, this, allowServiceContainerCommand, traceWriter);
|
||||
}
|
||||
|
||||
private static void NoOp()
|
||||
@@ -1418,10 +1539,13 @@ namespace GitHub.Runner.Worker
|
||||
|
||||
public static IPipelineTemplateEvaluator ToPipelineTemplateEvaluator(this IExecutionContext context, ObjectTemplating.ITraceWriter traceWriter = null)
|
||||
{
|
||||
var allowServiceContainerCommand = (context.Global.Variables.GetBoolean(Constants.Runner.Features.ServiceContainerCommand) ?? false)
|
||||
|| StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable("ACTIONS_SERVICE_CONTAINER_COMMAND"));
|
||||
|
||||
// Create wrapper?
|
||||
if ((context.Global.Variables.GetBoolean(Constants.Runner.Features.CompareWorkflowParser) ?? false) || StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable("ACTIONS_RUNNER_COMPARE_WORKFLOW_PARSER")))
|
||||
{
|
||||
return (context as ExecutionContext).ToPipelineTemplateEvaluatorInternal(traceWriter);
|
||||
return (context as ExecutionContext).ToPipelineTemplateEvaluatorInternal(allowServiceContainerCommand, traceWriter);
|
||||
}
|
||||
|
||||
// Legacy
|
||||
@@ -1433,6 +1557,7 @@ namespace GitHub.Runner.Worker
|
||||
return new PipelineTemplateEvaluator(traceWriter, schema, context.Global.FileTable)
|
||||
{
|
||||
MaxErrorMessageLength = int.MaxValue, // Don't truncate error messages otherwise we might not scrub secrets correctly
|
||||
AllowServiceContainerCommand = allowServiceContainerCommand,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -122,8 +122,16 @@ namespace GitHub.Runner.Worker
|
||||
{
|
||||
continue;
|
||||
}
|
||||
context.Global.PrependPath.RemoveAll(x => string.Equals(x, line, StringComparison.CurrentCulture));
|
||||
context.Global.PrependPath.Add(line);
|
||||
if (context.DeferredPrependPath != null)
|
||||
{
|
||||
context.DeferredPrependPath.RemoveAll(x => string.Equals(x, line, StringComparison.CurrentCulture));
|
||||
context.DeferredPrependPath.Add(line);
|
||||
}
|
||||
else
|
||||
{
|
||||
context.Global.PrependPath.RemoveAll(x => string.Equals(x, line, StringComparison.CurrentCulture));
|
||||
context.Global.PrependPath.Add(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,8 +180,15 @@ namespace GitHub.Runner.Worker
|
||||
string name,
|
||||
string value)
|
||||
{
|
||||
context.Global.EnvironmentVariables[name] = value;
|
||||
context.SetEnvContext(name, value);
|
||||
if (context.DeferredEnvironmentVariables != null)
|
||||
{
|
||||
context.DeferredEnvironmentVariables[name] = value;
|
||||
}
|
||||
else
|
||||
{
|
||||
context.Global.EnvironmentVariables[name] = value;
|
||||
context.SetEnvContext(name, value);
|
||||
}
|
||||
context.Debug($"{name}='{value}'");
|
||||
}
|
||||
|
||||
@@ -302,7 +317,14 @@ namespace GitHub.Runner.Worker
|
||||
var pairs = new EnvFileKeyValuePairs(context, filePath);
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ using GitHub.Actions.RunService.WebApi;
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using GitHub.Runner.Worker.Container;
|
||||
using GitHub.Runner.Worker.Dap;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Sdk.RSWebApi.Contracts;
|
||||
|
||||
@@ -27,6 +28,7 @@ namespace GitHub.Runner.Worker
|
||||
public StepsContext StepsContext { get; set; }
|
||||
public Variables Variables { get; set; }
|
||||
public bool WriteDebug { get; set; }
|
||||
public DebuggerConfig Debugger { get; set; }
|
||||
public string InfrastructureFailureCategory { get; set; }
|
||||
public JObject ContainerHookState { get; set; }
|
||||
public bool HasTemplateEvaluatorMismatch { get; set; }
|
||||
@@ -34,5 +36,8 @@ namespace GitHub.Runner.Worker
|
||||
public bool HasDeprecatedSetOutput { get; set; }
|
||||
public bool HasDeprecatedSaveState { get; set; }
|
||||
public HashSet<string> DeprecatedNode20Actions { get; set; }
|
||||
public HashSet<string> UpgradedToNode24Actions { get; set; }
|
||||
public HashSet<string> Arm32Node20Actions { get; set; }
|
||||
public IList<String> ActionsDependencies { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,7 +312,14 @@ namespace GitHub.Runner.Worker.Handlers
|
||||
// Emit start marker after full context setup so display name expressions resolve correctly
|
||||
if (emitCompositeMarkers)
|
||||
{
|
||||
step.TryUpdateDisplayName(out _);
|
||||
try
|
||||
{
|
||||
step.EvaluateDisplayName(step.ExecutionContext.ExpressionValues, step.ExecutionContext, out _);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Warning("Caught exception while evaluating embedded step display name. {0}", ex);
|
||||
}
|
||||
ExecutionContext.Output($"##[start-action display={EscapeProperty(SanitizeDisplayName(step.DisplayName))};id={EscapeProperty(markerId)}]");
|
||||
stepStopwatch = Stopwatch.StartNew();
|
||||
}
|
||||
|
||||
@@ -25,6 +25,14 @@ namespace GitHub.Runner.Worker.Handlers
|
||||
|
||||
public sealed class HandlerFactory : RunnerService, IHandlerFactory
|
||||
{
|
||||
internal static bool ShouldTrackAsArm32Node20(bool deprecateArm32, string preferredNodeVersion, string finalNodeVersion, string platformWarningMessage)
|
||||
{
|
||||
return deprecateArm32 &&
|
||||
!string.IsNullOrEmpty(platformWarningMessage) &&
|
||||
string.Equals(preferredNodeVersion, Constants.Runner.NodeMigration.Node24, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(finalNodeVersion, Constants.Runner.NodeMigration.Node20, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public IHandler Create(
|
||||
IExecutionContext executionContext,
|
||||
Pipelines.ActionStepDefinitionReference action,
|
||||
@@ -65,19 +73,12 @@ namespace GitHub.Runner.Worker.Handlers
|
||||
nodeData.NodeVersion = Common.Constants.Runner.NodeMigration.Node20;
|
||||
}
|
||||
|
||||
// Track Node.js 20 actions for deprecation annotation
|
||||
if (string.Equals(nodeData.NodeVersion, Constants.Runner.NodeMigration.Node20, StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
bool warnOnNode20 = executionContext.Global.Variables?.GetBoolean(Constants.Runner.NodeMigration.WarnOnNode20Flag) ?? false;
|
||||
if (warnOnNode20)
|
||||
{
|
||||
string actionName = GetActionName(action);
|
||||
if (!string.IsNullOrEmpty(actionName))
|
||||
{
|
||||
executionContext.Global.DeprecatedNode20Actions?.Add(actionName);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Read flags early; actionName is also resolved up front for tracking after version is determined
|
||||
bool warnOnNode20 = executionContext.Global.Variables?.GetBoolean(Constants.Runner.NodeMigration.WarnOnNode20Flag) ?? false;
|
||||
bool deprecateArm32 = executionContext.Global.Variables?.GetBoolean(Constants.Runner.NodeMigration.DeprecateLinuxArm32Flag) ?? false;
|
||||
bool killArm32 = executionContext.Global.Variables?.GetBoolean(Constants.Runner.NodeMigration.KillLinuxArm32Flag) ?? false;
|
||||
string node20RemovalDate = executionContext.Global.Variables?.Get(Constants.Runner.NodeMigration.Node20RemovalDateVariable);
|
||||
string actionName = GetActionName(action);
|
||||
|
||||
// Check if node20 was explicitly specified in the action
|
||||
// We don't modify if node24 was explicitly specified
|
||||
@@ -87,7 +88,15 @@ namespace GitHub.Runner.Worker.Handlers
|
||||
bool requireNode24 = executionContext.Global.Variables?.GetBoolean(Constants.Runner.NodeMigration.RequireNode24Flag) ?? false;
|
||||
|
||||
var (nodeVersion, configWarningMessage) = NodeUtil.DetermineActionsNodeVersion(environment, useNode24ByDefault, requireNode24);
|
||||
var (finalNodeVersion, platformWarningMessage) = NodeUtil.CheckNodeVersionForLinuxArm32(nodeVersion);
|
||||
var (finalNodeVersion, platformWarningMessage) = NodeUtil.CheckNodeVersionForLinuxArm32(nodeVersion, deprecateArm32, killArm32, node20RemovalDate);
|
||||
|
||||
// ARM32 kill switch: fail the step
|
||||
if (finalNodeVersion == null)
|
||||
{
|
||||
executionContext.Error(platformWarningMessage);
|
||||
throw new InvalidOperationException(platformWarningMessage);
|
||||
}
|
||||
|
||||
nodeData.NodeVersion = finalNodeVersion;
|
||||
|
||||
if (!string.IsNullOrEmpty(configWarningMessage))
|
||||
@@ -100,6 +109,26 @@ namespace GitHub.Runner.Worker.Handlers
|
||||
executionContext.Warning(platformWarningMessage);
|
||||
}
|
||||
|
||||
// Track actions based on their final node version
|
||||
if (!string.IsNullOrEmpty(actionName))
|
||||
{
|
||||
if (string.Equals(finalNodeVersion, Constants.Runner.NodeMigration.Node24, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Action was upgraded from node20 to node24
|
||||
executionContext.Global.UpgradedToNode24Actions?.Add(actionName);
|
||||
}
|
||||
else if (ShouldTrackAsArm32Node20(deprecateArm32, nodeVersion, finalNodeVersion, platformWarningMessage))
|
||||
{
|
||||
// Action is on node20 because ARM32 can't run node24
|
||||
executionContext.Global.Arm32Node20Actions?.Add(actionName);
|
||||
}
|
||||
else if (warnOnNode20)
|
||||
{
|
||||
// Action is still running on node20 (general case)
|
||||
executionContext.Global.DeprecatedNode20Actions?.Add(actionName);
|
||||
}
|
||||
}
|
||||
|
||||
// Show information about Node 24 migration in Phase 2
|
||||
if (useNode24ByDefault && !requireNode24 && string.Equals(finalNodeVersion, Constants.Runner.NodeMigration.Node24, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
@@ -109,6 +138,30 @@ namespace GitHub.Runner.Worker.Handlers
|
||||
executionContext.Output(infoMessage);
|
||||
}
|
||||
}
|
||||
else if (string.Equals(nodeData.NodeVersion, Constants.Runner.NodeMigration.Node24, StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
var (finalNodeVersion, platformWarningMessage) = NodeUtil.CheckNodeVersionForLinuxArm32(nodeData.NodeVersion, deprecateArm32, killArm32, node20RemovalDate);
|
||||
|
||||
// ARM32 kill switch: fail the step
|
||||
if (finalNodeVersion == null)
|
||||
{
|
||||
executionContext.Error(platformWarningMessage);
|
||||
throw new InvalidOperationException(platformWarningMessage);
|
||||
}
|
||||
|
||||
var preferredVersion = nodeData.NodeVersion;
|
||||
nodeData.NodeVersion = finalNodeVersion;
|
||||
|
||||
if (!string.IsNullOrEmpty(platformWarningMessage))
|
||||
{
|
||||
executionContext.Warning(platformWarningMessage);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(actionName) && ShouldTrackAsArm32Node20(deprecateArm32, preferredVersion, finalNodeVersion, platformWarningMessage))
|
||||
{
|
||||
executionContext.Global.Arm32Node20Actions?.Add(actionName);
|
||||
}
|
||||
}
|
||||
|
||||
(handler as INodeScriptActionHandler).Data = nodeData;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ using GitHub.Runner.Common.Util;
|
||||
using GitHub.Runner.Sdk;
|
||||
using GitHub.Runner.Worker.Container;
|
||||
using GitHub.Runner.Worker.Container.ContainerHooks;
|
||||
using GitHub.Services.Common;
|
||||
|
||||
namespace GitHub.Runner.Worker.Handlers
|
||||
{
|
||||
@@ -128,6 +129,15 @@ namespace GitHub.Runner.Worker.Handlers
|
||||
// file name character on Linux.
|
||||
string arguments = StepHost.ResolvePathForStepHost(ExecutionContext, StringUtil.Format(@"""{0}""", target.Replace(@"""", @"\""")));
|
||||
|
||||
// Disable maglev jit compiler in node.js 24.x.x on x64 Windows until the node.js bug is fixed.
|
||||
// https://github.com/nodejs/node/issues/62260
|
||||
if (nodeRuntimeVersion.StartsWith("node24", StringComparison.OrdinalIgnoreCase) &&
|
||||
(StringUtil.ConvertToBoolean(System.Environment.GetEnvironmentVariable("ACTIONS_RUNNER_DISABLE_NODE_MAGLEV")) || StringUtil.ConvertToBoolean(Environment.GetValueOrDefault("ACTIONS_RUNNER_DISABLE_NODE_MAGLEV"))))
|
||||
{
|
||||
Trace.Info("Disable maglev jit compiler in node.js");
|
||||
arguments = $"--no-maglev {arguments}";
|
||||
}
|
||||
|
||||
#if OS_WINDOWS
|
||||
// It appears that node.exe outputs UTF8 when not in TTY mode.
|
||||
Encoding outputEncoding = Encoding.UTF8;
|
||||
|
||||
@@ -58,13 +58,23 @@ namespace GitHub.Runner.Worker.Handlers
|
||||
|
||||
public Task<string> DetermineNodeRuntimeVersion(IExecutionContext executionContext, string preferredVersion)
|
||||
{
|
||||
// Use NodeUtil to check if Node24 is requested but we're on ARM32 Linux
|
||||
var (nodeVersion, warningMessage) = Common.Util.NodeUtil.CheckNodeVersionForLinuxArm32(preferredVersion);
|
||||
bool deprecateArm32 = executionContext.Global.Variables?.GetBoolean(Constants.Runner.NodeMigration.DeprecateLinuxArm32Flag) ?? false;
|
||||
bool killArm32 = executionContext.Global.Variables?.GetBoolean(Constants.Runner.NodeMigration.KillLinuxArm32Flag) ?? false;
|
||||
string node20RemovalDate = executionContext.Global.Variables?.Get(Constants.Runner.NodeMigration.Node20RemovalDateVariable);
|
||||
|
||||
var (nodeVersion, warningMessage) = Common.Util.NodeUtil.CheckNodeVersionForLinuxArm32(preferredVersion, deprecateArm32, killArm32, node20RemovalDate);
|
||||
|
||||
if (nodeVersion == null)
|
||||
{
|
||||
executionContext.Error(warningMessage);
|
||||
throw new InvalidOperationException(warningMessage);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(warningMessage))
|
||||
{
|
||||
executionContext.Warning(warningMessage);
|
||||
}
|
||||
|
||||
|
||||
return Task.FromResult(nodeVersion);
|
||||
}
|
||||
|
||||
@@ -142,8 +152,18 @@ namespace GitHub.Runner.Worker.Handlers
|
||||
|
||||
public async Task<string> DetermineNodeRuntimeVersion(IExecutionContext executionContext, string preferredVersion)
|
||||
{
|
||||
// Use NodeUtil to check if Node24 is requested but we're on ARM32 Linux
|
||||
var (nodeExternal, warningMessage) = Common.Util.NodeUtil.CheckNodeVersionForLinuxArm32(preferredVersion);
|
||||
bool deprecateArm32 = executionContext.Global.Variables?.GetBoolean(Constants.Runner.NodeMigration.DeprecateLinuxArm32Flag) ?? false;
|
||||
bool killArm32 = executionContext.Global.Variables?.GetBoolean(Constants.Runner.NodeMigration.KillLinuxArm32Flag) ?? false;
|
||||
string node20RemovalDate = executionContext.Global.Variables?.Get(Constants.Runner.NodeMigration.Node20RemovalDateVariable);
|
||||
|
||||
var (nodeExternal, warningMessage) = Common.Util.NodeUtil.CheckNodeVersionForLinuxArm32(preferredVersion, deprecateArm32, killArm32, node20RemovalDate);
|
||||
|
||||
if (nodeExternal == null)
|
||||
{
|
||||
executionContext.Error(warningMessage);
|
||||
throw new InvalidOperationException(warningMessage);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(warningMessage))
|
||||
{
|
||||
executionContext.Warning(warningMessage);
|
||||
@@ -273,8 +293,18 @@ namespace GitHub.Runner.Worker.Handlers
|
||||
|
||||
private string CheckPlatformForAlpineContainer(IExecutionContext executionContext, string preferredVersion)
|
||||
{
|
||||
// Use NodeUtil to check if Node24 is requested but we're on ARM32 Linux
|
||||
var (nodeExternal, warningMessage) = Common.Util.NodeUtil.CheckNodeVersionForLinuxArm32(preferredVersion);
|
||||
bool deprecateArm32 = executionContext.Global.Variables?.GetBoolean(Constants.Runner.NodeMigration.DeprecateLinuxArm32Flag) ?? false;
|
||||
bool killArm32 = executionContext.Global.Variables?.GetBoolean(Constants.Runner.NodeMigration.KillLinuxArm32Flag) ?? false;
|
||||
string node20RemovalDate = executionContext.Global.Variables?.Get(Constants.Runner.NodeMigration.Node20RemovalDateVariable);
|
||||
|
||||
var (nodeExternal, warningMessage) = Common.Util.NodeUtil.CheckNodeVersionForLinuxArm32(preferredVersion, deprecateArm32, killArm32, node20RemovalDate);
|
||||
|
||||
if (nodeExternal == null)
|
||||
{
|
||||
executionContext.Error(warningMessage);
|
||||
throw new InvalidOperationException(warningMessage);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(warningMessage))
|
||||
{
|
||||
executionContext.Warning(warningMessage);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("Test")]
|
||||
[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")]
|
||||
|
||||
@@ -82,5 +82,69 @@ namespace GitHub.Runner.Worker
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public string WorkflowRef
|
||||
{
|
||||
get
|
||||
{
|
||||
if (this.TryGetValue("workflow_ref", out var value) && value is StringContextData str)
|
||||
{
|
||||
return str.Value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
set
|
||||
{
|
||||
this["workflow_ref"] = value != null ? new StringContextData(value) : null;
|
||||
}
|
||||
}
|
||||
|
||||
public string WorkflowSha
|
||||
{
|
||||
get
|
||||
{
|
||||
if (this.TryGetValue("workflow_sha", out var value) && value is StringContextData str)
|
||||
{
|
||||
return str.Value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
set
|
||||
{
|
||||
this["workflow_sha"] = value != null ? new StringContextData(value) : null;
|
||||
}
|
||||
}
|
||||
|
||||
public string WorkflowRepository
|
||||
{
|
||||
get
|
||||
{
|
||||
if (this.TryGetValue("workflow_repository", out var value) && value is StringContextData str)
|
||||
{
|
||||
return str.Value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
set
|
||||
{
|
||||
this["workflow_repository"] = value != null ? new StringContextData(value) : null;
|
||||
}
|
||||
}
|
||||
|
||||
public string WorkflowFilePath
|
||||
{
|
||||
get
|
||||
{
|
||||
if (this.TryGetValue("workflow_file_path", out var value) && value is StringContextData str)
|
||||
{
|
||||
return str.Value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
set
|
||||
{
|
||||
this["workflow_file_path"] = value != null ? new StringContextData(value) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using GitHub.Runner.Sdk;
|
||||
using GitHub.Runner.Worker.Dap;
|
||||
using GitHub.Services.Common;
|
||||
using Newtonsoft.Json;
|
||||
using Pipelines = GitHub.DistributedTask.Pipelines;
|
||||
@@ -50,6 +51,7 @@ namespace GitHub.Runner.Worker
|
||||
private Task _diskSpaceCheckTask = null;
|
||||
private CancellationTokenSource _serviceConnectivityCheckToken = new();
|
||||
private Task _serviceConnectivityCheckTask = null;
|
||||
private IDapDebugger _dapDebugger;
|
||||
|
||||
// Download all required actions.
|
||||
// Make sure all condition inputs are valid.
|
||||
@@ -67,6 +69,7 @@ namespace GitHub.Runner.Worker
|
||||
|
||||
List<IStep> preJobSteps = new();
|
||||
List<IStep> jobSteps = new();
|
||||
var initSucceeded = false;
|
||||
using (var register = jobContext.CancellationToken.Register(() => { context.CancelToken(); }))
|
||||
{
|
||||
try
|
||||
@@ -77,20 +80,25 @@ namespace GitHub.Runner.Worker
|
||||
|
||||
var setting = HostContext.GetService<IConfigurationStore>().GetSettings();
|
||||
var credFile = HostContext.GetConfigFile(WellKnownConfigFile.Credentials);
|
||||
if (File.Exists(credFile))
|
||||
var credData = File.Exists(credFile) ? IOUtil.LoadObject<CredentialData>(credFile) : null;
|
||||
// self-hosted runner is the only runner type using OAuth, can be identified via clientId
|
||||
if (credData != null &&
|
||||
credData.Data.TryGetValue("clientId", out _))
|
||||
{
|
||||
var credData = IOUtil.LoadObject<CredentialData>(credFile);
|
||||
if (credData != null &&
|
||||
credData.Data.TryGetValue("clientId", out var clientId))
|
||||
context.Output($"Runner name: '{setting.AgentName}'");
|
||||
// use system variable for group name since self-hosted runners can be renamed
|
||||
if (message.Variables.TryGetValue("system.runnerGroupName", out VariableValue runnerGroupName))
|
||||
{
|
||||
// print out HostName for self-hosted runner
|
||||
context.Output($"Runner name: '{setting.AgentName}'");
|
||||
if (message.Variables.TryGetValue("system.runnerGroupName", out VariableValue runnerGroupName))
|
||||
{
|
||||
context.Output($"Runner group name: '{runnerGroupName.Value}'");
|
||||
}
|
||||
context.Output($"Machine name: '{Environment.MachineName}'");
|
||||
context.Output($"Runner group name: '{runnerGroupName.Value}'");
|
||||
}
|
||||
// print out machine name for self-hosted runner
|
||||
context.Output($"Machine name: '{Environment.MachineName}'");
|
||||
}
|
||||
// print runner info for lhr runners, skips standard runners (PoolId = 0)
|
||||
else if (setting.PoolId > 0 && !string.IsNullOrEmpty(setting.PoolName) && !string.IsNullOrEmpty(setting.AgentName))
|
||||
{
|
||||
context.Output($"Runner name: '{setting.AgentName}'");
|
||||
context.Output($"Runner group name: '{setting.PoolName}'");
|
||||
}
|
||||
|
||||
var setupInfoFile = HostContext.GetConfigFile(WellKnownConfigFile.SetupInfo);
|
||||
@@ -337,6 +345,38 @@ namespace GitHub.Runner.Worker
|
||||
preJobSteps.Add(preStep);
|
||||
}
|
||||
}
|
||||
else if (step.Type == Pipelines.StepType.BackgroundStepControl)
|
||||
{
|
||||
var ctrl = step as Pipelines.BackgroundStepControl;
|
||||
Trace.Info($"Adding {ctrl.ControlType} step for: {string.Join(", ", ctrl.StepIds ?? Array.Empty<string>())}");
|
||||
var controlType = ctrl.ControlType;
|
||||
if (string.IsNullOrEmpty(controlType))
|
||||
{
|
||||
throw new ArgumentException($"Background step control '{step.Name}' has no control type.");
|
||||
}
|
||||
if (controlType != Pipelines.BackgroundControlTypes.Wait &&
|
||||
controlType != Pipelines.BackgroundControlTypes.WaitAll &&
|
||||
controlType != Pipelines.BackgroundControlTypes.Cancel)
|
||||
{
|
||||
throw new ArgumentException($"Unknown background step control type '{controlType}' for step '{step.Name}'.");
|
||||
}
|
||||
var displayName = (ctrl.DisplayNameToken as GitHub.DistributedTask.ObjectTemplating.Tokens.StringToken)?.Value
|
||||
?? step.DisplayName ?? step.Name ?? ctrl.ControlType;
|
||||
var data = new BackgroundStepControlFlowData
|
||||
{
|
||||
Type = controlType,
|
||||
StepId = step.Id,
|
||||
StepName = step.Name,
|
||||
StepIds = ctrl.StepIds,
|
||||
ParallelGroupId = ctrl.ParallelGroupId,
|
||||
};
|
||||
var bgCoord = HostContext.GetService<IBackgroundStepCoordinator>();
|
||||
jobSteps.Add(new JobExtensionRunner(
|
||||
runAsync: bgCoord.RunControlFlowAsync,
|
||||
condition: $"{PipelineTemplateConstants.Always}()",
|
||||
displayName: displayName,
|
||||
data: data));
|
||||
}
|
||||
}
|
||||
|
||||
if (message.Variables.TryGetValue("system.workflowFileFullPath", out VariableValue workflowFileFullPath))
|
||||
@@ -392,13 +432,107 @@ namespace GitHub.Runner.Worker
|
||||
}
|
||||
|
||||
// Create execution context for job steps
|
||||
// Build mapping of logical step ID (ContextName) → external ID (timeline record GUID)
|
||||
// so wait/cancel steps can reference background steps by external ID.
|
||||
var contextNameToExternalId = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var hasBackgroundSteps = false;
|
||||
var backgroundStepExternalIds = new List<string>();
|
||||
|
||||
// Track which background steps are explicitly covered by wait/wait-all/cancel
|
||||
var coveredBackgroundIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var step in jobSteps)
|
||||
{
|
||||
if (step is IActionRunner actionStep)
|
||||
{
|
||||
ArgUtil.NotNull(actionStep, step.DisplayName);
|
||||
intraActionStates.TryGetValue(actionStep.Action.Id, out var intraActionState);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -476,6 +610,41 @@ namespace GitHub.Runner.Worker
|
||||
Trace.Info($"Start checking service connectivity in background.");
|
||||
_serviceConnectivityCheckTask = CheckServiceConnectivityAsync(context, _serviceConnectivityCheckToken.Token);
|
||||
|
||||
// Start the DAP debugger and wait for a client connection inside
|
||||
// "Set up job" so the step stays in-progress while we wait.
|
||||
if (jobContext.Global.Debugger?.Enabled == true)
|
||||
{
|
||||
Trace.Info("Debugger enabled — starting inside Set up job");
|
||||
context.Output("Starting debugger…");
|
||||
|
||||
try
|
||||
{
|
||||
_dapDebugger = HostContext.GetService<IDapDebugger>();
|
||||
await _dapDebugger.StartAsync(jobContext);
|
||||
|
||||
context.Output("Waiting for debugger client to connect…");
|
||||
|
||||
await _dapDebugger.WaitUntilReadyAsync();
|
||||
context.Output("Debugger connected.");
|
||||
AddDebuggerConnectionTelemetry(jobContext, "Connected");
|
||||
}
|
||||
catch (OperationCanceledException) when (jobContext.CancellationToken.IsCancellationRequested)
|
||||
{
|
||||
Trace.Info("Job was cancelled before debugger client connected.");
|
||||
AddDebuggerConnectionTelemetry(jobContext, "Canceled");
|
||||
context.Error("Job was cancelled before debugger client connected.");
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error($"DAP debugger failed: {ex.Message}");
|
||||
AddDebuggerConnectionTelemetry(jobContext, $"Failed: {ex.GetType().Name}");
|
||||
context.Error("The debugger failed to start or no debugger client connected in time.");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
initSucceeded = true;
|
||||
return steps;
|
||||
}
|
||||
catch (OperationCanceledException ex) when (jobContext.CancellationToken.IsCancellationRequested)
|
||||
@@ -496,12 +665,36 @@ namespace GitHub.Runner.Worker
|
||||
}
|
||||
finally
|
||||
{
|
||||
// If InitializeJob failed after the debugger was started,
|
||||
// tear down the transport here since FinalizeJob won't run.
|
||||
if (!initSucceeded && _dapDebugger != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _dapDebugger.StopAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Warning($"DAP debugger cleanup during failed init: {ex.Message}");
|
||||
}
|
||||
_dapDebugger = null;
|
||||
}
|
||||
|
||||
context.Debug("Finishing: Set up job");
|
||||
context.Complete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddDebuggerConnectionTelemetry(IExecutionContext jobContext, string result)
|
||||
{
|
||||
jobContext.Global.JobTelemetry.Add(new JobTelemetry
|
||||
{
|
||||
Type = JobTelemetryType.General,
|
||||
Message = $"DebuggerConnectionResult: {result}"
|
||||
});
|
||||
}
|
||||
|
||||
private string GetWorkflowReference(IDictionary<string, VariableValue> variables)
|
||||
{
|
||||
var reference = "";
|
||||
@@ -736,14 +929,38 @@ namespace GitHub.Runner.Worker
|
||||
}
|
||||
}
|
||||
|
||||
// Add deprecation warning annotation for Node.js 20 actions
|
||||
// Read dates from server variables with hardcoded fallbacks
|
||||
var node24DefaultDateRaw = context.Global.Variables?.Get(Constants.Runner.NodeMigration.Node24DefaultDateVariable);
|
||||
var node24DefaultDate = string.IsNullOrEmpty(node24DefaultDateRaw) ? Constants.Runner.NodeMigration.Node24DefaultDate : node24DefaultDateRaw;
|
||||
var node20RemovalDateRaw = context.Global.Variables?.Get(Constants.Runner.NodeMigration.Node20RemovalDateVariable);
|
||||
var node20RemovalDate = string.IsNullOrEmpty(node20RemovalDateRaw) ? Constants.Runner.NodeMigration.Node20RemovalDate : node20RemovalDateRaw;
|
||||
|
||||
// Add deprecation warning annotation for Node.js 20 actions (Phase 1 - actions still running on node20)
|
||||
if (context.Global.DeprecatedNode20Actions?.Count > 0)
|
||||
{
|
||||
var sortedActions = context.Global.DeprecatedNode20Actions.OrderBy(a => a, StringComparer.OrdinalIgnoreCase);
|
||||
var actionsList = string.Join(", ", sortedActions);
|
||||
var deprecationMessage = $"Node.js 20 actions are deprecated. The following actions are running on Node.js 20 and may not work as expected: {actionsList}. Actions will be forced to run with Node.js 24 by default starting June 2nd, 2026. Please check if updated versions of these actions are available that support Node.js 24. To opt into Node.js 24 now, set the FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true environment variable on the runner or in your workflow file. Once Node.js 24 becomes the default, you can temporarily opt out by setting ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true. For more information see: {Constants.Runner.NodeMigration.Node20DeprecationUrl}";
|
||||
var deprecationMessage = $"Node.js 20 actions are deprecated. The following actions are running on Node.js 20 and may not work as expected: {actionsList}. Actions will be forced to run with Node.js 24 by default starting {node24DefaultDate}. Node.js 20 will be removed from the runner on {node20RemovalDate}. Please check if updated versions of these actions are available that support Node.js 24. To opt into Node.js 24 now, set the FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true environment variable on the runner or in your workflow file. Once Node.js 24 becomes the default, you can temporarily opt out by setting ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true. For more information see: {Constants.Runner.NodeMigration.Node20DeprecationUrl}";
|
||||
context.Warning(deprecationMessage);
|
||||
}
|
||||
|
||||
// Add annotation for actions upgraded from Node.js 20 to Node.js 24 (Phase 2/3)
|
||||
if (context.Global.UpgradedToNode24Actions?.Count > 0)
|
||||
{
|
||||
var sortedActions = context.Global.UpgradedToNode24Actions.OrderBy(a => a, StringComparer.OrdinalIgnoreCase);
|
||||
var actionsList = string.Join(", ", sortedActions);
|
||||
var upgradeMessage = $"Node.js 20 is deprecated. The following actions target Node.js 20 but are being forced to run on Node.js 24: {actionsList}. For more information see: {Constants.Runner.NodeMigration.Node20DeprecationUrl}";
|
||||
context.Warning(upgradeMessage);
|
||||
}
|
||||
|
||||
// Add annotation for ARM32 actions stuck on Node.js 20 (ARM32 can't run node24)
|
||||
if (context.Global.Arm32Node20Actions?.Count > 0)
|
||||
{
|
||||
var sortedActions = context.Global.Arm32Node20Actions.OrderBy(a => a, StringComparer.OrdinalIgnoreCase);
|
||||
var actionsList = string.Join(", ", sortedActions);
|
||||
var arm32Message = $"The following actions are running on Node.js 20 because Node.js 24 is not available on Linux ARM32: {actionsList}. Linux ARM32 runners are deprecated and will no longer be supported after {node20RemovalDate}. Please migrate to a supported platform. For more information see: {Constants.Runner.NodeMigration.Node20DeprecationUrl}";
|
||||
context.Warning(arm32Message);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -753,6 +970,34 @@ namespace GitHub.Runner.Worker
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Pause for debugger inspection, then tear down the DAP session.
|
||||
// OnJobCompletedAsync pauses first, then sends terminated/exited
|
||||
// events and stops the transport.
|
||||
if (_dapDebugger != null)
|
||||
{
|
||||
context.Output("Job completed — pausing for debugger inspection. Press continue to finish.");
|
||||
try
|
||||
{
|
||||
await _dapDebugger.OnJobCompletedAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Warning($"DAP debugger completion error: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
await _dapDebugger.StopAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Warning($"DAP debugger stop error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
_dapDebugger = null;
|
||||
}
|
||||
|
||||
context.Debug("Finishing: Complete job");
|
||||
context.Complete();
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using GitHub.Runner.Sdk;
|
||||
using GitHub.Runner.Worker.Dap;
|
||||
using GitHub.Services.Common;
|
||||
using GitHub.Services.WebApi;
|
||||
using Sdk.RSWebApi.Contracts;
|
||||
@@ -178,6 +179,7 @@ namespace GitHub.Runner.Worker
|
||||
_tempDirectoryManager = HostContext.GetService<ITempDirectoryManager>();
|
||||
_tempDirectoryManager.InitializeTempDirectory(jobContext);
|
||||
|
||||
|
||||
// Get the job extension.
|
||||
Trace.Info("Getting job extension.");
|
||||
IJobExtension jobExtension = HostContext.CreateService<IJobExtension>();
|
||||
@@ -229,6 +231,12 @@ namespace GitHub.Runner.Worker
|
||||
jobContext.JobSteps.Enqueue(step);
|
||||
}
|
||||
|
||||
if (jobContext.Global.Debugger?.Enabled == true)
|
||||
{
|
||||
var dapDebugger = HostContext.GetService<IDapDebugger>();
|
||||
await dapDebugger.OnJobStepsInitializedAsync(jobContext.JobSteps, jobContext.PostJobSteps);
|
||||
}
|
||||
|
||||
await stepsRunner.RunAsync(jobContext);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using GitHub.Actions.WorkflowParser;
|
||||
using GitHub.DistributedTask.Expressions2;
|
||||
using GitHub.DistributedTask.ObjectTemplating.Tokens;
|
||||
@@ -23,6 +24,7 @@ namespace GitHub.Runner.Worker
|
||||
public PipelineTemplateEvaluatorWrapper(
|
||||
IHostContext hostContext,
|
||||
IExecutionContext context,
|
||||
bool allowServiceContainerCommand,
|
||||
ObjectTemplating.ITraceWriter traceWriter = null)
|
||||
{
|
||||
ArgUtil.NotNull(hostContext, nameof(hostContext));
|
||||
@@ -40,11 +42,14 @@ namespace GitHub.Runner.Worker
|
||||
_legacyEvaluator = new PipelineTemplateEvaluator(traceWriter, schema, context.Global.FileTable)
|
||||
{
|
||||
MaxErrorMessageLength = int.MaxValue, // Don't truncate error messages otherwise we might not scrub secrets correctly
|
||||
AllowServiceContainerCommand = allowServiceContainerCommand,
|
||||
};
|
||||
|
||||
// New evaluator
|
||||
var newTraceWriter = new GitHub.Actions.WorkflowParser.ObjectTemplating.EmptyTraceWriter();
|
||||
_newEvaluator = new WorkflowTemplateEvaluator(newTraceWriter, context.Global.FileTable, features: null)
|
||||
var features = WorkflowFeatures.GetDefaults();
|
||||
features.AllowServiceContainerCommand = allowServiceContainerCommand;
|
||||
_newEvaluator = new WorkflowTemplateEvaluator(newTraceWriter, context.Global.FileTable, features)
|
||||
{
|
||||
MaxErrorMessageLength = int.MaxValue, // Don't truncate error messages otherwise we might not scrub secrets correctly
|
||||
};
|
||||
@@ -222,8 +227,12 @@ namespace GitHub.Runner.Worker
|
||||
Func<TNew> newEvaluator,
|
||||
Func<TLegacy, TNew, bool> resultComparer)
|
||||
{
|
||||
// Capture cancellation state before evaluation
|
||||
var cancellationRequestedBefore = _context.CancellationToken.IsCancellationRequested;
|
||||
// Use the root (job-level) cancellation token to detect cancellation race conditions.
|
||||
// The step-level token only fires on step timeout, not on job cancellation.
|
||||
// Job cancellation mutates JobContext.Status which expression functions read,
|
||||
// so we need the root token to properly detect cancellation between evaluator runs.
|
||||
var rootCancellationToken = _context.Root?.CancellationToken ?? CancellationToken.None;
|
||||
var cancellationRequestedBefore = rootCancellationToken.IsCancellationRequested;
|
||||
|
||||
// Legacy evaluator
|
||||
var legacyException = default(Exception);
|
||||
@@ -257,7 +266,7 @@ namespace GitHub.Runner.Worker
|
||||
}
|
||||
|
||||
// Capture cancellation state after evaluation
|
||||
var cancellationRequestedAfter = _context.CancellationToken.IsCancellationRequested;
|
||||
var cancellationRequestedAfter = rootCancellationToken.IsCancellationRequested;
|
||||
|
||||
// Compare results or exceptions
|
||||
bool hasMismatch = false;
|
||||
@@ -401,6 +410,18 @@ namespace GitHub.Runner.Worker
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(legacyResult.Entrypoint, newResult.Entrypoint, StringComparison.Ordinal))
|
||||
{
|
||||
_trace.Info($"CompareJobContainer mismatch - Entrypoint differs (legacy='{legacyResult.Entrypoint}', new='{newResult.Entrypoint}')");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(legacyResult.Command, newResult.Command, StringComparison.Ordinal))
|
||||
{
|
||||
_trace.Info($"CompareJobContainer mismatch - Command differs (legacy='{legacyResult.Command}', new='{newResult.Command}')");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!CompareDictionaries(legacyResult.Environment, newResult.Environment, "Environment"))
|
||||
{
|
||||
return false;
|
||||
|
||||
@@ -19,10 +19,11 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
|
||||
<PackageReference Include="System.ServiceProcess.ServiceController" Version="8.0.1" />
|
||||
<PackageReference Include="System.Threading.Channels" Version="8.0.0" />
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.3" />
|
||||
<PackageReference Include="System.ServiceProcess.ServiceController" Version="10.0.3" />
|
||||
<PackageReference Include="System.Threading.Channels" Version="10.0.3" />
|
||||
<PackageReference Include="YamlDotNet.Signed" Version="5.3.0" />
|
||||
<PackageReference Include="Microsoft.DevTunnels.Connections" Version="1.3.48" />
|
||||
</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 readonly DictionaryContextData _contextData = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Clears memory for a composite action's isolated "steps" context, after the action
|
||||
@@ -25,9 +26,12 @@ namespace GitHub.Runner.Worker
|
||||
/// </summary>
|
||||
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>
|
||||
public DictionaryContextData GetScope(string scopeName)
|
||||
{
|
||||
if (scopeName == null)
|
||||
lock (_lock)
|
||||
{
|
||||
scopeName = string.Empty;
|
||||
}
|
||||
if (scopeName == null)
|
||||
{
|
||||
scopeName = string.Empty;
|
||||
}
|
||||
|
||||
var scope = default(DictionaryContextData);
|
||||
if (_contextData.TryGetValue(scopeName, out var scopeValue))
|
||||
{
|
||||
scope = scopeValue.AssertDictionary("scope");
|
||||
}
|
||||
else
|
||||
{
|
||||
scope = new DictionaryContextData();
|
||||
_contextData.Add(scopeName, scope);
|
||||
}
|
||||
var scope = default(DictionaryContextData);
|
||||
if (_contextData.TryGetValue(scopeName, out var scopeValue))
|
||||
{
|
||||
scope = scopeValue.AssertDictionary("scope");
|
||||
}
|
||||
else
|
||||
{
|
||||
scope = new DictionaryContextData();
|
||||
_contextData.Add(scopeName, scope);
|
||||
}
|
||||
|
||||
return scope;
|
||||
return scope;
|
||||
}
|
||||
}
|
||||
|
||||
public void SetOutput(
|
||||
@@ -67,16 +74,19 @@ namespace GitHub.Runner.Worker
|
||||
string value,
|
||||
out string reference)
|
||||
{
|
||||
var step = GetStep(scopeName, stepName);
|
||||
var outputs = step["outputs"].AssertDictionary("outputs");
|
||||
outputs[outputName] = new StringContextData(value);
|
||||
if (_propertyRegex.IsMatch(outputName))
|
||||
lock (_lock)
|
||||
{
|
||||
reference = $"steps.{stepName}.outputs.{outputName}";
|
||||
}
|
||||
else
|
||||
{
|
||||
reference = $"steps['{stepName}']['outputs']['{outputName}']";
|
||||
var step = GetStep(scopeName, stepName);
|
||||
var outputs = step["outputs"].AssertDictionary("outputs");
|
||||
outputs[outputName] = new StringContextData(value);
|
||||
if (_propertyRegex.IsMatch(outputName))
|
||||
{
|
||||
reference = $"steps.{stepName}.outputs.{outputName}";
|
||||
}
|
||||
else
|
||||
{
|
||||
reference = $"steps['{stepName}']['outputs']['{outputName}']";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,8 +95,11 @@ namespace GitHub.Runner.Worker
|
||||
string stepName,
|
||||
ActionResult conclusion)
|
||||
{
|
||||
var step = GetStep(scopeName, stepName);
|
||||
step["conclusion"] = new StringContextData(conclusion.ToString().ToLowerInvariant());
|
||||
lock (_lock)
|
||||
{
|
||||
var step = GetStep(scopeName, stepName);
|
||||
step["conclusion"] = new StringContextData(conclusion.ToString().ToLowerInvariant());
|
||||
}
|
||||
}
|
||||
|
||||
public void SetOutcome(
|
||||
@@ -94,8 +107,11 @@ namespace GitHub.Runner.Worker
|
||||
string stepName,
|
||||
ActionResult outcome)
|
||||
{
|
||||
var step = GetStep(scopeName, stepName);
|
||||
step["outcome"] = new StringContextData(outcome.ToString().ToLowerInvariant());
|
||||
lock (_lock)
|
||||
{
|
||||
var step = GetStep(scopeName, stepName);
|
||||
step["outcome"] = new StringContextData(outcome.ToString().ToLowerInvariant());
|
||||
}
|
||||
}
|
||||
|
||||
private DictionaryContextData GetStep(string scopeName, string stepName)
|
||||
|
||||
@@ -10,6 +10,7 @@ using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using GitHub.Runner.Sdk;
|
||||
using GitHub.Runner.Worker.Dap;
|
||||
using GitHub.Runner.Worker.Expressions;
|
||||
|
||||
namespace GitHub.Runner.Worker
|
||||
@@ -40,6 +41,8 @@ namespace GitHub.Runner.Worker
|
||||
ArgUtil.NotNull(jobContext, nameof(jobContext));
|
||||
ArgUtil.NotNull(jobContext.JobSteps, nameof(jobContext.JobSteps));
|
||||
|
||||
var _bgCoordinator = HostContext.GetService<IBackgroundStepCoordinator>();
|
||||
|
||||
// TaskResult:
|
||||
// Abandoned (Server set this.)
|
||||
// Canceled
|
||||
@@ -50,11 +53,21 @@ namespace GitHub.Runner.Worker
|
||||
jobContext.JobContext.Status = (jobContext.Result ?? TaskResult.Succeeded).ToActionResult();
|
||||
var scopeInputs = new Dictionary<string, PipelineContextData>(StringComparer.OrdinalIgnoreCase);
|
||||
bool checkPostJobActions = false;
|
||||
var dapDebugger = HostContext.GetService<IDapDebugger>();
|
||||
while (jobContext.JobSteps.Count > 0 || !checkPostJobActions)
|
||||
{
|
||||
if (jobContext.JobSteps.Count == 0 && !checkPostJobActions)
|
||||
{
|
||||
checkPostJobActions = true;
|
||||
|
||||
// Safety net: wait for any unwaited background steps before post-hooks
|
||||
var backgroundResult = await _bgCoordinator.WaitForUnwaitedStepsAsync(jobContext.CancellationToken);
|
||||
if (backgroundResult != TaskResult.Succeeded)
|
||||
{
|
||||
jobContext.Result = TaskResultUtil.MergeTaskResults(jobContext.Result, backgroundResult);
|
||||
jobContext.JobContext.Status = jobContext.Result?.ToActionResult();
|
||||
}
|
||||
|
||||
while (jobContext.PostJobSteps.TryPop(out var postStep))
|
||||
{
|
||||
jobContext.JobSteps.Enqueue(postStep);
|
||||
@@ -70,8 +83,11 @@ namespace GitHub.Runner.Worker
|
||||
ArgUtil.NotNull(step.ExecutionContext.Global, nameof(step.ExecutionContext.Global));
|
||||
ArgUtil.NotNull(step.ExecutionContext.Global.Variables, nameof(step.ExecutionContext.Global.Variables));
|
||||
|
||||
// Start
|
||||
step.ExecutionContext.Start();
|
||||
// Start — defer for background steps until the slot is acquired
|
||||
if (!step.ExecutionContext.IsBackground)
|
||||
{
|
||||
step.ExecutionContext.Start();
|
||||
}
|
||||
|
||||
// Expression functions
|
||||
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<AlwaysFunction>(PipelineTemplateConstants.Always, 0, 0));
|
||||
@@ -226,9 +242,22 @@ namespace GitHub.Runner.Worker
|
||||
}
|
||||
else
|
||||
{
|
||||
// Run the step
|
||||
await RunStepAsync(step, jobContext.CancellationToken);
|
||||
CompleteStep(step);
|
||||
if (step.ExecutionContext.IsBackground)
|
||||
{
|
||||
// Queue the background step via coordinator
|
||||
_bgCoordinator.StartBackgroundStep(step, jobContext.CancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Pause for DAP debugger before step execution
|
||||
await dapDebugger?.OnStepStartingAsync(step);
|
||||
|
||||
// Run the step synchronously (normal behavior)
|
||||
await RunStepAsync(step, jobContext.CancellationToken);
|
||||
CompleteStep(step);
|
||||
|
||||
dapDebugger?.OnStepCompleted(step);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
@@ -255,6 +284,7 @@ namespace GitHub.Runner.Worker
|
||||
|
||||
Trace.Info($"Current state: job state = '{jobContext.Result}'");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private async Task RunStepAsync(IStep step, CancellationToken jobCancellationToken)
|
||||
|
||||
@@ -17,10 +17,9 @@ namespace GitHub.DistributedTask.Expressions2
|
||||
String expression,
|
||||
ITraceWriter trace,
|
||||
IEnumerable<INamedValueInfo> namedValues,
|
||||
IEnumerable<IFunctionInfo> functions,
|
||||
Boolean allowCaseFunction = true)
|
||||
IEnumerable<IFunctionInfo> functions)
|
||||
{
|
||||
var context = new ParseContext(expression, trace, namedValues, functions, allowCaseFunction);
|
||||
var context = new ParseContext(expression, trace, namedValues, functions);
|
||||
context.Trace.Info($"Parsing expression: <{expression}>");
|
||||
return CreateTree(context);
|
||||
}
|
||||
@@ -416,12 +415,6 @@ namespace GitHub.DistributedTask.Expressions2
|
||||
String name,
|
||||
out IFunctionInfo functionInfo)
|
||||
{
|
||||
if (String.Equals(name, "case", StringComparison.OrdinalIgnoreCase) && !context.AllowCaseFunction)
|
||||
{
|
||||
functionInfo = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
return ExpressionConstants.WellKnownFunctions.TryGetValue(name, out functionInfo) ||
|
||||
context.ExtensionFunctions.TryGetValue(name, out functionInfo);
|
||||
}
|
||||
@@ -429,7 +422,6 @@ namespace GitHub.DistributedTask.Expressions2
|
||||
private sealed class ParseContext
|
||||
{
|
||||
public Boolean AllowUnknownKeywords;
|
||||
public Boolean AllowCaseFunction;
|
||||
public readonly String Expression;
|
||||
public readonly Dictionary<String, IFunctionInfo> ExtensionFunctions = new Dictionary<String, IFunctionInfo>(StringComparer.OrdinalIgnoreCase);
|
||||
public readonly Dictionary<String, INamedValueInfo> ExtensionNamedValues = new Dictionary<String, INamedValueInfo>(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -445,8 +437,7 @@ namespace GitHub.DistributedTask.Expressions2
|
||||
ITraceWriter trace,
|
||||
IEnumerable<INamedValueInfo> namedValues,
|
||||
IEnumerable<IFunctionInfo> functions,
|
||||
Boolean allowUnknownKeywords = false,
|
||||
Boolean allowCaseFunction = true)
|
||||
Boolean allowUnknownKeywords = false)
|
||||
{
|
||||
Expression = expression ?? String.Empty;
|
||||
if (Expression.Length > ExpressionConstants.MaxLength)
|
||||
@@ -467,7 +458,6 @@ namespace GitHub.DistributedTask.Expressions2
|
||||
|
||||
LexicalAnalyzer = new LexicalAnalyzer(Expression);
|
||||
AllowUnknownKeywords = allowUnknownKeywords;
|
||||
AllowCaseFunction = allowCaseFunction;
|
||||
}
|
||||
|
||||
private class NoOperationTraceWriter : ITraceWriter
|
||||
|
||||
@@ -86,12 +86,6 @@ namespace GitHub.DistributedTask.ObjectTemplating
|
||||
|
||||
internal ITraceWriter TraceWriter { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the case expression function is allowed.
|
||||
/// Defaults to true. Set to false to disable the case function.
|
||||
/// </summary>
|
||||
internal Boolean AllowCaseFunction { get; set; } = true;
|
||||
|
||||
private IDictionary<String, Int32> FileIds
|
||||
{
|
||||
get
|
||||
|
||||
@@ -57,7 +57,7 @@ namespace GitHub.DistributedTask.ObjectTemplating.Tokens
|
||||
var originalBytes = context.Memory.CurrentBytes;
|
||||
try
|
||||
{
|
||||
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction);
|
||||
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions);
|
||||
var options = new EvaluationOptions
|
||||
{
|
||||
MaxMemory = context.Memory.MaxBytes,
|
||||
@@ -94,7 +94,7 @@ namespace GitHub.DistributedTask.ObjectTemplating.Tokens
|
||||
var originalBytes = context.Memory.CurrentBytes;
|
||||
try
|
||||
{
|
||||
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction);
|
||||
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions);
|
||||
var options = new EvaluationOptions
|
||||
{
|
||||
MaxMemory = context.Memory.MaxBytes,
|
||||
@@ -123,7 +123,7 @@ namespace GitHub.DistributedTask.ObjectTemplating.Tokens
|
||||
var originalBytes = context.Memory.CurrentBytes;
|
||||
try
|
||||
{
|
||||
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction);
|
||||
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions);
|
||||
var options = new EvaluationOptions
|
||||
{
|
||||
MaxMemory = context.Memory.MaxBytes,
|
||||
@@ -152,7 +152,7 @@ namespace GitHub.DistributedTask.ObjectTemplating.Tokens
|
||||
var originalBytes = context.Memory.CurrentBytes;
|
||||
try
|
||||
{
|
||||
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction);
|
||||
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions);
|
||||
var options = new EvaluationOptions
|
||||
{
|
||||
MaxMemory = context.Memory.MaxBytes,
|
||||
|
||||
@@ -25,6 +25,7 @@ namespace GitHub.DistributedTask.Pipelines
|
||||
Inputs = actionToClone.Inputs?.Clone();
|
||||
ContextName = actionToClone?.ContextName;
|
||||
DisplayNameToken = actionToClone.DisplayNameToken?.Clone();
|
||||
Background = actionToClone.Background;
|
||||
}
|
||||
|
||||
public override StepType Type => StepType.Action;
|
||||
@@ -49,6 +50,9 @@ namespace GitHub.DistributedTask.Pipelines
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public TemplateToken Inputs { get; set; }
|
||||
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public bool Background { get; set; }
|
||||
|
||||
public override Step Clone()
|
||||
{
|
||||
return new ActionStep(this);
|
||||
|
||||
@@ -253,6 +253,50 @@ namespace GitHub.DistributedTask.Pipelines
|
||||
set;
|
||||
}
|
||||
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public bool EnableDebugger
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public DebuggerTunnelInfo DebuggerTunnel
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optional welcome message shown in the debugger console when a client connects.
|
||||
/// Only used when the <c>actions_runner_override_debugger_welcome_message</c>
|
||||
/// feature flag is set to <c>true</c> in the job variables. With the flag set,
|
||||
/// a non-empty value is shown as-is and a null or empty value suppresses the
|
||||
/// default welcome message. When the flag is not set, the runner shows its
|
||||
/// built-in help text and this field is ignored.
|
||||
/// </summary>
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public string DebuggerWelcomeMessage
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the workflow-level action dependencies (lockfile entries)
|
||||
/// </summary>
|
||||
public IList<String> ActionsDependencies
|
||||
{
|
||||
get
|
||||
{
|
||||
if (m_actionsDependencies == null)
|
||||
{
|
||||
m_actionsDependencies = new List<String>();
|
||||
}
|
||||
return m_actionsDependencies;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the collection of variables associated with the current context.
|
||||
/// </summary>
|
||||
@@ -427,6 +471,11 @@ namespace GitHub.DistributedTask.Pipelines
|
||||
m_variables = null;
|
||||
}
|
||||
|
||||
if (m_actionsDependencies?.Count == 0)
|
||||
{
|
||||
m_actionsDependencies = null;
|
||||
}
|
||||
|
||||
// todo: remove after feature-flag DistributedTask.EvaluateContainerOnRunner is enabled everywhere
|
||||
if (!string.IsNullOrEmpty(m_jobContainerResourceAlias))
|
||||
{
|
||||
@@ -452,6 +501,9 @@ namespace GitHub.DistributedTask.Pipelines
|
||||
[DataMember(Name = "Variables", EmitDefaultValue = false)]
|
||||
private IDictionary<String, VariableValue> m_variables;
|
||||
|
||||
[DataMember(Name = "dependencies", EmitDefaultValue = false)]
|
||||
private List<String> m_actionsDependencies;
|
||||
|
||||
// todo: remove after feature-flag DistributedTask.EvaluateContainerOnRunner is enabled everywhere
|
||||
[DataMember(Name = "JobSidecarContainers", EmitDefaultValue = false)]
|
||||
private IDictionary<String, String> m_jobSidecarContainers;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
24
src/Sdk/DTPipelines/Pipelines/DebuggerTunnelInfo.cs
Normal file
24
src/Sdk/DTPipelines/Pipelines/DebuggerTunnelInfo.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using System.Runtime.Serialization;
|
||||
|
||||
namespace GitHub.DistributedTask.Pipelines
|
||||
{
|
||||
/// <summary>
|
||||
/// Dev Tunnel information the runner needs to host the debugger tunnel.
|
||||
/// Matches the run-service <c>DebuggerTunnel</c> contract.
|
||||
/// </summary>
|
||||
[DataContract]
|
||||
public sealed class DebuggerTunnelInfo
|
||||
{
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public string TunnelId { get; set; }
|
||||
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public string ClusterId { get; set; }
|
||||
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public string HostToken { get; set; }
|
||||
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public ushort Port { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ namespace GitHub.DistributedTask.Pipelines.Expressions
|
||||
public const String Email = nameof(Email);
|
||||
public const String IPv4Address = nameof(IPv4Address);
|
||||
public const String SHA1 = nameof(SHA1);
|
||||
public const String CommitHash = nameof(CommitHash);
|
||||
public const String Url = nameof(Url);
|
||||
|
||||
/// <summary>
|
||||
@@ -24,7 +25,8 @@ namespace GitHub.DistributedTask.Pipelines.Expressions
|
||||
case IPv4Address:
|
||||
return s_validIPv4Address;
|
||||
case SHA1:
|
||||
return s_validSha1;
|
||||
case CommitHash:
|
||||
return s_validCommitHash;
|
||||
case Url:
|
||||
return s_validUrl;
|
||||
default:
|
||||
@@ -46,9 +48,9 @@ namespace GitHub.DistributedTask.Pipelines.Expressions
|
||||
)
|
||||
);
|
||||
|
||||
// 40 hex characters
|
||||
private static readonly Lazy<Regex> s_validSha1 = new Lazy<Regex>(() => new Regex(
|
||||
@"\b[0-9a-f]{40}\b",
|
||||
// 40 or 64 hex characters (SHA-1 or SHA-256 commit hash)
|
||||
private static readonly Lazy<Regex> s_validCommitHash = new Lazy<Regex>(() => new Regex(
|
||||
@"\b(?:[0-9a-f]{40}|[0-9a-f]{64})\b",
|
||||
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled, RegexUtility.GetRegexTimeOut()
|
||||
)
|
||||
);
|
||||
|
||||
@@ -39,6 +39,24 @@ namespace GitHub.DistributedTask.Pipelines
|
||||
set;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the container entrypoint override.
|
||||
/// </summary>
|
||||
public String Entrypoint
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the container command and args (after the image name).
|
||||
/// </summary>
|
||||
public String Command
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the volumes which are mounted into the container.
|
||||
/// </summary>
|
||||
|
||||
@@ -22,6 +22,7 @@ namespace GitHub.DistributedTask.Pipelines
|
||||
this.Condition = stepToClone.Condition;
|
||||
this.ContinueOnError = stepToClone.ContinueOnError?.Clone();
|
||||
this.TimeoutInMinutes = stepToClone.TimeoutInMinutes?.Clone();
|
||||
this.ParallelGroupId = stepToClone.ParallelGroupId;
|
||||
}
|
||||
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
@@ -44,5 +45,8 @@ namespace GitHub.DistributedTask.Pipelines
|
||||
get;
|
||||
set;
|
||||
}
|
||||
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public string ParallelGroupId { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,8 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
|
||||
public const String NumberStrategyContext = "number-strategy-context";
|
||||
public const String On = "on";
|
||||
public const String Options = "options";
|
||||
public const String Entrypoint = "entrypoint";
|
||||
public const String Command = "command";
|
||||
public const String Outputs = "outputs";
|
||||
public const String OutputsPattern = "needs.*.outputs";
|
||||
public const String Password = "password";
|
||||
|
||||
@@ -55,7 +55,18 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
|
||||
break;
|
||||
case ActionSourceType.Repository:
|
||||
var repositoryReference = step.Reference as RepositoryPathReference;
|
||||
name = !String.IsNullOrEmpty(repositoryReference.Name) ? repositoryReference.Name : PipelineConstants.SelfAlias;
|
||||
if (!String.IsNullOrEmpty(repositoryReference.Name))
|
||||
{
|
||||
name = repositoryReference.Name;
|
||||
}
|
||||
else if (String.Equals(repositoryReference.RepositoryType, PipelineConstants.SelfRepositoryAlias, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
name = PipelineConstants.SelfRepositoryAlias;
|
||||
}
|
||||
else
|
||||
{
|
||||
name = PipelineConstants.SelfAlias;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -237,7 +248,8 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
|
||||
internal static JobContainer ConvertToJobContainer(
|
||||
TemplateContext context,
|
||||
TemplateToken value,
|
||||
bool allowExpressions = false)
|
||||
bool allowExpressions = false,
|
||||
bool allowServiceContainerCommand = false)
|
||||
{
|
||||
var result = new JobContainer();
|
||||
if (allowExpressions && value.Traverse().Any(x => x is ExpressionToken))
|
||||
@@ -280,6 +292,22 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
|
||||
case PipelineTemplateConstants.Options:
|
||||
result.Options = containerPropertyPair.Value.AssertString($"{PipelineTemplateConstants.Container} {propertyName}").Value;
|
||||
break;
|
||||
case PipelineTemplateConstants.Entrypoint:
|
||||
if (!allowServiceContainerCommand)
|
||||
{
|
||||
context.Error(containerPropertyPair.Key, $"The key '{PipelineTemplateConstants.Entrypoint}' is not allowed");
|
||||
break;
|
||||
}
|
||||
result.Entrypoint = containerPropertyPair.Value.AssertString($"{PipelineTemplateConstants.Container} {propertyName}").Value;
|
||||
break;
|
||||
case PipelineTemplateConstants.Command:
|
||||
if (!allowServiceContainerCommand)
|
||||
{
|
||||
context.Error(containerPropertyPair.Key, $"The key '{PipelineTemplateConstants.Command}' is not allowed");
|
||||
break;
|
||||
}
|
||||
result.Command = containerPropertyPair.Value.AssertString($"{PipelineTemplateConstants.Container} {propertyName}").Value;
|
||||
break;
|
||||
case PipelineTemplateConstants.Ports:
|
||||
var ports = containerPropertyPair.Value.AssertSequence($"{PipelineTemplateConstants.Container} {propertyName}");
|
||||
var portList = new List<String>(ports.Count);
|
||||
@@ -326,7 +354,8 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
|
||||
internal static List<KeyValuePair<String, JobContainer>> ConvertToJobServiceContainers(
|
||||
TemplateContext context,
|
||||
TemplateToken services,
|
||||
bool allowExpressions = false)
|
||||
bool allowExpressions = false,
|
||||
bool allowServiceContainerCommand = false)
|
||||
{
|
||||
var result = new List<KeyValuePair<String, JobContainer>>();
|
||||
|
||||
@@ -340,7 +369,7 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
|
||||
foreach (var servicePair in servicesMapping)
|
||||
{
|
||||
var networkAlias = servicePair.Key.AssertString("services key").Value;
|
||||
var container = ConvertToJobContainer(context, servicePair.Value);
|
||||
var container = ConvertToJobContainer(context, servicePair.Value, allowExpressions, allowServiceContainerCommand);
|
||||
result.Add(new KeyValuePair<String, JobContainer>(networkAlias, container));
|
||||
}
|
||||
|
||||
@@ -582,6 +611,14 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
|
||||
Path = uses.Value
|
||||
};
|
||||
}
|
||||
else if (PipelineConstants.TryParseSelfRepository(uses.Value, out var selfPath))
|
||||
{
|
||||
result.Reference = new RepositoryPathReference
|
||||
{
|
||||
RepositoryType = PipelineConstants.SelfRepositoryAlias,
|
||||
Path = selfPath
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
var usesSegments = uses.Value.Split('@');
|
||||
@@ -663,7 +700,7 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
|
||||
var node = default(ExpressionNode);
|
||||
try
|
||||
{
|
||||
node = expressionParser.CreateTree(condition, null, namedValues, functions, allowCaseFunction: context.AllowCaseFunction) as ExpressionNode;
|
||||
node = expressionParser.CreateTree(condition, null, namedValues, functions) as ExpressionNode;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -51,6 +51,8 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
|
||||
|
||||
public Int32 MaxResultSize { get; set; } = 10 * 1024 * 1024; // 10 mb
|
||||
|
||||
public bool AllowServiceContainerCommand { get; set; }
|
||||
|
||||
public Boolean EvaluateStepContinueOnError(
|
||||
TemplateToken token,
|
||||
DictionaryContextData contextData,
|
||||
@@ -357,7 +359,7 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
|
||||
{
|
||||
token = TemplateEvaluator.Evaluate(context, PipelineTemplateConstants.Services, token, 0, null, omitHeader: true);
|
||||
context.Errors.Check();
|
||||
result = PipelineTemplateConverter.ConvertToJobServiceContainers(context, token);
|
||||
result = PipelineTemplateConverter.ConvertToJobServiceContainers(context, token, allowServiceContainerCommand: AllowServiceContainerCommand);
|
||||
}
|
||||
catch (Exception ex) when (!(ex is TemplateValidationException))
|
||||
{
|
||||
|
||||
@@ -38,10 +38,43 @@ namespace GitHub.DistributedTask.Pipelines
|
||||
public static readonly Int32 MaxNodeNameLength = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Alias for the self repository.
|
||||
/// Alias for the self local-workspace repository type (./ syntax).
|
||||
/// Resolves to the local checkout on the runner.
|
||||
/// </summary>
|
||||
public static readonly String SelfAlias = "self";
|
||||
|
||||
/// <summary>
|
||||
/// RepositoryType for self-repository references ($/ syntax).
|
||||
/// Resolves to "this repo, at this SHA" based on the containing YAML file.
|
||||
/// </summary>
|
||||
public static readonly String SelfRepositoryAlias = "selfRepository";
|
||||
|
||||
/// <summary>
|
||||
/// The prefix for self-repository references in uses: values.
|
||||
/// </summary>
|
||||
public const String SelfRepositoryPrefix = "$/";
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the uses value is a self-repository reference (starts with $/),
|
||||
/// and outputs the subpath after the prefix.
|
||||
/// </summary>
|
||||
public static bool TryParseSelfRepository(string usesValue, out string path)
|
||||
{
|
||||
if (usesValue != null && usesValue.StartsWith(SelfRepositoryPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
path = usesValue.Substring(SelfRepositoryPrefix.Length).TrimStart('/');
|
||||
if (string.IsNullOrEmpty(path))
|
||||
{
|
||||
path = null;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
path = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Error code during graph validation.
|
||||
/// </summary>
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace GitHub.DistributedTask.Pipelines
|
||||
{
|
||||
[DataContract]
|
||||
[KnownType(typeof(ActionStep))]
|
||||
[KnownType(typeof(BackgroundStepControl))]
|
||||
[JsonConverter(typeof(StepConverter))]
|
||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public abstract class Step
|
||||
@@ -68,5 +69,7 @@ namespace GitHub.DistributedTask.Pipelines
|
||||
{
|
||||
[DataMember]
|
||||
Action = 4,
|
||||
[DataMember]
|
||||
BackgroundStepControl = 5,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,9 @@ namespace GitHub.DistributedTask.Pipelines
|
||||
case StepType.Action:
|
||||
stepObject = new ActionStep();
|
||||
break;
|
||||
case StepType.BackgroundStepControl:
|
||||
stepObject = new BackgroundStepControl();
|
||||
break;
|
||||
}
|
||||
|
||||
using (var objectReader = value.CreateReader())
|
||||
|
||||
@@ -186,7 +186,16 @@
|
||||
"vars",
|
||||
"needs",
|
||||
"strategy",
|
||||
"matrix"
|
||||
"matrix",
|
||||
"steps",
|
||||
"job",
|
||||
"runner",
|
||||
"env",
|
||||
"always(0,0)",
|
||||
"failure(0,0)",
|
||||
"cancelled(0,0)",
|
||||
"success(0,0)",
|
||||
"hashFiles(1,255)"
|
||||
],
|
||||
"string": {}
|
||||
},
|
||||
@@ -430,6 +439,21 @@
|
||||
}
|
||||
},
|
||||
|
||||
"service-container-mapping": {
|
||||
"mapping": {
|
||||
"properties": {
|
||||
"image": "string",
|
||||
"options": "string",
|
||||
"entrypoint": "string",
|
||||
"command": "string",
|
||||
"env": "container-env",
|
||||
"ports": "sequence-of-non-empty-string",
|
||||
"volumes": "sequence-of-non-empty-string",
|
||||
"credentials": "container-registry-credentials"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"services": {
|
||||
"context": [
|
||||
"github",
|
||||
@@ -454,7 +478,7 @@
|
||||
],
|
||||
"one-of": [
|
||||
"string",
|
||||
"container-mapping"
|
||||
"service-container-mapping"
|
||||
]
|
||||
},
|
||||
|
||||
|
||||
@@ -12,5 +12,12 @@ namespace GitHub.DistributedTask.WebApi
|
||||
get;
|
||||
set;
|
||||
}
|
||||
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public IList<string> Dependencies
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2556,6 +2556,25 @@ namespace GitHub.DistributedTask.WebApi
|
||||
}
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public sealed class FailedToDownloadActionException : DistributedTaskException
|
||||
{
|
||||
public FailedToDownloadActionException(String message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public FailedToDownloadActionException(String message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
|
||||
private FailedToDownloadActionException(SerializationInfo info, StreamingContext context)
|
||||
: base(info, context)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public sealed class InvalidActionArchiveException : DistributedTaskException
|
||||
{
|
||||
|
||||
@@ -43,6 +43,10 @@ namespace GitHub.DistributedTask.WebApi
|
||||
this.WarningCount = recordToBeCloned.WarningCount;
|
||||
this.NoticeCount = recordToBeCloned.NoticeCount;
|
||||
this.AgentPlatform = recordToBeCloned.AgentPlatform;
|
||||
this.IsBackground = recordToBeCloned.IsBackground;
|
||||
this.BackgroundControlType = recordToBeCloned.BackgroundControlType;
|
||||
this.BackgroundControlStepIds = recordToBeCloned.BackgroundControlStepIds;
|
||||
this.ParallelGroupId = recordToBeCloned.ParallelGroupId;
|
||||
|
||||
if (recordToBeCloned.Log != null)
|
||||
{
|
||||
@@ -289,6 +293,34 @@ namespace GitHub.DistributedTask.WebApi
|
||||
set;
|
||||
}
|
||||
|
||||
[DataMember(Order = 140, EmitDefaultValue = false)]
|
||||
public bool IsBackground
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
|
||||
[DataMember(Order = 141, EmitDefaultValue = false)]
|
||||
public string BackgroundControlType
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
|
||||
[DataMember(Order = 142, EmitDefaultValue = false)]
|
||||
public string[] BackgroundControlStepIds
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
|
||||
[DataMember(Order = 144, EmitDefaultValue = false)]
|
||||
public string ParallelGroupId
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
|
||||
public IList<TimelineAttempt> PreviousAttempts
|
||||
{
|
||||
get
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
@@ -17,10 +17,9 @@ namespace GitHub.Actions.Expressions
|
||||
String expression,
|
||||
ITraceWriter trace,
|
||||
IEnumerable<INamedValueInfo> namedValues,
|
||||
IEnumerable<IFunctionInfo> functions,
|
||||
Boolean allowCaseFunction = true)
|
||||
IEnumerable<IFunctionInfo> functions)
|
||||
{
|
||||
var context = new ParseContext(expression, trace, namedValues, functions, allowCaseFunction: allowCaseFunction);
|
||||
var context = new ParseContext(expression, trace, namedValues, functions);
|
||||
context.Trace.Info($"Parsing expression: <{expression}>");
|
||||
return CreateTree(context);
|
||||
}
|
||||
@@ -322,7 +321,7 @@ namespace GitHub.Actions.Expressions
|
||||
context.Operators.Pop();
|
||||
}
|
||||
var functionOperands = PopOperands(context, parameterCount);
|
||||
|
||||
|
||||
// Node already exists on the operand stack
|
||||
function = (Function)context.Operands.Peek();
|
||||
|
||||
@@ -416,12 +415,6 @@ namespace GitHub.Actions.Expressions
|
||||
String name,
|
||||
out IFunctionInfo functionInfo)
|
||||
{
|
||||
if (String.Equals(name, "case", StringComparison.OrdinalIgnoreCase) && !context.AllowCaseFunction)
|
||||
{
|
||||
functionInfo = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
return ExpressionConstants.WellKnownFunctions.TryGetValue(name, out functionInfo) ||
|
||||
context.ExtensionFunctions.TryGetValue(name, out functionInfo);
|
||||
}
|
||||
@@ -429,7 +422,6 @@ namespace GitHub.Actions.Expressions
|
||||
private sealed class ParseContext
|
||||
{
|
||||
public Boolean AllowUnknownKeywords;
|
||||
public Boolean AllowCaseFunction;
|
||||
public readonly String Expression;
|
||||
public readonly Dictionary<String, IFunctionInfo> ExtensionFunctions = new Dictionary<String, IFunctionInfo>(StringComparer.OrdinalIgnoreCase);
|
||||
public readonly Dictionary<String, INamedValueInfo> ExtensionNamedValues = new Dictionary<String, INamedValueInfo>(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -445,8 +437,7 @@ namespace GitHub.Actions.Expressions
|
||||
ITraceWriter trace,
|
||||
IEnumerable<INamedValueInfo> namedValues,
|
||||
IEnumerable<IFunctionInfo> functions,
|
||||
Boolean allowUnknownKeywords = false,
|
||||
Boolean allowCaseFunction = true)
|
||||
Boolean allowUnknownKeywords = false)
|
||||
{
|
||||
Expression = expression ?? String.Empty;
|
||||
if (Expression.Length > ExpressionConstants.MaxLength)
|
||||
@@ -467,7 +458,6 @@ namespace GitHub.Actions.Expressions
|
||||
|
||||
LexicalAnalyzer = new LexicalAnalyzer(Expression);
|
||||
AllowUnknownKeywords = allowUnknownKeywords;
|
||||
AllowCaseFunction = allowCaseFunction;
|
||||
}
|
||||
|
||||
private class NoOperationTraceWriter : ITraceWriter
|
||||
|
||||
@@ -50,5 +50,14 @@ namespace GitHub.Actions.RunService.WebApi
|
||||
|
||||
[DataMember(Name = "annotations", EmitDefaultValue = false)]
|
||||
public List<Annotation> Annotations { get; set; }
|
||||
|
||||
[DataMember(Name = "is_background", EmitDefaultValue = false)]
|
||||
public bool IsBackground { get; set; }
|
||||
|
||||
[DataMember(Name = "background_control_type", EmitDefaultValue = false)]
|
||||
public string BackgroundControlType { get; set; }
|
||||
|
||||
[DataMember(Name = "background_control_step_ids", EmitDefaultValue = false)]
|
||||
public string[] BackgroundControlStepIds { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,14 +23,14 @@
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="Microsoft.AspNet.WebApi.Client" Version="6.0.0" />
|
||||
<PackageReference Include="System.Security.Cryptography.Cng" Version="5.0.0" />
|
||||
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="10.0.2" />
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
|
||||
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="10.0.7" />
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.3" />
|
||||
<PackageReference Include="Minimatch" Version="2.0.0" />
|
||||
<PackageReference Include="YamlDotNet.Signed" Version="5.3.0" />
|
||||
<PackageReference Include="System.Net.Http" Version="4.3.4" />
|
||||
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1" />
|
||||
<PackageReference Include="System.Private.Uri" Version="4.3.2" />
|
||||
<PackageReference Include="System.Formats.Asn1" Version="10.0.2" />
|
||||
<PackageReference Include="System.Formats.Asn1" Version="10.0.7" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -179,6 +179,14 @@ namespace GitHub.Services.Results.Contracts
|
||||
public string CompletedAt;
|
||||
[DataMember]
|
||||
public Conclusion Conclusion;
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public bool IsBackground;
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public string BackgroundControlType;
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public string[] BackgroundControlStepIds;
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public string ParallelGroupId;
|
||||
}
|
||||
|
||||
public enum Status
|
||||
|
||||
@@ -22,6 +22,9 @@ namespace GitHub.Services.Launch.Contracts
|
||||
{
|
||||
[DataMember(EmitDefaultValue = false, Name = "actions")]
|
||||
public IList<ActionReferenceRequest> Actions { get; set; }
|
||||
|
||||
[DataMember(EmitDefaultValue = false, Name = "actions_dependencies")]
|
||||
public IList<string> ActionsDependencies { get; set; }
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
|
||||
@@ -97,7 +97,8 @@ namespace GitHub.Services.Launch.Client
|
||||
{
|
||||
return new ActionReferenceRequestList
|
||||
{
|
||||
Actions = actionReferenceList.Actions?.Select(ToGitHubData).ToList()
|
||||
Actions = actionReferenceList.Actions?.Select(ToGitHubData).ToList(),
|
||||
ActionsDependencies = actionReferenceList.Dependencies
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -514,7 +514,7 @@ namespace GitHub.Services.Results.Client
|
||||
|
||||
private Step ConvertTimelineRecordToStep(TimelineRecord r)
|
||||
{
|
||||
return new Step()
|
||||
var step = new Step()
|
||||
{
|
||||
ExternalId = r.Id.ToString(),
|
||||
Number = r.Order.GetValueOrDefault(),
|
||||
@@ -522,8 +522,25 @@ namespace GitHub.Services.Results.Client
|
||||
Status = ConvertStateToStatus(r.State.GetValueOrDefault()),
|
||||
StartedAt = r.StartTime?.ToString(Constants.TimestampFormat, CultureInfo.InvariantCulture),
|
||||
CompletedAt = r.FinishTime?.ToString(Constants.TimestampFormat, CultureInfo.InvariantCulture),
|
||||
Conclusion = ConvertResultToConclusion(r.Result)
|
||||
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)
|
||||
|
||||
@@ -32,7 +32,7 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
||||
return;
|
||||
}
|
||||
|
||||
var effectiveMax = explicitMax ?? CreatePermissionsFromPolicy(context, permissionsPolicy, includeIdToken: isTrusted, includeModels: context.GetFeatures().AllowModelsPermission);
|
||||
var effectiveMax = explicitMax ?? CreatePermissionsFromPolicy(context, permissionsPolicy, includeIdToken: isTrusted, includeModels: context.GetFeatures().AllowModelsPermission, includeVulnerabilityAlerts: context.GetFeatures().AllowVulnerabilityAlertsPermission);
|
||||
|
||||
if (requested.ViolatesMaxPermissions(effectiveMax, out var permissionLevelViolations))
|
||||
{
|
||||
@@ -59,18 +59,19 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
||||
TemplateContext context,
|
||||
string permissionsPolicy,
|
||||
bool includeIdToken,
|
||||
bool includeModels)
|
||||
bool includeModels,
|
||||
bool includeVulnerabilityAlerts)
|
||||
{
|
||||
switch (permissionsPolicy)
|
||||
{
|
||||
case WorkflowConstants.PermissionsPolicy.LimitedRead:
|
||||
return new Permissions(PermissionLevel.NoAccess, includeIdToken: false, includeAttestations: false, includeModels: false)
|
||||
return new Permissions(PermissionLevel.NoAccess, includeIdToken: false, includeAttestations: false, includeModels: false, includeVulnerabilityAlerts: false)
|
||||
{
|
||||
Contents = PermissionLevel.Read,
|
||||
Packages = PermissionLevel.Read,
|
||||
};
|
||||
case WorkflowConstants.PermissionsPolicy.Write:
|
||||
return new Permissions(PermissionLevel.Write, includeIdToken: includeIdToken, includeAttestations: true, includeModels: includeModels);
|
||||
return new Permissions(PermissionLevel.Write, includeIdToken: includeIdToken, includeAttestations: true, includeModels: includeModels, includeVulnerabilityAlerts: includeVulnerabilityAlerts);
|
||||
default:
|
||||
throw new ArgumentException($"Unexpected permission policy: '{permissionsPolicy}'");
|
||||
}
|
||||
|
||||
@@ -62,6 +62,8 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
||||
public const String NumberStrategyContext = "number-strategy-context";
|
||||
public const String On = "on";
|
||||
public const String Options = "options";
|
||||
public const String Entrypoint = "entrypoint";
|
||||
public const String Command = "command";
|
||||
public const String Org = "org";
|
||||
public const String Organization = "organization";
|
||||
public const String Outputs = "outputs";
|
||||
|
||||
@@ -1079,7 +1079,8 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
||||
internal static JobContainer ConvertToJobContainer(
|
||||
TemplateContext context,
|
||||
TemplateToken value,
|
||||
bool isEarlyValidation = false)
|
||||
bool isEarlyValidation = false,
|
||||
bool isServiceContainer = false)
|
||||
{
|
||||
var result = new JobContainer();
|
||||
if (isEarlyValidation && value.Traverse().Any(x => x is ExpressionToken))
|
||||
@@ -1089,11 +1090,34 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
||||
|
||||
if (value is StringToken containerLiteral)
|
||||
{
|
||||
if (String.IsNullOrEmpty(containerLiteral.Value))
|
||||
// Trim "docker://"
|
||||
var trimmedImage = containerLiteral.Value;
|
||||
var hasDockerPrefix = containerLiteral.Value.StartsWith(WorkflowTemplateConstants.DockerUriPrefix, StringComparison.Ordinal);
|
||||
if (hasDockerPrefix)
|
||||
{
|
||||
trimmedImage = trimmedImage.Substring(WorkflowTemplateConstants.DockerUriPrefix.Length);
|
||||
}
|
||||
|
||||
// Empty shorthand after trimming "docker://" ?
|
||||
if (String.IsNullOrEmpty(trimmedImage))
|
||||
{
|
||||
// Error at parse-time for:
|
||||
// 1. container: 'docker://'
|
||||
// 2. services.foo: ''
|
||||
// 3. services.foo: 'docker://'
|
||||
//
|
||||
// Do not error for:
|
||||
// 1. container: ''
|
||||
if (isEarlyValidation && (hasDockerPrefix || isServiceContainer))
|
||||
{
|
||||
context.Error(value, "Container image cannot be empty");
|
||||
}
|
||||
|
||||
// Short-circuit
|
||||
return null;
|
||||
}
|
||||
|
||||
// Store original, trimmed further below
|
||||
result.Image = containerLiteral.Value;
|
||||
}
|
||||
else
|
||||
@@ -1122,6 +1146,22 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
||||
case WorkflowTemplateConstants.Options:
|
||||
result.Options = containerPropertyPair.Value.AssertString($"{WorkflowTemplateConstants.Container} {propertyName}").Value;
|
||||
break;
|
||||
case WorkflowTemplateConstants.Entrypoint:
|
||||
if (!context.GetFeatures().AllowServiceContainerCommand)
|
||||
{
|
||||
context.Error(containerPropertyPair.Key, $"The key '{WorkflowTemplateConstants.Entrypoint}' is not allowed");
|
||||
break;
|
||||
}
|
||||
result.Entrypoint = containerPropertyPair.Value.AssertString($"{WorkflowTemplateConstants.Container} {propertyName}").Value;
|
||||
break;
|
||||
case WorkflowTemplateConstants.Command:
|
||||
if (!context.GetFeatures().AllowServiceContainerCommand)
|
||||
{
|
||||
context.Error(containerPropertyPair.Key, $"The key '{WorkflowTemplateConstants.Command}' is not allowed");
|
||||
break;
|
||||
}
|
||||
result.Command = containerPropertyPair.Value.AssertString($"{WorkflowTemplateConstants.Container} {propertyName}").Value;
|
||||
break;
|
||||
case WorkflowTemplateConstants.Ports:
|
||||
var ports = containerPropertyPair.Value.AssertSequence($"{WorkflowTemplateConstants.Container} {propertyName}");
|
||||
var portList = new List<String>(ports.Count);
|
||||
@@ -1152,22 +1192,30 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
||||
}
|
||||
}
|
||||
|
||||
// Trim "docker://"
|
||||
var hadDockerPrefix = false;
|
||||
if (!String.IsNullOrEmpty(result.Image) && result.Image.StartsWith(WorkflowTemplateConstants.DockerUriPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
hadDockerPrefix = true;
|
||||
result.Image = result.Image.Substring(WorkflowTemplateConstants.DockerUriPrefix.Length);
|
||||
}
|
||||
|
||||
if (String.IsNullOrEmpty(result.Image))
|
||||
{
|
||||
// Only error during early validation (parse time)
|
||||
// At runtime (expression evaluation), empty image = no container
|
||||
if (isEarlyValidation)
|
||||
// Error at parse-time for:
|
||||
// 1. container: {image: 'docker://'}
|
||||
// 2. services.foo: {image: ''}
|
||||
// 3. services.foo: {image: 'docker://'}
|
||||
//
|
||||
// Do not error for:
|
||||
// 1. container: {image: ''}
|
||||
if (isEarlyValidation && (hadDockerPrefix || isServiceContainer))
|
||||
{
|
||||
context.Error(value, "Container image cannot be empty");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (result.Image.StartsWith(WorkflowTemplateConstants.DockerUriPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
result.Image = result.Image.Substring(WorkflowTemplateConstants.DockerUriPrefix.Length);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1188,7 +1236,7 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
||||
foreach (var servicePair in servicesMapping)
|
||||
{
|
||||
var networkAlias = servicePair.Key.AssertString("services key").Value;
|
||||
var container = ConvertToJobContainer(context, servicePair.Value);
|
||||
var container = ConvertToJobContainer(context, servicePair.Value, isEarlyValidation, isServiceContainer: true);
|
||||
result.Add(new KeyValuePair<String, JobContainer>(networkAlias, container));
|
||||
}
|
||||
|
||||
@@ -1557,6 +1605,10 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
||||
{
|
||||
id = WorkflowConstants.SelfAlias;
|
||||
}
|
||||
else if (GitHub.DistributedTask.Pipelines.PipelineConstants.TryParseSelfRepository(action.Uses!.Value, out _))
|
||||
{
|
||||
id = WorkflowConstants.SelfRepositoryAlias;
|
||||
}
|
||||
else
|
||||
{
|
||||
var usesSegments = action.Uses!.Value.Split('@');
|
||||
@@ -1780,7 +1832,7 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
||||
var node = default(ExpressionNode);
|
||||
try
|
||||
{
|
||||
node = expressionParser.CreateTree(condition, null, namedValues, functions, allowCaseFunction: context.AllowCaseFunction) as ExpressionNode;
|
||||
node = expressionParser.CreateTree(condition, null, namedValues, functions) as ExpressionNode;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -1829,7 +1881,7 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
||||
permissionsStr.AssertUnexpectedValue(permissionsStr.Value);
|
||||
break;
|
||||
}
|
||||
return new Permissions(permissionLevel, includeIdToken: true, includeAttestations: true, includeModels: context.GetFeatures().AllowModelsPermission);
|
||||
return new Permissions(permissionLevel, includeIdToken: true, includeAttestations: true, includeModels: context.GetFeatures().AllowModelsPermission, includeVulnerabilityAlerts: context.GetFeatures().AllowVulnerabilityAlertsPermission);
|
||||
}
|
||||
|
||||
var mapping = token.AssertMapping("permissions");
|
||||
@@ -1909,6 +1961,23 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
||||
context.Error(key, $"The permission 'models' is not allowed");
|
||||
}
|
||||
break;
|
||||
case "vulnerability-alerts":
|
||||
if (context.GetFeatures().AllowVulnerabilityAlertsPermission)
|
||||
{
|
||||
if (permissionLevel == PermissionLevel.Write)
|
||||
{
|
||||
permissions.VulnerabilityAlerts = PermissionLevel.Read;
|
||||
}
|
||||
else
|
||||
{
|
||||
permissions.VulnerabilityAlerts = permissionLevel;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
context.Error(key, $"The permission 'vulnerability-alerts' is not allowed");
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -2226,6 +2295,10 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
||||
new NamedValueInfo<NoOperationNamedValue>(WorkflowTemplateConstants.Needs),
|
||||
new NamedValueInfo<NoOperationNamedValue>(WorkflowTemplateConstants.Strategy),
|
||||
new NamedValueInfo<NoOperationNamedValue>(WorkflowTemplateConstants.Matrix),
|
||||
new NamedValueInfo<NoOperationNamedValue>(WorkflowTemplateConstants.Steps),
|
||||
new NamedValueInfo<NoOperationNamedValue>(WorkflowTemplateConstants.Job),
|
||||
new NamedValueInfo<NoOperationNamedValue>(WorkflowTemplateConstants.Runner),
|
||||
new NamedValueInfo<NoOperationNamedValue>(WorkflowTemplateConstants.Env),
|
||||
};
|
||||
private static readonly IFunctionInfo[] s_jobConditionFunctions = new IFunctionInfo[]
|
||||
{
|
||||
@@ -2242,6 +2315,13 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
||||
new FunctionInfo<NoOperation>(WorkflowTemplateConstants.Success, 0, 0),
|
||||
new FunctionInfo<NoOperation>(WorkflowTemplateConstants.HashFiles, 1, Byte.MaxValue),
|
||||
};
|
||||
private static readonly IFunctionInfo[] s_snapshotConditionFunctions = null;
|
||||
private static readonly IFunctionInfo[] s_snapshotConditionFunctions = new IFunctionInfo[]
|
||||
{
|
||||
new FunctionInfo<NoOperation>(WorkflowTemplateConstants.Always, 0, 0),
|
||||
new FunctionInfo<NoOperation>(WorkflowTemplateConstants.Cancelled, 0, 0),
|
||||
new FunctionInfo<NoOperation>(WorkflowTemplateConstants.Failure, 0, 0),
|
||||
new FunctionInfo<NoOperation>(WorkflowTemplateConstants.Success, 0, 0),
|
||||
new FunctionInfo<NoOperation>(WorkflowTemplateConstants.HashFiles, 1, Byte.MaxValue),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,24 @@ namespace GitHub.Actions.WorkflowParser
|
||||
set;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the container entrypoint override.
|
||||
/// </summary>
|
||||
public String Entrypoint
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the container command and args (after the image name).
|
||||
/// </summary>
|
||||
public String Command
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the volumes which are mounted into the container.
|
||||
/// </summary>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user