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
# e2e-fork.yaml pushes a fork's images only under names the base tree
# already exports, so an image the PR adds is refused there after the
# whole build. Refuse it here instead, before anything builds. The push
# check stays the boundary; a fork can edit this step away.
- name: Refuse images the base branch does not build (fork PR)
if: ${{ github.event.pull_request.head.repo.fork }}
run: |
bt="$(mktemp -d)/basetree"
git worktree add --quiet --detach "$bt" HEAD^1
hack/fork-new-images.sh "$bt" .
- 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//). 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 /_out/oci/.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 `, so buildx resolves a relative dest
# against the package dir — the archive would land in /_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: for every maintained release-.
# 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-- 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's presence is still checked in e2e-tag.yaml and promote-rc.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, like
# every other e2e lane. What containers cannot carry has no lane at all:
# 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
# Covers a green full suite. It does not cover a tenant-Kubernetes suite
# running into its node-join deadline, so a red there can end as a
# cancelled job. 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:-}"
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:-}"
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}`);