fix(ci): restore e2e for fork PRs and close the merge-gate bypass - #3262
Conversation
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughFork 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 ChangesFork E2E pipeline
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
Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
Aleksei Sviridkin (lexfrei)
left a comment
There was a problem hiding this comment.
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-talos — 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).
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
e2e-fork.yaml:203— the destination repository isbasenameof a fork-controlled artifact filename. The tag is pinned topr-<N>-<sha>so no real ref can be overwritten, but a fork can create arbitrary repositories under the CI registry. Validatenameagainst the expected image set.e2e-fork.yaml:201-208—shopt -s nullglobplusdownload-artifact's non-fatal emptypatternmatch letspublishsucceed having pushed nothing; e2e then fails on an obscure pull error. Assert at least one archive.e2e-fork.yaml:156—curl -fsSL https://fluxcd.io/install.sh | sudo bashruns an unpinned remote script as root in the job that then loads the registry token.pull-requests.yamldownloads to a temp file first; match that at minimum, and pin the version.e2e-fork.yaml:162,244—refs/pull/<N>/mergeis a moving ref, while the artifacts and the check-run belong toresolve.outputs.head_sha. A push landing in between makespublish/e2eoperate on a different tree than the SHA the check is stamped on, and can makegit apply --3wayconflict. Assertgit rev-parse HEAD^2 == head_shaafter checkout.e2e-fork.yaml:148—packages: writeis a ghcr scope; this job authenticates to a different registry withOCIR_USER/OCIR_TOKEN. Drop it.e2e-fork.yaml:107—assoc.data.find(p => p.state === 'open') || assoc.data[0]can resolve a closed PR whose merge ref no longer exists.skopeo copyruns without--all; if any image ever builds multi-platform,containerimage.digestbecomes the index digest while the copy pushes a single manifest, so the digest baked intopr.patchwill not resolve.- 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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) \ |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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')) { |
There was a problem hiding this comment.
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/.
There was a problem hiding this comment.
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).
| # 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) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
|
Aleksei Sviridkin (@lexfrei) thanks for the thorough review — all five blockers are addressed, plus the follow-ups:
Follow-ups: (1) archive basename validated against a strict charset, (2) publish asserts ≥1 archive, (3) flux installed from a downloaded script rather than 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. |
3ea7531 to
5fa0671
Compare
Summary of ChangesHello, 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
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
Using Gemini Code AssistThe 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
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 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
|
There was a problem hiding this comment.
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.
| $(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 |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
| OCI_EXPORT_DIR ?= | ||
| ifneq ($(strip $(OCI_EXPORT_DIR)),) | ||
| PUSH := 0 | ||
| LOAD := 0 | ||
| endif |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
|
|
||
| - **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). |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (2)
.github/workflows/e2e-fork.yaml (1)
354-359: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueDigest 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 pinnedFLUX_VERSIONplus the[-n "$digest"]fail-closed check make this reasonably safe today. Flux CLI does support--output jsononpush artifactfor 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-tagsstill has the multi-tag-under-export trap the capi fix closed elsewhere.Under
OCI_EXPORT_DIR,image-tagsstill appends--tag …:$(2)/--tag …:latestwheneverPUBLISH_VERSIONED/PUBLISH_FLOATINGare1, alongsideoci-output's single archive. Today no caller combinesOCI_EXPORT_DIRwithPUBLISH_VERSIONED=1/PUBLISH_FLOATING=1(fork builds always pass0/0), so it's dormant — but this is the exact same "two--tags → two manifests in one OCI archive →skopeo copydies withmore than one image in oci" class of bug that was fixed forcapi-providers-cpproviderspecifically. 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
📒 Files selected for processing (7)
.github/workflows/e2e-fork.yaml.github/workflows/pull-requests.yamldocs/agents/e2e-testing.mdhack/common-envs.mkhack/common-envs_test.batspackages/core/talos/Makefilepackages/system/capi-providers-cpprovider/Makefile
… 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>
`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>
…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>
There was a problem hiding this comment.
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 winFail closed on renamed or truncated PR file lists. Renamed files can still be code changes:
pulls.listFilesexposesprevious_filenameon renamed entries, so checking onlyfilenameletspackages/... -> 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
📒 Files selected for processing (6)
.github/workflows/e2e-fork.yaml.github/workflows/pull-requests.yamldocs/agents/e2e-testing.mdhack/common-envs.mkhack/common-envs_test.batspackages/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
IvanHunters
left a comment
There was a problem hiding this comment.
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-71 — grep -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/e2ejobs) depends on the repo's "Require approval for all outside collaborators" Actions setting — nothing in the diff enforces it, and noenvironment:protection rule is used. Worth pinning down out-of-band. .github/zizmor.ymldisablesdangerous-triggers/template-injectionaudits repo-wide — precisely the class most relevant to this new privilegedworkflow_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 gatedif !fork, push credentials only in the separatepublishjob); (2) the merge-gate no longer trusts fork-controlled artifacts blindly — aHEAD^2TOCTOU 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).
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>
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>
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>
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>
|
Aleksei Sviridkin (@lexfrei) this review was filed against B1 (talos pushing on fork builds) is closed in Two things changed direction since you looked. The gate is now a commit status, not a check-run ( |
|
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 The MAJOR one is fixed in 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 |
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>
Aleksei Sviridkin (lexfrei)
left a comment
There was a problem hiding this comment.
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.
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.
…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 -->
…#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. ```
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, somake imagepushed anonymously and died withdenied. That failed every build, collapsed thebuild → finalize → e2echain, 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_runsplit), pushing fork e2e images to the existing CI registry (OCIR):Build primitive (
hack/common-envs.mk)OCI_EXPORT_DIRmode: build each image to a per-image OCI archive instead of pushing, and forcePUSH/LOADoff. The digest is captured via--metadata-fileregardless of output type, so the refs baked intopr.patchmatch what the privileged run later pushes. Threaded through theimage-tagsmacro every package already uses — no per-package Makefile change.pull-requests.yaml(unprivileged, fork branch)build/build-talos/finalizeexport images to OCI archives and upload them as artifacts instead of pushing → the reddeniedbuild wall disappears and the chain no longer collapses."E2E Tests"and guarded to same-repo PRs. A newe2e-reportjob 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, privilegedworkflow_runfrom the default branch)"E2E Tests"check-run, and fails-closed if the fork build failed.git/skopeo/flux/yq) over data — never formakeor any fork-authored script.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_targetrun 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,
actionlintclean.Needs live validation (why this is a draft):
workflow_runartifact 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>/mergefetch +git apply --3way;skopeo --preserve-digestsend-to-end.workflow_runonly 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
e2ejob 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 thefull-e2eoverride sourced fromresolve'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 requiredE2E Testsgate is now published as a commit status (default token +statuses:write), not a COZYSTACK_CI app check-run — the app lackedchecks:write(checks.create403'd) and a default-token check-run floated under a labeler suite; a commit status has neither problem, and forks can't post one (theirpull_requesttoken is read-only). Two review-bot findings fixed too: forkOCI_EXPORT_DIRis now absolute (a relative path misplaced archives undermake -C <pkg>and every fork build would fail at upload), and the "≥1 archive" guard no longer no-ops undernullglob.Repo-admin prerequisites
One repository setting lives outside this diff:
E2E Testsrequired. The gate is now a commit status namedE2E Tests(posted with the defaultGITHUB_TOKEN— seedocs/agents/e2e-testing.md§10), not a job or an app check-run. Branch protection already requiresE2E Tests(currently the e2e job's name, renamed here toE2E (in-tree)), so the required context now resolves to the status — no GitHub App, nochecks:writegrant, no app-id pin needed. Just don't pin the context to a specific app.e2e-fork.yamlconsumes.Downstream repositories
Walked the trigger map in
docs/agents/contributing.mdagainst the diff (7 files: two CI workflows,docs/agents/e2e-testing.md,hack/common-envs.mk+ its bats suite, and two packageMakefiles).The one trigger worth naming is "change developer tooling (
cozyvalues-gen,cozypkg, the packageMakefiles) →development.md" on the website. It does not fire here:OCI_EXPORT_DIRis a CI-only build mode that defaults off, and the local developer workflow is unchanged —make imagestill builds and pushes exactly as before, which the new default-path case inhack/common-envs_test.batsnow pins. No chart values, no API surface, no app list, no platform variant, no Talos version bump and no release asset rename are touched.Release note
Summary by CodeRabbit
OCI_EXPORT_DIRand preventing unintended registry pushes.