Publish in a minute: build outside the lock, sign on a hosted runner (#869)
A one-package merge took 9 to 15 minutes to publish for about 15 seconds of signing and upload. The run-wide concurrency group made each merge wait for every earlier run, builds included (#854 waited 13 minutes behind an aarch64 batch), and the publish job waited about 3 minutes for a builder droplet even when every package had a PR artifact and nothing needed building. Build x86_64 trees that have no artifact in the rebuild job, one droplet per entry, as aarch64 already builds there on arm64 runners: in parallel, with no secrets, and outside any lock. The publish job now only collects artifacts, signs and uploads, on ubuntu-latest with the GHCR builder image (#850), and only it holds the publish group.
This commit is contained in:
1 parent
93b1655ca8
commit
ebd2d6a766
2 files changed
+49
-50
No files matched your search
@@ -24,11 +24,10 @@ on:
|
|||||||
description: "Space-separated package directories to publish from master"
|
description: "Space-separated package directories to publish from master"
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
# Merges serialize. Two publishes into one channel at once would race on
|
# Only the publish job serializes (see its concurrency group): two publishes
|
||||||
# the database; queued is fine, cancelled is not.
|
# into one channel at once would race on the database. Builds run outside the
|
||||||
concurrency:
|
# lock, so a merge waits behind another merge's signing and upload, seconds,
|
||||||
group: publish
|
# never behind its kernel build.
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
changes:
|
changes:
|
||||||
@@ -58,11 +57,10 @@ jobs:
|
|||||||
echo "matrix=$matrix" >> "$GITHUB_OUTPUT"
|
echo "matrix=$matrix" >> "$GITHUB_OUTPUT"
|
||||||
echo "count=$(jq '.include | length' <<<"$matrix")" >> "$GITHUB_OUTPUT"
|
echo "count=$(jq '.include | length' <<<"$matrix")" >> "$GITHUB_OUTPUT"
|
||||||
jq -r '.include[] | "\(.package) \(.arch) -> \(.channels)"' <<<"$matrix"
|
jq -r '.include[] | "\(.package) \(.arch) -> \(.channels)"' <<<"$matrix"
|
||||||
# Reuse or rebuild, decided per entry and said out loud. An aarch64
|
# Reuse or rebuild, decided per entry and said out loud. A tree with
|
||||||
# tree with no build artifact (PR artifacts last 7 days; a dispatch
|
# no build artifact (PR artifacts last 7 days; a dispatch may name
|
||||||
# may name any package) goes to the rebuild job, which builds it
|
# any package) goes to the rebuild job: aarch64 natively on GitHub's
|
||||||
# natively on GitHub's arm64 runner. x86_64 builds inside the
|
# arm64 runner, x86_64 on a builder droplet, one runner per entry.
|
||||||
# publish job on the droplet, as before.
|
|
||||||
rebuild=()
|
rebuild=()
|
||||||
echo "| package | arch | tree | decision |" >> "$GITHUB_STEP_SUMMARY"
|
echo "| package | arch | tree | decision |" >> "$GITHUB_STEP_SUMMARY"
|
||||||
echo "| --- | --- | --- | --- |" >> "$GITHUB_STEP_SUMMARY"
|
echo "| --- | --- | --- | --- |" >> "$GITHUB_STEP_SUMMARY"
|
||||||
@@ -77,9 +75,10 @@ jobs:
|
|||||||
decision="reuse the build artifact ($found)"
|
decision="reuse the build artifact ($found)"
|
||||||
elif [[ $arch == aarch64 ]]; then
|
elif [[ $arch == aarch64 ]]; then
|
||||||
decision="no build artifact: rebuild natively on ubuntu-24.04-arm"
|
decision="no build artifact: rebuild natively on ubuntu-24.04-arm"
|
||||||
rebuild+=("$entry")
|
rebuild+=("$(jq -c '. + {runner: "[\"ubuntu-24.04-arm\"]"}' <<<"$entry")")
|
||||||
else
|
else
|
||||||
decision="no build artifact: build in the publish job on the self-hosted builder"
|
decision="no build artifact: rebuild on a builder droplet"
|
||||||
|
rebuild+=("$(jq -c '. + {runner: "[\"self-hosted\",\"omarchy-builder\"]"}' <<<"$entry")")
|
||||||
fi
|
fi
|
||||||
echo "==> $label: $decision"
|
echo "==> $label: $decision"
|
||||||
echo "| $package | $arch | ${hash:0:12} | $decision |" >> "$GITHUB_STEP_SUMMARY"
|
echo "| $package | $arch | ${hash:0:12} | $decision |" >> "$GITHUB_STEP_SUMMARY"
|
||||||
@@ -87,16 +86,19 @@ jobs:
|
|||||||
echo "rebuild=$(printf '%s\n' "${rebuild[@]}" | jq -sc '{include: .}')" >> "$GITHUB_OUTPUT"
|
echo "rebuild=$(printf '%s\n' "${rebuild[@]}" | jq -sc '{include: .}')" >> "$GITHUB_OUTPUT"
|
||||||
echo "rebuild_count=${#rebuild[@]}" >> "$GITHUB_OUTPUT"
|
echo "rebuild_count=${#rebuild[@]}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
# The aarch64 half of "build it now when there is none". It builds exactly
|
# "Build it now when there is none". Each entry builds exactly as
|
||||||
# as build-pr.yml's aarch64 path does (same runner, same builder image,
|
# build-pr.yml builds it (same runner kind, same builder image, same
|
||||||
# same bin/build call) and uploads under the same label, so the publish
|
# bin/build call) and uploads under the same label, so the publish job
|
||||||
# job collects this run's artifact the way it collects a PR's. No secret
|
# collects this run's artifact the way it collects a PR's. No secret
|
||||||
# reaches this runner; signing and upload stay on the self-hosted builder.
|
# reaches these runners, and they hold no lock: entries build in parallel,
|
||||||
|
# and other merges publish while they do.
|
||||||
rebuild:
|
rebuild:
|
||||||
needs: changes
|
needs: changes
|
||||||
if: needs.changes.outputs.rebuild_count != '0'
|
if: needs.changes.outputs.rebuild_count != '0'
|
||||||
runs-on: ubuntu-24.04-arm
|
runs-on: ${{ fromJSON(matrix.runner) }}
|
||||||
timeout-minutes: 180
|
# The droplets are x86, so aarch64 never builds there. omarchy-mac-boot
|
||||||
|
# under QEMU took 2h47m; native arm64 and x86 kernels fit easily.
|
||||||
|
timeout-minutes: 240
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
strategy:
|
strategy:
|
||||||
@@ -109,7 +111,7 @@ jobs:
|
|||||||
# The same check the publish job makes before building: a re-run for a
|
# The same check the publish job makes before building: a re-run for a
|
||||||
# package the channel already holds at master's version builds
|
# package the channel already holds at master's version builds
|
||||||
# nothing, and uploads nothing that could shadow the published file.
|
# nothing, and uploads nothing that could shadow the published file.
|
||||||
- name: Build ${{ matrix.package }} (${{ matrix.arch }}, native)
|
- name: Build ${{ matrix.package }} (${{ matrix.arch }})
|
||||||
id: build
|
id: build
|
||||||
env:
|
env:
|
||||||
CONTAINER_ENGINE: docker
|
CONTAINER_ENGINE: docker
|
||||||
@@ -140,23 +142,26 @@ jobs:
|
|||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
# One job for the whole merge. It collects every PR artifact for the
|
# One job for the whole merge. It collects every artifact for the merged
|
||||||
# merged tree (building only what has none; aarch64 comes from the
|
# tree (PR builds, or the rebuild job above), then walks each channel and
|
||||||
# rebuild job above), then walks each channel and
|
|
||||||
# architecture slot exactly once: pull that database, add every package
|
# architecture slot exactly once: pull that database, add every package
|
||||||
# that belongs in it, upload. Six slots, six round trips, however many
|
# that belongs in it, upload. Six slots, six round trips, however many
|
||||||
# packages the merge carried. One process is the only writer, so there
|
# packages the merge carried. One process is the only writer, so there
|
||||||
# is no race between packages; the run-level concurrency group above
|
# is no race between packages; the concurrency group keeps one merge's
|
||||||
# keeps one merge from overlapping the next.
|
# publish from overlapping the next. It builds nothing, so it runs on a
|
||||||
|
# hosted runner in about a minute instead of waiting for a droplet.
|
||||||
# It waits for the rebuild job and runs whatever that job's result: a
|
# It waits for the rebuild job and runs whatever that job's result: a
|
||||||
# failed rebuild leaves its package without an artifact, and the collect
|
# failed rebuild leaves its package without an artifact, and the collect
|
||||||
# step below records that and stops before any publish.
|
# step below records that and stops before any publish.
|
||||||
publish:
|
publish:
|
||||||
needs: [changes, rebuild]
|
needs: [changes, rebuild]
|
||||||
if: ${{ !cancelled() && needs.changes.result == 'success' && needs.changes.outputs.count != '0' }}
|
if: ${{ !cancelled() && needs.changes.result == 'success' && needs.changes.outputs.count != '0' }}
|
||||||
runs-on: [self-hosted, omarchy-builder]
|
runs-on: ubuntu-latest
|
||||||
environment: publish
|
environment: publish
|
||||||
timeout-minutes: 240
|
timeout-minutes: 30
|
||||||
|
concurrency:
|
||||||
|
group: publish
|
||||||
|
cancel-in-progress: false
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
@@ -172,8 +177,8 @@ jobs:
|
|||||||
EOF_MATRIX
|
EOF_MATRIX
|
||||||
cat plan.txt
|
cat plan.txt
|
||||||
|
|
||||||
# Fetch each package's PR artifact into build-output/edge/<arch>/, or
|
# Fetch each package's artifact into build-output/edge/<arch>/: the PR's,
|
||||||
# build it when no artifact exists for exactly this tree. An artifact
|
# or the rebuild job's when the PR's had expired. An artifact
|
||||||
# carries its package files inside packages.tar (see build-pr.yml and
|
# carries its package files inside packages.tar (see build-pr.yml and
|
||||||
# helpers/artifact-helpers.sh: the upload action rejects the colon in
|
# helpers/artifact-helpers.sh: the upload action rejects the colon in
|
||||||
# an epoch filename).
|
# an epoch filename).
|
||||||
@@ -199,8 +204,8 @@ jobs:
|
|||||||
mkdir -p "build-output/edge/$arch"
|
mkdir -p "build-output/edge/$arch"
|
||||||
if [[ -n "$found" ]]; then
|
if [[ -n "$found" ]]; then
|
||||||
if [[ $from_run == "${{ github.run_id }}" ]]; then
|
if [[ $from_run == "${{ github.run_id }}" ]]; then
|
||||||
kind=native-rebuild
|
kind=rebuild
|
||||||
echo "==> $label: artifact from this run's native $arch rebuild"
|
echo "==> $label: artifact from this run's $arch rebuild"
|
||||||
else
|
else
|
||||||
kind=pr-artifact
|
kind=pr-artifact
|
||||||
echo "==> $label: reusing the build artifact from run $from_run"
|
echo "==> $label: reusing the build artifact from run $from_run"
|
||||||
@@ -225,20 +230,11 @@ jobs:
|
|||||||
jq -nc --arg p "$package" --arg a "$arch" '{package:$p, arch:$a, source:"already-published"}' >> sources.jsonl
|
jq -nc --arg p "$package" --arg a "$arch" '{package:$p, arch:$a, source:"already-published"}' >> sources.jsonl
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
# aarch64 never builds here: this droplet is x86 and would
|
# Nothing builds here. No artifact means the rebuild failed (see
|
||||||
# emulate it. No artifact means the native rebuild failed (see
|
# the rebuild job), or an artifact expired between planning and
|
||||||
# the rebuild job), or an artifact expired between planning
|
# now (re-run all jobs).
|
||||||
# and now (re-run all jobs).
|
echo "::error::$label: no artifact from the rebuild job"
|
||||||
if [[ $arch == aarch64 ]]; then
|
jq -nc --arg p "$package" --arg a "$arch" '{package:$p, arch:$a, source:"build-failed"}' >> sources.jsonl; failed=1; break
|
||||||
echo "::error::$label: no artifact from the native rebuild; not building aarch64 under emulation"
|
|
||||||
jq -nc --arg p "$package" --arg a "$arch" '{package:$p, arch:$a, source:"native-build-failed"}' >> sources.jsonl; failed=1; break
|
|
||||||
fi
|
|
||||||
echo "==> $label: no artifact for this tree, building"
|
|
||||||
if OMARCHY_KEEP_BUILD_WORKSPACE=1 bin/build --mirror edge --arch "$arch" --package "$package"; then
|
|
||||||
jq -nc --arg p "$package" --arg a "$arch" '{package:$p, arch:$a, source:"built"}' >> sources.jsonl
|
|
||||||
else
|
|
||||||
jq -nc --arg p "$package" --arg a "$arch" '{package:$p, arch:$a, source:"build-failed"}' >> sources.jsonl; failed=1; break
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
done < plan.txt
|
done < plan.txt
|
||||||
ls -1 build-output/edge/*/*.pkg.tar.zst 2>/dev/null || true
|
ls -1 build-output/edge/*/*.pkg.tar.zst 2>/dev/null || true
|
||||||
@@ -280,8 +276,8 @@ jobs:
|
|||||||
cat publish-record.json
|
cat publish-record.json
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
# A builder droplet starts with no images, so building this one here
|
# A fresh runner has no images, and building this one here cost
|
||||||
# cost every publish about 100 s (and 20 s more to start a container
|
# every publish about 100 s (and 20 s more to start a container
|
||||||
# from it) for the 14 s of signing and upload it is needed for.
|
# from it) for the 14 s of signing and upload it is needed for.
|
||||||
# builder-images.yml already publishes the tested image for exactly
|
# builder-images.yml already publishes the tested image for exactly
|
||||||
# these build inputs under their key; pull that. Build only when no
|
# these build inputs under their key; pull that. Build only when no
|
||||||
@@ -392,7 +388,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
jq -r --arg outcome "${{ needs.publish.result }}" '
|
jq -r --arg outcome "${{ needs.publish.result }}" '
|
||||||
def pkgs: [.packages[] | select(test("-debug-")|not)] | join(", ");
|
def pkgs: [.packages[] | select(test("-debug-")|not)] | join(", ");
|
||||||
def src: if .source=="pr-artifact" then "PR artifact" elif .source=="native-rebuild" then "rebuilt natively on arm64" elif .source=="built" then "built here" elif .source=="already-published" then "already published, skipped" else "**" + .source + "**" end;
|
def src: if .source=="pr-artifact" then "PR artifact" elif .source=="rebuild" or .source=="native-rebuild" then "rebuilt at merge" elif .source=="built" then "built here" elif .source=="already-published" then "already published, skipped" else "**" + .source + "**" end;
|
||||||
"### Publish " + (if $outcome=="success" then "succeeded" else "FAILED" end) +
|
"### Publish " + (if $outcome=="success" then "succeeded" else "FAILED" end) +
|
||||||
" → **" + .target + "**" + (if .target!="live" then " (proof prefix, not live)" else "" end),
|
" → **" + .target + "**" + (if .target!="live" then " (proof prefix, not live)" else "" end),
|
||||||
"",
|
"",
|
||||||
|
|||||||
+5
-2
@@ -80,8 +80,11 @@ changing them, on the box:
|
|||||||
packages then signatures then the db.
|
packages then signatures then the db.
|
||||||
- aarch64 under QEMU with credential-preserving binfmt. PR builds now run
|
- aarch64 under QEMU with credential-preserving binfmt. PR builds now run
|
||||||
aarch64 natively on `ubuntu-24.04-arm` (QEMU was up to ~15x slower). When a
|
aarch64 natively on `ubuntu-24.04-arm` (QEMU was up to ~15x slower). When a
|
||||||
merged aarch64 tree has no artifact, publish.yml rebuilds it there too, in its
|
merged tree has no artifact, publish.yml rebuilds it in its own job, aarch64
|
||||||
own job, and signs and uploads it on the droplet like a PR artifact.
|
there and x86_64 on a droplet, outside the publish lock.
|
||||||
|
- Publish itself builds nothing: it signs and uploads on `ubuntu-latest` in the
|
||||||
|
tested builder image pulled from GHCR, and only that job holds the `publish`
|
||||||
|
concurrency group, so a merge waits for seconds of signing, not for builds.
|
||||||
- Vouch gate: collaborators, `.github/VOUCHED.td`, or the `build-approved`
|
- Vouch gate: collaborators, `.github/VOUCHED.td`, or the `build-approved`
|
||||||
label; denounced authors cannot be overridden by the label.
|
label; denounced authors cannot be overridden by the label.
|
||||||
- Tests run on PRs only; `result`, `self-tests`, `build-isolation` are the
|
- Tests run on PRs only; `result`, `self-tests`, `build-isolation` are the
|
||||||
|
|||||||
Reference in new issue
Block a user