openvibers/openvibe.run Commits
Checks: quiet shadow, so a node check never holds a merge (#286)
Changes
@@@ -53,6 +53,10 @@ OV_OAUTH_CLIENT_SECRET=#RUN_CHECKS_OWNER=#OWNER_USERNAME=goosely#RUN_CHECKS=off++# The repositories whose checks report live on GitHub (owner/repo, comma-separated, or *). Every other repository is++# in quiet shadow: no queued or in-progress check run, and a finished check that did not succeed shows as neutral++# (the node's own conclusion in its title and in the shadow table), so a shadow check never holds a merge.++#RUN_CHECKS_LIVE=#RUN_CHECKS_TICK_MS=5000#RUN_CHECKS_TIMEOUT_MS=8000#RUN_CHECKS_GITHUB_API=https://api.github.com@@@ -109,8 +109,15 @@ function createGitHub({ config, fetchImpl = globalThis.fetch, now = () => Date.n}/** Create a check run (queued) → { id, html_url }. */-- async function createCheckRun(token, { owner, repo, name, headSha, detailsUrl = null, externalId = null }) {-- const body = { name, head_sha: headSha, status: 'queued', ...(detailsUrl ? { details_url: detailsUrl } : {}), ...(externalId ? { external_id: externalId } : {}) };++ async function createCheckRun(token, { owner, repo, name, headSha, detailsUrl = null, externalId = null, completed = null }) {++ // `completed` ({ conclusion, output, startedAt, completedAt }) creates the run already finished (quiet shadow).++ const done = completed ? {++ status: 'completed', conclusion: completed.conclusion,++ ...(completed.output ? { output: completed.output } : {}),++ ...(completed.startedAt ? { started_at: completed.startedAt } : {}),++ ...(completed.completedAt ? { completed_at: completed.completedAt } : {}),++ } : { status: 'queued' };++ const body = { name, head_sha: headSha, ...done, ...(detailsUrl ? { details_url: detailsUrl } : {}), ...(externalId ? { external_id: externalId } : {}) };const out = await request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/check-runs`, { method: 'POST', token, body });if (!out.json || !out.json.id) throw new GitHubError('checks.github_refused', 'GitHub created no check run');return { id: out.json.id, html_url: out.json.html_url || null };@@@ -76,6 +76,12 @@ function createChecks({ config, db, jobsStore = null, events = null, now = () =>const githubWeb = String(cfg.githubWeb || 'https://github.com').replace(/\/+$/, '');const tickMs = Math.max(500, Number(cfg.tickMs) || 5000);const enabled = cfg.enabled !== false && Boolean(key);++ // Quiet shadow (the default for every repository not in RUN_CHECKS_LIVE): see createCheckRun's `completed` and++ // finishJob. Live repositories report queued → in_progress → completed with the node's own conclusion.++ const live = new Set((Array.isArray(cfg.live) ? cfg.live : []).map((r) => String(r).toLowerCase()));++ const isLive = (repo) => live.has('*') || live.has(String(repo || '').toLowerCase());++ /** What a quiet shadow check shows on GitHub: success and skipped as they are, anything else neutral. */++ const shadowShown = (conclusion) => (conclusion === 'success' || conclusion === 'skipped' ? conclusion : 'neutral');const github = createGitHub({ config, fetchImpl, now, log });const tokenCache = new Map(); // installation id → { token, expiresAt }let timer = null;@@@ -225,7 +231,7 @@ function createChecks({ config, db, jobsStore = null, events = null, now = () =>/** Create the missing GitHub check runs for a plan's jobs (queued). A GitHub failure is logged, never fatal. */async function ensureCheckRuns(plan, jobs, token) {-- if (!token) return;++ if (!token || !isLive(plan.repo)) return;for (const job of jobs) {if (job.check_run_id || job.state === 'completed') continue;try {@@@ -278,7 +284,36 @@ function createChecks({ config, db, jobsStore = null, events = null, now = () =>*/async function finishJob(plan, job, { conclusion, summary, error = null, queueMs = null, runMs = null, startedAt = null, finishedAt = null, nodeId = null }) {const at = finishedAt || now();-- if (job.check_run_id) {++ const real = CHECK_CONCLUSIONS.has(conclusion) ? conclusion : 'failure';++ if (!job.check_run_id && !isLive(plan.repo)) {++ // Quiet shadow: the check run is made once, already complete; a failure shows as neutral, its title++ // says what the node found, and Actions stays the check that decides.++ const { token } = await planContext(plan);++ if (token) {++ const shown = shadowShown(real);++ const title = shown === real ? job.display_name : `${job.display_name}: shadow, node said ${real}; Actions decides`;++ try {++ const run = await github.createCheckRun(token, {++ owner: plan.owner, repo: plan.repo.split('/')[1], name: `openvibe / ${job.display_name}`,++ headSha: plan.sha, detailsUrl: detailsUrlOf(job.run_job_id), externalId: job.id,++ completed: {++ conclusion: shown, output: { title: title.slice(0, 250), summary: summary || 'Completed' },++ startedAt: startedAt ? new Date(startedAt).toISOString() : null, completedAt: new Date(at).toISOString(),++ },++ });++ await store.setCheckRun(db, job.id, run.id, now());++ job = { ...job, check_run_id: run.id };++ } catch (e) {++ const bumped = await store.bumpReportError(db, job.id, now());++ const errors = Number((bumped && bumped.report_errors) || 0);++ if (errors < 5) {++ log.warn(`[Run] checks: shadow check run for ${plan.repo} ${job.display_name} was not created (${e.code || e.message}); retrying`);++ return false;++ }++ log.warn(`[Run] checks: giving up on the shadow check run for ${plan.repo} ${job.display_name} (${e.code || e.message})`);++ }++ }++ } else if (job.check_run_id) {const { token } = await planContext(plan);if (!token) {log.warn(`[Run] checks: no token; ${plan.repo} ${job.display_name} completes without a check run update`);@@@ -369,8 +404,9 @@ function createChecks({ config, db, jobsStore = null, events = null, now = () =>const plan = await store.getPlan(db, planId);if (!plan || plan.state !== 'active') return { queued: 0, skipped: 0 };let jobs = await store.getJobs(db, planId);-- // A check run whose creation failed at planning is retried here before the job is queued.-- if (jobs.some((j) => !j.check_run_id && j.state !== 'completed')) {++ // A check run whose creation failed at planning is retried here before the job is queued (live repositories;++ // a quiet shadow one has no check run until it finishes).++ if (isLive(plan.repo) && jobs.some((j) => !j.check_run_id && j.state !== 'completed')) {const { token } = await planContext(plan);if (token) {await ensureCheckRuns(plan, jobs, token);@@@ -718,6 +754,7 @@ function createChecks({ config, db, jobsStore = null, events = null, now = () =>status: () => ({enabled, note: !key ? 'off: RUN_SECRETS_KEY is not set' : cfg.enabled === false ? 'off: RUN_CHECKS=off' : !project ? 'on, but RUN_CHECKS_PROJECT is not set so nothing is planned' : 'on',org, owners, tick_ms: tickMs, project_set: Boolean(project), running: Boolean(timer),++ reporting: live.has('*') ? 'live everywhere' : live.size ? `live: ${[...live].join(', ')}; quiet shadow elsewhere` : 'quiet shadow everywhere',}),deps: { config, db, github, store, owners, project, enabled },};@@@ -156,6 +156,10 @@ function loadConfig(env = process.env) {githubApi: trim(env.RUN_CHECKS_GITHUB_API || 'https://api.github.com'),githubWeb: trim(env.RUN_CHECKS_GITHUB_WEB || 'https://github.com'),tickMs: Math.max(500, int(env.RUN_CHECKS_TICK_MS, 5000)),++ // The repositories whose checks report live on GitHub (owner/repo, comma-separated, or *). Every other++ // repository is in quiet shadow: no queued or in-progress check run, and a finished one that did not++ // succeed shows as neutral, so a shadow check never holds a merge (server/checks/service.js).++ live: String(env.RUN_CHECKS_LIVE || '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean),requestTimeoutMs: Math.max(1000, int(env.RUN_CHECKS_TIMEOUT_MS, 8000)),},@@@ -80,6 +80,8 @@ async function bootChecks(gh, extraEnv = {}) {RUN_CHECKS_GITHUB_API: 'https://github.test',RUN_CHECKS_GITHUB_WEB: 'https://github.test',OWNER_USERNAME: 'goosely',++ // The demo repository reports live (queued → in_progress → completed); the quiet shadow test clears it.++ RUN_CHECKS_LIVE: 'OpenVibers/demo',...extraEnv,},appOpts: { fetchImpl: gh.fetchImpl },@@@ -351,6 +353,81 @@ async function main() {} finally { await t.close(); }});++ await check('quiet shadow: no check run until a job finishes; a failure shows neutral, the shadow row keeps it', async () => {++ const gh = startGitHubStub();++ gh.setFile('.github/workflows/ci.yml', CI);++ const t = await bootChecks(gh, { RUN_CHECKS_LIVE: '' });++ try {++ await seedApp(t, gh);++ assert.equal((await postWebhook(t, 'push', pushPayload(), { delivery: crypto.randomUUID() })).status, 204);++ const posts = () => gh.calls.filter((c) => c.method === 'POST' && c.path === '/repos/OpenVibers/demo/check-runs');++ const patches = () => gh.calls.filter((c) => c.method === 'PATCH' && c.path.startsWith('/repos/OpenVibers/demo/check-runs/'));++ assert.equal(await t.db.value('SELECT COUNT(*) FROM run_jobs'), 1, 'the job is still planned and queued');++ assert.equal(posts().length, 0, 'no queued check run in quiet shadow');++ assert.match(t.checks.status().reporting, /quiet shadow everywhere/);++++ const build = await t.db.maybe("SELECT cj.id AS check_job_id, rj.id AS run_job_id FROM run_checks_jobs cj JOIN run_jobs rj ON rj.id = cj.run_job_id WHERE cj.display_name = 'Build'");++ await t.db.maybe("UPDATE run_jobs SET state = 'running', started_ms = $2, node_id = 'dev_node_1' WHERE id = $1", [build.run_job_id, Date.now()]);++ await t.checks.tick();++ assert.equal(posts().length + patches().length, 0, 'no in-progress check run either');++ assert.equal(await t.db.value('SELECT check_state FROM run_checks_jobs WHERE id = $1', [build.check_job_id]), 'in_progress');++++ await t.db.maybe(++ "UPDATE run_jobs SET state = 'failed', finished_ms = $2, wall_ms = 900, result = $3::jsonb WHERE id = $1",++ [build.run_job_id, Date.now(), JSON.stringify({ ok: false, conclusion: 'failure', steps: [{ name: 'npm test', conclusion: 'failure', ms: 800 }], ms: 900 })]);++ await t.checks.tick();++ const made = posts();++ const buildRun = made.find((c) => c.body.name === 'openvibe / Build');++ assert.ok(buildRun, 'the finished job gets its check run');++ assert.equal(buildRun.body.status, 'completed');++ assert.equal(buildRun.body.conclusion, 'neutral', 'a shadow failure never fails the commit');++ assert.match(buildRun.body.output.title, /node said failure; Actions decides/);++ assert.ok(buildRun.body.completed_at && buildRun.body.head_sha === SHA);++ assert.equal(patches().length, 0);++ const row = await t.db.maybe('SELECT * FROM run_checks_jobs WHERE id = $1', [build.check_job_id]);++ assert.equal(row.conclusion, 'failure', 'Run keeps the real conclusion');++ assert.ok(row.check_run_id, 'the check run id is kept');++ const shadow = await t.db.maybe("SELECT * FROM run_checks_shadow WHERE job_name = 'Build'");++ assert.equal(shadow.node_conclusion, 'failure');++++ // The later stage is skipped because its need failed: shown as skipped, never as pending or failed.++ const testRun = made.find((c) => c.body.name === 'openvibe / Test');++ assert.ok(testRun, 'the skipped stage is reported once');++ assert.equal(testRun.body.status, 'completed');++ assert.equal(testRun.body.conclusion, 'skipped');++ assert.equal(made.length, 2);++ } finally { await t.close(); }++ });++++ await check('quiet shadow: a success shows as success; RUN_CHECKS_LIVE=* reports every repository live', async () => {++ const gh = startGitHubStub();++ gh.setFile('.github/workflows/ci.yml', CI);++ const t = await bootChecks(gh, { RUN_CHECKS_LIVE: '' });++ try {++ await seedApp(t, gh);++ await postWebhook(t, 'push', pushPayload(), { delivery: crypto.randomUUID() });++ const build = await t.db.maybe("SELECT rj.id AS run_job_id FROM run_checks_jobs cj JOIN run_jobs rj ON rj.id = cj.run_job_id WHERE cj.display_name = 'Build'");++ await t.db.maybe("UPDATE run_jobs SET state = 'succeeded', started_ms = $2, finished_ms = $3, wall_ms = 500, result = $4::jsonb WHERE id = $1",++ [build.run_job_id, Date.now() - 1000, Date.now(), JSON.stringify({ ok: true, conclusion: 'success', steps: [{ name: 'ok', conclusion: 'success', ms: 10 }] })]);++ await t.checks.tick();++ const made = gh.calls.filter((c) => c.method === 'POST' && c.path === '/repos/OpenVibers/demo/check-runs');++ assert.equal(made.length, 1);++ assert.equal(made[0].body.conclusion, 'success');++ assert.equal(made[0].body.output.title, 'Build');++ } finally { await t.close(); }++ const gh2 = startGitHubStub();++ gh2.setFile('.github/workflows/ci.yml', CI);++ const t2 = await bootChecks(gh2, { RUN_CHECKS_LIVE: '*' });++ try {++ await seedApp(t2, gh2);++ await postWebhook(t2, 'push', pushPayload(), { delivery: crypto.randomUUID() });++ const queued = gh2.calls.filter((c) => c.method === 'POST' && c.path === '/repos/OpenVibers/demo/check-runs');++ assert.equal(queued.length, 2);++ assert.ok(queued.every((c) => c.body.status === 'queued'));++ assert.equal(t2.checks.status().reporting, 'live everywhere');++ } finally { await t2.close(); }++ });++await check('shadow: workflow_job completed records the Actions conclusion; agreement is per repo and owner-only', async () => {const gh = startGitHubStub();gh.setFile('.github/workflows/ci.yml', CI);
@@ -53,6 +53,10 @@ OV_OAUTH_CLIENT_SECRET=
@@ -53,6 +53,10 @@ OV_OAUTH_CLIENT_SECRET=
#RUN_CHECKS_OWNER=
#RUN_CHECKS_OWNER=
#OWNER_USERNAME=goosely
#OWNER_USERNAME=goosely
#RUN_CHECKS=off
#RUN_CHECKS=off
+# The repositories whose checks report live on GitHub (owner/repo, comma-separated, or *). Every other repository is
+# in quiet shadow: no queued or in-progress check run, and a finished check that did not succeed shows as neutral
+# (the node's own conclusion in its title and in the shadow table), so a shadow check never holds a merge.
+#RUN_CHECKS_LIVE=
#RUN_CHECKS_TICK_MS=5000
#RUN_CHECKS_TICK_MS=5000
#RUN_CHECKS_TIMEOUT_MS=8000
#RUN_CHECKS_TIMEOUT_MS=8000
#RUN_CHECKS_GITHUB_API=https://api.github.com
#RUN_CHECKS_GITHUB_API=https://api.github.com
@@ -109,8 +109,15 @@ function createGitHub({ config, fetchImpl = globalThis.fetch, now = () => Date.n
@@ -109,8 +109,15 @@ function createGitHub({ config, fetchImpl = globalThis.fetch, now = () => Date.n
}
}
/** Create a check run (queued) → { id, html_url }. */
/** Create a check run (queued) → { id, html_url }. */
- async function createCheckRun(token, { owner, repo, name, headSha, detailsUrl = null, externalId = null }) {
- const body = { name, head_sha: headSha, status: 'queued', ...(detailsUrl ? { details_url: detailsUrl } : {}), ...(externalId ? { external_id: externalId } : {}) };
+ async function createCheckRun(token, { owner, repo, name, headSha, detailsUrl = null, externalId = null, completed = null }) {
+ // `completed` ({ conclusion, output, startedAt, completedAt }) creates the run already finished (quiet shadow).
+ const done = completed ? {
+ status: 'completed', conclusion: completed.conclusion,
+ ...(completed.output ? { output: completed.output } : {}),
+ ...(completed.startedAt ? { started_at: completed.startedAt } : {}),
+ ...(completed.completedAt ? { completed_at: completed.completedAt } : {}),
+ } : { status: 'queued' };
+ const body = { name, head_sha: headSha, ...done, ...(detailsUrl ? { details_url: detailsUrl } : {}), ...(externalId ? { external_id: externalId } : {}) };
const out = await request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/check-runs`, { method: 'POST', token, body });
const out = await request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/check-runs`, { method: 'POST', token, body });
if (!out.json || !out.json.id) throw new GitHubError('checks.github_refused', 'GitHub created no check run');
if (!out.json || !out.json.id) throw new GitHubError('checks.github_refused', 'GitHub created no check run');
return { id: out.json.id, html_url: out.json.html_url || null };
return { id: out.json.id, html_url: out.json.html_url || null };
@@ -76,6 +76,12 @@ function createChecks({ config, db, jobsStore = null, events = null, now = () =>
@@ -76,6 +76,12 @@ function createChecks({ config, db, jobsStore = null, events = null, now = () =>
const githubWeb = String(cfg.githubWeb || 'https://github.com').replace(/\/+$/, '');
const githubWeb = String(cfg.githubWeb || 'https://github.com').replace(/\/+$/, '');
const tickMs = Math.max(500, Number(cfg.tickMs) || 5000);
const tickMs = Math.max(500, Number(cfg.tickMs) || 5000);
const enabled = cfg.enabled !== false && Boolean(key);
const enabled = cfg.enabled !== false && Boolean(key);
+ // Quiet shadow (the default for every repository not in RUN_CHECKS_LIVE): see createCheckRun's `completed` and
+ // finishJob. Live repositories report queued → in_progress → completed with the node's own conclusion.
+ const live = new Set((Array.isArray(cfg.live) ? cfg.live : []).map((r) => String(r).toLowerCase()));
+ const isLive = (repo) => live.has('*') || live.has(String(repo || '').toLowerCase());
+ /** What a quiet shadow check shows on GitHub: success and skipped as they are, anything else neutral. */
+ const shadowShown = (conclusion) => (conclusion === 'success' || conclusion === 'skipped' ? conclusion : 'neutral');
const github = createGitHub({ config, fetchImpl, now, log });
const github = createGitHub({ config, fetchImpl, now, log });
const tokenCache = new Map(); // installation id → { token, expiresAt }
const tokenCache = new Map(); // installation id → { token, expiresAt }
let timer = null;
let timer = null;
@@ -225,7 +231,7 @@ function createChecks({ config, db, jobsStore = null, events = null, now = () =>
@@ -225,7 +231,7 @@ function createChecks({ config, db, jobsStore = null, events = null, now = () =>
/** Create the missing GitHub check runs for a plan's jobs (queued). A GitHub failure is logged, never fatal. */
/** Create the missing GitHub check runs for a plan's jobs (queued). A GitHub failure is logged, never fatal. */
async function ensureCheckRuns(plan, jobs, token) {
async function ensureCheckRuns(plan, jobs, token) {
- if (!token) return;
+ if (!token || !isLive(plan.repo)) return;
for (const job of jobs) {
for (const job of jobs) {
if (job.check_run_id || job.state === 'completed') continue;
if (job.check_run_id || job.state === 'completed') continue;
try {
try {
@@ -278,7 +284,36 @@ function createChecks({ config, db, jobsStore = null, events = null, now = () =>
@@ -278,7 +284,36 @@ function createChecks({ config, db, jobsStore = null, events = null, now = () =>
*/
*/
async function finishJob(plan, job, { conclusion, summary, error = null, queueMs = null, runMs = null, startedAt = null, finishedAt = null, nodeId = null }) {
async function finishJob(plan, job, { conclusion, summary, error = null, queueMs = null, runMs = null, startedAt = null, finishedAt = null, nodeId = null }) {
const at = finishedAt || now();
const at = finishedAt || now();
- if (job.check_run_id) {
+ const real = CHECK_CONCLUSIONS.has(conclusion) ? conclusion : 'failure';
+ if (!job.check_run_id && !isLive(plan.repo)) {
+ // Quiet shadow: the check run is made once, already complete; a failure shows as neutral, its title
+ // says what the node found, and Actions stays the check that decides.
+ const { token } = await planContext(plan);
+ if (token) {
+ const shown = shadowShown(real);
+ const title = shown === real ? job.display_name : `${job.display_name}: shadow, node said ${real}; Actions decides`;
+ try {
+ const run = await github.createCheckRun(token, {
+ owner: plan.owner, repo: plan.repo.split('/')[1], name: `openvibe / ${job.display_name}`,
+ headSha: plan.sha, detailsUrl: detailsUrlOf(job.run_job_id), externalId: job.id,
+ completed: {
+ conclusion: shown, output: { title: title.slice(0, 250), summary: summary || 'Completed' },
+ startedAt: startedAt ? new Date(startedAt).toISOString() : null, completedAt: new Date(at).toISOString(),
+ },
+ });
+ await store.setCheckRun(db, job.id, run.id, now());
+ job = { ...job, check_run_id: run.id };
+ } catch (e) {
+ const bumped = await store.bumpReportError(db, job.id, now());
+ const errors = Number((bumped && bumped.report_errors) || 0);
+ if (errors < 5) {
+ log.warn(`[Run] checks: shadow check run for ${plan.repo} ${job.display_name} was not created (${e.code || e.message}); retrying`);
+ return false;
+ }
+ log.warn(`[Run] checks: giving up on the shadow check run for ${plan.repo} ${job.display_name} (${e.code || e.message})`);
+ }
+ }
+ } else if (job.check_run_id) {
const { token } = await planContext(plan);
const { token } = await planContext(plan);
if (!token) {
if (!token) {
log.warn(`[Run] checks: no token; ${plan.repo} ${job.display_name} completes without a check run update`);
log.warn(`[Run] checks: no token; ${plan.repo} ${job.display_name} completes without a check run update`);
@@ -369,8 +404,9 @@ function createChecks({ config, db, jobsStore = null, events = null, now = () =>
@@ -369,8 +404,9 @@ function createChecks({ config, db, jobsStore = null, events = null, now = () =>
const plan = await store.getPlan(db, planId);
const plan = await store.getPlan(db, planId);
if (!plan || plan.state !== 'active') return { queued: 0, skipped: 0 };
if (!plan || plan.state !== 'active') return { queued: 0, skipped: 0 };
let jobs = await store.getJobs(db, planId);
let jobs = await store.getJobs(db, planId);
- // A check run whose creation failed at planning is retried here before the job is queued.
- if (jobs.some((j) => !j.check_run_id && j.state !== 'completed')) {
+ // A check run whose creation failed at planning is retried here before the job is queued (live repositories;
+ // a quiet shadow one has no check run until it finishes).
+ if (isLive(plan.repo) && jobs.some((j) => !j.check_run_id && j.state !== 'completed')) {
const { token } = await planContext(plan);
const { token } = await planContext(plan);
if (token) {
if (token) {
await ensureCheckRuns(plan, jobs, token);
await ensureCheckRuns(plan, jobs, token);
@@ -718,6 +754,7 @@ function createChecks({ config, db, jobsStore = null, events = null, now = () =>
@@ -718,6 +754,7 @@ function createChecks({ config, db, jobsStore = null, events = null, now = () =>
status: () => ({
status: () => ({
enabled, note: !key ? 'off: RUN_SECRETS_KEY is not set' : cfg.enabled === false ? 'off: RUN_CHECKS=off' : !project ? 'on, but RUN_CHECKS_PROJECT is not set so nothing is planned' : 'on',
enabled, note: !key ? 'off: RUN_SECRETS_KEY is not set' : cfg.enabled === false ? 'off: RUN_CHECKS=off' : !project ? 'on, but RUN_CHECKS_PROJECT is not set so nothing is planned' : 'on',
org, owners, tick_ms: tickMs, project_set: Boolean(project), running: Boolean(timer),
org, owners, tick_ms: tickMs, project_set: Boolean(project), running: Boolean(timer),
+ reporting: live.has('*') ? 'live everywhere' : live.size ? `live: ${[...live].join(', ')}; quiet shadow elsewhere` : 'quiet shadow everywhere',
}),
}),
deps: { config, db, github, store, owners, project, enabled },
deps: { config, db, github, store, owners, project, enabled },
};
};
@@ -156,6 +156,10 @@ function loadConfig(env = process.env) {
@@ -156,6 +156,10 @@ function loadConfig(env = process.env) {
githubApi: trim(env.RUN_CHECKS_GITHUB_API || 'https://api.github.com'),
githubApi: trim(env.RUN_CHECKS_GITHUB_API || 'https://api.github.com'),
githubWeb: trim(env.RUN_CHECKS_GITHUB_WEB || 'https://github.com'),
githubWeb: trim(env.RUN_CHECKS_GITHUB_WEB || 'https://github.com'),
tickMs: Math.max(500, int(env.RUN_CHECKS_TICK_MS, 5000)),
tickMs: Math.max(500, int(env.RUN_CHECKS_TICK_MS, 5000)),
+ // The repositories whose checks report live on GitHub (owner/repo, comma-separated, or *). Every other
+ // repository is in quiet shadow: no queued or in-progress check run, and a finished one that did not
+ // succeed shows as neutral, so a shadow check never holds a merge (server/checks/service.js).
+ live: String(env.RUN_CHECKS_LIVE || '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean),
requestTimeoutMs: Math.max(1000, int(env.RUN_CHECKS_TIMEOUT_MS, 8000)),
requestTimeoutMs: Math.max(1000, int(env.RUN_CHECKS_TIMEOUT_MS, 8000)),
},
},
@@ -80,6 +80,8 @@ async function bootChecks(gh, extraEnv = {}) {
@@ -80,6 +80,8 @@ async function bootChecks(gh, extraEnv = {}) {
RUN_CHECKS_GITHUB_API: 'https://github.test',
RUN_CHECKS_GITHUB_API: 'https://github.test',
RUN_CHECKS_GITHUB_WEB: 'https://github.test',
RUN_CHECKS_GITHUB_WEB: 'https://github.test',
OWNER_USERNAME: 'goosely',
OWNER_USERNAME: 'goosely',
+ // The demo repository reports live (queued → in_progress → completed); the quiet shadow test clears it.
+ RUN_CHECKS_LIVE: 'OpenVibers/demo',
...extraEnv,
...extraEnv,
},
},
appOpts: { fetchImpl: gh.fetchImpl },
appOpts: { fetchImpl: gh.fetchImpl },
@@ -351,6 +353,81 @@ async function main() {
@@ -351,6 +353,81 @@ async function main() {
} finally { await t.close(); }
} finally { await t.close(); }
});
});
+ await check('quiet shadow: no check run until a job finishes; a failure shows neutral, the shadow row keeps it', async () => {
+ const gh = startGitHubStub();
+ gh.setFile('.github/workflows/ci.yml', CI);
+ const t = await bootChecks(gh, { RUN_CHECKS_LIVE: '' });
+ try {
+ await seedApp(t, gh);
+ assert.equal((await postWebhook(t, 'push', pushPayload(), { delivery: crypto.randomUUID() })).status, 204);
+ const posts = () => gh.calls.filter((c) => c.method === 'POST' && c.path === '/repos/OpenVibers/demo/check-runs');
+ const patches = () => gh.calls.filter((c) => c.method === 'PATCH' && c.path.startsWith('/repos/OpenVibers/demo/check-runs/'));
+ assert.equal(await t.db.value('SELECT COUNT(*) FROM run_jobs'), 1, 'the job is still planned and queued');
+ assert.equal(posts().length, 0, 'no queued check run in quiet shadow');
+ assert.match(t.checks.status().reporting, /quiet shadow everywhere/);
+
+ const build = await t.db.maybe("SELECT cj.id AS check_job_id, rj.id AS run_job_id FROM run_checks_jobs cj JOIN run_jobs rj ON rj.id = cj.run_job_id WHERE cj.display_name = 'Build'");
+ await t.db.maybe("UPDATE run_jobs SET state = 'running', started_ms = $2, node_id = 'dev_node_1' WHERE id = $1", [build.run_job_id, Date.now()]);
+ await t.checks.tick();
+ assert.equal(posts().length + patches().length, 0, 'no in-progress check run either');
+ assert.equal(await t.db.value('SELECT check_state FROM run_checks_jobs WHERE id = $1', [build.check_job_id]), 'in_progress');
+
+ await t.db.maybe(
+ "UPDATE run_jobs SET state = 'failed', finished_ms = $2, wall_ms = 900, result = $3::jsonb WHERE id = $1",
+ [build.run_job_id, Date.now(), JSON.stringify({ ok: false, conclusion: 'failure', steps: [{ name: 'npm test', conclusion: 'failure', ms: 800 }], ms: 900 })]);
+ await t.checks.tick();
+ const made = posts();
+ const buildRun = made.find((c) => c.body.name === 'openvibe / Build');
+ assert.ok(buildRun, 'the finished job gets its check run');
+ assert.equal(buildRun.body.status, 'completed');
+ assert.equal(buildRun.body.conclusion, 'neutral', 'a shadow failure never fails the commit');
+ assert.match(buildRun.body.output.title, /node said failure; Actions decides/);
+ assert.ok(buildRun.body.completed_at && buildRun.body.head_sha === SHA);
+ assert.equal(patches().length, 0);
+ const row = await t.db.maybe('SELECT * FROM run_checks_jobs WHERE id = $1', [build.check_job_id]);
+ assert.equal(row.conclusion, 'failure', 'Run keeps the real conclusion');
+ assert.ok(row.check_run_id, 'the check run id is kept');
+ const shadow = await t.db.maybe("SELECT * FROM run_checks_shadow WHERE job_name = 'Build'");
+ assert.equal(shadow.node_conclusion, 'failure');
+
+ // The later stage is skipped because its need failed: shown as skipped, never as pending or failed.
+ const testRun = made.find((c) => c.body.name === 'openvibe / Test');
+ assert.ok(testRun, 'the skipped stage is reported once');
+ assert.equal(testRun.body.status, 'completed');
+ assert.equal(testRun.body.conclusion, 'skipped');
+ assert.equal(made.length, 2);
+ } finally { await t.close(); }
+ });
+
+ await check('quiet shadow: a success shows as success; RUN_CHECKS_LIVE=* reports every repository live', async () => {
+ const gh = startGitHubStub();
+ gh.setFile('.github/workflows/ci.yml', CI);
+ const t = await bootChecks(gh, { RUN_CHECKS_LIVE: '' });
+ try {
+ await seedApp(t, gh);
+ await postWebhook(t, 'push', pushPayload(), { delivery: crypto.randomUUID() });
+ const build = await t.db.maybe("SELECT rj.id AS run_job_id FROM run_checks_jobs cj JOIN run_jobs rj ON rj.id = cj.run_job_id WHERE cj.display_name = 'Build'");
+ await t.db.maybe("UPDATE run_jobs SET state = 'succeeded', started_ms = $2, finished_ms = $3, wall_ms = 500, result = $4::jsonb WHERE id = $1",
+ [build.run_job_id, Date.now() - 1000, Date.now(), JSON.stringify({ ok: true, conclusion: 'success', steps: [{ name: 'ok', conclusion: 'success', ms: 10 }] })]);
+ await t.checks.tick();
+ const made = gh.calls.filter((c) => c.method === 'POST' && c.path === '/repos/OpenVibers/demo/check-runs');
+ assert.equal(made.length, 1);
+ assert.equal(made[0].body.conclusion, 'success');
+ assert.equal(made[0].body.output.title, 'Build');
+ } finally { await t.close(); }
+ const gh2 = startGitHubStub();
+ gh2.setFile('.github/workflows/ci.yml', CI);
+ const t2 = await bootChecks(gh2, { RUN_CHECKS_LIVE: '*' });
+ try {
+ await seedApp(t2, gh2);
+ await postWebhook(t2, 'push', pushPayload(), { delivery: crypto.randomUUID() });
+ const queued = gh2.calls.filter((c) => c.method === 'POST' && c.path === '/repos/OpenVibers/demo/check-runs');
+ assert.equal(queued.length, 2);
+ assert.ok(queued.every((c) => c.body.status === 'queued'));
+ assert.equal(t2.checks.status().reporting, 'live everywhere');
+ } finally { await t2.close(); }
+ });
+
await check('shadow: workflow_job completed records the Actions conclusion; agreement is per repo and owner-only', async () => {
await check('shadow: workflow_job completed records the Actions conclusion; agreement is per repo and owner-only', async () => {
const gh = startGitHubStub();
const gh = startGitHubStub();
gh.setFile('.github/workflows/ci.yml', CI);
gh.setFile('.github/workflows/ci.yml', CI);