mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-04 13:48:57 +00:00
fix(process): prepare and the usage step run pipeline scripts from one pinned dev snapshot (#765)
The body of _process.yml is read from dev (@dev, #623). After "Перейти на ветку задачи" the working copy of job prepare is the task branch, and a show/ship branch with a clean merge is not rebased before review: its scripts/ may lag dev or be replaced. #749 fixed job integrate; prepare still ran four control scripts from the material — the issue-body digest for the anchor, --reuse of a green verdict (#499), validate-gate (#510) and the spec-change check (#517). The material decided its own admission: a branch whose review-doc-guard.mjs prints reuse=true merges without the model. model_review took model-usage.mjs from the material too: a lagging branch has none, and the publication silently wrote reason=missing. Now prepare extracts one snapshot right after setup-node, before the branch switch: `git rev-parse origin/dev` once and `git archive <sha> scripts .github/workflows/validate.yml`, so the git fetch of the track and rebase steps cannot mix versions. Every repo script of the job runs from it via TOOLS — the track step and the rebase guard lose their own extractions. The SHA goes out as job output tools_sha; the usage step of model_review archives the same commit inside itself, so a snapshot failure is a failure of the reporting step (continue-on-error), not of the stage. The model session runs on that runner, so the usage line stays untrusted input parsed strictly (#556, #737). withMaterialAnchors is idempotent: a repeated call drops the separator the previous call wrote instead of piling up `---` lines. Tests: test/process-prepare-tools.test.mjs — the job contract (no step calls scripts/ from the working copy, one pinned archive before the branch switch, tools_sha reaches model_review) and the steps as they are, on real bash and git: a branch behind dev without model-usage.mjs and with a substituted review-doc-guard.mjs; the anchor digest, reuse and the spec check come from dev, the usage line is data even after dev moved. Red on the old workflow: all four. Harnesses of process-track, rebase-generated, review-doc-guard and model-usage take the prepare snapshot. Three registry mutants (reuse from the material, usage from moving dev, separators). Canon: PROCESS.md §10.4 «Скрипты конвейера — из dev» covers prepare and the usage step; «Расход модели» names it a pipeline step, not the reviewer's. Issue: #765 User-Visible: no
This commit is contained in:
@@ -331,6 +331,9 @@ jobs:
|
||||
spec_body_doc: ${{ steps.spec_body.outputs.doc }}
|
||||
spec_body_recorded: ${{ steps.spec_body.outputs.recorded }}
|
||||
duration_seconds: ${{ steps.duration.outputs.seconds }}
|
||||
# #765: SHA снимка скриптов конвейера — одна версия на заход: model_review
|
||||
# снимает расход скриптом из того же коммита dev.
|
||||
tools_sha: ${{ steps.tools.outputs.sha }}
|
||||
track: ${{ steps.track.outputs.track }}
|
||||
mutants: ${{ steps.track.outputs.mutants }}
|
||||
full: ${{ steps.track.outputs.full }}
|
||||
@@ -383,6 +386,29 @@ jobs:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
# #765 (продолжение #749): тело читается из dev (@dev, #623), значит, и
|
||||
# управляющие скрипты подготовки — из dev. Рабочая копия после «Перейти на
|
||||
# ветку задачи» — материал: ветка show/ship с чистым слиянием до ревью не
|
||||
# ребейзится (§10.4) и может нести scripts/, отставшие на дни или
|
||||
# подменённые. Раньше якорь тела issue, reuse (#499), Validate-gate (#510)
|
||||
# и проверку ТЗ (#517) исполняли скрипты ветки — материал сам участвовал в
|
||||
# решении о своём допуске к ревью. Снимок один на заход: SHA закрепляется
|
||||
# здесь, до любого git fetch трека и ребейза, и выходом `tools_sha` уходит
|
||||
# в model_review. validate.yml в снимке — по той же причине, что в #749:
|
||||
# ci-proof.mjs (validate-gate) читает его по пути от себя. Git, дифф и
|
||||
# документы по-прежнему судятся в рабочей копии — снимок только исполняет.
|
||||
- name: Скрипты конвейера — из dev (#765)
|
||||
id: tools
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch -q origin dev
|
||||
sha=$(git rev-parse origin/dev)
|
||||
tools="$RUNNER_TEMP/dev-tools"
|
||||
rm -rf "$tools" && mkdir -p "$tools"
|
||||
git archive "$sha" scripts .github/workflows/validate.yml | tar -x -C "$tools"
|
||||
{ echo "dir=$tools"; echo "sha=$sha"; } >> "$GITHUB_OUTPUT"
|
||||
echo "скрипты конвейера: dev ${sha:0:12}"
|
||||
|
||||
# Материал ревью живёт в ветке задачи: ТЗ в docs/specs/ и код коммитятся
|
||||
# в issue/<NN>-slug. Если ветка запушена — переключаемся на неё, иначе
|
||||
# ревьюер прочтёт dev и не найдёт того, что должен оценивать.
|
||||
@@ -423,9 +449,9 @@ jobs:
|
||||
# ОДИН вызов process-track.mjs по меткам, диффу и комментариям; bash только
|
||||
# исполняет: пишет комментарий из файла скрипта и меняет метки по его
|
||||
# флагу. Выходы (`track`, `mutants`, `full`, `ship`, `risk_note`,
|
||||
# `ship_risk`) скрипт пишет в GITHUB_OUTPUT сам. Скрипт берётся из dev, как
|
||||
# помощник ребейза: show/ship до ревью не ребейзятся, и ветка, отставшая от
|
||||
# dev, его может ещё не нести.
|
||||
# `ship_risk`) скрипт пишет в GITHUB_OUTPUT сам. Скрипт — из снимка dev
|
||||
# (#765): show/ship до ревью не ребейзятся, и ветка, отставшая от dev, его
|
||||
# может ещё не нести.
|
||||
- name: "Трек задачи и рамки ship (#696)"
|
||||
id: track
|
||||
env:
|
||||
@@ -435,17 +461,15 @@ jobs:
|
||||
LABELS: ${{ needs.guard.outputs.labels }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
OWNER: ${{ github.repository_owner }}
|
||||
TOOLS: ${{ steps.tools.outputs.dir }}
|
||||
run: |
|
||||
git fetch -q origin dev
|
||||
tools="$RUNNER_TEMP/track-tools"
|
||||
rm -rf "$tools" && mkdir -p "$tools"
|
||||
git archive origin/dev scripts | tar -x -C "$tools"
|
||||
# Комментарии — для строки владельца «Трек: <x> — решение владельца»
|
||||
# (#707). Не прочитаны — пустой файл: происхождение не установлено.
|
||||
comments="$RUNNER_TEMP/track-comments.json"
|
||||
gh issue view "$NUM" --repo "${{ github.repository }}" --json comments > "$comments" 2>/dev/null \
|
||||
|| : > "$comments"
|
||||
out=$(node "$tools/scripts/process-track.mjs" stage --stage="$STAGE" --labels="$LABELS" \
|
||||
out=$(node "$TOOLS/scripts/process-track.mjs" stage --stage="$STAGE" --labels="$LABELS" \
|
||||
--branch="$BRANCH" --base=origin/dev --head=HEAD --comments="$comments" --owner="$OWNER" \
|
||||
--out="$RUNNER_TEMP/track" \
|
||||
--run-url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}")
|
||||
@@ -479,6 +503,7 @@ jobs:
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
TRACK: ${{ steps.track.outputs.track }}
|
||||
TOOLS: ${{ steps.tools.outputs.dir }}
|
||||
# rebase, в отличие от commit, не принимает -c user.*: он запускает
|
||||
# свои процессы и требует личность в окружении, иначе падает с
|
||||
# «unable to auto-detect email address».
|
||||
@@ -515,13 +540,10 @@ jobs:
|
||||
# сдвиге dev. Помощник ребейза пересобирает индекс по каталогу, если
|
||||
# ВСЕ конфликты остановки — индекс, и отказывает (с abort) на любом
|
||||
# другом. Список конфликтов он снимает ДО abort (#364) и печатает в
|
||||
# stdout по строке. Помощник берётся из dev, а не из ветки: ветка,
|
||||
# отставшая от dev, его ещё не несёт.
|
||||
tools="$RUNNER_TEMP/rebase-tools"
|
||||
rm -rf "$tools" && mkdir -p "$tools"
|
||||
git archive origin/dev scripts | tar -x -C "$tools"
|
||||
# stdout по строке. Помощник — из снимка dev (#765), а не из ветки:
|
||||
# ветка, отставшая от dev, его ещё не несёт.
|
||||
code=0
|
||||
files=$(node "$tools/scripts/rebase-generated.mjs" --onto=origin/dev) || code=$?
|
||||
files=$(node "$TOOLS/scripts/rebase-generated.mjs" --onto=origin/dev) || code=$?
|
||||
# Код 3 — отказ с перечнем (ребейз отменён); любой другой ненулевой —
|
||||
# сбой самого помощника, конфликтом ветки он не выдаётся.
|
||||
if [ "$code" -ne 0 ] && [ "$code" -ne 3 ]; then
|
||||
@@ -564,7 +586,7 @@ jobs:
|
||||
"https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
|
||||
"HEAD:refs/heads/$BRANCH" 2> "$push_err"; then
|
||||
refusal="$RUNNER_TEMP/push-refusal.md"
|
||||
kind=$(node "$tools/scripts/merge-candidate.mjs" --push-refusal="$push_err" \
|
||||
kind=$(node "$TOOLS/scripts/merge-candidate.mjs" --push-refusal="$push_err" \
|
||||
--branch="$BRANCH" --candidate="$(git rev-parse HEAD)" --stage=rebase \
|
||||
--run-url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
|
||||
--comment="$refusal" --summary="$GITHUB_STEP_SUMMARY") || kind=unknown
|
||||
@@ -621,6 +643,7 @@ jobs:
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
TOOLS: ${{ steps.tools.outputs.dir }}
|
||||
run: |
|
||||
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
# Якоря материала, устойчивые к ребейзу (#413, #414). SHA коммита
|
||||
@@ -646,7 +669,8 @@ jobs:
|
||||
body=$(mktemp)
|
||||
if gh issue view "$NUM" --repo "$REPO" --json body --jq .body > "$body"; then
|
||||
digest=$(node -e '
|
||||
import("./scripts/review-doc-guard.mjs").then(async (m) => {
|
||||
const { pathToFileURL } = require("node:url");
|
||||
import(pathToFileURL(`${process.env.TOOLS}/scripts/review-doc-guard.mjs`).href).then(async (m) => {
|
||||
const { readFileSync } = await import("node:fs");
|
||||
process.stdout.write(m.issueBodyDigest(readFileSync(process.argv[1], "utf8")));
|
||||
});
|
||||
@@ -676,8 +700,9 @@ jobs:
|
||||
# зелёного вердикта: иначе вызов модели пропускается и находку
|
||||
# «ТЗ менялось» некому напечатать (#517 AC6).
|
||||
ISSUE_BODY: ${{ steps.material.outputs.issue_body }}
|
||||
TOOLS: ${{ steps.tools.outputs.dir }}
|
||||
run: |
|
||||
out=$(node scripts/review-doc-guard.mjs --reuse --marker=CODE-REVIEW --num="$NUM" --head=HEAD \
|
||||
out=$(node "$TOOLS/scripts/review-doc-guard.mjs" --reuse --marker=CODE-REVIEW --num="$NUM" --head=HEAD \
|
||||
--issue-body="${ISSUE_BODY}")
|
||||
printf '%s\n' "$out"
|
||||
printf '%s\n' "$out" >> "$GITHUB_OUTPUT"
|
||||
@@ -757,6 +782,7 @@ jobs:
|
||||
MUTANTS: ${{ steps.track.outputs.mutants }}
|
||||
# #697: `ci:full`/`ci:golden` — полный набор на материале.
|
||||
FULL: ${{ steps.track.outputs.full }}
|
||||
TOOLS: ${{ steps.tools.outputs.dir }}
|
||||
run: |
|
||||
if [ "$STAGE" != "code" ] || [ "$REUSE" = "true" ] || [ -z "$BRANCH" ]; then
|
||||
echo "гейт не применяется: этап $STAGE, reuse=${REUSE:-false}, ветка ${BRANCH:-dev}"
|
||||
@@ -770,7 +796,7 @@ jobs:
|
||||
# S7), страховка — process-reconcile. Зелёный или красный завершённый
|
||||
# прогон гейт и без ожидания возвращает сразу.
|
||||
set +e
|
||||
node scripts/validate-gate.mjs --repo="${{ github.repository }}" --ref="$BRANCH" --sha="$SHA" --no-wait \
|
||||
node "$TOOLS/scripts/validate-gate.mjs" --repo="${{ github.repository }}" --ref="$BRANCH" --sha="$SHA" --no-wait \
|
||||
--mutants="${MUTANTS:-false}" --full="${FULL:-false}"
|
||||
code=$?
|
||||
set -e
|
||||
@@ -899,13 +925,15 @@ jobs:
|
||||
env:
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
DIGEST: ${{ steps.material.outputs.issue_body }}
|
||||
TOOLS: ${{ steps.tools.outputs.dir }}
|
||||
run: |
|
||||
if [ -z "$DIGEST" ]; then
|
||||
echo "хеша тела нет — сравнивать не с чем"
|
||||
exit 0
|
||||
fi
|
||||
out=$(node -e '
|
||||
import("./scripts/review-doc-guard.mjs").then(async (m) => {
|
||||
const { pathToFileURL } = require("node:url");
|
||||
import(pathToFileURL(`${process.env.TOOLS}/scripts/review-doc-guard.mjs`).href).then(async (m) => {
|
||||
const { execFileSync } = await import("node:child_process");
|
||||
const [num, digest] = process.argv.slice(1);
|
||||
const git = (args) => { try { return execFileSync("git", args, { encoding: "utf8" }); } catch { return ""; } };
|
||||
@@ -1312,14 +1340,33 @@ jobs:
|
||||
# execution_file — все сообщения сессии, с результатами инструментов —
|
||||
# не выгружается и не печатается: скрипт берёт из него только `result`.
|
||||
# `always()` — расход упавшей сессии хотя бы виден в сводке.
|
||||
#
|
||||
# #765: рабочая копия здесь — материал ревью. На отставшей ветке show/ship
|
||||
# model-usage.mjs нет, и публикация молча писала `reason=missing`; ветка
|
||||
# могла и подменить скрипт. Скрипт берётся из того же коммита dev, что и
|
||||
# снимок подготовки (`tools_sha`). Снимок извлекается здесь, а не отдельным
|
||||
# шагом до Review: его сбой — сбой отчётного шага (continue-on-error), а не
|
||||
# всей стадии. От сессии модели, исполнявшейся на этом раннере, снимок не
|
||||
# защищает — поэтому строка остаётся недоверенным вводом и разбирается
|
||||
# строго при публикации (#556, #737).
|
||||
- name: Снять расход модели
|
||||
id: usage
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
env:
|
||||
EXEC: ${{ steps.review.outputs.execution_file }}
|
||||
TOOLS_SHA: ${{ needs.prepare.outputs.tools_sha }}
|
||||
run: |
|
||||
line=$(node scripts/model-usage.mjs --execution-file="$EXEC")
|
||||
set -euo pipefail
|
||||
if [ -z "$TOOLS_SHA" ]; then
|
||||
echo "::error::нет SHA снимка скриптов из prepare — расход не снят"
|
||||
exit 1
|
||||
fi
|
||||
tools="$RUNNER_TEMP/dev-tools"
|
||||
rm -rf "$tools" && mkdir -p "$tools"
|
||||
git cat-file -e "${TOOLS_SHA}^{commit}" 2>/dev/null || git fetch -q origin "$TOOLS_SHA"
|
||||
git archive "$TOOLS_SHA" scripts | tar -x -C "$tools"
|
||||
line=$(node "$tools/scripts/model-usage.mjs" --execution-file="$EXEC")
|
||||
echo "line=$line" >> "$GITHUB_OUTPUT"
|
||||
echo "- расход модели: \`$line\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
|
||||
+18
-6
@@ -1327,16 +1327,27 @@ Validate запускает `ship-review.yml` dispatch'ем с `-f tag=nightly`
|
||||
origin/dev scripts .github/workflows/validate.yml` и зовёт из него каждый
|
||||
repo-скрипт: `review-result-gate`, `review-doc-guard`, `reviews-index`,
|
||||
`merge-candidate`, `process-track route`, `status-label`. Снимок один на job —
|
||||
одна версия на весь заход, а не смесь по шагам. Так же уже работают шаг трека
|
||||
(#707), страж ребейза (#698) и разбор отказа push (#723). Рабочая копия ветки
|
||||
одна версия на весь заход, а не смесь по шагам. Job `prepare` (#765) делает
|
||||
то же до перехода на ветку задачи, но архивом **закреплённого** SHA
|
||||
(`git rev-parse origin/dev` один раз — `git fetch` трека и ребейза его не
|
||||
сдвигают): из снимка идут шаг трека (#707), страж ребейза (#698) с разбором
|
||||
отказа push (#723), хеш тела issue для якоря, reuse зелёного
|
||||
вердикта (#499), Validate-гейт (#510) и проверка изменившегося ТЗ (#517).
|
||||
Прежде четыре последних исполняли скрипты ветки — материал сам решал, допущен
|
||||
ли он к ревью: подменённый `review-doc-guard.mjs` выдавал себе `reuse=true`.
|
||||
SHA уходит выходом `tools_sha` в `model_review`. Рабочая копия ветки
|
||||
задачи — только материал: git-команды, документ и пути судятся в ней. Ветка
|
||||
`show`/`ship` с чистым слиянием до ревью не ребейзится и может нести
|
||||
`scripts/`, отставшие от `dev` на дни: её `review-doc-guard.mjs` молча
|
||||
проглотил бы неизвестный флаг, её `merge-candidate.mjs` слил бы по-старому.
|
||||
`validate.yml` в снимке обязателен: `workflow-jobs.mjs` читает его по пути от
|
||||
себя, без него доказательство Validate кандидата — `failed (#622)`. Исключение
|
||||
— job `model_review`: ревьюер исполняет тесты и скрипты материала, это его
|
||||
работа, а не конвейера. Следствие для задач, меняющих сам конвейер: их
|
||||
себя, без него доказательство Validate кандидата — `failed (#622)`. В job
|
||||
`model_review` скрипты материала исполняет ревьюер — это его работа, а не
|
||||
конвейера; детерминированный шаг «Снять расход модели» (#737) — работа
|
||||
конвейера, и `model-usage.mjs` он берёт архивом `tools_sha` (#765): на
|
||||
отставшей ветке скрипта нет, и публикация молча писала `reason=missing`. От
|
||||
сессии модели на том же раннере снимок не защищает, поэтому строка расхода
|
||||
по-прежнему недоверенный ввод и разбирается строго. Следствие для задач, меняющих сам конвейер: их
|
||||
слияние судит версия `dev`, новая начинает действовать со следующей задачи.
|
||||
Правка контракта доказательства Validate (имена потребляемых job
|
||||
`validate.yml`, политики `ci-proof.mjs`, #622) пишется совместимой с
|
||||
@@ -1470,7 +1481,8 @@ merge. Timeout/cancel/failure называет конкретную стадию
|
||||
|
||||
**Расход модели** (#737). Шаг `Review` отдаёт `execution_file` — все сообщения
|
||||
сессии SDK, а журнал Actions расход намеренно прячет. Шаг «Снять расход модели»
|
||||
сразу после `Review` (`if: always()`, `continue-on-error`) берёт из файла только
|
||||
сразу после `Review` (`if: always()`, `continue-on-error`) — шаг конвейера, а не
|
||||
ревьюера, со скриптом из снимка `dev` (#765) — берёт из файла только
|
||||
последнее сообщение `result` (`scripts/model-usage.mjs`) и отдаёт одну строку
|
||||
выходом job `usage`: `<!-- hp:usage input_tokens=N output_tokens=N
|
||||
cache_creation_input_tokens=N cache_read_input_tokens=N num_turns=N -->` — суммы
|
||||
|
||||
@@ -14201,6 +14201,39 @@ const MUTANT_DEFINITIONS = [
|
||||
replace: ' const key = `${spaceId}|`; // mutant: no content fingerprint\n',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'prepare-reuse-runs-material-script',
|
||||
guard: 'node --test --test-name-pattern="#765 AC2" test/process-prepare-tools.test.mjs',
|
||||
because: '#765: reuse зелёного вердикта (#499) решает скрипт снимка dev; скрипт рабочей копии — '
|
||||
+ 'материал задачи, и подменённый review-doc-guard.mjs выдаёт себе reuse=true — слияние без модели',
|
||||
patches: [{
|
||||
file: '.github/workflows/_process.yml',
|
||||
find: ' out=$(node "$TOOLS/scripts/review-doc-guard.mjs" --reuse --marker=CODE-REVIEW',
|
||||
replace: ' out=$(node scripts/review-doc-guard.mjs --reuse --marker=CODE-REVIEW',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'usage-script-from-moving-dev',
|
||||
guard: 'node --test --test-name-pattern="#765 AC3" test/process-prepare-tools.test.mjs',
|
||||
because: '#765: расход снимается скриптом того коммита dev, что закрепила подготовка; архив '
|
||||
+ 'подвижной origin/dev после сдвига dev исполняет другую версию — смесь версий в одном заходе',
|
||||
patches: [{
|
||||
file: '.github/workflows/_process.yml',
|
||||
find: ' git archive "$TOOLS_SHA" scripts | tar -x -C "$tools"',
|
||||
replace: ' git fetch -q origin dev; git archive origin/dev scripts | tar -x -C "$tools"',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'material-anchors-pile-separators',
|
||||
guard: 'node --test --test-name-pattern="#765" test/review-doc-guard.test.mjs',
|
||||
because: '#765: повторная приписка блока якорей снимает и свой разделитель; без этого каждый '
|
||||
+ 'повтор копит ещё один `---` перед блоком',
|
||||
patches: [{
|
||||
file: 'scripts/review-doc-guard.mjs',
|
||||
find: " ? body.slice(0, at).replace(/(?:\\s*\\n---)*\\s*$/, '')",
|
||||
replace: " ? body.slice(0, at).replace(/\\s+$/, '')",
|
||||
}],
|
||||
},
|
||||
];
|
||||
|
||||
const mutationCardSource = readFileSync(join(repoRoot, 'src/houseplan-card.ts'), 'utf8');
|
||||
|
||||
@@ -388,11 +388,17 @@ export function parseSpecList(raw) {
|
||||
.filter((item) => /^[0-9a-f]{40}$/.test(item.blob) && item.path);
|
||||
}
|
||||
|
||||
/** Дописать или заменить блок якорей в тексте документа. */
|
||||
/**
|
||||
* Дописать или заменить блок якорей в тексте документа. Идемпотентно (#765):
|
||||
* при замене снимается и разделитель `---`, который поставила прошлая
|
||||
* приписка, — иначе каждый повтор копил бы ещё один перед блоком.
|
||||
*/
|
||||
export function withMaterialAnchors(text, anchors) {
|
||||
const body = String(text ?? '');
|
||||
const at = body.indexOf(ANCHOR_MARKER);
|
||||
const head = at >= 0 ? body.slice(0, at).replace(/\s+$/, '') : body.replace(/\s+$/, '');
|
||||
const head = at >= 0
|
||||
? body.slice(0, at).replace(/(?:\s*\n---)*\s*$/, '')
|
||||
: body.replace(/\s+$/, '');
|
||||
return `${head}\n\n---\n\n${materialAnchorBlock(anchors)}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -199,9 +199,19 @@ test('#737 lastUsageIn: последняя строка формата в тек
|
||||
// ---------- К3: проводка в обоих workflow ----------
|
||||
|
||||
const WORKFLOWS = fileURLToPath(new URL('../.github/workflows/', import.meta.url));
|
||||
// #765: в _process.yml рабочая копия model_review — материал ревью (ветка задачи
|
||||
// может отставать от dev или подменять скрипт), поэтому скрипт берётся снимком
|
||||
// того коммита dev, что закрепила подготовка (`tools_sha`). В _ship-review.yml
|
||||
// рабочая копия — кандидат линии dev, и скрипт зовётся из неё.
|
||||
const PIPELINES = [
|
||||
{ file: '_process.yml', publishJob: 'integrate', publishStep: 'Опубликовать документ ревью', consumes: /^ +--usage="\$USAGE" \\$/m },
|
||||
{ file: '_ship-review.yml', publishJob: 'publish', publishStep: 'Опубликовать документ', consumes: /^ +usage: process\.env\.USAGE \?\? "",$/m },
|
||||
{
|
||||
file: '_process.yml', publishJob: 'integrate', publishStep: 'Опубликовать документ ревью', consumes: /^ +--usage="\$USAGE" \\$/m,
|
||||
call: /^line=\$\(node "\$tools\/scripts\/model-usage\.mjs" --execution-file="\$EXEC"\)$/m, snapshot: true,
|
||||
},
|
||||
{
|
||||
file: '_ship-review.yml', publishJob: 'publish', publishStep: 'Опубликовать документ', consumes: /^ +usage: process\.env\.USAGE \?\? "",$/m,
|
||||
call: /^line=\$\(node scripts\/model-usage\.mjs --execution-file="\$EXEC"\)$/m, snapshot: false,
|
||||
},
|
||||
];
|
||||
|
||||
/** Блок job верхнего уровня `jobs:` — до следующего id на двух пробелах. */
|
||||
@@ -234,7 +244,7 @@ const named = (steps, name) => {
|
||||
const hasBash = () => process.platform !== 'win32' && spawnSync('bash', ['--version']).status === 0;
|
||||
|
||||
test('#737 AC5: шаг снятия расхода сразу после Review — always, continue-on-error, выход job usage; публикация его берёт', () => {
|
||||
for (const { file, publishJob, publishStep, consumes } of PIPELINES) {
|
||||
for (const { file, publishJob, publishStep, consumes, call } of PIPELINES) {
|
||||
const text = readFileSync(join(WORKFLOWS, file), 'utf8');
|
||||
const model = jobBlock(text, 'model_review');
|
||||
const steps = stepsOf(model);
|
||||
@@ -247,7 +257,7 @@ test('#737 AC5: шаг снятия расхода сразу после Review
|
||||
assert.match(usage, /^ {8}continue-on-error: true$/m, `${file}: сбой снятия не роняет стадию`);
|
||||
assert.match(usage, /^ {10}EXEC: \$\{\{ steps\.review\.outputs\.execution_file \}\}$/m, file);
|
||||
const run = runOf(usage);
|
||||
assert.match(run, /^line=\$\(node scripts\/model-usage\.mjs --execution-file="\$EXEC"\)$/m, file);
|
||||
assert.match(run, call, file);
|
||||
assert.match(run, /^echo "line=\$line" >> "\$GITHUB_OUTPUT"$/m, file);
|
||||
assert.match(run, /"\$GITHUB_STEP_SUMMARY"$/m, `${file}: строка — в сводку шага`);
|
||||
const head = model.slice(0, model.indexOf('\n steps:\n'));
|
||||
@@ -281,7 +291,9 @@ test('#737 AC5: execution_file никуда не выгружается, гра
|
||||
|
||||
test('#737 AC5: шаг снятия расхода на настоящем bash — выход line и строка в сводке, секрета нет', (t) => {
|
||||
if (!hasBash()) { t.skip('bash недоступен'); return; }
|
||||
for (const { file } of PIPELINES) {
|
||||
// #765: снимок — коммит HEAD этого дерева (скрипт расхода в нём тот же).
|
||||
const head = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: ROOT, encoding: 'utf8' }).stdout.trim();
|
||||
for (const { file, snapshot } of PIPELINES) {
|
||||
const text = readFileSync(join(WORKFLOWS, file), 'utf8');
|
||||
const body = runOf(named(stepsOf(jobBlock(text, 'model_review')), 'Снять расход модели'));
|
||||
const dir = tempDir(t);
|
||||
@@ -293,7 +305,8 @@ test('#737 AC5: шаг снятия расхода на настоящем bash
|
||||
rmSync(output, { force: true });
|
||||
rmSync(summary, { force: true });
|
||||
const r = spawnSync('bash', ['--noprofile', '--norc', '-e', '-c', body], {
|
||||
cwd: ROOT, encoding: 'utf8', env: { ...process.env, EXEC, GITHUB_OUTPUT: output, GITHUB_STEP_SUMMARY: summary },
|
||||
cwd: ROOT, encoding: 'utf8',
|
||||
env: { ...process.env, EXEC, GITHUB_OUTPUT: output, GITHUB_STEP_SUMMARY: summary, ...(snapshot ? { TOOLS_SHA: head, RUNNER_TEMP: dir } : {}) },
|
||||
});
|
||||
assert.equal(r.status, 0, `${file}: ${r.stderr}`);
|
||||
assert.equal(readFileSync(output, 'utf8'), `line=${line}\n`, file);
|
||||
|
||||
Executable
+276
@@ -0,0 +1,276 @@
|
||||
// #765: подготовка и снятие расхода исполняют скрипты конвейера из снимка `dev`.
|
||||
//
|
||||
// Продолжение #749 (там — job `integrate`). Тело `_process.yml` читается из
|
||||
// `dev` (`@dev`, #623), а рабочая копия `prepare` после «Перейти на ветку
|
||||
// задачи» — материал: ветка show/ship с чистым слиянием до ревью не ребейзится
|
||||
// (§10.4) и может нести `scripts/`, отставшие на дни или подменённые. Раньше
|
||||
// якорь тела issue, reuse (#499), Validate-gate (#510) и проверка ТЗ (#517)
|
||||
// исполняли скрипты ветки, а `model_review` снимал расход (#737) скриптом из
|
||||
// материала: на отставшей ветке его нет, и публикация молча писала
|
||||
// `reason=missing`.
|
||||
//
|
||||
// Здесь — контракт двух job (ни одного вызова `scripts/` из рабочей копии, один
|
||||
// снимок на заход, SHA закреплён и передан в `model_review`) и исполнение
|
||||
// шагов как есть, настоящим bash и git: ветка задачи отстала от dev (скрипта
|
||||
// расхода нет) и подменяет review-doc-guard.mjs и model-usage.mjs; подмена не
|
||||
// исполняется, а вывод привязан к материалу.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { chmodSync, cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { basename, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { issueBodyDigest } from '../scripts/review-doc-guard.mjs';
|
||||
import { formatUsage } from '../scripts/model-usage.mjs';
|
||||
|
||||
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
||||
const WORKFLOW = join(ROOT, '.github', 'workflows', '_process.yml');
|
||||
const TOOLS_STEP = 'Скрипты конвейера — из dev (#765)';
|
||||
const TOOLS_ENV = 'TOOLS: ${{ steps.tools.outputs.dir }}';
|
||||
const USAGE_STEP = 'Снять расход модели';
|
||||
|
||||
/** Блок job: от ` <id>:` до следующей job на том же отступе. */
|
||||
function jobBlock(text, id) {
|
||||
const start = text.indexOf(`\n ${id}:\n`);
|
||||
assert.ok(start >= 0, `job ${id}`);
|
||||
const rest = text.slice(start + 1);
|
||||
const next = rest.slice(1).search(/\n {2}[\w-]+:\n/);
|
||||
return next < 0 ? rest : rest.slice(0, next + 2);
|
||||
}
|
||||
|
||||
/** Шаги job: имя, id, env и тело `run` так, как его прочтёт YAML; `code` — без комментариев. */
|
||||
function stepsOf(job) {
|
||||
const lines = job.slice(job.indexOf('\n steps:\n') + 1).split('\n').slice(1);
|
||||
const steps = [];
|
||||
for (const line of lines) {
|
||||
if (/^ {6}- /.test(line)) steps.push([line]);
|
||||
else if (steps.length) steps.at(-1).push(line);
|
||||
}
|
||||
return steps.map((stepLines) => {
|
||||
const text = stepLines.join('\n');
|
||||
const field = (key) => text.match(new RegExp(`^ {6}(?:- | {2})${key}: (.+)$`, 'm'))?.[1];
|
||||
const env = [];
|
||||
const envAt = stepLines.indexOf(' env:');
|
||||
if (envAt >= 0) {
|
||||
for (const line of stepLines.slice(envAt + 1)) {
|
||||
if (/^ {10}#/.test(line)) continue;
|
||||
if (!/^ {10}\S/.test(line)) break;
|
||||
env.push(line.trim());
|
||||
}
|
||||
}
|
||||
let run = '';
|
||||
const runAt = stepLines.indexOf(' run: |');
|
||||
if (runAt >= 0) {
|
||||
const body = [];
|
||||
for (const line of stepLines.slice(runAt + 1)) {
|
||||
if (line.trim() && !/^ {10}/.test(line)) break;
|
||||
body.push(line.slice(10));
|
||||
}
|
||||
run = body.join('\n');
|
||||
}
|
||||
const code = run.split('\n').filter((line) => !/^\s*#/.test(line)).join('\n');
|
||||
return { name: field('name') ?? field('uses'), id: field('id'), env, run, code };
|
||||
});
|
||||
}
|
||||
|
||||
const workflow = () => readFileSync(WORKFLOW, 'utf8');
|
||||
const prepareSteps = () => stepsOf(jobBlock(workflow(), 'prepare'));
|
||||
const modelSteps = () => stepsOf(jobBlock(workflow(), 'model_review'));
|
||||
const named = (steps, name) => {
|
||||
const step = steps.find((item) => item.name === name || item.name === `"${name}"`);
|
||||
assert.ok(step, `шаг «${name}»`);
|
||||
return step;
|
||||
};
|
||||
|
||||
// Вызов repo-скрипта не через снимок: `scripts/` без `$TOOLS/`, `${…TOOLS}/`
|
||||
// или `$tools/` шага расхода перед ним; import() относительного пути.
|
||||
const LOCAL_CALL = /(?<!\$TOOLS\/|TOOLS\}\/|\$tools\/)\bscripts\/[\w.-]+\.mjs/;
|
||||
const LOCAL_IMPORT = /import\(\s*['"`]\.{0,2}\/?scripts\//;
|
||||
|
||||
test('#765 AC1: в job prepare ни один шаг не зовёт скрипт из рабочей копии — только из снимка dev', () => {
|
||||
const calls = [];
|
||||
for (const step of prepareSteps()) {
|
||||
assert.doesNotMatch(step.code, LOCAL_CALL, `«${step.name}»: repo-скрипт мимо снимка — рабочая копия здесь ветка задачи`);
|
||||
assert.doesNotMatch(step.code, LOCAL_IMPORT, `«${step.name}»: import() скрипта рабочей копии`);
|
||||
if (step.name !== TOOLS_STEP) {
|
||||
assert.doesNotMatch(step.code, /\$tools\b|git archive/, `«${step.name}»: своё извлечение скриптов вместо снимка захода`);
|
||||
}
|
||||
const used = [
|
||||
...[...step.code.matchAll(/node "\$TOOLS\/scripts\/([\w.-]+\.mjs)"/g)].map((m) => m[1]),
|
||||
...[...step.code.matchAll(/process\.env\.TOOLS\}\/scripts\/([\w.-]+\.mjs)/g)].map((m) => m[1]),
|
||||
];
|
||||
if (used.length) assert.ok(step.env.includes(TOOLS_ENV), `«${step.name}»: каталог снимка — из выхода шага tools`);
|
||||
for (const script of used) calls.push(`${step.name}: ${script}`);
|
||||
}
|
||||
const count = (name) => calls.filter((call) => call.endsWith(`: ${name}`)).length;
|
||||
assert.deepEqual(
|
||||
Object.fromEntries(['process-track.mjs', 'rebase-generated.mjs', 'merge-candidate.mjs', 'review-doc-guard.mjs', 'validate-gate.mjs']
|
||||
.map((name) => [name, count(name)])),
|
||||
// review-doc-guard: хеш тела для якоря, --reuse (#499), изменившееся ТЗ (#517).
|
||||
{ 'process-track.mjs': 1, 'rebase-generated.mjs': 1, 'merge-candidate.mjs': 1, 'review-doc-guard.mjs': 3, 'validate-gate.mjs': 1 },
|
||||
calls.join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
test('#765 AC1: снимок — один на заход, SHA dev закреплён до перехода на ветку и передан в model_review', () => {
|
||||
const steps = prepareSteps();
|
||||
const at = steps.findIndex((step) => step.name === TOOLS_STEP);
|
||||
assert.ok(at >= 0, `шаг «${TOOLS_STEP}»`);
|
||||
const snapshot = steps[at];
|
||||
assert.equal(snapshot.id, 'tools');
|
||||
assert.ok(steps.findIndex((step) => /^actions\/setup-node@/.test(step.name)) < at, 'после setup-node');
|
||||
assert.ok(at < steps.findIndex((step) => step.name === 'Перейти на ветку задачи'), 'до перехода на материал');
|
||||
assert.match(snapshot.code, /^set -euo pipefail$/m, 'сбой git archive не проходит молча через | tar');
|
||||
assert.match(snapshot.code, /^git fetch -q origin dev$/m);
|
||||
assert.match(snapshot.code, /^sha=\$\(git rev-parse origin\/dev\)$/m, 'SHA закрепляется один раз');
|
||||
assert.match(snapshot.code, /^git archive "\$sha" scripts \.github\/workflows\/validate\.yml \| tar -x -C "\$tools"$/m,
|
||||
'архив закреплённого SHA, а не движущейся origin/dev; validate.yml читает ci-proof.mjs по пути от себя');
|
||||
assert.match(snapshot.code, /^\{ echo "dir=\$tools"; echo "sha=\$sha"; \} >> "\$GITHUB_OUTPUT"$/m);
|
||||
const head = jobBlock(workflow(), 'prepare');
|
||||
assert.match(head.slice(0, head.indexOf('\n steps:\n')), /^ {6}tools_sha: \$\{\{ steps\.tools\.outputs\.sha \}\}$/m, 'выход job tools_sha');
|
||||
const usage = named(modelSteps(), USAGE_STEP);
|
||||
assert.ok(usage.env.includes('TOOLS_SHA: ${{ needs.prepare.outputs.tools_sha }}'), 'расход — из того же коммита dev');
|
||||
assert.doesNotMatch(usage.code, LOCAL_CALL, 'model-usage.mjs — не из материала');
|
||||
assert.match(usage.code, /^git archive "\$TOOLS_SHA" scripts \| tar -x -C "\$tools"$/m);
|
||||
assert.match(usage.code, /^line=\$\(node "\$tools\/scripts\/model-usage\.mjs" --execution-file="\$EXEC"\)$/m);
|
||||
for (const step of modelSteps()) {
|
||||
assert.doesNotMatch(step.code, LOCAL_CALL, `model_review «${step.name}»: repo-скрипт из материала`);
|
||||
}
|
||||
});
|
||||
|
||||
const hasTools = () => process.platform !== 'win32'
|
||||
&& ['bash', 'tar', 'git'].every((tool) => spawnSync(tool, ['--version']).status === 0);
|
||||
|
||||
// Окружение git без GIT_* родителя и без глобального конфига (урок #633, #496).
|
||||
const GIT_ENV = {
|
||||
...Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^GIT_/i.test(key))),
|
||||
GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t',
|
||||
GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null',
|
||||
GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'init.defaultBranch', GIT_CONFIG_VALUE_0: 'dev',
|
||||
};
|
||||
|
||||
const BODY = '## ТЗ\n\nтело задачи\n';
|
||||
const USAGE = { input_tokens: 11, output_tokens: 22, cache_creation_input_tokens: 33, cache_read_input_tokens: 44, num_turns: 5 };
|
||||
const EVIL = "#!/usr/bin/env node\nprocess.stdout.write('reuse=true\\ndoc=CODE-REVIEW-7-r1.md\\nround=1\\ntree=x\\n');\n"
|
||||
+ 'export const issueBodyDigest = () => "подменено"; export const issueBodyChanged = () => ({ doc: "x", recorded: "y" });\n';
|
||||
|
||||
/**
|
||||
* Временный origin: dev несёт нынешние scripts/ и validate.yml; ветка задачи
|
||||
* ответвлена раньше — model-usage.mjs в ней нет — и подменяет
|
||||
* review-doc-guard.mjs скриптом, который выдаёт себе reuse зелёного вердикта.
|
||||
*/
|
||||
function fixture(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'hp-765-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const git = (cwd, ...args) => {
|
||||
const r = spawnSync('git', args, { cwd, encoding: 'utf8', env: GIT_ENV });
|
||||
assert.equal(r.status, 0, `git ${args.join(' ')}: ${r.stderr}`);
|
||||
return r.stdout.trim();
|
||||
};
|
||||
const origin = join(root, 'origin.git');
|
||||
const work = join(root, 'work');
|
||||
const temp = join(root, 'runner');
|
||||
const bin = join(root, 'bin');
|
||||
mkdirSync(temp);
|
||||
mkdirSync(bin);
|
||||
git(root, 'init', '--bare', '-q', origin);
|
||||
git(root, 'clone', '-q', origin, work);
|
||||
git(work, 'checkout', '-q', '-b', 'dev');
|
||||
writeFileSync(join(work, 'README.md'), 'старый dev\n');
|
||||
git(work, 'add', '-A');
|
||||
git(work, 'commit', '-q', '-m', 'старый dev');
|
||||
const old = git(work, 'rev-parse', 'HEAD');
|
||||
cpSync(join(ROOT, 'scripts'), join(work, 'scripts'), {
|
||||
recursive: true, filter: (src) => !['node_modules', '__pycache__'].includes(basename(src)),
|
||||
});
|
||||
mkdirSync(join(work, '.github', 'workflows'), { recursive: true });
|
||||
cpSync(join(ROOT, '.github', 'workflows', 'validate.yml'), join(work, '.github', 'workflows', 'validate.yml'));
|
||||
git(work, 'add', '-A');
|
||||
git(work, 'commit', '-q', '-m', 'dev');
|
||||
git(work, 'push', '-q', 'origin', 'dev');
|
||||
git(work, 'checkout', '-q', '-b', 'issue/7-x', old);
|
||||
mkdirSync(join(work, 'scripts'), { recursive: true });
|
||||
writeFileSync(join(work, 'scripts', 'review-doc-guard.mjs'), EVIL);
|
||||
writeFileSync(join(work, 'src.txt'), 'работа задачи\n');
|
||||
git(work, 'add', '-A');
|
||||
git(work, 'commit', '-q', '-m', 'ветка задачи');
|
||||
git(work, 'push', '-q', 'origin', 'issue/7-x');
|
||||
git(work, 'checkout', '-q', 'dev');
|
||||
// gh: тело issue — фиксированное, сеть не нужна.
|
||||
writeFileSync(join(root, 'body.md'), BODY);
|
||||
writeFileSync(join(bin, 'gh'), `#!/usr/bin/env bash\ncat '${join(root, 'body.md')}'\n`);
|
||||
chmodSync(join(bin, 'gh'), 0o755);
|
||||
const env = { ...GIT_ENV, RUNNER_TEMP: temp, PATH: `${bin}:${process.env.PATH}` };
|
||||
const run = (body, extra) => {
|
||||
const output = join(temp, `output-${Math.random().toString(36).slice(2)}`);
|
||||
const summary = join(temp, 'summary.md');
|
||||
// Шаг без `shell:` GitHub исполняет как `bash -e {0}`.
|
||||
const r = spawnSync('bash', ['--noprofile', '--norc', '-e', '-c', body], {
|
||||
cwd: work, encoding: 'utf8', env: { ...env, GITHUB_OUTPUT: output, GITHUB_STEP_SUMMARY: summary, ...extra },
|
||||
});
|
||||
let out = '';
|
||||
try { out = readFileSync(output, 'utf8'); } catch { /* шаг ничего не записал */ }
|
||||
return { ...r, out };
|
||||
};
|
||||
return { root, work, temp, git, run };
|
||||
}
|
||||
|
||||
const outputOf = (out, key) => out.match(new RegExp(`^${key}=(.*)$`, 'm'))?.[1];
|
||||
|
||||
test('#765 AC2: шаги prepare на ветке, отставшей от dev и подменившей review-doc-guard.mjs, исполняют dev', (t) => {
|
||||
if (!hasTools()) { t.skip('bash/tar/git недоступны'); return; }
|
||||
const fx = fixture(t);
|
||||
const steps = prepareSteps();
|
||||
const tools = fx.run(named(steps, TOOLS_STEP).run);
|
||||
assert.equal(tools.status, 0, tools.stderr);
|
||||
const dir = outputOf(tools.out, 'dir');
|
||||
const sha = outputOf(tools.out, 'sha');
|
||||
assert.equal(sha, fx.git(fx.work, 'rev-parse', 'origin/dev'), 'закреплён SHA dev');
|
||||
assert.ok(!dir.startsWith(fx.work), 'снимок вне рабочей копии');
|
||||
// «Перейти на ветку задачи»: рабочая копия становится материалом.
|
||||
fx.git(fx.work, 'checkout', '-q', 'origin/issue/7-x');
|
||||
const env = { TOOLS: dir, NUM: '7', REPO: 'o/r', GH_TOKEN: 'x' };
|
||||
|
||||
const material = fx.run(named(steps, 'Зафиксировать SHA материала ревью').run, env);
|
||||
assert.equal(material.status, 0, material.stderr);
|
||||
assert.equal(outputOf(material.out, 'sha'), fx.git(fx.work, 'rev-parse', 'HEAD'), 'якорь — материал, а не снимок');
|
||||
assert.equal(outputOf(material.out, 'issue_body'), issueBodyDigest(BODY), 'хеш тела — функцией dev, не ветки');
|
||||
|
||||
const reuse = fx.run(named(steps, 'Зелёный вердикт прошлого захода применим без ревью (#499)').run,
|
||||
{ ...env, ISSUE_BODY: issueBodyDigest(BODY) });
|
||||
assert.equal(reuse.status, 0, reuse.stderr);
|
||||
assert.equal(outputOf(reuse.out, 'reuse'), 'false', 'подменённый скрипт выдал бы себе reuse=true — ревью без модели');
|
||||
|
||||
const spec = fx.run(named(steps, 'ТЗ менялось после зелёного ревью ТЗ (#517)').run,
|
||||
{ ...env, DIGEST: issueBodyDigest(BODY) });
|
||||
assert.equal(spec.status, 0, spec.stderr);
|
||||
assert.equal(outputOf(spec.out, 'changed'), 'false', 'подменённый скрипт объявил бы ТЗ изменившимся');
|
||||
});
|
||||
|
||||
test('#765 AC3: расход снимается скриптом закреплённого SHA — на ветке без model-usage.mjs и после сдвига dev', (t) => {
|
||||
if (!hasTools()) { t.skip('bash/tar/git недоступны'); return; }
|
||||
const fx = fixture(t);
|
||||
const tools = fx.run(named(prepareSteps(), TOOLS_STEP).run);
|
||||
assert.equal(tools.status, 0, tools.stderr);
|
||||
const sha = outputOf(tools.out, 'sha');
|
||||
// dev двинулся после подготовки: новая версия скрипта расхода печатает чужое.
|
||||
fx.git(fx.work, 'checkout', '-q', 'dev');
|
||||
writeFileSync(join(fx.work, 'scripts', 'model-usage.mjs'), "process.stdout.write('позже\\n');\n");
|
||||
fx.git(fx.work, 'add', '-A');
|
||||
fx.git(fx.work, 'commit', '-q', '-m', 'dev позже');
|
||||
fx.git(fx.work, 'push', '-q', 'origin', 'dev');
|
||||
// model_review: рабочая копия — материал, model-usage.mjs в нём нет.
|
||||
fx.git(fx.work, 'checkout', '-q', 'origin/issue/7-x');
|
||||
const exec = join(fx.temp, 'execution.json');
|
||||
writeFileSync(exec, JSON.stringify([{ type: 'system' }, { type: 'result', usage: USAGE, num_turns: USAGE.num_turns }]));
|
||||
const body = named(modelSteps(), USAGE_STEP).run;
|
||||
const usage = fx.run(body, { EXEC: exec, TOOLS_SHA: sha });
|
||||
assert.equal(usage.status, 0, usage.stderr);
|
||||
assert.equal(outputOf(usage.out, 'line'), formatUsage(USAGE), 'строка данных, а не missing и не версия позже');
|
||||
// Нет SHA из prepare — громкий сбой отчётного шага, а не молчаливая строка.
|
||||
const lost = fx.run(body, { EXEC: exec, TOOLS_SHA: '' });
|
||||
assert.notEqual(lost.status, 0);
|
||||
assert.match(lost.stdout + lost.stderr, /нет SHA снимка/);
|
||||
});
|
||||
@@ -101,7 +101,7 @@ test('#636 workflows: prepare exits pending with a sealed marker, resume relabel
|
||||
// #623: триггер — у тонкого вызывающего файла, тело — в `_process-resume.yml`.
|
||||
const resumeCaller = readFileSync(new URL('../.github/workflows/process-resume.yml', import.meta.url), 'utf8');
|
||||
const validate = readFileSync(new URL('../.github/workflows/validate.yml', import.meta.url), 'utf8');
|
||||
assert.match(process, /validate-gate\.mjs --repo="\$\{\{ github\.repository \}\}" --ref="\$BRANCH" --sha="\$SHA" --no-wait/);
|
||||
assert.match(process, /node "\$TOOLS\/scripts\/validate-gate\.mjs" --repo="\$\{\{ github\.repository \}\}" --ref="\$BRANCH" --sha="\$SHA" --no-wait/);
|
||||
assert.match(process, /2\) echo 'proceed=pending' >> "\$GITHUB_OUTPUT"/);
|
||||
assert.match(process, /review-pending-\$\{NUM\}-\$\{GITHUB_RUN_ID\}-\$\{GITHUB_RUN_ATTEMPT\}/);
|
||||
assert.match(process, /sha256sum pending\.json > manifest\.sha256/);
|
||||
|
||||
@@ -137,10 +137,12 @@ test('конвейер: трек снимается до ребейза, мут
|
||||
assert.ok(branch < track && track < rebase && rebase < gate, 'трек — после выбора ветки и до ребейза');
|
||||
const trackStep = workflow.slice(track, rebase);
|
||||
assert.match(trackStep, /LABELS: \$\{\{ needs\.guard\.outputs\.labels \}\}/, 'метки — текущие, из guard');
|
||||
assert.match(trackStep, /git archive origin\/dev scripts \| tar -x -C "\$tools"/, 'скрипт — из dev: ветка show/ship не ребейзится');
|
||||
// #765: скрипт — из снимка dev подготовки: ветка show/ship не ребейзится.
|
||||
assert.match(trackStep, /TOOLS: \$\{\{ steps\.tools\.outputs\.dir \}\}/, 'скрипт — из снимка dev: ветка show/ship не ребейзится');
|
||||
assert.doesNotMatch(trackStep, /git archive/, 'своего извлечения у шага нет — один снимок на заход');
|
||||
// #707: один вызов скрипта решает трек, рамки ship и риск; bash только исполняет.
|
||||
assert.equal((trackStep.match(/process-track\.mjs/g) || []).length, 1, 'скрипт трека вызывается один раз');
|
||||
assert.match(trackStep, /node "\$tools\/scripts\/process-track\.mjs" stage --stage="\$STAGE" --labels="\$LABELS" \\\n\s+--branch="\$BRANCH" --base=origin\/dev --head=HEAD --comments="\$comments" --owner="\$OWNER"/);
|
||||
assert.match(trackStep, /node "\$TOOLS\/scripts\/process-track\.mjs" stage --stage="\$STAGE" --labels="\$LABELS" \\\n\s+--branch="\$BRANCH" --base=origin\/dev --head=HEAD --comments="\$comments" --owner="\$OWNER"/);
|
||||
assert.match(trackStep, /if printf '%s\\n' "\$out" \| grep -qx 'raise=true'; then\n\s+gh issue comment "\$NUM" --repo "\$\{\{ github\.repository \}\}" --body-file "\$RUNNER_TEMP\/track\/raise\.md"/,
|
||||
'комментарий повышения — из файла скрипта, только по его флагу');
|
||||
assert.match(trackStep, /--add-label track:show --remove-label track:ship/, 'выход за рамки повышает трек');
|
||||
@@ -652,6 +654,8 @@ const TRACK_STEP = ' - name: "Трек задачи и рамки ship (#69
|
||||
const GUARD_STEP = ' - id: decide\n';
|
||||
const DECIDE_STEP = ' - name: Решение по вердикту\n';
|
||||
const TOOLS_STEP = ' - name: Скрипты конвейера — из dev (#749)\n';
|
||||
// #765: снимок подготовки — шаги prepare (трек, ребейз) зовут скрипты из него.
|
||||
const PREPARE_TOOLS_STEP = ' - name: Скрипты конвейера — из dev (#765)\n';
|
||||
const PUBLISH_STEP = ' - name: Опубликовать документ ревью\n';
|
||||
|
||||
test('#707 AC4: изменённые run шага трека, guard и решения по вердикту проходят bash -n', async (t) => {
|
||||
@@ -785,7 +789,7 @@ function trackSandbox(t, { change, base = () => {} }) {
|
||||
].join('\n'), { mode: 0o755 });
|
||||
const read = (path) => (existsSync(path) ? readFileSync(path, 'utf8') : '');
|
||||
let tools = '';
|
||||
return {
|
||||
const box = {
|
||||
work, fake,
|
||||
run(script, env) {
|
||||
for (const name of ['gh-calls', 'comment.md']) rmSync(join(fake, name), { force: true });
|
||||
@@ -811,15 +815,18 @@ function trackSandbox(t, { change, base = () => {} }) {
|
||||
if (list === null) rmSync(join(fake, 'labels'), { force: true });
|
||||
else writeFileSync(join(fake, 'labels'), `${list.join(',')}\n`);
|
||||
},
|
||||
/** #749: шаг снимка job integrate как есть; его каталог дальше идёт шагам как TOOLS. */
|
||||
snapshot() {
|
||||
const r = this.run(stepRun(readFileSync(WORKFLOW, 'utf8'), TOOLS_STEP), {});
|
||||
/** #749/#765: шаг снимка job как есть; его каталог дальше идёт шагам как TOOLS. */
|
||||
snapshot(step = TOOLS_STEP) {
|
||||
const r = this.run(stepRun(readFileSync(WORKFLOW, 'utf8'), step), {});
|
||||
assert.equal(r.status, 0, `снимок скриптов dev: ${r.stderr}`);
|
||||
assert.ok(r.output.dir && existsSync(join(r.output.dir, 'scripts', 'process-track.mjs')), 'снимок несёт скрипт трека');
|
||||
tools = r.output.dir;
|
||||
return tools;
|
||||
},
|
||||
};
|
||||
// #765: в prepare снимок dev идёт до перехода на материал и до трека.
|
||||
box.snapshot(PREPARE_TOOLS_STEP);
|
||||
return box;
|
||||
}
|
||||
|
||||
const touchChange = (work) => writeFileSync(join(work, 'src', 'pointer-modality.ts'),
|
||||
|
||||
@@ -211,8 +211,10 @@ test('#643 process.yml: шаг «Привести ветку к dev» ребей
|
||||
const step = rebaseStep();
|
||||
assert.ok(step.length > 0, 'шаг найден');
|
||||
assert.doesNotMatch(step, /^\s+if ! git rebase origin\/dev/m, 'голого git rebase больше нет');
|
||||
assert.match(step, /git archive origin\/dev scripts \| tar -x -C "\$tools"/, 'помощник — из dev, не из отставшей ветки');
|
||||
assert.match(step, /files=\$\(node "\$tools\/scripts\/rebase-generated\.mjs" --onto=origin\/dev\) \|\| code=\$\?/);
|
||||
// #765: помощник — из снимка dev подготовки, не из отставшей ветки.
|
||||
assert.match(step, /TOOLS: \$\{\{ steps\.tools\.outputs\.dir \}\}/, 'помощник — из снимка dev, не из отставшей ветки');
|
||||
assert.doesNotMatch(step, /git archive/, 'своего извлечения нет — один снимок на заход');
|
||||
assert.match(step, /files=\$\(node "\$TOOLS\/scripts\/rebase-generated\.mjs" --onto=origin\/dev\) \|\| code=\$\?/);
|
||||
assert.match(step, /if \[ "\$code" -ne 0 \] && \[ "\$code" -ne 3 \]; then/, 'сбой помощника — не конфликт');
|
||||
assert.match(step, /echo 'conflict=true'\n\s+echo 'conflicts<<EOF_FILES'/, 'выход conflict/conflicts на месте');
|
||||
// Порядок: ребейз → push с lease → ожидание ссылки. Индекс в ветке задачи
|
||||
@@ -237,16 +239,20 @@ function runStepRebase(work) {
|
||||
const step = rebaseStep();
|
||||
const body = step.slice(step.indexOf(' run: |\n') + ' run: |\n'.length)
|
||||
.split('\n').map((line) => line.replace(/^ {10}/, '')).join('\n');
|
||||
const from = body.indexOf('tools="$RUNNER_TEMP/rebase-tools"');
|
||||
const from = body.indexOf('code=0\nfiles=$(node "$TOOLS/scripts/rebase-generated.mjs"');
|
||||
const to = body.indexOf('# #657 (1б): индекс ревью в ветке');
|
||||
assert.ok(from >= 0 && to > from, 'ребейзная часть шага найдена');
|
||||
const temp = mkdtempSync(join(tmpdir(), 'hp-runner-'));
|
||||
try {
|
||||
const output = join(temp, 'output');
|
||||
writeFileSync(output, '');
|
||||
// #765: снимок dev подготовки (его шаг исполняет process-prepare-tools.test.mjs).
|
||||
const tools = join(temp, 'dev-tools');
|
||||
mkdirSync(tools);
|
||||
execFileSync('bash', ['-eo', 'pipefail', '-c', `git archive origin/dev scripts | tar -x -C "${tools}"`], { cwd: work, env: ENV });
|
||||
const script = `${body.slice(from, to)}\necho REBASED\n`;
|
||||
const r = spawnSync('bash', ['--noprofile', '--norc', '-eo', 'pipefail', '-c', script], {
|
||||
cwd: work, encoding: 'utf8', env: { ...ENV, RUNNER_TEMP: temp, GITHUB_OUTPUT: output, BRANCH: 'issue/9-fix' },
|
||||
cwd: work, encoding: 'utf8', env: { ...ENV, RUNNER_TEMP: temp, GITHUB_OUTPUT: output, BRANCH: 'issue/9-fix', TOOLS: tools },
|
||||
});
|
||||
return { status: r.status, stdout: r.stdout, stderr: r.stderr, output: readFileSync(output, 'utf8') };
|
||||
} finally {
|
||||
@@ -404,7 +410,7 @@ function runStepPush(pushStderr) {
|
||||
// #730: сводка шага, куда код слияния пишет причину отказа.
|
||||
const summary = join(temp, 'summary.md');
|
||||
writeFileSync(summary, '');
|
||||
const script = `tools=${JSON.stringify(resolve(SCRIPTS, '..'))}\nbefore=${'b'.repeat(40)}\n${block}\necho PUSHED\n`;
|
||||
const script = `TOOLS=${JSON.stringify(resolve(SCRIPTS, '..'))}\nbefore=${'b'.repeat(40)}\n${block}\necho PUSHED\n`;
|
||||
const r = spawnSync('bash', ['--noprofile', '--norc', '-eo', 'pipefail', '-c', script], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
@@ -469,7 +475,7 @@ test('#705 process.yml: отказ по праву на workflow возвращ
|
||||
const at = (marker) => { const i = WORKFLOW.indexOf(marker); assert.ok(i > 0, `нет «${marker}»`); return i; };
|
||||
const step = rebaseStep();
|
||||
assert.match(step, /"HEAD:refs\/heads\/\$BRANCH" 2> "\$push_err"; then/, 'stderr push идёт в разбор, а не мимо');
|
||||
assert.match(step, /kind=\$\(node "\$tools\/scripts\/merge-candidate\.mjs" --push-refusal="\$push_err"/, 'разбор — кодом слияния из dev');
|
||||
assert.match(step, /kind=\$\(node "\$TOOLS\/scripts\/merge-candidate\.mjs" --push-refusal="\$push_err"/, 'разбор — кодом слияния из снимка dev (#765)');
|
||||
const back = WORKFLOW.slice(at(' - name: "Push ребейза отклонён по праву на workflow — вернуть автору без ревью (#705)"\n'),
|
||||
at(' - name: Validate на материале\n'));
|
||||
assert.match(back, /if: steps\.rebase\.outputs\.refused == 'workflow'\n/);
|
||||
|
||||
@@ -179,6 +179,18 @@ test('повторная приписка заменяет блок, а не к
|
||||
assert.match(twice, /# отчёт/);
|
||||
});
|
||||
|
||||
test('#765: повторная приписка идемпотентна — разделитель перед блоком не копится', () => {
|
||||
const anchors = { sha: 'a'.repeat(40), tree: 'b'.repeat(40), branch: 'dev', specs: [] };
|
||||
const once = withMaterialAnchors('# отчёт\n\nтекст\n', anchors);
|
||||
const thrice = withMaterialAnchors(withMaterialAnchors(once, anchors), anchors);
|
||||
assert.equal(thrice, once);
|
||||
assert.equal(thrice.match(/^---$/gm).length, 1, 'один разделитель');
|
||||
// Разделитель самого документа — не наш: он остаётся.
|
||||
const own = withMaterialAnchors('# отчёт\n\n---\n\nвывод\n', anchors);
|
||||
assert.equal(withMaterialAnchors(own, anchors), own);
|
||||
assert.match(own, /---\n\nвывод\n\n---\n\n<!-- material-anchors/);
|
||||
});
|
||||
|
||||
test('список ТЗ разбирается и отсекает мусор (#414)', () => {
|
||||
const parsed = parseSpecList(
|
||||
`${'a'.repeat(40)} docs/specs/403-x.md;короткий docs/specs/y.md;${'b'.repeat(40)} ;`,
|
||||
@@ -647,7 +659,8 @@ test('конвейер: посторонняя метка не входит в c
|
||||
|
||||
test('конвейер: зелёный вердикт применяется повторно без модели, вердикт пишется в якоря (#499)', () => {
|
||||
const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8');
|
||||
assert.match(workflow, /review-doc-guard\.mjs --reuse --marker=CODE-REVIEW --num="\$NUM" --head=HEAD/);
|
||||
assert.match(workflow, /node "\$TOOLS\/scripts\/review-doc-guard\.mjs" --reuse --marker=CODE-REVIEW --num="\$NUM" --head=HEAD/,
|
||||
'reuse решает скрипт снимка dev, не ветки (#765)');
|
||||
const modelJob = workflow.slice(workflow.indexOf('\n model_review:'), workflow.indexOf('\n integrate:'));
|
||||
assert.match(modelJob, /if: needs\.prepare\.outputs\.proceed == 'true' && needs\.prepare\.outputs\.reuse != 'true'/,
|
||||
'при повторном применении вся стадия модели пропускается');
|
||||
@@ -673,7 +686,7 @@ test('#510 AC2: конвейер запускает Validate с мутантам
|
||||
assert.ok(material < gate && gate < back && back < modelJob && modelJob < deps && deps < review,
|
||||
'гейт закончен в отдельной стадии до установки зависимостей/ревью');
|
||||
const gateStep = workflow.slice(gate, back);
|
||||
assert.match(gateStep, /node scripts\/validate-gate\.mjs --repo="\$\{\{ github\.repository \}\}" --ref="\$BRANCH" --sha="\$SHA"/);
|
||||
assert.match(gateStep, /node "\$TOOLS\/scripts\/validate-gate\.mjs" --repo="\$\{\{ github\.repository \}\}" --ref="\$BRANCH" --sha="\$SHA"/);
|
||||
assert.match(gateStep, /if \[ "\$STAGE" != "code" \] \|\| \[ "\$REUSE" = "true" \]/, 'этап spec и reuse гейт не проходят');
|
||||
assert.match(gateStep, /SHA: \$\{\{ steps\.material\.outputs\.sha \}\}/, 'проверяется именно материал');
|
||||
// skip-ветка (spec/reuse) даёт proceed=true: ревью идёт, возврата S7→S6 нет (ревью ТЗ r2)
|
||||
@@ -865,7 +878,7 @@ test('#551: gates, модель и интеграция имеют незави
|
||||
assert.match(prepare, /timeout-minutes: 55/);
|
||||
assert.match(model, /timeout-minutes: 45/);
|
||||
assert.match(integrate, /timeout-minutes: 55/);
|
||||
assert.match(prepare, /node scripts\/validate-gate\.mjs/);
|
||||
assert.match(prepare, /node "\$TOOLS\/scripts\/validate-gate\.mjs"/);
|
||||
assert.doesNotMatch(model, /validate-gate\.mjs/, 'ожидания Validate нет в бюджете модели');
|
||||
assert.match(model, /needs: \[guard, prepare\]/);
|
||||
assert.match(integrate, /needs: \[guard, prepare, model_review\]/);
|
||||
|
||||
Reference in New Issue
Block a user