mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
`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
80 lines
4.4 KiB
JavaScript
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 });
|
|
}
|
|
});
|