diff --git a/docs/packaging/README.md b/docs/packaging/README.md index f0720c9..6a02131 100644 --- a/docs/packaging/README.md +++ b/docs/packaging/README.md @@ -26,7 +26,12 @@ See [`docs/release-runbook.md`](../release-runbook.md). In short: `node scripts/ release with `dist/qqq-*` and `dist/SHA256SUMS`, `node scripts/update-packaging.mjs `, copy `homebrew-tap/Formula/qqq.rb` to the tap and `qqq.json` to the bucket (the bucket's Excavator also picks up new releases by itself). Use the uploaded sums, not a -local rebuild: each build stamps its commit, so checksums differ between builds. +local rebuild: each build stamps its commit, so checksums differ between builds. The script exits 1 and writes +nothing when a sum is missing or malformed, and never writes zeros; check the result anyway with +`git diff` and `grep -c 0000000000000000 docs/packaging/qqq.json docs/packaging/homebrew-tap/Formula/qqq.rb` +(both must print 0) before copying the files out. The tap's CI and the bucket's manifest tests would catch +zeros (a download that does not match) only if their pull requests are opened and their checks are read; +checkpoint the sums here first, not there. ## Where the formula and manifest live diff --git a/docs/packaging/homebrew-tap/Formula/qqq.rb b/docs/packaging/homebrew-tap/Formula/qqq.rb index 25acc29..be1ae58 100644 --- a/docs/packaging/homebrew-tap/Formula/qqq.rb +++ b/docs/packaging/homebrew-tap/Formula/qqq.rb @@ -12,22 +12,22 @@ class Qqq < Formula on_macos do on_arm do url "https://github.com/QRun-IO/qqq-cli/releases/download/cli-v#{version}/qqq-darwin-arm64" - sha256 "0000000000000000000000000000000000000000000000000000000000000000" + sha256 "5017a32ac5031d9146d541381099a959e5c19bbf04c48f725045d89d6007b211" end on_intel do url "https://github.com/QRun-IO/qqq-cli/releases/download/cli-v#{version}/qqq-darwin-x64" - sha256 "0000000000000000000000000000000000000000000000000000000000000000" + sha256 "eb9b1d43d24b639118d94b175c343080a39ae9ba64c24645c1792ff3b16a37f1" end end on_linux do on_arm do url "https://github.com/QRun-IO/qqq-cli/releases/download/cli-v#{version}/qqq-linux-arm64" - sha256 "0000000000000000000000000000000000000000000000000000000000000000" + sha256 "25f513bdda13bbe40cf536844eebc1e3a7e21cf7804024f25ebe7c42793d7ebc" end on_intel do url "https://github.com/QRun-IO/qqq-cli/releases/download/cli-v#{version}/qqq-linux-x64" - sha256 "0000000000000000000000000000000000000000000000000000000000000000" + sha256 "804d06cb7c0012c2f185ae5d94c6bb8f9e2482a440bb3c165361f1a14c941e41" end end diff --git a/docs/packaging/homebrew-tap/README.md b/docs/packaging/homebrew-tap/README.md index 6e99238..215ca77 100644 --- a/docs/packaging/homebrew-tap/README.md +++ b/docs/packaging/homebrew-tap/README.md @@ -21,6 +21,8 @@ here (`Formula/qctl.rb`) once this tap exists, with `homebrew-qctl` kept as a po The formula is generated in qqq-cli: after a `cli-v` GitHub release, run `node scripts/update-packaging.mjs ` there and copy `docs/packaging/homebrew-tap/Formula/qqq.rb` here. CI (qqq-orb `homebrew-tap` profile) fetches every -platform's download, installs the formula and runs its test. +platform's download, installs the formula and runs its test, which fails on a wrong or all-zero sum, but +only once the pull request here is opened and its checks are read. The script refuses to write missing or +malformed sums; still compare the sums in the diff with the release's `SHA256SUMS` before opening the PR. Apache-2.0 diff --git a/docs/packaging/qqq.json b/docs/packaging/qqq.json index fdd9623..6eb3284 100644 --- a/docs/packaging/qqq.json +++ b/docs/packaging/qqq.json @@ -10,7 +10,7 @@ "architecture": { "64bit": { "url": "https://github.com/QRun-IO/qqq-cli/releases/download/cli-v1.0.0/qqq-windows-x64.exe", - "hash": "0000000000000000000000000000000000000000000000000000000000000000" + "hash": "cf418d7842a227cee412171878228004f9bc5098a272c64b89512d1e2d06a176" } }, "bin": [ diff --git a/docs/release-runbook.md b/docs/release-runbook.md index 8023887..a615b6f 100644 --- a/docs/release-runbook.md +++ b/docs/release-runbook.md @@ -180,9 +180,15 @@ release (the channel Homebrew and Scoop follow); a release candidate is reached ```sh gh release download cli-v1.0.0 --repo QRun-IO/qqq-cli -p SHA256SUMS -D /tmp/qqq-1.0.0 node scripts/update-packaging.mjs /tmp/qqq-1.0.0/SHA256SUMS +git diff docs/packaging # every sha256 differs from the placeholder, none is all zeros git commit -S -am "packaging: qqq 1.0.0" && git push # in qqq-cli, through a pull request ``` +The script exits 1 and writes nothing if `SHA256SUMS` lacks a target or has a malformed sum. Compare each sum +in the diff with the release's `SHA256SUMS` by eye before committing: the tap's CI and the bucket's manifest +tests catch a wrong sum (the download will not match) only when someone opens those pull requests and reads +their checks. + Then copy `docs/packaging/homebrew-tap/Formula/qqq.rb` to `Formula/qqq.rb` in `QRun-IO/homebrew-tap` and `docs/packaging/qqq.json` to `bucket/qqq.json` in `QRun-IO/scoop-bucket`, a pull request on each. The tap's CI fetches every platform's download, installs the formula and runs its test (which insists on diff --git a/package-lock.json b/package-lock.json index 7b27251..c1a0a92 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ ], "devDependencies": { "@stoplight/prism-cli": "5.16.0", - "@types/node": "22.15.3", + "@types/node": "26.6.6", "typescript": "5.8.3", "yaml": "2.9.1" }, @@ -564,13 +564,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.15.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.3.tgz", - "integrity": "sha512-lX7HFZeHf4QG/J7tBZqrCAXwz9J5RD56Y6MpP0eJkka8p+K0RY/yBTW7CYFJ4VGCclxqOLKmiGP5juQc6MKgcw==", + "version": "26.6.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz", + "integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~8.9.0" } }, "node_modules/@types/swagger-schema-official": { @@ -2722,9 +2722,9 @@ } }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index 7fed2c7..c7456b4 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "spec:merge-extensions": "node scripts/merge-extensions.mjs", "generate": "npm run generate -w @qrun-io/qqq-ui-api-client && node scripts/gen-operations.mjs", "build": "npm run build -w @qrun-io/qqq-ui-api-client && npm run build -w @qrun-io/qqq-core && npm run build -w @qrun-io/qqq-ui-api-conformance && npm run build -w @qrun-io/qqq-cli", - "test": "npm test --workspaces --if-present", + "test": "npm test --workspaces --if-present && npm run test:scripts", + "test:scripts": "node --test scripts/update-packaging.test.mjs", "mock": "prism mock --dynamic --port 4010 packages/ui-api-client/openapi/qqq-v1.json", "prepublishOnly": "npm run build", "lint": "node scripts/lint.mjs", @@ -29,7 +30,7 @@ }, "devDependencies": { "@stoplight/prism-cli": "5.16.0", - "@types/node": "22.15.3", + "@types/node": "26.6.6", "typescript": "5.8.3", "yaml": "2.9.1" } diff --git a/scripts/update-packaging.mjs b/scripts/update-packaging.mjs index b711bb7..085502b 100644 --- a/scripts/update-packaging.mjs +++ b/scripts/update-packaging.mjs @@ -10,36 +10,79 @@ * Run it on the binaries that were uploaded to the GitHub release, not on a local rebuild: the * commit is stamped into each binary, so every build has different checksums. * + * Refuses to write anything (exit 1, both files untouched) when a sum is missing, is not 64 lowercase hex digits, + * is all zeros, or when a placeholder in the files is left unfilled. + * * Usage: node scripts/update-packaging.mjs [dist/SHA256SUMS] */ -import { readFileSync, writeFileSync } from 'node:fs' +import { readFileSync, realpathSync, writeFileSync } from 'node:fs' import { join, resolve } from 'node:path' +const HEX64 = /^[0-9a-f]{64}$/ const root = resolve(import.meta.dirname, '..') -const sumsPath = resolve(process.argv[2] ?? join(root, 'dist/SHA256SUMS')) -const version = JSON.parse(readFileSync(join(root, 'packages/cli/package.json'), 'utf8')).version -const sums = new Map(readFileSync(sumsPath, 'utf8').trim().split('\n').map((line) => line.split(/\s+/).reverse())) +const FORMULA_SUMS = /(url "[^"]*\/(qqq-[a-z0-9-]+)"\n\s*sha256 ")([0-9a-f]{64})"/g -function sum(file) { - const hash = sums.get(file) - if (!hash) { - console.error(`update-packaging: ${sumsPath} has no ${file}`) - process.exit(1) +/** Parses `shasum -a 256` output (` `, `*` before the name tolerated) into a Map of file to hash. */ +export function parseSums(text) { + const sums = new Map() + for (const line of text.split('\n')) { + if (line.trim() === '') continue + const match = line.match(/^(\S+)\s+\*?(\S+)$/) + if (!match) throw new Error(`unparseable SHA256SUMS line: ${JSON.stringify(line)}`) + sums.set(match[2], match[1].toLowerCase()) } + return sums +} + +function sumFor(sums, file) { + const hash = sums.get(file) + if (hash === undefined) throw new Error(`SHA256SUMS has no ${file}`) + if (!HEX64.test(hash)) throw new Error(`SHA256SUMS has a malformed sum for ${file}: ${JSON.stringify(hash)}`) + if (/^0+$/.test(hash)) throw new Error(`SHA256SUMS has an all-zero sum for ${file}`) return hash } -const formulaPath = join(root, 'docs/packaging/homebrew-tap/Formula/qqq.rb') -let formula = readFileSync(formulaPath, 'utf8').replace(/^(\s*version ")[^"]+"/m, `$1${version}"`) -formula = formula.replace(/(url "[^"]*\/(qqq-[a-z0-9-]+)"\n\s*sha256 ")[0-9a-f]{64}"/g, (_, head, file) => `${head}${sum(file)}"`) -writeFileSync(formulaPath, formula) +/** Returns the new text of both packaging files; throws, leaving the caller nothing to write, on any problem. */ +export function updatePackaging({ version, sums, formula, manifest }) { + let formulaCount = 0 + const newFormula = formula + .replace(/^(\s*version ")[^"]+"/m, `$1${version}"`) + .replace(FORMULA_SUMS, (_, head, file) => { + formulaCount++ + return `${head}${sumFor(sums, file)}"` + }) + if (formulaCount === 0) throw new Error('the formula has no url/sha256 pairs to fill') + const left = newFormula.match(/sha256 "(?![0-9a-f]{64}")[^"]*"/g) + if (left || /sha256 "0{64}"/.test(newFormula)) throw new Error(`the formula still has unfilled sha256 values: ${left ?? 'zeros'}`) -const manifestPath = join(root, 'docs/packaging/qqq.json') -const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) -manifest.version = version -manifest.architecture['64bit'].url = `https://github.com/QRun-IO/qqq-cli/releases/download/cli-v${version}/qqq-windows-x64.exe` -manifest.architecture['64bit'].hash = sum('qqq-windows-x64.exe') -writeFileSync(manifestPath, JSON.stringify(manifest, null, 4) + '\n') + const parsed = JSON.parse(manifest) + parsed.version = version + parsed.architecture['64bit'].url = `https://github.com/QRun-IO/qqq-cli/releases/download/cli-v${version}/qqq-windows-x64.exe` + parsed.architecture['64bit'].hash = sumFor(sums, 'qqq-windows-x64.exe') + return { formula: newFormula, manifest: JSON.stringify(parsed, null, 4) + '\n', formulaCount } +} -console.log(`docs/packaging/homebrew-tap/Formula/qqq.rb and docs/packaging/qqq.json now name ${version} with the sums from ${sumsPath}`) +function main() { + const sumsPath = resolve(process.argv[2] ?? join(root, 'dist/SHA256SUMS')) + const version = JSON.parse(readFileSync(join(root, 'packages/cli/package.json'), 'utf8')).version + const formulaPath = join(root, 'docs/packaging/homebrew-tap/Formula/qqq.rb') + const manifestPath = join(root, 'docs/packaging/qqq.json') + try { + const out = updatePackaging({ + version, + sums: parseSums(readFileSync(sumsPath, 'utf8')), + formula: readFileSync(formulaPath, 'utf8'), + manifest: readFileSync(manifestPath, 'utf8'), + }) + writeFileSync(formulaPath, out.formula) + writeFileSync(manifestPath, out.manifest) + console.log(`docs/packaging/homebrew-tap/Formula/qqq.rb (${out.formulaCount} sums) and docs/packaging/qqq.json now name ${version} with the sums from ${sumsPath}`) + } catch (error) { + console.error(`update-packaging: ${error.message}; nothing was written`) + process.exit(1) + } +} + +// Run only as a script, not when the tests import it (realpath: argv[1] may go through a symlink such as macOS /var). +if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(import.meta.filename)) main() diff --git a/scripts/update-packaging.test.mjs b/scripts/update-packaging.test.mjs new file mode 100644 index 0000000..a175f5e --- /dev/null +++ b/scripts/update-packaging.test.mjs @@ -0,0 +1,73 @@ +/* + * Copyright 2026 QRun.IO, Inc. + * Licensed under the Apache License, Version 2.0 (see LICENSE). + */ + +/** Tests for scripts/update-packaging.mjs. Run: node --test scripts/update-packaging.test.mjs */ + +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { cpSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { parseSums, updatePackaging } from './update-packaging.mjs' + +const ZERO = '0'.repeat(64) +const h = (c) => c.repeat(64) +const FILES = ['qqq-darwin-arm64', 'qqq-darwin-x64', 'qqq-linux-arm64', 'qqq-linux-x64'] +const sumsText = (omit = []) => + [...FILES, 'qqq-linux-x64-musl', 'qqq-windows-x64.exe'].filter((f) => !omit.includes(f)).map((f, i) => `${'abcdef0123456'[i].repeat(64)} ${f}`).join('\n') + '\n' +const formula = `class Qqq < Formula + version "0.0.0" +${FILES.map((f) => ` url "https://github.com/QRun-IO/qqq-cli/releases/download/cli-v#{version}/${f}"\n sha256 "${ZERO}"`).join('\n')} +end +` +const manifest = JSON.stringify({ version: '0.0.0', architecture: { '64bit': { url: 'x', hash: ZERO } } }, null, 4) + '\n' + +test('fills every sum from shasum output and never leaves zeros', () => { + const out = updatePackaging({ version: '1.0.0', sums: parseSums(sumsText()), formula, manifest }) + assert.equal(out.formulaCount, 4) + assert.ok(!out.formula.includes(ZERO) && !out.manifest.includes(ZERO)) + assert.match(out.formula, /version "1\.0\.0"/) + assert.ok(out.formula.includes(`qqq-darwin-arm64"\n sha256 "${h('a')}"`)) + assert.equal(JSON.parse(out.manifest).architecture['64bit'].hash, h('f')) +}) + +test('a missing target throws', () => { + assert.throws(() => updatePackaging({ version: '1.0.0', sums: parseSums(sumsText(['qqq-linux-x64'])), formula, manifest }), /no qqq-linux-x64/) + assert.throws(() => updatePackaging({ version: '1.0.0', sums: parseSums(sumsText(['qqq-windows-x64.exe'])), formula, manifest }), /no qqq-windows-x64\.exe/) +}) + +test('malformed, short, uppercase-free or zero sums throw', () => { + for (const bad of ['abc', ZERO, 'g'.repeat(64)]) { + const sums = parseSums(sumsText().replace(h('a'), bad)) + assert.throws(() => updatePackaging({ version: '1.0.0', sums, formula, manifest }), /malformed|all-zero/) + } + assert.throws(() => parseSums('only-one-column\n'), /unparseable/) +}) + +test('a formula with nothing to fill throws', () => { + assert.throws(() => updatePackaging({ version: '1.0.0', sums: parseSums(sumsText()), formula: 'class Qqq < Formula\nend\n', manifest }), /no url\/sha256/) +}) + +test('the CLI exits 1 and writes nothing when a sum is missing', () => { + const dir = mkdtempSync(join(tmpdir(), 'update-packaging-')) + mkdirSync(join(dir, 'scripts')) + cpSync(join(import.meta.dirname, 'update-packaging.mjs'), join(dir, 'scripts/update-packaging.mjs')) + mkdirSync(join(dir, 'packages/cli'), { recursive: true }) + writeFileSync(join(dir, 'packages/cli/package.json'), '{"version":"1.0.0"}') + mkdirSync(join(dir, 'docs/packaging/homebrew-tap/Formula'), { recursive: true }) + writeFileSync(join(dir, 'docs/packaging/homebrew-tap/Formula/qqq.rb'), formula) + writeFileSync(join(dir, 'docs/packaging/qqq.json'), manifest) + writeFileSync(join(dir, 'SHA256SUMS'), sumsText(['qqq-linux-arm64'])) + const run = spawnSync(process.execPath, [join(dir, 'scripts/update-packaging.mjs'), join(dir, 'SHA256SUMS')], { encoding: 'utf8' }) + assert.equal(run.status, 1) + assert.match(run.stderr, /nothing was written/) + assert.equal(readFileSync(join(dir, 'docs/packaging/homebrew-tap/Formula/qqq.rb'), 'utf8'), formula) + assert.equal(readFileSync(join(dir, 'docs/packaging/qqq.json'), 'utf8'), manifest) + writeFileSync(join(dir, 'SHA256SUMS'), sumsText()) + const ok = spawnSync(process.execPath, [join(dir, 'scripts/update-packaging.mjs'), join(dir, 'SHA256SUMS')], { encoding: 'utf8' }) + assert.equal(ok.status, 0, ok.stderr) + assert.ok(!readFileSync(join(dir, 'docs/packaging/qqq.json'), 'utf8').includes(ZERO)) +})