Skip to content

Commit 479b2ef

Browse files
sgoedeckeCopilot
andcommitted
Add 'copilot' provider that shells out to GitHub Copilot CLI
Mirrors the --provider copilot capability added to sgoedecke/gh-standup. When 'provider: copilot' is set, the action invokes the GitHub Copilot CLI in programmatic mode (copilot -p <prompt> --no-ask-user) instead of calling the GitHub Models REST API. Following the documented GitHub Actions pattern, consumers install the CLI in an earlier workflow step (npm install -g @github/copilot) and authenticate via COPILOT_GITHUB_TOKEN. - Add 'provider', 'copilot-cli-path', 'copilot-allow-tools' inputs to action.yml - Add src/copilot.ts that spawns the CLI and returns stdout - Wire src/main.ts to route to copilotInference when provider == 'copilot', warning when MCP / responseFormat / custom-headers are set together with it - Skip forwarding the github-models default model (openai/gpt-4o) to Copilot - Add unit tests covering prompt assembly, model forwarding, allow-tool pass-through, missing-CLI handling, and provider routing in main.ts - Document the workflow setup in README Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent af6ad2c commit 479b2ef

8 files changed

Lines changed: 16305 additions & 13918 deletions

File tree

README.md

Lines changed: 65 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -231,9 +231,52 @@ steps:
231231

232232
**Security note**: Always use GitHub secrets for sensitive header values like API keys, tokens, or passwords. The action automatically masks common sensitive headers (containing `key`, `token`, `secret`, `password`, or `authorization`) in logs.
233233

234+
### Using GitHub Copilot CLI as the inference provider
235+
236+
By default the action calls the [GitHub Models](https://github.com/marketplace/models) REST API. You can instead route inference through the [GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/copilot-cli/automate-copilot-cli/automate-with-actions) by setting `provider: copilot`.
237+
238+
Because the Copilot CLI is not pre-installed on GitHub-hosted runners, you'll need to install and authenticate it in earlier steps before invoking this action. The pattern follows [the official GitHub Actions docs](https://docs.github.com/en/copilot/how-tos/copilot-cli/automate-copilot-cli/automate-with-actions):
239+
240+
```yaml
241+
name: 'AI inference (Copilot)'
242+
on: workflow_dispatch
243+
244+
jobs:
245+
inference:
246+
runs-on: ubuntu-latest
247+
steps:
248+
- uses: actions/checkout@v6
249+
250+
- uses: actions/setup-node@v4
251+
252+
- name: Install Copilot CLI
253+
run: npm install -g @github/copilot
254+
255+
- name: Run AI Inference via Copilot
256+
id: inference
257+
uses: actions/ai-inference@v1
258+
with:
259+
prompt: 'Summarise the latest changes in this repo.'
260+
provider: copilot
261+
model: gpt-4.1 # any model the Copilot CLI accepts; omit to use the CLI default
262+
env:
263+
# Create a fine-grained PAT with the "Copilot Requests" permission and
264+
# store it as a repository secret. See the docs linked above.
265+
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_PAT }}
266+
267+
- run: echo "${{ steps.inference.outputs.response }}"
268+
```
269+
270+
Notes when `provider: copilot`:
271+
272+
- The Copilot CLI must be on `PATH` (or pass `copilot-cli-path`) and authenticated via `COPILOT_GITHUB_TOKEN` (or another env var Copilot CLI accepts) before this action runs.
273+
- The action's default model (`openai/gpt-4o`) is a GitHub Models identifier and is not forwarded to Copilot. Set `model:` to a Copilot-compatible model (e.g. `gpt-4.1`, `claude-sonnet-4.5`) when you want to override the CLI default.
274+
- `enable-github-mcp`, `custom-headers`, `endpoint`, and `responseFormat` / `jsonSchema` are ignored under this provider — Copilot has its own tools and configuration mechanism. Use `copilot-allow-tools` (e.g. `shell(git:*),write`) to opt in to specific Copilot tools.
275+
- The action invokes the CLI with `-p <prompt> --no-ask-user`, so it never blocks on interactive prompts.
276+
234277
### GitHub MCP Integration (Model Context Protocol)
235278

236-
This action now supports **read-only** integration with the GitHub-hosted Model
279+
This action supports **read-only** integration with the GitHub-hosted Model
237280
Context Protocol (MCP) server, which provides access to GitHub tools like
238281
repository management, issue tracking, and pull request operations.
239282

@@ -303,24 +346,27 @@ perform actions like searching issues and PRs.
303346
Various inputs are defined in [`action.yml`](action.yml) to let you configure
304347
the action:
305348

306-
| Name | Description | Default |
307-
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------ |
308-
| `token` | Token to use for inference. Typically the GITHUB_TOKEN secret | `github.token` |
309-
| `prompt` | The prompt to send to the model | N/A |
310-
| `prompt-file` | Path to a file containing the prompt (supports .txt and .prompt.yml formats). If both `prompt` and `prompt-file` are provided, `prompt-file` takes precedence | `""` |
311-
| `input` | Template variables in YAML format for .prompt.yml files (e.g., `var1: value1` on separate lines) | `""` |
312-
| `file_input` | Template variables in YAML where values are file paths. The file contents are read and used for templating | `""` |
313-
| `system-prompt` | The system prompt to send to the model | `"You are a helpful assistant"` |
314-
| `system-prompt-file` | Path to a file containing the system prompt. If both `system-prompt` and `system-prompt-file` are provided, `system-prompt-file` takes precedence | `""` |
315-
| `model` | The model to use for inference. Must be available in the [GitHub Models](https://github.com/marketplace?type=models) catalog | `openai/gpt-4o` |
316-
| `endpoint` | The endpoint to use for inference. If you're running this as part of an org, you should probably use the org-specific Models endpoint | `https://models.github.ai/inference` |
317-
| `max-tokens` | The maximum number of tokens to generate (deprecated, use `max-completion-tokens` instead) | 200 |
318-
| `max-completion-tokens` | The maximum number of tokens to generate | `""` |
319-
| `temperature` | The sampling temperature to use (0-1) | `""` |
320-
| `top-p` | The nucleus sampling parameter to use (0-1) | `""` |
321-
| `enable-github-mcp` | Enable Model Context Protocol integration with GitHub tools | `false` |
322-
| `github-mcp-token` | Token to use for GitHub MCP server (defaults to the main token if not specified). | `""` |
323-
| `custom-headers` | Custom HTTP headers to include in API requests. Supports both YAML format (`header1: value1`) and JSON format (`{"header1": "value1"}`). Useful for API Management platforms, rate limiting, and request tracking. | `""` |
349+
| Name | Description | Default |
350+
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ |
351+
| `token` | Token to use for inference. Typically the GITHUB_TOKEN secret | `github.token` |
352+
| `prompt` | The prompt to send to the model | N/A |
353+
| `prompt-file` | Path to a file containing the prompt (supports .txt and .prompt.yml formats). If both `prompt` and `prompt-file` are provided, `prompt-file` takes precedence | `""` |
354+
| `input` | Template variables in YAML format for .prompt.yml files (e.g., `var1: value1` on separate lines) | `""` |
355+
| `file_input` | Template variables in YAML where values are file paths. The file contents are read and used for templating | `""` |
356+
| `system-prompt` | The system prompt to send to the model | `"You are a helpful assistant"` |
357+
| `system-prompt-file` | Path to a file containing the system prompt. If both `system-prompt` and `system-prompt-file` are provided, `system-prompt-file` takes precedence | `""` |
358+
| `model` | The model to use for inference. Must be available in the [GitHub Models](https://github.com/marketplace?type=models) catalog | `openai/gpt-4o` |
359+
| `endpoint` | The endpoint to use for inference. If you're running this as part of an org, you should probably use the org-specific Models endpoint | `https://models.github.ai/inference` |
360+
| `max-tokens` | The maximum number of tokens to generate (deprecated, use `max-completion-tokens` instead) | 200 |
361+
| `max-completion-tokens` | The maximum number of tokens to generate | `""` |
362+
| `temperature` | The sampling temperature to use (0-1) | `""` |
363+
| `top-p` | The nucleus sampling parameter to use (0-1) | `""` |
364+
| `enable-github-mcp` | Enable Model Context Protocol integration with GitHub tools | `false` |
365+
| `github-mcp-token` | Token to use for GitHub MCP server (defaults to the main token if not specified). | `""` |
366+
| `custom-headers` | Custom HTTP headers to include in API requests. Supports both YAML format (`header1: value1`) and JSON format (`{"header1": "value1"}`). Useful for API Management platforms, rate limiting, and request tracking. | `""` |
367+
| `provider` | Inference provider to use. `github-models` (default) calls the GitHub Models REST API. `copilot` shells out to the GitHub Copilot CLI, which must be installed and authenticated on the runner before this action runs. | `github-models` |
368+
| `copilot-cli-path` | Path to the Copilot CLI binary (only used when `provider: copilot`). Defaults to `copilot` on `PATH`. | `""` |
369+
| `copilot-allow-tools` | Comma-separated list of tools to allow when `provider: copilot` (passed through as `--allow-tool`). Example: `shell(git:*),write`. | `""` |
324370

325371
## Outputs
326372

__tests__/copilot.test.ts

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import {vi, describe, it, expect, beforeEach} from 'vitest'
2+
import * as core from '../__fixtures__/core.js'
3+
4+
vi.mock('@actions/core', () => core)
5+
6+
const {copilotInference, buildCopilotPrompt, DEFAULT_GITHUB_MODELS_MODEL} = await import('../src/copilot.js')
7+
8+
type RunResult = {stdout: string; stderr: string; exitCode: number | null}
9+
10+
function makeSpawner(result: RunResult | Error) {
11+
return vi.fn(() => {
12+
if (result instanceof Error) {
13+
return Promise.reject(result)
14+
}
15+
return Promise.resolve({...result})
16+
})
17+
}
18+
19+
describe('buildCopilotPrompt', () => {
20+
it('separates system and user messages', () => {
21+
const {systemMessage, prompt} = buildCopilotPrompt([
22+
{role: 'system', content: 'Be concise.'},
23+
{role: 'user', content: 'Hello.'},
24+
])
25+
expect(systemMessage).toBe('Be concise.')
26+
expect(prompt).toBe('Hello.')
27+
})
28+
29+
it('joins multiple system messages and labels non-user/system roles', () => {
30+
const {systemMessage, prompt} = buildCopilotPrompt([
31+
{role: 'system', content: 'System A'},
32+
{role: 'system', content: 'System B'},
33+
{role: 'user', content: 'Question'},
34+
{role: 'assistant', content: 'Prior answer'},
35+
])
36+
expect(systemMessage).toBe('System A\n\nSystem B')
37+
expect(prompt).toContain('Question')
38+
expect(prompt).toContain('ASSISTANT:\nPrior answer')
39+
})
40+
41+
it('skips empty messages', () => {
42+
const {systemMessage, prompt} = buildCopilotPrompt([
43+
{role: 'system', content: ' '},
44+
{role: 'user', content: 'Hi'},
45+
])
46+
expect(systemMessage).toBe('')
47+
expect(prompt).toBe('Hi')
48+
})
49+
50+
it('throws if there is no prompt content', () => {
51+
expect(() => buildCopilotPrompt([{role: 'system', content: 'only system'}])).toThrow(/no prompt configured/i)
52+
})
53+
})
54+
55+
describe('copilotInference', () => {
56+
beforeEach(() => {
57+
vi.clearAllMocks()
58+
})
59+
60+
it('spawns copilot with the merged prompt and returns stdout', async () => {
61+
const spawner = makeSpawner({stdout: ' hello world \n', stderr: '', exitCode: 0})
62+
63+
const result = await copilotInference(
64+
{
65+
messages: [
66+
{role: 'system', content: 'Be brief.'},
67+
{role: 'user', content: 'Say hi.'},
68+
],
69+
model: DEFAULT_GITHUB_MODELS_MODEL,
70+
},
71+
spawner,
72+
)
73+
74+
expect(result).toBe('hello world')
75+
expect(spawner).toHaveBeenCalledTimes(1)
76+
const [cmd, args] = spawner.mock.calls[0]
77+
expect(cmd).toBe('copilot')
78+
expect(args[0]).toBe('-p')
79+
expect(args[1]).toBe('Be brief.\n\nSay hi.')
80+
expect(args).toContain('--no-ask-user')
81+
// Default github-models model should NOT be forwarded
82+
expect(args).not.toContain('--model')
83+
})
84+
85+
it('forwards a non-default model via --model', async () => {
86+
const spawner = makeSpawner({stdout: 'ok', stderr: '', exitCode: 0})
87+
88+
await copilotInference(
89+
{
90+
messages: [{role: 'user', content: 'hi'}],
91+
model: 'claude-sonnet-4.5',
92+
},
93+
spawner,
94+
)
95+
96+
const [, args] = spawner.mock.calls[0]
97+
const modelIdx = args.indexOf('--model')
98+
expect(modelIdx).toBeGreaterThan(-1)
99+
expect(args[modelIdx + 1]).toBe('claude-sonnet-4.5')
100+
})
101+
102+
it('forwards allow-tool entries', async () => {
103+
const spawner = makeSpawner({stdout: 'ok', stderr: '', exitCode: 0})
104+
105+
await copilotInference(
106+
{
107+
messages: [{role: 'user', content: 'hi'}],
108+
model: '',
109+
allowTools: ['shell(git:*)', 'write'],
110+
},
111+
spawner,
112+
)
113+
114+
const [, args] = spawner.mock.calls[0]
115+
expect(args).toContain('--allow-tool=shell(git:*)')
116+
expect(args).toContain('--allow-tool=write')
117+
})
118+
119+
it('uses a custom cli path when provided', async () => {
120+
const spawner = makeSpawner({stdout: 'ok', stderr: '', exitCode: 0})
121+
122+
await copilotInference(
123+
{
124+
messages: [{role: 'user', content: 'hi'}],
125+
model: '',
126+
cliPath: '/opt/copilot/bin/copilot',
127+
},
128+
spawner,
129+
)
130+
131+
expect(spawner.mock.calls[0][0]).toBe('/opt/copilot/bin/copilot')
132+
})
133+
134+
it('throws a helpful error when the CLI is not installed', async () => {
135+
const enoent = new Error('spawn copilot ENOENT')
136+
const spawner = makeSpawner(enoent)
137+
138+
await expect(
139+
copilotInference(
140+
{
141+
messages: [{role: 'user', content: 'hi'}],
142+
model: '',
143+
},
144+
spawner,
145+
),
146+
).rejects.toThrow(/Copilot CLI not found/)
147+
})
148+
149+
it('throws on non-zero exit code with stderr context', async () => {
150+
const spawner = makeSpawner({stdout: '', stderr: 'auth failed\nbad token', exitCode: 2})
151+
152+
await expect(
153+
copilotInference(
154+
{
155+
messages: [{role: 'user', content: 'hi'}],
156+
model: '',
157+
},
158+
spawner,
159+
),
160+
).rejects.toThrow(/exited with code 2.*bad token/s)
161+
})
162+
163+
it('returns null when stdout is empty', async () => {
164+
const spawner = makeSpawner({stdout: ' ', stderr: '', exitCode: 0})
165+
166+
const result = await copilotInference(
167+
{
168+
messages: [{role: 'user', content: 'hi'}],
169+
model: '',
170+
},
171+
spawner,
172+
)
173+
174+
expect(result).toBeNull()
175+
})
176+
})

0 commit comments

Comments
 (0)