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..d41eda2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "devDependencies": { "@stoplight/prism-cli": "5.16.0", "@types/node": "22.15.3", - "typescript": "5.8.3", + "typescript": "7.0.2", "yaml": "2.9.1" }, "engines": { @@ -590,6 +590,346 @@ "@types/node": "*" } }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/abstract-logging": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", @@ -2708,17 +3048,38 @@ } }, "node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", "dev": true, "license": "Apache-2.0", "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" + "tsc": "bin/tsc" }, "engines": { - "node": ">=14.17" + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" } }, "node_modules/undici-types": { diff --git a/package.json b/package.json index 7fed2c7..5c005ad 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", @@ -30,7 +31,7 @@ "devDependencies": { "@stoplight/prism-cli": "5.16.0", "@types/node": "22.15.3", - "typescript": "5.8.3", + "typescript": "7.0.2", "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)) +})