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 }} trigger_map: ${{ steps.p.outputs.trigger_map }} 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@34e114876b0b11c390a56381ad16ebd13914f8d5 # 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 # docs/agents/contributing.md carries the downstream trigger map, which # hack/downstream-trigger-map.bats asserts against: every path the map # cites still exists, and its repository list still matches the PR # template. Editing the map alone is a docs-only PR, which would skip the # unit tests and exempt the map from its own guard. This flag lets the # unit-test job opt back in, and only that job: it deliberately does not # feed `code`, so a docs PR still builds nothing. # # Detect the map by its old path too, which is why this reads the # rename-blind list above: a PR that moves the map would otherwise skip # the very test that pins the literal below to it, and break for # whoever pushes next. if grep -qxF 'docs/agents/contributing.md' /tmp/changed_norenames.txt; then echo "trigger_map=true" >> "$GITHUB_OUTPUT" else echo "trigger_map=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@34e114876b0b11c390a56381ad16ebd13914f8d5 # 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@34e114876b0b11c390a56381ad16ebd13914f8d5 # 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; the Flux installer is not, because # it checksums the binary it fetches and the mutable part is its own # bootstrap script. promote-rc.yaml's copy of this step carries the full # reasoning for both. - name: Set up promotion toolchain (flux, yq) env: FLUX_VERSION: "2.8.6" YQ_VERSION: "4.53.3" YQ_SHA256: "fa52a4e758c63d38299163fbdd1edfb4c4963247918bf9c1c5d31d84789eded4" run: | if ! flux version --client 2>/dev/null | grep -qx "flux: v${FLUX_VERSION}"; then install_script="$(mktemp)" curl -fsSL https://fluxcd.io/install.sh -o "$install_script" sudo env "FLUX_VERSION=$FLUX_VERSION" bash "$install_script" rm -f "$install_script" fi 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 permissions: contents: read needs: ["plan"] # trigger_map opts a docs-only PR back in when it edits the downstream trigger # map, so hack/downstream-trigger-map.bats still guards it. No other job reads # that flag: a docs PR runs the unit tests and nothing heavier. if: | (needs.plan.outputs.code == 'true' || needs.plan.outputs.trigger_map == 'true') && !contains(github.event.pull_request.labels.*.name, 'release') steps: - name: Checkout code uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 # Ephemeral runners don't carry the helm-unittest plugin the persistent # self-hosted runner accreted. Install if absent (idempotent). - name: Set up test toolchain run: | helm plugin list 2>/dev/null | grep -q unittest \ || helm plugin install https://github.com/helm-unittest/helm-unittest # One make process owns all four runner CPUs. Each BATS file is a separate # prerequisite, so the long tail no longer runs as one nine-minute shell # loop; Helm and Go targets share the same bounded pool. Output stays # grouped by target instead of four test logs interleaving. - name: Run unit and controller tests run: make unit-tests test-controllers -j4 --output-sync=target # 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 packages: write strategy: fail-fast: false matrix: pkg: ${{ fromJSON(needs.plan.outputs.matrix) }} steps: - name: Checkout code uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 fetch-tags: true # installer is built in finalize, but some package image targets shell out # to flux; install it if absent (idempotent, cheap). - name: Set up build toolchain run: | if ! command -v flux >/dev/null; then install_script="$(mktemp)" curl -fsSL https://fluxcd.io/install.sh -o "$install_script" sudo bash "$install_script" rm -f "$install_script" fi - 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 }} # 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 packages: write steps: - name: Checkout code uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # 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 }} 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 packages: write steps: - name: Checkout code uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 fetch-tags: true - name: Set up build toolchain run: | if ! command -v flux >/dev/null; then install_script="$(mktemp)" curl -fsSL https://fluxcd.io/install.sh -o "$install_script" sudo bash "$install_script" rm -f "$install_script" fi - 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 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: | rm -rf _out/mainpkgs mkdir -p _out/mainpkgs if ! flux pull artifact "oci://${REGISTRY}/cozystack-packages:${BASE_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 [ "${BASE_REF}" = "main" ]; then echo "cozystack-packages:${BASE_REF} pull failed — keeping committed refs" else echo "::warning title=No packages artifact for ${BASE_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 ${BASE_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 }} 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@34e114876b0b11c390a56381ad16ebd13914f8d5 # 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@34e114876b0b11c390a56381ad16ebd13914f8d5 # 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" # ▸ 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 install-time workload whose chart selects # `replicated` explicitly instead of inheriting the default class. COZY_E2E_STORAGE_CLASS: local 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 # 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 - 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 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}`);