mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 11:49:16 +00:00
ci(process): раунд ревью ждёт Validate событием, а не сном раннера (#636)
Стадия prepare спала ≈ 28 минут на раунд, пока шёл Validate с мутантами на материале (модель работает 10–12); за неделю ≈ 420–500 job-минут простоя и потолок бюджета стадии 55 минут. - validate-gate.mjs: `--no-wait` — гейт диспатчит прогон, убеждается, что тот встал на материал (#539 сохранён), и возвращает `pending` (код 2) вместо ожидания; завершённый зелёный/красный отдаёт сразу, как прежде. - process.yml prepare: третий исход `proceed=pending`: запечатанный маркер `review-pending-<issue>-<run>-<attempt>` (issue, stage, branch, material_sha, validate run) и выход; модель и интеграция не запускаются; возврат автору — только на явном `false`. - process-resume.yml + scripts/process-resume.mjs: на `workflow_run: completed` Validate по ветке issue/* — если метка S7 стоит, активного прогона нет и последний прогон оставил маркер на этот SHA, переставить S7 (HP_PROCESS_TOKEN); новый прогон находит завершённый dispatch сразу. Без маркера не будит. - process-reconcile.mjs: читает маркер и состояние Validate на материале; идёт — wait, завершился/пропал без продолжения — retry; без маркера — прежний escalate. Общий loadSealedArtifact, экспорт processRuns/artifactNames. - preflight сверяет process-resume.yml между main и dev наравне с process.yml. - Тесты: validate-gate (4), process-resume (8, включая контракт трёх workflow), process-reconcile (2); мутанты gate-no-wait-still-sleeps, resume-wakes-round-without-marker, resume-ignores-active-run, reconcile-wakes-pending-while-validate-active. PROCESS.md §10.4, AGENTS.md. Issue: #636 User-Visible: no
This commit is contained in:
@@ -8449,6 +8449,51 @@ const MUTANT_DEFINITIONS = [
|
||||
+ ' никогда — именно оно в этом процессе заменяет тестирование.',
|
||||
}],
|
||||
},
|
||||
// #636: раунд продолжается по событию завершения Validate, а не сном раннера.
|
||||
// Каждая защита — от второго вызова модели или от вечного ожидания.
|
||||
{
|
||||
id: 'gate-no-wait-still-sleeps',
|
||||
guard: 'node --test --test-name-pattern="#636" test/validate-gate.test.mjs',
|
||||
because: 'with --no-wait the gate must return pending for a running dispatch instead of polling '
|
||||
+ 'it inside the pipeline job — otherwise the runner sleeps 28 minutes per round again (#636)',
|
||||
patches: [{
|
||||
file: 'scripts/validate-gate.mjs',
|
||||
find: " if (!wait) {\n // #636: прогон найден и идёт — ждать его будет событие, не раннер.",
|
||||
replace: " if (false && !wait) {\n // #636: прогон найден и идёт — ждать его будет событие, не раннер.",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'resume-wakes-round-without-marker',
|
||||
guard: 'node --test --test-name-pattern="#636" test/process-resume.test.mjs',
|
||||
because: 'a successful process run without a pending marker did not wait for Validate; relabelling '
|
||||
+ 'it would spend the model a second time (#636)',
|
||||
patches: [{
|
||||
file: 'scripts/process-resume.mjs',
|
||||
find: " if (!pending) return { action: 'noop', reason: 'latest process run left no pending marker — it did not wait for Validate' };",
|
||||
replace: " if (false && !pending) return { action: 'noop', reason: 'latest process run left no pending marker — it did not wait for Validate' };",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'resume-ignores-active-run',
|
||||
guard: 'node --test --test-name-pattern="#636" test/process-resume.test.mjs',
|
||||
because: 'relabelling while a process run is active queues a second round for the same request (#636)',
|
||||
patches: [{
|
||||
file: 'scripts/process-resume.mjs',
|
||||
find: " if (mine.some((run) => ACTIVE_RUN_STATES.has(run.status))) return { action: 'noop', reason: 'a process run for this issue is already active' };",
|
||||
replace: " if (false && mine.some((run) => ACTIVE_RUN_STATES.has(run.status))) return { action: 'noop', reason: 'a process run for this issue is already active' };",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'reconcile-wakes-pending-while-validate-active',
|
||||
guard: 'node --test --test-name-pattern="#636" test/process-reconcile.test.mjs',
|
||||
because: 'reconcile must wait while the Validate the round is pending on still runs; relabelling '
|
||||
+ 'early dispatches a second Validate and a second round (#636)',
|
||||
patches: [{
|
||||
file: 'scripts/process-reconcile.mjs',
|
||||
find: " if (run.pendingValidate === 'active') {",
|
||||
replace: " if (false && run.pendingValidate === 'active') {",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'process-reconcile-restarts-healthy-run',
|
||||
guard: 'node --test test/process-reconcile.test.mjs',
|
||||
|
||||
@@ -50,6 +50,10 @@ export function parseProcessRun(run = {}) {
|
||||
prepared: run.prepared || null,
|
||||
preparedArtifact: Boolean(run.preparedArtifact),
|
||||
resultArtifact: Boolean(run.resultArtifact),
|
||||
// #636: маркер «Validate на материале идёт, раунд продолжит событие» и
|
||||
// состояние этого Validate (`active` | `completed` | `missing`).
|
||||
pending: run.pending || null,
|
||||
pendingValidate: run.pendingValidate || null,
|
||||
evidenceError: run.evidenceError || null,
|
||||
};
|
||||
}
|
||||
@@ -143,6 +147,16 @@ export function decideReconciliation({
|
||||
if (Number.isFinite(settledAt) && now - settledAt < graceMs) {
|
||||
return result('wait', 'completed run is still within label-application grace', { label, stage, run });
|
||||
}
|
||||
if (run.conclusion === 'success' && run.pending) {
|
||||
// #636: успешный прогон без вердикта — это не потеря, а осознанный выход
|
||||
// подготовки: Validate с мутантами на материале ещё шёл. Пока он идёт —
|
||||
// ждать; завершился или пропал, а событие раунд не разбудило — разбудить
|
||||
// повторной меткой: новый прогон найдёт завершённый dispatch сразу.
|
||||
if (run.pendingValidate === 'active') {
|
||||
return result('wait', 'Validate on the material is still running; the round resumes on its completion', { label, stage, run });
|
||||
}
|
||||
return result('retry', `Validate on the material is ${run.pendingValidate || 'unknown'} but the round was not resumed`, { label, stage, run });
|
||||
}
|
||||
if (run.conclusion === 'success') {
|
||||
return result('escalate', 'successful run did not move the review label', { label, stage, run });
|
||||
}
|
||||
@@ -202,7 +216,7 @@ function openReviewIssues(repo) {
|
||||
.sort((a, b) => a.number - b.number);
|
||||
}
|
||||
|
||||
function processRuns(repo, issues = []) {
|
||||
export function processRuns(repo, issues = []) {
|
||||
const pages = [1, 2].flatMap((page) => {
|
||||
const response = ghJson(['api', `repos/${repo}/actions/workflows/process.yml/runs?event=issues&per_page=100&page=${page}`]);
|
||||
return response.workflow_runs || [];
|
||||
@@ -227,30 +241,54 @@ function processRuns(repo, issues = []) {
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
function artifactNames(repo, run) {
|
||||
export function artifactNames(repo, run) {
|
||||
const response = ghJson(['api', `repos/${repo}/actions/runs/${run.id}/artifacts?per_page=100`]);
|
||||
return response.artifacts || [];
|
||||
}
|
||||
|
||||
function loadPreparedArtifact(repo, run, artifact) {
|
||||
/** Sealed JSON artifact of the pipeline: one file plus its sha256 manifest. */
|
||||
export function loadSealedArtifact(repo, run, artifact, file) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'houseplan-process-reconcile-'));
|
||||
try {
|
||||
const downloaded = gh(['run', 'download', String(run.id), '--repo', repo,
|
||||
'--name', artifact.name, '--dir', dir], { allowFailure: true });
|
||||
if (downloaded.status !== 0) throw new Error(`artifact download failed: ${(downloaded.stderr || '').trim()}`);
|
||||
const file = join(dir, 'prepared.json');
|
||||
const path = join(dir, file);
|
||||
const manifest = join(dir, 'manifest.sha256');
|
||||
if (!existsSync(file) || !existsSync(manifest)) throw new Error('prepared artifact is incomplete');
|
||||
const body = readFileSync(file);
|
||||
if (!existsSync(path) || !existsSync(manifest)) throw new Error(`${file} artifact is incomplete`);
|
||||
const body = readFileSync(path);
|
||||
const expected = readFileSync(manifest, 'utf8').trim().split(/\s+/)[0];
|
||||
const actual = createHash('sha256').update(body).digest('hex');
|
||||
if (expected !== actual) throw new Error('prepared artifact checksum mismatch');
|
||||
if (expected !== actual) throw new Error(`${file} artifact checksum mismatch`);
|
||||
return JSON.parse(body.toString('utf8'));
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function loadPreparedArtifact(repo, run, artifact) {
|
||||
return loadSealedArtifact(repo, run, artifact, 'prepared.json');
|
||||
}
|
||||
|
||||
export const pendingArtifactName = (issue, run) => `review-pending-${issue.number ?? issue}-${run.id}-${run.attempt}`;
|
||||
|
||||
/**
|
||||
* #636: состояние Validate с мутантами на материале — по dispatch-прогонам на
|
||||
* SHA. `active` пока хоть один не завершён; `completed`, если завершённый есть;
|
||||
* иначе `missing` (диспатч не появился — новый прогон конвейера повторит его).
|
||||
*/
|
||||
export function validateStateOnMaterial(runs = []) {
|
||||
const dispatches = (runs || []).filter((run) => (run.event || run.workflowEvent) === 'workflow_dispatch');
|
||||
if (dispatches.some((run) => ACTIVE_RUN_STATES.has(String(run.status || '')))) return 'active';
|
||||
if (dispatches.some((run) => String(run.status || '') === 'completed')) return 'completed';
|
||||
return 'missing';
|
||||
}
|
||||
|
||||
function validateRunsOnSha(repo, sha) {
|
||||
const response = ghJson(['api', `repos/${repo}/actions/workflows/validate.yml/runs?head_sha=${sha}&per_page=20`]);
|
||||
return response.workflow_runs || [];
|
||||
}
|
||||
|
||||
function hydrateRunEvidence(repo, issue, run) {
|
||||
if (!run || run.status !== 'completed') return run;
|
||||
try {
|
||||
@@ -259,8 +297,14 @@ function hydrateRunEvidence(repo, issue, run) {
|
||||
const resultName = `review-result-${issue.number}-${run.id}-${run.attempt}`;
|
||||
const preparedArtifacts = artifacts.filter((artifact) => artifact.name === preparedName && !artifact.expired);
|
||||
const resultArtifacts = artifacts.filter((artifact) => artifact.name === resultName && !artifact.expired);
|
||||
if (preparedArtifacts.length > 1 || resultArtifacts.length > 1) {
|
||||
return { ...run, evidenceError: 'duplicate prepared/result artifacts' };
|
||||
const pendingName = pendingArtifactName(issue, run);
|
||||
const pendingArtifacts = artifacts.filter((artifact) => artifact.name === pendingName && !artifact.expired);
|
||||
if (preparedArtifacts.length > 1 || resultArtifacts.length > 1 || pendingArtifacts.length > 1) {
|
||||
return { ...run, evidenceError: 'duplicate prepared/result/pending artifacts' };
|
||||
}
|
||||
const pending = pendingArtifacts.length === 1 ? loadSealedArtifact(repo, run, pendingArtifacts[0], 'pending.json') : null;
|
||||
if (pending && (String(pending.issue) !== String(issue.number) || String(pending.run_id) !== String(run.id))) {
|
||||
return { ...run, evidenceError: 'pending marker belongs to another issue/run' };
|
||||
}
|
||||
return {
|
||||
...run,
|
||||
@@ -268,6 +312,8 @@ function hydrateRunEvidence(repo, issue, run) {
|
||||
resultArtifact: resultArtifacts.length === 1,
|
||||
prepared: preparedArtifacts.length === 1
|
||||
? loadPreparedArtifact(repo, run, preparedArtifacts[0]) : null,
|
||||
pending,
|
||||
pendingValidate: pending ? validateStateOnMaterial(validateRunsOnSha(repo, pending.material_sha)) : null,
|
||||
};
|
||||
} catch (error) {
|
||||
return { ...run, evidenceError: error instanceof Error ? error.message : String(error) };
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env node
|
||||
// #636: продолжение раунда ревью по событию завершения Validate.
|
||||
//
|
||||
// До #636 стадия `prepare` конвейера ждала Validate с мутантами на материале
|
||||
// внутри job: раннер спал ≈ 28 минут на раунд при 10–12 минутах работы модели
|
||||
// и упирался в бюджет стадии. Теперь `prepare` диспатчит прогон, кладёт
|
||||
// запечатанный маркер `review-pending-<issue>-<run>-<attempt>` и выходит.
|
||||
// Этот скрипт запускает `process-resume.yml` на `workflow_run: completed`
|
||||
// Validate и делает ровно одно: если раунд действительно ждёт этот прогон —
|
||||
// переставляет метку S7, и обычный контроллер продолжает с завершённым
|
||||
// dispatch на руках. Ничего не оценивает и не публикует: вердикт Validate
|
||||
// (зелёный или красный) разбирает новый прогон `prepare`.
|
||||
//
|
||||
// Без маркера ожидания будить раунд нельзя: «успешный прогон без вердикта»
|
||||
// иначе неотличим от потерянного запроса, а лишняя метка — это второй вызов
|
||||
// модели. Страховка на потерянное событие — process-reconcile (тот же маркер).
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { appendFileSync } from 'node:fs';
|
||||
import { isMainModule } from './spawn-portable.mjs';
|
||||
import {
|
||||
ACTIVE_RUN_STATES, artifactNames, loadSealedArtifact, parseProcessRun, pendingArtifactName,
|
||||
processRuns, relabel,
|
||||
} from './process-reconcile.mjs';
|
||||
|
||||
export const REVIEW_LABEL = 'S7-code-review';
|
||||
const STOP_LABELS = ['blocked', 'review-4'];
|
||||
|
||||
/** `issue/612-view-conflict` → 612; иначе null. */
|
||||
export function issueNumberFromBranch(branch) {
|
||||
const match = /^issue\/(\d+)-/.exec(String(branch || ''));
|
||||
return match ? Number(match[1]) : null;
|
||||
}
|
||||
|
||||
const at = (value) => {
|
||||
const parsed = Date.parse(String(value || ''));
|
||||
return Number.isFinite(parsed) ? parsed : NaN;
|
||||
};
|
||||
|
||||
/**
|
||||
* Чистое решение: будить раунд или нет.
|
||||
*
|
||||
* @param {object} p
|
||||
* @param {string[]} p.labels метки issue
|
||||
* @param {object} p.validateRun завершённый прогон Validate: { event, status, headSha }
|
||||
* @param {string} p.sha SHA материала, на котором завершился Validate
|
||||
* @param {object[]} p.runs прогоны конвейера этой issue (parseProcessRun-совместимые)
|
||||
* @param {(run) => object|null} p.pendingOf маркер ожидания прогона либо null
|
||||
* @returns {{action:'resume'|'noop', reason:string, run?:object}}
|
||||
*/
|
||||
export function decideResume({ labels = [], validateRun, sha, runs = [], pendingOf = () => null }) {
|
||||
if (validateRun?.event !== 'workflow_dispatch') return { action: 'noop', reason: 'not a dispatch run — push runs carry no mutants' };
|
||||
if (validateRun?.status !== 'completed') return { action: 'noop', reason: 'validate run is not completed' };
|
||||
if (validateRun?.headSha && sha && validateRun.headSha !== sha) return { action: 'noop', reason: 'validate run head differs from the material' };
|
||||
if (!labels.includes(REVIEW_LABEL)) return { action: 'noop', reason: 'issue is not awaiting code review' };
|
||||
const stop = STOP_LABELS.find((label) => labels.includes(label));
|
||||
if (stop) return { action: 'noop', reason: `owner stopped the review (${stop})` };
|
||||
const mine = runs.map((run) => run.issue ? run : parseProcessRun(run)).filter(Boolean)
|
||||
.filter((run) => run.label === REVIEW_LABEL)
|
||||
.sort((a, b) => at(b.createdAt) - at(a.createdAt) || Number(b.id) - Number(a.id));
|
||||
if (mine.some((run) => ACTIVE_RUN_STATES.has(run.status))) return { action: 'noop', reason: 'a process run for this issue is already active' };
|
||||
const latest = mine[0];
|
||||
if (!latest) return { action: 'noop', reason: 'no process run for this issue — reconcile owns lost requests' };
|
||||
if (latest.status !== 'completed' || latest.conclusion !== 'success') {
|
||||
return { action: 'noop', reason: `latest process run is ${latest.status}/${latest.conclusion || 'none'} — nothing was left pending` };
|
||||
}
|
||||
const pending = pendingOf(latest);
|
||||
if (!pending) return { action: 'noop', reason: 'latest process run left no pending marker — it did not wait for Validate' };
|
||||
if (String(pending.material_sha) !== String(sha)) {
|
||||
return { action: 'noop', reason: `pending marker waits for ${String(pending.material_sha).slice(0, 8)}, not ${String(sha).slice(0, 8)}` };
|
||||
}
|
||||
return { action: 'resume', reason: 'the round was waiting for exactly this Validate run', run: latest };
|
||||
}
|
||||
|
||||
function gh(args) {
|
||||
return execFileSync('gh', args, { encoding: 'utf8' });
|
||||
}
|
||||
|
||||
export async function resume({ repo, branch, sha, validateRun, apply = true, ops = null }) {
|
||||
const issue = issueNumberFromBranch(branch);
|
||||
if (!issue) return { action: 'noop', reason: `branch ${branch} is not an issue branch`, issue: null };
|
||||
const io = ops || {
|
||||
labels: () => JSON.parse(gh(['issue', 'view', String(issue), '--repo', repo, '--json', 'labels'])).labels.map((label) => label.name),
|
||||
runs: () => processRuns(repo, []),
|
||||
pendingOf: (run) => {
|
||||
const name = pendingArtifactName(issue, run);
|
||||
const artifact = artifactNames(repo, run).find((item) => item.name === name && !item.expired);
|
||||
if (!artifact) return null;
|
||||
const pending = loadSealedArtifact(repo, run, artifact, 'pending.json');
|
||||
return String(pending.issue) === String(issue) && String(pending.run_id) === String(run.id) ? pending : null;
|
||||
},
|
||||
relabel: () => relabel(repo, { number: issue }, REVIEW_LABEL),
|
||||
};
|
||||
const labels = io.labels();
|
||||
const runs = io.runs().filter((run) => run.issue === issue);
|
||||
const decision = decideResume({ labels, validateRun, sha, runs, pendingOf: io.pendingOf });
|
||||
if (decision.action === 'resume' && apply) io.relabel();
|
||||
return { ...decision, issue, applied: decision.action === 'resume' && apply };
|
||||
}
|
||||
|
||||
if (isMainModule(import.meta.url)) {
|
||||
const arg = (name) => process.argv.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3);
|
||||
const repo = arg('repo') || process.env.GITHUB_REPOSITORY;
|
||||
const branch = arg('branch');
|
||||
const sha = arg('sha');
|
||||
if (!repo || !branch || !sha) {
|
||||
console.error('usage: process-resume.mjs --repo=<owner/repo> --branch=<issue/NN-slug> --sha=<sha> --event=<event> --status=<status> [--apply=false]');
|
||||
process.exit(2);
|
||||
}
|
||||
const outcome = await resume({
|
||||
repo, branch, sha,
|
||||
validateRun: { event: arg('event'), status: arg('status') || 'completed', headSha: sha },
|
||||
apply: arg('apply') !== 'false',
|
||||
});
|
||||
const lines = [`action=${outcome.action}`, `issue=${outcome.issue ?? ''}`, `reason=${outcome.reason}`, `applied=${outcome.applied ? 'true' : 'false'}`];
|
||||
for (const line of lines) console.log(line);
|
||||
if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join('\n')}\n`);
|
||||
}
|
||||
@@ -7,10 +7,15 @@
|
||||
* доказательство для ревью — dispatch-прогон на точном SHA материала. Push-
|
||||
* прогон на том же SHA зелёный не считается: в нём мутантов нет.
|
||||
*
|
||||
* node scripts/validate-gate.mjs --repo=<owner/repo> --ref=<ветка> --sha=<sha> [--workflow=validate.yml]
|
||||
* node scripts/validate-gate.mjs --repo=<owner/repo> --ref=<ветка> --sha=<sha> [--workflow=validate.yml] [--no-wait]
|
||||
*
|
||||
* Печатает `result=green|failed|missing` и `url=…` (и в $GITHUB_OUTPUT, если он
|
||||
* задан); код выхода 0 только при green. Логика — чистая функция `validateGate`
|
||||
* Печатает `result=green|failed|missing|pending`, `url=…`, `run_id=…` (и в
|
||||
* $GITHUB_OUTPUT, если он задан); код выхода 0 только при green, 2 — pending.
|
||||
* `--no-wait` (#636): гейт диспатчит прогон и убеждается, что тот появился на
|
||||
* материале, но не ждёт его завершения — раннер конвейера освобождается, а
|
||||
* раунд продолжает `process-resume.yml` по событию `workflow_run` (страховка —
|
||||
* reconcile). Зелёный или красный завершённый прогон и с `--no-wait`
|
||||
* возвращается сразу. Логика — чистая функция `validateGate`
|
||||
* поверх инъектируемых `ops`, чтобы тесты и мутанты гоняли её без gh.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
@@ -58,9 +63,14 @@ export function provesMutants(jobs) {
|
||||
* @param {string} p.ref ветка, на которой запускать
|
||||
* @param {string} p.sha SHA материала
|
||||
* @param {object} p.ops GitHub run/proof operations plus dispatch, sleep and clock.
|
||||
* @returns {Promise<{result:'green'|'failed'|'missing', url:string|null, note:string}>}
|
||||
* @param {boolean} [p.wait] `false` — не ждать идущий прогон, а вернуть `pending` (#636):
|
||||
* раннер конвейера не спит 28 минут; продолжение разбудит событие
|
||||
* `workflow_run` (process-resume.yml) либо reconcile.
|
||||
* @returns {Promise<{result:'green'|'failed'|'missing'|'pending', url:string|null, note:string, runId?:number}>}
|
||||
*/
|
||||
export async function validateGate({ ref, sha, ops, appearMs = VALIDATE_APPEAR_MS, totalMs = VALIDATE_TOTAL_MS, pollMs = POLL_MS }) {
|
||||
export async function validateGate({
|
||||
ref, sha, ops, appearMs = VALIDATE_APPEAR_MS, totalMs = VALIDATE_TOTAL_MS, pollMs = POLL_MS, wait = true,
|
||||
}) {
|
||||
const started = ops.now();
|
||||
const candidateTree = await ops.candidateTree(sha);
|
||||
const ignored = new Set(); // завершённые dispatch без применимого proof
|
||||
@@ -84,6 +94,13 @@ export async function validateGate({ ref, sha, ops, appearMs = VALIDATE_APPEAR_M
|
||||
tracked = null;
|
||||
continue;
|
||||
}
|
||||
if (!wait) {
|
||||
// #636: прогон найден и идёт — ждать его будет событие, не раннер.
|
||||
return {
|
||||
result: 'pending', url: run.url || null, runId: run.databaseId,
|
||||
note: `Validate с мутантами идёт (${run.status}); продолжение — по завершении прогона`,
|
||||
};
|
||||
}
|
||||
} else if (dispatchedAt === null) {
|
||||
await ops.dispatch(ref);
|
||||
dispatchedAt = ops.now();
|
||||
@@ -151,9 +168,11 @@ if (invokedDirectly) {
|
||||
console.error('usage: validate-gate.mjs --repo=<owner/repo> --ref=<branch> --sha=<sha> [--workflow=validate.yml]');
|
||||
process.exit(2);
|
||||
}
|
||||
const outcome = await validateGate({ ref, sha, ops: realOps({ repo, workflow: arg('workflow') || 'validate.yml' }) });
|
||||
const lines = [`result=${outcome.result}`, `url=${outcome.url || ''}`, `note=${outcome.note}`];
|
||||
const wait = !process.argv.includes('--no-wait');
|
||||
const outcome = await validateGate({ ref, sha, wait, ops: realOps({ repo, workflow: arg('workflow') || 'validate.yml' }) });
|
||||
const lines = [`result=${outcome.result}`, `url=${outcome.url || ''}`, `run_id=${outcome.runId || ''}`, `note=${outcome.note}`];
|
||||
for (const line of lines) console.log(line);
|
||||
if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join('\n')}\n`);
|
||||
process.exit(outcome.result === 'green' ? 0 : 1);
|
||||
// 0 — зелёный, 2 — идёт (только с --no-wait), 1 — красный/пропавший.
|
||||
process.exit(outcome.result === 'green' ? 0 : outcome.result === 'pending' ? 2 : 1);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user