Files
houseplan-card/test/release-contract.test.mjs
T
Matysh eef3634f23 chore: drop Project v2 from the process, the docs and the release script
The owner stopped using GitHub Projects. Most of this is wording, but one part
was not: release-prerelease.mjs talked to the Project in code. finishIssues
looked up the project id, listed its items and its Status=Done option, and threw
when an issue was missing from the board — so the first release that closed an
issue would have died on a step with nothing to do with publishing. Found by
reading rather than by releasing, which was luck.

Closing issues stays, and now strips the status label first. That order is not
cosmetic: the invariant that a closed issue carries no status label has broken
twice already, both times because a manual step did it the other way round. The
close-merged job already does it in this order.

The documents now say labels and only labels. The explicit "no longer used"
lines are kept on purpose, in PROCESS.md and next to the code that used to sync:
a decision that vanishes quietly gets reintroduced a month later by someone who
never knew it was made.

Issue: #139
User-Visible: no
2026-08-14 10:48:58 +03:00

223 lines
11 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import {
assertReleaseContract,
changelogContainsVersion,
parseVersionSources,
validateReleaseNotes,
validateVersionSources,
versionFromTag,
} from '../scripts/release-contract.mjs';
import {
assertHacsDiscoverableTag, parseIssueList, parsePrereleaseArgs, prereleaseWorkflowSucceeded,
readZipEntries, verifyReleaseProjection,
} from '../scripts/release-prerelease.mjs';
const repo = 'Matysh/houseplan-card';
const packageVersion = JSON.parse(
readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
).version;
const tag = `v${packageVersion}`;
const packageIsPrerelease = packageVersion.includes('-');
const notes = `<!-- release: ${tag} -->
## Основное
- Значимое изменение.
- Мелкие исправления и улучшения.
## Highlights
- Significant change.
- Small fixes and improvements.
[RU](https://github.com/${repo}/blob/${tag}/docs/CHANGELOG.ru.md)
[EN](https://github.com/${repo}/blob/${tag}/docs/CHANGELOG.md)
`;
test('release SemVer parser accepts prereleases and rejects unsafe tags', () => {
assert.deepEqual(versionFromTag(tag), { version: packageVersion, prerelease: packageIsPrerelease });
assert.deepEqual(versionFromTag('v2.0.0'), { version: '2.0.0', prerelease: false });
assert.throws(() => versionFromTag('1.2.3-beta.1'), /start with v/);
assert.throws(() => versionFromTag('v1.2.3-beta.01'), /leading zeroes/);
assert.throws(() => validateVersionSources('v1.2.3', { source: '1.2.3' }), /requires a prerelease/);
});
test('HACS prerelease naming switches from beta.9 to rc.1', () => {
assert.equal(assertHacsDiscoverableTag('v1.62.0-beta.9'), 'v1.62.0-beta.9');
assert.equal(assertHacsDiscoverableTag('v1.62.0-rc.1'), 'v1.62.0-rc.1');
assert.throws(
() => assertHacsDiscoverableTag('v1.62.0-beta.10'),
/not HACS-discoverable.*use rc\.1/,
);
});
test('version sources include every shipped authority and must match the tag', () => {
const sources = parseVersionSources({
packageJson: '{"version":"1.2.3-beta.4"}',
packageLock: '{"version":"1.2.3-beta.4","packages":{"":{"version":"1.2.3-beta.4"}}}',
manifest: '{"version":"1.2.3-beta.4"}',
constSource: 'VERSION = "1.2.3-beta.4"',
cardSource: "const CARD_VERSION = '1.2.3-beta.4';",
});
assert.equal(Object.keys(sources).length, 6);
assert.equal(validateVersionSources('v1.2.3-beta.4', sources).version, '1.2.3-beta.4');
assert.throws(
() => validateVersionSources('v1.2.3-beta.5', sources),
/Version 1\.2\.3-beta\.5 is not synchronized/,
);
});
test('release notes enforce the canonical bilingual short body and pinned links', () => {
assert.equal(validateReleaseNotes(notes, { tag, repo }).trim(), notes.trim());
assert.throws(
() => validateReleaseNotes(notes.replace('## Основное', '## Русский'), { tag, repo }),
/Legacy release-note headings/,
);
assert.throws(
() => validateReleaseNotes(notes.replace('## Highlights', '## English\n\n## Highlights'), { tag, repo }),
/Legacy release-note headings/,
);
assert.throws(
() => validateReleaseNotes(notes.replace(`<!-- release: ${tag} -->`, '<!-- release: v9.9.9 -->'), { tag, repo }),
/exact .* marker/,
);
assert.throws(
() => validateReleaseNotes(notes.replace('## Основное', '- premature\n\n## Основное'), { tag, repo }),
/before ## Основное/,
);
assert.throws(
() => validateReleaseNotes(notes.replace(`/blob/${tag}/`, '/blob/dev/'), { tag, repo }),
/immutable/,
);
assert.throws(
() => validateReleaseNotes(notes.replace('- Significant change.\n', ''), { tag, repo }),
/equivalent RU\/EN lists/,
);
const oversized = notes
.replace('\n## Highlights', '\n- Second.\n- Third.\n- Fourth.\n\n## Highlights')
.replace('## Highlights\n', '## Highlights\n- Second.\n- Third.\n- Fourth.\n');
assert.throws(() => validateReleaseNotes(oversized, { tag, repo }), /at most four bullets/);
assert.equal(changelogContainsVersion(`## ${tag} — 2026-08-10\n\n- Released.\n`, tag), true);
assert.equal(changelogContainsVersion(`## ${tag}\n`, tag), false);
assert.equal(changelogContainsVersion(`## ${tag} — 2026-99-99\n\n- Released.\n`, tag), false);
assert.equal(changelogContainsVersion(`## ${tag} — 2026-08-10\n`, tag), false);
assert.equal(changelogContainsVersion(`\`\`\`md\n## ${tag} — 2026-08-10\n- Fake.\n\`\`\``, tag), false);
const nested = notes.replace('- Значимое изменение.', '- Значимое изменение.\n - Деталь реализации.');
assert.equal(validateReleaseNotes(nested, { tag, repo }).trim(), nested.trim());
});
test('current repository release metadata satisfies its own publication contract', () => {
const result = assertReleaseContract({ tag, repo, requirePrerelease: packageIsPrerelease });
assert.equal(result.version, packageVersion);
assert.equal(result.prerelease, packageIsPrerelease);
});
test('local orchestrator validates issue lists and public release assets', () => {
assert.deepEqual(parseIssueList('63, 56,63'), [63, 56]);
assert.throws(() => parseIssueList('63,nope'), /positive issue numbers/);
assert.deepEqual(parsePrereleaseArgs([tag, '--issues=63,64', '--yes']), {
tag, repo, branch: 'dev', issueOption: '63,64',
checkOnly: false, confirmed: true,
});
assert.throws(() => parsePrereleaseArgs([tag, '--isues=63']), /Unknown or malformed/);
// Project v2 больше не используется: опция снята вместе с синхронизацией
// статуса, и её молчаливое принятие обещало бы работу, которой нет.
assert.throws(() => parsePrereleaseArgs([tag, '--project=1']), /Unknown or malformed/);
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 }],
};
assert.equal(verifyReleaseProjection(release, { tag }), release);
assert.throws(
() => verifyReleaseProjection({ ...release, isDraft: true }, { tag }),
/still a draft/,
);
assert.throws(
() => verifyReleaseProjection({ ...release, assets: release.assets.slice(0, 1) }, { 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);
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',
);
});
test('release ZIP inspection is portable and does not depend on tar', () => {
const root = fileURLToPath(new URL('..', import.meta.url));
const temp = mkdtempSync(join(tmpdir(), 'houseplan-zip-test-'));
const zip = join(temp, 'houseplan.zip');
try {
const archived = spawnSync('git', [
'-c', 'core.autocrlf=false', 'archive', '--format=zip', `--output=${zip}`,
'HEAD:custom_components/houseplan',
], { cwd: root, encoding: 'utf8' });
assert.equal(archived.status, 0, archived.stderr || archived.stdout);
const entries = readZipEntries(zip, ['manifest.json', 'frontend/houseplan-card.js']);
assert.equal(typeof JSON.parse(entries.get('manifest.json').toString('utf8')).version, 'string');
assert.ok(entries.get('frontend/houseplan-card.js').length > 1_000);
const committed = spawnSync('git', ['show', 'HEAD:dist/houseplan-card.js'], { cwd: root }).stdout;
const hash = (contents) => createHash('sha256').update(contents).digest('hex');
assert.equal(hash(entries.get('frontend/houseplan-card.js')), hash(committed));
} finally {
rmSync(temp, { recursive: true, force: true });
}
});
test('prerelease CLI reports malformed arguments without a raw stack trace', () => {
const root = fileURLToPath(new URL('..', import.meta.url));
const result = spawnSync(process.execPath, [
'scripts/release-prerelease.mjs', tag, '--isues=63',
], { cwd: root, encoding: 'utf8' });
assert.notEqual(result.status, 0);
assert.match(result.stderr, /^prerelease publication failed: Unknown or malformed option:/);
assert.doesNotMatch(result.stderr, /\n\s+at\s/);
});
test('manual publish workflow is draft-first, exact-SHA gated and self-contained', () => {
const workflow = readFileSync(new URL('../.github/workflows/publish-prerelease.yml', import.meta.url), 'utf8');
for (const required of [
'workflow_dispatch:',
'node scripts/release-contract.mjs',
'node scripts/release-gate.mjs',
'--draft --prerelease',
"'houseplan-card.js', 'houseplan.zip'",
'--draft=false --prerelease',
'Verify HACS prerelease discovery order',
'group: publish-prerelease-${{ inputs.tag }}',
"if: ${{ needs.publish.outputs.newly_published == 'true' }}",
'uses: ./.github/workflows/announce.yml',
]) assert.ok(workflow.includes(required), `missing workflow contract: ${required}`);
assert.ok(workflow.indexOf('gh release upload') < workflow.indexOf('--draft=false --prerelease'));
const announce = readFileSync(new URL('../.github/workflows/announce.yml', import.meta.url), 'utf8');
assert.ok(announce.includes('workflow_call:'));
assert.ok(announce.includes('if: ${{ inputs.reusable == true }}'));
assert.ok(announce.includes('CALLED: ${{ inputs.reusable }}'));
assert.ok(announce.includes('BODY=$(cat docs/RELEASE-NOTES.md)'));
assert.ok(announce.includes("github.event_name == 'release' && github.event.release.prerelease == false"));
assert.ok(announce.includes("github.event_name == 'workflow_call' && inputs.prerelease == false"));
assert.ok(announce.includes('Prerelease Telegram announcement is disabled'));
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('Published release needs stale-asset recovery'));
assert.ok(local.includes("['SIGINT'"));
assert.ok(!local.includes("run('tar'"));
});