diff --git a/.github/workflows/build-pr.yml b/.github/workflows/build-pr.yml index a189ea1fba..4ddc6bc9ca 100644 --- a/.github/workflows/build-pr.yml +++ b/.github/workflows/build-pr.yml @@ -43,6 +43,7 @@ jobs: runs-on: ubuntu-latest outputs: matrix: ${{ steps.list.outputs.matrix }} + runners: ${{ steps.list.outputs.runners }} count: ${{ steps.gate.outputs.count }} trusted: ${{ steps.gate.outputs.trusted }} vouch_status: ${{ steps.vouch.outputs.status }} @@ -133,8 +134,18 @@ jobs: { echo "Reused existing build artifacts (${#reused[@]}):"; printf -- '- %s\n' "${reused[@]}"; } >> "$GITHUB_STEP_SUMMARY" fi fi - echo "matrix=$matrix" >> "$GITHUB_OUTPUT" - echo "planned=$(jq '.include | length' <<<"$matrix")" >> "$GITHUB_OUTPUT" + # The runner of each entry moves to a map: GitHub names a matrix job + # after its values, and a runner list in every check name helps + # nobody. A base whose build-matrix predates runners has none. + runners=$(jq -c 'reduce (.include[] | select((.runner // "") != "")) as $e ({}; .[$e.package][$e.arch] = ($e.runner | fromjson))' <<<"$matrix") + matrix=$(jq -c '{include: [.include[] | del(.runner)]}' <<<"$matrix") + { + echo "matrix=$matrix" + echo "runners=$runners" + # The DKMS check needs every package that would publish, reused ones too. + echo "reused=$(printf '%s\n' "${reused[@]:-}" | jq -Rsc 'split("\n") | map(select(length > 0))')" + echo "planned=$(jq '.include | length' <<<"$matrix")" + } >> "$GITHUB_OUTPUT" jq -r '.include[] | "\(.package) \(.arch) -> \(.channels)"' <<<"$matrix" # A PR whose diff against its base is empty changes nothing: its # content already landed some other way (a sync PR beat it, or a @@ -175,6 +186,28 @@ jobs: fi fi + # What this build ships, for the DKMS check (dkms-check.yml). It runs + # after this run completes, from master's copy, and reads this as data: + # whether the build ran, its matrix, and the artifacts it reused. + - name: Record what the DKMS check reads + if: github.event_name == 'pull_request' + env: + MATRIX: ${{ steps.list.outputs.matrix }} + REUSED: ${{ steps.list.outputs.reused }} + TRUSTED: ${{ steps.gate.outputs.trusted }} + run: | + jq -n --argjson matrix "$MATRIX" --argjson reused "$REUSED" --arg trusted "$TRUSTED" \ + '{trusted: ($trusted == "true"), matrix: $matrix, reused: $reused}' >dkms-request.json + cat dkms-request.json + - if: github.event_name == 'pull_request' + uses: actions/upload-artifact@v4 + with: + name: dkms-request + path: dkms-request.json + if-no-files-found: error + retention-days: 7 + overwrite: true + build: needs: changes if: needs.changes.outputs.count != '0' @@ -184,12 +217,14 @@ jobs: # build it natively in 11 minutes, and linux-aurora in 30 (72 under QEMU). # An aarch64 kernel goes to Namespace's Apple-backed runner instead: # linux-omarchy-n1x took 1h45m on the 4 vCPU, and takes 24 minutes there. bin/build-matrix names the runner for each - # entry (package_runner); a base branch whose build-matrix predates that - # gives none, and gets the runners named here. - # The name GitHub made from the matrix before it carried the runner, so - # the checks on a pull request keep the names they had. - name: build (${{ matrix.package }}, ${{ matrix.arch }}, ${{ matrix.channels }}, ${{ matrix.publish_arches }}) - runs-on: ${{ matrix.runner && fromJSON(matrix.runner) || (matrix.arch == 'aarch64' && 'ubuntu-24.04-arm' || fromJSON('["self-hosted","omarchy-builder"]')) }} + # entry (package_runner), and the changes job moves it to the runners + # map; a base branch whose build-matrix predates that gives none, and gets + # the runners named here. + # No name: GitHub's own, "build (package, arch, channels, publish_arches)", + # is the one these checks always had, and a build that is skipped shows as + # plain "build". A name with matrix expressions shows them unevaluated + # then, because GitHub evaluates nothing for a skipped job. + runs-on: ${{ fromJSON(needs.changes.outputs.runners || '{}')[matrix.package][matrix.arch] || (matrix.arch == 'aarch64' && 'ubuntu-24.04-arm' || fromJSON('["self-hosted","omarchy-builder"]')) }} timeout-minutes: 180 strategy: fail-fast: false diff --git a/.github/workflows/dkms-check.yml b/.github/workflows/dkms-check.yml new file mode 100644 index 0000000000..d7c53a1219 --- /dev/null +++ b/.github/workflows/dkms-check.yml @@ -0,0 +1,254 @@ +name: DKMS check + +# Builds DKMS modules against kernel headers (dkms-matrix.yml, bin/check-dkms): +# - after every pull request build ("Build changed packages"), on what the +# build would publish: a new kernel against every module, Arch's included, +# and a new module against every kernel. The pull request gets one check, +# "DKMS check: 41/45 passed", whose summary has a row per kernel and module +# and links this run, where every leg is its own job; +# - daily, on every pair on edge that has not built at its versions yet: +# Arch updates the DKMS packages Omarchy installs from it (ci/dkms-packages +# repo=) on its own schedule; +# - by hand, for a whole channel, or for a channel as a promotion into it +# would leave it (rc with what differs on edge moved in): +# gh workflow run dkms-check.yml -R omacom/omarchy-pkgs -f channel=stable +# gh workflow run dkms-check.yml -R omacom/omarchy-pkgs -f channel=rc -f from=edge +# - by hand for a pull request, the same path as after its build: +# gh workflow run dkms-check.yml -R omacom/omarchy-pkgs -f pr=1250 +# From a branch (--ref) it runs that branch's check, which is how a change +# to the check itself is proven before it merges; -f all=true checks every +# pair on the channel with the pull request's packages in place. +# promote.yml calls dkms-matrix.yml itself, on what a promotion moves. +# +# Why workflow_run and not the pull request's own workflow: every job of a +# pull_request run is a check on the pull request, about 45 of them for a +# kernel. This run's jobs attach to master's head instead, and the pull +# request gets the one check this workflow writes through the Checks API. +# +# workflow_run runs master's copy of this file with a token that could write, +# so: the only jobs that may write (checks: write, to open and close that one +# check) never check out the repository or download anything; they pass the +# event's commit and run ids and the matrix's verdict to the API through jq, +# as data. The matrix itself is read-only and runs master's tooling; the pull +# request's build is data until a leg unpacks it in a throwaway container. + +on: + workflow_run: + workflows: [Build changed packages] + types: [completed] + schedule: + - cron: "37 5 * * *" + workflow_dispatch: + inputs: + channel: + description: Channel whose kernels and DKMS packages to check + type: choice + options: [edge, rc, stable] + default: edge + from: + description: "A promotion's source: check the channel with what differs there moved in (empty: everything)" + required: false + default: "" + from_repos: + description: Only the DKMS packages from Arch's repositories, against every kernel + type: boolean + default: false + pr: + description: "A pull request: check what its newest green build ships, and report it there as one check" + required: false + default: "" + head_sha: + description: "The commit whose build to check (default: the pull request's head)" + required: false + default: "" + all: + description: With pr or head_sha, every pair on the channel, the build's packages in place + type: boolean + default: false + +permissions: {} + +# One run per commit at a time: a newer build of the same commit (a label +# starts one) replaces the check in flight, which closes as cancelled. Keyed by +# commit, so a build of an older commit that finishes late, or one this +# workflow ignores (failed, cancelled, not a pull request's), never cancels the +# check of the pull request's head. +concurrency: + group: >- + dkms-check-${{ github.event_name == 'workflow_run' + && (github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' + && github.event.workflow_run.path == '.github/workflows/build-pr.yml' + && format('sha-{0}', github.event.workflow_run.head_sha) || format('ignored-{0}', github.run_id)) + || ((inputs.pr || inputs.head_sha) && format('pr-{0}-{1}', inputs.pr, inputs.head_sha)) + || format('{0}-{1}-{2}-{3}', github.event_name, github.ref, inputs.channel || 'edge', inputs.from || 'all') }} + cancel-in-progress: ${{ (github.event_name == 'workflow_run' || inputs.pr || inputs.head_sha) && true || false }} + +jobs: + # A pull request's build: which commit and run, and the check, opened as in + # progress so the pull request shows it while the legs build. A commit the + # pull request has moved past gets no check. The build's dkms-request is + # only as good as the build-pr.yml that wrote it, and a fork's pull request + # runs its own copy: a build from a fork whose build-pr.yml differs from + # its merge base's is refused, never checked. Any API error fails this job + # before a check opens, so it fails closed. + target: + name: open the check + if: >- + (github.event_name == 'workflow_run' && github.event.workflow_run.event == 'pull_request' + && github.event.workflow_run.conclusion == 'success' + && github.event.workflow_run.path == '.github/workflows/build-pr.yml') + || (github.event_name == 'workflow_dispatch' && (inputs.pr != '' || inputs.head_sha != '')) + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + checks: write + actions: read + pull-requests: read + outputs: + head_sha: ${{ steps.open.outputs.head_sha }} + build_run: ${{ steps.open.outputs.build_run }} + check_run: ${{ steps.open.outputs.check_run }} + refused: ${{ steps.open.outputs.refused }} + steps: + # No checkout: nothing from the repository, let alone a pull request, + # runs next to this token. + - name: Find the build and open its check + id: open + env: + GH_TOKEN: ${{ github.token }} + EVENT_SHA: ${{ github.event.workflow_run.head_sha }} + EVENT_RUN: ${{ github.event.workflow_run.id }} + HEAD_OWNER: ${{ github.event.workflow_run.head_repository.owner.login }} + HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} + PR: ${{ inputs.pr }} + SHA: ${{ inputs.head_sha }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + pr=$PR refused="" + if [[ -n $EVENT_RUN ]]; then + sha=$EVENT_SHA run=$EVENT_RUN + # The event names no pull request for a fork's; find it by its + # head. One that has moved on to a newer commit gets no check. + pr=$(gh api -X GET "repos/$GITHUB_REPOSITORY/pulls" -f state=open -f head="$HEAD_OWNER:$HEAD_BRANCH" | + jq -r --arg sha "$sha" '[.[] | select(.head.sha == $sha)][0].number // empty') + if [[ -z $pr ]]; then + echo "no open pull request has $sha as its head; nothing to check" + exit 0 + fi + else + sha=$SHA + if [[ -n $PR ]]; then + if [[ ! $PR =~ ^[0-9]+$ ]]; then + echo "::error::pr must be a pull request number" + exit 1 + fi + [[ -n $sha ]] || sha=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR" --jq .head.sha) + fi + if [[ ! $sha =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::head_sha must be a full commit hash" + exit 1 + fi + # The newest green pull request build of that commit. + run=$(gh api "repos/$GITHUB_REPOSITORY/actions/workflows/build-pr.yml/runs?head_sha=$sha&event=pull_request&status=success&per_page=1" \ + --jq '.workflow_runs[0].id // empty') + if [[ -z $run ]]; then + echo "::error::$sha has no green pull request build to check" + exit 1 + fi + fi + # A fork's pull request builds with its own build-pr.yml, which + # writes the dkms-request this check reads. Whether this build's + # copy is the base's comes from the build run itself, in every mode. + build=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$run") + if [[ $(jq -r '.head_sha' <<<"$build") != "$sha" || $(jq -r '.path' <<<"$build") != .github/workflows/build-pr.yml ]]; then + echo "::error::run $run is not the pull request build of $sha" + exit 1 + fi + # build-pr.yml's blob at a commit, "missing" where it has none (a + # fork that branched before it existed); any other error stops here. + blob() { + local out + if out=$(gh api "repos/$GITHUB_REPOSITORY/contents/.github/workflows/build-pr.yml?ref=$1" --jq .sha 2>&1); then + echo "$out" + elif [[ $out == *"(HTTP 404)"* ]]; then + echo missing + else + echo "::error::cannot read build-pr.yml at $1" >&2 + return 1 + fi + } + if [[ $(jq -r '.head_repository.full_name // ""' <<<"$build") != "$GITHUB_REPOSITORY" ]]; then + base=$(gh api "repos/$GITHUB_REPOSITORY/compare/${DEFAULT_BRANCH}...$sha" --jq .merge_base_commit.sha) + ours=$(blob "$base") + theirs=$(blob "$sha") + if [[ ! $ours =~ ^([0-9a-f]{40}|missing)$ || $theirs != "$ours" ]]; then + refused="Not checked: this build comes from a fork whose .github/workflows/build-pr.yml differs from its merge base's, and that file writes what this check reads. Land the workflow change in a pull request of its own first." + fi + fi + check=$(jq -n --arg sha "$sha" --arg url "$URL" '{ + name: "DKMS check", head_sha: $sha, status: "in_progress", details_url: $url, + output: {title: "Building DKMS modules against kernel headers", summary: "[Open the run, with every leg](\($url))"} + }' | gh api -X POST "repos/$GITHUB_REPOSITORY/check-runs" --input - --jq .id) + echo "check run $check on $sha, for build run $run${pr:+ of pull request #$pr}" + { + echo "head_sha=$sha" + echo "build_run=$run" + echo "check_run=$check" + echo "refused=$refused" + } >>"$GITHUB_OUTPUT" + + dkms: + name: DKMS + needs: target + # After a build only for a pull request's open check; daily and by hand + # always. + if: >- + !cancelled() && ((needs.target.result == 'success' && needs.target.outputs.check_run != '' && needs.target.outputs.refused == '') + || (github.event_name != 'workflow_run' && needs.target.result == 'skipped')) + permissions: + contents: read + actions: read + uses: ./.github/workflows/dkms-matrix.yml + with: + channel: ${{ inputs.channel || 'edge' }} + from: ${{ inputs.from || '' }} + from_repos: ${{ inputs.from_repos == true }} + all: ${{ inputs.all == true }} + build_run: ${{ needs.target.outputs.build_run || '' }} + head_sha: ${{ needs.target.outputs.head_sha || '' }} + + # Closes the check with the matrix's verdict. A matrix that stopped before + # its summary fails the check (or cancels it), never passes it. + report: + name: close the check + needs: [target, dkms] + if: always() && needs.target.result == 'success' && needs.target.outputs.check_run != '' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + checks: write + steps: + - name: Close the check + env: + GH_TOKEN: ${{ github.token }} + CHECK: ${{ needs.target.outputs.check_run }} + REFUSED: ${{ needs.target.outputs.refused }} + RESULT: ${{ needs.dkms.result }} + CONCLUSION: ${{ needs.dkms.outputs.conclusion }} + TITLE: ${{ needs.dkms.outputs.title }} + SUMMARY: ${{ needs.dkms.outputs.summary }} + URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + if [[ -n $REFUSED ]]; then + CONCLUSION=failure TITLE="Not checked" SUMMARY=$REFUSED + elif [[ $RESULT == cancelled ]]; then + CONCLUSION=cancelled TITLE="Cancelled" SUMMARY="A newer build's check replaced this one. [Open the run]($URL)" + elif [[ $CONCLUSION != success && $CONCLUSION != failure ]]; then + CONCLUSION=failure TITLE="The DKMS check did not finish ($RESULT)" SUMMARY="[Open the run]($URL)" + fi + jq -n --arg c "$CONCLUSION" --arg t "$TITLE" --arg s "$SUMMARY" --arg url "$URL" \ + '{status: "completed", conclusion: $c, details_url: $url, output: {title: $t, summary: $s}}' | + gh api -X PATCH "repos/$GITHUB_REPOSITORY/check-runs/$CHECK" --input - --jq '"\(.conclusion): \(.output.title) \(.html_url)"' diff --git a/.github/workflows/dkms-matrix.yml b/.github/workflows/dkms-matrix.yml new file mode 100644 index 0000000000..2fab9f52cb --- /dev/null +++ b/.github/workflows/dkms-matrix.yml @@ -0,0 +1,312 @@ +name: DKMS matrix + +# Builds DKMS modules against kernel headers on a channel, the way a machine +# does on an update (bin/check-dkms). A kernel that renames or drops something +# an out-of-tree module uses turns this red before users reboot into it: +# linux-omarchy 7.2.8-9 renamed battery_hook_register and the TUXEDO modules +# stopped building on 4.0.5. +# +# The work of the check, for its callers: dkms-check.yml (a pull request's +# build, daily, by hand) and promote.yml (what a promotion moves). +# +# Fanned out: `plan` reads the channel on the host in seconds +# (bin/check-dkms matrix), and every kernel and DKMS package pair gets its own +# leg, which installs that kernel's headers and builds that package's trees in +# a throwaway container. A leg is named for what it builds, so a red one says +# which pair broke: "build (x86_64, linux-omarchy 7.2.8-13, nvidia-open-dkms +# 615.78.08-3)". `summary` turns the legs into one verdict (bin/check-dkms +# report), which dkms-check.yml posts on a pull request as its one check. +# +# Read-only, all of it: no job here holds more than contents: read and +# actions: read, or any secret. A pull request's build is data here: its +# request is checked against a strict shape (bin/build-artifacts), and its +# packages are only unpacked inside a leg's throwaway container, where their +# Makefiles run. + +on: + workflow_call: + inputs: + channel: + type: string + required: true + from: + description: A promotion's source; the channel is checked as it will be once what differs here moves into it (empty means everything) + type: string + default: "" + packages: + description: Only these packages or pkgbases (space-separated); empty means all + type: string + default: "" + from_repos: + description: Only the DKMS packages from Arch's repositories, against every kernel + type: boolean + default: false + all: + description: Every pair on the channel, with the build's packages in place of the published ones + type: boolean + default: false + build_run: + description: A pull request's "Build changed packages" run; its packages are what is checked + type: string + default: "" + head_sha: + description: The commit that build was made from + type: string + default: "" + outputs: + conclusion: + description: success or failure + value: ${{ jobs.summary.outputs.conclusion }} + legs: + description: The kernel and module pairs the check built, as JSON; a promotion compares them under its lock + value: ${{ jobs.plan.outputs.legs }} + title: + description: '"N/M passed, K known broken, F failed", or why nothing was checked' + value: ${{ jobs.summary.outputs.title }} + summary: + description: Markdown, a row per leg + value: ${{ jobs.summary.outputs.summary }} + +# bin/build-artifacts reads a pull request build's artifacts; the daily plan +# looks up which legs already built. +permissions: + contents: read + actions: read + +env: + CHANNEL: ${{ inputs.channel }} + FROM_CHANNEL: ${{ inputs.from }} + PACKAGES: ${{ inputs.packages }} + FROM_REPOS: ${{ inputs.from_repos }} + ALL: ${{ inputs.all }} + +jobs: + plan: + runs-on: ubuntu-latest + timeout-minutes: 30 + outputs: + legs: ${{ steps.plan.outputs.legs }} + note: ${{ steps.plan.outputs.note }} + local: ${{ steps.plan.outputs.local }} + steps: + # The caller's commit: master's for a pull request (workflow_run), so a + # pull request cannot change the check that judges it. + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Plan the legs + id: plan + env: + GH_TOKEN: ${{ github.token }} + BUILD_RUN: ${{ inputs.build_run }} + HEAD_SHA: ${{ inputs.head_sha }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + TOOLING: ${{ hashFiles('bin/check-dkms', 'ci/dkms-packages') }} + run: | + set -euo pipefail + sudo apt-get update -qq && sudo apt-get install -y -qq --no-install-recommends libarchive-tools >/dev/null + args=(--channel "$CHANNEL") + [[ -z $FROM_CHANNEL ]] || args+=(--from "$FROM_CHANNEL") + [[ -z $PACKAGES ]] || args+=(--packages "$PACKAGES") + [[ $FROM_REPOS != true ]] || args+=(--from-repos) + [[ $ALL != true ]] || args+=(--all) + note="" legs="" + # A pull request's build: exactly what it would publish, that run's + # artifacts and the ones its plan reused. The legs get only the + # kernels and DKMS packages among them; a build that ships nothing + # has none (with all, every pair on the channel still builds). + if [[ -n $BUILD_RUN ]]; then + if [[ ! $HEAD_SHA =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::head_sha must be a full commit hash" + exit 1 + fi + git fetch --no-tags --depth=1 origin "$HEAD_SHA" + bin/build-artifacts built "$BUILD_RUN" "$HEAD_SHA" + if compgen -G 'built/*.pkg.tar.*' >/dev/null; then + args+=(--local built --save-local dkms-local) + [[ $ALL != true ]] || note="Every pair on the channel, with this build's packages in place" + elif [[ $ALL == true ]]; then + note="Every pair on the channel; this build ships no packages" + else + note="This build ships no packages" + legs='[]' + fi + fi + # Each architecture on its own, so a failure in either stops here. + if [[ -z $legs ]]; then + x86_64=$(bin/check-dkms matrix --arch x86_64 "${args[@]}") + aarch64=$(bin/check-dkms matrix --arch aarch64 "${args[@]}") + legs=$(jq -cn --argjson a "$x86_64" --argjson b "$aarch64" '$a + $b') + fi + # Daily: a leg that built at these versions before is not built + # again; each green leg saves its key (below, the same formula). An + # unused cache entry expires after 7 days, so every pair is still + # rebuilt weekly, on whatever toolchain edge has by then. Only this + # ref's entries count: a pull request's run can save any key under + # its own ref, which must not skip a daily leg. + if [[ $GITHUB_EVENT_NAME == schedule ]]; then + keep='[]' + while IFS= read -r leg; do + key=$(jq -r --arg c "$CHANNEL" --arg t "${TOOLING:0:16}" \ + '"dkms-\($c)-\(.arch)-\(.kernel | gsub(" "; "_"))-\(.module | gsub(" "; "_"))-\($t)"' <<<"$leg") + hits=$(gh api -X GET "repos/$GITHUB_REPOSITORY/actions/caches" -f key="$key" -f ref="$GITHUB_REF" | + jq --arg k "$key" --arg r "$GITHUB_REF" '[.actions_caches[] | select(.key == $k and .ref == $r)] | length') + if ((hits)); then + echo "built before: $key" + else + keep=$(jq -c --argjson l "$leg" '. + [$l]' <<<"$keep") + fi + done < <(jq -c '.[]' <<<"$legs") + legs=$keep + fi + if [[ $legs == '[]' && -z $note && -n $BUILD_RUN ]]; then + note="This build ships no kernel or DKMS package" + fi + shipped=false + if compgen -G 'dkms-local/*' >/dev/null; then + shipped=true + fi + { + echo "legs=$legs" + echo "note=$note" + echo "local=$shipped" + } >>"$GITHUB_OUTPUT" + echo "$(jq length <<<"$legs") leg(s):" + jq -r '.[] | " \(.arch) \(.kernel) \(.module)"' <<<"$legs" + - name: Keep the packages the legs install + if: steps.plan.outputs.local == 'true' && steps.plan.outputs.legs != '[]' + uses: actions/upload-artifact@v4 + with: + name: dkms-check-local + path: dkms-local + if-no-files-found: error + retention-days: 1 + overwrite: true + + # One leg per kernel and DKMS package pair. A static name: GitHub appends + # the leg's values, and a skipped job shows its name unevaluated. + check: + name: build + needs: plan + if: needs.plan.outputs.legs != '[]' && needs.plan.outputs.legs != '' + permissions: + contents: read + strategy: + fail-fast: false + # A whole channel is about 45 legs; leave hosted runners for the + # package builds, which share them. + max-parallel: 12 + matrix: + include: ${{ fromJSON(needs.plan.outputs.legs) }} + # GitHub-hosted, with no secrets: the check only reads published packages, + # and it installs them as root in a throwaway container. + runs-on: ${{ matrix.arch == 'aarch64' && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }} + timeout-minutes: 90 + env: + ARCH: ${{ matrix.arch }} + KERNEL: ${{ matrix.kernel }} + MODULE: ${{ matrix.module }} + RESULT: dkms-result-${{ strategy.job-index }}.json + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Fetch the build's kernels and DKMS packages + if: needs.plan.outputs.local == 'true' + uses: actions/download-artifact@v4 + with: + name: dkms-check-local + path: built + # The outcome (passed, known broken, failed, ...) is decided on the + # host, from run's exit status, and written to $RESULT for `summary`. + - name: Build ${{ matrix.module }} against ${{ matrix.kernel }} + run: | + args=(--arch "$ARCH" --channel "$CHANNEL" --kernel "$KERNEL" --module "$MODULE" --result "$RESULT") + [[ -z $FROM_CHANNEL ]] || args+=(--from "$FROM_CHANNEL") + [[ -z $PACKAGES ]] || args+=(--packages "$PACKAGES") + [[ $FROM_REPOS != true ]] || args+=(--from-repos) + [[ $ALL != true ]] || args+=(--all) + [[ ! -d built ]] || args+=(--local built) + bin/check-dkms container "${args[@]}" + - name: Keep this leg's result + if: always() + uses: actions/upload-artifact@v4 + with: + name: dkms-result-${{ strategy.job-index }} + path: ${{ env.RESULT }} + if-no-files-found: ignore + retention-days: 7 + overwrite: true + # The daily plan skips a leg whose key exists (the same formula as there). + - name: Remember this leg as built + id: seen + if: github.event_name == 'schedule' + env: + TOOLING: ${{ hashFiles('bin/check-dkms', 'ci/dkms-packages') }} + run: | + key="dkms-$CHANNEL-$ARCH-${KERNEL// /_}-${MODULE// /_}-${TOOLING:0:16}" + mkdir -p .dkms-seen + echo "$key" > .dkms-seen/key + echo "key=$key" >> "$GITHUB_OUTPUT" + - if: github.event_name == 'schedule' + uses: actions/cache/save@v4 + with: + path: .dkms-seen + key: ${{ steps.seen.outputs.key }} + + # One verdict for the run: N/M passed, K known broken, F failed, with a + # row per leg. A leg that left no result counts as failed, and so does a + # plan that did not finish. + summary: + needs: [plan, check] + if: always() + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + outputs: + conclusion: ${{ steps.verdict.outputs.conclusion }} + title: ${{ steps.verdict.outputs.title }} + summary: ${{ steps.verdict.outputs.summary }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + # Results are data for jq; nothing here runs them. + - name: Fetch the legs' results + if: needs.plan.outputs.legs != '[]' && needs.plan.outputs.legs != '' + continue-on-error: true + uses: actions/download-artifact@v4 + with: + pattern: dkms-result-* + path: results + merge-multiple: true + - name: Decide + id: verdict + env: + LEGS: ${{ needs.plan.outputs.legs }} + NOTE: ${{ needs.plan.outputs.note }} + PLAN: ${{ needs.plan.result }} + URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + mkdir -p results + printf '%s\n' "${LEGS:-[]}" >legs.json + verdict=$(bin/check-dkms report --legs legs.json --results results --plan "$PLAN" --url "$URL" --note "$NOTE" --channel "$CHANNEL") + jq -r .summary <<<"$verdict" >>"$GITHUB_STEP_SUMMARY" + eof=$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n') + { + echo "conclusion=$(jq -r .conclusion <<<"$verdict")" + echo "title=$(jq -r .title <<<"$verdict")" + echo "summary<<$eof" + jq -r .summary <<<"$verdict" + echo "$eof" + } >>"$GITHUB_OUTPUT" + jq -r .title <<<"$verdict" + - name: Fail when a leg failed + if: steps.verdict.outputs.conclusion != 'success' + env: + TITLE: ${{ steps.verdict.outputs.title }} + run: | + echo "::error::DKMS check: $TITLE" + exit 1 diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml index b25dd1a2fb..7f00a56534 100644 --- a/.github/workflows/promote.yml +++ b/.github/workflows/promote.yml @@ -43,7 +43,24 @@ on: default: false jobs: + # Every kernel and DKMS module that moves is built against the other half + # first (bin/check-dkms): a kernel that breaks an out-of-tree module, or a + # module that no longer builds, stops here instead of reaching the channel. + # The check runs on the target as the promotion leaves it: a kernel that + # moves alone is built against the target's modules, not the source's. + dkms: + # dkms-matrix.yml reads Actions data in its other modes, so it asks for it. + permissions: + contents: read + actions: read + uses: ./.github/workflows/dkms-matrix.yml + with: + channel: ${{ startsWith(github.event.inputs.direction, 'edge') && 'rc' || 'stable' }} + from: ${{ startsWith(github.event.inputs.direction, 'edge') && 'edge' || 'rc' }} + packages: ${{ github.event.inputs.packages }} + promote: + needs: dkms runs-on: ubuntu-latest environment: publish # A first promotion into a sparse channel moves gigabytes. @@ -76,6 +93,37 @@ jobs: [[ $p =~ ^[a-z0-9@._+-]+$ ]] || { echo "::error::Not a package name: $p"; exit 1; } done + # The DKMS check ran before this job took the publish lock. A publish in + # between can move the source's kernel or the target's modules, and + # promote-channel reads the channels again. So plan the same legs now, + # under the lock, and stop if they are not the pairs that were built. + - name: Check that the channels still hold what the DKMS check built + env: + FROM: ${{ steps.inputs.outputs.from }} + TO: ${{ steps.inputs.outputs.to }} + PACKAGES: ${{ github.event.inputs.packages }} + CHECKED: ${{ needs.dkms.outputs.legs }} + run: | + set -euo pipefail + sudo apt-get update -qq && sudo apt-get install -y -qq --no-install-recommends libarchive-tools >/dev/null + args=(--channel "$TO" --from "$FROM") + if [[ -n $PACKAGES ]]; then + args+=(--packages "$PACKAGES") + fi + x86_64=$(bin/check-dkms matrix --arch x86_64 "${args[@]}") + aarch64=$(bin/check-dkms matrix --arch aarch64 "${args[@]}") + now=$(jq -cn --argjson a "$x86_64" --argjson b "$aarch64" '$a + $b | sort') + built=$(jq -c 'sort' <<<"${CHECKED:-[]}") + if [[ $now != "$built" ]]; then + echo "Built by the DKMS check:" + jq -r '.[] | " \(.arch) \(.kernel) \(.module)"' <<<"$built" + echo "In the channels now:" + jq -r '.[] | " \(.arch) \(.kernel) \(.module)"' <<<"$now" + echo "::error title=DKMS check::$FROM or $TO changed after the DKMS check; run the promotion again" + exit 1 + fi + echo "$TO and $FROM still hold the $(jq length <<<"$now") pair(s) the DKMS check built" + - name: Promote id: promote env: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index acd90718dc..ea6244f1fb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -90,6 +90,9 @@ jobs: - name: Test upstream sync reporting run: node --test tests/sync-upstream-summary.cjs + - name: Test the DKMS check's workflows + run: node --test tests/dkms-check-workflow.cjs + - name: Test builder images run: node --test tests/builder-image.cjs @@ -134,6 +137,8 @@ jobs: python tests/workflow-helpers-sourced.py ./tests/build-matrix.sh ./tests/mac-installer-dispatch.sh + ./tests/check-dkms.sh + ./tests/build-artifacts.sh ./tests/limine-mkinitcpio-hook.sh ./tests/voxtype-bin-install.sh ./tests/1password-polkit-owners.sh diff --git a/bin/build-artifacts b/bin/build-artifacts new file mode 100755 index 0000000000..75341b88a4 --- /dev/null +++ b/bin/build-artifacts @@ -0,0 +1,167 @@ +#!/bin/bash +# The packages a pull request build ships, into : the artifacts that +# run built, and the ones its plan reused from an earlier run (build-pr.yml +# skips a package whose exact tree already has an artifact). A matrix entry +# with no artifact in the run built nothing because edge already holds its +# version, so nothing new ships for it. A reused artifact that cannot be +# found, or any download or extraction error, fails: the DKMS check must see +# everything that would publish. +# +# The build records what it planned in its dkms-request artifact (\A and \z, +# not ^ and $, which in jq would let a trailing newline through): +# {"trusted": bool, "matrix": {"include": [{"package", "arch"}, ...]}, +# "reused": ["--", ...]} +# A pull request's run wrote it, so it is data: checked against a strict +# shape before any of it names a path or a URL. An untrusted build built +# nothing, so it ships nothing. +# +# A reused artifact was made by another run, and any run can upload an +# artifact under any name: a fork's pull request with its own workflow could +# upload one named for another pull request's package tree. So a reused +# artifact counts only when its run is this repository's build-pr.yml, and a +# fork's run only when its build-pr.yml is its merge base's: the rule +# dkms-check.yml applies to the build it checks. The newest such artifact +# wins; none fails. +# +# Usage: bin/build-artifacts +# Needs gh (GH_TOKEN), bsdtar and GITHUB_REPOSITORY, in a checkout that has +# . DEFAULT_BRANCH names the branch forks are compared with (by +# default the repository's). +set -euo pipefail + +die() { echo "build-artifacts: $*" >&2; exit 2; } +(($# == 3)) || die "usage: bin/build-artifacts " +dest=$1 run=$2 head=$3 +repo=${GITHUB_REPOSITORY:?build-artifacts needs GITHUB_REPOSITORY} +[[ $run =~ ^[0-9]+$ ]] || die "run id $run is not a number" +mkdir -p "$dest" +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +# Each artifact unpacks into a fresh directory and must yield packages; every +# file is then copied with its own error check. +fetch() { # fetch