fix(ci): reuse uv wheels and save caches after bundle failures
This commit is contained in:
39
.github/actions/save-pm-cache/README.md
vendored
Normal file
39
.github/actions/save-pm-cache/README.md
vendored
Normal 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.
|
||||
33
.github/actions/save-pm-cache/action.yml
vendored
Normal file
33
.github/actions/save-pm-cache/action.yml
vendored
Normal 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 }}
|
||||
36
.github/actions/setup-pm/action.yml
vendored
36
.github/actions/setup-pm/action.yml
vendored
@@ -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 }}
|
||||
|
||||
18
.github/workflows/desktop-bundled-release.yml
vendored
18
.github/workflows/desktop-bundled-release.yml
vendored
@@ -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: |
|
||||
|
||||
11
.github/workflows/pm-bundle.yml
vendored
11
.github/workflows/pm-bundle.yml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/pm-toolchain.yml
vendored
4
.github/workflows/pm-toolchain.yml
vendored
@@ -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
|
||||
|
||||
74
tests-js/setup-pm-cache.test.mjs
Normal file
74
tests-js/setup-pm-cache.test.mjs
Normal 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 }}' })
|
||||
})
|
||||
@@ -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"},
|
||||
|
||||
Reference in New Issue
Block a user