Merge branch 'main' into feature/pass-toolsets

This commit is contained in:
Maarten van Diemen
2025-11-30 22:08:41 +01:00
8 changed files with 193 additions and 92 deletions

View File

@@ -56,20 +56,51 @@ jobs:
id: checkout id: checkout
uses: actions/checkout@v6 uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: .node-version
- name: Start Mock Inference Server
id: mock-server
run: |
node script/mock-inference-server.mjs &
echo "pid=$!" >> $GITHUB_OUTPUT
# Wait for server to be ready
for i in {1..10}; do
if curl -s http://localhost:3456/health > /dev/null; then
echo "Mock server is ready"
break
fi
sleep 1
done
- name: Test Local Action - name: Test Local Action
id: test-action id: test-action
continue-on-error: true
uses: ./ uses: ./
with: with:
prompt: hello prompt: hello
endpoint: http://localhost:3456
env: env:
GITHUB_TOKEN: ${{ github.token }} GITHUB_TOKEN: ${{ github.token }}
- name: Print Output - name: Print Output
id: output id: output
continue-on-error: true
run: echo "${{ steps.test-action.outputs.response }}" run: echo "${{ steps.test-action.outputs.response }}"
- name: Verify Output
run: |
response="${{ steps.test-action.outputs.response }}"
if [[ -z "$response" ]]; then
echo "Error: No response received"
exit 1
fi
echo "Response received: $response"
- name: Stop Mock Server
if: always()
run: kill ${{ steps.mock-server.outputs.pid }} || true
test-action-prompt-file: test-action-prompt-file:
name: GitHub Actions Test with Prompt File name: GitHub Actions Test with Prompt File
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -79,6 +110,25 @@ jobs:
id: checkout id: checkout
uses: actions/checkout@v6 uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: .node-version
- name: Start Mock Inference Server
id: mock-server
run: |
node script/mock-inference-server.mjs &
echo "pid=$!" >> $GITHUB_OUTPUT
# Wait for server to be ready
for i in {1..10}; do
if curl -s http://localhost:3456/health > /dev/null; then
echo "Mock server is ready"
break
fi
sleep 1
done
- name: Create Prompt File - name: Create Prompt File
run: echo "hello" > prompt.txt run: echo "hello" > prompt.txt
@@ -87,16 +137,33 @@ jobs:
- name: Test Local Action with Prompt File - name: Test Local Action with Prompt File
id: test-action-prompt-file id: test-action-prompt-file
continue-on-error: true
uses: ./ uses: ./
with: with:
prompt-file: prompt.txt prompt-file: prompt.txt
system-prompt-file: system-prompt.txt system-prompt-file: system-prompt.txt
endpoint: http://localhost:3456
env: env:
GITHUB_TOKEN: ${{ github.token }} GITHUB_TOKEN: ${{ github.token }}
- name: Print Output - name: Print Output
continue-on-error: true
run: | run: |
echo "Response saved to: ${{ steps.test-action-prompt-file.outputs.response-file }}" echo "Response saved to: ${{ steps.test-action-prompt-file.outputs.response-file }}"
cat "${{ steps.test-action-prompt-file.outputs.response-file }}" cat "${{ steps.test-action-prompt-file.outputs.response-file }}"
- name: Verify Output
run: |
response_file="${{ steps.test-action-prompt-file.outputs.response-file }}"
if [[ ! -f "$response_file" ]]; then
echo "Error: Response file not found"
exit 1
fi
content=$(cat "$response_file")
if [[ -z "$content" ]]; then
echo "Error: Response file is empty"
exit 1
fi
echo "Response file content: $content"
- name: Stop Mock Server
if: always()
run: kill ${{ steps.mock-server.outputs.pid }} || true

View File

@@ -24,7 +24,7 @@ jobs:
steps: steps:
- name: Test Local Action - name: Test Local Action
id: inference id: inference
uses: actions/ai-inference@v2 uses: actions/ai-inference@v1
with: with:
prompt: 'Hello!' prompt: 'Hello!'
@@ -42,7 +42,7 @@ supports both plain text files and structured `.prompt.yml` files:
steps: steps:
- name: Run AI Inference with Text File - name: Run AI Inference with Text File
id: inference id: inference
uses: actions/ai-inference@v2 uses: actions/ai-inference@v1
with: with:
prompt-file: './path/to/prompt.txt' prompt-file: './path/to/prompt.txt'
``` ```
@@ -56,7 +56,7 @@ support templating, custom models, and JSON schema responses:
steps: steps:
- name: Run AI Inference with Prompt YAML - name: Run AI Inference with Prompt YAML
id: inference id: inference
uses: actions/ai-inference@v2 uses: actions/ai-inference@v1
with: with:
prompt-file: './.github/prompts/sample.prompt.yml' prompt-file: './.github/prompts/sample.prompt.yml'
input: | input: |
@@ -132,7 +132,7 @@ of an inline system prompt:
steps: steps:
- name: Run AI Inference with System Prompt File - name: Run AI Inference with System Prompt File
id: inference id: inference
uses: actions/ai-inference@v2 uses: actions/ai-inference@v1
with: with:
prompt: 'Hello!' prompt: 'Hello!'
system-prompt-file: './path/to/system-prompt.txt' system-prompt-file: './path/to/system-prompt.txt'
@@ -146,7 +146,7 @@ This can be useful when model response exceeds actions output limit
steps: steps:
- name: Test Local Action - name: Test Local Action
id: inference id: inference
uses: actions/ai-inference@v2 uses: actions/ai-inference@v1
with: with:
prompt: 'Hello!' prompt: 'Hello!'
@@ -162,18 +162,28 @@ This action now supports **read-only** integration with the GitHub-hosted Model
Context Protocol (MCP) server, which provides access to GitHub tools like Context Protocol (MCP) server, which provides access to GitHub tools like
repository management, issue tracking, and pull request operations. repository management, issue tracking, and pull request operations.
> [!NOTE] #### Authentication
> The GitHub MCP integration requires a Personal Access Token (PAT) and cannot use the built-in `GITHUB_TOKEN`.
You can authenticate the MCP server with **either**:
1. **Personal Access Token (PAT)** user-scoped token
2. **GitHub App Installation Token** (`ghs_…`) short-lived, app-scoped token
> The built-in `GITHUB_TOKEN` is **not** accepted by the MCP server.
> Using a **GitHub App installation token** is recommended in most CI environments because it is short-lived and least-privilege by design.
#### Enabling MCP in the action
Set `enable-github-mcp: true` and provide a token via `github-mcp-token`.
```yaml ```yaml
steps: steps:
- name: AI Inference with GitHub Tools - name: AI Inference with GitHub Tools
id: inference id: inference
uses: actions/ai-inference@v2 uses: actions/ai-inference@v1.2
with: with:
prompt: 'List my open pull requests and create a summary' prompt: 'List my open pull requests and create a summary'
enable-github-mcp: true enable-github-mcp: true
token: ${{ secrets.USER_PAT }} token: ${{ secrets.USER_PAT }} # or a ghs_ installation token
``` ```
If you want, you can use separate tokens for the AI inference endpoint If you want, you can use separate tokens for the AI inference endpoint
@@ -183,12 +193,12 @@ and the GitHub MCP server:
steps: steps:
- name: AI Inference with Separate MCP Token - name: AI Inference with Separate MCP Token
id: inference id: inference
uses: actions/ai-inference@v2 uses: actions/ai-inference@v1.2
with: with:
prompt: 'List my open pull requests and create a summary' prompt: 'List my open pull requests and create a summary'
enable-github-mcp: true enable-github-mcp: true
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
github-mcp-token: ${{ secrets.USER_PAT }} github-mcp-token: ${{ secrets.USER_PAT }} # or a ghs_ installation token
``` ```
#### Configuring GitHub MCP Toolsets #### Configuring GitHub MCP Toolsets
@@ -218,21 +228,20 @@ perform actions like searching issues and PRs.
Various inputs are defined in [`action.yml`](action.yml) to let you configure Various inputs are defined in [`action.yml`](action.yml) to let you configure
the action: the action:
| Name | Description | Default | | Name | Description | Default |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ |
| `token` | Token to use for inference. Typically the GITHUB_TOKEN secret | `github.token` | | `token` | Token to use for inference. Typically the GITHUB_TOKEN secret | `github.token` |
| `prompt` | The prompt to send to the model | N/A | | `prompt` | The prompt to send to the model | N/A |
| `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 | `""` | | `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 | `""` |
| `input` | Template variables in YAML format for .prompt.yml files (e.g., `var1: value1` on separate lines) | `""` | | `input` | Template variables in YAML format for .prompt.yml files (e.g., `var1: value1` on separate lines) | `""` |
| `file_input` | Template variables in YAML where values are file paths. The file contents are read and used for templating | `""` | | `file_input` | Template variables in YAML where values are file paths. The file contents are read and used for templating | `""` |
| `system-prompt` | The system prompt to send to the model | `"You are a helpful assistant"` | | `system-prompt` | The system prompt to send to the model | `"You are a helpful assistant"` |
| `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 | `""` | | `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 | `""` |
| `model` | The model to use for inference. Must be available in the [GitHub Models](https://github.com/marketplace?type=models) catalog | `openai/gpt-4o` | | `model` | The model to use for inference. Must be available in the [GitHub Models](https://github.com/marketplace?type=models) catalog | `openai/gpt-4o` |
| `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` | | `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` |
| `max-tokens` | The max number of tokens to generate | 200 | | `max-tokens` | The max number of tokens to generate | 200 |
| `enable-github-mcp` | Enable Model Context Protocol integration with GitHub tools | `false` | | `enable-github-mcp` | Enable Model Context Protocol integration with GitHub tools | `false` |
| `github-mcp-token` | Token to use for GitHub MCP server (defaults to the main token if not specified). This must be a PAT in order for MCP to work | `""` | | `github-mcp-token` | Token to use for GitHub MCP server (defaults to the main token if not specified). | `""` |
| `github-mcp-toolsets` | Comma-separated list of toolsets to enable for GitHub MCP (e.g., `repos,issues,pull_requests,actions`). Use `all` for all toolsets or `default` for the default set | `""` |
## Outputs ## Outputs

View File

@@ -75,17 +75,13 @@ vi.mock('fs', () => ({
writeFileSync: mockWriteFileSync, writeFileSync: mockWriteFileSync,
})) }))
// Mocks for tmp module to control temporary file creation and cleanup // Mocks for tmp module to control temporary file creation
const mockRemoveCallback = vi.fn()
const mockFileSync = vi.fn().mockReturnValue({ const mockFileSync = vi.fn().mockReturnValue({
name: '/secure/temp/dir/modelResponse-abc123.txt', name: '/secure/temp/dir/modelResponse-abc123.txt',
removeCallback: mockRemoveCallback,
}) })
const mockSetGracefulCleanup = vi.fn()
vi.mock('tmp', () => ({ vi.mock('tmp', () => ({
fileSync: mockFileSync, fileSync: mockFileSync,
setGracefulCleanup: mockSetGracefulCleanup,
})) }))
// Mock MCP and inference modules // Mock MCP and inference modules
@@ -283,7 +279,7 @@ describe('main.ts', () => {
expect(mockProcessExit).toHaveBeenCalledWith(1) expect(mockProcessExit).toHaveBeenCalledWith(1)
}) })
it('creates secure temporary files with proper cleanup', async () => { it('creates temporary files that persist for downstream steps', async () => {
mockInputs({ mockInputs({
prompt: 'Test prompt', prompt: 'Test prompt',
'system-prompt': 'You are a test assistant.', 'system-prompt': 'You are a test assistant.',
@@ -291,34 +287,16 @@ describe('main.ts', () => {
await run() await run()
expect(mockSetGracefulCleanup).toHaveBeenCalledOnce() // Verify temp file is created with keep: true so it persists
expect(mockFileSync).toHaveBeenCalledWith({ expect(mockFileSync).toHaveBeenCalledWith({
prefix: 'modelResponse-', prefix: 'modelResponse-',
postfix: '.txt', postfix: '.txt',
keep: true,
}) })
expect(core.setOutput).toHaveBeenNthCalledWith(2, 'response-file', '/secure/temp/dir/modelResponse-abc123.txt') expect(core.setOutput).toHaveBeenNthCalledWith(2, 'response-file', '/secure/temp/dir/modelResponse-abc123.txt')
expect(mockWriteFileSync).toHaveBeenCalledWith('/secure/temp/dir/modelResponse-abc123.txt', 'Hello, user!', 'utf-8') expect(mockWriteFileSync).toHaveBeenCalledWith('/secure/temp/dir/modelResponse-abc123.txt', 'Hello, user!', 'utf-8')
expect(mockRemoveCallback).toHaveBeenCalledOnce()
expect(mockProcessExit).toHaveBeenCalledWith(0) expect(mockProcessExit).toHaveBeenCalledWith(0)
}) })
it('handles cleanup errors gracefully', async () => {
mockRemoveCallback.mockImplementationOnce(() => {
throw new Error('Cleanup failed')
})
mockInputs({
prompt: 'Test prompt',
'system-prompt': 'You are a test assistant.',
})
await run()
expect(mockRemoveCallback).toHaveBeenCalledOnce()
expect(core.warning).toHaveBeenCalledWith('Failed to cleanup temporary file: Error: Cleanup failed')
expect(mockProcessExit).toHaveBeenCalledWith(0)
})
}) })

22
dist/index.js generated vendored
View File

@@ -52627,9 +52627,6 @@ function isPromptYamlFile(filePath) {
* @returns Resolves when the action is complete. * @returns Resolves when the action is complete.
*/ */
async function run() { async function run() {
let responseFile = null;
// Set up graceful cleanup for temporary files on process exit
tmpExports.setGracefulCleanup();
try { try {
const promptFilePath = coreExports.getInput('prompt-file'); const promptFilePath = coreExports.getInput('prompt-file');
const inputVariables = coreExports.getInput('input'); const inputVariables = coreExports.getInput('input');
@@ -52685,10 +52682,13 @@ async function run() {
modelResponse = await simpleInference(inferenceRequest); modelResponse = await simpleInference(inferenceRequest);
} }
coreExports.setOutput('response', modelResponse || ''); coreExports.setOutput('response', modelResponse || '');
// Create a secure temporary file instead of using the temp directory directly // Create a temporary file for the response that persists for downstream steps.
responseFile = tmpExports.fileSync({ // We use keep: true to prevent automatic cleanup - the file will be cleaned up
// by the runner when the job completes.
const responseFile = tmpExports.fileSync({
prefix: 'modelResponse-', prefix: 'modelResponse-',
postfix: '.txt', postfix: '.txt',
keep: true,
}); });
coreExports.setOutput('response-file', responseFile.name); coreExports.setOutput('response-file', responseFile.name);
if (modelResponse && modelResponse !== '') { if (modelResponse && modelResponse !== '') {
@@ -52705,18 +52705,6 @@ async function run() {
// Force exit to prevent hanging on open connections // Force exit to prevent hanging on open connections
process.exit(1); process.exit(1);
} }
finally {
// Explicit cleanup of temporary file if it was created
if (responseFile) {
try {
responseFile.removeCallback();
}
catch (cleanupError) {
// Log cleanup errors but don't fail the action
coreExports.warning(`Failed to cleanup temporary file: ${cleanupError}`);
}
}
}
// Force exit to prevent hanging on open connections // Force exit to prevent hanging on open connections
process.exit(0); process.exit(0);
} }

2
dist/index.js.map generated vendored

File diff suppressed because one or more lines are too long

View File

@@ -19,7 +19,7 @@ const compat = new FlatCompat({
export default [ export default [
{ {
ignores: ['**/coverage', '**/dist', '**/linter', '**/node_modules'], ignores: ['**/coverage', '**/dist', '**/linter', '**/node_modules', 'script/**'],
}, },
...compat.extends( ...compat.extends(
'eslint:recommended', 'eslint:recommended',

View File

@@ -0,0 +1,71 @@
#!/usr/bin/env node
/**
* A simple mock OpenAI-compatible inference server for CI testing.
* This returns predictable responses without needing real API credentials.
*/
import http from 'http'
const PORT = process.env.MOCK_SERVER_PORT || 3456
const server = http.createServer((req, res) => {
let body = ''
req.on('data', chunk => {
body += chunk.toString()
})
req.on('end', () => {
console.log(`[Mock Server] ${req.method} ${req.url}`)
// Handle chat completions endpoint
if (req.url === '/chat/completions' && req.method === 'POST') {
const request = JSON.parse(body)
const userMessage = request.messages?.find(m => m.role === 'user')?.content || 'No prompt'
const response = {
id: 'mock-completion-id',
object: 'chat.completion',
created: Date.now(),
model: request.model || 'mock-model',
choices: [
{
index: 0,
message: {
role: 'assistant',
content: `Mock response to: "${userMessage.slice(0, 50)}..."`,
},
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 10,
completion_tokens: 20,
total_tokens: 30,
},
}
res.writeHead(200, {'Content-Type': 'application/json'})
res.end(JSON.stringify(response))
return
}
// Health check endpoint
if (req.url === '/health' || req.url === '/') {
res.writeHead(200, {'Content-Type': 'application/json'})
res.end(JSON.stringify({status: 'ok'}))
return
}
// 404 for unknown routes
res.writeHead(404, {'Content-Type': 'application/json'})
res.end(JSON.stringify({error: 'Not found'}))
})
})
server.listen(PORT, () => {
console.log(`[Mock Server] Listening on http://localhost:${PORT}`)
console.log('[Mock Server] Endpoints:')
console.log(' POST /chat/completions - Mock chat completion')
console.log(' GET /health - Health check')
})

View File

@@ -18,11 +18,6 @@ import {
* @returns Resolves when the action is complete. * @returns Resolves when the action is complete.
*/ */
export async function run(): Promise<void> { export async function run(): Promise<void> {
let responseFile: tmp.FileResult | null = null
// Set up graceful cleanup for temporary files on process exit
tmp.setGracefulCleanup()
try { try {
const promptFilePath = core.getInput('prompt-file') const promptFilePath = core.getInput('prompt-file')
const inputVariables = core.getInput('input') const inputVariables = core.getInput('input')
@@ -102,10 +97,13 @@ export async function run(): Promise<void> {
core.setOutput('response', modelResponse || '') core.setOutput('response', modelResponse || '')
// Create a secure temporary file instead of using the temp directory directly // Create a temporary file for the response that persists for downstream steps.
responseFile = tmp.fileSync({ // We use keep: true to prevent automatic cleanup - the file will be cleaned up
// by the runner when the job completes.
const responseFile = tmp.fileSync({
prefix: 'modelResponse-', prefix: 'modelResponse-',
postfix: '.txt', postfix: '.txt',
keep: true,
}) })
core.setOutput('response-file', responseFile.name) core.setOutput('response-file', responseFile.name)
@@ -121,16 +119,6 @@ export async function run(): Promise<void> {
} }
// Force exit to prevent hanging on open connections // Force exit to prevent hanging on open connections
process.exit(1) process.exit(1)
} finally {
// Explicit cleanup of temporary file if it was created
if (responseFile) {
try {
responseFile.removeCallback()
} catch (cleanupError) {
// Log cleanup errors but don't fail the action
core.warning(`Failed to cleanup temporary file: ${cleanupError}`)
}
}
} }
// Force exit to prevent hanging on open connections // Force exit to prevent hanging on open connections