Skip to content

Commit 6b40f4d

Browse files
committed
refactor(ci): resolve service aliases instead of filtering
Better approach: resolve service aliases to their canonical service names instead of filtering them out. This preserves change information while ensuring we build the correct images. Changes: - Replace filterServiceAliases() with buildServiceAliasMapping() - Add resolveServiceAliases() to map alias names to canonical names - Apply resolution after detecting changed services - Duplicates are automatically removed during resolution Example workflow: 1. Changes detected in: ['historian', 'historian-secondary'] 2. Alias mapping: historian-secondary → historian 3. Resolved to build: ['historian'] 4. Build workflow uses canonical service name 'historian' 5. Image ghcr.io/groupsky/homy/historian works for both services Benefits: - Don't lose track of changes in alias-specific configuration - Correctly identify which image needs to be built - Maintain relationship between aliases and canonical services - More maintainable and explicit than filtering
1 parent 8a8e819 commit 6b40f4d

File tree

2 files changed

+82
-33
lines changed

2 files changed

+82
-33
lines changed

.github/scripts/detect-changes/src/index.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { Command } from 'commander';
1313
import { writeFileSync, readFileSync, existsSync } from 'fs';
1414
import * as path from 'path';
1515
import { discoverBaseImages, buildDirectoryToGhcrMapping } from './lib/base-images.js';
16-
import { discoverServicesFromCompose, filterGhcrServices, filterServiceAliases } from './lib/services.js';
16+
import { discoverServicesFromCompose, filterGhcrServices, buildServiceAliasMapping, resolveServiceAliases } from './lib/services.js';
1717
import { buildReverseDependencyMap, detectAffectedServices } from './lib/dependency-graph.js';
1818
import { detectChangedBaseImages, detectChangedServices, isTestOnlyChange } from './lib/change-detection.js';
1919
import { hasHealthcheck, extractFinalStageBase } from './lib/dockerfile-parser.js';
@@ -175,17 +175,24 @@ async function detectChanges(options: CliOptions): Promise<DetectionResult> {
175175

176176
console.error('Step 3: Discovering services from docker-compose...');
177177
const allServices = discoverServicesFromCompose(options.composeFile, options.envFile);
178-
const ghcrServices = filterGhcrServices(allServices);
179-
const services = filterServiceAliases(ghcrServices);
180-
console.error(`Found ${services.length} buildable services (${ghcrServices.length} GHCR, ${allServices.length} total)`);
178+
const services = filterGhcrServices(allServices);
179+
console.error(`Found ${services.length} GHCR services (${allServices.length} total)`);
180+
181+
console.error('Step 3.5: Building service alias mapping...');
182+
const serviceAliasMapping = buildServiceAliasMapping(services);
183+
const aliasCount = Array.from(serviceAliasMapping.entries()).filter(
184+
([alias, canonical]) => alias !== canonical
185+
).length;
186+
console.error(`Found ${aliasCount} service aliases`);
181187

182188
console.error('Step 4: Detecting changed base images...');
183189
const changedBaseImages = detectChangedBaseImages(options.baseRef, baseImages);
184190
console.error(`Changed base images: ${changedBaseImages.length}`);
185191

186192
console.error('Step 5: Detecting changed services...');
187-
const changedServices = detectChangedServices(options.baseRef, services);
188-
console.error(`Changed services: ${changedServices.length}`);
193+
const changedServicesRaw = detectChangedServices(options.baseRef, services);
194+
const changedServices = resolveServiceAliases(changedServicesRaw, serviceAliasMapping);
195+
console.error(`Changed services (raw): ${changedServicesRaw.length}, resolved: ${changedServices.length}`);
189196

190197
console.error('Step 6: Building reverse dependency map...');
191198
const reverseDeps = buildReverseDependencyMap(services, options.dockerDir, baseImageMapping);

.github/scripts/detect-changes/src/lib/services.ts

Lines changed: 69 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -197,37 +197,36 @@ export function filterGhcrServices(services: Service[]): Service[] {
197197
}
198198

199199
/**
200-
* Filters out service aliases that share the same image with another service.
200+
* Builds a mapping from service names (including aliases) to their canonical service names.
201201
*
202202
* Service aliases are docker-compose services that use the same Docker image as another
203-
* service but with different runtime configuration (environment variables, volumes, etc.).
204-
* These should not be built separately since they share the same image.
203+
* service but with different runtime configuration. They should resolve to the canonical
204+
* service name for build purposes.
205205
*
206-
* A service is considered an alias if:
207-
* 1. Its image name doesn't match its service name
208-
* 2. Another service exists whose service name matches the image name
206+
* A canonical service is one where the service name matches the image name.
207+
* An alias is any other service using that same image.
209208
*
210-
* @param services Array of services to filter
211-
* @returns Filtered array with service aliases removed
209+
* @param services Array of all services
210+
* @returns Map from service name to canonical service name
212211
*
213212
* @example
214213
* ```typescript
215214
* const services = [
216215
* { service_name: 'historian', image: 'ghcr.io/groupsky/homy/historian:latest', ... },
217216
* { service_name: 'historian-secondary', image: 'ghcr.io/groupsky/homy/historian:latest', ... }
218217
* ];
219-
* const filtered = filterServiceAliases(services);
220-
* console.log(filtered.length); // 1 (only historian, historian-secondary is filtered out)
218+
* const mapping = buildServiceAliasMapping(services);
219+
* console.log(mapping.get('historian')); // 'historian'
220+
* console.log(mapping.get('historian-secondary')); // 'historian'
221221
* ```
222222
*/
223-
export function filterServiceAliases(services: Service[]): Service[] {
224-
// Build a map of image names to service names for canonical services
225-
// A canonical service is one where the service name matches the image name
223+
export function buildServiceAliasMapping(services: Service[]): Map<string, string> {
224+
// First pass: find canonical services (service name matches image name)
226225
const imageToCanonicalService = new Map<string, string>();
227226

228227
for (const service of services) {
229228
if (!service.image) {
230-
// Services without images (local builds) are always canonical
229+
// Services without images (local builds) map to themselves
231230
imageToCanonicalService.set(service.service_name, service.service_name);
232231
continue;
233232
}
@@ -243,26 +242,69 @@ export function filterServiceAliases(services: Service[]): Service[] {
243242
}
244243
}
245244

246-
// Filter out service aliases
247-
return services.filter((service) => {
248-
// Services without image field are always kept (local builds)
245+
// Second pass: build alias mapping
246+
const aliasMapping = new Map<string, string>();
247+
248+
for (const service of services) {
249249
if (!service.image) {
250-
return true;
250+
// Local builds map to themselves
251+
aliasMapping.set(service.service_name, service.service_name);
252+
continue;
251253
}
252254

253-
// Check if this service's image has a canonical service
255+
// Look up canonical service for this image
254256
const canonicalService = imageToCanonicalService.get(service.image);
255257

256-
// Keep service if:
257-
// 1. No canonical service exists (orphan image), OR
258-
// 2. This IS the canonical service
259-
if (!canonicalService || canonicalService === service.service_name) {
260-
return true;
258+
if (canonicalService) {
259+
// Map to canonical service
260+
aliasMapping.set(service.service_name, canonicalService);
261+
} else {
262+
// No canonical service found - map to itself (orphan image)
263+
aliasMapping.set(service.service_name, service.service_name);
261264
}
265+
}
262266

263-
// This is a service alias - filter it out
264-
return false;
265-
});
267+
return aliasMapping;
268+
}
269+
270+
/**
271+
* Resolves a list of service names to their canonical names, removing duplicates.
272+
*
273+
* Service aliases are resolved to their canonical service names based on the
274+
* alias mapping. Duplicates are removed to ensure each canonical service appears
275+
* only once in the result.
276+
*
277+
* @param serviceNames Array of service names (may include aliases)
278+
* @param aliasMapping Map from service name to canonical service name
279+
* @returns Array of unique canonical service names, sorted
280+
*
281+
* @example
282+
* ```typescript
283+
* const aliasMapping = new Map([
284+
* ['historian', 'historian'],
285+
* ['historian-secondary', 'historian'],
286+
* ]);
287+
* const resolved = resolveServiceAliases(['historian', 'historian-secondary'], aliasMapping);
288+
* console.log(resolved); // ['historian']
289+
* ```
290+
*/
291+
export function resolveServiceAliases(
292+
serviceNames: string[],
293+
aliasMapping: Map<string, string>
294+
): string[] {
295+
const canonicalServices = new Set<string>();
296+
297+
for (const serviceName of serviceNames) {
298+
const canonical = aliasMapping.get(serviceName);
299+
if (canonical) {
300+
canonicalServices.add(canonical);
301+
} else {
302+
// No mapping found - use service name as-is
303+
canonicalServices.add(serviceName);
304+
}
305+
}
306+
307+
return Array.from(canonicalServices).sort();
266308
}
267309

268310
/**

0 commit comments

Comments
 (0)