Compare commits

..

2 Commits

Author SHA1 Message Date
Julian Dominguez-Schatz
5af96588bb [AI] Accept API tokens in sync-server authentication 2026-04-28 01:40:14 -04:00
Julian Dominguez-Schatz
1b107c3b5e [AI] Add sync-server API token persistence 2026-04-28 01:18:44 -04:00
596 changed files with 6621 additions and 16352 deletions

View File

@@ -1,6 +1,6 @@
issue_enrichment:
auto_enrich:
enabled: true
enabled: false
reviews:
request_changes_workflow: true
review_status: false

View File

@@ -1,7 +1,7 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-docker-compose
{
"name": "Actual Devcontainer",
"name": "Actual development",
"dockerComposeFile": ["../docker-compose.yml", "docker-compose.yml"],
// Alternatively:
// "image": "mcr.microsoft.com/devcontainers/typescript-node:0-16",

View File

@@ -3,6 +3,9 @@ contact_links:
- name: Bank-sync issues
url: https://discord.gg/pRYNYr4W5A
about: Is bank-sync not working? Returning too much or too few information? Reach out to the community on Discord.
- name: Support
url: https://discord.gg/pRYNYr4W5A
about: Need help with something? Having troubles setting up? Or perhaps issues using the API? Reach out to the community on Discord.
- name: Translations
url: https://hosted.weblate.org/projects/actualbudget/actual/
about: Found a string that needs a better translation? Add your suggestion or upvote an existing one in Weblate.

View File

@@ -1,17 +0,0 @@
name: Tech Support
description: Need help with something? Having troubles setting up? Or perhaps issues using the API?
title: '[Support]: '
labels: ['tech-support']
body:
- type: markdown
attributes:
value: |
> ⚠️ **Tech support tickets opened here are automatically closed.** GitHub Issues are reserved for bug reports and feature requests. The fastest way to get help is to ask the community on [Discord](https://discord.gg/pRYNYr4W5A) — that's where most of the community lives and can help you in real time.
- type: textarea
id: problem
attributes:
label: Describe your problem
description: Please describe, in as much detail as you can, what you need help with.
placeholder: I'm trying to [...] but [...]
validations:
required: true

View File

@@ -1,4 +1,4 @@
<!-- Thank you for submitting a pull request! Make sure to follow the instructions to write release notes for your PR — it should only take a minute or two: https://actualbudget.org/docs/contributing/#writing-good-release-notes. Try running yarn generate:release-notes *before* pushing your PR for an interactive experience. -->
<!-- Thank you for submitting a pull request! Make sure to follow the instructions to write release notes for your PR — it should only take a minute or two: https://github.com/actualbudget/docs#writing-good-release-notes. Try running yarn generate:release-notes *before* pushing your PR for an interactive experience. -->
## Description

View File

@@ -1,16 +1,13 @@
# See https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples:-excludes
(?:^|/)(?i).nojekyll
(?:^|/)(?i)COPYRIGHT
(?:^|/)(?i)docusaurus.config.js
(?:^|/)(?i)LICEN[CS]E
(?:^|/)(?i)README.md
(?:^|/)3rdparty/
(?:^|/)go\.sum$
(?:^|/)package(?:-lock|)\.json$
(?:^|/)pyproject.toml
(?:^|/)requirements(?:-dev|-doc|-test|)\.txt$
(?:^|/)vendor/
(?:^|/)yarn\.lock$
ignore$
\.a$
\.ai$
\.avi$
@@ -56,7 +53,6 @@
\.svgz?$
\.tar$
\.tiff?$
\.tsx$
\.ttf$
\.wav$
\.webm$
@@ -66,12 +62,15 @@
\.zip$
^\.github/actions/spelling/
^\.github/ISSUE_TEMPLATE/
^\.yarn/
^\Q.github/\E$
^\Q.github/workflows/spelling.yml\E$
^\.yarn/
^\Qnode_modules/\E$
^\Qsrc/\E$
^\Qstatic/\E$
^\Q.github/\E$
(?:^|/)yarn\.lock$
(?:^|/)(?i)docusaurus.config.js
(?:^|/)(?i)README.md
(?:^|/)(?i).nojekyll
^\static/
^packages/docs/docs/releases\.md$
ignore$
\.tsx$

View File

@@ -38,13 +38,10 @@ Cetelem
cimode
Citi
Citibank
claude
Cloudflare
CLP
CMCIFRPAXXX
COBADEFF
CODEOWNERS
Codespaces
COEP
commerzbank
Copiar
@@ -56,7 +53,6 @@ crt
CZK
Danske
datadir
datamodel
DATEDIF
Depositos
deselection
@@ -86,7 +82,6 @@ Globecard
GLS
gocardless
Grafana
Gruvbox
HABAL
Hampel
HELADEF
@@ -94,7 +89,6 @@ HLOOKUP
HUF
IFERROR
IFNA
Ilavenil
INDUSTRIEL
INGBPLPW
Ingo
@@ -133,7 +127,6 @@ murmurhash
NETWORKDAYS
nginx
nodenext
nord
OIDC
Okabe
overbudgeted
@@ -147,13 +140,14 @@ pluggyai
Poste
PPABPLPK
prefs
Primoco
Priotecs
proactively
Qatari
QNTOFRP
QONTO
Raiffeisen
REGEXREPLACE
relinking
revolut
RIED
RSchedule
@@ -178,6 +172,7 @@ SWEDBANK
SWEDNOKK
Synology
systemctl
tada
taskbar
templating
THB
@@ -185,7 +180,6 @@ TIMEFRAME
touchscreen
triaging
tsgo
tsgolint
TWD
UAH
ubuntu
@@ -201,6 +195,4 @@ websecure
WEEKNUM
Widiba
WOR
worktree
youngcw
zizmor

View File

@@ -10,10 +10,6 @@ inputs:
description: 'Whether to download translations as part of setup, default true'
required: false
default: 'true'
cache:
description: 'Whether to restore and save dependency and Lage caches, default true'
required: false
default: 'true'
runs:
using: composite
@@ -22,7 +18,6 @@ runs:
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
package-manager-cache: ${{ inputs.cache }}
- name: Install yarn
run: npm install -g yarn
shell: bash
@@ -33,7 +28,6 @@ runs:
shell: bash
- name: Cache
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
if: ${{ inputs.cache == 'true' }}
id: cache
with:
path: ${{ format('{0}/**/node_modules', inputs.working-directory) }}
@@ -43,7 +37,6 @@ runs:
shell: bash
- name: Cache Lage
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
if: ${{ inputs.cache == 'true' }}
with:
path: ${{ format('{0}/.lage', inputs.working-directory) }}
key: lage-${{ runner.os }}-${{ github.sha }}

View File

@@ -1,27 +0,0 @@
name: Add 'AI generated' label to '[AI]' PRs
##########################################################################################
# This workflow uses the 'pull_request_target' event so it has a token that can add a #
# label to PRs from forks. It does NOT check out or execute any code from the PR, so it #
# is not vulnerable to the usual 'pull_request_target' code-injection concerns. Keep it #
# that way - do not add a checkout step or run any PR-provided scripts here. #
##########################################################################################
on:
# This workflow never checks out or runs PR code; it only reads the PR title and adds a label.
pull_request_target: # zizmor: ignore[dangerous-triggers]
types: [opened, reopened, edited]
permissions:
pull-requests: write
jobs:
add-ai-generated-label:
name: Add 'AI generated' label
runs-on: ubuntu-latest
if: startsWith(github.event.pull_request.title, '[AI]')
steps:
- uses: actions-ecosystem/action-add-labels@bd52874380e3909a1ac983768df6976535ece7f8 # v1.1.0
with:
labels: AI generated
github_token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -14,9 +14,6 @@ on:
pull_request:
merge_group:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}

View File

@@ -7,9 +7,6 @@ on:
pull_request:
merge_group:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}

View File

@@ -11,11 +11,6 @@ on:
required: true
type: string
permissions:
contents: read
issues: read
pull-requests: read
jobs:
count-points:
runs-on: ubuntu-latest

View File

@@ -1,48 +0,0 @@
name: CRDT version bump check
on:
pull_request:
paths:
- 'packages/crdt/**'
permissions:
contents: read
jobs:
check-version-bump:
runs-on: ubuntu-latest
name: Ensure @actual-app/crdt version is bumped
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: false
- name: Verify version bump
env:
BASE_REF: ${{ github.base_ref }}
run: |
set -euo pipefail
if ! git cat-file -e "origin/${BASE_REF}:packages/crdt/package.json" 2>/dev/null; then
echo "packages/crdt/package.json does not exist on the base branch; skipping."
exit 0
fi
BASE_VERSION=$(git show "origin/${BASE_REF}:packages/crdt/package.json" | jq -r .version)
HEAD_VERSION=$(jq -r .version packages/crdt/package.json)
echo "Base version: $BASE_VERSION"
echo "Head version: $HEAD_VERSION"
if [ "$BASE_VERSION" = "$HEAD_VERSION" ]; then
echo "::error file=packages/crdt/package.json::Files in packages/crdt/ were modified but the @actual-app/crdt version was not bumped. Please update the \"version\" field in packages/crdt/package.json."
exit 1
fi
HIGHEST=$(printf '%s\n%s\n' "$BASE_VERSION" "$HEAD_VERSION" | sort -V | tail -n1)
if [ "$HIGHEST" != "$HEAD_VERSION" ]; then
echo "::error file=packages/crdt/package.json::The @actual-app/crdt version ($HEAD_VERSION) must be greater than the base version ($BASE_VERSION)."
exit 1
fi
echo "Version bumped from $BASE_VERSION to $HEAD_VERSION."

View File

@@ -26,7 +26,6 @@ permissions:
jobs:
cut-release-branch:
runs-on: ubuntu-latest
environment: release
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -37,8 +36,6 @@ jobs:
- name: Set up environment
uses: ./.github/actions/setup
with:
# Avoid restoring potentially poisoned caches in release jobs.
cache: 'false'
download-translations: 'false'
- name: Bump package versions

View File

@@ -32,7 +32,6 @@ jobs:
if: github.event_name == 'workflow_dispatch' || !github.event.repository.fork
name: Build Docker image
runs-on: ubuntu-latest
environment: release
strategy:
matrix:
os: [ubuntu, alpine]
@@ -75,9 +74,6 @@ jobs:
# This is faster and avoids yarn memory issues
- name: Set up environment
uses: ./.github/actions/setup
with:
# Avoid restoring potentially poisoned caches in release jobs.
cache: 'false'
- name: Build Web
run: yarn build:server
@@ -91,15 +87,10 @@ jobs:
tags: actualbudget/actual-server-testing
- name: Test that the docker image boots
timeout-minutes: 1
run: |
docker run --detach --network=host --name actual-server actualbudget/actual-server-testing
HEALTHCMD=$(yq -r '.services.actual_server.healthcheck.test[1]' packages/sync-server/docker-compose.yml)
until docker exec actual-server sh -c "$HEALTHCMD"; do sleep 1; done
- name: Dump container logs on failure
if: failure()
run: docker logs actual-server || true
docker run --detach --network=host actualbudget/actual-server-testing
sleep 10
curl --fail -sS -LI -w '%{http_code}\n' --retry 20 --retry-delay 1 --retry-connrefused localhost:5006
# This will use the cache from the earlier build step and not rebuild the image
# https://docs.docker.com/build/ci/github-actions/test-before-push/

View File

@@ -23,15 +23,10 @@ env:
TAGS: |
type=semver,pattern={{version}}
permissions:
contents: read
packages: write
jobs:
build:
name: Build Docker image
runs-on: ubuntu-latest
environment: release
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -81,29 +76,9 @@ jobs:
# This is faster and avoids yarn memory issues
- name: Set up environment
uses: ./.github/actions/setup
with:
# Avoid restoring potentially poisoned caches in release jobs.
cache: 'false'
- name: Build Web
run: yarn build:server
- name: Build ubuntu image for testing
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
push: false
load: true
file: packages/sync-server/docker/ubuntu.Dockerfile
tags: actualbudget/actual-server-testing
- name: Test that the ubuntu image boots
timeout-minutes: 1
run: |
docker rm -f actual-server 2>/dev/null || true
docker run --detach --network=host --name actual-server actualbudget/actual-server-testing
HEALTHCMD=$(yq -r '.services.actual_server.healthcheck.test[1]' packages/sync-server/docker-compose.yml)
until docker exec actual-server sh -c "$HEALTHCMD"; do sleep 1; done
- name: Build and push ubuntu image
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
@@ -113,23 +88,6 @@ jobs:
platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: ${{ steps.meta.outputs.tags }}
- name: Build alpine image for testing
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
push: false
load: true
file: packages/sync-server/docker/alpine.Dockerfile
tags: actualbudget/actual-server-testing
- name: Test that the alpine image boots
timeout-minutes: 1
run: |
docker rm -f actual-server 2>/dev/null || true
docker run --detach --network=host --name actual-server actualbudget/actual-server-testing
HEALTHCMD=$(yq -r '.services.actual_server.healthcheck.test[1]' packages/sync-server/docker-compose.yml)
until docker exec actual-server sh -c "$HEALTHCMD"; do sleep 1; done
- name: Build and push alpine image
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
@@ -138,7 +96,3 @@ jobs:
file: packages/sync-server/docker/alpine.Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6
tags: ${{ steps.alpine-meta.outputs.tags }}
- name: Dump container logs on failure
if: failure()
run: docker logs actual-server || true

View File

@@ -46,12 +46,13 @@ 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 build:browser --skip-translations
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
- name: Upload build artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
@@ -199,13 +200,11 @@ jobs:
if: github.event_name == 'pull_request'
run: |
mkdir -p vrt-metadata
echo "${PR_NUMBER}" > vrt-metadata/pr-number.txt
echo "${VRT_RESULT}" > vrt-metadata/vrt-result.txt
echo "${{ github.event.pull_request.number }}" > vrt-metadata/pr-number.txt
echo "${{ needs.vrt.result }}" > vrt-metadata/vrt-result.txt
echo "${STEPS_PLAYWRIGHT_REPORT_VRT_OUTPUTS_ARTIFACT_URL}" > vrt-metadata/artifact-url.txt
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
STEPS_PLAYWRIGHT_REPORT_VRT_OUTPUTS_ARTIFACT_URL: ${{ steps.playwright-report-vrt.outputs.artifact-url }}
VRT_RESULT: ${{ needs.vrt.result }}
- name: Upload VRT metadata
if: github.event_name == 'pull_request'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1

View File

@@ -21,7 +21,6 @@ jobs:
# this is so the assets can be added to the release
permissions:
contents: write
environment: release
strategy:
fail-fast: false
matrix:
@@ -67,9 +66,6 @@ jobs:
STEPS_PROCESS_VERSION_OUTPUTS_VERSION: ${{ steps.process_version.outputs.version }}
- name: Set up environment
uses: ./.github/actions/setup
with:
# Avoid restoring potentially poisoned caches in release jobs.
cache: 'false'
- name: Build Electron for Mac
if: ${{ startsWith(matrix.os, 'macos') }}
run: ./bin/package-electron
@@ -120,7 +116,48 @@ jobs:
!packages/desktop-electron/dist/Actual-windows.exe
packages/desktop-electron/dist/*.AppImage
packages/desktop-electron/dist/*.flatpak
packages/desktop-electron/dist/*.appx
outputs:
version: ${{ steps.process_version.outputs.version }}
publish-microsoft-store:
needs: build
runs-on: windows-latest
if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }}
steps:
- name: Install StoreBroker
shell: powershell
run: |
Install-Module -Name StoreBroker -AcceptLicense -Force -Scope CurrentUser -Verbose
- name: Download Microsoft Store artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: actual-electron-windows-latest-appx
- name: Submit to Microsoft Store
shell: powershell
run: |
# Disable telemetry
$global:SBDisableTelemetry = $true
# Authenticate against the store
$pass = ConvertTo-SecureString -String '${{ secrets.MICROSOFT_STORE_CLIENT_SECRET }}' -AsPlainText -Force
$cred = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList ${{ secrets.MICROSOFT_STORE_CLIENT_ID }},$pass
Set-StoreBrokerAuthentication -TenantId '${{ secrets.MICROSOFT_STORE_TENANT_ID }}' -Credential $cred
# Zip and create metadata files
$artifacts = Get-ChildItem -Path . -Filter *.appx | Select-Object -ExpandProperty FullName
New-StoreBrokerConfigFile -Path "$PWD/config.json" -AppId ${{ secrets.MICROSOFT_STORE_PRODUCT_ID }}
New-SubmissionPackage -ConfigPath "$PWD/config.json" -DisableAutoPackageNameFormatting -AppxPath $artifacts -OutPath "$PWD" -OutName submission
# Submit the app
# See https://github.com/microsoft/StoreBroker/blob/master/Documentation/USAGE.md#the-easy-way
Update-ApplicationSubmission `
-AppId ${{ secrets.MICROSOFT_STORE_PRODUCT_ID }} `
-SubmissionDataPath "submission.json" `
-PackagePath "submission.zip" `
-ReplacePackages `
-NoStatus `
-AutoCommit `
-Force

View File

@@ -19,9 +19,6 @@ on:
- '!packages/docs/**' # Docs changes don't affect Electron
- '!packages/eslint-plugin-actual/**' # Eslint plugin changes don't affect Electron
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true

View File

@@ -6,9 +6,6 @@ on:
- cron: '0 4 * * *'
workflow_dispatch:
permissions:
contents: read
jobs:
extract-and-upload-i18n-strings:
runs-on: ubuntu-latest

View File

@@ -4,9 +4,6 @@ on:
issues:
types: [labeled]
permissions:
issues: write
jobs:
needs-votes:
if: ${{ github.event.label.name == 'feature' }}

View File

@@ -1,26 +0,0 @@
name: Close tech support issues with automated message
on:
issues:
types: [labeled]
permissions:
issues: write
jobs:
tech-support:
if: ${{ github.event.label.name == 'tech-support' }}
runs-on: ubuntu-latest
steps:
- name: Create comment and close issue
run: |
gh issue comment "$ISSUE_URL" --body ":wave: Thanks for reaching out!
GitHub Issues are reserved for bug reports and feature requests, so tech support tickets are automatically closed. The fastest way to get help is to ask the community on [Discord](https://discord.gg/pRYNYr4W5A) — that's where most of the community lives and can help you in real time.
<!-- tech-support-auto-close-comment -->"
gh issue close "$ISSUE_URL"
env:
ISSUE_URL: https://github.com/actualbudget/actual/issues/${{ github.event.issue.number }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -4,9 +4,6 @@ on:
issues:
types: [closed]
permissions:
issues: write
jobs:
remove-help-wanted:
if: ${{ !contains(github.event.issue.labels.*.name, 'feature') && contains(github.event.issue.labels.*.name, 'help wanted') }}

View File

@@ -0,0 +1,37 @@
# When the "unfreeze" label is added to a PR, add that PR to Merge Freeze's unblocked list
# so it can be merged during a freeze. Uses pull_request_target so the workflow runs in
# the base repo and has access to MERGEFREEZE_ACCESS_TOKEN for fork PRs; it does not
# checkout or run any PR code. Requires MERGEFREEZE_ACCESS_TOKEN repo secret
# (project-specific token from Merge Freeze Web API panel for actualbudget/actual / master).
# See: https://docs.mergefreeze.com/web-api#post-freeze-status
name: Merge Freeze add PR to unblocked list
on:
pull_request_target:
types: [labeled]
jobs:
unfreeze:
if: ${{ github.event.label.name == 'unfreeze' }}
runs-on: ubuntu-latest
permissions: {}
concurrency:
group: merge-freeze-unfreeze-${{ github.ref }}-labels
cancel-in-progress: false
steps:
- name: POST to Merge Freeze add PR to unblocked list
env:
MERGEFREEZE_ACCESS_TOKEN: ${{ secrets.MERGEFREEZE_ACCESS_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
USER_NAME: ${{ github.actor }}
run: |
set -e
if [ -z "$MERGEFREEZE_ACCESS_TOKEN" ]; then
echo "::error::MERGEFREEZE_ACCESS_TOKEN secret is not set"
exit 1
fi
url="https://www.mergefreeze.com/api/branches/actualbudget/actual/master/?access_token=${MERGEFREEZE_ACCESS_TOKEN}"
payload=$(jq -n --arg user_name "$USER_NAME" --argjson pr "$PR_NUMBER" '{frozen: true, user_name: $user_name, unblocked_prs: [$pr]}')
curl -sf -X POST "$url" -H "Content-Type: application/json" -d "$payload"
echo "Merge Freeze updated: PR #$PR_NUMBER added to unblocked list."

View File

@@ -12,9 +12,6 @@ on:
tags:
- v**
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
@@ -22,7 +19,6 @@ concurrency:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
environment: release
steps:
- name: Repository Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -31,9 +27,6 @@ jobs:
- name: Set up environment
uses: ./.github/actions/setup
with:
# Avoid restoring potentially poisoned caches in release jobs.
cache: 'false'
- name: Install Netlify
run: npm install netlify-cli@17.10.1 -g

View File

@@ -1,86 +0,0 @@
name: Publish @actual-app/crdt
# Automatically publishes @actual-app/crdt when its package.json version
# changes on master (typically via a merged PR that bumped the version).
on:
push:
branches:
- master
paths:
- 'packages/crdt/package.json'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: publish-crdt
cancel-in-progress: false
jobs:
check-version:
runs-on: ubuntu-latest
name: Check if publish is needed
outputs:
should-publish: ${{ steps.check.outputs.should-publish }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Compare local version against npm registry
id: check
run: |
set -euo pipefail
LOCAL_VERSION=$(jq -r .version packages/crdt/package.json)
echo "Local version: $LOCAL_VERSION"
PUBLISHED_VERSION=$(npm view @actual-app/crdt version 2>/dev/null || echo "")
echo "Published version: ${PUBLISHED_VERSION:-<none>}"
if [ "$LOCAL_VERSION" = "$PUBLISHED_VERSION" ]; then
echo "Versions match - nothing to publish."
echo "should-publish=false" >> "$GITHUB_OUTPUT"
else
echo "Version changed - publish required."
echo "should-publish=true" >> "$GITHUB_OUTPUT"
fi
publish:
needs: check-version
if: needs.check-version.outputs.should-publish == 'true'
runs-on: ubuntu-latest
name: Publish @actual-app/crdt to npm
permissions:
contents: read
id-token: write # Required for npm OIDC provenance
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment
uses: ./.github/actions/setup
with:
# Avoid restoring potentially poisoned caches in release jobs.
cache: 'false'
download-translations: 'false'
- name: Build @actual-app/crdt
run: yarn workspace @actual-app/crdt build
- name: Pack @actual-app/crdt
run: yarn workspace @actual-app/crdt pack --filename @actual-app/crdt.tgz
- name: Setup node and npm registry
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 24
check-latest: true
# Avoid restoring potentially poisoned caches in release jobs.
package-manager-cache: false
registry-url: 'https://registry.npmjs.org'
- name: Publish to npm
run: npm publish packages/crdt/@actual-app/crdt.tgz --access public --provenance

View File

@@ -18,13 +18,9 @@ concurrency:
group: publish-flathub
cancel-in-progress: false
permissions:
contents: read
jobs:
publish-flathub:
runs-on: ubuntu-22.04
environment: release
steps:
- name: Resolve version
id: resolve_version

View File

@@ -1,116 +0,0 @@
name: Publish Microsoft Store
defaults:
run:
shell: bash
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: 'Release tag (e.g. v25.3.0)'
required: true
type: string
concurrency:
group: publish-microsoft-store
cancel-in-progress: false
permissions:
contents: read
jobs:
publish-microsoft-store:
runs-on: windows-latest
environment: release
steps:
- name: Resolve version
id: resolve_version
env:
EVENT_NAME: ${{ github.event_name }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
INPUT_TAG: ${{ inputs.tag }}
run: |
if [[ "$EVENT_NAME" == "release" ]]; then
TAG="$RELEASE_TAG"
else
TAG="$INPUT_TAG"
fi
if [[ -z "$TAG" ]]; then
echo "::error::No tag provided"
exit 1
fi
# Validate tag format (v-prefixed semver, e.g. v25.3.0 or v1.2.3-beta.1)
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then
echo "::error::Invalid tag format: $TAG (expected v-prefixed semver, e.g. v25.3.0)"
exit 1
fi
VERSION="${TAG#v}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Resolved tag=$TAG version=$VERSION"
- name: Verify release assets exist
env:
GH_TOKEN: ${{ github.token }}
STEPS_RESOLVE_VERSION_OUTPUTS_TAG: ${{ steps.resolve_version.outputs.tag }}
run: |
TAG="${STEPS_RESOLVE_VERSION_OUTPUTS_TAG}"
echo "Checking release assets for tag $TAG..."
ASSETS=$(gh api "repos/${{ github.repository }}/releases/tags/$TAG" --jq '.assets[].name')
echo "Found assets:"
echo "$ASSETS"
if ! echo "$ASSETS" | grep -q "\.appx$"; then
echo "::error::No .appx assets found in release $TAG"
exit 1
fi
echo "Required .appx assets found."
- name: Download Microsoft Store artifacts
env:
GH_TOKEN: ${{ github.token }}
STEPS_RESOLVE_VERSION_OUTPUTS_TAG: ${{ steps.resolve_version.outputs.tag }}
run: |
TAG="${STEPS_RESOLVE_VERSION_OUTPUTS_TAG}"
gh release download "$TAG" --repo "${{ github.repository }}" --pattern "*.appx"
- name: Install StoreBroker
shell: powershell
run: |
Install-Module -Name StoreBroker -AcceptLicense -Force -Scope CurrentUser -Verbose
- name: Submit to Microsoft Store
shell: powershell
run: |
# Disable telemetry
$global:SBDisableTelemetry = $true
# Authenticate against the store
$pass = ConvertTo-SecureString -String '${{ secrets.MICROSOFT_STORE_CLIENT_SECRET }}' -AsPlainText -Force
$cred = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList ${{ secrets.MICROSOFT_STORE_CLIENT_ID }},$pass
Set-StoreBrokerAuthentication -TenantId '${{ secrets.MICROSOFT_STORE_TENANT_ID }}' -Credential $cred
# Zip and create metadata files
$artifacts = Get-ChildItem -Path . -Filter *.appx | Select-Object -ExpandProperty FullName
New-StoreBrokerConfigFile -Path "$PWD/config.json" -AppId ${{ secrets.MICROSOFT_STORE_PRODUCT_ID }}
New-SubmissionPackage -ConfigPath "$PWD/config.json" -DisableAutoPackageNameFormatting -AppxPath $artifacts -OutPath "$PWD" -OutName submission
# Submit the app
# See https://github.com/microsoft/StoreBroker/blob/master/Documentation/USAGE.md#the-easy-way
Update-ApplicationSubmission `
-AppId ${{ secrets.MICROSOFT_STORE_PRODUCT_ID }} `
-SubmissionDataPath "submission.json" `
-PackagePath "submission.zip" `
-ReplacePackages `
-NoStatus `
-AutoCommit `
-Force

View File

@@ -13,9 +13,6 @@ defaults:
env:
CI: true
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
@@ -30,7 +27,6 @@ jobs:
- windows-latest
- macos-latest
runs-on: ${{ matrix.os }}
environment: release
if: github.event.repository.fork == false
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -48,9 +44,6 @@ jobs:
- name: Set up environment
uses: ./.github/actions/setup
with:
# Avoid restoring potentially poisoned caches in release jobs.
cache: 'false'
- if: ${{ startsWith(matrix.os, 'ubuntu') }}
name: Setup Flatpak dependencies

View File

@@ -9,9 +9,6 @@ on:
- cron: '0 0 * * *'
workflow_dispatch:
permissions:
contents: read
jobs:
build-and-pack:
runs-on: ubuntu-latest
@@ -24,9 +21,6 @@ jobs:
- name: Set up environment
uses: ./.github/actions/setup
with:
# Avoid restoring potentially poisoned caches in release jobs.
cache: 'false'
- name: Update package versions
if: github.event_name != 'push'
@@ -93,7 +87,6 @@ jobs:
runs-on: ubuntu-latest
name: Publish npm packages
needs: build-and-pack
environment: release
permissions:
contents: read
packages: write
@@ -111,8 +104,6 @@ jobs:
with:
node-version: 24
check-latest: true
# Avoid restoring potentially poisoned caches in release jobs.
package-manager-cache: false
registry-url: 'https://registry.npmjs.org'
- name: Publish Core

View File

@@ -33,7 +33,6 @@ jobs:
permissions:
pull-requests: write
contents: read
actions: read
steps:
- name: Checkout base branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -45,120 +44,140 @@ jobs:
with:
download-translations: 'false'
# Resolve one successful `build.yml` run for each side (master and PR
# head) up front, then pin every download below to its `run_id`. This
# ensures artifact downloads are consistent and prevents race conditions.
- name: Resolve build runs
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
id: build-runs
env:
BASE_REF: ${{ github.base_ref }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
- name: Wait for ${{github.base_ref}} web build to succeed
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
id: master-web-build
with:
script: |
const TIMEOUT_MS = 30 * 60 * 1000;
const SLEEP_MS = 15000;
token: ${{ secrets.GITHUB_TOKEN }}
checkName: web
ref: ${{github.base_ref}}
- name: Wait for ${{github.base_ref}} API build to succeed
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
id: master-api-build
with:
token: ${{ secrets.GITHUB_TOKEN }}
checkName: api
ref: ${{github.base_ref}}
- name: Wait for ${{github.base_ref}} CLI build to succeed
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
id: master-cli-build
with:
token: ${{ secrets.GITHUB_TOKEN }}
checkName: cli
ref: ${{github.base_ref}}
- name: Wait for ${{github.base_ref}} CRDT build to succeed
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
id: master-crdt-build
with:
token: ${{ secrets.GITHUB_TOKEN }}
checkName: crdt
ref: ${{github.base_ref}}
async function resolveRun({ label, filter, notFoundHint }) {
const deadline = Date.now() + TIMEOUT_MS;
while (true) {
const { data } = await github.rest.actions.listWorkflowRuns({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'build.yml',
...filter,
status: 'success',
per_page: 1,
});
if (data.workflow_runs.length > 0) {
const run = data.workflow_runs[0];
core.info(`Found ${label} build run ${run.id} (${run.html_url})`);
return run.id;
}
if (Date.now() > deadline) {
throw new Error(
`No successful build.yml run found for ${label} within 30 min — ${notFoundHint}.`,
);
}
core.info(`No successful ${label} build run yet — sleeping 15s.`);
await new Promise(r => setTimeout(r, SLEEP_MS));
}
}
- name: Wait for PR build to succeed
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
id: wait-for-web-build
with:
token: ${{ secrets.GITHUB_TOKEN }}
checkName: web
ref: ${{github.event.pull_request.head.sha}}
- name: Wait for API PR build to succeed
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
id: wait-for-api-build
with:
token: ${{ secrets.GITHUB_TOKEN }}
checkName: api
ref: ${{github.event.pull_request.head.sha}}
- name: Wait for CLI PR build to succeed
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
id: wait-for-cli-build
with:
token: ${{ secrets.GITHUB_TOKEN }}
checkName: cli
ref: ${{github.event.pull_request.head.sha}}
- name: Wait for CRDT PR build to succeed
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
id: wait-for-crdt-build
with:
token: ${{ secrets.GITHUB_TOKEN }}
checkName: crdt
ref: ${{github.event.pull_request.head.sha}}
const baseRef = process.env.BASE_REF;
const headSha = process.env.HEAD_SHA;
const [masterRunId, headRunId] = await Promise.all([
resolveRun({
label: baseRef,
filter: { branch: baseRef },
notFoundHint: `${baseRef} may be broken`,
}),
resolveRun({
label: `PR head ${headSha}`,
filter: { head_sha: headSha },
notFoundHint:
'build may still be running, have failed, or the branch may have been force-pushed',
}),
]);
core.setOutput('master_run_id', masterRunId);
core.setOutput('head_run_id', headRunId);
- name: Report build failure
if: steps.wait-for-web-build.outputs.conclusion == 'failure' || steps.wait-for-api-build.outputs.conclusion == 'failure' || steps.wait-for-cli-build.outputs.conclusion == 'failure' || steps.wait-for-crdt-build.outputs.conclusion == 'failure'
run: |
echo "Build failed on PR branch or ${GITHUB_BASE_REF}"
exit 1
- name: Download web build artifact from ${{github.base_ref}}
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
id: pr-web-build
with:
run_id: ${{ steps.build-runs.outputs.master_run_id }}
branch: ${{github.base_ref}}
workflow: build.yml
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
name: build-stats
path: base
- name: Download API build artifact from ${{github.base_ref}}
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
id: pr-api-build
with:
run_id: ${{ steps.build-runs.outputs.master_run_id }}
branch: ${{github.base_ref}}
workflow: build.yml
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
name: api-build-stats
path: base
- name: Download build stats from PR
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
with:
run_id: ${{ steps.build-runs.outputs.head_run_id }}
pr: ${{github.event.pull_request.number}}
workflow: build.yml
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
name: build-stats
path: head
allow_forks: true
- name: Download API stats from PR
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
with:
run_id: ${{ steps.build-runs.outputs.head_run_id }}
pr: ${{github.event.pull_request.number}}
workflow: build.yml
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
name: api-build-stats
path: head
allow_forks: true
- name: Download CLI build artifact from ${{github.base_ref}}
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
with:
run_id: ${{ steps.build-runs.outputs.master_run_id }}
branch: ${{github.base_ref}}
workflow: build.yml
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
name: cli-build-stats
path: base
- name: Download CLI stats from PR
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
with:
run_id: ${{ steps.build-runs.outputs.head_run_id }}
pr: ${{github.event.pull_request.number}}
workflow: build.yml
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
name: cli-build-stats
path: head
allow_forks: true
- name: Download CRDT build artifact from ${{github.base_ref}}
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
with:
run_id: ${{ steps.build-runs.outputs.master_run_id }}
branch: ${{github.base_ref}}
workflow: build.yml
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
name: crdt-build-stats
path: base
- name: Download CRDT stats from PR
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
with:
run_id: ${{ steps.build-runs.outputs.head_run_id }}
pr: ${{github.event.pull_request.number}}
workflow: build.yml
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
name: crdt-build-stats
path: head
allow_forks: true
- name: Strip content hashes from stats files
run: |
if [ -f ./head/web-stats.json ]; then

View File

@@ -75,12 +75,9 @@ jobs:
echo "Found patch file: $PATCH_FILE"
# Validate patch only contains PNG files. `git format-patch` emits a
# `GIT binary patch` block for PNGs (no +++/--- lines), so check
# `diff --git` headers — those are present for both text and binary.
# Validate patch only contains PNG files
echo "Validating patch contains only PNG files..."
if grep -E '^diff --git ' "$PATCH_FILE" \
| grep -vE '^diff --git a/[^[:space:]]+\.png b/[^[:space:]]+\.png$'; then
if grep -E '^(\+\+\+|---) [ab]/' "$PATCH_FILE" | grep -v '\.png$'; then
echo "ERROR: Patch contains non-PNG files! Rejecting for security."
echo "applied=false" >> "$GITHUB_OUTPUT"
echo "error=Patch validation failed: contains non-PNG files" >> "$GITHUB_OUTPUT"
@@ -88,7 +85,7 @@ jobs:
fi
# Extract file list for verification
FILES_CHANGED=$(grep -cE '^diff --git ' "$PATCH_FILE")
FILES_CHANGED=$(grep -E '^\+\+\+ b/' "$PATCH_FILE" | sed 's/^+++ b\///' | wc -l)
echo "Patch modifies $FILES_CHANGED PNG file(s)"
# Configure git

View File

@@ -36,16 +36,15 @@ jobs:
content: 'eyes'
});
get-pr:
name: Resolve PR details
generate-vrt-updates:
name: Generate VRT Updates
runs-on: ubuntu-latest
# Only run on PR comments containing /update-vrt
if: >
github.event.issue.pull_request &&
startsWith(github.event.comment.body, '/update-vrt')
outputs:
head_sha: ${{ steps.pr.outputs.head_sha }}
head_ref: ${{ steps.pr.outputs.head_ref }}
head_repo: ${{ steps.pr.outputs.head_repo }}
container:
image: mcr.microsoft.com/playwright:v1.59.1-jammy
steps:
- name: Get PR details
id: pr
@@ -61,132 +60,11 @@ jobs:
core.setOutput('head_ref', pr.head.ref);
core.setOutput('head_repo', pr.head.repo.full_name);
build-web:
name: Build web bundle
runs-on: ubuntu-latest
needs: get-pr
container:
image: mcr.microsoft.com/playwright:v1.59.1-jammy
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ needs.get-pr.outputs.head_sha }}
ref: ${{ steps.pr.outputs.head_sha }}
persist-credentials: false
- name: Trust workspace directory
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
shell: bash
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Build browser bundle
# REACT_APP_NETLIFY=true flips isNonProductionEnvironment() on in the
# bundle so the "Create test file" button (used by every e2e beforeEach
# 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 build:browser --skip-translations
- name: Upload build artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: desktop-client-build
path: packages/desktop-client/build/
retention-days: 1
overwrite: true
browser-vrt:
name: Browser VRT (shard ${{ matrix.shard }}/3)
runs-on: ubuntu-latest
needs: [get-pr, build-web]
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3]
container:
image: mcr.microsoft.com/playwright:v1.59.1-jammy
env:
E2E_USE_BUILD: '1'
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ needs.get-pr.outputs.head_sha }}
persist-credentials: false
- name: Trust workspace directory
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
shell: bash
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Download web build
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: desktop-client-build
path: packages/desktop-client/build/
- name: Run VRT Tests
continue-on-error: true
run: yarn vrt --update-snapshots --shard=${{ matrix.shard }}/3
- name: Create shard patch with PNG changes only
id: create-patch
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
git add "**/*.png"
if git diff --staged --quiet; then
echo "has_changes=false" >> "$GITHUB_OUTPUT"
echo "No VRT changes in this shard"
exit 0
fi
echo "has_changes=true" >> "$GITHUB_OUTPUT"
git commit -m "Update VRT screenshots (browser shard ${{ matrix.shard }})"
git format-patch -1 HEAD --stdout > vrt-shard.patch
# Validate patch only contains PNG files. `git format-patch` emits a
# `GIT binary patch` block for PNGs (no +++/--- lines), so check
# `diff --git` headers — those are present for both text and binary.
if grep -E '^diff --git ' vrt-shard.patch \
| grep -vE '^diff --git a/[^[:space:]]+\.png b/[^[:space:]]+\.png$'; then
echo "ERROR: Shard patch contains non-PNG files!"
exit 1
fi
- name: Upload shard patch
if: steps.create-patch.outputs.has_changes == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: vrt-shard-browser-${{ matrix.shard }}
path: vrt-shard.patch
retention-days: 1
overwrite: true
desktop-vrt:
name: Desktop VRT
runs-on: ubuntu-latest
needs: get-pr
container:
image: mcr.microsoft.com/playwright:v1.59.1-jammy
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ needs.get-pr.outputs.head_sha }}
persist-credentials: false
- name: Trust workspace directory
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
shell: bash
- name: Set up environment
uses: ./.github/actions/setup
with:
@@ -196,124 +74,48 @@ jobs:
- name: Install build tools
run: apt-get update && apt-get install -y build-essential python3
- name: Run Desktop VRT Tests
- name: Run VRT Tests on Desktop app
continue-on-error: true
run: |
yarn rebuild-electron
xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" -- yarn e2e:desktop --update-snapshots
- name: Create shard patch with PNG changes only
- name: Run VRT Tests
continue-on-error: true
run: yarn vrt --update-snapshots
- name: Create patch with PNG changes only
id: create-patch
run: |
# Trust the repository directory (required for container environments)
git config --global --add safe.directory "$GITHUB_WORKSPACE"
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
# Stage only PNG files
git add "**/*.png"
# Check if there are any changes
if git diff --staged --quiet; then
echo "has_changes=false" >> "$GITHUB_OUTPUT"
echo "No VRT changes in desktop shard"
exit 0
fi
echo "has_changes=true" >> "$GITHUB_OUTPUT"
git commit -m "Update VRT screenshots (desktop)"
git format-patch -1 HEAD --stdout > vrt-shard.patch
# See validation note in browser-vrt above.
if grep -E '^diff --git ' vrt-shard.patch \
| grep -vE '^diff --git a/[^[:space:]]+\.png b/[^[:space:]]+\.png$'; then
echo "ERROR: Desktop shard patch contains non-PNG files!"
exit 1
fi
- name: Upload shard patch
if: steps.create-patch.outputs.has_changes == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: vrt-shard-desktop
path: vrt-shard.patch
retention-days: 1
overwrite: true
merge-patch:
name: Merge VRT Patches
runs-on: ubuntu-latest
needs: [get-pr, browser-vrt, desktop-vrt]
if: ${{ !cancelled() && needs.get-pr.result == 'success' }}
container:
image: mcr.microsoft.com/playwright:v1.59.1-jammy
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ needs.get-pr.outputs.head_sha }}
persist-credentials: false
- name: Download all shard patches
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: /tmp/shard-patches
pattern: vrt-shard-*
- name: Merge shard patches
id: create-patch
shell: bash
run: |
git config --global --add safe.directory "$GITHUB_WORKSPACE"
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
# actions/download-artifact puts a lone matched artifact directly in
# `path` but gives each of several its own `path/<name>/` subdir, so
# recurse instead of globbing `*/vrt-shard.patch` (which would miss
# the common single-shard case).
mapfile -t patches < <(find /tmp/shard-patches -type f -name 'vrt-shard.patch' | sort)
if [ ${#patches[@]} -eq 0 ]; then
echo "has_changes=false" >> "$GITHUB_OUTPUT"
echo "No shard patches to merge"
exit 0
fi
# Defense in depth: re-validate every shard patch before applying.
# See validation note in browser-vrt above for why we match
# `diff --git` headers instead of +++/--- lines.
for patch in "${patches[@]}"; do
echo "Validating $patch"
if grep -E '^diff --git ' "$patch" \
| grep -vE '^diff --git a/[^[:space:]]+\.png b/[^[:space:]]+\.png$'; then
echo "ERROR: $patch contains non-PNG files!"
exit 1
fi
done
# Apply each shard patch. Shards touch disjoint PNG files so
# order does not matter. --index stages the applied changes.
for patch in "${patches[@]}"; do
echo "Applying $patch"
git apply --index "$patch"
done
if git diff --staged --quiet; then
echo "has_changes=false" >> "$GITHUB_OUTPUT"
echo "No VRT changes after merge"
echo "No VRT changes to commit"
exit 0
fi
echo "has_changes=true" >> "$GITHUB_OUTPUT"
# Create commit and patch
git commit -m "Update VRT screenshots"
git format-patch -1 HEAD --stdout > vrt-update.patch
# Final guard on the combined patch.
if grep -E '^diff --git ' vrt-update.patch \
| grep -vE '^diff --git a/[^[:space:]]+\.png b/[^[:space:]]+\.png$'; then
echo "ERROR: Merged patch contains non-PNG files!"
# Validate patch only contains PNG files
if grep -E '^(\+\+\+|---) [ab]/' vrt-update.patch | grep -v '\.png$'; then
echo "ERROR: Patch contains non-PNG files!"
exit 1
fi
echo "Merged patch created successfully with PNG changes only"
echo "Patch created successfully with PNG changes only"
- name: Upload patch artifact
if: steps.create-patch.outputs.has_changes == 'true'
@@ -328,11 +130,11 @@ jobs:
run: |
mkdir -p pr-metadata
echo "${{ github.event.issue.number }}" > pr-metadata/pr-number.txt
echo "${NEEDS_GET_PR_OUTPUTS_HEAD_REF}" > pr-metadata/head-ref.txt
echo "${NEEDS_GET_PR_OUTPUTS_HEAD_REPO}" > pr-metadata/head-repo.txt
echo "${STEPS_PR_OUTPUTS_HEAD_REF}" > pr-metadata/head-ref.txt
echo "${STEPS_PR_OUTPUTS_HEAD_REPO}" > pr-metadata/head-repo.txt
env:
NEEDS_GET_PR_OUTPUTS_HEAD_REF: ${{ needs.get-pr.outputs.head_ref }}
NEEDS_GET_PR_OUTPUTS_HEAD_REPO: ${{ needs.get-pr.outputs.head_repo }}
STEPS_PR_OUTPUTS_HEAD_REF: ${{ steps.pr.outputs.head_ref }}
STEPS_PR_OUTPUTS_HEAD_REPO: ${{ steps.pr.outputs.head_repo }}
- name: Upload PR metadata
if: steps.create-patch.outputs.has_changes == 'true'

3
.gitignore vendored
View File

@@ -42,9 +42,6 @@ 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

@@ -15,8 +15,7 @@
"vi": "readonly",
"backend": "readonly",
"importScripts": "readonly",
"FS": "readonly",
"__APP_VERSION__": "readonly"
"FS": "readonly"
},
"rules": {
// Import sorting
@@ -338,11 +337,6 @@
"group": ["**/*.api", "**/*.electron"],
"message": "Don't directly reference imports from other platforms"
},
{
"group": ["uuid"],
"importNames": ["*"],
"message": "Use `import { v4 as uuidv4 } from 'uuid'` instead"
},
{
"group": ["**/style", "**/colors"],
"importNames": ["colors"],
@@ -376,8 +370,7 @@
"files": ["**/*.test.{js,ts,jsx,tsx}", "packages/docs/**/*"],
"rules": {
"actual/no-untranslated-strings": "off",
"actual/prefer-logger-over-console": "off",
"typescript/unbound-method": "off"
"actual/prefer-logger-over-console": "off"
}
},
{

View File

@@ -7,7 +7,3 @@ enableTransparentWorkspaces: false
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.13.0.cjs
# Secure default: don't run postinstall scripts.
# If a new package requires them, add it to dependenciesMeta in package.json.
enableScripts: false

View File

@@ -4,30 +4,21 @@ ROOT=`dirname $0`
cd "$ROOT/.."
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
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
lage build:browser --to=@actual-app/web
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"

View File

@@ -57,7 +57,8 @@ 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 build:browser
yarn workspace @actual-app/core build:browser
yarn workspace @actual-app/web 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,14 +25,6 @@ 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,16 +24,18 @@
"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:plugins-service",
"desktop-dependencies": "npm-run-all --parallel rebuild-electron build:browser-backend 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": "npm-run-all --parallel 'start:browser-*' 'start:service-plugins'",
"start:browser": "yarn workspace plugins-service build-dev && npm-run-all --parallel 'start:browser-*'",
"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",
@@ -52,7 +54,7 @@
"playwright": "yarn workspace @actual-app/web run playwright",
"vrt": "yarn workspace @actual-app/web run vrt",
"vrt:docker": "./bin/run-vrt",
"rebuild-electron": "./node_modules/.bin/electron-rebuild -m ./packages/desktop-electron -o better-sqlite3,bcrypt --build-from-source -f",
"rebuild-electron": "./node_modules/.bin/electron-rebuild -m ./packages/loot-core && ./node_modules/.bin/electron-rebuild -m ./packages/desktop-electron -o better-sqlite3,bcrypt",
"rebuild-node": "yarn workspace @actual-app/core rebuild",
"lint": "oxfmt --check . && oxlint --type-aware --quiet",
"lint:fix": "oxfmt . && oxlint --fix --type-aware --quiet",
@@ -87,23 +89,6 @@
"typescript": "^6.0.2",
"vitest": "^4.1.2"
},
"dependenciesMeta": {
"bcrypt": {
"built": true
},
"better-sqlite3": {
"built": true
},
"electron": {
"built": true
},
"esbuild": {
"built": true
},
"sharp": {
"built": true
}
},
"resolutions": {
"adm-zip": "patch:adm-zip@npm%3A0.5.16#~/.yarn/patches/adm-zip-npm-0.5.16-4556fea098.patch",
"minimatch@10.2.1": "10.2.5",

View File

@@ -6,11 +6,6 @@ import { vi } from 'vitest';
import * as api from './index';
declare global {
var IS_TESTING: boolean;
var currentMonth: string | null;
}
// In tests we run from source; loot-core's API fs uses __dirname (for the built dist/).
// Mock the fs so path constants point at loot-core package root where migrations live.
vi.mock(
@@ -516,29 +511,6 @@ describe('API CRUD operations', () => {
);
});
// apis: getNote, updateNote
test('Notes: successfully get and update note', async () => {
const categories = await api.getCategories();
const categoryId = categories[0].id;
// No note exists initially
const initial = await api.getNote(categoryId);
expect(initial).toBeNull();
// Set a note
await api.updateNote(categoryId, 'Test note content');
const afterSet = await api.getNote(categoryId);
expect(afterSet).toEqual({ id: categoryId, note: 'Test note content' });
// Update the note
await api.updateNote(categoryId, 'Updated note content');
const afterUpdate = await api.getNote(categoryId);
expect(afterUpdate).toEqual({
id: categoryId,
note: 'Updated note content',
});
});
// apis: getRules, getPayeeRules, createRule, updateRule, deleteRule
test('Rules: successfully update rules', async () => {
await api.createPayee({ name: 'test-payee' });

View File

@@ -13,7 +13,6 @@ import type { ImportTransactionsOpts } from '@actual-app/core/types/api-handlers
import type { Handlers } from '@actual-app/core/types/handlers';
import type {
ImportTransactionEntity,
NoteEntity,
RuleEntity,
TransactionEntity,
} from '@actual-app/core/types/models';
@@ -204,8 +203,8 @@ export function getAccountBalance(id: APIAccountEntity['id'], cutoff?: Date) {
return send('api/account-balance', { id, cutoff });
}
export function getCategoryGroups(options: { hidden?: boolean } = {}) {
return send('api/category-groups-get', options);
export function getCategoryGroups() {
return send('api/category-groups-get');
}
export function createCategoryGroup(group: Omit<APICategoryGroupEntity, 'id'>) {
@@ -226,8 +225,8 @@ export function deleteCategoryGroup(
return send('api/category-group-delete', { id, transferCategoryId });
}
export function getCategories(options: { hidden?: boolean } = {}) {
return send('api/categories-get', { grouped: false, ...options });
export function getCategories() {
return send('api/categories-get', { grouped: false });
}
export function createCategory(category: Omit<APICategoryEntity, 'id'>) {
@@ -248,14 +247,6 @@ export function deleteCategory(
return send('api/category-delete', { id, transferCategoryId });
}
export function getNote(id: NoteEntity['id']) {
return send('api/note-get', { id });
}
export function updateNote(id: NoteEntity['id'], note: NoteEntity['note']) {
return send('api/note-update', { id, note });
}
export function getCommonPayees() {
return send('api/common-payees-get');
}

View File

@@ -1,6 +1,6 @@
{
"name": "@actual-app/api",
"version": "26.5.2",
"version": "26.4.0",
"description": "An API for Actual",
"license": "MIT",
"repository": {
@@ -10,9 +10,7 @@
},
"files": [
"@types",
"dist",
"!@types/**/*.test.d.ts",
"!@types/**/*.test.d.ts.map"
"dist"
],
"main": "dist/index.js",
"types": "@types/index.d.ts",
@@ -49,8 +47,7 @@
"@actual-app/core": "workspace:*",
"@actual-app/crdt": "workspace:*",
"better-sqlite3": "^12.8.0",
"compare-versions": "^6.1.1",
"uuid": "^14.0.0"
"compare-versions": "^6.1.1"
},
"devDependencies": {
"@typescript/native-preview": "beta",

View File

@@ -35,6 +35,7 @@
"**/node_modules/*",
"dist",
"@types",
"*.test.ts",
"*.config.ts",
"*.config.mts"
]

View File

@@ -85,12 +85,6 @@ export default defineConfig({
},
test: {
globals: true,
// Each test loads a budget file and runs all DB migrations, which can be
// slow on busy CI runners; the default 5s timeout is too tight and causes
// flaky timeouts (and a cascade of unhandled rejections from in-flight work
// continuing after teardown).
testTimeout: 20_000,
hookTimeout: 20_000,
onConsoleLog(log: string, type: 'stdout' | 'stderr'): boolean | void {
// print only console.error
return type === 'stderr';

View File

@@ -1,6 +1,6 @@
{
"name": "@actual-app/cli",
"version": "26.5.2",
"version": "26.4.0",
"description": "CLI for Actual Budget",
"license": "MIT",
"repository": {

View File

@@ -1,131 +0,0 @@
import * as api from '@actual-app/api';
import { Command } from 'commander';
import { printOutput } from '#output';
import { registerCategoriesCommand } from './categories';
import { registerCategoryGroupsCommand } from './category-groups';
vi.mock('@actual-app/api', () => ({
getCategories: vi.fn().mockResolvedValue([]),
createCategory: vi.fn().mockResolvedValue('new-id'),
updateCategory: vi.fn().mockResolvedValue(undefined),
deleteCategory: vi.fn().mockResolvedValue(undefined),
getCategoryGroups: vi.fn().mockResolvedValue([]),
createCategoryGroup: vi.fn().mockResolvedValue('new-group-id'),
updateCategoryGroup: vi.fn().mockResolvedValue(undefined),
deleteCategoryGroup: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('#connection', () => ({
withConnection: vi.fn((_opts, fn) => fn()),
}));
vi.mock('#output', () => ({
printOutput: vi.fn(),
}));
function createProgram(): Command {
const program = new Command();
program.option('--format <format>');
program.option('--server-url <url>');
program.option('--password <pw>');
program.option('--session-token <token>');
program.option('--sync-id <id>');
program.option('--data-dir <dir>');
program.option('--verbose');
program.exitOverride();
registerCategoriesCommand(program);
registerCategoryGroupsCommand(program);
return program;
}
async function run(args: string[]) {
const program = createProgram();
await program.parseAsync(['node', 'test', ...args]);
}
describe('categories commands', () => {
let stderrSpy: ReturnType<typeof vi.spyOn>;
let stdoutSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
stderrSpy = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
stdoutSpy = vi
.spyOn(process.stdout, 'write')
.mockImplementation(() => true);
});
afterEach(() => {
stderrSpy.mockRestore();
stdoutSpy.mockRestore();
});
describe('categories list', () => {
it('asks the API to exclude hidden categories by default', async () => {
await run(['categories', 'list']);
expect(api.getCategories).toHaveBeenCalledWith({ hidden: false });
});
it('asks the API for all categories when --include-hidden is passed', async () => {
await run(['categories', 'list', '--include-hidden']);
expect(api.getCategories).toHaveBeenCalledWith({});
});
it('prints whatever the API returns', async () => {
const visible = {
id: '1',
name: 'Visible',
group_id: 'g1',
hidden: false,
};
vi.mocked(api.getCategories).mockResolvedValue([visible]);
await run(['categories', 'list']);
expect(printOutput).toHaveBeenCalledWith([visible], undefined);
});
it('passes format option to printOutput', async () => {
vi.mocked(api.getCategories).mockResolvedValue([]);
await run(['--format', 'csv', 'categories', 'list']);
expect(printOutput).toHaveBeenCalledWith([], 'csv');
});
});
describe('category-groups list', () => {
it('asks the API to exclude hidden groups by default', async () => {
await run(['category-groups', 'list']);
expect(api.getCategoryGroups).toHaveBeenCalledWith({ hidden: false });
});
it('asks the API for all groups when --include-hidden is passed', async () => {
await run(['category-groups', 'list', '--include-hidden']);
expect(api.getCategoryGroups).toHaveBeenCalledWith({});
});
it('prints whatever the API returns', async () => {
const group = {
id: 'g1',
name: 'Group',
is_income: false,
hidden: false,
categories: [{ id: 'c1', name: 'Cat', group_id: 'g1', hidden: false }],
};
vi.mocked(api.getCategoryGroups).mockResolvedValue([group]);
await run(['category-groups', 'list']);
expect(printOutput).toHaveBeenCalledWith([group], undefined);
});
});
});

View File

@@ -12,16 +12,13 @@ export function registerCategoriesCommand(program: Command) {
categories
.command('list')
.description('List categories (excludes hidden by default)')
.option('--include-hidden', 'Include hidden categories', false)
.action(async cmdOpts => {
.description('List all categories')
.action(async () => {
const opts = program.opts();
await withConnection(
opts,
async () => {
const result = await api.getCategories(
cmdOpts.includeHidden ? {} : { hidden: false },
);
const result = await api.getCategories();
printOutput(result, opts.format);
},
{ mutates: false },

View File

@@ -12,16 +12,13 @@ export function registerCategoryGroupsCommand(program: Command) {
groups
.command('list')
.description('List category groups (excludes hidden by default)')
.option('--include-hidden', 'Include hidden groups and categories', false)
.action(async cmdOpts => {
.description('List all category groups')
.action(async () => {
const opts = program.opts();
await withConnection(
opts,
async () => {
const result = await api.getCategoryGroups(
cmdOpts.includeHidden ? {} : { hidden: false },
);
const result = await api.getCategoryGroups();
printOutput(result, opts.format);
},
{ mutates: false },

View File

@@ -1,5 +1,4 @@
#!/bin/bash
set -euo pipefail
cd "$(dirname "$(dirname "$0")")"
@@ -8,10 +7,20 @@ if ! [ -x "$(command -v protoc)" ]; then
exit 1
fi
protoc --plugin="protoc-gen-es=../../node_modules/.bin/protoc-gen-es" \
--es_opt=target=ts \
--es_out="src/proto" \
export PATH="$PWD/bin:$PATH"
protoc --plugin="protoc-gen-ts=../../node_modules/.bin/protoc-gen-ts" \
--ts_opt=esModuleInterop=true \
--ts_out="src/proto" \
--js_out=import_style=commonjs,binary:src/proto \
--proto_path=src/proto \
sync.proto
../../node_modules/.bin/oxfmt src/proto/*.ts
../../node_modules/.bin/oxfmt src/proto/*.d.ts
for file in src/proto/*.d.ts; do
{ echo "/* oxlint-disable typescript/no-namespace */"; sed 's/export class/export declare class/g' "$file"; } > "${file%.d.ts}.ts"
rm "$file"
done
echo 'One more step! Find the `var global = ...` declaration in src/proto/sync_pb.js and change it to `var global = globalThis;`'

View File

@@ -1,25 +1,19 @@
{
"name": "@actual-app/crdt",
"version": "3.0.0",
"version": "2.1.0",
"description": "CRDT layer of Actual",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/actualbudget/actual.git",
"directory": "packages/crdt"
},
"files": [
"dist",
"!dist/**/*.test.d.ts",
"!dist/**/*.test.d.ts.map",
"!dist/**/*.spec.d.ts",
"!dist/**/*.spec.d.ts.map"
"dist"
],
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": "./src/index.ts"
".": {
"types": "./dist/index.d.ts",
"development": "./src/index.ts",
"default": "./dist/index.js"
}
},
"publishConfig": {
"exports": {
@@ -27,26 +21,25 @@
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"main": "dist/index.js",
"types": "dist/index.d.ts"
}
},
"scripts": {
"build:node": "vite build",
"proto:generate": "./bin/generate-proto",
"build": "yarn run build:node && tsgo -b",
"build": "yarn run build:node && tsgo -p tsconfig.build.json --emitDeclarationOnly",
"test": "vitest --run",
"typecheck": "tsgo -b"
},
"dependencies": {
"@bufbuild/protobuf": "^2.11.0",
"murmurhash": "^2.0.1",
"uuid": "^14.0.0"
"google-protobuf": "^3.21.4",
"murmurhash": "^2.0.1"
},
"devDependencies": {
"@bufbuild/protoc-gen-es": "^2.11.0",
"@types/google-protobuf": "3.15.12",
"@typescript/native-preview": "beta",
"protoc-gen-js": "3.21.4-4",
"rollup-plugin-visualizer": "^7.0.1",
"ts-protoc-gen": "0.15.0",
"vite": "^8.0.5",
"vitest": "^4.1.2"
}

View File

@@ -1,5 +1,4 @@
import murmurhash from 'murmurhash';
import { v4 as uuidv4 } from 'uuid';
import type { TrieNode } from './merkle';
@@ -77,7 +76,7 @@ export function deserializeClock(clock: string): Clock {
}
export function makeClientId() {
return uuidv4().replace(/-/g, '').slice(-16);
return crypto.randomUUID().replace(/-/g, '').slice(-16);
}
const config = {

View File

@@ -1,3 +1,6 @@
import './proto/sync_pb.js'; // Import for side effects
import type * as SyncPb from './proto/sync_pb';
export {
merkle,
getClock,
@@ -10,17 +13,16 @@ export {
Timestamp,
} from './crdt';
export {
type EncryptedData,
type Message,
type MessageEnvelope,
type SyncRequest,
type SyncResponse,
EncryptedDataSchema,
MessageSchema,
MessageEnvelopeSchema,
SyncRequestSchema,
SyncResponseSchema,
} from './proto/sync_pb';
declare global {
var proto: typeof SyncPb;
}
export { create, fromBinary, toBinary } from '@bufbuild/protobuf';
const { proto } = globalThis;
export const SyncRequest = proto.SyncRequest;
export const SyncResponse = proto.SyncResponse;
export const Message = proto.Message;
export const MessageEnvelope = proto.MessageEnvelope;
export const EncryptedData = proto.EncryptedData;
export const SyncProtoBuf = proto;

View File

@@ -21,7 +21,6 @@ message MessageEnvelope {
}
message SyncRequest {
reserved 4;
repeated MessageEnvelope messages = 1;
string fileId = 2;
string groupId = 3;

File diff suppressed because it is too large Load Diff

View File

@@ -1,161 +1,217 @@
// @generated by protoc-gen-es v2.11.0 with parameter "target=ts"
// @generated from file sync.proto (syntax proto3)
/* eslint-disable */
/* oxlint-disable typescript/no-namespace */
// package:
// file: sync.proto
import type { Message as Message$1 } from '@bufbuild/protobuf';
import type { GenFile, GenMessage } from '@bufbuild/protobuf/codegenv2';
import { fileDesc, messageDesc } from '@bufbuild/protobuf/codegenv2';
import * as jspb from 'google-protobuf';
/**
* Describes the file sync.proto.
*/
export const file_sync: GenFile /*@__PURE__*/ = fileDesc(
'CgpzeW5jLnByb3RvIjoKDUVuY3J5cHRlZERhdGESCgoCaXYYASABKAwSDwoHYXV0aFRhZxgCIAEoDBIMCgRkYXRhGAMgASgMIkYKB01lc3NhZ2USDwoHZGF0YXNldBgBIAEoCRILCgNyb3cYAiABKAkSDgoGY29sdW1uGAMgASgJEg0KBXZhbHVlGAQgASgJIkoKD01lc3NhZ2VFbnZlbG9wZRIRCgl0aW1lc3RhbXAYASABKAkSEwoLaXNFbmNyeXB0ZWQYAiABKAgSDwoHY29udGVudBgDIAEoDCJ2CgtTeW5jUmVxdWVzdBIiCghtZXNzYWdlcxgBIAMoCzIQLk1lc3NhZ2VFbnZlbG9wZRIOCgZmaWxlSWQYAiABKAkSDwoHZ3JvdXBJZBgDIAEoCRINCgVrZXlJZBgFIAEoCRINCgVzaW5jZRgGIAEoCUoECAQQBSJCCgxTeW5jUmVzcG9uc2USIgoIbWVzc2FnZXMYASADKAsyEC5NZXNzYWdlRW52ZWxvcGUSDgoGbWVya2xlGAIgASgJYgZwcm90bzM',
);
export declare class EncryptedData extends jspb.Message {
getIv(): Uint8Array | string;
getIv_asU8(): Uint8Array;
getIv_asB64(): string;
setIv(value: Uint8Array | string): void;
/**
* @generated from message EncryptedData
*/
export type EncryptedData = Message$1<'EncryptedData'> & {
/**
* @generated from field: bytes iv = 1;
*/
iv: Uint8Array;
getAuthtag(): Uint8Array | string;
getAuthtag_asU8(): Uint8Array;
getAuthtag_asB64(): string;
setAuthtag(value: Uint8Array | string): void;
/**
* @generated from field: bytes authTag = 2;
*/
authTag: Uint8Array;
getData(): Uint8Array | string;
getData_asU8(): Uint8Array;
getData_asB64(): string;
setData(value: Uint8Array | string): void;
/**
* @generated from field: bytes data = 3;
*/
data: Uint8Array;
};
serializeBinary(): Uint8Array;
toObject(includeInstance?: boolean): EncryptedData.AsObject;
static toObject(
includeInstance: boolean,
msg: EncryptedData,
): EncryptedData.AsObject;
static extensions: { [key: number]: jspb.ExtensionFieldInfo<jspb.Message> };
static extensionsBinary: {
[key: number]: jspb.ExtensionFieldBinaryInfo<jspb.Message>;
};
static serializeBinaryToWriter(
message: EncryptedData,
writer: jspb.BinaryWriter,
): void;
static deserializeBinary(bytes: Uint8Array): EncryptedData;
static deserializeBinaryFromReader(
message: EncryptedData,
reader: jspb.BinaryReader,
): EncryptedData;
}
/**
* Describes the message EncryptedData.
* Use `create(EncryptedDataSchema)` to create a new message.
*/
export const EncryptedDataSchema: GenMessage<EncryptedData> /*@__PURE__*/ =
messageDesc(file_sync, 0);
export namespace EncryptedData {
export type AsObject = {
iv: Uint8Array | string;
authtag: Uint8Array | string;
data: Uint8Array | string;
};
}
/**
* @generated from message Message
*/
export type Message = Message$1<'Message'> & {
/**
* @generated from field: string dataset = 1;
*/
dataset: string;
export declare class Message extends jspb.Message {
getDataset(): string;
setDataset(value: string): void;
/**
* @generated from field: string row = 2;
*/
row: string;
getRow(): string;
setRow(value: string): void;
/**
* @generated from field: string column = 3;
*/
column: string;
getColumn(): string;
setColumn(value: string): void;
/**
* @generated from field: string value = 4;
*/
value: string;
};
getValue(): string;
setValue(value: string): void;
/**
* Describes the message Message.
* Use `create(MessageSchema)` to create a new message.
*/
export const MessageSchema: GenMessage<Message> /*@__PURE__*/ = messageDesc(
file_sync,
1,
);
serializeBinary(): Uint8Array;
toObject(includeInstance?: boolean): Message.AsObject;
static toObject(includeInstance: boolean, msg: Message): Message.AsObject;
static extensions: { [key: number]: jspb.ExtensionFieldInfo<jspb.Message> };
static extensionsBinary: {
[key: number]: jspb.ExtensionFieldBinaryInfo<jspb.Message>;
};
static serializeBinaryToWriter(
message: Message,
writer: jspb.BinaryWriter,
): void;
static deserializeBinary(bytes: Uint8Array): Message;
static deserializeBinaryFromReader(
message: Message,
reader: jspb.BinaryReader,
): Message;
}
/**
* @generated from message MessageEnvelope
*/
export type MessageEnvelope = Message$1<'MessageEnvelope'> & {
/**
* @generated from field: string timestamp = 1;
*/
timestamp: string;
export namespace Message {
export type AsObject = {
dataset: string;
row: string;
column: string;
value: string;
};
}
/**
* @generated from field: bool isEncrypted = 2;
*/
isEncrypted: boolean;
export declare class MessageEnvelope extends jspb.Message {
getTimestamp(): string;
setTimestamp(value: string): void;
/**
* @generated from field: bytes content = 3;
*/
content: Uint8Array;
};
getIsencrypted(): boolean;
setIsencrypted(value: boolean): void;
/**
* Describes the message MessageEnvelope.
* Use `create(MessageEnvelopeSchema)` to create a new message.
*/
export const MessageEnvelopeSchema: GenMessage<MessageEnvelope> /*@__PURE__*/ =
messageDesc(file_sync, 2);
getContent(): Uint8Array | string;
getContent_asU8(): Uint8Array;
getContent_asB64(): string;
setContent(value: Uint8Array | string): void;
/**
* @generated from message SyncRequest
*/
export type SyncRequest = Message$1<'SyncRequest'> & {
/**
* @generated from field: repeated MessageEnvelope messages = 1;
*/
messages: MessageEnvelope[];
serializeBinary(): Uint8Array;
toObject(includeInstance?: boolean): MessageEnvelope.AsObject;
static toObject(
includeInstance: boolean,
msg: MessageEnvelope,
): MessageEnvelope.AsObject;
static extensions: { [key: number]: jspb.ExtensionFieldInfo<jspb.Message> };
static extensionsBinary: {
[key: number]: jspb.ExtensionFieldBinaryInfo<jspb.Message>;
};
static serializeBinaryToWriter(
message: MessageEnvelope,
writer: jspb.BinaryWriter,
): void;
static deserializeBinary(bytes: Uint8Array): MessageEnvelope;
static deserializeBinaryFromReader(
message: MessageEnvelope,
reader: jspb.BinaryReader,
): MessageEnvelope;
}
/**
* @generated from field: string fileId = 2;
*/
fileId: string;
export namespace MessageEnvelope {
export type AsObject = {
timestamp: string;
isencrypted: boolean;
content: Uint8Array | string;
};
}
/**
* @generated from field: string groupId = 3;
*/
groupId: string;
export declare class SyncRequest extends jspb.Message {
clearMessagesList(): void;
getMessagesList(): Array<MessageEnvelope>;
setMessagesList(value: Array<MessageEnvelope>): void;
addMessages(value?: MessageEnvelope, index?: number): MessageEnvelope;
/**
* @generated from field: string keyId = 5;
*/
keyId: string;
getFileid(): string;
setFileid(value: string): void;
/**
* @generated from field: string since = 6;
*/
since: string;
};
getGroupid(): string;
setGroupid(value: string): void;
/**
* Describes the message SyncRequest.
* Use `create(SyncRequestSchema)` to create a new message.
*/
export const SyncRequestSchema: GenMessage<SyncRequest> /*@__PURE__*/ =
messageDesc(file_sync, 3);
getKeyid(): string;
setKeyid(value: string): void;
/**
* @generated from message SyncResponse
*/
export type SyncResponse = Message$1<'SyncResponse'> & {
/**
* @generated from field: repeated MessageEnvelope messages = 1;
*/
messages: MessageEnvelope[];
getSince(): string;
setSince(value: string): void;
/**
* @generated from field: string merkle = 2;
*/
merkle: string;
};
serializeBinary(): Uint8Array;
toObject(includeInstance?: boolean): SyncRequest.AsObject;
static toObject(
includeInstance: boolean,
msg: SyncRequest,
): SyncRequest.AsObject;
static extensions: { [key: number]: jspb.ExtensionFieldInfo<jspb.Message> };
static extensionsBinary: {
[key: number]: jspb.ExtensionFieldBinaryInfo<jspb.Message>;
};
static serializeBinaryToWriter(
message: SyncRequest,
writer: jspb.BinaryWriter,
): void;
static deserializeBinary(bytes: Uint8Array): SyncRequest;
static deserializeBinaryFromReader(
message: SyncRequest,
reader: jspb.BinaryReader,
): SyncRequest;
}
/**
* Describes the message SyncResponse.
* Use `create(SyncResponseSchema)` to create a new message.
*/
export const SyncResponseSchema: GenMessage<SyncResponse> /*@__PURE__*/ =
messageDesc(file_sync, 4);
export namespace SyncRequest {
export type AsObject = {
messagesList: Array<MessageEnvelope.AsObject>;
fileid: string;
groupid: string;
keyid: string;
since: string;
};
}
export declare class SyncResponse extends jspb.Message {
clearMessagesList(): void;
getMessagesList(): Array<MessageEnvelope>;
setMessagesList(value: Array<MessageEnvelope>): void;
addMessages(value?: MessageEnvelope, index?: number): MessageEnvelope;
getMerkle(): string;
setMerkle(value: string): void;
serializeBinary(): Uint8Array;
toObject(includeInstance?: boolean): SyncResponse.AsObject;
static toObject(
includeInstance: boolean,
msg: SyncResponse,
): SyncResponse.AsObject;
static extensions: { [key: number]: jspb.ExtensionFieldInfo<jspb.Message> };
static extensionsBinary: {
[key: number]: jspb.ExtensionFieldBinaryInfo<jspb.Message>;
};
static serializeBinaryToWriter(
message: SyncResponse,
writer: jspb.BinaryWriter,
): void;
static deserializeBinary(bytes: Uint8Array): SyncResponse;
static deserializeBinaryFromReader(
message: SyncResponse,
reader: jspb.BinaryReader,
): SyncResponse;
}
export namespace SyncResponse {
export type AsObject = {
messagesList: Array<MessageEnvelope.AsObject>;
merkle: string;
};
}

View File

@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": false,
"emitDeclarationOnly": false
},
"exclude": ["**/*.test.ts", "**/*.spec.ts"]
}

View File

@@ -4,8 +4,8 @@
"rootDir": "./src",
"composite": true,
"target": "ES2021",
"module": "ES2022",
"moduleResolution": "bundler",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": false,
"emitDeclarationOnly": true,
"declaration": true,

View File

@@ -6,7 +6,7 @@ import { defineConfig } from 'vite';
export default defineConfig({
ssr: {
noExternal: true,
external: ['@bufbuild/protobuf', 'murmurhash'],
external: ['google-protobuf', 'murmurhash'],
},
build: {
ssr: true,
@@ -16,7 +16,7 @@ export default defineConfig({
sourcemap: true,
lib: {
entry: path.resolve(__dirname, 'src/index.ts'),
formats: ['es'],
formats: ['cjs'],
fileName: () => 'index.js',
},
},

View File

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

View File

@@ -0,0 +1,17 @@
#!/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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 69 KiB

View File

@@ -1,5 +1,5 @@
import { test as base, expect as baseExpect } from '@playwright/test';
import type { Browser, Locator, Page } from '@playwright/test';
import type { Browser, Locator } from '@playwright/test';
/**
* Disable CSS transitions and animations globally in e2e (non-VRT) runs.
@@ -51,7 +51,7 @@ export const test = process.env.VRT
});
export const expect = baseExpect.extend({
async toMatchThemeScreenshots(target: Locator | Page) {
async toMatchThemeScreenshots(locator: Locator) {
// Disable screenshot assertions in regular e2e tests;
// only enable them when doing VRT tests
if (!process.env.VRT) {
@@ -62,33 +62,38 @@ export const expect = baseExpect.extend({
}
const config = {
mask: [target.locator('[data-vrt-mask="true"]')],
mask: [locator.locator('[data-vrt-mask="true"]')],
maxDiffPixels: 5,
};
const page: Page = 'page' in target ? target.page() : target;
const dataThemeLocator = page.locator('[data-theme]');
// Get the data-theme attribute from page.
// If there is a page() function, it means that the locator
// is not a page object but a locator object.
const dataThemeLocator =
typeof locator.page === 'function'
? locator.page().locator('[data-theme]')
: locator.locator('[data-theme]');
// Check lightmode
await page.evaluate(() => window.Actual.setTheme('auto'));
await locator.evaluate(() => window.Actual.setTheme('auto'));
await baseExpect(dataThemeLocator).toHaveAttribute('data-theme', 'auto');
await baseExpect(target).toHaveScreenshot(config);
await baseExpect(locator).toHaveScreenshot(config);
// Switch to darkmode and check
await page.evaluate(() => window.Actual.setTheme('dark'));
await locator.evaluate(() => window.Actual.setTheme('dark'));
await baseExpect(dataThemeLocator).toHaveAttribute('data-theme', 'dark');
await baseExpect(target).toHaveScreenshot(config);
await baseExpect(locator).toHaveScreenshot(config);
// Switch to midnight theme and check
await page.evaluate(() => window.Actual.setTheme('midnight'));
await locator.evaluate(() => window.Actual.setTheme('midnight'));
await baseExpect(dataThemeLocator).toHaveAttribute(
'data-theme',
'midnight',
);
await baseExpect(target).toHaveScreenshot(config);
await baseExpect(locator).toHaveScreenshot(config);
// Switch back to lightmode
await page.evaluate(() => window.Actual.setTheme('auto'));
await locator.evaluate(() => window.Actual.setTheme('auto'));
return {
message: () => 'pass',
pass: true,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Some files were not shown because too many files have changed in this diff Show More