From 9e5a78129ad05c5e8f6b7bb5d2e87a7e9339dcfe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 02:42:09 +0000 Subject: [PATCH 1/5] Initial plan From 303b44cbede5d4f637d56c471da4ee83da840db4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 02:56:05 +0000 Subject: [PATCH 2/5] fix: use GITHUB_WORKFLOW_REF for cross-repo lock file hash check When workflows run cross-repo via org rulesets, context.repo resolves to the target repository (Repo B), not the source workflow repository (Repo A). This caused the "Check workflow file timestamps" step to fail with ERR_CONFIG because the GitHub API 404'd when trying to fetch files from the wrong repository. Fix: parse GITHUB_WORKFLOW_REF (format: owner/repo/.github/workflows/ file.yml@ref) to determine the correct workflow source repository and ref. Falls back to context.repo/context.sha when GITHUB_WORKFLOW_REF is not available or malformed. Agent-Logs-Url: https://github.com/github/gh-aw/sessions/edee5988-f4b3-4e71-a134-a292cf59a23a Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../setup/js/check_workflow_timestamp_api.cjs | 25 ++- .../js/check_workflow_timestamp_api.test.cjs | 168 ++++++++++++++++++ 2 files changed, 191 insertions(+), 2 deletions(-) diff --git a/actions/setup/js/check_workflow_timestamp_api.cjs b/actions/setup/js/check_workflow_timestamp_api.cjs index caa9ccce1e1..af66b51c1db 100644 --- a/actions/setup/js/check_workflow_timestamp_api.cjs +++ b/actions/setup/js/check_workflow_timestamp_api.cjs @@ -30,8 +30,29 @@ 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 repoMatch = workflowEnvRef.match(/^([^/]+)\/([^/]+)\//); + const refMatch = workflowEnvRef.match(/@(.+)$/); + + // Use the workflow source repo if parseable, otherwise fall back to context.repo + const owner = repoMatch ? repoMatch[1] : context.repo.owner; + const repo = repoMatch ? repoMatch[2] : context.repo.repo; + + // Use the workflow ref if parseable, otherwise fall back to context.sha + const ref = refMatch ? refMatch[1] : context.sha; + + // Log cross-repo detection for debugging + const currentRepo = process.env.GITHUB_REPOSITORY || `${context.repo.owner}/${context.repo.repo}`; + const workflowRepo = `${owner}/${repo}`; + if (workflowRepo !== currentRepo) { + core.info(`Cross-repo invocation detected: workflow source is "${workflowRepo}", current repo is "${currentRepo}"`); + } // 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..18b59dc8ae2 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"); @@ -466,4 +468,170 @@ 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 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; + + 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 context.repo (test-owner/test-repo) as fallback + expect(mockGithub.rest.repos.getContent).toHaveBeenCalledWith(expect.objectContaining({ owner: "test-owner", repo: "test-repo", 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"; + + 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(); + + // When GITHUB_WORKFLOW_REF is malformed, fall back to context.repo and context.sha + expect(mockGithub.rest.repos.getContent).toHaveBeenCalledWith(expect.objectContaining({ owner: "test-owner", repo: "test-repo", ref: "abc123" })); + }); + }); }); From 813e540246d26c1c3367d75c8eddf06a3a70f4db Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 03:10:49 +0000 Subject: [PATCH 3/5] fix: add detailed logging for cross-repo resolution in hash check Add GITHUB_WORKFLOW_REF, GITHUB_REPOSITORY, resolved source repo, and same-repo/cross-repo invocation logging consistent with resolve_host_repo.cjs. Also adds tests covering same-repo logging and the new log lines. Agent-Logs-Url: https://github.com/github/gh-aw/sessions/905801db-b0c1-482f-b2e9-8d176009b69a Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../setup/js/check_workflow_timestamp_api.cjs | 9 ++++++-- .../js/check_workflow_timestamp_api.test.cjs | 23 +++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/actions/setup/js/check_workflow_timestamp_api.cjs b/actions/setup/js/check_workflow_timestamp_api.cjs index af66b51c1db..b3e38a0944e 100644 --- a/actions/setup/js/check_workflow_timestamp_api.cjs +++ b/actions/setup/js/check_workflow_timestamp_api.cjs @@ -37,6 +37,7 @@ async function main() { // 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}`; const repoMatch = workflowEnvRef.match(/^([^/]+)\/([^/]+)\//); const refMatch = workflowEnvRef.match(/@(.+)$/); @@ -47,11 +48,15 @@ async function main() { // Use the workflow ref if parseable, otherwise fall back to context.sha const ref = refMatch ? refMatch[1] : context.sha; - // Log cross-repo detection for debugging - const currentRepo = process.env.GITHUB_REPOSITORY || `${context.repo.owner}/${context.repo.repo}`; + core.info(`GITHUB_WORKFLOW_REF: ${workflowEnvRef || "(not set)"}`); + core.info(`GITHUB_REPOSITORY: ${currentRepo}`); + core.info(`Resolved source repo: ${owner}/${repo} @ ${ref}`); + const workflowRepo = `${owner}/${repo}`; 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 diff --git a/actions/setup/js/check_workflow_timestamp_api.test.cjs b/actions/setup/js/check_workflow_timestamp_api.test.cjs index 18b59dc8ae2..529eeca0331 100644 --- a/actions/setup/js/check_workflow_timestamp_api.test.cjs +++ b/actions/setup/js/check_workflow_timestamp_api.test.cjs @@ -105,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)", () => { @@ -520,6 +533,16 @@ engine: copilot 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} From 600c3e7825d4f8d5da6e45fc21530965a4de4e60 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 04:04:37 +0000 Subject: [PATCH 4/5] fix: parse repo+ref as a single unit from GITHUB_WORKFLOW_REF Use a single regex so owner/repo and ref are always consistent. When GITHUB_WORKFLOW_REF has repo but no @ref in a cross-repo context, use undefined ref (API default branch) instead of mixing the source repo with context.sha from the triggering repo. Adds a test for that case and updates the existing fallback tests to match the new behavior. Agent-Logs-Url: https://github.com/github/gh-aw/sessions/7709128b-fdb5-48db-a958-a8314b4c5771 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../setup/js/check_workflow_timestamp_api.cjs | 30 +++++-- .../js/check_workflow_timestamp_api.test.cjs | 81 ++++++------------- 2 files changed, 45 insertions(+), 66 deletions(-) diff --git a/actions/setup/js/check_workflow_timestamp_api.cjs b/actions/setup/js/check_workflow_timestamp_api.cjs index b3e38a0944e..825e9e4bbfd 100644 --- a/actions/setup/js/check_workflow_timestamp_api.cjs +++ b/actions/setup/js/check_workflow_timestamp_api.cjs @@ -38,21 +38,35 @@ async function main() { // 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}`; - const repoMatch = workflowEnvRef.match(/^([^/]+)\/([^/]+)\//); - const refMatch = workflowEnvRef.match(/@(.+)$/); + + // 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 = repoMatch ? repoMatch[1] : context.repo.owner; - const repo = repoMatch ? repoMatch[2] : context.repo.repo; + const owner = workflowRefMatch ? workflowRefMatch[1] : context.repo.owner; + const repo = workflowRefMatch ? workflowRefMatch[2] : context.repo.repo; + const workflowRepo = `${owner}/${repo}`; - // Use the workflow ref if parseable, otherwise fall back to context.sha - const ref = refMatch ? refMatch[1] : context.sha; + // 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}`); + core.info(`Resolved source repo: ${owner}/${repo} @ ${ref || "(default branch)"}`); - const workflowRepo = `${owner}/${repo}`; if (workflowRepo !== currentRepo) { core.info(`Cross-repo invocation detected: workflow source is "${workflowRepo}", current repo is "${currentRepo}"`); } else { diff --git a/actions/setup/js/check_workflow_timestamp_api.test.cjs b/actions/setup/js/check_workflow_timestamp_api.test.cjs index 529eeca0331..1906e78a8ed 100644 --- a/actions/setup/js/check_workflow_timestamp_api.test.cjs +++ b/actions/setup/js/check_workflow_timestamp_api.test.cjs @@ -584,77 +584,42 @@ engine: copilot it("should fall back to context.repo when GITHUB_WORKFLOW_REF is not set", async () => { delete process.env.GITHUB_WORKFLOW_REF; - 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"), - }, - }); + mockGithub.rest.repos.getContent.mockResolvedValue({ data: null }); await main(); - // Verify the API was called with context.repo (test-owner/test-repo) as fallback - expect(mockGithub.rest.repos.getContent).toHaveBeenCalledWith(expect.objectContaining({ owner: "test-owner", repo: "test-repo", ref: "abc123" })); + // 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"; - const validHash = "c2a79263dc72f28c76177afda9bf0935481b26da094407a50155a6e0244084e3"; - const lockFileContent = `# frontmatter-hash: ${validHash} -name: Test Workflow -on: push -jobs: - test: - runs-on: ubuntu-latest`; + mockGithub.rest.repos.getContent.mockResolvedValue({ data: null }); - const mdFileContent = `--- -engine: copilot ---- -# Test Workflow`; + await main(); - 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"), - }, - }); + // 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(); - // When GITHUB_WORKFLOW_REF is malformed, fall back to context.repo and context.sha - expect(mockGithub.rest.repos.getContent).toHaveBeenCalledWith(expect.objectContaining({ owner: "test-owner", repo: "test-repo", ref: "abc123" })); + // 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)")); }); }); }); From 759cb5d4bd9288e9bcefe0a035c3df461ae09135 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 04:45:04 +0000 Subject: [PATCH 5/5] feat: add on.stale-check: false to disable frontmatter hash check step When on.stale-check is set to false in the workflow frontmatter, the compiler no longer emits the "Check workflow file timestamps" step in the activation job. Useful when cross-repo org ruleset workflows don't need the stale check. - Add StaleCheckDisabled field to WorkflowData - Read on.stale-check from frontmatter in orchestrator - Guard hash check step emission with !data.StaleCheckDisabled - Add stale-check boolean to on: schema - Add unit tests Agent-Logs-Url: https://github.com/github/gh-aw/sessions/11cf3837-b6ed-4f1e-8ebf-97820e45943c Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/parser/schemas/main_workflow_schema.json | 4 + pkg/workflow/compiler_activation_job.go | 17 ++-- .../compiler_orchestrator_workflow.go | 11 +++ pkg/workflow/compiler_types.go | 1 + pkg/workflow/stale_check_test.go | 94 +++++++++++++++++++ 5 files changed, 120 insertions(+), 7 deletions(-) create mode 100644 pkg/workflow/stale_check_test.go 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") + } + }) + } +}