Skip to content

Commit 23f9518

Browse files
committed
perf: shared generation prep job for parity pipeline
Add a 'generate' job that runs TYPECHECK_ONLY once for all 28 packages, caches the output, then firely/hl7/internal jobs restore that cache and set SKIP_GENERATION=1 to skip Phase 1 entirely. Before: each of 57+ jobs (28 firely + 28 hl7 + 1 internal) downloaded and generated all packages independently. After: 1 prep job generates, 57 jobs validate only. Also removed redundant cache steps (fhir-resources, seed-fhir-cache) from validator jobs since generated output is fully cached.
1 parent a2f6428 commit 23f9518

2 files changed

Lines changed: 137 additions & 75 deletions

File tree

.github/workflows/_parity-tests.yml

Lines changed: 92 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ jobs:
6464
runs-on: ubuntu-latest
6565
outputs:
6666
matrix: ${{ steps.set-matrix.outputs.matrix }}
67+
cache-key: ${{ steps.cache-key.outputs.key }}
6768
steps:
6869
- uses: actions/checkout@v4
6970
- uses: actions/setup-node@v4
@@ -75,10 +76,86 @@ jobs:
7576
PACKAGES=$(node scripts/list-parity-packages.js "${{ inputs.packages }}")
7677
echo "matrix=$PACKAGES" >> "$GITHUB_OUTPUT"
7778
echo "Packages to test: $PACKAGES"
79+
- name: Compute shared cache key
80+
id: cache-key
81+
run: |
82+
KEY="fhir-generated-${{ hashFiles('src/test/pipelineParity.test.ts', 'src/generator/**/*.ts', 'src/generator/fhir/**/*.json') }}-v1"
83+
echo "key=$KEY" >> "$GITHUB_OUTPUT"
84+
85+
# ── Generate all packages once (shared prep job) ───────────────────────────
86+
generate:
87+
needs: setup
88+
runs-on: ubuntu-latest
89+
permissions:
90+
contents: read
91+
steps:
92+
- name: Checkout code
93+
uses: actions/checkout@v4
94+
95+
- name: Setup Node.js
96+
uses: actions/setup-node@v4
97+
with:
98+
node-version: '22'
99+
cache: 'npm'
100+
101+
- name: Install dependencies
102+
run: npm ci || (npm cache clean --force && npm ci)
103+
104+
- name: Restore FHIR packages cache
105+
id: cache-restore
106+
uses: actions/cache/restore@v4
107+
with:
108+
path: .cache/.fhir/packages
109+
key: ${{ needs.setup.outputs.cache-key }}
110+
111+
- name: Cache tx-server ValueSet expansions
112+
uses: actions/cache@v4
113+
with:
114+
path: .cache/tx-expansions
115+
key: tx-expansions-v2
116+
restore-keys: tx-expansions-v2
117+
118+
- name: Cache FHIR resources (StructureDefinitions)
119+
uses: actions/cache@v4
120+
with:
121+
path: .cache/fhir-resources
122+
key: fhir-resources-v4
123+
restore-keys: fhir-resources-v4
124+
125+
- name: Seed FHIR resources cache
126+
run: bash scripts/seed-fhir-cache.sh
127+
128+
- name: Build zod-r4 runtime package
129+
run: |
130+
cd packages/zod-r4
131+
npm ci || (npm cache clean --force && npm ci)
132+
npm run build
133+
134+
- name: Clear generated code to force regeneration
135+
if: steps.cache-restore.outputs.cache-hit != 'true'
136+
run: |
137+
if [ -d ".cache/.fhir/packages" ]; then
138+
find .cache/.fhir/packages -type d -name "generated" -exec rm -rf {} + 2>/dev/null || true
139+
fi
140+
141+
- name: Generate all packages
142+
if: steps.cache-restore.outputs.cache-hit != 'true'
143+
run: npm run test:parity
144+
env:
145+
CI: true
146+
PIPELINE_PACKAGES: ${{ inputs.packages }}
147+
TYPECHECK_ONLY: '1'
148+
149+
- name: Save FHIR packages cache (with generated output)
150+
if: steps.cache-restore.outputs.cache-hit != 'true'
151+
uses: actions/cache/save@v4
152+
with:
153+
path: .cache/.fhir/packages
154+
key: ${{ needs.setup.outputs.cache-key }}
78155

79156
# ── Firely (.NET) — one job per IG ─────────────────────────────────────────
80157
firely:
81-
needs: [setup, resolve-versions]
158+
needs: [setup, resolve-versions, generate]
82159
runs-on: ubuntu-latest
83160
permissions:
84161
contents: read
@@ -118,11 +195,11 @@ jobs:
118195
rm -rf /tmp/FhirArtifactCache-* 2>/dev/null || true
119196
rm -rf /tmp/fhir-spec-cache-* 2>/dev/null || true
120197
121-
- name: Cache FHIR packages
122-
uses: actions/cache@v4
198+
- name: Restore pre-generated FHIR packages
199+
uses: actions/cache/restore@v4
123200
with:
124201
path: .cache/.fhir/packages
125-
key: fhir-packages-${{ matrix.package }}-${{ hashFiles('src/test/pipelineParity.test.ts', 'src/generator/**/*.ts', 'src/generator/fhir/**/*.json') }}-v8
202+
key: ${{ needs.setup.outputs.cache-key }}
126203

127204
- name: Cache tx-server ValueSet expansions
128205
uses: actions/cache@v4
@@ -131,33 +208,18 @@ jobs:
131208
key: tx-expansions-v2
132209
restore-keys: tx-expansions-v2
133210

134-
- name: Cache FHIR resources (StructureDefinitions)
135-
uses: actions/cache@v4
136-
with:
137-
path: .cache/fhir-resources
138-
key: fhir-resources-v4
139-
restore-keys: fhir-resources-v4
140-
141-
- name: Seed FHIR resources cache
142-
run: bash scripts/seed-fhir-cache.sh
143-
144211
- name: Build zod-r4 runtime package
145212
run: |
146213
cd packages/zod-r4
147214
npm ci || (npm cache clean --force && npm ci)
148215
npm run build
149216
150-
- name: Clear generated code to force regeneration
151-
run: |
152-
if [ -d ".cache/.fhir/packages" ]; then
153-
find .cache/.fhir/packages -type d -name "generated" -exec rm -rf {} + 2>/dev/null || true
154-
fi
155-
156217
- name: Run Firely parity test (${{ matrix.package }})
157218
id: firely-test
158219
run: npm run test:parity
159220
env:
160221
CI: true
222+
SKIP_GENERATION: '1'
161223
PIPELINE_PACKAGES: ${{ matrix.package }}
162224
FIRELY_SDK_VERSION: ${{ needs.resolve-versions.outputs.firely_version }}
163225
continue-on-error: true
@@ -224,7 +286,7 @@ jobs:
224286

225287
# ── HL7 Java Validator — one job per IG ────────────────────────────────────
226288
hl7:
227-
needs: [setup, resolve-versions]
289+
needs: [setup, resolve-versions, generate]
228290
runs-on: ubuntu-latest
229291
permissions:
230292
contents: read
@@ -264,11 +326,11 @@ jobs:
264326
VALIDATOR_VERSION=${{ needs.resolve-versions.outputs.hl7_version }} \
265327
scripts/hl7-validator/validate.sh --download-only
266328
267-
- name: Cache FHIR packages
268-
uses: actions/cache@v4
329+
- name: Restore pre-generated FHIR packages
330+
uses: actions/cache/restore@v4
269331
with:
270332
path: .cache/.fhir/packages
271-
key: fhir-packages-${{ matrix.package }}-${{ hashFiles('src/test/pipelineParity.test.ts', 'src/generator/**/*.ts', 'src/generator/fhir/**/*.json') }}-v8
333+
key: ${{ needs.setup.outputs.cache-key }}
272334

273335
- name: Cache tx-server ValueSet expansions
274336
uses: actions/cache@v4
@@ -277,33 +339,18 @@ jobs:
277339
key: tx-expansions-v2
278340
restore-keys: tx-expansions-v2
279341

280-
- name: Cache FHIR resources (StructureDefinitions)
281-
uses: actions/cache@v4
282-
with:
283-
path: .cache/fhir-resources
284-
key: fhir-resources-v4
285-
restore-keys: fhir-resources-v4
286-
287-
- name: Seed FHIR resources cache
288-
run: bash scripts/seed-fhir-cache.sh
289-
290342
- name: Build zod-r4 runtime package
291343
run: |
292344
cd packages/zod-r4
293345
npm ci || (npm cache clean --force && npm ci)
294346
npm run build
295347
296-
- name: Clear generated code to force regeneration
297-
run: |
298-
if [ -d ".cache/.fhir/packages" ]; then
299-
find .cache/.fhir/packages -type d -name "generated" -exec rm -rf {} + 2>/dev/null || true
300-
fi
301-
302348
- name: Run HL7 parity test (${{ matrix.package }})
303349
id: hl7-test
304350
run: npm run test:parity
305351
env:
306352
CI: true
353+
SKIP_GENERATION: '1'
307354
PIPELINE_PACKAGES: ${{ matrix.package }}
308355
FHIR_VALIDATOR: hl7
309356
HL7_VALIDATOR_VERSION: ${{ needs.resolve-versions.outputs.hl7_version }}
@@ -371,6 +418,7 @@ jobs:
371418

372419
# ── Internal (validate) — single job, all IGs sequentially ──────────
373420
internal:
421+
needs: [setup, generate]
374422
runs-on: ubuntu-latest
375423
permissions:
376424
contents: read
@@ -388,11 +436,11 @@ jobs:
388436
- name: Install dependencies
389437
run: npm ci || (npm cache clean --force && npm ci)
390438

391-
- name: Cache FHIR packages
392-
uses: actions/cache@v4
439+
- name: Restore pre-generated FHIR packages
440+
uses: actions/cache/restore@v4
393441
with:
394442
path: .cache/.fhir/packages
395-
key: fhir-packages-all-${{ hashFiles('src/test/pipelineParity.test.ts', 'src/generator/**/*.ts', 'src/generator/fhir/**/*.json') }}-v8
443+
key: ${{ needs.setup.outputs.cache-key }}
396444

397445
- name: Cache tx-server ValueSet expansions
398446
uses: actions/cache@v4
@@ -401,33 +449,18 @@ jobs:
401449
key: tx-expansions-v2
402450
restore-keys: tx-expansions-v2
403451

404-
- name: Cache FHIR resources (StructureDefinitions)
405-
uses: actions/cache@v4
406-
with:
407-
path: .cache/fhir-resources
408-
key: fhir-resources-v4
409-
restore-keys: fhir-resources-v4
410-
411-
- name: Seed FHIR resources cache
412-
run: bash scripts/seed-fhir-cache.sh
413-
414452
- name: Build zod-r4 runtime package
415453
run: |
416454
cd packages/zod-r4
417455
npm ci || (npm cache clean --force && npm ci)
418456
npm run build
419457
420-
- name: Clear generated code to force regeneration
421-
run: |
422-
if [ -d ".cache/.fhir/packages" ]; then
423-
find .cache/.fhir/packages -type d -name "generated" -exec rm -rf {} + 2>/dev/null || true
424-
fi
425-
426458
- name: Run internal parity tests (all packages)
427459
id: internal-test
428460
run: npm run test:parity
429461
env:
430462
CI: true
463+
SKIP_GENERATION: '1'
431464
PIPELINE_PACKAGES: ${{ inputs.packages }}
432465
FHIR_VALIDATOR: internal
433466
continue-on-error: true

src/test/pipelineParity.test.ts

Lines changed: 45 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
* - FHIR_VALIDATOR: Which validator to use: 'firely' (default) or 'hl7'
3535
* - HL7_VALIDATOR_VERSION: HL7 validator version (default: 6.9.4)
3636
* - TYPECHECK_ONLY: Only run generation + TypeScript compilation check (no validator, no resource gen)
37+
* - SKIP_GENERATION: Reuse pre-generated output from FHIR package cache (set by CI prep job)
3738
* - ALLOW_GENERATION_FAILURES: Continue test even if generation fails (report errors without aborting)
3839
*
3940
* Generation always uses --tx-server https://tx.fhir.org/r4 to expand ValueSets
@@ -61,6 +62,7 @@ const SKIP_CLEANUP = process.env.SKIP_CLEANUP === '1' || process.env.SKIP_CLEANU
6162
const VERBOSE = process.env.VERBOSE_TESTS === '1' || process.env.VERBOSE_TESTS === 'true';
6263
const TYPECHECK_ONLY = process.env.TYPECHECK_ONLY === '1' || process.env.TYPECHECK_ONLY === 'true';
6364
const ALLOW_GENERATION_FAILURES = process.env.ALLOW_GENERATION_FAILURES === '1' || process.env.ALLOW_GENERATION_FAILURES === 'true';
65+
const SKIP_GENERATION = process.env.SKIP_GENERATION === '1' || process.env.SKIP_GENERATION === 'true';
6466
const FHIR_RELEASE = process.env.FHIR_RELEASE || 'R4';
6567

6668
// Filter to only requested packages
@@ -190,7 +192,7 @@ ${rows}
190192

191193
console.log(`\n${'═'.repeat(70)}`);
192194
console.log(`📦 Pipeline Parity — ${selectedPackages.length} package(s) SEQUENTIALLY`);
193-
console.log(` Validator: ${VALIDATOR_TYPE.toUpperCase()} | FHIR: ${FHIR_RELEASE} | TYPECHECK_ONLY: ${TYPECHECK_ONLY}`);
195+
console.log(` Validator: ${VALIDATOR_TYPE.toUpperCase()} | FHIR: ${FHIR_RELEASE} | TYPECHECK_ONLY: ${TYPECHECK_ONLY}${SKIP_GENERATION ? ' | SKIP_GENERATION: true' : ''}`);
194196
console.log(` Packages: ${selectedPackages.map(p => p.name).join(', ')}`);
195197
console.log(` Heap: ${formatMB(process.memoryUsage().heapUsed)} (initial)`);
196198
console.log(`${'═'.repeat(70)}`);
@@ -207,23 +209,50 @@ ${rows}
207209
// PHASE 1: Generate TypeScript for all packages SEQUENTIALLY to avoid cache conflicts
208210
// Each package generation fetches base FHIR StructureDefinitions from hl7.org and caches them
209211
// Parallel runs can corrupt the cache, causing some packages to fail with TypeScript errors
210-
console.log(`\n[${timestamp()}] 🚀 Phase 1: Downloading and generating ${selectedPackages.length} packages...`);
212+
//
213+
// SKIP_GENERATION: When set, reuse pre-generated output from the FHIR package cache.
214+
// A dedicated CI prep job generates all packages once; validator jobs skip regeneration.
211215
const generationResults: Array<PromiseSettledResult<{ pkg: PackageConfig; tgzPath: string; packageDir: string; classInfos: ClassInfo[] }>> = [];
212-
213-
for (let genIdx = 0; genIdx < selectedPackages.length; genIdx++) {
214-
const pkg = selectedPackages[genIdx];
215-
const genStart = Date.now();
216-
console.log(`[${timestamp()}] [${genIdx + 1}/${selectedPackages.length}] Generating ${pkg.name} (${pkg.spec})...`);
217-
try {
218-
const { tgzPath, packageDir } = await generateForPackage(pkg, { repoRoot, validatorType: VALIDATOR_TYPE, verbose: VERBOSE });
216+
217+
if (SKIP_GENERATION) {
218+
console.log(`\n[${timestamp()}] ⏭ Phase 1: SKIP_GENERATION — reusing pre-generated output for ${selectedPackages.length} packages...`);
219+
const { getFhirPackagesCacheDir } = await import('../generator/core/cacheConfig.js');
220+
const fhirPackageCacheDir = getFhirPackagesCacheDir();
221+
222+
for (let genIdx = 0; genIdx < selectedPackages.length; genIdx++) {
223+
const pkg = selectedPackages[genIdx];
224+
const [pkgName, version] = pkg.spec.split('@');
225+
const packageDir = path.join(fhirPackageCacheDir, `${pkgName}@${version}`);
226+
const generatedDir = path.join(packageDir, 'generated');
227+
228+
if (!fs.existsSync(generatedDir)) {
229+
console.error(`[${timestamp()}] [${genIdx + 1}/${selectedPackages.length}] ✗ ${pkg.name}: generated/ not found at ${generatedDir}`);
230+
generationResults.push({ status: 'rejected', reason: new Error(`Pre-generated output not found for ${pkg.name}: ${generatedDir}`) });
231+
continue;
232+
}
233+
219234
const classInfos = listGeneratedClassFiles(packageDir, pkg.name);
220-
const genElapsed = ((Date.now() - genStart) / 1000).toFixed(1);
221-
console.log(`[${timestamp()}] [${genIdx + 1}/${selectedPackages.length}] ✓ ${pkg.name}: ${classInfos.length} classes (${genElapsed}s)`);
222-
generationResults.push({ status: 'fulfilled', value: { pkg, tgzPath, packageDir, classInfos } });
223-
} catch (error) {
224-
const genElapsed = ((Date.now() - genStart) / 1000).toFixed(1);
225-
console.error(`[${timestamp()}] [${genIdx + 1}/${selectedPackages.length}] ✗ ${pkg.name} FAILED (${genElapsed}s):`, error);
226-
generationResults.push({ status: 'rejected', reason: error });
235+
console.log(`[${timestamp()}] [${genIdx + 1}/${selectedPackages.length}] ✓ ${pkg.name}: ${classInfos.length} classes (cached)`);
236+
generationResults.push({ status: 'fulfilled', value: { pkg, tgzPath: '', packageDir, classInfos } });
237+
}
238+
} else {
239+
console.log(`\n[${timestamp()}] 🚀 Phase 1: Downloading and generating ${selectedPackages.length} packages...`);
240+
241+
for (let genIdx = 0; genIdx < selectedPackages.length; genIdx++) {
242+
const pkg = selectedPackages[genIdx];
243+
const genStart = Date.now();
244+
console.log(`[${timestamp()}] [${genIdx + 1}/${selectedPackages.length}] Generating ${pkg.name} (${pkg.spec})...`);
245+
try {
246+
const { tgzPath, packageDir } = await generateForPackage(pkg, { repoRoot, validatorType: VALIDATOR_TYPE, verbose: VERBOSE });
247+
const classInfos = listGeneratedClassFiles(packageDir, pkg.name);
248+
const genElapsed = ((Date.now() - genStart) / 1000).toFixed(1);
249+
console.log(`[${timestamp()}] [${genIdx + 1}/${selectedPackages.length}] ✓ ${pkg.name}: ${classInfos.length} classes (${genElapsed}s)`);
250+
generationResults.push({ status: 'fulfilled', value: { pkg, tgzPath, packageDir, classInfos } });
251+
} catch (error) {
252+
const genElapsed = ((Date.now() - genStart) / 1000).toFixed(1);
253+
console.error(`[${timestamp()}] [${genIdx + 1}/${selectedPackages.length}] ✗ ${pkg.name} FAILED (${genElapsed}s):`, error);
254+
generationResults.push({ status: 'rejected', reason: error });
255+
}
227256
}
228257
}
229258

0 commit comments

Comments
 (0)