mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 15:09:30 +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:
@@ -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']]);
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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'"));
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user