mirror of
https://github.com/actualbudget/actual.git
synced 2026-05-06 15:12:35 -05:00
Compare commits
21 Commits
matiss/crd
...
copilot/su
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c4f0fff58 | ||
|
|
acadee2a5c | ||
|
|
1a1c7447ad | ||
|
|
c8d7e4bc92 | ||
|
|
7b19600b29 | ||
|
|
9804625b57 | ||
|
|
10374316db | ||
|
|
bbd039e572 | ||
|
|
c5db712481 | ||
|
|
11837ddad5 | ||
|
|
0830540168 | ||
|
|
c22d445ae0 | ||
|
|
1cbacdd192 | ||
|
|
e678a69c70 | ||
|
|
6c75b4545d | ||
|
|
c42390ca9f | ||
|
|
ac8d247def | ||
|
|
34f0c6c2e7 | ||
|
|
63de281d13 | ||
|
|
751e886796 | ||
|
|
89d5b7b6ad |
@@ -1,6 +1,6 @@
|
||||
issue_enrichment:
|
||||
auto_enrich:
|
||||
enabled: true
|
||||
enabled: false
|
||||
reviews:
|
||||
request_changes_workflow: true
|
||||
review_status: false
|
||||
@@ -13,6 +13,8 @@ reviews:
|
||||
mode: off
|
||||
enabled: false
|
||||
labeling_instructions:
|
||||
- label: 'suspect ai generated'
|
||||
instructions: 'This issue or PR is suspected to be generated by AI.'
|
||||
- label: 'API'
|
||||
instructions: 'This issue or PR updates the API in `packages/api`.'
|
||||
- label: 'documentation'
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
---
|
||||
description: Rules for AI-generated commits and pull requests
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# PR and Commit Rules for AI Agents
|
||||
|
||||
Canonical source: `.github/agents/pr-and-commit-rules.md`
|
||||
|
||||
## Commit Rules
|
||||
|
||||
### [AI] Prefix Requirement
|
||||
|
||||
**ALL commit messages MUST be prefixed with `[AI]`.** This is a mandatory requirement with no exceptions.
|
||||
|
||||
**Examples:**
|
||||
|
||||
- `[AI] Fix type error in account validation`
|
||||
- `[AI] Add support for new transaction categories`
|
||||
- `Fix type error in account validation` (MISSING PREFIX - NOT ALLOWED)
|
||||
- `Add support for new transaction categories` (MISSING PREFIX - NOT ALLOWED)
|
||||
|
||||
### Git Safety Rules
|
||||
|
||||
- **Never** update git config
|
||||
- **Never** run destructive git operations (force push, hard reset) unless the user explicitly requests it
|
||||
- **Never** skip hooks (`--no-verify`, `--no-gpg-sign`)
|
||||
- **Never** force push to `main`/`master`
|
||||
- **Never** commit unless explicitly asked by the user
|
||||
|
||||
## Pre-Commit Quality Checklist
|
||||
|
||||
Before committing, ensure all of the following:
|
||||
|
||||
- [ ] Commit message is prefixed with `[AI]`
|
||||
- [ ] `yarn typecheck` passes
|
||||
- [ ] `yarn lint:fix` has been run
|
||||
- [ ] Relevant tests pass
|
||||
- [ ] User-facing strings are translated
|
||||
- [ ] Code style conventions followed (see `AGENTS.md` for full style guide)
|
||||
|
||||
## Pull Request Rules
|
||||
|
||||
### [AI] Prefix Requirement
|
||||
|
||||
**ALL pull request titles MUST be prefixed with `[AI]`.** This is a mandatory requirement with no exceptions.
|
||||
|
||||
**Examples:**
|
||||
|
||||
- `[AI] Fix type error in account validation`
|
||||
- `[AI] Add support for new transaction categories`
|
||||
- `Fix type error in account validation` (MISSING PREFIX - NOT ALLOWED)
|
||||
|
||||
### Labels
|
||||
|
||||
Add the **"AI generated"** label to all AI-created pull requests.
|
||||
|
||||
### PR Template: Do Not Fill In
|
||||
|
||||
- **NEVER fill in the PR template** (`.github/PULL_REQUEST_TEMPLATE.md`). Leave all blank spaces and placeholder comments as-is.
|
||||
- **Exception**: If a human **explicitly asks** you to fill out the PR template, then fill it out **in Chinese** (简体中文).
|
||||
|
||||
## Quick-Reference Workflow
|
||||
|
||||
1. Make your changes
|
||||
2. Run `yarn typecheck` — fix any errors
|
||||
3. Run `yarn lint:fix` — fix any remaining lint errors
|
||||
4. Run relevant tests (`yarn test` for all, or workspace-specific)
|
||||
5. Stage files and commit with `[AI]` prefix — do not skip hooks
|
||||
6. When creating a PR:
|
||||
- Use `[AI]` prefix in the title
|
||||
- Add the `"AI generated"` label
|
||||
- Leave the PR template blank (do not fill it in)
|
||||
22
.github/PULL_REQUEST_TEMPLATE.md
vendored
22
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,21 +1 @@
|
||||
<!-- 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. -->
|
||||
|
||||
## Description
|
||||
|
||||
<!-- What does this PR do? Why is it needed? Please give context on the "why?": why do we need this change? What problem is it solving for you?-->
|
||||
|
||||
## Related issue(s)
|
||||
|
||||
<!-- e.g. Fixes #123, Relates to #456 -->
|
||||
|
||||
## Testing
|
||||
|
||||
<!-- What did you test? How can we reproduce the issue you are fixing or how can we test the feature you built? -->
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Release notes added (see link above)
|
||||
- [ ] No obvious regressions in affected areas
|
||||
- [ ] Self-review has been performed - I understand what each change in the code does and why it is needed
|
||||
|
||||
<!--- actual-bot-sections --->
|
||||
<!-- 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. -->
|
||||
|
||||
@@ -74,4 +74,4 @@ async function checkReleaseNotesExists() {
|
||||
}
|
||||
}
|
||||
|
||||
void checkReleaseNotesExists();
|
||||
checkReleaseNotesExists();
|
||||
|
||||
@@ -74,4 +74,4 @@ async function commentOnPR() {
|
||||
}
|
||||
}
|
||||
|
||||
void commentOnPR();
|
||||
commentOnPR();
|
||||
|
||||
@@ -16,19 +16,14 @@ if (!token || !repo || !issueNumber || !summaryDataJson || !category) {
|
||||
const [owner, repoName] = repo.split('/');
|
||||
const octokit = new Octokit({ auth: token });
|
||||
|
||||
const VALID_CATEGORIES = [
|
||||
'Features',
|
||||
'Bugfixes',
|
||||
'Enhancements',
|
||||
'Maintenance',
|
||||
];
|
||||
const GITHUB_USERNAME_RE =
|
||||
/^[a-zA-Z0-9](?:[a-zA-Z0-9]|-(?=[a-zA-Z0-9])){0,38}$/;
|
||||
|
||||
async function createReleaseNotesFile() {
|
||||
try {
|
||||
const summaryData = JSON.parse(summaryDataJson);
|
||||
|
||||
console.log('Debug - Category value:', category);
|
||||
console.log('Debug - Category type:', typeof category);
|
||||
console.log('Debug - Category JSON stringified:', JSON.stringify(category));
|
||||
|
||||
if (!summaryData) {
|
||||
console.log('No summary data available, cannot create file');
|
||||
return;
|
||||
@@ -39,62 +34,26 @@ async function createReleaseNotesFile() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalize category - strip surrounding quotes and validate against allow-list
|
||||
// Create file content - ensure category is not quoted
|
||||
const cleanCategory =
|
||||
typeof category === 'string'
|
||||
? category.replace(/^["']|["']$/g, '')
|
||||
: category;
|
||||
|
||||
if (!VALID_CATEGORIES.includes(cleanCategory)) {
|
||||
console.log(
|
||||
`Invalid category "${cleanCategory}". Must be one of: ${VALID_CATEGORIES.join(', ')}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate author is a plausible GitHub username
|
||||
const author = String(summaryData.author || '');
|
||||
if (!GITHUB_USERNAME_RE.test(author)) {
|
||||
console.log(
|
||||
`Invalid author "${author}", aborting release notes creation`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalize summary: collapse whitespace to a single line so it cannot
|
||||
// introduce extra YAML frontmatter or break the markdown structure.
|
||||
const cleanSummary = String(summaryData.summary || '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (!cleanSummary) {
|
||||
console.log('Empty summary, aborting release notes creation');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate PR number - must be a positive integer. The value comes from
|
||||
// the GitHub API, but we harden it because it's used to build a file path
|
||||
// and a commit message.
|
||||
const validatedPrNumber = Number(summaryData.prNumber);
|
||||
if (!Number.isInteger(validatedPrNumber) || validatedPrNumber <= 0) {
|
||||
console.log(
|
||||
`Invalid PR number "${summaryData.prNumber}", aborting release notes creation`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.log('Debug - Clean category:', cleanCategory);
|
||||
|
||||
const fileContent = `---
|
||||
category: ${cleanCategory}
|
||||
authors: [${author}]
|
||||
authors: [${summaryData.author}]
|
||||
---
|
||||
|
||||
${cleanSummary}
|
||||
${summaryData.summary}
|
||||
`;
|
||||
|
||||
const fileName = `upcoming-release-notes/${validatedPrNumber}.md`;
|
||||
const fileName = `upcoming-release-notes/${summaryData.prNumber}.md`;
|
||||
|
||||
console.log(
|
||||
`Creating release notes file: ${fileName} (category: ${cleanCategory}, author: ${author})`,
|
||||
);
|
||||
console.log(`Creating release notes file: ${fileName}`);
|
||||
console.log('File content:');
|
||||
console.log(fileContent);
|
||||
|
||||
// Get PR info
|
||||
const { data: pr } = await octokit.rest.pulls.get({
|
||||
@@ -116,7 +75,7 @@ ${cleanSummary}
|
||||
owner: headOwner,
|
||||
repo: headRepo,
|
||||
path: fileName,
|
||||
message: `Add release notes for PR #${validatedPrNumber}`,
|
||||
message: `Add release notes for PR #${summaryData.prNumber}`,
|
||||
content: Buffer.from(fileContent).toString('base64'),
|
||||
branch: prBranch,
|
||||
committer: {
|
||||
@@ -135,4 +94,4 @@ ${cleanSummary}
|
||||
}
|
||||
}
|
||||
|
||||
void createReleaseNotesFile();
|
||||
createReleaseNotesFile();
|
||||
|
||||
@@ -25,6 +25,8 @@ try {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log('CodeRabbit comment body:', commentBody);
|
||||
|
||||
const data = JSON.stringify({
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [
|
||||
|
||||
@@ -37,57 +37,19 @@ async function getPRDetails() {
|
||||
console.log('- PR Author:', pr.user.login);
|
||||
console.log('- PR Title:', pr.title);
|
||||
console.log('- Base Branch:', pr.base.ref);
|
||||
console.log('- Head Branch:', pr.head.ref);
|
||||
|
||||
// Fetch all changed files to detect docs-only PRs
|
||||
const files = await octokit.paginate(octokit.rest.pulls.listFiles, {
|
||||
owner,
|
||||
repo: repoName,
|
||||
pull_number: issueNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const changedFiles = files.map(f => f.filename);
|
||||
const isDocsOnly =
|
||||
changedFiles.length > 0 &&
|
||||
changedFiles.every(file => file.startsWith('packages/docs/'));
|
||||
|
||||
console.log('- Changed Files:', changedFiles.length);
|
||||
console.log('- Is Docs Only:', isDocsOnly);
|
||||
|
||||
const result = {
|
||||
number: pr.number,
|
||||
author: pr.user.login,
|
||||
title: pr.title,
|
||||
baseBranch: pr.base.ref,
|
||||
headBranch: pr.head.ref,
|
||||
};
|
||||
|
||||
let eligible = true;
|
||||
if (pr.base.ref !== 'master') {
|
||||
console.log(
|
||||
'PR does not target master branch, skipping release notes generation',
|
||||
);
|
||||
eligible = false;
|
||||
} else if (pr.head.ref.startsWith('release/')) {
|
||||
console.log(
|
||||
'PR head branch is a release branch, skipping release notes generation',
|
||||
);
|
||||
eligible = false;
|
||||
} else if (isDocsOnly) {
|
||||
console.log(
|
||||
'PR only changes documentation, skipping release notes generation',
|
||||
);
|
||||
eligible = false;
|
||||
}
|
||||
|
||||
setOutput('result', JSON.stringify(result));
|
||||
setOutput('eligible', JSON.stringify(eligible));
|
||||
} catch (error) {
|
||||
console.log('Error getting PR details:', error.message);
|
||||
console.log('Stack:', error.stack);
|
||||
setOutput('result', 'null');
|
||||
setOutput('eligible', 'false');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -96,6 +58,5 @@ getPRDetails().catch(error => {
|
||||
console.log('Unhandled error:', error.message);
|
||||
console.log('Stack:', error.stack);
|
||||
setOutput('result', 'null');
|
||||
setOutput('eligible', 'false');
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// overview:
|
||||
// 1. Identify the migrations in packages/loot-core/migrations/* on `master` and HEAD
|
||||
// 2. Make sure that any new migrations on HEAD are dated after the latest migration on `master`.
|
||||
|
||||
import { spawnSync } from 'child_process';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
const { spawnSync } = require('child_process');
|
||||
const path = require('path');
|
||||
|
||||
const migrationsDir = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'packages',
|
||||
@@ -16,7 +16,7 @@ const migrationsDir = path.join(
|
||||
'migrations',
|
||||
);
|
||||
|
||||
function readMigrations(ref: string) {
|
||||
function readMigrations(ref) {
|
||||
const { stdout } = spawnSync('git', [
|
||||
'ls-tree',
|
||||
'--name-only',
|
||||
1
.github/actions/docs-spelling/excludes.txt
vendored
1
.github/actions/docs-spelling/excludes.txt
vendored
@@ -68,6 +68,7 @@ ignore$
|
||||
^\Qsrc/\E$
|
||||
^\Qstatic/\E$
|
||||
^\Q.github/\E$
|
||||
(?:^|/)package(?:-lock|)\.json$
|
||||
(?:^|/)yarn\.lock$
|
||||
(?:^|/)(?i)docusaurus.config.js
|
||||
(?:^|/)(?i)README.md
|
||||
|
||||
13
.github/actions/docs-spelling/expect.txt
vendored
13
.github/actions/docs-spelling/expect.txt
vendored
@@ -2,9 +2,7 @@ Abanca
|
||||
ABNAMRO
|
||||
ABNANL
|
||||
Activo
|
||||
actualrc
|
||||
AESUDEF
|
||||
ajv
|
||||
ALZEY
|
||||
Anglais
|
||||
ANZ
|
||||
@@ -33,7 +31,6 @@ CAGLPTPL
|
||||
Caixa
|
||||
CAMT
|
||||
cashflow
|
||||
Catppuccin
|
||||
Cetelem
|
||||
cimode
|
||||
Citi
|
||||
@@ -61,7 +58,6 @@ Dockerfiles
|
||||
Dominguez
|
||||
DUSSDEDDXXX
|
||||
DUSSELDORF
|
||||
ecf
|
||||
EDATE
|
||||
ENTERCARD
|
||||
Entra
|
||||
@@ -127,7 +123,6 @@ Moldovan
|
||||
murmurhash
|
||||
NETWORKDAYS
|
||||
nginx
|
||||
nodenext
|
||||
OIDC
|
||||
Okabe
|
||||
overbudgeted
|
||||
@@ -135,12 +130,12 @@ overbudgeting
|
||||
oxc
|
||||
Paribas
|
||||
passwordless
|
||||
PAYPAL
|
||||
picomatch
|
||||
pluggyai
|
||||
Poste
|
||||
PPABPLPK
|
||||
prefs
|
||||
Primoco
|
||||
Priotecs
|
||||
proactively
|
||||
Qatari
|
||||
QNTOFRP
|
||||
@@ -171,14 +166,12 @@ SWEDBANK
|
||||
SWEDNOKK
|
||||
Synology
|
||||
systemctl
|
||||
tada
|
||||
taskbar
|
||||
templating
|
||||
THB
|
||||
TIMEFRAME
|
||||
touchscreen
|
||||
triaging
|
||||
tsgo
|
||||
TWD
|
||||
UAH
|
||||
ubuntu
|
||||
undici
|
||||
|
||||
3
.github/actions/docs-spelling/patterns.txt
vendored
3
.github/actions/docs-spelling/patterns.txt
vendored
@@ -79,6 +79,3 @@
|
||||
|
||||
# allowlist specific non-English words with non-ASCII characters
|
||||
\b(Länsförsäkringar|München|Złoty)\b
|
||||
|
||||
# allowlist specific proper nouns
|
||||
\b(CodeRabbit)\b
|
||||
|
||||
17
.github/actions/release-notes/check/action.yml
vendored
17
.github/actions/release-notes/check/action.yml
vendored
@@ -1,17 +0,0 @@
|
||||
name: Check release notes
|
||||
description: Validate that a PR includes a properly formatted release note file
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: yarn workspaces focus @actual-app/ci-actions
|
||||
- name: Check release notes
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
shell: bash
|
||||
run: node packages/ci-actions/bin/release-notes-check.mjs
|
||||
@@ -1,17 +0,0 @@
|
||||
name: Generate release notes
|
||||
description: Generate release documentation from release note files
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: yarn workspaces focus actual @actual-app/ci-actions
|
||||
- name: Generate release notes
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: node packages/ci-actions/bin/release-notes-generate.mjs
|
||||
13
.github/actions/setup/action.yml
vendored
13
.github/actions/setup/action.yml
vendored
@@ -15,7 +15,7 @@ runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Install node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Install yarn
|
||||
@@ -27,7 +27,7 @@ runs:
|
||||
run: echo "version=$(node -v)" >> "$GITHUB_OUTPUT"
|
||||
shell: bash
|
||||
- name: Cache
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
id: cache
|
||||
with:
|
||||
path: ${{ format('{0}/**/node_modules', inputs.working-directory) }}
|
||||
@@ -36,7 +36,7 @@ runs:
|
||||
run: mkdir -p ${{ format('{0}/.lage', inputs.working-directory) }}
|
||||
shell: bash
|
||||
- name: Cache Lage
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: ${{ format('{0}/.lage', inputs.working-directory) }}
|
||||
key: lage-${{ runner.os }}-${{ github.sha }}
|
||||
@@ -48,13 +48,12 @@ runs:
|
||||
shell: bash
|
||||
if: steps.cache.outputs.cache-hit != 'true'
|
||||
- name: Download translations
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
repository: actualbudget/translations
|
||||
path: ${{ inputs.working-directory }}/packages/desktop-client/locale
|
||||
persist-credentials: false
|
||||
if: ${{ inputs.download-translations == 'true' && !env.ACT }}
|
||||
if: ${{ inputs.download-translations == 'true' }}
|
||||
- name: Remove untranslated languages
|
||||
run: packages/desktop-client/bin/remove-untranslated-languages
|
||||
shell: bash
|
||||
if: ${{ inputs.download-translations == 'true' && !env.ACT }}
|
||||
if: ${{ inputs.download-translations == 'true' }}
|
||||
|
||||
70
.github/agents/pr-and-commit-rules.md
vendored
70
.github/agents/pr-and-commit-rules.md
vendored
@@ -1,70 +0,0 @@
|
||||
# PR and Commit Rules for AI Agents
|
||||
|
||||
This is the single source of truth for all commit and pull request rules that AI agents must follow when working with Actual Budget.
|
||||
|
||||
## Commit Rules
|
||||
|
||||
### [AI] Prefix Requirement
|
||||
|
||||
**ALL commit messages MUST be prefixed with `[AI]`.** This is a mandatory requirement with no exceptions.
|
||||
|
||||
**Examples:**
|
||||
|
||||
- `[AI] Fix type error in account validation`
|
||||
- `[AI] Add support for new transaction categories`
|
||||
- `Fix type error in account validation` (MISSING PREFIX - NOT ALLOWED)
|
||||
- `Add support for new transaction categories` (MISSING PREFIX - NOT ALLOWED)
|
||||
|
||||
### Git Safety Rules
|
||||
|
||||
- **Never** update git config
|
||||
- **Never** run destructive git operations (force push, hard reset) unless the user explicitly requests it
|
||||
- **Never** skip hooks (`--no-verify`, `--no-gpg-sign`)
|
||||
- **Never** force push to `main`/`master`
|
||||
- **Never** commit unless explicitly asked by the user
|
||||
|
||||
## Pre-Commit Quality Checklist
|
||||
|
||||
Before committing, ensure all of the following:
|
||||
|
||||
- [ ] Commit message is prefixed with `[AI]`
|
||||
- [ ] `yarn typecheck` passes
|
||||
- [ ] `yarn lint:fix` has been run
|
||||
- [ ] Relevant tests pass
|
||||
- [ ] User-facing strings are translated
|
||||
- [ ] Code style conventions followed (see `AGENTS.md` for full style guide)
|
||||
|
||||
## Pull Request Rules
|
||||
|
||||
### [AI] Prefix Requirement
|
||||
|
||||
**ALL pull request titles MUST be prefixed with `[AI]`.** This is a mandatory requirement with no exceptions.
|
||||
|
||||
**Examples:**
|
||||
|
||||
- `[AI] Fix type error in account validation`
|
||||
- `[AI] Add support for new transaction categories`
|
||||
- `Fix type error in account validation` (MISSING PREFIX - NOT ALLOWED)
|
||||
|
||||
### Labels
|
||||
|
||||
Add the **"AI generated"** label to all AI-created pull requests. This helps maintainers understand the nature of the contribution.
|
||||
|
||||
### PR Template: Do Not Fill In
|
||||
|
||||
- **NEVER fill in the PR template** (`.github/PULL_REQUEST_TEMPLATE.md`). Leave all blank spaces and placeholder comments as-is. Humans are expected to fill in the Description, Related issue(s), Testing, and Checklist sections.
|
||||
- **Exception**: If a human **explicitly asks** you to fill out the PR template, then fill it out **in Chinese**, using Chinese characters (简体中文) for all content you add.
|
||||
|
||||
## Quick-Reference Workflow
|
||||
|
||||
Follow these steps when committing and creating PRs:
|
||||
|
||||
1. Make your changes
|
||||
2. Run `yarn typecheck` — fix any errors
|
||||
3. Run `yarn lint:fix` — fix any remaining lint errors
|
||||
4. Run relevant tests (`yarn test` for all, or workspace-specific)
|
||||
5. Stage files and commit with `[AI]` prefix — do not skip hooks
|
||||
6. When creating a PR:
|
||||
- Use `[AI]` prefix in the title
|
||||
- Add the `"AI generated"` label
|
||||
- Leave the PR template blank (do not fill it in)
|
||||
134
.github/scripts/count-points.mjs
vendored
134
.github/scripts/count-points.mjs
vendored
@@ -8,13 +8,13 @@ const CONFIG = {
|
||||
POINTS_PER_ISSUE_TRIAGE_ACTION: 1,
|
||||
POINTS_PER_ISSUE_CLOSING_ACTION: 1,
|
||||
POINTS_PER_RELEASE_PR: 4, // Awarded to whoever merges the release PR
|
||||
PR_CONTRIBUTION_POINTS: [
|
||||
{ categories: ['Features'], points: 2 },
|
||||
{ categories: ['Enhancements'], points: 2 },
|
||||
{ categories: ['Bugfixes', 'Bugfix'], points: 3 },
|
||||
{ categories: ['Maintenance'], points: 2 },
|
||||
{ categories: ['Unknown'], points: 2 },
|
||||
],
|
||||
PR_CONTRIBUTION_POINTS: {
|
||||
Features: 2,
|
||||
Enhancements: 2,
|
||||
Bugfix: 3,
|
||||
Maintenance: 2,
|
||||
Unknown: 2,
|
||||
},
|
||||
// Point tiers for code changes (non-docs)
|
||||
CODE_PR_REVIEW_POINT_TIERS: [
|
||||
{ minChanges: 500, points: 8 },
|
||||
@@ -35,11 +35,7 @@ const CONFIG = {
|
||||
'release-notes/**/*',
|
||||
'upcoming-release-notes/**/*',
|
||||
],
|
||||
DOCS_FILES_PATTERNS: [
|
||||
'packages/docs/**/*',
|
||||
'!packages/docs/package.json',
|
||||
'.github/actions/docs-spelling/*',
|
||||
],
|
||||
DOCS_FILES_PATTERN: 'packages/docs/**/*',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -61,38 +57,84 @@ function parseReleaseNotesCategory(content) {
|
||||
return categoryMatch[1].trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last commit SHA on or before a given date.
|
||||
* @param {Octokit} octokit - The Octokit instance.
|
||||
* @param {string} owner - Repository owner.
|
||||
* @param {string} repo - Repository name.
|
||||
* @param {Date} beforeDate - The date to find the last commit before.
|
||||
* @returns {Promise<string|null>} The commit SHA or null if not found.
|
||||
*/
|
||||
async function getLastCommitBeforeDate(octokit, owner, repo, beforeDate) {
|
||||
try {
|
||||
// Get the default branch from the repository
|
||||
const { data: repoData } = await octokit.repos.get({ owner, repo });
|
||||
const defaultBranch = repoData.default_branch;
|
||||
|
||||
const { data: commits } = await octokit.repos.listCommits({
|
||||
owner,
|
||||
repo,
|
||||
sha: defaultBranch,
|
||||
until: beforeDate.toISOString(),
|
||||
per_page: 1,
|
||||
});
|
||||
|
||||
if (commits.length > 0) {
|
||||
return commits[0].sha;
|
||||
}
|
||||
} catch {
|
||||
// If error occurs, return null to fall back to default branch
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the category and points for a PR by reading its release notes file.
|
||||
* @param {Octokit} octokit - The Octokit instance.
|
||||
* @param {string} owner - Repository owner.
|
||||
* @param {string} repo - Repository name.
|
||||
* @param {string|null} releaseNoteBlobSha - The blob SHA of the release notes file, or null if not found.
|
||||
* @returns {Promise<Object>} Object with category and points.
|
||||
* @param {number} prNumber - PR number.
|
||||
* @param {Date} monthEnd - The end date of the month to use as base revision.
|
||||
* @returns {Object} Object with category and points, or null if error.
|
||||
*/
|
||||
async function getPRCategoryAndPoints(
|
||||
octokit,
|
||||
owner,
|
||||
repo,
|
||||
releaseNoteBlobSha,
|
||||
prNumber,
|
||||
monthEnd,
|
||||
) {
|
||||
const releaseNotesPath = `upcoming-release-notes/${prNumber}.md`;
|
||||
|
||||
try {
|
||||
if (releaseNoteBlobSha) {
|
||||
const { data: blob } = await octokit.git.getBlob({
|
||||
owner,
|
||||
repo,
|
||||
file_sha: releaseNoteBlobSha,
|
||||
});
|
||||
// Get the last commit of the month to use as base revision
|
||||
const commitSha = await getLastCommitBeforeDate(
|
||||
octokit,
|
||||
owner,
|
||||
repo,
|
||||
monthEnd,
|
||||
);
|
||||
|
||||
const content = Buffer.from(blob.content, 'base64').toString('utf-8');
|
||||
const category = parseReleaseNotesCategory(content);
|
||||
const tier = CONFIG.PR_CONTRIBUTION_POINTS.find(e =>
|
||||
e.categories.includes(category),
|
||||
// Try to read the release notes file from the last commit of the month
|
||||
const { data: fileContent } = await octokit.repos.getContent({
|
||||
owner,
|
||||
repo,
|
||||
path: releaseNotesPath,
|
||||
ref: commitSha || undefined, // Use commit SHA if available, otherwise default branch
|
||||
});
|
||||
|
||||
if (fileContent.content) {
|
||||
// Decode base64 content
|
||||
const content = Buffer.from(fileContent.content, 'base64').toString(
|
||||
'utf-8',
|
||||
);
|
||||
const category = parseReleaseNotesCategory(content);
|
||||
|
||||
if (tier) {
|
||||
if (category && CONFIG.PR_CONTRIBUTION_POINTS[category]) {
|
||||
return {
|
||||
category,
|
||||
points: tier.points,
|
||||
points: CONFIG.PR_CONTRIBUTION_POINTS[category],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -100,12 +142,9 @@ async function getPRCategoryAndPoints(
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
const unknownTier = CONFIG.PR_CONTRIBUTION_POINTS.find(e =>
|
||||
e.categories.includes('Unknown'),
|
||||
);
|
||||
return {
|
||||
category: 'Unknown',
|
||||
points: unknownTier.points,
|
||||
points: CONFIG.PR_CONTRIBUTION_POINTS.Unknown,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -231,25 +270,13 @@ async function countContributorPoints() {
|
||||
),
|
||||
);
|
||||
|
||||
const isDocsFile = file => {
|
||||
const positivePatterns = CONFIG.DOCS_FILES_PATTERNS.filter(
|
||||
p => !p.startsWith('!'),
|
||||
);
|
||||
const negativePatterns = CONFIG.DOCS_FILES_PATTERNS.filter(p =>
|
||||
p.startsWith('!'),
|
||||
);
|
||||
return (
|
||||
positivePatterns.some(p =>
|
||||
minimatch(file.filename, p, { dot: true }),
|
||||
) &&
|
||||
negativePatterns.every(p =>
|
||||
minimatch(file.filename, p, { dot: true }),
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const docsFiles = filteredFiles.filter(isDocsFile);
|
||||
const codeFiles = filteredFiles.filter(file => !isDocsFile(file));
|
||||
const docsFiles = filteredFiles.filter(file =>
|
||||
minimatch(file.filename, CONFIG.DOCS_FILES_PATTERN, { dot: true }),
|
||||
);
|
||||
const codeFiles = filteredFiles.filter(
|
||||
file =>
|
||||
!minimatch(file.filename, CONFIG.DOCS_FILES_PATTERN, { dot: true }),
|
||||
);
|
||||
|
||||
const docsChanges = docsFiles.reduce(
|
||||
(sum, file) => sum + file.additions + file.deletions,
|
||||
@@ -296,15 +323,12 @@ async function countContributorPoints() {
|
||||
// Award points to PR author if they are a core maintainer
|
||||
const prAuthor = pr.user?.login;
|
||||
if (prAuthor && orgMemberLogins.has(prAuthor)) {
|
||||
const releaseNoteFile = modifiedFiles.find(
|
||||
file =>
|
||||
file.filename === `upcoming-release-notes/${pr.number}.md`,
|
||||
);
|
||||
const categoryAndPoints = await getPRCategoryAndPoints(
|
||||
octokit,
|
||||
owner,
|
||||
repo,
|
||||
releaseNoteFile?.sha ?? null,
|
||||
pr.number,
|
||||
until,
|
||||
);
|
||||
|
||||
if (categoryAndPoints) {
|
||||
|
||||
27
.github/workflows/ai-generated-release-notes.yml
vendored
27
.github/workflows/ai-generated-release-notes.yml
vendored
@@ -17,9 +17,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
@@ -43,8 +41,21 @@ jobs:
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
|
||||
- name: Check if PR targets master branch
|
||||
if: steps.check-first-comment.outputs.result == 'true' && steps.pr-details.outputs.result != 'null'
|
||||
id: check-base-branch
|
||||
run: |
|
||||
BASE_BRANCH=$(echo '${{ steps.pr-details.outputs.result }}' | jq -r '.baseBranch')
|
||||
echo "Base branch: $BASE_BRANCH"
|
||||
if [ "$BASE_BRANCH" = "master" ]; then
|
||||
echo "targets_master=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "targets_master=false" >> $GITHUB_OUTPUT
|
||||
echo "PR does not target master branch, skipping release notes generation"
|
||||
fi
|
||||
|
||||
- name: Check if release notes file already exists
|
||||
if: steps.pr-details.outputs.eligible == 'true'
|
||||
if: steps.check-first-comment.outputs.result == 'true' && steps.pr-details.outputs.result != 'null' && steps.check-base-branch.outputs.targets_master == 'true'
|
||||
id: check-release-notes-exists
|
||||
run: node .github/actions/ai-generated-release-notes/check-release-notes-exists.js
|
||||
env:
|
||||
@@ -54,7 +65,7 @@ jobs:
|
||||
PR_DETAILS: ${{ steps.pr-details.outputs.result }}
|
||||
|
||||
- name: Generate summary with OpenAI
|
||||
if: steps.check-release-notes-exists.outputs.result == 'false'
|
||||
if: steps.check-first-comment.outputs.result == 'true' && steps.check-release-notes-exists.outputs.result == 'false'
|
||||
id: generate-summary
|
||||
run: node .github/actions/ai-generated-release-notes/generate-summary.js
|
||||
env:
|
||||
@@ -63,7 +74,7 @@ jobs:
|
||||
PR_DETAILS: ${{ steps.pr-details.outputs.result }}
|
||||
|
||||
- name: Determine category with OpenAI
|
||||
if: steps.generate-summary.outputs.result != 'null' && steps.generate-summary.outputs.result != ''
|
||||
if: steps.check-first-comment.outputs.result == 'true' && steps.check-release-notes-exists.outputs.result == 'false' && steps.generate-summary.outputs.result != 'null'
|
||||
id: determine-category
|
||||
run: node .github/actions/ai-generated-release-notes/determine-category.js
|
||||
env:
|
||||
@@ -73,7 +84,7 @@ jobs:
|
||||
SUMMARY_DATA: ${{ steps.generate-summary.outputs.result }}
|
||||
|
||||
- name: Create and commit release notes file via GitHub API
|
||||
if: steps.determine-category.outputs.result != 'null' && steps.determine-category.outputs.result != ''
|
||||
if: steps.check-first-comment.outputs.result == 'true' && steps.check-release-notes-exists.outputs.result == 'false' && steps.generate-summary.outputs.result != 'null' && steps.determine-category.outputs.result != 'null' && steps.determine-category.outputs.result != ''
|
||||
run: node .github/actions/ai-generated-release-notes/create-release-notes-file.js
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.ACTIONS_UPDATE_TOKEN }}
|
||||
@@ -83,7 +94,7 @@ jobs:
|
||||
CATEGORY: ${{ steps.determine-category.outputs.result }}
|
||||
|
||||
- name: Comment on PR
|
||||
if: steps.determine-category.outputs.result != 'null' && steps.determine-category.outputs.result != ''
|
||||
if: steps.check-first-comment.outputs.result == 'true' && steps.check-release-notes-exists.outputs.result == 'false' && steps.generate-summary.outputs.result != 'null' && steps.determine-category.outputs.result != 'null' && steps.determine-category.outputs.result != ''
|
||||
run: node .github/actions/ai-generated-release-notes/comment-on-pr.js
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
6
.github/workflows/autofix.yml
vendored
6
.github/workflows/autofix.yml
vendored
@@ -15,13 +15,11 @@ jobs:
|
||||
autofix:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
download-translations: 'false'
|
||||
- name: Format code
|
||||
run: yarn lint:fix
|
||||
- uses: autofix-ci/action@7a166d7532b277f34e16238930461bf77f9d7ed8 # v1.3.3
|
||||
- uses: autofix-ci/action@635ffb0c9798bd160680f18fd73371e355b85f27
|
||||
|
||||
82
.github/workflows/build.yml
vendored
82
.github/workflows/build.yml
vendored
@@ -19,54 +19,35 @@ concurrency:
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
|
||||
|
||||
jobs:
|
||||
setup:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
download-translations: 'false'
|
||||
|
||||
api:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
download-translations: 'false'
|
||||
- name: Build API
|
||||
run: yarn build:api
|
||||
run: cd packages/api && yarn build
|
||||
- name: Create package tgz
|
||||
run: cd packages/api && yarn pack && mv package.tgz actual-api.tgz
|
||||
- name: Prepare bundle stats artifact
|
||||
run: cp packages/api/app/stats.json api-stats.json
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: actual-api
|
||||
path: packages/api/actual-api.tgz
|
||||
- name: Upload API bundle stats
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: api-build-stats
|
||||
path: api-stats.json
|
||||
|
||||
crdt:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
@@ -75,76 +56,35 @@ jobs:
|
||||
run: cd packages/crdt && yarn build
|
||||
- name: Create package tgz
|
||||
run: cd packages/crdt && yarn pack && mv package.tgz actual-crdt.tgz
|
||||
- name: Prepare bundle stats artifact
|
||||
run: cp packages/crdt/dist/stats.json crdt-stats.json
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: actual-crdt
|
||||
path: packages/crdt/actual-crdt.tgz
|
||||
- name: Upload CRDT bundle stats
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: crdt-build-stats
|
||||
path: crdt-stats.json
|
||||
|
||||
web:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Build Web
|
||||
run: yarn build:browser
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: actual-web
|
||||
path: packages/desktop-client/build
|
||||
- name: Upload Build Stats
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: build-stats
|
||||
path: packages/desktop-client/build-stats
|
||||
|
||||
cli:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
download-translations: 'false'
|
||||
- name: Build CLI
|
||||
run: yarn build:cli
|
||||
- name: Create package tgz
|
||||
run: cd packages/cli && yarn pack && mv package.tgz actual-cli.tgz
|
||||
- name: Prepare bundle stats artifact
|
||||
run: cp packages/cli/dist/stats.json cli-stats.json
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: actual-cli
|
||||
path: packages/cli/actual-cli.tgz
|
||||
- name: Upload CLI bundle stats
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: cli-build-stats
|
||||
path: cli-stats.json
|
||||
|
||||
server:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
@@ -152,7 +92,7 @@ jobs:
|
||||
- name: Build Server
|
||||
run: yarn workspace @actual-app/sync-server build
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: sync-server
|
||||
path: packages/sync-server/build
|
||||
|
||||
68
.github/workflows/check.yml
vendored
68
.github/workflows/check.yml
vendored
@@ -12,40 +12,10 @@ concurrency:
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
|
||||
|
||||
jobs:
|
||||
setup:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
download-translations: 'false'
|
||||
constraints:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
download-translations: 'false'
|
||||
- name: Check dependency version consistency
|
||||
run: yarn constraints
|
||||
- name: Check tsconfig project references are in sync
|
||||
run: yarn check:tsconfig-references
|
||||
lint:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
@@ -53,12 +23,9 @@ jobs:
|
||||
- name: Lint
|
||||
run: yarn lint
|
||||
typecheck:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
@@ -66,12 +33,9 @@ jobs:
|
||||
- name: Typecheck
|
||||
run: yarn typecheck
|
||||
validate-cli:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
@@ -81,39 +45,23 @@ jobs:
|
||||
- name: Check that the built CLI works
|
||||
run: node packages/sync-server/build/bin/actual-server.js --version
|
||||
test:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
download-translations: 'false'
|
||||
- name: Test
|
||||
run: yarn test
|
||||
check-gh-actions:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
|
||||
|
||||
migrations:
|
||||
needs: setup
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
download-translations: 'false'
|
||||
node-version: 22
|
||||
- name: Check migrations
|
||||
run: yarn workspace @actual-app/ci-actions tsx bin/check-migrations.ts
|
||||
run: node ./.github/actions/check-migrations.js
|
||||
|
||||
8
.github/workflows/codeql.yml
vendored
8
.github/workflows/codeql.yml
vendored
@@ -22,16 +22,14 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: javascript
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: '/language:javascript'
|
||||
|
||||
4
.github/workflows/count-points.yml
vendored
4
.github/workflows/count-points.yml
vendored
@@ -16,9 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
|
||||
101
.github/workflows/cut-release-branch.yml
vendored
101
.github/workflows/cut-release-branch.yml
vendored
@@ -1,101 +0,0 @@
|
||||
name: Cut release branch
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# 17:00 UTC on the 25th of each month
|
||||
- cron: '0 17 25 * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: 'Commit or branch to release'
|
||||
required: true
|
||||
default: 'master'
|
||||
version:
|
||||
description: 'Version number for the release (optional)'
|
||||
required: false
|
||||
default: ''
|
||||
release-date:
|
||||
description: 'Expected release date, YYYY-MM-DD (optional)'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
cut-release-branch:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.inputs.ref || 'master' }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
download-translations: 'false'
|
||||
|
||||
- name: Bump package versions
|
||||
id: bump_package_versions
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
declare -A packages=(
|
||||
[web]="desktop-client"
|
||||
[electron]="desktop-electron"
|
||||
[sync]="sync-server"
|
||||
[api]="api"
|
||||
[cli]="cli"
|
||||
[core]="loot-core"
|
||||
)
|
||||
declare -A new_versions
|
||||
|
||||
for key in "${!packages[@]}"; do
|
||||
pkg="${packages[$key]}"
|
||||
|
||||
if [[ -n "$INPUT_VERSION" ]]; then
|
||||
version=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts \
|
||||
--package-json "./packages/$pkg/package.json" \
|
||||
--version "$INPUT_VERSION" \
|
||||
--update)
|
||||
else
|
||||
version=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts \
|
||||
--package-json "./packages/$pkg/package.json" \
|
||||
--type auto \
|
||||
--update)
|
||||
fi
|
||||
|
||||
new_versions[$key]="$version"
|
||||
done
|
||||
|
||||
echo "version=${new_versions[web]}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Compute release date
|
||||
id: release_date
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_DATE: ${{ github.event.inputs['release-date'] }}
|
||||
run: |
|
||||
if [[ -n "$INPUT_DATE" ]]; then
|
||||
echo "date=$INPUT_DATE" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
# default to the 1st of next month
|
||||
echo "date=$(date -d '+1 month' '+%Y-%m-01')" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Create release branch and PR
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
token: ${{ secrets.ACTIONS_UPDATE_TOKEN }}
|
||||
commit-message: '🔖 (${{ steps.bump_package_versions.outputs.version }})'
|
||||
title: '🔖 (${{ steps.bump_package_versions.outputs.version }})'
|
||||
body: |
|
||||
Generated by [cut-release-branch.yml](../tree/master/.github/workflows/cut-release-branch.yml)
|
||||
|
||||
<!-- release-date:${{ steps.release_date.outputs.date }} -->
|
||||
branch: 'release/${{ steps.bump_package_versions.outputs.version }}'
|
||||
base: master
|
||||
22
.github/workflows/docker-edge.yml
vendored
22
.github/workflows/docker-edge.yml
vendored
@@ -36,19 +36,17 @@ jobs:
|
||||
matrix:
|
||||
os: [ubuntu, alpine]
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
|
||||
with:
|
||||
# Push to both Docker Hub and Github Container Registry
|
||||
images: ${{ env.IMAGES }}
|
||||
@@ -56,14 +54,14 @@ jobs:
|
||||
tags: ${{ env.TAGS }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
if: github.event_name != 'pull_request' && !github.event.repository.fork
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
registry: ghcr.io
|
||||
@@ -78,7 +76,7 @@ jobs:
|
||||
run: yarn build:server
|
||||
|
||||
- name: Build image for testing
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
@@ -89,13 +87,13 @@ jobs:
|
||||
- name: Test that the docker image boots
|
||||
run: |
|
||||
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
|
||||
sleep 5
|
||||
curl --fail -sS -LI -w '%{http_code}\n' --retry 10 --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/
|
||||
- name: Build and push images
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
20
.github/workflows/docker-release.yml
vendored
20
.github/workflows/docker-release.yml
vendored
@@ -28,19 +28,17 @@ jobs:
|
||||
name: Build Docker image
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
|
||||
with:
|
||||
# Push to both Docker Hub and Github Container Registry
|
||||
images: ${{ env.IMAGES }}
|
||||
@@ -50,7 +48,7 @@ jobs:
|
||||
|
||||
- name: Docker meta for Alpine image
|
||||
id: alpine-meta
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
|
||||
with:
|
||||
images: ${{ env.IMAGES }}
|
||||
# Automatically update :latest
|
||||
@@ -60,13 +58,13 @@ jobs:
|
||||
tags: ${{ env.TAGS }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -80,7 +78,7 @@ jobs:
|
||||
run: yarn build:server
|
||||
|
||||
- name: Build and push ubuntu image
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
@@ -89,7 +87,7 @@ jobs:
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
|
||||
- name: Build and push alpine image
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
||||
14
.github/workflows/docs-spelling.yml
vendored
14
.github/workflows/docs-spelling.yml
vendored
@@ -79,12 +79,12 @@ jobs:
|
||||
steps:
|
||||
- name: check-spelling
|
||||
id: spelling
|
||||
uses: check-spelling/check-spelling@cfb6f7e75bbfc89c71eaa30366d0c166f1bd9c8c # main
|
||||
uses: check-spelling/check-spelling@main
|
||||
with:
|
||||
suppress_push_for_open_pull_request: 1
|
||||
checkout: true
|
||||
check_file_names: 1
|
||||
spell_check_this: check-spelling/spell-check-this@92453f59cac8985482dfc301a8ece4c6d7de8a0c # prerelease
|
||||
spell_check_this: check-spelling/spell-check-this@prerelease
|
||||
post_comment: 0
|
||||
use_magic_file: 1
|
||||
experimental_apply_changes_via_bot: 1
|
||||
@@ -114,10 +114,10 @@ jobs:
|
||||
if: (success() || failure()) && needs.spelling.outputs.followup && github.event_name == 'push'
|
||||
steps:
|
||||
- name: comment
|
||||
uses: check-spelling/check-spelling@cfb6f7e75bbfc89c71eaa30366d0c166f1bd9c8c # main
|
||||
uses: check-spelling/check-spelling@main
|
||||
with:
|
||||
checkout: true
|
||||
spell_check_this: check-spelling/spell-check-this@92453f59cac8985482dfc301a8ece4c6d7de8a0c # prerelease
|
||||
spell_check_this: check-spelling/spell-check-this@prerelease
|
||||
task: ${{ needs.spelling.outputs.followup }}
|
||||
config: .github/actions/docs-spelling
|
||||
|
||||
@@ -131,10 +131,10 @@ jobs:
|
||||
if: (success() || failure()) && needs.spelling.outputs.followup && contains(github.event_name, 'pull_request')
|
||||
steps:
|
||||
- name: comment
|
||||
uses: check-spelling/check-spelling@cfb6f7e75bbfc89c71eaa30366d0c166f1bd9c8c # main
|
||||
uses: check-spelling/check-spelling@main
|
||||
with:
|
||||
checkout: true
|
||||
spell_check_this: check-spelling/spell-check-this@92453f59cac8985482dfc301a8ece4c6d7de8a0c # prerelease
|
||||
spell_check_this: check-spelling/spell-check-this@prerelease
|
||||
task: ${{ needs.spelling.outputs.followup }}
|
||||
experimental_apply_changes_via_bot: 1
|
||||
config: .github/actions/docs-spelling
|
||||
@@ -156,7 +156,7 @@ jobs:
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: apply spelling updates
|
||||
uses: check-spelling/check-spelling@cfb6f7e75bbfc89c71eaa30366d0c166f1bd9c8c # main
|
||||
uses: check-spelling/check-spelling@main
|
||||
with:
|
||||
experimental_apply_changes_via_bot: 1
|
||||
checkout: true
|
||||
|
||||
116
.github/workflows/e2e-test.yml
vendored
116
.github/workflows/e2e-test.yml
vendored
@@ -17,80 +17,32 @@ on:
|
||||
env:
|
||||
GITHUB_PR_NUMBER: ${{github.event.pull_request.number}}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-web:
|
||||
name: Build web bundle
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.59.1-jammy
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
download-translations: 'false'
|
||||
- name: Trust the repository directory
|
||||
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
- 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
|
||||
|
||||
functional:
|
||||
name: Functional (shard ${{ matrix.shard }}/3)
|
||||
name: Functional (shard ${{ matrix.shard }}/5)
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-web
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3]
|
||||
shard: [1, 2, 3, 4, 5]
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.59.1-jammy
|
||||
env:
|
||||
E2E_USE_BUILD: '1'
|
||||
image: mcr.microsoft.com/playwright:v1.57.0-jammy
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
download-translations: 'false'
|
||||
- name: Trust the repository directory
|
||||
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
- name: Download web build
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: desktop-client-build
|
||||
path: packages/desktop-client/build/
|
||||
- name: Run E2E Tests
|
||||
run: yarn e2e --shard=${{ matrix.shard }}/3
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: failure()
|
||||
run: yarn e2e --shard=${{ matrix.shard }}/5
|
||||
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
if: always()
|
||||
with:
|
||||
name: desktop-client-test-results-shard-${{ matrix.shard }}
|
||||
path: packages/desktop-client/test-results/
|
||||
@@ -101,27 +53,19 @@ jobs:
|
||||
name: Functional Desktop App
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.59.1-jammy
|
||||
image: mcr.microsoft.com/playwright:v1.57.0-jammy
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
download-translations: 'false'
|
||||
- name: Trust the repository directory
|
||||
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
|
||||
# Build tools are needed to rebuild native modules like better-sqlite3 used by the Desktop app, which is required to run E2E tests on the Desktop app.
|
||||
- name: Install build tools
|
||||
run: apt-get update && apt-get install -y build-essential python3
|
||||
|
||||
- name: Run Desktop app E2E Tests
|
||||
run: |
|
||||
yarn rebuild-electron
|
||||
xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" -- yarn e2e:desktop
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
if: always()
|
||||
with:
|
||||
name: desktop-app-test-results
|
||||
@@ -130,35 +74,23 @@ jobs:
|
||||
overwrite: true
|
||||
|
||||
vrt:
|
||||
name: Visual regression (shard ${{ matrix.shard }}/3)
|
||||
name: Visual regression (shard ${{ matrix.shard }}/5)
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-web
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3]
|
||||
shard: [1, 2, 3, 4, 5]
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.59.1-jammy
|
||||
env:
|
||||
E2E_USE_BUILD: '1'
|
||||
image: mcr.microsoft.com/playwright:v1.57.0-jammy
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
download-translations: 'false'
|
||||
- name: Trust the repository directory
|
||||
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
- 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
|
||||
run: yarn vrt --shard=${{ matrix.shard }}/3
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
run: yarn vrt --shard=${{ matrix.shard }}/5
|
||||
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
if: always()
|
||||
with:
|
||||
name: vrt-blob-report-${{ matrix.shard }}
|
||||
@@ -172,15 +104,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ !cancelled() }}
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.59.1-jammy
|
||||
image: mcr.microsoft.com/playwright:v1.57.0-jammy
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Download all blob reports
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
path: packages/desktop-client/all-blob-reports
|
||||
pattern: vrt-blob-report-*
|
||||
@@ -188,7 +118,7 @@ jobs:
|
||||
- name: Merge reports
|
||||
id: merge-reports
|
||||
run: yarn workspace @actual-app/web run playwright merge-reports --reporter html ./all-blob-reports
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
id: playwright-report-vrt
|
||||
with:
|
||||
name: html-report--attempt-${{ github.run_attempt }}
|
||||
@@ -201,12 +131,10 @@ jobs:
|
||||
mkdir -p vrt-metadata
|
||||
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:
|
||||
STEPS_PLAYWRIGHT_REPORT_VRT_OUTPUTS_ARTIFACT_URL: ${{ steps.playwright-report-vrt.outputs.artifact-url }}
|
||||
echo "${{ steps.playwright-report-vrt.outputs.artifact-url }}" > vrt-metadata/artifact-url.txt
|
||||
- name: Upload VRT metadata
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: vrt-comment-metadata
|
||||
path: vrt-metadata/
|
||||
|
||||
4
.github/workflows/e2e-vrt-comment.yml
vendored
4
.github/workflows/e2e-vrt-comment.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
||||
if: github.event.workflow_run.event == 'pull_request'
|
||||
steps:
|
||||
- name: Download VRT metadata
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
|
||||
- name: Comment on PR with VRT report link
|
||||
if: steps.metadata.outputs.should_comment == 'true'
|
||||
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
||||
uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
|
||||
with:
|
||||
number: ${{ steps.metadata.outputs.pr_number }}
|
||||
header: vrt-comment
|
||||
|
||||
67
.github/workflows/electron-master.yml
vendored
67
.github/workflows/electron-master.yml
vendored
@@ -22,7 +22,6 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-22.04
|
||||
@@ -30,9 +29,7 @@ jobs:
|
||||
- macos-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- if: ${{ startsWith(matrix.os, 'windows') }}
|
||||
run: pip.exe install setuptools
|
||||
- if: ${{ ! startsWith(matrix.os, 'windows') }}
|
||||
@@ -59,11 +56,9 @@ jobs:
|
||||
|
||||
METAINFO_FILE="packages/desktop-electron/extra-resources/linux/com.actualbudget.actual.metainfo.xml"
|
||||
TODAY=$(date +%Y-%m-%d)
|
||||
VERSION=${STEPS_PROCESS_VERSION_OUTPUTS_VERSION}
|
||||
VERSION=${{ steps.process_version.outputs.version }}
|
||||
sed -i "s/%RELEASE_VERSION%/$VERSION/g; s/%RELEASE_DATE%/$TODAY/g" "$METAINFO_FILE"
|
||||
flatpak run --command=flatpak-builder-lint org.flatpak.Builder appstream "$METAINFO_FILE"
|
||||
env:
|
||||
STEPS_PROCESS_VERSION_OUTPUTS_VERSION: ${{ steps.process_version.outputs.version }}
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Build Electron for Mac
|
||||
@@ -79,7 +74,7 @@ jobs:
|
||||
if: ${{ ! startsWith(matrix.os, 'macos') }}
|
||||
run: ./bin/package-electron
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: actual-electron-${{ matrix.os }}
|
||||
path: |
|
||||
@@ -90,13 +85,13 @@ jobs:
|
||||
packages/desktop-electron/dist/*.flatpak
|
||||
- name: Upload Windows Store Build
|
||||
if: ${{ startsWith(matrix.os, 'windows') }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: actual-electron-${{ matrix.os }}-appx
|
||||
path: |
|
||||
packages/desktop-electron/dist/*.appx
|
||||
- name: Add to new release
|
||||
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1
|
||||
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
|
||||
with:
|
||||
draft: true
|
||||
body: |
|
||||
@@ -131,7 +126,7 @@ jobs:
|
||||
Install-Module -Name StoreBroker -AcceptLicense -Force -Scope CurrentUser -Verbose
|
||||
|
||||
- name: Download Microsoft Store artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: actual-electron-windows-latest-appx
|
||||
|
||||
@@ -161,3 +156,53 @@ jobs:
|
||||
-NoStatus `
|
||||
-AutoCommit `
|
||||
-Force
|
||||
|
||||
publish-flathub:
|
||||
needs: build
|
||||
runs-on: ubuntu-22.04
|
||||
if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }}
|
||||
steps:
|
||||
- name: Download Linux artifacts
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: actual-electron-ubuntu-22.04
|
||||
|
||||
- name: Calculate AppImage SHA256
|
||||
id: appimage_sha256
|
||||
run: |
|
||||
APPIMAGE_X64_SHA256=$(sha256sum Actual-linux-x86_64.AppImage | awk '{ print $1 }')
|
||||
APPIMAGE_ARM64_SHA256=$(sha256sum Actual-linux-arm64.AppImage | awk '{ print $1 }')
|
||||
echo "APPIMAGE_X64_SHA256=$APPIMAGE_X64_SHA256" >> "$GITHUB_ENV"
|
||||
echo "APPIMAGE_ARM64_SHA256=$APPIMAGE_ARM64_SHA256" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Checkout Flathub repo
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: flathub/com.actualbudget.actual
|
||||
token: ${{ secrets.FLATHUB_GITHUB_TOKEN }}
|
||||
|
||||
- name: Update manifest with new SHA256
|
||||
run: |
|
||||
# Replace x86_64 entry
|
||||
sed -i "/x86_64.AppImage/{n;s|sha256:.*|sha256: ${{ env.APPIMAGE_X64_SHA256 }}|}" com.actualbudget.actual.yml
|
||||
sed -i "/x86_64.AppImage/s|url:.*|url: https://github.com/actualbudget/actual/releases/download/v${{ needs.build.outputs.version }}/Actual-linux-x86_64.AppImage|" com.actualbudget.actual.yml
|
||||
|
||||
# Replace arm64 entry
|
||||
sed -i "/arm64.AppImage/{n;s|sha256:.*|sha256: ${{ env.APPIMAGE_ARM64_SHA256 }}|}" com.actualbudget.actual.yml
|
||||
sed -i "/arm64.AppImage/s|url:.*|url: https://github.com/actualbudget/actual/releases/download/v${{ needs.build.outputs.version }}/Actual-linux-arm64.AppImage|" com.actualbudget.actual.yml
|
||||
|
||||
cat com.actualbudget.actual.yml
|
||||
|
||||
- name: Create PR in Flathub repo
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
token: ${{ secrets.FLATHUB_GITHUB_TOKEN }}
|
||||
commit-message: 'Update Actual flatpak to version ${{ needs.build.outputs.version }}'
|
||||
branch: 'release/${{ needs.build.outputs.version }}'
|
||||
draft: true
|
||||
title: 'Update Actual flatpak to version ${{ needs.build.outputs.version }}'
|
||||
body: |
|
||||
This PR updates the Actual desktop flatpak to version ${{ needs.build.outputs.version }}.
|
||||
|
||||
:link: [View release notes](https://actualbudget.org/blog/release-${{ needs.build.outputs.version }})
|
||||
reviewers: 'jfdoming,MatissJanis,youngcw' # The core team that have accepted the collaborator access to the Flathub repo
|
||||
|
||||
29
.github/workflows/electron-pr.yml
vendored
29
.github/workflows/electron-pr.yml
vendored
@@ -26,7 +26,6 @@ concurrency:
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-22.04
|
||||
@@ -34,9 +33,7 @@ jobs:
|
||||
- macos-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- if: ${{ startsWith(matrix.os, 'windows') }}
|
||||
run: pip.exe install setuptools
|
||||
- if: ${{ ! startsWith(matrix.os, 'windows') }}
|
||||
@@ -45,8 +42,6 @@ jobs:
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
python3 -m pip install setuptools
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- if: ${{ startsWith(matrix.os, 'ubuntu') }}
|
||||
name: Setup Flatpak dependencies
|
||||
run: |
|
||||
@@ -61,63 +56,65 @@ jobs:
|
||||
|
||||
METAINFO_FILE="packages/desktop-electron/extra-resources/linux/com.actualbudget.actual.metainfo.xml"
|
||||
TODAY=$(date +%Y-%m-%d)
|
||||
VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/desktop-electron/package.json --type nightly)
|
||||
VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/desktop-electron/package.json --type nightly)
|
||||
sed -i "s/%RELEASE_VERSION%/$VERSION/g; s/%RELEASE_DATE%/$TODAY/g" "$METAINFO_FILE"
|
||||
flatpak run --command=flatpak-builder-lint org.flatpak.Builder appstream "$METAINFO_FILE"
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Build Electron
|
||||
run: ./bin/package-electron
|
||||
|
||||
- name: Upload Linux x64 AppImage
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: Actual-linux-x86_64.AppImage
|
||||
if-no-files-found: ignore
|
||||
path: packages/desktop-electron/dist/Actual-linux-x86_64.AppImage
|
||||
|
||||
- name: Upload Linux arm64 AppImage
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: Actual-linux-arm64.AppImage
|
||||
if-no-files-found: ignore
|
||||
path: packages/desktop-electron/dist/Actual-linux-arm64.AppImage
|
||||
|
||||
- name: Upload Linux x64 flatpak
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: Actual-linux-x86_64.flatpak
|
||||
if-no-files-found: ignore
|
||||
path: packages/desktop-electron/dist/Actual-linux-x86_64.flatpak
|
||||
|
||||
- name: Upload Windows x32 exe
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: Actual-windows-ia32.exe
|
||||
if-no-files-found: ignore
|
||||
path: packages/desktop-electron/dist/Actual-windows-ia32.exe
|
||||
|
||||
- name: Upload Windows x64 exe
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: Actual-windows-x64.exe
|
||||
if-no-files-found: ignore
|
||||
path: packages/desktop-electron/dist/Actual-windows-x64.exe
|
||||
|
||||
- name: Upload Windows arm64 exe
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: Actual-windows-arm64.exe
|
||||
if-no-files-found: ignore
|
||||
path: packages/desktop-electron/dist/Actual-windows-arm64.exe
|
||||
|
||||
- name: Upload Mac x64 dmg
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: Actual-mac-x64.dmg
|
||||
if-no-files-found: ignore
|
||||
path: packages/desktop-electron/dist/Actual-mac-x64.dmg
|
||||
|
||||
- name: Upload Mac arm64 dmg
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: Actual-mac-arm64.dmg
|
||||
if-no-files-found: ignore
|
||||
@@ -125,7 +122,7 @@ jobs:
|
||||
|
||||
- name: Upload Windows Store Build
|
||||
if: ${{ startsWith(matrix.os, 'windows') }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: actual-electron-${{ matrix.os }}-appx
|
||||
path: |
|
||||
|
||||
2
.github/workflows/fork-pr-welcome.yml
vendored
2
.github/workflows/fork-pr-welcome.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
if: github.event.pull_request.head.repo.full_name != github.repository
|
||||
steps:
|
||||
- name: Post welcome comment
|
||||
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
||||
uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
number: ${{ github.event.pull_request.number }}
|
||||
|
||||
60
.github/workflows/generate-release-pr.yml
vendored
Normal file
60
.github/workflows/generate-release-pr.yml
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
name: Generate release PR
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: 'Commit or branch to release'
|
||||
required: true
|
||||
default: 'master'
|
||||
version:
|
||||
description: 'Version number for the release (optional)'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
jobs:
|
||||
generate-release-pr:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
ref: ${{ github.event.inputs.ref }}
|
||||
- name: Bump package versions
|
||||
id: bump_package_versions
|
||||
shell: bash
|
||||
run: |
|
||||
declare -A packages=(
|
||||
[web]="desktop-client"
|
||||
[electron]="desktop-electron"
|
||||
[sync]="sync-server"
|
||||
[api]="api"
|
||||
)
|
||||
|
||||
for key in "${!packages[@]}"; do
|
||||
pkg="${packages[$key]}"
|
||||
|
||||
if [[ -n "${{ github.event.inputs.version }}" ]]; then
|
||||
version=$(node ./packages/ci-actions/bin/get-next-package-version.js \
|
||||
--package-json "./packages/$pkg/package.json" \
|
||||
--version "${{ github.event.inputs.version }}" \
|
||||
--update)
|
||||
else
|
||||
version=$(node ./packages/ci-actions/bin/get-next-package-version.js \
|
||||
--package-json "./packages/$pkg/package.json" \
|
||||
--type auto \
|
||||
--update)
|
||||
fi
|
||||
|
||||
eval "NEW_${key^^}_VERSION=\"$version\""
|
||||
done
|
||||
|
||||
echo "version=$NEW_WEB_VERSION" >> "$GITHUB_OUTPUT"
|
||||
- name: Create PR
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
|
||||
with:
|
||||
token: ${{ secrets.ACTIONS_UPDATE_TOKEN }}
|
||||
commit-message: '🔖 (${{ steps.bump_package_versions.outputs.version }})'
|
||||
title: '🔖 (${{ steps.bump_package_versions.outputs.version }})'
|
||||
body: 'Generated by [generate-release-pr.yml](../tree/master/.github/workflows/generate-release-pr.yml)'
|
||||
branch: 'release/v${{ steps.bump_package_versions.outputs.version }}'
|
||||
23
.github/workflows/i18n-string-extract-master.yml
vendored
23
.github/workflows/i18n-string-extract-master.yml
vendored
@@ -12,10 +12,9 @@ jobs:
|
||||
if: github.repository == 'actualbudget/actual'
|
||||
steps:
|
||||
- name: Check out main repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
path: actual
|
||||
persist-credentials: false
|
||||
- name: Set up environment
|
||||
uses: ./actual/.github/actions/setup
|
||||
with:
|
||||
@@ -28,23 +27,12 @@ jobs:
|
||||
- name: Configure i18n client
|
||||
run: |
|
||||
pip install wlc
|
||||
- name: Configure Weblate API credentials
|
||||
env:
|
||||
WEBLATE_API_KEY: ${{ secrets.WEBLATE_API_KEY_CI_STRINGS }}
|
||||
run: |
|
||||
# Write the API key to wlc's config file instead of passing it on
|
||||
# the command line, so the secret doesn't appear in process listings.
|
||||
mkdir -p "$HOME/.config"
|
||||
umask 077
|
||||
cat > "$HOME/.config/weblate" <<EOF
|
||||
[keys]
|
||||
https://hosted.weblate.org/api/ = ${WEBLATE_API_KEY}
|
||||
EOF
|
||||
|
||||
- name: Lock translations
|
||||
run: |
|
||||
wlc \
|
||||
--url https://hosted.weblate.org/api/ \
|
||||
--key "${{ secrets.WEBLATE_API_KEY_CI_STRINGS }}" \
|
||||
lock \
|
||||
actualbudget/actual
|
||||
|
||||
@@ -52,16 +40,15 @@ jobs:
|
||||
run: |
|
||||
wlc \
|
||||
--url https://hosted.weblate.org/api/ \
|
||||
--key "${{ secrets.WEBLATE_API_KEY_CI_STRINGS }}" \
|
||||
push \
|
||||
actualbudget/actual
|
||||
- name: Check out updated translations
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
ssh-key: ${{ secrets.STRING_IMPORT_DEPLOY_KEY }}
|
||||
repository: actualbudget/translations
|
||||
path: translations
|
||||
# Need to be able to push back extracted strings
|
||||
persist-credentials: true
|
||||
- name: Generate i18n strings
|
||||
working-directory: actual
|
||||
run: |
|
||||
@@ -86,6 +73,7 @@ jobs:
|
||||
run: |
|
||||
wlc \
|
||||
--url https://hosted.weblate.org/api/ \
|
||||
--key "${{ secrets.WEBLATE_API_KEY_CI_STRINGS }}" \
|
||||
pull \
|
||||
actualbudget/actual
|
||||
|
||||
@@ -94,5 +82,6 @@ jobs:
|
||||
run: |
|
||||
wlc \
|
||||
--url https://hosted.weblate.org/api/ \
|
||||
--key "${{ secrets.WEBLATE_API_KEY_CI_STRINGS }}" \
|
||||
unlock \
|
||||
actualbudget/actual
|
||||
|
||||
@@ -24,10 +24,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# This is not a security concern because we have approved & merged the PR
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Handle feature requests
|
||||
|
||||
37
.github/workflows/merge-freeze-unfreeze.yml
vendored
37
.github/workflows/merge-freeze-unfreeze.yml
vendored
@@ -1,37 +0,0 @@
|
||||
# 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."
|
||||
9
.github/workflows/netlify-release.yml
vendored
9
.github/workflows/netlify-release.yml
vendored
@@ -21,9 +21,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Repository Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
@@ -36,11 +34,10 @@ jobs:
|
||||
|
||||
- name: Deploy to Netlify
|
||||
id: netlify_deploy
|
||||
env:
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_API_TOKEN }}
|
||||
run: |
|
||||
netlify deploy \
|
||||
--dir packages/desktop-client/build \
|
||||
--site ${{ secrets.NETLIFY_SITE_ID }} \
|
||||
--auth ${{ secrets.NETLIFY_API_TOKEN }} \
|
||||
--filter @actual-app/web \
|
||||
--prod
|
||||
|
||||
47
.github/workflows/nightly-theme-catalog-scan.yml
vendored
47
.github/workflows/nightly-theme-catalog-scan.yml
vendored
@@ -1,47 +0,0 @@
|
||||
name: Nightly theme catalog scan
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# 05:15 UTC daily — runs after the i18n extract job (04:00) and well
|
||||
# before the nightly Electron/npm publishes (00:00 UTC the next day).
|
||||
- cron: '15 5 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate-theme-catalog:
|
||||
name: Validate custom theme catalog
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'actualbudget/actual'
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
download-translations: 'false'
|
||||
- name: Validate themes
|
||||
run: yarn workspace @actual-app/web validate:theme-catalog
|
||||
|
||||
notify-failure:
|
||||
name: Notify Discord on failure
|
||||
needs: validate-theme-catalog
|
||||
if: failure() && github.repository == 'actualbudget/actual'
|
||||
runs-on: ubuntu-latest
|
||||
environment: nightly-alerts
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Notify Discord
|
||||
uses: sarisia/actions-status-discord@eb045afee445dc055c18d3d90bd0f244fd062708 # v1.16.0
|
||||
with:
|
||||
webhook: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
status: Failure
|
||||
title: Nightly theme catalog scan failed
|
||||
description: The nightly scan failed. One or more themes may be broken, or the scan itself did not complete.
|
||||
username: Actual Nightly
|
||||
nofail: true
|
||||
25
.github/workflows/pr-ai-label-cleanup.yml
vendored
Normal file
25
.github/workflows/pr-ai-label-cleanup.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
name: Remove 'suspect ai generated' label when 'AI generated' is present
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [labeled]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
remove-suspect-label:
|
||||
if: >-
|
||||
${{ contains(github.event.pull_request.labels.*.name, 'AI generated') &&
|
||||
contains(github.event.pull_request.labels.*.name, 'suspect ai generated') }}
|
||||
runs-on: ubuntu-slim
|
||||
steps:
|
||||
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
name: 'suspect ai generated'
|
||||
});
|
||||
132
.github/workflows/publish-flathub.yml
vendored
132
.github/workflows/publish-flathub.yml
vendored
@@ -1,132 +0,0 @@
|
||||
name: Publish Flathub
|
||||
|
||||
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-flathub
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish-flathub:
|
||||
runs-on: ubuntu-22.04
|
||||
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 -qx "Actual-linux-x86_64.AppImage"; then
|
||||
echo "::error::Missing asset: Actual-linux-x86_64.AppImage"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! echo "$ASSETS" | grep -qx "Actual-linux-arm64.AppImage"; then
|
||||
echo "::error::Missing asset: Actual-linux-arm64.AppImage"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "All required AppImage assets found."
|
||||
|
||||
- name: Calculate AppImage SHA256 (streamed)
|
||||
run: |
|
||||
VERSION="${STEPS_RESOLVE_VERSION_OUTPUTS_VERSION}"
|
||||
BASE_URL="https://github.com/${{ github.repository }}/releases/download/v${VERSION}"
|
||||
|
||||
echo "Streaming x86_64 AppImage to compute SHA256..."
|
||||
APPIMAGE_X64_SHA256=$(curl -fSL "${BASE_URL}/Actual-linux-x86_64.AppImage" | sha256sum | awk '{ print $1 }')
|
||||
echo "x86_64 SHA256: $APPIMAGE_X64_SHA256"
|
||||
|
||||
echo "Streaming arm64 AppImage to compute SHA256..."
|
||||
APPIMAGE_ARM64_SHA256=$(curl -fSL "${BASE_URL}/Actual-linux-arm64.AppImage" | sha256sum | awk '{ print $1 }')
|
||||
echo "arm64 SHA256: $APPIMAGE_ARM64_SHA256"
|
||||
|
||||
echo "APPIMAGE_X64_SHA256=$APPIMAGE_X64_SHA256" >> "$GITHUB_ENV"
|
||||
echo "APPIMAGE_ARM64_SHA256=$APPIMAGE_ARM64_SHA256" >> "$GITHUB_ENV"
|
||||
env:
|
||||
STEPS_RESOLVE_VERSION_OUTPUTS_VERSION: ${{ steps.resolve_version.outputs.version }}
|
||||
|
||||
- name: Checkout Flathub repo
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
repository: flathub/com.actualbudget.actual
|
||||
token: ${{ secrets.FLATHUB_GITHUB_TOKEN }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Update manifest with new version
|
||||
run: |
|
||||
VERSION="${STEPS_RESOLVE_VERSION_OUTPUTS_VERSION}"
|
||||
|
||||
# Replace x86_64 entry
|
||||
sed -i "/x86_64.AppImage/{n;s|sha256:.*|sha256: ${APPIMAGE_X64_SHA256}|}" com.actualbudget.actual.yml
|
||||
sed -i "/x86_64.AppImage/s|url:.*|url: https://github.com/actualbudget/actual/releases/download/v${VERSION}/Actual-linux-x86_64.AppImage|" com.actualbudget.actual.yml
|
||||
|
||||
# Replace arm64 entry
|
||||
sed -i "/arm64.AppImage/{n;s|sha256:.*|sha256: ${APPIMAGE_ARM64_SHA256}|}" com.actualbudget.actual.yml
|
||||
sed -i "/arm64.AppImage/s|url:.*|url: https://github.com/actualbudget/actual/releases/download/v${VERSION}/Actual-linux-arm64.AppImage|" com.actualbudget.actual.yml
|
||||
|
||||
echo "Updated manifest:"
|
||||
cat com.actualbudget.actual.yml
|
||||
env:
|
||||
STEPS_RESOLVE_VERSION_OUTPUTS_VERSION: ${{ steps.resolve_version.outputs.version }}
|
||||
|
||||
- name: Create PR in Flathub repo
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
token: ${{ secrets.FLATHUB_GITHUB_TOKEN }}
|
||||
commit-message: 'Update Actual flatpak to version ${{ steps.resolve_version.outputs.version }}'
|
||||
branch: 'release/${{ steps.resolve_version.outputs.version }}'
|
||||
title: 'Update Actual flatpak to version ${{ steps.resolve_version.outputs.version }}'
|
||||
body: |
|
||||
This PR updates the Actual desktop flatpak to version ${{ steps.resolve_version.outputs.version }}.
|
||||
|
||||
:link: [View release notes](https://actualbudget.org/blog/release-${{ steps.resolve_version.outputs.version }})
|
||||
reviewers: 'jfdoming,MatissJanis,youngcw'
|
||||
30
.github/workflows/publish-nightly-electron.yml
vendored
30
.github/workflows/publish-nightly-electron.yml
vendored
@@ -20,7 +20,6 @@ concurrency:
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-22.04
|
||||
@@ -29,9 +28,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
if: github.event.repository.fork == false
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- if: ${{ startsWith(matrix.os, 'windows') }}
|
||||
run: pip.exe install setuptools
|
||||
|
||||
@@ -42,9 +39,6 @@ jobs:
|
||||
source .venv/bin/activate
|
||||
python3 -m pip install setuptools
|
||||
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- if: ${{ startsWith(matrix.os, 'ubuntu') }}
|
||||
name: Setup Flatpak dependencies
|
||||
run: |
|
||||
@@ -59,14 +53,16 @@ jobs:
|
||||
|
||||
METAINFO_FILE="packages/desktop-electron/extra-resources/linux/com.actualbudget.actual.metainfo.xml"
|
||||
TODAY=$(date +%Y-%m-%d)
|
||||
VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/desktop-electron/package.json --type nightly)
|
||||
VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/desktop-electron/package.json --type nightly)
|
||||
sed -i "s/%RELEASE_VERSION%/$VERSION/g; s/%RELEASE_DATE%/$TODAY/g" "$METAINFO_FILE"
|
||||
flatpak run --command=flatpak-builder-lint org.flatpak.Builder appstream "$METAINFO_FILE"
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Update package versions
|
||||
run: |
|
||||
# Get new nightly version
|
||||
NEW_DESKTOP_APP_VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/desktop-electron/package.json --type nightly)
|
||||
NEW_DESKTOP_APP_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/desktop-electron/package.json --type nightly)
|
||||
|
||||
# Set package version
|
||||
npm version $NEW_DESKTOP_APP_VERSION --no-git-tag-version --workspace=desktop-electron --no-workspaces-update
|
||||
@@ -86,49 +82,49 @@ jobs:
|
||||
run: ./bin/package-electron
|
||||
|
||||
- name: Upload Linux x64 AppImage
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: Actual-linux-x86_64.AppImage
|
||||
if-no-files-found: ignore
|
||||
path: packages/desktop-electron/dist/Actual-linux-x86_64.AppImage
|
||||
|
||||
- name: Upload Linux arm64 AppImage
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: Actual-linux-arm64.AppImage
|
||||
if-no-files-found: ignore
|
||||
path: packages/desktop-electron/dist/Actual-linux-arm64.AppImage
|
||||
|
||||
- name: Upload Windows x32 exe
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: Actual-windows-ia32.exe
|
||||
if-no-files-found: ignore
|
||||
path: packages/desktop-electron/dist/Actual-windows-ia32.exe
|
||||
|
||||
- name: Upload Windows x64 exe
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: Actual-windows-x64.exe
|
||||
if-no-files-found: ignore
|
||||
path: packages/desktop-electron/dist/Actual-windows-x64.exe
|
||||
|
||||
- name: Upload Windows arm64 exe
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: Actual-windows-arm64.exe
|
||||
if-no-files-found: ignore
|
||||
path: packages/desktop-electron/dist/Actual-windows-arm64.exe
|
||||
|
||||
- name: Upload Mac x64 dmg
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: Actual-mac-x64.dmg
|
||||
if-no-files-found: ignore
|
||||
path: packages/desktop-electron/dist/Actual-mac-x64.dmg
|
||||
|
||||
- name: Upload Mac arm64 dmg
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: Actual-mac-arm64.dmg
|
||||
if-no-files-found: ignore
|
||||
@@ -136,7 +132,7 @@ jobs:
|
||||
|
||||
- name: Upload Windows Store Build
|
||||
if: ${{ startsWith(matrix.os, 'windows') }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: actual-electron-${{ matrix.os }}-appx
|
||||
path: |
|
||||
|
||||
95
.github/workflows/publish-nightly-npm-packages.yml
vendored
Normal file
95
.github/workflows/publish-nightly-npm-packages.yml
vendored
Normal file
@@ -0,0 +1,95 @@
|
||||
name: Publish nightly npm packages
|
||||
|
||||
# Nightly npm packages are built daily at midnight UTC
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-pack:
|
||||
runs-on: ubuntu-latest
|
||||
name: Build and pack npm packages
|
||||
if: github.event.repository.fork == false
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Update package versions
|
||||
run: |
|
||||
# Get new nightly versions
|
||||
NEW_WEB_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/desktop-client/package.json --type nightly)
|
||||
NEW_SYNC_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/sync-server/package.json --type nightly)
|
||||
NEW_API_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/api/package.json --type nightly)
|
||||
|
||||
# Set package versions
|
||||
npm version $NEW_WEB_VERSION --no-git-tag-version --workspace=@actual-app/web --no-workspaces-update
|
||||
npm version $NEW_SYNC_VERSION --no-git-tag-version --workspace=@actual-app/sync-server --no-workspaces-update
|
||||
npm version $NEW_API_VERSION --no-git-tag-version --workspace=@actual-app/api --no-workspaces-update
|
||||
|
||||
- name: Yarn install
|
||||
run: |
|
||||
yarn install
|
||||
|
||||
- name: Build Server & Web
|
||||
run: yarn build:server
|
||||
|
||||
- name: Pack the web and server packages
|
||||
run: |
|
||||
yarn workspace @actual-app/web pack --filename @actual-app/web.tgz
|
||||
yarn workspace @actual-app/sync-server pack --filename @actual-app/sync-server.tgz
|
||||
|
||||
- name: Build API
|
||||
run: yarn build:api
|
||||
|
||||
- name: Pack the api package
|
||||
run: |
|
||||
yarn workspace @actual-app/api pack --filename @actual-app/api.tgz
|
||||
|
||||
- name: Upload package artifacts
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: npm-packages
|
||||
path: |
|
||||
packages/desktop-client/@actual-app/web.tgz
|
||||
packages/sync-server/@actual-app/sync-server.tgz
|
||||
packages/api/@actual-app/api.tgz
|
||||
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
name: Publish Nightly npm packages
|
||||
needs: build-and-pack
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Download the artifacts
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: npm-packages
|
||||
|
||||
- name: Setup node and npm registry
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Publish Web
|
||||
run: |
|
||||
npm publish desktop-client/@actual-app/web.tgz --access public --tag nightly
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Publish Sync-Server
|
||||
run: |
|
||||
npm publish sync-server/@actual-app/sync-server.tgz --access public --tag nightly
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Publish API
|
||||
run: |
|
||||
npm publish api/@actual-app/api.tgz --access public --tag nightly
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
81
.github/workflows/publish-npm-packages.yml
vendored
81
.github/workflows/publish-npm-packages.yml
vendored
@@ -1,55 +1,22 @@
|
||||
name: Publish npm packages
|
||||
|
||||
# Npm packages are published for every new tag and nightly schedule
|
||||
# # Npm packages are published for every new tag
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-pack:
|
||||
runs-on: ubuntu-latest
|
||||
name: Build and pack npm packages
|
||||
if: github.event_name == 'push' || (github.event.repository.fork == false)
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Update package versions
|
||||
if: github.event_name != 'push'
|
||||
run: |
|
||||
# Get new nightly versions
|
||||
NEW_CORE_VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/loot-core/package.json --type nightly)
|
||||
NEW_WEB_VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/desktop-client/package.json --type nightly)
|
||||
NEW_SYNC_VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/sync-server/package.json --type nightly)
|
||||
NEW_API_VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/api/package.json --type nightly)
|
||||
NEW_CLI_VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/cli/package.json --type nightly)
|
||||
|
||||
# Set package versions
|
||||
npm version $NEW_CORE_VERSION --no-git-tag-version --workspace=@actual-app/core --no-workspaces-update
|
||||
npm version $NEW_WEB_VERSION --no-git-tag-version --workspace=@actual-app/web --no-workspaces-update
|
||||
npm version $NEW_SYNC_VERSION --no-git-tag-version --workspace=@actual-app/sync-server --no-workspaces-update
|
||||
npm version $NEW_API_VERSION --no-git-tag-version --workspace=@actual-app/api --no-workspaces-update
|
||||
npm version $NEW_CLI_VERSION --no-git-tag-version --workspace=@actual-app/cli --no-workspaces-update
|
||||
|
||||
- name: Yarn install
|
||||
if: github.event_name != 'push'
|
||||
run: |
|
||||
# Required after nightly `npm version` updates workspace manifests.
|
||||
yarn install
|
||||
|
||||
- name: Pack the core package
|
||||
run: |
|
||||
yarn workspace @actual-app/core pack --filename @actual-app/core.tgz
|
||||
|
||||
- name: Build Server & Web
|
||||
- name: Build Web
|
||||
run: yarn build:server
|
||||
|
||||
- name: Pack the web and server packages
|
||||
@@ -64,24 +31,14 @@ jobs:
|
||||
run: |
|
||||
yarn workspace @actual-app/api pack --filename @actual-app/api.tgz
|
||||
|
||||
- name: Build CLI
|
||||
run: yarn workspace @actual-app/cli build
|
||||
|
||||
- name: Pack the cli package
|
||||
run: |
|
||||
yarn workspace @actual-app/cli pack --filename @actual-app/cli.tgz
|
||||
|
||||
- name: Upload package artifacts
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: ${{ !env.ACT }}
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: npm-packages
|
||||
path: |
|
||||
packages/loot-core/@actual-app/core.tgz
|
||||
packages/desktop-client/@actual-app/web.tgz
|
||||
packages/sync-server/@actual-app/sync-server.tgz
|
||||
packages/api/@actual-app/api.tgz
|
||||
packages/cli/@actual-app/cli.tgz
|
||||
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -90,38 +47,32 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write # Required for OIDC
|
||||
env:
|
||||
NPM_DIST_TAG: ${{ github.event_name != 'push' && 'nightly' || '' }}
|
||||
steps:
|
||||
- name: Download the artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: npm-packages
|
||||
|
||||
- name: Setup node and npm registry
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version: 24
|
||||
check-latest: true
|
||||
node-version: 22
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Publish Core
|
||||
run: |
|
||||
npm publish loot-core/@actual-app/core.tgz --access public ${NPM_DIST_TAG:+--tag "$NPM_DIST_TAG"}
|
||||
|
||||
- name: Publish Web
|
||||
run: |
|
||||
npm publish desktop-client/@actual-app/web.tgz --access public ${NPM_DIST_TAG:+--tag "$NPM_DIST_TAG"}
|
||||
npm publish desktop-client/@actual-app/web.tgz --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Publish Sync-Server
|
||||
run: |
|
||||
npm publish sync-server/@actual-app/sync-server.tgz --access public ${NPM_DIST_TAG:+--tag "$NPM_DIST_TAG"}
|
||||
npm publish sync-server/@actual-app/sync-server.tgz --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Publish API
|
||||
run: |
|
||||
npm publish api/@actual-app/api.tgz --access public ${NPM_DIST_TAG:+--tag "$NPM_DIST_TAG"}
|
||||
|
||||
- name: Publish CLI
|
||||
run: |
|
||||
npm publish cli/@actual-app/cli.tgz --access public ${NPM_DIST_TAG:+--tag "$NPM_DIST_TAG"}
|
||||
npm publish api/@actual-app/api.tgz --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
48
.github/workflows/release-notes.yml
vendored
48
.github/workflows/release-notes.yml
vendored
@@ -3,10 +3,6 @@ name: Release notes
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
@@ -15,37 +11,15 @@ jobs:
|
||||
release-notes:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check if triggered by bot
|
||||
id: bot-check
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const { data: commit } = await github.rest.git.getCommit({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
commit_sha: context.payload.pull_request.head.sha,
|
||||
});
|
||||
const skip = commit.author.name === 'github-actions[bot]'
|
||||
&& commit.message.startsWith('Generate release notes');
|
||||
console.log(`Head commit by "${commit.author.name}": ${commit.message.split('\n')[0]}`);
|
||||
console.log(`Skip: ${skip}`);
|
||||
core.setOutput('skip', String(skip));
|
||||
|
||||
- name: Checkout
|
||||
if: steps.bot-check.outputs.skip != 'true'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.ACTIONS_UPDATE_TOKEN || github.token }}
|
||||
# Need to be able to commit release notes after generation
|
||||
persist-credentials: true
|
||||
|
||||
- name: Get changed files
|
||||
if: steps.bot-check.outputs.skip != 'true'
|
||||
id: changed-files
|
||||
run: |
|
||||
git fetch origin ${GITHUB_BASE_REF}
|
||||
CHANGED_FILES=$(git diff --name-only origin/${GITHUB_BASE_REF}...HEAD)
|
||||
git fetch origin ${{ github.base_ref }}
|
||||
CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD)
|
||||
NON_DOCS_FILES=$(echo "$CHANGED_FILES" | grep -v -e "^packages/docs/" -e "^\.github/actions/docs-spelling/" || true)
|
||||
|
||||
if [ -z "$NON_DOCS_FILES" ] && [ -n "$CHANGED_FILES" ]; then
|
||||
@@ -54,17 +28,9 @@ jobs:
|
||||
else
|
||||
echo "only_docs=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Check release notes
|
||||
if: >-
|
||||
steps.bot-check.outputs.skip != 'true'
|
||||
&& startsWith(github.head_ref, 'release/') == false
|
||||
&& steps.changed-files.outputs.only_docs != 'true'
|
||||
uses: ./.github/actions/release-notes/check
|
||||
|
||||
if: startsWith(github.head_ref, 'release/') == false && steps.changed-files.outputs.only_docs != 'true'
|
||||
uses: actualbudget/actions/release-notes/check@main
|
||||
- name: Generate release notes
|
||||
if: >-
|
||||
steps.bot-check.outputs.skip != 'true'
|
||||
&& startsWith(github.head_ref, 'release/') == true
|
||||
&& github.event.pull_request.head.repo.full_name == github.event.pull_request.base.repo.full_name
|
||||
uses: ./.github/actions/release-notes/generate
|
||||
if: startsWith(github.head_ref, 'release/') == true
|
||||
uses: actualbudget/actions/release-notes/generate@main
|
||||
|
||||
81
.github/workflows/size-compare.yml
vendored
81
.github/workflows/size-compare.yml
vendored
@@ -35,10 +35,9 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout base branch
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
ref: ${{ github.base_ref }}
|
||||
persist-credentials: false
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
@@ -58,20 +57,6 @@ jobs:
|
||||
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}}
|
||||
|
||||
- name: Wait for PR build to succeed
|
||||
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
|
||||
@@ -87,29 +72,15 @@ jobs:
|
||||
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}}
|
||||
|
||||
- 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'
|
||||
if: steps.wait-for-web-build.outputs.conclusion == 'failure' || steps.wait-for-api-build.outputs.conclusion == 'failure'
|
||||
run: |
|
||||
echo "Build failed on PR branch or ${GITHUB_BASE_REF}"
|
||||
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
|
||||
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
|
||||
id: pr-web-build
|
||||
with:
|
||||
branch: ${{github.base_ref}}
|
||||
@@ -118,7 +89,7 @@ jobs:
|
||||
name: build-stats
|
||||
path: base
|
||||
- name: Download API build artifact from ${{github.base_ref}}
|
||||
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
|
||||
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
|
||||
id: pr-api-build
|
||||
with:
|
||||
branch: ${{github.base_ref}}
|
||||
@@ -127,7 +98,7 @@ jobs:
|
||||
name: api-build-stats
|
||||
path: base
|
||||
- name: Download build stats from PR
|
||||
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
|
||||
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
|
||||
with:
|
||||
pr: ${{github.event.pull_request.number}}
|
||||
workflow: build.yml
|
||||
@@ -136,7 +107,7 @@ jobs:
|
||||
path: head
|
||||
allow_forks: true
|
||||
- name: Download API stats from PR
|
||||
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
|
||||
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
|
||||
with:
|
||||
pr: ${{github.event.pull_request.number}}
|
||||
workflow: build.yml
|
||||
@@ -144,40 +115,6 @@ jobs:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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
|
||||
@@ -199,13 +136,9 @@ jobs:
|
||||
--base desktop-client=./base/web-stats.json \
|
||||
--base loot-core=./base/loot-core-stats.json \
|
||||
--base api=./base/api-stats.json \
|
||||
--base cli=./base/cli-stats.json \
|
||||
--base crdt=./base/crdt-stats.json \
|
||||
--head desktop-client=./head/web-stats.json \
|
||||
--head loot-core=./head/loot-core-stats.json \
|
||||
--head api=./head/api-stats.json \
|
||||
--head cli=./head/cli-stats.json \
|
||||
--head crdt=./head/crdt-stats.json \
|
||||
--identifier combined \
|
||||
--format pr-body > bundle-stats-comment.md
|
||||
- name: Post combined bundle stats comment
|
||||
|
||||
13
.github/workflows/stale.yml
vendored
13
.github/workflows/stale.yml
vendored
@@ -3,15 +3,12 @@ on:
|
||||
schedule:
|
||||
- cron: '30 1 * * *'
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
permissions:
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
with:
|
||||
stale-pr-message: 'This PR is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days.'
|
||||
close-pr-message: 'This PR was closed because it has been stalled for 5 days with no activity.'
|
||||
@@ -19,11 +16,9 @@ jobs:
|
||||
days-before-close: 5
|
||||
days-before-issue-stale: -1
|
||||
stale-wip:
|
||||
permissions:
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
with:
|
||||
stale-pr-message: ':wave: Hi! It looks like this PR has not had any changes for a week now. Would you like someone to review this PR? If so - please remove the "[WIP]" prefix from the PR title. That will let the community know that this PR is open for a review.'
|
||||
days-before-stale: 7
|
||||
@@ -32,11 +27,9 @@ jobs:
|
||||
days-before-issue-stale: -1
|
||||
|
||||
stale-needs-info:
|
||||
permissions:
|
||||
issues: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
with:
|
||||
stale-issue-label: 'needs info'
|
||||
days-before-stale: -1
|
||||
|
||||
19
.github/workflows/vrt-update-apply.yml
vendored
19
.github/workflows/vrt-update-apply.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
steps:
|
||||
- name: Download patch artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
path: /tmp/artifacts
|
||||
|
||||
- name: Download metadata artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
|
||||
- name: Checkout fork branch
|
||||
if: steps.metadata.outputs.pr_number != ''
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
repository: ${{ steps.metadata.outputs.head_repo }}
|
||||
ref: ${{ steps.metadata.outputs.head_ref }}
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
fi
|
||||
|
||||
# Commit
|
||||
git commit -m "Update VRT screenshots" -m "Auto-generated by VRT workflow" -m "PR: #${STEPS_METADATA_OUTPUTS_PR_NUMBER}"
|
||||
git commit -m "Update VRT screenshots" -m "Auto-generated by VRT workflow" -m "PR: #${{ steps.metadata.outputs.pr_number }}"
|
||||
|
||||
echo "applied=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
@@ -116,8 +116,6 @@ jobs:
|
||||
echo "error=Patch conflicts with current branch state" >> "$GITHUB_OUTPUT"
|
||||
exit 1
|
||||
fi
|
||||
env:
|
||||
STEPS_METADATA_OUTPUTS_PR_NUMBER: ${{ steps.metadata.outputs.pr_number }}
|
||||
|
||||
- name: Push changes
|
||||
if: steps.apply.outputs.applied == 'true'
|
||||
@@ -135,15 +133,12 @@ jobs:
|
||||
|
||||
- name: Comment on PR - Failure
|
||||
if: failure() && steps.metadata.outputs.pr_number != ''
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
APPLY_ERROR: ${{ steps.apply.outputs.error }}
|
||||
PR_NUMBER: ${{ steps.metadata.outputs.pr_number }}
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const error = process.env.APPLY_ERROR || 'Unknown error occurred';
|
||||
const error = `${{ steps.apply.outputs.error }}` || 'Unknown error occurred';
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: parseInt(process.env.PR_NUMBER, 10),
|
||||
issue_number: ${{ steps.metadata.outputs.pr_number }},
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: `❌ Failed to apply VRT updates: ${error}\n\nPlease check the workflow logs for details.`
|
||||
|
||||
25
.github/workflows/vrt-update-generate.yml
vendored
25
.github/workflows/vrt-update-generate.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Add 👀 reaction to comment
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
await github.rest.reactions.createForIssueComment({
|
||||
@@ -44,11 +44,11 @@ jobs:
|
||||
github.event.issue.pull_request &&
|
||||
startsWith(github.event.comment.body, '/update-vrt')
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.59.1-jammy
|
||||
image: mcr.microsoft.com/playwright:v1.57.0-jammy
|
||||
steps:
|
||||
- name: Get PR details
|
||||
id: pr
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
@@ -60,24 +60,18 @@ jobs:
|
||||
core.setOutput('head_ref', pr.head.ref);
|
||||
core.setOutput('head_repo', pr.head.repo.full_name);
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
ref: ${{ steps.pr.outputs.head_sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
download-translations: 'false'
|
||||
|
||||
# Build tools are needed to rebuild native modules like better-sqlite3 used by the Desktop app, which is required to run VRT tests on the Desktop app and generate updated snapshots.
|
||||
- name: Install build tools
|
||||
run: apt-get update && apt-get install -y build-essential python3
|
||||
|
||||
- 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: Run VRT Tests
|
||||
@@ -119,7 +113,7 @@ jobs:
|
||||
|
||||
- name: Upload patch artifact
|
||||
if: steps.create-patch.outputs.has_changes == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: vrt-patch-${{ github.event.issue.number }}
|
||||
path: vrt-update.patch
|
||||
@@ -130,15 +124,12 @@ jobs:
|
||||
run: |
|
||||
mkdir -p pr-metadata
|
||||
echo "${{ github.event.issue.number }}" > pr-metadata/pr-number.txt
|
||||
echo "${STEPS_PR_OUTPUTS_HEAD_REF}" > pr-metadata/head-ref.txt
|
||||
echo "${STEPS_PR_OUTPUTS_HEAD_REPO}" > pr-metadata/head-repo.txt
|
||||
env:
|
||||
STEPS_PR_OUTPUTS_HEAD_REF: ${{ steps.pr.outputs.head_ref }}
|
||||
STEPS_PR_OUTPUTS_HEAD_REPO: ${{ steps.pr.outputs.head_repo }}
|
||||
echo "${{ steps.pr.outputs.head_ref }}" > pr-metadata/head-ref.txt
|
||||
echo "${{ steps.pr.outputs.head_repo }}" > pr-metadata/head-repo.txt
|
||||
|
||||
- name: Upload PR metadata
|
||||
if: steps.create-patch.outputs.has_changes == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: vrt-metadata-${{ github.event.issue.number }}
|
||||
path: pr-metadata/
|
||||
|
||||
16
.gitignore
vendored
16
.gitignore
vendored
@@ -33,18 +33,13 @@ packages/desktop-electron/dist
|
||||
packages/desktop-electron/loot-core
|
||||
packages/desktop-client/service-worker
|
||||
packages/plugins-service/dist
|
||||
packages/component-library/dist
|
||||
packages/loot-core/lib-dist
|
||||
**/.tsbuildinfo
|
||||
packages/sync-server/coverage
|
||||
bundle.desktop.js
|
||||
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/*
|
||||
@@ -61,10 +56,6 @@ bundle.mobile.js.map
|
||||
# IntelliJ IDEA
|
||||
.idea
|
||||
|
||||
# Claude Code
|
||||
.claude/worktrees/*
|
||||
.claude/settings.local.json
|
||||
|
||||
# Misc
|
||||
.#*
|
||||
|
||||
@@ -88,10 +79,3 @@ build/
|
||||
|
||||
*storybook.log
|
||||
storybook-static
|
||||
|
||||
# cli config when testing locally
|
||||
.actualrc.json
|
||||
.actualrc
|
||||
.actualrc.yaml
|
||||
.actualrc.yml
|
||||
actual.config.js
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Run yarn install when switching branches (if yarn.lock changed)
|
||||
# or when creating a new worktree (node_modules won't exist yet)
|
||||
|
||||
# $3 is 1 for branch checkout, 0 for file checkout
|
||||
if [ "$3" != "1" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Worktree creation: node_modules doesn't exist yet, always install
|
||||
if [ ! -d "node_modules" ]; then
|
||||
echo "New worktree detected — running yarn install..."
|
||||
yarn install || exit 1
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if yarn.lock changed between the old and new HEAD
|
||||
if git diff --name-only "$1" "$2" | grep -q "^yarn.lock$"; then
|
||||
echo "yarn.lock changed — running yarn install..."
|
||||
yarn install
|
||||
fi
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Run yarn install after pulling/merging (if yarn.lock changed)
|
||||
|
||||
if git diff --name-only ORIG_HEAD HEAD | grep -q "^yarn.lock$"; then
|
||||
echo "yarn.lock changed — running yarn install..."
|
||||
yarn install
|
||||
fi
|
||||
0
.husky/pre-commit
Executable file → Normal file
0
.husky/pre-commit
Executable file → Normal file
@@ -9,14 +9,24 @@
|
||||
"react",
|
||||
"builtin",
|
||||
"external",
|
||||
["parent", "subpath"],
|
||||
"loot-core",
|
||||
"parent",
|
||||
"sibling",
|
||||
"index"
|
||||
"index",
|
||||
"desktop-client"
|
||||
],
|
||||
"customGroups": [
|
||||
{
|
||||
"groupName": "react",
|
||||
"elementNamePattern": ["react", "react-dom/*", "react-*"]
|
||||
"elementNamePattern": ["react"]
|
||||
},
|
||||
{
|
||||
"groupName": "loot-core",
|
||||
"elementNamePattern": ["loot-core"]
|
||||
},
|
||||
{
|
||||
"groupName": "desktop-client",
|
||||
"elementNamePattern": ["@desktop-client"]
|
||||
}
|
||||
],
|
||||
"newlinesBetween": true
|
||||
|
||||
280
.oxlintrc.json
280
.oxlintrc.json
@@ -15,81 +15,77 @@
|
||||
"vi": "readonly",
|
||||
"backend": "readonly",
|
||||
"importScripts": "readonly",
|
||||
"FS": "readonly",
|
||||
"__APP_VERSION__": "readonly"
|
||||
"FS": "readonly"
|
||||
},
|
||||
"rules": {
|
||||
// Import sorting
|
||||
"perfectionist/sort-named-imports": [
|
||||
"error",
|
||||
"warn",
|
||||
{
|
||||
"groups": ["value-import", "type-import"]
|
||||
}
|
||||
],
|
||||
|
||||
// Actual rules
|
||||
"actual/typography": "error",
|
||||
"actual/typography": "warn",
|
||||
"actual/no-untranslated-strings": "error",
|
||||
"actual/prefer-trans-over-t": "error",
|
||||
"actual/prefer-if-statement": "error",
|
||||
"actual/prefer-if-statement": "warn",
|
||||
"actual/prefer-logger-over-console": "error",
|
||||
"actual/object-shorthand-properties": "error",
|
||||
"actual/prefer-const": "error",
|
||||
"actual/no-anchor-tag": "error",
|
||||
"actual/no-react-default-import": "error",
|
||||
"actual/prefer-subpath-imports": "error",
|
||||
"actual/enforce-boundaries": "error",
|
||||
"actual/no-extraneous-dependencies": "error",
|
||||
"actual/object-shorthand-properties": "warn",
|
||||
"actual/prefer-const": "warn",
|
||||
"actual/no-anchor-tag": "warn",
|
||||
"actual/no-react-default-import": "warn",
|
||||
|
||||
// JSX A11y rules
|
||||
"jsx-a11y/no-autofocus": [
|
||||
"error",
|
||||
"warn",
|
||||
{
|
||||
"ignoreNonDOM": true
|
||||
}
|
||||
],
|
||||
"jsx-a11y/alt-text": "error",
|
||||
"jsx-a11y/anchor-has-content": "error",
|
||||
"jsx-a11y/alt-text": "warn",
|
||||
"jsx-a11y/anchor-has-content": "warn",
|
||||
"jsx-a11y/anchor-is-valid": [
|
||||
"error",
|
||||
"warn",
|
||||
{
|
||||
"aspects": ["noHref", "invalidHref"]
|
||||
}
|
||||
],
|
||||
"jsx-a11y/aria-activedescendant-has-tabindex": "error",
|
||||
"jsx-a11y/aria-props": "error",
|
||||
"jsx-a11y/aria-proptypes": "error",
|
||||
"jsx-a11y/aria-activedescendant-has-tabindex": "warn",
|
||||
"jsx-a11y/aria-props": "warn",
|
||||
"jsx-a11y/aria-proptypes": "warn",
|
||||
"jsx-a11y/aria-role": [
|
||||
"error",
|
||||
"warn",
|
||||
{
|
||||
"ignoreNonDOM": true
|
||||
}
|
||||
],
|
||||
"jsx-a11y/aria-unsupported-elements": "error",
|
||||
"jsx-a11y/heading-has-content": "error",
|
||||
"jsx-a11y/iframe-has-title": "error",
|
||||
"jsx-a11y/img-redundant-alt": "error",
|
||||
"jsx-a11y/no-access-key": "error",
|
||||
"jsx-a11y/no-distracting-elements": "error",
|
||||
"jsx-a11y/no-redundant-roles": "error",
|
||||
"jsx-a11y/role-has-required-aria-props": "error",
|
||||
"jsx-a11y/role-supports-aria-props": "error",
|
||||
"jsx-a11y/scope": "error",
|
||||
"jsx-a11y/aria-unsupported-elements": "warn",
|
||||
"jsx-a11y/heading-has-content": "warn",
|
||||
"jsx-a11y/iframe-has-title": "warn",
|
||||
"jsx-a11y/img-redundant-alt": "warn",
|
||||
"jsx-a11y/no-access-key": "warn",
|
||||
"jsx-a11y/no-distracting-elements": "warn",
|
||||
"jsx-a11y/no-redundant-roles": "warn",
|
||||
"jsx-a11y/role-has-required-aria-props": "warn",
|
||||
"jsx-a11y/role-supports-aria-props": "warn",
|
||||
"jsx-a11y/scope": "warn",
|
||||
|
||||
// Typescript rules
|
||||
"typescript/ban-ts-comment": ["error"],
|
||||
"typescript/consistent-type-definitions": ["error", "type"],
|
||||
"typescript/ban-ts-comment": ["warn"],
|
||||
"typescript/consistent-type-definitions": ["warn", "type"],
|
||||
"typescript/consistent-type-imports": [
|
||||
"error",
|
||||
"warn",
|
||||
{
|
||||
"prefer": "type-imports",
|
||||
"fixStyle": "inline-type-imports"
|
||||
}
|
||||
],
|
||||
"typescript/no-implied-eval": "error",
|
||||
"typescript/no-explicit-any": "error",
|
||||
"typescript/no-implied-eval": "warn",
|
||||
"typescript/no-explicit-any": "warn",
|
||||
"typescript/no-restricted-types": [
|
||||
"error",
|
||||
"warn",
|
||||
{
|
||||
"types": {
|
||||
// forbid FC as superfluous
|
||||
@@ -102,30 +98,19 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"typescript/no-var-requires": "error",
|
||||
// we want to allow unions such as "{ name: DbAccount['name'] | DbPayee['name'] }"
|
||||
"typescript/no-duplicate-type-constituents": "off",
|
||||
// we want to allow unions such as "string | 'network' | 'file-key-mismatch'"
|
||||
"typescript/no-redundant-type-constituents": "off",
|
||||
"typescript/await-thenable": "error",
|
||||
"typescript/no-floating-promises": "error",
|
||||
"typescript/require-array-sort-compare": "error",
|
||||
"typescript/unbound-method": "error",
|
||||
"typescript/no-for-in-array": "error",
|
||||
"typescript/restrict-template-expressions": "error",
|
||||
"typescript/no-misused-spread": "warn", // TODO: enable this
|
||||
"typescript/no-base-to-string": "warn", // TODO: enable this
|
||||
"typescript/no-unsafe-unary-minus": "warn", // TODO: enable this
|
||||
"typescript/no-unsafe-type-assertion": "warn", // TODO: enable this
|
||||
"typescript/no-var-requires": "warn",
|
||||
|
||||
// Import rules
|
||||
"import/consistent-type-specifier-style": "error",
|
||||
"import/consistent-type-specifier-style": "warn",
|
||||
"import/first": "error",
|
||||
"import/no-amd": "error",
|
||||
"import/no-default-export": "error",
|
||||
"import/no-default-export": "warn",
|
||||
"import/no-webpack-loader-syntax": "error",
|
||||
"import/no-useless-path-segments": "warn",
|
||||
"import/no-unresolved": "warn",
|
||||
"import/no-unused-modules": "warn",
|
||||
"import/no-duplicates": [
|
||||
"error",
|
||||
"warn",
|
||||
{
|
||||
"prefer-inline": false
|
||||
}
|
||||
@@ -133,121 +118,122 @@
|
||||
|
||||
// React rules
|
||||
"react/exhaustive-deps": [
|
||||
"error",
|
||||
"warn",
|
||||
{
|
||||
"additionalHooks": "(^useQuery$|^useEffectAfterMount$)"
|
||||
}
|
||||
],
|
||||
"react/jsx-curly-brace-presence": "error",
|
||||
"react/jsx-curly-brace-presence": "warn",
|
||||
"react/jsx-filename-extension": [
|
||||
"error",
|
||||
"warn",
|
||||
{
|
||||
"extensions": [".jsx", ".tsx"],
|
||||
"allow": "as-needed"
|
||||
}
|
||||
],
|
||||
"react/jsx-no-comment-textnodes": "error",
|
||||
"react/jsx-no-duplicate-props": "error",
|
||||
"react/jsx-no-target-blank": "error",
|
||||
"react/jsx-no-comment-textnodes": "warn",
|
||||
"react/jsx-no-duplicate-props": "warn",
|
||||
"react/jsx-no-target-blank": "warn",
|
||||
"react/jsx-no-undef": "error",
|
||||
"react/jsx-no-useless-fragment": "error",
|
||||
"react/jsx-no-useless-fragment": "warn",
|
||||
"react/jsx-pascal-case": [
|
||||
"error",
|
||||
"warn",
|
||||
{
|
||||
"allowAllCaps": true,
|
||||
"ignore": []
|
||||
}
|
||||
],
|
||||
"react/no-danger-with-children": "error",
|
||||
"react/no-direct-mutation-state": "error",
|
||||
"react/no-is-mounted": "error",
|
||||
"react/no-danger-with-children": "warn",
|
||||
"react/no-direct-mutation-state": "warn",
|
||||
"react/no-is-mounted": "warn",
|
||||
"react/no-unstable-nested-components": "warn",
|
||||
"react/require-render-return": "error",
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/self-closing-comp": "error",
|
||||
"react/style-prop-object": "error",
|
||||
"react/jsx-boolean-value": "error",
|
||||
"react/self-closing-comp": "warn",
|
||||
"react/style-prop-object": "warn",
|
||||
"react/jsx-boolean-value": "warn",
|
||||
|
||||
// ESLint rules
|
||||
"eslint/array-callback-return": "error",
|
||||
"eslint/curly": ["error", "multi-line", "consistent"],
|
||||
"eslint/array-callback-return": "warn",
|
||||
"eslint/curly": ["warn", "multi-line", "consistent"],
|
||||
"eslint/default-case": [
|
||||
"error",
|
||||
"warn",
|
||||
{
|
||||
"commentPattern": "^no default$"
|
||||
}
|
||||
],
|
||||
"eslint/eqeqeq": ["error", "smart"],
|
||||
"eslint/no-array-constructor": "error",
|
||||
"eslint/no-caller": "error",
|
||||
"eslint/no-cond-assign": ["error", "except-parens"],
|
||||
"eslint/no-const-assign": "error",
|
||||
"eslint/no-control-regex": "error",
|
||||
"eslint/no-delete-var": "error",
|
||||
"eslint/no-dupe-class-members": "error",
|
||||
"eslint/no-dupe-keys": "error",
|
||||
"eslint/no-duplicate-case": "error",
|
||||
"eslint/no-empty-character-class": "error",
|
||||
"eslint/no-empty-function": "error",
|
||||
"eslint/no-empty-pattern": "error",
|
||||
"eslint/no-eval": "error",
|
||||
"eslint/no-ex-assign": "error",
|
||||
"eslint/no-extend-native": "error",
|
||||
"eslint/no-extra-bind": "error",
|
||||
"eslint/no-extra-label": "error",
|
||||
"eslint/no-fallthrough": "error",
|
||||
"eslint/no-func-assign": "error",
|
||||
"eslint/no-invalid-regexp": "error",
|
||||
"eslint/no-iterator": "error",
|
||||
"eslint/no-label-var": "error",
|
||||
"eslint/no-var": "error",
|
||||
"eslint/eqeqeq": ["warn", "smart"],
|
||||
"eslint/no-array-constructor": "warn",
|
||||
"eslint/no-caller": "warn",
|
||||
"eslint/no-cond-assign": ["warn", "except-parens"],
|
||||
"eslint/no-const-assign": "warn",
|
||||
"eslint/no-control-regex": "warn",
|
||||
"eslint/no-delete-var": "warn",
|
||||
"eslint/no-dupe-class-members": "warn",
|
||||
"eslint/no-dupe-keys": "warn",
|
||||
"eslint/no-duplicate-case": "warn",
|
||||
"eslint/no-empty-character-class": "warn",
|
||||
"eslint/no-empty-function": "warn",
|
||||
"eslint/no-empty-pattern": "warn",
|
||||
"eslint/no-eval": "warn",
|
||||
"eslint/no-ex-assign": "warn",
|
||||
"eslint/no-extend-native": "warn",
|
||||
"eslint/no-extra-bind": "warn",
|
||||
"eslint/no-extra-label": "warn",
|
||||
"eslint/no-fallthrough": "warn",
|
||||
"eslint/no-func-assign": "warn",
|
||||
"eslint/no-invalid-regexp": "warn",
|
||||
"eslint/no-iterator": "warn",
|
||||
"eslint/no-label-var": "warn",
|
||||
"eslint/no-var": "warn",
|
||||
"eslint/no-labels": [
|
||||
"error",
|
||||
"warn",
|
||||
{
|
||||
"allowLoop": true,
|
||||
"allowSwitch": false
|
||||
}
|
||||
],
|
||||
"eslint/no-new-func": "error",
|
||||
"eslint/no-script-url": "error",
|
||||
"eslint/no-self-assign": "error",
|
||||
"eslint/no-self-compare": "error",
|
||||
"eslint/no-sequences": "error",
|
||||
"eslint/no-shadow-restricted-names": "error",
|
||||
"eslint/no-sparse-arrays": "error",
|
||||
"eslint/no-template-curly-in-string": "error",
|
||||
"eslint/no-this-before-super": "error",
|
||||
"eslint/no-throw-literal": "error",
|
||||
"eslint/no-unreachable": "error",
|
||||
"eslint/no-obj-calls": "error",
|
||||
"eslint/no-new-wrappers": "error",
|
||||
"eslint/no-unsafe-negation": "error",
|
||||
"eslint/no-multi-str": "error",
|
||||
"eslint/no-global-assign": "error",
|
||||
"eslint/no-lone-blocks": "error",
|
||||
"eslint/no-unused-labels": "error",
|
||||
"eslint/no-object-constructor": "error",
|
||||
"eslint/no-new-native-nonconstructor": "error",
|
||||
"eslint/no-redeclare": "error",
|
||||
"eslint/no-useless-computed-key": "error",
|
||||
"eslint/no-useless-concat": "error",
|
||||
"eslint/no-useless-escape": "error",
|
||||
"eslint/require-yield": "error",
|
||||
"eslint/getter-return": "error",
|
||||
"eslint/unicode-bom": ["error", "never"],
|
||||
"eslint/use-isnan": "error",
|
||||
"eslint/valid-typeof": "error",
|
||||
"eslint/no-new-func": "warn",
|
||||
"eslint/no-script-url": "warn",
|
||||
"eslint/no-self-assign": "warn",
|
||||
"eslint/no-self-compare": "warn",
|
||||
"eslint/no-sequences": "warn",
|
||||
"eslint/no-shadow-restricted-names": "warn",
|
||||
"eslint/no-sparse-arrays": "warn",
|
||||
"eslint/no-template-curly-in-string": "warn",
|
||||
"eslint/no-this-before-super": "warn",
|
||||
"eslint/no-throw-literal": "warn",
|
||||
"eslint/no-unreachable": "warn",
|
||||
"eslint/no-obj-calls": "warn",
|
||||
"eslint/no-new-wrappers": "warn",
|
||||
"eslint/no-unsafe-negation": "warn",
|
||||
"eslint/no-multi-str": "warn",
|
||||
"eslint/no-global-assign": "warn",
|
||||
"eslint/no-lone-blocks": "warn",
|
||||
"eslint/no-unused-labels": "warn",
|
||||
"eslint/no-object-constructor": "warn",
|
||||
"eslint/no-new-native-nonconstructor": "warn",
|
||||
"eslint/no-redeclare": "warn",
|
||||
"eslint/no-useless-computed-key": "warn",
|
||||
"eslint/no-useless-concat": "warn",
|
||||
"eslint/no-useless-escape": "warn",
|
||||
"eslint/require-yield": "warn",
|
||||
"eslint/getter-return": "warn",
|
||||
"eslint/unicode-bom": ["warn", "never"],
|
||||
"eslint/no-use-isnan": "warn",
|
||||
"eslint/valid-typeof": "warn",
|
||||
"eslint/no-useless-rename": [
|
||||
"error",
|
||||
"warn",
|
||||
{
|
||||
"ignoreDestructuring": false,
|
||||
"ignoreImport": false,
|
||||
"ignoreExport": false
|
||||
}
|
||||
],
|
||||
"eslint/no-with": "error",
|
||||
"eslint/no-regex-spaces": "error",
|
||||
"eslint/no-with": "warn",
|
||||
"eslint/no-regex-spaces": "warn",
|
||||
"eslint/no-restricted-globals": [
|
||||
"error",
|
||||
"warn",
|
||||
|
||||
// https://github.com/facebook/create-react-app/tree/main/packages/confusing-browser-globals
|
||||
"addEventListener",
|
||||
@@ -309,7 +295,7 @@
|
||||
"top"
|
||||
],
|
||||
"eslint/no-restricted-imports": [
|
||||
"error",
|
||||
"warn",
|
||||
{
|
||||
"paths": [
|
||||
{
|
||||
@@ -335,9 +321,14 @@
|
||||
],
|
||||
"patterns": [
|
||||
{
|
||||
"group": ["**/*.api", "**/*.electron"],
|
||||
"group": ["**/*.api", "**/*.web", "**/*.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"],
|
||||
@@ -354,11 +345,9 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"eslint/no-useless-constructor": "error",
|
||||
"eslint/no-undef": "error",
|
||||
"eslint/no-unused-expressions": "error",
|
||||
"eslint/no-return-assign": "error",
|
||||
"eslint/no-unused-vars": "error"
|
||||
"eslint/no-useless-constructor": "warn",
|
||||
"eslint/no-undef": "warn",
|
||||
"eslint/no-unused-expressions": "warn"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
@@ -371,14 +360,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"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["packages/eslint-plugin-actual/lib/rules/__tests__/**/*"],
|
||||
"rules": {
|
||||
"actual/enforce-boundaries": "off"
|
||||
"actual/prefer-logger-over-console": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -425,16 +407,6 @@
|
||||
"rules": {
|
||||
"eslint/no-empty-function": "off"
|
||||
}
|
||||
},
|
||||
// crdt enforces the repo's "TODO: enable this" typescript rules as errors
|
||||
{
|
||||
"files": ["packages/crdt/**/*"],
|
||||
"rules": {
|
||||
"typescript/no-misused-spread": "error",
|
||||
"typescript/no-base-to-string": "error",
|
||||
"typescript/no-unsafe-unary-minus": "error",
|
||||
"typescript/no-unsafe-type-assertion": "error"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
942
.yarn/releases/yarn-4.10.3.cjs
vendored
Executable file
942
.yarn/releases/yarn-4.10.3.cjs
vendored
Executable file
File diff suppressed because one or more lines are too long
940
.yarn/releases/yarn-4.13.0.cjs
vendored
940
.yarn/releases/yarn-4.13.0.cjs
vendored
File diff suppressed because one or more lines are too long
@@ -6,4 +6,4 @@ enableTransparentWorkspaces: false
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.13.0.cjs
|
||||
yarnPath: .yarn/releases/yarn-4.10.3.cjs
|
||||
|
||||
68
AGENTS.md
68
AGENTS.md
@@ -42,12 +42,6 @@ yarn start:desktop
|
||||
- Use `yarn workspace <workspace-name> run <command>` for workspace-specific tasks
|
||||
- Tests run once and exit by default (using `vitest --run`)
|
||||
|
||||
### ⚠️ CRITICAL REQUIREMENT: AI-Generated Commit Messages and PR Titles
|
||||
|
||||
**ALL commit messages and PR titles MUST be prefixed with `[AI]`.** No exceptions.
|
||||
|
||||
See [PR and Commit Rules](.github/agents/pr-and-commit-rules.md) for the full specification, including git safety rules, pre-commit checklist, and PR workflow.
|
||||
|
||||
### Task Orchestration with Lage
|
||||
|
||||
The project uses **[lage](https://microsoft.github.io/lage/)** (a task runner for JavaScript monorepos) to efficiently run tests and other tasks across multiple workspaces:
|
||||
@@ -84,7 +78,7 @@ The core application logic that runs on any platform.
|
||||
|
||||
```bash
|
||||
# Run all loot-core tests
|
||||
yarn workspace @actual-app/core run test
|
||||
yarn workspace loot-core run test
|
||||
|
||||
# Or run tests across all packages using lage
|
||||
yarn test
|
||||
@@ -219,7 +213,7 @@ yarn test
|
||||
yarn test:debug
|
||||
|
||||
# Run tests for a specific package
|
||||
yarn workspace @actual-app/core run test
|
||||
yarn workspace loot-core run test
|
||||
```
|
||||
|
||||
**E2E Tests (Playwright)**
|
||||
@@ -281,6 +275,7 @@ Always run `yarn typecheck` before committing.
|
||||
- Avoid `any` or `unknown` unless absolutely necessary
|
||||
- Look for existing type definitions in the codebase
|
||||
- Avoid type assertions (`as`, `!`) - prefer `satisfies`
|
||||
- Use inline type imports: `import { type MyType } from '...'`
|
||||
|
||||
**Naming:**
|
||||
|
||||
@@ -297,7 +292,6 @@ Always run `yarn typecheck` before committing.
|
||||
|
||||
**React Patterns:**
|
||||
|
||||
- The project uses **React Compiler** (`babel-plugin-react-compiler`) in the desktop-client. The compiler auto-memoizes component bodies, so you can omit manual `useCallback`, `useMemo`, and `React.memo` when adding or refactoring code; prefer inline callbacks and values unless a stable identity is required by a non-compiled dependency.
|
||||
- Don't use `React.FunctionComponent` or `React.FC` - type props directly
|
||||
- Don't use `React.*` patterns - use named imports instead
|
||||
- Use `<Link>` instead of `<a>` tags
|
||||
@@ -330,7 +324,7 @@ Always maintain newlines between import groups.
|
||||
|
||||
### Platform-Specific Code
|
||||
|
||||
- Don't directly reference platform-specific imports (`.api`, `.electron`)
|
||||
- Don't directly reference platform-specific imports (`.api`, `.web`, `.electron`)
|
||||
- Use conditional exports in `loot-core` for platform-specific code
|
||||
- Platform resolution happens at build time via package.json exports
|
||||
|
||||
@@ -344,7 +338,11 @@ Always maintain newlines between import groups.
|
||||
|
||||
**Git Commands:**
|
||||
|
||||
See [PR and Commit Rules](.github/agents/pr-and-commit-rules.md) for complete git safety rules, commit message requirements, and PR workflow.
|
||||
- Never update git config
|
||||
- Never run destructive git operations (force push, hard reset) unless explicitly requested
|
||||
- Never skip hooks (--no-verify, --no-gpg-sign)
|
||||
- Never force push to main/master
|
||||
- Never commit unless explicitly asked
|
||||
|
||||
## File Structure Patterns
|
||||
|
||||
@@ -500,14 +498,14 @@ Icons in `packages/component-library/src/icons/` are auto-generated. Don't manua
|
||||
|
||||
1. Check `tsconfig.json` for path mappings
|
||||
2. Check package.json `exports` field (especially for loot-core)
|
||||
3. Verify platform-specific imports (`.electron`, `.api`)
|
||||
3. Verify platform-specific imports (`.web`, `.electron`, `.api`)
|
||||
4. Use absolute imports in `desktop-client` (enforced by ESLint)
|
||||
|
||||
### Build Failures
|
||||
|
||||
1. Clean build artifacts: `rm -rf packages/*/dist packages/*/lib-dist packages/*/build`
|
||||
2. Reinstall dependencies: `yarn install`
|
||||
3. Check Node.js version (requires >=22)
|
||||
3. Check Node.js version (requires >=20)
|
||||
4. Check Yarn version (requires ^4.9.1)
|
||||
|
||||
## Testing Patterns
|
||||
@@ -543,7 +541,6 @@ Icons in `packages/component-library/src/icons/` are auto-generated. Don't manua
|
||||
|
||||
Before committing changes, ensure:
|
||||
|
||||
- [ ] Commit and PR rules followed (see [PR and Commit Rules](.github/agents/pr-and-commit-rules.md))
|
||||
- [ ] `yarn typecheck` passes
|
||||
- [ ] `yarn lint:fix` has been run
|
||||
- [ ] Relevant tests pass
|
||||
@@ -556,7 +553,9 @@ Before committing changes, ensure:
|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
See [PR and Commit Rules](.github/agents/pr-and-commit-rules.md) for complete PR creation rules, including title prefix requirements, labeling, and PR template handling.
|
||||
When creating pull requests:
|
||||
|
||||
- **AI-Generated PRs**: If you create a PR using AI assistance, add the **"AI generated"** label to the pull request. This helps maintainers understand the nature of the contribution.
|
||||
|
||||
## Code Review Guidelines
|
||||
|
||||
@@ -587,7 +586,7 @@ yarn install:server
|
||||
|
||||
## Environment Requirements
|
||||
|
||||
- **Node.js**: >=22
|
||||
- **Node.js**: >=20
|
||||
- **Yarn**: ^4.9.1 (managed by packageManager field)
|
||||
- **Browser Targets**: Electron >= 35.0, modern browsers (see browserslist)
|
||||
|
||||
@@ -600,40 +599,3 @@ The codebase is actively being migrated:
|
||||
- **React.\* → Named Imports**: Legacy React.\* patterns being removed
|
||||
|
||||
When working with older code, follow the newer patterns described in this guide.
|
||||
|
||||
## Cursor Cloud specific instructions
|
||||
|
||||
### Services overview
|
||||
|
||||
| Service | Command | Port | Required |
|
||||
| ------------------- | ----------------------- | ---- | ----------------------------- |
|
||||
| Web Frontend (Vite) | `yarn start` | 3001 | Yes |
|
||||
| Sync Server | `yarn start:server-dev` | 5006 | Optional (sync features only) |
|
||||
|
||||
All storage is **SQLite** (file-based via `better-sqlite3`). No external databases or services are needed.
|
||||
|
||||
### Running the app
|
||||
|
||||
- `yarn start` builds the plugins-service worker, loot-core browser backend, and starts the Vite dev server on port **3001**.
|
||||
- `yarn start:server-dev` starts both the sync server (port 5006) and the web frontend together.
|
||||
- The Vite HMR dev server serves many unbundled modules. In constrained environments, the browser may hit `ERR_INSUFFICIENT_RESOURCES`. If that happens, use `yarn build:browser` followed by serving the built output from `packages/desktop-client/build/` with proper COOP/COEP headers (`Cross-Origin-Opener-Policy: same-origin`, `Cross-Origin-Embedder-Policy: require-corp`).
|
||||
|
||||
### Lint, test, typecheck
|
||||
|
||||
Standard commands documented in `package.json` scripts and the Quick Start section above:
|
||||
|
||||
- `yarn lint` / `yarn lint:fix` (uses oxlint + oxfmt)
|
||||
- `yarn test` (lage across all workspaces)
|
||||
- `yarn typecheck` (tsgo + lage typecheck)
|
||||
|
||||
### Testing and previewing the app
|
||||
|
||||
When running the app for manual testing or demos, use **"View demo"** on the initial setup screen (after selecting "Don't use a server"). This creates a test budget pre-populated with realistic sample data (accounts, transactions, categories, and budgeted amounts), which is far more useful than starting with an empty budget.
|
||||
|
||||
### Gotchas
|
||||
|
||||
- The `engines` field requires **Node.js >=22** and **Yarn ^4.9.1**. The `.nvmrc` specifies `v22/*`.
|
||||
- Pre-commit hook runs `lint-staged` (oxfmt + oxlint) via Husky. Run `yarn prepare` once after install to set up hooks.
|
||||
- Lage caches test results in `.lage/`. If tests behave unexpectedly, clear with `rm -rf .lage`.
|
||||
- Native modules (`better-sqlite3`, `bcrypt`) require build tools (`gcc`, `make`, `python3`). These are pre-installed in the Cloud VM.
|
||||
- All yarn commands must be run from the repository root, never from child workspaces.
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
Please review the contributing documentation on our website: https://actualbudget.org/docs/contributing/
|
||||
|
||||
If you plan to use AI tools when contributing, please also read our [AI Usage Policy](https://actualbudget.org/docs/contributing/ai-usage-policy).
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
derivePublishImports,
|
||||
validatePackage,
|
||||
} from '../validate-publish-imports.js';
|
||||
|
||||
describe('derivePublishImports', () => {
|
||||
it('prepends ./build/ to .js paths', () => {
|
||||
const imports = {
|
||||
'#account-db': './src/account-db.js',
|
||||
};
|
||||
expect(derivePublishImports(imports)).toEqual({
|
||||
'#account-db': './build/src/account-db.js',
|
||||
});
|
||||
});
|
||||
|
||||
it('converts .ts extension to .js and prepends ./build/', () => {
|
||||
const imports = {
|
||||
'#migrations': './src/migrations.ts',
|
||||
};
|
||||
expect(derivePublishImports(imports)).toEqual({
|
||||
'#migrations': './build/src/migrations.js',
|
||||
});
|
||||
});
|
||||
|
||||
it('converts .tsx extension to .js and prepends ./build/', () => {
|
||||
const imports = {
|
||||
'#component': './src/component.tsx',
|
||||
};
|
||||
expect(derivePublishImports(imports)).toEqual({
|
||||
'#component': './build/src/component.js',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves wildcard patterns', () => {
|
||||
const imports = {
|
||||
'#accounts/*': './src/accounts/*.js',
|
||||
'#services/*': './src/app-gocardless/services/*.ts',
|
||||
};
|
||||
expect(derivePublishImports(imports)).toEqual({
|
||||
'#accounts/*': './build/src/accounts/*.js',
|
||||
'#services/*': './build/src/app-gocardless/services/*.js',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles multiple entries with mixed extensions', () => {
|
||||
const imports = {
|
||||
'#account-db': './src/account-db.js',
|
||||
'#migrations': './src/migrations.ts',
|
||||
'#app-gocardless/errors': './src/app-gocardless/errors.ts',
|
||||
'#util/*': './src/util/*.ts',
|
||||
'#scripts/*': './src/scripts/*.js',
|
||||
};
|
||||
expect(derivePublishImports(imports)).toEqual({
|
||||
'#account-db': './build/src/account-db.js',
|
||||
'#migrations': './build/src/migrations.js',
|
||||
'#app-gocardless/errors': './build/src/app-gocardless/errors.js',
|
||||
'#util/*': './build/src/util/*.js',
|
||||
'#scripts/*': './build/src/scripts/*.js',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty object for empty imports', () => {
|
||||
expect(derivePublishImports({})).toEqual({});
|
||||
});
|
||||
|
||||
it('throws error for non-string imports values', () => {
|
||||
const imports = {
|
||||
'#foo': './src/foo.js',
|
||||
'#conditional': {
|
||||
browser: './src/browser.js',
|
||||
node: './src/node.js',
|
||||
},
|
||||
};
|
||||
expect(() => derivePublishImports(imports)).toThrow(
|
||||
'Unsupported imports target for "#conditional". Expected a string path.',
|
||||
);
|
||||
});
|
||||
|
||||
it('handles paths with /index.js suffix', () => {
|
||||
const imports = {
|
||||
'#util/title': './src/util/title/index.js',
|
||||
};
|
||||
expect(derivePublishImports(imports)).toEqual({
|
||||
'#util/title': './build/src/util/title/index.js',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('validatePackage', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'validate-imports-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writePackageJson(content: Record<string, unknown>) {
|
||||
const filePath = path.join(tmpDir, 'package.json');
|
||||
fs.writeFileSync(filePath, JSON.stringify(content, null, 2) + '\n');
|
||||
return filePath;
|
||||
}
|
||||
|
||||
it('skips packages with no publishConfig', () => {
|
||||
const filePath = writePackageJson({
|
||||
name: 'test-pkg',
|
||||
imports: { '#foo': './src/foo.js' },
|
||||
});
|
||||
const { result, warnings } = validatePackage(filePath);
|
||||
expect(result).toBeNull();
|
||||
expect(warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips packages with publishConfig but no publishConfig.imports', () => {
|
||||
const filePath = writePackageJson({
|
||||
name: 'test-pkg',
|
||||
imports: { '#foo': './src/foo.js' },
|
||||
publishConfig: { access: 'public' },
|
||||
});
|
||||
const { result, warnings } = validatePackage(filePath);
|
||||
expect(result).toBeNull();
|
||||
expect(warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it('warns when publishConfig.imports exists but imports does not', () => {
|
||||
const filePath = writePackageJson({
|
||||
name: 'test-pkg',
|
||||
publishConfig: {
|
||||
imports: { '#foo': './build/src/foo.js' },
|
||||
},
|
||||
});
|
||||
const { result, warnings } = validatePackage(filePath);
|
||||
expect(result).toBeNull();
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]).toContain('orphaned');
|
||||
});
|
||||
|
||||
it('returns no errors when publishConfig.imports matches', () => {
|
||||
const filePath = writePackageJson({
|
||||
name: 'test-pkg',
|
||||
imports: {
|
||||
'#foo': './src/foo.js',
|
||||
'#bar': './src/bar.ts',
|
||||
},
|
||||
publishConfig: {
|
||||
imports: {
|
||||
'#foo': './build/src/foo.js',
|
||||
'#bar': './build/src/bar.js',
|
||||
},
|
||||
},
|
||||
});
|
||||
const { result } = validatePackage(filePath);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.missingKeys).toEqual([]);
|
||||
expect(result!.extraKeys).toEqual([]);
|
||||
expect(result!.wrongValues).toEqual([]);
|
||||
});
|
||||
|
||||
it('detects missing keys in publishConfig.imports', () => {
|
||||
const filePath = writePackageJson({
|
||||
name: 'test-pkg',
|
||||
imports: {
|
||||
'#foo': './src/foo.js',
|
||||
'#bar': './src/bar.ts',
|
||||
},
|
||||
publishConfig: {
|
||||
imports: {
|
||||
'#foo': './build/src/foo.js',
|
||||
},
|
||||
},
|
||||
});
|
||||
const { result } = validatePackage(filePath);
|
||||
expect(result!.missingKeys).toEqual(['#bar']);
|
||||
});
|
||||
|
||||
it('detects extra keys in publishConfig.imports', () => {
|
||||
const filePath = writePackageJson({
|
||||
name: 'test-pkg',
|
||||
imports: {
|
||||
'#foo': './src/foo.js',
|
||||
},
|
||||
publishConfig: {
|
||||
imports: {
|
||||
'#foo': './build/src/foo.js',
|
||||
'#orphan': './build/src/orphan.js',
|
||||
},
|
||||
},
|
||||
});
|
||||
const { result } = validatePackage(filePath);
|
||||
expect(result!.extraKeys).toEqual(['#orphan']);
|
||||
});
|
||||
|
||||
it('detects wrong values in publishConfig.imports', () => {
|
||||
const filePath = writePackageJson({
|
||||
name: 'test-pkg',
|
||||
imports: {
|
||||
'#foo': './src/foo.ts',
|
||||
},
|
||||
publishConfig: {
|
||||
imports: {
|
||||
'#foo': './src/foo.ts',
|
||||
},
|
||||
},
|
||||
});
|
||||
const { result } = validatePackage(filePath);
|
||||
expect(result!.wrongValues).toEqual([
|
||||
{ key: '#foo', expected: './build/src/foo.js', actual: './src/foo.ts' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -4,30 +4,20 @@ 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 plugins-service build
|
||||
yarn workspace loot-core build:browser
|
||||
yarn workspace @actual-app/web build:browser
|
||||
|
||||
echo "packages/desktop-client/build"
|
||||
|
||||
@@ -43,7 +43,6 @@ if [ $SKIP_TRANSLATIONS == false ]; then
|
||||
|
||||
pushd packages/desktop-client/locale > /dev/null
|
||||
|
||||
git checkout .
|
||||
git pull
|
||||
popd > /dev/null
|
||||
packages/desktop-client/bin/remove-untranslated-languages
|
||||
@@ -51,18 +50,15 @@ fi
|
||||
|
||||
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:node
|
||||
yarn workspace loot-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 loot-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
|
||||
yarn workspace @actual-app/core exec tsgo -p tsconfig.json
|
||||
|
||||
yarn workspace desktop-electron update-client
|
||||
|
||||
(
|
||||
|
||||
@@ -178,4 +178,4 @@ async function execAsync(cmd: string, errorLog?: string): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
void run();
|
||||
run();
|
||||
|
||||
@@ -28,5 +28,5 @@ echo "Running VRT tests with the following parameters:"
|
||||
echo "E2E_START_URL: $E2E_START_URL"
|
||||
echo "VRT_ARGS: $VRT_ARGS"
|
||||
|
||||
MSYS_NO_PATHCONV=1 docker run --rm --network host -v "$(pwd)":/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.59.1-jammy /bin/bash \
|
||||
MSYS_NO_PATHCONV=1 docker run --rm --network host -v "$(pwd)":/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.57.0-jammy /bin/bash \
|
||||
-c "E2E_START_URL=$E2E_START_URL yarn vrt $VRT_ARGS"
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* Derives publishConfig.imports from imports by:
|
||||
* 1. Prepending ./build/ to each value path
|
||||
* 2. Replacing .ts/.tsx extensions with .js
|
||||
*/
|
||||
export function derivePublishImports(
|
||||
imports: Record<string, string | object>,
|
||||
): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(imports)) {
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(
|
||||
`Unsupported imports target for "${key}". Expected a string path.`,
|
||||
);
|
||||
}
|
||||
const withBuildPrefix = value.replace(/^\.\//, './build/');
|
||||
const withJsExtension = withBuildPrefix.replace(/\.tsx?$/, '.js');
|
||||
result[key] = withJsExtension;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export type ValidationResult = {
|
||||
packagePath: string;
|
||||
packageName: string;
|
||||
missingKeys: string[];
|
||||
extraKeys: string[];
|
||||
wrongValues: Array<{ key: string; expected: string; actual: string }>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates publishConfig.imports against imports for a single package.json.
|
||||
* Returns null if the package should be skipped (no publishConfig.imports).
|
||||
* Returns a ValidationResult if the package has both fields.
|
||||
*/
|
||||
export function validatePackage(packageJsonPath: string): {
|
||||
result: ValidationResult | null;
|
||||
warnings: string[];
|
||||
} {
|
||||
const warnings: string[] = [];
|
||||
const content = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
|
||||
const packageName: string = content.name ?? packageJsonPath;
|
||||
|
||||
const imports: Record<string, string | object> | undefined = content.imports;
|
||||
const publishImports: Record<string, string> | undefined =
|
||||
content.publishConfig?.imports;
|
||||
|
||||
// No publishConfig.imports → skip
|
||||
if (!publishImports) {
|
||||
return { result: null, warnings };
|
||||
}
|
||||
|
||||
// Has publishConfig.imports but no imports → warn
|
||||
if (!imports) {
|
||||
warnings.push(
|
||||
`${packageName}: orphaned publishConfig.imports (no imports field)`,
|
||||
);
|
||||
return { result: null, warnings };
|
||||
}
|
||||
|
||||
const expected = derivePublishImports(imports);
|
||||
const expectedKeys = new Set(Object.keys(expected));
|
||||
const actualKeys = new Set(Object.keys(publishImports));
|
||||
|
||||
const missingKeys = [...expectedKeys].filter(k => !actualKeys.has(k));
|
||||
const extraKeys = [...actualKeys].filter(k => !expectedKeys.has(k));
|
||||
const wrongValues: ValidationResult['wrongValues'] = [];
|
||||
|
||||
for (const key of expectedKeys) {
|
||||
if (actualKeys.has(key) && publishImports[key] !== expected[key]) {
|
||||
wrongValues.push({
|
||||
key,
|
||||
expected: expected[key],
|
||||
actual: publishImports[key],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
result: {
|
||||
packagePath: packageJsonPath,
|
||||
packageName,
|
||||
missingKeys,
|
||||
extraKeys,
|
||||
wrongValues,
|
||||
},
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
export function fixPackage(packageJsonPath: string): boolean {
|
||||
const raw = fs.readFileSync(packageJsonPath, 'utf-8');
|
||||
const content = JSON.parse(raw);
|
||||
|
||||
if (!content.imports || !content.publishConfig?.imports) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expected = derivePublishImports(content.imports);
|
||||
|
||||
// Check if already correct
|
||||
if (
|
||||
JSON.stringify(content.publishConfig.imports) === JSON.stringify(expected)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
content.publishConfig.imports = expected;
|
||||
fs.writeFileSync(packageJsonPath, JSON.stringify(content, null, 2) + '\n');
|
||||
return true;
|
||||
}
|
||||
|
||||
function findPackageJsonFiles(): string[] {
|
||||
const packagesDir = path.resolve(__dirname, '..', 'packages');
|
||||
const entries = fs.readdirSync(packagesDir, { withFileTypes: true });
|
||||
const results: string[] = [];
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const pkgPath = path.join(packagesDir, entry.name, 'package.json');
|
||||
if (fs.existsSync(pkgPath)) {
|
||||
results.push(pkgPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function resolvePackageJsonPaths(filePaths: string[]): string[] {
|
||||
const packagesRoot = path.resolve(__dirname, '..', 'packages');
|
||||
const seen = new Set<string>();
|
||||
for (const filePath of filePaths) {
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
let dir = path.dirname(resolvedPath);
|
||||
while (dir.startsWith(packagesRoot + path.sep)) {
|
||||
const candidate = path.join(dir, 'package.json');
|
||||
if (
|
||||
fs.existsSync(candidate) &&
|
||||
candidate.startsWith(packagesRoot + path.sep)
|
||||
) {
|
||||
seen.add(candidate);
|
||||
break;
|
||||
}
|
||||
dir = path.dirname(dir);
|
||||
}
|
||||
}
|
||||
return [...seen];
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const fixMode = args.includes('--fix');
|
||||
const filePaths = args.filter(arg => !arg.startsWith('--'));
|
||||
|
||||
const packageJsonFiles =
|
||||
filePaths.length > 0
|
||||
? resolvePackageJsonPaths(filePaths)
|
||||
: findPackageJsonFiles();
|
||||
|
||||
let hasErrors = false;
|
||||
const allWarnings: string[] = [];
|
||||
|
||||
for (const pkgPath of packageJsonFiles) {
|
||||
if (fixMode) {
|
||||
const fixed = fixPackage(pkgPath);
|
||||
if (fixed) {
|
||||
const name = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).name;
|
||||
console.log(`Fixed publishConfig.imports in ${name}`);
|
||||
}
|
||||
} else {
|
||||
const { result, warnings } = validatePackage(pkgPath);
|
||||
allWarnings.push(...warnings);
|
||||
|
||||
if (result) {
|
||||
const hasIssues =
|
||||
result.missingKeys.length > 0 ||
|
||||
result.extraKeys.length > 0 ||
|
||||
result.wrongValues.length > 0;
|
||||
|
||||
if (hasIssues) {
|
||||
hasErrors = true;
|
||||
console.error(`\n${result.packageName}:`);
|
||||
|
||||
for (const key of result.missingKeys) {
|
||||
console.error(` Missing key: ${key}`);
|
||||
}
|
||||
for (const key of result.extraKeys) {
|
||||
console.error(` Extra key: ${key}`);
|
||||
}
|
||||
for (const { key, expected, actual } of result.wrongValues) {
|
||||
console.error(` Wrong value for ${key}:`);
|
||||
console.error(` expected: ${expected}`);
|
||||
console.error(` actual: ${actual}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const warning of allWarnings) {
|
||||
console.warn(`Warning: ${warning}`);
|
||||
}
|
||||
|
||||
if (hasErrors) {
|
||||
console.error(
|
||||
'\npublishConfig.imports is out of sync. Run with --fix to auto-fix.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
include: ['__tests__/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
@@ -1,12 +1,6 @@
|
||||
const BUILD_OUTPUT_GLOBS = ['lib-dist/**', 'dist/**', 'build/**', '@types/**'];
|
||||
|
||||
/** @type {import('lage').ConfigOptions} */
|
||||
module.exports = {
|
||||
pipeline: {
|
||||
typecheck: {
|
||||
type: 'npmScript',
|
||||
dependsOn: ['^typecheck'],
|
||||
},
|
||||
test: {
|
||||
type: 'npmScript',
|
||||
options: {
|
||||
@@ -19,25 +13,16 @@ module.exports = {
|
||||
},
|
||||
build: {
|
||||
type: 'npmScript',
|
||||
dependsOn: ['^build'],
|
||||
cache: true,
|
||||
options: {
|
||||
outputGlob: BUILD_OUTPUT_GLOBS,
|
||||
outputGlob: ['lib-dist/**', 'dist/**', 'build/**'],
|
||||
},
|
||||
},
|
||||
// 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: {
|
||||
provider: 'local',
|
||||
outputGlob: BUILD_OUTPUT_GLOBS,
|
||||
outputGlob: ['lib-dist/**', 'dist/**', 'build/**'],
|
||||
},
|
||||
},
|
||||
npmClient: 'yarn',
|
||||
|
||||
72
package.json
72
package.json
@@ -24,22 +24,22 @@
|
||||
"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",
|
||||
"start:desktop-node": "yarn workspace @actual-app/core watch:node",
|
||||
"desktop-dependencies": "npm-run-all --parallel rebuild-electron build:browser-backend build:plugins-service",
|
||||
"start:desktop-node": "yarn workspace loot-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 loot-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 loot-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",
|
||||
"build:plugins-service": "yarn workspace plugins-service build",
|
||||
"build:api": "yarn build --scope=@actual-app/api",
|
||||
"build:cli": "yarn build --scope=@actual-app/cli",
|
||||
"build:api": "yarn workspace @actual-app/api build",
|
||||
"build:docs": "yarn workspace docs build",
|
||||
"build:storybook": "yarn workspace @actual-app/components build:storybook",
|
||||
"deploy:docs": "yarn workspace docs deploy",
|
||||
@@ -52,62 +52,50 @@
|
||||
"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-node": "yarn workspace @actual-app/core rebuild",
|
||||
"lint": "oxfmt --check . && oxlint --type-aware --quiet",
|
||||
"lint:fix": "oxfmt . && oxlint --fix --type-aware --quiet",
|
||||
"rebuild-electron": "./node_modules/.bin/electron-rebuild -m ./packages/loot-core",
|
||||
"rebuild-node": "yarn workspace loot-core rebuild",
|
||||
"lint": "oxfmt --check . && oxlint --deny-warnings",
|
||||
"lint:fix": "oxfmt . && oxlint --deny-warnings --fix",
|
||||
"install:server": "yarn workspaces focus @actual-app/sync-server --production",
|
||||
"constraints": "yarn constraints",
|
||||
"typecheck": "tsgo -p tsconfig.root.json --noEmit && lage typecheck",
|
||||
"check:tsconfig-references": "workspaces-to-typescript-project-references --check",
|
||||
"sync:tsconfig-references": "workspaces-to-typescript-project-references",
|
||||
"typecheck": "yarn tsc --incremental && tsc-strict",
|
||||
"jq": "./node_modules/node-jq/bin/jq",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@monorepo-utils/workspaces-to-typescript-project-references": "^2.10.3",
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@types/node": "^22.19.17",
|
||||
"@types/node": "^22.19.3",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"@typescript/native-preview": "beta",
|
||||
"@yarnpkg/types": "^4.0.1",
|
||||
"eslint": "^10.2.0",
|
||||
"eslint-plugin-perfectionist": "^5.8.0",
|
||||
"baseline-browser-mapping": "^2.9.14",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-perfectionist": "^4.15.1",
|
||||
"eslint-plugin-typescript-paths": "^0.0.33",
|
||||
"html-to-image": "^1.11.13",
|
||||
"husky": "^9.1.7",
|
||||
"lage": "^2.15.5",
|
||||
"lint-staged": "^16.4.0",
|
||||
"minimatch": "^10.2.5",
|
||||
"lage": "^2.14.15",
|
||||
"lint-staged": "^16.2.7",
|
||||
"minimatch": "^10.1.1",
|
||||
"node-jq": "^6.3.1",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"oxfmt": "^0.44.0",
|
||||
"oxlint": "^1.59.0",
|
||||
"oxlint-tsgolint": "^0.20.0",
|
||||
"p-limit": "^7.3.0",
|
||||
"oxfmt": "^0.26.0",
|
||||
"oxlint": "^1.41.0",
|
||||
"p-limit": "^7.2.0",
|
||||
"prompts": "^2.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^6.0.2",
|
||||
"vitest": "^4.1.2"
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-strict-plugin": "^2.4.4"
|
||||
},
|
||||
"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",
|
||||
"minimatch@3.1.2": "3.1.5",
|
||||
"minimatch@>=10.0.0 <11.0.0": "10.2.5",
|
||||
"minimatch@>=3.0.0 <4.0.0": "3.1.5",
|
||||
"minimatch@>=5.0.0 <6.0.0": "5.1.9",
|
||||
"minimatch@>=9.0.0 <10.0.0": "9.0.9",
|
||||
"rollup": "4.40.1",
|
||||
"socks": ">=2.8.3"
|
||||
},
|
||||
"lint-staged": {
|
||||
"packages/*/{package.json,tsconfig.json}": [
|
||||
"ts-node ./bin/validate-publish-imports.ts --fix",
|
||||
"yarn sync:tsconfig-references"
|
||||
],
|
||||
"*.{js,mjs,jsx,ts,tsx,md,json,yml,yaml}": [
|
||||
"oxfmt --no-error-on-unmatched-pattern"
|
||||
],
|
||||
"*.{js,mjs,jsx,ts,tsx}": [
|
||||
"oxlint --fix --type-aware --quiet"
|
||||
"oxlint --deny-warnings --fix"
|
||||
]
|
||||
},
|
||||
"browserslist": [
|
||||
@@ -118,5 +106,5 @@
|
||||
"node": ">=22",
|
||||
"yarn": "^4.9.1"
|
||||
},
|
||||
"packageManager": "yarn@4.13.0"
|
||||
"packageManager": "yarn@4.10.3"
|
||||
}
|
||||
|
||||
@@ -3,7 +3,3 @@ npm install @actual-app/api
|
||||
```
|
||||
|
||||
View docs here: https://actualbudget.org/docs/api/
|
||||
|
||||
## TypeScript
|
||||
|
||||
`@actual-app/api` publishes TypeScript declarations. Consumers using TypeScript must set `moduleResolution` to `"bundler"`, `"nodenext"`, or `"node16"` in their `tsconfig.json`. Legacy `"node"` / `"node10"` / `"classic"` resolution is not supported in strict mode — the published declarations rely on package.json `exports` conditions that older resolvers don't honor.
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
class Query {
|
||||
/** @type {import('@actual-app/core/shared/query').QueryState} */
|
||||
state;
|
||||
|
||||
constructor(state) {
|
||||
this.state = {
|
||||
filterExpressions: state.filterExpressions || [],
|
||||
|
||||
@@ -1,30 +1,53 @@
|
||||
import { init as initLootCore } from '@actual-app/core/server/main';
|
||||
import type { InitConfig, lib } from '@actual-app/core/server/main';
|
||||
import type {
|
||||
RequestInfo as FetchInfo,
|
||||
RequestInit as FetchInit,
|
||||
} from 'node-fetch';
|
||||
|
||||
// loot-core types
|
||||
import type { InitConfig } from 'loot-core/server/main';
|
||||
|
||||
// oxlint-disable-next-line typescript/ban-ts-comment
|
||||
// @ts-ignore: bundle not available until we build it
|
||||
import * as bundle from './app/bundle.api.js';
|
||||
import * as injected from './injected';
|
||||
import { validateNodeVersion } from './validateNodeVersion';
|
||||
|
||||
let actualApp: null | typeof bundle.lib;
|
||||
export const internal = bundle.lib;
|
||||
|
||||
export * from './methods';
|
||||
export * as utils from './utils';
|
||||
|
||||
/** @deprecated Please use return value of `init` instead */
|
||||
export let internal: typeof lib | null = null;
|
||||
|
||||
export async function init(config: InitConfig = {}) {
|
||||
if (actualApp) {
|
||||
return;
|
||||
}
|
||||
|
||||
validateNodeVersion();
|
||||
|
||||
internal = await initLootCore(config);
|
||||
return internal;
|
||||
if (!globalThis.fetch) {
|
||||
globalThis.fetch = (url: URL | RequestInfo, init?: RequestInit) => {
|
||||
return import('node-fetch').then(({ default: fetch }) =>
|
||||
fetch(url as unknown as FetchInfo, init as unknown as FetchInit),
|
||||
) as unknown as Promise<Response>;
|
||||
};
|
||||
}
|
||||
|
||||
await bundle.init(config);
|
||||
actualApp = bundle.lib;
|
||||
|
||||
injected.override(bundle.lib.send);
|
||||
return bundle.lib;
|
||||
}
|
||||
|
||||
export async function shutdown() {
|
||||
if (internal) {
|
||||
if (actualApp) {
|
||||
try {
|
||||
await internal.send('sync');
|
||||
await actualApp.send('sync');
|
||||
} catch {
|
||||
// most likely that no budget is loaded, so the sync failed
|
||||
}
|
||||
|
||||
await internal.send('close-budget');
|
||||
internal = null;
|
||||
await actualApp.send('close-budget');
|
||||
actualApp = null;
|
||||
}
|
||||
}
|
||||
|
||||
7
packages/api/injected.js
Normal file
7
packages/api/injected.js
Normal file
@@ -0,0 +1,7 @@
|
||||
// TODO: comment on why it works this way
|
||||
|
||||
export let send;
|
||||
|
||||
export function override(sendImplementation) {
|
||||
send = sendImplementation;
|
||||
}
|
||||
@@ -1,33 +1,10 @@
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
import type { RuleEntity } from '@actual-app/core/types/models';
|
||||
import { vi } from 'vitest';
|
||||
import type { RuleEntity } from 'loot-core/types/models';
|
||||
|
||||
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(
|
||||
'../loot-core/src/platform/server/fs/index.api',
|
||||
async importOriginal => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
const pathMod = await import('path');
|
||||
const lootCoreRoot = pathMod.join(__dirname, '..', 'loot-core');
|
||||
return {
|
||||
...actual,
|
||||
migrationsPath: pathMod.join(lootCoreRoot, 'migrations'),
|
||||
bundledDatabasePath: pathMod.join(lootCoreRoot, 'default-db.sqlite'),
|
||||
demoBudgetPath: pathMod.join(lootCoreRoot, 'demo-budget'),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const budgetName = 'test-budget';
|
||||
|
||||
global.IS_TESTING = true;
|
||||
@@ -379,143 +356,6 @@ describe('API CRUD operations', () => {
|
||||
);
|
||||
});
|
||||
|
||||
// apis: createTag, getTags, updateTag, deleteTag
|
||||
test('Tags: successfully complete tag operations', async () => {
|
||||
// Create tags
|
||||
const tagId1 = await api.createTag({ tag: 'test-tag1', color: '#ff0000' });
|
||||
const tagId2 = await api.createTag({
|
||||
tag: 'test-tag2',
|
||||
description: 'A test tag',
|
||||
});
|
||||
|
||||
let tags = await api.getTags();
|
||||
expect(tags).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: tagId1,
|
||||
tag: 'test-tag1',
|
||||
color: '#ff0000',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: tagId2,
|
||||
tag: 'test-tag2',
|
||||
description: 'A test tag',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
// Update tag
|
||||
await api.updateTag(tagId1, { tag: 'updated-tag', color: '#00ff00' });
|
||||
tags = await api.getTags();
|
||||
expect(tags).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: tagId1,
|
||||
tag: 'updated-tag',
|
||||
color: '#00ff00',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
// Delete tag
|
||||
await api.deleteTag(tagId2);
|
||||
tags = await api.getTags();
|
||||
expect(tags).not.toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ id: tagId2 })]),
|
||||
);
|
||||
});
|
||||
|
||||
test('Tags: create tag with minimal fields', async () => {
|
||||
const tagId = await api.createTag({ tag: 'minimal-tag' });
|
||||
const tags = await api.getTags();
|
||||
expect(tags).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: tagId,
|
||||
tag: 'minimal-tag',
|
||||
color: null,
|
||||
description: null,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('Tags: update single field only', async () => {
|
||||
const tagId = await api.createTag({ tag: 'original', color: '#ff0000' });
|
||||
|
||||
// Update only color, tag and description should remain unchanged
|
||||
await api.updateTag(tagId, { color: '#00ff00' });
|
||||
|
||||
const tags = await api.getTags();
|
||||
expect(tags).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: tagId,
|
||||
tag: 'original',
|
||||
color: '#00ff00',
|
||||
description: null,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('Tags: handle null values correctly', async () => {
|
||||
const tagId = await api.createTag({
|
||||
tag: 'with-nulls',
|
||||
color: null,
|
||||
description: null,
|
||||
});
|
||||
|
||||
const tags = await api.getTags();
|
||||
expect(tags).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: tagId,
|
||||
color: null,
|
||||
description: null,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('Tags: clear optional field', async () => {
|
||||
const tagId = await api.createTag({
|
||||
tag: 'clearable',
|
||||
color: '#ff0000',
|
||||
description: 'will be cleared',
|
||||
});
|
||||
|
||||
// Clear color by setting to null
|
||||
await api.updateTag(tagId, { color: null });
|
||||
|
||||
let tags = await api.getTags();
|
||||
expect(tags).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: tagId,
|
||||
tag: 'clearable',
|
||||
color: null,
|
||||
description: 'will be cleared',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
// Clear description by setting to null
|
||||
await api.updateTag(tagId, { description: null });
|
||||
|
||||
tags = await api.getTags();
|
||||
expect(tags).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: tagId,
|
||||
tag: 'clearable',
|
||||
color: null,
|
||||
description: null,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
// apis: getRules, getPayeeRules, createRule, updateRule, deleteRule
|
||||
test('Rules: successfully update rules', async () => {
|
||||
await api.createPayee({ name: 'test-payee' });
|
||||
@@ -900,73 +740,6 @@ describe('API CRUD operations', () => {
|
||||
);
|
||||
expect(transactions[0].notes).toBeNull();
|
||||
});
|
||||
|
||||
test('Transactions: reimportDeleted=false prevents reimporting deleted transactions', async () => {
|
||||
const accountId = await api.createAccount({ name: 'test-account' }, 0);
|
||||
|
||||
// Import a transaction
|
||||
const result1 = await api.importTransactions(accountId, [
|
||||
{
|
||||
date: '2023-11-03',
|
||||
imported_id: 'reimport-test-1',
|
||||
amount: 100,
|
||||
account: accountId,
|
||||
},
|
||||
]);
|
||||
expect(result1.added).toHaveLength(1);
|
||||
|
||||
// Delete the transaction
|
||||
await api.deleteTransaction(result1.added[0]);
|
||||
|
||||
// Reimport the same transaction with reimportDeleted=false
|
||||
const result2 = await api.importTransactions(
|
||||
accountId,
|
||||
[
|
||||
{
|
||||
date: '2023-11-03',
|
||||
imported_id: 'reimport-test-1',
|
||||
amount: 100,
|
||||
account: accountId,
|
||||
},
|
||||
],
|
||||
{ reimportDeleted: false },
|
||||
);
|
||||
|
||||
// Should match the deleted transaction and not create a new one
|
||||
expect(result2.added).toHaveLength(0);
|
||||
expect(result2.updated).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Transactions: reimportDeleted=true reimports deleted transactions', async () => {
|
||||
const accountId = await api.createAccount({ name: 'test-account' }, 0);
|
||||
|
||||
// Import a transaction
|
||||
const result1 = await api.importTransactions(accountId, [
|
||||
{
|
||||
date: '2023-11-03',
|
||||
imported_id: 'reimport-test-2',
|
||||
amount: 200,
|
||||
account: accountId,
|
||||
},
|
||||
]);
|
||||
expect(result1.added).toHaveLength(1);
|
||||
|
||||
// Delete the transaction
|
||||
await api.deleteTransaction(result1.added[0]);
|
||||
|
||||
// Reimport the same transaction relying on reimportDeleted=true default
|
||||
const result2 = await api.importTransactions(accountId, [
|
||||
{
|
||||
date: '2023-11-03',
|
||||
imported_id: 'reimport-test-2',
|
||||
amount: 200,
|
||||
account: accountId,
|
||||
},
|
||||
]);
|
||||
|
||||
// Should create a new transaction since deleted ones are ignored
|
||||
expect(result2.added).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
//apis: createSchedule, getSchedules, updateSchedule, deleteSchedule
|
||||
|
||||
@@ -5,17 +5,16 @@ import type {
|
||||
APIFileEntity,
|
||||
APIPayeeEntity,
|
||||
APIScheduleEntity,
|
||||
APITagEntity,
|
||||
} from '@actual-app/core/server/api-models';
|
||||
import { lib } from '@actual-app/core/server/main';
|
||||
import type { Query } from '@actual-app/core/shared/query';
|
||||
import type { ImportTransactionsOpts } from '@actual-app/core/types/api-handlers';
|
||||
import type { Handlers } from '@actual-app/core/types/handlers';
|
||||
} from 'loot-core/server/api-models';
|
||||
import type { Query } from 'loot-core/shared/query';
|
||||
import type { Handlers } from 'loot-core/types/handlers';
|
||||
import type {
|
||||
ImportTransactionEntity,
|
||||
RuleEntity,
|
||||
TransactionEntity,
|
||||
} from '@actual-app/core/types/models';
|
||||
} from 'loot-core/types/models';
|
||||
|
||||
import * as injected from './injected';
|
||||
|
||||
export { q } from './app/query';
|
||||
|
||||
@@ -23,7 +22,7 @@ function send<K extends keyof Handlers, T extends Handlers[K]>(
|
||||
name: K,
|
||||
args?: Parameters<T>[0],
|
||||
): Promise<Awaited<ReturnType<T>>> {
|
||||
return lib.send(name, args);
|
||||
return injected.send(name, args);
|
||||
}
|
||||
|
||||
export async function runImport(
|
||||
@@ -126,6 +125,11 @@ export function addTransactions(
|
||||
});
|
||||
}
|
||||
|
||||
export type ImportTransactionsOpts = {
|
||||
defaultCleared?: boolean;
|
||||
dryRun?: boolean;
|
||||
};
|
||||
|
||||
export function importTransactions(
|
||||
accountId: APIAccountEntity['id'],
|
||||
transactions: ImportTransactionEntity[],
|
||||
@@ -270,25 +274,6 @@ export function deletePayee(id: APIPayeeEntity['id']) {
|
||||
return send('api/payee-delete', { id });
|
||||
}
|
||||
|
||||
export function getTags() {
|
||||
return send('api/tags-get');
|
||||
}
|
||||
|
||||
export function createTag(tag: Omit<APITagEntity, 'id'>) {
|
||||
return send('api/tag-create', { tag });
|
||||
}
|
||||
|
||||
export function updateTag(
|
||||
id: APITagEntity['id'],
|
||||
fields: Partial<Omit<APITagEntity, 'id'>>,
|
||||
) {
|
||||
return send('api/tag-update', { id, fields });
|
||||
}
|
||||
|
||||
export function deleteTag(id: APITagEntity['id']) {
|
||||
return send('api/tag-delete', { id });
|
||||
}
|
||||
|
||||
export function mergePayees(
|
||||
targetId: APIPayeeEntity['id'],
|
||||
mergeIds: APIPayeeEntity['id'][],
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export type * from '@actual-app/core/server/api-models';
|
||||
@@ -1,63 +1,35 @@
|
||||
{
|
||||
"name": "@actual-app/api",
|
||||
"version": "26.5.0",
|
||||
"version": "26.2.0",
|
||||
"description": "An API for Actual",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/actualbudget/actual.git",
|
||||
"directory": "packages/api"
|
||||
},
|
||||
"files": [
|
||||
"@types",
|
||||
"dist",
|
||||
"!@types/**/*.test.d.ts",
|
||||
"!@types/**/*.test.d.ts.map"
|
||||
"dist"
|
||||
],
|
||||
"main": "dist/index.js",
|
||||
"types": "@types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./@types/index.d.ts",
|
||||
"development": "./index.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./models": {
|
||||
"types": "./@types/models.d.ts",
|
||||
"development": "./models.ts",
|
||||
"default": "./dist/models.js"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./@types/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./models": {
|
||||
"types": "./@types/models.d.ts",
|
||||
"default": "./dist/models.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build && tsgo --emitDeclarationOnly",
|
||||
"test": "vitest --run",
|
||||
"typecheck": "tsgo -b && tsc-strict"
|
||||
"build:app": "yarn workspace loot-core build:api",
|
||||
"build:crdt": "yarn workspace @actual-app/crdt build",
|
||||
"build:node": "tsc --p tsconfig.dist.json && tsc-alias -p tsconfig.dist.json",
|
||||
"build:migrations": "cp migrations/*.sql dist/migrations",
|
||||
"build:default-db": "cp default-db.sqlite dist/",
|
||||
"build": "yarn run clean && yarn run build:app && yarn run build:node && yarn run build:migrations && yarn run build:default-db",
|
||||
"test": "yarn run clean && yarn run build:app && yarn run build:crdt && vitest --run",
|
||||
"clean": "rm -rf dist @types"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actual-app/core": "workspace:*",
|
||||
"@actual-app/crdt": "workspace:*",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"compare-versions": "^6.1.1"
|
||||
"@actual-app/crdt": "workspace:^",
|
||||
"better-sqlite3": "^12.5.0",
|
||||
"compare-versions": "^6.1.1",
|
||||
"node-fetch": "^3.3.2",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript/native-preview": "beta",
|
||||
"rollup-plugin-visualizer": "^7.0.1",
|
||||
"typescript-strict-plugin": "^2.4.4",
|
||||
"vite": "^8.0.5",
|
||||
"vite-plugin-peggy-loader": "^2.0.1",
|
||||
"vitest": "^4.1.2"
|
||||
"tsc-alias": "^1.8.16",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.16"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
||||
19
packages/api/tsconfig.dist.json
Normal file
19
packages/api/tsconfig.dist.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
// Using ES2021 because that's the newest version where
|
||||
// the latest Node 16.x release supports all of the features
|
||||
"target": "ES2021",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node10",
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"declarationDir": "@types",
|
||||
"paths": {
|
||||
"loot-core/*": ["./@types/loot-core/src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["."],
|
||||
"exclude": ["**/node_modules/*", "dist", "@types", "*.test.ts"]
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
// Using ES2021 because that's the newest version where
|
||||
// the latest Node 16.x release supports all of the features
|
||||
"target": "ES2021",
|
||||
"module": "es2022",
|
||||
"moduleResolution": "bundler",
|
||||
"customConditions": ["api"],
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"declarationDir": "@types",
|
||||
"tsBuildInfoFile": "dist/.tsbuildinfo",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "typescript-strict-plugin",
|
||||
"paths": ["."]
|
||||
}
|
||||
]
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"path": "../loot-core"
|
||||
},
|
||||
{
|
||||
"path": "../crdt"
|
||||
}
|
||||
],
|
||||
"include": ["."],
|
||||
"exclude": [
|
||||
"**/node_modules/*",
|
||||
"dist",
|
||||
"@types",
|
||||
"*.config.ts",
|
||||
"*.config.mts"
|
||||
]
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
declare module 'hyperformula/i18n/languages/enUS';
|
||||
declare module '*.pegjs';
|
||||
1
packages/api/typings/pegjs.d.ts
vendored
1
packages/api/typings/pegjs.d.ts
vendored
@@ -1 +0,0 @@
|
||||
declare module '*.pegjs';
|
||||
@@ -1,4 +1,6 @@
|
||||
import { lib } from '@actual-app/core/server/main';
|
||||
// oxlint-disable-next-line typescript/ban-ts-comment
|
||||
// @ts-ignore: bundle not available until we build it
|
||||
import * as bundle from './app/bundle.api.js';
|
||||
|
||||
export const amountToInteger = lib.amountToInteger;
|
||||
export const integerToAmount = lib.integerToAmount;
|
||||
export const amountToInteger = bundle.lib.amountToInteger;
|
||||
export const integerToAmount = bundle.lib.integerToAmount;
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { visualizer } from 'rollup-plugin-visualizer';
|
||||
import { defineConfig } from 'vite';
|
||||
import peggyLoader from 'vite-plugin-peggy-loader';
|
||||
|
||||
const lootCoreRoot = path.resolve(__dirname, '../loot-core');
|
||||
const distDir = path.resolve(__dirname, 'dist');
|
||||
const typesDir = path.resolve(__dirname, '@types');
|
||||
|
||||
function cleanOutputDirs() {
|
||||
return {
|
||||
name: 'clean-output-dirs',
|
||||
buildStart() {
|
||||
if (fs.existsSync(distDir)) fs.rmSync(distDir, { recursive: true });
|
||||
if (fs.existsSync(typesDir)) fs.rmSync(typesDir, { recursive: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function copyMigrationsAndDefaultDb() {
|
||||
return {
|
||||
name: 'copy-migrations-and-default-db',
|
||||
closeBundle() {
|
||||
const migrationsSrc = path.join(lootCoreRoot, 'migrations');
|
||||
const defaultDbPath = path.join(lootCoreRoot, 'default-db.sqlite');
|
||||
|
||||
if (!fs.existsSync(migrationsSrc)) {
|
||||
throw new Error(`migrations directory not found at ${migrationsSrc}`);
|
||||
}
|
||||
const migrationsStat = fs.statSync(migrationsSrc);
|
||||
if (!migrationsStat.isDirectory()) {
|
||||
throw new Error(`migrations path is not a directory: ${migrationsSrc}`);
|
||||
}
|
||||
|
||||
const migrationsDest = path.join(distDir, 'migrations');
|
||||
fs.mkdirSync(migrationsDest, { recursive: true });
|
||||
for (const name of fs.readdirSync(migrationsSrc)) {
|
||||
if (name.endsWith('.sql') || name.endsWith('.js')) {
|
||||
fs.copyFileSync(
|
||||
path.join(migrationsSrc, name),
|
||||
path.join(migrationsDest, name),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!fs.existsSync(defaultDbPath)) {
|
||||
throw new Error(`default-db.sqlite not found at ${defaultDbPath}`);
|
||||
}
|
||||
fs.copyFileSync(defaultDbPath, path.join(distDir, 'default-db.sqlite'));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
ssr: {
|
||||
noExternal: true,
|
||||
external: ['better-sqlite3'],
|
||||
resolve: { conditions: ['api'] },
|
||||
},
|
||||
build: {
|
||||
ssr: true,
|
||||
target: 'node20',
|
||||
outDir: distDir,
|
||||
emptyOutDir: true,
|
||||
sourcemap: true,
|
||||
lib: {
|
||||
entry: {
|
||||
index: path.resolve(__dirname, 'index.ts'),
|
||||
models: path.resolve(__dirname, 'models.ts'),
|
||||
},
|
||||
formats: ['cjs'],
|
||||
fileName: (_format, entryName) => `${entryName}.js`,
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
cleanOutputDirs(),
|
||||
peggyLoader(),
|
||||
copyMigrationsAndDefaultDb(),
|
||||
visualizer({ template: 'raw-data', filename: 'app/stats.json' }),
|
||||
],
|
||||
resolve: {
|
||||
conditions: ['api'],
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
onConsoleLog(log: string, type: 'stdout' | 'stderr'): boolean | void {
|
||||
// print only console.error
|
||||
return type === 'stderr';
|
||||
},
|
||||
maxWorkers: 2,
|
||||
},
|
||||
});
|
||||
10
packages/api/vitest.config.ts
Normal file
10
packages/api/vitest.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export default {
|
||||
test: {
|
||||
globals: true,
|
||||
onConsoleLog(log: string, type: 'stdout' | 'stderr'): boolean | void {
|
||||
// print only console.error
|
||||
return type === 'stderr';
|
||||
},
|
||||
maxWorkers: 2,
|
||||
},
|
||||
};
|
||||
1
packages/ci-actions/.gitignore
vendored
1
packages/ci-actions/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
dist/*
|
||||
44
packages/ci-actions/bin/get-next-package-version.ts → packages/ci-actions/bin/get-next-package-version.js
Normal file → Executable file
44
packages/ci-actions/bin/get-next-package-version.ts → packages/ci-actions/bin/get-next-package-version.js
Normal file → Executable file
@@ -2,13 +2,13 @@
|
||||
|
||||
// This script is used in GitHub Actions to get the next version based on the current package.json version.
|
||||
// It supports three types of versioning: nightly, hotfix, and monthly.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import { parseArgs } from 'node:util';
|
||||
|
||||
import {
|
||||
getNextVersion,
|
||||
isValidVersionType,
|
||||
} from '../src/versions/get-next-package-version';
|
||||
import { getNextVersion } from '../src/versions/get-next-package-version.js';
|
||||
|
||||
const args = process.argv;
|
||||
|
||||
const options = {
|
||||
'package-json': {
|
||||
@@ -28,53 +28,40 @@ const options = {
|
||||
short: 'u',
|
||||
default: false,
|
||||
},
|
||||
} as const;
|
||||
|
||||
function fail(message: string): never {
|
||||
console.error(message);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
const { values } = parseArgs({
|
||||
args,
|
||||
options,
|
||||
allowPositionals: true,
|
||||
});
|
||||
|
||||
const packageJsonPath = values['package-json'];
|
||||
if (!packageJsonPath) {
|
||||
fail(
|
||||
if (!values['package-json']) {
|
||||
console.error(
|
||||
'Please specify the path to package.json using --package-json or -p option.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const packageJsonPath = values['package-json'];
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
|
||||
if (!('version' in packageJson) || typeof packageJson.version !== 'string') {
|
||||
fail('The specified package.json does not contain a valid version field.');
|
||||
}
|
||||
|
||||
const currentVersion = packageJson.version;
|
||||
|
||||
const explicitVersion = values.version;
|
||||
let newVersion;
|
||||
|
||||
if (explicitVersion) {
|
||||
newVersion = explicitVersion;
|
||||
} else {
|
||||
const type = values.type;
|
||||
if (!type || !isValidVersionType(type)) {
|
||||
fail('Please specify the release type using --type or -t.');
|
||||
}
|
||||
|
||||
try {
|
||||
newVersion = getNextVersion({
|
||||
currentVersion,
|
||||
type,
|
||||
type: values.type,
|
||||
currentDate: new Date(),
|
||||
});
|
||||
} catch (error) {
|
||||
fail(error instanceof Error ? error.message : String(error));
|
||||
} catch (e) {
|
||||
console.error(e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,5 +76,6 @@ try {
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(`Error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
console.error('Error:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
import matter from 'gray-matter';
|
||||
|
||||
import {
|
||||
categoryAutocorrections,
|
||||
categoryOrder,
|
||||
} from '../src/release-notes/util.mjs';
|
||||
|
||||
console.log('Looking in ' + fs.realpathSync('upcoming-release-notes'));
|
||||
|
||||
const expectedPath = `upcoming-release-notes/${process.env.PR_NUMBER}.md`;
|
||||
|
||||
function reportError(message) {
|
||||
console.log(`::error::${message}`);
|
||||
|
||||
process.stdout.write('::notice::');
|
||||
fs.createReadStream('upcoming-release-notes/README.md').pipe(process.stdout);
|
||||
|
||||
fs.createReadStream('upcoming-release-notes/README.md')
|
||||
.pipe(fs.createWriteStream(process.env.GITHUB_STEP_SUMMARY))
|
||||
.on('close', () => {
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
(() => {
|
||||
if (!fs.existsSync(expectedPath)) {
|
||||
reportError(`Release note file ${expectedPath} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { data, content } = matter(fs.readFileSync(expectedPath, 'utf-8'));
|
||||
|
||||
if (!data.category) {
|
||||
reportError(`Release note is missing a category.`);
|
||||
return;
|
||||
}
|
||||
if (categoryAutocorrections[data.category]) {
|
||||
data.category = categoryAutocorrections[data.category];
|
||||
}
|
||||
if (!categoryOrder.includes(data.category)) {
|
||||
reportError(
|
||||
`Release note category "${data.category}" is not one of ${categoryOrder
|
||||
.map(JSON.stringify)
|
||||
.join(', ')}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data.authors) {
|
||||
reportError(`Release note is missing authors.`);
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(data.authors)) {
|
||||
reportError(`Release note authors should be a list.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (content.trim().split('\n').length !== 1) {
|
||||
reportError(
|
||||
`Release note file ${expectedPath} body should contain exactly one line`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Everything looks good! \u{1f389}');
|
||||
})();
|
||||
@@ -1,312 +0,0 @@
|
||||
import * as childProcess from 'node:child_process';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { inspect, promisify } from 'node:util';
|
||||
|
||||
import matter from 'gray-matter';
|
||||
import listify from 'listify';
|
||||
|
||||
import {
|
||||
categoryAutocorrections,
|
||||
categoryOrder,
|
||||
} from '../src/release-notes/util.mjs';
|
||||
|
||||
const exec = promisify(childProcess.exec);
|
||||
|
||||
const [owner, repo] = process.env.GITHUB_REPOSITORY.split('/');
|
||||
|
||||
const apiResult = await fetch('https://api.github.com/graphql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `bearer ${process.env.GITHUB_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: /* GraphQL */ `
|
||||
query GetPRMetadata(
|
||||
$name: String!
|
||||
$owner: String!
|
||||
$headRefName: String!
|
||||
) {
|
||||
repository(name: $name, owner: $owner) {
|
||||
pullRequests(headRefName: $headRefName, first: 1) {
|
||||
edges {
|
||||
node {
|
||||
number
|
||||
headRefName
|
||||
body
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
name: repo,
|
||||
owner,
|
||||
headRefName: process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME,
|
||||
},
|
||||
}),
|
||||
}).then(res => res.json());
|
||||
|
||||
await collapsedLog('API Response', apiResult);
|
||||
|
||||
const prData = apiResult.data.repository.pullRequests.edges[0].node;
|
||||
|
||||
const version = prData.headRefName.split('/')[1].replace(/^v/, '');
|
||||
const slug = version.replace(/\./g, '-');
|
||||
const author = process.env.GITHUB_ACTOR || 'TODO';
|
||||
const commitMessage = `Generate release notes for v${version}`;
|
||||
|
||||
const releaseDateMatch = (prData.body || '').match(
|
||||
/<!-- release-date:(\d{4}-\d{2}-\d{2}) -->/,
|
||||
);
|
||||
const releaseDate = releaseDateMatch ? releaseDateMatch[1] : 'TODO';
|
||||
|
||||
const botName = 'github-actions[bot]';
|
||||
const botEmail = '41898282+github-actions[bot]@users.noreply.github.com';
|
||||
|
||||
await exec(`git config user.name '${botName}'`);
|
||||
await exec(`git config user.email '${botEmail}'`);
|
||||
|
||||
const AUTOGEN_MARKER = '<!-- release-notes:auto-generated -->';
|
||||
|
||||
await group('Prepare branch', async () => {
|
||||
if (process.env.GITHUB_HEAD_REF) {
|
||||
await exec(`git fetch origin ${process.env.GITHUB_HEAD_REF}`, {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
await exec(`git checkout ${process.env.GITHUB_HEAD_REF}`, {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
}
|
||||
|
||||
// recover deleted release note files from previous generation commits
|
||||
const baseRef = process.env.GITHUB_BASE_REF || 'master';
|
||||
await exec(`git fetch origin ${baseRef}`, { stdio: 'inherit' });
|
||||
const { stdout: mergeBase } = await exec(
|
||||
`git merge-base HEAD origin/${baseRef}`,
|
||||
);
|
||||
const base = mergeBase.trim();
|
||||
const { stdout: genLog } = await exec(
|
||||
`git log --grep='${commitMessage}' --format=%H ${base}..HEAD`,
|
||||
);
|
||||
const genCommits = genLog.split('\n').filter(Boolean);
|
||||
console.log(
|
||||
`Reversing upcoming-release-notes deletions from ${genCommits.length} prior generation commit(s)`,
|
||||
);
|
||||
const tmpDir = process.env.RUNNER_TEMP || '/tmp';
|
||||
for (const sha of genCommits) {
|
||||
const patchPath = join(tmpDir, `revert-${sha}.patch`);
|
||||
try {
|
||||
await exec(
|
||||
`git diff --diff-filter=D ${sha}~1..${sha} -- upcoming-release-notes > ${patchPath}`,
|
||||
);
|
||||
const { size } = await fs.stat(patchPath);
|
||||
if (size > 0) {
|
||||
await exec(`git apply -R --3way ${patchPath}`, { stdio: 'inherit' });
|
||||
}
|
||||
} finally {
|
||||
await fs.unlink(patchPath).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const { notesByCategory, files } = await parseReleaseNotes(
|
||||
'upcoming-release-notes',
|
||||
);
|
||||
const categorizedNotes = formatNotes(notesByCategory);
|
||||
|
||||
await collapsedLog('Release Notes', categorizedNotes);
|
||||
|
||||
if (files.length === 0) {
|
||||
console.log('No release notes found, nothing to generate');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const highlights = '- TODO: Add release highlights';
|
||||
|
||||
const blogPath = join(
|
||||
'packages/docs/blog',
|
||||
`${releaseDate}-release-${slug}.md`,
|
||||
);
|
||||
const releasesPath = 'packages/docs/docs/releases.md';
|
||||
|
||||
await group('Generate blog post', async () => {
|
||||
const template = `---
|
||||
title: Release ${version}
|
||||
description: New release of Actual.
|
||||
date: ${releaseDate}T10:00
|
||||
slug: release-${version}
|
||||
tags: [announcement, release]
|
||||
hide_table_of_contents: false
|
||||
authors: ${author}
|
||||
---
|
||||
|
||||
${highlights}
|
||||
|
||||
<!--truncate-->
|
||||
|
||||
**Docker Tag: ${version}**
|
||||
|
||||
${AUTOGEN_MARKER}
|
||||
|
||||
${categorizedNotes}
|
||||
`;
|
||||
|
||||
let blogContent;
|
||||
try {
|
||||
const existing = await fs.readFile(blogPath, 'utf-8');
|
||||
const idx = existing.indexOf(AUTOGEN_MARKER);
|
||||
if (idx === -1) {
|
||||
console.log(
|
||||
`WARNING: ${blogPath} missing ${AUTOGEN_MARKER}, rewriting from template`,
|
||||
);
|
||||
blogContent = template;
|
||||
} else {
|
||||
blogContent =
|
||||
existing.slice(0, idx + AUTOGEN_MARKER.length) +
|
||||
'\n' +
|
||||
categorizedNotes +
|
||||
'\n';
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT') throw e;
|
||||
blogContent = template;
|
||||
}
|
||||
|
||||
await fs.writeFile(blogPath, blogContent);
|
||||
console.log(`Wrote ${blogPath}`);
|
||||
});
|
||||
|
||||
await group('Update releases.md', async () => {
|
||||
const existing = await fs.readFile(releasesPath, 'utf-8');
|
||||
|
||||
const sectionRe = new RegExp(
|
||||
`(^|\\n)## ${escapeRegExp(version)}\\n[\\s\\S]*?(?=\\n## |$)`,
|
||||
);
|
||||
const match = existing.match(sectionRe);
|
||||
|
||||
let updated;
|
||||
if (match) {
|
||||
const section = match[0];
|
||||
const idx = section.indexOf(AUTOGEN_MARKER);
|
||||
if (idx === -1) {
|
||||
console.log(
|
||||
`WARNING: section for ${version} in ${releasesPath} missing ${AUTOGEN_MARKER}, leaving as-is`,
|
||||
);
|
||||
updated = existing;
|
||||
} else {
|
||||
const newSection =
|
||||
section.slice(0, idx + AUTOGEN_MARKER.length) + '\n' + categorizedNotes;
|
||||
updated = existing.replace(section, newSection);
|
||||
}
|
||||
} else {
|
||||
const newSection = `## ${version}
|
||||
|
||||
Release date: ${releaseDate}
|
||||
|
||||
${highlights}
|
||||
|
||||
**Docker Tag: ${version}**
|
||||
|
||||
${AUTOGEN_MARKER}
|
||||
|
||||
${categorizedNotes}`;
|
||||
updated = existing.replace(
|
||||
'# Release Notes\n',
|
||||
`# Release Notes\n\n${newSection}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
await fs.writeFile(releasesPath, updated);
|
||||
console.log(`Updated ${releasesPath}`);
|
||||
});
|
||||
|
||||
await group('Remove used release notes', async () => {
|
||||
await Promise.all(
|
||||
files.map(f => fs.unlink(join('upcoming-release-notes', f))),
|
||||
);
|
||||
});
|
||||
|
||||
await group('Format generated files', async () => {
|
||||
await exec(`yarn exec oxfmt ${blogPath} ${releasesPath}`, {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
});
|
||||
|
||||
await group('Commit and push', async () => {
|
||||
await exec(
|
||||
'git add upcoming-release-notes packages/docs/blog packages/docs/docs/releases.md',
|
||||
{ stdio: 'inherit' },
|
||||
);
|
||||
|
||||
try {
|
||||
await exec('git diff --cached --quiet');
|
||||
console.log('No changes to commit');
|
||||
return;
|
||||
} catch {
|
||||
// there are staged changes
|
||||
}
|
||||
|
||||
await exec(`git commit -m '${commitMessage}'`);
|
||||
await exec('git push origin', { stdio: 'inherit' });
|
||||
});
|
||||
|
||||
async function parseReleaseNotes(dir) {
|
||||
const files = (await fs.readdir(dir)).filter(f => f.match(/^\d+\.md$/));
|
||||
const notes = files.map(async name => {
|
||||
const content = await fs.readFile(join(dir, name), 'utf-8');
|
||||
const { data, content: body } = matter(content);
|
||||
const number = name.replace('.md', '');
|
||||
const authors = listify(
|
||||
data.authors.map(a => `@${a}`),
|
||||
{ finalWord: '&' },
|
||||
);
|
||||
return {
|
||||
category: categoryAutocorrections[data.category] ?? data.category,
|
||||
value: `- [#${number}](https://github.com/actualbudget/${repo}/pull/${number}) ${body.trim()} — thanks ${authors}`,
|
||||
};
|
||||
});
|
||||
|
||||
const notesByCategory = (await Promise.all(notes)).reduce(
|
||||
(acc, note) => {
|
||||
if (!acc[note.category]) {
|
||||
console.log(`WARNING: Unrecognized category "${note.category}"`);
|
||||
acc[note.category] = [];
|
||||
}
|
||||
acc[note.category].push(note.value);
|
||||
return acc;
|
||||
},
|
||||
Object.fromEntries(categoryOrder.map(c => [c, []])),
|
||||
);
|
||||
|
||||
return { notesByCategory, files };
|
||||
}
|
||||
|
||||
function escapeRegExp(str) {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function formatNotes(notes) {
|
||||
return Object.entries(notes)
|
||||
.filter(([_, values]) => values.length > 0)
|
||||
.map(([category, values]) => `#### ${category}\n\n${values.join('\n')}`)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
async function collapsedLog(name, value) {
|
||||
await group(name, () => {
|
||||
if (typeof value === 'string') {
|
||||
console.log(value);
|
||||
} else {
|
||||
console.log(inspect(value, { depth: null }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function group(name, cb) {
|
||||
console.log(`::group::${name}`);
|
||||
await cb();
|
||||
console.log('::endgroup::');
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
cd ../../
|
||||
|
||||
script="$1"
|
||||
shift
|
||||
exec node --import=extensionless/register --experimental-strip-types packages/ci-actions/"$script" "$@"
|
||||
@@ -3,21 +3,9 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"tsx": "bin/tsx",
|
||||
"test": "vitest --run",
|
||||
"typecheck": "tsgo -b"
|
||||
"test": "vitest --run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@typescript/native-preview": "beta",
|
||||
"extensionless": "^2.0.6",
|
||||
"gray-matter": "^4.0.3",
|
||||
"listify": "^1.0.3",
|
||||
"vitest": "^4.1.2"
|
||||
},
|
||||
"extensionless": {
|
||||
"lookFor": [
|
||||
"ts"
|
||||
]
|
||||
"vitest": "^4.0.16"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
export const categoryAutocorrections = {
|
||||
Feature: 'Features',
|
||||
Enhancement: 'Enhancements',
|
||||
Bugfix: 'Bugfixes',
|
||||
};
|
||||
|
||||
export const categoryOrder = [
|
||||
'Features',
|
||||
'Enhancements',
|
||||
'Bugfixes',
|
||||
'Maintenance',
|
||||
];
|
||||
@@ -1,69 +1,35 @@
|
||||
export const versionTypeArray = [
|
||||
'auto',
|
||||
'hotfix',
|
||||
'monthly',
|
||||
'nightly',
|
||||
] as const;
|
||||
export type VersionType = (typeof versionTypeArray)[number];
|
||||
|
||||
type ParsedVersion = {
|
||||
versionYear: number;
|
||||
versionMonth: number;
|
||||
versionHotfix: number;
|
||||
};
|
||||
|
||||
type GetNextVersionOptions = {
|
||||
currentVersion: string;
|
||||
type: VersionType;
|
||||
currentDate?: Date;
|
||||
};
|
||||
|
||||
function parseVersion(version: string): ParsedVersion {
|
||||
function parseVersion(version) {
|
||||
const [y, m, p] = version.split('.');
|
||||
return {
|
||||
versionYear: Number.parseInt(y, 10),
|
||||
versionMonth: Number.parseInt(m, 10),
|
||||
versionHotfix: Number.parseInt(p, 10),
|
||||
versionYear: parseInt(y, 10),
|
||||
versionMonth: parseInt(m, 10),
|
||||
versionHotfix: parseInt(p, 10),
|
||||
};
|
||||
}
|
||||
|
||||
function computeNextMonth(versionYear: number, versionMonth: number) {
|
||||
const versionDate = new Date(2000 + versionYear, versionMonth - 1, 1);
|
||||
function computeNextMonth(versionYear, versionMonth) {
|
||||
// Create date and add 1 month
|
||||
const versionDate = new Date(2000 + versionYear, versionMonth - 1, 1); // month is 0-indexed
|
||||
const nextVersionMonthDate = new Date(
|
||||
versionDate.getFullYear(),
|
||||
versionDate.getMonth() + 1,
|
||||
1,
|
||||
);
|
||||
|
||||
// Format back to YY.M format
|
||||
const fullYear = nextVersionMonthDate.getFullYear();
|
||||
const nextVersionYear = fullYear.toString().slice(fullYear < 2100 ? -2 : -3);
|
||||
const nextVersionMonth = nextVersionMonthDate.getMonth() + 1;
|
||||
|
||||
const nextVersionMonth = nextVersionMonthDate.getMonth() + 1; // Convert back to 1-indexed
|
||||
return { nextVersionYear, nextVersionMonth };
|
||||
}
|
||||
|
||||
export function isValidVersionType(value: string): value is VersionType {
|
||||
return versionTypeArray.includes(value as VersionType);
|
||||
}
|
||||
|
||||
function resolveType(
|
||||
type: VersionType,
|
||||
currentDate: Date,
|
||||
versionYear: number,
|
||||
versionMonth: number,
|
||||
) {
|
||||
if (type !== 'auto') {
|
||||
return type;
|
||||
}
|
||||
|
||||
// Determine logical type from 'auto' based on the current date and version
|
||||
function resolveType(type, currentDate, versionYear, versionMonth) {
|
||||
if (type !== 'auto') return type;
|
||||
const inPatchMonth =
|
||||
currentDate.getFullYear() === 2000 + versionYear &&
|
||||
currentDate.getMonth() + 1 === versionMonth;
|
||||
|
||||
if (inPatchMonth && currentDate.getDate() < 25) {
|
||||
return 'hotfix';
|
||||
}
|
||||
|
||||
if (inPatchMonth && currentDate.getDate() <= 25) return 'hotfix';
|
||||
return 'monthly';
|
||||
}
|
||||
|
||||
@@ -71,7 +37,7 @@ export function getNextVersion({
|
||||
currentVersion,
|
||||
type,
|
||||
currentDate = new Date(),
|
||||
}: GetNextVersionOptions) {
|
||||
}) {
|
||||
const { versionYear, versionMonth, versionHotfix } =
|
||||
parseVersion(currentVersion);
|
||||
const { nextVersionYear, nextVersionMonth } = computeNextMonth(
|
||||
@@ -85,10 +51,11 @@ export function getNextVersion({
|
||||
versionMonth,
|
||||
);
|
||||
|
||||
// Format date stamp once for nightly
|
||||
const currentDateString = currentDate
|
||||
.toISOString()
|
||||
.split('T')[0]
|
||||
.replace(/-/g, '');
|
||||
.replaceAll('-', '');
|
||||
|
||||
switch (resolvedType) {
|
||||
case 'nightly':
|
||||
@@ -99,7 +66,7 @@ export function getNextVersion({
|
||||
return `${nextVersionYear}.${nextVersionMonth}.0`;
|
||||
default:
|
||||
throw new Error(
|
||||
`Invalid type ${String(resolvedType satisfies never)} specified. Use "auto", "nightly", "hotfix", or "monthly".`,
|
||||
'Invalid type specified. Use "auto", "nightly", "hotfix", or "monthly".',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,7 @@ describe('getNextVersion (lib)', () => {
|
||||
expect(() =>
|
||||
getNextVersion({
|
||||
currentVersion: '25.8.4',
|
||||
type: 'unknown' as never,
|
||||
type: 'unknown',
|
||||
currentDate: new Date('2025-08-10'),
|
||||
}),
|
||||
).toThrow(/Invalid type/);
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": [],
|
||||
"module": "es2022",
|
||||
"moduleResolution": "bundler",
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"types": ["node"],
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*", "bin/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
7
packages/cli/.gitignore
vendored
7
packages/cli/.gitignore
vendored
@@ -1,7 +0,0 @@
|
||||
dist
|
||||
coverage
|
||||
.actualrc.json
|
||||
.actualrc
|
||||
.actualrc.yaml
|
||||
.actualrc.yml
|
||||
actual.config.js
|
||||
@@ -1,199 +0,0 @@
|
||||
# @actual-app/cli
|
||||
|
||||
> **WARNING:** This CLI is experimental.
|
||||
|
||||
Command-line interface for [Actual Budget](https://actualbudget.org). Query and modify your budget data from the terminal — accounts, transactions, categories, payees, rules, schedules, and more.
|
||||
|
||||
> **Note:** This CLI connects to a running [Actual sync server](https://actualbudget.org/docs/install/). It does not operate on local budget files directly.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install -g @actual-app/cli
|
||||
```
|
||||
|
||||
Requires Node.js >= 22.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Set connection details
|
||||
export ACTUAL_SERVER_URL=http://localhost:5006
|
||||
export ACTUAL_PASSWORD=your-password
|
||||
export ACTUAL_SYNC_ID=your-sync-id # Found in Settings → Advanced → Sync ID
|
||||
|
||||
# List your accounts
|
||||
actual accounts list
|
||||
|
||||
# Check a balance
|
||||
actual accounts balance <account-id>
|
||||
|
||||
# View this month's budget
|
||||
actual budgets month 2026-03
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Configuration is resolved in this order (highest priority first):
|
||||
|
||||
1. **CLI flags** (`--server-url`, `--password`, etc.)
|
||||
2. **Environment variables**
|
||||
3. **Config file** (via [cosmiconfig](https://github.com/cosmiconfig/cosmiconfig))
|
||||
4. **Defaults** (`dataDir` defaults to `~/.actual-cli/data`)
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
| ---------------------- | ----------------------------------------------------- |
|
||||
| `ACTUAL_SERVER_URL` | URL of the Actual sync server (required) |
|
||||
| `ACTUAL_PASSWORD` | Server password (required unless using token) |
|
||||
| `ACTUAL_SESSION_TOKEN` | Session token (alternative to password) |
|
||||
| `ACTUAL_SYNC_ID` | Budget Sync ID (required for most commands) |
|
||||
| `ACTUAL_DATA_DIR` | Local directory for cached budget data |
|
||||
| `ACTUAL_CACHE_TTL` | Cache TTL in seconds (default: 60) |
|
||||
| `ACTUAL_LOCK_TIMEOUT` | Budget-dir lock wait timeout in seconds (default: 10) |
|
||||
| `ACTUAL_NO_LOCK` | Set to `1` to disable budget-dir locking |
|
||||
|
||||
### Config File
|
||||
|
||||
Create an `.actualrc.json` (or `.actualrc`, `.actualrc.yaml`, `actual.config.js`):
|
||||
|
||||
```json
|
||||
{
|
||||
"serverUrl": "http://localhost:5006",
|
||||
"password": "your-password",
|
||||
"syncId": "1cfdbb80-6274-49bf-b0c2-737235a4c81f",
|
||||
"cacheTtl": 60,
|
||||
"lockTimeout": 10,
|
||||
"noLock": false
|
||||
}
|
||||
```
|
||||
|
||||
**Security:** Do not store plaintext passwords in config files (e.g. `.actualrc.json`, `.actualrc`, `.actualrc.yaml`, `actual.config.js`). Add these files to `.gitignore` if they contain secrets. Prefer the `ACTUAL_SESSION_TOKEN` environment variable instead of the `password` field. See [Environment Variables](#environment-variables) for using a session token.
|
||||
|
||||
### Global Flags
|
||||
|
||||
| Flag | Description |
|
||||
| ------------------------- | ----------------------------------------------- |
|
||||
| `--server-url <url>` | Server URL |
|
||||
| `--password <pw>` | Server password |
|
||||
| `--session-token <token>` | Session token |
|
||||
| `--sync-id <id>` | Budget Sync ID |
|
||||
| `--data-dir <path>` | Data directory |
|
||||
| `--cache-ttl <seconds>` | Cache TTL; `0` disables caching (default: 60) |
|
||||
| `--refresh` | Force a sync on this call, ignoring the cache |
|
||||
| `--no-cache` | Alias for `--refresh` |
|
||||
| `--lock-timeout <secs>` | Lock wait timeout (default: 10) |
|
||||
| `--no-lock` | Disable budget-dir locking (use with care) |
|
||||
| `--format <format>` | Output format: `json` (default), `table`, `csv` |
|
||||
| `--verbose` | Show informational messages |
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
| ----------------- | ------------------------------ |
|
||||
| `accounts` | Manage accounts |
|
||||
| `budgets` | Manage budgets and allocations |
|
||||
| `categories` | Manage categories |
|
||||
| `category-groups` | Manage category groups |
|
||||
| `transactions` | Manage transactions |
|
||||
| `payees` | Manage payees |
|
||||
| `tags` | Manage tags |
|
||||
| `rules` | Manage transaction rules |
|
||||
| `schedules` | Manage scheduled transactions |
|
||||
| `query` | Run an ActualQL query |
|
||||
| `server` | Server utilities and lookups |
|
||||
| `sync` | Refresh or inspect local cache |
|
||||
|
||||
Run `actual <command> --help` for subcommands and options.
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
# List all accounts (as a table; excludes closed by default)
|
||||
actual accounts list [--include-closed] --format table
|
||||
|
||||
# Find an entity ID by name
|
||||
actual server get-id --type accounts --name "Checking"
|
||||
|
||||
# Add a transaction (amount in integer cents: -2500 = -$25.00)
|
||||
actual transactions add --account <id> \
|
||||
--data '[{"date":"2026-03-14","amount":-2500,"payee_name":"Coffee Shop"}]'
|
||||
|
||||
# Export transactions to CSV
|
||||
actual transactions list --account <id> \
|
||||
--start 2026-01-01 --end 2026-12-31 --format csv > transactions.csv
|
||||
|
||||
# Set budget amount ($500 = 50000 cents)
|
||||
actual budgets set-amount --month 2026-03 --category <id> --amount 50000
|
||||
|
||||
# Run an ActualQL query
|
||||
actual query run --table transactions \
|
||||
--select "date,amount,payee" --filter '{"amount":{"$lt":0}}' --limit 10
|
||||
```
|
||||
|
||||
### Amount Convention
|
||||
|
||||
All monetary amounts are **integer cents** when passed as input (flags, JSON):
|
||||
|
||||
| CLI Value | Dollar Amount |
|
||||
| --------- | ------------- |
|
||||
| `5000` | $50.00 |
|
||||
| `-12350` | -$123.50 |
|
||||
|
||||
**Output formatting:** Table (`--format table`) and CSV (`--format csv`) output automatically converts cent values to decimal (e.g. `1665.00` instead of `166500`). JSON output always returns raw cents for programmatic use.
|
||||
|
||||
### Tips & Common Pitfalls
|
||||
|
||||
- **Split transactions:** When summing or counting transactions, filter `"is_parent": false` to avoid double-counting. A split parent holds the total amount, and its children hold the individual parts — including both would count the total twice.
|
||||
|
||||
- **Rapid sequential requests:** The CLI caches the budget locally (see [Caching](#caching)), so read-heavy scripts no longer need a single-query workaround by default. For very chatty scripts, run `actual sync` once and then use a long `--cache-ttl` for reads:
|
||||
|
||||
```bash
|
||||
actual sync
|
||||
actual --cache-ttl 3600 query run ...
|
||||
actual --cache-ttl 3600 accounts list
|
||||
```
|
||||
|
||||
- **Uncategorized transactions:** `category.name` is `null` for transactions without a category. Account for this when filtering or grouping by category.
|
||||
|
||||
- **No date sub-fields in AQL:** `date.month`, `date.year`, etc. are not supported as query fields. To group by month, fetch raw transactions with a date range filter and aggregate locally in a script.
|
||||
|
||||
## Caching
|
||||
|
||||
The CLI keeps a local copy of your budget so repeated commands don't hit the sync server on every call. Within the TTL (default `60` seconds), read commands (`list`, `balance`, `query run`, …) reuse the cached budget without a network round-trip. Write commands (`add`, `update`, `set-amount`, …) always sync with the server before and after the write.
|
||||
|
||||
- `actual sync` — refresh the cache now.
|
||||
- `actual sync --status` — show how stale the local cache is.
|
||||
- `actual sync --clear` — delete the local cache; the next command re-downloads.
|
||||
- `--refresh` (or `--no-cache`) — force a sync on a single call.
|
||||
- `--cache-ttl <seconds>` — override the TTL for a single call (use `0` to disable caching).
|
||||
|
||||
### Concurrency
|
||||
|
||||
The CLI takes a shared lock for reads and an exclusive lock for writes on the per-budget cache directory. Many parallel reads are safe; writes serialize. If another CLI process is holding the lock, subsequent invocations wait up to `--lock-timeout` seconds (default `10`) before failing with an error. Pass `--no-lock` to opt out in trusted single-process setups.
|
||||
|
||||
## Running Locally (Development)
|
||||
|
||||
If you're working on the CLI within the monorepo:
|
||||
|
||||
```bash
|
||||
# 1. Build the CLI
|
||||
yarn build:cli
|
||||
|
||||
# 2. Start a local sync server (in a separate terminal)
|
||||
yarn start:server-dev
|
||||
|
||||
# 3. Open http://localhost:5006 in your browser, create a budget,
|
||||
# then find the Sync ID in Settings → Advanced → Sync ID
|
||||
|
||||
# 4. Run the CLI directly from the build output
|
||||
ACTUAL_SERVER_URL=http://localhost:5006 \
|
||||
ACTUAL_PASSWORD=your-password \
|
||||
ACTUAL_SYNC_ID=your-sync-id \
|
||||
node packages/cli/dist/cli.js accounts list
|
||||
|
||||
# Or use a shorthand alias for convenience
|
||||
alias actual-dev="node $(pwd)/packages/cli/dist/cli.js"
|
||||
actual-dev budgets list
|
||||
```
|
||||
@@ -1,52 +0,0 @@
|
||||
{
|
||||
"name": "@actual-app/cli",
|
||||
"version": "26.5.0",
|
||||
"description": "CLI for Actual Budget",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/actualbudget/actual.git",
|
||||
"directory": "packages/cli"
|
||||
},
|
||||
"bin": {
|
||||
"actual": "./dist/cli.js",
|
||||
"actual-cli": "./dist/cli.js"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"type": "module",
|
||||
"imports": {
|
||||
"#cache": "./src/cache.ts",
|
||||
"#commands/*": "./src/commands/*.ts",
|
||||
"#config": "./src/config.ts",
|
||||
"#connection": "./src/connection.ts",
|
||||
"#input": "./src/input.ts",
|
||||
"#lock": "./src/lock.ts",
|
||||
"#output": "./src/output.ts",
|
||||
"#utils": "./src/utils.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest --run",
|
||||
"typecheck": "tsgo -b"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actual-app/api": "workspace:*",
|
||||
"cli-table3": "^0.6.5",
|
||||
"commander": "^14.0.3",
|
||||
"cosmiconfig": "^9.0.1",
|
||||
"proper-lockfile": "^4.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.19.17",
|
||||
"@types/proper-lockfile": "^4",
|
||||
"@typescript/native-preview": "beta",
|
||||
"rollup-plugin-visualizer": "^7.0.1",
|
||||
"vite": "^8.0.5",
|
||||
"vitest": "^4.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import {
|
||||
CACHE_FILE_NAME,
|
||||
decideSyncAction,
|
||||
readCacheState,
|
||||
writeCacheState,
|
||||
} from './cache';
|
||||
|
||||
describe('readCacheState', () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'actual-cli-cache-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns null when the file does not exist', () => {
|
||||
expect(readCacheState(dir)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the file is corrupt', () => {
|
||||
writeFileSync(join(dir, CACHE_FILE_NAME), 'not json');
|
||||
expect(readCacheState(dir)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the file has the wrong version', () => {
|
||||
writeFileSync(
|
||||
join(dir, CACHE_FILE_NAME),
|
||||
JSON.stringify({
|
||||
version: 999,
|
||||
syncId: 'a',
|
||||
budgetId: 'b',
|
||||
serverUrl: 'c',
|
||||
lastSyncedAt: 1,
|
||||
lastDownloadedAt: 1,
|
||||
}),
|
||||
);
|
||||
expect(readCacheState(dir)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the parsed state when the file is valid', () => {
|
||||
writeFileSync(
|
||||
join(dir, CACHE_FILE_NAME),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
syncId: 'a',
|
||||
budgetId: 'b',
|
||||
serverUrl: 'c',
|
||||
lastSyncedAt: 1234,
|
||||
lastDownloadedAt: 5678,
|
||||
}),
|
||||
);
|
||||
expect(readCacheState(dir)).toEqual({
|
||||
version: 1,
|
||||
syncId: 'a',
|
||||
budgetId: 'b',
|
||||
serverUrl: 'c',
|
||||
lastSyncedAt: 1234,
|
||||
lastDownloadedAt: 5678,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeCacheState', () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'actual-cli-cache-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('writes the state to the cache file', () => {
|
||||
writeCacheState(dir, {
|
||||
version: 1,
|
||||
syncId: 'a',
|
||||
budgetId: 'b',
|
||||
serverUrl: 'c',
|
||||
lastSyncedAt: 1,
|
||||
lastDownloadedAt: 1,
|
||||
});
|
||||
const raw = readFileSync(join(dir, CACHE_FILE_NAME), 'utf-8');
|
||||
expect(JSON.parse(raw).syncId).toBe('a');
|
||||
});
|
||||
|
||||
it('is atomic: removes the tmp file after rename', () => {
|
||||
writeCacheState(dir, {
|
||||
version: 1,
|
||||
syncId: 'a',
|
||||
budgetId: 'b',
|
||||
serverUrl: 'c',
|
||||
lastSyncedAt: 1,
|
||||
lastDownloadedAt: 1,
|
||||
});
|
||||
expect(existsSync(join(dir, `${CACHE_FILE_NAME}.tmp`))).toBe(false);
|
||||
});
|
||||
|
||||
it('does not throw when the filesystem refuses the write', () => {
|
||||
// Force ENOTDIR by pointing writeCacheState at a path whose parent is a
|
||||
// regular file — no OS-specific pseudo-filesystem semantics needed.
|
||||
const file = join(dir, 'not-a-dir');
|
||||
writeFileSync(file, '');
|
||||
expect(() =>
|
||||
writeCacheState(join(file, 'nested'), {
|
||||
version: 1,
|
||||
syncId: 'a',
|
||||
budgetId: 'b',
|
||||
serverUrl: 'c',
|
||||
lastSyncedAt: 1,
|
||||
lastDownloadedAt: 1,
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('decideSyncAction', () => {
|
||||
const base = {
|
||||
state: {
|
||||
version: 1 as const,
|
||||
syncId: 'sync-1',
|
||||
budgetId: 'bud-1',
|
||||
serverUrl: 'http://s',
|
||||
lastSyncedAt: 1_000_000,
|
||||
lastDownloadedAt: 1_000_000,
|
||||
},
|
||||
config: { syncId: 'sync-1', serverUrl: 'http://s' },
|
||||
now: 1_000_000,
|
||||
ttlMs: 60_000,
|
||||
mutates: false,
|
||||
refresh: false,
|
||||
encrypted: false,
|
||||
};
|
||||
|
||||
it('returns "download" when state is null', () => {
|
||||
expect(decideSyncAction({ ...base, state: null }).action).toBe('download');
|
||||
});
|
||||
|
||||
it('returns "download" when syncId changed', () => {
|
||||
expect(
|
||||
decideSyncAction({
|
||||
...base,
|
||||
config: { ...base.config, syncId: 'other' },
|
||||
}).action,
|
||||
).toBe('download');
|
||||
});
|
||||
|
||||
it('returns "download" when serverUrl changed', () => {
|
||||
expect(
|
||||
decideSyncAction({
|
||||
...base,
|
||||
config: { ...base.config, serverUrl: 'http://other' },
|
||||
}).action,
|
||||
).toBe('download');
|
||||
});
|
||||
|
||||
it('returns "skip" for a read within the TTL', () => {
|
||||
expect(decideSyncAction({ ...base, now: 1_000_000 + 30_000 }).action).toBe(
|
||||
'skip',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns "sync" for a read past the TTL', () => {
|
||||
expect(decideSyncAction({ ...base, now: 1_000_000 + 61_000 }).action).toBe(
|
||||
'sync',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns "sync" for a write even when fresh', () => {
|
||||
expect(decideSyncAction({ ...base, mutates: true }).action).toBe('sync');
|
||||
});
|
||||
|
||||
it('returns "sync" when refresh is true', () => {
|
||||
expect(decideSyncAction({ ...base, refresh: true }).action).toBe('sync');
|
||||
});
|
||||
|
||||
it('returns "sync" when ttlMs is 0', () => {
|
||||
expect(decideSyncAction({ ...base, ttlMs: 0 }).action).toBe('sync');
|
||||
});
|
||||
|
||||
it('returns "sync" for encrypted budgets within the TTL', () => {
|
||||
expect(decideSyncAction({ ...base, encrypted: true }).action).toBe('sync');
|
||||
});
|
||||
|
||||
it('treats clock skew (negative age) as stale', () => {
|
||||
expect(decideSyncAction({ ...base, now: 999_999 }).action).toBe('sync');
|
||||
});
|
||||
|
||||
it('carries cached state on non-download actions', () => {
|
||||
const decision = decideSyncAction({ ...base, mutates: true });
|
||||
expect(decision).toEqual({ action: 'sync', state: base.state });
|
||||
});
|
||||
});
|
||||
@@ -1,107 +0,0 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { isRecord } from './utils';
|
||||
|
||||
export const CACHE_FILE_NAME = 'state.json';
|
||||
export const CACHE_VERSION = 1;
|
||||
export const META_ROOT_DIR = '.actual-cli';
|
||||
|
||||
export type CacheState = {
|
||||
version: typeof CACHE_VERSION;
|
||||
syncId: string;
|
||||
budgetId: string;
|
||||
serverUrl: string;
|
||||
lastSyncedAt: number;
|
||||
lastDownloadedAt: number;
|
||||
};
|
||||
|
||||
export function getMetaDir(dataDir: string, syncId: string): string {
|
||||
return join(dataDir, META_ROOT_DIR, syncId);
|
||||
}
|
||||
|
||||
function cachePath(metaDir: string): string {
|
||||
return join(metaDir, CACHE_FILE_NAME);
|
||||
}
|
||||
|
||||
function isCacheState(value: unknown): value is CacheState {
|
||||
if (!isRecord(value)) return false;
|
||||
return (
|
||||
value.version === CACHE_VERSION &&
|
||||
typeof value.syncId === 'string' &&
|
||||
typeof value.budgetId === 'string' &&
|
||||
typeof value.serverUrl === 'string' &&
|
||||
typeof value.lastSyncedAt === 'number' &&
|
||||
typeof value.lastDownloadedAt === 'number'
|
||||
);
|
||||
}
|
||||
|
||||
export function readCacheState(metaDir: string): CacheState | null {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(cachePath(metaDir), 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return isCacheState(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
export function writeCacheState(metaDir: string, state: CacheState): void {
|
||||
try {
|
||||
mkdirSync(metaDir, { recursive: true });
|
||||
const target = cachePath(metaDir);
|
||||
// Unique tmp name per writer: concurrent shared-lock commands (encrypted
|
||||
// budgets, --refresh, stale TTL) can both publish, and a shared tmp path
|
||||
// lets the second writer's truncate destroy the first writer's bytes
|
||||
// before either renames into place.
|
||||
const tmp = `${target}.${process.pid}-${randomBytes(4).toString('hex')}.tmp`;
|
||||
writeFileSync(tmp, JSON.stringify(state));
|
||||
renameSync(tmp, target);
|
||||
} catch {
|
||||
// Cache persistence is best-effort. A read-only or unreachable dir must
|
||||
// not crash the CLI; the next invocation simply won't find a cache.
|
||||
}
|
||||
}
|
||||
|
||||
export type SyncDecision =
|
||||
| { action: 'download' }
|
||||
| { action: 'skip'; state: CacheState }
|
||||
| { action: 'sync'; state: CacheState };
|
||||
|
||||
export type DecideSyncArgs = {
|
||||
state: CacheState | null;
|
||||
config: { syncId: string; serverUrl: string };
|
||||
now: number;
|
||||
ttlMs: number;
|
||||
mutates: boolean;
|
||||
refresh: boolean;
|
||||
encrypted: boolean;
|
||||
};
|
||||
|
||||
export function decideSyncAction({
|
||||
state,
|
||||
config,
|
||||
now,
|
||||
ttlMs,
|
||||
mutates,
|
||||
refresh,
|
||||
encrypted,
|
||||
}: DecideSyncArgs): SyncDecision {
|
||||
if (state === null) return { action: 'download' };
|
||||
if (state.syncId !== config.syncId) return { action: 'download' };
|
||||
if (state.serverUrl !== config.serverUrl) return { action: 'download' };
|
||||
if (mutates || refresh || ttlMs === 0 || encrypted) {
|
||||
return { action: 'sync', state };
|
||||
}
|
||||
const age = now - state.lastSyncedAt;
|
||||
if (age < 0) return { action: 'sync', state };
|
||||
if (age < ttlMs) return { action: 'skip', state };
|
||||
return { action: 'sync', state };
|
||||
}
|
||||
@@ -1,326 +0,0 @@
|
||||
import * as api from '@actual-app/api';
|
||||
import { Command } from 'commander';
|
||||
|
||||
import { printOutput } from '#output';
|
||||
|
||||
import { registerAccountsCommand } from './accounts';
|
||||
|
||||
vi.mock('@actual-app/api', () => ({
|
||||
getAccounts: vi.fn().mockResolvedValue([]),
|
||||
createAccount: vi.fn().mockResolvedValue('new-id'),
|
||||
updateAccount: vi.fn().mockResolvedValue(undefined),
|
||||
closeAccount: vi.fn().mockResolvedValue(undefined),
|
||||
reopenAccount: vi.fn().mockResolvedValue(undefined),
|
||||
deleteAccount: vi.fn().mockResolvedValue(undefined),
|
||||
getAccountBalance: vi.fn().mockResolvedValue(10000),
|
||||
}));
|
||||
|
||||
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();
|
||||
registerAccountsCommand(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
async function run(args: string[]) {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', ...args]);
|
||||
}
|
||||
|
||||
describe('accounts 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('list', () => {
|
||||
it('calls api.getAccounts and prints result with computed balance', async () => {
|
||||
const accounts = [
|
||||
{ id: '1', name: 'Checking', offbudget: false, closed: false },
|
||||
];
|
||||
vi.mocked(api.getAccounts).mockResolvedValue(accounts);
|
||||
|
||||
await run(['accounts', 'list']);
|
||||
|
||||
expect(api.getAccounts).toHaveBeenCalled();
|
||||
expect(api.getAccountBalance).toHaveBeenCalledWith('1');
|
||||
expect(printOutput).toHaveBeenCalledWith(
|
||||
[
|
||||
{
|
||||
id: '1',
|
||||
name: 'Checking',
|
||||
offbudget: false,
|
||||
closed: false,
|
||||
balance: 10000,
|
||||
},
|
||||
],
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('passes format option to printOutput', async () => {
|
||||
vi.mocked(api.getAccounts).mockResolvedValue([]);
|
||||
|
||||
await run(['--format', 'csv', 'accounts', 'list']);
|
||||
|
||||
expect(printOutput).toHaveBeenCalledWith([], 'csv');
|
||||
});
|
||||
|
||||
it('filters out closed accounts by default', async () => {
|
||||
vi.mocked(api.getAccounts).mockResolvedValue([
|
||||
{ id: '1', name: 'Open', offbudget: false, closed: false },
|
||||
{ id: '2', name: 'Closed', offbudget: false, closed: true },
|
||||
]);
|
||||
|
||||
await run(['accounts', 'list']);
|
||||
|
||||
expect(printOutput).toHaveBeenCalledWith(
|
||||
[
|
||||
{
|
||||
id: '1',
|
||||
name: 'Open',
|
||||
offbudget: false,
|
||||
closed: false,
|
||||
balance: 10000,
|
||||
},
|
||||
],
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('includes closed accounts when --include-closed is passed', async () => {
|
||||
vi.mocked(api.getAccounts).mockResolvedValue([
|
||||
{ id: '1', name: 'Open', offbudget: false, closed: false },
|
||||
{ id: '2', name: 'Closed', offbudget: false, closed: true },
|
||||
]);
|
||||
|
||||
await run(['accounts', 'list', '--include-closed']);
|
||||
|
||||
expect(printOutput).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: '2', closed: true }),
|
||||
]),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('sorts on-budget accounts before off-budget', async () => {
|
||||
vi.mocked(api.getAccounts).mockResolvedValue([
|
||||
{ id: '1', name: 'OffBudget', offbudget: true, closed: false },
|
||||
{ id: '2', name: 'OnBudget', offbudget: false, closed: false },
|
||||
]);
|
||||
|
||||
await run(['accounts', 'list']);
|
||||
|
||||
const output = vi.mocked(printOutput).mock.calls[0][0] as Array<{
|
||||
id: string;
|
||||
}>;
|
||||
expect(output[0].id).toBe('2'); // on-budget first
|
||||
expect(output[1].id).toBe('1'); // off-budget second
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('passes name and defaults to api.createAccount', async () => {
|
||||
await run(['accounts', 'create', '--name', 'Savings']);
|
||||
|
||||
expect(api.createAccount).toHaveBeenCalledWith(
|
||||
{ name: 'Savings', offbudget: false },
|
||||
0,
|
||||
);
|
||||
expect(printOutput).toHaveBeenCalledWith({ id: 'new-id' }, undefined);
|
||||
});
|
||||
|
||||
it('passes offbudget and balance options', async () => {
|
||||
await run([
|
||||
'accounts',
|
||||
'create',
|
||||
'--name',
|
||||
'Investments',
|
||||
'--offbudget',
|
||||
'--balance',
|
||||
'50000',
|
||||
]);
|
||||
|
||||
expect(api.createAccount).toHaveBeenCalledWith(
|
||||
{ name: 'Investments', offbudget: true },
|
||||
50000,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('passes fields to api.updateAccount', async () => {
|
||||
await run(['accounts', 'update', 'acct-1', '--name', 'NewName']);
|
||||
|
||||
expect(api.updateAccount).toHaveBeenCalledWith('acct-1', {
|
||||
name: 'NewName',
|
||||
});
|
||||
expect(printOutput).toHaveBeenCalledWith(
|
||||
{ success: true, id: 'acct-1' },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('passes offbudget true', async () => {
|
||||
await run([
|
||||
'accounts',
|
||||
'update',
|
||||
'acct-1',
|
||||
'--name',
|
||||
'X',
|
||||
'--offbudget',
|
||||
'true',
|
||||
]);
|
||||
|
||||
expect(api.updateAccount).toHaveBeenCalledWith('acct-1', {
|
||||
name: 'X',
|
||||
offbudget: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes offbudget false', async () => {
|
||||
await run([
|
||||
'accounts',
|
||||
'update',
|
||||
'acct-1',
|
||||
'--name',
|
||||
'X',
|
||||
'--offbudget',
|
||||
'false',
|
||||
]);
|
||||
|
||||
expect(api.updateAccount).toHaveBeenCalledWith('acct-1', {
|
||||
name: 'X',
|
||||
offbudget: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects invalid offbudget value', async () => {
|
||||
await expect(
|
||||
run(['accounts', 'update', 'acct-1', '--offbudget', 'yes']),
|
||||
).rejects.toThrow(
|
||||
'Invalid --offbudget: "yes". Expected "true" or "false".',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects empty name', async () => {
|
||||
await expect(
|
||||
run(['accounts', 'update', 'acct-1', '--name', ' ']),
|
||||
).rejects.toThrow('Invalid --name: must be a non-empty string.');
|
||||
});
|
||||
|
||||
it('rejects update with no fields', async () => {
|
||||
await expect(run(['accounts', 'update', 'acct-1'])).rejects.toThrow(
|
||||
'No update fields provided. Use --name or --offbudget.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('close', () => {
|
||||
it('passes transfer options to api.closeAccount', async () => {
|
||||
await run([
|
||||
'accounts',
|
||||
'close',
|
||||
'acct-1',
|
||||
'--transfer-account',
|
||||
'acct-2',
|
||||
]);
|
||||
|
||||
expect(api.closeAccount).toHaveBeenCalledWith(
|
||||
'acct-1',
|
||||
'acct-2',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('passes transfer category', async () => {
|
||||
await run([
|
||||
'accounts',
|
||||
'close',
|
||||
'acct-1',
|
||||
'--transfer-category',
|
||||
'cat-1',
|
||||
]);
|
||||
|
||||
expect(api.closeAccount).toHaveBeenCalledWith(
|
||||
'acct-1',
|
||||
undefined,
|
||||
'cat-1',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reopen', () => {
|
||||
it('calls api.reopenAccount', async () => {
|
||||
await run(['accounts', 'reopen', 'acct-1']);
|
||||
|
||||
expect(api.reopenAccount).toHaveBeenCalledWith('acct-1');
|
||||
expect(printOutput).toHaveBeenCalledWith(
|
||||
{ success: true, id: 'acct-1' },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('calls api.deleteAccount', async () => {
|
||||
await run(['accounts', 'delete', 'acct-1']);
|
||||
|
||||
expect(api.deleteAccount).toHaveBeenCalledWith('acct-1');
|
||||
expect(printOutput).toHaveBeenCalledWith(
|
||||
{ success: true, id: 'acct-1' },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('balance', () => {
|
||||
it('calls api.getAccountBalance without cutoff', async () => {
|
||||
await run(['accounts', 'balance', 'acct-1']);
|
||||
|
||||
expect(api.getAccountBalance).toHaveBeenCalledWith('acct-1', undefined);
|
||||
expect(printOutput).toHaveBeenCalledWith(
|
||||
{ id: 'acct-1', balance: 10000 },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('calls api.getAccountBalance with cutoff date', async () => {
|
||||
await run(['accounts', 'balance', 'acct-1', '--cutoff', '2025-01-15']);
|
||||
|
||||
expect(api.getAccountBalance).toHaveBeenCalledWith(
|
||||
'acct-1',
|
||||
new Date('2025-01-15'),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user