mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 12:49:56 +00:00
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:
+37
-30
@@ -4,11 +4,17 @@
|
||||
*
|
||||
* Стабильный релиз проходил Validate и Full Performance на точном SHA, но ни
|
||||
* разу не запускался в настоящем HA. Репозиторий houseplan-e2e ставит House
|
||||
* Plan из `houseplan.zip` релиза — те же байты, что скачивает HACS, — и гоняет
|
||||
* 13 сценариев Playwright. Этот скрипт запускает его workflow на теге и ждёт
|
||||
* зелёного; `release.yml` вызывает его для `!prerelease` после Full Performance.
|
||||
* Plan и гоняет 13 сценариев Playwright. Этот скрипт запускает его workflow и
|
||||
* ждёт зелёного; `release.yml` вызывает его для стабильных после Full Performance.
|
||||
*
|
||||
* node scripts/e2e-gate.mjs --tag=<vX.Y.Z> [--repo=Matysh/houseplan-e2e] [--workflow=e2e.yml]
|
||||
* #540: под тестом — коммит-кандидат (`--ref=<sha>`), а не публичный релиз.
|
||||
* install-houseplan.mjs для ветки/коммита ставит `custom_components/houseplan`
|
||||
* из tarball codeload — то же дерево, из которого `git archive` строит
|
||||
* `houseplan.zip`. Так релиз проверяется ДО того, как станет публичным; раньше
|
||||
* гейт качал ZIP из релиза, то есть требовал публикации до проверки. `--tag`
|
||||
* при этом остаётся: он исключает выпускаемый тег из выбора `upgrade_from`.
|
||||
*
|
||||
* node scripts/e2e-gate.mjs --tag=<vX.Y.Z> [--ref=<sha>] [--repo=Matysh/houseplan-e2e] [--workflow=e2e.yml]
|
||||
*
|
||||
* Печатает `result=green|red|missing|error`, `url=…`, `note=…` (и в
|
||||
* $GITHUB_OUTPUT), код выхода 0 только при green. Логика — чистая функция
|
||||
@@ -16,9 +22,8 @@
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { appendFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { resolve } from 'node:path';
|
||||
import { VALIDATE_APPEAR_MS, VALIDATE_TOTAL_MS } from './merge-candidate.mjs';
|
||||
import { isMainModule } from './spawn-portable.mjs';
|
||||
|
||||
export const POLL_MS = 20_000;
|
||||
export const E2E_REPO = 'Matysh/houseplan-e2e';
|
||||
@@ -42,15 +47,16 @@ export function previousStable(releases, tag) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Прогон — наш, если сьют, ставящий сам тег, назван по нему: имя job в
|
||||
* Прогон — наш, если сьют, ставящий сам кандидат, назван по нему: имя job в
|
||||
* e2e.yml — `"${suite} · HP ${ref} · HA ${ha}"`, и у `journeys`/`first-run`
|
||||
* `ref` — это `houseplan_ref`. Сьют `upgrade` носит `upgrade_from` — тег
|
||||
* ПРЕДЫДУЩЕГО stable, поэтому «любая job с HP <tag>» приняла бы прогон нового
|
||||
* релиза за прогон старого (живой прогон 09.09: v1.72.0 ← run для v1.73.0).
|
||||
* `ref` — это `houseplan_ref` (тег или SHA, #540). Сьют `upgrade` носит
|
||||
* `upgrade_from` — тег ПРЕДЫДУЩЕГО stable, поэтому «любая job с HP <ref>»
|
||||
* приняла бы прогон нового релиза за прогон старого (живой прогон 09.09:
|
||||
* v1.72.0 ← run для v1.73.0).
|
||||
*/
|
||||
export const TAG_SUITES = ['journeys', 'first-run'];
|
||||
export function isOurRun(jobs, tag) {
|
||||
const needles = TAG_SUITES.map((suite) => `${suite} · HP ${tag} · `);
|
||||
export function isOurRun(jobs, ref) {
|
||||
const needles = TAG_SUITES.map((suite) => `${suite} · HP ${ref} · `);
|
||||
return (Array.isArray(jobs) ? jobs : []).some((job) => needles.some((needle) => String(job?.name || '').startsWith(needle)));
|
||||
}
|
||||
|
||||
@@ -59,25 +65,26 @@ export function isOurRun(jobs, tag) {
|
||||
* сначала планирует матрицу отдельной job, и первые секунды виден только
|
||||
* «Матрица прогона». Живой прогон 09.09 записал такой run в чужие навсегда.
|
||||
*/
|
||||
export function classifyRun(jobs, tag) {
|
||||
export function classifyRun(jobs, ref) {
|
||||
const named = (Array.isArray(jobs) ? jobs : []).filter((job) => / · HP .+ · /.test(String(job?.name || '')));
|
||||
if (!named.length) return 'unknown';
|
||||
return isOurRun(named, tag) ? 'ours' : 'foreign';
|
||||
return isOurRun(named, ref) ? 'ours' : 'foreign';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} p
|
||||
* @param {string} p.tag тег релиза (houseplan_ref для e2e.yml)
|
||||
* @param {object} p.ops { releases() → [{tagName,isDraft,isPrerelease}] новые первыми, dispatch(tag, upgradeFrom), listRuns() → [{databaseId,status,conclusion,url,createdAt}], jobs(runId) → [{name,conclusion}], sleep(ms), now() }
|
||||
* @param {string} p.tag выпускаемый тег — исключается из выбора `upgrade_from`
|
||||
* @param {string} [p.ref] что ставить под тест (`houseplan_ref` для e2e.yml): SHA кандидата (#540); по умолчанию сам тег
|
||||
* @param {object} p.ops { releases() → [{tagName,isDraft,isPrerelease}] новые первыми, dispatch(ref, upgradeFrom), listRuns() → [{databaseId,status,conclusion,url,createdAt}], jobs(runId) → [{name,conclusion}], sleep(ms), now() }
|
||||
* @returns {Promise<{result:'green'|'red'|'missing'|'error', url:string|null, note:string}>}
|
||||
*/
|
||||
export async function e2eGate({ tag, ops, appearMs = VALIDATE_APPEAR_MS, totalMs = VALIDATE_TOTAL_MS, pollMs = POLL_MS }) {
|
||||
export async function e2eGate({ tag, ref = tag, ops, appearMs = VALIDATE_APPEAR_MS, totalMs = VALIDATE_TOTAL_MS, pollMs = POLL_MS }) {
|
||||
const started = ops.now();
|
||||
try {
|
||||
// Список релизов читается ДО dispatch и обязан падать громко (ревью r3 M1):
|
||||
// fine-grained токен «только houseplan-e2e» не видит houseplan-card, и
|
||||
// тихий пустой список дал бы upgrade_from=stable — тег сам на себя.
|
||||
await ops.dispatch(tag, previousStable(await ops.releases(), tag));
|
||||
await ops.dispatch(ref, previousStable(await ops.releases(), tag));
|
||||
} catch (error) {
|
||||
const message = String(error?.message || error);
|
||||
const forbidden = /403|Resource not accessible|not accessible by/i.test(message);
|
||||
@@ -93,7 +100,7 @@ export async function e2eGate({ tag, ops, appearMs = VALIDATE_APPEAR_MS, totalMs
|
||||
for (const candidate of runs) {
|
||||
const createdAt = Date.parse(candidate.createdAt || '') || 0;
|
||||
if (createdAt < started - CLOCK_SKEW_MS) continue;
|
||||
const kind = classifyRun(await ops.jobs(candidate.databaseId), tag);
|
||||
const kind = classifyRun(await ops.jobs(candidate.databaseId), ref);
|
||||
if (kind === 'ours') { run = candidate; break; }
|
||||
if (kind === 'foreign' || candidate.status === 'completed') foreign.add(candidate.databaseId);
|
||||
}
|
||||
@@ -101,16 +108,16 @@ export async function e2eGate({ tag, ops, appearMs = VALIDATE_APPEAR_MS, totalMs
|
||||
if (run) {
|
||||
tracked = run.databaseId;
|
||||
if (run.status === 'completed') {
|
||||
if (run.conclusion === 'success') return { result: 'green', url: run.url, note: `E2E на ${tag} зелёный` };
|
||||
if (run.conclusion === 'cancelled') return { result: 'red', url: run.url, note: `E2E на ${tag} отменён вручную — перезапустите гейт` };
|
||||
return { result: 'red', url: run.url, note: `E2E на ${tag} завершился: ${run.conclusion}` };
|
||||
if (run.conclusion === 'success') return { result: 'green', url: run.url, note: `E2E на ${ref} зелёный` };
|
||||
if (run.conclusion === 'cancelled') return { result: 'red', url: run.url, note: `E2E на ${ref} отменён вручную — перезапустите гейт` };
|
||||
return { result: 'red', url: run.url, note: `E2E на ${ref} завершился: ${run.conclusion}` };
|
||||
}
|
||||
} else if (ops.now() - started > appearMs) {
|
||||
return { result: 'missing', url: null, note: `dispatch e2e.yml на ${tag} не появился за ${Math.round(appearMs / 60000)} мин` };
|
||||
return { result: 'missing', url: null, note: `dispatch e2e.yml на ${ref} не появился за ${Math.round(appearMs / 60000)} мин` };
|
||||
}
|
||||
await ops.sleep(pollMs);
|
||||
}
|
||||
return { result: 'red', url: tracked ? `run ${tracked}` : null, note: `E2E на ${tag} не завершился за ${Math.round(totalMs / 60000)} мин` };
|
||||
return { result: 'red', url: tracked ? `run ${tracked}` : null, note: `E2E на ${ref} не завершился за ${Math.round(totalMs / 60000)} мин` };
|
||||
}
|
||||
|
||||
const sh = (cmd, args) => spawnSync(cmd, args, { encoding: 'utf8' });
|
||||
@@ -124,9 +131,9 @@ export function realOps({ repo = E2E_REPO, workflow = E2E_WORKFLOW, cardRepo = C
|
||||
if (r.status !== 0) throw new Error(`gh release list ${cardRepo}: ${(r.stderr || r.stdout || '').trim()}`);
|
||||
return r.stdout ? JSON.parse(r.stdout) : [];
|
||||
},
|
||||
dispatch: async (tag, upgradeFrom = 'stable') => {
|
||||
dispatch: async (ref, upgradeFrom = 'stable') => {
|
||||
const r = exec('gh', ['workflow', 'run', workflow, '--repo', repo, '--ref', 'main',
|
||||
'-f', `houseplan_ref=${tag}`, '-f', `upgrade_from=${upgradeFrom}`, '-f', 'ha_version=stable']);
|
||||
'-f', `houseplan_ref=${ref}`, '-f', `upgrade_from=${upgradeFrom}`, '-f', 'ha_version=stable']);
|
||||
if (r.status !== 0) throw new Error(`gh workflow run: ${(r.stderr || r.stdout || '').trim()}`);
|
||||
},
|
||||
listRuns: async () => parse(exec('gh', ['run', 'list', '--repo', repo, '--workflow', workflow, '--event', 'workflow_dispatch', '--json', fields, '--limit', '10'])),
|
||||
@@ -139,15 +146,15 @@ export function realOps({ repo = E2E_REPO, workflow = E2E_WORKFLOW, cardRepo = C
|
||||
};
|
||||
}
|
||||
|
||||
const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
|
||||
if (invokedDirectly) {
|
||||
if (isMainModule(import.meta.url)) { // #496: переносимо для Windows
|
||||
const arg = (name) => process.argv.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3);
|
||||
const tag = arg('tag');
|
||||
if (!tag) {
|
||||
console.error('usage: e2e-gate.mjs --tag=<vX.Y.Z> [--repo=Matysh/houseplan-e2e] [--workflow=e2e.yml] [--card-repo=Matysh/houseplan-card]');
|
||||
console.error('usage: e2e-gate.mjs --tag=<vX.Y.Z> [--ref=<sha>] [--repo=Matysh/houseplan-e2e] [--workflow=e2e.yml] [--card-repo=Matysh/houseplan-card]');
|
||||
process.exit(2);
|
||||
}
|
||||
const outcome = await e2eGate({ tag, ops: realOps({ repo: arg('repo') || E2E_REPO, workflow: arg('workflow') || E2E_WORKFLOW, cardRepo: arg('card-repo') || CARD_REPO }) });
|
||||
const ref = arg('ref') || tag;
|
||||
const outcome = await e2eGate({ tag, ref, ops: realOps({ repo: arg('repo') || E2E_REPO, workflow: arg('workflow') || E2E_WORKFLOW, cardRepo: arg('card-repo') || CARD_REPO }) });
|
||||
const lines = [`result=${outcome.result}`, `url=${outcome.url || ''}`, `note=${outcome.note}`];
|
||||
for (const line of lines) console.log(line);
|
||||
if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join('\n')}\n`);
|
||||
|
||||
@@ -7782,8 +7782,70 @@ const MUTANT_DEFINITIONS = [
|
||||
+ 'ответ». Без неё анонс уходит при красном гейте, то есть ровно то, что случилось',
|
||||
patches: [{
|
||||
file: '.github/workflows/release.yml',
|
||||
find: ' name: Оповещение о релизе после выкладки\n needs: build',
|
||||
replace: ' name: Оповещение о релизе после выкладки\n if: always()',
|
||||
find: ' name: Оповещение о релизе после выкладки\n needs: [candidate, publish]\n'
|
||||
+ " if: ${{ needs.publish.outputs.newly_published == 'true' }}",
|
||||
replace: ' name: Оповещение о релизе после выкладки\n needs: [candidate]\n if: always()',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'asset-upload-stops-needing-the-gate',
|
||||
guard: 'node --test test/release-workflow.test.mjs',
|
||||
because: '#540: единственный публикатор ассетов ценен ровно тем, что стоит ЗА гейтом. '
|
||||
+ 'Снятая зависимость — это `release-zip.yml` под другим именем: ассеты уходят в '
|
||||
+ 'ту же минуту, когда Validate, Full Performance и E2E ещё идут или уже красные',
|
||||
patches: [{
|
||||
file: '.github/workflows/release.yml',
|
||||
find: ' stage:\n name: "Сборка: ассеты, SHA256SUMS и загрузка в черновик"\n needs: [candidate, gate]',
|
||||
replace: ' stage:\n name: "Сборка: ассеты, SHA256SUMS и загрузка в черновик"\n needs: [candidate]',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'hand-published-release-stays-public-during-the-gates',
|
||||
guard: 'node --test test/release-workflow.test.mjs',
|
||||
because: '#540: fail-closed держится на одном шаге — релиз, опубликованный руками, '
|
||||
+ 'немедленно возвращается в черновик. Без него всё время гейтов (час и больше) '
|
||||
+ 'снаружи висит публичный релиз с непроверенными или отсутствующими ассетами — '
|
||||
+ 'состояние v1.75.0 12.09',
|
||||
patches: [{
|
||||
file: '.github/workflows/release.yml',
|
||||
find: ' gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft\n',
|
||||
replace: ' true\n',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'repair-clobbers-the-public-asset',
|
||||
guard: 'node --test test/release-workflow.test.mjs',
|
||||
because: '#540: ремонт догружает только недостающее. `--clobber` на публичном релизе '
|
||||
+ 'подменяет байты, которые кто-то уже скачал и установил, — молча и без следа; '
|
||||
+ 'расхождение хеша обязано быть отказом, а не перезаписью',
|
||||
patches: [{
|
||||
file: '.github/workflows/release.yml',
|
||||
find: ' gh release upload "$TAG" $missing --repo "$GITHUB_REPOSITORY"\n',
|
||||
replace: ' gh release upload "$TAG" $missing --repo "$GITHUB_REPOSITORY" --clobber\n',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'e2e-gate-tests-the-tag-instead-of-the-candidate',
|
||||
guard: 'node --test test/e2e-gate.test.mjs',
|
||||
because: '#540: E2E на теге качает `houseplan.zip` из публичного релиза — то есть '
|
||||
+ 'требует публикации ДО проверки, и цикл «релиз должен быть публичным, чтобы его '
|
||||
+ 'проверить» возвращается. Под тестом обязан быть SHA кандидата',
|
||||
patches: [{
|
||||
file: 'scripts/e2e-gate.mjs',
|
||||
find: ' await ops.dispatch(ref, previousStable(await ops.releases(), tag));',
|
||||
replace: ' await ops.dispatch(tag, previousStable(await ops.releases(), tag));',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'passport-accepts-a-different-hash',
|
||||
guard: 'node --test test/release-assets.test.mjs',
|
||||
because: '#540: паспорт ассетов существует ради одного сравнения — публичные байты '
|
||||
+ 'равны проверенным. Ослеплённое сравнение делает SHA256SUMS украшением: релиз с '
|
||||
+ 'подменённым ZIP проходит сверку зелёным',
|
||||
patches: [{
|
||||
file: 'scripts/release-assets.mjs',
|
||||
find: ' else if (actual[name] !== expected[name]) mismatched.push(name);',
|
||||
replace: ' else if (false) mismatched.push(name);',
|
||||
}],
|
||||
},
|
||||
{
|
||||
@@ -8812,8 +8874,8 @@ const MUTANT_DEFINITIONS = [
|
||||
+ 'a failed run as green ships the assets the run just rejected (#514 AC1)',
|
||||
patches: [{
|
||||
file: 'scripts/e2e-gate.mjs',
|
||||
find: " if (run.conclusion === 'success') return { result: 'green', url: run.url, note: `E2E на ${tag} зелёный` };",
|
||||
replace: " return { result: 'green', url: run.url, note: `E2E на ${tag} зелёный` }; // mutant: completed means green",
|
||||
find: " if (run.conclusion === 'success') return { result: 'green', url: run.url, note: `E2E на ${ref} зелёный` };",
|
||||
replace: " return { result: 'green', url: run.url, note: `E2E на ${ref} зелёный` }; // mutant: completed means green",
|
||||
}],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Хеши установочных ассетов релиза (#540).
|
||||
*
|
||||
* Релиз ставится из `houseplan.zip` (HACS) и `houseplan-card.js` (ручная
|
||||
* установка). Раз проверив кандидата, публиковать надо ровно те байты, что
|
||||
* проверены, — поэтому у ассетов есть паспорт `SHA256SUMS`, который считается
|
||||
* на этапе сборки, кладётся в релиз рядом с ассетами и сверяется с тем, что
|
||||
* реально скачивается после публикации или при ремонте существующего релиза.
|
||||
*
|
||||
* node scripts/release-assets.mjs sums <dir> [--out=<file>]
|
||||
* посчитать sha256 установочных ассетов в <dir>, записать SHA256SUMS
|
||||
* node scripts/release-assets.mjs check <dir> <SHA256SUMS> [--allow-missing]
|
||||
* сверить файлы в <dir> с паспортом; расхождение — код выхода 1
|
||||
*
|
||||
* Логика — чистые функции, чтобы контракт проверялся юнитами без диска.
|
||||
*/
|
||||
import { createHash } from 'node:crypto';
|
||||
import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { isMainModule } from './spawn-portable.mjs';
|
||||
|
||||
/** Установочные ассеты — то, что скачивает HACS и человек. Ровно эти два. */
|
||||
export const INSTALLABLE_ASSETS = ['houseplan-card.js', 'houseplan.zip'];
|
||||
export const SUMS_FILE = 'SHA256SUMS';
|
||||
|
||||
export const sha256Hex = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
||||
|
||||
/** Формат `sha256sum`: `<hex> <name>` по строке, детерминированный порядок. */
|
||||
export function formatSums(entries) {
|
||||
const names = Object.keys(entries).sort();
|
||||
if (!names.length) throw new Error('SHA256SUMS: нет ни одного ассета');
|
||||
return `${names.map((name) => `${entries[name]} ${name}`).join('\n')}\n`;
|
||||
}
|
||||
|
||||
export function parseSums(text) {
|
||||
const entries = {};
|
||||
for (const raw of String(text).split(/\r?\n/)) {
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
const match = /^([0-9a-f]{64})\s+\*?(\S+)$/.exec(line);
|
||||
if (!match) throw new Error(`SHA256SUMS: непонятная строка «${raw}»`);
|
||||
if (entries[match[2]]) throw new Error(`SHA256SUMS: ${match[2]} встречается дважды`);
|
||||
entries[match[2]] = match[1];
|
||||
}
|
||||
if (!Object.keys(entries).length) throw new Error('SHA256SUMS: пустой паспорт');
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сравнить паспорт с фактическими хешами. `actual` может не содержать файла —
|
||||
* это «missing» (при ремонте такой ассет догружается), а вот присутствующий
|
||||
* файл с другим хешем — «mismatched», и это всегда отказ: публичные байты не
|
||||
* подменяются молча.
|
||||
*/
|
||||
export function compareSums(expected, actual) {
|
||||
const missing = [];
|
||||
const mismatched = [];
|
||||
for (const name of Object.keys(expected).sort()) {
|
||||
if (!(name in actual)) missing.push(name);
|
||||
else if (actual[name] !== expected[name]) mismatched.push(name);
|
||||
}
|
||||
const extra = Object.keys(actual).filter((name) => !(name in expected)).sort();
|
||||
return { ok: !missing.length && !mismatched.length, missing, mismatched, extra };
|
||||
}
|
||||
|
||||
export function sumsOfDirectory(dir, names = INSTALLABLE_ASSETS) {
|
||||
const entries = {};
|
||||
for (const name of names) {
|
||||
const path = resolve(dir, name);
|
||||
if (!existsSync(path)) continue;
|
||||
entries[name] = sha256Hex(readFileSync(path));
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
if (isMainModule(import.meta.url)) { // #496: переносимо для Windows
|
||||
try {
|
||||
const args = process.argv.slice(2);
|
||||
const flag = (name) => args.find((a) => a === `--${name}` || a.startsWith(`--${name}=`));
|
||||
const value = (name) => flag(name)?.split('=').slice(1).join('=') || '';
|
||||
const positionals = args.filter((a) => !a.startsWith('--'));
|
||||
const [command, dir, sumsPath] = positionals;
|
||||
if (command === 'sums') {
|
||||
if (!dir) throw new Error('usage: release-assets.mjs sums <dir> [--out=<file>]');
|
||||
const entries = sumsOfDirectory(dir);
|
||||
for (const name of INSTALLABLE_ASSETS) {
|
||||
if (!entries[name]) throw new Error(`${name} отсутствует в ${dir} — паспорт не выписывается на неполный набор`);
|
||||
}
|
||||
const out = value('out') || resolve(dir, SUMS_FILE);
|
||||
writeFileSync(out, formatSums(entries));
|
||||
console.log(formatSums(entries).trimEnd());
|
||||
console.log(`→ ${out}`);
|
||||
} else if (command === 'check') {
|
||||
if (!dir || !sumsPath) throw new Error('usage: release-assets.mjs check <dir> <SHA256SUMS> [--allow-missing]');
|
||||
const expected = parseSums(readFileSync(sumsPath, 'utf8'));
|
||||
const actual = sumsOfDirectory(dir, Object.keys(expected));
|
||||
const result = compareSums(expected, actual);
|
||||
for (const name of Object.keys(expected).sort()) {
|
||||
const state = result.mismatched.includes(name) ? 'MISMATCH'
|
||||
: result.missing.includes(name) ? 'missing' : 'ok';
|
||||
console.log(`${state.padEnd(8)} ${name}`);
|
||||
}
|
||||
if (result.mismatched.length) {
|
||||
throw new Error(`хеш расходится: ${result.mismatched.join(', ')} — байты не те, что проверены`);
|
||||
}
|
||||
if (result.missing.length && !flag('allow-missing')) {
|
||||
throw new Error(`ассетов нет на месте: ${result.missing.join(', ')}`);
|
||||
}
|
||||
if (process.env.GITHUB_OUTPUT) {
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, `missing=${result.missing.join(' ')}\n`);
|
||||
}
|
||||
} else {
|
||||
throw new Error('usage: release-assets.mjs sums|check …');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -41,10 +41,15 @@ export function parseVersionSources({
|
||||
};
|
||||
}
|
||||
|
||||
export function validateVersionSources(tag, sources, { requirePrerelease = true } = {}) {
|
||||
export function validateVersionSources(tag, sources, { requirePrerelease = true, requireStable = false } = {}) {
|
||||
const parsed = versionFromTag(tag);
|
||||
if (requirePrerelease && !parsed.prerelease)
|
||||
throw new Error(`Prerelease publication requires a prerelease SemVer tag: ${tag}`);
|
||||
// #540: стабильный путь (release.yml) — зеркальное требование: тег без
|
||||
// пре-релизного суффикса. Режима «любой тег» нет: публикатор всегда знает,
|
||||
// что выпускает.
|
||||
if (requireStable && parsed.prerelease)
|
||||
throw new Error(`Stable publication requires a stable SemVer tag, got prerelease ${tag}`);
|
||||
const mismatches = Object.entries(sources)
|
||||
.filter(([, value]) => value !== parsed.version)
|
||||
.map(([name, value]) => `${name}=${JSON.stringify(value)}`);
|
||||
@@ -133,10 +138,10 @@ export function readReleaseContract(root = process.cwd()) {
|
||||
}
|
||||
|
||||
export function assertReleaseContract({
|
||||
root = process.cwd(), tag, repo = 'Matysh/houseplan-card', requirePrerelease = true,
|
||||
root = process.cwd(), tag, repo = 'Matysh/houseplan-card', requirePrerelease = true, requireStable = false,
|
||||
} = {}) {
|
||||
const contract = readReleaseContract(root);
|
||||
const parsed = validateVersionSources(tag, contract.sources, { requirePrerelease });
|
||||
const parsed = validateVersionSources(tag, contract.sources, { requirePrerelease, requireStable });
|
||||
if (!changelogContainsVersion(contract.changelogRu, tag))
|
||||
throw new Error(`docs/CHANGELOG.ru.md has no dated ${tag} section`);
|
||||
if (!changelogContainsVersion(contract.changelogEn, tag))
|
||||
@@ -152,14 +157,18 @@ if (invokedDirectly) {
|
||||
const args = process.argv.slice(2);
|
||||
const positionals = args.filter((arg) => !arg.startsWith('--'));
|
||||
const repoArgs = args.filter((arg) => arg.startsWith('--repo='));
|
||||
const unknown = args.filter((arg) => arg.startsWith('--') && !arg.startsWith('--repo='));
|
||||
// #540: `--stable` — контракт стабильного релиза (release.yml). Тот же
|
||||
// набор проверок; отличие одно — тег обязан быть БЕЗ пре-релизного суффикса,
|
||||
// как без флага он обязан быть с ним. Третьего режима «любой тег» нет.
|
||||
const stable = args.includes('--stable');
|
||||
const unknown = args.filter((arg) => arg.startsWith('--') && !arg.startsWith('--repo=') && arg !== '--stable');
|
||||
if (positionals.length !== 1) throw new Error('Exactly one release tag is required');
|
||||
if (repoArgs.length > 1 || unknown.length)
|
||||
throw new Error(`Unknown or duplicate release-contract arguments: ${[...repoArgs.slice(1), ...unknown].join(', ')}`);
|
||||
const tag = positionals[0];
|
||||
const repo = repoArgs[0]?.slice('--repo='.length)
|
||||
|| process.env.GITHUB_REPOSITORY || 'Matysh/houseplan-card';
|
||||
const result = assertReleaseContract({ tag, repo, requirePrerelease: true });
|
||||
const result = assertReleaseContract({ tag, repo, requirePrerelease: !stable, requireStable: stable });
|
||||
console.log(JSON.stringify({
|
||||
ok: true, tag: result.tag, version: result.version,
|
||||
prerelease: result.prerelease, sources: result.sources,
|
||||
|
||||
@@ -14,8 +14,8 @@ import { stdin, stdout } from 'node:process';
|
||||
import { assertReleaseContract } from './release-contract.mjs';
|
||||
import { classifyValidateRuns } from './release-gate.mjs';
|
||||
import { assertBundleManifest } from './bundle-tree.mjs';
|
||||
import { SUMS_FILE, compareSums, formatSums, parseSums, sumsOfDirectory } from './release-assets.mjs';
|
||||
|
||||
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
|
||||
const SUBPROCESS_MAX_BUFFER = 64 * 1024 * 1024;
|
||||
|
||||
class ReleaseAssetContentError extends Error {}
|
||||
@@ -80,19 +80,13 @@ export function verifyReleaseProjection(release, { tag }) {
|
||||
if (release.isDraft) throw new Error(`GitHub release ${tag} is still a draft`);
|
||||
if (!release.isPrerelease) throw new Error(`GitHub release ${tag} is not marked as a prerelease`);
|
||||
const assets = new Map((release.assets || []).map((asset) => [asset.name, asset]));
|
||||
for (const name of ['houseplan-card.js', 'houseplan.zip']) {
|
||||
for (const name of ['houseplan-card.js', 'houseplan.zip', SUMS_FILE]) {
|
||||
const asset = assets.get(name);
|
||||
if (!asset || !(Number(asset.size) > 0)) throw new Error(`Release asset ${name} is missing or empty`);
|
||||
}
|
||||
return release;
|
||||
}
|
||||
|
||||
/** Telegram announcements are deliberately skipped for prereleases. */
|
||||
export function prereleaseWorkflowSucceeded(label, conclusion) {
|
||||
return conclusion === 'success'
|
||||
|| (label === 'Announce release' && conclusion === 'skipped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Read selected root entries from an ordinary ZIP archive without relying on
|
||||
* platform-specific `tar`/`unzip` executables. GitHub runners, Git Bash, WSL
|
||||
@@ -358,7 +352,7 @@ if (invokedDirectly) {
|
||||
try {
|
||||
run('gh', [
|
||||
'release', 'download', tag, '--repo', repo, '--dir', download,
|
||||
'--pattern', 'houseplan-card.js', '--pattern', 'houseplan.zip', '--clobber',
|
||||
'--pattern', 'houseplan-card.js', '--pattern', 'houseplan.zip', '--pattern', SUMS_FILE, '--clobber',
|
||||
]);
|
||||
try {
|
||||
const cardPath = resolve(download, 'houseplan-card.js');
|
||||
@@ -366,6 +360,12 @@ if (invokedDirectly) {
|
||||
if (cardHash !== bundleSnapshot.entrySha256)
|
||||
throw new Error(`Published houseplan-card.js hash ${cardHash} != candidate ${bundleSnapshot.entrySha256}`);
|
||||
verifyZipContents(resolve(download, 'houseplan.zip'), version, bundleSnapshot);
|
||||
// #540: паспорт обязан быть и обязан описывать ровно эти байты.
|
||||
const passport = compareSums(
|
||||
parseSums(readFileSync(resolve(download, SUMS_FILE), 'utf8')),
|
||||
sumsOfDirectory(download),
|
||||
);
|
||||
if (!passport.ok) throw new Error(`Published ${SUMS_FILE} disagrees with the assets: ${JSON.stringify(passport)}`);
|
||||
} catch (error) {
|
||||
throw new ReleaseAssetContentError(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
@@ -408,45 +408,9 @@ if (invokedDirectly) {
|
||||
return runs;
|
||||
};
|
||||
|
||||
const waitForRun = async (runId, label) => {
|
||||
let last = '';
|
||||
for (let attempt = 0; attempt < 360; attempt++) {
|
||||
const row = ghJson([
|
||||
'run', 'view', String(runId), '--repo', repo, '--json', 'status,conclusion,url',
|
||||
]);
|
||||
const state = `${row.status}/${row.conclusion || '-'}`;
|
||||
if (state !== last) console.log(`${label}: ${state} ${row.url}`);
|
||||
last = state;
|
||||
if (row.status === 'completed') {
|
||||
if (!prereleaseWorkflowSucceeded(label, row.conclusion))
|
||||
throw new Error(`${label} concluded ${row.conclusion}: ${row.url}`);
|
||||
return row;
|
||||
}
|
||||
await sleep(10_000);
|
||||
}
|
||||
throw new Error(`${label} did not complete within one hour`);
|
||||
};
|
||||
|
||||
const waitForReleaseWorkflows = async (sha) => {
|
||||
const expected = [
|
||||
['release.yml', 'Release'],
|
||||
['release-zip.yml', 'Attach HACS zip'],
|
||||
['announce.yml', 'Announce release'],
|
||||
];
|
||||
for (const [workflow, label] of expected) {
|
||||
let match = null;
|
||||
for (let attempt = 0; attempt < 300 && !match; attempt++) {
|
||||
const runs = ghJson([
|
||||
'run', 'list', '--repo', repo, '--workflow', workflow, '--event', 'release',
|
||||
'--limit', '30', '--json', 'databaseId,headBranch,headSha,status,conclusion,url',
|
||||
]);
|
||||
match = runs.find((row) => row.headBranch === tag && row.headSha === sha) || null;
|
||||
if (!match) await sleep(2_000);
|
||||
}
|
||||
if (!match) throw new Error(`${label} workflow did not start for ${tag} at ${sha}`);
|
||||
await waitForRun(match.databaseId, label);
|
||||
}
|
||||
};
|
||||
// #540: после публикации никто больше не ждёт релизные workflow на событии:
|
||||
// независимых републикаторов нет, ассеты беты выкладывает только этот путь,
|
||||
// и сверка выложенного с кандидатом (sha256) делается здесь же ниже.
|
||||
|
||||
const verifyHacsDiscovery = () => {
|
||||
const pages = ghJson(['api', '--paginate', '--slurp', `repos/${repo}/releases?per_page=100`]);
|
||||
@@ -611,23 +575,28 @@ if (invokedDirectly) {
|
||||
release = releaseView();
|
||||
}
|
||||
|
||||
// #540: паспорт ассетов — единый вид релиза с release.yml. Считается с
|
||||
// тех самых файлов, что уходят наверх, и сверяется после публикации.
|
||||
const sumsPath = resolve(artifactsDir, SUMS_FILE);
|
||||
writeFileSync(sumsPath, formatSums({
|
||||
'houseplan-card.js': sha256Path(bundlePath),
|
||||
'houseplan.zip': sha256Path(zipPath),
|
||||
}));
|
||||
run('gh', [
|
||||
'release', 'upload', tag, bundlePath, zipPath,
|
||||
'release', 'upload', tag, bundlePath, zipPath, sumsPath,
|
||||
'--repo', repo, '--clobber',
|
||||
], { inherit: true });
|
||||
const staged = releaseView();
|
||||
const stagedAssets = new Map((staged?.assets || []).map((asset) => [asset.name, asset]));
|
||||
for (const name of ['houseplan-card.js', 'houseplan.zip']) {
|
||||
for (const name of ['houseplan-card.js', 'houseplan.zip', SUMS_FILE]) {
|
||||
if (!(Number(stagedAssets.get(name)?.size) > 0))
|
||||
throw new Error(`Draft release asset ${name} is missing or empty`);
|
||||
}
|
||||
|
||||
const wasDraft = staged.isDraft;
|
||||
run('gh', [
|
||||
'release', 'edit', tag, '--repo', repo, '--draft=false', '--prerelease',
|
||||
'--title', tag, '--notes-file', 'docs/RELEASE-NOTES.md',
|
||||
], { inherit: true });
|
||||
if (wasDraft) await waitForReleaseWorkflows(sha);
|
||||
|
||||
const published = verifyReleaseProjection(releaseView(), { tag });
|
||||
const finalTag = remoteTag();
|
||||
|
||||
Reference in New Issue
Block a user