ci: adopt detect-changes gating, runloopai actions, pnpm 10 workspace security pins, drop ink-big-text #1224
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| all: | |
| description: Run all jobs regardless of changed files | |
| type: boolean | |
| default: false | |
| push: | |
| branches: [main] | |
| pull_request: | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.event_name == 'push' && github.run_id || github.ref }} | |
| cancel-in-progress: true | |
| permissions: | |
| contents: read | |
| env: | |
| FORCE_COLOR: '1' | |
| jobs: | |
| detect-changes: | |
| runs-on: ubuntu-slim | |
| timeout-minutes: 5 | |
| outputs: | |
| format: ${{ steps.result.outputs.format }} | |
| lint: ${{ steps.result.outputs.lint }} | |
| build: ${{ steps.result.outputs.build }} | |
| test: ${{ steps.result.outputs.test }} | |
| steps: | |
| - uses: runloopai/checkout@main | |
| with: | |
| fetch-depth: ${{ github.event_name == 'pull_request' && 1 || 2 }} | |
| - name: Detect changed files | |
| if: ${{ !inputs.all }} | |
| id: changed | |
| uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 | |
| with: | |
| files_yaml: | | |
| ci: | |
| - '.github/workflows/ci.yml' | |
| format: | |
| - 'src/**' | |
| - '.prettierrc' | |
| - '.prettierignore' | |
| - 'package.json' | |
| - 'pnpm-lock.yaml' | |
| lint: | |
| - 'src/**' | |
| - 'eslint.config.js' | |
| - 'tsconfig.json' | |
| - 'tsconfig.test.json' | |
| - 'package.json' | |
| - 'pnpm-lock.yaml' | |
| build: | |
| - 'src/**' | |
| - 'tsconfig.json' | |
| - 'package.json' | |
| - 'pnpm-lock.yaml' | |
| test: | |
| - 'src/**' | |
| - 'tests/**' | |
| - 'jest.config.js' | |
| - 'jest.components.config.js' | |
| - 'tsconfig.json' | |
| - 'tsconfig.test.json' | |
| - 'package.json' | |
| - 'pnpm-lock.yaml' | |
| - name: Detect ci.yml self-changes | |
| if: ${{ !inputs.all && steps.changed.outputs.ci_any_modified == 'true' }} | |
| id: self | |
| uses: runloopai/github-script@main | |
| with: | |
| script: | | |
| // Inlined from changed_lines.js: addedLines + parseDiffHunks + detectCiSelfChanges | |
| const fs = require('fs'); | |
| const HUNK_RE = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/; | |
| function* addedLines(body, includeDeletionMarkers = false) { | |
| let newLine = null; | |
| for (const line of body.split('\n')) { | |
| const m = HUNK_RE.exec(line); | |
| if (m) { | |
| const start = parseInt(m[1], 10); | |
| const count = m[2] !== undefined ? parseInt(m[2], 10) : 1; | |
| if (count === 0) { | |
| if (includeDeletionMarkers) yield start; | |
| newLine = null; | |
| } else { | |
| newLine = start; | |
| } | |
| continue; | |
| } | |
| if (newLine === null) continue; | |
| if (line.startsWith('+')) { | |
| yield newLine++; | |
| } else if (line.startsWith('-')) { | |
| // removed line: don't advance new-file numbering | |
| } else if (!line.startsWith('\\')) { | |
| newLine++; | |
| } | |
| } | |
| } | |
| function parseDiffHunks(diffText) { | |
| const result = {}; | |
| let currentFile = null; | |
| const bodyLines = []; | |
| function flush() { | |
| if (currentFile && bodyLines.length > 0) { | |
| const arr = result[currentFile] ?? (result[currentFile] = []); | |
| for (const n of addedLines(bodyLines.join('\n'), true)) arr.push(n); | |
| } | |
| bodyLines.length = 0; | |
| } | |
| for (const line of diffText.split('\n')) { | |
| if (line.startsWith('diff --git ')) { | |
| flush(); | |
| currentFile = null; | |
| } else if (line.startsWith('+++ b/')) { | |
| flush(); | |
| currentFile = line.slice(6); | |
| } else if (line.startsWith('+++ /dev/null')) { | |
| flush(); | |
| currentFile = null; | |
| } else if (currentFile) { | |
| bodyLines.push(line); | |
| } | |
| } | |
| flush(); | |
| return result; | |
| } | |
| const CI_FILE = '.github/workflows/ci.yml'; | |
| let base; | |
| if (context.eventName === 'push' || context.eventName === 'workflow_dispatch') { | |
| base = 'HEAD~1'; | |
| } else { | |
| base = context.payload.pull_request.base.sha; | |
| const { exitCode, stderr } = await exec.getExecOutput( | |
| 'git', ['fetch', 'origin', base, '--depth=1'], | |
| { ignoreReturnCode: true, silent: true }, | |
| ); | |
| if (exitCode !== 0) { | |
| core.warning(`git fetch of base ${base} failed: ${stderr.trim()} — running all jobs`); | |
| core.setOutput('give_up', 'true'); | |
| return; | |
| } | |
| } | |
| const { stdout: diffOut } = await exec.getExecOutput( | |
| 'git', ['diff', base, 'HEAD', '--unified=0', '--', CI_FILE], | |
| { silent: true }, | |
| ); | |
| const diffLines = new Set(parseDiffHunks(diffOut)[CI_FILE] ?? []); | |
| if (diffLines.size === 0) { | |
| core.notice('ci group triggered but ci.yml unchanged — running all jobs'); | |
| core.setOutput('give_up', 'true'); | |
| return; | |
| } | |
| core.startGroup('ci.yml self-change detection'); | |
| core.info(`Changed lines: ${[...diffLines].sort((a, b) => a - b).join(', ')}`); | |
| const { stdout: yqOut } = await exec.getExecOutput( | |
| 'yq', ['-r', '.jobs | keys | .[]', CI_FILE], | |
| { silent: true }, | |
| ); | |
| const jobKeys = yqOut.trim().split('\n').filter(Boolean); | |
| const checkKeys = jobKeys.filter(k => k !== 'detect-changes'); | |
| const fileLines = fs.readFileSync(CI_FILE, 'utf8').split('\n'); | |
| const totalLines = fileLines.length; | |
| const jobsLine = fileLines.findIndex(l => l.trimEnd() === 'jobs:') + 1; | |
| const jobStarts = {}; | |
| fileLines.forEach((line, i) => { | |
| for (const key of jobKeys) { | |
| if (line.trimEnd() === ` ${key}:`) jobStarts[key] = i + 1; | |
| } | |
| }); | |
| const sortedStarts = Object.entries(jobStarts).sort(([, a], [, b]) => a - b); | |
| const jobRanges = {}; | |
| for (let i = 0; i < sortedStarts.length; i++) { | |
| const [k, start] = sortedStarts[i]; | |
| const end = i + 1 < sortedStarts.length ? sortedStarts[i + 1][1] - 1 : totalLines; | |
| jobRanges[k] = [start, end]; | |
| } | |
| for (const [k, [s, e]] of Object.entries(jobRanges).sort(([, [a]], [, [b]]) => a - b)) { | |
| core.info(` ${k}: lines ${s}–${e}`); | |
| } | |
| const [dcStart, dcEnd] = jobRanges['detect-changes'] ?? [0, 0]; | |
| const topLevel = [...diffLines].some(l => l <= jobsLine || (l >= dcStart && l <= dcEnd)); | |
| let selfKeys; | |
| if (topLevel) { | |
| core.info('Top-level YAML or detect-changes changed — triggering all jobs'); | |
| selfKeys = checkKeys; | |
| } else { | |
| selfKeys = checkKeys.filter(k => { | |
| const [s, e] = jobRanges[k] ?? [0, -1]; | |
| for (const l of diffLines) if (l >= s && l <= e) return true; | |
| return false; | |
| }); | |
| for (const k of selfKeys) core.info(` ${k}: overlapping changes detected`); | |
| } | |
| // No shard jobs in rl-cli — output keys match job keys 1:1 | |
| const jobToOutputKey = {}; | |
| const selfOutputKeys = [...new Set(selfKeys.map(k => jobToOutputKey[k] ?? k))]; | |
| core.setOutput('keys', selfOutputKeys.join(' ')); | |
| if (selfOutputKeys.length > 0) { | |
| core.notice(`ci.yml self-change triggers: ${selfOutputKeys.join(', ')}`); | |
| } else { | |
| core.info('No job-specific overlap found'); | |
| } | |
| core.endGroup(); | |
| - name: Set outputs | |
| if: ${{ !inputs.all }} | |
| id: result | |
| uses: runloopai/github-script@main | |
| with: | |
| script: | | |
| // Inlined from changed_lines.js: setDetectChangesOutputs | |
| const CI_FILE = '.github/workflows/ci.yml'; | |
| const { stdout: yqOut } = await exec.getExecOutput( | |
| 'yq', ['-r', '.jobs["detect-changes"].outputs | keys | .[]', CI_FILE], | |
| { silent: true }, | |
| ); | |
| const allOutputKeys = yqOut.trim().split('\n').filter(Boolean); | |
| const modifiedKeys = `${{ steps.changed.outputs.modified_keys }}`; | |
| const selfKeys = `${{ steps.self.outputs.keys }}`; | |
| const giveUp = `${{ steps.self.outputs.give_up }}` === 'true'; | |
| const ciModified = `${{ steps.changed.outputs.ci_any_modified }}` === 'true'; | |
| const modList = modifiedKeys.split(' ').filter(Boolean); | |
| const selfList = selfKeys.split(' ').filter(Boolean); | |
| const runSet = new Set([...modList, ...selfList]); | |
| core.startGroup('Setting outputs'); | |
| const skipped = new Set(); | |
| if (giveUp) { | |
| core.notice('give_up: cannot determine ci.yml self-changes — all jobs will run'); | |
| } else { | |
| for (const k of allOutputKeys) { | |
| if (runSet.has(k)) { | |
| core.info(` ${k} = (run)`); | |
| } else { | |
| core.info(` ${k} = false (skipped)`); | |
| core.setOutput(k, 'false'); | |
| skipped.add(k); | |
| } | |
| } | |
| } | |
| core.endGroup(); | |
| const modCell = modList.map(k => `\`${k}\``).join(' ') || '_(none)_'; | |
| const selfCell = !ciModified | |
| ? '_(not modified)_' | |
| : selfList.length > 0 | |
| ? selfList.map(k => `\`${k}\``).join(' ') | |
| : '_(no job overlap)_'; | |
| const runList = allOutputKeys.filter(k => !skipped.has(k)); | |
| const summary = giveUp | |
| ? 'All jobs will run (give_up)' | |
| : `Running: ${runList.map(k => `\`${k}\``).join(', ') || '_(none)_'}`; | |
| core.notice(summary); | |
| await core.summary | |
| .addHeading('Change Detection', 3) | |
| .addRaw(`| | |\n|---|---|\n| **Event** | \`${context.eventName}\` |\n| **Modified groups** | ${modCell} |\n| **ci.yml self-change** | ${selfCell} |\n\n${summary}`) | |
| .write(); | |
| format: | |
| runs-on: ${{ needs.detect-changes.outputs.format != 'false' && 'ubuntu-latest' || 'ubuntu-slim' }} | |
| needs: [detect-changes] | |
| if: failure() || (success() && (github.event_name == 'pull_request' || needs.detect-changes.outputs.format != 'false')) | |
| env: | |
| SHOULD_RUN: ${{ needs.detect-changes.outputs.format != 'false' }} | |
| steps: | |
| - if: env.SHOULD_RUN == 'true' | |
| uses: runloopai/checkout@main | |
| - if: env.SHOULD_RUN == 'true' | |
| uses: runloopai/pnpm-action@master | |
| - if: env.SHOULD_RUN == 'true' | |
| uses: runloopai/setup-node@main | |
| with: | |
| node-version: "20" | |
| cache: pnpm | |
| - if: env.SHOULD_RUN == 'true' | |
| name: Install dependencies | |
| run: pnpm install --frozen-lockfile | |
| - if: env.SHOULD_RUN == 'true' | |
| name: Run Prettier check | |
| run: pnpm run format:check | |
| lint: | |
| runs-on: ${{ needs.detect-changes.outputs.lint != 'false' && 'ubuntu-latest' || 'ubuntu-slim' }} | |
| needs: [detect-changes] | |
| if: failure() || (success() && (github.event_name == 'pull_request' || needs.detect-changes.outputs.lint != 'false')) | |
| env: | |
| SHOULD_RUN: ${{ needs.detect-changes.outputs.lint != 'false' }} | |
| steps: | |
| - if: env.SHOULD_RUN == 'true' | |
| uses: runloopai/checkout@main | |
| - if: env.SHOULD_RUN == 'true' | |
| uses: runloopai/pnpm-action@master | |
| - if: env.SHOULD_RUN == 'true' | |
| uses: runloopai/setup-node@main | |
| with: | |
| node-version: "20" | |
| cache: pnpm | |
| - if: env.SHOULD_RUN == 'true' | |
| name: Install dependencies | |
| run: pnpm install --frozen-lockfile | |
| - if: env.SHOULD_RUN == 'true' | |
| name: Run ESLint | |
| run: pnpm run lint | |
| build: | |
| runs-on: ${{ needs.detect-changes.outputs.build != 'false' && 'ubuntu-latest' || 'ubuntu-slim' }} | |
| needs: [detect-changes] | |
| if: failure() || (success() && (github.event_name == 'pull_request' || needs.detect-changes.outputs.build != 'false')) | |
| env: | |
| SHOULD_RUN: ${{ needs.detect-changes.outputs.build != 'false' }} | |
| steps: | |
| - if: env.SHOULD_RUN == 'true' | |
| uses: runloopai/checkout@main | |
| - if: env.SHOULD_RUN == 'true' | |
| uses: runloopai/pnpm-action@master | |
| - if: env.SHOULD_RUN == 'true' | |
| uses: runloopai/setup-node@main | |
| with: | |
| node-version: "20" | |
| cache: pnpm | |
| - if: env.SHOULD_RUN == 'true' | |
| name: Install dependencies | |
| run: pnpm install --frozen-lockfile | |
| - if: env.SHOULD_RUN == 'true' | |
| name: Build TypeScript | |
| run: pnpm run build | |
| test: | |
| runs-on: ${{ needs.detect-changes.outputs.test != 'false' && 'ubuntu-latest' || 'ubuntu-slim' }} | |
| needs: [detect-changes, build] | |
| if: failure() || (success() && (github.event_name == 'pull_request' || needs.detect-changes.outputs.test != 'false')) | |
| env: | |
| SHOULD_RUN: ${{ needs.detect-changes.outputs.test != 'false' }} | |
| steps: | |
| - if: env.SHOULD_RUN == 'true' | |
| uses: runloopai/checkout@main | |
| - if: env.SHOULD_RUN == 'true' | |
| uses: runloopai/pnpm-action@master | |
| - if: env.SHOULD_RUN == 'true' | |
| uses: runloopai/setup-node@main | |
| with: | |
| node-version: "20" | |
| cache: pnpm | |
| - if: env.SHOULD_RUN == 'true' | |
| name: Install dependencies | |
| run: pnpm install --frozen-lockfile | |
| - if: env.SHOULD_RUN == 'true' | |
| name: Run component tests with coverage | |
| run: pnpm run test:components | |
| - name: Upload coverage report | |
| uses: runloopai/upload-artifact@main | |
| if: always() && env.SHOULD_RUN == 'true' | |
| with: | |
| name: component-coverage | |
| path: coverage/ | |
| retention-days: 7 |