mirror of
https://github.com/actions/runner.git
synced 2026-04-13 02:48:06 +08:00
Compare commits
4 Commits
salmanmkc/
...
adr-10024/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04ee71a5d1 | ||
|
|
4342a1b8f0 | ||
|
|
5806ab55db | ||
|
|
cb8124fc4a |
@@ -900,6 +900,10 @@ namespace GitHub.Runner.Worker
|
||||
jobContext[pair.Key] = pair.Value;
|
||||
}
|
||||
}
|
||||
|
||||
// Derive workflow_repository and workflow_file_path from workflow_ref
|
||||
// if the server sent workflow_ref but not the decomposed fields
|
||||
jobContext.DeriveWorkflowRefComponents();
|
||||
ExpressionValues["job"] = jobContext;
|
||||
|
||||
Trace.Info("Initialize GitHub context");
|
||||
|
||||
@@ -146,5 +146,51 @@ namespace GitHub.Runner.Worker
|
||||
this["workflow_file_path"] = value != null ? new StringContextData(value) : null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a composite workflow_ref (e.g. "owner/repo/.github/workflows/file.yml@refs/heads/main")
|
||||
/// and populates workflow_repository and workflow_file_path if they are not already set.
|
||||
/// </summary>
|
||||
public void DeriveWorkflowRefComponents()
|
||||
{
|
||||
var workflowRef = WorkflowRef;
|
||||
if (string.IsNullOrEmpty(workflowRef))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Format: owner/repo/.github/workflows/file.yml@ref
|
||||
var atIndex = workflowRef.IndexOf('@');
|
||||
var pathPart = atIndex >= 0 ? workflowRef.Substring(0, atIndex) : workflowRef;
|
||||
|
||||
// Split at /.github/workflows/ to correctly handle repos named ".github"
|
||||
// e.g. "octo-org/.github/.github/workflows/ci.yml" → repo="octo-org/.github"
|
||||
var marker = "/.github/workflows/";
|
||||
var markerIndex = pathPart.IndexOf(marker);
|
||||
if (markerIndex < 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var repo = pathPart.Substring(0, markerIndex);
|
||||
var filePath = pathPart.Substring(markerIndex + 1); // skip leading '/'
|
||||
|
||||
// Validate repo is owner/repo format (must have at least one slash with non-empty segments)
|
||||
var slashIndex = repo.IndexOf('/');
|
||||
if (slashIndex <= 0 || slashIndex >= repo.Length - 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (WorkflowRepository == null || WorkflowRepository == "")
|
||||
{
|
||||
WorkflowRepository = repo;
|
||||
}
|
||||
|
||||
if (WorkflowFilePath == null || WorkflowFilePath == "")
|
||||
{
|
||||
WorkflowFilePath = filePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
||||
return;
|
||||
}
|
||||
|
||||
var effectiveMax = explicitMax ?? CreatePermissionsFromPolicy(context, permissionsPolicy, includeIdToken: isTrusted, includeModels: context.GetFeatures().AllowModelsPermission, includeWorkflows: context.GetFeatures().AllowWorkflowsPermission);
|
||||
var effectiveMax = explicitMax ?? CreatePermissionsFromPolicy(context, permissionsPolicy, includeIdToken: isTrusted, includeModels: context.GetFeatures().AllowModelsPermission);
|
||||
|
||||
if (requested.ViolatesMaxPermissions(effectiveMax, out var permissionLevelViolations))
|
||||
{
|
||||
@@ -59,8 +59,7 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
||||
TemplateContext context,
|
||||
string permissionsPolicy,
|
||||
bool includeIdToken,
|
||||
bool includeModels,
|
||||
bool includeWorkflows)
|
||||
bool includeModels)
|
||||
{
|
||||
switch (permissionsPolicy)
|
||||
{
|
||||
@@ -71,7 +70,7 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
||||
Packages = PermissionLevel.Read,
|
||||
};
|
||||
case WorkflowConstants.PermissionsPolicy.Write:
|
||||
return new Permissions(PermissionLevel.Write, includeIdToken: includeIdToken, includeAttestations: true, includeModels: includeModels, includeWorkflows: includeWorkflows);
|
||||
return new Permissions(PermissionLevel.Write, includeIdToken: includeIdToken, includeAttestations: true, includeModels: includeModels);
|
||||
default:
|
||||
throw new ArgumentException($"Unexpected permission policy: '{permissionsPolicy}'");
|
||||
}
|
||||
|
||||
@@ -1877,7 +1877,7 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
||||
permissionsStr.AssertUnexpectedValue(permissionsStr.Value);
|
||||
break;
|
||||
}
|
||||
return new Permissions(permissionLevel, includeIdToken: true, includeAttestations: true, includeModels: context.GetFeatures().AllowModelsPermission, includeWorkflows: context.GetFeatures().AllowWorkflowsPermission);
|
||||
return new Permissions(permissionLevel, includeIdToken: true, includeAttestations: true, includeModels: context.GetFeatures().AllowModelsPermission);
|
||||
}
|
||||
|
||||
var mapping = token.AssertMapping("permissions");
|
||||
@@ -1957,24 +1957,6 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
||||
context.Error(key, $"The permission 'models' is not allowed");
|
||||
}
|
||||
break;
|
||||
case "workflows":
|
||||
if (context.GetFeatures().AllowWorkflowsPermission)
|
||||
{
|
||||
// Workflows only supports write; downgrade read to none
|
||||
if (permissionLevel == PermissionLevel.Read)
|
||||
{
|
||||
permissions.Workflows = PermissionLevel.NoAccess;
|
||||
}
|
||||
else
|
||||
{
|
||||
permissions.Workflows = permissionLevel;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
context.Error(key, $"The permission 'workflows' is not allowed");
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.Serialization;
|
||||
using GitHub.Actions.WorkflowParser.Conversion;
|
||||
@@ -17,7 +17,7 @@ namespace GitHub.Actions.WorkflowParser
|
||||
public Permissions(Permissions copy)
|
||||
{
|
||||
Actions = copy.Actions;
|
||||
ArtifactMetadata = copy.ArtifactMetadata;
|
||||
ArtifactMetadata = copy.ArtifactMetadata;
|
||||
Attestations = copy.Attestations;
|
||||
Checks = copy.Checks;
|
||||
Contents = copy.Contents;
|
||||
@@ -32,18 +32,16 @@ namespace GitHub.Actions.WorkflowParser
|
||||
SecurityEvents = copy.SecurityEvents;
|
||||
IdToken = copy.IdToken;
|
||||
Models = copy.Models;
|
||||
Workflows = copy.Workflows;
|
||||
}
|
||||
|
||||
public Permissions(
|
||||
PermissionLevel permissionLevel,
|
||||
bool includeIdToken,
|
||||
bool includeAttestations,
|
||||
bool includeModels,
|
||||
bool includeWorkflows = false)
|
||||
bool includeModels)
|
||||
{
|
||||
Actions = permissionLevel;
|
||||
ArtifactMetadata = permissionLevel;
|
||||
ArtifactMetadata = permissionLevel;
|
||||
Attestations = includeAttestations ? permissionLevel : PermissionLevel.NoAccess;
|
||||
Checks = permissionLevel;
|
||||
Contents = permissionLevel;
|
||||
@@ -58,12 +56,8 @@ namespace GitHub.Actions.WorkflowParser
|
||||
SecurityEvents = permissionLevel;
|
||||
IdToken = includeIdToken ? permissionLevel : PermissionLevel.NoAccess;
|
||||
// Models must not have higher permissions than Read
|
||||
Models = includeModels
|
||||
? (permissionLevel == PermissionLevel.Write ? PermissionLevel.Read : permissionLevel)
|
||||
: PermissionLevel.NoAccess;
|
||||
// Workflows is write-only, so only grant it when permissionLevel is Write
|
||||
Workflows = includeWorkflows && permissionLevel == PermissionLevel.Write
|
||||
? PermissionLevel.Write
|
||||
Models = includeModels
|
||||
? (permissionLevel == PermissionLevel.Write ? PermissionLevel.Read : permissionLevel)
|
||||
: PermissionLevel.NoAccess;
|
||||
}
|
||||
|
||||
@@ -87,7 +81,6 @@ namespace GitHub.Actions.WorkflowParser
|
||||
new KeyValuePair<string, (PermissionLevel, PermissionLevel)>("security-events", (left.SecurityEvents, right.SecurityEvents)),
|
||||
new KeyValuePair<string, (PermissionLevel, PermissionLevel)>("id-token", (left.IdToken, right.IdToken)),
|
||||
new KeyValuePair<string, (PermissionLevel, PermissionLevel)>("models", (left.Models, right.Models)),
|
||||
new KeyValuePair<string, (PermissionLevel, PermissionLevel)>("workflows", (left.Workflows, right.Workflows)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -203,13 +196,6 @@ namespace GitHub.Actions.WorkflowParser
|
||||
set;
|
||||
}
|
||||
|
||||
[DataMember(Name = "workflows", EmitDefaultValue = false)]
|
||||
public PermissionLevel Workflows
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
|
||||
public Permissions Clone()
|
||||
{
|
||||
return new Permissions(this);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
@@ -41,13 +41,6 @@ namespace GitHub.Actions.WorkflowParser
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public bool AllowModelsPermission { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether users may use the "workflows" permission.
|
||||
/// Used during parsing only.
|
||||
/// </summary>
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public bool AllowWorkflowsPermission { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the expression function fromJson performs strict JSON parsing.
|
||||
/// Used during evaluation only.
|
||||
@@ -74,7 +67,6 @@ namespace GitHub.Actions.WorkflowParser
|
||||
Snapshot = false, // Default to false since this feature is still in an experimental phase
|
||||
StrictJsonParsing = false, // Default to false since this is temporary for telemetry purposes only
|
||||
AllowModelsPermission = false, // Default to false since we want this to be disabled for all non-production environments
|
||||
AllowWorkflowsPermission = false, // Default to false; gated by feature flag for controlled rollout
|
||||
AllowServiceContainerCommand = false, // Default to false since this feature is gated by actions_service_container_command
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1256,22 +1256,22 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
var ec = new Runner.Worker.ExecutionContext();
|
||||
ec.Initialize(hc);
|
||||
|
||||
// Arrange: Server sends all 4 workflow identity fields
|
||||
// Arrange: Add workflow identity to the job context
|
||||
var jobContext = new Pipelines.ContextData.DictionaryContextData();
|
||||
jobContext["workflow_ref"] = new StringContextData("my-org/my-repo/.github/workflows/reusable.yml@refs/heads/main");
|
||||
jobContext["workflow_sha"] = new StringContextData("abc123def456");
|
||||
jobContext["workflow_repository"] = new StringContextData("my-org/my-repo");
|
||||
jobContext["workflow_file_path"] = new StringContextData(".github/workflows/reusable.yml");
|
||||
jobRequest.ContextData["job"] = jobContext;
|
||||
jobRequest.ContextData["github"] = new Pipelines.ContextData.DictionaryContextData();
|
||||
|
||||
// Act
|
||||
ec.InitializeJob(jobRequest, CancellationToken.None);
|
||||
|
||||
// Assert: all properties hydrated from server
|
||||
// Assert: direct properties from server
|
||||
Assert.NotNull(ec.JobContext);
|
||||
Assert.Equal("my-org/my-repo/.github/workflows/reusable.yml@refs/heads/main", ec.JobContext.WorkflowRef);
|
||||
Assert.Equal("abc123def456", ec.JobContext.WorkflowSha);
|
||||
|
||||
// Assert: derived properties
|
||||
Assert.Equal("my-org/my-repo", ec.JobContext.WorkflowRepository);
|
||||
Assert.Equal(".github/workflows/reusable.yml", ec.JobContext.WorkflowFilePath);
|
||||
}
|
||||
@@ -1310,6 +1310,41 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void InitializeJob_WorkflowIdentityDerived_WhenServerSendsAllFields()
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
// Arrange: Server sends all 4 fields explicitly
|
||||
var variables = new Dictionary<string, VariableValue>();
|
||||
var jobRequest = new Pipelines.AgentJobRequestMessage(new TaskOrchestrationPlanReference(), new TimelineReference(), Guid.NewGuid(), "some job name", "some job name", null, null, null, variables, new List<MaskHint>(), new Pipelines.JobResources(), new Pipelines.ContextData.DictionaryContextData(), new Pipelines.WorkspaceOptions(), new List<Pipelines.ActionStep>(), null, null, null, null, null);
|
||||
var pagingLogger = new Moq.Mock<IPagingLogger>();
|
||||
var jobServerQueue = new Moq.Mock<IJobServerQueue>();
|
||||
hc.EnqueueInstance(pagingLogger.Object);
|
||||
hc.SetSingleton(jobServerQueue.Object);
|
||||
var ec = new Runner.Worker.ExecutionContext();
|
||||
ec.Initialize(hc);
|
||||
|
||||
// Arrange: Server sends all fields, derivation should not overwrite
|
||||
var jobContext = new Pipelines.ContextData.DictionaryContextData();
|
||||
jobContext["workflow_ref"] = new StringContextData("my-org/my-repo/.github/workflows/reusable.yml@refs/heads/main");
|
||||
jobContext["workflow_sha"] = new StringContextData("abc123def456");
|
||||
jobContext["workflow_repository"] = new StringContextData("explicit-org/explicit-repo");
|
||||
jobContext["workflow_file_path"] = new StringContextData(".github/workflows/explicit.yml");
|
||||
jobRequest.ContextData["job"] = jobContext;
|
||||
jobRequest.ContextData["github"] = new Pipelines.ContextData.DictionaryContextData();
|
||||
|
||||
// Act
|
||||
ec.InitializeJob(jobRequest, CancellationToken.None);
|
||||
|
||||
// Assert: explicit values should be preserved, not overwritten by derivation
|
||||
Assert.Equal("explicit-org/explicit-repo", ec.JobContext.WorkflowRepository);
|
||||
Assert.Equal(".github/workflows/explicit.yml", ec.JobContext.WorkflowFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
private bool ExpressionValuesAssertEqual(DictionaryContextData expect, DictionaryContextData actual)
|
||||
{
|
||||
foreach (var key in expect.Keys.ToList())
|
||||
|
||||
@@ -138,5 +138,120 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
ctx.WorkflowFilePath = null;
|
||||
Assert.Null(ctx.WorkflowFilePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeriveWorkflowRefComponents_PopulatesRepositoryAndFilePath()
|
||||
{
|
||||
var ctx = new JobContext();
|
||||
ctx.WorkflowRef = "my-org/my-repo/.github/workflows/deploy.yml@refs/heads/main";
|
||||
ctx.DeriveWorkflowRefComponents();
|
||||
|
||||
Assert.Equal("my-org/my-repo", ctx.WorkflowRepository);
|
||||
Assert.Equal(".github/workflows/deploy.yml", ctx.WorkflowFilePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeriveWorkflowRefComponents_DoesNotOverwriteExistingValues()
|
||||
{
|
||||
var ctx = new JobContext();
|
||||
ctx.WorkflowRef = "my-org/my-repo/.github/workflows/deploy.yml@refs/heads/main";
|
||||
ctx.WorkflowRepository = "explicit/override";
|
||||
ctx.WorkflowFilePath = ".github/workflows/override.yml";
|
||||
ctx.DeriveWorkflowRefComponents();
|
||||
|
||||
Assert.Equal("explicit/override", ctx.WorkflowRepository);
|
||||
Assert.Equal(".github/workflows/override.yml", ctx.WorkflowFilePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeriveWorkflowRefComponents_NoOp_WhenWorkflowRefIsNull()
|
||||
{
|
||||
var ctx = new JobContext();
|
||||
ctx.DeriveWorkflowRefComponents();
|
||||
|
||||
Assert.Null(ctx.WorkflowRepository);
|
||||
Assert.Null(ctx.WorkflowFilePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeriveWorkflowRefComponents_NoOp_WhenRefHasNoGithubDir()
|
||||
{
|
||||
var ctx = new JobContext();
|
||||
ctx.WorkflowRef = "some/path/without/github/dir@refs/heads/main";
|
||||
ctx.DeriveWorkflowRefComponents();
|
||||
|
||||
Assert.Null(ctx.WorkflowRepository);
|
||||
Assert.Null(ctx.WorkflowFilePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeriveWorkflowRefComponents_HandlesRefWithoutAtSign()
|
||||
{
|
||||
var ctx = new JobContext();
|
||||
ctx.WorkflowRef = "my-org/my-repo/.github/workflows/deploy.yml";
|
||||
ctx.DeriveWorkflowRefComponents();
|
||||
|
||||
Assert.Equal("my-org/my-repo", ctx.WorkflowRepository);
|
||||
Assert.Equal(".github/workflows/deploy.yml", ctx.WorkflowFilePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeriveWorkflowRefComponents_HandlesDotGithubRepoName()
|
||||
{
|
||||
// Repos can be named ".github" — the marker must be /.github/workflows/ not /.github/
|
||||
var ctx = new JobContext();
|
||||
ctx.WorkflowRef = "octo-org/.github/.github/workflows/ci.yml@refs/heads/main";
|
||||
ctx.DeriveWorkflowRefComponents();
|
||||
|
||||
Assert.Equal("octo-org/.github", ctx.WorkflowRepository);
|
||||
Assert.Equal(".github/workflows/ci.yml", ctx.WorkflowFilePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeriveWorkflowRefComponents_TreatsEmptyStringAsUnset()
|
||||
{
|
||||
var ctx = new JobContext();
|
||||
ctx.WorkflowRef = "my-org/my-repo/.github/workflows/deploy.yml@refs/heads/main";
|
||||
ctx.WorkflowRepository = "";
|
||||
ctx.WorkflowFilePath = "";
|
||||
ctx.DeriveWorkflowRefComponents();
|
||||
|
||||
Assert.Equal("my-org/my-repo", ctx.WorkflowRepository);
|
||||
Assert.Equal(".github/workflows/deploy.yml", ctx.WorkflowFilePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeriveWorkflowRefComponents_HandlesPRMergeRef()
|
||||
{
|
||||
var ctx = new JobContext();
|
||||
ctx.WorkflowRef = "my-org/my-repo/.github/workflows/ci.yml@refs/pull/42/merge";
|
||||
ctx.DeriveWorkflowRefComponents();
|
||||
|
||||
Assert.Equal("my-org/my-repo", ctx.WorkflowRepository);
|
||||
Assert.Equal(".github/workflows/ci.yml", ctx.WorkflowFilePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeriveWorkflowRefComponents_HandlesTagRef()
|
||||
{
|
||||
var ctx = new JobContext();
|
||||
ctx.WorkflowRef = "my-org/my-repo/.github/workflows/release.yml@refs/tags/v1.0.0";
|
||||
ctx.DeriveWorkflowRefComponents();
|
||||
|
||||
Assert.Equal("my-org/my-repo", ctx.WorkflowRepository);
|
||||
Assert.Equal(".github/workflows/release.yml", ctx.WorkflowFilePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeriveWorkflowRefComponents_RejectsInvalidRepoFormat()
|
||||
{
|
||||
// No owner/repo slash — should no-op
|
||||
var ctx = new JobContext();
|
||||
ctx.WorkflowRef = "noslash.github/workflows/ci.yml@refs/heads/main";
|
||||
ctx.DeriveWorkflowRefComponents();
|
||||
|
||||
Assert.Null(ctx.WorkflowRepository);
|
||||
Assert.Null(ctx.WorkflowFilePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user