Files
cozystack/.github/workflows/pull-requests.yaml
T
Myasnikov Daniil cc942fed68 test(tests): run unit suites with Bats and retain POSIX coverage
The shell translator rewrites test bodies and loses Bats reporting.
The unit lane needs complete TAP results and JUnit reports without
changing how live-cluster suites run.

Sourced production helpers still need a dash pass, and unit test bodies
need nounset without exporting shell options into subprocesses.

Assisted-by: LLM
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-10-07 11:04:00 +03:00

1471 lines
77 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
name: Pull Request
env:
# TODO: unhardcode this
REGISTRY: iad.ocir.io/idyksih5sir9/cozystack
on:
pull_request:
# `labeled` makes the promote PR's release-candidate verification and manual
# full-e2e opt-in actionable after the bot opens it. The plan/resolve guards
# below discard every other label event before it can launch work.
types: [opened, synchronize, reopened, labeled]
# Cancel in‑flight runs for the same PR when a new push arrives.
#
# A `labeled` event that `plan` discards gets its OWN group, so labelling an
# open PR cannot cancel a live opened/synchronize run. Any label other than
# full-e2e/release makes `plan` skip, and `e2e-report` skips with it, so without the
# split the pair "cancel the real run, then skip" left the head SHA with no
# "E2E Tests" status at all — and an unposted required status stays "Expected",
# blocking the merge until someone pushes again or re-runs the cancelled run.
# Observed on live CI: run 30614258694 (cancelled) followed by 30614280603
# (skipped) on the same SHA, 24s apart, from a maintainer adding kind/* labels
# just after a push. (`pr-labeler` cannot cause this — it labels with the
# default GITHUB_TOKEN, which by design triggers no further workflow runs.)
#
# The condition is the exact complement of `plan`'s guard, so only a run that
# will publish NOTHING is moved aside. A `labeled` run carrying full-e2e or
# release does publish, so it stays in the main group and supersedes the head
# run the way a push does. That keeps exactly one publisher of the `E2E Tests`
# context alive per head SHA, which the status depends on: two live publishers are latest-wins
# per context, and the full suite is the superset, so a full-e2e run that fails
# early — a build flake, a suite failing at minute twenty — would finish before
# the narrower head run it was meant to widen, and that run's later green would
# erase the failure. Cancelling also means opting into the full suite costs one
# suite rather than two, and a second label landing afterwards cannot cancel the
# full-e2e run, because it is no longer in the same group as it.
#
# `release` publishes on the SAME-REPO lane only, hence the fork term. A promote
# PR is opened same-repo by the release bot, so the label on a fork PR is always
# a mislabel — and on that lane it would be a destructive one. e2e-fork.yaml's
# `resolve` decides "this run built nothing, so it is not a verdict" by asking
# whether `Plan build` skipped; a fork run that gets past `plan` then fails
# closed on the pr-patch artifact its own `release` label gated off, posting red
# on a suite that was never going to run, with no `unlabeled` trigger to undo
# it. Excluding forks keeps the label as inert on that lane as it was before
# promotion started using it. The term appears in both this key and `plan`'s
# guard because the two must stay exact complements: NOT(A OR B OR (C AND NOT
# F)) is (NOT A) AND (NOT B) AND (NOT C OR F), and for a same-repo PR F is false
# and both sides reduce to exactly what they were.
concurrency:
group: pr-${{ github.workflow }}-${{ github.event.pull_request.number }}${{ (github.event.action == 'labeled' && github.event.label.name != 'full-e2e' && (github.event.label.name != 'release' || github.event.pull_request.head.repo.fork)) && '-label' || '' }}
cancel-in-progress: true
# Top-level token defaults to read-only. A job-level block replaces this one
# rather than adding to it, so each job restates every scope it needs.
permissions:
contents: read
jobs:
# Compute the docs-only gate and the per-package build matrix from the PR
# diff. hack/build-matrix.sh parses the root Makefile `build:` target (the
# single source of truth) and emits only the units whose package dir changed,
# escalating to the full set on a shared-dependency change. talos + installer
# are handled by dedicated jobs (see build-matrix.sh for why).
plan:
name: Plan build
runs-on: ubuntu-latest
# Exact complement of the concurrency key above; see the reasoning there,
# including why `release` is admitted for same-repo PRs only.
if: github.event.action != 'labeled' || github.event.label.name == 'full-e2e' || (github.event.label.name == 'release' && !github.event.pull_request.head.repo.fork)
permissions:
contents: read
# For the opening `E2E Tests` status below; e2e-report writes the terminal
# one with the same scope.
statuses: write
outputs:
code: ${{ steps.p.outputs.code }}
matrix: ${{ steps.p.outputs.matrix }}
any: ${{ steps.p.outputs.any }}
touched: ${{ steps.p.outputs.touched }}
talos: ${{ steps.p.outputs.talos }}
steps:
# Claim the required context for THIS run before anything can be merged on
# the strength of the last one. Commit statuses are latest-wins per (sha,
# context) and nothing expires them, so a head SHA that already went green
# keeps reading green while a newly started run works — re-run all jobs, a
# full-e2e label, a reopen — and the PR stays mergeable through a suite
# that may be about to fail. While the gate was a job, GitHub created a
# fresh non-success check run each time and that window did not exist.
# Overwriting with `pending` here restores it: the run that owns the
# status says so at the start and concludes it in e2e-report.
#
# Same-repo only. A fork's `pull_request` token is read-only, so this call
# would 403 and fail `plan` on every fork PR, and that read-only token is
# exactly what makes the whole commit-status design safe (§10). Fork PRs
# get their pending from e2e-fork.yaml's `resolve` instead, which is
# privileged.
#
# Note what that costs a fork. `resolve` cannot start until this entire
# run has finished, so the window this step shuts in seconds stays open
# for the length of the build on the fork lane. Structural rather than an
# oversight, since a read-only token cannot post a status at any point in
# the run, and bounded by the build. §10 records it.
- name: Open the "E2E Tests" status for this run
if: ${{ !github.event.pull_request.head.repo.fork }}
# A transient status-API failure here must not take the run with it.
# This step is the first in `plan`, and `plan` is what `checks`,
# `build`, `build-talos`, `finalize` and `e2e` all hang off, so a
# failure costs a full rebuild for nothing. Skipping the opener only
# degrades to the behaviour that preceded it — the terminal status
# still gets posted by `e2e-report`, and a run that dies before that
# leaves the merge blocked either way.
continue-on-error: true
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
env:
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
with:
script: |
const { HEAD_SHA, RUN_URL } = process.env;
await github.rest.repos.createCommitStatus({
owner: context.repo.owner,
repo: context.repo.repo,
sha: HEAD_SHA,
state: 'pending',
context: 'E2E Tests',
description: 'e2e in progress — this run has not reported yet',
target_url: RUN_URL,
});
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0
- name: Compute changed files and build matrix
id: p
env:
BASE_REF: ${{ github.base_ref }}
run: |
git diff --name-only "origin/${BASE_REF}...HEAD" > /tmp/changed.txt
# The docs-only decision reads a RENAME-BLIND list. `git diff
# --name-only` applies rename detection and reports a rename as its
# new path alone, so moving a code file into docs/ reads as a
# docs-only change: nothing builds, no e2e runs, and the removal of
# that code path ships untested. It also deadlocks the fork lane,
# which answers the same question from `pulls.listFiles` and does see
# the old path through `previous_filename`. That lane concludes e2e is
# required, then fails closed on a pr-patch artifact this lane never
# produced — an unfixable red on a legitimate contribution, with no
# label override and no push that clears it. Both lanes have to answer
# "is this docs-only" the same way, and the only answer that is also
# correct keeps both sides of a rename.
#
# The build matrix still reads the rename-detected list, which is not
# a claim that doing so is right. Moving a file between packages
# leaves the source package changed and unselected, so the matrix
# under-builds on a cross-package rename. That behaviour predates this
# line and is not what the docs-only decision is for, so it is left
# alone here rather than widened by a side effect.
git diff --name-only --no-renames "origin/${BASE_REF}...HEAD" > /tmp/changed_norenames.txt
echo "Changed files:"; sed 's/^/ /' /tmp/changed_norenames.txt
if grep -qvE '^docs/' /tmp/changed_norenames.txt; then
echo "code=true" >> "$GITHUB_OUTPUT"
else
echo "code=false" >> "$GITHUB_OUTPUT"
fi
matrix="$(hack/build-matrix.sh /tmp/changed.txt)"
echo "Build matrix: $matrix"
echo "matrix=$matrix" >> "$GITHUB_OUTPUT"
talos="$(hack/build-talos-needed.sh /tmp/changed_norenames.txt)"
echo "Talos build needed: $talos"
echo "talos=$talos" >> "$GITHUB_OUTPUT"
# Package dirs the PR edited (packages/<area>/<name>). Their committed
# refs are the PR's intent and must survive the current-main overlay —
# otherwise an upstream image bump in a non-build-unit package would be
# silently reverted in e2e/the installer artifact (see
# hack/overlay-main-images.sh). Whitespace-separated, no trailing slash.
touched="$(sed -nE 's#^(packages/[^/]+/[^/]+)/.*#\1#p' /tmp/changed.txt | sort -u | tr '\n' ' ')"
echo "Touched packages: $touched"
echo "touched=$touched" >> "$GITHUB_OUTPUT"
if [ "$matrix" = "[]" ]; then
echo "any=false" >> "$GITHUB_OUTPUT"
else
echo "any=true" >> "$GITHUB_OUTPUT"
fi
# Promotion publishes the rewritten packages artifact before opening its PR.
# Verify GitHub's prospective merge tree here, so a `packages/` edit pushed to
# this PR fails the required E2E Tests status before it can merge.
#
# Checking the MERGE tree rather than the head is what makes base drift
# visible when this job runs — but nothing re-runs it when the base moves:
# GitHub does not fire `pull_request` on the base branch advancing, it only
# recomputes refs/pull/N/merge. So a `packages/` change landing on the base
# after this went green is never re-verified here, and the PR stays mergeable
# on the stale result. Finalize repeats the check against the real merge
# commit before creating the write-once stable tag, which is where that case
# is actually caught — loudly, and before any stable name exists.
verify-release-candidate:
name: Verify release packages candidate
runs-on: ubuntu-latest
# Matching `checks` and `finalize`. Without a ceiling the job inherits the
# 6-hour default, and a stalled `flux pull artifact` would hold the required
# `E2E Tests` status pending for all of it, since e2e-report needs this job.
timeout-minutes: 30
if: |
contains(github.event.pull_request.labels.*.name, 'release')
&& github.event.pull_request.user.login == 'cozystack-ci[bot]'
&& (github.event.action != 'labeled' || github.event.label.name == 'full-e2e' || github.event.label.name == 'release')
permissions:
contents: read
packages: read
steps:
# The default pull_request checkout is GitHub's prospective merge commit,
# not just the head branch. That makes base-branch package drift visible.
- name: Checkout prospective merge
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0
# Execute the verifier from the trusted base SHA. An older rc-derived head
# may not contain it, and a PR must not be able to weaken its own guard.
- name: Checkout release tooling from base
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ github.event.pull_request.base.sha }}
path: .release-tooling
sparse-checkout: hack
persist-credentials: false
# Both tools are version-pinned. This job decides whether a stable release
# may be created, so what it runs has to be a fixed input: `latest/download`
# resolves to whatever mikefarah shipped this morning, and a yq that changed
# how it emits or compares a value turns a release gate into a moving one.
# The version tracks packages/core/testing/images/e2e-sandbox/Dockerfile.
# yq is additionally pinned by content; flux comes from the commit-pinned
# upstream setup action, which checksums the release tarball.
# promote-rc.yaml's copy of this step carries the full reasoning for both.
- name: Set up flux
uses: fluxcd/flux2/action@04acaec6161ac4fb1a82ffafa88901c03271d34f # v2.8.6
with:
version: "2.8.6"
- name: Set up promotion toolchain (yq)
env:
YQ_VERSION: "4.53.3"
YQ_SHA256: "fa52a4e758c63d38299163fbdd1edfb4c4963247918bf9c1c5d31d84789eded4"
run: |
if ! yq --version 2>/dev/null | grep -q "mikefarah.* version v${YQ_VERSION}\$"; then
yq_bin="$(mktemp)"
curl -fsSL -o "$yq_bin" \
"https://github.com/mikefarah/yq/releases/download/v${YQ_VERSION}/yq_linux_amd64"
printf '%s %s\n' "$YQ_SHA256" "$yq_bin" | sha256sum -c -
sudo install -m 0755 "$yq_bin" /usr/local/bin/yq
rm -f "$yq_bin"
fi
- name: Login to registry (GHCR)
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
registry: ghcr.io
env:
DOCKER_CONFIG: ${{ runner.temp }}/.docker
- name: Verify stable packages candidate
env:
DOCKER_CONFIG: ${{ runner.temp }}/.docker
HEAD_BRANCH: ${{ github.head_ref }}
run: |
STABLE_VERSION="${HEAD_BRANCH#release-}"
.release-tooling/hack/verify-promoted-packages.sh "$STABLE_VERSION"
# Helm-unittest + Go controller tests. Decoupled from the image build and from
# e2e so they give fast, independent signal; they gate the PR as their own
# required check rather than blocking e2e from starting.
checks:
name: Unit & controller tests
runs-on: oracle-vm-4cpu-16gb-x86-64
timeout-minutes: 30
env:
# The pipe falls back to direct on any proxy error (hack/common-envs.mk).
GOPROXY: "https://proxy.golang.org|direct"
permissions:
contents: read
needs: ["plan"]
# Unit contracts read both code and documentation. Run this job for every
# non-release PR: code changes take the complete unit path, while docs-only
# changes run the Bats lane that owns those cross-tree contracts. Builds and
# E2E remain gated on `code` below.
if: |
!contains(github.event.pull_request.labels.*.name, 'release')
steps:
- name: Checkout code
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0
# The module cache is restored from the key build-main.yaml writes on
# main, which every PR run can read, so a PR's first run starts warm. The
# download fetches whatever the cache lacks, the whole set on a miss. The
# Go steps are for code changes only: a docs-only run is the Bats lane alone.
- name: Locate Go module cache
if: needs.plan.outputs.code == 'true'
id: gomodcache
run: echo "dir=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT"
- name: Cache Go modules
if: needs.plan.outputs.code == 'true'
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ steps.gomodcache.outputs.dir }}
key: go-mod-${{ runner.os }}-${{ hashFiles('**/go.sum') }}
restore-keys: go-mod-${{ runner.os }}-
- name: Download Go modules
if: needs.plan.outputs.code == 'true'
run: |
for mod in $(git ls-files go.mod '*/go.mod' | xargs -n1 dirname); do
(cd "$mod" && "$GITHUB_WORKSPACE/hack/go-mod-download.sh")
done
# Ephemeral runners don't carry the helm-unittest plugin the persistent
# self-hosted runner accreted. The plugin embeds its own Helm, so its
# version is pinned: a plugin release must not change test results. Bats is
# installed on every run, docs-only ones included.
- name: Set up test toolchain
env:
CODE_CHANGED: ${{ needs.plan.outputs.code }}
HELM_UNITTEST_VERSION: "1.1.2"
BATS_VERSION: "1.14.0"
BATS_COMMIT: eb7f42f8d608ac693d7a4b67474f6714ea68cfc5
run: |
set -euo pipefail
if [ "$CODE_CHANGED" = true ]; then
installed="$(helm plugin list 2>/dev/null | awk '$1 == "unittest" { print $2 }' || true)"
if [ "$installed" != "$HELM_UNITTEST_VERSION" ]; then
[ -z "$installed" ] || helm plugin uninstall unittest
helm plugin install https://github.com/helm-unittest/helm-unittest --version "v${HELM_UNITTEST_VERSION}"
fi
fi
# Source archive bytes can change; verify the tag's commit before install.
if ! bats --version 2>/dev/null | grep -qx "Bats $BATS_VERSION"; then
bats_parent=$(mktemp -d "$RUNNER_TEMP/bats-core.XXXXXX")
bats_source="$bats_parent/source"
git clone --quiet --depth=1 --branch "v$BATS_VERSION" \
https://github.com/bats-core/bats-core.git "$bats_source"
test "$(git -C "$bats_source" rev-parse HEAD)" = "$BATS_COMMIT"
sudo "$bats_source/install.sh" /usr/local
fi
bats --version | grep -qx "Bats $BATS_VERSION"
# -k is required, not cosmetic: unit-tests and test-controllers are two
# independent goals on the same command line, and without -k make stops
# handing out new work the moment any unit-tests prerequisite fails —
# test-controllers then never starts and reports nothing, so a red BATS
# file hides the entire Go controller suite. -k lets already-schedulable
# goals keep running; the job still fails overall on any red target.
- name: Run unit and controller tests
if: needs.plan.outputs.code == 'true'
run: make unit-tests test-controllers -j4 -k --output-sync=target
- name: Run Bats unit tests for docs-only changes
if: needs.plan.outputs.code == 'false'
run: make bats-unit-tests
- name: Upload unit test report
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: bats-unit-report
path: _out/test-reports/
if-no-files-found: ignore
# One image (package) per job, fanned out across the ephemeral pool. Each job
# gets its own buildx (docker-container driver) so the shared-buildkit
# contention that wedged the old single Build job (#2937) is severed by
# construction. Each job patches only its own package's values.yaml/.tag files
# and uploads that diff as a patch fragment; `finalize` reassembles them.
build:
name: Build ${{ matrix.pkg }}
needs: ["plan"]
if: |
needs.plan.outputs.code == 'true'
&& needs.plan.outputs.any == 'true'
&& !contains(github.event.pull_request.labels.*.name, 'release')
runs-on: oracle-vm-4cpu-16gb-x86-64
timeout-minutes: 30
permissions:
contents: read
strategy:
fail-fast: false
matrix:
pkg: ${{ fromJSON(needs.plan.outputs.matrix) }}
steps:
- name: Checkout code
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0
fetch-tags: true
# installer is built in finalize, but some package image targets shell out
# to flux.
- name: Set up flux
uses: fluxcd/flux2/action@04acaec6161ac4fb1a82ffafa88901c03271d34f # v2.8.6
with:
version: "2.8.6"
- name: Set up Docker config
run: |
if [ -d ~/.docker ]; then
cp -r ~/.docker "${{ runner.temp }}/.docker"
fi
- name: Login to GitHub Container Registry
if: ${{ !github.event.pull_request.head.repo.fork }}
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
username: ${{ secrets.OCIR_USER }}
password: ${{ secrets.OCIR_TOKEN }}
registry: iad.ocir.io
env:
DOCKER_CONFIG: ${{ runner.temp }}/.docker
# docker-container driver: isolated buildkit per job (no shared-buildkit
# contention) and a prerequisite for the mode=max cache reads (#2938).
# Its buildkitd also routes docker.io base-image pulls through
# mirror.gcr.io (hack/buildkitd.toml): the Build step authenticates only
# to the push registry (OCIR), so base images (golang/ubuntu/nginx/...)
# would otherwise be pulled from Docker Hub anonymously and intermittently
# trip its anonymous-pull rate limit; the mirror sidesteps that and falls
# back to Docker Hub on a miss/outage. Create the builder under the SAME
# DOCKER_CONFIG the Build step reads (runner.temp/.docker, holding the
# OCIR auth) so buildx forwards push/cache auth to the container builder.
- name: Set up Buildx (docker-container driver, docker.io mirror)
id: buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
with:
driver: docker-container
buildkitd-config: hack/buildkitd.toml
env:
DOCKER_CONFIG: ${{ runner.temp }}/.docker
- name: Build ${{ matrix.pkg }}
run: |
mkdir -p _out/oci
make -C "$PKG" image
env:
PKG: ${{ matrix.pkg }}
DOCKER_CONFIG: ${{ runner.temp }}/.docker
# Build on the docker-container builder created above so base-image
# pulls go through the docker.io mirror (BUILDX_ARGS adds
# --builder=$(BUILDER)).
BUILDER: ${{ steps.buildx.outputs.name }}
# common-envs.mk defaults to amd64+arm64. Nothing here sets up QEMU,
# and on this x86-64 runner the arm64 half would be emulated anyway;
# CI builds amd64 until arm64 has a native leg of its own.
PLATFORM: linux/amd64
# Unique per (PR, commit) so concurrent PR builds never push the same
# tag. PR images are consumed by digest; :latest belongs to releases.
IMAGE_TAG: pr-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }}
PUBLISH_VERSIONED: '0'
PUBLISH_FLOATING: '0'
# Fork PRs get no registry credentials, so instead of pushing, build
# each image to <workspace>/_out/oci/<name>.oci.tar and upload it below;
# the privileged e2e-fork.yaml workflow_run pushes them by digest. Empty
# on same-repo PRs → normal push path. Setting it also forces PUSH/LOAD
# off in common-envs.mk (#3257). Must be ABSOLUTE (workspace-rooted):
# the build runs `make -C <pkg>`, so buildx resolves a relative dest
# against the package dir — the archive would land in <pkg>/_out/oci and
# miss the repo-root _out/oci the upload reads.
OCI_EXPORT_DIR: ${{ github.event.pull_request.head.repo.fork && format('{0}/_out/oci', github.workspace) || '' }}
- name: Capture digest patch fragment
id: frag
env:
PKG: ${{ matrix.pkg }}
run: |
safe="$(echo "$PKG" | tr '/' '-')"
mkdir -p _out/fragments
# --binary so vendored binary provider bundles (components*.gz),
# which some package image targets regenerate, survive reassembly
# in finalize (a plain "Binary files differ" stub is unappliable).
git diff --binary HEAD > "_out/fragments/${safe}.patch"
echo "safe=${safe}" >> "$GITHUB_OUTPUT"
echo "Fragment for ${PKG}:"; git --no-pager diff --stat HEAD
- name: Upload patch fragment
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: pr-patch-fragment-${{ steps.frag.outputs.safe }}
path: _out/fragments/${{ steps.frag.outputs.safe }}.patch
if-no-files-found: error
# Fork PR only: the images were exported to OCI archives instead of pushed
# (OCI_EXPORT_DIR above). Upload them for e2e-fork.yaml to push by digest.
- name: Upload OCI image archives (fork PR)
if: ${{ github.event.pull_request.head.repo.fork }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: pr-oci-${{ steps.frag.outputs.safe }}
path: _out/oci/*.oci.tar
if-no-files-found: error
# Talos is its own leg: the installer tarball is heavy and the Talos image is
# built from it, so both live in one job. No nocloud disk is built here any
# more, and e2e no longer downloads one -- the container lane boots the
# upstream Talos container image. `images/talos/profiles/nocloud.yaml` is
# therefore compiled by nightly.yaml and by the tag build's `make assets`.
#
# For same-repo PRs, the plan job classifies the rename-blind changed-path
# list with build-talos-needed.sh. That list includes direct Talos sources and
# the shared Makefile/Buildx/workflow inputs that determine this job's output;
# line-based classification avoids depending on the formatting of `touched`.
# A successful run stamps packages/extra/bootbox/images/matchbox.tag, and
# finalize preserves that generated ref when it overlays base-branch images.
#
# Fork PRs keep the previous unconditional behavior as a conservative scope
# boundary. Their build/export and privileged publication path spans a second
# workflow and is not optimized here. The installer job also exports an OCI
# archive, so archive availability is not a reason to require Talos itself.
build-talos:
name: Build Talos
needs: ["plan"]
if: |
needs.plan.outputs.code == 'true'
&& !contains(github.event.pull_request.labels.*.name, 'release')
&& (
github.event.pull_request.head.repo.fork
|| needs.plan.outputs.talos == 'true'
)
runs-on: oracle-vm-4cpu-16gb-x86-64
timeout-minutes: 40
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0
fetch-tags: true
- name: Set up Docker config
run: |
if [ -d ~/.docker ]; then
cp -r ~/.docker "${{ runner.temp }}/.docker"
fi
- name: Login to GitHub Container Registry
if: ${{ !github.event.pull_request.head.repo.fork }}
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
username: ${{ secrets.OCIR_USER }}
password: ${{ secrets.OCIR_TOKEN }}
registry: iad.ocir.io
env:
DOCKER_CONFIG: ${{ runner.temp }}/.docker
# buildkitd-config routes docker.io base-image pulls through mirror.gcr.io
# (hack/buildkitd.toml) so the talos image build dodges Docker Hub's
# anonymous-pull rate limit; see the build job for the full rationale.
- name: Set up Buildx (docker-container driver, docker.io mirror)
id: buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
with:
driver: docker-container
buildkitd-config: hack/buildkitd.toml
env:
DOCKER_CONFIG: ${{ runner.temp }}/.docker
- name: Build Talos image (matchbox + installer tarball)
run: |
mkdir -p _out/oci
make -C packages/core/talos image
env:
DOCKER_CONFIG: ${{ runner.temp }}/.docker
BUILDER: ${{ steps.buildx.outputs.name }}
# amd64 only, as in the build job. The arm64 installer needs no QEMU,
# but nothing a PR runs boots it. matchbox still gets the arm64 kernel
# and initramfs.
PLATFORM: linux/amd64
IMAGE_TAG: pr-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }}
PUBLISH_VERSIONED: '0'
PUBLISH_FLOATING: '0'
# Fork PR: export to OCI archive instead of pushing (see build job).
OCI_EXPORT_DIR: ${{ github.event.pull_request.head.repo.fork && format('{0}/_out/oci', github.workspace) || '' }}
- name: Capture Talos patch fragment
run: |
mkdir -p _out/fragments
git diff --binary HEAD > _out/fragments/talos.patch
echo "Talos fragment:"; git --no-pager diff --stat HEAD
- name: Upload Talos patch fragment
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: pr-patch-fragment-talos
path: _out/fragments/talos.patch
if-no-files-found: error
# Fork PR only: upload the Talos image OCI archive for e2e-fork.yaml.
- name: Upload Talos OCI image archive (fork PR)
if: ${{ github.event.pull_request.head.repo.fork }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: pr-oci-talos
path: _out/oci/*.oci.tar
if-no-files-found: error
# Reassemble the unified pr.patch from every fragment, then build the installer
# LAST: `make -C packages/core/installer image` runs `flux push artifact
# --path=packages`, which bundles the WHOLE digest-patched tree into the OCI
# artifact the operator pulls — so every other package's digest edit must
# already be applied here. e2e consumes the resulting pr.patch unchanged.
finalize:
name: Finalize (merge patches + installer)
# Depends on `checks` too: gating finalize on unit/controller tests cascades
# to e2e (which needs finalize), so a red unit test skips the expensive
# install+e2e path instead of wasting ~1h on it. Restores the gating the old
# in-build-job tests gave, without re-coupling the cheap parallel builds.
needs: ["plan", "checks", "build", "build-talos"]
if: |
!cancelled()
&& needs.plan.outputs.code == 'true'
&& !contains(github.event.pull_request.labels.*.name, 'release')
&& needs.checks.result == 'success'
&& (needs.build.result == 'success' || needs.build.result == 'skipped')
&& (needs.build-talos.result == 'success' || needs.build-talos.result == 'skipped')
runs-on: oracle-vm-4cpu-16gb-x86-64
timeout-minutes: 30
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0
fetch-tags: true
- name: Set up flux
uses: fluxcd/flux2/action@04acaec6161ac4fb1a82ffafa88901c03271d34f # v2.8.6
with:
version: "2.8.6"
- name: Download patch fragments
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
pattern: pr-patch-fragment-*
merge-multiple: true
path: _out/fragments
- name: Apply digest fragments
run: |
applied=0
# Each fragment patches only its own package's files; --3way lets two
# fragments that touch adjacent lines of a shared file (e.g. a package
# Makefile that writes a sibling package's values.yaml) merge cleanly
# instead of the second apply aborting the whole finalize.
for f in _out/fragments/*.patch; do
[ -s "$f" ] || continue
echo "Applying $f"
git apply --3way "$f"
applied=$((applied + 1))
done
echo "Applied $applied fragment(s)"
git --no-pager diff --stat HEAD
- name: Set up Docker config
run: |
if [ -d ~/.docker ]; then
cp -r ~/.docker "${{ runner.temp }}/.docker"
fi
- name: Login to GitHub Container Registry
if: ${{ !github.event.pull_request.head.repo.fork }}
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
username: ${{ secrets.OCIR_USER }}
password: ${{ secrets.OCIR_TOKEN }}
registry: iad.ocir.io
env:
DOCKER_CONFIG: ${{ runner.temp }}/.docker
# Overlay current-main image refs onto every package this PR did NOT
# rebuild. Without it, unbuilt packages fall back to the last-release refs
# committed in the tree (e.g. v1.5.0) even when main is hundreds of commits
# ahead — so e2e and the installer's packages artifact would ship stale
# images for everything outside this PR's build matrix. Source of truth is
# the cozystack-packages:main OCI artifact build-main.yaml already
# publishes (the whole current-main packages tree, refs digest-pinned to
# main's images). Runs after login (creds available) and before the
# installer build below so the flux-pushed packages tree — and the
# pr.patch e2e applies — already reflect the overlay. A missing artifact
# (build-main not yet green) is a no-op: packages keep their committed
# refs (prior behaviour). See hack/overlay-main-images.sh.
#
# Read the artifact for the PR's OWN base branch, not always main. Overlaying
# main's images onto a release-line PR mixes generations: main's binaries
# against that line's charts. #3437 (a one-line change on release-1.6) failed
# install deterministically that way, because main's cozystack-controller
# served an aggregated OpenAPI release-1.6's charts could not validate
# against (`unknown model in reference: …v1alpha1.OptionSpec`) — a broken
# lane reading as a broken PR. build-release.yaml publishes
# cozystack-packages:<line> for every maintained release-<major>.<minor>
# branch, so each base branch has its own artifact to read.
#
# A base that is neither main nor a line (a stacked PR's feature branch) has
# no artifact, and a `/` in it is not even a valid OCI tag. It reads the
# artifact of whichever of main and the lines it has the fewest commits on
# top of. A tie goes to main. A line with no commit of its own yet is, in
# the graph, main at the cut commit, so a stack on it ties and reads the
# artifact built from main's tip. That artifact carries newer images than
# the line once main moves past the cut. The stack keeps reading it until
# it contains a commit of the line's own. Breaking the tie towards the
# line instead would misroute the far more common branch forked from main
# before the cut, which ties the same way.
#
# A missing artifact stays a no-op (packages keep their committed refs), which
# also covers a line whose first build-release run has not landed yet.
- name: Pull base-branch packages tree
run: |
ARTIFACT_REF="$BASE_REF"
if [ "$BASE_REF" != "main" ] \
&& ! printf '%s' "$BASE_REF" | grep -qE '^release-[0-9]+\.[0-9]+$'; then
ARTIFACT_REF=main
# A re-run can outlive its base: the payload keeps a branch deleted since.
if git rev-parse --verify --quiet "origin/${BASE_REF}" >/dev/null; then
best="$(git rev-list --count "origin/main..origin/${BASE_REF}")"
for line in $(git for-each-ref --format='%(refname:lstrip=3)' 'refs/remotes/origin/release-*' \
| grep -E '^release-[0-9]+\.[0-9]+$'); do
n="$(git rev-list --count "origin/${line}..origin/${BASE_REF}")"
if [ "$n" -lt "$best" ]; then best="$n"; ARTIFACT_REF="$line"; fi
done
fi
echo "::notice::Base ${BASE_REF} publishes no packages artifact; overlaying cozystack-packages:${ARTIFACT_REF}."
fi
rm -rf _out/mainpkgs
mkdir -p _out/mainpkgs
if ! flux pull artifact "oci://${REGISTRY}/cozystack-packages:${ARTIFACT_REF}" \
--output _out/mainpkgs; then
rm -rf _out/mainpkgs
# Degrading to committed refs is safe, but SAY so: on a release line a
# missing artifact means build-release.yaml has not published this line
# yet (first build pending, or its branch filter does not match this
# branch), and the PR is then testing the line's last release instead of
# its tip. Silent would look identical to a working overlay.
if [ "${ARTIFACT_REF}" = "main" ]; then
echo "cozystack-packages:${ARTIFACT_REF} pull failed — keeping committed refs"
else
echo "::warning title=No packages artifact for ${ARTIFACT_REF}::Keeping committed refs, so unbuilt packages use this line's last release rather than its tip. Check that build-release.yaml has run for ${ARTIFACT_REF}."
fi
fi
env:
DOCKER_CONFIG: ${{ runner.temp }}/.docker
# Env-passed, not interpolated into the script: base_ref is repo-controlled
# here (a branch name in this repo), but the workflow-injection hygiene
# applies uniformly.
BASE_REF: ${{ github.base_ref }}
- name: Overlay base-branch refs for unbuilt packages
# Best-effort: this only improves WHICH images e2e and the installer use,
# so an unexpected failure must degrade to the committed refs (prior
# behaviour), never block the PR. overlay-main-images.sh already handles
# the expected paths (missing artifact, drift, absent unit) internally —
# including the empty directory the step above leaves when a line has no
# artifact yet.
run: |
rebuilt="$BUILD_MATRIX"
if [ "$TALOS_RESULT" = "success" ]; then
case "$rebuilt" in
'[]') rebuilt='["packages/extra/bootbox"]' ;;
*) rebuilt="${rebuilt%]},\"packages/extra/bootbox\"]" ;;
esac
fi
hack/overlay-main-images.sh _out/mainpkgs "$rebuilt" "$PR_TOUCHED" \
|| echo "::warning::overlay-main-images failed; unbuilt packages keep committed refs"
env:
# The set of packages THIS PR rebuilt; their pr-<N>-<sha> refs are
# authoritative, so overlay-main-images.sh skips them. Computed by the
# plan job from the Makefile (a JSON array of package dirs), not from
# untrusted PR input — passed via env per workflow-injection hygiene.
BUILD_MATRIX: ${{ needs.plan.outputs.matrix }}
# build-talos writes matchbox.tag under extra/bootbox rather than its
# own package. Preserve that generated ref only when the job actually
# succeeded; when it was skipped, the base-branch ref should overlay.
TALOS_RESULT: ${{ needs.build-talos.result }}
# Packages THIS PR edited (changed-file dirs from the plan job's git
# diff). Their committed refs are the PR's intent — an upstream image
# bump in a non-build-unit package would otherwise be silently reverted
# by the overlay — so overlay-main-images.sh preserves them. Also
# env-passed, derived from the diff, not from untrusted PR input.
PR_TOUCHED: ${{ needs.plan.outputs.touched }}
# buildkitd-config routes docker.io base-image pulls through mirror.gcr.io
# (hack/buildkitd.toml) so the installer build dodges Docker Hub's
# anonymous-pull rate limit; see the build job for the full rationale.
- name: Set up Buildx (docker-container driver, docker.io mirror)
id: buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
with:
driver: docker-container
buildkitd-config: hack/buildkitd.toml
env:
DOCKER_CONFIG: ${{ runner.temp }}/.docker
# Same-repo PR: build the operator image AND flux-push the now-fully-patched
# packages tree, then patch the installer's platformSourceRef to that
# artifact. Fork PR: no registry creds — build ONLY the operator image to an
# OCI archive (image-packages/chart, which push, are skipped) and upload it;
# e2e-fork.yaml pushes the operator + flux-pushes the packages tree and
# bakes platformSourceRef there. The overlay above already ran, so the
# pr.patch assembled below carries the fully digest-pinned tree either way.
- name: Build installer (operator + packages artifact)
run: |
mkdir -p _out/oci
if [ "$IS_FORK" = "true" ]; then
make -C packages/core/installer pre-checks image-operator
else
make -C packages/core/installer image
fi
env:
DOCKER_CONFIG: ${{ runner.temp }}/.docker
BUILDER: ${{ steps.buildx.outputs.name }}
# amd64 only, as in the build job.
PLATFORM: linux/amd64
IMAGE_TAG: pr-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }}
PUBLISH_VERSIONED: '0'
PUBLISH_FLOATING: '0'
IS_FORK: ${{ github.event.pull_request.head.repo.fork }}
OCI_EXPORT_DIR: ${{ github.event.pull_request.head.repo.fork && format('{0}/_out/oci', github.workspace) || '' }}
# Fork PR only: upload the operator image OCI archive for e2e-fork.yaml.
- name: Upload installer OCI image archive (fork PR)
if: ${{ github.event.pull_request.head.repo.fork }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: pr-oci-installer
path: _out/oci/*.oci.tar
if-no-files-found: error
- name: Assemble pr.patch
run: |
mkdir -p _out/assets
# --binary so regenerated provider bundles (components*.gz) stay in
# the patch e2e applies; mirrors main's pr.patch binary fix (#e566992).
git diff --binary HEAD > _out/assets/pr.patch
echo "Final pr.patch:"; git --no-pager diff --stat HEAD
- name: Upload git diff patch
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: pr-patch
path: _out/assets/pr.patch
if-no-files-found: error
resolve_assets:
name: "Resolve assets"
runs-on: ubuntu-latest
# In lockstep with `plan`, fork term included: this job is on `e2e`'s
# release arm, so a label event `plan` runs for and this job skips for
# leaves `e2e` with both arms false while `e2e-report` still runs. On a
# promote PR already carrying full-e2e that reaches the final `else` — the
# "e2e not repeated" branch is bypassed by full-e2e — and posts `E2E Tests`
# = failure over a green one, unclearable without a push because
# `unlabeled` is not a trigger. The fork term keeps a `release` label on a
# fork PR as inert as it was before promotion used the label: without it
# this job runs there and fails resolving a draft release for a branch that
# is not `release-X.Y.Z`. It short-circuits on `action != 'labeled'`, so the
# pre-existing behaviour on a push to such a PR is unchanged.
if: |
contains(github.event.pull_request.labels.*.name, 'release')
&& (github.event.action != 'labeled' || github.event.label.name == 'full-e2e' || (github.event.label.name == 'release' && !github.event.pull_request.head.repo.fork))
permissions:
contents: read
steps:
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
with:
app-id: ${{ secrets.COZYSTACK_CI_APP_ID }}
private-key: ${{ secrets.COZYSTACK_CI_PRIVATE_KEY }}
owner: cozystack
repositories: ${{ github.event.repository.name }}
permission-contents: read
- name: Checkout code
if: contains(github.event.pull_request.labels.*.name, 'release')
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0
fetch-tags: true
- name: Extract tag from PR branch (release PR)
if: contains(github.event.pull_request.labels.*.name, 'release')
id: get_tag
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
script: |
const branch = context.payload.pull_request.head.ref;
const m = branch.match(/^release-(\d+\.\d+\.\d+(?:[-\w\.]+)?)$/);
if (!m) {
core.setFailed(`❌ Branch '${branch}' does not match 'release-X.Y.Z[-suffix]'`);
return;
}
core.setOutput('tag', `v${m[1]}`);
# The draft release has to exist for a promote PR's e2e arm to mean
# anything, and this job is what proves it. It used to also resolve the
# nocloud disk asset and export its id; the container lane boots no disk,
# so nothing in this workflow reads that id any more and resolving it only
# added a way to fail a release PR over an asset it does not use. The
# asset is still resolved where it is still consumed, in e2e-tag.yaml.
- name: Verify draft release exists (release PR)
if: contains(github.event.pull_request.labels.*.name, 'release')
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const tag = '${{ steps.get_tag.outputs.tag }}';
const releases = await github.rest.repos.listReleases({
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100
});
const draft = releases.data.find(r => r.tag_name === tag && r.draft);
if (!draft) {
core.setFailed(`Draft release '${tag}' not found`);
return;
}
# In-tree e2e for SAME-REPO PRs, on Talos nodes running as CONTAINERS. The
# QEMU substrate this lane used to boot is off the per-PR path only: it is
# still wired into nightly.yaml and e2e-tag.yaml, both of which run `make
# prepare-env` -> `make -C packages/core/testing prepare-cluster`. That target
# and hack/e2e-prepare-cluster.bats are therefore live code, not leftovers,
# and deleting them takes nightly and every release-candidate run with them.
# They carry the coverage containers cannot: DRBD, and with it the
# `replicated` StorageClass, its Immediate binding mode and the tenant
# StorageClass propagation that only applies to remotely-accessible classes.
#
# Deliberately NOT named "E2E Tests": the
# required "E2E Tests" context is a commit status posted by the `e2e-report`
# job (non-fork) or by e2e-fork.yaml (fork). A required *job* that skips
# counts as PASSED by branch protection, which is exactly how fork PRs used
# to merge with e2e never run (#3257); routing the required context through
# a status removes that skip-to-green path. Fork PRs skip this job
# (they have no pushed images to pull) and run e2e in the privileged
# workflow_run instead.
e2e:
name: "E2E (in-tree)"
# hack/e2e-compose.yaml gives each of the three nodes `cpus: 8` and
# `mem_limit: 24576m`, matching what the QEMU guests were given, so the nodes
# still demand 24 vCPU and 72 GiB between them. The same headroom argument
# therefore still decides the class: on a 24-vCPU runner that is a 1:1 commit
# with nothing left for containerd, the kernel, the host iptables MASQUERADE
# every image pull traverses and the runner agent, and during "Install
# Cozystack" the nodes contend with those and lose cycles. Readiness and
# liveness probes then miss their deadlines, a HelmRelease flips InProgress ->
# Failed and its dependents cascade to DependencyNotReady -- on a different
# component each run, the signature of resource thrash rather than a product
# bug. The 32-vCPU / 128 GiB class leaves 8 vCPU (25%) and 56 GiB of headroom
# and removes that oversubscription.
#
# One thing this class does NOT fix, and it is worth knowing before reading a
# red run: a container node reports the HOST's CPU and memory as its capacity,
# and hack/e2e-container-up.sh counterbalances that with kubelet
# systemReserved so the scheduler sees 8 CPU / 24 GiB. That corrects the
# scheduler and not what a pod reads from /proc, so a workload that sizes
# itself from /proc/cpuinfo or /proc/meminfo is configured for the whole host.
runs-on: oracle-vm-32cpu-128gb-x86-64
# Sized above what the tenant-Kubernetes pair can spend on a failing run, and
# held to one figure across every lane that runs the suite.
timeout-minutes: 215
permissions:
contents: read
packages: read
checks: write
needs: ["finalize", "resolve_assets", "verify-release-candidate"]
# Regular PRs still enter through finalize. A release-labelled promote PR
# enters through resolve_assets only when a maintainer explicitly adds
# full-e2e; without that label the immutable rc's already-green suite is not
# repeated. The labeled trigger above makes that post-open opt-in start a run.
#
# Fork PRs never enter here: their build exports OCI archives instead of
# pushing images, so there is no closure for this job to test. e2e-fork.yaml
# runs the suite for them from the privileged workflow_run and concludes the
# required "E2E Tests" status; for same-repo PRs e2e-report concludes it.
if: |
!cancelled()
&& !github.event.pull_request.head.repo.fork
&& (
needs.finalize.result == 'success'
|| (
needs.resolve_assets.result == 'success'
&& needs.verify-release-candidate.result == 'success'
&& contains(github.event.pull_request.labels.*.name, 'release')
&& contains(github.event.pull_request.labels.*.name, 'full-e2e')
)
)
steps:
# ▸ Checkout and prepare the codebase
# fetch-depth: 0 is required by the "Select E2E tests" step so that
# `git diff origin/${BASE_REF}...HEAD` can resolve the base ref.
- name: Checkout code
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0
# ▸ Regular PR path – pr.patch from finalize. No Talos disk: the nodes boot
# from ghcr.io/siderolabs/talos directly (hack/e2e-compose.yaml), so this
# lane consumes no nocloud asset and waits on no Talos build.
- name: Download PR patch
if: "!contains(github.event.pull_request.labels.*.name, 'release')"
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: pr-patch
path: _out/assets
- name: Apply patch
if: "!contains(github.event.pull_request.labels.*.name, 'release')"
run: |
git apply _out/assets/pr.patch
- name: Set sandbox ID
run: echo "SANDBOX_NAME=cozy-e2e-sandbox-$(echo "${GITHUB_REPOSITORY}:${GITHUB_WORKFLOW}:${GITHUB_REF}" | sha256sum | cut -c1-10)" >> $GITHUB_ENV
# ▸ Host kernel modules. MANDATORY and up front, because nothing later can
# supply them: the nodes take their kernel from this host, and Talos
# cannot load modules in container mode -- `machine.kernel.modules` is a
# silent no-op there, `kernel_module_spec.go` returns early on
# ModeContainer with no error and no event.
#
# A module missing here is missing for every node for the whole run, and
# it surfaces in disguise: no `openvswitch` and kube-ovn comes up with no
# dataplane, no `zfs` and every LINSTOR pool creation fails. Both read as
# a CNI or a storage regression an hour later. So this loads both and
# then asserts both, and a failure here names the host rather than the
# product.
- name: Prepare container-lane host
run: |
set -eu
echo "::group::Loading kernel modules required by the container lane"
for m in openvswitch zfs; do
if grep -q "^${m} " /proc/modules; then
echo "${m}: already loaded"
continue
fi
if [ "${m}" = zfs ]; then
sudo apt-get update -qq
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq zfsutils-linux
fi
echo "${m}: loading"
sudo modprobe "${m}" || echo "${m}: modprobe failed"
done
echo "::endgroup::"
missing=''
for m in openvswitch zfs; do
grep -q "^${m} " /proc/modules || missing="${missing} ${m}"
done
if [ -n "${missing}" ]; then
echo "::error title=container-lane host::kernel module(s) could not be loaded:${missing}"
echo "This is a host problem, not a product failure. The e2e nodes run as" >&2
echo "containers on this runner's kernel and Talos cannot load modules in" >&2
echo "container mode, so what is not here now is unavailable for the whole" >&2
echo "run: openvswitch backs the kube-ovn dataplane, zfs backs every" >&2
echo "LINSTOR pool. Failing here rather than an hour later is the point." >&2
exit 1
fi
grep -E '^(openvswitch|zfs) ' /proc/modules
# ▸ Prepare environment. Two make targets rather than one: the nodes are
# siblings on the runner's docker daemon (the /dev and /run/udev binds in
# hack/e2e-compose.yaml must resolve against the real host devtmpfs, which
# is what lets this lane carry LINSTOR at all), so the host-side half
# needs a docker socket the sandbox does not have.
- name: Prepare environment
run: |
attempt=0
until make SANDBOX_NAME=$SANDBOX_NAME prepare-env-container; do
attempt=$((attempt + 1))
if [ $attempt -ge 3 ]; then
echo "Attempt $attempt failed, exiting..."
exit 1
fi
echo "Attempt $attempt failed, retrying..."
make -C packages/core/testing SANDBOX_NAME=$SANDBOX_NAME delete-cluster-container || true
done
echo "Prepare environment completed after $((attempt + 1)) attempts"
# Test Impact Analysis (TIA): walk the PackageSource graph for the PR
# diff and select only the Chainsaw suites affected. Runs by default; opt
# out via the `full-e2e` label to run the full suite.
#
# `skip=true` means every changed path was matched by one of select-e2e.sh's
# rules that select nothing — docs and dashboards, a disabled Chainsaw
# suite, a backups example with no suite, or the inert repo-meta pattern
# — so skipping Chainsaw and reporting green is the correct verdict. A
# path none of the rules recognise escalates to the full suite instead of
# returning empty, so `skip=true` can no longer mean "TIA had no idea"
# (#3392). What it still cannot rule out is a workflow nobody classified:
# .github/ is inert as a directory and the e2e workflows are exempted
# from it by name, so a workflow added under .github/workflows/ reads as
# inert until someone puts it on that list, and the fall-through never
# sees it.
- name: Select E2E tests
id: select
if: ${{ !contains(github.event.pull_request.labels.*.name, 'full-e2e') }}
env:
BASE_REF: ${{ github.base_ref }}
run: |
git diff --name-only "origin/${BASE_REF}...HEAD" > /tmp/changed.txt
apps=$(./hack/select-e2e.sh /tmp/changed.txt)
echo "Selected apps: ${apps:-<none>}"
echo "apps=${apps}" >> $GITHUB_OUTPUT
if [ -z "${apps}" ]; then
echo "skip=true" >> $GITHUB_OUTPUT
else
echo "skip=false" >> $GITHUB_OUTPUT
fi
# The complement of what the selected suites need, for a reduced
# install. An empty selection installs everything as before.
disabled=""
if [ -n "${apps}" ]; then
disabled="$(./hack/select-install.sh --disabled "${apps}")"
fi
echo "Disabled packages: ${disabled:-<none>}"
echo "disabled=${disabled}" >> $GITHUB_OUTPUT
# ▸ Install Cozystack
- name: Install Cozystack into sandbox
env:
# This lane has no ARP VIP: .11 is a plain control-plane node standing
# in for the QEMU lane's 192.168.123.10.
COZY_APISERVER_ENDPOINT: https://192.168.123.11:6443
# Talos containers share the runner kernel and cannot load DRBD, so the
# harness suppresses DRBD-dependent chart content, registers the host's
# pre-created zpools and keeps LINSTOR's local ZFS path.
COZY_LINSTOR_DRBD_ENABLED: "false"
# Root monitoring is the one root-tenant workload whose chart selects
# `replicated` explicitly instead of inheriting the default class. The
# install leaves it off but still seeds its local-storage override, so
# a run that turns it on does not create a replicated PVC.
COZY_E2E_STORAGE_CLASS: local
# Empty on the full-e2e label and on an empty selection, which keeps
# the full install.
COZY_DISABLED_PACKAGES: ${{ steps.select.outputs.disabled }}
run: |
if ! make -C packages/core/testing SANDBOX_NAME=$SANDBOX_NAME install-cozystack; then
echo "❌ Install failed (no retry — see diagnostics below)"
echo "::group::Diagnostics: HelmRelease status"
docker exec "$SANDBOX_NAME" sh -c 'kubectl get hr -A -o wide 2>&1 | tail -100' || true
echo "::endgroup::"
echo "::group::Diagnostics: not-Ready HelmReleases (describe)"
docker exec "$SANDBOX_NAME" sh -c 'kubectl get hr -A --no-headers 2>/dev/null | awk "\$4 != \"True\" {print \$1\" \"\$2}"' \
| while read -r ns name; do
echo "--- $ns/$name ---"
docker exec "$SANDBOX_NAME" sh -c "kubectl describe hr '$name' -n '$ns' 2>&1 | tail -50" || true
done
echo "::endgroup::"
echo "::group::Diagnostics: recent events"
docker exec "$SANDBOX_NAME" sh -c 'kubectl get events -A --sort-by=.lastTimestamp 2>&1 | tail -50' || true
echo "::endgroup::"
exit 1
fi
- name: Run OpenAPI tests
run: |
make -C packages/core/testing SANDBOX_NAME=$SANDBOX_NAME test-openapi
# ▸ Run E2E tests (Chainsaw, scoped by Test Impact Analysis)
#
# The "Select E2E tests" step above picks the suites impacted by the PR
# diff. With the `full-e2e` label that step is skipped, so FULL_E2E is
# the only way to reach this step with no selection and we run the whole
# suite; an all-inert selection skips this step entirely (the
# skip != 'true' guard).
#
# Chainsaw discovers each named suite under hack/e2e-chainsaw/ and polls
# every assertion to its own timeout, so the old per-app 3-retry loop is
# gone — a failure is reported as a structured assertion diff, with
# cluster-state diagnostics dumped below on a non-zero exit.
- name: Run E2E tests
id: e2e_tests
if: ${{ steps.select.outputs.skip != 'true' }}
env:
# No DRBD-backed class exists here. Chainsaw templates the storage
# fixtures with `local`, and the Kubernetes helper omits only the
# remote-propagation and RWX assertions while running the same suites.
COZY_E2E_STORAGE_CLASS: local
SELECTED_APPS: ${{ steps.select.outputs.apps }}
FULL_E2E: ${{ contains(github.event.pull_request.labels.*.name, 'full-e2e') }}
run: |
# Only the full-e2e label can get here with an empty selection: when
# `select` runs, an empty result sets skip=true and this step never
# starts. Testing FULL_E2E alone keeps that invariant visible.
if [ "$FULL_E2E" = "true" ]; then
suites="" # empty => test-chainsaw runs the whole suite
echo "Running full chainsaw suite"
else
suites="$SELECTED_APPS"
echo "Running selected chainsaw suites: $suites"
fi
if ! make -C packages/core/testing SANDBOX_NAME=$SANDBOX_NAME test-chainsaw CHAINSAW_SUITES="$suites"; then
echo "❌ Chainsaw E2E failed (see the assertion diffs or LEAKED-PODS lines above and diagnostics below)"
echo "::group::Diagnostics: HelmRelease status"
docker exec "$SANDBOX_NAME" sh -c 'kubectl get hr -A -o wide 2>&1 | tail -50' || true
echo "::endgroup::"
echo "::group::Diagnostics: recent events"
docker exec "$SANDBOX_NAME" sh -c 'kubectl get events -A --sort-by=.lastTimestamp 2>&1 | tail -30' || true
echo "::endgroup::"
# COSI state at the moment of failure — the CRDs ship no printer
# columns, so pull the readiness fields explicitly (cozyreport
# captures the same at end-of-job, but bucket states move between
# a test failing and the report being taken).
echo "::group::Diagnostics: COSI bucket state"
docker exec "$SANDBOX_NAME" sh -c '
kubectl get bucketclaims.objectstorage.k8s.io -A -o custom-columns="NAMESPACE:.metadata.namespace,NAME:.metadata.name,READY:.status.bucketReady,BUCKET:.status.bucketName" 2>&1
kubectl get buckets.objectstorage.k8s.io -o custom-columns="NAME:.metadata.name,READY:.status.bucketReady,ID:.status.bucketID,CLAIMNS:.spec.bucketClaim.namespace" 2>&1
kubectl get bucketaccesses.objectstorage.k8s.io -A -o custom-columns="NAMESPACE:.metadata.namespace,NAME:.metadata.name,GRANTED:.status.accessGranted,CLAIM:.spec.bucketClaimName" 2>&1
' || true
echo "::endgroup::"
exit 1
fi
echo "✅ All selected E2E tests passed"
- name: Collect chainsaw report
if: always()
run: |
mkdir -p _out
docker cp "$SANDBOX_NAME":/workspace/hack/e2e-chainsaw/chainsaw-report.xml \
_out/chainsaw-report.xml || true
- name: Upload chainsaw report
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: chainsaw-report
path: _out/chainsaw-report.xml
if-no-files-found: ignore
# ▸ Collect debug information (always runs)
#
# A ceiling of its own, not the job's 215 minutes. `Upload
# cozyreport.tgz` is the next step and the collector writes its tarball
# at the very end of its run, so a read that never returns spends what
# is left of the job budget, the job is cancelled, and the upload never
# runs -- the artifact is lost rather than truncated, on exactly the run
# where it is the only evidence left. `|| true` guards against a
# non-zero exit, not against a read that does not return.
#
# 30 minutes is measured against the step: it takes 3-42s across sampled
# runs, on failed e2e jobs as well as green ones. It RESERVES nothing --
# `timeout-minutes` caps a step, it does not carve budget out of the job,
# so a suite that has already burned 210 of the 215 leaves this step five
# minutes and the upload is cancelled with everything else. What the cap
# buys is that a hung read cannot be the reason.
# `continue-on-error` because the ceiling must not change what the job
# REPORTS. `|| true` on the make call is this step's standing statement
# that collecting diagnostics is never fatal, and a step killed at its
# ceiling never reaches that `|| true` -- the runner ends the process. So
# without this, a passing E2E run whose collection ran long is published
# as a failed one, and every later `failure()` in this job starts meaning
# "the tests failed OR the report was slow".
- name: Collect report
id: collect_report
if: always()
timeout-minutes: 30
continue-on-error: true
run: |
make -C packages/core/testing SANDBOX_NAME=$SANDBOX_NAME collect-report || true
# `continue-on-error` keeps the overrun out of the job's result, which also
# keeps it out of everyone's view: a step that ended at its ceiling renders
# green and the only trace is in the raw log. This branch's own standard is
# that a bound which fires is recorded, and that has to hold for the outer
# bound too.
#
# The wording says what `outcome` establishes and no more: a step can end
# non-zero without any ceiling firing, so naming the timeout as the cause
# would be the false-mechanism claim the collector refuses to make for a 137.
- name: Report collection overran its ceiling
if: always() && steps.collect_report.outcome == 'failure'
run: |
echo "::warning title=cozyreport::Collect report ended non-zero -- its timeout-minutes fired, or the step failed before the collector ran; cozyreport.tgz is missing or partial for this run"
- name: Upload cozyreport.tgz
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: cozyreport
path: _out/cozyreport.tgz
- name: Collect images list
if: always()
run: |
make -C packages/core/testing SANDBOX_NAME=$SANDBOX_NAME collect-images || true
- name: Upload image list
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: image-list
path: _out/images.txt
# ▸ Open an SSH breakpoint to the failing sandbox so maintainers can attach,
# inspect Talos/Cozystack state and resume with `breakpoint resume`.
#
# Opens when the E2E job fails AND the PR carries the `debug` label or
# the `release` label (release PRs are bot-created, so the label
# implies maintainer authorship). authorized-users is kept as a second
# defense layer controlling who may actually SSH in.
#
# `failure()` is the whole condition now. It used to be joined by a
# soft-red output, because a node-join red the lane decided not to block
# on arrived here as a SUCCESSFUL job and would have been skipped. That
# carve-out is gone with the QEMU substrate: the node-join deadline
# fails its suite like any other deadline, so a sandbox worth attaching
# to always belongs to a failed job. Label-gating is what keeps this
# affordable, and it is unchanged.
#
# Uses cozystack/breakpoint-action (fork of namespacelabs/breakpoint-action)
# pinned by SHA. The fork adds: pause-idle mode (initial grace period for
# the first SSH connection, idle-aware exit afterwards), endpoint output
# and ::notice:: annotation, and a dedicated Check Run "Breakpoint Open"
# that carries the SSH endpoint in output.summary while the breakpoint is
# paused (conclusion=failure → standard ✗ in `gh pr checks`; updated to
# conclusion=success when the breakpoint exits).
#
# Configured via the BREAKPOINT_ENDPOINT repository variable — in fork
# PRs repository variables are not exposed, so the step is skipped and
# forks cannot reach the self-hosted rendezvous server.
- name: Breakpoint on E2E failure
if: |
failure() &&
vars.BREAKPOINT_ENDPOINT != '' &&
(contains(github.event.pull_request.labels.*.name, 'debug')
|| contains(github.event.pull_request.labels.*.name, 'release'))
# A step that fails here would fail the job on its own account. Same
# reason `Collect report` above carries it: what the job REPORTS must not
# depend on what a helper did.
continue-on-error: true
# cozystack/breakpoint-action v2-cozy.1
# mode: pause-idle defaults: grace-period=20m, idle-timeout=10m
uses: cozystack/breakpoint-action@a6f3a6f87be398ad63b6577351e3398e53f578e4
with:
mode: pause-idle
endpoint: ${{ vars.BREAKPOINT_ENDPOINT }}
authorized-users: androndo, Arsolitt, IvanHunters, kvaps, lexfrei, lllamnyp, mattia-eleuteri, matthieu-robin, myasnikovdaniil, sircthulhu, tym83
check-run-name: "Breakpoint Open"
github-token: ${{ github.token }}
check-run-summary-template: |
## 🔴 SSH breakpoint open — paused for debug
```
{endpoint}
```
Enter the e2e sandbox after SSH:
```
docker exec -ti $(docker ps --filter name=cozy-e2e-sandbox -q | head -1) bash
export KUBECONFIG=/workspace/kubeconfig
```
Resume from inside: `breakpoint resume`. Otherwise the breakpoint
exits 10 minutes after the last SSH session disconnects.
# ▸ Tear down environment (always runs)
#
# Both halves, and in this order: the compose project owns the nodes and
# the zpools outlive it. A leftover pool makes the next run on a reused
# host adopt storage that already carries a dead cluster's volumes.
- name: Tear down sandbox
if: always()
run: |
# Loud, but never the job's verdict. A failed teardown means a dirty
# host, and this runner is discarded after the job, so there is no next
# run to protect -- while failing the job here posts the required
# "E2E Tests" status as a failure that no reader can tell apart from a
# real one. The annotation is what a reused host (a developer machine)
# needs; `delete-cluster-container` returning non-zero is not evidence
# about the product.
cleanup_rc=0
make -C packages/core/testing SANDBOX_NAME=$SANDBOX_NAME delete-cluster-container || cleanup_rc=$?
make -C packages/core/testing SANDBOX_NAME=$SANDBOX_NAME delete || true
if [ "$cleanup_rc" -ne 0 ]; then
echo "::error title=sandbox teardown::teardown exited $cleanup_rc; the host may hold a stale compose project or zpool"
fi
# Report the required "E2E Tests" gate as a commit status rather than relying
# on a job named "E2E Tests". A required *job* that is skipped counts
# as PASSED by branch protection — desired for docs-only PRs, but the exact
# hole that let fork PRs merge with e2e never run (#3257). This job always runs
# on same-repo PRs and concludes "E2E Tests" deterministically:
# • plan failed → failure
# • docs-only PR (code!=true)→ success (e2e not required)
# • in-tree e2e succeeded → success
# • otherwise (skipped/failed/cancelled with code changes) → failure
# Fork PRs skip this job; e2e-fork.yaml concludes "E2E Tests" for them, so a
# fork PR stays "Expected"/not-mergeable until the privileged run reports.
#
# The required "E2E Tests" merge gate is published as a COMMIT STATUS with the
# default GITHUB_TOKEN (+ statuses:write) — NOT as a job, and NOT as a
# check-run.
# - Not a job: a *skipped* required job counts as passed by branch
# protection, which is how fork PRs used to merge with e2e never run
# (#3257). A status is "success" only when this job posts it; a
# never-posted required status stays "Expected" and blocks the merge.
# - Not a check-run: a check-run made with the default token floats into an
# arbitrary GitHub Actions check-suite (it rendered under "PR size label"),
# and an app-token check-run needs the app granted checks:write. A commit
# status has neither problem — branch protection matches it by context name
# and it carries no check-suite grouping. Safe with the default token: a
# fork's pull_request token is read-only and cannot post a status, so only
# same-repo runs (this job, guarded !fork) and the privileged workflow_run
# in e2e-fork.yaml can set it.
# REQUIRES main branch protection to require the "E2E Tests" status context —
# see docs/agents/e2e-testing.md §10.
#
# The `labeled` guard must stay in lockstep with `plan`'s: this job runs if and
# only if `plan` runs. `labeled` events other than full-e2e/release are
# discarded there, so without the same guard this job would run with plan *skipped*, read
# PLAN_RESULT != 'success', and post "E2E Tests" = failure — clobbering an
# already-green required status every time anyone labels an open PR.
e2e-report:
name: "Report E2E status"
needs: ["plan", "e2e", "verify-release-candidate"]
# !cancelled() rather than always(). A cancelled run no longer owns the
# status for its head SHA, because something superseded it and that
# something is now responsible. Posting from here would stamp a red on a
# suite that never finished, and the run that replaced it may be one that
# deliberately posts nothing, leaving nothing to flip it back. A cancelled
# run must report nothing.
#
# Cancellation still reaches this job from a push, from a `full-e2e` label
# run superseding the head run, and from a manual cancel. It no longer
# reaches it from a label the workflow discards: those run in their own
# concurrency group (see the key at the top of this file) precisely so they
# cannot cancel a live run.
if: ${{ !cancelled() && !github.event.pull_request.head.repo.fork && (github.event.action != 'labeled' || github.event.label.name == 'full-e2e' || github.event.label.name == 'release') }}
runs-on: ubuntu-latest
permissions:
contents: read
statuses: write
steps:
- name: Report "E2E Tests" commit status
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
env:
PLAN_RESULT: ${{ needs.plan.result }}
CODE: ${{ needs.plan.outputs.code }}
E2E_RESULT: ${{ needs.e2e.result }}
CANDIDATE_RESULT: ${{ needs.verify-release-candidate.result }}
IS_RELEASE: ${{ contains(github.event.pull_request.labels.*.name, 'release') }}
IS_FULL_E2E: ${{ contains(github.event.pull_request.labels.*.name, 'full-e2e') }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
with:
script: |
const { PLAN_RESULT, CODE, E2E_RESULT, CANDIDATE_RESULT, IS_RELEASE, IS_FULL_E2E, HEAD_SHA, RUN_URL } = process.env;
let state, description;
if (PLAN_RESULT !== 'success') {
state = 'failure';
description = `plan job did not succeed (result: ${PLAN_RESULT})`;
} else if (IS_RELEASE === 'true' && CANDIDATE_RESULT !== 'success') {
state = 'failure';
description = `release packages candidate did not verify (result: ${CANDIDATE_RESULT})`;
} else if (CODE !== 'true') {
state = 'success';
description = 'docs-only PR — e2e not required';
} else if (IS_RELEASE === 'true' && IS_FULL_E2E !== 'true') {
// Release promotion PRs deliberately do not repeat e2e: the rc was
// already verified against the immutable candidate whose digests
// this tree pins, which is the whole point of the rc gate. The `e2e`
// job skips for exactly this case, and while the gate was a job that
// skip satisfied branch protection. Now that the gate is a status,
// the skip has to be reported explicitly or every promotion needs a
// full suite re-run or an admin override.
state = 'success';
description = 'release promotion — verified at rc, e2e not repeated (add full-e2e to force a run)';
} else if (E2E_RESULT === 'success') {
state = 'success';
description = 'in-tree e2e passed';
} else {
state = 'failure';
description = `in-tree e2e did not pass (result: ${E2E_RESULT})`;
}
try {
await github.rest.repos.createCommitStatus({
owner: context.repo.owner,
repo: context.repo.repo,
sha: HEAD_SHA,
state,
context: 'E2E Tests',
description,
target_url: RUN_URL,
});
} catch (err) {
// The 'E2E Tests' status is the required merge gate. If we cannot
// publish it, fail loudly and fail-closed: setFailed keeps the job
// red and the required status unreported ("Expected"), so the PR
// stays non-mergeable.
core.setFailed(`Failed to report E2E Tests status: ${err}`);
return;
}
core.info(`E2E Tests → ${state}: ${description}`);