diff --git a/actions/setup/js/check_workflow_timestamp_api.cjs b/actions/setup/js/check_workflow_timestamp_api.cjs index caa9ccce1e1..825e9e4bbfd 100644 --- a/actions/setup/js/check_workflow_timestamp_api.cjs +++ b/actions/setup/js/check_workflow_timestamp_api.cjs @@ -30,8 +30,48 @@ async function main() { core.info(` Source: ${workflowMdPath}`); core.info(` Lock file: ${lockFilePath}`); - const { owner, repo } = context.repo; - const ref = context.sha; + // Determine workflow source repository from GITHUB_WORKFLOW_REF for cross-repo support. + // GITHUB_WORKFLOW_REF format: owner/repo/.github/workflows/file.yml@ref + // This env var always reflects the repo where the workflow file is defined, + // not the repo where the triggering event occurred (context.repo). + // When running cross-repo via org rulesets, context.repo points to the target + // repository, not the repository that defines the workflow files. + const workflowEnvRef = process.env.GITHUB_WORKFLOW_REF || ""; + const currentRepo = process.env.GITHUB_REPOSITORY || `${context.repo.owner}/${context.repo.repo}`; + + // Parse owner, repo, and optional ref from GITHUB_WORKFLOW_REF as a single unit so that + // repo and ref are always consistent with each other. The @ref segment may be absent (e.g. + // when the env var was set without a ref suffix), so treat it as optional. + const workflowRefMatch = workflowEnvRef.match(/^([^/]+)\/([^/]+)\/.+?(?:@(.+))?$/); + + // Use the workflow source repo if parseable, otherwise fall back to context.repo + const owner = workflowRefMatch ? workflowRefMatch[1] : context.repo.owner; + const repo = workflowRefMatch ? workflowRefMatch[2] : context.repo.repo; + const workflowRepo = `${owner}/${repo}`; + + // Determine ref in a way that keeps repo+ref consistent: + // - If a ref is present in GITHUB_WORKFLOW_REF, use it. + // - For same-repo runs without a parsed ref, fall back to context.sha (existing behavior). + // - For cross-repo runs without a parsed ref, omit ref so the API uses the default branch + // (avoids mixing source repo owner/name with a SHA that only exists in the triggering repo). + let ref; + if (workflowRefMatch && workflowRefMatch[3]) { + ref = workflowRefMatch[3]; + } else if (workflowRepo === currentRepo) { + ref = context.sha; + } else { + ref = undefined; + } + + core.info(`GITHUB_WORKFLOW_REF: ${workflowEnvRef || "(not set)"}`); + core.info(`GITHUB_REPOSITORY: ${currentRepo}`); + core.info(`Resolved source repo: ${owner}/${repo} @ ${ref || "(default branch)"}`); + + if (workflowRepo !== currentRepo) { + core.info(`Cross-repo invocation detected: workflow source is "${workflowRepo}", current repo is "${currentRepo}"`); + } else { + core.info(`Same-repo invocation: checking out ${workflowRepo} @ ${ref}`); + } // Helper function to compute and compare frontmatter hashes // Returns: { match: boolean, storedHash: string, recomputedHash: string } or null on error diff --git a/actions/setup/js/check_workflow_timestamp_api.test.cjs b/actions/setup/js/check_workflow_timestamp_api.test.cjs index 66dd28f2b8c..1906e78a8ed 100644 --- a/actions/setup/js/check_workflow_timestamp_api.test.cjs +++ b/actions/setup/js/check_workflow_timestamp_api.test.cjs @@ -46,6 +46,8 @@ describe("check_workflow_timestamp_api.cjs", () => { beforeEach(async () => { vi.clearAllMocks(); delete process.env.GH_AW_WORKFLOW_FILE; + delete process.env.GITHUB_WORKFLOW_REF; + delete process.env.GITHUB_REPOSITORY; // Dynamically import the module to get fresh instance const module = await import("./check_workflow_timestamp_api.cjs"); @@ -103,6 +105,19 @@ engine: copilot expect(mockCore.setFailed).not.toHaveBeenCalled(); expect(mockCore.summary.addRaw).not.toHaveBeenCalled(); }); + + it("should log same-repo invocation when GITHUB_WORKFLOW_REF matches GITHUB_REPOSITORY", async () => { + process.env.GITHUB_WORKFLOW_REF = "test-owner/test-repo/.github/workflows/test.lock.yml@refs/heads/main"; + process.env.GITHUB_REPOSITORY = "test-owner/test-repo"; + + mockGithub.rest.repos.getContent.mockResolvedValue({ data: null }); + + await main(); + + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Same-repo invocation")); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("GITHUB_WORKFLOW_REF:")); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Resolved source repo:")); + }); }); describe("when lock file is outdated (hashes differ)", () => { @@ -466,4 +481,145 @@ model: claude-sonnet-4 expect(mockCore.summary.write).toHaveBeenCalled(); }); }); + + describe("cross-repo invocation via org rulesets", () => { + beforeEach(() => { + process.env.GH_AW_WORKFLOW_FILE = "test.lock.yml"; + // Simulate cross-repo: workflow defined in platform-repo, running in target-repo + process.env.GITHUB_WORKFLOW_REF = "source-owner/source-repo/.github/workflows/test.lock.yml@refs/heads/main"; + process.env.GITHUB_REPOSITORY = "target-owner/target-repo"; + }); + + it("should fetch files from the workflow source repo, not context.repo", async () => { + const validHash = "c2a79263dc72f28c76177afda9bf0935481b26da094407a50155a6e0244084e3"; + const lockFileContent = `# frontmatter-hash: ${validHash} +name: Test Workflow +on: push +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo "test"`; + + const mdFileContent = `--- +engine: copilot +--- +# Test Workflow`; + + mockGithub.rest.repos.getContent + .mockResolvedValueOnce({ + data: { + type: "file", + encoding: "base64", + content: Buffer.from(lockFileContent).toString("base64"), + }, + }) + .mockResolvedValueOnce({ + data: { + type: "file", + encoding: "base64", + content: Buffer.from(mdFileContent).toString("base64"), + }, + }); + + await main(); + + // Verify the API was called with the workflow source repo (source-owner/source-repo), + // not context.repo (test-owner/test-repo) + expect(mockGithub.rest.repos.getContent).toHaveBeenCalledWith(expect.objectContaining({ owner: "source-owner", repo: "source-repo" })); + expect(mockGithub.rest.repos.getContent).not.toHaveBeenCalledWith(expect.objectContaining({ owner: "test-owner", repo: "test-repo" })); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Cross-repo invocation detected")); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("✅ Lock file is up to date (hashes match)")); + expect(mockCore.setFailed).not.toHaveBeenCalled(); + }); + + it("should log GITHUB_WORKFLOW_REF, GITHUB_REPOSITORY, and resolved source repo", async () => { + mockGithub.rest.repos.getContent.mockResolvedValue({ data: null }); + + await main(); + + expect(mockCore.info).toHaveBeenCalledWith("GITHUB_WORKFLOW_REF: source-owner/source-repo/.github/workflows/test.lock.yml@refs/heads/main"); + expect(mockCore.info).toHaveBeenCalledWith("GITHUB_REPOSITORY: target-owner/target-repo"); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Resolved source repo: source-owner/source-repo @ refs/heads/main")); + }); + + it("should use the workflow ref from GITHUB_WORKFLOW_REF, not context.sha", async () => { + const validHash = "c2a79263dc72f28c76177afda9bf0935481b26da094407a50155a6e0244084e3"; + const lockFileContent = `# frontmatter-hash: ${validHash} +name: Test Workflow +on: push +jobs: + test: + runs-on: ubuntu-latest`; + + const mdFileContent = `--- +engine: copilot +--- +# Test Workflow`; + + mockGithub.rest.repos.getContent + .mockResolvedValueOnce({ + data: { + type: "file", + encoding: "base64", + content: Buffer.from(lockFileContent).toString("base64"), + }, + }) + .mockResolvedValueOnce({ + data: { + type: "file", + encoding: "base64", + content: Buffer.from(mdFileContent).toString("base64"), + }, + }); + + await main(); + + // Verify the API was called with the ref from GITHUB_WORKFLOW_REF (refs/heads/main), + // not context.sha (abc123) + expect(mockGithub.rest.repos.getContent).toHaveBeenCalledWith(expect.objectContaining({ ref: "refs/heads/main" })); + expect(mockGithub.rest.repos.getContent).not.toHaveBeenCalledWith(expect.objectContaining({ ref: "abc123" })); + }); + + it("should fall back to context.repo when GITHUB_WORKFLOW_REF is not set", async () => { + delete process.env.GITHUB_WORKFLOW_REF; + + mockGithub.rest.repos.getContent.mockResolvedValue({ data: null }); + + await main(); + + // Falls back to context.repo for owner/repo; ref is undefined because workflowRepo + // (test-owner/test-repo) differs from currentRepo (target-owner/target-repo) — cross-repo + expect(mockGithub.rest.repos.getContent).toHaveBeenCalledWith(expect.objectContaining({ owner: "test-owner", repo: "test-repo" })); + expect(mockGithub.rest.repos.getContent).not.toHaveBeenCalledWith(expect.objectContaining({ ref: "abc123" })); + }); + + it("should fall back to context.repo when GITHUB_WORKFLOW_REF is malformed", async () => { + process.env.GITHUB_WORKFLOW_REF = "not-a-valid-workflow-ref"; + + mockGithub.rest.repos.getContent.mockResolvedValue({ data: null }); + + await main(); + + // Falls back to context.repo for owner/repo; ref is undefined (cross-repo, no parsed ref) + expect(mockGithub.rest.repos.getContent).toHaveBeenCalledWith(expect.objectContaining({ owner: "test-owner", repo: "test-repo" })); + expect(mockGithub.rest.repos.getContent).not.toHaveBeenCalledWith(expect.objectContaining({ ref: "abc123" })); + }); + + it("should use the default branch for cross-repo when GITHUB_WORKFLOW_REF has no @ref segment", async () => { + // GITHUB_WORKFLOW_REF with owner/repo but missing the @ref suffix + process.env.GITHUB_WORKFLOW_REF = "source-owner/source-repo/.github/workflows/test.lock.yml"; + + mockGithub.rest.repos.getContent.mockResolvedValue({ data: null }); + + await main(); + + // Should resolve to the source repo parsed from GITHUB_WORKFLOW_REF + expect(mockGithub.rest.repos.getContent).toHaveBeenCalledWith(expect.objectContaining({ owner: "source-owner", repo: "source-repo" })); + // Should NOT use context.sha — ref must be undefined so GitHub API uses the default branch + expect(mockGithub.rest.repos.getContent).not.toHaveBeenCalledWith(expect.objectContaining({ ref: "abc123" })); + // Log should indicate default branch is being used + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("(default branch)")); + }); + }); }); diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 53f1bd89fa2..628d14a6cb7 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -2074,6 +2074,10 @@ "pull-requests": "read" } ] + }, + "stale-check": { + "type": "boolean", + "description": "When set to false, disables the frontmatter hash check step in the activation job. Default is true (check is enabled). Useful when the workflow source files are managed outside the default GitHub repo context (e.g. cross-repo org rulesets) and the stale check is not needed." } }, "additionalProperties": false, diff --git a/pkg/workflow/compiler_activation_job.go b/pkg/workflow/compiler_activation_job.go index e0d1a027181..ba8f984b383 100644 --- a/pkg/workflow/compiler_activation_job.go +++ b/pkg/workflow/compiler_activation_job.go @@ -180,13 +180,16 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate // Add timestamp check for lock file vs source file using GitHub API // No checkout step needed - uses GitHub API to check commit times - steps = append(steps, " - name: Check workflow file timestamps\n") - steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/github-script"))) - steps = append(steps, " env:\n") - steps = append(steps, fmt.Sprintf(" GH_AW_WORKFLOW_FILE: \"%s\"\n", lockFilename)) - steps = append(steps, " with:\n") - steps = append(steps, " script: |\n") - steps = append(steps, generateGitHubScriptWithRequire("check_workflow_timestamp_api.cjs")) + // Skipped when on.stale-check: false is set in the frontmatter. + if !data.StaleCheckDisabled { + steps = append(steps, " - name: Check workflow file timestamps\n") + steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/github-script"))) + steps = append(steps, " env:\n") + steps = append(steps, fmt.Sprintf(" GH_AW_WORKFLOW_FILE: \"%s\"\n", lockFilename)) + steps = append(steps, " with:\n") + steps = append(steps, " script: |\n") + steps = append(steps, generateGitHubScriptWithRequire("check_workflow_timestamp_api.cjs")) + } // Add compile-agentic version update check, unless disabled via check-for-updates: false. // The check downloads .github/aw/releases.json from the gh-aw repository and verifies that the diff --git a/pkg/workflow/compiler_orchestrator_workflow.go b/pkg/workflow/compiler_orchestrator_workflow.go index d496fe32513..2be299adaf2 100644 --- a/pkg/workflow/compiler_orchestrator_workflow.go +++ b/pkg/workflow/compiler_orchestrator_workflow.go @@ -232,6 +232,17 @@ func (c *Compiler) buildInitialWorkflowData( } } + // Populate stale-check flag: disabled when on.stale-check: false is set in frontmatter. + if onVal, ok := result.Frontmatter["on"]; ok { + if onMap, ok := onVal.(map[string]any); ok { + if staleCheck, ok := onMap["stale-check"]; ok { + if boolVal, ok := staleCheck.(bool); ok && !boolVal { + workflowData.StaleCheckDisabled = true + } + } + } + } + return workflowData } diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go index b41412bcb50..c0550e8d5fb 100644 --- a/pkg/workflow/compiler_types.go +++ b/pkg/workflow/compiler_types.go @@ -435,6 +435,7 @@ type WorkflowData struct { ConcurrencyJobDiscriminator string // optional discriminator expression appended to job-level concurrency groups (from concurrency.job-discriminator) IsDetectionRun bool // true when this WorkflowData is used for inline threat detection (not the main agent run) UpdateCheckDisabled bool // true when check-for-updates: false is set in frontmatter (disables version check step in activation job) + StaleCheckDisabled bool // true when on.stale-check: false is set in frontmatter (disables frontmatter hash check step in activation job) EngineConfigSteps []map[string]any // steps returned by engine.RenderConfig — prepended before execution steps } diff --git a/pkg/workflow/stale_check_test.go b/pkg/workflow/stale_check_test.go new file mode 100644 index 00000000000..d5a7b2a78a3 --- /dev/null +++ b/pkg/workflow/stale_check_test.go @@ -0,0 +1,94 @@ +//go:build !integration + +package workflow + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/github/gh-aw/pkg/stringutil" + "github.com/github/gh-aw/pkg/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestStaleCheckInActivationJob tests that the frontmatter hash check step is correctly +// added or omitted based on the on.stale-check flag. +func TestStaleCheckInActivationJob(t *testing.T) { + baseWorkflowMD := `--- +engine: copilot +on: + issues: + types: [opened] +--- +Test workflow for stale check step. +` + disabledWorkflowMD := `--- +engine: copilot +on: + issues: + types: [opened] + stale-check: false +--- +Test workflow for stale check step disabled. +` + enabledExplicitWorkflowMD := `--- +engine: copilot +on: + issues: + types: [opened] + stale-check: true +--- +Test workflow for stale check step explicitly enabled. +` + + tests := []struct { + name string + workflowMD string + wantStep bool + }{ + { + name: "step present when stale-check not set (default)", + workflowMD: baseWorkflowMD, + wantStep: true, + }, + { + name: "step absent when stale-check: false", + workflowMD: disabledWorkflowMD, + wantStep: false, + }, + { + name: "step present when stale-check: true explicitly", + workflowMD: enabledExplicitWorkflowMD, + wantStep: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := testutil.TempDir(t, "stale-check-test") + testFile := filepath.Join(tmpDir, "test-workflow.md") + require.NoError(t, os.WriteFile(testFile, []byte(tt.workflowMD), 0644), "Should write workflow file") + + compiler := NewCompiler() + err := compiler.CompileWorkflow(testFile) + require.NoError(t, err, "Workflow should compile without errors") + + lockFile := stringutil.MarkdownToLockFile(testFile) + lockContent, err := os.ReadFile(lockFile) + require.NoError(t, err, "Lock file should be readable") + lockStr := string(lockContent) + + hasStep := strings.Contains(lockStr, "Check workflow file timestamps") + if tt.wantStep { + assert.True(t, hasStep, + "Expected 'Check workflow file timestamps' step in activation job but not found") + } else { + assert.False(t, hasStep, + "Expected no 'Check workflow file timestamps' step in activation job but it was found") + } + }) + } +}