ci: единый staged→tested→published путь установочных ассетов (#540)

`release-zip.yml` выкладывал `houseplan.zip` в ту же секунду, когда релиз
становился публичным — до Validate, Full Performance и E2E; `release.yml`
параллельно пересобирал `houseplan-card.js`, а E2E требовал публичного ZIP,
чтобы вообще начаться. Публикаторов было четыре, порядок — ни одного.

Теперь публикатор стабильных один — `release.yml`: закрепить SHA → релиз в
черновике (опубликованный руками немедленно возвращается в черновик) → гейты
на SHA (трейлер `Release: <tag>`, контракт `--stable`, Validate, Full
Performance, E2E на коммите-кандидате через tarball codeload) → одна сборка,
`git archive` ZIP из того же дерева, `SHA256SUMS` → загрузка в черновик →
публикация → скачать публичное и сверить с паспортом → анонс. Dispatch на
публичный тег — ремонт: догружается только недостающее, расходящийся хеш —
отказ. Беты кладут тот же паспорт; локальный публикатор больше не ждёт
републикаторов — их нет.

- `.github/workflows/release-zip.yml` удалён
- `scripts/release-assets.mjs` — паспорт ассетов (`sums`/`check`), чистые
  функции под юнитами
- `scripts/e2e-gate.mjs --ref=<sha>` — под тестом кандидат, `--tag` только
  для выбора `upgrade_from`
- `scripts/release-contract.mjs --stable`
- мутанты: независимый публикатор, снятая зависимость от гейта, релиз без
  возврата в черновик, `--clobber` в ремонте, E2E на теге, слепой паспорт

Issue: #540
User-Visible: no
This commit is contained in:
Claude
2026-09-13 07:42:23 +03:00
parent c53f9ffc02
commit eb77224e0c
18 changed files with 948 additions and 278 deletions
+31
View File
@@ -151,3 +151,34 @@ test('#514: realOps dispatches e2e.yml on main with the tag and the previous sta
await ops.releases();
assert.deepEqual(calls[2].slice(0, 5), ['gh', 'release', 'list', '--repo', 'Matysh/houseplan-card']);
});
// #540: под тестом — коммит-кандидат, не публичный релиз. Гейт диспатчит e2e.yml
// на SHA (install-houseplan.mjs ставит дерево из tarball codeload), опознаёт
// прогон по этому SHA в именах job, а `upgrade_from` по-прежнему выбирает по
// тегу — выпускаемый тег из кандидатов исключается.
const SHA = 'a1b2c3d4e5f60718293a4b5c6d7e8f9012345678';
test('#540 AC1: with --ref the dispatch and the recognition use the candidate SHA, upgrade_from still excludes the tag', async () => {
const oursBySha = [{ name: `journeys · HP ${SHA} · HA stable`, conclusion: 'success' }, { name: 'upgrade · HP v1.73.0 · HA stable', conclusion: 'success' }];
const fake = fakeOps({ snapshots: [[run({ status: 'in_progress', conclusion: null })], [run()]], jobsById: { 1: oursBySha } });
const outcome = await e2eGate({ tag: TAG, ref: SHA, ops: fake.ops, pollMs: 1000 });
assert.equal(outcome.result, 'green');
assert.match(outcome.note, new RegExp(SHA));
assert.deepEqual(fake.dispatched, [[SHA, 'v1.73.0']], 'houseplan_ref is the SHA; upgrade_from is the previous stable, not the tag under release');
});
test('#540 AC1: a run whose jobs carry the tag, not the SHA, is foreign to a SHA-dispatched gate', async () => {
const byTag = run({ databaseId: 3, url: 'https://e2e/run/3' });
const fake = fakeOps({ snapshots: [[byTag], [byTag], [byTag]], jobsById: { 3: ours() }, startedAt: 100_000 });
const outcome = await e2eGate({ tag: TAG, ref: SHA, ops: fake.ops, pollMs: 1000, appearMs: 2500 });
assert.equal(outcome.result, 'missing', 'a tag-named run is not the SHA run — the old ZIP-from-release path is gone');
assert.equal(isOurRun(ours(), SHA), false);
assert.equal(classifyRun([{ name: `first-run · HP ${SHA} · HA stable` }], SHA), 'ours');
});
test('#540: without --ref the gate behaves exactly as before — the tag is the ref', async () => {
const fake = fakeOps({ snapshots: [[run()]], jobsById: { 1: ours() } });
const outcome = await e2eGate({ tag: TAG, ops: fake.ops, pollMs: 1000 });
assert.equal(outcome.result, 'green');
assert.deepEqual(fake.dispatched, [[TAG, 'v1.73.0']]);
});
+5 -2
View File
@@ -59,11 +59,14 @@ test('full performance is isolated to stable, scheduled and manual entry points'
assert.equal((workflow.match(/--candidate-sha=/g) || []).length, 2);
const release = readWorkflow('release.yml');
assert.ok(release.includes('if: ${{ !github.event.release.prerelease }}'));
// #540: признак стабильного — тег кандидата, не поле события: тот же гейт
// работает и по `workflow_dispatch`, где события нет.
assert.ok(release.includes("if: ${{ needs.candidate.outputs.prerelease != 'true' }}"));
assert.ok(release.includes('--workflow=performance.yml --label="Полные бенчмарки производительности"'));
assert.ok(release.includes('test -s dist/houseplan-panel.js'));
assert.ok(release.includes('files: dist/houseplan-card.js'),
assert.ok(release.includes('cp dist/houseplan-card.js houseplan.zip release-assets/'),
'the standalone release asset remains card-only; the panel ships through HACS zip');
assert.ok(!release.includes('softprops/action-gh-release'), 'one upload path (gh release upload into the draft), not two');
});
test('#160 Stage 3 dense fixture extends rather than mutates the historical witness', () => {
+79
View File
@@ -0,0 +1,79 @@
// #540: паспорт установочных ассетов — публикуются ровно те байты, что прошли гейты.
import assert from 'node:assert/strict';
import test from 'node:test';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import {
INSTALLABLE_ASSETS, SUMS_FILE, compareSums, formatSums, parseSums, sha256Hex, sumsOfDirectory,
} from '../scripts/release-assets.mjs';
const A = 'a'.repeat(64);
const B = 'b'.repeat(64);
const C = 'c'.repeat(64);
test('#540: the passport covers exactly the two installable assets, in sha256sum format, sorted', () => {
assert.deepEqual(INSTALLABLE_ASSETS, ['houseplan-card.js', 'houseplan.zip']);
assert.equal(SUMS_FILE, 'SHA256SUMS');
const text = formatSums({ 'houseplan.zip': A, 'houseplan-card.js': B });
assert.equal(text, `${B} houseplan-card.js\n${A} houseplan.zip\n`);
assert.deepEqual(parseSums(text), { 'houseplan-card.js': B, 'houseplan.zip': A });
assert.deepEqual(parseSums(`${A} *houseplan.zip\r\n`), { 'houseplan.zip': A }, 'binary marker and CRLF tolerated');
assert.throws(() => formatSums({}), /нет ни одного/);
assert.throws(() => parseSums(''), /пустой/);
assert.throws(() => parseSums('deadbeef x'), /непонятная/);
assert.throws(() => parseSums(`${A} x\n${B} x\n`), /дважды/);
});
test('#540 AC3: a present asset with another hash is a mismatch — never a silent replacement; an absent one is merely missing', () => {
const expected = { 'houseplan-card.js': B, 'houseplan.zip': A };
assert.deepEqual(compareSums(expected, { 'houseplan-card.js': B, 'houseplan.zip': A }),
{ ok: true, missing: [], mismatched: [], extra: [] });
assert.deepEqual(compareSums(expected, { 'houseplan-card.js': B }),
{ ok: false, missing: ['houseplan.zip'], mismatched: [], extra: [] }, 'repair may add what is missing');
assert.deepEqual(compareSums(expected, { 'houseplan-card.js': B, 'houseplan.zip': C }),
{ ok: false, missing: [], mismatched: ['houseplan.zip'], extra: [] }, 'v1.75.0 class: public bytes differ from the verified ones');
assert.deepEqual(compareSums(expected, { 'houseplan-card.js': B, 'houseplan.zip': A, 'extra.bin': C }).extra, ['extra.bin']);
});
test('#540: the CLI writes the passport from real files and refuses an incomplete set; check exits 1 on a mismatch', () => {
const script = fileURLToPath(new URL('../scripts/release-assets.mjs', import.meta.url));
const dir = mkdtempSync(join(tmpdir(), 'hp-release-assets-'));
try {
writeFileSync(join(dir, 'houseplan-card.js'), 'card');
let r = spawnSync(process.execPath, [script, 'sums', dir], { encoding: 'utf8' });
assert.equal(r.status, 1, 'no passport for a half set');
assert.match(r.stderr, /houseplan\.zip отсутствует/);
writeFileSync(join(dir, 'houseplan.zip'), 'zip');
r = spawnSync(process.execPath, [script, 'sums', dir], { encoding: 'utf8' });
assert.equal(r.status, 0, r.stderr);
const sums = readFileSync(join(dir, SUMS_FILE), 'utf8');
assert.deepEqual(parseSums(sums), {
'houseplan-card.js': sha256Hex(Buffer.from('card')),
'houseplan.zip': sha256Hex(Buffer.from('zip')),
});
assert.deepEqual(sumsOfDirectory(dir), parseSums(sums));
r = spawnSync(process.execPath, [script, 'check', dir, join(dir, SUMS_FILE)], { encoding: 'utf8' });
assert.equal(r.status, 0, r.stderr);
writeFileSync(join(dir, 'houseplan.zip'), 'other bytes');
r = spawnSync(process.execPath, [script, 'check', dir, join(dir, SUMS_FILE)], { encoding: 'utf8' });
assert.equal(r.status, 1);
assert.match(r.stdout, /MISMATCH houseplan\.zip/);
assert.match(r.stderr, /хеш расходится: houseplan\.zip/);
rmSync(join(dir, 'houseplan.zip'));
r = spawnSync(process.execPath, [script, 'check', dir, join(dir, SUMS_FILE)], { encoding: 'utf8' });
assert.equal(r.status, 1, 'missing is a failure by default');
r = spawnSync(process.execPath, [script, 'check', dir, join(dir, SUMS_FILE), '--allow-missing'], { encoding: 'utf8' });
assert.equal(r.status, 0, 'repair mode tolerates a missing asset — it will be added');
assert.match(r.stdout, /missing {2}houseplan\.zip/);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
+19 -14
View File
@@ -15,7 +15,7 @@ import {
versionFromTag,
} from '../scripts/release-contract.mjs';
import {
assertHacsDiscoverableTag, parseIssueList, parsePrereleaseArgs, prereleaseWorkflowSucceeded,
assertHacsDiscoverableTag, parseIssueList, parsePrereleaseArgs,
readZipEntries, verifyReleaseProjection,
} from '../scripts/release-prerelease.mjs';
@@ -138,7 +138,7 @@ test('local orchestrator validates issue lists and public release assets', () =>
assert.throws(() => parsePrereleaseArgs([tag, 'extra']), /Exactly one/);
const release = {
tagName: tag, isDraft: false, isPrerelease: true,
assets: [{ name: 'houseplan-card.js', size: 10 }, { name: 'houseplan.zip', size: 20 }],
assets: [{ name: 'houseplan-card.js', size: 10 }, { name: 'houseplan.zip', size: 20 }, { name: 'SHA256SUMS', size: 5 }],
};
assert.equal(verifyReleaseProjection(release, { tag }), release);
assert.throws(
@@ -146,21 +146,23 @@ test('local orchestrator validates issue lists and public release assets', () =>
/still a draft/,
);
assert.throws(
() => verifyReleaseProjection({ ...release, assets: release.assets.slice(0, 1) }, { tag }),
() => verifyReleaseProjection({ ...release, assets: release.assets.filter((a) => a.name !== 'houseplan.zip') }, { tag }),
/houseplan\.zip/,
);
assert.equal(prereleaseWorkflowSucceeded('Release', 'success'), true);
assert.equal(prereleaseWorkflowSucceeded('Announce release', 'skipped'), true);
assert.equal(prereleaseWorkflowSucceeded('Release', 'skipped'), false);
assert.equal(prereleaseWorkflowSucceeded('Announce release', 'failure'), false);
// #540: паспорт — часть единого вида релиза; бета без него неполна.
assert.throws(
() => verifyReleaseProjection({ ...release, assets: release.assets.slice(0, 2) }, { tag }),
/SHA256SUMS/,
);
const orchestrator = readFileSync(
new URL('../scripts/release-prerelease.mjs', import.meta.url), 'utf8',
);
assert.match(
orchestrator,
/if \(!prereleaseWorkflowSucceeded\(label, row\.conclusion\)\)/,
'the workflow waiter must use the prerelease-aware conclusion policy',
);
// #540: после публикации никто не ждёт независимых републикаторов — их нет.
assert.ok(!/waitForReleaseWorkflows|release-zip\.yml|prereleaseWorkflowSucceeded/.test(orchestrator),
'the local publisher no longer waits for release.yml/release-zip.yml to re-upload what it already verified');
assert.match(orchestrator, /formatSums\(\{\n\s+'houseplan-card\.js': sha256Path\(bundlePath\),\n\s+'houseplan\.zip': sha256Path\(zipPath\),/,
'the passport is computed from the very files that are uploaded');
assert.match(orchestrator, /'release', 'upload', tag, bundlePath, zipPath, sumsPath,/);
});
test('release ZIP inspection is portable and does not depend on tar', () => {
@@ -225,8 +227,11 @@ test('manual publish workflow is draft-first, exact-SHA gated and self-contained
'node scripts/release-contract.mjs',
'node scripts/release-gate.mjs',
'--draft --prerelease',
"'houseplan-card.js', 'houseplan.zip'",
"'houseplan-card.js', 'houseplan.zip', 'SHA256SUMS'",
'test -s dist/houseplan-panel.js',
'node scripts/release-assets.mjs sums release-assets',
'node scripts/release-assets.mjs check public release-assets/SHA256SUMS',
'git -c core.autocrlf=false archive --format=zip --output=houseplan.zip',
'--draft=false --prerelease',
'Verify HACS prerelease discovery order',
'group: publish-prerelease-${{ inputs.tag }}',
@@ -250,7 +255,7 @@ test('manual publish workflow is draft-first, exact-SHA gated and self-contained
const local = readFileSync(new URL('../scripts/release-prerelease.mjs', import.meta.url), 'utf8');
assert.ok(local.includes("'core.autocrlf=false', 'archive', '--format=zip'"));
assert.ok(local.includes("'release', 'download'"));
assert.ok(local.includes("'release-zip.yml'"));
assert.ok(!local.includes("'release-zip.yml'"), '#540: no republisher to wait for');
assert.ok(local.includes('Published release needs stale-asset recovery'));
assert.ok(local.includes("['SIGINT'"));
assert.ok(!local.includes("run('tar'"));
+91 -27
View File
@@ -1,34 +1,99 @@
// #514: release.yml holds the assets of a stable release until E2E on a real HA is green.
// #540: release.yml is the ONLY publisher of installable assets, and it publishes
// only after the gates saw the very same bytes.
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { readdirSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const workflow = readFileSync(new URL('../.github/workflows/release.yml', import.meta.url), 'utf8');
const at = (marker) => { const i = workflow.indexOf(marker); assert.ok(i > 0, `нет «${marker}»`); return i; };
const WORKFLOWS = fileURLToPath(new URL('../.github/workflows/', import.meta.url));
const read = (name) => readFileSync(new URL(name, `file://${WORKFLOWS}`), 'utf8');
const workflow = read('release.yml');
const at = (marker, text = workflow) => { const i = text.indexOf(marker); assert.ok(i > 0, `нет «${marker}»`); return i; };
const job = (name) => {
const start = at(`\n ${name}:\n`);
const rest = workflow.slice(start + 1);
const next = rest.slice(1).search(/\n {2}[a-z-]+:\n/);
return next < 0 ? rest : rest.slice(0, next + 1);
};
const jobNeeds = (name) => {
const m = /^ {4}needs: (.+)$/m.exec(job(name));
if (!m) return [];
return m[1].replace(/[[\]\s]/g, '').split(',').filter(Boolean);
};
test('#514 AC1/AC3: the E2E gate step exists in job gate, after Full Performance, for stable releases only', () => {
const gate = at(' gate:\n');
const build = at(' build:\n');
const perf = at(' - name: Require full performance for a stable release\n');
const e2e = at(' - name: Require green E2E on a real Home Assistant for a stable release\n');
assert.ok(gate < perf && perf < e2e && e2e < build, 'E2E stands after Full Performance inside job gate');
const step = workflow.slice(e2e, build);
assert.match(step, /if: \$\{\{ !github\.event\.release\.prerelease \}\}/, 'prereleases skip the step');
assert.match(step, /node scripts\/e2e-gate\.mjs --tag="\$TAG"/);
assert.match(step, /TAG: \$\{\{ github\.event\.release\.tag_name \}\}/);
test('#540 AC1: exactly one workflow reacts to the release event, and none of them publishes on it', () => {
const listeners = readdirSync(WORKFLOWS).filter((name) => name.endsWith('.yml'))
.filter((name) => /^\s*release:\s*\n\s+types:/m.test(read(name).slice(0, read(name).indexOf('\njobs:'))));
assert.deepEqual(listeners, ['release.yml'], 'release-zip.yml (immediate ZIP upload) is gone and must not come back');
assert.ok(!readdirSync(WORKFLOWS).includes('release-zip.yml'));
// asset uploads live only in the job that needs the gate
const jobs = [...workflow.slice(at('\njobs:\n')).matchAll(/^ {2}([a-z-]+):\n/gm)].map((m) => m[1]);
assert.deepEqual(jobs, ['candidate', 'gate', 'stage', 'publish', 'announce', 'hacs-discovery']);
const uploads = jobs.filter((name) => /gh release upload|softprops\/action-gh-release/.test(job(name)));
assert.deepEqual(uploads, ['stage'], 'the one uploading job');
assert.deepEqual(jobNeeds('stage'), ['candidate', 'gate']);
assert.deepEqual(jobNeeds('publish'), ['candidate', 'gate', 'stage']);
});
test('#514: the gate dispatches with a token that can reach houseplan-e2e, with the process token as fallback', () => {
const e2e = at(' - name: Require green E2E on a real Home Assistant for a stable release\n');
const step = workflow.slice(e2e, at(' build:\n'));
assert.match(step, /GH_TOKEN: \$\{\{ secrets\.E2E_DISPATCH_TOKEN \|\| secrets\.HP_PROCESS_TOKEN \}\}/);
test('#540 AC2: a release published by hand is taken back to draft before any gate runs', () => {
const candidate = job('candidate');
assert.match(candidate, /gh release edit "\$TAG" --repo "\$GITHUB_REPOSITORY" --draft\n/, 'fail-closed re-draft');
assert.ok(at('--draft\n', candidate) < at('\n gate:\n'), 're-draft is in the candidate job, ahead of the gate');
assert.match(candidate, /if \[ "\$EVENT" = "release" \]; then/, 'only a hand-made publication is re-drafted');
assert.match(candidate, /echo "mode=repair"/, 'a dispatch on a public release is a repair, not a re-publication');
assert.match(candidate, /if: \$\{\{ github\.event_name == 'workflow_dispatch' \|\| !github\.event\.release\.prerelease \}\}/,
'betas published by hand are skipped: they have their own staged path');
const triggers = workflow.slice(at('\non:\n'), at('\npermissions:'));
assert.match(triggers, /release:\n\s+types: \[published\]/, '`created` never fires for drafts — `published` catches both paths');
assert.match(triggers, /workflow_dispatch:\n\s+inputs:\n\s+tag:/);
});
test('#540 AC1/#514: the gate judges the exact SHA — trailer names the tag, contract, Validate, Full Performance, E2E on the SHA', () => {
const gate = job('gate');
const trailer = at('grep -Fxq "Release: $TAG"', gate);
const contract = at('node scripts/release-contract.mjs "$TAG" --repo="$GITHUB_REPOSITORY" --stable', gate);
const validate = at('node scripts/release-gate.mjs "$SHA"\n', gate);
const perf = at(' - name: Require full performance for a stable release\n', gate);
const e2e = at(' - name: Require green E2E on a real Home Assistant for a stable release\n', gate);
assert.ok(trailer < contract && contract < validate && validate < perf && perf < e2e, 'order: trailer, contract, Validate, Full Performance, E2E');
const e2eStep = gate.slice(e2e);
assert.match(e2eStep, /if: \$\{\{ needs\.candidate\.outputs\.prerelease != 'true' \}\}/, 'prereleases skip the step');
assert.match(e2eStep, /node scripts\/e2e-gate\.mjs --ref="\$SHA" --tag="\$TAG"/, 'E2E installs the candidate tree, not a public ZIP');
assert.match(e2eStep, /GH_TOKEN: \$\{\{ secrets\.E2E_DISPATCH_TOKEN \|\| secrets\.HP_PROCESS_TOKEN \}\}/);
assert.match(gate, /--workflow=performance\.yml --label="Полные бенчмарки производительности"/);
assert.ok(!/github\.event\.release\.tag_name/.test(gate + job('stage') + job('publish')), 'every job works from the resolved candidate, not the event payload');
});
test('#540 AC3: one build, deterministic ZIP from the tree E2E installed, passport, verified public bytes', () => {
const stage = job('stage');
assert.match(stage, /git -c core\.autocrlf=false archive --format=zip --output=houseplan\.zip \\\n\s+"\$SHA:custom_components\/houseplan"/);
assert.match(stage, /node scripts\/verify-houseplan-zip\.mjs houseplan\.zip/);
assert.match(stage, /git rev-parse "\$SHA:custom_components\/houseplan"/, 'tree hash printed: identity with the E2E tarball');
assert.match(stage, /node scripts\/release-assets\.mjs sums release-assets/);
assert.match(stage, /test -s dist\/houseplan-panel\.js/);
assert.ok(at('node scripts/release-assets.mjs sums', stage) < at('gh release upload', stage), 'passport before upload');
// repair: only missing assets, a differing hash is a failure
assert.match(stage, /if \[ "\$MODE" = "repair" \]; then/);
assert.match(stage, /node scripts\/release-assets\.mjs check public release-assets\/SHA256SUMS --allow-missing/);
const repair = stage.slice(at('if [ "$MODE" = "repair" ]', stage), at(' else\n # Draft', stage));
const repairCommands = repair.split('\n').filter((line) => !/^\s*#/.test(line) && !/gh release download/.test(line)).join('\n');
assert.ok(!/--clobber/.test(repairCommands), 'repair never clobbers a public asset');
assert.match(repair, /gh release upload "\$TAG" \$missing --repo "\$GITHUB_REPOSITORY"\n/);
const publish = job('publish');
assert.ok(at('--draft=false', publish) < at('gh release download', publish), 'publish, then read back what the public sees');
assert.match(publish, /diff -u passport\/SHA256SUMS public\/SHA256SUMS/);
assert.match(publish, /node scripts\/release-assets\.mjs check public passport\/SHA256SUMS\n/);
assert.match(publish, /test "\$\(git rev-list -n 1 "refs\/tags\/\$TAG"\)" = "\$SHA"/);
assert.match(publish, /download-artifact@v7/, 'the passport travels from stage as an artifact, not via the release');
});
// #538: анонс — последнее звено выпуска, а не параллельное ему. Пока он висел
// на самом событии `release: published`, гонку он выигрывал всегда: проверять
// ему нечего. 12.09 v1.75.0 объявили в канале в ту же минуту, когда гейт
// отказал выкладывать ассеты, и снаружи это выглядело обычным релизом.
const announce = readFileSync(new URL('../.github/workflows/announce.yml', import.meta.url), 'utf8');
const announce = read('announce.yml');
test('#538 AC1: событие релиза не может запустить анонс', () => {
const triggers = announce.slice(announce.indexOf('\non:'), announce.indexOf('\npermissions:'));
@@ -39,16 +104,15 @@ test('#538 AC1: событие релиза не может запустить
'мёртвая ветка события не оставлена в шагах');
});
test('#538 AC2: release.yml зовёт анонс после выкладки ассетов', () => {
const job = at(' announce:\n');
const build = at(' build:\n');
assert.ok(build < job, 'анонс описан после сборки, а не до неё');
const block = workflow.slice(job, workflow.indexOf('\n hacs-discovery:'));
// Не `/needs: build/`: в том же блоке лежит комментарий, где эта строка
test('#538 AC2 / #540: release.yml зовёт анонс только после публикации проверенных ассетов', () => {
const block = job('announce');
assert.ok(at('\n publish:\n') < at('\n announce:\n'), 'анонс описан после публикации, а не до неё');
// Не `/needs: publish/`: в том же блоке лежит комментарий, где эта строка
// процитирована, и проверка зеленела бы на нём. Требуется сама директива.
assert.match(block, /^ {4}needs: build$/m, 'анонс зависит от выкладки ассетов');
assert.match(block, /^ {4}needs: \[candidate, publish\]$/m, 'анонс зависит от публикации');
assert.match(block, /if: \$\{\{ needs\.publish\.outputs\.newly_published == 'true' \}\}/, 'ремонт не анонсируется');
assert.match(block, /uses: \.\/\.github\/workflows\/announce\.yml/);
assert.match(block, /prerelease: \$\{\{ github\.event\.release\.prerelease \}\}/,
'беты остаются тихими по тому же признаку, что и раньше');
assert.match(block, /prerelease: \$\{\{ needs\.candidate\.outputs\.prerelease == 'true' \}\}/,
'беты остаются тихими по признаку тега');
assert.match(block, /secrets: inherit/);
});