Compare commits

...

4 Commits

Author SHA1 Message Date
Salman Muin Kayser Chishti 04ee71a5d1 fix: remove stale flag gate, validate repo format, add edge case tests
- Remove AddCheckRunIdToJobContext flag gate from job context hydration.
  The flag is permanently force-true server-side (acquirejobhandler.go:1658).
  The server controls what to send via its own feature flags.

- Add owner/repo format validation to DeriveWorkflowRefComponents()

- Add tests for PR merge refs, tag refs, invalid repo format, and
  .github repo names

- Update check_run_id flag-disabled test to reflect always-copy behavior
2026-04-10 14:04:28 +00:00
Salman Muin Kayser Chishti 4342a1b8f0 fix: use /.github/workflows/ marker for derivation parsing
Fixes incorrect parsing for repos named .github (e.g. octo-org/.github).
The old /.github/ marker would match too early, deriving the wrong
repository name. Also treats empty-string fields as unset during derivation.
2026-04-10 13:51:54 +00:00
Salman Chishti 5806ab55db Merge branch 'main' into adr-10024/job-workflow-context 2026-04-10 14:32:02 +01:00
Salman Muin Kayser Chishti cb8124fc4a feat: add job.workflow_* typed accessors and derivation fallback
Adds typed C# accessors for workflow_ref, workflow_sha, workflow_repository,
and workflow_file_path to JobContext. Includes DeriveWorkflowRefComponents()
to derive repository and file_path from workflow_ref if not sent by server.

Not strictly required — the run-service already sends all 4 fields and the
existing blanket copy populates them in expressions. This is a code quality
improvement for typed access and a fallback safety net.

Part of ADR 10024 / c2c-actions#10025
2026-04-10 13:25:37 +00:00
4 changed files with 452 additions and 16 deletions
+8 -7
View File
@@ -892,17 +892,18 @@ 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;
}
}
// 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");
+110
View File
@@ -82,5 +82,115 @@ 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;
}
}
/// <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;
}
}
}
}
+115 -9
View File
@@ -1203,19 +1203,19 @@ namespace GitHub.Runner.Common.Tests.Worker
}
}
// TODO: this test can be deleted when `AddCheckRunIdToJobContext` is fully rolled out
// AddCheckRunIdToJobContext is now permanently enabled server-side (hardcoded to "true"
// in acquirejobhandler.go). The runner always copies ContextData["job"] entries, so the
// flag-disabled test is no longer applicable. Replaced with a test that verifies
// check_run_id is always hydrated regardless of the flag value.
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void InitializeJob_HydratesJobContextWithCheckRunId_FeatureFlagDisabled()
public void InitializeJob_HydratesJobContextWithCheckRunId_AlwaysCopied()
{
using (TestHostContext hc = CreateTestContext())
{
// Arrange: Create a job request message and make sure the feature flag is disabled
var variables = new Dictionary<string, VariableValue>()
{
[Constants.Runner.Features.AddCheckRunIdToJobContext] = new VariableValue("false"),
};
// Arrange: No feature flag set at all
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>();
@@ -1233,9 +1233,115 @@ namespace GitHub.Runner.Common.Tests.Worker
// Act
ec.InitializeJob(jobRequest, CancellationToken.None);
// Assert
// Assert: check_run_id is always copied regardless of flag
Assert.NotNull(ec.JobContext);
Assert.Null(ec.JobContext.CheckRunId); // with the feature flag disabled we should not have added a CheckRunId to the JobContext
Assert.Equal(123456, ec.JobContext.CheckRunId);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void InitializeJob_HydratesJobContextWithWorkflowIdentity()
{
using (TestHostContext hc = CreateTestContext())
{
// Arrange
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: 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");
jobRequest.ContextData["job"] = jobContext;
jobRequest.ContextData["github"] = new Pipelines.ContextData.DictionaryContextData();
// Act
ec.InitializeJob(jobRequest, CancellationToken.None);
// 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);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void InitializeJob_WorkflowIdentityNotSet_WhenServerSendsNoData()
{
using (TestHostContext hc = CreateTestContext())
{
// Arrange: Server sends no workflow identity in job context
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: empty job context
jobRequest.ContextData["job"] = new Pipelines.ContextData.DictionaryContextData();
jobRequest.ContextData["github"] = new Pipelines.ContextData.DictionaryContextData();
// Act
ec.InitializeJob(jobRequest, CancellationToken.None);
// Assert: no workflow identity
Assert.NotNull(ec.JobContext);
Assert.Null(ec.JobContext.WorkflowRef);
Assert.Null(ec.JobContext.WorkflowSha);
Assert.Null(ec.JobContext.WorkflowRepository);
Assert.Null(ec.JobContext.WorkflowFilePath);
}
}
[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);
}
}
+219
View File
@@ -34,5 +34,224 @@ namespace GitHub.Runner.Common.Tests.Worker
ctx.CheckRunId = null;
Assert.Null(ctx.CheckRunId);
}
[Fact]
public void WorkflowRef_SetAndGet_WorksCorrectly()
{
var ctx = new JobContext();
ctx.WorkflowRef = "owner/repo/.github/workflows/ci.yml@refs/heads/main";
Assert.Equal("owner/repo/.github/workflows/ci.yml@refs/heads/main", ctx.WorkflowRef);
Assert.True(ctx.TryGetValue("workflow_ref", out var value));
Assert.IsType<StringContextData>(value);
}
[Fact]
public void WorkflowRef_NotSet_ReturnsNull()
{
var ctx = new JobContext();
Assert.Null(ctx.WorkflowRef);
}
[Fact]
public void WorkflowRef_SetNull_ClearsValue()
{
var ctx = new JobContext();
ctx.WorkflowRef = "owner/repo/.github/workflows/ci.yml@refs/heads/main";
ctx.WorkflowRef = null;
Assert.Null(ctx.WorkflowRef);
}
[Fact]
public void WorkflowSha_SetAndGet_WorksCorrectly()
{
var ctx = new JobContext();
ctx.WorkflowSha = "abc123def456";
Assert.Equal("abc123def456", ctx.WorkflowSha);
Assert.True(ctx.TryGetValue("workflow_sha", out var value));
Assert.IsType<StringContextData>(value);
}
[Fact]
public void WorkflowSha_NotSet_ReturnsNull()
{
var ctx = new JobContext();
Assert.Null(ctx.WorkflowSha);
}
[Fact]
public void WorkflowSha_SetNull_ClearsValue()
{
var ctx = new JobContext();
ctx.WorkflowSha = "abc123def456";
ctx.WorkflowSha = null;
Assert.Null(ctx.WorkflowSha);
}
[Fact]
public void WorkflowRepository_SetAndGet_WorksCorrectly()
{
var ctx = new JobContext();
ctx.WorkflowRepository = "owner/repo";
Assert.Equal("owner/repo", ctx.WorkflowRepository);
Assert.True(ctx.TryGetValue("workflow_repository", out var value));
Assert.IsType<StringContextData>(value);
}
[Fact]
public void WorkflowRepository_NotSet_ReturnsNull()
{
var ctx = new JobContext();
Assert.Null(ctx.WorkflowRepository);
}
[Fact]
public void WorkflowRepository_SetNull_ClearsValue()
{
var ctx = new JobContext();
ctx.WorkflowRepository = "owner/repo";
ctx.WorkflowRepository = null;
Assert.Null(ctx.WorkflowRepository);
}
[Fact]
public void WorkflowFilePath_SetAndGet_WorksCorrectly()
{
var ctx = new JobContext();
ctx.WorkflowFilePath = ".github/workflows/ci.yml";
Assert.Equal(".github/workflows/ci.yml", ctx.WorkflowFilePath);
Assert.True(ctx.TryGetValue("workflow_file_path", out var value));
Assert.IsType<StringContextData>(value);
}
[Fact]
public void WorkflowFilePath_NotSet_ReturnsNull()
{
var ctx = new JobContext();
Assert.Null(ctx.WorkflowFilePath);
}
[Fact]
public void WorkflowFilePath_SetNull_ClearsValue()
{
var ctx = new JobContext();
ctx.WorkflowFilePath = ".github/workflows/ci.yml";
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);
}
}
}