From 4a587ada27a33b7b2efc15c6acf7963eb2ed2706 Mon Sep 17 00:00:00 2001 From: Salman Chishti Date: Fri, 10 Apr 2026 19:39:33 +0100 Subject: [PATCH] feat: add `job.workflow_*` typed accessors to JobContext (#4335) --- src/Runner.Worker/ExecutionContext.cs | 11 +-- src/Runner.Worker/JobContext.cs | 64 ++++++++++++++ src/Test/L0/Worker/ExecutionContextL0.cs | 89 +++++++++++++++++-- src/Test/L0/Worker/JobContextL0.cs | 104 +++++++++++++++++++++++ 4 files changed, 252 insertions(+), 16 deletions(-) diff --git a/src/Runner.Worker/ExecutionContext.cs b/src/Runner.Worker/ExecutionContext.cs index 6acb3e385..4bdf3baf9 100644 --- a/src/Runner.Worker/ExecutionContext.cs +++ b/src/Runner.Worker/ExecutionContext.cs @@ -892,15 +892,12 @@ namespace GitHub.Runner.Worker Trace.Info("Initializing Job context"); var jobContext = new JobContext(); - if (Global.Variables.GetBoolean(Constants.Runner.Features.AddCheckRunIdToJobContext) ?? false) + ExpressionValues.TryGetValue("job", out var jobDictionary); + if (jobDictionary != null) { - ExpressionValues.TryGetValue("job", out var jobDictionary); - if (jobDictionary != null) + foreach (var pair in jobDictionary.AssertDictionary("job")) { - foreach (var pair in jobDictionary.AssertDictionary("job")) - { - jobContext[pair.Key] = pair.Value; - } + jobContext[pair.Key] = pair.Value; } } ExpressionValues["job"] = jobContext; diff --git a/src/Runner.Worker/JobContext.cs b/src/Runner.Worker/JobContext.cs index 09f3296de..7150f2e8b 100644 --- a/src/Runner.Worker/JobContext.cs +++ b/src/Runner.Worker/JobContext.cs @@ -82,5 +82,69 @@ namespace GitHub.Runner.Worker } } } + + public string WorkflowRef + { + get + { + if (this.TryGetValue("workflow_ref", out var value) && value is StringContextData str) + { + return str.Value; + } + return null; + } + set + { + this["workflow_ref"] = value != null ? new StringContextData(value) : null; + } + } + + public string WorkflowSha + { + get + { + if (this.TryGetValue("workflow_sha", out var value) && value is StringContextData str) + { + return str.Value; + } + return null; + } + set + { + this["workflow_sha"] = value != null ? new StringContextData(value) : null; + } + } + + public string WorkflowRepository + { + get + { + if (this.TryGetValue("workflow_repository", out var value) && value is StringContextData str) + { + return str.Value; + } + return null; + } + set + { + this["workflow_repository"] = value != null ? new StringContextData(value) : null; + } + } + + public string WorkflowFilePath + { + get + { + if (this.TryGetValue("workflow_file_path", out var value) && value is StringContextData str) + { + return str.Value; + } + return null; + } + set + { + this["workflow_file_path"] = value != null ? new StringContextData(value) : null; + } + } } } diff --git a/src/Test/L0/Worker/ExecutionContextL0.cs b/src/Test/L0/Worker/ExecutionContextL0.cs index 2f28f797f..d35be3acc 100644 --- a/src/Test/L0/Worker/ExecutionContextL0.cs +++ b/src/Test/L0/Worker/ExecutionContextL0.cs @@ -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() - { - [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,80 @@ 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(); + 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 4 workflow identity fields + 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.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.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.WorkflowRef); + Assert.Null(ec.JobContext.WorkflowSha); + Assert.Null(ec.JobContext.WorkflowRepository); + Assert.Null(ec.JobContext.WorkflowFilePath); } } diff --git a/src/Test/L0/Worker/JobContextL0.cs b/src/Test/L0/Worker/JobContextL0.cs index 87e334379..a99d841a6 100644 --- a/src/Test/L0/Worker/JobContextL0.cs +++ b/src/Test/L0/Worker/JobContextL0.cs @@ -34,5 +34,109 @@ 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(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); + } } }