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
24 changes: 24 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,30 @@ The publishing workflow includes build validation in preflight checks to prevent

This prevents the known issue in `gro publish` where build failures leave repos in broken state (version bumped but not published).

**Dependency Installation with Cache Healing**

The publishing workflow automatically installs dependencies after package.json updates with smart cache healing:

1. **When installations happen:**
- After each iteration when dependency updates occur
- Batch installs all repos with updated dependencies before next iteration
- Published packages skip install (`gro publish` handles it internally)

2. **Cache healing strategy:**
- First attempt: regular `npm install`
- On ETARGET error (package not found due to stale cache): `npm cache clean --force` then retry
- On other errors: fail immediately without cache cleaning
- Detects variations: "ETARGET", "notarget", "No matching version found"

3. **Why cache healing is needed:**
- After publishing and waiting for NPM propagation, npm's local cache may still have stale "404" metadata
- Cache clean forces fresh metadata fetch from registry
- Ensures newly published packages can be installed by dependents

4. **Configuration:**
- `--skip-install`: Disable installs during publishing (for speed/testing)
- Installs enabled by default

**Plan vs Dry Run**

`gro gitops_plan`:
Expand Down
23 changes: 1 addition & 22 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions src/fixtures/mock_operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,9 @@ export const create_mock_npm_ops = (): Npm_Operations => ({

// Registry check - simulate package available immediately
wait_for_package: async () => ({ok: true}),

// Cache clean - no-op for tests
cache_clean: async () => ({ok: true}),
});

/**
Expand Down Expand Up @@ -321,6 +324,9 @@ export const create_configurable_npm_ops = (
}
return {ok: true};
},

// Cache clean
cache_clean: async () => ({ok: true}),
});

/**
Expand Down
18 changes: 17 additions & 1 deletion src/lib/gitops_publish.task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ export const Args = z.strictObject({
.number()
.meta({description: 'max time to wait for npm propagation in ms'})
.default(600000), // 10 minutes
skip_install: z
.boolean()
.meta({description: 'skip npm install after dependency updates'})
.default(false),
outfile: z.string().meta({description: 'write output to file instead of logging'}).optional(),
});
export type Args = z.infer<typeof Args>;
Expand All @@ -51,7 +55,18 @@ export const task: Task<Args> = {
summary: 'publish all repos in dependency order',
Args,
run: async ({args, log}): Promise<void> => {
const {path, dir, peer_strategy, dry_run, format, deploy, plan, max_wait, outfile} = args;
const {
path,
dir,
peer_strategy,
dry_run,
format,
deploy,
plan,
max_wait,
skip_install,
outfile,
} = args;

// Load repos
const {local_repos: repos} = await get_gitops_ready({
Expand Down Expand Up @@ -88,6 +103,7 @@ export const task: Task<Args> = {
version_strategy: peer_strategy,
deploy,
max_wait,
skip_install,
log,
};

Expand Down
76 changes: 76 additions & 0 deletions src/lib/multi_repo_publisher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -704,3 +704,79 @@ test('converges early when no new packages publish', async () => {
// NOTE: MAX_ITERATIONS warning test for multi_repo_publisher is complex to simulate
// because it requires stateful mocking across iterations. The core warning logic is
// already tested in publishing_plan.test.ts. See TODO.md for details.

test('skip_install flag prevents npm install', async () => {
const repos: Array<Local_Repo> = [
create_mock_repo({name: 'lib', version: '1.0.0'}),
create_mock_repo({name: 'app', version: '1.0.0', deps: {lib: '^1.0.0'}}),
];

const mock_fs_ops = create_populated_fs_ops(repos);
let install_called = false;

const mock_ops = create_mock_gitops_ops({
preflight: create_preflight_mock(['lib'], ['app']),
fs: mock_fs_ops,
npm: {
install: async () => {
install_called = true;
return {ok: true};
},
wait_for_package: async () => ({ok: true}),
check_package_available: async () => ({ok: true, value: true}),
check_auth: async () => ({ok: true, username: 'testuser'}),
check_registry: async () => ({ok: true}),
cache_clean: async () => ({ok: true}),
},
});

await publish_repos(repos, {dry_run: false, update_deps: true, skip_install: true}, mock_ops);

// Install should NOT be called
expect(install_called).toBe(false);
});

test('install failures are handled gracefully', async () => {
const repos: Array<Local_Repo> = [
create_mock_repo({name: 'lib', version: '1.0.0'}),
create_mock_repo({name: 'app1', version: '1.0.0', deps: {lib: '^1.0.0'}}),
create_mock_repo({name: 'app2', version: '1.0.0', deps: {lib: '^1.0.0'}}),
];

const mock_fs_ops = create_populated_fs_ops(repos);
let install_attempts = 0;

const mock_ops = create_mock_gitops_ops({
preflight: create_preflight_mock(['lib', 'app1', 'app2']), // All have changesets
fs: mock_fs_ops,
git: create_mock_git_ops({
add_and_commit: async () => ({ok: true}),
}),
npm: {
install: async (options) => {
install_attempts++;
// Fail on app1, succeed on app2
if (options?.cwd?.includes('app1')) {
return {ok: false, message: 'Network error', stderr: 'npm ERR! network timeout'};
}
return {ok: true};
},
wait_for_package: async () => ({ok: true}),
check_package_available: async () => ({ok: true, value: true}),
check_auth: async () => ({ok: true, username: 'testuser'}),
check_registry: async () => ({ok: true}),
cache_clean: async () => ({ok: true}),
},
});

const result = await publish_repos(repos, {dry_run: false, update_deps: true}, mock_ops);

// Install should have been attempted for both apps
expect(install_attempts).toBeGreaterThan(0);

// One failure should be tracked
expect(result.failed.some((f) => f.name === 'app1')).toBe(true);
});

// NOTE: Cache healing during publishing is thoroughly tested in npm_install_helpers.test.ts (9 tests)
// Integration testing here is complex due to dependency update flow complexity.
Loading