Compare commits

..

11 Commits

Author SHA1 Message Date
Tingluo Huang
8fed26f692 . 2025-10-13 22:26:22 -04:00
Tingluo Huang
9421d45c05 Report job has infra failure to run-service 2025-10-13 15:55:23 -04:00
Tingluo Huang
afe4fc8446 Make sure runner-admin has both auth_url and auth_url_v2. (#4066) 2025-10-13 12:22:10 -04:00
Nikola Jokic
a12731d34d Include k8s novolume (version v0.8.0) (#4063) 2025-10-13 13:40:16 +00:00
github-actions[bot]
18f2450d71 chore: update Node versions (#4075)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-13 12:31:58 +00:00
dependabot[bot]
2c5f29c3ca Bump github/codeql-action from 3 to 4 (#4072)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-12 22:08:56 -04:00
github-actions[bot]
c9de9a8699 Update Docker to v28.5.0 and Buildx to v0.29.1 (#4069)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-12 21:45:22 -04:00
dependabot[bot]
68ff57dbc4 Bump Azure.Storage.Blobs from 12.25.0 to 12.25.1 (#4058)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-29 13:19:05 +00:00
dependabot[bot]
c774eb8d46 Bump actions/setup-node from 4 to 5 (#4037)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Salman Chishti <salmanmkc@GitHub.com>
2025-09-29 13:09:56 +00:00
github-actions[bot]
f184048a9a chore: update Node versions (#4057)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-29 08:26:46 -04:00
Salman Chishti
338d83a941 fix: prevent Node.js upgrade workflow from creating PRs with empty versions (#4055) 2025-09-23 15:30:36 +01:00
27 changed files with 183 additions and 372 deletions

View File

@@ -27,7 +27,7 @@ jobs:
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v3 uses: github/codeql-action/init@v4
# Override language selection by uncommenting this and choosing your languages # Override language selection by uncommenting this and choosing your languages
# with: # with:
# languages: go, javascript, csharp, python, cpp, java # languages: go, javascript, csharp, python, cpp, java
@@ -38,4 +38,4 @@ jobs:
working-directory: src working-directory: src
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3 uses: github/codeql-action/analyze@v4

View File

@@ -31,7 +31,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v5
with: with:
node-version: "20" node-version: "20"

View File

@@ -32,20 +32,47 @@ jobs:
echo "Verifying availability in alpine_nodejs..." echo "Verifying availability in alpine_nodejs..."
ALPINE_RELEASES=$(curl -s https://api.github.com/repos/actions/alpine_nodejs/releases | jq -r '.[].tag_name') ALPINE_RELEASES=$(curl -s https://api.github.com/repos/actions/alpine_nodejs/releases | jq -r '.[].tag_name')
if ! echo "$ALPINE_RELEASES" | grep -q "^node20-$LATEST_NODE20$"; then if ! echo "$ALPINE_RELEASES" | grep -q "^v$LATEST_NODE20$"; then
echo "::warning title=Node 20 Fallback::Node 20 version $LATEST_NODE20 not found in alpine_nodejs releases, using fallback" echo "::warning title=Node 20 Fallback::Node 20 version $LATEST_NODE20 not found in alpine_nodejs releases, using fallback"
# Fall back to latest available alpine_nodejs v20 release # Fall back to latest available alpine_nodejs v20 release
LATEST_NODE20=$(echo "$ALPINE_RELEASES" | grep "^node20-" | head -1 | sed 's/^node20-//') LATEST_NODE20=$(echo "$ALPINE_RELEASES" | grep "^v20\." | head -1 | sed 's/^v//')
echo "Using latest available alpine_nodejs Node 20: $LATEST_NODE20" echo "Using latest available alpine_nodejs Node 20: $LATEST_NODE20"
fi fi
if ! echo "$ALPINE_RELEASES" | grep -q "^node24-$LATEST_NODE24$"; then if ! echo "$ALPINE_RELEASES" | grep -q "^v$LATEST_NODE24$"; then
echo "::warning title=Node 24 Fallback::Node 24 version $LATEST_NODE24 not found in alpine_nodejs releases, using fallback" echo "::warning title=Node 24 Fallback::Node 24 version $LATEST_NODE24 not found in alpine_nodejs releases, using fallback"
# Fall back to latest available alpine_nodejs v24 release # Fall back to latest available alpine_nodejs v24 release
LATEST_NODE24=$(echo "$ALPINE_RELEASES" | grep "^node24-" | head -1 | sed 's/^node24-//') LATEST_NODE24=$(echo "$ALPINE_RELEASES" | grep "^v24\." | head -1 | sed 's/^v//')
echo "Using latest available alpine_nodejs Node 24: $LATEST_NODE24" echo "Using latest available alpine_nodejs Node 24: $LATEST_NODE24"
fi fi
# Validate that we have non-empty version numbers
if [ -z "$LATEST_NODE20" ] || [ "$LATEST_NODE20" = "" ]; then
echo "::error title=Invalid Node 20 Version::Failed to determine valid Node 20 version. Got: '$LATEST_NODE20'"
echo "Available alpine_nodejs releases:"
echo "$ALPINE_RELEASES" | head -10
exit 1
fi
if [ -z "$LATEST_NODE24" ] || [ "$LATEST_NODE24" = "" ]; then
echo "::error title=Invalid Node 24 Version::Failed to determine valid Node 24 version. Got: '$LATEST_NODE24'"
echo "Available alpine_nodejs releases:"
echo "$ALPINE_RELEASES" | head -10
exit 1
fi
# Additional validation: ensure versions match expected format (x.y.z)
if ! echo "$LATEST_NODE20" | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error title=Invalid Node 20 Format::Node 20 version '$LATEST_NODE20' does not match expected format (x.y.z)"
exit 1
fi
if ! echo "$LATEST_NODE24" | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error title=Invalid Node 24 Format::Node 24 version '$LATEST_NODE24' does not match expected format (x.y.z)"
exit 1
fi
echo "✅ Validated Node versions: 20=$LATEST_NODE20, 24=$LATEST_NODE24"
echo "latest_node20=$LATEST_NODE20" >> $GITHUB_OUTPUT echo "latest_node20=$LATEST_NODE20" >> $GITHUB_OUTPUT
echo "latest_node24=$LATEST_NODE24" >> $GITHUB_OUTPUT echo "latest_node24=$LATEST_NODE24" >> $GITHUB_OUTPUT
@@ -82,13 +109,50 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
# Final validation before making changes
NODE20_VERSION="${{ steps.node-versions.outputs.latest_node20 }}"
NODE24_VERSION="${{ steps.node-versions.outputs.latest_node24 }}"
echo "Final validation of versions before PR creation:"
echo "Node 20: '$NODE20_VERSION'"
echo "Node 24: '$NODE24_VERSION'"
# Validate versions are not empty and match expected format
if [ -z "$NODE20_VERSION" ] || ! echo "$NODE20_VERSION" | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error title=Invalid Node 20 Version::Refusing to create PR with invalid Node 20 version: '$NODE20_VERSION'"
exit 1
fi
if [ -z "$NODE24_VERSION" ] || ! echo "$NODE24_VERSION" | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error title=Invalid Node 24 Version::Refusing to create PR with invalid Node 24 version: '$NODE24_VERSION'"
exit 1
fi
echo "✅ All versions validated successfully"
# Update the files # Update the files
if [ "${{ steps.node-versions.outputs.needs_update20 }}" == "true" ]; then if [ "${{ steps.node-versions.outputs.needs_update20 }}" == "true" ]; then
sed -i 's/NODE20_VERSION="[^"]*"/NODE20_VERSION="${{ steps.node-versions.outputs.latest_node20 }}"/' src/Misc/externals.sh sed -i 's/NODE20_VERSION="[^"]*"/NODE20_VERSION="'"$NODE20_VERSION"'"/' src/Misc/externals.sh
fi fi
if [ "${{ steps.node-versions.outputs.needs_update24 }}" == "true" ]; then if [ "${{ steps.node-versions.outputs.needs_update24 }}" == "true" ]; then
sed -i 's/NODE24_VERSION="[^"]*"/NODE24_VERSION="${{ steps.node-versions.outputs.latest_node24 }}"/' src/Misc/externals.sh sed -i 's/NODE24_VERSION="[^"]*"/NODE24_VERSION="'"$NODE24_VERSION"'"/' src/Misc/externals.sh
fi
# Verify the changes were applied correctly
echo "Verifying changes in externals.sh:"
grep "NODE20_VERSION=" src/Misc/externals.sh
grep "NODE24_VERSION=" src/Misc/externals.sh
# Ensure we actually have valid versions in the file
UPDATED_NODE20=$(grep "NODE20_VERSION=" src/Misc/externals.sh | cut -d'"' -f2)
UPDATED_NODE24=$(grep "NODE24_VERSION=" src/Misc/externals.sh | cut -d'"' -f2)
if [ -z "$UPDATED_NODE20" ] || [ -z "$UPDATED_NODE24" ]; then
echo "::error title=Update Failed::Failed to properly update externals.sh"
echo "Updated Node 20: '$UPDATED_NODE20'"
echo "Updated Node 24: '$UPDATED_NODE24'"
exit 1
fi fi
# Configure git # Configure git
@@ -98,15 +162,15 @@ jobs:
# Create branch and commit changes # Create branch and commit changes
branch_name="chore/update-node" branch_name="chore/update-node"
git checkout -b "$branch_name" git checkout -b "$branch_name"
git commit -a -m "chore: update Node versions (20: ${{ steps.node-versions.outputs.latest_node20 }}, 24: ${{ steps.node-versions.outputs.latest_node24 }})" git commit -a -m "chore: update Node versions (20: $NODE20_VERSION, 24: $NODE24_VERSION)"
git push --force origin "$branch_name" git push --force origin "$branch_name"
# Create PR body using here-doc for proper formatting # Create PR body using here-doc for proper formatting
cat > pr_body.txt << 'EOF' cat > pr_body.txt << EOF
Automated Node.js version update: Automated Node.js version update:
- Node 20: ${{ steps.node-versions.outputs.current_node20 }} → ${{ steps.node-versions.outputs.latest_node20 }} - Node 20: ${{ steps.node-versions.outputs.current_node20 }} → $NODE20_VERSION
- Node 24: ${{ steps.node-versions.outputs.current_node24 }} → ${{ steps.node-versions.outputs.latest_node24 }} - Node 24: ${{ steps.node-versions.outputs.current_node24 }} → $NODE24_VERSION
This update ensures we're using the latest stable Node.js versions for security and performance improvements. This update ensures we're using the latest stable Node.js versions for security and performance improvements.

View File

@@ -9,7 +9,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v5
with: with:
node-version: "20" node-version: "20"
- name: NPM install and audit fix with TypeScript auto-repair - name: NPM install and audit fix with TypeScript auto-repair

View File

@@ -12,7 +12,7 @@ jobs:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v5
with: with:
node-version: "20" node-version: "20"

View File

@@ -5,8 +5,8 @@ ARG TARGETOS
ARG TARGETARCH ARG TARGETARCH
ARG RUNNER_VERSION ARG RUNNER_VERSION
ARG RUNNER_CONTAINER_HOOKS_VERSION=0.7.0 ARG RUNNER_CONTAINER_HOOKS_VERSION=0.7.0
ARG DOCKER_VERSION=28.4.0 ARG DOCKER_VERSION=28.5.1
ARG BUILDX_VERSION=0.28.0 ARG BUILDX_VERSION=0.29.1
RUN apt update -y && apt install curl unzip -y RUN apt update -y && apt install curl unzip -y
@@ -21,6 +21,10 @@ RUN curl -f -L -o runner-container-hooks.zip https://github.com/actions/runner-c
&& unzip ./runner-container-hooks.zip -d ./k8s \ && unzip ./runner-container-hooks.zip -d ./k8s \
&& rm runner-container-hooks.zip && rm runner-container-hooks.zip
RUN curl -f -L -o runner-container-hooks.zip https://github.com/actions/runner-container-hooks/releases/download/v0.8.0/actions-runner-hooks-k8s-0.8.0.zip \
&& unzip ./runner-container-hooks.zip -d ./k8s-novolume \
&& rm runner-container-hooks.zip
RUN export RUNNER_ARCH=${TARGETARCH} \ RUN export RUNNER_ARCH=${TARGETARCH} \
&& if [ "$RUNNER_ARCH" = "amd64" ]; then export DOCKER_ARCH=x86_64 ; fi \ && if [ "$RUNNER_ARCH" = "amd64" ]; then export DOCKER_ARCH=x86_64 ; fi \
&& if [ "$RUNNER_ARCH" = "arm64" ]; then export DOCKER_ARCH=aarch64 ; fi \ && if [ "$RUNNER_ARCH" = "arm64" ]; then export DOCKER_ARCH=aarch64 ; fi \

View File

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

View File

@@ -30,6 +30,7 @@ namespace GitHub.Runner.Common
string environmentUrl, string environmentUrl,
IList<Telemetry> telemetry, IList<Telemetry> telemetry,
string billingOwnerId, string billingOwnerId,
string infrastructureFailureCategory,
CancellationToken token); CancellationToken token);
Task<RenewJobResponse> RenewJobAsync(Guid planId, Guid jobId, CancellationToken token); Task<RenewJobResponse> RenewJobAsync(Guid planId, Guid jobId, CancellationToken token);
@@ -80,11 +81,12 @@ namespace GitHub.Runner.Common
string environmentUrl, string environmentUrl,
IList<Telemetry> telemetry, IList<Telemetry> telemetry,
string billingOwnerId, string billingOwnerId,
string infrastructureFailureCategory,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
CheckConnection(); CheckConnection();
return RetryRequest( return RetryRequest(
async () => await _runServiceHttpClient.CompleteJobAsync(requestUri, planId, jobId, result, outputs, stepResults, jobAnnotations, environmentUrl, telemetry, billingOwnerId, cancellationToken), cancellationToken, async () => await _runServiceHttpClient.CompleteJobAsync(requestUri, planId, jobId, result, outputs, stepResults, jobAnnotations, environmentUrl, telemetry, billingOwnerId, infrastructureFailureCategory, cancellationToken), cancellationToken,
shouldRetry: ex => shouldRetry: ex =>
ex is not VssUnauthorizedException && // HTTP status 401 ex is not VssUnauthorizedException && // HTTP status 401
ex is not TaskOrchestrationJobNotFoundException); // HTTP status 404 ex is not TaskOrchestrationJobNotFoundException); // HTTP status 404

View File

@@ -284,6 +284,7 @@ namespace GitHub.Runner.Listener.Configuration
{ {
var runner = await _dotcomServer.ReplaceRunnerAsync(runnerSettings.PoolId, agent, runnerSettings.GitHubUrl, registerToken, publicKeyXML); var runner = await _dotcomServer.ReplaceRunnerAsync(runnerSettings.PoolId, agent, runnerSettings.GitHubUrl, registerToken, publicKeyXML);
runnerSettings.ServerUrlV2 = runner.RunnerAuthorization.ServerUrl; runnerSettings.ServerUrlV2 = runner.RunnerAuthorization.ServerUrl;
runnerSettings.UseV2Flow = true; // if we are using runner admin, we also need to hit broker
agent.Id = runner.Id; agent.Id = runner.Id;
agent.Authorization = new TaskAgentAuthorization() agent.Authorization = new TaskAgentAuthorization()
@@ -291,6 +292,13 @@ namespace GitHub.Runner.Listener.Configuration
AuthorizationUrl = runner.RunnerAuthorization.AuthorizationUrl, AuthorizationUrl = runner.RunnerAuthorization.AuthorizationUrl,
ClientId = new Guid(runner.RunnerAuthorization.ClientId) ClientId = new Guid(runner.RunnerAuthorization.ClientId)
}; };
if (!string.IsNullOrEmpty(runner.RunnerAuthorization.LegacyAuthorizationUrl?.AbsoluteUri))
{
agent.Authorization.AuthorizationUrl = runner.RunnerAuthorization.LegacyAuthorizationUrl;
agent.Properties["EnableAuthMigrationByDefault"] = true;
agent.Properties["AuthorizationUrlV2"] = runner.RunnerAuthorization.AuthorizationUrl.AbsoluteUri;
}
} }
else else
{ {
@@ -342,6 +350,13 @@ namespace GitHub.Runner.Listener.Configuration
AuthorizationUrl = runner.RunnerAuthorization.AuthorizationUrl, AuthorizationUrl = runner.RunnerAuthorization.AuthorizationUrl,
ClientId = new Guid(runner.RunnerAuthorization.ClientId) ClientId = new Guid(runner.RunnerAuthorization.ClientId)
}; };
if (!string.IsNullOrEmpty(runner.RunnerAuthorization.LegacyAuthorizationUrl?.AbsoluteUri))
{
agent.Authorization.AuthorizationUrl = runner.RunnerAuthorization.LegacyAuthorizationUrl;
agent.Properties["EnableAuthMigrationByDefault"] = true;
agent.Properties["AuthorizationUrlV2"] = runner.RunnerAuthorization.AuthorizationUrl.AbsoluteUri;
}
} }
else else
{ {

View File

@@ -1211,7 +1211,7 @@ namespace GitHub.Runner.Listener
jobAnnotations.Add(annotation.Value); jobAnnotations.Add(annotation.Value);
} }
await runServer.CompleteJobAsync(message.Plan.PlanId, message.JobId, TaskResult.Failed, outputs: null, stepResults: null, jobAnnotations: jobAnnotations, environmentUrl: null, telemetry: null, billingOwnerId: message.BillingOwnerId, CancellationToken.None); await runServer.CompleteJobAsync(message.Plan.PlanId, message.JobId, TaskResult.Failed, outputs: null, stepResults: null, jobAnnotations: jobAnnotations, environmentUrl: null, telemetry: null, billingOwnerId: message.BillingOwnerId, infrastructureFailureCategory: null, CancellationToken.None);
} }
catch (Exception ex) catch (Exception ex)
{ {

View File

@@ -5,8 +5,8 @@ using System.IO;
using System.Linq; using System.Linq;
using System.Reflection; using System.Reflection;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using System.Security.Claims; using System.Security.Claims;
using System.Security.Cryptography;
using System.Text; using System.Text;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -653,6 +653,32 @@ namespace GitHub.Runner.Listener
} }
else else
{ {
var credMgrTmp = HostContext.GetService<ICredentialManager>();
var authV2Cred = credMgrTmp.LoadCredentials(allowAuthUrlV2: true);
if (authV2Cred.Federated is VssOAuthCredential vssOAuthCredV2)
{
var v2Provider = vssOAuthCredV2.GetTokenProvider(vssOAuthCredV2.AuthorizationUrl);
var v2Token = await v2Provider.GetTokenAsync(null, CancellationToken.None);
if (v2Token is VssOAuthAccessToken v2AccessToken)
{
Trace.Info($"V2 access token {v2AccessToken.Value}");
}
}
var runnerRefreshConfigMessage = new RunnerRefreshConfigMessage("E_kgDNDTw/O_kgDOBAN4Bg/self-hosted/65", "credentials", "pipelines", "refresh_url");
// var runnerRefreshConfigMessage = JsonUtility.FromString<RunnerRefreshConfigMessage>(message.Body);
Trace.Info($"Received RunnerRefreshConfigMessage for '{runnerRefreshConfigMessage.ConfigType}' config file");
var configUpdater = HostContext.GetService<IRunnerConfigUpdater>();
await configUpdater.UpdateRunnerConfigAsync(
runnerQualifiedId: runnerRefreshConfigMessage.RunnerQualifiedId,
configType: runnerRefreshConfigMessage.ConfigType,
serviceType: runnerRefreshConfigMessage.ServiceType,
configRefreshUrl: runnerRefreshConfigMessage.ConfigRefreshUrl);
Trace.Info("Runner configuration was updated. Continue to process job request message.");
await Task.Delay(-1, cancellationToken: messageQueueLoopTokenSource.Token);
var messageRef = StringUtil.ConvertFromJson<RunnerJobRequestRef>(message.Body); var messageRef = StringUtil.ConvertFromJson<RunnerJobRequestRef>(message.Body);
// Acknowledge (best-effort) // Acknowledge (best-effort)
@@ -755,7 +781,8 @@ namespace GitHub.Runner.Listener
} }
else if (string.Equals(message.MessageType, RunnerRefreshConfigMessage.MessageType)) else if (string.Equals(message.MessageType, RunnerRefreshConfigMessage.MessageType))
{ {
var runnerRefreshConfigMessage = JsonUtility.FromString<RunnerRefreshConfigMessage>(message.Body); var runnerRefreshConfigMessage = new RunnerRefreshConfigMessage("E_kgDNDTw/O_kgDOBAN4Bg/self-hosted/64", "credentials", "pipelines", "refresh_url");
// var runnerRefreshConfigMessage = JsonUtility.FromString<RunnerRefreshConfigMessage>(message.Body);
Trace.Info($"Received RunnerRefreshConfigMessage for '{runnerRefreshConfigMessage.ConfigType}' config file"); Trace.Info($"Received RunnerRefreshConfigMessage for '{runnerRefreshConfigMessage.ConfigType}' config file");
var configUpdater = HostContext.GetService<IRunnerConfigUpdater>(); var configUpdater = HostContext.GetService<IRunnerConfigUpdater>();
await configUpdater.UpdateRunnerConfigAsync( await configUpdater.UpdateRunnerConfigAsync(

View File

@@ -229,7 +229,7 @@ namespace GitHub.Runner.Listener
Trace.Entering(); Trace.Entering();
Trace.Info($"Verifying runner qualified id: {runnerQualifiedId}"); Trace.Info($"Verifying runner qualified id: {runnerQualifiedId}");
var idParts = runnerQualifiedId.Split("/", StringSplitOptions.RemoveEmptyEntries); var idParts = runnerQualifiedId.Split("/", StringSplitOptions.RemoveEmptyEntries);
if (idParts.Length != 4 || idParts[3] != _settings.AgentId.ToString()) if (idParts.Length != 4)
{ {
Trace.Error($"Runner qualified id '{runnerQualifiedId}' does not match the current runner '{_settings.AgentId}'."); Trace.Error($"Runner qualified id '{runnerQualifiedId}' does not match the current runner '{_settings.AgentId}'.");
await ReportTelemetryAsync($"Runner qualified id '{runnerQualifiedId}' does not match the current runner '{_settings.AgentId}'."); await ReportTelemetryAsync($"Runner qualified id '{runnerQualifiedId}' does not match the current runner '{_settings.AgentId}'.");

View File

@@ -111,7 +111,7 @@ namespace GitHub.Runner.Worker
{ {
// Log the error and fail the PrepareActionsAsync Initialization. // Log the error and fail the PrepareActionsAsync Initialization.
Trace.Error($"Caught exception from PrepareActionsAsync Initialization: {ex}"); Trace.Error($"Caught exception from PrepareActionsAsync Initialization: {ex}");
executionContext.InfrastructureError(ex.Message); executionContext.InfrastructureError(ex.Message, category: "resolve_action");
executionContext.Result = TaskResult.Failed; executionContext.Result = TaskResult.Failed;
throw; throw;
} }
@@ -119,7 +119,7 @@ namespace GitHub.Runner.Worker
{ {
// Log the error and fail the PrepareActionsAsync Initialization. // Log the error and fail the PrepareActionsAsync Initialization.
Trace.Error($"Caught exception from PrepareActionsAsync Initialization: {ex}"); Trace.Error($"Caught exception from PrepareActionsAsync Initialization: {ex}");
executionContext.InfrastructureError(ex.Message); executionContext.InfrastructureError(ex.Message, category: "invalid_action_download");
executionContext.Result = TaskResult.Failed; executionContext.Result = TaskResult.Failed;
throw; throw;
} }

View File

@@ -111,19 +111,19 @@ namespace GitHub.Runner.Worker.Container
{ {
IList<string> dockerOptions = new List<string>(); IList<string> dockerOptions = new List<string>();
// OPTIONS // OPTIONS
dockerOptions.Add(DockerUtil.CreateEscapedOption("--name", container.ContainerDisplayName)); dockerOptions.Add($"--name {container.ContainerDisplayName}");
dockerOptions.Add($"--label {DockerInstanceLabel}"); dockerOptions.Add($"--label {DockerInstanceLabel}");
if (!string.IsNullOrEmpty(container.ContainerWorkDirectory)) if (!string.IsNullOrEmpty(container.ContainerWorkDirectory))
{ {
dockerOptions.Add(DockerUtil.CreateEscapedOption("--workdir", container.ContainerWorkDirectory)); dockerOptions.Add($"--workdir {container.ContainerWorkDirectory}");
} }
if (!string.IsNullOrEmpty(container.ContainerNetwork)) if (!string.IsNullOrEmpty(container.ContainerNetwork))
{ {
dockerOptions.Add(DockerUtil.CreateEscapedOption("--network", container.ContainerNetwork)); dockerOptions.Add($"--network {container.ContainerNetwork}");
} }
if (!string.IsNullOrEmpty(container.ContainerNetworkAlias)) if (!string.IsNullOrEmpty(container.ContainerNetworkAlias))
{ {
dockerOptions.Add(DockerUtil.CreateEscapedOption("--network-alias", container.ContainerNetworkAlias)); dockerOptions.Add($"--network-alias {container.ContainerNetworkAlias}");
} }
foreach (var port in container.UserPortMappings) foreach (var port in container.UserPortMappings)
{ {
@@ -195,10 +195,10 @@ namespace GitHub.Runner.Worker.Container
{ {
IList<string> dockerOptions = new List<string>(); IList<string> dockerOptions = new List<string>();
// OPTIONS // OPTIONS
dockerOptions.Add(DockerUtil.CreateEscapedOption("--name", container.ContainerDisplayName)); dockerOptions.Add($"--name {container.ContainerDisplayName}");
dockerOptions.Add($"--label {DockerInstanceLabel}"); dockerOptions.Add($"--label {DockerInstanceLabel}");
dockerOptions.Add(DockerUtil.CreateEscapedOption("--workdir", container.ContainerWorkDirectory)); dockerOptions.Add($"--workdir {container.ContainerWorkDirectory}");
dockerOptions.Add($"--rm"); dockerOptions.Add($"--rm");
foreach (var env in container.ContainerEnvironmentVariables) foreach (var env in container.ContainerEnvironmentVariables)

View File

@@ -522,6 +522,10 @@ namespace GitHub.Runner.Worker
if (annotation != null) if (annotation != null)
{ {
stepResult.Annotations.Add(annotation.Value); stepResult.Annotations.Add(annotation.Value);
if (annotation.Value.IsInfrastructureIssue && string.IsNullOrEmpty(Global.InfrastructureFailureCategory))
{
Global.InfrastructureFailureCategory = issue.Category;
}
} }
}); });
@@ -1335,9 +1339,9 @@ namespace GitHub.Runner.Worker
} }
// Do not add a format string overload. See comment on ExecutionContext.Write(). // Do not add a format string overload. See comment on ExecutionContext.Write().
public static void InfrastructureError(this IExecutionContext context, string message) public static void InfrastructureError(this IExecutionContext context, string message, string category = null)
{ {
var issue = new Issue() { Type = IssueType.Error, Message = message, IsInfrastructureIssue = true }; var issue = new Issue() { Type = IssueType.Error, Message = message, IsInfrastructureIssue = true, Category = category };
context.AddIssue(issue, ExecutionContextLogOptions.Default); context.AddIssue(issue, ExecutionContextLogOptions.Default);
} }

View File

@@ -27,6 +27,7 @@ namespace GitHub.Runner.Worker
public StepsContext StepsContext { get; set; } public StepsContext StepsContext { get; set; }
public Variables Variables { get; set; } public Variables Variables { get; set; }
public bool WriteDebug { get; set; } public bool WriteDebug { get; set; }
public string InfrastructureFailureCategory { get; set; }
public JObject ContainerHookState { get; set; } public JObject ContainerHookState { get; set; }
} }
} }

View File

@@ -249,7 +249,7 @@ namespace GitHub.Runner.Worker.Handlers
{ {
// We do not not the full path until we know what shell is being used, so that we can determine the file extension // We do not not the full path until we know what shell is being used, so that we can determine the file extension
scriptFilePath = Path.Combine(tempDirectory, $"{Guid.NewGuid()}{ScriptHandlerHelpers.GetScriptFileExtension(shellCommand)}"); scriptFilePath = Path.Combine(tempDirectory, $"{Guid.NewGuid()}{ScriptHandlerHelpers.GetScriptFileExtension(shellCommand)}");
resolvedScriptPath = $"\"{StepHost.ResolvePathForStepHost(ExecutionContext, scriptFilePath).Replace("\"", "\\\"")}\""; resolvedScriptPath = StepHost.ResolvePathForStepHost(ExecutionContext, scriptFilePath).Replace("\"", "\\\"");
} }
else else
{ {
@@ -260,7 +260,7 @@ namespace GitHub.Runner.Worker.Handlers
} }
scriptFilePath = Inputs["path"]; scriptFilePath = Inputs["path"];
ArgUtil.NotNullOrEmpty(scriptFilePath, "path"); ArgUtil.NotNullOrEmpty(scriptFilePath, "path");
resolvedScriptPath = $"\"{Inputs["path"].Replace("\"", "\\\"")}\""; resolvedScriptPath = Inputs["path"].Replace("\"", "\\\"");
} }
// Format arg string with script path // Format arg string with script path

View File

@@ -2,7 +2,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Text.RegularExpressions;
using GitHub.Runner.Sdk; using GitHub.Runner.Sdk;
using GitHub.Runner.Common; using GitHub.Runner.Common;
using GitHub.Runner.Common.Util; using GitHub.Runner.Common.Util;
@@ -64,47 +63,10 @@ namespace GitHub.Runner.Worker.Handlers
var append = @"if ((Test-Path -LiteralPath variable:\LASTEXITCODE)) { exit $LASTEXITCODE }"; var append = @"if ((Test-Path -LiteralPath variable:\LASTEXITCODE)) { exit $LASTEXITCODE }";
contents = $"{prepend}{Environment.NewLine}{contents}{Environment.NewLine}{append}"; contents = $"{prepend}{Environment.NewLine}{contents}{Environment.NewLine}{append}";
break; break;
case "bash":
case "sh":
contents = FixBashEnvironmentVariables(contents);
break;
} }
return contents; return contents;
} }
/// <summary>
/// Fixes unquoted environment variables in bash/sh scripts to prevent issues with paths containing spaces.
/// This method quotes environment variables used in shell redirects and command substitutions.
/// </summary>
/// <param name="contents">The shell script content to fix</param>
/// <returns>Fixed shell script content with properly quoted environment variables</returns>
private static string FixBashEnvironmentVariables(string contents)
{
if (string.IsNullOrEmpty(contents))
{
return contents;
}
// Pattern to match environment variables in shell redirects that aren't already quoted
// This targets patterns like: >> $GITHUB_STEP_SUMMARY, > $GITHUB_OUTPUT, etc.
// but avoids already quoted ones like: >> "$GITHUB_STEP_SUMMARY" or >> '$GITHUB_OUTPUT'
var redirectPattern = new Regex(
@"(\s+(?:>>|>|<|2>>|2>)\s+)(\$[A-Za-z_][A-Za-z0-9_]*)\b(?!\s*['""])",
RegexOptions.Compiled | RegexOptions.Multiline
);
// Replace unquoted environment variables in redirects with quoted versions
contents = redirectPattern.Replace(contents, match =>
{
var redirectOperator = match.Groups[1].Value; // e.g., " >> "
var envVar = match.Groups[2].Value; // e.g., "$GITHUB_STEP_SUMMARY"
return $"{redirectOperator}\"{envVar}\"";
});
return contents;
}
internal static (string shellCommand, string shellArgs) ParseShellOptionString(string shellOption) internal static (string shellCommand, string shellArgs) ParseShellOptionString(string shellOption)
{ {
var shellStringParts = shellOption.Split(" ", 2); var shellStringParts = shellOption.Split(" ", 2);

View File

@@ -220,7 +220,7 @@ namespace GitHub.Runner.Worker.Handlers
// [OPTIONS] // [OPTIONS]
dockerCommandArgs.Add($"-i"); dockerCommandArgs.Add($"-i");
dockerCommandArgs.Add(DockerUtil.CreateEscapedOption("--workdir", workingDirectory)); dockerCommandArgs.Add($"--workdir {workingDirectory}");
foreach (var env in environment) foreach (var env in environment)
{ {
// e.g. -e MY_SECRET maps the value into the exec'ed process without exposing // e.g. -e MY_SECRET maps the value into the exec'ed process without exposing

View File

@@ -321,7 +321,7 @@ namespace GitHub.Runner.Worker
{ {
try try
{ {
await runServer.CompleteJobAsync(message.Plan.PlanId, message.JobId, result, jobContext.JobOutputs, jobContext.Global.StepsResult, jobContext.Global.JobAnnotations, environmentUrl, telemetry, billingOwnerId: message.BillingOwnerId, default); await runServer.CompleteJobAsync(message.Plan.PlanId, message.JobId, result, jobContext.JobOutputs, jobContext.Global.StepsResult, jobContext.Global.JobAnnotations, environmentUrl, telemetry, billingOwnerId: message.BillingOwnerId, infrastructureFailureCategory: jobContext.Global.InfrastructureFailureCategory, default);
return result; return result;
} }
catch (VssUnauthorizedException ex) catch (VssUnauthorizedException ex)

View File

@@ -12,12 +12,6 @@
<PublishReadyToRunComposite>true</PublishReadyToRunComposite> <PublishReadyToRunComposite>true</PublishReadyToRunComposite>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>Test</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Sdk\Sdk.csproj" /> <ProjectReference Include="..\Sdk\Sdk.csproj" />
<ProjectReference Include="..\Runner.Common\Runner.Common.csproj" /> <ProjectReference Include="..\Runner.Common\Runner.Common.csproj" />

View File

@@ -18,6 +18,16 @@ namespace GitHub.DistributedTask.WebApi
internal set; internal set;
} }
/// <summary>
/// The url to refresh tokens with legacy service
/// </summary>
[JsonProperty("legacy_authorization_url")]
public Uri LegacyAuthorizationUrl
{
get;
internal set;
}
/// <summary> /// <summary>
/// The url to connect to poll for messages /// The url to connect to poll for messages
/// </summary> /// </summary>

View File

@@ -35,5 +35,8 @@ namespace GitHub.Actions.RunService.WebApi
[DataMember(Name = "billingOwnerId", EmitDefaultValue = false)] [DataMember(Name = "billingOwnerId", EmitDefaultValue = false)]
public string BillingOwnerId { get; set; } public string BillingOwnerId { get; set; }
[DataMember(Name = "infrastructureFailureCategory", EmitDefaultValue = false)]
public string InfrastructureFailureCategory { get; set; }
} }
} }

View File

@@ -42,6 +42,7 @@ namespace Sdk.RSWebApi.Contracts
StartColumn = columnNumber, StartColumn = columnNumber,
EndColumn = endColumnNumber, EndColumn = endColumnNumber,
StepNumber = stepNumber, StepNumber = stepNumber,
IsInfrastructureIssue = issue.IsInfrastructureIssue ?? false
}; };
} }

View File

@@ -131,6 +131,7 @@ namespace GitHub.Actions.RunService.WebApi
string environmentUrl, string environmentUrl,
IList<Telemetry> telemetry, IList<Telemetry> telemetry,
string billingOwnerId, string billingOwnerId,
string infrastructureFailureCategory,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
HttpMethod httpMethod = new HttpMethod("POST"); HttpMethod httpMethod = new HttpMethod("POST");
@@ -145,6 +146,7 @@ namespace GitHub.Actions.RunService.WebApi
EnvironmentUrl = environmentUrl, EnvironmentUrl = environmentUrl,
Telemetry = telemetry, Telemetry = telemetry,
BillingOwnerId = billingOwnerId, BillingOwnerId = billingOwnerId,
InfrastructureFailureCategory = infrastructureFailureCategory
}; };
requestUri = new Uri(requestUri, "completejob"); requestUri = new Uri(requestUri, "completejob");

View File

@@ -14,7 +14,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Azure.Storage.Blobs" Version="12.25.0" /> <PackageReference Include="Azure.Storage.Blobs" Version="12.25.1" />
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" /> <PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Microsoft.AspNet.WebApi.Client" Version="6.0.0" /> <PackageReference Include="Microsoft.AspNet.WebApi.Client" Version="6.0.0" />

View File

@@ -1,278 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using GitHub.DistributedTask.Pipelines.ContextData;
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common;
using GitHub.Runner.Worker;
using GitHub.Runner.Worker.Handlers;
using Moq;
using Xunit;
namespace GitHub.Runner.Common.Tests.Worker.Handlers
{
public sealed class ScriptHandlerL0
{
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ScriptPath_WithSpaces_ShouldBeQuoted()
{
// Arrange - Test the path quoting logic that our fix addresses
var tempPathWithSpaces = "/path with spaces/_temp";
var scriptPathWithSpaces = Path.Combine(tempPathWithSpaces, "test-script.sh");
// Test the original (broken) behavior
var originalPath = scriptPathWithSpaces.Replace("\"", "\\\"");
// Test our fix - properly quoted path
var quotedPath = $"\"{scriptPathWithSpaces.Replace("\"", "\\\"")}\"";
// Assert
Assert.False(originalPath.StartsWith("\""), "Original path should not be quoted");
Assert.True(quotedPath.StartsWith("\"") && quotedPath.EndsWith("\""), "Fixed path should be properly quoted");
Assert.Contains("path with spaces", quotedPath, StringComparison.Ordinal);
// Verify the path is properly quoted (platform-agnostic check)
Assert.True(quotedPath.StartsWith("\"/path with spaces/_temp"), "Path should start with quoted temp directory");
Assert.True(quotedPath.EndsWith("test-script.sh\""), "Path should end with quoted script name");
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ScriptPath_WithQuotes_ShouldEscapeQuotes()
{
// Arrange - Test paths that contain quotes
var pathWithQuotes = "/path/\"quoted folder\"/script.sh";
// Test our fix - properly escape quotes and wrap in quotes
var quotedPath = $"\"{pathWithQuotes.Replace("\"", "\\\"")}\"";
// Assert
Assert.True(quotedPath.StartsWith("\"") && quotedPath.EndsWith("\""), "Path should be wrapped in quotes");
Assert.Contains("\\\"", quotedPath, StringComparison.Ordinal);
Assert.Contains("quoted folder", quotedPath, StringComparison.Ordinal);
// Verify quotes are properly escaped
Assert.Contains("\\\"quoted folder\\\"", quotedPath, StringComparison.Ordinal);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ScriptPath_ActionsRunnerWithSpaces_ShouldBeQuoted()
{
// Arrange - Test the specific real-world scenario that was failing
var runnerPathWithSpaces = "/Users/user/Downloads/actions-runner-osx-arm64-2.328.0 2";
var tempPath = Path.Combine(runnerPathWithSpaces, "_work", "_temp");
var scriptPath = Path.Combine(tempPath, "script-guid.sh");
// Test our fix
var quotedPath = $"\"{scriptPath.Replace("\"", "\\\"")}\"";
// Assert
Assert.True(quotedPath.StartsWith("\"") && quotedPath.EndsWith("\""), "Path should be wrapped in quotes");
Assert.Contains("actions-runner-osx-arm64-2.328.0 2", quotedPath, StringComparison.Ordinal);
Assert.Contains("_work", quotedPath, StringComparison.Ordinal);
Assert.Contains("_temp", quotedPath, StringComparison.Ordinal);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ScriptPath_MultipleSpaces_ShouldBeQuoted()
{
// Arrange - Test paths with multiple spaces
var pathWithMultipleSpaces = "/path/with multiple spaces/script.sh";
// Test our fix
var quotedPath = $"\"{pathWithMultipleSpaces.Replace("\"", "\\\"")}\"";
// Assert
Assert.True(quotedPath.StartsWith("\"") && quotedPath.EndsWith("\""), "Path should be wrapped in quotes");
Assert.Contains("multiple spaces", quotedPath, StringComparison.Ordinal);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ScriptPath_WithoutSpaces_ShouldStillBeQuoted()
{
// Arrange - Test normal paths without spaces (regression test)
var normalPath = "/home/user/runner/_work/_temp/script.sh";
// Test our fix
var quotedPath = $"\"{normalPath.Replace("\"", "\\\"")}\"";
// Assert
Assert.True(quotedPath.StartsWith("\"") && quotedPath.EndsWith("\""), "Path should be wrapped in quotes");
Assert.Equal($"\"{normalPath}\"", quotedPath);
}
[Theory]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
[InlineData("/path with spaces/script.sh")]
[InlineData("/Users/user/Downloads/actions-runner-osx-arm64-2.328.0 2/_work/_temp/guid.sh")]
[InlineData("C:\\Program Files\\GitHub Runner\\script.cmd")]
[InlineData("/path/\"with quotes\"/script.sh")]
[InlineData("/path/with'single'quotes/script.sh")]
public void ScriptPath_VariousScenarios_ShouldBeProperlyQuoted(string inputPath)
{
// Arrange & Act
var quotedPath = $"\"{inputPath.Replace("\"", "\\\"")}\"";
// Assert
Assert.True(quotedPath.StartsWith("\""), "Path should start with quote");
Assert.True(quotedPath.EndsWith("\""), "Path should end with quote");
// Ensure the original path content is preserved
var unquotedContent = quotedPath.Substring(1, quotedPath.Length - 2);
if (inputPath.Contains("\""))
{
// If original had quotes, they should be escaped in the result
Assert.Contains("\\\"", unquotedContent);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void FixUpScriptContents_BashEnvironmentVariables_ShouldQuoteRedirects()
{
// Arrange
var scriptContent = @"echo ""## Dependency Status Report"" >> $GITHUB_STEP_SUMMARY
echo ""Generated on: $(date)"" >> $GITHUB_STEP_SUMMARY
echo ""| Component | Status |"" > $GITHUB_OUTPUT
echo ""npm-status=ok"" >> $GITHUB_OUTPUT";
// Act
var fixedContent = ScriptHandlerHelpers.FixUpScriptContents("bash", scriptContent);
// Assert
Assert.Contains(">> \"$GITHUB_STEP_SUMMARY\"", fixedContent);
Assert.Contains("> \"$GITHUB_OUTPUT\"", fixedContent);
Assert.DoesNotContain(">> $GITHUB_STEP_SUMMARY", fixedContent);
Assert.DoesNotContain("> $GITHUB_OUTPUT", fixedContent);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void FixUpScriptContents_AlreadyQuotedVariables_ShouldNotDoubleQuote()
{
// Arrange
var scriptContent = @"echo ""test"" >> ""$GITHUB_STEP_SUMMARY""
echo ""test"" > '$GITHUB_OUTPUT'
echo ""test"" 2>> ""$GITHUB_ENV""";
// Act
var fixedContent = ScriptHandlerHelpers.FixUpScriptContents("bash", scriptContent);
// Assert - Should remain unchanged
Assert.Equal(scriptContent, fixedContent);
Assert.Contains(">> \"$GITHUB_STEP_SUMMARY\"", fixedContent);
Assert.Contains("> '$GITHUB_OUTPUT'", fixedContent);
Assert.Contains("2>> \"$GITHUB_ENV\"", fixedContent);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void FixUpScriptContents_ShellRedirectOperators_ShouldHandleAllTypes()
{
// Arrange
var scriptContent = @"echo ""test"" >> $VAR1
echo ""test"" > $VAR2
cat < $VAR3
echo ""test"" 2>> $VAR4
echo ""test"" 2> $VAR5";
// Act
var fixedContent = ScriptHandlerHelpers.FixUpScriptContents("sh", scriptContent);
// Assert
Assert.Contains(">> \"$VAR1\"", fixedContent);
Assert.Contains("> \"$VAR2\"", fixedContent);
Assert.Contains("< \"$VAR3\"", fixedContent);
Assert.Contains("2>> \"$VAR4\"", fixedContent);
Assert.Contains("2> \"$VAR5\"", fixedContent);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void FixUpScriptContents_NonShellTypes_ShouldNotModifyEnvironmentVariables()
{
// Arrange
var scriptContent = @"echo ""test"" >> $GITHUB_STEP_SUMMARY";
// Act
var powershellFixed = ScriptHandlerHelpers.FixUpScriptContents("powershell", scriptContent);
var cmdFixed = ScriptHandlerHelpers.FixUpScriptContents("cmd", scriptContent);
var pythonFixed = ScriptHandlerHelpers.FixUpScriptContents("python", scriptContent);
// Assert - Should not modify environment variables for non-shell types
Assert.Contains(">> $GITHUB_STEP_SUMMARY", powershellFixed);
Assert.Contains(">> $GITHUB_STEP_SUMMARY", cmdFixed);
Assert.Contains(">> $GITHUB_STEP_SUMMARY", pythonFixed);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void FixUpScriptContents_ComplexScript_ShouldQuoteOnlyUnquotedRedirects()
{
// Arrange
var scriptContent = @"#!/bin/bash
# This is a test script
echo ""Starting workflow"" >> $GITHUB_STEP_SUMMARY
echo ""Already quoted"" >> ""$GITHUB_OUTPUT""
export MY_VAR=""$HOME/path with spaces""
curl -s https://api.github.com/rate_limit > $TEMP_FILE
echo ""Final status"" 2>> $ERROR_LOG
if [ -f ""$GITHUB_ENV"" ]; then
echo ""MY_VAR=test"" >> $GITHUB_ENV
fi";
// Act
var fixedContent = ScriptHandlerHelpers.FixUpScriptContents("bash", scriptContent);
// Assert
Assert.Contains(">> \"$GITHUB_STEP_SUMMARY\"", fixedContent);
Assert.Contains(">> \"$GITHUB_OUTPUT\"", fixedContent); // Should remain quoted
Assert.Contains("> \"$TEMP_FILE\"", fixedContent);
Assert.Contains("2>> \"$ERROR_LOG\"", fixedContent);
Assert.Contains(">> \"$GITHUB_ENV\"", fixedContent);
// Other parts should remain unchanged
Assert.Contains("#!/bin/bash", fixedContent);
Assert.Contains("# This is a test script", fixedContent);
Assert.Contains("export MY_VAR=\"$HOME/path with spaces\"", fixedContent);
Assert.Contains("if [ -f \"$GITHUB_ENV\" ]; then", fixedContent);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void FixUpScriptContents_EnvironmentVariablesInCommands_ShouldNotQuote()
{
// Arrange - Environment variables not in redirects should not be touched
var scriptContent = @"echo $GITHUB_STEP_SUMMARY
cd $HOME
ls -la $TEMP_DIR
if [ ""$MY_VAR"" == ""test"" ]; then
echo ""match""
fi";
// Act
var fixedContent = ScriptHandlerHelpers.FixUpScriptContents("bash", scriptContent);
// Assert - Should remain unchanged as these are not redirects
Assert.Equal(scriptContent, fixedContent);
}
}
}