diff --git a/src/Runner.Worker/ExecutionContext.cs b/src/Runner.Worker/ExecutionContext.cs
index 6acb3e385d5..6d95fd81c68 100644
--- a/src/Runner.Worker/ExecutionContext.cs
+++ b/src/Runner.Worker/ExecutionContext.cs
@@ -892,17 +892,18 @@ public void InitializeJob(Pipelines.AgentJobRequestMessage message, Cancellation
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");
diff --git a/src/Runner.Worker/JobContext.cs b/src/Runner.Worker/JobContext.cs
index 09f3296de9c..e5b9797c1d8 100644
--- a/src/Runner.Worker/JobContext.cs
+++ b/src/Runner.Worker/JobContext.cs
@@ -82,5 +82,115 @@ public double? CheckRunId
}
}
}
+
+ 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;
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ 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;
+ }
+ }
}
}
diff --git a/src/Test/L0/Worker/ExecutionContextL0.cs b/src/Test/L0/Worker/ExecutionContextL0.cs
index 2f28f797fb5..629150f6aa5 100644
--- a/src/Test/L0/Worker/ExecutionContextL0.cs
+++ b/src/Test/L0/Worker/ExecutionContextL0.cs
@@ -1203,19 +1203,19 @@ public void InitializeJob_HydratesJobContextWithCheckRunId()
}
}
- // 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()
- {
- [Constants.Runner.Features.AddCheckRunIdToJobContext] = new VariableValue("false"),
- };
+ // Arrange: No feature flag set at all
+ var variables = new Dictionary();
var jobRequest = new Pipelines.AgentJobRequestMessage(new TaskOrchestrationPlanReference(), new TimelineReference(), Guid.NewGuid(), "some job name", "some job name", null, null, null, variables, new List(), new Pipelines.JobResources(), new Pipelines.ContextData.DictionaryContextData(), new Pipelines.WorkspaceOptions(), new List(), null, null, null, null, null);
var pagingLogger = new Moq.Mock();
var jobServerQueue = new Moq.Mock();
@@ -1233,9 +1233,115 @@ public void InitializeJob_HydratesJobContextWithCheckRunId_FeatureFlagDisabled()
// Act
ec.InitializeJob(jobRequest, CancellationToken.None);
- // Assert
+ // Assert: check_run_id is always copied regardless of flag
+ Assert.NotNull(ec.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();
+ var jobRequest = new Pipelines.AgentJobRequestMessage(new TaskOrchestrationPlanReference(), new TimelineReference(), Guid.NewGuid(), "some job name", "some job name", null, null, null, variables, new List(), new Pipelines.JobResources(), new Pipelines.ContextData.DictionaryContextData(), new Pipelines.WorkspaceOptions(), new List(), null, null, null, null, null);
+ var pagingLogger = new Moq.Mock();
+ var jobServerQueue = new Moq.Mock();
+ 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();
+ var jobRequest = new Pipelines.AgentJobRequestMessage(new TaskOrchestrationPlanReference(), new TimelineReference(), Guid.NewGuid(), "some job name", "some job name", null, null, null, variables, new List(), new Pipelines.JobResources(), new Pipelines.ContextData.DictionaryContextData(), new Pipelines.WorkspaceOptions(), new List(), null, null, null, null, null);
+ var pagingLogger = new Moq.Mock();
+ var jobServerQueue = new Moq.Mock();
+ 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.CheckRunId); // with the feature flag disabled we should not have added a CheckRunId to the 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();
+ var jobRequest = new Pipelines.AgentJobRequestMessage(new TaskOrchestrationPlanReference(), new TimelineReference(), Guid.NewGuid(), "some job name", "some job name", null, null, null, variables, new List(), new Pipelines.JobResources(), new Pipelines.ContextData.DictionaryContextData(), new Pipelines.WorkspaceOptions(), new List(), null, null, null, null, null);
+ var pagingLogger = new Moq.Mock();
+ var jobServerQueue = new Moq.Mock();
+ 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);
}
}
diff --git a/src/Test/L0/Worker/JobContextL0.cs b/src/Test/L0/Worker/JobContextL0.cs
index 87e33437914..9c04270291a 100644
--- a/src/Test/L0/Worker/JobContextL0.cs
+++ b/src/Test/L0/Worker/JobContextL0.cs
@@ -34,5 +34,224 @@ public void CheckRunId_SetNull_RemovesKey()
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(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(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(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(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);
+ }
}
}