diff --git a/.github/actions/ci-success/README.md b/.github/actions/ci-success/README.md new file mode 100644 index 0000000..0d781f8 --- /dev/null +++ b/.github/actions/ci-success/README.md @@ -0,0 +1,51 @@ +# CI Success + +`CI Success` is a first-party promptfoo GitHub Action that rolls up all other checks and legacy status contexts on the current commit into a single required check. + +It is intended for repositories that want exactly one required status check in rulesets while still enforcing: + +- matrix jobs +- checks from other workflows +- legacy commit status contexts + +## Usage + +Add a thin wrapper job to the repository workflow that should publish the required check: + +```yaml +permissions: + contents: read + checks: read + statuses: read + +jobs: + ci-success: + name: CI Success + runs-on: ubuntu-latest + if: always() + steps: + - uses: promptfoo/.github/.github/actions/ci-success@ + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + timeout-seconds: 300 +``` + +If the repository already knows which local jobs must finish before the rollup should start, keep using `needs:` in the wrapper job. That shortens the polling window, but it is not required for correctness. + +## Inputs + +- `github-token`: token used to read checks and statuses +- `check-name`: rollup check name to ignore while polling, defaults to `CI Success` +- `timeout-seconds`: total timeout, defaults to `300` +- `poll-interval-seconds`: delay between polls, defaults to `10` +- `settle-polls`: number of identical all-green polls required before success, defaults to `2` +- `ignore-checks`: newline or comma separated regular expressions for checks or statuses to ignore +- `allowed-conclusions`: newline or comma separated allowed terminal conclusions, defaults to `success,neutral,skipped` +- `require-observed-checks`: require at least one non-self check before success, defaults to `true` + +## Notes + +- On `pull_request` events, the action watches the PR head SHA. +- On other events, it falls back to `GITHUB_SHA`. +- The action observes both GitHub Checks and legacy commit statuses so it works during ruleset migrations. +- Pin the action by full commit SHA in consuming repositories once this action is released. diff --git a/.github/actions/ci-success/action.yml b/.github/actions/ci-success/action.yml new file mode 100644 index 0000000..078d4c5 --- /dev/null +++ b/.github/actions/ci-success/action.yml @@ -0,0 +1,41 @@ +name: CI Success +description: Wait for all other checks and statuses on the current commit to complete successfully. +author: promptfoo +branding: + icon: check-circle + color: green +inputs: + github-token: + description: Token used to read checks and statuses for the current repository. + required: true + check-name: + description: Name of the rollup check itself. Matching checks or statuses are ignored. + required: false + default: CI Success + timeout-seconds: + description: Total time to wait before failing. + required: false + default: "300" + poll-interval-seconds: + description: Delay between polls. + required: false + default: "10" + settle-polls: + description: Number of identical all-green polls required before succeeding. + required: false + default: "2" + ignore-checks: + description: Newline or comma separated regular expressions for checks or statuses to ignore. + required: false + default: "" + allowed-conclusions: + description: Newline or comma separated allowed terminal conclusions or states. + required: false + default: "success,neutral,skipped" + require-observed-checks: + description: Whether at least one non-self check or status must be observed before succeeding. + required: false + default: "true" +runs: + using: node24 + main: main.mjs diff --git a/.github/actions/ci-success/lib/monitor.mjs b/.github/actions/ci-success/lib/monitor.mjs new file mode 100644 index 0000000..3b7f56c --- /dev/null +++ b/.github/actions/ci-success/lib/monitor.mjs @@ -0,0 +1,338 @@ +import fs from 'node:fs'; + +const DEFAULT_ALLOWED_CONCLUSIONS = ['success', 'neutral', 'skipped']; +const DEFAULT_CHECK_NAME = 'CI Success'; +const GITHUB_API_VERSION = '2022-11-28'; +const PER_PAGE = 100; + +export function parseBoolean(value, defaultValue) { + if (value == null || value === '') { + return defaultValue; + } + + const normalized = value.trim().toLowerCase(); + + if (['1', 'true', 'yes', 'on'].includes(normalized)) { + return true; + } + + if (['0', 'false', 'no', 'off'].includes(normalized)) { + return false; + } + + throw new Error(`Invalid boolean value: ${value}`); +} + +export function parsePositiveInteger(value, fieldName) { + const parsed = Number.parseInt(String(value), 10); + + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`${fieldName} must be a positive integer. Received: ${value}`); + } + + return parsed; +} + +export function parseList(value) { + if (!value) { + return []; + } + + return value + .split(/\r?\n|,/u) + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function compileIgnorePatterns(patterns) { + return patterns.map((pattern) => new RegExp(pattern, 'u')); +} + +function getInput(env, name, defaultValue) { + const key = `INPUT_${name.replace(/ /gu, '_').replace(/-/gu, '_').toUpperCase()}`; + const value = env[key]; + return value == null || value === '' ? defaultValue : value; +} + +function loadGitHubEvent(env, fsModule = fs) { + const eventPath = env.GITHUB_EVENT_PATH; + + if (!eventPath) { + return {}; + } + + return JSON.parse(fsModule.readFileSync(eventPath, 'utf8')); +} + +export function determineRepository(env) { + const repository = env.GITHUB_REPOSITORY; + + if (!repository || !repository.includes('/')) { + throw new Error('GITHUB_REPOSITORY must be set to owner/repo.'); + } + + return repository; +} + +export function determineCommitSha(env, event) { + if (event?.pull_request?.head?.sha) { + return event.pull_request.head.sha; + } + + if (env.GITHUB_SHA) { + return env.GITHUB_SHA; + } + + throw new Error('Unable to determine commit SHA from GITHUB_EVENT_PATH or GITHUB_SHA.'); +} + +export function normalizeCheckRun(run) { + return { + id: run.id, + name: run.name, + kind: 'check_run', + status: run.status, + conclusion: run.conclusion, + }; +} + +export function normalizeStatus(status) { + return { + id: status.id, + name: status.context, + kind: 'status', + status: status.state === 'pending' ? 'in_progress' : 'completed', + conclusion: status.state, + }; +} + +function dedupeLatestByName(items) { + const latestByName = new Map(); + + for (const item of items) { + const current = latestByName.get(item.name); + + if (!current || item.id > current.id) { + latestByName.set(item.name, item); + } + } + + return [...latestByName.values()]; +} + +function isIgnored(item, options) { + if (item.name === options.checkName) { + return true; + } + + return options.ignorePatterns.some((pattern) => pattern.test(item.name)); +} + +async function githubRequest({ fetchImpl, repository, token, path, page = 1 }) { + const separator = path.includes('?') ? '&' : '?'; + const response = await fetchImpl(`https://api.github.com/repos/${repository}${path}${separator}per_page=${PER_PAGE}&page=${page}`, { + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token}`, + 'X-GitHub-Api-Version': GITHUB_API_VERSION, + }, + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`GitHub API request failed for ${path}: ${response.status} ${response.statusText} ${body}`.trim()); + } + + return response.json(); +} + +async function listCheckRuns(client, sha) { + const checkRuns = []; + + for (let page = 1; ; page += 1) { + const payload = await githubRequest({ + ...client, + path: `/commits/${sha}/check-runs`, + page, + }); + const pageRuns = payload.check_runs ?? []; + checkRuns.push(...pageRuns); + + if (pageRuns.length < PER_PAGE) { + break; + } + } + + return dedupeLatestByName(checkRuns.map(normalizeCheckRun)); +} + +async function listStatuses(client, sha) { + const statuses = []; + + for (let page = 1; ; page += 1) { + const pageStatuses = await githubRequest({ + ...client, + path: `/commits/${sha}/statuses`, + page, + }); + statuses.push(...pageStatuses); + + if (pageStatuses.length < PER_PAGE) { + break; + } + } + + return dedupeLatestByName(statuses.map(normalizeStatus)); +} + +export async function listObservedChecks(client, sha, options) { + const [checkRuns, statuses] = await Promise.all([listCheckRuns(client, sha), listStatuses(client, sha)]); + + return [...checkRuns, ...statuses] + .filter((item) => !isIgnored(item, options)) + .sort((left, right) => { + if (left.name === right.name) { + return left.kind.localeCompare(right.kind); + } + + return left.name.localeCompare(right.name); + }); +} + +function isSuccessful(item, allowedConclusions) { + return allowedConclusions.has(item.conclusion ?? ''); +} + +export function renderObservation(item) { + return `${item.kind}:${item.name}:${item.status}/${item.conclusion ?? 'null'}`; +} + +export function evaluateObservedChecks(items, options) { + if (items.length === 0 && options.requireObservedChecks) { + return { + outcome: 'waiting', + reason: 'No non-self checks or statuses found yet.', + }; + } + + const failed = items.filter((item) => item.status === 'completed' && !isSuccessful(item, options.allowedConclusions)); + + if (failed.length > 0) { + return { + outcome: 'failure', + reason: `Failing checks detected: ${failed.map(renderObservation).join(', ')}`, + }; + } + + const pending = items.filter((item) => item.status !== 'completed'); + + if (pending.length > 0) { + return { + outcome: 'waiting', + reason: `Waiting on checks: ${pending.map(renderObservation).join(', ')}`, + }; + } + + return { + outcome: 'success', + key: JSON.stringify(items.map((item) => `${item.kind}:${item.name}`)), + reason: `All observed checks passed: ${items.map(renderObservation).join(', ')}`, + }; +} + +export async function waitForSuccess({ + loadObservedChecks, + options, + log = console, + now = () => Date.now(), + sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), +}) { + const deadline = now() + options.timeoutMs; + let stableSuccessPolls = 0; + let previousSuccessKey = null; + + while (now() < deadline) { + const items = await loadObservedChecks(); + log.log(`Observed ${items.length} checks/statuses.`); + + if (items.length > 0) { + log.log(items.map(renderObservation).join(', ')); + } + + const evaluation = evaluateObservedChecks(items, options); + + if (evaluation.outcome === 'failure') { + throw new Error(evaluation.reason); + } + + if (evaluation.outcome === 'waiting') { + stableSuccessPolls = 0; + previousSuccessKey = null; + log.log(evaluation.reason); + await sleep(options.pollIntervalMs); + continue; + } + + if (evaluation.key === previousSuccessKey) { + stableSuccessPolls += 1; + } else { + previousSuccessKey = evaluation.key; + stableSuccessPolls = 1; + } + + if (stableSuccessPolls >= options.settlePolls) { + log.log(evaluation.reason); + return; + } + + log.log(`All checks are green. Waiting for ${options.settlePolls - stableSuccessPolls} more stable poll(s).`); + await sleep(options.pollIntervalMs); + } + + throw new Error('Timed out waiting for checks and statuses to complete.'); +} + +export function buildOptions(env = process.env) { + const githubToken = getInput(env, 'github-token', env.GITHUB_TOKEN); + + if (!githubToken) { + throw new Error('github-token input is required.'); + } + + return { + githubToken, + checkName: getInput(env, 'check-name', DEFAULT_CHECK_NAME), + timeoutMs: parsePositiveInteger(getInput(env, 'timeout-seconds', '300'), 'timeout-seconds') * 1000, + pollIntervalMs: parsePositiveInteger(getInput(env, 'poll-interval-seconds', '10'), 'poll-interval-seconds') * 1000, + settlePolls: parsePositiveInteger(getInput(env, 'settle-polls', '2'), 'settle-polls'), + allowedConclusions: new Set(parseList(getInput(env, 'allowed-conclusions', DEFAULT_ALLOWED_CONCLUSIONS.join(',')))), + ignorePatterns: compileIgnorePatterns(parseList(getInput(env, 'ignore-checks', ''))), + requireObservedChecks: parseBoolean(getInput(env, 'require-observed-checks', 'true'), true), + }; +} + +export async function runFromGithubAction({ + env = process.env, + fetchImpl = fetch, + fsModule = fs, + log = console, +} = {}) { + const options = buildOptions(env); + const repository = determineRepository(env); + const event = loadGitHubEvent(env, fsModule); + const sha = determineCommitSha(env, event); + + log.log(`Waiting for checks on ${repository}@${sha}`); + + const client = { + fetchImpl, + repository, + token: options.githubToken, + }; + + await waitForSuccess({ + loadObservedChecks: () => listObservedChecks(client, sha, options), + options, + log, + }); +} diff --git a/.github/actions/ci-success/main.mjs b/.github/actions/ci-success/main.mjs new file mode 100644 index 0000000..ea3873c --- /dev/null +++ b/.github/actions/ci-success/main.mjs @@ -0,0 +1,9 @@ +import { runFromGithubAction } from './lib/monitor.mjs'; + +try { + await runFromGithubAction(); +} catch (error) { + const message = error instanceof Error ? error.stack ?? error.message : String(error); + console.error(message); + process.exit(1); +} diff --git a/.github/actions/ci-success/test/monitor.test.mjs b/.github/actions/ci-success/test/monitor.test.mjs new file mode 100644 index 0000000..9a52190 --- /dev/null +++ b/.github/actions/ci-success/test/monitor.test.mjs @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + buildOptions, + evaluateObservedChecks, + parseBoolean, + parseList, + waitForSuccess, +} from '../lib/monitor.mjs'; + +function createLogger() { + return { + lines: [], + log(message) { + this.lines.push(message); + }, + }; +} + +test('parseList supports comma and newline separated input', () => { + assert.deepEqual(parseList('success,neutral\nskipped'), ['success', 'neutral', 'skipped']); +}); + +test('parseBoolean handles common truthy and falsey values', () => { + assert.equal(parseBoolean('true', false), true); + assert.equal(parseBoolean('off', true), false); +}); + +test('buildOptions reads inputs from the provided environment', () => { + const options = buildOptions({ + INPUT_GITHUB_TOKEN: 'fallback-token', + INPUT_CHECK_NAME: 'Org CI Success', + INPUT_TIMEOUT_SECONDS: '15', + }); + + assert.equal(options.githubToken, 'fallback-token'); + assert.equal(options.checkName, 'Org CI Success'); + assert.equal(options.timeoutMs, 15_000); +}); + +test('evaluateObservedChecks fails when any completed check fails', () => { + const result = evaluateObservedChecks( + [ + { kind: 'check_run', name: 'test (20)', status: 'completed', conclusion: 'success' }, + { kind: 'check_run', name: 'lint', status: 'completed', conclusion: 'failure' }, + ], + { + allowedConclusions: new Set(['success', 'neutral', 'skipped']), + requireObservedChecks: true, + }, + ); + + assert.equal(result.outcome, 'failure'); + assert.match(result.reason, /lint/u); +}); + +test('waitForSuccess waits for matrix jobs and commit statuses to settle', async () => { + const polls = [ + [ + { kind: 'check_run', name: 'test (20)', status: 'completed', conclusion: 'success' }, + { kind: 'check_run', name: 'test (22)', status: 'in_progress', conclusion: null }, + { kind: 'check_run', name: 'biome', status: 'completed', conclusion: 'success' }, + { kind: 'status', name: 'CodeQL', status: 'in_progress', conclusion: 'pending' }, + ], + [ + { kind: 'check_run', name: 'test (20)', status: 'completed', conclusion: 'success' }, + { kind: 'check_run', name: 'test (22)', status: 'completed', conclusion: 'success' }, + { kind: 'check_run', name: 'test (24)', status: 'completed', conclusion: 'success' }, + { kind: 'check_run', name: 'biome', status: 'completed', conclusion: 'success' }, + { kind: 'status', name: 'CodeQL', status: 'completed', conclusion: 'success' }, + ], + [ + { kind: 'check_run', name: 'test (20)', status: 'completed', conclusion: 'success' }, + { kind: 'check_run', name: 'test (22)', status: 'completed', conclusion: 'success' }, + { kind: 'check_run', name: 'test (24)', status: 'completed', conclusion: 'success' }, + { kind: 'check_run', name: 'biome', status: 'completed', conclusion: 'success' }, + { kind: 'status', name: 'CodeQL', status: 'completed', conclusion: 'success' }, + ], + ]; + const logger = createLogger(); + let index = 0; + + await waitForSuccess({ + loadObservedChecks: async () => polls[Math.min(index++, polls.length - 1)], + options: { + timeoutMs: 5_000, + pollIntervalMs: 1, + settlePolls: 2, + allowedConclusions: new Set(['success', 'neutral', 'skipped']), + requireObservedChecks: true, + }, + log: logger, + now: () => index * 10, + sleep: async () => {}, + }); + + assert.ok(logger.lines.some((line) => line.includes('CodeQL'))); + assert.ok(logger.lines.some((line) => line.includes('test (24)'))); +}); + +test('waitForSuccess times out if no other checks ever appear', async () => { + let nowValue = 0; + + await assert.rejects( + waitForSuccess({ + loadObservedChecks: async () => [], + options: { + timeoutMs: 5, + pollIntervalMs: 1, + settlePolls: 2, + allowedConclusions: new Set(['success', 'neutral', 'skipped']), + requireObservedChecks: true, + }, + log: createLogger(), + now: () => nowValue++, + sleep: async () => {}, + }), + /Timed out/u, + ); +}); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d047a02 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,19 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6.0.2 + - uses: actions/setup-node@v6.3.0 + with: + node-version: 24 + - run: node --test .github/actions/ci-success/test/monitor.test.mjs diff --git a/README.md b/README.md index 5760653..f0ee28e 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,12 @@ Community health files in this repository serve as defaults for all promptfoo re - **LICENSE** - MIT License - _(More community health files coming soon)_ +### Shared GitHub Actions + +This repository also hosts first-party promptfoo GitHub Actions that can be reused across organization repositories: + +- **[.github/actions/ci-success](.github/actions/ci-success/README.md)** - Roll up all other checks and statuses into a single `CI Success` required check + ## Learn More - [GitHub Documentation: Creating a default community health file](https://docs.github.com/en/communities/setting-up-your-project-for-healthy-contributions/creating-a-default-community-health-file)