Skip to content

fix(ci): restore e2e for fork PRs and close the merge-gate bypass - #3262

Merged
Aleksei Sviridkin (lexfrei) merged 50 commits into
mainfrom
ci/fork-pr-e2e
Aug 8, 2026
Merged

fix(ci): restore e2e for fork PRs and close the merge-gate bypass#3262
Aleksei Sviridkin (lexfrei) merged 50 commits into
mainfrom
ci/fork-pr-e2e

Conversation

@myasnikovdaniil

@myasnikovdaniil myasnikovdaniil commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Fixes #3257 — fork PRs never ran e2e, and the skipped required check let them merge anyway.

Fork PRs get no secrets on pull_request, so make image pushed anonymously and died with denied. That failed every build, collapsed the build → finalize → e2e chain, and left the required E2E Tests job skipped — which branch protection treats as satisfied, so an approved fork PR could merge with e2e never having run.

This implements Option A from the issue (privileged workflow_run split), pushing fork e2e images to the existing CI registry (OCIR):

Build primitive (hack/common-envs.mk)

  • New OCI_EXPORT_DIR mode: build each image to a per-image OCI archive instead of pushing, and force PUSH/LOAD off. The digest is captured via --metadata-file regardless of output type, so the refs baked into pr.patch match what the privileged run later pushes. Threaded through the image-tags macro every package already uses — no per-package Makefile change.

pull-requests.yaml (unprivileged, fork branch)

  • build / build-talos / finalize export images to OCI archives and upload them as artifacts instead of pushing → the red denied build wall disappears and the chain no longer collapses.
  • The in-tree e2e job is renamed off "E2E Tests" and guarded to same-repo PRs. A new e2e-report job concludes the required "E2E Tests" context as an explicit check-run (success on a docs-only PR or when e2e passed, failure otherwise). A skipped job can no longer satisfy the required check — this also closes the same hole for same-repo PRs whose build fails.

e2e-fork.yaml (new, privileged workflow_run from the default branch)

  • Resolves the fork PR, opens a pending "E2E Tests" check-run, and fails-closed if the fork build failed.
  • Pushes the pre-built image archives to OCIR by digest and flux-pushes the digest-pinned packages tree. The OCIR token is live only for trusted binaries (git/skopeo/flux/yq) over data — never for make or any fork-authored script.
  • Runs the e2e suite against the pushed closure with no push token and concludes the check-run on the PR head SHA.

Fork build code never receives registry credentials; the fork's images and test scripts run only in the credential-less, ephemeral e2e job — a stronger posture than a pull_request_target run that would hand fork code the secrets directly.

Validation

Local: buildx OCI-archive output + digest capture, macro expansion for single- and multi-image packages, non-fork push path unchanged, actionlint clean.

Needs live validation (why this is a draft): workflow_run artifact download across runs; that the "E2E Tests" commit status satisfies the required context and keeps a fork PR non-mergeable until the privileged run concludes it; refs/pull/<N>/merge fetch + git apply --3way; skopeo --preserve-digests end-to-end. workflow_run only runs from the default branch, so the privileged half is exercised by mirroring the workflow onto a personal fork's default branch and opening a throwaway fork PR into it — not by merging first.

Since review: the fork e2e job ran the pre-#2826 BATS app loop that the merged Chainsaw migration deleted (already broken); it now runs the same Chainsaw + TIA path as the in-tree job, with suite selection and the full-e2e override sourced from resolve's trusted base-repo file list / labels (not a merge-ref diff). Publish job hardened per the non-blocking follow-ups: base-tree image allowlist, skopeo copy --all, pinned flux CLI. The required E2E Tests gate is now published as a commit status (default token + statuses:write), not a COZYSTACK_CI app check-run — the app lacked checks:write (checks.create 403'd) and a default-token check-run floated under a labeler suite; a commit status has neither problem, and forks can't post one (their pull_request token is read-only). Two review-bot findings fixed too: fork OCI_EXPORT_DIR is now absolute (a relative path misplaced archives under make -C <pkg> and every fork build would fail at upload), and the "≥1 archive" guard no longer no-ops under nullglob.

Repo-admin prerequisites

One repository setting lives outside this diff:

  1. Branch protection: keep E2E Tests required. The gate is now a commit status named E2E Tests (posted with the default GITHUB_TOKEN — see docs/agents/e2e-testing.md §10), not a job or an app check-run. Branch protection already requires E2E Tests (currently the e2e job's name, renamed here to E2E (in-tree)), so the required context now resolves to the status — no GitHub App, no checks:write grant, no app-id pin needed. Just don't pin the context to a specific app.
  2. Actions → "Require approval for all outside collaborators". Each fork run must be a deliberate maintainer click before it executes, since the fork's unprivileged run produces the artifacts the privileged e2e-fork.yaml consumes.

Downstream repositories

Walked the trigger map in docs/agents/contributing.md against the diff (7 files: two CI workflows, docs/agents/e2e-testing.md, hack/common-envs.mk + its bats suite, and two package Makefiles).

The one trigger worth naming is "change developer tooling (cozyvalues-gen, cozypkg, the package Makefiles) → development.md" on the website. It does not fire here: OCI_EXPORT_DIR is a CI-only build mode that defaults off, and the local developer workflow is unchanged — make image still builds and pushes exactly as before, which the new default-path case in hack/common-envs_test.bats now pins. No chart values, no API surface, no app list, no platform variant, no Talos version bump and no release asset rename are touched.

  • No downstream repository is affected by this change

Release note

fix(ci): fork pull requests now run e2e via a privileged workflow, and a fork PR can no longer merge on a skipped (never-run) required E2E Tests check

Summary by CodeRabbit

  • New Features
    • Added a dedicated fork E2E workflow that runs after the base PR and reports results via the required E2E Tests commit status.
    • Fork PRs now export build images as OCI archive artifacts for later publishing.
    • Added a same-repo E2E reporting job and renamed the in-tree E2E flow to E2E (in-tree).
  • Bug Fixes
    • Added tighter, fail-closed gating to ensure merge blocking when E2E Tests isn’t correctly finalized.
  • Documentation
    • Documented fork vs same-repo E2E split, OCI export behavior, and required fork security settings.
  • Tests
    • Added Bats coverage validating OCI_EXPORT_DIR and preventing unintended registry pushes.

@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Fork pull requests now export OCI images without registry credentials. A privileged workflow validates and publishes those artifacts, runs sandboxed E2E tests, and reports the required E2E Tests commit status. Same-repository PRs use a separate in-tree E2E path and status reporter.

Changes

Fork E2E pipeline

Layer / File(s) Summary
Fork OCI image export
.github/workflows/pull-requests.yaml, hack/common-envs.mk, hack/common-envs_test.bats, packages/.../Makefile
Fork builds export package, Talos, and installer images as OCI archives; Makefile behavior and archive output are covered by dry-run tests.
Privileged fork artifact publishing
.github/workflows/e2e-fork.yaml
The workflow resolves fork metadata, validates trusted patches and archive names, pushes images by digest, and publishes the packages artifact.
Sandbox E2E execution and status reporting
.github/workflows/e2e-fork.yaml, .github/workflows/pull-requests.yaml, docs/agents/e2e-testing.md
Fork E2E runs with trusted TIA inputs in an ephemeral sandbox, while same-repository E2E and both workflows’ required commit-status behavior are wired and documented.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PullRequestWorkflow
  participant E2EForkWorkflow
  participant OCIR
  participant Sandbox
  participant E2ETestsStatus
  PullRequestWorkflow->>E2EForkWorkflow: workflow_run completion and OCI artifacts
  E2EForkWorkflow->>OCIR: validate and push images by digest
  E2EForkWorkflow->>Sandbox: install published artifacts and run E2E
  Sandbox-->>E2EForkWorkflow: test results and reports
  E2EForkWorkflow->>E2ETestsStatus: conclude E2E Tests commit status
Loading

Possibly related PRs

Suggested labels: area/build

Suggested reviewers: androndo

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main CI change: restoring fork PR E2E and fixing the merge-gate bypass.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ci/fork-pr-e2e

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added size/XL This PR changes 500-999 lines, ignoring generated files area/ci Issues or PRs related to CI workflows, GitHub Actions, automation kind/bug Categorizes issue or PR as related to a bug labels Jul 9, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NOT LGTM — the fork build still dies on an authenticated push, so fork e2e never runs; and the privileged workflow derives the required-check verdict from artifacts produced by fork-controlled workflow code, which re-opens the merge-gate bypass this PR sets out to close.

Business context: fork PRs never run e2e because make image pushes anonymously and fails, and the resulting skipped required E2E Tests check is treated as satisfied by branch protection — so external contributions can merge with zero e2e coverage.

Blockers

B1: packages/core/talos still pushes to the registry on fork builds

File: .github/workflows/pull-requests.yaml:266

Issue: make -C packages/core/talos image is image-matchbox image-talos. image-matchbox goes through image-tags and correctly exports an OCI archive, but image-talos (packages/core/talos/Makefile:13-19) is not a buildx call — it runs skopeo copy "$SRC" docker://$(REGISTRY)/talos:$(IMAGE_TAG) unconditionally. Neither OCI_EXPORT_DIR nor PUSH reaches it.

Evidence: the build-talos job skips Login to ... registry on forks, so this is an anonymous push and fails with denied — the exact failure #3257 describes. finalize requires needs.build-talos.result == 'success', so the chain still collapses, pr-patch is never uploaded, the run concludes failure, and e2e-fork.yaml's resolve fails closed.

Impact: every non-docs fork PR gets a red required check and still never runs e2e. The PR's primary goal is not met.

Fix: honour the export mode in image-talosskopeo copy "$SRC" "oci-archive:$(OCI_EXPORT_DIR)/talos.oci.tar:$(IMAGE_TAG)" when OCI_EXPORT_DIR is set (or skip image-talos on forks if the pushed talos: tag has no e2e consumer).

B2: capi-providers-cpprovider emits a two-manifest OCI archive, which skopeo copy refuses

File: packages/system/capi-providers-cpprovider/Makefile:25

Issue: the recipe keeps two --tag flags ($(IMAGE_BASE):$(IMAGE_TAG) and $(IMAGE_BASE):$(IMAGE_TAG)-$(TAG)). Under --output type=oci buildx writes both refs into the archive's index.json, and e2e-fork.yaml:200-208 copies each archive with no source reference under set -eu.

Evidence: reproduced locally with buildx and skopeo. A two-tag OCI archive yields two manifest descriptors in index.json, and skopeo copy --preserve-digests oci-archive:<file> docker://… fails with more than one image in oci, choose an image; the identical single-tag build copies cleanly. hack/build-matrix_test.bats:98 pins that any hack/common-envs.mk change forces the full build matrix, so cpprovider is present for exactly the class of PR this feature targets, and its archive sorts first in the glob.

Impact: publish aborts on the first archive, e2e never runs, required check red.

Fix: emit a single tag when OCI_EXPORT_DIR is set, or pass an explicit source reference (oci-archive:$f:$IMAGE_TAG) in publish. The same trap is latent in image-tags whenever PUBLISH_VERSIONED or PUBLISH_FLOATING is 1.

B3: the required-check verdict is derived from fork-controlled data

File: .github/workflows/e2e-fork.yaml:122

Issue: resolve concludes E2E Tests = success ("docs-only PR") whenever the triggering run produced no pr-patch artifact. Artifact presence is decided by pull-requests.yaml — and on pull_request that file comes from the PR's merge commit, i.e. the fork's copy.

Evidence: GitHub documents pull_request_target as running "in the context of the default branch of the base repository, rather than in the context of the merge commit, as the pull_request event does"; the workflow only has to exist on the default branch to trigger. A fork can therefore set plan's code output to false, or add if: false to finalize, so every job skips, the run concludes success, no pr-patch is uploaded, and this branch marks the required check green with e2e never run.

Impact: the skip-to-green path is closed for same-repo PRs but re-created for fork PRs, which are the ones #3257 is about. The only thing standing in the way is a reviewer noticing a workflow edit in the diff.

Fix: decide docs-only inside the privileged workflow from trusted data — github.rest.pulls.listFiles against the resolved PR number — and require a pr-patch artifact whenever the file list contains anything outside docs/.

B4: new build logic ships without tests, in an area that has an enforced test convention

File: hack/common-envs.mk:103

Issue: oci-output, the OCI_EXPORT_DIR → PUSH/LOAD override and the relocated comma escape are new, load-bearing expansion logic with no test.

Evidence: Makefile:157 fails bats-unit-tests when no hack/*.bats exists, and hack/build-matrix_test.bats:98 already treats hack/common-envs.mk as covered surface.

Impact: both B1 and B2 are mechanically detectable and shipped undetected.

Fix: add hack/common-envs_test.bats. make -n -C <pkg> image OCI_EXPORT_DIR=/tmp/x prints every recipe without executing it — assert that each unit in the root build: target emits exactly one --tag and one --output type=oci, and that no docker:// push survives.

B5: documentation for the E2E CI is not updated

File: docs/agents/e2e-testing.md

Issue: the doc is the mandated reference for changes to the E2E CI workflow and names .github/workflows/pull-requests.yaml as the E2E workflow; its "Two skip layers" section describes the plan docs-only gate as what skips the pipeline.

Evidence: after this PR there are two E2E workflows, the fork docs-only decision lives in e2e-fork.yaml, and the required E2E Tests context is a synthetic check-run rather than a job — an invariant a future rename of the e2e job would silently break. The diff touches no docs.

Fix: document the split, the check-run indirection, and the repo setting the security model depends on (Actions → Require approval for all outside collaborators) — it belongs in the repo, not only in a PR body.

Non-blocking follow-ups

  1. e2e-fork.yaml:203 — the destination repository is basename of a fork-controlled artifact filename. The tag is pinned to pr-<N>-<sha> so no real ref can be overwritten, but a fork can create arbitrary repositories under the CI registry. Validate name against the expected image set.
  2. e2e-fork.yaml:201-208shopt -s nullglob plus download-artifact's non-fatal empty pattern match lets publish succeed having pushed nothing; e2e then fails on an obscure pull error. Assert at least one archive.
  3. e2e-fork.yaml:156curl -fsSL https://fluxcd.io/install.sh | sudo bash runs an unpinned remote script as root in the job that then loads the registry token. pull-requests.yaml downloads to a temp file first; match that at minimum, and pin the version.
  4. e2e-fork.yaml:162,244refs/pull/<N>/merge is a moving ref, while the artifacts and the check-run belong to resolve.outputs.head_sha. A push landing in between makes publish/e2e operate on a different tree than the SHA the check is stamped on, and can make git apply --3way conflict. Assert git rev-parse HEAD^2 == head_sha after checkout.
  5. e2e-fork.yaml:148packages: write is a ghcr scope; this job authenticates to a different registry with OCIR_USER/OCIR_TOKEN. Drop it.
  6. e2e-fork.yaml:107assoc.data.find(p => p.state === 'open') || assoc.data[0] can resolve a closed PR whose merge ref no longer exists.
  7. skopeo copy runs without --all; if any image ever builds multi-platform, containerimage.digest becomes the index digest while the copy pushes a single manifest, so the digest baked into pr.patch will not resolve.
  8. Fork e2e always runs the full suite (no TIA) for up to 180 minutes on a 32-vCPU self-hosted runner. That cost is gated only by the outside-collaborator approval setting.

Verified and dismissed

These were investigated and are not problems, recorded so they do not get re-raised: the --metadata-file digest does match what gets pushed (checked locally: for a single-tag OCI archive containerimage.digest equals the index.json manifest digest, and skopeo copy --preserve-digests preserves it); anonymous pulls from the CI registry work, since the in-tree e2e job has no registry login either, which also means the overlay-main-images step does not degrade on fork PRs; packages/core/platform/.build-revision is never read anywhere, so the fork path skipping image-packages is harmless; and the installer chart never needs to be pushed, because hack/e2e-install-cozystack.bats:110 installs from the local chart directory.

One process note

#3257 ends with "I am filing this to track the gap and settle direction before implementing — not proposing a specific fix yet", and it has no comments. This picks Option A unilaterally. Option A is the right call on security posture, but the direction was never settled — worth recording the decision on the issue before this leaves draft.

run: make -C packages/core/talos image
run: |
mkdir -p _out/oci
make -C packages/core/talos image

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

B1 — this still fails on fork PRs. packages/core/talos image is image-matchbox image-talos, and image-talos is not a buildx call: it runs skopeo copy "$SRC" docker://$(REGISTRY)/talos:$(IMAGE_TAG) unconditionally (packages/core/talos/Makefile:13-19), so neither OCI_EXPORT_DIR nor PUSH reaches it. With the registry login skipped on forks this is an anonymous push and dies with denied, finalize requires build-talos to have succeeded, and the whole chain collapses exactly as it does today — no pr-patch, run concludes failure, e2e-fork.yaml fails closed.

Suggested fix: skopeo copy "$SRC" "oci-archive:$(OCI_EXPORT_DIR)/talos.oci.tar:$(IMAGE_TAG)" when OCI_EXPORT_DIR is set, or skip image-talos on forks if the pushed talos: tag has no e2e consumer.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 94205dd. image-talos now exports to oci-archive:$(OCI_EXPORT_DIR)/talos.oci.tar:$(IMAGE_TAG) when OCI_EXPORT_DIR is set, falling back to the docker:// push otherwise — a make-level $(if …) rather than a shell if/else, so make -n prints only the taken branch (and the B4 test can assert no ungated docker:// survives). The build-talos job's pr-oci-talos artifact now carries talos.oci.tar, and the privileged publish loop pushes it to $(REGISTRY)/talos:pr-<N>-<sha>.

--cache-from type=registry,ref=$(IMAGE_BASE):latest \
--cache-to type=inline \
$(if $(strip $(OCI_EXPORT_DIR)),,--cache-to type=inline) \
$(call oci-output,cluster-api-control-plane-provider-kamaji) \

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

B2 — oci-output alone is not enough here: the recipe still passes two --tag flags, and with --output type=oci buildx writes both refs into the archive's index.json. e2e-fork.yaml's push loop then runs skopeo copy --preserve-digests "oci-archive:$f" … with no source reference under set -eu.

Reproduced locally: a two-tag OCI archive gives two manifest descriptors and skopeo copy fails with more than one image in oci, choose an image; the same build with one tag copies cleanly. Since hack/build-matrix_test.bats:98 pins that a hack/common-envs.mk change forces the full matrix, this package is in the matrix for exactly the PRs this feature targets — and cluster-api-… sorts first in the glob, so publish dies on the first archive.

Either emit a single tag under OCI_EXPORT_DIR, or pass an explicit source ref (oci-archive:$f:$IMAGE_TAG) in publish. Note image-tags has the same latent trap whenever PUBLISH_VERSIONED/PUBLISH_FLOATING is 1.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 05f7efa. Under OCI_EXPORT_DIR the recipe now emits a single --tag (the :$(IMAGE_TAG)-$(TAG) tag is gated off), so the archive holds one manifest and skopeo copy oci-archive:<f> docker://… resolves without a source reference. hack/common-envs_test.bats asserts capi emits exactly one --tag and one --output type=oci under export so it can't regress.

github.rest.actions.listWorkflowRunArtifacts,
{ owner, repo, run_id: RUN_ID, per_page: 100 },
);
if (!arts.some(a => a.name === 'pr-patch')) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

B3 — this is the bypass, reintroduced. Artifact presence is decided by pull-requests.yaml, and on pull_request that file comes from the merge commit, i.e. the fork's copy (this is precisely the distinction pull_request_target exists for: it "runs in the context of the default branch of the base repository, rather than in the context of the merge commit, as the pull_request event does"). A fork only has to make plan emit code=false, or add if: false to finalize: every job skips, the run concludes success, no pr-patch is uploaded, and this branch concludes the required E2E Tests check green with e2e never having run.

Decide docs-only from trusted data instead — github.rest.pulls.listFiles on the resolved PR number — and require a pr-patch artifact whenever the file list contains anything outside docs/.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a43bf0a. resolve no longer infers docs-only from artifact presence — it derives it from the base-repo PR file list via github.rest.pulls.listFiles: the check is concluded green only when every changed file is under docs/; any non-docs file makes e2e required, and a missing pr-patch artifact then fails the check closed instead of passing it. PR resolution is also open-only now (no closed/merged fallback).

Comment thread hack/common-envs.mk
# call reachable from a fork build must include this — via image-tags, or
# directly for recipes that bypass image-tags (#3257). A missing archive is
# caught loudly by the fork build's `if-no-files-found: error` upload.
oci-output = $(if $(strip $(OCI_EXPORT_DIR)), --output type=oci$(comma)dest=$(OCI_EXPORT_DIR)/$(subst /,-,$(1)).oci.tar)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

B4 — this macro and the OCI_EXPORT_DIR → PUSH/LOAD override are new, load-bearing expansion logic shipped without a test, in a directory with an enforced bats convention (Makefile:157 fails bats-unit-tests when no hack/*.bats is found, and hack/build-matrix_test.bats:98 already treats this file as covered surface).

make -n -C <pkg> image OCI_EXPORT_DIR=/tmp/x prints every recipe without executing it. A hack/common-envs_test.bats asserting that each unit of the root build: target emits exactly one --tag and one --output type=oci, and that no docker:// push survives, would have caught both blockers above.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 25d6339: hack/common-envs_test.bats, run by make bats-unit-tests. For a representative slice of the build: matrix — including capi-providers-cpprovider and core/talos — it asserts that under OCI_EXPORT_DIR each built image emits exactly one --output type=oci and no ungated docker:// push (and a single --tag for capi). That is the check that would have caught B1 and B2.

@myasnikovdaniil

Copy link
Copy Markdown
Contributor Author

Aleksei Sviridkin (@lexfrei) thanks for the thorough review — all five blockers are addressed, plus the follow-ups:

  • B1 (94205dd): image-talos exports an OCI archive under OCI_EXPORT_DIR instead of the anonymous docker:// push.
  • B2 (05f7efa): capi emits a single tag under export (one-manifest archive).
  • B3 (a43bf0a): docs-only is decided from the base-repo PR file list, not fork-controlled artifacts; a missing pr-patch on a non-docs PR fails closed.
  • B4 (25d6339): hack/common-envs_test.bats covers the export mode.
  • B5 (ad996f9): docs/agents/e2e-testing.md documents the split, the synthetic E2E Tests check-run, and the "Require approval for all outside collaborators" prerequisite.

Follow-ups: (1) archive basename validated against a strict charset, (2) publish asserts ≥1 archive, (3) flux installed from a downloaded script rather than curl | sudo bash, (4) both privileged jobs assert HEAD^2 == resolved head SHA, (5) packages: write dropped from publish, (6) open-PR-only resolution. (7) skopeo --all for a future multi-arch build and (8) the full-suite runtime are recorded as known follow-ups in the workflow.

On direction: Option A was chosen for the security posture you outlined — untrusted fork code never sees the push credentials. I'll record that decision on #3257 before this leaves draft.

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request addresses a security and CI reliability issue where fork-based pull requests could bypass required E2E tests and fail due to registry authentication errors. By decoupling the build and test phases, the system now safely handles fork-contributed code in an unprivileged environment while maintaining strict branch protection gates via a synthetic check-run.

Highlights

  • CI Workflow Split: Implemented a two-part workflow system for E2E tests: unprivileged fork builds now export images as OCI archives, while a privileged workflow_run pushes them to the registry by digest.
  • Merge Gate Hardening: Replaced the required 'E2E Tests' job with a synthetic check-run to prevent fork PRs from merging when tests are skipped, ensuring the gate is only satisfied by explicit success.
  • Build System Updates: Added OCI_EXPORT_DIR support to common build macros and updated specific package Makefiles to ensure fork builds avoid anonymous registry pushes.
  • Regression Testing: Introduced unit tests in hack/common-envs_test.bats to verify that OCI export mode correctly handles image archiving and prevents unauthorized pushes.
New Features

🧠 You can now enable Memory (public preview) to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Ignored Files
  • Ignored by pattern: .github/workflows/** (2)
    • .github/workflows/e2e-fork.yaml
    • .github/workflows/pull-requests.yaml
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment Gemini (@gemini-code-assist) Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on Gemini (@gemini-code-assist) comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces support for OCI_EXPORT_DIR, enabling unprivileged fork PRs to export built images as OCI archives instead of pushing them directly to a registry. The changes span hack/common-envs.mk, package Makefiles, documentation, and a new bats test suite. The reviewer feedback highlights several important improvements: gating remote pushes for versioned/floating tags in the Talos Makefile, converting OCI_EXPORT_DIR to an absolute path to ensure consistency across sub-makes, guarding optional tags in the global image-tags macro to prevent multi-tag archive regressions, and a minor grammatical correction in the documentation.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread packages/core/talos/Makefile Outdated
Comment on lines 17 to 19
$(if $(strip $(OCI_EXPORT_DIR)),mkdir -p "$(OCI_EXPORT_DIR)"; skopeo copy "$$SRC" "oci-archive:$(OCI_EXPORT_DIR)/talos.oci.tar:$(IMAGE_TAG)",skopeo copy "$$SRC" docker://$(REGISTRY)/talos:$(IMAGE_TAG)); \
if [ "$(PUBLISH_VERSIONED)" = "1" ]; then skopeo copy "$$SRC" docker://$(REGISTRY)/talos:$(TALOS_VERSION); fi; \
if [ "$(PUBLISH_FLOATING)" = "1" ]; then skopeo copy "$$SRC" docker://$(REGISTRY)/talos:latest; fi

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

When OCI_EXPORT_DIR is set, we should not attempt to push the versioned or floating tags to the remote registry via docker:// as this will fail with a denied error on fork PRs. Gating these skopeo copy commands ensures that no remote pushes are attempted when exporting.

	$(if $(strip $(OCI_EXPORT_DIR)),mkdir -p "$(OCI_EXPORT_DIR)"; skopeo copy "$$SRC" "oci-archive:$(OCI_EXPORT_DIR)/talos.oci.tar:$(IMAGE_TAG)",skopeo copy "$$SRC" docker://$(REGISTRY)/talos:$(IMAGE_TAG)); \
	if [ -z "$(OCI_EXPORT_DIR)" ] && [ "$(PUBLISH_VERSIONED)" = "1" ]; then skopeo copy "$$SRC" docker://$(REGISTRY)/talos:$(TALOS_VERSION); fi; \
	if [ -z "$(OCI_EXPORT_DIR)" ] && [ "$(PUBLISH_FLOATING)" = "1" ]; then skopeo copy "$$SRC" docker://$(REGISTRY)/talos:latest; fi

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is what packages/core/talos/Makefile:17-19 does now. Line 17 sends the primary tag to oci-archive:$(OCI_EXPORT_DIR)/talos.oci.tar when exporting, and lines 18-19 gate the versioned and floating skopeo copy calls on [ -z "$(OCI_EXPORT_DIR)" ], so neither attempts a remote push under export.

hack/common-envs_test.bats covers it: the PUBLISH_VERSIONED=1 PUBLISH_FLOATING=1 case asserts no ungated docker:// push survives with OCI_EXPORT_DIR set.

Comment thread hack/common-envs.mk
Comment on lines +50 to +54
OCI_EXPORT_DIR ?=
ifneq ($(strip $(OCI_EXPORT_DIR)),)
PUSH := 0
LOAD := 0
endif

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since package builds are executed via sub-makes (e.g., make -C packages/...), any relative path passed to OCI_EXPORT_DIR will resolve differently depending on the active directory of the sub-make. Converting OCI_EXPORT_DIR to an absolute path using abspath ensures consistency across all sub-makes. Note that the override directive is required here because OCI_EXPORT_DIR is typically passed as a command-line variable.

OCI_EXPORT_DIR ?=
ifneq ($(strip $(OCI_EXPORT_DIR)),)
    override OCI_EXPORT_DIR := $(abspath $(OCI_EXPORT_DIR))
    PUSH := 0
    LOAD := 0
endif

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not taking this one — the suggested fix does not do what it claims.

The premise is that a sub-make re-resolves a relative OCI_EXPORT_DIR against its own directory. override does not prevent that: command-line variables reach sub-makes through MAKEFLAGS carrying their original value, so the sub-make re-runs abspath against its own cwd. I reproduced it with a two-level makefile using exactly the proposed snippet — the root resolved out/oci to <root>/out/oci and the sub-make to <root>/sub/out/oci, which is the inconsistency the change is meant to remove.

It is also moot in practice: the only caller that sets this is pull-requests.yaml, which already passes an absolute path built from github.workspace.

Comment thread hack/common-envs.mk
Comment on lines 105 to 107
define image-tags
--tag $(REGISTRY)/$(1):$(IMAGE_TAG)$(if $(filter 1,$(PUBLISH_VERSIONED)),$(if $(filter-out $(IMAGE_TAG),$(strip $(2))), --tag $(REGISTRY)/$(1):$(strip $(2))))$(if $(filter 1,$(PUBLISH_FLOATING)), --tag $(REGISTRY)/$(1):latest)
--tag $(REGISTRY)/$(1):$(IMAGE_TAG)$(if $(filter 1,$(PUBLISH_VERSIONED)),$(if $(filter-out $(IMAGE_TAG),$(strip $(2))), --tag $(REGISTRY)/$(1):$(strip $(2))))$(if $(filter 1,$(PUBLISH_FLOATING)), --tag $(REGISTRY)/$(1):latest)$(call oci-output,$(1))
endef

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To prevent the "two-tag regression" (where multiple tags in an OCI archive cause skopeo copy to fail) across all packages using the image-tags macro, we should guard the optional tags so they are only generated when OCI_EXPORT_DIR is empty.

define image-tags
--tag $(REGISTRY)/$(1):$(IMAGE_TAG)$(if $(strip $(OCI_EXPORT_DIR)),,$(if $(filter 1,$(PUBLISH_VERSIONED)),$(if $(filter-out $(IMAGE_TAG),$(strip $(2))), --tag $(REGISTRY)/$(1):$(strip $(2))))$(if $(filter 1,$(PUBLISH_FLOATING)), --tag $(REGISTRY)/$(1):latest))$(call oci-output,$(1))
endef

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and this is what image-tags does now at hack/common-envs.mk:112 — the versioned and floating tags sit behind $(if $(strip $(OCI_EXPORT_DIR)),,…), so only the build-unique tag survives under export and the archive holds a single manifest.

hack/common-envs_test.bats pins it with PUBLISH_VERSIONED=1 PUBLISH_FLOATING=1 forced on; removing the guard fails that case.

Comment thread docs/agents/e2e-testing.md Outdated

- **Fork builds export, they do not push (`OCI_EXPORT_DIR`).** The fork's unprivileged build sets `OCI_EXPORT_DIR` (see `hack/common-envs.mk`) so every image is written to a per-image OCI archive artifact instead of being pushed; the privileged `e2e-fork.yaml` later pushes those archives to the registry **by digest**. Any build recipe that shells out to push directly — e.g. a `skopeo copy … docker://…` that bypasses the `image-tags`/`oci-output` macros — must honour `OCI_EXPORT_DIR` too, or a fork build dies with an anonymous-push `denied` (#3257). This is covered by `hack/common-envs_test.bats`.
- **"E2E Tests" is a synthetic check-run, not a job.** The required merge-gate context named `E2E Tests` is an explicit GitHub check-run — concluded by the `e2e-report` job for same-repo PRs and by `e2e-fork.yaml` for forks — **not** the `e2e` job itself. This is deliberate: a required *job* that is skipped (as the `e2e` job is for forks) counts as passed by branch protection, which is exactly how fork PRs used to merge with e2e never run (#3257). If you rename the `e2e` job, do **not** name it `E2E Tests` and do not wire branch protection to a job name — the check-run must stay the gate.
- **The check-run is published by the COZYSTACK_CI GitHub App, not the default `GITHUB_TOKEN`.** A token-created check-run is owned by the app that created it, and GitHub files it under that app's check-suite. Created with the default token (the "GitHub Actions" app) a *floating* check-run — one with no owning job — attaches to an arbitrary GitHub Actions check-suite for the head SHA, in practice the earliest-registered one, which is a tiny labeler workflow; that is why `E2E Tests` used to render under "PR size label". Publishing it as the app (both `e2e-report` in `pull-requests.yaml` and the `open`/`report` steps in `e2e-fork.yaml` pass `github-token: ${{ steps.app-token.outputs.token }}`) gives the check its own dedicated suite shown under the app's name, and is stricter — only the CI app can satisfy the gate, not any workflow holding the default token. The app token is minted with `permission-checks: write` only; in `e2e-fork.yaml`'s `resolve` the PR/artifact lookups stay on the default `workflow_run` token, so the decision step and the check-publishing step use different tokens by design. **Consequence for branch protection:** the required `E2E Tests` context must be required *from the COZYSTACK_CI app's id*, not from GitHub Actions (`app_id 15368`). If it is pinned to GitHub Actions, the app-published check will not satisfy the gate and every PR stays "Expected"; conversely a `checks.create`/`checks.update` must always run as the same app that opened the pending run (the fork `report` step re-mints the token for exactly this reason).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

low

Add a comma after app) to improve readability and sentence flow.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sentence this points at was removed when the merge gate moved from a check-run to a commit status, so there is no longer a line here to correct.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
.github/workflows/e2e-fork.yaml (1)

354-359: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Digest extraction relies on parsing flux's human-readable log line.

awk -F@ '/artifact successfully pushed/ {print $2}' matches the Flux 2.8.x CLI's actual text output, and the pinned FLUX_VERSION plus the [-n "$digest"] fail-closed check make this reasonably safe today. Flux CLI does support --output json on push artifact for structured digest output (... | jq -r '.repository + "@" + .digest' style), which would be more robust against future CLI wording changes than grepping for a specific sentence.

♻️ Optional refactor
-          flux push artifact "oci://${REGISTRY}/cozystack-packages:${IMAGE_TAG}" \
-            --path=packages \
-            --source=https://github.com/cozystack/cozystack \
-            --revision="fork-pr:${HEAD_SHA}" 2>&1 | tee _push.log
-          digest="$(awk -F@ '/artifact successfully pushed/ {print $2}' _push.log)"
+          out=$(flux push artifact "oci://${REGISTRY}/cozystack-packages:${IMAGE_TAG}" \
+            --path=packages \
+            --source=https://github.com/cozystack/cozystack \
+            --revision="fork-pr:${HEAD_SHA}" --output json)
+          digest="$(echo "$out" | jq -r '.digest')"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/e2e-fork.yaml around lines 354 - 359, Optionally replace
the human-readable log parsing after the flux push artifact command with Flux's
structured --output json response, extracting the repository and digest via jq.
Preserve the existing digest validation and failure behavior while updating the
pipeline to consume the structured output.
hack/common-envs.mk (1)

105-107: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

image-tags still has the multi-tag-under-export trap the capi fix closed elsewhere.

Under OCI_EXPORT_DIR, image-tags still appends --tag …:$(2) / --tag …:latest whenever PUBLISH_VERSIONED/PUBLISH_FLOATING are 1, alongside oci-output's single archive. Today no caller combines OCI_EXPORT_DIR with PUBLISH_VERSIONED=1/PUBLISH_FLOATING=1 (fork builds always pass 0/0), so it's dormant — but this is the exact same "two --tags → two manifests in one OCI archive → skopeo copy dies with more than one image in oci" class of bug that was fixed for capi-providers-cpprovider specifically. Since every other package routes through this shared macro, gating it here once closes the trap for all of them, not just one.

♻️ Suggested guard
 define image-tags
---tag $(REGISTRY)/$(1):$(IMAGE_TAG)$(if $(filter 1,$(PUBLISH_VERSIONED)),$(if $(filter-out $(IMAGE_TAG),$(strip $(2))), --tag $(REGISTRY)/$(1):$(strip $(2))))$(if $(filter 1,$(PUBLISH_FLOATING)), --tag $(REGISTRY)/$(1):latest)$(call oci-output,$(1))
+--tag $(REGISTRY)/$(1):$(IMAGE_TAG)$(if $(strip $(OCI_EXPORT_DIR)),,$(if $(filter 1,$(PUBLISH_VERSIONED)),$(if $(filter-out $(IMAGE_TAG),$(strip $(2))), --tag $(REGISTRY)/$(1):$(strip $(2))))$(if $(filter 1,$(PUBLISH_FLOATING)), --tag $(REGISTRY)/$(1):latest))$(call oci-output,$(1))
 endef
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@hack/common-envs.mk` around lines 105 - 107, Update the shared image-tags
macro so versioned and floating --tag options are omitted whenever
OCI_EXPORT_DIR is active, while preserving the primary tag and oci-output
behavior. Apply the guard to both PUBLISH_VERSIONED and PUBLISH_FLOATING
branches, ensuring OCI archive exports produce only one manifest for every
package using image-tags.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/e2e-fork.yaml:
- Around line 328-333: Replace the ls-based archive count in the OCI push guard
with a nullglob-safe check that directly tests whether _out/oci/*.oci.tar
expanded to at least one file. Preserve the existing failure message and exit
behavior, while leaving the subsequent for loop unchanged.

---

Nitpick comments:
In @.github/workflows/e2e-fork.yaml:
- Around line 354-359: Optionally replace the human-readable log parsing after
the flux push artifact command with Flux's structured --output json response,
extracting the repository and digest via jq. Preserve the existing digest
validation and failure behavior while updating the pipeline to consume the
structured output.

In `@hack/common-envs.mk`:
- Around line 105-107: Update the shared image-tags macro so versioned and
floating --tag options are omitted whenever OCI_EXPORT_DIR is active, while
preserving the primary tag and oci-output behavior. Apply the guard to both
PUBLISH_VERSIONED and PUBLISH_FLOATING branches, ensuring OCI archive exports
produce only one manifest for every package using image-tags.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 8f01a340-fd33-4128-98cf-3131b16cc74a

📥 Commits

Reviewing files that changed from the base of the PR and between 29e5a55 and 5fa0671.

📒 Files selected for processing (7)
  • .github/workflows/e2e-fork.yaml
  • .github/workflows/pull-requests.yaml
  • docs/agents/e2e-testing.md
  • hack/common-envs.mk
  • hack/common-envs_test.bats
  • packages/core/talos/Makefile
  • packages/system/capi-providers-cpprovider/Makefile

Comment thread .github/workflows/e2e-fork.yaml Outdated
myasnikovdaniil added a commit that referenced this pull request Jul 16, 2026
… upload reads

The fork build sets OCI_EXPORT_DIR to a relative `_out/oci`, but each image is
built with `make -C <pkg> image`, so buildx resolves the relative `dest=`
against the package directory — the archive landed in <pkg>/_out/oci/ while the
`if-no-files-found: error` upload reads repo-root `_out/oci/*.oci.tar`. Every
non-docs fork build would have failed at that upload (never caught, since
same-repo PRs leave OCI_EXPORT_DIR empty). Root it at github.workspace so the
dest resolves to repo-root _out/oci regardless of the sub-make cwd.

Reported by gemini-code-assist on #3262 (its abspath suggestion resolves against
the package dir under `make -C`, so this fixes it at the workflow level instead).

Assisted-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
myasnikovdaniil added a commit that referenced this pull request Jul 16, 2026
`count=$(ls _out/oci/*.oci.tar | wc -l)` runs under `shopt -s nullglob`, so
when nothing matches the glob expands to zero words and `ls` lists the current
directory instead — count is always >=1 and the "at least one archive" guard
never fires. A zero-archive build then falls through to `flux push` and defers
the real failure to an obscure image-pull error deep in the ~180-minute e2e
run. Glob into an array (empty under nullglob) and test its length; reuse it
for the push loop.

Reported by coderabbitai on #3262.

Assisted-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
myasnikovdaniil added a commit that referenced this pull request Jul 16, 2026
…n-depth)

Belt-and-suspenders for the B2-class trap lexfrei flagged as latent in
image-tags. PUBLISH_* are 0 on fork PRs, so today the export path never emits a
versioned/floating tag — but if it ever did:

- image-tags: multiple --tag under `--output type=oci` write >1 manifest into
  the archive's index.json, and `skopeo copy oci-archive:<f>` then refuses it
  ("more than one image in oci"). Suppress the versioned/floating tags when
  OCI_EXPORT_DIR is set (macro-level $(if …), so `make -n` prints only the
  taken branch).
- packages/core/talos: gate its versioned/floating `skopeo copy docker://…`
  release copies on an empty OCI_EXPORT_DIR too.

The release (non-export) path is unchanged — versioned + :latest still emitted.
Covered by a new common-envs_test.bats case that forces PUBLISH_*=1 under
export.

Reported by gemini-code-assist on #3262.

Assisted-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
.github/workflows/e2e-fork.yaml (1)

138-143: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Fail closed on renamed or truncated PR file lists. Renamed files can still be code changes: pulls.listFiles exposes previous_filename on renamed entries, so checking only filename lets packages/... -> docs/... skip E2E. Also fail closed if the file list is incomplete; the endpoint is capped at 3,000 files.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/e2e-fork.yaml around lines 138 - 143, Update the PR file
classification around github.paginate and hasNonDocs to treat a file as non-docs
when either filename or previous_filename is outside docs/. Also fail closed
when the paginated file list may be truncated at GitHub’s 3,000-file limit,
ensuring docs-only success is granted only for a complete, verified list.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/e2e-fork.yaml:
- Around line 69-73: Update the fork PR E2E workflow around the TIA outputs from
step r so an empty changed-file selection is treated as full_e2e=true, ensuring
the required suite runs instead of being skipped. Preserve the existing trusted
base-repository inputs and docs-only behavior, and use the existing full_e2e
selector rather than introducing a separate execution path.

---

Outside diff comments:
In @.github/workflows/e2e-fork.yaml:
- Around line 138-143: Update the PR file classification around github.paginate
and hasNonDocs to treat a file as non-docs when either filename or
previous_filename is outside docs/. Also fail closed when the paginated file
list may be truncated at GitHub’s 3,000-file limit, ensuring docs-only success
is granted only for a complete, verified list.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c2ea451d-767b-4f25-8ed4-f596266f113f

📥 Commits

Reviewing files that changed from the base of the PR and between 5fa0671 and 548b82b.

📒 Files selected for processing (6)
  • .github/workflows/e2e-fork.yaml
  • .github/workflows/pull-requests.yaml
  • docs/agents/e2e-testing.md
  • hack/common-envs.mk
  • hack/common-envs_test.bats
  • packages/core/talos/Makefile
🚧 Files skipped from review as they are similar to previous changes (4)
  • docs/agents/e2e-testing.md
  • hack/common-envs_test.bats
  • packages/core/talos/Makefile
  • hack/common-envs.mk

Comment thread .github/workflows/e2e-fork.yaml

@IvanHunters IvanHunters left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict

NOT LGTM

fix(ci): restore e2e for fork PRs and close the merge-gate bypass (+855/-16, 7 files, security-sensitive: fork-PR e2e + workflow_run merge-gate). The trust boundary itself checks out — no proven defect there, and the core security claims were verified by direct reproduction. It falls short of LGTM on a test-adequacy gap plus an unenforced external security dependency.

Findings

[MAJOR] hack/common-envs_test.bats:1-78 — the new OCI_EXPORT_DIR toggle has no off-path (default push) regression test. All four new tests set OCI_EXPORT_DIR=/tmp/ocitest; none exercises the unset/default path that every same-repo and release build still uses. The off-path is currently safe (verified by diffing make -n against merge-base 29e5a554 — controller/talos byte-identical; capi-providers differs only by a harmless blank continuation line) — but that is the reviewer's verification, not a shipped test. Add a default-path case so a future refactor cannot silently break it.

[MINOR] hack/common-envs_test.bats:70-71grep -c -- '--tag' counts matching lines, not occurrences; for cozystack-controller the whole macro expands onto one line, so it always returns 1 regardless of how many --tag flags survive. Proven vacuous by mutation. Fix: grep -o -- '--tag' | wc -l.

[MINOR] PR body is missing the mandatory "Downstream repositories" checklist section from the PR template.

Caveats (non-blocking, but important for a security-sensitive CI change)

  • The entire untrusted-fork execution boundary (now including the new privileged publish / e2e jobs) depends on the repo's "Require approval for all outside collaborators" Actions setting — nothing in the diff enforces it, and no environment: protection rule is used. Worth pinning down out-of-band.
  • .github/zizmor.yml disables dangerous-triggers / template-injection audits repo-wide — precisely the class most relevant to this new privileged workflow_run. zizmor was not runnable in the hermetic review environment.

Prior-feedback reconciliation

  • No verdict mismatch: lexfrei's CHANGES_REQUESTED (2026-07-09) is aligned with this NOT LGTM (both blocking); the two bot reviews are COMMENTED only.
  • New commits (head 548b82bb, after lexfrei's review) appear to structurally address lexfrei's two original blockers, independently verified as OK: (1) fork builds no longer die on authenticated push (login gated if !fork, push credentials only in the separate publish job); (2) the merge-gate no longer trusts fork-controlled artifacts blindly — a HEAD^2 TOCTOU assertion fails closed on SHA mismatch. lexfrei has not re-reviewed, so their review is stale relative to current head. This NOT LGTM stands on fresh, different grounds (test adequacy + unenforced setting).

myasnikovdaniil added a commit that referenced this pull request Jul 21, 2026
Address review feedback from IvanHunters on hack/common-envs_test.bats:70:
`grep -c` counts matching LINES, not occurrences. The image-tags macro
expands onto a single line, so every "exactly one tag survives under
OCI_EXPORT_DIR" assertion returned 1 no matter how many --tag flags were
actually emitted.

Proven vacuous by mutation: removing the OCI_EXPORT_DIR guard from
image-tags restores three --tag flags on that one line and the suite
stayed green. Switching to `grep -o … | wc -l` counts occurrences, and
the same mutation now fails the suite as it should.

Applied to the --output type=oci,dest= assertions too, which count
occurrences the same way and had the same blind spot.

Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
myasnikovdaniil added a commit that referenced this pull request Jul 21, 2026
Address review feedback from IvanHunters on hack/common-envs_test.bats:
every existing case set OCI_EXPORT_DIR, so the OFF path — the one every
same-repo PR, main and release build still takes — had no regression
test at all. A refactor of the export wiring could stop pushing entirely,
or strip the release tags, and the suite would stay green.

Add a case asserting that with OCI_EXPORT_DIR unset buildx still pushes
(--push=1) and writes no archive, image-tags expands all three release
tags, and talos' skopeo copies still target docker:// with the
`[ -z "$(OCI_EXPORT_DIR)" ]` release gates inert.

Mutation-checked: making the PUSH/LOAD override unconditional (so export
mode leaks into the default path) fails this case.

Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
myasnikovdaniil added a commit that referenced this pull request Jul 21, 2026
Address review feedback from coderabbitai on
.github/workflows/e2e-fork.yaml: the docs-only decision read only
`filename`, so a rename reported the NEW path alone. A PR renaming
packages/<app>/values.yaml -> docs/whatever.md therefore looked
docs-only, concluded "E2E Tests" green, and never ran the suite —
while in fact removing a code path. Both sides of a rename must be
under docs/ for a PR to count as docs-only.

`pulls.listFiles` is also capped at 3000 files, and a truncated list
could omit the only non-docs path. Fail closed at the cap instead of
ruling docs-only on a list known to be incomplete.

Feed both sides of a rename to TIA as well: the old path is a removal
the selector must see. Extra paths can only widen the selection, so
this cannot cause a suite to be skipped.

Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
myasnikovdaniil added a commit that referenced this pull request Jul 21, 2026
Both comments stated that select-e2e.sh escalates to the full suite on
any unrecognised path, so fork TIA "can only narrow". That is not what
it does: it escalates for an unrecognised path INSIDE packages/, but a
path it does not recognise at all (hack/*.mk, for one) selects nothing,
the Chainsaw step is skipped, and the required status still concludes
success.

Found by coderabbitai in review. The behaviour is pre-existing and
identical for same-repo PRs through pull-requests.yaml, so it is tracked
separately in #3392 rather than fixed on the fork lane alone — which
would leave forks stricter than same-repo for the same diff. Correct the
comments here so the guarantee is not overstated in the meantime.

Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
@myasnikovdaniil

Copy link
Copy Markdown
Contributor Author

Aleksei Sviridkin (@lexfrei) this review was filed against 3da40bd0 and the branch has moved a long way since — all five blockers and all eight non-blocking follow-ups are addressed, so it would be worth a fresh look rather than a re-read of the old diff.

B1 (talos pushing on fork builds) is closed in 1332a78cimage-talos now honours OCI_EXPORT_DIR. B2 (two-manifest OCI archive) in d5adfb58, and the same trap you flagged as latent in image-tags is now gated at the macro in 548b82bb. B3 (verdict derived from fork-controlled data) in 08b14039 — the docs-only decision comes from pulls.listFiles against the base repo, and TIA inputs come from the same trusted source. B4 in 2862b23d (hack/common-envs_test.bats), B5 in 8d9d25fb (docs/agents/e2e-testing.md §10). The follow-ups landed across 5fa06717, cc6caa04 and 548b82bb: allowlisted image names, an array-based archive guard, pinned flux to a temp file, a HEAD^2 TOCTOU assertion, packages: write dropped, open-PR-only association, skopeo copy --all, and TIA so the fork lane no longer always runs the full 180-minute suite.

Two things changed direction since you looked. The gate is now a commit status, not a check-run (c724cfd7) — a token-created check-run floats into an arbitrary check-suite, and an app-token one would need checks: write; a status needs neither and branch protection matches it by context name. And on your process note: this stays Option A, with the reasoning and the repo-setting prerequisite now written down in docs/agents/e2e-testing.md §10 rather than living only in the PR body.

@myasnikovdaniil

Copy link
Copy Markdown
Contributor Author

IvanHunters both test findings were right, and the MINOR one was worse than reported — thanks for proving it by mutation rather than asserting it.

The grep -c point is fixed in a6ddbcd1. You are right that it counts matching lines: the image-tags macro expands onto a single line, so the assertion returned 1 no matter how many --tag flags survived. I confirmed it the same way you did — removing the OCI_EXPORT_DIR guard from image-tags puts three --tag flags on that one line and the suite stayed green. It is now grep -o -- '--tag' | wc -l, and that same mutation fails the suite. I applied it to the --output type=oci,dest= assertions too, which count occurrences the same way and had the identical blind spot.

The MAJOR one is fixed in dc1d95f3: a case that runs with OCI_EXPORT_DIR unset and asserts buildx still pushes (--push=1), no archive is written, image-tags still expands all three release tags, and talos' skopeo copy calls still target docker:// with the [ -z "$(OCI_EXPORT_DIR)" ] release gates inert. Mutation-checked by making the PUSH/LOAD override unconditional so export mode leaks into the default path — the new case catches it. Your point stands that the off-path being safe was previously only the reviewer's verification and not a shipped test.

The PR body now carries the Downstream repositories section.

On the two caveats: the Actions "Require approval for all outside collaborators" setting genuinely cannot be enforced from the diff, so it is written down as a prerequisite in docs/agents/e2e-testing.md §10 and needs confirming out-of-band on the repo settings — agreed that is unsatisfying, and an environment: protection rule is worth considering separately. The .github/zizmor.yml suppressions predate this PR and I would rather not change repo-wide audit config inside a security-sensitive change; re-enabling dangerous-triggers/template-injection and fixing the fallout deserves its own PR.

Six assertions ran `grep -c` against a variable holding a single line,
so `-eq 1` only asked whether the pattern appeared at all. A key naming
the same field twice — two run-id interpolations, two branch
components — satisfied the pin exactly as the correct key does.
Demonstrated on the run-id assertion: duplicating the interpolation
leaves `grep -c` reporting 1 and passing, while counting occurrences
reports 2 and fails.

Use `grep -o … | wc -l` for the single-line pins. The two whole-file
`cancel-in-progress` counts stay on `grep -c`, where counting matching
lines is the intended question. This is the same correction made once
already in the sibling contract suite; the form did not survive being
copied.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
The check that the head SHA carries exactly one open pull request is a
check on list completeness, and it was reading a single unpaginated
page. GitHub returns 30 associations by default, so the one shape the
check could not see was the shape it exists to catch: a second open PR
sitting past the page boundary. Route the call through `github.paginate`
like the three other lookups in the same script, and read the result as
the array paginate returns rather than as a response envelope.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
Four comments and a documentation bullet claimed that routing the
changed-file list through the base-repo API stops a fork from making
test selection pick nothing. It does not. The `e2e` job runs from the
fork's own tree, so `hack/select-e2e.sh` is the fork's script: a PR that
edits it to print nothing gets `skip=true`, the suite step never runs,
the job succeeds and the required status goes green — the exact outcome
the paragraph presented as prevented. The same holds for the suites
under `hack/e2e-chainsaw/`, the install bats and the testing package's
recipes, all of which the fork authors and this job executes.

Hardening the inputs is still worth doing, since a merge-ref diff would
be a second bypass on top of the one that exists, so the code does not
change. What changes is the claim: the inputs are trusted, the decision
is not, and the fork lane is a correctness gate against an honest
contributor's regression rather than a boundary against a hostile fork.
State plainly what does contain a hostile fork — the maintainer
approval required before any fork run executes, the absence of any
credential in that job, and a diff editing the harness being visible in
review.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
The contract asserts that the conclusions routed into a per-run group
match the ones `resolve` posts nothing for, which reads as covering
every case where a run stays silent. It does not. `resolve` also
returns silently when the triggering run succeeded but its `Plan build`
job skipped, and such a run sits in the shared group where it can still
cancel the live publisher.

That path is unreachable today, because every job in pull-requests.yaml
either carries the discarded-label guard or needs `plan`'s outputs, so a
skipped `plan` yields a skipped run and the conclusion check catches it
first. Adding a job that runs independently of `plan` would reopen it,
and the extraction here matches on the conclusion constant and would not
notice. Say so in the header rather than leaving the next reader to
infer coverage the pin does not provide.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
The privileged lane builds the changed-file list in JavaScript and hands
it to `hack/select-e2e.sh` through base64. `Array.join` puts a separator
between entries and none at the end, so the decoded file had no trailing
newline — and the selector reads it with `while IFS= read -r file`,
which POSIX defines as returning non-zero at EOF without a newline.
The loop body never runs for that last path, so it is silently dropped.

For a single-file pull request, the modal fork contribution, that is the
whole list: the selection comes back empty, the Chainsaw step is skipped
on it, the job concludes success and the required status is published
green with no suite executed. With more files the last one is dropped
instead, which under-selects — and it can also suppress an escalation,
since a path the selector does not recognise inside packages/ is exactly
what would have widened the run to the full suite.

Nothing in the log gave it away: the step prints the list with `sed`,
which renders an unterminated last line the same as a terminated one, so
the log showed a path the selector had never seen.

Terminate the list where it is built. The in-tree lane is unaffected and
always was — it feeds `git diff --name-only`, whose output ends with a
newline — which is why this is the first caller to meet the behaviour.
The read loop itself still drops an unterminated last line for any
future caller; fixing that belongs with the selector and its own test
surface, not here.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
The header claims the third silent path is unreachable because every job
either carries the discarded-label guard or needs `plan`'s outputs. The
`e2e` job does neither literally: it gates on the results of `finalize`,
`build-talos` and `resolve_assets`, which are the jobs that read those
outputs. The conclusion survives — a skipped `plan` still skips it, one
hop further along — but transitivity is the part the claim rests on, and
a reader checking the stated reason against `e2e` finds it false and has
no way to tell whether the conclusion failed with it.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
Moving the gate from a job to a commit status lost a property the job
had for free. A commit status is latest-wins per (sha, context) and
never expires, so a head SHA that already went green keeps reading green
from the moment a new run starts until `e2e-report` posts at the end.
Re-running all jobs, reopening, or adding full-e2e therefore leaves the
pull request mergeable for the whole of 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 the window did not exist.

Claim the context at the start of `plan`, the way the fork lane already
claims it in `resolve`, and let `e2e-report` conclude it as before. The
call is same-repo only: a fork's `pull_request` token is read-only, so
posting from there would 403 and fail the job on every fork PR — and
that read-only token is what makes the whole commit-status design safe.
Fork PRs get their opening status from the privileged workflow instead.

The contract test grows a case pinning that each lane writes the context
from two points, one of them pending. Both assertions on the same-repo
opener are scoped to the `plan` job: the bare fork guard occurs on
several steps in other jobs, so a whole-file "at least one" passed with
the opener's guard deleted, pinning unrelated guards and reporting that
as coverage.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
The case asserting that both lanes open the required status before they
conclude it counted occurrences of the context string per file and
required two. That measured nothing on the fork lane: the two
occurrences there are the shared `setStatus` helper and the terminal
report, while the opener calls the helper and contributes no occurrence
of its own. Deleting the opener outright left all six tests green, under
a comment promising that dropping either half would be caught — and the
window it guards is the wider of the two, since a privileged run holds
the head SHA for up to three hours.

Scope every assertion to the job that owns it and pin the call rather
than a string that happens to appear twice: `plan` opens with `pending`
under a same-repo guard, `e2e-report` concludes, `resolve` opens through
the helper, `report` concludes. Removing any one of the four now fails
this test.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
… run

The step that claims the required context is the first in `plan`, and
`checks`, `build`, `build-talos`, `finalize` and `e2e` all hang off that
job. A transient failure from the status API would therefore discard a
full build and suite for a call that is an optimisation, not a gate.

Mark it `continue-on-error`. Losing the opener degrades exactly to the
behaviour that preceded it — the head SHA keeps whatever status it had
until `e2e-report` concludes — while a run that dies before reporting
leaves the merge blocked either way. The terminal post keeps its own
error handling, because that one failing silently is what would let a
run finish with no verdict at all.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
The section that tells the next editor to keep exactly one run per head
SHA responsible for the status listed only two of the three jobs that
write it, and described the same-repo lane as posting once at the end.
Both stopped being true when the same-repo lane gained an opener. The
section also spent a paragraph on the concurrency invariants without
saying why an opener has to exist at all, which is the part a reader
would otherwise remove as redundant.

Say that each lane opens with `pending` and concludes, that a status
never expires so the opener is what stops a re-run inheriting the
previous run's green, and that `plan`'s opener is `continue-on-error`
because every build and test job hangs off that job.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
The two lanes answered "is this docs-only" from different sources and
could disagree. `plan` reads `git diff --name-only`, which applies rename
detection and reports a rename as its new path alone, so moving a code
file into docs/ read as docs-only. The fork lane reads the PR file list,
where a rename carries `previous_filename`, and reads the same change as
code.

Disagreement is not a near-miss here. The same-repo lane builds nothing
and uploads no patch artifact; the fork lane concludes e2e is required
and then fails closed because that artifact is missing, reporting that
the fork build did not complete. It completed — it correctly built
nothing. There is no push the contributor can make to clear it and no
label that overrides it, so a legitimate contribution is permanently
red.

Point the docs-only decision at the rename-blind list, which the job
already computes for the downstream-trigger-map flag and whose comment
names this exact trap. Both lanes now agree, and the same-repo side
stops shipping a removed code path untested — until now such a change
skipped every build and reported e2e as not required. The build matrix
keeps reading the rename-detected list: it picks packages to rebuild,
and a path that no longer exists has nothing to build.

The contract file gains the agreement as a pin, and grows a header to
match what it now covers. The fork half is pinned on the docs-only
expression rather than on `previous_filename`, which also appears in the
file list a few lines down and would have kept the assertion satisfied
with the check deleted.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
The invariant that a cancelled run publishes nothing was written down in
three places and held in two of them.

The fork lane's terminal reporter ran under `always()`, which includes
cancellation. When a second privileged run supersedes the first, the
dying one still posted `failure` on a head SHA it no longer owned, for a
suite that never finished. It self-corrected once the superseding run
reported, but that can be two hours later, and until then a legitimate
pull request reads red. The same-repo lane was moved off `always()` for
exactly this reason. Move this one too: a failed publish or e2e still
reports, since neither is a cancellation, and a cancelled run now leaves
the `pending` it opened standing until its successor concludes.

The same-repo guard's rationale cited a scenario that no longer exists.
It described a label event cancelling the run that opened the pull
request, which the concurrency split made impossible for every label the
workflow discards. The guard is still needed, for a push, for a
`full-e2e` label superseding the head run, and for a manual cancel, so
the comment now names those instead of an extinct one.

The reference section listed the pieces this invariant rests on and
omitted the reporters, which is the half a reader is most likely to
touch, and counted them, which was already wrong and would go wrong
again on the next addition. It now enumerates without counting.

The contract pins both reporters, because both lanes have had this
defect at different times in the same shape.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
…ed list

The comment justified the split by saying a path that no longer exists
has nothing to build. That is only half the picture: on a rename between
packages the source package is still there and still needs rebuilding,
and rename detection drops it from the list the matrix reads, so the
matrix under-builds. The behaviour predates this change and is out of
scope for a docs-only decision, but the comment presented it as
deliberate and correct rather than as inherited and left alone.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
The reference section claimed both lanes open the required context when
their run starts, and enumerated the triggers it protects against: a
re-run, a reopen, a `full-e2e` label. Every one of those starts a Pull
Request run, and on a fork that run cannot open the status at all. The
opener is guarded same-repo only, because a fork's `pull_request` token
is read-only, and the fork's opener lives in the privileged workflow,
which starts only once the unprivileged build has finished. So on the
lane the sentence was most likely to be read about, an already-green
head SHA keeps reading green for the whole build.

The gap is structural. A read-only token cannot post a status at any
point in the run, and that same read-only token is what makes publishing
the gate with the default token safe in the first place. It is bounded
by the build rather than open-ended. What was wrong was only the claim
that it does not exist.

Say so in all three places that carried the overstatement: the section
itself, the opener's own comment, and the contract test, which pins that
each lane has an opener and cannot express when it lands.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
`resolve` posted its `pending` last, after four paginated API calls. Any
of them returning 5xx fails the job, `should_run` is never set, `report`
skips on it, and nothing writes the context at all. A head SHA that was
already green then stays green with no suite behind it, for good rather
than for the length of a build, and the privileged run's own red is
invisible on the pull request because a `workflow_run` check suite hangs
off the default branch. That is the exact state the opener exists to
prevent, and the same-repo lane already treats this failure mode twice,
with `continue-on-error` on its opener and a try/catch around its
terminal post.

Post the pending as soon as the run is known to own the SHA, which is
immediately after the two silent returns. Three of the four lookups now
happen with the context already claimed, so a failure in any of them
leaves `pending` standing, and pending blocks.

The jobs lookup keeps its place ahead of the opener because the
discarded-label decision reads it, and posting before that decision is
exactly what lets a run stamp a status it does not own. A failure there
still leaves the SHA untouched; that half is a deliberate trade between
two ways of being wrong, and it is now written down next to the code
rather than implied by the ordering.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
The check collected every comparison against the label name and required
them to agree on one string. Agreement is not presence: deleting
`e2e-report`'s guard outright leaves the other two agreeing with each
other, so the assertion stayed green while a discarded label event began
publishing again and clobbering the verdict of the run that had actually
tested the SHA. That clobber is the defect two earlier commits were
written to stop.

Require the guard inside each of the two jobs that must carry it, and
keep the agreement check for the rename it does catch. Deleting either
copy now fails.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
Two sentences predate the second workflow and were left describing the
world before it.

The scope line at the top is the pointer AGENTS.md routes agents through
before they touch the E2E CI, and it named only the in-tree workflow. A
reader sent there to change the fork lane would not learn that the
section holding its invariants exists, which is the one thing that line
has to do. It now names both workflows and says which pull requests each
serves.

The TIA escalation list said edits to "the E2E workflows" escalate to
the full suite. That was true while there was one. The selector's list
names `pull-requests.yaml` and not `e2e-fork.yaml`, so a change touching
only the fork workflow selects nothing: the Chainsaw step is skipped,
while the platform install and the OpenAPI tests still run. Say that
plainly, and say why it is tolerable rather than pretending it is
designed. A `workflow_run` always executes the default-branch copy of
the file, so running the suite against a change to it would not exercise
that change anyway. It is the same shape as the fail-open the section
already cites.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
The guard requiring exactly one open pull request was written for PRs
that share a head commit, but the endpoint it reads is broader than
that. Per the API reference it lists pull requests ASSOCIATED with a
commit, which for a commit outside the default branch means any open PR
whose branch contains it. Stacking one branch on another and opening
both therefore counts as two, and the lower one fails closed on a state
its author cannot clear by pushing, because pushing does not remove the
PR above it.

That is the same unfixable red this workflow refuses to inflict
elsewhere, and it costs a contributor the one thing fail-closed is
supposed to protect: a way forward.

Filter the associations by head SHA as well as state. Two pull requests
genuinely pointing at the same commit still fail closed, which is the
case the guard exists for and the case where a verdict computed for one
PR would otherwise satisfy the other. Checked against all four shapes:
stacked branches now resolve, a real collision still refuses, a lone PR
resolves, and an association with no open PR at the head still refuses.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. The run-ownership hole from my last review is closed at 3af1ad3: when the triggering run concluded cancelled or skipped, the privileged run's concurrency key now appends that run's id, so a label event landing mid-flight no longer kills the real run and wedges E2E Tests on the SHA. The contract test pins this among eight cases, and I verified the cases are load-bearing: mutations against the opener, the reporter condition, the skipped-conclusion key and the label guard each redden exactly their own case.

Since my last pass the branch also closed several holes I had not flagged, and I checked each against the file history rather than the replies: the two publishers racing on one SHA are gone, resolve now matches pull requests by head SHA instead of taking the first open one (this also removes a permanent false red for stacked PRs), the fork TIA no longer drops the last file of the changed list, and the same-repo lane posts a pending status while its run is in flight. That last one is observable on this very PR right now: E2E Tests sits pending with the full suite running, which is the stale-green window closing in practice, not just in the diff.

One transition note for reviewers of other PRs: open fork PRs will show one red E2E Tests until their next push, because the privileged copy executes from the default branch and predates their runs. That is expected and self-heals on push.

Non-blocking, worth a follow-up commit or PR: the allowlist derivation silences make -n stderr and only checks the count is non-zero, so a dropout of one package's names would surface as a confusing contributor-side failure; a one-line warning would name the real cause. The unpinned yq install and the never-executed actionlint config are pre-existing and tracked in #3640 and #3641, and the parse-time side effects of common-envs.mk in #3643.

Reviewed with Opus assistance; two consecutive clean passes on this head.

@lexfrei
Aleksei Sviridkin (lexfrei) dismissed IvanHunters’s stale review August 8, 2026 01:23

Dismissing as stale: the MAJOR finding (no default-path regression test) is addressed at the current head, hack/common-envs_test.bats now carries a dedicated default-path case asserting pushes and release tags survive with OCI_EXPORT_DIR unset, and the out-of-band caveat about the fork-approval Actions setting has been pinned down since (approval required for all outside collaborators). Head moved from 548b82b to 3af1ad3 with two full review passes in between.

@lexfrei
Aleksei Sviridkin (lexfrei) merged commit 7dca642 into main Aug 8, 2026
43 of 44 checks passed
@lexfrei
Aleksei Sviridkin (lexfrei) deleted the ci/fork-pr-e2e branch August 8, 2026 01:26
Aleksei Sviridkin (lexfrei) pushed a commit that referenced this pull request Aug 8, 2026
…3511)

## What this PR does

Closes #3392. Stacked on top of #3262 — that PR turns the `E2E Tests`
merge gate into a commit status, which is what makes this fail-open path
matter: a skipped required *job* used to pass branch protection either
way, but a status posted `success` with no suite run is an explicit
green verdict on untested code.

`hack/select-e2e.sh` silently selected nothing for a changed path it did
not recognise outside `packages/`. Both lanes read an empty selection as
"skip Chainsaw" and then report green. The worked example from the issue
is `hack/common-envs.mk`, which supplies the tag, push and output flags
of every image in the tree and matched nothing: `full_suite_pattern`
covered `hack/*.sh` and `hack/*.bats` but not `hack/*.mk`. Walking the
tree turned up five more in the same position — `pkg/`, `tools/`,
`go.mod`/`go.sum`, `hack/lib/` and `hack/buildkitd.toml` — plus
`.github/workflows/e2e-fork.yaml`, which #3262 adds while
`pull-requests.yaml` was already escalated.

As the issue argues, a blanket "empty selection means full suite"
fallback would be the wrong fix, because it escalates exactly the inert
paths the design deliberately skips. Instead every changed path now
lands in one of three classes by an explicit rule:

- `full_suite_pattern` — shared build inputs and the e2e harness:
`packages/library/`, `packages/core/`, the Go trees (`api/`, `cmd/`,
`internal/`, `pkg/`), the codegen under `tools/`, `go.mod`/`go.sum`,
`hack/*.sh|*.bats|*.mk`, `hack/lib/`, `hack/buildkitd.toml`,
`hack/e2e-*.yaml`, the `Makefile`, and the workflows that run the suite.
- `inert_config_pattern` — paths that cannot affect what e2e exercises:
`examples/`, `.github/` (bar the e2e workflows), `.claude/`, `.gemini/`,
`img/`, `hack/testdata/`, the codegen boilerplate header, and the
top-level meta files (`LICENSE`, `.gitignore`,
`.pre-commit-config.yaml`, `.coderabbit.yaml`).
- the PackageSource graph for `packages/(apps|system|extra)/`,
unchanged.

A path matching none of them escalates to the full suite and logs which
path forced it, so the next path someone adds fails safe rather than
fails open. The two lists are checked in that order, so a specific
escalation beats a broad inert directory — `.github/` is inert,
`.github/workflows/e2e-fork.yaml` is not. `*.md`, `docs/` and
`dashboards/` stay ahead of both, so a `README.md` under
`packages/core/` does not inherit that tree's escalation.

The second commit removes the `|| [ -z "$SELECTED_APPS" ]` arm from the
Chainsaw step in both lanes. Under a step gated on `skip != 'true'` it
could never decide anything the `FULL_E2E` test had not, while the
comment above it advertised "full suite when the selection is empty" in
the file whose whole point is that a green status means tests ran.

### Deliberate choices worth a second opinion

- **Which workflows escalate.** The four that actually invoke the suite
(`pull-requests`, `e2e-fork`, `e2e-tag`, `nightly`), enumerated rather
than matched by prefix so an unrelated workflow does not burn a full
run. That does mean an `e2e-tag.yaml` or `nightly.yaml` edit now costs a
full PR suite even though the lane it changes is not the PR lane. Easy
to narrow to the two PR lanes if that is the wrong trade.
- **`go.mod`/`go.sum` escalate.** A dependency bump changes the shipped
binaries, so this is the honest classification, but it does make
Renovate's Go PRs pay for a full suite.

### Verification

- `hack/select-e2e_test.bats`: 25 tests green (15 existing, 10 new),
covering both sides of the classification.
- Mutation-checked rather than asserted: reverting the fall-through
fails the unclassified-path test; dropping `.github/` from the inert
list fails the non-e2e-workflow test; swapping the order of the two list
checks fails the existing `pull-requests.yaml` test.
- The per-entry escalations are belt-and-braces by design: removing
`hack/*.mk` from the pattern still escalates via the fall-through, so
that test pins the outcome rather than the pattern entry. Called out so
nobody reads it as stronger coverage than it is.
- `make bats-unit-tests`: 417 tests, 0 failures. `actionlint` clean on
both workflows.
- Self-check: this branch's own diff selects the full suite (it touches
both e2e workflows and a `hack/*.sh`).

### Downstream repositories

Walked the trigger map in `docs/agents/contributing.md` against the diff
file by file. The only `hack/`-related trigger is `cozystack/ccp`, which
gates on `hack/package.mk` and `hack/common-envs.mk` existing as anchor
files and on what `make generate` does — nothing here is moved or
renamed, neither anchor file is touched, and no make target changes
behaviour (`BATS_UNIT_FILES` already globbed `hack/*.bats`). The rest of
the diff is e2e workflows and agent docs, which appear nowhere in the
map.

- [x] No downstream repository is affected by this change
- [ ] [cozystack/website](https://github.com/cozystack/website) -
follow-up:
- [ ]
[cozystack/terraform-provider-cozystack](https://github.com/cozystack/terraform-provider-cozystack)
- follow-up:
- [ ]
[cozystack/ansible-cozystack](https://github.com/cozystack/ansible-cozystack)
- follow-up:
- [ ] [cozystack/ccp](https://github.com/cozystack/ccp) - follow-up:
- [ ] [cozystack/talm](https://github.com/cozystack/talm) - follow-up:
- [ ] [cozystack/cozyhr](https://github.com/cozystack/cozyhr) -
follow-up:
- [ ] [cozystack/cozy-proxy](https://github.com/cozystack/cozy-proxy) -
follow-up:
- [ ]
[cozystack/cozystack-telemetry-server](https://github.com/cozystack/cozystack-telemetry-server)
- follow-up:
- [ ]
[cozystack/external-apps-example](https://github.com/cozystack/external-apps-example)
- follow-up:
- [ ] [cozystack/examples](https://github.com/cozystack/examples) -
follow-up:

### Release note

```release-note
fix(ci): Test-Impact Analysis now classifies every changed path explicitly and escalates an unrecognised one to the full E2E suite. Previously a path the selector did not recognise (`hack/*.mk`, `pkg/`, `go.mod`, `hack/lib/`, the fork E2E workflow) selected no suites, which both E2E lanes read as "nothing to test" and reported the required `E2E Tests` gate green with no suite run.
```

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Improved end-to-end test selection so unrecognized or high-impact
changes trigger the full suite instead of being skipped.
* Inert-only changes now safely skip testing, while empty selections no
longer trigger the full suite.
* Full-suite testing is triggered explicitly by the `full-e2e` label or
applicable change types.

* **Tests**
* Added coverage for build files, source code, workflows, scripts,
documentation, metadata, and unclassified paths.

* **Documentation**
* Clarified end-to-end test selection, escalation rules, skipped file
categories, and workflow handling.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Aleksei Sviridkin (lexfrei) added a commit that referenced this pull request Aug 11, 2026
…#3569)

## What this PR does

A run joins its concurrency group before any job-level `if` is
evaluated, so `prepare`'s guard in `backport.yaml` is too late to help.
By the time it works out that a label event is irrelevant, that run has
already cancelled whatever was in flight for the same PR. Labels set
with the default `GITHUB_TOKEN` start no run, which is why the in-repo
labelers never showed this. Third-party GitHub Apps do start runs, and
they arrive in bursts.

The delivery survived, which is why this went unnoticed for so long. The
old guard admitted a run on the PR's cumulative label set, so the last
label of a burst, the one nothing cancels, requalified and redid the
work. In the bursts on record the kill lands within seconds of the merge
run starting, well before the cherry-pick, so the cost was a restart
rather than a lost backport, recorded as nothing more than a cancelled
run sitting next to a green one.

Two changes to the concurrency key, and both are needed. A label event
that requests no backport now goes into a group of its own, so it can
neither cancel nor displace the run doing the work. A label event that
does request one queues behind that run instead of killing it, because
`backport` and `backport-previous` are separate requests and both have
to finish. Splitting alone would let `backport` cancel the
merge-triggered run. Queuing alone would let an unrelated label evict a
pending request, since the group holds a single pending run and a newer
one replaces it.

`prepare`'s condition is scoped in the same commit. It read the PR's
cumulative label set, so once a merged PR carried `backport`, any later
unrelated label re-entered the job to redo a backport already delivered.
That run used to get cancelled by the next label in the burst. After the
change above it runs to completion, so the scoping has to land here
rather than as a follow-up.

The coupling runs the other way too, and it is the sharper half. That
redundant run is exactly what redelivered the backport the burst had
just killed, so narrowing the guard on its own would convert a noisy
delivery into a missing one. Neither change is safe to land without the
other.

Both jobs also gain a `timeout-minutes` ceiling, which is a consequence
of the queuing rather than housekeeping. A cancelling key disposed of a
stuck run by killing it; queuing makes the next genuine request wait
behind it instead, on the six-hour job default, which turns one wedged
job into a six-hour hole in the release line. That ceiling reaches a
stuck job and nothing else: a run can also wedge at the run level with
every job already finished, and `timeout-minutes` has no run-level
equivalent, so nothing in the workflow reaches that state.
`docs/release.md` now tells an operator to look for a run still in
flight and clear it before re-applying the label, because the retry
queues behind it rather than replacing it. The ceiling bounds execution,
not the wait for a runner, and thirty minutes is generous rather than
calculated. Execution on run 29844315422 was six seconds for `prepare`
and two and sixteen for the matrix legs, against 9m24s, 5m28s and 5m45s
of runner queue for the same three jobs. That queue is a third unbounded
case: GitHub's limits page says "job execution time" for the six-hour
job cap while spelling out that the 35-day run cap "includes execution
duration, and time spent on waiting and approval", and
[orgs/community#50926](https://github.com/orgs/community/discussions/50926)
shows a job sitting six hours on "Waiting for a runner to pick up this
job" under `timeout-minutes: 5`. So the value is picked to leave room
for a cherry-pick far larger than any on record while replacing the
six-hour default, and nothing here is sized against the queue, because
nothing here can be.

This removes a mitigation, and the trade is worth naming on both sides.
Under an unconditional cancel a run wedged at the run level cleared
itself, because the next event killed whatever was sitting in the group.
That is the same behaviour that killed live backports, so the mitigation
is inseparable from the bug being fixed: keeping it means keeping label
events that cancel a backport in flight. What it buys is a change of
failure mode rather than one fewer failure. A backport lost silently,
discovered weeks later as a fix missing from a release line, becomes a
delay the operator can see in the run list, with the recovery written
into `docs/release.md`. There is no lossless alternative here: `queue:
max` removes the eviction but only by dropping the conditional cancel
entirely, and the group key cannot tell a manual retry from a second
genuine request.

The run cited above, 31232645133, is still live and retained on purpose:
it is the only observed instance of the run-level wedge, so clearing it
would remove the evidence for the paragraph that describes it. Its
exposure is one pull request, #3262, which carries no backport label, so
nothing is queued behind it.

The old condition also carried a disjunct that could never decide
anything. A `labeled` payload already lists the new label in
`pull_request.labels`, so `contains` was true whenever the explicit
`github.event.label.name` check was, and the name check only ever agreed
with a `contains` that had already matched. The restructure drops it by
construction rather than by deletion: the name check is now the whole of
what a `labeled` event may match on.

### Why this no longer touches `pull-requests.yaml`

`main` fixed it there by splitting the group key, then refined that so a
`full-e2e` label stays in the main group instead of publishing a second
`E2E Tests` status on one SHA. That is the better mechanism, it neither
queues nor delays, and `gate-concurrency-contract.bats` now pins
`cancel-in-progress: true` in that file, so what this PR originally did
would turn it red. Nothing was left to add there, so the hunk is dropped
and its pin with it. The split-key idea is applied in `backport.yaml`
instead, where it did not exist.

One claim from the earlier description is withdrawn. It said the
leftover risk was a duplicate backport PR, opened when the original had
merged and its branch had been deleted before the queued run pushed.
`main` has since gained a guard that lists closed PRs on the
`backport-<n>-to-<target>` branch and skips the target when one of them
merged. Branch deletion does not defeat it: `GET
/branches/backport-3510-to-release-1.6` is 404 while the same head
filter on `/pulls?state=closed` still returns the merged PR. So a
redundant run only cost runner minutes, and the scoping above removes
the run.

### Screenshots

Not applicable, no UI change.

### Downstream repositories

Walked the trigger map in `docs/agents/contributing.md` against the
diff. The only workflow it cites is `tags.yaml`, for the release-prep
behaviour `cozystack/ccp` documents. Every `hack/` trigger it lists
either names a specific file another repository mirrors or gates on, or
covers moving and renaming under `hack/`. This PR appends tests to a
contract file the map does not name, and moves nothing. No package,
chart, values file, CRD, image reference, namespace, variant or node
prerequisite in the diff.

- [x] No downstream repository is affected by this change
- [ ] [cozystack/website](https://github.com/cozystack/website) -
follow-up:
- [ ]
[cozystack/terraform-provider-cozystack](https://github.com/cozystack/terraform-provider-cozystack)
- follow-up:
- [ ]
[cozystack/ansible-cozystack](https://github.com/cozystack/ansible-cozystack)
- follow-up:
- [ ] [cozystack/ccp](https://github.com/cozystack/ccp) - follow-up:
- [ ] [cozystack/talm](https://github.com/cozystack/talm) - follow-up:
- [ ] [cozystack/cozyhr](https://github.com/cozystack/cozyhr) -
follow-up:
- [ ] [cozystack/cozy-proxy](https://github.com/cozystack/cozy-proxy) -
follow-up:
- [ ]
[cozystack/cozystack-telemetry-server](https://github.com/cozystack/cozystack-telemetry-server)
- follow-up:
- [ ]
[cozystack/external-apps-example](https://github.com/cozystack/external-apps-example)
- follow-up:
- [ ] [cozystack/examples](https://github.com/cozystack/examples) -
follow-up:

### Release note

```release-note
fix(ci): stop `labeled` pull request events from cancelling the automatic backport run already in flight. A burst of labels from a third-party app could previously kill the backport the merge had just started, leaving the work to be redone by whichever label arrived last.
```
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/ci Issues or PRs related to CI workflows, GitHub Actions, automation kind/bug Categorizes issue or PR as related to a bug size/XXL This PR changes 1000+ lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fork pull requests never run e2e — build image push is denied, and the skipped required check still lets the PR merge

4 participants