Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/changeset.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions .github/workflows/craft.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion .github/workflows/daily-safeoutputs-git-simulator.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions .github/workflows/design-decision-gate.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions .github/workflows/mergefest.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions .github/workflows/necromancer.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions .github/workflows/poem-bot.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions .github/workflows/pr-sous-chef.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions .github/workflows/smoke-claude.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions .github/workflows/smoke-update-cross-repo-pr.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions .github/workflows/tidy.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 27 additions & 1 deletion actions/setup/js/validate_secrets.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,30 @@ async function testGitHubGraphQLAPI(token, owner, repo) {
}
}

/**
* Test Copilot token availability, accounting for copilot org billing mode.
*
* When all repository workflows use `copilot-requests: write`, the built-in
* GITHUB_TOKEN is used for Copilot authentication and no separate
* COPILOT_GITHUB_TOKEN secret is required. In that case the maintenance
* workflow sets GH_AW_COPILOT_ORG_BILLING="true" and validation is skipped
* rather than flagging the missing token as unconfigured.
*
* @param {string | undefined} token - Value of GH_AW_COPILOT_TOKEN
* @param {boolean} orgBilling - Whether copilot org billing mode is active (GH_AW_COPILOT_ORG_BILLING="true")
* @returns {Promise<{status: string, message: string, details?: any}>}
*/
async function testCopilotToken(token, orgBilling) {
if (!token && orgBilling) {
return {
status: Status.SKIPPED,
message: "Copilot org billing mode — GITHUB_TOKEN is used for Copilot authentication; COPILOT_GITHUB_TOKEN is not required",
details: { note: "copilot-requests: write is set in the workflow permissions, so the built-in GITHUB_TOKEN handles Copilot authentication" },
};
Comment on lines +262 to +266
}
return testCopilotCLI(token);
}

/**
* Test Copilot CLI availability
* @param {string | undefined} token
Expand Down Expand Up @@ -665,7 +689,8 @@ async function main() {
// Test GH_AW_COPILOT_TOKEN
core.info("Testing GH_AW_COPILOT_TOKEN...");
const copilotToken = process.env.GH_AW_COPILOT_TOKEN;
const copilotResult = await testCopilotCLI(copilotToken);
const copilotOrgBilling = process.env.GH_AW_COPILOT_ORG_BILLING === "true";
const copilotResult = await testCopilotToken(copilotToken, copilotOrgBilling);
results.push({
secret: "GH_AW_COPILOT_TOKEN",
test: "Copilot CLI Availability",
Expand Down Expand Up @@ -756,6 +781,7 @@ module.exports = {
testGitHubRESTAPI,
testGitHubGraphQLAPI,
testCopilotCLI,
testCopilotToken,
testAnthropicAPI,
testOpenAIAPI,
testBraveSearchAPI,
Expand Down
33 changes: 33 additions & 0 deletions actions/setup/js/validate_secrets.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
testGitHubRESTAPI,
testGitHubGraphQLAPI,
testCopilotCLI,
testCopilotToken,
testAnthropicAPI,
testOpenAIAPI,
testBraveSearchAPI,
Expand Down Expand Up @@ -55,6 +56,38 @@ describe("validate_secrets", () => {
});
});

describe("testCopilotToken", () => {
it("should return SKIPPED when token is not set and org billing is active", async () => {
const result = await testCopilotToken("", true);
expect(result.status).toBe("skipped");
expect(result.message).toContain("org billing");
});

it("should return SKIPPED when token is undefined and org billing is active", async () => {
const result = await testCopilotToken(undefined, true);
expect(result.status).toBe("skipped");
expect(result.message).toContain("GITHUB_TOKEN");
});

it("should return NOT_SET when token is not set and org billing is not active", async () => {
const result = await testCopilotToken("", false);
expect(result.status).toBe("not_set");
expect(result.message).toBe("Token not set");
});

it("should delegate to testCopilotCLI when token is set regardless of org billing", async () => {
// testCopilotCLI with a non-empty token checks CLI availability (skipped if not installed)
const result = await testCopilotToken("some-token", true);
// Result should be skipped or success depending on environment, but NOT the org billing skip
expect(result.message).not.toContain("org billing");
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Weak assertion on delegation path: the only check is not.toContain("org billing"), which passes even if testCopilotToken returned an unexpected status (e.g., "not_set" from an accidental token-stripping bug).

💡 Suggested fix

Add a status guard to confirm the token was actually forwarded to testCopilotCLI:

it("should delegate to testCopilotCLI when token is set regardless of org billing", async () => {
  const result = await testCopilotToken("some-token", true);
  // NOT_SET is only returned by testCopilotCLI when token is falsy;
  // receiving it here would mean the token was not passed through.
  expect(result.status).not.toBe("not_set");
  expect(result.message).not.toContain("org billing");
});

This makes the test fail if the delegation is accidentally bypassed (e.g., early-return path incorrectly handles a truthy token).

it("should not suppress warning when token is missing and org billing is false", async () => {
const result = await testCopilotToken(undefined, false);
expect(result.status).toBe("not_set");
});
});

describe("testAnthropicAPI", () => {
it("should return NOT_SET when API key is not provided", async () => {
const result = await testAnthropicAPI("");
Expand Down
40 changes: 40 additions & 0 deletions docs/adr/38459-detect-copilot-org-billing-at-compile-time.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# ADR-38459: Detect Copilot Org Billing at Compile Time to Skip Secret Validation

**Date**: 2026-06-10
**Status**: Draft

## Context

When a repository uses Copilot *org billing* (every Copilot-engine workflow declares `copilot-requests: write`), the built-in `GITHUB_TOKEN` serves as the Copilot authentication token and no separate `COPILOT_GITHUB_TOKEN` secret exists. The generated maintenance workflow's "Validate Secrets" step nonetheless tested for `GH_AW_COPILOT_TOKEN` and reported it as `NOT_SET`, producing a false warning on every maintenance run for org-billing repos. The compiler already has full visibility into all workflows and their permissions at generation time, so the billing mode can be determined statically rather than guessed at runtime.

## Decision

We decided to detect org billing mode at compile time and propagate it to the runtime validator. A new Go helper `allCopilotWorkflowsUseOrgBilling()` returns `true` only when every Copilot-engine workflow in the repo sets `copilot-requests: write` (non-Copilot engines are ignored; empty or mixed sets return `false`). When `true`, the generated maintenance YAML injects `GH_AW_COPILOT_ORG_BILLING: "true"` into the Validate Secrets step's env block. The JS validator gains `testCopilotToken(token, orgBilling)`, which returns `SKIPPED` (not `NOT_SET`) when the token is absent and org billing is active, and otherwise delegates to the existing `testCopilotCLI`.

## Alternatives Considered

### Alternative 1: Runtime-only detection in the validator
The JS validator could inspect the live token/permissions at runtime to infer billing mode, avoiding a generated env flag. Rejected because the validator runs inside a single maintenance workflow and cannot reliably observe the permissions of *all* Copilot workflows in the repo; the compiler already has that complete view, making compile-time detection both simpler and more accurate.

### Alternative 2: Unconditionally suppress the missing-Copilot-token warning
We could drop the `NOT_SET` warning whenever `GH_AW_COPILOT_TOKEN` is absent. Rejected because it would mask genuine misconfiguration in non-org-billing repos, where the missing secret is a real problem the warning is meant to surface.

## Consequences

### Positive
- Eliminates the false `NOT_SET` warning for org-billing repos, replacing it with an explanatory `SKIPPED` status.
- The "all Copilot workflows" gate prevents false positives: mixed or empty configurations correctly fall back to the existing warning behavior.
- Decision is fully covered by unit tests on both the Go helper and the JS `testCopilotToken` function.

### Negative
- Introduces compile-time coupling: the maintenance YAML must be regenerated when a repo's billing mode changes, or the flag will be stale.
- Mixed-billing repos (some workflows with, some without `copilot-requests: write`) still emit the warning, which may be unexpected for partial-migration scenarios.
- Adds a new environment variable (`GH_AW_COPILOT_ORG_BILLING`) to the secret-validation contract that must be kept in sync between the Go generator and the JS validator.

### Neutral
- The flag is emitted only in the maintenance workflow's Validate Secrets step; other generated steps are unaffected.
- `ResolveEngineID` treats the empty/default engine as Copilot, so default-engine workflows participate in the org-billing determination.

---

*This is a DRAFT ADR generated by the [Design Decision Gate](https://github.com/github/gh-aw/actions/runs/27312938315) workflow. The PR author must review, complete, and finalize this document before the PR can merge.*
27 changes: 27 additions & 0 deletions pkg/workflow/maintenance_workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ func GenerateMaintenanceWorkflow(ctx context.Context, opts GenerateMaintenanceWo
enableCompileCreatePullRequest,
strings.TrimSpace(compileGitHubTokenSecret) != "",
)
copilotOrgBilling := allCopilotWorkflowsUseOrgBilling(workflowDataList)
content := buildMaintenanceWorkflowYAML(ctx, buildMaintenanceWorkflowYAMLOptions{
cronSchedule: cronSchedule,
scheduleDesc: scheduleDesc,
Expand All @@ -246,6 +247,7 @@ func GenerateMaintenanceWorkflow(ctx context.Context, opts GenerateMaintenanceWo
disableLabelTrigger: disableLabelTrigger,
compileGitHubToken: getEffectiveMaintenanceGitHubToken(compileGitHubTokenSecret),
createCompilePR: enableCompileCreatePullRequest,
copilotOrgBilling: copilotOrgBilling,
})

// Write the maintenance workflow file
Expand Down Expand Up @@ -310,6 +312,31 @@ func handleMaintenanceDisabled(workflowDataList []*WorkflowData, workflowDir str
return nil
}

// allCopilotWorkflowsUseOrgBilling reports whether all Copilot-engine workflows
// in the list have copilot-requests: write set. This indicates org billing mode,
// where the GITHUB_TOKEN is used for Copilot authentication and the
// COPILOT_GITHUB_TOKEN secret is not required.
Comment on lines +315 to +318
// Returns false if no Copilot workflows are found (billing mode is indeterminate)
// or if any Copilot workflow does not have copilot-requests: write set.
func allCopilotWorkflowsUseOrgBilling(workflowDataList []*WorkflowData) bool {
copilotCount := 0
for _, data := range workflowDataList {
if data == nil {
continue
}
engineID := ResolveEngineID(data)
// Default engine (empty string) is Copilot, as is an explicit "copilot" ID.
if engineID != "" && engineID != string(constants.CopilotEngine) {
continue
}
copilotCount++
if !hasCopilotRequestsWritePermission(data) {
return false
}
}
return copilotCount > 0
}

// scanWorkflowsForExpires checks all workflow data for expires fields and returns
// whether any expires fields are set and the minimum expires value in hours.
func scanWorkflowsForExpires(workflowDataList []*WorkflowData) (bool, int) {
Expand Down
Loading
Loading