Compare commits

...
Author SHA1 Message Date
james.maesandClaude Fable 5.1 4f41f41a05 fix(release): update-packaging refuses to write missing sums; real 1.0.0 sums
scripts/update-packaging.mjs could not be shown to write zeros: run against the real
cli-v1.0.0 SHA256SUMS from the all-zero placeholders at 1bc4ddd it fills correct values
(reproduced in a clean worktree), and branch head 8fc630d already carried them. The old
script was fragile, though: it read lines with split/reverse (a `*file` binary-mode name
or a stray column would silently mis-key), never checked that a sum was 64 hex, accepted
all-zero sums, did not check that any placeholder had actually matched, and wrote the
formula before it knew the manifest would succeed.

Now it parses `shasum -a 256` lines strictly, requires every target to be 64 lowercase
hex and not all zeros, requires at least one url/sha256 pair and no unfilled sha256 left in
the formula, computes both files first and writes only if everything passed, otherwise
exits 1 with "nothing was written". Tested in scripts/update-packaging.test.mjs (fixture
SHA256SUMS; run by `npm test` via `test:scripts`). Docs (packaging README, release runbook
step 7, tap README) say to compare the diff with SHA256SUMS and that tap/bucket CI catches
a wrong or zero sum only when its PR is opened and read.

Ran against /Volumes/HD-2/Git.Local/QRun-IO/.worktrees/qqq-cli-release/dist/SHA256SUMS;
the packaging files are unchanged from 8fc630d, and each sum was checked by hand against it:
  darwin-arm64  5017a32ac5031d9146d541381099a959e5c19bbf04c48f725045d89d6007b211
  darwin-x64    eb9b1d43d24b639118d94b175c343080a39ae9ba64c24645c1792ff3b16a37f1
  linux-arm64   25f513bdda13bbe40cf536844eebc1e3a7e21cf7804024f25ebe7c42793d7ebc
  linux-x64     804d06cb7c0012c2f185ae5d94c6bb8f9e2482a440bb3c165361f1a14c941e41
  windows-x64   cf418d7842a227cee412171878228004f9bc5098a272c64b89512d1e2d06a176
(linux musl sums are in SHA256SUMS but not used by the formula or manifest.)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 12:57:49 -06:00
james.maesandClaude Fable 5.1 8fc630df42 packaging: qqq 1.0.0 checksums for the Homebrew formula and the Scoop manifest
Written by scripts/update-packaging.mjs from the SHA256SUMS published on the cli-v1.0.0 release.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 12:50:53 -06:00
8 changed files with 158 additions and 28 deletions

No files matched your search

+6 -1
View File
@@ -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 <that SHA256SUMS>`,
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
+4 -4
View File
@@ -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
+3 -1
View File
@@ -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<version>` GitHub release, run
`node scripts/update-packaging.mjs <release SHA256SUMS>` 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
+1 -1
View File
@@ -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": [
+6
View File
@@ -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
+2 -1
View File
@@ -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",
+63 -20
View File
@@ -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 (`<hash> <file>`, `*` 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()
+73
View File
@@ -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))
})