ops(web): bound immutable release history

This commit is contained in:
lingniu
2026-07-16 04:18:15 +08:00
parent 8e8f3f9f5c
commit 326d03cd6d
10 changed files with 392 additions and 3 deletions

View File

@@ -1,4 +1,4 @@
import { copyFileSync } from 'node:fs';
import { copyFileSync, readdirSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
const source = resolve(process.cwd(), 'public/app-config.example.js');
@@ -10,3 +10,5 @@ const destination = resolve(process.cwd(), 'dist/app-config.js');
// runtime configuration at /app-config.js on the server.
copyFileSync(source, destination);
const assets = readdirSync(resolve(process.cwd(), 'dist/assets')).sort();
writeFileSync(resolve(process.cwd(), 'dist/.release-assets'), `${assets.join('\n')}\n`);

View File

@@ -7,6 +7,7 @@ const excelAssets = assets.filter((name) => /^exceljs(?:\.min)?-[\w-]+\.js$/.tes
const mileageWorkers = assets.filter((name) => /^mileageExport\.worker-[\w-]+\.js$/.test(name));
const runtimeConfig = readFileSync(resolve(process.cwd(), 'dist/app-config.js'), 'utf8');
const safeRuntimeTemplate = readFileSync(resolve(process.cwd(), 'public/app-config.example.js'), 'utf8');
const releaseManifest = readFileSync(resolve(process.cwd(), 'dist/.release-assets'), 'utf8').split(/\r?\n/).filter(Boolean);
if (excelAssets.length !== 1) {
throw new Error(`production build must contain exactly one ExcelJS asset, found ${excelAssets.length}: ${excelAssets.join(', ') || 'none'}`);
@@ -17,6 +18,12 @@ if (mileageWorkers.length !== 1) {
if (runtimeConfig !== safeRuntimeTemplate) {
throw new Error('production dist/app-config.js must match the credential-free runtime template');
}
if (releaseManifest.length !== assets.length || releaseManifest.some((name, index) => name !== [...assets].sort()[index])) {
throw new Error('production dist/.release-assets must list every generated asset exactly once in sorted order');
}
if (releaseManifest.some((name) => name.includes('/') || name === '.' || name === '..')) {
throw new Error('production dist/.release-assets contains an unsafe asset name');
}
const configuredSecurityCode = runtimeConfig.match(/["']?amapSecurityJsCode["']?\s*:\s*(["'])(.*?)\1/s)?.[2].trim();
if (configuredSecurityCode) {
throw new Error('production dist/app-config.js must not contain an AMap security code');
@@ -24,4 +31,4 @@ if (configuredSecurityCode) {
const excelBytes = statSync(resolve(assetsDirectory, excelAssets[0])).size;
const workerBytes = statSync(resolve(assetsDirectory, mileageWorkers[0])).size;
process.stdout.write(`web_build_gate=ok exceljs_assets=1 exceljs_bytes=${excelBytes} mileage_workers=1 worker_bytes=${workerBytes} runtime_config_sanitized=1\n`);
process.stdout.write(`web_build_gate=ok release_assets=${releaseManifest.length} exceljs_assets=1 exceljs_bytes=${excelBytes} mileage_workers=1 worker_bytes=${workerBytes} runtime_config_sanitized=1\n`);

View File

@@ -0,0 +1,117 @@
#!/usr/bin/env bash
set -euo pipefail
RELEASE_ID=${1:?release id is required}
ARCHIVE=${2:?web archive is required}
ROOT=${PLATFORM_ROOT:-/opt/lingniu-vehicle-platform}
BASE_URL=${PLATFORM_BASE_URL:-http://127.0.0.1:20300}
SERVICE=${PLATFORM_SERVICE:-lingniu-vehicle-platform}
COMPATIBILITY_GENERATIONS=${COMPATIBILITY_GENERATIONS:-3}
RELEASE_HISTORY_LIMIT=${RELEASE_HISTORY_LIMIT:-20}
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
SYSTEMCTL_BIN=${SYSTEMCTL_BIN:-systemctl}
CURL_BIN=${CURL_BIN:-curl}
case "$RELEASE_ID" in
''|*[!A-Za-z0-9._-]*) printf 'invalid release id: %s\n' "$RELEASE_ID" >&2; exit 1 ;;
esac
test -f "$ARCHIVE" || { printf 'web archive is missing: %s\n' "$ARCHIVE" >&2; exit 1; }
test -x "$SCRIPT_DIR/prepare-web-release-tree.sh" || { printf 'release tree helper is missing\n' >&2; exit 1; }
test -f "$SCRIPT_DIR/prune-release-history.py" || { printf 'release pruning helper is missing\n' >&2; exit 1; }
old=$(readlink -f "$ROOT/current")
next="$ROOT/releases/$RELEASE_ID"
env_file="$ROOT/env/platform.env"
old_release=$(sed -n 's/^PLATFORM_RELEASE=//p' "$env_file" | tail -1)
switched=false
set_release_env() {
python3 - "$env_file" "$1" <<'PY'
from __future__ import print_function
import io
import sys
path, release = sys.argv[1], sys.argv[2]
with io.open(path, 'r', encoding='utf-8') as handle:
lines = handle.read().splitlines()
updated = []
found = False
for line in lines:
if line.startswith('PLATFORM_RELEASE='):
updated.append('PLATFORM_RELEASE=' + release)
found = True
else:
updated.append(line)
if not found:
updated.append('PLATFORM_RELEASE=' + release)
with io.open(path, 'w', encoding='utf-8') as handle:
handle.write('\n'.join(updated) + '\n')
PY
}
rollback() {
local status=$?
trap - ERR
set +e
if test "$switched" = true; then
ln -s "$old" "$ROOT/current.rollback"
python3 -c 'import os,sys; os.replace(sys.argv[1], sys.argv[2])' "$ROOT/current.rollback" "$ROOT/current"
set_release_env "$old_release"
"$SYSTEMCTL_BIN" restart "$SERVICE"
fi
printf 'web release install failed; current restored to %s\n' "$old" >&2
exit "$status"
}
trap rollback ERR
test ! -e "$next" || { printf 'release already exists: %s\n' "$next" >&2; exit 1; }
mkdir -p "$next/web"
cp "$old/platform-api" "$next/platform-api"
cp "$old/lingniu-vehicle-platform.service" "$next/lingniu-vehicle-platform.service"
cp -a "$old/deploy" "$next/deploy"
cp "$SCRIPT_DIR/prepare-web-release-tree.sh" "$next/deploy/prepare-web-release-tree.sh"
cp "$SCRIPT_DIR/prune-release-history.py" "$next/deploy/prune-release-history.py"
cp "$0" "$next/deploy/install-web-release.sh"
chmod +x "$next/platform-api" "$next/deploy/"*.sh "$next/deploy/prune-release-history.py"
while IFS= read -r member; do
case "$member" in
/*|../*|*/../*|*/..) printf 'web archive contains unsafe path: %s\n' "$member" >&2; exit 1 ;;
esac
done < <(tar -tzf "$ARCHIVE")
tar --no-same-owner -xzf "$ARCHIVE" -C "$next/web"
find "$next/web" -type f -name '._*' -delete
"$next/deploy/prepare-web-release-tree.sh" "$next/web" "$old/web" "$COMPATIBILITY_GENERATIONS"
ln -s "$next" "$ROOT/current.next"
python3 -c 'import os,sys; os.replace(sys.argv[1], sys.argv[2])' "$ROOT/current.next" "$ROOT/current"
switched=true
set_release_env "$RELEASE_ID"
"$SYSTEMCTL_BIN" restart "$SERVICE"
ready=false
for _ in $(seq 1 15); do
if "$SYSTEMCTL_BIN" is-active --quiet "$SERVICE" && "$CURL_BIN" -fsS "$BASE_URL/" >/dev/null; then ready=true; break; fi
sleep 1
done
test "$ready" = true || { printf 'service did not become ready\n' >&2; exit 1; }
verify_bin=${VERIFY_WEB_RELEASE_BIN:-$next/deploy/verify-web-release.sh}
"$verify_bin" "$next/web" "$BASE_URL" "$next/web/.compatibility-assets"
if test -f "$ROOT/env/access-tokens.env"; then
set -a
# shellcheck disable=SC1090
. "$ROOT/env/access-tokens.env"
set +a
test -n "${VIEWER_TOKEN:-}" || { printf 'viewer token is unavailable for health verification\n' >&2; exit 1; }
health=$("$CURL_BIN" -fsS -H "Authorization: Bearer $VIEWER_TOKEN" "$BASE_URL/api/ops/health")
printf '%s' "$health" | python3 -c 'import json,sys; value=(((json.load(sys.stdin).get("data") or {}).get("runtime") or {}).get("platformRelease")); sys.exit(0 if value == sys.argv[1] else 1)' "$RELEASE_ID"
fi
prune_bin=${PRUNE_RELEASE_BIN:-$next/deploy/prune-release-history.py}
python3 "$prune_bin" "$ROOT" "$RELEASE_HISTORY_LIMIT" "$next" "$old"
rm -f "$ARCHIVE"
switched=false
trap - ERR
printf 'web_release_install=ok release=%s previous=%s\n' "$RELEASE_ID" "$(basename "$old")"

View File

@@ -0,0 +1,61 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
fixture=$(mktemp -d)
trap 'rm -rf "$fixture"' EXIT
root="$fixture/platform"
old="$root/releases/old-release"
mkdir -p "$old/web/assets" "$old/deploy" "$root/env" "$fixture/bin"
printf '#!/usr/bin/env bash\nexit 0\n' > "$old/platform-api"
chmod +x "$old/platform-api"
printf '[Service]\n' > "$old/lingniu-vehicle-platform.service"
cp "$SCRIPT_DIR/verify-web-release.sh" "$old/deploy/verify-web-release.sh"
printf 'old.js\n' > "$old/web/.release-assets"
printf 'old\n' > "$old/web/assets/old.js"
ln -s "$old" "$root/current"
printf 'PLATFORM_RELEASE=old-release\n' > "$root/env/platform.env"
for index in 1 2 3 4; do mkdir -p "$root/releases/stale-$index"; touch -t "20260716010${index}" "$root/releases/stale-$index"; done
new_web="$fixture/new-web"
mkdir -p "$new_web/assets"
printf 'new.js\n' > "$new_web/.release-assets"
printf 'new\n' > "$new_web/assets/new.js"
printf '<main>new</main>\n' > "$new_web/index.html"
printf 'window.__LINGNIU_APP_CONFIG__={"amapSecurityServiceHost":"/_AMapService"};\n' > "$new_web/app-config.js"
tar -C "$new_web" -czf "$fixture/new.tar.gz" .
printf '#!/usr/bin/env bash\nexit 0\n' > "$fixture/bin/systemctl"
printf '#!/usr/bin/env bash\nexit 0\n' > "$fixture/bin/curl"
printf '#!/usr/bin/env bash\nprintf "mock_verify=ok\\n"\n' > "$fixture/bin/verify"
chmod +x "$fixture/bin/"*
PLATFORM_ROOT="$root" SYSTEMCTL_BIN="$fixture/bin/systemctl" CURL_BIN="$fixture/bin/curl" VERIFY_WEB_RELEASE_BIN="$fixture/bin/verify" RELEASE_HISTORY_LIMIT=3 "$SCRIPT_DIR/install-web-release.sh" new-release "$fixture/new.tar.gz" > "$fixture/install.out"
test "$(readlink -f "$root/current")" = "$(cd "$root/releases/new-release" && pwd -P)"
grep -q '^PLATFORM_RELEASE=new-release$' "$root/env/platform.env"
test -f "$root/current/web/assets/new.js"
test -f "$root/current/web/assets/old.js"
test -f "$root/current/web/.compatibility-manifests/1.assets"
test "$(find "$root/releases" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')" = 3
grep -q 'web_release_install=ok release=new-release previous=old-release' "$fixture/install.out"
rollback_web="$fixture/rollback-web"
mkdir -p "$rollback_web/assets"
printf 'rollback.js\n' > "$rollback_web/.release-assets"
printf 'rollback\n' > "$rollback_web/assets/rollback.js"
printf '<main>rollback</main>\n' > "$rollback_web/index.html"
printf 'window.__LINGNIU_APP_CONFIG__={"amapSecurityServiceHost":"/_AMapService"};\n' > "$rollback_web/app-config.js"
tar -C "$rollback_web" -czf "$fixture/rollback.tar.gz" .
printf '#!/usr/bin/env bash\nexit 1\n' > "$fixture/bin/fail-verify"
chmod +x "$fixture/bin/fail-verify"
if PLATFORM_ROOT="$root" SYSTEMCTL_BIN="$fixture/bin/systemctl" CURL_BIN="$fixture/bin/curl" VERIFY_WEB_RELEASE_BIN="$fixture/bin/fail-verify" RELEASE_HISTORY_LIMIT=3 "$SCRIPT_DIR/install-web-release.sh" broken-release "$fixture/rollback.tar.gz" > "$fixture/rollback.out" 2>&1; then
printf 'expected failed verification to roll back\n' >&2
exit 1
fi
test "$(readlink -f "$root/current")" = "$(cd "$root/releases/new-release" && pwd -P)"
grep -q '^PLATFORM_RELEASE=new-release$' "$root/env/platform.env"
grep -q 'current restored' "$fixture/rollback.out"
printf 'install-web-release tests passed\n'

View File

@@ -0,0 +1,72 @@
#!/usr/bin/env bash
set -euo pipefail
NEW_WEB=${1:?new web root is required}
OLD_WEB=${2:?old web root is required}
MAX_COMPATIBILITY_GENERATIONS=${3:-3}
case "$MAX_COMPATIBILITY_GENERATIONS" in
''|*[!0-9]*) printf 'invalid compatibility generation limit: %s\n' "$MAX_COMPATIBILITY_GENERATIONS" >&2; exit 1 ;;
esac
test "$MAX_COMPATIBILITY_GENERATIONS" -ge 1 || { printf 'compatibility generation limit must be positive\n' >&2; exit 1; }
test -f "$NEW_WEB/.release-assets" || { printf 'new release manifest is missing\n' >&2; exit 1; }
test -f "$OLD_WEB/.release-assets" || { printf 'old release manifest is missing\n' >&2; exit 1; }
mkdir -p "$NEW_WEB/assets" "$NEW_WEB/.compatibility-manifests"
validate_asset() {
local asset=$1
case "$asset" in
''|/*|*/*|.|..) printf 'unsafe release asset: %s\n' "$asset" >&2; return 1 ;;
esac
}
while IFS= read -r asset || test -n "$asset"; do
asset=${asset%$'\r'}
test -n "$asset" || continue
validate_asset "$asset"
test -f "$NEW_WEB/assets/$asset" || { printf 'current asset is missing: %s\n' "$NEW_WEB/assets/$asset" >&2; exit 1; }
done < "$NEW_WEB/.release-assets"
copy_manifest_assets() {
local manifest=$1
local asset source destination
while IFS= read -r asset || test -n "$asset"; do
asset=${asset%$'\r'}
test -n "$asset" || continue
validate_asset "$asset"
source="$OLD_WEB/assets/$asset"
destination="$NEW_WEB/assets/$asset"
test -f "$source" || { printf 'compatibility asset is missing: %s\n' "$source" >&2; return 1; }
if test -f "$destination"; then
cmp -s "$source" "$destination" || { printf 'asset hash collision has different content: %s\n' "$asset" >&2; return 1; }
else
cp "$source" "$destination"
fi
done < "$manifest"
}
cp "$OLD_WEB/.release-assets" "$NEW_WEB/.compatibility-manifests/1.assets"
generation=2
while test "$generation" -le "$MAX_COMPATIBILITY_GENERATIONS"; do
previous=$((generation - 1))
source_manifest="$OLD_WEB/.compatibility-manifests/$previous.assets"
test -f "$source_manifest" || break
cp "$source_manifest" "$NEW_WEB/.compatibility-manifests/$generation.assets"
generation=$((generation + 1))
done
compatibility_manifest="$NEW_WEB/.compatibility-assets"
: > "$compatibility_manifest"
for manifest in "$NEW_WEB"/.compatibility-manifests/*.assets; do
test -f "$manifest" || continue
copy_manifest_assets "$manifest"
cat "$manifest" >> "$compatibility_manifest"
done
LC_ALL=C sort -u "$compatibility_manifest" -o "$compatibility_manifest"
current_count=$(awk 'NF { count += 1 } END { print count + 0 }' "$NEW_WEB/.release-assets")
compatibility_count=$(awk 'NF { count += 1 } END { print count + 0 }' "$compatibility_manifest")
generation_count=$(find "$NEW_WEB/.compatibility-manifests" -maxdepth 1 -type f -name '*.assets' | wc -l | tr -d ' ')
served_count=$(find "$NEW_WEB/assets" -maxdepth 1 -type f | wc -l | tr -d ' ')
printf 'web_release_tree=ok current_assets=%s compatibility_assets=%s compatibility_generations=%s served_assets=%s\n' "$current_count" "$compatibility_count" "$generation_count" "$served_count"

View File

@@ -0,0 +1,38 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
fixture=$(mktemp -d)
trap 'rm -rf "$fixture"' EXIT
old="$fixture/old"
new="$fixture/new"
mkdir -p "$old/assets" "$old/.compatibility-manifests" "$new/assets"
printf 'old-current.js\nshared.js\n' > "$old/.release-assets"
printf 'older-one.js\n' > "$old/.compatibility-manifests/1.assets"
printf 'too-old.js\n' > "$old/.compatibility-manifests/2.assets"
for asset in old-current.js shared.js older-one.js too-old.js orphan.js; do printf '%s\n' "$asset" > "$old/assets/$asset"; done
printf 'new-current.js\nshared.js\n' > "$new/.release-assets"
printf 'new-current.js\n' > "$new/assets/new-current.js"
cp "$old/assets/shared.js" "$new/assets/shared.js"
output=$("$SCRIPT_DIR/prepare-web-release-tree.sh" "$new" "$old" 2)
test "$output" = 'web_release_tree=ok current_assets=2 compatibility_assets=3 compatibility_generations=2 served_assets=4'
test -f "$new/assets/old-current.js"
test -f "$new/assets/older-one.js"
test ! -e "$new/assets/too-old.js"
test ! -e "$new/assets/orphan.js"
test "$(cat "$new/.compatibility-manifests/1.assets")" = "$(cat "$old/.release-assets")"
test "$(cat "$new/.compatibility-manifests/2.assets")" = "$(cat "$old/.compatibility-manifests/1.assets")"
unsafe="$fixture/unsafe"
cp -R "$new" "$unsafe"
printf '../secret.js\n' > "$old/.release-assets"
if "$SCRIPT_DIR/prepare-web-release-tree.sh" "$unsafe" "$old" 2 > "$fixture/unsafe.out" 2>&1; then
printf 'expected an unsafe asset to fail\n' >&2
exit 1
fi
grep -q 'unsafe release asset' "$fixture/unsafe.out"
printf 'prepare-web-release tests passed\n'

View File

@@ -0,0 +1,53 @@
#!/usr/bin/env python3
from __future__ import print_function
import argparse
import os
import shutil
from pathlib import Path
def main():
parser = argparse.ArgumentParser(description='Keep a bounded number of immutable platform releases.')
parser.add_argument('root')
parser.add_argument('limit', type=int)
parser.add_argument('protected', nargs='*')
args = parser.parse_args()
root = Path(args.root).resolve()
releases = (root / 'releases').resolve()
if root == Path('/') or releases.parent != root or not releases.is_dir():
raise SystemExit('unsafe or missing release root: {}'.format(releases))
if args.limit < 2:
raise SystemExit('release history limit must be at least 2')
protected = set()
for value in args.protected:
path = Path(value).resolve()
if path.parent != releases or not path.is_dir():
raise SystemExit('protected release is outside the release root: {}'.format(path))
protected.add(path)
candidates = [path.resolve() for path in releases.iterdir() if path.is_dir() and not path.is_symlink()]
candidates.sort(key=lambda path: path.stat().st_mtime, reverse=True)
keep = set(protected)
for path in candidates:
if len(keep) >= args.limit:
break
keep.add(path)
removed = 0
for path in candidates:
if path in keep:
continue
if path.parent != releases:
raise SystemExit('refusing to remove a nested or escaped path: {}'.format(path))
shutil.rmtree(str(path))
removed += 1
print('release_prune=ok before={} kept={} removed={}'.format(len(candidates), len(keep), removed))
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
fixture=$(mktemp -d)
trap 'rm -rf "$fixture"' EXIT
mkdir -p "$fixture/releases"
for index in 1 2 3 4 5 6; do
mkdir "$fixture/releases/r$index"
touch -t "20260716010${index}" "$fixture/releases/r$index"
done
output=$(python3 "$SCRIPT_DIR/prune-release-history.py" "$fixture" 3 "$fixture/releases/r1")
test "$output" = 'release_prune=ok before=6 kept=3 removed=3'
test -d "$fixture/releases/r1"
test -d "$fixture/releases/r6"
test -d "$fixture/releases/r5"
test ! -e "$fixture/releases/r2"
test ! -e "$fixture/releases/r3"
test ! -e "$fixture/releases/r4"
if python3 "$SCRIPT_DIR/prune-release-history.py" / 3 > "$fixture/unsafe.out" 2>&1; then
printf 'expected an unsafe root to fail\n' >&2
exit 1
fi
grep -q 'unsafe or missing release root' "$fixture/unsafe.out"
printf 'prune-release-history tests passed\n'

View File

@@ -44,6 +44,16 @@ Release `web-release-smoke-20260716015150` installs `deploy/verify-web-release.s
Behavioral tests cover a valid release, a missing compatibility file, a wrong HTTP 200 response body and an unsafe manifest path. The deployed gate passed all 20 current assets and all 20 compatibility assets from `mileage-export-worker-20260716014414`; the platform service remained active and the authenticated health endpoint reported the new release. The ECS host still runs Python 3.6, so the test fixture uses a working-directory subshell instead of the newer `http.server --directory` option and now proves the same cases on both the development host and ECS. The final release is `web-release-smoke-portable-20260716015528`.
## 2026-07-16: bounded immutable release history
The initial immutable-release workflow copied every asset from the active release into the next release. Because the active release already contained its own compatibility assets, this formed a transitive chain: every deployment retained all earlier assets. The ECS host had accumulated 747 release directories occupying 12 GB, even though only the current and recent rollback revisions could serve traffic.
The production build now emits `.release-assets` itself and verifies that it lists every generated file exactly once. Release preparation keeps three explicit compatibility generations, copies only assets named by those manifests and rejects unsafe names, missing files or same-name/different-content collisions. Older tabs remain protected by the route boundary's one-shot current-version reload, while recent tabs continue loading their original hashed chunks without interruption.
`install-web-release.sh` stages an immutable directory, validates archive paths, atomically replaces the `current` symlink, restarts the service, verifies every current and compatibility asset byte-for-byte, checks the authenticated runtime release and only then prunes history to 20 releases. Any readiness, asset or health failure restores the previous symlink, release environment and service before returning an error. Tests exercise successful installation, bounded compatibility generations, unsafe input, missing assets, release pruning and a forced post-switch rollback.
This applies the same lifecycle principles as blue/green deployment: stage a separate revision, validate it, switch traffic and retain a bounded rollback history. AWS documents fast rollback by switching back to the blue environment, while Kubernetes exposes `revisionHistoryLimit` because unbounded old revisions consume control-plane resources: <https://docs.aws.amazon.com/whitepapers/latest/blue-green-deployments/introduction.html>, <https://kubernetes.io/docs/concepts/workloads/controllers/deployment/#revision-history-limit>.
## 2026-07-16: cancellable, off-main-thread mileage export
The full-fleet mileage export previously performed three long-running phases as one opaque foreground action: paginating vehicle identities, paginating daily mileage rows and building the ExcelJS workbook on the browser main thread. Navigating away did not cancel the export requests, and a large `writeBuffer()` could make controls and route transitions unresponsive.

View File

@@ -5,7 +5,7 @@
"web:build": "pnpm --dir apps/web run build",
"web:test": "pnpm --dir apps/web run test",
"api:test": "cd apps/api && go test ./...",
"release:web-smoke:test": "bash deploy/verify-web-release.test.sh",
"release:web-smoke:test": "bash deploy/verify-web-release.test.sh && bash deploy/prepare-web-release.test.sh && bash deploy/prune-release-history.test.sh && bash deploy/install-web-release.test.sh",
"build": "pnpm run web:build && cd apps/api && go build -o ../../dist/platform-api ./cmd/platform-api && go build -o ../../dist/oneos-scope-sync ./cmd/oneos-scope-sync"
}
}