fix(ci): reuse uv wheels and save caches after bundle failures

This commit is contained in:
ethernet
2026-09-11 13:28:15 -04:00
parent 284dbaf537
commit 493ae9daa3
8 changed files with 209 additions and 8 deletions

39
.github/actions/save-pm-cache/README.md vendored Normal file
View File

@@ -0,0 +1,39 @@
# Bundle dependency caches
`setup-pm` restores uv's real cache, not the installed virtual environment.
Its fallback keys retain the native target, OS version, Python version, and
pruning mode. A metadata or dependency change can reuse compatible wheels;
uv still resolves and installs from the frozen project lock.
Bundle jobs set `save-python-cache: false` and call `save-pm-cache` after their
build step with the `uv-path`, `uv-cache-path`, and `python-cache-key` outputs.
Both the caller and this composite use `!cancelled()` so a failed build does
not suppress the save. Cancellation is excluded to avoid racing a dying uv
process. A runner crash or job timeout can still prevent the save.
Snapshots use a run ID, attempt, and producer job suffix because Actions caches
are immutable. Sibling bundle workflows in one caller run must not race to save
different contents under the same key.
Restore tries the current dependency set's rolling snapshots first, then the
compatible prefix, which also admits the existing pre-change caches. This lets
a retry add wheels to a snapshot saved by a partially failed build. The ordinary
automatic cache path and its exact-hit smoke tests remain available.
New smoke snapshots use a separate `setup-pm-uv-isolated-` prefix, outside
production's restore prefix. Their run suffix stays at the end so the existing
PM Toolchain cleanup still removes them. Pre-change smoke snapshots retain
their old keys until that cleanup or GitHub's retention removes them; preserving
legacy production caches also preserves the possibility of matching those old
smoke entries during migration.
Before saving, `uv cache prune` removes dangling entries. It does not use
`--ci`: that option discards downloaded wheels, while bundles copy the full
cache for offline dependency installation. Pruning happens after payload staging
and packaging, and it does not change the staged payload.
This is not a lockfile-aware or size-bounded cache. Old, still-referenced package
versions can remain inside a snapshot and be copied forward. GitHub evicts whole
cache snapshots under its retention and repository quota policies; that does
not remove old versions inside the newest snapshot. A future size policy must
account for the offline payload too, rather than deleting uv internals or
promising that `prune` keeps only the current lock.

View File

@@ -0,0 +1,33 @@
name: Save the PM Python dependency cache
description: Prune dangling entries and save a rolling cache after the consumer, even when it failed.
inputs:
uv:
description: Absolute path to the PM-provisioned uv executable.
required: true
path:
description: Cache directory exported by setup-pm.
required: true
key:
description: Rolling snapshot key exported by setup-pm.
required: true
runs:
using: composite
steps:
# Status checks are needed inside the composite too: the caller can
# enter after a failed build. Never prune while cancellation kills uv.
- name: Prune dangling uv cache entries
id: prune
if: ${{ !cancelled() }}
shell: bash
env:
PM_UV: ${{ inputs.uv }}
UV_CACHE_DIR: ${{ inputs.path }}
# Keep downloaded wheels as well as source-built wheels. Bundles copy
# this cache for offline installs; --ci would discard required inputs.
run: '"$PM_UV" cache prune'
- name: Save reusable Python dependencies
if: ${{ !cancelled() && steps.prune.outcome == 'success' }}
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ inputs.path }}
key: ${{ inputs.key }}

View File

@@ -16,6 +16,9 @@ inputs:
cache-python:
description: Cache uv downloads and built wheels; never cache the dependency environment.
default: 'true'
save-python-cache:
description: Save automatically on success. Set false to save a rolling snapshot with save-pm-cache after the consumer, including on failure.
default: 'true'
cache-node:
description: Cache npm downloads; never cache node_modules.
default: 'true'
@@ -28,7 +31,7 @@ inputs:
description: npm lockfiles that invalidate the npm download cache; supports multiline globs.
default: package-lock.json
prune-python-cache:
description: Prune uv's cache at job teardown before saving, as setup-uv v8 did.
description: Use uv cache prune --ci before automatic saves. Explicit bundle saves preserve downloaded wheels.
default: 'false'
cache-suffix:
description: Optional namespace for isolated cache smoke tests.
@@ -63,7 +66,13 @@ outputs:
value: ${{ steps.tools-cache.outputs.cache-hit }}
python-cache-hit:
description: Exact uv dependency cache hit.
value: ${{ steps.python-cache.outputs.cache-hit }}
value: ${{ steps.python-cache.outputs.cache-hit || steps.python-cache-restore.outputs.cache-hit }}
python-cache-key:
description: Rolling snapshot key for save-pm-cache when save-python-cache is false.
value: ${{ steps.python-cache-restore.outputs.cache-primary-key }}
uv-cache-path:
description: Shared uv cache directory for save-pm-cache.
value: ${{ steps.install.outputs.uv-cache-path }}
node-cache-hit:
description: Exact npm dependency cache hit.
value: ${{ steps.node-cache.outputs.cache-hit }}
@@ -117,15 +126,32 @@ runs:
- name: Cache uv dependency downloads and builds
id: python-cache
if: inputs.toolchain != 'node' && inputs.cache-python == 'true'
if: inputs.toolchain != 'node' && inputs.cache-python == 'true' && inputs.save-python-cache == 'true'
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ steps.install.outputs.uv-cache-path }}
key: setup-pm-uv-v1-${{ steps.prepare.outputs.target }}-${{ steps.prepare.outputs.os-version }}-${{ steps.prepare.outputs.python-version }}-${{ inputs.prune-python-cache }}-${{ hashFiles(inputs.python-cache-dependency-glob) || 'no-dependency-glob' }}-${{ inputs.cache-suffix }}
key: setup-pm-uv-${{ inputs.cache-suffix != '' && 'isolated-' || '' }}v1-${{ steps.prepare.outputs.target }}-${{ steps.prepare.outputs.os-version }}-${{ steps.prepare.outputs.python-version }}-${{ inputs.prune-python-cache }}-${{ hashFiles(inputs.python-cache-dependency-glob) || 'no-dependency-glob' }}-${{ inputs.cache-suffix }}
# Isolated writes also need a distinct prefix: production's broad
# fallback must not restore a smoke run's partial dependency set.
restore-keys: ${{ inputs.cache-suffix == '' && format('setup-pm-uv-v1-{0}-{1}-{2}-{3}-', steps.prepare.outputs.target, steps.prepare.outputs.os-version, steps.prepare.outputs.python-version, inputs.prune-python-cache) || '' }}
- name: Restore uv dependencies for an explicit save
id: python-cache-restore
if: inputs.toolchain != 'node' && inputs.cache-python == 'true' && inputs.save-python-cache == 'false'
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ steps.install.outputs.uv-cache-path }}
# Caches are immutable. Each producer needs its own snapshot, even
# when a caller run contains both PM Bundle and Desktop Release.
key: setup-pm-uv-${{ inputs.cache-suffix != '' && 'isolated-' || '' }}v1-${{ steps.prepare.outputs.target }}-${{ steps.prepare.outputs.os-version }}-${{ steps.prepare.outputs.python-version }}-${{ inputs.prune-python-cache }}-${{ hashFiles(inputs.python-cache-dependency-glob) || 'no-dependency-glob' }}-${{ inputs.cache-suffix }}-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}
restore-keys: |
setup-pm-uv-${{ inputs.cache-suffix != '' && 'isolated-' || '' }}v1-${{ steps.prepare.outputs.target }}-${{ steps.prepare.outputs.os-version }}-${{ steps.prepare.outputs.python-version }}-${{ inputs.prune-python-cache }}-${{ hashFiles(inputs.python-cache-dependency-glob) || 'no-dependency-glob' }}-${{ inputs.cache-suffix }}-
${{ inputs.cache-suffix == '' && format('setup-pm-uv-v1-{0}-{1}-{2}-{3}-', steps.prepare.outputs.target, steps.prepare.outputs.os-version, steps.prepare.outputs.python-version, inputs.prune-python-cache) || '' }}
# Post steps run in reverse registration order: prune before cache save.
- name: Register uv cache pruning
if: inputs.toolchain != 'node' && inputs.cache-python == 'true' && inputs.prune-python-cache == 'true' && steps.python-cache.outputs.cache-hit != 'true'
if: inputs.toolchain != 'node' && inputs.cache-python == 'true' && inputs.save-python-cache == 'true' && inputs.prune-python-cache == 'true' && steps.python-cache.outputs.cache-hit != 'true'
uses: ./.github/actions/setup-pm/prune
with:
uv: ${{ steps.install.outputs.uv-path }}

View File

@@ -317,6 +317,7 @@ jobs:
archive-inputs: true
# The payload tools cache does not include uv's built wheels.
cache-python: true
save-python-cache: false
- name: Resolve toolchain cache key
id: toolchain
@@ -500,6 +501,14 @@ jobs:
python scripts/bundles/desktop.py --tag="$HERMES_PAYLOAD_TAG" --variant=store
fi
- name: Save warmed Python dependencies even after a packaging failure
if: ${{ !cancelled() && steps.pm.outcome == 'success' }}
uses: ./.github/actions/save-pm-cache
with:
uv: ${{ steps.pm.outputs.uv-path }}
path: ${{ steps.pm.outputs.uv-cache-path }}
key: ${{ steps.pm.outputs.python-cache-key }}
- name: Verify native signature cache contracts
shell: bash
working-directory: apps/desktop
@@ -603,6 +612,7 @@ jobs:
archive-inputs: true
# The payload tools cache does not include uv's built wheels.
cache-python: true
save-python-cache: false
- name: Resolve toolchain cache key
id: toolchain
@@ -767,6 +777,14 @@ jobs:
python scripts/bundles/desktop.py --tag="$HERMES_PAYLOAD_TAG" --variant=bundled
fi
- name: Save warmed Python dependencies even after a packaging failure
if: ${{ !cancelled() && steps.pm.outcome == 'success' }}
uses: ./.github/actions/save-pm-cache
with:
uv: ${{ steps.pm.outputs.uv-path }}
path: ${{ steps.pm.outputs.uv-cache-path }}
key: ${{ steps.pm.outputs.python-cache-key }}
- name: Audit bundle architecture
shell: bash
run: |

View File

@@ -78,8 +78,9 @@ jobs:
# The host and payload toolchains use the same PM pins and installer.
- uses: ./.github/actions/setup-pm
id: pm
with:
cache-python: false
save-python-cache: false
# One cache for the pm store: keyed on the lockfile, so a pin bump
# rotates it. pm verifies every restored entry against the lock
@@ -142,6 +143,14 @@ jobs:
--out build/agent-payload \
--ref HEAD
- name: Save warmed Python dependencies even after a staging failure
if: ${{ !cancelled() && steps.pm.outcome == 'success' }}
uses: ./.github/actions/save-pm-cache
with:
uv: ${{ steps.pm.outputs.uv-path }}
path: ${{ steps.pm.outputs.uv-cache-path }}
key: ${{ steps.pm.outputs.python-cache-key }}
# The payload must boot with nothing from this checkout: the staged
# venv's interpreter, cwd at the staged REPO (the desktop spawn
# convention — sys.path[0] from cwd survives relocation where the

View File

@@ -5,10 +5,12 @@ on:
pull_request:
paths:
- '.github/actions/setup-pm/**'
- '.github/actions/save-pm-cache/**'
- '.github/workflows/pm-toolchain*.yml'
- 'scripts/ci/*toolchain.py'
- 'tests/scripts/test_setup_toolchain.py'
- 'tests-js/setup-pm-post.test.mjs'
- 'tests-js/setup-pm-cache.test.mjs'
- 'pm/**'
workflow_dispatch:
permissions:
@@ -60,7 +62,7 @@ jobs:
working-directory: apps/desktop
run: node ../../node_modules/vitest/vitest.mjs run --project electron scripts/after-pack-toolchain.test.mjs
- name: Verify the icon and cache-post contracts
run: node node_modules/vitest/vitest.mjs run --root tests-js generate-icons.test.mjs setup-pm-post.test.mjs
run: node node_modules/vitest/vitest.mjs run --root tests-js generate-icons.test.mjs setup-pm-post.test.mjs setup-pm-cache.test.mjs
- name: Exercise the payload and icon build entrypoints
run: |
npm run payload --workspace apps/desktop -- --help

View File

@@ -0,0 +1,74 @@
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')
// An exact legacy entry wins over prefix matching. Prefer rolling entries
// first so the next attempt can use additions from the last failed build.
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]).toContain('inputs.prune-python-cache')
expect(prefixes[1]).not.toContain('hashFiles')
expect(restored.uses.split('@')[0]).toBe('actions/cache/restore')
expect(cached.if).toContain("inputs.save-python-cache == 'true'")
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 != '' && 'isolated-' || '' }}"
for (const template of [cached.with.key, restored.with.key, rollingPrefix]) {
const production = template.replace(namespace, '')
const smoke = template.replace(namespace, 'isolated-')
expect(production.startsWith('setup-pm-uv-v1-')).toBe(true)
expect(smoke.startsWith('setup-pm-uv-isolated-v1-')).toBe(true)
expect(smoke.startsWith('setup-pm-uv-v1-')).toBe(false)
expect(production.startsWith('setup-pm-uv-isolated-v1-')).toBe(false)
}
})
it('all bundle consumers save on failure, after building, without discarding offline wheels', () => {
const workflows = [
action('../.github/workflows/desktop-bundled-release.yml'),
action('../.github/workflows/pm-bundle.yml'),
]
const jobs = workflows.flatMap(workflow => Object.values(workflow.jobs)).filter(job =>
job.steps?.some(step => ['Build and package', 'Stage the payload'].includes(step.name)))
expect(jobs.length).toBeGreaterThan(0)
for (const job of jobs) {
const setupIndex = job.steps.findIndex(step => step.uses === './.github/actions/setup-pm')
const saveIndex = job.steps.findIndex(step => step.uses === './.github/actions/save-pm-cache')
const setupStep = job.steps[setupIndex]
const saveStep = job.steps[saveIndex]
expect(setupStep.with['save-python-cache']).toBe(false)
expect(saveIndex).toBeGreaterThan(setupIndex)
expect(job.steps.slice(setupIndex + 1, saveIndex).some(step => step.run?.includes('bundle'))).toBe(true)
expect(saveStep.if).toBe(`\${{ !cancelled() && steps.${setupStep.id}.outcome == 'success' }}`)
expect(saveStep.with).toEqual({
uv: `\${{ steps.${setupStep.id}.outputs.uv-path }}`,
path: `\${{ steps.${setupStep.id}.outputs.uv-cache-path }}`,
key: `\${{ steps.${setupStep.id}.outputs.python-cache-key }}`,
})
}
const [prune, upload] = save.runs.steps
expect(prune.if).toBe('${{ !cancelled() }}')
expect(prune.run).toBe('"$PM_UV" cache prune')
expect(prune.env.PM_UV).toBe('${{ inputs.uv }}')
expect(prune.env.UV_CACHE_DIR).toBe('${{ inputs.path }}')
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 }}' })
})

View File

@@ -10,7 +10,7 @@ def test_cleanup_collects_all_pages_then_deletes_only_its_run():
rows = [
{"id": 1, "key": "setup-pm-tools-x64-smoke-42-1"},
{"id": 2, "key": "node-cache-Windows-x64-smoke-42-2"},
{"id": 3, "key": "setup-pm-uv-x64-smoke-prune-42-1"},
{"id": 3, "key": "setup-pm-uv-isolated-v1-x64-smoke-prune-42-1"},
{"id": 4, "key": "setup-pm-tools-x64-smoke-consumers-42-1"},
{"id": 5, "key": "setup-pm-tools-x64-smoke-420-1"},
{"id": 6, "key": "node-cache-Windows-x64-normal"},