Files
houseplan-card/test/release-assets.test.mjs
T
Claude eb77224e0c 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
2026-09-13 07:42:23 +03:00

80 lines
4.4 KiB
JavaScript

// #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 });
}
});