diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1f60677c..e3cd56eb 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -28,7 +28,9 @@ HACS discovery are checked before completion. Downloaded JS/ZIP contents are bound to the candidate hash and manifest version; per-tag concurrency rejects parallel publication. ZIP inspection is portable across Windows and Linux - without a system `tar`; stale assets are repaired automatically and handled + without a system `tar`, while standalone JS and hashes come from exact Git + blobs rather than CRLF-sensitive worktree bytes; stale assets are repaired + automatically and handled interruptions release the local lock. The public release is staged as a draft first and partial failures are safe to retry. Existing release workflows remain available as a fallback. ([#63](https://github.com/Matysh/houseplan-card/issues/63)) diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index 08c9adf4..399eff0f 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -35,7 +35,9 @@ аннотированный тег, оба ассета и обнаружение версии в HACS. Содержимое скачанных JS/ZIP сверяется с hash кандидата и версией manifest, а per-tag concurrency не допускает параллельную публикацию. ZIP проверяется одинаково - на Windows и Linux без зависимости от системного `tar`; устаревшие ассеты + на Windows и Linux без зависимости от системного `tar`, а standalone JS и + хеши берутся из точных Git blobs без зависимости от CRLF рабочего дерева; + устаревшие ассеты автоматически заменяются, лок снимается и при обрабатываемом прерывании. Публичный релиз сначала безопасно собирается как draft, частичный сбой допускает повторный запуск. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 98a6f187..9db7284a 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -215,7 +215,12 @@ npm run release:check -- v1.61.0-beta.4 --issues=63,64 ``` The orchestrator requires a clean, synchronized `dev`, byte-identical bundle -snapshots and a completed green Validate for `HEAD`. It creates or verifies an +snapshots and a completed green Validate for `HEAD`. Snapshot hashes and the +uploaded standalone JS are read from the exact Git blobs rather than checkout +bytes, so Windows CRLF conversion cannot disagree with the LF-tagged archive. +The archive command additionally forces `core.autocrlf=false` for that one +operation; it does not modify the developer's Git configuration. +It creates or verifies an annotated exact-SHA tag, builds `houseplan.zip` directly from that committed tree, verifies its manifest and embedded frontend against the candidate hash, stages a draft prerelease and uploads both assets. Only then does it make the diff --git a/scripts/release-prerelease.mjs b/scripts/release-prerelease.mjs index 936be8d3..2012b17f 100644 --- a/scripts/release-prerelease.mjs +++ b/scripts/release-prerelease.mjs @@ -187,6 +187,19 @@ if (invokedDirectly) { stderr: `${result.stderr || ''}`.trim(), }; }; + const runBytes = (command, commandArgs, { cwd = root } = {}) => { + const result = spawnSync(command, commandArgs, { + cwd, + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (result.error) throw result.error; + if (result.status !== 0) { + const detail = Buffer.concat([result.stderr || Buffer.alloc(0), result.stdout || Buffer.alloc(0)]) + .toString('utf8').trim(); + throw new Error(`${command} ${commandArgs.join(' ')} failed${detail ? `: ${detail}` : ''}`); + } + return result.stdout; + }; const ghJson = (commandArgs, options) => JSON.parse(run('gh', commandArgs, options).stdout); const owner = repo.split('/')[0]; @@ -210,20 +223,34 @@ if (invokedDirectly) { return result.ok ? JSON.parse(result.stdout) : null; }; - const sha256Path = (name) => createHash('sha256').update(readFileSync(name)).digest('hex'); - const sha256 = (name) => sha256Path(resolve(root, name)); - const assertBundleSnapshots = () => { + const sha256Bytes = (contents) => createHash('sha256').update(contents).digest('hex'); + const sha256Path = (name) => sha256Bytes(readFileSync(name)); + const committedFile = (sha, name) => runBytes('git', ['show', `${sha}:${name}`]); + const assertBundleSnapshots = (sha) => { const names = [ 'dist/houseplan-card.js', 'custom_components/houseplan/frontend/houseplan-card.js', 'demo/srv/assets/houseplan-card.js', ]; - const hashes = names.map((name) => [name, sha256(name)]); + // Hash Git blobs, not checkout bytes. On Windows, Git can expose CRLF in + // the worktree while the exact tagged blobs and Linux release checkout use + // LF. The release must be bound to the immutable commit representation. + const hashes = names.map((name) => [name, sha256Bytes(committedFile(sha, name))]); if (new Set(hashes.map(([, hash]) => hash)).size !== 1) throw new Error(`Committed bundle snapshots differ: ${hashes.map(([name, hash]) => `${name}=${hash}`).join(', ')}`); return hashes[0][1]; }; + const materializeCommittedBundle = (sha, expectedSha256, artifactsDir) => { + const contents = committedFile(sha, 'dist/houseplan-card.js'); + const actual = sha256Bytes(contents); + if (actual !== expectedSha256) + throw new Error(`Committed bundle hash ${actual} != preflight ${expectedSha256}`); + const bundlePath = resolve(artifactsDir, 'houseplan-card.js'); + writeFileSync(bundlePath, contents); + return bundlePath; + }; + const verifyZipContents = (zipPath, version, bundleSha256) => { const entries = readZipEntries(zipPath, ['manifest.json', 'frontend/houseplan-card.js']); const manifest = JSON.parse(entries.get('manifest.json').toString('utf8')); @@ -238,7 +265,9 @@ if (invokedDirectly) { const buildZip = (sha, version, bundleSha256, artifactsDir) => { const zipPath = resolve(artifactsDir, 'houseplan.zip'); run('git', [ - 'archive', '--format=zip', `--output=${zipPath}`, + // `git archive` on Windows otherwise applies local core.autocrlf and + // produces bytes that differ from the tagged blobs/Linux fallback. + '-c', 'core.autocrlf=false', 'archive', '--format=zip', `--output=${zipPath}`, `${sha}:custom_components/houseplan`, ]); verifyZipContents(zipPath, version, bundleSha256); @@ -420,7 +449,7 @@ if (invokedDirectly) { const sha = run('git', ['rev-parse', 'HEAD']).stdout; const remoteBranch = run('git', ['rev-parse', `origin/${branch}`]).stdout; if (sha !== remoteBranch) throw new Error(`HEAD ${sha} is not synchronized with origin/${branch} ${remoteBranch}`); - const bundleSha256 = assertBundleSnapshots(); + const bundleSha256 = assertBundleSnapshots(sha); const validateRuns = assertGreenValidate(sha); validateIssues(); const existingTag = remoteTag(); @@ -460,6 +489,7 @@ if (invokedDirectly) { for (const [signal, handler] of signalHandlers) process.once(signal, handler); try { artifactsDir = mkdtempSync(resolve(tmpdir(), 'houseplan-release-')); + const bundlePath = materializeCommittedBundle(sha, bundleSha256, artifactsDir); if (existingRelease && !existingRelease.isDraft) { let complete; try { @@ -509,7 +539,7 @@ if (invokedDirectly) { } run('gh', [ - 'release', 'upload', tag, 'dist/houseplan-card.js', zipPath, + 'release', 'upload', tag, bundlePath, zipPath, '--repo', repo, '--clobber', ], { inherit: true }); const staged = releaseView(); diff --git a/test/release-contract.test.mjs b/test/release-contract.test.mjs index 34e82d10..869024e6 100644 --- a/test/release-contract.test.mjs +++ b/test/release-contract.test.mjs @@ -1,5 +1,6 @@ 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'; @@ -136,12 +137,16 @@ test('release ZIP inspection is portable and does not depend on tar', () => { const zip = join(temp, 'houseplan.zip'); try { const archived = spawnSync('git', [ - 'archive', '--format=zip', `--output=${zip}`, 'HEAD:custom_components/houseplan', + '-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 }); } @@ -181,7 +186,7 @@ test('manual publish workflow is draft-first, exact-SHA gated and self-contained assert.ok(!announce.includes("github.event_name == 'workflow_call'")); const local = readFileSync(new URL('../scripts/release-prerelease.mjs', import.meta.url), 'utf8'); - assert.ok(local.includes("'archive', '--format=zip'")); + 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'));