From 08585aa064d9fbcec1064f3e8c9fb6b6379ecb8a Mon Sep 17 00:00:00 2001 From: thc1006 <84045975+thc1006@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:24:43 +0800 Subject: [PATCH 1/2] feat: add the /meow command Adds a /meow command that replies with a random cat image (#78). It matches only a standalone /meow line, so /meowvie and prose mentioning /meow do not trigger it. The image is fetched from the cat api with the global fetch (no new dependency) under a request timeout and a small bounded retry, the response is validated, and the url is embedded with the angle bracket markdown form. A cat api outage degrades to a short note rather than failing the workflow; only the github write fails the action. An optional cat-api-key input is sent as the x-api-key header when set. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com> --- __tests__/issueCommentTest/meow.test.ts | 442 ++++++++++++++++++++++++ action.yml | 3 + dist/index.js | 167 +++++++++ docs/commands.md | 27 ++ src/issueComment/handleIssueComment.ts | 4 + src/issueComment/meow.ts | 151 ++++++++ 6 files changed, 794 insertions(+) create mode 100644 __tests__/issueCommentTest/meow.test.ts create mode 100644 src/issueComment/meow.ts diff --git a/__tests__/issueCommentTest/meow.test.ts b/__tests__/issueCommentTest/meow.test.ts new file mode 100644 index 0000000..df67e2b --- /dev/null +++ b/__tests__/issueCommentTest/meow.test.ts @@ -0,0 +1,442 @@ +import * as core from '@actions/core' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' + +import { handleIssueComment } from '../../src/issueComment/handleIssueComment' +import { meowConfig } from '../../src/issueComment/meow' +import * as comments from '../../src/utils/comments' +import issueCommentEvent from '../fixtures/issues/issueCommentEvent.json' +import * as utils from '../testUtils' + +const catApi = 'https://api.thecatapi.com/v1/images/search' + +const server = setupServer() +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + jest.restoreAllMocks() +}) +afterAll(() => server.close()) + +function contextFor(body: string) { + issueCommentEvent.comment.body = body + return new utils.MockContext(issueCommentEvent) +} + +describe('/meow', () => { + let createComment: jest.SpiedFunction + + beforeEach(() => { + utils.setupActionsEnv('/meow') + meowConfig.timeoutMs = 10_000 + meowConfig.retryDelayMs = 0 + meowConfig.maxAttempts = 3 + createComment = jest.spyOn(comments, 'createComment').mockResolvedValue() + }) + + it('comments with a cat image for a standalone /meow', async () => { + server.use( + http.get(catApi, () => + HttpResponse.json([{ url: 'https://cataas.com/cat.jpg' }])), + ) + + await handleIssueComment(contextFor('/meow')) + + expect(createComment).toHaveBeenCalledTimes(1) + expect(createComment).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 1, + '![cat]()', + ) + }) + + it('safely renders a url that contains parentheses', async () => { + const url = 'https://example.test/cats/a_(b).jpg' + server.use(http.get(catApi, () => HttpResponse.json([{ url }]))) + + await handleIssueComment(contextFor('/meow')) + + expect(createComment).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 1, + `![cat](<${url}>)`, + ) + }) + + it.each(['/meowvie', '/meow-debug', '/meow cat', 'please run /meow later'])( + 'does not trigger for %p', + async (body) => { + await handleIssueComment(contextFor(body)) + expect(createComment).not.toHaveBeenCalled() + }, + ) + + it('matches a standalone /meow once in a CRLF comment', async () => { + server.use( + http.get(catApi, () => + HttpResponse.json([{ url: 'https://cataas.com/cat.jpg' }])), + ) + + await handleIssueComment(contextFor('/meow\r\n/something-else')) + + expect(createComment).toHaveBeenCalledTimes(1) + }) + + it('retries a transient status and then succeeds', async () => { + let calls = 0 + server.use( + http.get(catApi, () => { + calls++ + if (calls === 1) + return new HttpResponse(null, { status: 503 }) + return HttpResponse.json([{ url: 'https://cataas.com/cat.jpg' }]) + }), + ) + + await handleIssueComment(contextFor('/meow')) + + expect(calls).toBe(2) + expect(createComment).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 1, + '![cat]()', + ) + }) + + it('degrades to a note when the cat api keeps failing', async () => { + let calls = 0 + server.use( + http.get(catApi, () => { + calls++ + return new HttpResponse(null, { status: 500 }) + }), + ) + const warning = jest.spyOn(core, 'warning').mockImplementation(() => {}) + + await handleIssueComment(contextFor('/meow')) + + expect(calls).toBe(meowConfig.maxAttempts) + expect(warning).toHaveBeenCalled() + expect(createComment).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 1, + 'The cat API is unavailable right now.', + ) + }) + + it('degrades to a note on a network error', async () => { + server.use(http.get(catApi, () => HttpResponse.error())) + jest.spyOn(core, 'warning').mockImplementation(() => {}) + + await handleIssueComment(contextFor('/meow')) + + expect(createComment).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 1, + 'The cat API is unavailable right now.', + ) + }) + + it('degrades to a note when the cat api times out and does not retry', async () => { + meowConfig.timeoutMs = 50 + let calls = 0 + server.use( + http.get(catApi, () => { + calls++ + return new Promise(() => {}) + }), + ) + jest.spyOn(core, 'warning').mockImplementation(() => {}) + + await handleIssueComment(contextFor('/meow')) + + expect(calls).toBe(1) + expect(createComment).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 1, + 'The cat API is unavailable right now.', + ) + }) + + it('degrades to a note when the response has no usable url', async () => { + server.use(http.get(catApi, () => HttpResponse.json([]))) + jest.spyOn(core, 'warning').mockImplementation(() => {}) + + await handleIssueComment(contextFor('/meow')) + + expect(createComment).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 1, + 'The cat API is unavailable right now.', + ) + }) + + it('sends the api key header only when configured', async () => { + process.env['INPUT_CAT-API-KEY'] = 'secret-key' + const setSecret = jest.spyOn(core, 'setSecret').mockImplementation(() => {}) + let seenKey: string | null = null + server.use( + http.get(catApi, ({ request }) => { + seenKey = request.headers.get('x-api-key') + return HttpResponse.json([{ url: 'https://cataas.com/cat.jpg' }]) + }), + ) + + await handleIssueComment(contextFor('/meow')) + + expect(setSecret).toHaveBeenCalledWith('secret-key') + expect(seenKey).toBe('secret-key') + }) + + it('fails the action when the github comment write fails', async () => { + server.use( + http.get(catApi, () => + HttpResponse.json([{ url: 'https://cataas.com/cat.jpg' }])), + ) + createComment.mockRejectedValue(new Error('could not add comment: boom')) + const setFailed = jest.spyOn(core, 'setFailed').mockImplementation(() => {}) + + await handleIssueComment(contextFor('/meow')) + + expect(setFailed).toHaveBeenCalledWith( + expect.stringContaining('could not add comment'), + ) + }) + + it('does not send an api key header when none is configured', async () => { + let sentKey: boolean | null = null + server.use( + http.get(catApi, ({ request }) => { + sentKey = request.headers.has('x-api-key') + return HttpResponse.json([{ url: 'https://cataas.com/cat.jpg' }]) + }), + ) + + await handleIssueComment(contextFor('/meow')) + + expect(sentKey).toBe(false) + }) + + it('does not retry a client error', async () => { + let calls = 0 + server.use( + http.get(catApi, () => { + calls++ + return new HttpResponse(null, { status: 400 }) + }), + ) + jest.spyOn(core, 'warning').mockImplementation(() => {}) + + await handleIssueComment(contextFor('/meow')) + + expect(calls).toBe(1) + expect(createComment).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 1, + 'The cat API is unavailable right now.', + ) + }) + + it('requests a single medium image', async () => { + let requestedUrl: string | null = null + server.use( + http.get(catApi, ({ request }) => { + requestedUrl = request.url + return HttpResponse.json([{ url: 'https://cataas.com/cat.jpg' }]) + }), + ) + + await handleIssueComment(contextFor('/meow')) + + expect(requestedUrl).toContain('limit=1') + expect(requestedUrl).toContain('size=med') + }) + + it('retries a network error up to the attempt limit', async () => { + let calls = 0 + server.use( + http.get(catApi, () => { + calls++ + return HttpResponse.error() + }), + ) + jest.spyOn(core, 'warning').mockImplementation(() => {}) + + await handleIssueComment(contextFor('/meow')) + + expect(calls).toBe(meowConfig.maxAttempts) + }) + + it('degrades to a note for a non-https image url', async () => { + server.use( + http.get(catApi, () => + HttpResponse.json([{ url: 'http://cataas.com/cat.jpg' }])), + ) + jest.spyOn(core, 'warning').mockImplementation(() => {}) + + await handleIssueComment(contextFor('/meow')) + + expect(createComment).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 1, + 'The cat API is unavailable right now.', + ) + }) + + it('degrades to a note for a url that embeds credentials', async () => { + server.use( + http.get(catApi, () => + HttpResponse.json([{ url: 'https://user:pass@cataas.com/cat.jpg' }])), + ) + jest.spyOn(core, 'warning').mockImplementation(() => {}) + + await handleIssueComment(contextFor('/meow')) + + expect(createComment).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 1, + 'The cat API is unavailable right now.', + ) + }) + + it('does not follow a redirect or forward the key', async () => { + process.env['INPUT_CAT-API-KEY'] = 'secret-key' + // the redirect target is intentionally not mocked: onUnhandledRequest error + // fails the test if the request is ever followed there + server.use( + http.get(catApi, () => + new HttpResponse(null, { + status: 302, + headers: { location: 'https://redirect.example/cat' }, + })), + ) + jest.spyOn(core, 'warning').mockImplementation(() => {}) + + await handleIssueComment(contextFor('/meow')) + + expect(createComment).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 1, + 'The cat API is unavailable right now.', + ) + }) + + it('degrades to a note when the first item has no url', async () => { + server.use(http.get(catApi, () => HttpResponse.json([{}]))) + jest.spyOn(core, 'warning').mockImplementation(() => {}) + + await handleIssueComment(contextFor('/meow')) + + expect(createComment).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 1, + 'The cat API is unavailable right now.', + ) + }) + + it('degrades to a note when the url does not parse', async () => { + server.use( + http.get(catApi, () => HttpResponse.json([{ url: 'not a url' }])), + ) + jest.spyOn(core, 'warning').mockImplementation(() => {}) + + await handleIssueComment(contextFor('/meow')) + + expect(createComment).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 1, + 'The cat API is unavailable right now.', + ) + }) + + it('degrades to a note for an excessively long url', async () => { + const longUrl = `https://cataas.com/${'a'.repeat(5000)}.jpg` + server.use(http.get(catApi, () => HttpResponse.json([{ url: longUrl }]))) + jest.spyOn(core, 'warning').mockImplementation(() => {}) + + await handleIssueComment(contextFor('/meow')) + + expect(createComment).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 1, + 'The cat API is unavailable right now.', + ) + }) + + it('does not retry a rate limit', async () => { + let calls = 0 + server.use( + http.get(catApi, () => { + calls++ + return new HttpResponse(null, { status: 429 }) + }), + ) + jest.spyOn(core, 'warning').mockImplementation(() => {}) + + await handleIssueComment(contextFor('/meow')) + + expect(calls).toBe(1) + expect(createComment).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 1, + 'The cat API is unavailable right now.', + ) + }) + + it('cancels the response body before retrying', async () => { + let cancelled = false + const stream = new ReadableStream({ + cancel() { + cancelled = true + }, + }) + jest + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(new Response(stream, { status: 503 })) + .mockResolvedValueOnce( + Response.json([{ url: 'https://cataas.com/cat.jpg' }]), + ) + + await handleIssueComment(contextFor('/meow')) + + expect(cancelled).toBe(true) + expect(createComment).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 1, + '![cat]()', + ) + }) + + it('cancels the response body for a non-retryable status', async () => { + let cancelled = false + const stream = new ReadableStream({ + cancel() { + cancelled = true + }, + }) + jest + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(stream, { status: 400 })) + jest.spyOn(core, 'warning').mockImplementation(() => {}) + + await handleIssueComment(contextFor('/meow')) + + expect(cancelled).toBe(true) + }) +}) diff --git a/action.yml b/action.yml index a4d4fbd..adabb20 100644 --- a/action.yml +++ b/action.yml @@ -14,6 +14,9 @@ inputs: merge-method: description: "Strategy for Prow-github-actions to take when merging a pull request using the lgtm cron-job. Can be 'squash', 'rebase', or 'merge'. Defaults to 'merge'" required: false + cat-api-key: + description: 'Optional API key for the /meow image provider (https://thecatapi.com), sent as the x-api-key header. Provide it from a repository secret. The action registers it for runner masking before use and never intentionally includes it in request URLs or GitHub comments.' + required: false branding: color: blue icon: anchor diff --git a/dist/index.js b/dist/index.js index b6fbb94..766192f 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1027,6 +1027,7 @@ const assign_1 = __nccwpck_require__(7752); const cc_1 = __nccwpck_require__(423); const close_1 = __nccwpck_require__(6273); const lock_1 = __nccwpck_require__(8886); +const meow_1 = __nccwpck_require__(8841); const milestone_1 = __nccwpck_require__(2771); const reopen_1 = __nccwpck_require__(1328); const retitle_1 = __nccwpck_require__(7068); @@ -1080,6 +1081,8 @@ async function handleIssueComment(context = github.context) { return await (0, reopen_1.reopen)(context).catch(normalizeError); case '/milestone': return await (0, milestone_1.milestone)(context).catch(normalizeError); + case '/meow': + return await (0, meow_1.meow)(context).catch(normalizeError); case '': return new Error(`please provide a list of space delimited commands / jobs to run. None found`); default: @@ -1259,6 +1262,170 @@ async function lock(context = github.context) { } +/***/ }), + +/***/ 8841: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.meowConfig = void 0; +exports.meow = meow; +const core = __importStar(__nccwpck_require__(7484)); +const github = __importStar(__nccwpck_require__(3228)); +const rest_1 = __nccwpck_require__(5772); +const comments_1 = __nccwpck_require__(2666); +const catApi = 'https://api.thecatapi.com/v1/images/search?limit=1&size=med'; +// a line of exactly /meow, not /meowvie or a mention +const meowCommand = /^[\t ]*\/meow[\t ]*$/m; +// bounded so a slow provider cannot stall the runner; exported so tests can shrink the waits +exports.meowConfig = { + timeoutMs: 5_000, + maxAttempts: 3, + retryDelayMs: 500, +}; +/** + * /meow replies with a random cat image + * + * @param context - the github actions event context + */ +async function meow(context = github.context) { + if (!hasMeowCommand(context.payload.comment?.body)) + return; + const token = core.getInput('github-token', { required: true }); + const octokit = new rest_1.Octokit({ auth: token }); + const issueNumber = context.payload.issue?.number; + if (issueNumber === undefined) { + throw new Error(`github context payload missing issue number: ${context.payload}`); + } + // a provider outage degrades to a note; only the github write can fail the action + let body; + try { + const image = await fetchCatImage(); + body = `![cat](<${image.href}>)`; + } + catch (error) { + core.warning(`Could not fetch a cat image: ${error}`); + body = 'The cat API is unavailable right now.'; + } + await (0, comments_1.createComment)(octokit, context, issueNumber, body); +} +// hasMeowCommand reports whether the body has a standalone /meow line +function hasMeowCommand(body) { + return typeof body === 'string' && meowCommand.test(body); +} +async function fetchCatImage() { + const headers = { accept: 'application/json' }; + const key = core.getInput('cat-api-key', { required: false }); + if (key !== '') { + core.setSecret(key); + headers['x-api-key'] = key; + } + let lastError = new Error('cat api was not reached'); + for (let attempt = 1; attempt <= exports.meowConfig.maxAttempts; attempt++) { + if (attempt > 1) + await delay(exports.meowConfig.retryDelayMs); + try { + const response = await fetch(catApi, { + headers, + // refuse redirects so the api key cannot leak cross-origin + redirect: 'manual', + signal: AbortSignal.timeout(exports.meowConfig.timeoutMs), + }); + if (response.ok && response.type !== 'opaqueredirect') + return parseCatImage(await response.json()); + // undici holds the socket until the body is read + await cancelResponseBody(response); + // retry a 5xx; a 429, a redirect, and other 4xx fall back + const error = new Error(`cat api responded with ${response.status || 'a redirect'}`); + if (response.status >= 500) { + lastError = error; + continue; + } + throw error; + } + catch (error) { + // retry a network failure; a timeout has spent its deadline + if (!(error instanceof TypeError)) + throw error; + lastError = error; + } + } + throw lastError; +} +async function cancelResponseBody(response) { + try { + await response.body?.cancel(); + } + catch (error) { + core.debug(`could not cancel cat api response body: ${error}`); + } +} +// parseCatImage validates the response and returns a usable https url +function parseCatImage(value) { + const images = value; + if (!Array.isArray(images) || images.length === 0) + throw new Error('cat api returned no images'); + const url = images[0]?.url; + if (typeof url !== 'string') + throw new Error('cat api returned an invalid image record'); + if (url.length > 4096) + throw new Error('cat api returned an excessively long image url'); + let image; + try { + image = new URL(url); + } + catch { + throw new Error('cat api returned an invalid image url'); + } + if (image.protocol !== 'https:' + || image.username !== '' + || image.password !== '') { + throw new Error('cat api returned an unusable image url'); + } + return image; +} +function delay(ms) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + + /***/ }), /***/ 2771: diff --git a/docs/commands.md b/docs/commands.md index 7d953e1..d9c51f3 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -13,6 +13,7 @@ Commands | Policy | Description `/lock [resolved / off-topic / too-heated / spam]` | Collaborators | locks the issue / PR with the specified reason `/milestone milestone-name` | Collaborators | Adds issue / PR to an existing milestone `/retitle some new title` | Collaborators | Renames the issue / PR +`/meow` | anyone | replies with a random cat image from [the cat API](https://thecatapi.com) Label Commands | Policy | Description --- | --- | --- @@ -25,6 +26,32 @@ Label Commands | Policy | Description `/priority [label1 label2 ...]` | anyone | adds a priority/<> label(s) if it's defined in [the `.prowlabels.yaml` file](./automatic-merging.md) `/remove [label1 label2 ...]` | Collaborators | removes a specified label(s) on an issue / PR +## Enabling `/meow` + +`/meow` is opt in and calls a third party image provider ([the cat API](https://thecatapi.com)). Anyone who can comment on the repository can invoke it and consume the configured API quota, and the command must be on its own line. It is best effort: if the provider is unavailable it leaves a short note instead of failing the workflow. + +```yaml +permissions: + issues: write + pull-requests: write + +jobs: + prow: + runs-on: ubuntu-latest + steps: + - uses: cncf/prow-github-actions@v2 + with: + prow-commands: /meow + github-token: '${{ secrets.GITHUB_TOKEN }}' + cat-api-key: '${{ secrets.CAT_API_KEY }}' +``` + +The workflow token needs `issues: write` or `pull-requests: write` to post the response, because a new repository's `GITHUB_TOKEN` often defaults to read only. Grant both when the same workflow handles comments on issues and pull requests. + +The `@v2` ref is a floating tag the maintainers move per release. `/meow` ships in the next `v2.x` release, so this example applies once that release is published and the `v2` tag points at it; until then, pin to the release that includes it. + +The API key is optional; unauthenticated access is best effort and may be rate limited by the provider. When set, it is provided from a repository secret and registered for runner masking before use, and is never intentionally included in the request URL or a GitHub comment. + ## OWNERS A simplified version of [Prow's OWNERS](https://go.k8s.io/owners) file is supported. When an OWNERS file is present at the root of the repository, it is used to authorize the /lgtm and /approve commands. See an [example][owners-example] using an OWNERS file. diff --git a/src/issueComment/handleIssueComment.ts b/src/issueComment/handleIssueComment.ts index bb64241..722f49b 100644 --- a/src/issueComment/handleIssueComment.ts +++ b/src/issueComment/handleIssueComment.ts @@ -14,6 +14,7 @@ import { assign } from './assign' import { cc } from './cc' import { close } from './close' import { lock } from './lock' +import { meow } from './meow' import { milestone } from './milestone' import { reopen } from './reopen' import { retitle } from './retitle' @@ -86,6 +87,9 @@ export async function handleIssueComment(context: Context = github.context): Pro case '/milestone': return await milestone(context).catch(normalizeError) + case '/meow': + return await meow(context).catch(normalizeError) + case '': return new Error( `please provide a list of space delimited commands / jobs to run. None found`, diff --git a/src/issueComment/meow.ts b/src/issueComment/meow.ts new file mode 100644 index 0000000..acb782c --- /dev/null +++ b/src/issueComment/meow.ts @@ -0,0 +1,151 @@ +import type { Context } from '@actions/github/lib/context' +import * as core from '@actions/core' +import * as github from '@actions/github' + +import { Octokit } from '@octokit/rest' + +import { createComment } from '../utils/comments' + +const catApi = 'https://api.thecatapi.com/v1/images/search?limit=1&size=med' + +// a line of exactly /meow, not /meowvie or a mention +const meowCommand = /^[\t ]*\/meow[\t ]*$/m + +// bounded so a slow provider cannot stall the runner; exported so tests can shrink the waits +export const meowConfig = { + timeoutMs: 5_000, + maxAttempts: 3, + retryDelayMs: 500, +} + +/** + * /meow replies with a random cat image + * + * @param context - the github actions event context + */ +export async function meow(context: Context = github.context): Promise { + if (!hasMeowCommand(context.payload.comment?.body)) + return + + const token = core.getInput('github-token', { required: true }) + const octokit = new Octokit({ auth: token }) + + const issueNumber: number | undefined = context.payload.issue?.number + if (issueNumber === undefined) { + throw new Error( + `github context payload missing issue number: ${context.payload}`, + ) + } + + // a provider outage degrades to a note; only the github write can fail the action + let body: string + try { + const image = await fetchCatImage() + body = `![cat](<${image.href}>)` + } + catch (error) { + core.warning(`Could not fetch a cat image: ${error}`) + body = 'The cat API is unavailable right now.' + } + + await createComment(octokit, context, issueNumber, body) +} + +// hasMeowCommand reports whether the body has a standalone /meow line +function hasMeowCommand(body: unknown): boolean { + return typeof body === 'string' && meowCommand.test(body) +} + +async function fetchCatImage(): Promise { + const headers: Record = { accept: 'application/json' } + const key = core.getInput('cat-api-key', { required: false }) + if (key !== '') { + core.setSecret(key) + headers['x-api-key'] = key + } + + let lastError: unknown = new Error('cat api was not reached') + for (let attempt = 1; attempt <= meowConfig.maxAttempts; attempt++) { + if (attempt > 1) + await delay(meowConfig.retryDelayMs) + + try { + const response = await fetch(catApi, { + headers, + // refuse redirects so the api key cannot leak cross-origin + redirect: 'manual', + signal: AbortSignal.timeout(meowConfig.timeoutMs), + }) + + if (response.ok && response.type !== 'opaqueredirect') + return parseCatImage(await response.json()) + + // undici holds the socket until the body is read + await cancelResponseBody(response) + + // retry a 5xx; a 429, a redirect, and other 4xx fall back + const error = new Error( + `cat api responded with ${response.status || 'a redirect'}`, + ) + if (response.status >= 500) { + lastError = error + continue + } + throw error + } + catch (error) { + // retry a network failure; a timeout has spent its deadline + if (!(error instanceof TypeError)) + throw error + lastError = error + } + } + + throw lastError +} + +async function cancelResponseBody(response: Response): Promise { + try { + await response.body?.cancel() + } + catch (error) { + core.debug(`could not cancel cat api response body: ${error}`) + } +} + +// parseCatImage validates the response and returns a usable https url +function parseCatImage(value: unknown): URL { + const images = value as Array<{ url?: unknown }> + if (!Array.isArray(images) || images.length === 0) + throw new Error('cat api returned no images') + + const url = images[0]?.url + if (typeof url !== 'string') + throw new Error('cat api returned an invalid image record') + if (url.length > 4096) + throw new Error('cat api returned an excessively long image url') + + let image: URL + try { + image = new URL(url) + } + catch { + throw new Error('cat api returned an invalid image url') + } + + if ( + image.protocol !== 'https:' + || image.username !== '' + || image.password !== '' + ) { + throw new Error('cat api returned an unusable image url') + } + + return image +} + +function delay(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} From a325a0ec1d6faa99a4fb5a1942f06a199dc833a5 Mon Sep 17 00:00:00 2001 From: Jeffrey Sica Date: Tue, 8 Sep 2026 00:29:09 -0500 Subject: [PATCH 2/2] fix(meow): only render images from the provider's own CDN The response was validated for shape and scheme but any https host was accepted, so a compromised or spoofed API response could embed an arbitrary third-party image. Restrict to cdn2.thecatapi.com and the provider's S3 bucket path (the host the API currently returns). Also drop the release-timing paragraph from the docs, which would go stale, and note that the command should only be enabled where public trigger rights are acceptable. Signed-off-by: Jeffrey Sica --- __tests__/issueCommentTest/meow.test.ts | 65 +++++++++++++++++++------ dist/index.js | 10 ++++ docs/commands.md | 4 +- src/issueComment/meow.ts | 13 +++++ 4 files changed, 74 insertions(+), 18 deletions(-) diff --git a/__tests__/issueCommentTest/meow.test.ts b/__tests__/issueCommentTest/meow.test.ts index df67e2b..9bc7bbd 100644 --- a/__tests__/issueCommentTest/meow.test.ts +++ b/__tests__/issueCommentTest/meow.test.ts @@ -37,7 +37,7 @@ describe('/meow', () => { it('comments with a cat image for a standalone /meow', async () => { server.use( http.get(catApi, () => - HttpResponse.json([{ url: 'https://cataas.com/cat.jpg' }])), + HttpResponse.json([{ url: 'https://cdn2.thecatapi.com/images/cat.jpg' }])), ) await handleIssueComment(contextFor('/meow')) @@ -47,12 +47,12 @@ describe('/meow', () => { expect.anything(), expect.anything(), 1, - '![cat]()', + '![cat]()', ) }) it('safely renders a url that contains parentheses', async () => { - const url = 'https://example.test/cats/a_(b).jpg' + const url = 'https://cdn2.thecatapi.com/images/a_(b).jpg' server.use(http.get(catApi, () => HttpResponse.json([{ url }]))) await handleIssueComment(contextFor('/meow')) @@ -76,7 +76,7 @@ describe('/meow', () => { it('matches a standalone /meow once in a CRLF comment', async () => { server.use( http.get(catApi, () => - HttpResponse.json([{ url: 'https://cataas.com/cat.jpg' }])), + HttpResponse.json([{ url: 'https://cdn2.thecatapi.com/images/cat.jpg' }])), ) await handleIssueComment(contextFor('/meow\r\n/something-else')) @@ -91,7 +91,7 @@ describe('/meow', () => { calls++ if (calls === 1) return new HttpResponse(null, { status: 503 }) - return HttpResponse.json([{ url: 'https://cataas.com/cat.jpg' }]) + return HttpResponse.json([{ url: 'https://cdn2.thecatapi.com/images/cat.jpg' }]) }), ) @@ -102,7 +102,7 @@ describe('/meow', () => { expect.anything(), expect.anything(), 1, - '![cat]()', + '![cat]()', ) }) @@ -185,7 +185,7 @@ describe('/meow', () => { server.use( http.get(catApi, ({ request }) => { seenKey = request.headers.get('x-api-key') - return HttpResponse.json([{ url: 'https://cataas.com/cat.jpg' }]) + return HttpResponse.json([{ url: 'https://cdn2.thecatapi.com/images/cat.jpg' }]) }), ) @@ -198,7 +198,7 @@ describe('/meow', () => { it('fails the action when the github comment write fails', async () => { server.use( http.get(catApi, () => - HttpResponse.json([{ url: 'https://cataas.com/cat.jpg' }])), + HttpResponse.json([{ url: 'https://cdn2.thecatapi.com/images/cat.jpg' }])), ) createComment.mockRejectedValue(new Error('could not add comment: boom')) const setFailed = jest.spyOn(core, 'setFailed').mockImplementation(() => {}) @@ -215,7 +215,7 @@ describe('/meow', () => { server.use( http.get(catApi, ({ request }) => { sentKey = request.headers.has('x-api-key') - return HttpResponse.json([{ url: 'https://cataas.com/cat.jpg' }]) + return HttpResponse.json([{ url: 'https://cdn2.thecatapi.com/images/cat.jpg' }]) }), ) @@ -250,7 +250,7 @@ describe('/meow', () => { server.use( http.get(catApi, ({ request }) => { requestedUrl = request.url - return HttpResponse.json([{ url: 'https://cataas.com/cat.jpg' }]) + return HttpResponse.json([{ url: 'https://cdn2.thecatapi.com/images/cat.jpg' }]) }), ) @@ -278,7 +278,7 @@ describe('/meow', () => { it('degrades to a note for a non-https image url', async () => { server.use( http.get(catApi, () => - HttpResponse.json([{ url: 'http://cataas.com/cat.jpg' }])), + HttpResponse.json([{ url: 'http://cdn2.thecatapi.com/images/cat.jpg' }])), ) jest.spyOn(core, 'warning').mockImplementation(() => {}) @@ -295,7 +295,7 @@ describe('/meow', () => { it('degrades to a note for a url that embeds credentials', async () => { server.use( http.get(catApi, () => - HttpResponse.json([{ url: 'https://user:pass@cataas.com/cat.jpg' }])), + HttpResponse.json([{ url: 'https://user:pass@cdn2.thecatapi.com/images/cat.jpg' }])), ) jest.spyOn(core, 'warning').mockImplementation(() => {}) @@ -309,6 +309,41 @@ describe('/meow', () => { ) }) + it('renders an image served from the provider s3 bucket', async () => { + const url = 'https://s3.us-west-2.amazonaws.com/cdn2.thecatapi.com/images/cat.jpg' + server.use(http.get(catApi, () => HttpResponse.json([{ url }]))) + + await handleIssueComment(contextFor('/meow')) + + expect(createComment).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 1, + `![cat](<${url}>)`, + ) + }) + + it.each([ + 'https://cataas.com/cat.jpg', + 'https://s3.us-west-2.amazonaws.com/other-bucket/cat.jpg', + 'https://cdn2.thecatapi.com.evil.example/cat.jpg', + ])('degrades to a note for an image from an unexpected host %p', async (url) => { + server.use(http.get(catApi, () => HttpResponse.json([{ url }]))) + const warning = jest.spyOn(core, 'warning').mockImplementation(() => {}) + + await handleIssueComment(contextFor('/meow')) + + expect(warning).toHaveBeenCalledWith( + expect.stringContaining('unexpected host'), + ) + expect(createComment).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 1, + 'The cat API is unavailable right now.', + ) + }) + it('does not follow a redirect or forward the key', async () => { process.env['INPUT_CAT-API-KEY'] = 'secret-key' // the redirect target is intentionally not mocked: onUnhandledRequest error @@ -363,7 +398,7 @@ describe('/meow', () => { }) it('degrades to a note for an excessively long url', async () => { - const longUrl = `https://cataas.com/${'a'.repeat(5000)}.jpg` + const longUrl = `https://cdn2.thecatapi.com/images/${'a'.repeat(5000)}.jpg` server.use(http.get(catApi, () => HttpResponse.json([{ url: longUrl }]))) jest.spyOn(core, 'warning').mockImplementation(() => {}) @@ -409,7 +444,7 @@ describe('/meow', () => { .spyOn(globalThis, 'fetch') .mockResolvedValueOnce(new Response(stream, { status: 503 })) .mockResolvedValueOnce( - Response.json([{ url: 'https://cataas.com/cat.jpg' }]), + Response.json([{ url: 'https://cdn2.thecatapi.com/images/cat.jpg' }]), ) await handleIssueComment(contextFor('/meow')) @@ -419,7 +454,7 @@ describe('/meow', () => { expect.anything(), expect.anything(), 1, - '![cat]()', + '![cat]()', ) }) diff --git a/dist/index.js b/dist/index.js index 766192f..99e83ac 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1417,8 +1417,18 @@ function parseCatImage(value) { || image.password !== '') { throw new Error('cat api returned an unusable image url'); } + if (!isTrustedImageSource(image)) + throw new Error('cat api returned an image from an unexpected host'); return image; } +// only render images the provider actually serves; a compromised or spoofed +// api response must not be able to embed an arbitrary third-party url +function isTrustedImageSource(image) { + if (image.hostname === 'cdn2.thecatapi.com') + return true; + return image.hostname === 's3.us-west-2.amazonaws.com' + && image.pathname.startsWith('/cdn2.thecatapi.com/'); +} function delay(ms) { return new Promise((resolve) => { setTimeout(resolve, ms); diff --git a/docs/commands.md b/docs/commands.md index d9c51f3..3649e47 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -28,7 +28,7 @@ Label Commands | Policy | Description ## Enabling `/meow` -`/meow` is opt in and calls a third party image provider ([the cat API](https://thecatapi.com)). Anyone who can comment on the repository can invoke it and consume the configured API quota, and the command must be on its own line. It is best effort: if the provider is unavailable it leaves a short note instead of failing the workflow. +`/meow` is opt in and calls a third party image provider ([the cat API](https://thecatapi.com)). Anyone who can comment on the repository can invoke it and consume the configured API quota, so enable it only on repositories where that is acceptable. The command must be on its own line. It is best effort: if the provider is unavailable it leaves a short note instead of failing the workflow. Only images served from the provider's own CDN are rendered. ```yaml permissions: @@ -48,8 +48,6 @@ jobs: The workflow token needs `issues: write` or `pull-requests: write` to post the response, because a new repository's `GITHUB_TOKEN` often defaults to read only. Grant both when the same workflow handles comments on issues and pull requests. -The `@v2` ref is a floating tag the maintainers move per release. `/meow` ships in the next `v2.x` release, so this example applies once that release is published and the `v2` tag points at it; until then, pin to the release that includes it. - The API key is optional; unauthenticated access is best effort and may be rate limited by the provider. When set, it is provided from a repository secret and registered for runner masking before use, and is never intentionally included in the request URL or a GitHub comment. ## OWNERS diff --git a/src/issueComment/meow.ts b/src/issueComment/meow.ts index acb782c..4ff7460 100644 --- a/src/issueComment/meow.ts +++ b/src/issueComment/meow.ts @@ -141,9 +141,22 @@ function parseCatImage(value: unknown): URL { throw new Error('cat api returned an unusable image url') } + if (!isTrustedImageSource(image)) + throw new Error('cat api returned an image from an unexpected host') + return image } +// only render images the provider actually serves; a compromised or spoofed +// api response must not be able to embed an arbitrary third-party url +function isTrustedImageSource(image: URL): boolean { + if (image.hostname === 'cdn2.thecatapi.com') + return true + + return image.hostname === 's3.us-west-2.amazonaws.com' + && image.pathname.startsWith('/cdn2.thecatapi.com/') +} + function delay(ms: number): Promise { return new Promise((resolve) => { setTimeout(resolve, ms)