mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Диспатч уходит после того, как ссылка ветки доехала
`workflow_dispatch` в API принимает только ref: SHA туда передать нельзя, имя ветки резолвится на стороне GitHub в момент запуска. Конвейер перед этим сам переписывает ветку ребейзом — и 12.09 на #536 диспатч, отправленный через три секунды после force-push, встал на ДОпушевый SHA. Гейт искал прогон строго на SHA материала, не нашёл и вернул задачу автору со словами «материал сменился». Чинить было нечего: дерево задачи не менялось ни на байт, материал сдвинул сам конвейер. Две меры, у каждой своя роль. Шаг ребейза не заканчивается, пока REST не отдаст новую вершину — именно REST, потому что через него же идёт диспатч. Минута ожидания, после чего отказ, а не молчание: диспатч на устаревший SHA стоит трёх минут гейта и потерянного захода. Гейт, не дождавшись прогона на материале и увидев на ветке диспатч на другом SHA, сначала пробует запустить ещё раз. Своя гонка этим закрывается, чужой коммит переживает и вторую попытку, а формулировка отказа больше не называет сменой материала то, что ею не является. Issue: #539 User-Visible: no
This commit is contained in:
@@ -347,6 +347,8 @@ jobs:
|
||||
if: needs.guard.outputs.stage == 'code' && steps.branch.outputs.name != ''
|
||||
env:
|
||||
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
# #539: тем же токеном спрашивается REST — через него идёт и диспатч.
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
# rebase, в отличие от commit, не принимает -c user.*: он запускает
|
||||
# свои процессы и требует личность в окружении, иначе падает с
|
||||
@@ -389,6 +391,25 @@ jobs:
|
||||
echo "::error::ветка $BRANCH изменилась во время ребейза — прогон прерван, чтобы не потерять коммит автора"
|
||||
exit 1
|
||||
fi
|
||||
# #539: ссылка на стороне GitHub доезжает не мгновенно, а гейт ниже
|
||||
# просит `workflow_dispatch` ПО ИМЕНИ ВЕТКИ — SHA туда передать
|
||||
# нельзя. 12.09 на #536 диспатч, отправленный через три секунды после
|
||||
# этого push, встал на ДОпушевый SHA: гейт не нашёл прогона на
|
||||
# материале и вернул задачу автору, которому чинить было нечего.
|
||||
# Поэтому шаг не заканчивается, пока REST не отдаст новую вершину —
|
||||
# именно REST, потому что через него же идёт и сам диспатч.
|
||||
after=$(git rev-parse HEAD)
|
||||
settled=false
|
||||
for _ in $(seq 1 30); do
|
||||
seen=$(gh api "repos/${{ github.repository }}/git/ref/heads/$BRANCH" \
|
||||
--jq .object.sha 2>/dev/null || true)
|
||||
if [ "$seen" = "$after" ]; then settled=true; break; fi
|
||||
sleep 2
|
||||
done
|
||||
if [ "$settled" != "true" ]; then
|
||||
echo "::error::ссылка $BRANCH за минуту не стала указывать на $after — диспатч встал бы на устаревший SHA"
|
||||
exit 1
|
||||
fi
|
||||
# Локальная ссылка обновляется тоже: шаг слияния берёт origin/$BRANCH,
|
||||
# и без этого он ребейзил бы заново уже приведённое.
|
||||
git fetch -q origin "+refs/heads/$BRANCH:refs/remotes/origin/$BRANCH"
|
||||
|
||||
@@ -7710,6 +7710,31 @@ const MUTANT_DEFINITIONS = [
|
||||
replace: '',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'gate-blames-the-author-for-its-own-stale-dispatch',
|
||||
guard: 'node --test test/validate-gate.test.mjs',
|
||||
because: '#539: `workflow_dispatch` принимает только ref, и конвейер сам переписывает '
|
||||
+ 'ветку ребейзом за секунды до запуска. Без повторной попытки собственная гонка '
|
||||
+ 'выглядит как «материал сменился», и задача уезжает автору, которому чинить нечего — '
|
||||
+ 'ровно это случилось с #536 12.09',
|
||||
patches: [{
|
||||
file: 'scripts/validate-gate.mjs',
|
||||
find: ' if (elsewhere && attempts < DISPATCH_ATTEMPTS) {',
|
||||
replace: ' if (false && elsewhere && attempts < DISPATCH_ATTEMPTS) {',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'rebase-dispatches-before-the-ref-settles',
|
||||
guard: 'node --test test/review-doc-guard.test.mjs',
|
||||
because: '#539: ссылка на стороне GitHub доезжает не мгновенно, а диспатч идёт по имени '
|
||||
+ 'ветки. Без ожидания шаг возвращается сразу после push, и запуск встаёт на вершину, '
|
||||
+ 'которой на ветке уже нет',
|
||||
patches: [{
|
||||
file: '.github/workflows/process.yml',
|
||||
find: ' if [ "$seen" = "$after" ]; then settled=true; break; fi',
|
||||
replace: ' settled=true; break',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'render-invalidation-unknown-key-ignored',
|
||||
guard: 'node --test test/render-invalidation.test.mjs',
|
||||
|
||||
@@ -20,6 +20,17 @@ import { resolve } from 'node:path';
|
||||
import { VALIDATE_APPEAR_MS, VALIDATE_TOTAL_MS } from './merge-candidate.mjs';
|
||||
|
||||
export const POLL_MS = 20_000;
|
||||
/**
|
||||
* Сколько раз гейт пробует запустить свой dispatch (#539).
|
||||
*
|
||||
* `workflow_dispatch` в API принимает только ref, а не SHA: имя ветки
|
||||
* резолвится на стороне GitHub в момент запуска. Конвейер перед этим сам
|
||||
* переписывает ветку ребейзом, и 12.09 на #536 диспатч, отправленный через
|
||||
* три секунды после force-push, встал на ДОпушевый SHA — гейт не нашёл прогона
|
||||
* на материале и вернул задачу автору, которому чинить было нечего. Вторая
|
||||
* попытка закрывает это окно: ссылка к тому времени доезжает гарантированно.
|
||||
*/
|
||||
export const DISPATCH_ATTEMPTS = 2;
|
||||
|
||||
/** Кандидат в доказательства: dispatch — только там мутанты могут быть запрошены. */
|
||||
export function isMutantRun(run) {
|
||||
@@ -51,6 +62,7 @@ export async function validateGate({ ref, sha, ops, appearMs = VALIDATE_APPEAR_M
|
||||
const ignored = new Set(); // завершённые dispatch, которые ничего не доказывают: отменённые и зелёные без мутантов
|
||||
let tracked = null;
|
||||
let dispatchedAt = null;
|
||||
let attempts = 0;
|
||||
while (ops.now() - started < totalMs) {
|
||||
const runs = (await ops.listRuns(sha)).filter((x) => isMutantRun(x) && !ignored.has(x.databaseId));
|
||||
const run = runs.find((x) => tracked && x.databaseId === tracked) || runs[0];
|
||||
@@ -75,13 +87,27 @@ export async function validateGate({ ref, sha, ops, appearMs = VALIDATE_APPEAR_M
|
||||
} else if (dispatchedAt === null) {
|
||||
await ops.dispatch(ref);
|
||||
dispatchedAt = ops.now();
|
||||
attempts = 1;
|
||||
} else if (ops.now() - dispatchedAt > appearMs) {
|
||||
// Прогон должен был появиться. Если на ветке появился dispatch на другом
|
||||
// SHA — материал сменился под ногами; иначе запуск просто не прошёл.
|
||||
// Прогон должен был появиться. Если на ветке стоит dispatch на другом
|
||||
// SHA — либо ссылка ветки в момент запуска ещё отдавала прежнюю вершину
|
||||
// (#539: конвейер сам переписал её ребейзом за секунды до этого), либо
|
||||
// материал действительно сменился. Различать гадательно нечем, поэтому
|
||||
// гейт сначала пробует ещё раз: собственная гонка этим закрывается, а
|
||||
// чужой коммит переживёт и вторую попытку.
|
||||
const elsewhere = (await ops.listRunsOnRef(ref)).filter(isMutantRun).find((x) => x.headSha && x.headSha !== sha);
|
||||
if (elsewhere && attempts < DISPATCH_ATTEMPTS) {
|
||||
await ops.dispatch(ref);
|
||||
dispatchedAt = ops.now();
|
||||
attempts += 1;
|
||||
await ops.sleep(pollMs);
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
result: 'missing', url: elsewhere?.url || null,
|
||||
note: elsewhere ? `материал сменился: dispatch-прогон стоит на ${String(elsewhere.headSha).slice(0, 8)}` : 'dispatch-прогон не появился за 3 минуты',
|
||||
note: elsewhere
|
||||
? `ссылка ветки не указывает на материал: ${attempts} попыт(ки) диспатча встали на ${String(elsewhere.headSha).slice(0, 8)}`
|
||||
: 'dispatch-прогон не появился за 3 минуты',
|
||||
};
|
||||
}
|
||||
await ops.sleep(pollMs);
|
||||
|
||||
@@ -743,3 +743,25 @@ test('#517 AC4: документы процесса не требуют файл
|
||||
assert.equal((readme.match(/^\| \[#\d+\]/gm) || []).length, 0, 'таблица-индекс удалена');
|
||||
assert.doesNotMatch(readme, /Статус ТЗ/);
|
||||
});
|
||||
|
||||
// #539: `workflow_dispatch` принимает только ref, а не SHA. Конвейер сам
|
||||
// переписывает ветку ребейзом и тут же просит GitHub разрешить эту же ссылку:
|
||||
// 12.09 на #536 диспатч встал на ДОпушевый SHA, гейт не нашёл прогона на
|
||||
// материале и вернул задачу автору, которому чинить было нечего. Шаг ребейза
|
||||
// обязан дождаться, что ссылка доехала, и спрашивать об этом REST — через него
|
||||
// же идёт диспатч.
|
||||
test('конвейер: ребейз не заканчивается, пока ссылка не укажет на новую вершину (#539)', () => {
|
||||
const workflow = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8');
|
||||
const rebase = workflow.slice(
|
||||
workflow.indexOf(' - name: Привести ветку к dev\n'),
|
||||
workflow.indexOf(' - name: Зафиксировать SHA материала ревью\n'),
|
||||
);
|
||||
assert.ok(rebase.length > 0, 'шаг ребейза найден');
|
||||
assert.match(rebase, /gh api "repos\/\$\{\{ github\.repository \}\}\/git\/ref\/heads\/\$BRANCH"/,
|
||||
'вершина спрашивается у REST, а не у git ls-remote');
|
||||
assert.match(rebase, /^\s+GH_TOKEN: \$\{\{ secrets\.HP_PROCESS_TOKEN \}\}$/m, 'токен для REST есть');
|
||||
const wait = rebase.indexOf('git api') >= 0 ? -1 : rebase.indexOf('gh api');
|
||||
const fetchLocal = rebase.indexOf('git fetch -q origin "+refs/heads/$BRANCH');
|
||||
assert.ok(wait > 0 && wait < fetchLocal, 'ожидание стоит после push и до конца шага');
|
||||
assert.match(rebase, /ссылка \$BRANCH за минуту не стала указывать/, 'не доехавшая ссылка — отказ, а не молчание');
|
||||
});
|
||||
|
||||
@@ -84,8 +84,28 @@ test('#510 AC2: the dispatch that never appears is reported as missing, naming a
|
||||
const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, appearMs: 5000, pollMs: 1000 });
|
||||
assert.equal(outcome.result, 'missing');
|
||||
assert.equal(outcome.url, 'https://run/other');
|
||||
assert.match(outcome.note, /материал сменился: dispatch-прогон стоит на bbbbbbbb/);
|
||||
assert.deepEqual(fake.dispatched, ['issue/1']);
|
||||
assert.match(outcome.note, /ссылка ветки не указывает на материал/);
|
||||
assert.match(outcome.note, /bbbbbbbb/);
|
||||
// #539: прежде чем обвинять автора, гейт пробует ещё раз — своя гонка
|
||||
// закрывается, чужой коммит переживает и вторую попытку.
|
||||
assert.deepEqual(fake.dispatched, ['issue/1', 'issue/1']);
|
||||
});
|
||||
|
||||
// #539: `workflow_dispatch` принимает только ref, а имя ветки резолвится на
|
||||
// стороне GitHub. Конвейер сам переписывает ветку ребейзом, и 12.09 на #536
|
||||
// диспатч встал на ДОпушевый SHA — задача вернулась автору, которому чинить
|
||||
// было нечего. Вторая попытка обязана попасть на материал.
|
||||
test('#539: диспатч, промахнувшийся по устаревшей ссылке, повторяется и принимается', async () => {
|
||||
const landed = run({ databaseId: 7, url: 'https://run/7' });
|
||||
const fake = fakeOps({
|
||||
// пусто → пусто (промах, повтор) → прогон на материале
|
||||
snapshots: [[], [], [landed]],
|
||||
onRef: [run({ headSha: 'b'.repeat(40), url: 'https://run/other' })],
|
||||
});
|
||||
const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, appearMs: 5000, pollMs: 6000 });
|
||||
assert.equal(outcome.result, 'green', 'повторный диспатч попал на материал');
|
||||
assert.equal(outcome.url, 'https://run/7');
|
||||
assert.deepEqual(fake.dispatched, ['issue/1', 'issue/1'], 'ровно две попытки, не бесконечно');
|
||||
});
|
||||
|
||||
test('#510 AC2: a dispatch that never finishes is red after the total window', async () => {
|
||||
|
||||
Reference in New Issue
Block a user