Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 86 additions & 5 deletions .github/actions/build-guest-artifacts/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,30 @@ inputs:
still builds them but leaves the cache unchanged
required: false
default: "true"
record-artifact:
description: >-
Name of the artifact in which to upload build/guest-artifacts.json, the
record of the selected components' exact cache keys and of the SHA-256
of each of their files, so that the dev push that merges this run's
pull request can check them before it reuses them; "" records nothing
required: false
default: ""
reuse:
description: >-
"true", in the run of a pushed merge commit, installs every file of the
selected components, when one missed the cache, from the artifacts of
the latest pull_request run of this workflow for the merged head, from
this repository and never a fork, once they check out against that
run's record-artifact record, and builds them otherwise
required: false
default: "false"

outputs:
cache-keys:
description: >-
The exact cache keys of the guest artifacts, as a JSON object by name,
from which a guest artifact record takes those of its components
value: ${{ toJSON(steps.guest-cache-keys.outputs) }}
kernel-cache-key:
description: Exact cache key for the Linux kernel artifacts
value: ${{ steps.guest-cache-keys.outputs.kernel }}
Expand Down Expand Up @@ -189,11 +211,40 @@ runs:
key: ${{ steps.guest-cache-keys.outputs.azurelinux }}
enableCrossOsArchive: true

# A push can't restore the caches that its pull request's runs saved, so
# it installs the selected components, when one missed the cache, from the
# artifacts of the latest run for the merged head, from this repository
# and never a fork, when that run recorded these keys and the files match
# the record, their provenance, and this checkout. Any other outcome
# builds them below, and the saves cache what it installed.
- name: Reuse the merged pull request's guest artifacts
id: reuse
if: >-
inputs.restore-only != 'true' &&
inputs.reuse == 'true' &&
inputs.record-artifact != ''
continue-on-error: true
shell: bash
env:
GH_TOKEN: ${{ github.token }}
COMPONENTS: ${{ inputs.components }}
GUEST_CACHE_KEYS: ${{ toJSON(steps.guest-cache-keys.outputs) }}
RECORD_ARTIFACT: ${{ inputs.record-artifact }}
run: >-
python3 scripts/nvx.py reuse-guest-artifacts
--repository "${GITHUB_REPOSITORY}"
--workflow-ref "${GITHUB_WORKFLOW_REF}"
--commit "${GITHUB_SHA}"
--components "${COMPONENTS}"
--record-artifact "${RECORD_ARTIFACT}"
--keys "${GUEST_CACHE_KEYS}"

- name: Build Linux kernel (Docker)
if: >-
inputs.restore-only != 'true' &&
steps.components.outputs.kernel == 'true' &&
steps.cache-linux-kernel-zstd.outputs.cache-hit != 'true'
steps.cache-linux-kernel-zstd.outputs.cache-hit != 'true' &&
steps.reuse.outputs.reused != 'true'
shell: bash
run: >-
docker build
Expand Down Expand Up @@ -261,7 +312,8 @@ runs:
if: >-
inputs.restore-only != 'true' &&
steps.components.outputs.alpine == 'true' &&
steps.cache-alpine-image-zstd.outputs.cache-hit != 'true'
steps.cache-alpine-image-zstd.outputs.cache-hit != 'true' &&
steps.reuse.outputs.reused != 'true'
shell: bash
run: >-
docker build
Expand Down Expand Up @@ -289,7 +341,8 @@ runs:
if: >-
inputs.restore-only != 'true' &&
steps.components.outputs.ubuntu == 'true' &&
steps.cache-ubuntu-initramfs-zstd.outputs.cache-hit != 'true'
steps.cache-ubuntu-initramfs-zstd.outputs.cache-hit != 'true' &&
steps.reuse.outputs.reused != 'true'
shell: bash
run: >-
docker build
Expand All @@ -298,11 +351,14 @@ runs:
--output type=local,dest=build
.

# A reused image passed this check in the run that built it, as a cached
# one did.
- name: Verify Ubuntu artifact determinism (Docker)
if: >-
inputs.restore-only != 'true' &&
(
steps.components.outputs.ubuntu == 'true' &&
steps.reuse.outputs.reused != 'true' &&
(
steps.cache-ubuntu-initramfs-zstd.outputs.cache-hit != 'true' ||
steps.cache-ubuntu-distro-zstd.outputs.cache-hit != 'true'
Expand Down Expand Up @@ -333,7 +389,8 @@ runs:
if: >-
inputs.restore-only != 'true' &&
steps.components.outputs.azurelinux == 'true' &&
steps.cache-azurelinux-initramfs-zstd.outputs.cache-hit != 'true'
steps.cache-azurelinux-initramfs-zstd.outputs.cache-hit != 'true' &&
steps.reuse.outputs.reused != 'true'
shell: bash
env:
AZURELINUX_IMAGE: ${{ steps.guest-versions.outputs.azurelinux-image }}
Expand Down Expand Up @@ -365,7 +422,8 @@ runs:
if: >-
inputs.restore-only != 'true' &&
steps.components.outputs.ubuntu == 'true' &&
steps.cache-ubuntu-distro-zstd.outputs.cache-hit != 'true'
steps.cache-ubuntu-distro-zstd.outputs.cache-hit != 'true' &&
steps.reuse.outputs.reused != 'true'
shell: bash
run: >-
docker build
Expand Down Expand Up @@ -400,3 +458,26 @@ runs:
-U 4f3c8bf7-1959-47b4-8811-62b17bbddaf4 \
-E lazy_itable_init=0,lazy_journal_init=0 \
build/ubuntu-smoke-scratch.ext4

# Uploaded beside the components' own artifacts, so that the dev push that
# merges this run's pull request can check them before it reuses them.
# The scratch template, which every run formats anew, is in no record.
- name: Record guest artifact keys and digests
if: inputs.restore-only != 'true' && inputs.record-artifact != ''
shell: bash
env:
COMPONENTS: ${{ inputs.components }}
GUEST_CACHE_KEYS: ${{ toJSON(steps.guest-cache-keys.outputs) }}
run: >-
python3 scripts/nvx.py record-guest-artifacts
--components "${COMPONENTS}"
--keys "${GUEST_CACHE_KEYS}"

- name: Upload guest artifact record
if: inputs.restore-only != 'true' && inputs.record-artifact != ''
uses: actions/upload-artifact@v7
with:
name: ${{ inputs.record-artifact }}
path: build/guest-artifacts.json
if-no-files-found: error
retention-days: 1
85 changes: 83 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -377,20 +377,29 @@ jobs:
}}
runs-on: ubuntu-latest
timeout-minutes: 60
# A push reads its pull request's runs and downloads their artifacts.
permissions:
actions: read
contents: read
outputs:
guest-cache-keys: ${{ steps.guest-artifacts.outputs.cache-keys }}
steps:
- name: Checkout
uses: actions/checkout@v5
with:
persist-credentials: false

# Full runs build the guest artifacts that miss the cache without saving
# them.
# them. A push installs those that its pull request's latest run built
# instead, when they check out.
- name: Build guest artifacts
id: guest-artifacts
uses: ./.github/actions/build-guest-artifacts
with:
components: kernel alpine
save-cache: ${{ github.event_name == 'pull_request' || github.event_name == 'push' }}
record-artifact: kernel-alpine-record
reuse: ${{ github.event_name == 'push' }}

# One artifact per component, so that each job downloads only the
# components that it reads (doc/ci.md, "Guest artifact handoff").
Expand Down Expand Up @@ -434,20 +443,29 @@ jobs:
}}
runs-on: ubuntu-latest
timeout-minutes: 60
# A push reads its pull request's runs and downloads their artifacts.
permissions:
actions: read
contents: read
outputs:
guest-cache-keys: ${{ steps.guest-artifacts.outputs.cache-keys }}
steps:
- name: Checkout
uses: actions/checkout@v5
with:
persist-credentials: false

# Full runs build the guest artifacts that miss the cache without saving
# them.
# them. A push installs those that its pull request's latest run built
# instead, when they check out.
- name: Build guest artifacts
id: guest-artifacts
uses: ./.github/actions/build-guest-artifacts
with:
components: ubuntu azurelinux
save-cache: ${{ github.event_name == 'pull_request' || github.event_name == 'push' }}
record-artifact: ubuntu-azurelinux-record
reuse: ${{ github.event_name == 'push' }}

- name: Upload Ubuntu initramfs
uses: actions/upload-artifact@v7
Expand Down Expand Up @@ -487,6 +505,67 @@ jobs:
if-no-files-found: error
retention-days: 1

# A dev push reuses the guest artifact components that the pull request it
# merges built (see build-guest-artifacts). A same-repository pull request
# rehearses that selection and check on the artifacts that its own run
# uploaded, beside the jobs that use them, and installs nothing.
guest-artifact-reuse:
name: Rehearse guest artifact reuse
needs: [openvmm-changes, artifacts, distro-artifacts]
if: >-
${{
always() &&
!cancelled() &&
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository &&
needs.artifacts.result == 'success' &&
needs.distro-artifacts.result == 'success' &&
(
needs.openvmm-changes.result != 'success' ||
needs.openvmm-changes.outputs.run-guest-artifacts == 'true'
)
}}
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
actions: read
contents: read
steps:
- name: Checkout
uses: actions/checkout@v5
with:
persist-credentials: false

- name: Rehearse kernel and Alpine artifact reuse
shell: bash
env:
GH_TOKEN: ${{ github.token }}
GUEST_CACHE_KEYS: ${{ needs.artifacts.outputs.guest-cache-keys }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: >-
python3 scripts/nvx.py reuse-guest-artifacts --dry-run
--repository "${GITHUB_REPOSITORY}"
--workflow-ref "${GITHUB_WORKFLOW_REF}"
--head-sha "${HEAD_SHA}"
--components "kernel alpine"
--record-artifact kernel-alpine-record
--keys "${GUEST_CACHE_KEYS}"

- name: Rehearse Ubuntu and Azure Linux artifact reuse
shell: bash
env:
GH_TOKEN: ${{ github.token }}
GUEST_CACHE_KEYS: ${{ needs.distro-artifacts.outputs.guest-cache-keys }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: >-
python3 scripts/nvx.py reuse-guest-artifacts --dry-run
--repository "${GITHUB_REPOSITORY}"
--workflow-ref "${GITHUB_WORKFLOW_REF}"
--head-sha "${HEAD_SHA}"
--components "ubuntu azurelinux"
--record-artifact ubuntu-azurelinux-record
--keys "${GUEST_CACHE_KEYS}"

# Beside the shared artifacts, so that a kernel rebuild doesn't delay the
# jobs that don't use the debug kernel.
debug-kernel:
Expand Down Expand Up @@ -1702,6 +1781,7 @@ jobs:
- artifacts
- distro-artifacts
- debug-kernel
- guest-artifact-reuse
- build-openvmm-linux-gnu
- build-openvmm-linux-musl
- build-openvmm-windows-msvc
Expand Down Expand Up @@ -1752,6 +1832,7 @@ jobs:
ARTIFACTS_RESULT: ${{ needs.artifacts.result }}
DISTRO_ARTIFACTS_RESULT: ${{ needs.distro-artifacts.result }}
DEBUG_KERNEL_RESULT: ${{ needs.debug-kernel.result }}
GUEST_ARTIFACT_REUSE_RESULT: ${{ needs.guest-artifact-reuse.result }}
BUILD_LINUX_GNU_RESULT: ${{ needs.build-openvmm-linux-gnu.result }}
BUILD_LINUX_MUSL_RESULT: ${{ needs.build-openvmm-linux-musl.result }}
BUILD_WINDOWS_MSVC_RESULT: ${{ needs.build-openvmm-windows-msvc.result }}
Expand Down
81 changes: 80 additions & 1 deletion doc/ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,11 @@ release`, `Persist performance baseline`, and `Required status check` wait for
the jobs whose results they check. Required handoffs between these jobs use
same-run workflow artifacts; caches only skip rebuilding their inputs.

For a same-repository pull request, `guest-artifact-reuse` also waits for
`artifacts` and `distro-artifacts`, and rehearses the
[guest artifact reuse](#guest-artifact-reuse) of the push that merges it;
only `Required status check` waits for it.

The NVX CLI tests do not depend on the backend, so they run once per operating
system in `nvx-cli-linux` and `nvx-cli-windows` rather than in every
hypervisor job. Each checks out OpenVMM, whose pinned CPU profiles a test
Expand Down Expand Up @@ -602,7 +607,81 @@ artifact job before. From October 19 to 21, 2026, before the guest image keys
covered only the guest binaries' inputs, 159 of 200 guest artifact jobs missed
the cache, nearly all for the Alpine, Ubuntu, and Azure Linux images;
[#703](https://github.com/microsoft/nvx/issues/703) records the measurements
and how many of those misses the narrower keys remove.
and how many of those misses the narrower keys remove. A `dev` push installs
the components that its pull request's run built instead of building them,
when they check out; see [Guest artifact reuse](#guest-artifact-reuse).

## Guest artifact reuse

GitHub scopes the caches that a pull request's runs save to the pull request,
so the `dev` push that merges it cannot restore them, and used to rebuild
every guest image whose inputs the pull request had changed. From October 10
to 21, 2026, when one job built every guest image, 50 of the 87 `dev` pushes
that ran it built at least one image, and the job took a median of 5.4
minutes, and at most 11.3, instead of 0.3, which delayed every job that waits
for it. A push therefore reuses the components that its pull request's run
already built, when they check out.

`artifacts` and `distro-artifacts` each record their components. The last
steps of
[`build-guest-artifacts`](../.github/actions/build-guest-artifacts/action.yml)
run [`record-guest-artifacts`](usage.md#record-guest-artifacts), which writes
the guest artifact record, `build/guest-artifacts.json`, with the exact keys of
the components' cache entries and the SHA-256 of each of their files, and
upload it for one day under the name that the action's `record-artifact` input
gives: `kernel-alpine-record` for the kernel and the Alpine initramfs, and
`ubuntu-azurelinux-record` for the Ubuntu and Azure Linux images. No job of the
run downloads a record. On a push, after the cache restores and before any
build, [`reuse-guest-artifacts`](usage.md#reuse-guest-artifacts) runs in each
of the two jobs when a file of its components is missing. It takes the merged
pull request's head from the second parent of the push's merge commit, and
the latest `pull_request` run of `ci.yml` for that head whose head repository
is `microsoft/nvx`, so never a fork's run. It downloads the job's record from
that run, unless the record has expired, which it does after one day, or lacks
the SHA-256 digest that GitHub records at upload, and compares its keys with
the push's own keys for the job's components, so the keys of the other job's
components never matter. Only when they are equal does it download the
components' artifacts, `guest-kernel` and `guest-alpine`, or `guest-ubuntu`,
`guest-ubuntu-distro`, and `guest-azurelinux`, under the same conditions. It
installs their files only when each artifact matches its digest, every file
matches the record, and the kernel and initramfs provenance, or the Ubuntu and
Azure Linux manifests, match the files and the push's checkout, as
[`package`](usage.md#package) requires. Then it replaces every file of the
job's components, not only the missing ones, because a cache restore that fails
partway can leave a truncated file behind and still report a miss; the files
that the cache restored have the same keys. The job then skips the builds of
its components and, in `distro-artifacts`, the Ubuntu determinism check, which
the run that built the images passed, as for a cache hit. It still saves the
components in its caches for later runs, records them, and uploads them as its
own artifacts, which its consumers download as before, so no job depends on
another run's artifact or on a cache. The debug kernel is never reused.

Every other outcome builds the components that missed the cache, as before: a
merge commit without a same-repository pull request run, an expired record or
artifact or one without a digest, a run from before the records, other keys,
as after another merge changed the job's guest inputs, a failed request or
download, a file that does not check out, or a failed installation, which puts
back the files that it replaced. After two minutes, a timer stops a stalled
download or check in the same way, but never an installation, which it could
cut short with its files half put back. The step continues on error and never
fails the run. It reports why in the job summary and as a notice, or as a
warning for a fault. `artifacts`, `distro-artifacts`, and the rehearsal below
add `actions: read`, which reading runs and downloading their artifacts needs,
to the workflow's `contents: read`.

Pull requests can't exercise the reuse itself, so the `guest-artifact-reuse`
job (`Rehearse guest artifact reuse`) rehearses it for each same-repository
pull request once both guest artifact jobs succeed, beside the jobs that wait
for them. It runs `reuse-guest-artifacts --dry-run` for the pull request's
head and each job's components, record, and keys, which selects the run in
progress, downloads the records and the artifacts that the two jobs just
uploaded, checks them as a push would, and reports in the job summary whether
a push would reuse them, without installing anything. A reason not to reuse
fails nothing, but it warns, because a run's own artifacts should always check
out. The change detector selects the rehearsal with the guest artifacts, and
`Required status check` requires it to succeed for a same-repository pull
request that builds them, so a pull request that breaks the reuse command
can't merge.

## Run concurrency

Expand Down
Loading
Loading