[AI] lage - move browser build to using lage (#7602)

* Simplify desktop client browser build

* [AI] Move browser build orchestration into vite config and lage

Moves loot-core worker build, public/ staging (migrations, default-db,
sql-wasm, data-file-index), and build-stats wiring from the deleted
packages/desktop-client/bin/build-browser shell script into a
lootCoreBackend vite plugin in packages/desktop-client/vite.config.mts.

Adds a build:browser target to lage.config.js so bin/package-browser
runs as a single `lage build:browser --to=@actual-app/web` call, with
crdt + loot-core built via lage's ^build dependency before the
desktop-client build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Refactor e2e-test workflow and update desktop-client configurations

* [AI] Move plugins-service staging into desktop-client vite config

Declares plugins-service as a workspace devDependency of @actual-app/web
so lage's ^build edge picks it up automatically in the build:browser
pipeline, and moves the cross-package file staging (production copy +
dev serving) into vite.config.mts, mirroring the lootCoreBackend
pattern. Drops the plugins-service shell wrapper script and simplifies
its package.json scripts to invoke vite build directly. Updates root
start:browser to run plugins-service watch in parallel with the dev
server instead of pre-building once.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* [AI] Sync tsconfig project references for plugins-service edge

Follow-up to the plugins-service workspace edge: adds the
../plugins-service project reference in packages/desktop-client/tsconfig.json
via yarn sync:tsconfig-references.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Release notes

* [AI] Ignore .venv/ so lage's git hasher skips Electron CI's Python venv

Electron CI provisions a Python virtualenv at the repo root for
setuptools. With browser builds now routed through lage, lage's
git hash-object pass walks untracked-not-ignored files and fails on
the venv's broken lib64 symlink ("fatal: Unable to hash .../.venv/lib64").

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* [AI] Bake Weblate translations back into VRT/e2e bundle

build-web set download-translations: false and relied on bin/package-browser's
ad-hoc git clone + git pull. That path is fragile inside the playwright
container, so vite's import.meta.glob('/locale/*.json') frequently produced an
empty languages map and the bundle shipped with no en.json. VRTs then rendered
source-code English and diffed against snapshots authored from Weblate strings.

Route translation provisioning back through actions/checkout (download-translations: true)
in build-web and vrt-update-generate, and add --skip-translations to bin/package-browser
(mirroring bin/package-electron) so the in-script git pull is bypassed when CI
has already staged the locale dir.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* [AI] Skip translation cloning in build-web bundle for VRT determinism

bin/package-browser used to unconditionally clone actualbudget/translations
before vite ran, baking Weblate en.json into the build artifact. With the
e2e-test pipeline now serving that artifact via serve-build.mjs, VRT
screenshots ended up rendering Weblate strings — drifting from the snapshots,
which were authored against source-code English (master VRTs ran on vite dev
without a locale dir).

Pass --skip-translations to bin/package-browser from build-web so the bundle
ships with no locale chunks. download-translations stays 'false' across the
e2e-test and vrt-update-generate workflows, matching the prior behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matiss Janis Aboltins
2026-04-28 21:20:13 +01:00
committed by GitHub
parent 11ce29e7fd
commit ff0f5bdb35
18 changed files with 254 additions and 131 deletions

View File

@@ -46,13 +46,12 @@ jobs:
# via ConfigurationPage.createTestFile()) is still rendered in a
# production build. Without it, e2e tests would time out waiting for
# a button that was tree-shaken out.
# --skip-translations keeps VRT screenshots deterministic by rendering
# source-code English instead of upstream Weblate en.json (which can
# drift between snapshot capture and test runs).
env:
REACT_APP_NETLIFY: 'true'
run: |
yarn workspace plugins-service build
yarn workspace @actual-app/crdt build
yarn workspace @actual-app/core build:browser
yarn workspace @actual-app/web build:browser
run: yarn build:browser --skip-translations
- name: Upload build artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:

3
.gitignore vendored
View File

@@ -42,6 +42,9 @@ bundle.desktop.js.map
bundle.mobile.js
bundle.mobile.js.map
# Python virtualenv (Electron CI provisions one at the repo root for setuptools)
.venv/
# Yarn
.pnp.*
.yarn/*

View File

@@ -4,21 +4,30 @@ ROOT=`dirname $0`
cd "$ROOT/.."
echo "Updating translations..."
if ! [ -d packages/desktop-client/locale ]; then
git clone https://github.com/actualbudget/translations packages/desktop-client/locale
SKIP_TRANSLATIONS=false
while [[ $# -gt 0 ]]; do
case "$1" in
--skip-translations)
SKIP_TRANSLATIONS=true
shift
;;
*)
echo "Unknown argument: $1" >&2
exit 1
;;
esac
done
if [ "$SKIP_TRANSLATIONS" = false ]; then
echo "Updating translations..."
if ! [ -d packages/desktop-client/locale ]; then
git clone https://github.com/actualbudget/translations packages/desktop-client/locale
fi
pushd packages/desktop-client/locale > /dev/null
git checkout .
git pull
popd > /dev/null
packages/desktop-client/bin/remove-untranslated-languages
fi
pushd packages/desktop-client/locale > /dev/null
git checkout .
git pull
popd > /dev/null
packages/desktop-client/bin/remove-untranslated-languages
export NODE_OPTIONS="--max-old-space-size=4096"
yarn workspace @actual-app/crdt build
yarn workspace plugins-service build
yarn workspace @actual-app/core build:browser
yarn workspace @actual-app/web build:browser
echo "packages/desktop-client/build"
lage build:browser --to=@actual-app/web

View File

@@ -57,8 +57,7 @@ yarn workspace @actual-app/core build:node
yarn workspace @actual-app/web build --mode=desktop # electron specific build
# required for running the sync-server server
yarn workspace @actual-app/core build:browser
yarn workspace @actual-app/web build:browser
yarn build:browser
yarn workspace @actual-app/sync-server build
# Emit @actual-app/core declarations so desktop-electron (which includes typings/window.ts) can build

View File

@@ -25,6 +25,14 @@ module.exports = {
outputGlob: BUILD_OUTPUT_GLOBS,
},
},
// Not cached: the script stages files into public/ and build-stats/ that
// fall outside BUILD_OUTPUT_GLOBS, so a cache hit would skip the side
// effects.
'build:browser': {
type: 'npmScript',
dependsOn: ['^build'],
cache: false,
},
},
cacheOptions: {
cacheStorageConfig: {

View File

@@ -24,18 +24,16 @@
"start:server-dev": "NODE_ENV=development BROWSER_OPEN=localhost:5006 yarn npm-run-all --parallel 'start:server-monitor' 'start'",
"start:desktop": "yarn desktop-dependencies && npm-run-all --parallel 'start:desktop-*'",
"start:docs": "yarn workspace docs start",
"desktop-dependencies": "npm-run-all --parallel rebuild-electron build:browser-backend build:plugins-service",
"desktop-dependencies": "npm-run-all --parallel rebuild-electron build:plugins-service",
"start:desktop-node": "yarn workspace @actual-app/core watch:node",
"start:desktop-client": "yarn workspace @actual-app/web watch",
"start:desktop-server-client": "yarn workspace @actual-app/web build:browser",
"start:desktop-electron": "yarn workspace desktop-electron watch",
"start:browser": "yarn workspace plugins-service build-dev && npm-run-all --parallel 'start:browser-*'",
"start:browser": "npm-run-all --parallel 'start:browser-*' 'start:service-plugins'",
"start:service-plugins": "yarn workspace plugins-service watch",
"start:browser-backend": "yarn workspace @actual-app/core watch:browser",
"start:browser-frontend": "yarn workspace @actual-app/web start:browser",
"start:storybook": "yarn workspace @actual-app/components start:storybook",
"build": "lage build",
"build:browser-backend": "yarn workspace @actual-app/core build:browser",
"build:server": "yarn build:browser && yarn workspace @actual-app/sync-server build",
"build:browser": "./bin/package-browser",
"build:desktop": "./bin/package-electron",

View File

@@ -8,6 +8,7 @@ coverage
test-results
playwright-report
blob-report
.playwright-cli
# production
build

View File

@@ -1,17 +0,0 @@
#!/bin/sh -ex
ROOT=`dirname $0`
cd "$ROOT/.."
echo "Building the browser..."
rm -fr build
export REACT_APP_BACKEND_WORKER_HASH=`ls "$ROOT"/../public/kcab/kcab.worker.*.js | sed 's/.*kcab\.worker\.\(.*\)\.js/\1/'`
yarn build --mode=browser
rm -fr build-stats
mkdir build-stats
mv build/kcab/stats.json build-stats/loot-core-stats.json
mv ./stats.json build-stats/web-stats.json

View File

@@ -104,7 +104,7 @@
"start:browser": "cross-env ./bin/watch-browser",
"watch": "cross-env BROWSER=none yarn start",
"build": "vite build",
"build:browser": "cross-env ./bin/build-browser",
"build:browser": "vite build --mode=browser",
"generate:i18n": "i18next",
"test": "vitest --run",
"validate:theme-catalog": "node --experimental-strip-types bin/validate-theme-catalog.mts",
@@ -164,6 +164,7 @@
"mdast-util-newline-to-break": "^2.0.0",
"memoize-one": "^6.0.0",
"pikaday": "1.8.2",
"plugins-service": "workspace:*",
"promise-retry": "^2.0.1",
"re-resizable": "^6.11.2",
"react": "19.2.4",

View File

@@ -24,6 +24,9 @@
},
{
"path": "../loot-core"
},
{
"path": "../plugins-service"
}
],
"include": ["**/*.ts", "src/**/*.tsx", "src/**/*.js"],

View File

@@ -1,3 +1,7 @@
import { spawn } from 'node:child_process';
import type { ChildProcess } from 'node:child_process';
import { createReadStream } from 'node:fs';
import { cp, mkdir, readdir, rename, rm, writeFile } from 'node:fs/promises';
import * as path from 'path';
import { fileURLToPath } from 'url';
@@ -8,7 +12,7 @@ import react, { reactCompilerPreset } from '@vitejs/plugin-react';
import type { PreRenderedAsset } from 'rolldown';
import { visualizer } from 'rollup-plugin-visualizer';
/// <reference types="vitest" />
import { defineConfig, loadEnv } from 'vite';
import { build, defineConfig, loadEnv } from 'vite';
import type { Plugin } from 'vite';
import { VitePWA } from 'vite-plugin-pwa';
@@ -103,7 +107,163 @@ const injectShims = (): Plugin[] => {
// https://vitejs.dev/config/
export default defineConfig(async ({ mode }) => {
const lootCoreRoot = path.resolve(__dirname, '../loot-core');
const lootCoreOutDir = path.resolve(lootCoreRoot, 'lib-dist/browser');
const lootCoreConfig = path.resolve(lootCoreRoot, 'vite.config.mts');
const sqlWasmSrc = path.resolve(
__dirname,
'../../node_modules/@jlongster/sql.js/dist/sql-wasm.wasm',
);
const publicDir = path.resolve(__dirname, 'public');
const publicDataDir = path.resolve(publicDir, 'data');
const publicKcabDir = path.resolve(publicDir, 'kcab');
const buildStatsDir = path.resolve(__dirname, 'build-stats');
const pluginsServiceDistDir = path.resolve(
__dirname,
'../plugins-service/dist',
);
const serviceWorkerDir = path.resolve(__dirname, 'service-worker');
const WORKER_FILENAME_RE = /^kcab\.worker\.(.+)\.js$/;
async function extractWorkerHash(): Promise<string> {
const files = await readdir(lootCoreOutDir);
for (const f of files) {
const match = f.match(WORKER_FILENAME_RE);
if (match) return match[1];
}
throw new Error(
`loot-core worker build produced no hashed output at ${lootCoreOutDir}`,
);
}
// Serve loot-core worker assets with correct content types so the browser can
// stream-compile the sql.js wasm module.
const CONTENT_TYPES: Record<string, string> = {
'.js': 'application/javascript',
'.mjs': 'application/javascript',
'.map': 'application/json',
'.wasm': 'application/wasm',
};
async function stagePluginsService(): Promise<void> {
await rm(serviceWorkerDir, { recursive: true, force: true });
await cp(pluginsServiceDistDir, serviceWorkerDir, { recursive: true });
}
async function stagePublicData(): Promise<void> {
const migrationsDest = path.resolve(publicDataDir, 'migrations');
await mkdir(publicDataDir, { recursive: true });
await rm(migrationsDest, { recursive: true, force: true });
await Promise.all([
cp(path.resolve(lootCoreRoot, 'migrations'), migrationsDest, {
recursive: true,
}),
cp(
path.resolve(lootCoreRoot, 'default-db.sqlite'),
path.resolve(publicDataDir, 'default-db.sqlite'),
),
cp(sqlWasmSrc, path.resolve(publicDir, 'sql-wasm.wasm')),
]);
const entries = await readdir(publicDataDir, {
recursive: true,
withFileTypes: true,
});
const files = entries
.filter(e => e.isFile())
.map(e =>
path
.relative(publicDataDir, path.join(e.parentPath, e.name))
.replaceAll(path.sep, '/'),
)
.sort();
await writeFile(
path.resolve(publicDir, 'data-file-index.txt'),
files.join('\n') + '\n',
);
}
const lootCoreBackend = (): Plugin => ({
name: 'loot-core-backend',
configureServer(server) {
const child: ChildProcess = spawn(
'yarn',
[
'vite',
'build',
'--config',
lootCoreConfig,
'--mode',
'development',
'--watch',
],
{ cwd: lootCoreRoot, stdio: 'inherit' },
);
child.on('error', err => {
server.config.logger.error(
`loot-core backend failed to spawn: ${err.message}`,
);
});
const cleanup = () => {
if (!child.killed) child.kill('SIGTERM');
};
server.httpServer?.once('close', cleanup);
process.once('SIGINT', cleanup);
process.once('SIGTERM', cleanup);
process.once('exit', cleanup);
server.middlewares.use('/kcab', (req, res, next) => {
const url = new URL(req.url ?? '/', 'http://localhost');
const filePath = path.join(lootCoreOutDir, url.pathname);
if (!filePath.startsWith(lootCoreOutDir + path.sep)) return next();
const stream = createReadStream(filePath);
stream
.on('open', () => {
res.setHeader(
'Content-Type',
CONTENT_TYPES[path.extname(filePath)] ?? 'application/octet-stream',
);
stream.pipe(res);
})
.on('error', () => next());
});
},
async closeBundle() {
await mkdir(buildStatsDir, { recursive: true });
try {
await rename(
path.resolve(__dirname, 'build/kcab/stats.json'),
path.resolve(buildStatsDir, 'loot-core-stats.json'),
);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
}
},
});
const pluginsServiceAssets = (): Plugin => ({
name: 'plugins-service-assets',
configureServer(server) {
server.middlewares.use('/service-worker', (req, res, next) => {
const url = new URL(req.url ?? '/', 'http://localhost');
const filePath = path.join(pluginsServiceDistDir, url.pathname);
if (!filePath.startsWith(pluginsServiceDistDir + path.sep)) return next();
const stream = createReadStream(filePath);
stream
.on('open', () => {
res.setHeader(
'Content-Type',
CONTENT_TYPES[path.extname(filePath)] ?? 'application/octet-stream',
);
stream.pipe(res);
})
.on('error', () => next());
});
},
});
export default defineConfig(async ({ mode, command }) => {
const env = loadEnv(mode, process.cwd(), '');
const devHeaders = {
'Cross-Origin-Opener-Policy': 'same-origin',
@@ -116,6 +276,32 @@ export default defineConfig(async ({ mode }) => {
process.env.REACT_APP_BRANCH = process.env.BRANCH;
}
// Electron packaging (--mode=desktop) bundles loot-core directly, so skip
// all browser-only staging there.
if (mode !== 'desktop') {
if (command === 'build') {
const stageKcab = build({
configFile: lootCoreConfig,
mode: 'production',
root: lootCoreRoot,
}).then(async () => {
const hash = await extractWorkerHash();
await rm(publicKcabDir, { recursive: true, force: true });
await cp(lootCoreOutDir, publicKcabDir, { recursive: true });
return hash;
});
const [, , hash] = await Promise.all([
stagePublicData(),
stagePluginsService(),
stageKcab,
]);
process.env.REACT_APP_BACKEND_WORKER_HASH = hash;
} else {
await stagePublicData();
process.env.REACT_APP_BACKEND_WORKER_HASH = 'dev';
}
}
const browserOpen = env.BROWSER_OPEN ? `//${env.BROWSER_OPEN}` : true;
return {
@@ -213,13 +399,18 @@ export default defineConfig(async ({ mode }) => {
}),
injectShims(),
addWatchers(),
mode === 'desktop' ? undefined : lootCoreBackend(),
mode === 'desktop' ? undefined : pluginsServiceAssets(),
react(),
babel({
include: [reactCompilerInclude],
// n.b. Must be a string to ensure plugin resolution order. See https://github.com/actualbudget/actual/pull/5853
presets: [reactCompilerPreset()],
}),
visualizer({ template: 'raw-data' }),
visualizer({
template: 'raw-data',
filename: 'build-stats/web-stats.json',
}),
!!env.HTTPS && basicSsl(),
],
test: {

View File

@@ -1,40 +0,0 @@
#!/bin/bash -e
cd `dirname "$0"`
ROOT=`pwd -P`
VITE_ARGS=""
PUBLIC_DIR="$ROOT"/../../desktop-client/public
DATA_DIR="$PUBLIC_DIR"/data
mkdir -p "$DATA_DIR"
"$ROOT"/copy-migrations "$DATA_DIR"
cd "$DATA_DIR"
find * -type f | sort > ../data-file-index.txt
cd "$ROOT"
# Clean out previous build files
rm -f ../lib-dist/browser/*
rm -rf ../../desktop-client/public/kcab
if [ $NODE_ENV == 'development' ]; then
# In dev mode, always enable watch mode and symlink the build files.
# Make sure to do this before starting the build since watch mode
# will block
VITE_ARGS="$VITE_ARGS --watch"
if [ "$OSTYPE" == "msys" ]; then
# Ensure symlinks are created as native Windows symlinks.
export MSYS=winsymlinks:nativestrict
fi
ln -snf "$ROOT"/../lib-dist/browser ../../desktop-client/public/kcab
fi
cp ../../../node_modules/@jlongster/sql.js/dist/sql-wasm.wasm "$PUBLIC_DIR"/sql-wasm.wasm
yarn vite build --config ../vite.config.mts --mode $NODE_ENV $VITE_ARGS
if [ $NODE_ENV == 'production' ]; then
# In production, just copy the built files
mkdir ../../desktop-client/public/kcab
cp -r ../lib-dist/browser/* ../../desktop-client/public/kcab
fi

View File

@@ -1,9 +0,0 @@
#!/bin/bash -e
ROOT=`dirname $(dirname "$0")`
DEST="$1"
rm -rf "$DEST"/migrations
mkdir -p "$DEST"/migrations
cp "$ROOT"/migrations/* "$DEST"/migrations/
cp "$ROOT"/default-db.sqlite "$DEST"

View File

@@ -158,8 +158,6 @@
"scripts": {
"build:node": "cross-env NODE_ENV=production vite build --config ./vite.desktop.config.mts",
"watch:node": "cross-env NODE_ENV=development vite build --config ./vite.desktop.config.mts --watch",
"build:browser": "cross-env NODE_ENV=production ./bin/build-browser",
"watch:browser": "cross-env NODE_ENV=development ./bin/build-browser",
"generate:i18n": "i18next",
"test": "npm-run-all -cp 'test:*'",
"test:node": "ENV=node vitest --run",

View File

@@ -1,28 +0,0 @@
#!/bin/bash -e
cd `dirname "$0"`
ROOT=`pwd -P`
VITE_ARGS=""
DESKTOP_DIR="$ROOT"/../../desktop-client
SERVICE_WORKER_DIR="$DESKTOP_DIR"/service-worker
# Clean out previous build files
rm -f ../dist/*
rm -rf "$DESKTOP_DIR"/service-worker
if [ $NODE_ENV == 'development' ]; then
if [ "$OSTYPE" == "msys" ]; then
# Ensure symlinks are created as native Windows symlinks.
export MSYS=winsymlinks:nativestrict
fi
ln -snf "$ROOT"/../dist/ "$DESKTOP_DIR"/service-worker
fi
yarn vite build --config ../vite.config.mts --mode $NODE_ENV $VITE_ARGS
if [ $NODE_ENV == 'production' ]; then
# In production, just copy the built files
mkdir -p "$SERVICE_WORKER_DIR"
cp -r ../dist/* "$DESKTOP_DIR"/service-worker
fi

View File

@@ -6,9 +6,9 @@
"author": "",
"main": "plugin-sw.js",
"scripts": {
"build": "cross-env NODE_ENV=production ./bin/build-service-worker",
"build-dev": "cross-env NODE_ENV=development ./bin/build-service-worker",
"watch": "cross-env NODE_ENV=development ./bin/build-service-worker --watch",
"build": "cross-env NODE_ENV=production vite build",
"build-dev": "cross-env NODE_ENV=development vite build",
"watch": "cross-env NODE_ENV=development vite build --watch",
"typecheck": "tsgo -b && tsc-strict"
},
"dependencies": {

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [MatissJanis]
---
Build scripts: port browser build version to using lage

View File

@@ -264,6 +264,7 @@ __metadata:
mdast-util-newline-to-break: "npm:^2.0.0"
memoize-one: "npm:^6.0.0"
pikaday: "npm:1.8.2"
plugins-service: "workspace:*"
promise-retry: "npm:^2.0.1"
re-resizable: "npm:^6.11.2"
react: "npm:19.2.4"
@@ -22776,7 +22777,7 @@ __metadata:
languageName: node
linkType: hard
"plugins-service@workspace:packages/plugins-service":
"plugins-service@workspace:*, plugins-service@workspace:packages/plugins-service":
version: 0.0.0-use.local
resolution: "plugins-service@workspace:packages/plugins-service"
dependencies: