mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
ci: classify against the last proven-green ancestor
Диапазон классификации брался от `github.event.before` — головы предыдущего пуша. Это допущение «до этого уже проверено», и оно неверно ровно тогда, когда прогон предыдущего пуша не завершился. А не завершается он штатно: concurrency отменяет его следующим пушем. На #86 (r5) это дало ложный зелёный: push04da7eb1тронул dist/** и frontend/**, его прогон отменили через три минуты; следующий pushfa146fb1тронул только docs/images/**, классификация сравнила эти два коммита и выставила frontend=false. Job «Фронтенд», а за ней golden, smoke и backend оказались skipped — прогон при этом success. Маркеры переиспользования эти гейты тоже не подтверждали: `Cache not found` по всем четырём. Теперь база — самый новый предок HEAD, для которого Validate ДЕЙСТВИТЕЛЬНО завершился успешно; если такого нет, диапазон расширяется до merge-base с dev, то есть до всего вклада ветки. Работает по индукции: цепочка узких диффов покрывает всё изменённое с последней настоящей проверки, а одно незавершённое звено теперь расширяет диапазон, а не сужает. Недоступность API не роняет job: пустой ответ опускает базу до merge-base, то есть в сторону большего объёма проверок. Защита от force-push (#347) сохранена: механизм, из-за которого merge-base врал на переписанной истории, до конца не разобран, и снимать защиту, не объяснив её, — способ получить #347 второй раз. Issue: #387 User-Visible: no
This commit is contained in:
@@ -140,20 +140,49 @@ jobs:
|
||||
changes:
|
||||
name: Классификация изменённых файлов
|
||||
runs-on: ubuntu-latest
|
||||
# Чтение списка прогонов Validate — единственное, что этой job нужно сверх
|
||||
# содержимого репозитория (#387).
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
outputs:
|
||||
frontend: ${{ steps.classify.outputs.frontend }}
|
||||
backend: ${{ steps.classify.outputs.backend }}
|
||||
integration: ${{ steps.classify.outputs.integration }}
|
||||
base: ${{ steps.base.outputs.base }}
|
||||
steps:
|
||||
# `git diff --name-only` содержимого файлов не читает вовсе, поэтому
|
||||
# блобы истории этой job не нужны ни на одном шаге (#345).
|
||||
- uses: actions/checkout@v7
|
||||
with: { fetch-depth: 0, filter: 'blob:none' }
|
||||
# База диапазона — самый новый предок с УСПЕШНО завершённым Validate
|
||||
# (#387). Прежде бралась голова предыдущего пуша (`github.event.before`),
|
||||
# то есть допущение «до этого всё проверено». Concurrency отменяет прогон
|
||||
# предыдущего пуша штатно, и на #86 (r5) это дало зелёный статус ветки
|
||||
# без единого исполненного тяжёлого гейта.
|
||||
- id: base
|
||||
if: github.event_name != 'pull_request' && github.ref != 'refs/heads/dev'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
BRANCH: ${{ github.ref_name }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
git fetch -q origin dev
|
||||
merge_base=$(git merge-base origin/dev "$HEAD_SHA" || git rev-parse "$HEAD_SHA~1")
|
||||
# Недоступность API — не отказ гейта: пустой ответ опускает базу до
|
||||
# merge-base, то есть в сторону БОЛЬШЕГО объёма проверок.
|
||||
gh api -X GET "repos/$REPO/actions/workflows/validate.yml/runs" \
|
||||
-f branch="$BRANCH" -f status=success -F per_page=100 \
|
||||
> /tmp/validate-runs.json || echo '{}' > /tmp/validate-runs.json
|
||||
node scripts/classify-base.mjs --head="$HEAD_SHA" \
|
||||
--merge-base="$merge_base" --runs=/tmp/validate-runs.json
|
||||
- id: classify
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
GREEN_BASE: ${{ steps.base.outputs.base }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
REF: ${{ github.ref }}
|
||||
run: |
|
||||
@@ -163,32 +192,38 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
zero=$(printf '%040d' 0)
|
||||
base="$BEFORE_SHA"
|
||||
if [ "$EVENT_NAME" = "pull_request" ]; then base="$BASE_SHA"; fi
|
||||
# #347: непустой before, которого больше не существует, — это
|
||||
# force-push (ребейз/аменд переписал историю ветки). Диапазон в
|
||||
# этом случае НЕ угадывается: merge-base после переписанной истории
|
||||
# уже выдавал «изменились только два doc-файла» при реальном диффе
|
||||
# в custom_components/**, и тяжёлые job тихо скипались, а общий
|
||||
# статус рана оставался success — ровно класс тихого пропуска
|
||||
# #171/#207. Force-push редок, полный прогон дешевле ложного
|
||||
# «зелёного».
|
||||
if [ -n "$base" ] && [ "$base" != "$zero" ] \
|
||||
&& ! git cat-file -e "$base" 2>/dev/null; then
|
||||
echo "force-push: before=$base переписан — полный прогон без классификации"
|
||||
# force-push (ребейз/аменд переписал историю ветки). Диапазон в этом
|
||||
# случае НЕ угадывается: merge-base после переписанной истории уже
|
||||
# выдавал «изменились только два doc-файла» при реальном диффе в
|
||||
# custom_components/**, и тяжёлые job тихо скипались, а общий статус
|
||||
# рана оставался success — ровно класс тихого пропуска #171/#207.
|
||||
# Force-push редок, полный прогон дешевле ложного «зелёного».
|
||||
#
|
||||
# Проверка сохранена как есть и после #387: там база стала доказуемой
|
||||
# (зелёный предок), но механизм, из-за которого merge-base врал на
|
||||
# переписанной истории, не разобран до конца. Снимать защиту, не
|
||||
# объяснив её, — способ получить #347 второй раз.
|
||||
if [ -n "$BEFORE_SHA" ] && [ "$BEFORE_SHA" != "$zero" ] \
|
||||
&& ! git cat-file -e "$BEFORE_SHA" 2>/dev/null; then
|
||||
echo "force-push: before=$BEFORE_SHA переписан — полный прогон без классификации"
|
||||
{
|
||||
echo "### Классификация изменённых файлов"
|
||||
echo "\`before\` (=$base) переписан force-push'ем: диапазон недоказуем,"
|
||||
echo "\`before\` (=$BEFORE_SHA) переписан force-push'ем: диапазон недоказуем,"
|
||||
echo "все тяжёлые job запускаются без фильтра путей (#347)."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
# Новая ветка: before нулевой, диапазон считается от merge-base с dev,
|
||||
# иначе классифицировалась бы вся история.
|
||||
if [ -z "$base" ] || [ "$base" = "$zero" ]; then
|
||||
git fetch -q origin dev
|
||||
base=$(git merge-base origin/dev "$HEAD_SHA" || echo "$HEAD_SHA~1")
|
||||
# Диапазон считается от последнего ДОКАЗАННО зелёного предка (#387),
|
||||
# а не от головы предыдущего пуша: его прогон штатно отменяется
|
||||
# следующим пушем. PR — исключение: там база задана событием.
|
||||
base="$GREEN_BASE"
|
||||
if [ "$EVENT_NAME" = "pull_request" ]; then base="$BASE_SHA"; fi
|
||||
if [ -z "$base" ]; then
|
||||
echo "база не определена — полный прогон без классификации"
|
||||
printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
files=$(git diff --name-only "$base" "$HEAD_SHA")
|
||||
printf '%s\n' "$files" | head -50
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// База для классификации изменённых файлов (issue #387).
|
||||
//
|
||||
// Job `changes` решает, запускать ли тяжёлые гейты, по списку файлов в
|
||||
// диапазоне. Раньше диапазон брался от `github.event.before` — головы ветки на
|
||||
// момент ПРЕДЫДУЩЕГО push. Это молчаливое допущение: «то, что было до, уже
|
||||
// проверено». Допущение неверно ровно тогда, когда прогон предыдущего пуша не
|
||||
// завершился, — а он не завершается штатно, потому что concurrency отменяет
|
||||
// его следующим пушем.
|
||||
//
|
||||
// Так вышло на #86 (r5): push `04da7eb1` тронул dist/** и frontend/**, его
|
||||
// прогон отменили через три минуты; следующий push `fa146fb1` тронул только
|
||||
// docs/images/**, классификация сравнила эти два коммита, выставила
|
||||
// frontend=false — и job «Фронтенд», а за ней golden, smoke и backend оказались
|
||||
// skipped. Прогон при этом success. Зелёный статус ветки не подтверждался
|
||||
// исполнением ни одного тяжёлого гейта.
|
||||
//
|
||||
// Здесь допущение заменяется проверяемым фактом: база — самый новый предок
|
||||
// HEAD, для которого Validate ДЕЙСТВИТЕЛЬНО завершился успешно; если такого
|
||||
// нет — merge-base с dev, то есть весь вклад ветки.
|
||||
//
|
||||
// Почему этого достаточно — индукция. Если каждое звено цепочки
|
||||
// «предыдущий зелёный → текущий» классифицировано от зелёного предка, то
|
||||
// объединение диффов покрывает всё, что ветка изменила с последней настоящей
|
||||
// проверки. Одно незавершённое звено рвёт цепочку — и именно оно теперь
|
||||
// заставляет расширить диапазон, а не сузить.
|
||||
//
|
||||
// `github.event.before` больше не читается вовсе. Отдельная ветка про
|
||||
// force-push (#347) поэтому не нужна: кандидаты берутся из `rev-list
|
||||
// <merge-base>..HEAD`, то есть предки HEAD по построению, а переписанная
|
||||
// история просто не даёт зелёных совпадений и опускает базу до merge-base.
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { appendFileSync, readFileSync } from 'node:fs';
|
||||
|
||||
/**
|
||||
* Ограничение обхода: на застоявшейся ветке rev-list бывает длинным, а ответ
|
||||
* при этом всё равно один — merge-base.
|
||||
*/
|
||||
export const MAX_CANDIDATES = 300;
|
||||
|
||||
/**
|
||||
* SHA прогонов, завершившихся успешно. Вход — тело ответа
|
||||
* `/actions/workflows/validate.yml/runs`; всё, что не массив прогонов,
|
||||
* считается пустым списком: недоступность API обязана вести к более широкому
|
||||
* диапазону, а не к падению job.
|
||||
*/
|
||||
export function greenShas(payload) {
|
||||
const runs = payload && Array.isArray(payload.workflow_runs) ? payload.workflow_runs : [];
|
||||
return new Set(
|
||||
runs
|
||||
.filter((run) => run && run.conclusion === 'success' && typeof run.head_sha === 'string')
|
||||
.map((run) => run.head_sha),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Выбор базы.
|
||||
*
|
||||
* @param candidates SHA предков HEAD от новых к старым, БЕЗ самого HEAD:
|
||||
* у текущего пуша зелёного прогона быть не может — он идёт.
|
||||
* @param green множество SHA с успешным Validate.
|
||||
* @param mergeBase merge-base с dev: пол, ниже которого опускаться незачем.
|
||||
*/
|
||||
export function pickBase({ candidates, green, mergeBase }) {
|
||||
const list = Array.isArray(candidates) ? candidates.slice(0, MAX_CANDIDATES) : [];
|
||||
const proven = green instanceof Set ? green : new Set();
|
||||
for (let i = 0; i < list.length; i += 1) {
|
||||
if (proven.has(list[i])) {
|
||||
return { base: list[i], reason: 'green-ancestor', proven: true, skipped: i };
|
||||
}
|
||||
}
|
||||
return { base: mergeBase, reason: 'merge-base', proven: false, skipped: list.length };
|
||||
}
|
||||
|
||||
const short = (sha) => (typeof sha === 'string' ? sha.slice(0, 8) : '?');
|
||||
|
||||
/** Строки для summary: почему диапазон именно такой. */
|
||||
export function baseSummary(choice, { head, mergeBase }) {
|
||||
if (choice.reason === 'green-ancestor') {
|
||||
const skipped = choice.skipped
|
||||
? ` Пропущено коммитов без завершённого прогона: ${choice.skipped}.`
|
||||
: '';
|
||||
return [
|
||||
'### База классификации (#387)',
|
||||
`Диапазон \`${short(choice.base)}..${short(head)}\`: это самый новый предок,`
|
||||
+ ` для которого Validate завершился успешно.${skipped}`,
|
||||
];
|
||||
}
|
||||
return [
|
||||
'### База классификации (#387)',
|
||||
'Ни у одного предка до merge-base с dev нет завершённого зелёного Validate,'
|
||||
+ ` поэтому диапазон расширен до \`${short(mergeBase)}..${short(head)}\` —`
|
||||
+ ' весь вклад ветки. Узкий диапазон здесь означал бы «проверено» про то,'
|
||||
+ ' чего никто не проверял.',
|
||||
];
|
||||
}
|
||||
|
||||
const arg = (argv, name, fallback = '') =>
|
||||
(argv.find((a) => a.startsWith(`--${name}=`)) || '').slice(name.length + 3) || fallback;
|
||||
|
||||
function main(argv) {
|
||||
const head = arg(argv, 'head');
|
||||
const mergeBase = arg(argv, 'merge-base');
|
||||
const runsFile = arg(argv, 'runs');
|
||||
if (!head || !mergeBase) {
|
||||
process.stderr.write('usage: classify-base.mjs --head=<sha> --merge-base=<sha> [--runs=<file>]\n');
|
||||
process.exit(2);
|
||||
}
|
||||
let payload = null;
|
||||
if (runsFile) {
|
||||
try {
|
||||
payload = JSON.parse(readFileSync(runsFile, 'utf8'));
|
||||
} catch {
|
||||
// Пустой или битый ответ — сознательно не ошибка: см. greenShas.
|
||||
payload = null;
|
||||
}
|
||||
}
|
||||
// `--skip=1` убирает сам HEAD: его прогон — это текущий, зелёным он быть не
|
||||
// может по определению.
|
||||
const candidates = execFileSync('git', [
|
||||
'rev-list', `--max-count=${MAX_CANDIDATES}`, '--skip=1', `${mergeBase}..${head}`,
|
||||
], { encoding: 'utf8' }).split('\n').map((line) => line.trim()).filter(Boolean);
|
||||
|
||||
const choice = pickBase({ candidates, green: greenShas(payload), mergeBase });
|
||||
const summary = baseSummary(choice, { head, mergeBase });
|
||||
process.stdout.write(`${summary.join('\n')}\n`);
|
||||
if (process.env.GITHUB_OUTPUT) {
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, `base=${choice.base}\nproven=${choice.proven}\n`);
|
||||
}
|
||||
if (process.env.GITHUB_STEP_SUMMARY) {
|
||||
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summary.join('\n\n')}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && process.argv[1].endsWith('classify-base.mjs')) {
|
||||
try {
|
||||
main(process.argv.slice(2));
|
||||
} catch (err) {
|
||||
process.stderr.write(`classify-base: ${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { MAX_CANDIDATES, baseSummary, greenShas, pickBase } from '../scripts/classify-base.mjs';
|
||||
|
||||
const SCRIPT = fileURLToPath(new URL('../scripts/classify-base.mjs', import.meta.url));
|
||||
|
||||
test('зелёными считаются только успешно завершённые прогоны (#387)', () => {
|
||||
const green = greenShas({
|
||||
workflow_runs: [
|
||||
{ head_sha: 'aaa', conclusion: 'success' },
|
||||
// Ровно тот случай, из-за которого заведён #387: прогон предыдущего
|
||||
// пуша отменён следующим пушем.
|
||||
{ head_sha: 'bbb', conclusion: 'cancelled' },
|
||||
{ head_sha: 'ccc', conclusion: 'failure' },
|
||||
{ head_sha: 'ddd', conclusion: null, status: 'in_progress' },
|
||||
{ head_sha: 'eee', conclusion: 'skipped' },
|
||||
],
|
||||
});
|
||||
assert.deepEqual([...green], ['aaa']);
|
||||
});
|
||||
|
||||
test('недоступный или битый ответ API даёт пустой список, а не падение (#387)', () => {
|
||||
for (const payload of [null, undefined, {}, { workflow_runs: null }, 'мусор', 42]) {
|
||||
assert.equal(greenShas(payload).size, 0, `${JSON.stringify(payload)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('база — самый новый зелёный предок (#387)', () => {
|
||||
const choice = pickBase({
|
||||
candidates: ['c3', 'c2', 'c1'],
|
||||
green: new Set(['c2', 'c1']),
|
||||
mergeBase: 'mb',
|
||||
});
|
||||
assert.equal(choice.base, 'c2', 'из двух зелёных берётся более новый');
|
||||
assert.equal(choice.proven, true);
|
||||
assert.equal(choice.skipped, 1);
|
||||
});
|
||||
|
||||
test('цепочка отменённых прогонов расширяет диапазон до merge-base (#387)', () => {
|
||||
// Сценарий #86 r5: у предыдущего пуша прогон cancelled, зелёных предков нет.
|
||||
const choice = pickBase({ candidates: ['fa146fb1', '04da7eb1'], green: new Set(), mergeBase: 'mb' });
|
||||
assert.equal(choice.base, 'mb');
|
||||
assert.equal(choice.proven, false);
|
||||
assert.match(baseSummary(choice, { head: 'head1234', mergeBase: 'mb345678' }).join('\n'),
|
||||
/расширен до/);
|
||||
});
|
||||
|
||||
test('ветка без собственных коммитов не ломает выбор (#387)', () => {
|
||||
const choice = pickBase({ candidates: [], green: new Set(['x']), mergeBase: 'mb' });
|
||||
assert.equal(choice.base, 'mb');
|
||||
assert.equal(choice.skipped, 0);
|
||||
});
|
||||
|
||||
test('обход кандидатов ограничен сверху (#387)', () => {
|
||||
const many = Array.from({ length: MAX_CANDIDATES + 5 }, (_, i) => `c${i}`);
|
||||
// Зелёный есть, но лежит за пределом обхода: ответ обязан быть безопасным,
|
||||
// то есть более широким, а не «ничего не нашли — берём последний».
|
||||
const choice = pickBase({ candidates: many, green: new Set([`c${MAX_CANDIDATES + 2}`]), mergeBase: 'mb' });
|
||||
assert.equal(choice.base, 'mb');
|
||||
});
|
||||
|
||||
test('CLI считает базу по настоящей истории git (#387)', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'hp-classify-'));
|
||||
const git = (...args) => execFileSync('git', args, { cwd: dir, encoding: 'utf8' }).trim();
|
||||
try {
|
||||
git('init', '-q', '-b', 'main');
|
||||
git('config', 'user.email', 'test@example.com');
|
||||
git('config', 'user.name', 'test');
|
||||
const commit = (text) => {
|
||||
writeFileSync(join(dir, 'file.txt'), text);
|
||||
git('add', '-A');
|
||||
git('commit', '-qm', text);
|
||||
return git('rev-parse', 'HEAD');
|
||||
};
|
||||
const root = commit('root');
|
||||
const green = commit('проверенный');
|
||||
const cancelled = commit('прогон отменён');
|
||||
const head = commit('текущий пуш');
|
||||
|
||||
const runsFile = join(dir, 'runs.json');
|
||||
const out = join(dir, 'out.txt');
|
||||
const run = (runs) => {
|
||||
writeFileSync(runsFile, JSON.stringify(runs));
|
||||
writeFileSync(out, '');
|
||||
const result = spawnSync(process.execPath, [
|
||||
SCRIPT, `--head=${head}`, `--merge-base=${root}`, `--runs=${runsFile}`,
|
||||
], { cwd: dir, encoding: 'utf8', env: { ...process.env, GITHUB_OUTPUT: out } });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
return { stdout: result.stdout, output: readFileSync(out, 'utf8') };
|
||||
};
|
||||
|
||||
const withGreen = run({
|
||||
workflow_runs: [
|
||||
{ head_sha: green, conclusion: 'success' },
|
||||
{ head_sha: cancelled, conclusion: 'cancelled' },
|
||||
// HEAD в списке зелёных быть не может, но даже если API его вернёт,
|
||||
// сам себя коммит проверенным не объявляет.
|
||||
{ head_sha: head, conclusion: 'success' },
|
||||
],
|
||||
});
|
||||
assert.match(withGreen.output, new RegExp(`base=${green}\\n`));
|
||||
assert.match(withGreen.output, /proven=true/);
|
||||
|
||||
const noGreen = run({ workflow_runs: [{ head_sha: cancelled, conclusion: 'cancelled' }] });
|
||||
assert.match(noGreen.output, new RegExp(`base=${root}\\n`));
|
||||
assert.match(noGreen.output, /proven=false/);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -141,3 +141,29 @@ test('упавшая golden называет первопричину, а не
|
||||
// свидетеля и смысл сообщения перевернётся.
|
||||
assert.match(golden, /if: failure\(\) && steps\.fail_note\.outputs\.first == 'true'/);
|
||||
});
|
||||
|
||||
test('классификация опирается на завершённый прогон, а не на предыдущий пуш (#387)', () => {
|
||||
const workflow = read('validate.yml');
|
||||
const changes = workflow.slice(
|
||||
workflow.indexOf('\n changes:\n'), workflow.indexOf('\n reuse:\n'),
|
||||
);
|
||||
// `github.event.before` — источник ложного «уже проверено»: прогон
|
||||
// предыдущего пуша штатно отменяется следующим. Классификация читать его
|
||||
// больше не имеет права. В процессном гейте он законен: там проверяются
|
||||
// трейлеры именно отправленных коммитов, а не объём проверок.
|
||||
// `before` остаётся ровно одной проверкой — жив он или переписан (#347).
|
||||
// Базой диапазона он больше не служит: его прогон штатно отменяется.
|
||||
assert.equal(/base="\$BEFORE_SHA"/.test(changes), false,
|
||||
'диапазон не имеет права опираться на голову предыдущего пуша (#387)');
|
||||
assert.match(changes, /git cat-file -e "\$BEFORE_SHA"/,
|
||||
'защита от force-push остаётся на месте (#347)');
|
||||
assert.match(changes, /node scripts\/classify-base\.mjs --head=/);
|
||||
assert.match(changes, /actions: read/, 'чтение прогонов требует прав');
|
||||
// База считается только для push вне dev: у PR диапазон задан событием.
|
||||
assert.match(changes, /if: github\.event_name != 'pull_request' && github\.ref != 'refs\/heads\/dev'/);
|
||||
// Пустая база означает «доказательства нет» и обязана вести к полному
|
||||
// прогону, а не к пустому диффу, который выглядел бы как «ничего не менялось».
|
||||
const empty = changes.slice(changes.indexOf('if [ -z "$base" ]'));
|
||||
assert.match(empty, /frontend=true\\nbackend=true\\nintegration=true/,
|
||||
'без базы классификация обязана раскрываться в полный прогон');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user