From cb8124fc4ac4d7de9bb69ef353c785e9f9025342 Mon Sep 17 00:00:00 2001 From: Salman Muin Kayser Chishti Date: Fri, 10 Apr 2026 13:25:37 +0000 Subject: [PATCH 1/3] feat: add job.workflow_* typed accessors and derivation fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/Runner.Worker/ExecutionContext.cs | 4 + src/Runner.Worker/JobContext.cs | 98 ++++++++++++++ src/Test/L0/Worker/ExecutionContextL0.cs | 118 +++++++++++++++++ src/Test/L0/Worker/JobContextL0.cs | 160 +++++++++++++++++++++++ 4 files changed, 380 insertions(+) diff --git a/src/Runner.Worker/ExecutionContext.cs b/src/Runner.Worker/ExecutionContext.cs index d835682a47f..b5947338e52 100644 --- a/src/Runner.Worker/ExecutionContext.cs +++ b/src/Runner.Worker/ExecutionContext.cs @@ -902,6 +902,10 @@ public void InitializeJob(Pipelines.AgentJobRequestMessage message, Cancellation 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; diff --git a/src/Runner.Worker/JobContext.cs b/src/Runner.Worker/JobContext.cs index 09f3296de9c..0ce8690110b 100644 --- a/src/Runner.Worker/JobContext.cs +++ b/src/Runner.Worker/JobContext.cs @@ -82,5 +82,103 @@ 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/path/to/file.yml@ref + var atIndex = workflowRef.IndexOf('@'); + var pathPart = atIndex >= 0 ? workflowRef.Substring(0, atIndex) : workflowRef; + + // Split into owner/repo and file path at the .github/ boundary + var githubDirIndex = pathPart.IndexOf("/.github/"); + if (githubDirIndex < 0) + { + return; + } + + if (WorkflowRepository == null) + { + WorkflowRepository = pathPart.Substring(0, githubDirIndex); + } + + if (WorkflowFilePath == null) + { + WorkflowFilePath = pathPart.Substring(githubDirIndex + 1); // skip leading '/' + } + } } } diff --git a/src/Test/L0/Worker/ExecutionContextL0.cs b/src/Test/L0/Worker/ExecutionContextL0.cs index 2f28f797fb5..e72e8f9d32c 100644 --- a/src/Test/L0/Worker/ExecutionContextL0.cs +++ b/src/Test/L0/Worker/ExecutionContextL0.cs @@ -1239,6 +1239,124 @@ public void InitializeJob_HydratesJobContextWithCheckRunId_FeatureFlagDisabled() } } + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void InitializeJob_HydratesJobContextWithWorkflowIdentity() + { + using (TestHostContext hc = CreateTestContext()) + { + // Arrange: Create a job request message with the feature flag enabled + var variables = new Dictionary() + { + [Constants.Runner.Features.AddCheckRunIdToJobContext] = new VariableValue("true"), + }; + 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_WhenFeatureFlagDisabled() + { + using (TestHostContext hc = CreateTestContext()) + { + // Arrange: Create a job request message with the feature flag disabled + var variables = new Dictionary() + { + [Constants.Runner.Features.AddCheckRunIdToJobContext] = new VariableValue("false"), + }; + 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: properties should not be populated when flag is off + 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() + { + [Constants.Runner.Features.AddCheckRunIdToJobContext] = new VariableValue("true"), + }; + 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); + } + } + private bool ExpressionValuesAssertEqual(DictionaryContextData expect, DictionaryContextData actual) { foreach (var key in expect.Keys.ToList()) diff --git a/src/Test/L0/Worker/JobContextL0.cs b/src/Test/L0/Worker/JobContextL0.cs index 87e33437914..1a2206ab0b3 100644 --- a/src/Test/L0/Worker/JobContextL0.cs +++ b/src/Test/L0/Worker/JobContextL0.cs @@ -34,5 +34,165 @@ 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); + } } } From 4342a1b8f0412f649caa099b7a99a10fe0016ad3 Mon Sep 17 00:00:00 2001 From: Salman Muin Kayser Chishti Date: Fri, 10 Apr 2026 13:51:29 +0000 Subject: [PATCH 2/3] 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. --- src/Runner.Worker/JobContext.cs | 18 ++++++++++-------- src/Test/L0/Worker/JobContextL0.cs | 25 +++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/src/Runner.Worker/JobContext.cs b/src/Runner.Worker/JobContext.cs index 0ce8690110b..beff55d7798 100644 --- a/src/Runner.Worker/JobContext.cs +++ b/src/Runner.Worker/JobContext.cs @@ -159,25 +159,27 @@ public void DeriveWorkflowRefComponents() return; } - // Format: owner/repo/path/to/file.yml@ref + // Format: owner/repo/.github/workflows/file.yml@ref var atIndex = workflowRef.IndexOf('@'); var pathPart = atIndex >= 0 ? workflowRef.Substring(0, atIndex) : workflowRef; - // Split into owner/repo and file path at the .github/ boundary - var githubDirIndex = pathPart.IndexOf("/.github/"); - if (githubDirIndex < 0) + // 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; } - if (WorkflowRepository == null) + if (WorkflowRepository == null || WorkflowRepository == "") { - WorkflowRepository = pathPart.Substring(0, githubDirIndex); + WorkflowRepository = pathPart.Substring(0, markerIndex); } - if (WorkflowFilePath == null) + if (WorkflowFilePath == null || WorkflowFilePath == "") { - WorkflowFilePath = pathPart.Substring(githubDirIndex + 1); // skip leading '/' + WorkflowFilePath = pathPart.Substring(markerIndex + 1); // skip leading '/' } } } diff --git a/src/Test/L0/Worker/JobContextL0.cs b/src/Test/L0/Worker/JobContextL0.cs index 1a2206ab0b3..f1b1be6ece0 100644 --- a/src/Test/L0/Worker/JobContextL0.cs +++ b/src/Test/L0/Worker/JobContextL0.cs @@ -194,5 +194,30 @@ public void DeriveWorkflowRefComponents_HandlesRefWithoutAtSign() 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); + } } } From 04ee71a5d13aed17d5938f44600d225996d995de Mon Sep 17 00:00:00 2001 From: Salman Muin Kayser Chishti Date: Fri, 10 Apr 2026 14:04:28 +0000 Subject: [PATCH 3/3] 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 --- src/Runner.Worker/ExecutionContext.cs | 19 ++++------ src/Runner.Worker/JobContext.cs | 14 ++++++- src/Test/L0/Worker/ExecutionContextL0.cs | 48 +++++++++--------------- src/Test/L0/Worker/JobContextL0.cs | 34 +++++++++++++++++ 4 files changed, 72 insertions(+), 43 deletions(-) diff --git a/src/Runner.Worker/ExecutionContext.cs b/src/Runner.Worker/ExecutionContext.cs index 28c27ddc043..6d95fd81c68 100644 --- a/src/Runner.Worker/ExecutionContext.cs +++ b/src/Runner.Worker/ExecutionContext.cs @@ -892,21 +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(); } + + // 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 beff55d7798..e5b9797c1d8 100644 --- a/src/Runner.Worker/JobContext.cs +++ b/src/Runner.Worker/JobContext.cs @@ -172,14 +172,24 @@ public void DeriveWorkflowRefComponents() 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 = pathPart.Substring(0, markerIndex); + WorkflowRepository = repo; } if (WorkflowFilePath == null || WorkflowFilePath == "") { - WorkflowFilePath = pathPart.Substring(markerIndex + 1); // skip leading '/' + WorkflowFilePath = filePath; } } } diff --git a/src/Test/L0/Worker/ExecutionContextL0.cs b/src/Test/L0/Worker/ExecutionContextL0.cs index e72e8f9d32c..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,9 @@ 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.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); } } @@ -1246,11 +1246,8 @@ public void InitializeJob_HydratesJobContextWithWorkflowIdentity() { using (TestHostContext hc = CreateTestContext()) { - // Arrange: Create a job request message with the feature flag enabled - var variables = new Dictionary() - { - [Constants.Runner.Features.AddCheckRunIdToJobContext] = new VariableValue("true"), - }; + // 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(); @@ -1283,15 +1280,12 @@ public void InitializeJob_HydratesJobContextWithWorkflowIdentity() [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] - public void InitializeJob_WorkflowIdentityNotSet_WhenFeatureFlagDisabled() + public void InitializeJob_WorkflowIdentityNotSet_WhenServerSendsNoData() { using (TestHostContext hc = CreateTestContext()) { - // Arrange: Create a job request message with the feature flag disabled - var variables = new Dictionary() - { - [Constants.Runner.Features.AddCheckRunIdToJobContext] = new VariableValue("false"), - }; + // 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(); @@ -1300,17 +1294,14 @@ public void InitializeJob_WorkflowIdentityNotSet_WhenFeatureFlagDisabled() 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; + // 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: properties should not be populated when flag is off + // Assert: no workflow identity Assert.NotNull(ec.JobContext); Assert.Null(ec.JobContext.WorkflowRef); Assert.Null(ec.JobContext.WorkflowSha); @@ -1327,10 +1318,7 @@ public void InitializeJob_WorkflowIdentityDerived_WhenServerSendsAllFields() using (TestHostContext hc = CreateTestContext()) { // Arrange: Server sends all 4 fields explicitly - var variables = new Dictionary() - { - [Constants.Runner.Features.AddCheckRunIdToJobContext] = new VariableValue("true"), - }; + 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(); diff --git a/src/Test/L0/Worker/JobContextL0.cs b/src/Test/L0/Worker/JobContextL0.cs index f1b1be6ece0..9c04270291a 100644 --- a/src/Test/L0/Worker/JobContextL0.cs +++ b/src/Test/L0/Worker/JobContextL0.cs @@ -219,5 +219,39 @@ public void DeriveWorkflowRefComponents_TreatsEmptyStringAsUnset() 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); + } } }