diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index a70f402f7..25d7ffd23 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -37,6 +37,11 @@ on: options: - "true" - "false" + rollout_hours: + description: "Linear rollout duration in hours. Use 0 for instant rollout." + required: false + default: "24" + type: string concurrency: group: desktop-release-${{ github.ref }} @@ -46,6 +51,7 @@ env: SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }} CHECKOUT_REF: ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.checkout_ref || github.ref_name) || github.ref_name }} SHOULD_PUBLISH: ${{ github.event_name != 'workflow_dispatch' || github.event.inputs.publish != 'false' }} + ROLLOUT_HOURS: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.rollout_hours || '24' }} DESKTOP_WORKSPACE: "@getpaseo/desktop" DESKTOP_PACKAGE_PATH: "packages/desktop" @@ -187,13 +193,30 @@ jobs: steps: - uses: actions/checkout@v4 with: - sparse-checkout: scripts + sparse-checkout: | + package.json + package-lock.json + scripts ref: ${{ env.CHECKOUT_REF }} - name: Resolve release tag shell: bash run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV" + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: package-lock.json + registry-url: "https://npm.pkg.github.com" + scope: "@boudra" + + - name: Install JS dependencies + run: npm ci + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Download manifest artifacts if: env.IS_SMOKE_TAG != 'true' uses: actions/download-artifact@v4 @@ -205,68 +228,11 @@ jobs: shell: bash run: | set -euo pipefail - - node <<'NODE' - const fs = require('node:fs'); - - // Simple YAML parser for electron-builder's latest-mac.yml format - function parseManifest(text) { - const lines = text.split('\n'); - const result = { files: [] }; - let currentFile = null; - - for (const line of lines) { - if (line.startsWith('version:')) result.version = line.split(': ')[1].trim(); - else if (line.startsWith('path:')) result.path = line.split(': ')[1].trim(); - else if (line.startsWith('sha512:') && !currentFile) result.sha512 = line.split(': ')[1].trim(); - else if (line.startsWith('releaseDate:')) result.releaseDate = line.split(': ')[1].trim().replace(/'/g, ''); - else if (line.trim().startsWith('- url:')) { - currentFile = { url: line.trim().replace('- url: ', '') }; - result.files.push(currentFile); - } else if (line.trim().startsWith('sha512:') && currentFile) { - currentFile.sha512 = line.trim().split(': ')[1].trim(); - } else if (line.trim().startsWith('size:') && currentFile) { - currentFile.size = parseInt(line.trim().split(': ')[1].trim(), 10); - currentFile = null; - } - } - return result; - } - - function toYaml(manifest) { - let out = `version: ${manifest.version}\n`; - out += `files:\n`; - for (const f of manifest.files) { - out += ` - url: ${f.url}\n`; - out += ` sha512: ${f.sha512}\n`; - out += ` size: ${f.size}\n`; - } - out += `path: ${manifest.path}\n`; - out += `sha512: ${manifest.sha512}\n`; - out += `releaseDate: '${manifest.releaseDate}'\n`; - return out; - } - - const manifestName = `${process.env.RELEASE_CHANNEL}-mac.yml`; - const arm64Text = fs.readFileSync(`mac-manifest-arm64/${manifestName}`, 'utf8'); - const x64Text = fs.readFileSync(`mac-manifest-x64/${manifestName}`, 'utf8'); - - const arm64 = parseManifest(arm64Text); - const x64 = parseManifest(x64Text); - - // Merge: all files from both, default path points to arm64 zip - const merged = { - version: arm64.version, - files: [...arm64.files, ...x64.files], - path: arm64.path, - sha512: arm64.sha512, - releaseDate: arm64.releaseDate || x64.releaseDate, - }; - - const output = toYaml(merged); - fs.writeFileSync(manifestName, output); - console.log('Merged manifest:\n' + output); - NODE + manifest_name="${RELEASE_CHANNEL}-mac.yml" + node scripts/merge-mac-manifest.mjs \ + "mac-manifest-arm64/${manifest_name}" \ + "mac-manifest-x64/${manifest_name}" \ + "${manifest_name}" - name: Upload merged manifest to release if: env.IS_SMOKE_TAG != 'true' @@ -424,3 +390,60 @@ jobs: fi npm run build --workspace="$DESKTOP_WORKSPACE" "${build_args[@]}" + + finalize-rollout: + needs: [publish-macos, finalize-mac-manifest, publish-linux, publish-windows] + if: ${{ always() && (needs.publish-macos.result == 'success' || needs.publish-macos.result == 'skipped') && (needs.finalize-mac-manifest.result == 'success' || needs.finalize-mac-manifest.result == 'skipped') && (needs.publish-linux.result == 'success' || needs.publish-linux.result == 'skipped') && (needs.publish-windows.result == 'success' || needs.publish-windows.result == 'skipped') && (github.event_name != 'workflow_dispatch' || github.event.inputs.publish != 'false') }} + permissions: + contents: write + runs-on: ubuntu-latest + concurrency: + group: desktop-rollout-${{ github.event.inputs.tag || github.ref_name }} + cancel-in-progress: false + + steps: + - uses: actions/checkout@v4 + with: + sparse-checkout: | + package.json + package-lock.json + scripts + ref: ${{ env.CHECKOUT_REF }} + + - name: Resolve release tag + shell: bash + run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV" + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: package-lock.json + registry-url: "https://npm.pkg.github.com" + scope: "@boudra" + + - name: Install JS dependencies + run: npm ci + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Stamp rollout metadata + if: env.IS_SMOKE_TAG != 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + mkdir release-manifests + cd release-manifests + gh release download "$RELEASE_TAG" --repo "${{ github.repository }}" --pattern "${RELEASE_CHANNEL}*.yml" + shopt -s nullglob + files=( ./*.yml ) + if (( ${#files[@]} == 0 )); then + echo "::error::No manifests matched ${RELEASE_CHANNEL}*.yml on $RELEASE_TAG" + exit 1 + fi + timestamp="$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")" + node ../scripts/stamp-rollout.mjs --release-date "$timestamp" --rollout-hours "$ROLLOUT_HOURS" "${files[@]}" + gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}" diff --git a/.github/workflows/desktop-rollout.yml b/.github/workflows/desktop-rollout.yml new file mode 100644 index 000000000..1cfb62cfe --- /dev/null +++ b/.github/workflows/desktop-rollout.yml @@ -0,0 +1,152 @@ +name: Desktop Rollout + +on: + workflow_dispatch: + inputs: + tag: + description: "Existing release tag to re-stamp (e.g. v0.1.42)." + required: true + type: string + rollout_hours: + description: "Total rollout duration since the original release date, in hours. Use 0 to admit everyone immediately." + required: true + type: string + +concurrency: + group: desktop-rollout-${{ inputs.tag }} + cancel-in-progress: false + +env: + SOURCE_TAG: ${{ inputs.tag }} + +jobs: + stamp: + permissions: + contents: write + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + sparse-checkout: | + package.json + package-lock.json + scripts + + - name: Resolve release metadata + shell: bash + run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV" + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: package-lock.json + registry-url: "https://npm.pkg.github.com" + scope: "@boudra" + + - name: Install JS dependencies + run: npm ci + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Download release manifests + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + mkdir release-manifests + cd release-manifests + gh release download "$RELEASE_TAG" --repo "${{ github.repository }}" --pattern "${RELEASE_CHANNEL}*.yml" + shopt -s nullglob + files=( ./*.yml ) + if (( ${#files[@]} == 0 )); then + echo "::error::No manifests matched ${RELEASE_CHANNEL}*.yml on $RELEASE_TAG" + exit 1 + fi + echo "Downloaded ${#files[@]} manifest(s):" + printf ' %s\n' "${files[@]}" + + - name: Capture before state + id: before + shell: bash + run: | + set -euo pipefail + cd release-manifests + summary=$(node -e ' + const yaml = require("js-yaml"); + const fs = require("fs"); + for (const f of process.argv.slice(1)) { + const m = yaml.load(fs.readFileSync(f, "utf8")) ?? {}; + console.log(` ${f}: rolloutHours=${m.rolloutHours ?? ""} releaseDate=${m.releaseDate ?? ""}`); + } + ' ./*.yml) + echo "$summary" + { + echo "summary<> "$GITHUB_OUTPUT" + + - name: Re-stamp rolloutHours + env: + NEW_HOURS: ${{ inputs.rollout_hours }} + shell: bash + run: | + set -euo pipefail + cd release-manifests + node ../scripts/stamp-rollout.mjs --rollout-hours "$NEW_HOURS" ./*.yml + + - name: Validate rewritten manifests + env: + EXPECTED: ${{ inputs.rollout_hours }} + shell: bash + run: | + set -euo pipefail + cd release-manifests + node -e ' + const yaml = require("js-yaml"); + const fs = require("fs"); + const expected = Number(process.env.EXPECTED); + if (!Number.isFinite(expected) || expected < 0) { + throw new Error(`EXPECTED must be a non-negative number, got ${process.env.EXPECTED}`); + } + for (const f of process.argv.slice(1)) { + const m = yaml.load(fs.readFileSync(f, "utf8")) ?? {}; + if (m.rolloutHours !== expected) { + throw new Error(`${f}: rolloutHours=${m.rolloutHours}, expected ${expected}`); + } + if (typeof m.version !== "string" || m.version.length === 0) { + throw new Error(`${f}: missing or invalid version`); + } + } + ' ./*.yml + + - name: Upload to release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + cd release-manifests + gh release upload "$RELEASE_TAG" ./*.yml --clobber --repo "${{ github.repository }}" + + - name: Write summary + env: + BEFORE: ${{ steps.before.outputs.summary }} + shell: bash + run: | + { + echo "## Rollout updated" + echo "" + echo "**Tag:** \`$RELEASE_TAG\`" + echo "**Channel:** \`$RELEASE_CHANNEL\`" + echo "**New rolloutHours:** \`${{ inputs.rollout_hours }}\`" + echo "" + echo "### Before" + echo '```' + echo "$BEFORE" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 16c5ad183..67c275cfc 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -59,6 +59,65 @@ Use the beta path when you need to: - send a build to a user who is hitting a specific problem - iterate on `beta.1`, `beta.2`, `beta.3`, and so on before deciding to ship broadly +## Staged rollout (stable channel) + +Stable desktop releases go out via a linear time-based rollout: 0% admitted at publish, 100% admitted 24 hours later, linear ramp in between. Beta releases bypass the rollout entirely — beta users always receive updates immediately. + +The rollout is driven by a `rolloutHours` field stamped into the GitHub Release manifests (`latest-mac.yml`, `latest-linux.yml`, `latest.yml`) by the `finalize-rollout` job in `desktop-release.yml`. + +### Default behavior + +`npm run release:patch` → tag push → 24h ramp. No extra action needed. + +### Adjusting an already-published release + +To change the rollout duration on a release that's already shipped — e.g. flip a hotfix to instant admit, or slow a release down — use the dedicated `desktop-rollout.yml` workflow. It edits the manifests in place on the GitHub release without rebuilding anything. It only rewrites `rolloutHours`; `releaseDate` is preserved, so the rollout clock keeps ticking from the original publish time. + +**Hotfix (instant admit):** + +```bash +gh workflow run desktop-rollout.yml \ + -f tag=v0.1.42 \ + -f rollout_hours=0 +``` + +`rollout_hours=0` admits 100% of stable users on their next update check (within ~30 min for active clients). + +**Slow a rollout down** (e.g. extend total duration to 72h since the original release): + +```bash +gh workflow run desktop-rollout.yml \ + -f tag=v0.1.42 \ + -f rollout_hours=72 +``` + +`rollout_hours` is **total duration since the original release date**, not "extend by N more hours from now." If `v0.1.42` was published 2h ago and you set `rollout_hours=72`, the ramp finishes 70h from now. + +The dispatch is idempotent and shares a concurrency group with `desktop-release.yml`'s `finalize-rollout` job keyed on the tag, so it serializes safely against an in-flight tag-push pipeline targeting the same release. + +### Faster ramp at release time + +For low-risk changes (doc-only, dependency bumps with no behavior change), trigger `desktop-release.yml` manually with a shorter rollout window so the build itself stamps a smaller `rolloutHours`: + +```bash +gh workflow run desktop-release.yml \ + -f tag=v0.1.43 \ + -f rollout_hours=6 +``` + +### Releasing during an active rollout + +If you ship N+1 while N is still ramping, N+1 starts a fresh rollout from its own publish timestamp. N's rollout effectively ends — the newer manifest supersedes it. + +If N+1 is a hotfix for a bug in N, dispatch `desktop-rollout.yml -f tag=v0.1. -f rollout_hours=0` after N+1 publishes so the users who already got N reach the fix fast. + +### Limitations + +- **No pause / kill switch.** Once a stable user is admitted, they will install the update on next quit (`autoInstallOnAppQuit = true`). To stop new admissions, ship a superseding release. To "recall" already-admitted users, ship a hotfix `+1` patch. +- **No rollback.** `allowDowngrade = false`. Bad release = ship a hotfix. +- **Bootstrap caveat.** Clients running a build older than the rollout feature ignore `rolloutHours` and admit immediately. Rollout protection only applies to clients running the rollout-aware version or later. +- **Up to ~30 min admission latency.** Renderer polls every 30 minutes, so a stable user may take up to that long to be evaluated against the rollout window. + ## Website behavior - The website download page points to GitHub's latest published **stable** release. diff --git a/package-lock.json b/package-lock.json index 65c87bc5f..e4c7f780d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "@typescript/native-preview": "7.0.0-dev.20260423.1", "concurrently": "^9.2.1", "get-port-cli": "^3.0.0", + "js-yaml": "^4.1.1", "knip": "^5.82.1", "lefthook": "^2.1.6", "oxfmt": "0.46.0", @@ -4592,13 +4593,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/@eslint/eslintrc/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -4610,19 +4604,6 @@ "concat-map": "0.0.1" } }, - "node_modules/@eslint/eslintrc/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -6747,6 +6728,20 @@ "node": ">=8" } }, + "node_modules/@expo/plugin-help/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/@expo/plugin-warn-if-update-available": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@expo/plugin-warn-if-update-available/-/plugin-warn-if-update-available-2.5.1.tgz", @@ -6817,6 +6812,20 @@ "node": ">=8" } }, + "node_modules/@expo/plugin-warn-if-update-available/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/@expo/prebuild-config": { "version": "8.0.17", "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-8.0.17.tgz", @@ -7054,24 +7063,6 @@ "excpretty": "build/cli.js" } }, - "node_modules/@expo/xcpretty/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0" - }, - "node_modules/@expo/xcpretty/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/@floating-ui/core": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", @@ -7954,6 +7945,19 @@ "node": ">=8" } }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", @@ -9591,6 +9595,20 @@ "node": ">=8" } }, + "node_modules/@oclif/core/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/@oclif/linewrap": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@oclif/linewrap/-/linewrap-1.0.0.tgz", @@ -9663,6 +9681,20 @@ "node": ">=8" } }, + "node_modules/@oclif/plugin-autocomplete/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/@oclif/screen": { "version": "3.0.8", "resolved": "https://registry.npmjs.org/@oclif/screen/-/screen-3.0.8.tgz", @@ -15754,13 +15786,6 @@ "semver": "bin/semver.js" } }, - "node_modules/app-builder-lib/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, "node_modules/app-builder-lib/node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -15833,19 +15858,6 @@ "node": ">=18" } }, - "node_modules/app-builder-lib/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/app-builder-lib/node_modules/jsonfile": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", @@ -17004,13 +17016,6 @@ "node": ">= 14" } }, - "node_modules/builder-util/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, "node_modules/builder-util/node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -17039,19 +17044,6 @@ "node": ">= 14" } }, - "node_modules/builder-util/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/bunyan": { "version": "1.8.15", "resolved": "https://registry.npmjs.org/bunyan/-/bunyan-1.8.15.tgz", @@ -18949,26 +18941,6 @@ "dmg-license": "^1.0.11" } }, - "node_modules/dmg-builder/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/dmg-builder/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/dmg-license": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/dmg-license/-/dmg-license-1.0.11.tgz", @@ -19621,24 +19593,6 @@ "tiny-typed-emitter": "^2.1.0" } }, - "node_modules/electron-updater/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0" - }, - "node_modules/electron-updater/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/electron-winstaller": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", @@ -27175,18 +27129,23 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "license": "MIT", "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" + "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, + "node_modules/js-yaml/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, "node_modules/jsc-safe-url": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/jsc-safe-url/-/jsc-safe-url-0.2.4.tgz", @@ -38151,24 +38110,6 @@ "node": ">=20.0" } }, - "node_modules/xmlbuilder2/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0" - }, - "node_modules/xmlbuilder2/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/xmlchars": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", @@ -38592,7 +38533,8 @@ "@getpaseo/server": "*", "electron-log": "^5.4.3", "electron-updater": "^6.6.2", - "ws": "^8.14.2" + "ws": "^8.14.2", + "zod": "^3.23.8" }, "devDependencies": { "@types/node": "24.6.0", @@ -38622,6 +38564,15 @@ "dev": true, "license": "MIT" }, + "packages/desktop/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", "version": "0.1.63-beta.5", diff --git a/package.json b/package.json index a209644e0..b4733bcbc 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,7 @@ "@typescript/native-preview": "7.0.0-dev.20260423.1", "concurrently": "^9.2.1", "get-port-cli": "^3.0.0", + "js-yaml": "^4.1.1", "knip": "^5.82.1", "lefthook": "^2.1.6", "oxfmt": "0.46.0", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index e92f2d8d0..251a3143f 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -27,7 +27,8 @@ "@getpaseo/server": "*", "electron-log": "^5.4.3", "electron-updater": "^6.6.2", - "ws": "^8.14.2" + "ws": "^8.14.2", + "zod": "^3.23.8" }, "devDependencies": { "@types/node": "24.6.0", diff --git a/packages/desktop/src/features/auto-updater.test.ts b/packages/desktop/src/features/auto-updater.test.ts new file mode 100644 index 000000000..b8d8f122f --- /dev/null +++ b/packages/desktop/src/features/auto-updater.test.ts @@ -0,0 +1,121 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { UUID } from "builder-util-runtime"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("electron", () => ({ + app: { + getPath: vi.fn(), + }, +})); + +vi.mock("electron-updater", () => ({ + autoUpdater: {}, +})); + +import { resolveStagingUserId, rolloutManifestSchema, shouldAdmitToRollout } from "./auto-updater"; + +describe("shouldAdmitToRollout", () => { + it("admits beta, missing rollout hours, zero-hour rollout, and missing release date", () => { + expect( + shouldAdmitToRollout({ + channel: "beta", + rolloutHours: 24, + releaseDate: "2026-04-28T00:00:00.000Z", + now: Date.parse("2026-04-28T01:00:00.000Z"), + bucket: 0.99, + }), + ).toBe(true); + expect( + shouldAdmitToRollout({ + channel: "stable", + rolloutHours: undefined, + releaseDate: "2026-04-28T00:00:00.000Z", + now: Date.parse("2026-04-28T01:00:00.000Z"), + bucket: 0.99, + }), + ).toBe(true); + expect( + shouldAdmitToRollout({ + channel: "stable", + rolloutHours: 0, + releaseDate: "2026-04-28T00:00:00.000Z", + now: Date.parse("2026-04-28T01:00:00.000Z"), + bucket: 0.99, + }), + ).toBe(true); + expect( + shouldAdmitToRollout({ + channel: "stable", + rolloutHours: 24, + releaseDate: undefined, + now: Date.parse("2026-04-28T01:00:00.000Z"), + bucket: 0.99, + }), + ).toBe(true); + }); + + it("blocks future releases and respects the linear threshold mid-rollout", () => { + expect( + shouldAdmitToRollout({ + channel: "stable", + rolloutHours: 24, + releaseDate: "2026-04-28T02:00:00.000Z", + now: Date.parse("2026-04-28T01:00:00.000Z"), + bucket: 0, + }), + ).toBe(false); + expect( + shouldAdmitToRollout({ + channel: "stable", + rolloutHours: 24, + releaseDate: "2026-04-28T00:00:00.000Z", + now: Date.parse("2026-04-28T12:00:00.000Z"), + bucket: 0.49, + }), + ).toBe(true); + expect( + shouldAdmitToRollout({ + channel: "stable", + rolloutHours: 24, + releaseDate: "2026-04-28T00:00:00.000Z", + now: Date.parse("2026-04-28T12:00:00.000Z"), + bucket: 0.51, + }), + ).toBe(false); + }); + + it("treats garbage manifest rollout fields as missing and admits", () => { + const parsed = rolloutManifestSchema.parse({ + rolloutHours: "not a number", + releaseDate: 12345, + }); + + expect( + shouldAdmitToRollout({ + channel: "stable", + rolloutHours: parsed.rolloutHours, + releaseDate: parsed.releaseDate, + now: Date.parse("2026-04-28T12:00:00.000Z"), + bucket: 0.99, + }), + ).toBe(true); + }); + + it("creates and then reuses the on-disk staging user id", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "paseo-updater-id-")); + const filePath = path.join(tempDir, ".updaterId"); + + try { + const first = await resolveStagingUserId(filePath); + const stored = (await readFile(filePath, "utf8")).trim(); + const second = await resolveStagingUserId(filePath); + + expect(UUID.check(stored)).toBeTruthy(); + expect(second).toBe(first); + } finally { + await rm(tempDir, { force: true, recursive: true }); + } + }); +}); diff --git a/packages/desktop/src/features/auto-updater.ts b/packages/desktop/src/features/auto-updater.ts index 23a3064f9..f68867836 100644 --- a/packages/desktop/src/features/auto-updater.ts +++ b/packages/desktop/src/features/auto-updater.ts @@ -1,5 +1,10 @@ +import { randomBytes } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; import { app } from "electron"; +import { UUID } from "builder-util-runtime"; import { autoUpdater, type UpdateInfo } from "electron-updater"; +import { z } from "zod"; // --------------------------------------------------------------------------- // Types @@ -22,6 +27,15 @@ export interface AppUpdateInstallResult { export type AppReleaseChannel = "stable" | "beta"; +export const rolloutManifestSchema = z.object({ + rolloutHours: z + .union([z.number(), z.string().transform(Number)]) + .pipe(z.number().finite().nonnegative()) + .optional() + .catch(undefined), + releaseDate: z.string().optional().catch(undefined), +}); + // --------------------------------------------------------------------------- // State // --------------------------------------------------------------------------- @@ -31,6 +45,66 @@ let downloadedUpdateVersion: string | null = null; let downloading = false; let autoUpdaterConfigured = false; let configuredReleaseChannel: AppReleaseChannel | null = null; +let cachedStagingUserIdPromise: Promise | null = null; + +export function shouldAdmitToRollout(args: { + channel: AppReleaseChannel; + rolloutHours: number | undefined; + releaseDate: string | undefined; + now: number; + bucket: number; +}): boolean { + if (args.channel !== "stable") return true; + if (args.rolloutHours == null) return true; + if (args.rolloutHours === 0) return true; + if (!args.releaseDate) return true; + + const releaseTime = new Date(args.releaseDate).getTime(); + if (Number.isNaN(releaseTime)) return true; + + const ageHours = (args.now - releaseTime) / 3_600_000; + if (ageHours < 0) return false; + + const pct = Math.min(100, (ageHours / args.rolloutHours) * 100); + return args.bucket * 100 < pct; +} + +function bucketFromStagingUserId(stagingUserId: string): number { + return UUID.parse(stagingUserId).readUInt32BE(12) / 0xffffffff; +} + +export async function resolveStagingUserId(filePath: string): Promise { + try { + const id = (await readFile(filePath, "utf8")).trim(); + if (UUID.check(id)) { + return id; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + console.warn(`[auto-updater] Couldn't read staging user ID, creating a blank one: ${error}`); + } + } + + const id = UUID.v5(randomBytes(4096), UUID.OID); + + try { + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, id); + } catch (error) { + console.warn(`[auto-updater] Couldn't write out staging user ID: ${error}`); + } + + return id; +} + +export function getStagingUserId(): Promise { + if (cachedStagingUserIdPromise == null) { + cachedStagingUserIdPromise = resolveStagingUserId( + path.join(app.getPath("userData"), ".updaterId"), + ); + } + return cachedStagingUserIdPromise; +} // --------------------------------------------------------------------------- // Configuration @@ -46,6 +120,22 @@ function configureAutoUpdater(releaseChannel: AppReleaseChannel): void { autoUpdater.allowPrerelease = releaseChannel === "beta"; autoUpdater.channel = releaseChannel === "beta" ? "beta" : "latest"; autoUpdater.allowDowngrade = false; + autoUpdater.isUserWithinRollout = async (info) => { + try { + const parsed = rolloutManifestSchema.parse(info); + const stagingUserId = await getStagingUserId(); + + return shouldAdmitToRollout({ + channel: releaseChannel, + rolloutHours: parsed.rolloutHours, + releaseDate: parsed.releaseDate, + now: Date.now(), + bucket: bucketFromStagingUserId(stagingUserId), + }); + } catch { + return true; + } + }; if (configuredReleaseChannel !== releaseChannel) { cachedUpdateInfo = null; diff --git a/scripts/merge-mac-manifest.mjs b/scripts/merge-mac-manifest.mjs new file mode 100644 index 000000000..7ebb92957 --- /dev/null +++ b/scripts/merge-mac-manifest.mjs @@ -0,0 +1,21 @@ +import fs from "node:fs"; +import { dump, load } from "js-yaml"; + +export function mergeMacManifest(arm64Path, x64Path, outputPath) { + const arm64 = load(fs.readFileSync(arm64Path, "utf8")); + const x64 = load(fs.readFileSync(x64Path, "utf8")); + const files = [...(arm64.files ?? []), ...(x64.files ?? [])].filter( + (file, index, all) => all.findIndex((entry) => entry.url === file.url) === index, + ); + const output = dump({ ...arm64, files }, { lineWidth: -1, noRefs: true }); + fs.writeFileSync(outputPath, output); + return output; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const [, , arm64Path, x64Path, outputPath] = process.argv; + if (!arm64Path || !x64Path || !outputPath) { + throw new Error("Usage: node scripts/merge-mac-manifest.mjs "); + } + process.stdout.write(mergeMacManifest(arm64Path, x64Path, outputPath)); +} diff --git a/scripts/merge-mac-manifest.test.mjs b/scripts/merge-mac-manifest.test.mjs new file mode 100644 index 000000000..f007b77af --- /dev/null +++ b/scripts/merge-mac-manifest.test.mjs @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "vitest"; +import { mergeMacManifest } from "./merge-mac-manifest.mjs"; + +test("preserves unknown fields while merging files by url", () => { + const dir = mkdtempSync(path.join(tmpdir(), "paseo-merge-mac-manifest-")); + try { + const arm64Path = path.join(dir, "arm64.yml"); + const x64Path = path.join(dir, "x64.yml"); + const outputPath = path.join(dir, "latest-mac.yml"); + writeFileSync( + arm64Path, + "version: 1.2.3\nfiles:\n - url: app-arm64.zip\n sha512: arm\nstagingPercentage: 25\npath: app-arm64.zip\nsha512: arm\nreleaseDate: '2026-04-28T00:00:00.000Z'\n", + ); + writeFileSync( + x64Path, + "version: 1.2.3\nfiles:\n - url: app-x64.zip\n sha512: x64\nstagingPercentage: 50\npath: app-x64.zip\nsha512: x64\nreleaseDate: '2026-04-29T00:00:00.000Z'\n", + ); + mergeMacManifest(arm64Path, x64Path, outputPath); + const output = readFileSync(outputPath, "utf8"); + assert.match(output, /stagingPercentage: 25/); + assert.match(output, /url: app-arm64\.zip/); + assert.match(output, /url: app-x64\.zip/); + } finally { + rmSync(dir, { force: true, recursive: true }); + } +}); diff --git a/scripts/stamp-rollout.mjs b/scripts/stamp-rollout.mjs new file mode 100644 index 000000000..2455d6b42 --- /dev/null +++ b/scripts/stamp-rollout.mjs @@ -0,0 +1,46 @@ +import fs from "node:fs"; +import { dump, load } from "js-yaml"; + +export function stampRollout({ releaseDate, rolloutHours }, paths) { + if (releaseDate === undefined && rolloutHours === undefined) { + throw new Error("stampRollout requires at least one of releaseDate or rolloutHours"); + } + for (const filePath of paths) { + const manifest = load(fs.readFileSync(filePath, "utf8")) ?? {}; + const next = { ...manifest }; + if (releaseDate !== undefined) next.releaseDate = releaseDate; + if (rolloutHours !== undefined) next.rolloutHours = rolloutHours; + fs.writeFileSync(filePath, dump(next, { lineWidth: -1, noRefs: true })); + } +} + +function parseArgs(argv) { + const opts = {}; + const paths = []; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--release-date") { + opts.releaseDate = argv[++i]; + } else if (arg === "--rollout-hours") { + const raw = argv[++i]; + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed < 0) { + throw new Error(`--rollout-hours must be a non-negative number, got ${raw}`); + } + opts.rolloutHours = parsed; + } else { + paths.push(arg); + } + } + return { opts, paths }; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const { opts, paths } = parseArgs(process.argv.slice(2)); + if (paths.length === 0 || (opts.releaseDate === undefined && opts.rolloutHours === undefined)) { + throw new Error( + "Usage: node scripts/stamp-rollout.mjs [--release-date ] [--rollout-hours ] ...", + ); + } + stampRollout(opts, paths); +} diff --git a/scripts/stamp-rollout.test.mjs b/scripts/stamp-rollout.test.mjs new file mode 100644 index 000000000..c46be50ed --- /dev/null +++ b/scripts/stamp-rollout.test.mjs @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "vitest"; +import { stampRollout } from "./stamp-rollout.mjs"; + +test("rewrites rollout fields and preserves unrelated manifest data", () => { + const dir = mkdtempSync(path.join(tmpdir(), "paseo-stamp-rollout-")); + try { + const firstPath = path.join(dir, "latest.yml"); + const secondPath = path.join(dir, "latest-mac.yml"); + writeFileSync( + firstPath, + "version: 1.2.3\nfiles:\n - url: app.zip\n sha512: abc\nreleaseDate: '2026-04-28T00:00:00.000Z'\nrolloutHours: 24\nstagingPercentage: 25\n", + ); + writeFileSync( + secondPath, + "version: 1.2.3\nfiles:\n - url: app-arm64.zip\n sha512: def\nreleaseDate: '2026-04-28T00:00:00.000Z'\npath: app-arm64.zip\n", + ); + stampRollout({ releaseDate: "2026-05-01T12:00:00.000Z", rolloutHours: 6 }, [ + firstPath, + secondPath, + ]); + + const first = readFileSync(firstPath, "utf8"); + const second = readFileSync(secondPath, "utf8"); + + assert.match(first, /releaseDate: '2026-05-01T12:00:00.000Z'/); + assert.match(first, /rolloutHours: 6/); + assert.match(first, /stagingPercentage: 25/); + assert.match(second, /releaseDate: '2026-05-01T12:00:00.000Z'/); + assert.match(second, /rolloutHours: 6/); + assert.match(second, /path: app-arm64\.zip/); + } finally { + rmSync(dir, { force: true, recursive: true }); + } +}); + +test("only updates rolloutHours when releaseDate is omitted", () => { + const dir = mkdtempSync(path.join(tmpdir(), "paseo-stamp-rollout-")); + try { + const filePath = path.join(dir, "latest.yml"); + writeFileSync( + filePath, + "version: 1.2.3\nfiles:\n - url: app.zip\n sha512: abc\nreleaseDate: '2026-04-28T00:00:00.000Z'\nrolloutHours: 24\nstagingPercentage: 25\n", + ); + stampRollout({ rolloutHours: 0 }, [filePath]); + + const out = readFileSync(filePath, "utf8"); + assert.match(out, /releaseDate: '2026-04-28T00:00:00.000Z'/); + assert.match(out, /rolloutHours: 0/); + assert.match(out, /stagingPercentage: 25/); + } finally { + rmSync(dir, { force: true, recursive: true }); + } +}); + +test("throws when neither releaseDate nor rolloutHours is provided", () => { + assert.throws(() => stampRollout({}, ["/tmp/does-not-matter.yml"])); +});