From d15d1254bb63a41e153dc3ebb2b33659b3b8c9ad Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Sat, 11 Apr 2026 04:27:15 +0200 Subject: [PATCH 1/3] v6.1.3: Isolate release verification --- .github/workflows/release.yml | 33 +---- RELEASING.md | 16 ++- package-lock.json | 4 +- package.json | 4 +- scripts/tag-main-release.sh | 102 +++++++++++++++ scripts/verify-registry-install.js | 204 +++++++++++++++++++++++++++++ src/lib/constants.ts | 2 +- 7 files changed, 331 insertions(+), 34 deletions(-) create mode 100644 scripts/tag-main-release.sh create mode 100644 scripts/verify-registry-install.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6de167e..0d0a602 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -79,35 +79,14 @@ jobs: echo "Package was not visible on npm in time." exit 1 - - name: Verify npx install path + - name: Verify registry install paths run: | VERSION="$(node -p "require('./package.json').version")" - EXPECTED="TTDash v${VERSION}" - for attempt in 1 2 3 4 5 6; do - OUTPUT="$(npm exec --yes --package "@roastcodes/ttdash@${VERSION}" -- ttdash --help 2>&1)" && { - printf '%s\n' "$OUTPUT" - printf '%s\n' "$OUTPUT" | grep -F "$EXPECTED" >/dev/null - exit 0 - } - sleep 10 - done - echo "npm exec install path did not become ready in time." - exit 1 - - - name: Verify bunx install path - run: | - VERSION="$(node -p "require('./package.json').version")" - EXPECTED="TTDash v${VERSION}" - for attempt in 1 2 3 4 5 6; do - OUTPUT="$(bunx "@roastcodes/ttdash@${VERSION}" --help 2>&1)" && { - printf '%s\n' "$OUTPUT" - printf '%s\n' "$OUTPUT" | grep -F "$EXPECTED" >/dev/null - exit 0 - } - sleep 10 - done - echo "bunx install path did not become ready in time." - exit 1 + node scripts/verify-registry-install.js \ + --package @roastcodes/ttdash \ + --version "${VERSION}" \ + --retries 6 \ + --retry-delay-ms 10000 - name: Create GitHub release env: diff --git a/RELEASING.md b/RELEASING.md index 93d0639..eea57e8 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -33,9 +33,19 @@ PLAYWRIGHT_TEST_PORT=3016 npm_config_cache=/tmp/ttdash-npm-cache npm run test:e2 Example: ```bash -VERSION=$(node -p "require('./package.json').version") -git tag "v$VERSION" -git push origin "v$VERSION" +bash scripts/tag-main-release.sh +``` + +Optional explicit version: + +```bash +bash scripts/tag-main-release.sh 6.1.3 +``` + +Dry-run preview: + +```bash +bash scripts/tag-main-release.sh --dry-run ``` ## What the Release Workflow Does diff --git a/package-lock.json b/package-lock.json index 1ea749a..8e791d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@roastcodes/ttdash", - "version": "6.1.2", + "version": "6.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@roastcodes/ttdash", - "version": "6.1.2", + "version": "6.1.3", "license": "MIT", "dependencies": { "i18next": "^26.0.3", diff --git a/package.json b/package.json index 3438902..63cb634 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@roastcodes/ttdash", - "version": "6.1.2", + "version": "6.1.3", "description": "Local-first dashboard and CLI for toktrack usage data", "main": "server.js", "repository": { @@ -28,7 +28,9 @@ "test:e2e:ci": "playwright test", "test:all": "npm run test:unit && npm run test:e2e", "pack:dry-run": "npm pack --dry-run", + "tag:main-release": "bash scripts/tag-main-release.sh", "verify:package": "node scripts/verify-package.js", + "verify:registry-install": "node scripts/verify-registry-install.js", "verify:release": "npm run test:unit:coverage && npm run build && npm run verify:package", "prepare": "npm run build" }, diff --git a/scripts/tag-main-release.sh b/scripts/tag-main-release.sh new file mode 100644 index 0000000..9b2d0cd --- /dev/null +++ b/scripts/tag-main-release.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +usage() { + cat <<'EOF' +Usage: + bash scripts/tag-main-release.sh [version] [--dry-run] + +Behavior: + - must be run from a clean local main branch + - fetches origin + - fast-forwards local main to origin/main + - creates annotated tag v + - pushes the tag to origin + +Defaults: + - if version is omitted, package.json version is used + +Examples: + bash scripts/tag-main-release.sh + bash scripts/tag-main-release.sh 6.1.3 + bash scripts/tag-main-release.sh --dry-run +EOF +} + +PACKAGE_VERSION="$(node -p "require('./package.json').version")" +VERSION="" +DRY_RUN=0 + +for arg in "$@"; do + case "$arg" in + -h|--help) + usage + exit 0 + ;; + --dry-run) + DRY_RUN=1 + ;; + *) + if [[ -n "$VERSION" ]]; then + echo "Only one version argument is allowed." + usage + exit 1 + fi + VERSION="$arg" + ;; + esac +done + +if [[ -z "$VERSION" ]]; then + VERSION="$PACKAGE_VERSION" +fi + +if [[ "$VERSION" != "$PACKAGE_VERSION" ]]; then + echo "Version mismatch: package.json is $PACKAGE_VERSION but requested version is $VERSION." + exit 1 +fi + +CURRENT_BRANCH="$(git branch --show-current)" +if [[ "$CURRENT_BRANCH" != "main" ]]; then + echo "This script must be run from the local main branch. Current branch: $CURRENT_BRANCH" + exit 1 +fi + +if [[ -n "$(git status --porcelain)" ]]; then + echo "Working tree is not clean. Commit or stash changes before tagging a release." + exit 1 +fi + +TAG_NAME="v$VERSION" + +if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then + echo "Tag already exists locally: $TAG_NAME" + exit 1 +fi + +if git ls-remote --tags origin "refs/tags/$TAG_NAME" | grep -q .; then + echo "Tag already exists on origin: $TAG_NAME" + exit 1 +fi + +run() { + echo "+ $*" + if [[ "$DRY_RUN" -eq 0 ]]; then + "$@" + fi +} + +run git fetch origin +run git pull --ff-only origin main +run git tag -a "$TAG_NAME" -m "$TAG_NAME" +run git push origin "$TAG_NAME" + +if [[ "$DRY_RUN" -eq 1 ]]; then + echo "Dry run complete." +else + echo "Release tag pushed: $TAG_NAME" +fi diff --git a/scripts/verify-registry-install.js b/scripts/verify-registry-install.js new file mode 100644 index 0000000..c9996c8 --- /dev/null +++ b/scripts/verify-registry-install.js @@ -0,0 +1,204 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +function log(message) { + process.stdout.write(`${message}\n`); +} + +function fail(message) { + process.stderr.write(`${message}\n`); + process.exit(1); +} + +function mktemp(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function parseArgs(argv) { + const options = { + packageName: null, + version: null, + retries: 6, + retryDelayMs: 10000, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + const next = argv[index + 1]; + + if (arg === '--package' && next) { + options.packageName = next; + index += 1; + continue; + } + + if (arg === '--version' && next) { + options.version = next; + index += 1; + continue; + } + + if (arg === '--retries' && next) { + options.retries = Number.parseInt(next, 10); + index += 1; + continue; + } + + if (arg === '--retry-delay-ms' && next) { + options.retryDelayMs = Number.parseInt(next, 10); + index += 1; + continue; + } + } + + if (!options.packageName || !options.version) { + fail('Usage: node scripts/verify-registry-install.js --package --version [--retries N] [--retry-delay-ms MS]'); + } + + if (!Number.isInteger(options.retries) || options.retries <= 0) { + fail(`Invalid retries value: ${options.retries}`); + } + + if (!Number.isInteger(options.retryDelayMs) || options.retryDelayMs < 0) { + fail(`Invalid retry delay value: ${options.retryDelayMs}`); + } + + return options; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function createIsolatedWorkingDir(prefix) { + const cwd = mktemp(prefix); + fs.writeFileSync(path.join(cwd, 'package.json'), JSON.stringify({ + name: 'ttdash-registry-verify', + private: true, + }, null, 2) + '\n'); + return cwd; +} + +function runCommand(command, args, { cwd, env }) { + return execFileSync(command, args, { + cwd, + env, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); +} + +function buildEnv(extra = {}) { + return { + ...process.env, + ...extra, + }; +} + +function verifyExpectedVersion(output, expected) { + if (!output.includes(expected)) { + throw new Error(`Expected output to contain "${expected}" but got:\n${output}`); + } +} + +async function verifyNpmExec(packageName, version, retries, retryDelayMs) { + const expected = `TTDash v${version}`; + let lastError = null; + + for (let attempt = 1; attempt <= retries; attempt += 1) { + const cwd = createIsolatedWorkingDir('ttdash-registry-npm-'); + const cacheDir = mktemp('ttdash-registry-npm-cache-'); + + try { + const output = runCommand('npm', [ + 'exec', + '--yes', + '--prefer-online', + '--package', + `${packageName}@${version}`, + '--', + 'ttdash', + '--help', + ], { + cwd, + env: buildEnv({ + npm_config_cache: cacheDir, + NPM_CONFIG_CACHE: cacheDir, + }), + }); + + verifyExpectedVersion(output, expected); + log(`Verified npm exec install path on attempt ${attempt}.`); + log(output.trim()); + return; + } catch (error) { + lastError = error; + const output = error && error.stderr ? String(error.stderr).trim() : String(error); + log(`npm exec attempt ${attempt}/${retries} failed.`); + if (output) { + log(output); + } + if (attempt < retries) { + await sleep(retryDelayMs); + } + } + } + + throw new Error(`npm exec install path did not become ready in time.\n${lastError && lastError.stderr ? String(lastError.stderr).trim() : String(lastError)}`); +} + +async function verifyBunx(packageName, version, retries, retryDelayMs) { + const expected = `TTDash v${version}`; + let lastError = null; + + for (let attempt = 1; attempt <= retries; attempt += 1) { + const cwd = createIsolatedWorkingDir('ttdash-registry-bun-'); + const bunInstallDir = mktemp('ttdash-registry-bun-install-'); + const bunCacheDir = mktemp('ttdash-registry-bun-cache-'); + + try { + const output = runCommand('bunx', [ + `${packageName}@${version}`, + '--help', + ], { + cwd, + env: buildEnv({ + BUN_INSTALL: bunInstallDir, + BUN_INSTALL_CACHE_DIR: bunCacheDir, + }), + }); + + verifyExpectedVersion(output, expected); + log(`Verified bunx install path on attempt ${attempt}.`); + log(output.trim()); + return; + } catch (error) { + lastError = error; + const output = error && error.stderr ? String(error.stderr).trim() : String(error); + log(`bunx attempt ${attempt}/${retries} failed.`); + if (output) { + log(output); + } + if (attempt < retries) { + await sleep(retryDelayMs); + } + } + } + + throw new Error(`bunx install path did not become ready in time.\n${lastError && lastError.stderr ? String(lastError.stderr).trim() : String(lastError)}`); +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + + await verifyNpmExec(options.packageName, options.version, options.retries, options.retryDelayMs); + await verifyBunx(options.packageName, options.version, options.retries, options.retryDelayMs); +} + +main().catch((error) => { + fail(error instanceof Error ? error.message : String(error)); +}); diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 2d85236..9b49db1 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -1,4 +1,4 @@ -export const VERSION = '6.1.2' +export const VERSION = '6.1.3' export const MODEL_COLORS: Record = { 'Opus 4.6': 'hsl(262, 60%, 55%)', From 642b55131e9c2023b1ae8618e41028559164a19b Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Sat, 11 Apr 2026 04:37:36 +0200 Subject: [PATCH 2/3] v6.1.3: Harden release scripts --- scripts/tag-main-release.sh | 34 +++++++++++++++++++++--------- scripts/verify-registry-install.js | 26 +++++++++++++++++++---- 2 files changed, 46 insertions(+), 14 deletions(-) diff --git a/scripts/tag-main-release.sh b/scripts/tag-main-release.sh index 9b2d0cd..50606fa 100644 --- a/scripts/tag-main-release.sh +++ b/scripts/tag-main-release.sh @@ -27,7 +27,6 @@ Examples: EOF } -PACKAGE_VERSION="$(node -p "require('./package.json').version")" VERSION="" DRY_RUN=0 @@ -51,15 +50,6 @@ for arg in "$@"; do esac done -if [[ -z "$VERSION" ]]; then - VERSION="$PACKAGE_VERSION" -fi - -if [[ "$VERSION" != "$PACKAGE_VERSION" ]]; then - echo "Version mismatch: package.json is $PACKAGE_VERSION but requested version is $VERSION." - exit 1 -fi - CURRENT_BRANCH="$(git branch --show-current)" if [[ "$CURRENT_BRANCH" != "main" ]]; then echo "This script must be run from the local main branch. Current branch: $CURRENT_BRANCH" @@ -92,6 +82,30 @@ run() { run git fetch origin run git pull --ff-only origin main + +PACKAGE_VERSION="$(node -p "require('./package.json').version")" + +if [[ -z "$VERSION" ]]; then + VERSION="$PACKAGE_VERSION" +fi + +if [[ "$VERSION" != "$PACKAGE_VERSION" ]]; then + echo "Version mismatch after sync: package.json is $PACKAGE_VERSION but requested version is $VERSION." + exit 1 +fi + +TAG_NAME="v$VERSION" + +if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then + echo "Tag already exists locally: $TAG_NAME" + exit 1 +fi + +if git ls-remote --tags origin "refs/tags/$TAG_NAME" | grep -q .; then + echo "Tag already exists on origin: $TAG_NAME" + exit 1 +fi + run git tag -a "$TAG_NAME" -m "$TAG_NAME" run git push origin "$TAG_NAME" diff --git a/scripts/verify-registry-install.js b/scripts/verify-registry-install.js index c9996c8..bee5475 100644 --- a/scripts/verify-registry-install.js +++ b/scripts/verify-registry-install.js @@ -89,9 +89,27 @@ function runCommand(command, args, { cwd, env }) { env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], + timeout: 120000, + killSignal: 'SIGTERM', }); } +function formatCommandError(error) { + if (!error) { + return 'Unknown command error'; + } + + if (error.code === 'ETIMEDOUT' || error.signal === 'SIGTERM') { + return 'Command timed out after 120000 ms.'; + } + + if (error.stderr) { + return String(error.stderr).trim(); + } + + return String(error); +} + function buildEnv(extra = {}) { return { ...process.env, @@ -137,7 +155,7 @@ async function verifyNpmExec(packageName, version, retries, retryDelayMs) { return; } catch (error) { lastError = error; - const output = error && error.stderr ? String(error.stderr).trim() : String(error); + const output = formatCommandError(error); log(`npm exec attempt ${attempt}/${retries} failed.`); if (output) { log(output); @@ -148,7 +166,7 @@ async function verifyNpmExec(packageName, version, retries, retryDelayMs) { } } - throw new Error(`npm exec install path did not become ready in time.\n${lastError && lastError.stderr ? String(lastError.stderr).trim() : String(lastError)}`); + throw new Error(`npm exec install path did not become ready in time.\n${formatCommandError(lastError)}`); } async function verifyBunx(packageName, version, retries, retryDelayMs) { @@ -178,7 +196,7 @@ async function verifyBunx(packageName, version, retries, retryDelayMs) { return; } catch (error) { lastError = error; - const output = error && error.stderr ? String(error.stderr).trim() : String(error); + const output = formatCommandError(error); log(`bunx attempt ${attempt}/${retries} failed.`); if (output) { log(output); @@ -189,7 +207,7 @@ async function verifyBunx(packageName, version, retries, retryDelayMs) { } } - throw new Error(`bunx install path did not become ready in time.\n${lastError && lastError.stderr ? String(lastError.stderr).trim() : String(lastError)}`); + throw new Error(`bunx install path did not become ready in time.\n${formatCommandError(lastError)}`); } async function main() { From 53ec957fc953041314840158377726b55095e28d Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Sat, 11 Apr 2026 04:42:41 +0200 Subject: [PATCH 3/3] v6.1.3: Fix tag script version resolution --- scripts/tag-main-release.sh | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/scripts/tag-main-release.sh b/scripts/tag-main-release.sh index 50606fa..5727530 100644 --- a/scripts/tag-main-release.sh +++ b/scripts/tag-main-release.sh @@ -61,18 +61,6 @@ if [[ -n "$(git status --porcelain)" ]]; then exit 1 fi -TAG_NAME="v$VERSION" - -if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then - echo "Tag already exists locally: $TAG_NAME" - exit 1 -fi - -if git ls-remote --tags origin "refs/tags/$TAG_NAME" | grep -q .; then - echo "Tag already exists on origin: $TAG_NAME" - exit 1 -fi - run() { echo "+ $*" if [[ "$DRY_RUN" -eq 0 ]]; then