import { readFileSync } from 'node:fs' import { load } from 'js-yaml' import { expect, it } from 'vitest' const action = path => load(readFileSync(new URL(path, import.meta.url), 'utf8')) const setup = action('../.github/actions/setup-pm/action.yml') const save = action('../.github/actions/save-pm-cache/action.yml') it('restores compatible wheels without freezing a partial build under its dependency key', () => { const cached = setup.runs.steps.find(step => step.id === 'python-cache') const restored = setup.runs.steps.find(step => step.id === 'python-cache-restore') const prefixes = restored.with['restore-keys'].trim().split('\n') // Prefer this dependency set before falling back across dependency changes. const rollingPrefix = prefixes[0] expect(restored.with.key).toBe(`${rollingPrefix}\${{ github.run_id }}-\${{ github.run_attempt }}-\${{ github.job }}`) expect(rollingPrefix).toBe(`${cached.with.key}-`) expect(prefixes[1].trim()).toBe(cached.with['restore-keys']) for (const boundary of ['target', 'os-version', 'python-version']) { expect(prefixes[1]).toContain(`steps.prepare.outputs.${boundary}`) } expect(prefixes[1]).toContain("inputs.cache-suffix == ''") expect(prefixes[1]).not.toContain('hashFiles') // Only dependency-carrying callers save; tool-only jobs must not freeze an // empty cache under the production key (a stub exact-hit blocks real saves). expect(cached.if).toContain("inputs.extras != ''") expect(restored.if).toContain("inputs.save-python-cache == 'false'") expect(setup.outputs['python-cache-key'].value).toContain('steps.python-cache-restore.outputs.cache-primary-key') // A suffix-only namespace isolates smoke reads but still lets production // restore smoke writes through its broad dependency fallback. const namespace = "${{ inputs.cache-suffix || 'production' }}" for (const template of [cached.with.key, restored.with.key, rollingPrefix]) { const production = template.replace(namespace, 'production') const smoke = template.replace(namespace, 'smoke-42-1') expect(production.startsWith('setup-pm-uv-v3-production-')).toBe(true) expect(smoke.startsWith('setup-pm-uv-v3-smoke-42-1-')).toBe(true) expect(smoke.startsWith('setup-pm-uv-v3-production-')).toBe(false) expect(production.startsWith('setup-pm-uv-v3-smoke-42-1-')).toBe(false) } }) it('explicit PM saves prune to the lock and do not prune during cancellation', () => { const [prune, upload] = save.runs.steps expect(prune.if).toBe('${{ !cancelled() }}') expect(prune.run).toBe('"$PM_PYTHON" -m pm.build_env --exact-lock --cache "$PM_CACHE" --lock-source "$PM_LOCK_SOURCE"') expect(prune.env.PM_PYTHON).toBe('${{ inputs.python }}') expect(prune.env.PM_CACHE).toBe('${{ inputs.path }}') expect(prune.env.PM_LOCK_SOURCE).toBe('${{ github.workspace }}') expect(upload.if).toBe(`\${{ !cancelled() && steps.${prune.id}.outcome == 'success' }}`) expect(upload.uses.split('@')[0]).toBe('actions/cache/save') expect(upload.with).toEqual({ path: '${{ inputs.path }}', key: '${{ inputs.key }}' }) }) it('explicit npm snapshots remain replaceable and isolated from toolchain-only producers', () => { const restored = setup.runs.steps.find(step => step.id === 'node-cache-restore') expect(restored).toBeDefined() const prefix = restored.with['restore-keys'].trim() expect(restored.with.key).toBe(`${prefix}\${{ github.run_id }}-\${{ github.run_attempt }}`) // A toolchain-only job must not shadow the desktop consumer's warm snapshot. for (const boundary of ['github.job', 'node-cache-dependency-path', 'cache-suffix', 'target', 'npm-version']) { expect(prefix).toContain(boundary) } expect(setup.outputs['node-cache-key'].value).toContain('steps.node-cache-restore.outputs.cache-primary-key') }) // Key isolation, offline wheel/receipt relocation and the transport action are // covered by tests/scripts/test_desktop_build_cache.py. These declarations guard // the caller seam: a cache hit never replaces preparation. const desktop = action('../.github/workflows/desktop-bundled-release.yml') const payload = action('../.github/workflows/pm-bundle.yml') const desktopSaveGate = "${{ !cancelled() && steps.prepare.outcome == 'success' && inputs.build_commit == '' && inputs.channel == '' }}" const payloadSaveGate = "${{ !cancelled() && steps.prepare.outcome == 'success' && github.event_name != 'pull_request' && github.ref == 'refs/heads/main' && (inputs.ref == '' || inputs.ref == github.sha) }}" it.each([ ...['win32', 'darwin'].flatMap(platform => ['x64', 'arm64'].flatMap(arch => [ [`build-${platform}-${arch}-release`, desktop, 'desktop', 'write', 'scripts/bundles/desktop.py', desktopSaveGate], [`build-${platform}-${arch}-commit`, desktop, 'desktop', 'read', 'scripts/bundles/desktop.py', desktopSaveGate], ])), ['bundle', payload, 'payload-test', undefined, 'scripts/bundles/native_build.py', payloadSaveGate], ])('%s restores, admits and saves candidates before consuming them', (id, workflow, producer, cacheMode, driver, saveGate) => { const job = workflow.jobs[id] expect(job).toBeDefined() if (cacheMode) { expect(job['cache-mode']).toBe(cacheMode) expect(job.needs).toEqual(['validate']) expect(job.if).toContain(`inputs.build_commit ${cacheMode === 'read' ? '!=' : '=='} ''`) } const cacheSteps = job.steps.filter(step => step.uses === './.github/actions/desktop-build-cache') expect(cacheSteps.map(step => step.with.phase)).toEqual(['restore', 'save']) const [restore, upload] = cacheSteps const prepare = job.steps.find(step => step.id === 'prepare') const builds = job.steps.filter(step => step.run?.includes(driver) && step.run.includes('--prepared ')) expect(prepare).toBeDefined() expect(builds).not.toHaveLength(0) expect(prepare.run).toContain(driver) expect(prepare.run).toContain('--prepare-only') // Admission runs on warm hits too; a failed preparation must fail the job. for (const step of [restore, prepare, ...builds]) { expect(step.if).toBeUndefined() expect(step['continue-on-error']).toBeUndefined() } expect(job.steps.indexOf(prepare)).toBeGreaterThan(job.steps.indexOf(restore)) expect(job.steps.indexOf(upload)).toBeGreaterThan(job.steps.indexOf(prepare)) for (const build of builds) { expect(job.steps.indexOf(build)).toBeGreaterThan(job.steps.indexOf(upload)) expect(build.run).not.toContain('--prepare-only') } expect(restore.with.producer).toBe(producer) expect(restore.with.source).toBe('${{ github.workspace }}') expect(upload.with).toEqual({ ...restore.with, phase: 'save', key: `\${{ steps.${restore.id}.outputs.cache-key }}`, }) // No cache-hit gate: immutable underfilled snapshots must be replaceable. // A failed/cancelled preparation cannot publish; later build failure cannot // discard an already-saved candidate snapshot. expect(upload.if).toBe(saveGate) }) it.each(['win32', 'darwin'].flatMap(platform => ['x64', 'arm64'].map(arch => [`${platform}-${arch}`])))('%s publication requires the selected build to succeed, not merely skip', target => { const gate = desktop.jobs[`build-${target}`] expect(gate.needs).toEqual(['validate', `build-${target}-release`, `build-${target}-commit`]) expect(gate.if).toContain("needs.validate.result == 'success'") const selected = gate.env.SELECTED_BUILD_SUCCEEDED.replace(/\s+/g, ' ') for (const [chosen, other] of [['release', 'commit'], ['commit', 'release']]) { expect(selected).toContain(`needs.build-${target}-${chosen}.result == 'success'`) expect(selected).toContain(`needs.build-${target}-${other}.result == 'skipped'`) } // Publication sits downstream of the gate, directly or through the bundle // assembly job. const upstream = new Set() const walk = id => { for (const need of desktop.jobs[id].needs ?? []) { if (!upstream.has(need)) { upstream.add(need); walk(need) } } } walk(`publish-${target.split('-')[0]}-updater`) expect(upstream).toContain(`build-${target}`) })