Files
John SmithandClaude Opus 5.5 7f6bbcb724 ci: bots approve their PR's held validate run instead of dispatching one
GitHub creates pull_request runs for PRs opened or updated by github-actions[bot] as action_required, and that run (not a workflow_dispatch run on the same commit) is what the required check on main binds to. The README workflow, openPullRequest (submissions, scout, health) and reprobe now approve the held validate run. Generated files are allowed only on PRs opened by github-actions[bot] from this repo's bot/readme-* branches.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:28:58 -04:00

76 lines
3.4 KiB
JavaScript

/**
* Open or update a bot pull request from file changes (used by submissions, scout, health).
*
* GitHub holds pull_request workflow runs for bot-opened PRs as action_required; the bot approves the
* "validate" run on its own head commit so the PR gets the required check.
*/
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const { rest } = require('./github');
const { REPO, addLabels, approvePendingRuns } = require('./repo');
const ROOT = path.join(__dirname, '../..');
const BOT = ['-c', 'user.name=github-actions[bot]', '-c', 'user.email=41898282+github-actions[bot]@users.noreply.github.com'];
const git = (...args) => execFileSync('git', args, { cwd: ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
/**
* @param {object} options
* @param {string} options.branch - e.g. "submission/123"
* @param {Object<string, string|null>} options.files - repo-relative path -> new content (null deletes)
* @param {string} options.title
* @param {string} options.body
* @param {string[]} [options.labels]
* @param {string} [options.base="main"]
* @param {boolean} [options.dryRun]
* @returns {Promise<{number: number, url: string, created: boolean}|null>} null when there is nothing to change
*/
async function openPullRequest({ branch, files, title, body, labels = [], base = 'main', dryRun = false }) {
if (!/^[a-z0-9][a-z0-9/_.-]*$/i.test(branch)) throw new Error(`unsafe branch name: ${branch}`);
const original = git('rev-parse', '--abbrev-ref', 'HEAD');
git('fetch', '--quiet', 'origin', base);
git('checkout', '--quiet', '-B', branch, `origin/${base}`);
try {
for (const [file, content] of Object.entries(files)) {
const target = path.join(ROOT, file);
if (!target.startsWith(path.join(ROOT, 'data') + path.sep)) throw new Error(`refusing to write outside data/: ${file}`);
if (content === null) {
if (fs.existsSync(target)) git('rm', '--quiet', '--', file);
} else {
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, content);
git('add', '--', file);
}
}
if (!git('diff', '--cached', '--name-only')) return null;
if (dryRun) {
console.log(`[dry run] would push ${branch}: ${git('diff', '--cached', '--stat')}`);
return null;
}
git(...BOT, 'commit', '--quiet', '-m', title);
git('push', '--quiet', '--force', 'origin', `HEAD:refs/heads/${branch}`);
} finally {
git('checkout', '--quiet', '--force', original === 'HEAD' ? `origin/${base}` : original);
}
const owner = REPO.split('/')[0];
const { data: existing } = await rest(`/repos/${REPO}/pulls?state=open&head=${owner}:${encodeURIComponent(branch)}`);
let pr;
let created = false;
if (Array.isArray(existing) && existing.length) {
({ data: pr } = await rest(`/repos/${REPO}/pulls/${existing[0].number}`, { method: 'PATCH', body: { title, body } }));
} else {
const res = await rest(`/repos/${REPO}/pulls`, { method: 'POST', body: { title, body, head: branch, base } });
if (res.status >= 300) throw new Error(`create PR for ${branch}: HTTP ${res.status} ${JSON.stringify(res.data).slice(0, 300)}`);
pr = res.data;
created = true;
}
if (labels.length) await addLabels(pr.number, labels);
await approvePendingRuns(pr.head.sha);
return { number: pr.number, url: pr.html_url, created };
}
module.exports = { openPullRequest };