Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

### Fixed
- In a monorepo with one Localhero project per app, a sync from the web app is now applied only by the app it belongs to. Every app's job receives the same sync, and before, the others tried to write its files into their own folder and failed the run, which could cancel the job that was meant to apply it. They now print a notice and exit 0 without touching any file. Needs the matching server change; against an older server the CLI behaves as before.

## [0.0.78] - 2026-10-02

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@ export default {
'**/tests/**/*.test.ts',
'**/tests/**/*.test.js'
],
testPathIgnorePatterns: ['/node_modules/', '/.claude/']
testPathIgnorePatterns: ['/node_modules/', '<rootDir>/.claude/']
};
12 changes: 9 additions & 3 deletions src/api/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ export interface PaginationMetadata {
export interface SyncResponse {
sync: {
sync_id: string;
project_id?: string;
project_matches?: boolean;
status: string;
created_at: string;
sync_url?: string;
Expand All @@ -55,20 +57,24 @@ export interface SyncResponse {
/**
* Get sync translations from the Sync API
* @param syncId The sync ID from localhero.json
* @param options Optional pagination parameters
* @param options Optional pagination parameters, and the caller's projectId so the
* response says whether the sync belongs to it (project_matches)
* @returns The sync response with translations
*/
export async function getSyncTranslations(
syncId: string,
options: { page?: number; perPage?: number } = {}
options: { page?: number; perPage?: number; projectId?: string } = {}
): Promise<SyncResponse> {
const apiKey = await getApiKey();
const { page = 1, perPage = 500 } = options;
const { page = 1, perPage = 500, projectId } = options;

const queryParams = new URLSearchParams({
page: page.toString(),
per_page: perPage.toString()
});
if (projectId) {
queryParams.set('project_id', projectId);
}

return apiRequest(`/api/v1/translation_syncs/${syncId}?${queryParams}`, { apiKey });
}
Expand Down
19 changes: 17 additions & 2 deletions src/commands/ci.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,11 +99,21 @@ async function runTranslateMode(
await translateCommand(translateOptions);
}

/**
* In a monorepo every app's job receives the same sync dispatch. Only the app
* whose project owns the sync applies it; the others have nothing to do.
*/
function reportSyncForAnotherProject(deps: CiDependencies, owner: string | undefined, projectId: string): void {
const message = `Nothing to apply here. This sync is for project ${owner}; this directory is project ${projectId}.`;
deps.console.log(deps.githubUtils.isGitHubAction() ? `::notice::${message}` : chalk.blue(message));
}

/**
* Run sync mode - fetch done translations from Sync API and update files
*/
async function runSyncMode(
syncId: string,
projectId: string,
deps: CiDependencies,
options?: { verbose?: boolean; syncUpdateVersion?: number; skipCommit?: boolean }
): Promise<void> {
Expand All @@ -121,12 +131,17 @@ async function runSyncMode(

try {
while (currentPage <= totalPages) {
const response = await syncApi.getSyncTranslations(syncId, { page: currentPage });
const response = await syncApi.getSyncTranslations(syncId, { page: currentPage, projectId });

if (!response || !response.sync || !response.pagination) {
throw new Error(`Invalid response from Sync API for page ${currentPage}`);
}

if (response.sync.project_matches === false) {
reportSyncForAnotherProject(deps, response.sync.project_id, projectId);
return;
}

if (currentPage === 1) {
syncUrl = response.sync.sync_url;
branchName = response.sync.branch_name;
Expand Down Expand Up @@ -263,7 +278,7 @@ export async function ci(
console.log(chalk.blue('📥 Sync mode detected'));
}
try {
await runSyncMode(syncTriggerId, deps, { verbose: options.verbose, syncUpdateVersion, skipCommit: options.skipCommit });
await runSyncMode(syncTriggerId, config.projectId, deps, { verbose: options.verbose, syncUpdateVersion, skipCommit: options.skipCommit });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(chalk.red('\n✖ Sync failed:', errorMessage));
Expand Down
116 changes: 116 additions & 0 deletions tests/commands/ci-monorepo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'fs';
import os from 'os';
import path from 'path';
import { ci } from '../../src/commands/ci.js';
import { configService } from '../../src/utils/config.js';
import { githubService } from '../../src/utils/github.js';
import { updateTranslationFile } from '../../src/utils/translation-updater/index.js';

const EDITOR = 'newsroom-editor';
const PORTAL = 'newsroom-portal';

function writeApp(repo: string, app: string, config: object, localeDir: string): void {
const dir = path.join(repo, 'apps', app);
mkdirSync(path.join(dir, localeDir), { recursive: true });
writeFileSync(path.join(dir, 'localhero.json'), JSON.stringify(config, null, 2));
writeFileSync(path.join(dir, localeDir, 'en.json'), JSON.stringify({ headline: 'News' }, null, 2));
writeFileSync(path.join(dir, localeDir, 'sv.json'), JSON.stringify({ headline: 'Gamla nyheter' }, null, 2));
}

function appConfig(projectId: string, localeDir: string, outputLocales = ['sv']) {
return { projectId, sourceLocale: 'en', outputLocales, translationFiles: { paths: [`${localeDir}/`] } };
}

// Stands in for the server: it owns the matching decision, given the caller's projectId.
function editorSyncApi() {
return {
getSyncTranslations: jest.fn(async (_syncId: string, options: { projectId?: string } = {}) => ({
sync: {
sync_id: 'sync_editor',
project_id: EDITOR,
project_matches: options.projectId === EDITOR,
status: 'completed',
created_at: '2026-10-08T00:00:00Z',
files: [{
path: 'public/locale/sv.json',
language: 'sv',
translations: [{ key: 'headline', name: 'headline', value: 'Nyheter', updated_at: '2026-10-08T00:00:00Z' }]
}]
},
pagination: { current_page: 1, total_pages: 1, total_count: 1, next_page: null, prev_page: null, items_per_page: 500 }
})),
completeSyncUpdate: jest.fn(async () => ({ success: true, status: 'completed' }))
};
}

describe('ci sync mode in a monorepo with one project per app', () => {
let repo: string;
let originalCwd: string;
let exit: jest.SpiedFunction<typeof process.exit>;

const read = (relative: string) => readFileSync(path.join(repo, relative), 'utf8');

async function runCiIn(app: string, syncApi: ReturnType<typeof editorSyncApi>) {
process.chdir(path.join(repo, 'apps', app));
await ci({}, {
console: { log: jest.fn(), error: jest.fn() },
configUtils: configService,
authUtils: { checkAuth: async () => true },
githubUtils: { ...githubService, isGitHubAction: () => false },
env: { LOCALHERO_SYNC_ID: 'sync_editor', LOCALHERO_SYNC_VERSION: '2' },
translateCommand: jest.fn(async () => undefined),
syncApi,
updateTranslationFile
});
}

beforeEach(() => {
originalCwd = process.cwd();
repo = mkdtempSync(path.join(os.tmpdir(), 'ci-monorepo-'));
writeApp(repo, 'editor', appConfig(EDITOR, 'public/locale'), 'public/locale');
writeApp(repo, 'portal', appConfig(PORTAL, 'public/locales'), 'public/locales');
exit = jest.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`process.exit(${code})`);
}) as never);
});

afterEach(() => {
exit.mockRestore();
process.chdir(originalCwd);
rmSync(repo, { recursive: true, force: true });
});

it('the owning app applies the sync to its own files', async () => {
const syncApi = editorSyncApi();

await runCiIn('editor', syncApi);

expect(JSON.parse(read('apps/editor/public/locale/sv.json')).headline).toBe('Nyheter');
expect(syncApi.completeSyncUpdate).toHaveBeenCalledWith('sync_editor', 2);
expect(read('apps/portal/public/locales/sv.json')).toContain('Gamla nyheter');
});

it('a sibling app leaves every file alone and does not complete the sync', async () => {
const syncApi = editorSyncApi();
const editorBefore = read('apps/editor/public/locale/sv.json');
const portalConfigBefore = read('apps/portal/localhero.json');

await runCiIn('portal', syncApi);

expect(read('apps/portal/public/locales/sv.json')).toContain('Gamla nyheter');
expect(existsSync(path.join(repo, 'apps/portal/public/locale'))).toBe(false);
expect(read('apps/editor/public/locale/sv.json')).toBe(editorBefore);
expect(read('apps/portal/localhero.json')).toBe(portalConfigBefore);
expect(syncApi.completeSyncUpdate).not.toHaveBeenCalled();
});

it('a sibling app with a config that would fail validation still stands down cleanly', async () => {
writeFileSync(
path.join(repo, 'apps/portal/localhero.json'),
JSON.stringify(appConfig(PORTAL, 'public/locales', []), null, 2)
);

await expect(runCiIn('portal', editorSyncApi())).resolves.toBeUndefined();
});
});
47 changes: 47 additions & 0 deletions tests/commands/ci.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -620,5 +620,52 @@ describe('ci command', () => {
const passedTranslations = deps.updateTranslationFile.mock.calls[0][1];
expect(passedTranslations[0].metadata?.source_references).toEqual(['lib/web.ex:10']);
});

describe('when the sync belongs to another project in the repository', () => {
const otherProjectsSync = {
sync: { ...syncResponse.sync, project_id: 'editor', project_matches: false },
pagination: { ...syncResponse.pagination, total_pages: 3, next_page: 2 }
};

it('applies nothing, completes nothing and exits cleanly', async () => {
mockEnv.LOCALHERO_SYNC_ID = 'sync_abc';
mockEnv.LOCALHERO_SYNC_VERSION = '3';
const deps = buildSyncDeps();
deps.syncApi.getSyncTranslations.mockResolvedValue(otherProjectsSync);
const exit = jest.spyOn(process, 'exit').mockImplementation((() => undefined) as never);

try {
await ci({}, deps);

expect(exit).not.toHaveBeenCalled();
expect(deps.syncApi.getSyncTranslations).toHaveBeenCalledTimes(1);
expect(deps.updateTranslationFile).not.toHaveBeenCalled();
expect(mockConfigUtils.saveProjectConfig).not.toHaveBeenCalled();
expect(deps.githubUtils.autoCommitSyncChanges).not.toHaveBeenCalled();
expect(deps.syncApi.completeSyncUpdate).not.toHaveBeenCalled();
expect(mockConsole.log).toHaveBeenCalledWith(
'::notice::Nothing to apply here. This sync is for project editor; this directory is project test-project.'
);
} finally {
exit.mockRestore();
}
});

});

it('sends its project with every page so the server can say whose sync it is', async () => {
mockEnv.LOCALHERO_SYNC_ID = 'sync_abc';
const deps = buildSyncDeps();
const ownSync = { ...syncResponse.sync, project_id: 'test-project', project_matches: true };
deps.syncApi.getSyncTranslations
.mockResolvedValueOnce({ sync: ownSync, pagination: { ...syncResponse.pagination, total_pages: 2, next_page: 2 } })
.mockResolvedValueOnce({ sync: ownSync, pagination: { ...syncResponse.pagination, current_page: 2, total_pages: 2 } });

await ci({ skipCommit: true }, deps);

expect(deps.syncApi.getSyncTranslations).toHaveBeenNthCalledWith(1, 'sync_abc', { page: 1, projectId: 'test-project' });
expect(deps.syncApi.getSyncTranslations).toHaveBeenNthCalledWith(2, 'sync_abc', { page: 2, projectId: 'test-project' });
expect(deps.updateTranslationFile).toHaveBeenCalledTimes(2);
});
});
});
Loading