mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 04:09:17 +00:00
ci: merge the exact candidate; nightly waits for its Validate
scripts/merge-candidate.mjs owns the review pipeline's merge: when dev moved during review, the rebased candidate is pushed to the issue branch, its diff is compared to the reviewed one by patch-id, Validate on that SHA is awaited, and only then dev is advanced with --force-with-lease on the base the candidate was built on — a rejected lease restarts, at most three times. Every non-merge outcome moves the label with a comment, so the "label always changes" invariant holds. nightly.yml now finds the Validate run it dispatched and inherits its conclusion. Three mutants guard this. Issue: #492 User-Visible: no
This commit is contained in:
@@ -13,6 +13,11 @@
|
||||
# Красный ночной прогон — сигнал автору последних коммитов на dev, не гейт:
|
||||
# гейт беты по-прежнему требует зелёный Validate на точном SHA кандидата, и
|
||||
# там полный набор идёт заново.
|
||||
#
|
||||
# Сигнал обязан быть настоящим (#492 §7): до этой задачи job завершалась
|
||||
# успехом в момент постановки Validate в очередь, и красный полный прогон не
|
||||
# делал ночной workflow красным. Теперь job находит запущенный прогон и ждёт
|
||||
# его: успешный dispatch — не успешная проверка.
|
||||
name: Ночной полный прогон dev
|
||||
|
||||
on:
|
||||
@@ -26,13 +31,34 @@ permissions:
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
name: "Запустить Validate на dev с полным набором"
|
||||
name: "Запустить Validate на dev с полным набором и дождаться результата"
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
since=$(date -u +%FT%TZ)
|
||||
gh workflow run validate.yml --repo "$REPO" --ref dev -f full=true
|
||||
echo "Validate(dev, full=true) поставлен в очередь: $(date -u +%FT%TZ)"
|
||||
echo "Validate(dev, full=true) поставлен в очередь: $since"
|
||||
# Найти именно этот прогон: workflow_dispatch на dev, созданный не
|
||||
# раньше момента запуска. До трёх минут на появление в списке.
|
||||
run_id=""
|
||||
for _ in $(seq 1 18); do
|
||||
sleep 10
|
||||
run_id=$(gh run list --repo "$REPO" --workflow validate.yml --branch dev \
|
||||
--event workflow_dispatch --json databaseId,createdAt --limit 5 \
|
||||
--jq "[.[] | select(.createdAt >= \"$since\")] | sort_by(.createdAt) | last | .databaseId // empty")
|
||||
[ -n "$run_id" ] && break
|
||||
done
|
||||
if [ -z "$run_id" ]; then
|
||||
echo "::error::прогон Validate не появился за 3 минуты — dispatch не равен проверке"
|
||||
exit 1
|
||||
fi
|
||||
url="${{ github.server_url }}/$REPO/actions/runs/$run_id"
|
||||
echo "дочерний прогон: $url"
|
||||
echo "- Validate(dev, full=true): $url" >> "$GITHUB_STEP_SUMMARY"
|
||||
# Ждём завершения; красный дочерний прогон — красный ночной.
|
||||
gh run watch "$run_id" --repo "$REPO" --exit-status --interval 30
|
||||
|
||||
@@ -1012,16 +1012,19 @@ jobs:
|
||||
# S8-merged утверждает, что код в dev. Значит слияние обязано произойти
|
||||
# ДО метки, иначе она врёт в промежутке.
|
||||
#
|
||||
# При конфликте шаг НЕ падает и метку не оставляет на месте. Первая
|
||||
# редакция делала именно так, и это оказалось тупиком: автор ждёт смену
|
||||
# метки, метка не менялась, и он тридцать раз опрашивал впустую, чтобы
|
||||
# затем отчитаться «лимит исчерпан» — при зелёном вердикте. Инвариант
|
||||
# теперь жёстче: ПОСЛЕ ПРОГОНА РЕВЬЮ МЕТКА МЕНЯЕТСЯ ВСЕГДА.
|
||||
# Слияние — точный кандидат (#492 §4, scripts/merge-candidate.mjs):
|
||||
# ветка сверяется с материалом (#312); если dev не двигался — push с
|
||||
# lease на текущую вершину; если двигался — ребейз, сравнение patch-id
|
||||
# с проверенным диффом, публикация кандидата в ветку, ожидание
|
||||
# Validate на этом SHA и только потом push в dev с lease. Повторное
|
||||
# движение dev — новая попытка, не более трёх. Каждый исход, кроме
|
||||
# успеха, ведёт в S6-in-progress/S7-code-review с комментарием, ПОСЛЕ
|
||||
# ПРОГОНА МЕТКА МЕНЯЕТСЯ ВСЕГДА — инвариант тот же, что и раньше.
|
||||
- name: Слить ветку в dev
|
||||
id: merge
|
||||
if: needs.guard.outputs.stage == 'code' && steps.decide.outputs.green == 'true'
|
||||
env:
|
||||
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
HP_PROCESS_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
@@ -1032,58 +1035,8 @@ jobs:
|
||||
echo "merged=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
git fetch -q origin dev "$BRANCH"
|
||||
# #312: сливается только проверенный код. Допустимые вершины ветки:
|
||||
# сам SHA материала либо он же плюс ровно один коммит публикации
|
||||
# документа ревью (дифф только docs/reviews/). Любой другой коммит —
|
||||
# ветка уехала после ревью, вердикт к ней не применим: возврат в
|
||||
# S6-in-progress через merged=false, как при конфликте.
|
||||
actual=$(git rev-parse "origin/$BRANCH")
|
||||
reviewed="$MATERIAL_SHA"
|
||||
fresh=false
|
||||
if [ "$actual" = "$reviewed" ]; then
|
||||
fresh=true
|
||||
elif [ "$(git rev-parse "$actual^" 2>/dev/null)" = "$reviewed" ] \
|
||||
&& [ -z "$(git diff --name-only "$reviewed" "$actual" -- . ':!docs/reviews')" ]; then
|
||||
fresh=true
|
||||
fi
|
||||
if [ "$fresh" != true ]; then
|
||||
echo "merged=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning::ветка $BRANCH уехала после проверенного SHA $reviewed (сейчас $actual) — слияние отменено (#312)"
|
||||
cat > /tmp/stale-verdict.md <<EOF
|
||||
**Слияние отменено: ветка изменилась после проверенного материала (#312).**
|
||||
|
||||
Ревью выполнялось на \`$(git rev-parse --short "$reviewed")\`, а вершина ветки сейчас \`$(git rev-parse --short "$actual")\` — в ней есть коммиты, которых вердикт не покрывает. Зелёный вердикт остаётся в силе только для проверенного SHA.
|
||||
|
||||
Задача переведена в \`S6-in-progress\`. Дальше: убедиться, что вершина ветки — именно то, что должно ехать в dev, и вернуть метку \`S7-code-review\`. Если вершина отличается от проверенного материала только коммитами публикации документов ревью, новый заход применит зелёный вердикт повторно без вызова модели (#499).
|
||||
EOF
|
||||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/stale-verdict.md
|
||||
exit 0
|
||||
fi
|
||||
git checkout -q -B merge-into-dev "$actual"
|
||||
if ! git -c user.name="claude[bot]" \
|
||||
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
|
||||
rebase origin/dev; then
|
||||
git rebase --abort || true
|
||||
echo "merged=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning::ветка $BRANCH не сливается в dev без конфликта"
|
||||
cat > /tmp/conflict.md <<EOF
|
||||
**Код-ревью зелёное — вердикт выше в силе, переделывать работу не нужно.** Не удалось только слияние: ветка \`$BRANCH\` конфликтует с \`dev\`.
|
||||
|
||||
Задача переведена в \`S6-in-progress\`, потому что работа вернулась к автору. Осталась не правка кода, а ребейз:
|
||||
|
||||
1. \`git fetch origin\`, затем \`git rebase origin/dev\` в ветке задачи, разрешить конфликт;
|
||||
2. запушить ветку;
|
||||
3. вернуть метку \`S7-code-review\`.
|
||||
|
||||
Повторный прогон ревью — не формальность: после ребейза на новый \`dev\` это другой код, и принимать его без проверки нельзя. Цикл считается по этапу, лимит на код-ревью тратится отдельно от ревью ТЗ.
|
||||
EOF
|
||||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/conflict.md
|
||||
exit 0
|
||||
fi
|
||||
git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" HEAD:dev
|
||||
echo "merged=true" >> "$GITHUB_OUTPUT"
|
||||
echo "слито в dev: $(git rev-parse --short HEAD)"
|
||||
node scripts/merge-candidate.mjs --branch="$BRANCH" --material="$MATERIAL_SHA" \
|
||||
--issue="$NUM" --repo="${{ github.repository }}"
|
||||
|
||||
- name: Переставить метку
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
@@ -1094,7 +1047,10 @@ jobs:
|
||||
FROM: ${{ steps.decide.outputs.from }}
|
||||
# Зелёное код-ревью без слияния ведёт не в S8-merged, а обратно к
|
||||
# автору: метка утверждала бы, что код в dev, а его там нет.
|
||||
TO: ${{ (needs.guard.outputs.stage == 'code' && steps.decide.outputs.green == 'true' && steps.merge.outputs.merged != 'true') && 'S6-in-progress' || steps.decide.outputs.to }}
|
||||
# Точный кандидат (#492) сам называет исход: S8 после push, S6/S7 —
|
||||
# когда кандидат не слит (конфликт, красный Validate, изменившийся
|
||||
# patch-id, ушедший dev). Без исхода от скрипта — как раньше: S6.
|
||||
TO: ${{ (needs.guard.outputs.stage == 'code' && steps.decide.outputs.green == 'true' && steps.merge.outputs.to != '') && steps.merge.outputs.to || ((needs.guard.outputs.stage == 'code' && steps.decide.outputs.green == 'true' && steps.merge.outputs.merged != 'true') && 'S6-in-progress' || steps.decide.outputs.to) }}
|
||||
run: |
|
||||
gh issue edit "$NUM" --repo "${{ github.repository }}" \
|
||||
--add-label "$TO" --remove-label "$FROM"
|
||||
|
||||
Executable
+244
@@ -0,0 +1,244 @@
|
||||
#!/usr/bin/env node
|
||||
// Слияние точного кандидата (#492 §4).
|
||||
//
|
||||
// До этой задачи шаг «Слить ветку в dev» ревью-конвейера после зелёного
|
||||
// вердикта делал `git rebase origin/dev` и сразу `push HEAD:dev`. Если `dev`
|
||||
// продвинулся за время ревью (28 августа — четыре раза за день), в `dev`
|
||||
// уезжало дерево, которого не видел никто: ни ревью (материал другой), ни
|
||||
// Validate (на этот SHA он не бежал). Чистый ребейз не доказывает
|
||||
// совместимость: соседняя правка в `dev` меняет поведение без единого
|
||||
// конфликта (эксперимент аудита: 20 → 40).
|
||||
//
|
||||
// Правило теперь: в `dev` попадает только SHA, для которого есть зелёный
|
||||
// Validate, и попадает атомарно — `--force-with-lease` на ту вершину `dev`,
|
||||
// поверх которой кандидат собран. Движение `dev` во время проверки не
|
||||
// перезаписывает чужое: lease отклоняется, кандидат собирается заново, не
|
||||
// более трёх раз. Дифф, изменившийся при ребейзе (patch-id), — не предмет
|
||||
// этого шага: вердикт к нему не применим, задача возвращается на ревью.
|
||||
//
|
||||
// Решение (`decideMerge`) отделено от git и gh (`ops`), чтобы таблица
|
||||
// случаев §8.4 была юнит-тестом, а не верой в shell.
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { appendFileSync } from 'node:fs';
|
||||
|
||||
export const MAX_ATTEMPTS = 3;
|
||||
export const VALIDATE_APPEAR_MS = 3 * 60 * 1000;
|
||||
export const VALIDATE_TOTAL_MS = 45 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Чистое решение по состоянию одной попытки. Возвращает действие и, где
|
||||
* применимо, статусную метку, к которой ведёт это действие.
|
||||
*
|
||||
* @param {object} s
|
||||
* @param {boolean} s.fresh вершина ветки = материал (+ документ ревью), #312
|
||||
* @param {boolean} s.devMoved dev не равен базе материала
|
||||
* @param {boolean} s.conflict ребейз на dev упал
|
||||
* @param {boolean} s.patchIdEqual дифф после ребейза совпадает с проверенным
|
||||
* @param {'green'|'red'|'missing'|null} s.validate результат Validate на кандидате
|
||||
* @param {boolean} s.leaseRejected push в dev отклонён: dev двинулся снова
|
||||
* @param {number} s.attempt номер попытки, с 1
|
||||
*/
|
||||
export function decideMerge(s) {
|
||||
if (!s.fresh) return { action: 'reject-stale', to: 'S6-in-progress' };
|
||||
if (s.conflict) return { action: 'conflict', to: 'S6-in-progress' };
|
||||
if (!s.devMoved) {
|
||||
if (s.leaseRejected) return { action: 'retry' };
|
||||
return { action: 'fast-forward', to: 'S8-merged' };
|
||||
}
|
||||
if (!s.patchIdEqual) return { action: 'rereview', to: 'S7-code-review' };
|
||||
if (s.validate === null || s.validate === undefined) return { action: 'validate' };
|
||||
if (s.validate === 'missing') return { action: 'validation-missing', to: 'S6-in-progress' };
|
||||
if (s.validate === 'red') return { action: 'validation-red', to: 'S6-in-progress' };
|
||||
if (s.leaseRejected) {
|
||||
if ((s.attempt ?? 1) >= (s.maxAttempts ?? MAX_ATTEMPTS)) return { action: 'give-up', to: 'S6-in-progress' };
|
||||
return { action: 'retry' };
|
||||
}
|
||||
return { action: 'push', to: 'S8-merged' };
|
||||
}
|
||||
|
||||
/** Тексты комментариев в issue — один на исход. */
|
||||
export function commentFor(action, ctx) {
|
||||
const short = (sha) => String(sha || '').slice(0, 8);
|
||||
switch (action) {
|
||||
case 'reject-stale':
|
||||
return `**Слияние отменено: ветка изменилась после проверенного материала (#312).**\n\n`
|
||||
+ `Ревью выполнялось на \`${short(ctx.material)}\`, а вершина ветки сейчас \`${short(ctx.actual)}\` — в ней есть коммиты, которых вердикт не покрывает. Зелёный вердикт остаётся в силе только для проверенного SHA.\n\n`
|
||||
+ `Задача переведена в \`S6-in-progress\`. Дальше: убедиться, что вершина ветки — именно то, что должно ехать в dev, и вернуть метку \`S7-code-review\`. Если вершина отличается от проверенного материала только коммитами публикации документов ревью, новый заход применит зелёный вердикт повторно без вызова модели (#499).`;
|
||||
case 'conflict':
|
||||
return `**Код-ревью зелёное — вердикт выше в силе, переделывать работу не нужно.** Не удалось только слияние: ветка \`${ctx.branch}\` конфликтует с \`dev\`.\n\n`
|
||||
+ `Задача переведена в \`S6-in-progress\`, потому что работа вернулась к автору. Осталась не правка кода, а ребейз:\n\n`
|
||||
+ `1. \`git fetch origin\`, затем \`git rebase origin/dev\` в ветке задачи, разрешить конфликт;\n2. запушить ветку;\n3. вернуть метку \`S7-code-review\`.\n\n`
|
||||
+ `Повторный прогон ревью — не формальность: после ребейза на новый \`dev\` это другой код, и принимать его без проверки нельзя. Цикл считается по этапу, лимит на код-ревью тратится отдельно от ревью ТЗ.`;
|
||||
case 'rereview':
|
||||
return `**Дифф изменился при ребейзе на \`dev@${short(ctx.devNow)}\` — вердикт к нему не применим (§7.2, #492).**\n\n`
|
||||
+ `Материал ревью \`${short(ctx.material)}\` и кандидат \`${short(ctx.candidate)}\` дают разные patch-id: соседние правки в \`dev\` изменили содержимое патча. Кандидат опубликован в ветку; задача возвращена в \`S7-code-review\` — новый заход ревью читает актуальный код.`;
|
||||
case 'validation-red':
|
||||
return `**Кандидат после ребейза на \`dev@${short(ctx.devNow)}\` красный (#492).**\n\n`
|
||||
+ `Вердикт ревью на \`${short(ctx.material)}\` в силе, но точный кандидат \`${short(ctx.candidate)}\` не прошёл Validate: ${ctx.runUrl || 'прогон не найден'}. Задача переведена в \`S6-in-progress\`: разобраться с прогоном на ветке, затем вернуть \`S7-code-review\`.`;
|
||||
case 'validation-missing':
|
||||
return `**Validate на кандидате \`${short(ctx.candidate)}\` не появился за ${Math.round(VALIDATE_APPEAR_MS / 60000)} мин (#492).**\n\n`
|
||||
+ `Кандидат опубликован в ветку, но прогон не стартовал — проверьте токен конвейера и очередь Actions. Слияние без проверки не выполняется; задача в \`S6-in-progress\`, после зелёного Validate на этом SHA вернуть \`S7-code-review\`.`;
|
||||
case 'give-up':
|
||||
return `**\`dev\` движется быстрее слияния: ${ctx.attempt} попытки собрать и проверить кандидата, каждый раз \`dev\` уходил до push (#492).**\n\n`
|
||||
+ `Последний проверенный кандидат \`${short(ctx.candidate)}\` опубликован в ветку. Задача в \`S6-in-progress\`; вернуть \`S7-code-review\`, когда \`dev\` успокоится.`;
|
||||
case 'push':
|
||||
case 'fast-forward':
|
||||
return `материал \`${short(ctx.material)}\` · dev@\`${short(ctx.devNow)}\` → кандидат \`${short(ctx.candidate)}\``
|
||||
+ (action === 'push' ? ` · Validate ${ctx.runUrl} зелёный` : ' · dev не двигался')
|
||||
+ ' · слито';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Исполнение: git + gh через `ops`, чтобы тест подменял их целиком.
|
||||
|
||||
const sh = (cmd, args, opts = {}) => {
|
||||
const r = spawnSync(cmd, args, { encoding: 'utf8', ...opts });
|
||||
return { status: r.status ?? 1, stdout: (r.stdout || '').trim(), stderr: (r.stderr || '').trim() };
|
||||
};
|
||||
|
||||
export function realOps({ repo, token, workflow = 'validate.yml', sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = Date.now }) {
|
||||
const pushUrl = `https://x-access-token:${token}@github.com/${repo}`;
|
||||
const git = (...args) => sh('git', args);
|
||||
const must = (r, what) => { if (r.status !== 0) throw new Error(`${what}: ${r.stderr || r.stdout}`); return r.stdout; };
|
||||
return {
|
||||
fetch: (...refs) => must(git('fetch', '-q', 'origin', ...refs), 'git fetch'),
|
||||
revParse: (ref) => must(git('rev-parse', ref), `rev-parse ${ref}`),
|
||||
mergeBase: (a, b) => must(git('merge-base', a, b), 'merge-base'),
|
||||
diffNames: (from, to, pathspec = []) => must(git('diff', '--name-only', from, to, '--', ...pathspec), 'diff').split('\n').filter(Boolean),
|
||||
patchId: (from, to) => {
|
||||
const diff = must(git('diff', '--full-index', from, to), 'diff');
|
||||
const r = spawnSync('git', ['patch-id', '--stable'], { input: diff, encoding: 'utf8' });
|
||||
return (r.stdout || '').trim().split(' ')[0] || 'empty';
|
||||
},
|
||||
rebaseOnto: (branchTip, onto) => {
|
||||
must(git('checkout', '-q', '-B', 'merge-into-dev', branchTip), 'checkout');
|
||||
const r = spawnSync('git', ['-c', 'user.name=claude[bot]', '-c', 'user.email=209825114+claude[bot]@users.noreply.github.com', 'rebase', onto], { encoding: 'utf8' });
|
||||
if (r.status !== 0) { spawnSync('git', ['rebase', '--abort']); return null; }
|
||||
return must(git('rev-parse', 'HEAD'), 'rev-parse HEAD');
|
||||
},
|
||||
pushWithLease: (sha, ref, expected) => {
|
||||
const r = git('push', '-q', `--force-with-lease=refs/heads/${ref}:${expected}`, pushUrl, `${sha}:refs/heads/${ref}`);
|
||||
if (r.status === 0) return true;
|
||||
if (/stale info|rejected|fetch first|lease/i.test(r.stderr)) return false;
|
||||
throw new Error(`git push ${ref}: ${r.stderr}`);
|
||||
},
|
||||
waitValidate: async (sha) => {
|
||||
const started = now();
|
||||
let runId = null;
|
||||
while (now() - started < VALIDATE_TOTAL_MS) {
|
||||
const r = sh('gh', ['run', 'list', '--repo', repo, '--workflow', workflow, '--commit', sha, '--json', 'databaseId,status,conclusion,url', '--limit', '5']);
|
||||
const runs = r.status === 0 && r.stdout ? JSON.parse(r.stdout) : [];
|
||||
const run = runs.find((x) => x.databaseId === runId) || runs[0];
|
||||
if (run) {
|
||||
runId = run.databaseId;
|
||||
if (run.status === 'completed') return { result: run.conclusion === 'success' ? 'green' : 'red', url: run.url };
|
||||
} else if (now() - started > VALIDATE_APPEAR_MS) {
|
||||
return { result: 'missing', url: null };
|
||||
}
|
||||
await sleep(20_000);
|
||||
}
|
||||
return { result: 'red', url: runId ? `run ${runId} (timeout)` : null };
|
||||
},
|
||||
comment: (issue, body) => {
|
||||
const r = spawnSync('gh', ['issue', 'comment', String(issue), '--repo', repo, '--body-file', '-'], { input: body, encoding: 'utf8' });
|
||||
if (r.status !== 0) throw new Error(`gh issue comment: ${r.stderr}`);
|
||||
},
|
||||
log: (line) => console.log(line),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Слияние по алгоритму §4.2. Возвращает { merged, to, action, candidate }.
|
||||
*/
|
||||
export async function mergeCandidate({ branch, material, issue, ops, maxAttempts = MAX_ATTEMPTS }) {
|
||||
ops.fetch('dev', branch);
|
||||
const actual = ops.revParse(`origin/${branch}`);
|
||||
const reviewedFresh = actual === material
|
||||
|| (safe(() => ops.revParse(`${actual}^`)) === material
|
||||
&& ops.diffNames(material, actual, ['.', ':!docs/reviews']).length === 0);
|
||||
const ctx = { branch, material, actual, issue };
|
||||
const finish = (decision, extra = {}) => {
|
||||
const body = commentFor(decision.action, { ...ctx, ...extra, attempt: extra.attempt });
|
||||
if (body) ops.comment(issue, body);
|
||||
const merged = decision.action === 'push' || decision.action === 'fast-forward';
|
||||
ops.log(`решение: ${decision.action} → ${decision.to || '(метка по вердикту)'}`);
|
||||
return { merged, to: decision.to, action: decision.action, candidate: extra.candidate || actual };
|
||||
};
|
||||
|
||||
if (!reviewedFresh) return finish(decideMerge({ fresh: false }));
|
||||
|
||||
let tip = actual;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
ops.fetch('dev');
|
||||
const devNow = ops.revParse('origin/dev');
|
||||
const materialBase = ops.mergeBase(material, 'origin/dev');
|
||||
const devMoved = devNow !== materialBase;
|
||||
ops.log(`попытка ${attempt}: dev@${devNow.slice(0, 8)}, база материала ${materialBase.slice(0, 8)}, dev ${devMoved ? 'двигался' : 'на месте'}`);
|
||||
|
||||
if (!devMoved) {
|
||||
const pushed = ops.pushWithLease(tip, 'dev', devNow);
|
||||
const decision = decideMerge({ fresh: true, devMoved: false, leaseRejected: !pushed });
|
||||
if (decision.action === 'retry') continue;
|
||||
return finish(decision, { candidate: tip, devNow });
|
||||
}
|
||||
|
||||
const candidate = ops.rebaseOnto(tip, 'origin/dev');
|
||||
if (!candidate) return finish(decideMerge({ fresh: true, devMoved: true, conflict: true }), { devNow });
|
||||
|
||||
const patchIdEqual = ops.patchId(materialBase, material) === ops.patchId(devNow, candidate);
|
||||
// кандидат публикуется в ветку в любом случае: он и есть то, что должно
|
||||
// ехать в dev, и Validate стартует именно от этого push
|
||||
if (!ops.pushWithLease(candidate, branch, tip)) {
|
||||
// ветку задачи подвинули, пока шло ревью или ребейз — это #312, не наш случай
|
||||
return finish(decideMerge({ fresh: false }), { candidate, devNow });
|
||||
}
|
||||
tip = candidate;
|
||||
if (!patchIdEqual) return finish(decideMerge({ fresh: true, devMoved: true, patchIdEqual: false }), { candidate, devNow });
|
||||
|
||||
ops.log(`Validate на кандидате ${candidate.slice(0, 8)} — ждём`);
|
||||
const { result, url } = await ops.waitValidate(candidate);
|
||||
let decision = decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: result, attempt, maxAttempts });
|
||||
if (decision.action !== 'push') return finish(decision, { candidate, devNow, runUrl: url });
|
||||
|
||||
const pushed = ops.pushWithLease(candidate, 'dev', devNow);
|
||||
decision = decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: result, leaseRejected: !pushed, attempt, maxAttempts });
|
||||
if (decision.action === 'retry') { ops.log('dev двинулся снова — ещё попытка'); continue; }
|
||||
return finish(decision, { candidate, devNow, runUrl: url, attempt });
|
||||
}
|
||||
return finish(decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: 'green', leaseRejected: true, attempt: maxAttempts, maxAttempts }), { candidate: tip, attempt: maxAttempts });
|
||||
}
|
||||
|
||||
const safe = (fn) => { try { return fn(); } catch { return null; } };
|
||||
|
||||
const arg = (name) => process.argv.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3);
|
||||
|
||||
if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) {
|
||||
const branch = arg('branch');
|
||||
const material = arg('material');
|
||||
const issue = arg('issue');
|
||||
const repo = arg('repo') || process.env.GITHUB_REPOSITORY;
|
||||
const token = process.env.HP_PROCESS_TOKEN || process.env.TOKEN;
|
||||
if (!branch || !material || !issue || !repo || !token) {
|
||||
console.error('usage: merge-candidate.mjs --branch=<issue branch> --material=<sha> --issue=<n> [--repo=owner/name]; HP_PROCESS_TOKEN in env');
|
||||
process.exit(2);
|
||||
}
|
||||
const ops = realOps({ repo, token });
|
||||
mergeCandidate({ branch, material, issue, ops }).then((r) => {
|
||||
const out = `merged=${r.merged}\nto=${r.to || ''}\naction=${r.action}\ncandidate=${r.candidate}\n`;
|
||||
if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, out);
|
||||
process.stdout.write(out);
|
||||
}, (err) => {
|
||||
// Инвариант конвейера: после прогона метка меняется всегда. Сбой самого
|
||||
// слияния — не повод оставить задачу висеть в S7: S6 и внятный комментарий.
|
||||
console.error(err);
|
||||
try {
|
||||
ops.comment(issue, `**Слияние не выполнено: сбой шага слияния (#492).**\n\n\`\`\`\n${String(err && err.message || err).slice(0, 1500)}\n\`\`\`\n\nВердикт ревью в силе. Задача в \`S6-in-progress\`; после разбора сбоя вернуть \`S7-code-review\`.`);
|
||||
} catch (e) { console.error(e); }
|
||||
if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, 'merged=false\nto=S6-in-progress\naction=error\n');
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
@@ -3615,6 +3615,39 @@ const MUTANT_DEFINITIONS = [
|
||||
replace: ' byRegistry = []; // mutant: definitions never select',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'merge-pushes-unvalidated-candidate',
|
||||
guard: 'node --test --test-name-pattern="эксперимент аудита" test/merge-candidate.test.mjs',
|
||||
because: 'after a clean rebase onto a moved dev the candidate is a tree nobody checked; pushing '
|
||||
+ 'it without a green Validate on that SHA is the false-green the audit reproduced (#492 §4)',
|
||||
patches: [{
|
||||
file: 'scripts/merge-candidate.mjs',
|
||||
find: ' const { result, url } = await ops.waitValidate(candidate);',
|
||||
replace: " const { result, url } = { result: 'green', url: 'skipped' }; // mutant: no validation",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'merge-ignores-lease-rejection',
|
||||
guard: 'node --test --test-name-pattern="lease отклонён" test/merge-candidate.test.mjs',
|
||||
because: 'dev moving again between Validate and push must restart the candidate, not be '
|
||||
+ 'reported as merged: a rejected lease treated as success leaves S8-merged on code not in dev (#492 AC2)',
|
||||
patches: [{
|
||||
file: 'scripts/merge-candidate.mjs',
|
||||
find: " const pushed = ops.pushWithLease(candidate, 'dev', devNow);\n decision = decideMerge(",
|
||||
replace: " const pushed = ops.pushWithLease(candidate, 'dev', devNow) || true; // mutant: lease never rejected\n decision = decideMerge(",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'nightly-does-not-wait',
|
||||
guard: 'node --test --test-name-pattern="nightly ждёт запущенный Validate" test/nightly-workflow.test.mjs',
|
||||
because: 'a nightly that returns green at dispatch time hides a red full run; the job must wait '
|
||||
+ 'for the child and inherit its conclusion (#492 §7)',
|
||||
patches: [{
|
||||
file: '.github/workflows/nightly.yml',
|
||||
find: ' gh run watch "$run_id" --repo "$REPO" --exit-status --interval 30',
|
||||
replace: ' echo "watching skipped" # mutant: dispatch counted as success',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'changed-selection-ignores-guard-files',
|
||||
guard: 'node --test --test-name-pattern="#475 AC2" test/mutation-gate.test.mjs',
|
||||
|
||||
Executable
+222
@@ -0,0 +1,222 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { MAX_ATTEMPTS, commentFor, decideMerge, mergeCandidate, realOps } from '../scripts/merge-candidate.mjs';
|
||||
|
||||
// #492 §4 / §8.4: слияние точного кандидата. Таблица решений — на чистой
|
||||
// функции; последовательность операций — на фальшивых git/gh; эксперимент
|
||||
// аудита «20 → 40» — на настоящем git в temp-репозитории.
|
||||
|
||||
test('§8.4 таблица решений decideMerge', () => {
|
||||
assert.deepEqual(decideMerge({ fresh: false }), { action: 'reject-stale', to: 'S6-in-progress' });
|
||||
assert.deepEqual(decideMerge({ fresh: true, conflict: true }), { action: 'conflict', to: 'S6-in-progress' });
|
||||
// dev не двигался — fast-forward с lease
|
||||
assert.deepEqual(decideMerge({ fresh: true, devMoved: false }), { action: 'fast-forward', to: 'S8-merged' });
|
||||
assert.deepEqual(decideMerge({ fresh: true, devMoved: false, leaseRejected: true }), { action: 'retry' });
|
||||
// dev двигался: сначала patch-id, потом Validate, потом push с lease
|
||||
assert.deepEqual(decideMerge({ fresh: true, devMoved: true, patchIdEqual: false }), { action: 'rereview', to: 'S7-code-review' });
|
||||
assert.deepEqual(decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: null }), { action: 'validate' });
|
||||
assert.deepEqual(decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: 'missing' }), { action: 'validation-missing', to: 'S6-in-progress' });
|
||||
assert.deepEqual(decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: 'red' }), { action: 'validation-red', to: 'S6-in-progress' });
|
||||
assert.deepEqual(decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: 'green' }), { action: 'push', to: 'S8-merged' });
|
||||
assert.deepEqual(decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: 'green', leaseRejected: true, attempt: 1 }), { action: 'retry' });
|
||||
assert.deepEqual(decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: 'green', leaseRejected: true, attempt: 2 }), { action: 'retry' });
|
||||
assert.deepEqual(decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: 'green', leaseRejected: true, attempt: MAX_ATTEMPTS }), { action: 'give-up', to: 'S6-in-progress' });
|
||||
// ни один исход не ведёт в S8 без зелёного Validate при движении dev
|
||||
for (const validate of [null, 'missing', 'red']) {
|
||||
assert.notEqual(decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate }).to, 'S8-merged', String(validate));
|
||||
}
|
||||
assert.equal(MAX_ATTEMPTS, 3);
|
||||
});
|
||||
|
||||
test('каждый исход, меняющий метку, объясняется комментарием; успех — одной строкой', () => {
|
||||
const ctx = { material: 'a'.repeat(40), actual: 'b'.repeat(40), candidate: 'c'.repeat(40), devNow: 'd'.repeat(40), branch: 'issue/1-x', runUrl: 'https://run', attempt: 3 };
|
||||
for (const action of ['reject-stale', 'conflict', 'rereview', 'validation-red', 'validation-missing', 'give-up']) {
|
||||
const body = commentFor(action, ctx);
|
||||
assert.ok(body.length > 80, action);
|
||||
assert.match(body, /S6-in-progress|S7-code-review/, action);
|
||||
}
|
||||
assert.match(commentFor('push', ctx), /^материал `aaaaaaaa` · dev@`dddddddd` → кандидат `cccccccc` · Validate https:\/\/run зелёный · слито$/);
|
||||
assert.match(commentFor('fast-forward', ctx), /dev не двигался · слито$/);
|
||||
assert.equal(commentFor('validate', ctx), '');
|
||||
});
|
||||
|
||||
/**
|
||||
* Фальшивые git/gh: `base` — merge-base материала с dev, `devTips` — вершины
|
||||
* dev по порядку (следующая после каждого отклонённого lease), ответы
|
||||
* Validate — по порядку кандидатов.
|
||||
*/
|
||||
function fakeOps({ base = 'dev0', devTips = ['dev0'], validate = [], leaseRejects = 0, patchIds = {}, branchTip, material, conflictOnce = false }) {
|
||||
const calls = [];
|
||||
let devIndex = 0;
|
||||
let validateIndex = 0;
|
||||
let rejects = leaseRejects;
|
||||
let conflict = conflictOnce;
|
||||
const dev = () => devTips[Math.min(devIndex, devTips.length - 1)];
|
||||
return {
|
||||
calls,
|
||||
fetch: (...refs) => { calls.push(['fetch', ...refs]); },
|
||||
revParse: (ref) => {
|
||||
if (ref === 'origin/dev') return dev();
|
||||
if (ref.startsWith('origin/issue')) return branchTip;
|
||||
if (ref.endsWith('^')) return material;
|
||||
return ref;
|
||||
},
|
||||
mergeBase: () => base,
|
||||
diffNames: () => [],
|
||||
patchId: (from, to) => patchIds[`${from}..${to}`] || 'same',
|
||||
rebaseOnto: (tip, onto) => {
|
||||
calls.push(['rebase', tip, onto]);
|
||||
if (conflict) { conflict = false; return null; }
|
||||
return `cand-${tip}-on-${dev()}`;
|
||||
},
|
||||
pushWithLease: (sha, ref, expected) => {
|
||||
calls.push(['push', sha, ref, expected]);
|
||||
if (ref === 'dev' && rejects > 0) { rejects -= 1; devIndex += 1; return false; }
|
||||
return true;
|
||||
},
|
||||
waitValidate: async (sha) => {
|
||||
calls.push(['validate', sha]);
|
||||
const result = validate[Math.min(validateIndex, validate.length - 1)] || 'green';
|
||||
validateIndex += 1;
|
||||
return { result, url: `https://run/${sha}` };
|
||||
},
|
||||
comment: (issue, body) => { calls.push(['comment', body.split('\n')[0]]); },
|
||||
log: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
test('dev не двигался: push кандидата как есть, с lease на текущий dev', async () => {
|
||||
const ops = fakeOps({ devTips: ['dev0'], branchTip: 'mat', material: 'mat' });
|
||||
const r = await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops });
|
||||
assert.equal(r.action, 'fast-forward');
|
||||
assert.equal(r.merged, true);
|
||||
assert.deepEqual(ops.calls.filter((c) => c[0] === 'push'), [['push', 'mat', 'dev', 'dev0']]);
|
||||
assert.ok(!ops.calls.some((c) => c[0] === 'validate'), 'без движения dev Validate не ждётся');
|
||||
});
|
||||
|
||||
test('эксперимент аудита: dev двигался, ребейз чистый, patch-id равен — Validate ОБЯЗАТЕЛЕН до push', async () => {
|
||||
const ops = fakeOps({ devTips: ['dev1'], branchTip: 'mat', material: 'mat' });
|
||||
const r = await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops });
|
||||
assert.equal(r.action, 'push');
|
||||
assert.equal(r.merged, true);
|
||||
const order = ops.calls.map((c) => c[0]);
|
||||
const validateAt = order.indexOf('validate');
|
||||
const devPushAt = ops.calls.findIndex((c) => c[0] === 'push' && c[2] === 'dev');
|
||||
assert.ok(validateAt >= 0 && validateAt < devPushAt, `Validate (${validateAt}) раньше push в dev (${devPushAt})`);
|
||||
// кандидат сначала опубликован в ветку (от этого push стартует Validate)
|
||||
assert.deepEqual(ops.calls.find((c) => c[0] === 'push'), ['push', 'cand-mat-on-dev1', 'issue/1-x', 'mat']);
|
||||
assert.deepEqual(ops.calls.find((c) => c[0] === 'push' && c[2] === 'dev'), ['push', 'cand-mat-on-dev1', 'dev', 'dev1']);
|
||||
});
|
||||
|
||||
test('красный Validate на кандидате — S6, без push в dev', async () => {
|
||||
const ops = fakeOps({ devTips: ['dev1'], branchTip: 'mat', material: 'mat', validate: ['red'] });
|
||||
const r = await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops });
|
||||
assert.equal(r.action, 'validation-red');
|
||||
assert.equal(r.to, 'S6-in-progress');
|
||||
assert.equal(r.merged, false);
|
||||
assert.ok(!ops.calls.some((c) => c[0] === 'push' && c[2] === 'dev'));
|
||||
});
|
||||
|
||||
test('patch-id изменился при ребейзе — S7-code-review, без Validate и без push в dev', async () => {
|
||||
const ops = fakeOps({ devTips: ['dev1'], branchTip: 'mat', material: 'mat', patchIds: { 'dev0..mat': 'p1', 'dev1..cand-mat-on-dev1': 'p2' } });
|
||||
const r = await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops });
|
||||
assert.equal(r.action, 'rereview');
|
||||
assert.equal(r.to, 'S7-code-review');
|
||||
assert.ok(!ops.calls.some((c) => c[0] === 'validate'));
|
||||
assert.ok(!ops.calls.some((c) => c[0] === 'push' && c[2] === 'dev'));
|
||||
});
|
||||
|
||||
test('dev ушёл снова после Validate: lease отклонён → новая попытка; трижды → S6', async () => {
|
||||
const twice = fakeOps({ devTips: ['dev1', 'dev2', 'dev3'], branchTip: 'mat', material: 'mat', leaseRejects: 2 });
|
||||
const ok = await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops: twice });
|
||||
assert.equal(ok.action, 'push');
|
||||
assert.equal(twice.calls.filter((c) => c[0] === 'validate').length, 3, 'каждый новый кандидат проверен заново');
|
||||
assert.equal(twice.calls.filter((c) => c[0] === 'push' && c[2] === 'dev').length, 3);
|
||||
|
||||
const always = fakeOps({ devTips: ['dev1', 'dev2', 'dev3', 'dev4'], branchTip: 'mat', material: 'mat', leaseRejects: 99 });
|
||||
const giveUp = await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops: always });
|
||||
assert.equal(giveUp.action, 'give-up');
|
||||
assert.equal(giveUp.to, 'S6-in-progress');
|
||||
assert.equal(giveUp.merged, false);
|
||||
});
|
||||
|
||||
test('ветка уехала после материала — #312, без ребейза и push', async () => {
|
||||
const ops = fakeOps({ devTips: ['dev0'], branchTip: 'other', material: 'mat' });
|
||||
ops.revParse = (ref) => (ref === 'origin/dev' ? 'dev0' : ref.startsWith('origin/issue') ? 'other' : ref.endsWith('^') ? 'zzz' : ref);
|
||||
const r = await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops });
|
||||
assert.equal(r.action, 'reject-stale');
|
||||
assert.ok(!ops.calls.some((c) => c[0] === 'push' || c[0] === 'rebase'));
|
||||
});
|
||||
|
||||
test('конфликт при ребейзе — S6 с инструкцией, без push', async () => {
|
||||
const ops = fakeOps({ devTips: ['dev1'], branchTip: 'mat', material: 'mat', conflictOnce: true });
|
||||
const r = await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops });
|
||||
assert.equal(r.action, 'conflict');
|
||||
assert.ok(!ops.calls.some((c) => c[0] === 'push'));
|
||||
});
|
||||
|
||||
// --- настоящий git: «20 → 40» -----------------------------------------------
|
||||
|
||||
const git = (cwd, ...args) => execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8' }).trim();
|
||||
|
||||
test('на настоящем git: чистый ребейз с равным patch-id и изменённым поведением идёт через Validate, не мимо', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'hp-merge-'));
|
||||
try {
|
||||
const bare = join(dir, 'origin.git');
|
||||
execFileSync('git', ['init', '-q', '--bare', bare]);
|
||||
const work = join(dir, 'work');
|
||||
execFileSync('git', ['clone', '-q', bare, work]);
|
||||
const cfg = ['-c', 'user.name=t', '-c', 'user.email=t@x'];
|
||||
const commit = (msg) => execFileSync('git', ['-C', work, ...cfg, 'commit', '-q', '-am', msg]);
|
||||
writeFileSync(join(work, 'a.mjs'), 'export const a = 20;\n');
|
||||
writeFileSync(join(work, 'b.mjs'), 'export const b = 1;\n');
|
||||
git(work, 'add', '.');
|
||||
commit('base');
|
||||
git(work, 'branch', '-M', 'dev');
|
||||
git(work, 'push', '-q', '-u', 'origin', 'dev');
|
||||
// ветка задачи: b = a * 2 (проверено ревью при a = 20 → 40)
|
||||
git(work, 'checkout', '-q', '-b', 'issue/7-double');
|
||||
writeFileSync(join(work, 'b.mjs'), "import { a } from './a.mjs';\nexport const b = a * 2;\n");
|
||||
commit('double');
|
||||
const material = git(work, 'rev-parse', 'HEAD');
|
||||
git(work, 'push', '-q', '-u', 'origin', 'issue/7-double');
|
||||
// dev уходит вперёд: a = 40 — другой файл, конфликта нет, поведение b: 40 → 80
|
||||
git(work, 'checkout', '-q', 'dev');
|
||||
writeFileSync(join(work, 'a.mjs'), 'export const a = 40;\n');
|
||||
commit('a is 40 now');
|
||||
git(work, 'push', '-q', 'origin', 'dev');
|
||||
|
||||
const calls = [];
|
||||
const ops = realOps({ repo: 'x/y', token: 'none' });
|
||||
ops.pushWithLease = (sha, ref, expected) => {
|
||||
calls.push(['push', ref, expected]);
|
||||
const r = spawnSync('git', ['-C', work, 'push', '-q', `--force-with-lease=refs/heads/${ref}:${expected}`, 'origin', `${sha}:refs/heads/${ref}`], { encoding: 'utf8' });
|
||||
return r.status === 0;
|
||||
};
|
||||
ops.waitValidate = async (sha) => { calls.push(['validate', sha]); return { result: 'green', url: 'https://run/1' }; };
|
||||
ops.comment = (issue, body) => { calls.push(['comment', body.slice(0, 40)]); };
|
||||
ops.log = () => {};
|
||||
const inWork = (fn) => (...args) => { const cwd = process.cwd(); process.chdir(work); try { return fn(...args); } finally { process.chdir(cwd); } };
|
||||
for (const name of ['fetch', 'revParse', 'mergeBase', 'diffNames', 'patchId', 'rebaseOnto']) ops[name] = inWork(ops[name]);
|
||||
|
||||
const r = await mergeCandidate({ branch: 'issue/7-double', material, issue: 7, ops });
|
||||
assert.equal(r.action, 'push', JSON.stringify(calls));
|
||||
const validateAt = calls.findIndex((c) => c[0] === 'validate');
|
||||
const devPushAt = calls.findIndex((c) => c[0] === 'push' && c[1] === 'dev');
|
||||
assert.ok(validateAt >= 0 && validateAt < devPushAt, 'без Validate кандидат в dev не уходит');
|
||||
const devTip = git(work, 'rev-parse', 'origin/dev');
|
||||
assert.equal(devTip, r.candidate, 'в dev ровно проверенный кандидат');
|
||||
assert.match(git(work, 'show', `${devTip}:a.mjs`), /a = 40/);
|
||||
assert.match(git(work, 'show', `${devTip}:b.mjs`), /a \* 2/);
|
||||
// lease: dev ждали на вершине «a is 40 now»
|
||||
const lease = calls.find((c) => c[0] === 'push' && c[1] === 'dev')[2];
|
||||
assert.equal(lease, git(work, 'rev-parse', `${devTip}^`));
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
// #492 §7: ночной workflow обязан ждать дочерний Validate и наследовать его
|
||||
// исход — успешный dispatch не равен успешной проверке.
|
||||
|
||||
const read = (name) => readFileSync(new URL(`../.github/workflows/${name}`, import.meta.url), 'utf8');
|
||||
|
||||
test('nightly ждёт запущенный Validate и падает вместе с ним (#492 §7)', () => {
|
||||
const nightly = read('nightly.yml');
|
||||
assert.match(nightly, /gh workflow run validate\.yml --repo "\$REPO" --ref dev -f full=true/);
|
||||
// найти именно свой прогон: dispatch на dev, созданный не раньше запуска
|
||||
assert.match(nightly, /gh run list --repo "\$REPO" --workflow validate\.yml --branch dev/);
|
||||
assert.match(nightly, /--event workflow_dispatch/);
|
||||
assert.match(nightly, /createdAt >= /);
|
||||
// отсутствие прогона — ошибка, не тихий успех
|
||||
assert.match(nightly, /прогон Validate не появился[^\n]*\n\s+exit 1/);
|
||||
// ждать с наследованием кода возврата
|
||||
assert.match(nightly, /gh run watch "\$run_id" --repo "\$REPO" --exit-status/);
|
||||
assert.match(nightly, /timeout-minutes: 90/);
|
||||
assert.match(nightly, /set -euo pipefail/);
|
||||
});
|
||||
|
||||
test('ночная job носит русское имя и не выдаёт очередь за результат (#327, #492)', () => {
|
||||
const nightly = read('nightly.yml');
|
||||
assert.match(nightly, /name: "Запустить Validate на dev с полным набором и дождаться результата"/);
|
||||
assert.ok(!/поставлен в очередь[^\n]*\n\s*$/.test(nightly), 'echo про очередь не может быть последним шагом');
|
||||
});
|
||||
Reference in New Issue
Block a user