mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
feat(desktop): add time-based staged rollout for stable updates
Linear ramp from 0% at publish to 100% at releaseDate + rolloutHours (default 24h, configurable per release). Beta channel and rolloutHours=0 short-circuit to admit everyone. Per-machine bucket derives from a persistent UUID at <userData>/.updaterId and feeds electron-updater's isUserWithinRollout hook. Adds desktop-rollout.yml workflow for in-place rolloutHours edits on already-published releases (hotfix to 0 or extend the ramp), serialized against finalize-rollout in desktop-release.yml via a shared concurrency group keyed on the tag. Replaces the hand-rolled mac manifest merge with a js-yaml round-trip that preserves unknown fields.
This commit is contained in:
149
.github/workflows/desktop-release.yml
vendored
149
.github/workflows/desktop-release.yml
vendored
@@ -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 }}"
|
||||
|
||||
152
.github/workflows/desktop-rollout.yml
vendored
Normal file
152
.github/workflows/desktop-rollout.yml
vendored
Normal file
@@ -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 ?? "<unset>"} releaseDate=${m.releaseDate ?? "<unset>"}`);
|
||||
}
|
||||
' ./*.yml)
|
||||
echo "$summary"
|
||||
{
|
||||
echo "summary<<EOF"
|
||||
echo "$summary"
|
||||
echo "EOF"
|
||||
} >> "$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"
|
||||
@@ -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.<N+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.
|
||||
|
||||
231
package-lock.json
generated
231
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
121
packages/desktop/src/features/auto-updater.test.ts
Normal file
121
packages/desktop/src/features/auto-updater.test.ts
Normal file
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<string> | 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<string> {
|
||||
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<string> {
|
||||
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;
|
||||
|
||||
21
scripts/merge-mac-manifest.mjs
Normal file
21
scripts/merge-mac-manifest.mjs
Normal file
@@ -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 <arm64.yml> <x64.yml> <out.yml>");
|
||||
}
|
||||
process.stdout.write(mergeMacManifest(arm64Path, x64Path, outputPath));
|
||||
}
|
||||
30
scripts/merge-mac-manifest.test.mjs
Normal file
30
scripts/merge-mac-manifest.test.mjs
Normal file
@@ -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 });
|
||||
}
|
||||
});
|
||||
46
scripts/stamp-rollout.mjs
Normal file
46
scripts/stamp-rollout.mjs
Normal file
@@ -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 <iso>] [--rollout-hours <n>] <yaml-path>...",
|
||||
);
|
||||
}
|
||||
stampRollout(opts, paths);
|
||||
}
|
||||
61
scripts/stamp-rollout.test.mjs
Normal file
61
scripts/stamp-rollout.test.mjs
Normal file
@@ -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"]));
|
||||
});
|
||||
Reference in New Issue
Block a user