diff --git a/src/Runner.Worker/ExecutionContext.cs b/src/Runner.Worker/ExecutionContext.cs index d835682a4..b5947338e 100644 --- a/src/Runner.Worker/ExecutionContext.cs +++ b/src/Runner.Worker/ExecutionContext.cs @@ -902,6 +902,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; diff --git a/src/Runner.Worker/JobContext.cs b/src/Runner.Worker/JobContext.cs index 09f3296de..0ce869011 100644 --- a/src/Runner.Worker/JobContext.cs +++ b/src/Runner.Worker/JobContext.cs @@ -82,5 +82,103 @@ 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; + } + } + + /// + /// 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 2f28f797f..e72e8f9d3 100644 --- a/src/Test/L0/Worker/ExecutionContextL0.cs +++ b/src/Test/L0/Worker/ExecutionContextL0.cs @@ -1239,6 +1239,124 @@ namespace GitHub.Runner.Common.Tests.Worker } } + [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 87e334379..1a2206ab0 100644 --- a/src/Test/L0/Worker/JobContextL0.cs +++ b/src/Test/L0/Worker/JobContextL0.cs @@ -34,5 +34,165 @@ 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); + } + + [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); + } } }