Merge pull request #15 from actions/aiqiaoy/read-prompt-from-file

read prompt from file and print output to file
This commit is contained in:
Aiqiao Yan
2025-04-17 17:11:26 -04:00
committed by GitHub
9 changed files with 196 additions and 43 deletions

View File

@@ -67,3 +67,28 @@ jobs:
- name: Print Output
id: output
run: echo "${{ steps.test-action.outputs.response }}"
test-action-prompt-file:
name: GitHub Actions Test with Prompt File
runs-on: ubuntu-latest
steps:
- name: Checkout
id: checkout
uses: actions/checkout@v4
- name: Create Prompt File
run: echo "hello" > prompt.txt
- name: Test Local Action with Prompt File
id: test-action-prompt-file
uses: ./
with:
prompt-file: prompt.txt
env:
GITHUB_TOKEN: ${{ github.token }}
- name: Print Output
run: |
echo "Response saved to: ${{ steps.test-action-prompt-file.outputs.response-file }}"
cat "${{ steps.test-action-prompt-file.outputs.response-file }}"

View File

@@ -46,7 +46,7 @@ avoid having to include the `node_modules/` directory in the repository.
1. Make your change, add tests, and make sure the tests still pass:
`npm run test`
1. Make sure your code is correctly formatted: `npm run format`
1. Update `dist/index.js` using `npm run build`. This creates a single
1. Update `dist/index.js` using `npm run bundle`. This creates a single
JavaScript file that is used as an entrypoint for the action
1. Push to your fork and [submit a pull request][pr]
1. Pat yourself on the back and wait for your pull request to be reviewed and

View File

@@ -34,6 +34,37 @@ jobs:
run: echo "${{ steps.inference.outputs.response }}"
```
### Using a prompt file
You can also provide a prompt file instead of an inline prompt:
```yaml
steps:
- name: Run AI Inference with Prompt File
id: inference
uses: actions/ai-inference@v1
with:
prompt-file: './path/to/prompt.txt'
```
### Read output from file instead of output
This can be useful when model response exceeds actions output limit
```yaml
steps:
- name: Test Local Action
id: inference
uses: actions/ai-inference@v1
with:
prompt: 'Hello!'
- name: Use Response File
run: |
echo "Response saved to: ${{ steps.inference.outputs.response-file }}"
cat "${{ steps.inference.outputs.response-file }}"
```
## Inputs
Various inputs are defined in [`action.yml`](action.yml) to let you configure
@@ -43,6 +74,7 @@ the action:
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ |
| `token` | Token to use for inference. Typically the GITHUB_TOKEN secret | `github.token` |
| `prompt` | The prompt to send to the model | N/A |
| `prompt-file` | Path to a file containing the prompt. If both `prompt` and `prompt-file` are provided, `prompt-file` takes precedence | `""` |
| `system-prompt` | The system prompt to send to the model | `""` |
| `model` | The model to use for inference. Must be available in the [GitHub Models](https://github.com/marketplace?type=models) catalog | `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` |
@@ -52,9 +84,10 @@ the action:
The AI inference action provides the following outputs:
| Name | Description |
| ---------- | --------------------------- |
| `response` | The response from the model |
| Name | Description |
| --------------- | ----------------------------------------------------------------------- |
| `response` | The response from the model |
| `response-file` | The file path where the response is saved (useful for larger responses) |
## Required Permissions

View File

@@ -7,7 +7,6 @@
*/
import { jest } from '@jest/globals'
import * as core from '../__fixtures__/core.js'
const mockPost = jest.fn().mockImplementation(() => ({
body: {
choices: [
@@ -29,6 +28,14 @@ jest.unstable_mockModule('@azure-rest/ai-inference', () => ({
isUnexpected: jest.fn(() => false)
}))
const mockExistsSync = jest.fn().mockReturnValue(true)
const mockReadFileSync = jest.fn().mockReturnValue('Hello, AI!')
jest.unstable_mockModule('fs', () => ({
existsSync: mockExistsSync,
readFileSync: mockReadFileSync
}))
jest.unstable_mockModule('@actions/core', () => core)
// The module being tested should be imported dynamically. This ensures that the
@@ -36,7 +43,7 @@ jest.unstable_mockModule('@actions/core', () => core)
const { run } = await import('../src/main.js')
describe('main.ts', () => {
beforeEach(() => {
it('Sets the response output', async () => {
// Set the action's inputs as return values from core.getInput().
core.getInput.mockImplementation((name) => {
if (name === 'prompt') return 'Hello, AI!'
@@ -44,13 +51,7 @@ describe('main.ts', () => {
if (name === 'model_name') return 'gpt-4o'
return ''
})
})
afterEach(() => {
jest.resetAllMocks()
})
it('Sets the response output', async () => {
await run()
expect(core.setOutput).toHaveBeenNthCalledWith(
@@ -58,15 +59,50 @@ describe('main.ts', () => {
'response',
'Hello, user!'
)
expect(core.setOutput).toHaveBeenNthCalledWith(
2,
'response-file',
expect.stringContaining('modelResponse.txt')
)
})
it('Sets a failed status', async () => {
// Clear the getInput mock and return an empty prompt
core.getInput.mockClear().mockReturnValueOnce('')
// Clear the getInput mock and simulate no prompt or prompt-file input
core.getInput.mockImplementation((name) => {
if (name === 'prompt') return ''
if (name === 'prompt_file') return ''
return ''
})
await run()
// Verify that the action was marked as failed.
expect(core.setFailed).toHaveBeenNthCalledWith(1, 'prompt is not set')
})
it('uses prompt-file', async () => {
const promptFile = 'prompt.txt'
core.getInput.mockImplementation((name) => {
if (name === 'prompt-file') return promptFile
if (name === 'system-prompt') return 'You are a test assistant.'
if (name === 'model-name') return 'gpt-4o'
return ''
})
await run()
expect(mockExistsSync).toHaveBeenCalledWith(promptFile)
expect(mockReadFileSync).toHaveBeenCalledWith(promptFile, 'utf-8')
expect(core.setOutput).toHaveBeenNthCalledWith(
1,
'response',
'Hello, user!'
)
expect(core.setOutput).toHaveBeenNthCalledWith(
2,
'response-file',
expect.stringContaining('modelResponse.txt')
)
})
})

View File

@@ -11,7 +11,11 @@ branding:
inputs:
prompt:
description: The prompt for the model
required: true
required: false
default: ''
prompt-file:
description: Path to a file containing the prompt
required: false
default: ''
model:
description: The model to use
@@ -38,6 +42,8 @@ inputs:
outputs:
response:
description: The response from the model
response-file:
description: The file path where the response is saved
runs:
using: node20

71
dist/index.js generated vendored
View File

@@ -1,7 +1,10 @@
import require$$0 from 'os';
import * as require$$0 from 'os';
import require$$0__default from 'os';
import require$$0$1, { randomUUID as randomUUID$1 } from 'crypto';
import require$$1 from 'fs';
import require$$1$5 from 'path';
import * as fs from 'fs';
import fs__default from 'fs';
import * as require$$1 from 'path';
import require$$1__default from 'path';
import require$$2 from 'http';
import require$$1$1 from 'https';
import require$$0$4 from 'net';
@@ -30,7 +33,7 @@ import require$$6$1 from 'timers';
import * as os from 'node:os';
import { EOL } from 'node:os';
import * as process$1 from 'node:process';
import require$$1$6 from 'tty';
import require$$1$5 from 'tty';
import * as http from 'node:http';
import * as https from 'node:https';
import * as zlib from 'node:zlib';
@@ -120,7 +123,7 @@ function requireCommand () {
};
Object.defineProperty(command, "__esModule", { value: true });
command.issue = command.issueCommand = void 0;
const os = __importStar(require$$0);
const os = __importStar(require$$0__default);
const utils_1 = requireUtils$1();
/**
* Commands
@@ -229,8 +232,8 @@ function requireFileCommand () {
// We use any as a valid input type
/* eslint-disable @typescript-eslint/no-explicit-any */
const crypto = __importStar(require$$0$1);
const fs = __importStar(require$$1);
const os = __importStar(require$$0);
const fs = __importStar(fs__default);
const os = __importStar(require$$0__default);
const utils_1 = requireUtils$1();
function issueFileCommand(command, message) {
const filePath = process.env[`GITHUB_${command}`];
@@ -25206,8 +25209,8 @@ function requireSummary () {
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.summary = exports.markdownSummary = exports.SUMMARY_DOCS_URL = exports.SUMMARY_ENV_VAR = void 0;
const os_1 = require$$0;
const fs_1 = require$$1;
const os_1 = require$$0__default;
const fs_1 = fs__default;
const { access, appendFile, writeFile } = fs_1.promises;
exports.SUMMARY_ENV_VAR = 'GITHUB_STEP_SUMMARY';
exports.SUMMARY_DOCS_URL = 'https://docs.github.com/actions/using-workflows/workflow-commands-for-github-actions#adding-a-job-summary';
@@ -25513,7 +25516,7 @@ function requirePathUtils () {
};
Object.defineProperty(pathUtils, "__esModule", { value: true });
pathUtils.toPlatformPath = pathUtils.toWin32Path = pathUtils.toPosixPath = void 0;
const path = __importStar(require$$1$5);
const path = __importStar(require$$1__default);
/**
* toPosixPath converts the given path to the posix form. On Windows, \\ will be
* replaced with /.
@@ -25599,8 +25602,8 @@ function requireIoUtil () {
var _a;
Object.defineProperty(exports, "__esModule", { value: true });
exports.getCmdPath = exports.tryGetExecutablePath = exports.isRooted = exports.isDirectory = exports.exists = exports.READONLY = exports.UV_FS_O_EXLOCK = exports.IS_WINDOWS = exports.unlink = exports.symlink = exports.stat = exports.rmdir = exports.rm = exports.rename = exports.readlink = exports.readdir = exports.open = exports.mkdir = exports.lstat = exports.copyFile = exports.chmod = void 0;
const fs = __importStar(require$$1);
const path = __importStar(require$$1$5);
const fs = __importStar(fs__default);
const path = __importStar(require$$1__default);
_a = fs.promises
// export const {open} = 'fs'
, exports.chmod = _a.chmod, exports.copyFile = _a.copyFile, exports.lstat = _a.lstat, exports.mkdir = _a.mkdir, exports.open = _a.open, exports.readdir = _a.readdir, exports.readlink = _a.readlink, exports.rename = _a.rename, exports.rm = _a.rm, exports.rmdir = _a.rmdir, exports.stat = _a.stat, exports.symlink = _a.symlink, exports.unlink = _a.unlink;
@@ -25790,7 +25793,7 @@ function requireIo () {
Object.defineProperty(io, "__esModule", { value: true });
io.findInPath = io.which = io.mkdirP = io.rmRF = io.mv = io.cp = void 0;
const assert_1 = require$$0$3;
const path = __importStar(require$$1$5);
const path = __importStar(require$$1__default);
const ioUtil = __importStar(requireIoUtil());
/**
* Copies a file or folder.
@@ -26095,10 +26098,10 @@ function requireToolrunner () {
};
Object.defineProperty(toolrunner, "__esModule", { value: true });
toolrunner.argStringToArray = toolrunner.ToolRunner = void 0;
const os = __importStar(require$$0);
const os = __importStar(require$$0__default);
const events = __importStar(require$$4);
const child = __importStar(require$$2$2);
const path = __importStar(require$$1$5);
const path = __importStar(require$$1__default);
const io = __importStar(requireIo());
const ioUtil = __importStar(requireIoUtil());
const timers_1 = require$$6$1;
@@ -26838,7 +26841,7 @@ function requirePlatform () {
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.getDetails = exports.isLinux = exports.isMacOS = exports.isWindows = exports.arch = exports.platform = void 0;
const os_1 = __importDefault(require$$0);
const os_1 = __importDefault(require$$0__default);
const exec = __importStar(requireExec());
const getWindowsInfo = () => __awaiter(void 0, void 0, void 0, function* () {
const { stdout: version } = yield exec.getExecOutput('powershell -command "(Get-CimInstance -ClassName Win32_OperatingSystem).Version"', undefined, {
@@ -26941,8 +26944,8 @@ function requireCore () {
const command_1 = requireCommand();
const file_command_1 = requireFileCommand();
const utils_1 = requireUtils$1();
const os = __importStar(require$$0);
const path = __importStar(require$$1$5);
const os = __importStar(require$$0__default);
const path = __importStar(require$$1__default);
const oidc_utils_1 = requireOidcUtils();
/**
* The code to exit an action
@@ -29841,8 +29844,8 @@ var hasRequiredSupportsColor;
function requireSupportsColor () {
if (hasRequiredSupportsColor) return supportsColor_1;
hasRequiredSupportsColor = 1;
const os = require$$0;
const tty = require$$1$6;
const os = require$$0__default;
const tty = require$$1$5;
const hasFlag = requireHasFlag();
const {env} = process;
@@ -29988,7 +29991,7 @@ function requireNode () {
if (hasRequiredNode) return node.exports;
hasRequiredNode = 1;
(function (module, exports) {
const tty = require$$1$6;
const tty = require$$1$5;
const util = require$$0$2;
/**
@@ -33548,6 +33551,7 @@ function getPathFromMapKey(mapKey) {
return mapKey.slice(pathStart);
}
const RESPONSE_FILE = 'modelResponse.txt';
/**
* The main function for the action.
*
@@ -33555,8 +33559,19 @@ function getPathFromMapKey(mapKey) {
*/
async function run() {
try {
const prompt = coreExports.getInput('prompt');
if (prompt === undefined || prompt === '') {
const promptFile = coreExports.getInput('prompt-file');
const promptString = coreExports.getInput('prompt');
let prompt;
if (promptFile !== undefined && promptFile !== '') {
if (!fs.existsSync(promptFile)) {
throw new Error(`Prompt file not found: ${promptFile}`);
}
prompt = fs.readFileSync(promptFile, 'utf-8');
}
else if (promptString !== undefined && promptString !== '') {
prompt = promptString;
}
else {
throw new Error('prompt is not set');
}
const systemPrompt = coreExports.getInput('system-prompt');
@@ -33595,6 +33610,12 @@ async function run() {
const modelResponse = response.body.choices[0].message.content;
// Set outputs for other workflow steps to use
coreExports.setOutput('response', modelResponse || '');
// Save the response to a file in case the response overflow the output limit
const responseFilePath = require$$1.join(tempDir(), RESPONSE_FILE);
coreExports.setOutput('response-file', responseFilePath);
if (modelResponse && modelResponse !== '') {
fs.writeFileSync(responseFilePath, modelResponse, 'utf-8');
}
}
catch (error) {
// Fail the workflow run if an error occurs
@@ -33606,6 +33627,10 @@ async function run() {
}
}
}
function tempDir() {
const tempDirectory = process.env['RUNNER_TEMP'] || require$$0.tmpdir();
return tempDirectory;
}
/**
* The entrypoint for the action. This file simply imports and runs the action's

2
dist/index.js.map generated vendored

File diff suppressed because one or more lines are too long

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "typescript-action",
"version": "0.0.0",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "typescript-action",
"version": "0.0.0",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"@actions/core": "^1.11.1"

View File

@@ -1,6 +1,11 @@
import * as core from '@actions/core'
import ModelClient, { isUnexpected } from '@azure-rest/ai-inference'
import { AzureKeyCredential } from '@azure/core-auth'
import * as fs from 'fs'
import * as os from 'os'
import * as path from 'path'
const RESPONSE_FILE = 'modelResponse.txt'
/**
* The main function for the action.
@@ -9,8 +14,18 @@ import { AzureKeyCredential } from '@azure/core-auth'
*/
export async function run(): Promise<void> {
try {
const prompt: string = core.getInput('prompt')
if (prompt === undefined || prompt === '') {
const promptFile: string = core.getInput('prompt-file')
const promptString: string = core.getInput('prompt')
let prompt: string
if (promptFile !== undefined && promptFile !== '') {
if (!fs.existsSync(promptFile)) {
throw new Error(`Prompt file not found: ${promptFile}`)
}
prompt = fs.readFileSync(promptFile, 'utf-8')
} else if (promptString !== undefined && promptString !== '') {
prompt = promptString
} else {
throw new Error('prompt is not set')
}
@@ -60,6 +75,14 @@ export async function run(): Promise<void> {
// Set outputs for other workflow steps to use
core.setOutput('response', modelResponse || '')
// Save the response to a file in case the response overflow the output limit
const responseFilePath = path.join(tempDir(), RESPONSE_FILE)
core.setOutput('response-file', responseFilePath)
if (modelResponse && modelResponse !== '') {
fs.writeFileSync(responseFilePath, modelResponse, 'utf-8')
}
} catch (error) {
// Fail the workflow run if an error occurs
if (error instanceof Error) {
@@ -69,3 +92,8 @@ export async function run(): Promise<void> {
}
}
}
function tempDir(): string {
const tempDirectory = process.env['RUNNER_TEMP'] || os.tmpdir()
return tempDirectory
}