Skip to content

ci(api-review-gate): publish the gate as a commit status - #3440

Merged
Timofei Larkin (lllamnyp) merged 7 commits into
mainfrom
ci/narrow-api-gate-review-trigger
Nov 2, 2026
Merged

Timofei Larkin (lllamnyp) merged 7 commits into
mainfrom
ci/narrow-api-gate-review-trigger

Conversation

@myasnikovdaniil

@myasnikovdaniil myasnikovdaniil commented Jul 23, 2026 •

Copy link
Copy Markdown
Contributor

What this PR does

Publishes the API Review Gate merge context as a commit status, so a skipped or cancelled run cannot pass it or poison it, and gives fork PRs the same gate through a privileged workflow_run workflow.

Why

The merge context was a job name. A required job that skips counts as passed, and check runs are not deduplicated per name, so one cancelled run on a head keeps the context failed until the next push. Measured on this repo: 41d1153c has four check runs with the gate's job name (one cancelled, three success). Commit statuses are deduplicated per context and the newest wins, so a run with no verdict posts nothing and the previous status stays.

What changes

Same-repo PRs get status from api-gate-report. A pending opener marks the head before the evaluation on approve, dismiss and request-changes reviews, so an old success is not mergeable for the minute the evaluation takes. COMMENTED reviews no longer count as a reviewer's latest review, so an owner who approved and then commented keeps the approval. Concurrency is split, so bot comment run cannot cancel live evaluation.

Fork PRs: api-review-gate-fork.yaml runs from the default branch and reaches the verdict itself, the fork's run is only a trigger. resolve finds PR by head SHA in open PR list (review runs on a fork report base repo as head repository and have no pull requests). detect fetches the head as data and runs the detector in a job with contents: read only. conclude applies the approval rule and posts. A sweep every 15 minutes moves the status of fork PRs whose runs wait for a maintainer in action_required, those never fire workflow_run. This is lllamnyp's patch, applied whole, with these changes on top:

  • two open fork PRs on one head fail closed in resolve and in the sweep, a head carried by a same-repo PR stays with api-gate-report
  • detector is built from default branch only, merge base is picked by PR author and an old one would mean an old detector
  • the fork detect step runs under bash -e and detector exit 2 ended it before the code was read, so it has set +e like the same-repo job
  • head is checked out with links as links, like the same-repo gate does, because flattening them hid a CRD behind a file link from the detector while Helm still ships it

Limits

  • A fork PR whose push run waits for a maintainer's approval has no status until that run executes, same as E2E Tests.
  • A fork PR on a head that a same-repo PR also carries shows the verdict standing there, one commit status cannot hold a verdict per base.
  • The sweep can publish a stale success if it reads an approval, stalls, and the owner withdraws it meanwhile. The next sweep flips it back, so the window is up to 15 minutes.
  • Two copies of one first-party CRD and CRDs behind directory links are gaps of the detector on main, same for same-repo PRs, they are in api-gate: two copies of one CRD, and CRDs behind directory links #5170.

Testing

actionlint is clean on both workflows. zizmor is clean too, but dangerous-triggers and template-injection are disabled repo-wide in .github/zizmor.yml, so that says little about a privileged workflow. I ran the github-script bodies under node with a mocked API (every conclusion, approval and shared head case) and the run steps under bash -e with a stub detector exiting 0, 2 and 1. make unit-tests passes with LC_ALL=C (a kafka bats test depends on the locale, nothing to do with this PR).

Not exercised live: the fork path and the sweep run only from the default branch, so their first run is after merge.

Repo admin

Branch protection requires the old job name Require API owner review for sizeable API changes. This PR renames that job, so after merge the required context has to be changed to the API Review Gate status.

Downstream repositories

Release note

NONE

@github-actions github-actions Bot added size/S This PR changes 10-29 lines, ignoring generated files area/uncategorized PR auto-labeler could not map title scope to a known area/*; please review labels Jul 23, 2026
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@dosubot dosubot Bot added area/ci Issues or PRs related to CI workflows, GitHub Actions, automation kind/cleanup Categorizes issue or PR as related to cleanup of code, process, or technical debt labels Jul 23, 2026
@coderabbitai

coderabbitai Bot commented Jul 23, 2026 •

Copy link
Copy Markdown
Contributor

Review in 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

No actionable comments were generated in the recent review.

Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between cbfaf80 and b5e3b37.

Files selected for processing: 1
  • .github/workflows/api-review-gate-fork.yaml

Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 4 remain after this review.

Walkthrough

The API Review Gate workflows publish commit statuses for same-repository and fork pull requests. They evaluate detector results and API-owner reviews, and update eligible fork statuses during scheduled sweeps.

Changes: API Review Gate statuses

Layer / File(s) Summary
Evaluate actionable API reviews
.github/workflows/api-review-gate.yaml
The evaluator skips plain commented reviews, exposes sizeability and approval results, and selects each reviewer’s latest non-comment review on the current head.
Publish same-repository gate status
.github/workflows/api-review-gate.yaml
The workflow attempts a pending status and publishes the final status for eligible same-repository pull requests.
Resolve and detect fork pull requests
.github/workflows/api-review-gate-fork.yaml
The fork workflow resolves eligible pull requests by head SHA, posts a pending status, and runs the detector against fetched head and merge-base worktrees.
Report and sweep fork statuses
.github/workflows/api-review-gate-fork.yaml, docs/agents/e2e-testing.md
The workflow reports detection and owner-approval results, then sweeps eligible fork statuses as approvals change. The E2E guidance documents status ownership, deduplication, fork-token restrictions, and shared-head handling.

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

Change: Bug fix

Sequence Diagram(s)

sequenceDiagram
  participant ReviewEvent
  participant SameRepoEvaluator
  participant SameRepoStatusJob
  participant ForkResolver
  participant ForkDetector
  participant ForkStatusJob
  participant CommitStatus
  ReviewEvent->>SameRepoEvaluator: start actionable review evaluation
  SameRepoEvaluator->>SameRepoStatusJob: provide sizeability and approval result
  SameRepoStatusJob->>CommitStatus: publish same-repository gate status
  SameRepoEvaluator->>ForkResolver: trigger completed gate workflow run
  ForkResolver->>ForkDetector: provide eligible fork pull request and base ref
  ForkDetector->>ForkStatusJob: provide detector result
  ForkStatusJob->>CommitStatus: publish fork gate status
Loading
Merge Risk: Minimal · up to b5e3b

This change publishes the API Review Gate as a commit status for same-repository and fork pull requests, with a 15-minute sweep for fork statuses. No concrete merge-blocking defect was found. The fork workflow and sweep cannot run until the change reaches the default branch, so they get their first live run after merge. Branch protection must also be switched to the new status name after merge.

Security architecture risk: Moderate · up to 0fb54

The privileged publisher is separated from fork code, but the new gate does not invalidate an earlier passing status when approval changes require reevaluation. Cancellation or reporting failure can leave that stale success in place. Fork publication also depends on an unverified assumption about workflows whose jobs all skip. Actual merge exposure depends on repository protection settings.

Retained concerns

  • Medium · security · inferred: The new terminal-only status gate preserves an earlier same-head success while an approval dismissal or changes-requested review is reevaluated. Cancellation or a failed status write can preserve it indefinitely. Unlike the previous job-check design, reevaluation does not create a non-success state for the required gate. This can leave the API-owner control passing after its approval basis changes; actual merge bypass depends on branch protection and independent review requirements.
  • Medium · security · inferred: The new fork publisher places all completed runs for a fork repository and branch in one cancelling concurrency group, including conclusions that subsequently publish nothing. A non-verdict completion can therefore interrupt a real verdict publisher without replacing its status. This can strand an absent status or preserve an older success instead of a newer failure. The source exposes this transition hazard; a concrete cancellation/API-write interleaving has not been observed.
Security review details

Security Blast Radius

  • inferred — The affected security outcome is this repository’s API-owner merge gate. Publisher tokens declare repository status-write authority, while the implemented writes use a fixed context and event-recorded SHA. The scoped evidence shows no publication into another repository or runtime service.

Security Findings and Attack Paths

  • inferred — A stale-approval path requires a previously passing status on the same head, an API owner changing their review or an authorized approval dismissal, and merge authorization that relies on this status without another blocking review rule. Reevaluation interruption or reporting failure can preserve the old success. This does not establish that a fork author can forge approval or obtain the publisher token.

Trust Boundaries and Controls

  • observed — The evaluator declares read permissions and disables persisted checkout credentials. Status-write authority is assigned to reporting jobs. The fork publisher limits its trigger by workflow name, checks that head and base repositories differ, validates SHA format, and excludes non-success/failure conclusions. Runtime event authenticity and effective token identity remain outside the supplied source evidence.
  • inferred — The fork contract remains unresolved for all-skipped evaluations: commented reviews skip both producer jobs, while the publisher accepts any aggregate success without checking evaluator execution. If GitHub reports such a run as success, it would become a passing status without evaluation. Earlier inspection did not establish that platform premise, so this is uncertainty rather than a verified bypass. The documentation’s effective fork-token setting likewise remains unverified.

Resilience and Maintainability Implications

  • observed — The evaluator isolates commented-review no-ops from actionable evaluation cancellation. The fork companion does not carry that isolation into publication: its concurrency key ignores conclusion even though its script later declines non-verdict writes.

Hardening Proposals

  • proposed — Add a trusted pending or invalidation transition for actionable same-head reevaluations, with defined recovery after cancellation and publication failure. Preserve the privilege separation rather than granting fork evaluation a write token.
  • proposed — Require evidence that the intended evaluator actually completed before fork success publication, and isolate non-verdict completions from verdict publishers. Define freshness and ordering checks for repeated same-head evaluations instead of relying solely on cancellation.
Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title clearly and concisely describes the main change: publishing the API review gate as a commit status.
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.
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Finishing Touches
Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

@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

🤖 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/api-review-gate.yaml:
- Around line 41-43: Update the workflow’s concurrency group so non-actionable
pull_request_review events use a distinct group, preventing skipped runs from
cancelling the active pull_request gate. Preserve the existing shared group for
actionable events and keep the job condition around github.event_name and
github.event.review.state unchanged.
🪄 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 Plus

Run ID: d12930e4-b50a-41ca-9c1b-8de396135fd9

📥 Commits

Reviewing files that changed from the base of the PR and between 18e459a and f44eedf.

📒 Files selected for processing (1)
  • .github/workflows/api-review-gate.yaml

Comment thread .github/workflows/api-review-gate.yaml Resolved
@IvanHunters

Copy link
Copy Markdown
Collaborator

Review summary

The noise problem this PR fixes is real, and the intent is right. But the safety argument in the PR description only covers the case where the push-anchor run passed; it does not cover the case where the anchor run failed — which is exactly the case the gate exists to enforce. Flagging one blocking-severity concern to verify before merge.

[CRITICAL — please verify] A commented-review skip supersedes a failing anchor status on the same commit, turning the required gate green

The PR description reasons only about the pass case: "the real success from the pull_request run on the head SHA remains." Now consider a sizeable API change with no owner approval:

  1. Push commit X (sizeable API change, no approval). pull_request: synchronize → run A evaluates → reports the check Require API owner review for sizeable API changes = failure. Correct: red, merge blocked.
  2. A review bot (CodeRabbit / dosubot — both named in the PR body, both active here) posts a plain comment on X. pull_request_review: submitted, state=commented → run B. The new job-level if: → skipped → GitHub creates a check run with the same context name and conclusion skipped, timestamped after run A.
  3. Branch protection evaluates the latest check run per context on the head SHA. That is now run B (skipped), which required-status-check evaluation treats as non-blocking / satisfied — the very property this PR relies on for the pass case.
  4. Net: the earlier failure is superseded by skipped. The required gate shows green and the sizeable API change becomes mergeable without an API-owner approval.

This is not a rare race — it's the normal flow. Review bots comment automatically right after a push, so on essentially every sizeable API PR with a bot enabled, the failing gate would be overwritten by a skipped shortly after it fails.

Regression vs. current main: before this change, the commented-review run executes the full job and re-reports the real verdict (failure again), so the terminal status after a trailing comment is always a real evaluation and the gate stays enforced. After this change, a trailing commented event leaves skipped as the authoritative status.

Secondary interaction with concurrency (same root cause): the top-level concurrency group is shared between the pull_request anchor and pull_request_review triggers with cancel-in-progress: true. Workflow-level concurrency cancels the in-progress run before the job if: is evaluated. So a commented review landing during the ~30s anchor run cancels that run and then skips its own job — same outcome: no real evaluation is the terminal state.

Load-bearing assumption to verify empirically before merging (don't take my word for it): "for one check-context name on one commit, the most-recent check run wins, and a later skipped run supersedes an earlier failure." This is standard GitHub required-status-check behavior, but the whole safety of this change hinges on it and it wasn't tested. Concrete ~5-minute repro: on a scratch PR that fails this gate, post a plain review comment and confirm whether the required check flips from red to green (skipped). "YAML parses clean" doesn't exercise any of this. Impact is also conditional on this check actually being a required status check in branch protection (which the workflow header states is its purpose).

Direction for a fix (the tension is inherent — needs a design choice, not a one-liner)

The pull_request_review trigger exists specifically so an approval can flip the required context green without a new push, which means review-triggered runs must write that same context. Any path that writes skipped to that context can therefore supersede a real failure. Options worth weighing:

  • Keep re-running fully on all review events and cut cost differently — e.g. cache/prebuild api-gate so the commented re-run is cheap instead of skipped, while still re-reporting the true verdict.
  • On commented events, run a minimal step that re-reports the anchor's current conclusion into the same context instead of skipping, so the context is never downgraded to skipped.
  • Don't have review-triggered runs share the required context at all, and re-drive the anchor context another way on approval — larger redesign.

Non-blocking notes

  • The inline comments (workflow header and the on: block) are genuinely good — clear intent, helpful for the next maintainer.
  • The boolean logic of the if: is correct for its stated intent: pull_request always runs; approved / changes_requested / dismissed still re-run (review.state on a dismissed action is "dismissed", not "commented"); only commented skips.
  • The underlying problem (a dozen cancelled runs per PR from bot-comment churn) is real and worth fixing — the objection is to how, not whether.

@myasnikovdaniil
myasnikovdaniil force-pushed the ci/narrow-api-gate-review-trigger branch from f44eedf to 179d59a Compare August 3, 2026 12:30
@github-actions github-actions Bot added size/XXL This PR changes 1000+ lines, ignoring generated files and removed size/S This PR changes 10-29 lines, ignoring generated files labels Aug 3, 2026
@myasnikovdaniil myasnikovdaniil changed the title ci(api-review-gate): skip review-bot comment events ci(api-review-gate): publish the gate as a commit status Aug 3, 2026
@myasnikovdaniil
myasnikovdaniil changed the base branch from main to ci/fork-pr-e2e August 3, 2026 12:34
@myasnikovdaniil
myasnikovdaniil marked this pull request as draft August 3, 2026 12:34

@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: 3

🧹 Nitpick comments (2)
packages/core/talos/Makefile (1)

17-17: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Consider removing a stale archive before the copy.

skopeo copy to an existing oci-archive: destination can fail or append a second manifest, which then breaks the single-image assumption in e2e-fork.yaml. A repeated make image-talos in the same workspace hits this. Add an rm -f before the copy.

♻️ Proposed change
-	$(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 $(strip $(OCI_EXPORT_DIR)),mkdir -p "$(OCI_EXPORT_DIR)"; rm -f "$(OCI_EXPORT_DIR)/talos.oci.tar"; skopeo copy "$$SRC" "oci-archive:$(OCI_EXPORT_DIR)/talos.oci.tar:$(IMAGE_TAG)",skopeo copy "$$SRC" docker://$(REGISTRY)/talos:$(IMAGE_TAG)); \
🤖 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 `@packages/core/talos/Makefile` at line 17, Update the OCI export branch in the
Makefile command to remove the existing talos.oci.tar archive with rm -f
immediately before invoking skopeo copy, while leaving the registry-copy branch
unchanged.
hack/common-envs_test.bats (1)

24-24: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Pass COZYSTACK_VERSION=0 in these three tests to keep them hermetic.

hack/common-envs.mk runs a version probe at parse time. When COZYSTACK_VERSION is empty, it executes git remote add upstream … and git fetch upstream --tags (lines 128-132). Tests 4 and 5 already set COZYSTACK_VERSION=0, and e2e-fork.yaml sets it for the same reason. Tests 1-3 omit it, so a shallow or tagless checkout makes them mutate git remotes and reach the network.

♻️ Proposed change
-  out=$(make -n -C packages/system/cozystack-controller image OCI_EXPORT_DIR=/tmp/ocitest IMAGE_TAG=pr-1-abc BUILDER=b)
+  out=$(make -n -C packages/system/cozystack-controller image OCI_EXPORT_DIR=/tmp/ocitest IMAGE_TAG=pr-1-abc COZYSTACK_VERSION=0 BUILDER=b)

Apply the same addition to the capi-providers-cpprovider and talos invocations.

Also applies to: 37-37, 51-51

🤖 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_test.bats` at line 24, Set COZYSTACK_VERSION=0 on the three
hermetic make invocations in hack/common-envs_test.bats, including the shown
system/cozystack-controller command and the capi-providers-cpprovider and talos
invocations. Preserve the existing arguments and command structure while
preventing parse-time version probing.
🤖 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/api-review-gate-fork.yaml:
- Around line 44-67: Bind fork status publication to the triggering pull
request: in .github/workflows/api-review-gate-fork.yaml lines 44-67, grant
pull-requests: read, query pull requests associated with HEAD_SHA, and publish
nothing unless an open request has head.sha === HEAD_SHA and head.repo.full_name
matching github.event.workflow_run.head_repository.full_name; in
.github/workflows/e2e-fork.yaml lines 143-152, extend the existing
assoc.data.find predicate with those same SHA and repository checks so all
decisions and status targets use the triggering fork’s pull request.

In @.github/workflows/e2e-fork.yaml:
- Around line 304-322: Update the “Derive trusted image allowlist (base tree)”
step so cozystack-operator is always included, matching the installer targets
invoked by the fork build even when image-operator’s prereq output is skipped.
Prefer deriving the name from the same installer/image targets; otherwise
explicitly add cozystack-operator to _allow.txt while preserving the existing
archive-derived entries and validation.

In `@docs/agents/e2e-testing.md`:
- Line 103: Update the section 10 heading to refer to the “E2E Tests” commit
status instead of a check-run, keeping the existing same-repo versus fork
workflow wording unchanged.

---

Nitpick comments:
In `@hack/common-envs_test.bats`:
- Line 24: Set COZYSTACK_VERSION=0 on the three hermetic make invocations in
hack/common-envs_test.bats, including the shown system/cozystack-controller
command and the capi-providers-cpprovider and talos invocations. Preserve the
existing arguments and command structure while preventing parse-time version
probing.

In `@packages/core/talos/Makefile`:
- Line 17: Update the OCI export branch in the Makefile command to remove the
existing talos.oci.tar archive with rm -f immediately before invoking skopeo
copy, while leaving the registry-copy branch unchanged.
🪄 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 Plus

Run ID: 11484404-e518-44c5-b22f-068c661b9230

📥 Commits

Reviewing files that changed from the base of the PR and between f44eedf and 179d59a.

📒 Files selected for processing (8)
  • .github/workflows/api-review-gate-fork.yaml
  • .github/workflows/api-review-gate.yaml
  • .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

@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.

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

Actionable comments posted: 3

🧹 Nitpick comments (2)
packages/core/talos/Makefile (1)

17-17: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Consider removing a stale archive before the copy.

skopeo copy to an existing oci-archive: destination can fail or append a second manifest, which then breaks the single-image assumption in e2e-fork.yaml. A repeated make image-talos in the same workspace hits this. Add an rm -f before the copy.

♻️ Proposed change
-	$(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 $(strip $(OCI_EXPORT_DIR)),mkdir -p "$(OCI_EXPORT_DIR)"; rm -f "$(OCI_EXPORT_DIR)/talos.oci.tar"; skopeo copy "$$SRC" "oci-archive:$(OCI_EXPORT_DIR)/talos.oci.tar:$(IMAGE_TAG)",skopeo copy "$$SRC" docker://$(REGISTRY)/talos:$(IMAGE_TAG)); \
🤖 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 `@packages/core/talos/Makefile` at line 17, Update the OCI export branch in the
Makefile command to remove the existing talos.oci.tar archive with rm -f
immediately before invoking skopeo copy, while leaving the registry-copy branch
unchanged.
hack/common-envs_test.bats (1)

24-24: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Pass COZYSTACK_VERSION=0 in these three tests to keep them hermetic.

hack/common-envs.mk runs a version probe at parse time. When COZYSTACK_VERSION is empty, it executes git remote add upstream … and git fetch upstream --tags (lines 128-132). Tests 4 and 5 already set COZYSTACK_VERSION=0, and e2e-fork.yaml sets it for the same reason. Tests 1-3 omit it, so a shallow or tagless checkout makes them mutate git remotes and reach the network.

♻️ Proposed change
-  out=$(make -n -C packages/system/cozystack-controller image OCI_EXPORT_DIR=/tmp/ocitest IMAGE_TAG=pr-1-abc BUILDER=b)
+  out=$(make -n -C packages/system/cozystack-controller image OCI_EXPORT_DIR=/tmp/ocitest IMAGE_TAG=pr-1-abc COZYSTACK_VERSION=0 BUILDER=b)

Apply the same addition to the capi-providers-cpprovider and talos invocations.

Also applies to: 37-37, 51-51

🤖 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_test.bats` at line 24, Set COZYSTACK_VERSION=0 on the three
hermetic make invocations in hack/common-envs_test.bats, including the shown
system/cozystack-controller command and the capi-providers-cpprovider and talos
invocations. Preserve the existing arguments and command structure while
preventing parse-time version probing.
🤖 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/api-review-gate-fork.yaml:
- Around line 44-67: Bind fork status publication to the triggering pull
request: in .github/workflows/api-review-gate-fork.yaml lines 44-67, grant
pull-requests: read, query pull requests associated with HEAD_SHA, and publish
nothing unless an open request has head.sha === HEAD_SHA and head.repo.full_name
matching github.event.workflow_run.head_repository.full_name; in
.github/workflows/e2e-fork.yaml lines 143-152, extend the existing
assoc.data.find predicate with those same SHA and repository checks so all
decisions and status targets use the triggering fork’s pull request.

In @.github/workflows/e2e-fork.yaml:
- Around line 304-322: Update the “Derive trusted image allowlist (base tree)”
step so cozystack-operator is always included, matching the installer targets
invoked by the fork build even when image-operator’s prereq output is skipped.
Prefer deriving the name from the same installer/image targets; otherwise
explicitly add cozystack-operator to _allow.txt while preserving the existing
archive-derived entries and validation.

In `@docs/agents/e2e-testing.md`:
- Line 103: Update the section 10 heading to refer to the “E2E Tests” commit
status instead of a check-run, keeping the existing same-repo versus fork
workflow wording unchanged.

---

Nitpick comments:
In `@hack/common-envs_test.bats`:
- Line 24: Set COZYSTACK_VERSION=0 on the three hermetic make invocations in
hack/common-envs_test.bats, including the shown system/cozystack-controller
command and the capi-providers-cpprovider and talos invocations. Preserve the
existing arguments and command structure while preventing parse-time version
probing.

In `@packages/core/talos/Makefile`:
- Line 17: Update the OCI export branch in the Makefile command to remove the
existing talos.oci.tar archive with rm -f immediately before invoking skopeo
copy, while leaving the registry-copy branch unchanged.
🪄 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 Plus

Run ID: 11484404-e518-44c5-b22f-068c661b9230

📥 Commits

Reviewing files that changed from the base of the PR and between f44eedf and 179d59a.

📒 Files selected for processing (8)
  • .github/workflows/api-review-gate-fork.yaml
  • .github/workflows/api-review-gate.yaml
  • .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
🛑 Comments failed to post (3)
.github/workflows/api-review-gate-fork.yaml (1)

44-67: 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Privileged publishers do not bind the status SHA to an open pull request from the triggering fork. Both workflows publish a required merge-gate status using only workflow_run.head_sha. Neither proves that the SHA is the head of an open pull request whose head repository equals github.event.workflow_run.head_repository.full_name. A fork can point a branch at a commit that another open pull request also uses as its head, so the verdict computed for the fork branch lands on the shared SHA. Commit statuses are deduplicated per context and newest wins, so the fork verdict replaces the verdict a real run reached.

  • .github/workflows/api-review-gate-fork.yaml#L44-L67: add pull-requests: read, look up the pull requests associated with HEAD_SHA, and post nothing unless an open pull request exists with head.sha === HEAD_SHA and head.repo.full_name === github.event.workflow_run.head_repository.full_name.
  • .github/workflows/e2e-fork.yaml#L143-L152: extend the existing assoc.data.find(...) predicate with p.head.sha === HEAD_SHA and p.head.repo?.full_name === github.event.workflow_run.head_repository.full_name, so the docs-only decision, the TIA inputs, and the status target all come from the triggering fork's own pull request.
📍 Affects 2 files
  • .github/workflows/api-review-gate-fork.yaml#L44-L67 (this comment)
  • .github/workflows/e2e-fork.yaml#L143-L152
🤖 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/api-review-gate-fork.yaml around lines 44 - 67, Bind fork
status publication to the triggering pull request: in
.github/workflows/api-review-gate-fork.yaml lines 44-67, grant pull-requests:
read, query pull requests associated with HEAD_SHA, and publish nothing unless
an open request has head.sha === HEAD_SHA and head.repo.full_name matching
github.event.workflow_run.head_repository.full_name; in
.github/workflows/e2e-fork.yaml lines 143-152, extend the existing
assoc.data.find predicate with those same SHA and repository checks so all
decisions and status targets use the triggering fork’s pull request.
.github/workflows/e2e-fork.yaml (1)

304-322: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect the installer image targets and what the derivation would emit.
fd -t f 'Makefile' packages/core/installer --exec cat -n {}
rg -n 'image-operator|^image:|image-packages' packages/core/installer

Repository: cozystack/cozystack

Length of output: 2743


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate and inspect the workflow derivation and push sections.
fd -t f 'e2e-fork.yaml' .github/workflows --exec sh -c '
  echo "--- $1 ---"
  sed -n "300,370p" "$1" | cat -n
' sh {}

# Inspect helper functions used in the installer Makefile image target.
fd -t f 'common-envs.mk|pre-checks.sh|Dockerfile' --exec sh -c '
  echo "--- $1 ---"
  wc -l "$1"
  sed -n "1,220p" "$1" | cat -n
' sh {}

# Simulate the make -n derivation behavior as shell (read-only from sources).
python3 - <<'PY'
from pathlib import Path
text = Path("packages/core/installer/Makefile").read_text()
print("Makefile contains image operator line:", "IMAGE=\"$(REGISTRY)/cozystack-operator" in text)
print("make derivation command present:", "make -n" in Path(".github/workflows/e2e-fork.yaml").read_text())
print("operator image name literal:", "cozystack-operator" in text)
PY

Repository: cozystack/cozystack

Length of output: 50375


🌐 Web query:

GNU make -n rule execution order prerequisities target recipe error handling

💡 Result:

In GNU make, the execution flow and error handling follow specific mechanisms: Rule Execution and Prerequisites GNU make processes targets by first identifying the dependency graph [1]. When you request a target, make evaluates its prerequisites to determine if the target is out of date (i.e., if the target does not exist or is older than any of its prerequisites) [1]. It recursively processes these prerequisites, ensuring they are built before the recipe for the main target is executed [2][1]. The order in which rules appear in the makefile does not determine execution order, except that the first non-pattern target in the first makefile becomes the default goal [1]. Error Handling in Recipes When a recipe line (a shell command) returns a non-zero exit status, make treats it as an error [2][3]. By default, make stops executing the current rule and immediately terminates, returning a non-zero status [2][4]. You can modify this behavior using several methods: - Ignore Errors for a Specific Line: Prefix a recipe line with a hyphen - (e.g., -rm file.o). This causes make to ignore any non-zero exit status for that specific command [2][3]. - Global Ignore: Run make with the -i or --ignore-errors flag to ignore all errors in all recipes [2][4]. Alternatively, you can define the special target.IGNORE in the makefile [2][3]. - Continue Processing: Run make with the -k or --keep-going flag. This instructs make to continue building other independent prerequisites even after a failure occurs, rather than exiting immediately [2][4]. - Cleanup on Failure: To prevent corrupted files from remaining with an updated timestamp after a failed recipe, use the special target.DELETE_ON_ERROR [2][3]. When enabled, make will delete a target if its recipe fails after it has begun modifying the file [2][3]. Expansion Timing Note that make differentiates between variable expansion and prerequisite evaluation [5]. Prerequisites are typically expanded during the first phase (parsing) of the makefile, whereas variables used within recipes are expanded during the second phase (when the recipe is executed) [5].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "--- installer Makefile image target ---"
sed -n '1,35p' packages/core/installer/Makefile | cat -n

echo "--- prereq files/scripts that image: depends on ---"
git ls-files packages/core/installer/pre-checks packages/*/hack/pre-checks.sh hack/pre-checks.sh | xargs -r ls -la

echo "--- make dry-run if make is available ---"
if command -v make >/dev/null 2>&1; then
  make -n -C packages/core/installer image OCI_EXPORT_DIR=/oci REGISTRY=r COZYSTACK_VERSION=0 | sed -n '1,80p'
fi

Repository: cozystack/cozystack

Length of output: 1625


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "--- make dry-run output ---"
make -n -C packages/core/installer image OCI_EXPORT_DIR=/oci REGISTRY=r COZYSTACK_VERSION=0 | sed -n '1,100p'

echo "--- pipeline output for current dry-run string ---"
make -n -C packages/core/installer image OCI_EXPORT_DIR=/oci REGISTRY=r COZYSTACK_VERSION=0 | \
  grep -oE '(dest=/oci/|oci-archive:/oci/)[a-z0-9._-]+\.oci\.tar' || true

Repository: cozystack/cozystack

Length of output: 227


Add cozystack-operator to the image allowlist.

The derivation only scans make -n image output for dest=.../.oci.tar, but the installer image target builds the operator first with image-tags(cozystack-operator, ...) and writes that image name to pr-oci-installer. A failed or skipped pre-checks prereq stops the recipe for image-operator, so the operator archive can be excluded while the push still creates r/cozystack-operator@.... Derive from the same targets the fork build invokes, or include cozystack-operator reliably.

🤖 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 304 - 322, Update the “Derive
trusted image allowlist (base tree)” step so cozystack-operator is always
included, matching the installer targets invoked by the fork build even when
image-operator’s prereq output is skipped. Prefer deriving the name from the
same installer/image targets; otherwise explicitly add cozystack-operator to
_allow.txt while preserving the existing archive-derived entries and validation.
docs/agents/e2e-testing.md (1)

103-103: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix the section heading: the gate is a commit status.

The heading says check-run, but the section states the opposite and explains why a check run is wrong. The heading is the first thing a reader sees, so it inverts the invariant.

📝 Proposed change
-### 10. The E2E workflow split: same-repo vs fork, and the "E2E Tests" check-run
+### 10. The E2E workflow split: same-repo vs fork, and the "E2E Tests" commit status
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

### 10. The E2E workflow split: same-repo vs fork, and the "E2E Tests" commit status
🤖 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 `@docs/agents/e2e-testing.md` at line 103, Update the section 10 heading to
refer to the “E2E Tests” commit status instead of a check-run, keeping the
existing same-repo versus fork workflow wording unchanged.

@myasnikovdaniil

Copy link
Copy Markdown
Contributor Author

Thanks — the core of this was right and it changed the PR substantially. Two parts of the stated mechanism didn't survive checking, though, and since you explicitly asked not to take your word for it, here is what I measured. All of it is read-only against real commits in this repo, so no scratch PR was needed.

The load-bearing assumption does not hold — check runs are not deduplicated per name. You asked to verify that "for one check-context name on one commit, the most-recent check run wins." It does not. Each workflow run gets its own check suite, and GET /commits/{sha}/check-runs?filter=latest returns a byte-identical set to filter=all; filter=latest only collapses re-runs within a suite. Commit 41d1153c currently carries four check runs named Require API owner review for sizeable API changes — one cancelled and three success, in four different suites. Nothing supersedes anything.

And the aggregate is worst-of, not latest-wins. Commit 0c14d6cf is precisely the shape this PR would produce: E2E Tests cancelled at 12:51:22, then E2E Tests skipped at 12:51:23. Every other context on that commit is success or skipped, and the rollup state is FAILURE. The later skipped did not rescue the earlier cancelled. So the bypass in step 4 of your walkthrough — skipped superseding failure and turning the gate green — is not the behaviour; if anything the risk inverts into a false red that no further review event can clear.

But your conclusion was right, for a different reason, and it's the reason the PR got rebuilt. The real regression is that a commented review landing inside the ~30s evaluation window produces zero real evaluations: workflow-level concurrency cancels the anchor before the job if: is even considered, and the replacement run then skips. That half of your comment is not only correct, it is already happening on main — 41d1153c and 4b1966c5 both show a pull_request run cancelled by a pull_request_review run seconds later. Under worst-of aggregation that stale cancelled sticks to the context until the next push. So the concurrency interaction you filed as secondary is actually the primary defect here, and it predates this PR.

What the PR does now. Rather than tuning the if:, the gate is no longer a job name at all. It follows the pattern #3262 established for E2E Tests: the merge context is a commit status, published by a dedicated reporting job, and skipped or cancelled runs post nothing. That inverts the whole argument — statuses are deduplicated per context with the newest winning (verified: 0c14d6cf has six CodeRabbit statuses and the combined status returns exactly one), so declining to post preserves whatever verdict a real run last reached and can never invent one. This is what makes the commented skip safe on its own terms instead of relying on skip-to-green. The concurrency group is also split so a run that computes no verdict cannot cancel one that does.

Two consequences worth flagging since they weren't in the original PR either. A fork PR cannot publish this status from any trigger — the read-only-token restriction covers pull_request_review as well as pull_request, which I'd assumed was pull_request-only — so forks need the privileged workflow_run half, added here. And this PR is now stacked on #3262 to reuse that infrastructure rather than duplicate it, so it should land after it.

Your three suggested directions all pointed at this; option 2 in particular is close to what the reporting job does, just expressed as "post nothing" instead of "re-post the previous conclusion", which needs no lookup and cannot race. Thanks for pushing back on the "YAML parses clean" testing section — that was the actual weak point.

Base automatically changed from ci/fork-pr-e2e to main August 8, 2026 01:26
@lexfrei

Copy link
Copy Markdown
Contributor

myasnikovdaniil this has been a draft with no activity since 2026-08-03. Are you still working on it, or should I close it?

@myasnikovdaniil
myasnikovdaniil force-pushed the ci/narrow-api-gate-review-trigger branch from 179d59a to 0fb5411 Compare September 30, 2026 07:39
@github-actions github-actions Bot added size/L This PR changes 100-499 lines, ignoring generated files and removed size/XXL This PR changes 1000+ lines, ignoring generated files labels Sep 30, 2026
@myasnikovdaniil
myasnikovdaniil marked this pull request as ready for review September 30, 2026 08:10

@lllamnyp Timofei Larkin (lllamnyp) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

myasnikovdaniil the same-repo half is in good shape, and the commit-status design is what makes the fork fix below possible at all. The fork half needs more than the guard change Aleksei Sviridkin (@lexfrei) pointed at, though, so here is the whole change, ready to apply, with the reasoning and the steps to land it. Nothing has been pushed to your branch.

Why the guard fix alone is not enough

There are three layers, and lexfrei's finding is the first:

  1. The guard misreads review runs. For a pull_request_review run on a fork PR, workflow_run.head_repository is the base repository and workflow_run.pull_requests is empty (run 36760348596 on #3501), so the fork publisher treats every review-triggered run as same-repo and skips it.
  2. External contributors' review runs never fire workflow_run. The repository's fork-run approval policy is all_external_contributors, so every run from an external contributor, review runs included, waits in action_required, and a run in that state does not trigger workflow_run: eight action_required "Pull Request" runs on 2026-10-27 produced no E2E (fork) run. With only the guard fixed, an approval would reach the status for org members' forks and nobody else.
  3. The relayed conclusion is the fork's to choose. A fork's pull_request and pull_request_review runs execute the fork's own copy of api-review-gate.yaml, so a fork that edits the evaluate job to exit 0 gets a success conclusion, which the current publisher turns into a green status.

What the patch changes

  • api-review-gate-fork.yaml reaches the verdict itself and treats the fork's run only as a trigger. resolve finds the PR in the base repo's open PR list by head SHA, as e2e-fork.yaml does, acts only when that PR's head repo differs from the base, fails closed when two open PRs share the head, and posts pending. detect checks out the default branch, fetches refs/pull/N/head as data with symlinks disabled, builds api-gate from the merge base or the default branch (never the head), and runs it in a job whose token can write nothing, with no Go cache. conclude re-checks that the PR is still open at that SHA, applies the same review rule as the same-repo gate, and posts. It reads API_OWNERS from api-review-gate.yaml on the default branch with yq, so there is still one owner list.
  • A sweep every 15 minutes covers the runs that never execute. One GraphQL query per 100 open PRs returns the gate status on each head; for fork heads whose status this workflow set to "needs an approving review" or "approved", it re-reads the reviews and flips between those two. It never runs the detector and never creates a status, so a fork PR whose push run still awaits a maintainer's approval stays without a status, exactly like E2E Tests.
  • Concurrency is keyed by head SHA, and a cancelled or skipped trigger run gets a group of its own, as in e2e-fork.yaml, so a bot comment cannot cancel a live evaluation after it has posted pending.
  • Same-repo: a pending opener on actionable review events (lexfrei's non-blocking 1), continue-on-error like the E2E opener; api-gate-report now also needs it.
  • Same-repo: COMMENTED reviews are dropped before picking each reviewer's latest. As written, an owner who approved and then left a follow-up comment withdrew their own approval, and with the new comment-review skip, whether that showed depended on which event last re-ran the gate. The fork workflow uses the same rule.
  • The e2e-testing.md paragraph now says what the fork path does and does not cover, instead of claiming an approval reaches it.

Steps

  1. Rebase onto current main and push your branch. The rebase is clean as of 0b3f5dd7e:
    git fetch origin
    git rebase --signoff origin/main
    git push --force-with-lease origin ci/narrow-api-gate-review-trigger
  2. Pull the patch out of this review and apply it. Extracting it through the API keeps whitespace intact, which copying from the rendered page may not:
    gh api repos/cozystack/cozystack/pulls/3440/reviews --paginate \
      --jq '.[] | select(.body | contains("<!-- api-gate-fork-patch -->")) | .body' \
      | awk '/^````diff$/{f=1;next} /^````$/{f=0} f' > api-gate-fork.patch
    git apply api-gate-fork.patch
    git hash-object .github/workflows/api-review-gate.yaml .github/workflows/api-review-gate-fork.yaml docs/agents/e2e-testing.md
    The last command should print d9f6617ebc5d9b6d99c825f0475709863bdc3f66, a8141b2d680f86364ebd9a17c16766824a81c937 and a12687ae06b4c4b93e209a7b75cd37dbfdd39c12. Those are the files that were linted and tested.
  3. Commit with git commit -s, keeping exactly one Assisted-by: LLM trailer, since the patch was machine-assisted. A suggested message is below. Run hack/check-commit-trailers.sh origin/main..HEAD before pushing.
  4. Run actionlint on both workflows, and the zizmor pre-commit hook. Note that .github/zizmor.yml disables dangerous-triggers, template-injection and cache-poisoning, so that pass says little about a privileged workflow. With --no-config, the only finding on this version is the inherent workflow_run dangerous-trigger, the same one e2e-fork.yaml carries.
  5. Update the PR body. "Fork PRs need the privileged half" and the testing section still describe the conclusion-relaying publisher; "Not exercised live" still holds, and now covers the sweep too.
  6. Reply to lexfrei's review once it is pushed. A suggested reply is below.
  7. Leave branch protection alone, and ping Timofei Larkin (@lllamnyp) before merging. main currently requires the old job name Require API owner review for sizeable API changes, which this PR renames, so an admin has to drop that check before the merge and add the API Review Gate status context after it. That part is on our side.

What was checked

actionlint is clean on both workflows. Every github-script body in the patch ran under node against a mocked API, through 29 scenarios: push, owner approval, dismissal, changes requested, a later comment, an approval on an older commit, a non-owner approval, a moved head, two PRs at one head, a deleted fork, a malformed SHA, and the sweep's transitions, pagination and creator check. The detect shell steps ran end to end against live fork PR #5137, plus stubbed detector exits for the sizeable and error branches. The workflow_run and schedule paths only run from the default branch, so their first live run is after merge.

Expected behaviour per path:

Path Push Owner approves Owner dismisses or requests changes Bot comment Push after approval
same-repo api-gate-report posts pending, then success pending, then failure if sizeable skipped, status kept new SHA, failure if sizeable
fork, org member full privileged evaluation pending, then success failure trigger skipped, no-op full evaluation, failure
fork, external contributor no status until a maintainer approves the run, then full evaluation sweep flips to success within ~15 min sweep flips to failure within ~15 min no-op no status until approved, then failure; owner re-approves, sweep flips
Suggested commit message
ci(api-review-gate): let owner approval reach forks

On a fork PR an API owner's approval could never turn the status green.
For a pull_request_review run on a fork PR, workflow_run.head_repository
names the base repository, so the fork publisher's guard took every
review-triggered run for a same-repo one and skipped it, while the
same-repo reporter skipped it for being a fork. A sizeable fork PR was
stuck on the red its push run posted.

Fixing the guard alone would not have been enough. The publisher
relayed the gate run's conclusion, but a fork's run executes the fork's
own copy of the gate workflow, so that conclusion is whatever the fork
makes it. And the repository makes every run from an external
contributor wait for a maintainer's approval; a run in action_required
never fires workflow_run, so their review-triggered runs would not have
reached the publisher in any case.

So the privileged workflow now reaches the verdict itself: it resolves
the PR from the base repo's open PR list by head SHA, runs the detector
built from the base branch over the fetched head as data, in a job that
can write nothing, and reads the reviews API with the same rule as the
same-repo gate. A sweep every fifteen minutes covers the runs that
await approval. It runs no detector and moves only a status the full
evaluation set after finding a sizeable change, between needs-approval
and approved, as the reviews on the head commit dictate. The owner list
is read from api-review-gate.yaml on the default branch rather than
restated.

The same-repo gate now posts pending as soon as an actionable review
arrives, since a dismissal can turn a green head red and the old
success would otherwise stay mergeable for the minute the evaluation
takes. And both paths drop COMMENTED reviews before taking each
reviewer's latest: an owner who approved and then left a follow-up
comment had withdrawn their own approval as far as the gate was
concerned, which GitHub does not do.

Assisted-by: LLM
Suggested reply to lexfrei's review
@lexfrei thanks, the `head_repository` measurement was exactly the hole, and the `action_required` number turned out to matter even more: the repository's fork-run approval policy is `all_external_contributors`, so on an external contributor's PR every review-triggered run waits for approval, and a run in `action_required` never fires `workflow_run`. Fixing the guard alone would only have covered org members' forks.

So the fork publisher no longer relays anything from the gate run. It resolves the PR from the base repo's open PR list by head SHA, as `e2e-fork.yaml` does, and acts only when that PR's head repo differs from the base; two open PRs at one head fail closed. It then evaluates the gate itself, because a fork's run executes the fork's copy of `api-review-gate.yaml` and its conclusion was never trustworthy either: the head is fetched as data, `api-gate` is built from the merge base or the default branch and runs in a job whose token can write nothing, and a separate job applies the same review rule as the same-repo gate before posting.

For the runs that never execute, a sweep every 15 minutes re-reads the reviews on fork heads whose status this workflow set to "needs an approving review" or "approved" and flips between those two. It never runs the detector and never creates a status. That is what turns an owner's approval green on an external contributor's PR, and a dismissal back red. A push from an external contributor still has no status until a maintainer approves its run, same as `E2E Tests`, and the docs paragraph now says so.

Your non-blocking 1 is in: same-repo actionable reviews post `pending` first, and the fork path claims `pending` before it evaluates. Also fixed along the way: the approval query counted `COMMENTED` reviews when picking each reviewer's latest, so an owner who approved and then commented had withdrawn their own approval.
The patch (apply on top of the rebased branch)
diff --git a/.github/workflows/api-review-gate-fork.yaml b/.github/workflows/api-review-gate-fork.yaml
index e498d93d4..a8141b2d6 100644
--- a/.github/workflows/api-review-gate-fork.yaml
+++ b/.github/workflows/api-review-gate-fork.yaml
@@ -1,102 +1,365 @@
 name: API Review Gate (fork)
 
 # Privileged publisher for the "API Review Gate" merge gate on FORK pull
-# requests, mirroring the split e2e-fork.yaml uses for "E2E Tests"
+# requests, after the split e2e-fork.yaml uses for "E2E Tests"
 # (docs/agents/e2e-testing.md §10).
 #
-# A fork PR cannot publish its own merge gate: the GITHUB_TOKEN is read-only for
-# pull requests from a fork on BOTH the `pull_request` and the
-# `pull_request_review` trigger, so no run of api-review-gate.yaml can call
-# createCommitStatus for a fork — the read-only restriction is not specific to
-# `pull_request`. This workflow runs from the DEFAULT branch after that run
-# completes, so it holds a write-capable token, and concludes the status on the
-# PR head SHA.
+# No run of api-review-gate.yaml can post a status for a fork: the GITHUB_TOKEN
+# is read-only for a fork PR on both the `pull_request` and the
+# `pull_request_review` trigger. Nor can that run's verdict be relayed, because a
+# fork's run executes the fork's own copy of the workflow, so its conclusion is
+# whatever the fork wants it to be. This workflow runs from the DEFAULT branch
+# and reaches the verdict itself, from inputs a fork cannot forge: the detector
+# built from the base branch, reading the PR head as data, plus the base repo's
+# PR and review APIs. The fork's run is used only as a trigger.
 #
-# Nothing untrusted runs here, and nothing needs to: the evaluation already
-# happened in the unprivileged run. The only inputs are that run's conclusion and
-# its head SHA — base-repo payload data a fork cannot forge — so this workflow
-# checks nothing out, builds nothing, and executes no fork-authored code. That is
-# the whole reason the gate is not a `pull_request_target` run, which would hand
-# fork code a privileged token to reach the same result.
+# Fork code never executes here. The head is fetched into a worktree that only
+# the base-built detector reads, in a job whose token can write nothing; the jobs
+# that can write statuses never see the head at all. That is why this is a
+# workflow_run and not a pull_request_target run, which would offer fork code a
+# privileged token for no gain.
 #
-# Same-repo PRs are published by api-review-gate.yaml's `api-gate-report` job;
-# this workflow no-ops for them (see the job guard), so the two publishers are
-# mutually exclusive and can never clobber each other.
+# Two ways in:
+#   - workflow_run, after any gate run that evaluated: a push, or a review on a
+#     PR whose runs need no maintainer approval. Full evaluation.
+#   - schedule, for reviews on PRs from external contributors. The repository
+#     makes every run from them wait for a maintainer's approval, and a run in
+#     `action_required` never fires workflow_run, so an API owner's approval
+#     would otherwise not reach the status until a maintainer approved the
+#     review-triggered run. The sweep never runs the detector. It moves only a
+#     status that the full evaluation set after finding a sizeable change,
+#     between "needs an approving review" and "approved", as the reviews on the
+#     head commit now dictate. A fork PR whose push run still awaits approval
+#     has no status to move and stays unmergeable, as it does for "E2E Tests".
+#
+# Same-repo PRs are published by api-review-gate.yaml's `api-gate-report`. Every
+# path here acts only on PRs whose head lives in another repository, decided
+# from the base repo's PR list: for a `pull_request_review` run on a fork PR,
+# `workflow_run.head_repository` names the BASE repository and
+# `workflow_run.pull_requests` is empty, so neither can tell a fork apart.
 
 on:
   workflow_run:
     workflows: ["API Review Gate"]
     types: [completed]
+  schedule:
+    - cron: "*/15 * * * *"
+  workflow_dispatch: {}
 
-# Supersede a stale publish when the fork pushes again. Keyed by fork repo plus
-# branch because the PR number is not available at workflow level. Safe to cancel:
-# statuses are per-SHA, and a newer gate run publishes its own.
+# Keyed by head SHA: a newer evaluation of the same commit reads newer reviews,
+# so it may supersede an older one, while a push gets a new SHA and leaves the
+# old commit's run alone. A cancelled or skipped gate run evaluated nothing and
+# `resolve` ignores it; in the shared group it would still cancel the live run
+# that owns the SHA after that run had posted `pending`, leaving nothing to
+# conclude it. Keyed by its own run id, it can cancel only itself.
 concurrency:
-  group: api-review-gate-fork-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
+  group: >-
+    api-review-gate-fork-${{
+    github.event_name != 'workflow_run' && 'sweep'
+    || (github.event.workflow_run.conclusion == 'cancelled'
+    || github.event.workflow_run.conclusion == 'skipped')
+    && format('discarded-{0}', github.event.workflow_run.id)
+    || github.event.workflow_run.head_sha }}
   cancel-in-progress: true
 
 permissions:
   contents: read
 
 jobs:
-  report:
-    name: Report fork API review gate status
-    # Fork PRs only: the head repository differs from the base repository.
+  resolve:
+    name: Resolve fork PR
+    # A `commented` review skips every job of the gate run, so it concludes
+    # `skipped`; a run superseded by a newer one concludes `cancelled`. Neither is
+    # a reason to re-evaluate. A fork can force either outcome by editing its copy
+    # of the gate, which can only withhold a re-evaluation: a push then leaves no
+    # status at all, and a withdrawn approval is caught by the sweep.
     if: >-
-      ${{ github.event.workflow_run.head_repository.full_name != github.event.workflow_run.repository.full_name }}
+      ${{ github.event_name == 'workflow_run'
+      && github.event.workflow_run.conclusion != 'cancelled'
+      && github.event.workflow_run.conclusion != 'skipped' }}
     runs-on: ubuntu-latest
     permissions:
-      contents: read
+      pull-requests: read
       statuses: write
+    outputs:
+      should_run: ${{ steps.r.outputs.should_run }}
+      pr_number: ${{ steps.r.outputs.pr_number }}
+      base_ref: ${{ steps.r.outputs.base_ref }}
     steps:
-      - name: Report "API Review Gate" commit status
+      - name: Resolve fork PR and claim "API Review Gate"
+        id: r
         uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
         env:
           HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
-          CONCLUSION: ${{ github.event.workflow_run.conclusion }}
-          GATE_RUN_URL: ${{ github.event.workflow_run.html_url }}
         with:
           script: |
-            const { HEAD_SHA, CONCLUSION, GATE_RUN_URL } = process.env;
+            const { HEAD_SHA } = process.env;
             const { owner, repo } = context.repo;
-
-            // Validate before the SHA flows into a status target.
+            core.setOutput('should_run', 'false');
             if (!/^[0-9a-f]{40}$/.test(HEAD_SHA)) {
               core.setFailed(`unexpected head_sha '${HEAD_SHA}'`);
               return;
             }
+            const setStatus = (state, description) => github.rest.repos.createCommitStatus({
+              owner, repo, sha: HEAD_SHA, state, context: 'API Review Gate', description,
+              target_url: `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`,
+            });
 
-            // Publish only a real verdict. A `commented` review skips every job in
-            // the gate run (conclusion `skipped`); a run cancelled by the
-            // concurrency group never evaluated; a fork run still awaiting
-            // maintainer approval concludes `action_required`. In every one of those
-            // cases the previous status on this SHA is still the correct one, and
-            // writing over it would either invent a pass or clobber a green.
-            // Declining to post is also fail-closed: on a SHA that never got a
-            // status the required context stays "Expected" and blocks the merge.
-            if (CONCLUSION !== 'success' && CONCLUSION !== 'failure') {
-              core.info(`gate run concluded '${CONCLUSION}' — posting nothing`);
+            // The association endpoint does not index fork heads, so match the
+            // open PR list on the head SHA, as e2e-fork.yaml's `resolve` does.
+            // A closed or merged PR never counts.
+            const openPrs = await github.paginate(
+              github.rest.pulls.list,
+              { owner, repo, state: 'open', per_page: 100 },
+            );
+            const atHead = openPrs.filter(p => p.head && p.head.sha === HEAD_SHA);
+            const isFork = p => !p.head.repo || p.head.repo.full_name !== `${owner}/${repo}`;
+            if (!atHead.some(isFork)) {
+              core.info(`no open fork PR has head ${HEAD_SHA} — nothing to publish`);
               return;
             }
+            // The status lands on the SHA, so it would speak for every PR at this
+            // head while being computed from one of them.
+            if (atHead.length !== 1) {
+              await setStatus('failure', `expected exactly one open PR for ${HEAD_SHA}, found ${atHead.length}`);
+              return;
+            }
+            const pr = atHead[0];
+            if (!/^[A-Za-z0-9._\/-]+$/.test(pr.base.ref)) {
+              core.setFailed(`unexpected base ref '${pr.base.ref}'`);
+              return;
+            }
+
+            // Claimed before the minute-long evaluation, so a dismissal cannot
+            // leave a stale `success` mergeable meanwhile.
+            await setStatus('pending', 're-evaluating in the privileged fork workflow');
+            core.setOutput('pr_number', String(pr.number));
+            core.setOutput('base_ref', pr.base.ref);
+            core.setOutput('should_run', 'true');
+
+  # The only job that touches fork bytes, so its token can write nothing and it
+  # reaches no secret. The detector is built from the merge base, as in the
+  # unprivileged gate, or else from the default branch; never from the head.
+  detect:
+    name: Detect sizeable API changes
+    needs: [resolve]
+    if: ${{ needs.resolve.outputs.should_run == 'true' }}
+    runs-on: ubuntu-latest
+    permissions:
+      contents: read
+    outputs:
+      sizeable: ${{ steps.detect.outputs.sizeable }}
+    env:
+      HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
+      PR_NUMBER: ${{ needs.resolve.outputs.pr_number }}
+      BASE_REF: ${{ needs.resolve.outputs.base_ref }}
+      GOPROXY: "https://proxy.golang.org|direct"
+    steps:
+      - name: Checkout default branch
+        uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+        with:
+          fetch-depth: 0
+          persist-credentials: false
+
+      # From the default branch's go.mod, and with no cache: a cache saved here
+      # would be keyed in the default branch's scope.
+      - name: Set up Go
+        uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
+        with:
+          go-version-file: go.mod
+          cache: false
+
+      - name: Materialize head and merge-base worktrees
+        run: |
+          set -euo pipefail
+          git fetch --no-tags origin "refs/pull/${PR_NUMBER}/head" "${BASE_REF}"
+          got="$(git rev-parse FETCH_HEAD)"
+          if [ "${got}" != "${HEAD_SHA}" ]; then
+            echo "::error::PR head moved: refs/pull/${PR_NUMBER}/head is ${got}, resolved ${HEAD_SHA}"
+            exit 1
+          fi
+          merge_base="$(git merge-base "${HEAD_SHA}" "origin/${BASE_REF}")"
+          echo "Merge base: ${merge_base}"
+          # Symlinks land as plain files, so a fork cannot point a manifest the
+          # detector reads at a file elsewhere on the runner and have it echoed
+          # into the public log. The detector never follows the tree's own
+          # directory links, so the verdict is unchanged.
+          git -c core.symlinks=false worktree add --detach "${RUNNER_TEMP}/head" "${HEAD_SHA}"
+          git worktree add --detach "${RUNNER_TEMP}/base" "${merge_base}"
+
+      - name: Build api-gate
+        run: |
+          set -euo pipefail
+          if [ -d "${RUNNER_TEMP}/base/cmd/api-gate" ]; then
+            build_dir="${RUNNER_TEMP}/base"
+          else
+            build_dir="${GITHUB_WORKSPACE}"
+            echo "The merge base predates api-gate; building it from the default branch."
+          fi
+          ( cd "${build_dir}" && go build -o "${RUNNER_TEMP}/api-gate" ./cmd/api-gate )
+
+      - name: Detect sizeable API changes
+        id: detect
+        run: |
+          set -uo pipefail
+          "${RUNNER_TEMP}/api-gate" \
+            --base "${RUNNER_TEMP}/base" \
+            --head "${RUNNER_TEMP}/head" \
+            --format markdown > "${RUNNER_TEMP}/report.md"
+          code=$?
+          set -e
+          cat "${RUNNER_TEMP}/report.md"
+          { echo "## API Review Gate (fork PR #${PR_NUMBER})"; echo; cat "${RUNNER_TEMP}/report.md"; } >> "$GITHUB_STEP_SUMMARY"
+          case "${code}" in
+            0) echo "sizeable=false" >> "$GITHUB_OUTPUT" ;;
+            2) echo "sizeable=true"  >> "$GITHUB_OUTPUT" ;;
+            *) echo "::error::api-gate failed with exit code ${code}"; exit "${code}" ;;
+          esac
 
-            // A workflow_run payload carries no job outputs, so unlike the
-            // same-repo `api-gate-report` this cannot separate "needs an API owner
-            // approval" from "the evaluation broke". target_url points at the gate
-            // run, whose job summary spells the verdict out.
-            const state = CONCLUSION === 'success' ? 'success' : 'failure';
-            const description = state === 'success'
-              ? 'API review gate satisfied'
-              : 'API review gate not satisfied — see the gate run';
-
-            try {
-              await github.rest.repos.createCommitStatus({
-                owner, repo, sha: HEAD_SHA, state,
-                context: 'API Review Gate', description, target_url: GATE_RUN_URL,
-              });
-            } catch (err) {
-              // Fail loudly and fail-closed: an unposted required status stays
-              // "Expected", which blocks the merge.
-              core.setFailed(`Failed to report API Review Gate status: ${err}`);
+  # Publishes the verdict of a workflow_run evaluation, or sweeps on a schedule.
+  # One script for both, so the approval rule exists once in this file.
+  conclude:
+    name: Report fork API review gate status
+    needs: [resolve, detect]
+    if: >-
+      ${{ !cancelled()
+      && (github.event_name != 'workflow_run'
+      || needs.resolve.outputs.should_run == 'true') }}
+    runs-on: ubuntu-latest
+    permissions:
+      contents: read
+      pull-requests: read
+      statuses: write
+    steps:
+      - name: Checkout the gate workflow
+        uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+        with:
+          sparse-checkout: .github/workflows/api-review-gate.yaml
+          sparse-checkout-cone-mode: false
+          persist-credentials: false
+
+      # api-review-gate.yaml owns the list, so the two publishers cannot drift.
+      # Read from the default branch, which is where this workflow runs from.
+      - name: Read API owners
+        id: owners
+        run: |
+          set -euo pipefail
+          owners="$(yq -r '.env.API_OWNERS' .github/workflows/api-review-gate.yaml)"
+          if ! [[ "${owners}" =~ ^[A-Za-z0-9-]+(\ [A-Za-z0-9-]+)*$ ]]; then
+            echo "::error::unexpected API_OWNERS '${owners}' in api-review-gate.yaml"
+            exit 1
+          fi
+          echo "owners=${owners}" >> "$GITHUB_OUTPUT"
+
+      - name: Report "API Review Gate" commit status
+        uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
+        env:
+          API_OWNERS: ${{ steps.owners.outputs.owners }}
+          HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
+          PR_NUMBER: ${{ needs.resolve.outputs.pr_number }}
+          DETECT_RESULT: ${{ needs.detect.result }}
+          SIZEABLE: ${{ needs.detect.outputs.sizeable }}
+        with:
+          script: |
+            const { API_OWNERS, HEAD_SHA, PR_NUMBER, DETECT_RESULT, SIZEABLE } = process.env;
+            const { owner, repo } = context.repo;
+            const owners = new Set(API_OWNERS.toLowerCase().split(' '));
+            // The same descriptions api-gate-report posts. The sweep reads the
+            // first two back as state, so change them only together with it.
+            const APPROVED = 'sizeable API change approved by an API owner';
+            const NEEDS_PREFIX = 'sizeable API change needs an approving review from: ';
+            const NEEDS = NEEDS_PREFIX + API_OWNERS;
+
+            const setStatus = (sha, state, description) => github.rest.repos.createCommitStatus({
+              owner, repo, sha, state, context: 'API Review Gate', description,
+              target_url: `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`,
+            });
+
+            // The rule of api-review-gate.yaml's approval step: each reviewer's
+            // latest non-comment review on this exact commit, so an approval of an
+            // older commit, a dismissed one and one superseded by a request for
+            // changes all fail to count.
+            const ownerApproved = async (pull_number, sha) => {
+              const reviews = await github.paginate(
+                github.rest.pulls.listReviews,
+                { owner, repo, pull_number, per_page: 100 },
+              );
+              const latest = new Map();
+              for (const r of reviews) {
+                if (!r.user || r.commit_id !== sha || r.state === 'COMMENTED') continue;
+                const login = r.user.login.toLowerCase();
+                const prev = latest.get(login);
+                if (!prev || (r.submitted_at || '') >= (prev.submitted_at || '')) latest.set(login, r);
+              }
+              return [...latest].some(([login, r]) => owners.has(login) && r.state === 'APPROVED');
+            };
+
+            if (context.eventName === 'workflow_run') {
+              const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: Number(PR_NUMBER) });
+              // A push since `resolve` started a newer run, which owns the new head.
+              if (pr.state !== 'open' || pr.head.sha !== HEAD_SHA) {
+                core.info(`PR #${PR_NUMBER} is no longer open at ${HEAD_SHA} — posting nothing`);
+                return;
+              }
+              if (DETECT_RESULT === 'success' && SIZEABLE === 'false') {
+                await setStatus(HEAD_SHA, 'success', 'no sizeable API change');
+              } else if (DETECT_RESULT === 'success' && SIZEABLE === 'true') {
+                const ok = await ownerApproved(pr.number, HEAD_SHA);
+                await setStatus(HEAD_SHA, ok ? 'success' : 'failure', ok ? APPROVED : NEEDS);
+              } else {
+                await setStatus(HEAD_SHA, 'failure', 'gate evaluation did not complete — see the run for details');
+              }
               return;
             }
-            core.info(`API Review Gate → ${state}: ${description}`);
+
+            // Sweep. One GraphQL page returns 100 open PRs with the gate's status
+            // on each head, which keeps a 15-minute cadence well inside the
+            // GITHUB_TOKEN rate limit; reviews are fetched only for the few PRs
+            // whose status the reviews can move.
+            const query = `query($owner: String!, $repo: String!, $cursor: String) {
+              repository(owner: $owner, name: $repo) {
+                pullRequests(states: OPEN, first: 100, after: $cursor) {
+                  pageInfo { hasNextPage endCursor }
+                  nodes {
+                    number
+                    isCrossRepository
+                    headRefOid
+                    commits(last: 1) { nodes { commit { oid status {
+                      context(name: "API Review Gate") { state description creator { login } }
+                    } } } }
+                  }
+                }
+              }
+            }`;
+            let cursor = null;
+            let errors = 0;
+            do {
+              const page = (await github.graphql(query, { owner, repo, cursor })).repository.pullRequests;
+              for (const pr of page.nodes) {
+                const commit = pr.commits.nodes[0]?.commit;
+                const status = commit?.status?.context;
+                // Only a status a workflow posted: a fork's own runs cannot post
+                // one, so on a fork head that means this workflow.
+                if (!pr.isCrossRepository || !status || commit.oid !== pr.headRefOid
+                    || status.creator?.login !== 'github-actions') continue;
+                const description = status.description || '';
+                const awaiting = status.state === 'FAILURE' && description.startsWith(NEEDS_PREFIX);
+                const approved = status.state === 'SUCCESS' && description === APPROVED;
+                if (!awaiting && !approved) continue;
+                try {
+                  const ok = await ownerApproved(pr.number, pr.headRefOid);
+                  if (awaiting && ok) {
+                    await setStatus(pr.headRefOid, 'success', APPROVED);
+                    core.info(`#${pr.number}: approved by an API owner → success`);
+                  } else if (approved && !ok) {
+                    await setStatus(pr.headRefOid, 'failure', NEEDS);
+                    core.info(`#${pr.number}: API owner approval withdrawn → failure`);
+                  }
+                } catch (err) {
+                  errors++;
+                  core.warning(`#${pr.number}: ${err}`);
+                }
+              }
+              cursor = page.pageInfo.hasNextPage ? page.pageInfo.endCursor : null;
+            } while (cursor);
+            if (errors) core.setFailed(`${errors} PR(s) could not be swept`);
diff --git a/.github/workflows/api-review-gate.yaml b/.github/workflows/api-review-gate.yaml
index 62a11ce01..d9f6617eb 100644
--- a/.github/workflows/api-review-gate.yaml
+++ b/.github/workflows/api-review-gate.yaml
@@ -191,14 +191,18 @@ jobs:
           # An APPROVED review by any API owner satisfies the gate, but only
           # each reviewer's *latest* review *on the current head commit* counts:
           # this drops approvals that a later commit invalidated, and approvals
-          # an owner subsequently superseded with CHANGES_REQUESTED. GitHub
-          # forbids self-approval, so a sizeable PR authored by an owner still
-          # needs the other owner.
+          # an owner subsequently superseded with CHANGES_REQUESTED or that was
+          # dismissed. A COMMENTED review is dropped before picking the latest:
+          # GitHub does not let a comment withdraw an approval, and the review
+          # trigger skips comments, so counting one here would make the verdict
+          # depend on which event happened to re-run the gate. GitHub forbids
+          # self-approval, so a sizeable PR authored by an owner still needs the
+          # other owner. api-review-gate-fork.yaml applies the same rule.
           approvals="$(
             gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" --paginate |
             jq -rs --arg head "${HEAD_SHA}" '
               add
-              | map(select(.commit_id == $head))
+              | map(select(.commit_id == $head and .state != "COMMENTED"))
               | sort_by(.submitted_at // "")
               | group_by(.user.login)
               | map(last)
@@ -224,6 +228,37 @@ jobs:
           echo "::error::This PR makes a sizeable API change (new group, new resource, or breaking change) and needs an approving review from one of: ${API_OWNERS}. See the job summary for details."
           exit 1
 
+  # A dismissal or a request for changes can turn a green head red, and until
+  # the evaluation reports, about a minute later, the old `success` would still
+  # let the PR merge. Same opener as the "E2E Tests" lane. Pushes need none: a
+  # new head SHA starts with no status. Losing it to a transient API error only
+  # reopens that window, so it must not fail the run.
+  api-gate-pending:
+    name: Mark API review gate as re-evaluating
+    if: >-
+      ${{ github.event_name == 'pull_request_review'
+      && github.event.review.state != 'commented'
+      && !github.event.pull_request.head.repo.fork }}
+    runs-on: ubuntu-latest
+    continue-on-error: true
+    permissions:
+      statuses: write
+    steps:
+      - name: Report pending "API Review Gate" commit status
+        uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
+        env:
+          HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+          RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+        with:
+          script: |
+            const { HEAD_SHA, RUN_URL } = process.env;
+            const { owner, repo } = context.repo;
+            await github.rest.repos.createCommitStatus({
+              owner, repo, sha: HEAD_SHA, state: 'pending',
+              context: 'API Review Gate', description: 're-evaluating after a review',
+              target_url: RUN_URL,
+            });
+
   # Publish the required "API Review Gate" commit status. Separate from the
   # evaluating job for the reasons in the header: the gate must not be a job
   # name, and a run that reached no verdict must post nothing.
@@ -237,12 +272,14 @@ jobs:
   #
   # Fork PRs skip this job. Their GITHUB_TOKEN is read-only on BOTH the
   # `pull_request` and `pull_request_review` triggers, so no run of this workflow
-  # can post a status for a fork; api-review-gate-fork.yaml concludes the context
-  # for them from a privileged workflow_run. Until it does, the required status is
-  # unposted and the fork PR is not mergeable — fail-closed by construction.
+  # can post a status for a fork, and its verdict could not be relayed anyway: a
+  # fork's run executes the fork's copy of this file. api-review-gate-fork.yaml
+  # evaluates fork PRs again from the default branch. Until it does, the required
+  # status is unposted and the fork PR is not mergeable — fail-closed by
+  # construction.
   api-gate-report:
     name: Report API review gate status
-    needs: [api-review-gate]
+    needs: [api-gate-pending, api-review-gate]
     if: >-
       ${{ !cancelled()
       && !github.event.pull_request.head.repo.fork
diff --git a/docs/agents/e2e-testing.md b/docs/agents/e2e-testing.md
index 9ee1084a7..a12687ae0 100644
--- a/docs/agents/e2e-testing.md
+++ b/docs/agents/e2e-testing.md
@@ -166,7 +166,9 @@ Consequences to keep in mind when touching either workflow:
   - *Not a check-run:* a check-run created with the default `GITHUB_TOKEN` floats into an arbitrary GitHub Actions check-suite (in practice the earliest-registered one, a tiny labeler — that is why `E2E Tests` once rendered under "PR size label"), and an app-token check-run would need the COZYSTACK_CI app granted `checks: write`. A commit status has neither problem: branch protection matches it by context name and it carries no check-suite grouping. It is safe to post with the default token because a fork's `pull_request` token is **read-only** and cannot post a status — so only same-repo runs (`plan`'s opener and `e2e-report`, both guarded `!fork`) and the privileged `workflow_run` in `e2e-fork.yaml` can set it. Each publishing job carries `permissions: statuses: write` and calls `repos.createCommitStatus`. `plan`'s opener is additionally `continue-on-error`, since every build and test job hangs off `plan` and a transient status-API failure there must not discard the run; losing it only restores the stale-green window for that one run.
   **Consequence for branch protection:** require the `E2E Tests` **status context** (no GitHub App and no app-id pin needed).
 
-  **This pattern now has a second consumer — change it in both places.** `.github/workflows/api-review-gate.yaml` publishes the `API Review Gate` context the same way: an `api-gate-report` job for same-repo PRs, a privileged `workflow_run` (`api-review-gate-fork.yaml`) for forks. Two details measured while wiring it up are worth recording here, because they are the load-bearing reason the gate cannot be a job. First, check runs are **not** deduplicated per name: each workflow run gets its own check suite, so `GET /commits/{sha}/check-runs?filter=latest` returns the same set as `filter=all`, and two runs on one head SHA leave two verdicts standing for one context name — the aggregate is worst-of, so a cancelled run poisons the context until the next push. Commit statuses **are** deduplicated per context, newest wins. Second, the read-only-token restriction on fork PRs covers `pull_request_review` as well as `pull_request`, so an approval arriving on a fork PR cannot publish a status either — which is why both gates need a privileged `workflow_run` half rather than just handling the push path. Together these give the invariant both gates rely on: a run that posts nothing preserves whatever verdict a real run last reached, and can never invent one.
+  **This pattern now has a second consumer — change it in both places.** `.github/workflows/api-review-gate.yaml` publishes the `API Review Gate` context the same way for same-repo PRs, from an `api-gate-report` job, and `api-review-gate-fork.yaml` publishes it for forks. Two details measured while wiring it up are worth recording here, because they are the load-bearing reason the gate cannot be a job. First, check runs are **not** deduplicated per name: each workflow run gets its own check suite, so `GET /commits/{sha}/check-runs?filter=latest` returns the same set as `filter=all`, and two runs on one head SHA leave two verdicts standing for one context name — the aggregate is worst-of, so a cancelled run poisons the context until the next push. Commit statuses **are** deduplicated per context, newest wins. Second, the read-only-token restriction on fork PRs covers `pull_request_review` as well as `pull_request`, so an approval arriving on a fork PR cannot publish a status either. Together these give the invariant both gates rely on: a run that posts nothing preserves whatever verdict a real run last reached, and can never invent one.
+
+  The fork half of the API gate departs from `e2e-fork.yaml` in three ways, each forced by a measurement. It does not take a fork from `workflow_run.head_repository` or `workflow_run.pull_requests`: for a `pull_request_review` run on a fork PR the first names the base repository and the second is empty, so it resolves the PR from the base repo's open PR list by head SHA instead. It does not relay the triggering run's conclusion, because a fork's run executes the fork's copy of the gate workflow; it rebuilds the detector from the base branch, runs it over the fetched head as data, and reads the reviews API itself. And it also runs on a schedule, because the repository makes every run from an external contributor wait for a maintainer's approval and a run in `action_required` never fires `workflow_run`, so an API owner's approval on such a PR reaches the status only through the sweep, scheduled every fifteen minutes (GitHub may start a scheduled run late). The sweep moves only a status the full evaluation already set after finding a sizeable change, between "needs an approving review" and "approved"; a fork PR whose push run still awaits approval has no status for it to move, and stays unmergeable until a maintainer lets that run execute, as it does for `E2E Tests`.
 
 - **Fork TIA reads trusted inputs, but the fork still writes the selector.** `e2e-fork.yaml`'s `e2e` job runs the same `select-e2e.sh` TIA as in-tree (§7), and its inputs are hardened: the changed-file list and the `full-e2e` label come from `resolve`'s base-repo API lookups (`pulls.listFiles` / the PR's labels), the same trusted source as the docs-only decision, passed in as outputs (`changed_b64`, `full_e2e`) rather than re-derived from a `git diff` of the fork-controlled merge ref. Keep that part as it is: re-deriving the selection from the checked-out tree would add a second bypass for no gain.
 - **What the fork lane is, and is not.** The `e2e` job runs from the fork's tree, so `select-e2e.sh`, the suites under `hack/e2e-chainsaw/`, `hack/e2e-install-cozystack.bats` and `packages/core/testing`'s recipes are all fork-authored. A fork that wants its own suite to report green can edit any of them, and hardening the TIA inputs does not change that. Read the lane accordingly: it is a correctness gate that stops an honest contributor shipping a regression, not a boundary against a hostile one. What contains a hostile fork is that no fork run executes without maintainer approval (below), that the job holds no push credential, and that a diff editing the test harness is visible in review.

The gate's merge context was its job name, which inherits two GitHub
behaviours that together make it both unenforceable and self-poisoning.

A required *job* that skips counts as PASSED by branch protection, so
any path that skips the evaluating job hands out a free pass — the hole
that let fork PRs merge with e2e never run (#3257). And check runs are
not deduplicated per name: every workflow run gets its own check suite,
so `GET /commits/{sha}/check-runs?filter=latest` returns the same set as
`filter=all`, and the rollup is worst-of. Two runs on one head SHA
therefore leave two verdicts standing for one context name, and a
cancelled run poisons the context until the next push. Commit statuses
are deduplicated per context, newest wins.

Rename the evaluating job off the gate context and publish "API Review
Gate" as a commit status: `api-gate-report` for same-repo PRs, and a
privileged workflow_run for forks, whose GITHUB_TOKEN is read-only on
both the pull_request and the pull_request_review trigger and so can
never post a status itself. Skipped and cancelled runs post nothing, so
declining to post can only ever preserve a verdict some real run
reached — it can never invent one.

That invariant is what makes it safe to stop re-evaluating on the plain
`commented` reviews the review bots post: such a review cannot move a
verdict that counts only APPROVED reviews, and the run that skips now
leaves the previous status untouched instead of overwriting it.

Also split the concurrency group so a run that computes no verdict
cannot cancel one that does. A bot commenting during the ~30s
evaluation used to cancel it and then contribute nothing, leaving a
cancelled run as that SHA's only api-gate verdict (41d1153, 4b1966c).

For the gate to actually block a merge, main branch protection must
require the "API Review Gate" status context; until then it is
advisory, as the job name was.

Assisted-by: LLM
Signed-off-by: Myasnikov Daniil <[email protected]>
The "E2E Tests" commit-status pattern in §10 now has a second consumer
in api-review-gate.yaml, and the two GitHub behaviours that make it
load-bearing were not written down anywhere.

Check runs are not deduplicated per name across workflow runs — each run
gets its own check suite, so filter=latest returns the same set as
filter=all and the aggregate is worst-of, meaning a cancelled run
poisons a context until the next push — while commit statuses are
deduplicated per context with the newest winning. And the fork
read-only token restriction covers pull_request_review as well as
pull_request, so an approval on a fork PR cannot publish a status
either, which is why both gates need a privileged workflow_run half
rather than only handling the push path.

Assisted-by: LLM
Signed-off-by: Myasnikov Daniil <[email protected]>
On a fork PR an API owner's approval could never turn the status green.
For a pull_request_review run on a fork PR, workflow_run.head_repository
names the base repository and pull_requests is empty, so the fork
publisher took every review-triggered run for a same-repo one and
skipped it, while the same-repo reporter skipped it for being a fork. A
sizeable fork PR stayed on the failure its push run had posted.

Fixing the guard alone would leave most forks stuck. The repository
makes every run from an external contributor wait for a maintainer's
approval, and a run in action_required never fires workflow_run, so
their review-triggered runs never reach the publisher. And a fork's run
executes the fork's own copy of the gate workflow, so the conclusion the
publisher relayed was whatever the fork made it.

The privileged workflow therefore reaches the verdict itself, from
inputs a fork cannot forge, and a scheduled sweep covers the runs that
never execute. The sweep only moves a status the full evaluation already
set after finding a sizeable change, so it cannot create a pass.

The same-repo gate now posts pending as soon as an actionable review
arrives: a dismissal can turn a green head red, and the old success
would stay mergeable for the minute the evaluation takes. Both paths
also ignore COMMENTED reviews when taking each reviewer's latest, since
an owner who approved and then commented had withdrawn their own
approval as far as the gate was concerned, which GitHub does not do.

Assisted-by: LLM
Signed-off-by: Myasnikov Daniil <[email protected]>
A commit status belongs to the commit, so two open PRs that carry the
same head share one "API Review Gate" status. The fork workflow
attributed that status to a fork PR by head SHA alone. A fork PR opened
at a commit of this repository could therefore overwrite the verdict of
the same-repo PR that owns it: resolve wrote a failure after
api-gate-report had posted success, and the sweep applied the fork PR's
reviews to a status the other PR had earned. A fork can reference any
commit of the repository's network, so anyone able to open a PR could
hold another PR's gate red.

The fork workflow now leaves a head that an open same-repo PR carries to
api-gate-report, and the sweep skips any head carried by more than one
open PR. Two fork PRs at one head still fail closed in resolve, since
their bases, and so their verdicts, can differ.

The header of the same-repo workflow said skipped and cancelled runs
post nothing. That stopped being true once a review posts pending ahead
of its evaluation, so it now says they post no verdict.

Assisted-by: LLM
Signed-off-by: Myasnikov Daniil <[email protected]>
api-gate exits 2 to report a sizeable change, and the runner runs a step
without an explicit shell as bash -e. The fork detect step switched
errexit off only after reading the exit code, so the first failing
command ended the step with 2 before sizeable=true was recorded. Every
fork PR with a sizeable API change then got "gate evaluation did not
complete", which the sweep deliberately ignores, so an owner's approval
could not turn it green by either path. The same-repo step already
switches errexit off before the call.

Assisted-by: LLM
Signed-off-by: Myasnikov Daniil <[email protected]>
A commit status belongs to the commit. When two open fork PRs carry the
same head, a success already standing on it may have been earned by one
of them on another base, or may rest on an approval that was withdrawn
on a PR whose run waits for a maintainer and so never reaches resolve.
The sweep skipped such a head and left that success in place, which let
a duplicate fork PR keep a withdrawn approval from taking effect.

The sweep now writes the same failure resolve writes, once per head,
when a success set by a workflow stands on a head that only fork PRs
carry. It still never creates a status, and a head that an open
same-repo PR carries stays with api-gate-report.

Assisted-by: LLM
Signed-off-by: Myasnikov Daniil <[email protected]>
@myasnikovdaniil
myasnikovdaniil force-pushed the ci/narrow-api-gate-review-trigger branch from 43b1108 to cbfaf80 Compare October 30, 2026 11:20
@github-actions github-actions Bot added size/XL This PR changes 500-999 lines, ignoring generated files and removed size/L This PR changes 100-499 lines, ignoring generated files labels Oct 30, 2026
@myasnikovdaniil

Copy link
Copy Markdown
Contributor Author

Aleksei Sviridkin (@lexfrei) blocker is solved, and by more than a guard fix: fork workflow finds PR by head SHA and computes verdict itself in a job that only reads, fork's run is just a trigger. Runs stuck in action_required are covered by 15 minute sweep. Please take another look.

@myasnikovdaniil

Copy link
Copy Markdown
Contributor Author

Timofei Larkin (@lllamnyp) thanks, applied your patch whole. On top of it: two fork PRs on one head fail closed, fork detect step got set +e (exit 2 ended it under bash -e), detector refuses links that leave the tree and files over size cap, and it is built from default branch, not merge base, because PR author picks merge base.

Branch protection still requires the old job name Require API owner review for sizeable API changes, after merge it has to be swapped to API Review Gate. Can you do it, or tell me who does?

Detector evasions I found on main while testing are in #5170.

@lllamnyp

Copy link
Copy Markdown
Member

myasnikovdaniil thanks, and good catches on top of the patch: the missing set +e would have sent every sizeable fork PR to "evaluation did not complete", building the detector from the default branch is right, and the shared-head handling closes a hole I left open. Those stay.

One scope call before this goes in. The gate exists so that a change to our own API (the cozystack.io groups) does not land without an API owner seeing it. It is not meant to stop an author who is deliberately hiding such a change from the detector: a CRD shipped as .json, tucked under templates/ without a .yaml extension, reached through a symlink or padded past a size limit is exactly the kind of placement a reviewer notices at once, and every PR already needs a human approval. I would rather keep the detector simple than grow it into a SAST tool, so please take the evasion hardening back out of this PR:

  • Drop 7a46eac25 (symlink resolution in the detector) and cbfaf80bb (size cap as an error), so internal/apigate is back to what main has.
  • From f008468ea, keep the default-branch build and the plain git worktree add checkout. No link handling is needed there either: a link from the head to a file elsewhere on the runner only gets that file parsed, the detector's errors name the path and the parser's complaint but not the content, and the detect job holds nothing worth reading anyway.
  • Update the PR body and the docs paragraph wherever they describe the link and size handling.

For #5170 the same reasoning covers cases 1 to 3, so I'd close those as out of scope. Case 4 is different, but not because of the decoy. If the tree ships two copies of the same first-party CRD, either something went wrong or we have a legitimate reason, such as two variants of one package. In the legitimate case neither copy should win over the other: each one should be checked against its previous version, and a sizeable change in either one is sizeable. Could you first check whether the tree has any such duplicates today? Then narrow #5170 to that, and fix it in its own PR rather than here.

One part of the symlink work is worth keeping for that issue: follow directory links during the walk, as Helm does when it packages a chart, remembering visited targets so a loop cannot hang it. A chart whose crds/ links outside packages/ is a plausible honest layout, and today its CRDs would ship unseen.

On branch protection: I'll handle it. I'll remove the old Require API owner review for sizeable API changes check right before merging, since this PR renames that job and could never satisfy it, and add the API Review Gate status once it is in. Merging waits on the changes above, Aleksei Sviridkin (@lexfrei)'s re-review and green CI.

The merge base is the commit the fork's author branched from, so a
branch cut from an old commit selected an old detector, and a fix to
the detector never reached the PRs an attacker controls. The workflow
itself comes from the default branch, so the detector now does too.

The head is also checked out with its links intact. Flattening them
hid a CRD behind a file link from the detector while Helm still
shipped it, and made the verdict differ from the one the same-repo
gate reaches.

Assisted-by: LLM
Signed-off-by: Myasnikov Daniil <[email protected]>
@myasnikovdaniil

Copy link
Copy Markdown
Contributor Author

Timofei Larkin (@lllamnyp) done, both commits are out and internal/apigate is the same as main again. The default branch build and the plain git worktree add stay.

No first-party CRD is defined twice on main today, I checked with the detector's own parser (3229 yaml files, 71 keys). #5170 is rewritten to the duplicate case and directory links, cases 1 to 3 are removed from it.

Aleksei Sviridkin (@lexfrei) the branch changed since your review, please take another look.

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.

myasnikovdaniil LGTM. An API owner's approval now reaches fork PRs, so my blocker from September is fixed, and lllamnyp's three points are in.

The fork guard no longer reads the review run's payload. resolve finds the PR in the open PR list by head SHA and calls it a fork when its head repo differs from the base. Review runs on org-member forks now get a full re-evaluation, and the sweep covers external contributors whose review runs wait in action_required. The docs paragraph says what that path doesn't cover. My non-blocking point about a stale success after a dismissal is also done with api-gate-pending.

The two places where the branch differs from lllamnyp's patch look right to me. Building the detector from the default branch takes the choice of detector away from the fork's author. Keeping symlinks is safe: detect has a read-only token, no persisted credentials and no secrets. The report only prints group, resource and field names from parsed manifests, so a link to a file elsewhere on the runner has nothing to leak into the log. Flattened links did let a CRD behind a file link get past the detector.

What I checked on b5e3b37a9. A trial merge with main d32092ac9 is clean. actionlint is clean on both workflows. zizmor 1.26.1 offline is clean with the repo config, and with --no-config the only finding is the workflow_run dangerous trigger, same as in e2e-fork.yaml. I ran the resolve and conclude scripts under node with a mocked API through 17 cases: a fork PR, a fork and a same-repo PR on one head, two forks on one head, a bad SHA, approval, approval followed by changes requested, approval on an older commit, a non-owner approval, a moved head, a failed detect, and the sweep transitions. Each posted what the comments say. On this PR's head GraphQL returns github-actions as the status creator, so the sweep's creator filter matches. The same-repo path works live here: API Review Gate is success with "no sizeable API change". E2E is still running, and the diff only touches workflows and a doc.

Non-blocking:

  1. The comment on resolve says a fork that forces a skipped or cancelled gate run "can only withhold a re-evaluation: a push then leaves no status at all". That is true for a SHA nobody evaluated before. If the same SHA was evaluated earlier through another PR with a different base, its status stays. The sweep only moves "needs approval" and "approved", so a "no sizeable API change" from the other base stays too. It needs a fork PR that edits its own gate workflow, which is visible in the diff, and the Limits section already says one status can't hold a verdict per base. I'd narrow the comment to match.
  2. Nothing pins the new concurrency key or the lockstep if between api-review-gate and api-gate-report. For E2E this is what hack/gate-concurrency-contract.bats does. A small bats file like it would catch someone merging the noop and eval groups later.
  3. Branch protection on main still requires Require API owner review for sizeable API changes, and API Review Gate isn't required yet. As the PR body says, the swap has to happen at merge time, otherwise every open PR waits for a check that never reports.

@lllamnyp
Timofei Larkin (lllamnyp) merged commit e31f518 into main Nov 2, 2026
29 checks passed
@lllamnyp
Timofei Larkin (lllamnyp) deleted the ci/narrow-api-gate-review-trigger branch November 2, 2026 15:15
myasnikovdaniil added a commit that referenced this pull request Nov 11, 2026
## What this PR does

Backports to `release-1.7` from `main`, picked by the rules of the
[backport
policy](https://github.com/cozystack/cozystack/blob/main/docs/release/backports.md)
in merge order. A merged pull request comes as its own commits, picked
with `git cherry-pick -x` so each keeps its author, message and
sign-offs, and a merge commit that carries the original's merge message
with a `Backported-from:` trailer naming it; a squash-merged one comes
as one pick. `backport-batch verify` checks that shape. A pick listed as
changed differs from the commit it came from; `git range-diff
<merge>^1..<merge>^2 <batch merge>^1..<batch merge>^2` shows how. A
conflict or a failing check here is a [backport
task](https://github.com/cozystack/cozystack/blob/main/docs/release/backport-tasks/README.md).

- [#4756](#4756)
`fix(backupstrategy-controller): run backup clients on a package-owned
image`, 2 commits, changed in the pick: `48d0cb2958a5`
- [#4867](#4867) `fix(e2e):
keep chainsaw asserts polling through transient API errors`, 1 commit
- [#4888](#4888) `fix(ci):
dispatch frozen-line builds with GITHUB_TOKEN`, 1 commit
- [#4985](#4985) `fix(etcd):
stop counting canceled watch streams as failed gRPC requests`, 1 commit
- [#5005](#5005)
`fix(tcp-balancer): cap HAProxy fds and threads by the pod's own
limits`, 1 commit
- [#4917](#4917)
`fix(kubernetes): run Proxmox CCM v0.16.1 and pin the Proxmox images by
digest`, 2 commits
- [#4974](#4974)
`fix(kubernetes-nodes): plan autoscaler capacity on the cores the VM
actually gets`, 1 commit
- [#4971](#4971) `fix(e2e):
keep the Proxmox tenant kubeconfig per run and remove it`, 2 commits
- [#4970](#4970) `fix(e2e):
read the KubernetesNodes pool release in the HelmRelease guard`, 3
commits
- [#4972](#4972) `fix(e2e):
validate the leak allowlist against every suite layout`, 3 commits
- [#5082](#5082)
`fix(marketplace): pin a tapped repository to a digest instead of a
mutable tag`, 15 commits, changed in the pick: `a31ae9090928`,
`b326e0badd62`, `a3d883b4bf5b`, `f8509450f7ca`
- [#4004](#4004)
`fix(dashboard): link a tenant row to its detail page`, 2 commits
- [#5212](#5212)
`fix(cilium): pass the CiliumNode list to jq on stdin in the IPAM hook`,
1 commit
- [#5213](#5213)
`fix(kubernetes): move the OIDC users out of the bootstrap Job's
script`, 1 commit
- [#5208](#5208)
`fix(clickhouse): restore the archive a Backup was taken as`, 3 commits
- [#5230](#5230)
`fix(backups): re-render the default backup objects when the S3 endpoint
changes`, 8 commits
- [#5133](#5133)
`fix(securitygroups): project a membership-only group to a policy Cilium
accepts`, 1 commit
- [#5091](#5091)
`fix(postgres): render the declared databases in a stable order`, 1
commit
- [#5123](#5123)
`fix(mariadb-operator): derive the cluster name from the platform
domain`, 2 commits
- [#5151](#5151) `fix(redis):
run one Sentinel when Redis runs a single pod`, 5 commits
- [#5124](#5124)
`fix(lineage, kubernetes): resolve CAPK node VMs to their cluster`, 2
commits
- [#5184](#5184) `fix(api):
read pool usage under the quota key the controller counts`, 1 commit
- [#5190](#5190)
`fix(platform): remove the dead registries value and patch-containerd
Secret`, 2 commits
- [#5211](#5211)
`fix(postgres): fail a backup job as soon as its cnpg Backup fails`, 1
commit
- [#5155](#5155)
`fix(postgres): refuse system databases and never drop them`, 1 commit
- [#5207](#5207)
`fix(mariadb): fail backup and restore jobs whose operator Job failed`,
5 commits
- [#5024](#5024)
`fix(kubernetes, monitoring): send tenant cluster metrics to the
tenant's vminsert`, 1 commit

### Maintainer overrides

- [#5083](#5083)
`fix(workloadmonitor): stop doubling the bucket- prefix in bucket
Workload names`: `backport/skip`, left out
- [#4973](#4973)
`fix(kubernetes-nodes): refuse a partial resources spec on every pool`:
`backport/skip`, left out
- [#5180](#5180) `fix(tests):
keep two unit tests green after the release image restamp`:
`backport/skip`, left out

### Skipped

- Rule 1, already on `release-1.7`: 19
- Rule 2, the title type is not `fix` or `security`, or the scope is
`deps`: 26
- Rule 3, a breaking change: 5
- Rule 4, touches platform migrations, `targetVersion` or a CRD, or
makes a sizeable API change: 2

<details><summary>Rule 1, already on `release-1.7`</summary>

- [#4815](#4815)
`fix(vm-disk): name the importer error in the disk's WorkloadsReady
message`, `its 2 commits picked, the last in 4323363`
- [#4293](#4293)
`fix(fluxcd): keep Helm uninstall working during flux-plunger recovery`,
`its 1 commit picked, the last in 0210407`
- [#4791](#4791)
`fix(kubernetes): keep NFS publish from hanging on a dead server`, `its
1 commit picked, the last in fa88bd1`
- [#4830](#4830)
`fix(multus): drop the CPU limit and make resources configurable`, `its
2 commits picked, the last in 363c842`
- [#4865](#4865) `fix(kafka):
pass the container limits to the Kafka JVMs`, `its 3 commits picked, the
last in fc38a54`
- [#4866](#4866)
`fix(linstor): mark volumes restored from a snapshot DRBD-initialized`,
`its 1 commit picked, the last in f9550b7`
- [#4819](#4819)
`fix(seaweedfs): build SeaweedFS in-tree with a bounded S3 read-ahead`,
`its 5 commits picked, the last in 7f4c346`
- [#3427](#3427)
`fix(backups): prepare standalone VMDisk restore targets and add VM
backup/restore e2e round-trips`, `its 3 commits picked, the last in
4ac0cd5`
- [#4889](#4889)
`fix(monitoring): send alerts to every Alertmanager replica`, `its 1
commit picked, the last in 5033d53`
- [#4929](#4929)
`fix(backups): gate every default Strategy CR on its CRD`, `its 2
commits picked, the last in dee1eef`
- [#4914](#4914)
`fix(fluxcd): close the flux-plunger review follow-ups for the hook,
drain and Warnings`, `its 7 commits picked, the last in a8e4916`
- [#4939](#4939)
`fix(cluster-api): resync capi-operator every minute`, `its 1 commit
picked, the last in cc8d6a4`
- [#5027](#5027) `fix(hack):
keep the backup stack in the vminstance selective install`, `its 1
commit picked, the last in 8ba8386`
- [#4955](#4955)
`fix(vm-disk): let CDI adopt the PVC a Velero restore recreates`, `its 2
commits picked, the last in 691d7bc`
- [#5071](#5071)
`fix(marketplace): do not install a connected repository's components on
connect`, `its 2 commits picked, the last in 25f8f11`
- [#5086](#5086)
`fix(tenant): stop the gateway release waiting on TenantGateway
listeners and certificates`, `its 1 commit picked, the last in
18b2249`
- [#5087](#5087)
`fix(controller): carry healthCheckExprs and waitStrategy to existing
releases`, `its 1 commit picked, the last in d2a452e`
- [#5088](#5088)
`fix(ingress-nginx): drop the admission-webhook policy a rebase brought
back`, `its 1 commit picked, the last in cf00b41`
- [#5142](#5142)
`fix(release): keep release bodies within GitHub's size limit`, `its 1
commit picked, the last in c740515`

</details>

<details><summary>Rule 2, the title type is not `fix` or `security`, or
the scope is `deps`</summary>

- [#4845](#4845)
`docs(governance): welcome new contributors and link LF sanctions
guidance`, `type docs`
- [#4881](#4881)
`feat(bucket): add a ui flag to skip the s3manager web UI`, `type feat`
- [#4899](#4899) `chore: link
the FOSSA license scan for CLOMonitor`, `type chore`
- [#4898](#4898)
`feat(backups): back up Valkey with the Redis strategy`, `type feat`
- [#4940](#4940)
`docs(release): add changelog for v1.6.5`, `type docs`
- [#4861](#4861)
`feat(release): sign the release assets and attach a source SBOM`, `type
feat`
- [#3780](#3780) `docs: move
the operator guides out of docs/ to where readers find them`, `type
docs`
- [#4936](#4936)
`docs(release): check changelog claims against the code`, `type docs`
- [#4959](#4959)
`docs(release): point changelog check B at the Talos installer profile`,
`type docs`
- [#3777](#3777)
`docs(agents): define where a new document belongs`, `type docs`
- [#5025](#5025)
`test(harbor): pin the resources of the nginx proxy and the portal`,
`type test`
- [#5039](#5039)
`feat(keycloak): load additional CA files from Secret or ConfigMap
keys`, `type feat`
- [#4860](#4860)
`feat(kubernetes-nodes): trust a factory CA in the Proxmox template
script`, `type feat`
- [#5090](#5090) `ci(e2e):
end the Proxmox lane's SSH key with a newline`, `type ci`
- [#4969](#4969)
`test(kubernetes): pin RetryOnFailure on the Proxmox csi release`, `type
test`
- [#5000](#5000)
`test(kubernetes): name the Proxmox fixture's node pool
<cluster>-<pool>`, `type test`
- [#5168](#5168)
`docs(agents): steer agents away from Helm lookup`, `type docs`
- [#4997](#4997)
`feat(release): attach an SBOM to every release image`, `type feat`
- [#3440](#3440)
`ci(api-review-gate): publish the gate as a commit status`, `type ci`
- [#5034](#5034) `docs: move
the repo-only guides onto the website`, `type docs`
- [#5215](#5215)
`feat(monitoring-agents): export PVC labels from kube-state-metrics`,
`type feat`
- [#5217](#5217)
`feat(monitoring-agents): keep application identity in container log
streams`, `type feat`
- [#5171](#5171) `feat(api):
expose HelmRelease failure state on Application status`, `type feat`
- [#5237](#5237)
`feat(operator): let the platform source trust a private registry CA`,
`type feat`
- [#5106](#5106)
`feat(release): make backports automatic with backport-batch`, `type
feat`
- [#4983](#4983) `feat(api):
add the reservation evaluator for tenant quotas`, `type feat`

</details>

<details><summary>Rule 3, a breaking change</summary>

- [#4790](#4790)
`fix(platform)!: refuse a networking variant change on a live cluster`,
`! in the title`
- [#4796](#4796)
`fix(vm-disk)!: refuse a golden-image clone CDI would leave unbound`, `!
in the title`
- [#5085](#5085)
`fix(kubernetes, opensearch, foundationdb)!: restrict managed
application images`, `! in the title`
- [#4818](#4818)
`fix(keycloak)!: make the KMS proxy keep its keys across restarts`, `!
in the title`
- [#5167](#5167)
`fix(monitoring-agents)!: isolate merged log payload under
log_processed`, `! in the title`

</details>

<details><summary>Rule 4, touches platform migrations, `targetVersion`
or a CRD, or makes a sizeable API change</summary>

- [#4941](#4941)
`fix(backups): keep the backupstrategy-controller Helm release under the
Secret size limit`, `a sizeable API change (breaking-change: [v1alpha1]
spec.spec.template: field "metadata" was removed, and 5 more), a CRD`
- [#5223](#5223)
`fix(backups): report sizeBytes for MariaDB and CNPG backup artifacts`,
`a CRD`

</details>

```backport-batch
{"line":"release-1.7","deferred":[]}
```

### Screenshots

See the original pull requests.

### Downstream repositories

- [ ] 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:
- [ ] [cozystack/community](https://github.com/cozystack/community) -
follow-up:

### Release note

```release-note
fix(backupstrategy-controller): the ClickHouse (Altinity) and RabbitMQ backup strategy Pods now run a dedicated `backup-http-client` image (alpine + curl + jq + ca-certificates, non-root, ~7 MB) instead of the 349 MB `platform-migrations` image. Air-gapped mirrors need the new `ghcr.io/cozystack/cozystack/backup-http-client` repository.

fix(e2e): a chainsaw assert in the e2e suites keeps polling through a transient API error such as an etcd leader change, instead of failing the suite long before its timeout.

fix(ci): use GITHUB_TOKEN to start the initial packages build when freezing a release line

fix(etcd): the tenant etcd alert etcdHighNumberOfFailedGRPCRequests now counts only the gRPC codes that the etcd mixin and the platform copy of the alert count, so the Canceled watch streams of a Kubernetes API server that stops no longer fire it on every etcd member.

fix(tcp-balancer): HAProxy now takes its fd and thread caps from the replica's memory and CPU limits instead of the node, so a t1.nano balancer is no longer OOMKilled on nodes with many CPUs. maxconn follows the memory limit at about 16 connections per MiB, 2035 on t1.nano, and the pods roll once on upgrade.

fix(kubernetes): the Proxmox cloud-controller-manager now runs v0.16.1, and the Proxmox CCM and CSI driver images are pinned by digest. The v0.15.0 tag referenced before was re-pushed upstream to a build that panics on start, which left every new Proxmox worker with the uninitialized taint.

fix(marketplace): a tapped repository is pinned to a digest instead of following a mutable tag, so moving the tag upstream no longer changes what runs in the cluster; connect accepts oci://...@sha256:... and an optional keyless cosign verify policy (anchored to an exact identity and preserved across re-connects), and with verify flags the CLI verifies the signature and pins the verified digest at tap time.

fix(dashboard): tenant name in the tenant list links to its detail page now (where Delete is), and top level tenants get their Edit button back.

fix(cilium): the IPAM migration hook no longer fails with "Argument list too long" on clusters with more than a few nodes, which blocked the cilium upgrade and every package that depends on networking.

fix(kubernetes): the OIDC bootstrap Job no longer fails to start for a tenant cluster with about 240 or more oidc.users, the bindings now come from a ConfigMap the Job mounts.

fix(clickhouse): a RestoreJob now restores the archive its Backup was taken as, not the newest one. New Backups record the archive name, Backups taken before this release are restored best effort from the newest archive stamped no later than their takenAt, with a warning in the restore pod log. A ClickHouse BackupJob created again under a name whose Backup still exists now fails instead of reporting that Backup as its result.

fix(backups): the backup controller now re-renders the Velero BSL, the default backup strategies and the platform bucket's credentials Secret when their S3 endpoint is stale, so after the platform bucket's BucketAccess is re-granted for a new publishing.host, backups move to the new endpoint without forcing releases by hand.

fix(securitygroups): a SecurityGroup with attachments and no rules no longer leaves an invalid CiliumNetworkPolicy behind; its backing policy is now accepted by Cilium and still allows or denies nothing.

fix(postgres): the init script renders the declared databases in a stable order, so an upgrade that leaves the databases unchanged no longer rewrites the init-script Secret.

fix(mariadb-operator): the MariaDB operator now uses the platform's networking.clusterDomain instead of a hard-coded cozy.local, so replication and User/Database/Grant reconciliation work on clusters with a non-default cluster domain.

fix(redis): an instance with `replicas: 1` now runs one Sentinel instead of three, without a Sentinel PodDisruptionBudget; on upgrade, the PDB the operator created earlier is deleted. Instances with two replicas or more are unchanged.

fix(lineage, kubernetes): the virt-launcher pods of Kubernetes worker nodes are now resolved to their Kubernetes application and marked as managed by Cozystack, so a SecurityGroup attached to a Kubernetes cluster now applies to its worker nodes, not only to its control plane.

fix(api): tenant quota admission now counts the parent pool's usage of extended resources and hugepages, so a child tenant can no longer get a share the parent has already used. After upgrading, updates to existing tenants may be rejected if their quota for these resources exceeds the parent's remaining budget. This includes updates that leave the quota unchanged.

fix(postgres): a Postgres BackupJob now fails as soon as its CloudNativePG Backup fails, with the recorded error, instead of after 30 minutes, and a BackupJob waiting on a CloudNativePG Backup reports a WaitingForCNPGBackup condition with the Backup's phase.

fix(postgres): databases now refuses the PostgreSQL system databases postgres, template0 and template1, and the init job never runs DROP DATABASE on them. A system database a previous version took over is handed back to the superuser on the next upgrade (event trigger removed, ownership and public schema restored, its <db>_admin and <db>_readonly roles dropped). A release that declares one of them must remove the entry before upgrading.

fix(mariadb): a MariaDB BackupJob or RestoreJob whose operator Job failed now ends Failed instead of Succeeded, and a failed backup no longer creates a Ready Backup without an archive. Backups created by failed runs before this fix are not removed; restoring from one can fail or select another dump from the shared prefix.

fix(kubernetes, monitoring): the vmagent of tenant Kubernetes clusters now writes to the tenant's vminsert and labels series with the owning tenant instead of `cozy-monitoring`. Previously no tenant cluster metric reached the tenant's Monitoring.
```
myasnikovdaniil added a commit that referenced this pull request Nov 12, 2026
## What this PR does

Backports to `release-1.7` from `main`, picked by the rules of the
[backport
policy](https://github.com/cozystack/cozystack/blob/main/docs/release/backports.md)
in merge order. A merged pull request comes as its own commits, picked
with `git cherry-pick -x` so each keeps its author, message and
sign-offs, and a merge commit that carries the original's merge message
with a `Backported-from:` trailer naming it; a squash-merged one comes
as one pick. `backport-batch verify` checks that shape. A pick listed as
changed differs from the commit it came from; `git range-diff
<merge>^1..<merge>^2 <batch merge>^1..<batch merge>^2` shows how. A
conflict or a failing check here is a [backport
task](https://github.com/cozystack/cozystack/blob/main/docs/release/backport-tasks/README.md).

- [#5284](#5284)
`fix(seaweedfs): bump the COSI driver to v0.4.0 for the lost-grant fix`,
1 commit

### Maintainer overrides

- [#5083](#5083)
`fix(workloadmonitor): stop doubling the bucket- prefix in bucket
Workload names`: `backport/skip`, left out
- [#4973](#4973)
`fix(kubernetes-nodes): refuse a partial resources spec on every pool`:
`backport/skip`, left out
- [#5180](#5180) `fix(tests):
keep two unit tests green after the release image restamp`:
`backport/skip`, left out

### Skipped

- Rule 1, already on `release-1.7`: 46
- Rule 2, the title type is not `fix` or `security`, or the scope is
`deps`: 31
- Rule 3, a breaking change: 5
- Rule 4, touches platform migrations, `targetVersion` or a CRD, or
makes a sizeable API change: 2

<details><summary>Rule 1, already on `release-1.7`</summary>

- [#4815](#4815)
`fix(vm-disk): name the importer error in the disk's WorkloadsReady
message`, `its 2 commits picked, the last in 4323363`
- [#4756](#4756)
`fix(backupstrategy-controller): run backup clients on a package-owned
image`, `its 2 commits picked, the last in 4643366`
- [#4293](#4293)
`fix(fluxcd): keep Helm uninstall working during flux-plunger recovery`,
`its 1 commit picked, the last in 0210407`
- [#4791](#4791)
`fix(kubernetes): keep NFS publish from hanging on a dead server`, `its
1 commit picked, the last in fa88bd1`
- [#4830](#4830)
`fix(multus): drop the CPU limit and make resources configurable`, `its
2 commits picked, the last in 363c842`
- [#4865](#4865) `fix(kafka):
pass the container limits to the Kafka JVMs`, `its 3 commits picked, the
last in fc38a54`
- [#4866](#4866)
`fix(linstor): mark volumes restored from a snapshot DRBD-initialized`,
`its 1 commit picked, the last in f9550b7`
- [#4867](#4867) `fix(e2e):
keep chainsaw asserts polling through transient API errors`, `its 1
commit picked, the last in df75adc`
- [#4819](#4819)
`fix(seaweedfs): build SeaweedFS in-tree with a bounded S3 read-ahead`,
`its 5 commits picked, the last in 7f4c346`
- [#3427](#3427)
`fix(backups): prepare standalone VMDisk restore targets and add VM
backup/restore e2e round-trips`, `its 3 commits picked, the last in
4ac0cd5`
- [#4889](#4889)
`fix(monitoring): send alerts to every Alertmanager replica`, `its 1
commit picked, the last in 5033d53`
- [#4929](#4929)
`fix(backups): gate every default Strategy CR on its CRD`, `its 2
commits picked, the last in dee1eef`
- [#4914](#4914)
`fix(fluxcd): close the flux-plunger review follow-ups for the hook,
drain and Warnings`, `its 7 commits picked, the last in a8e4916`
- [#4888](#4888) `fix(ci):
dispatch frozen-line builds with GITHUB_TOKEN`, `its 1 commit picked,
the last in bf81e2d`
- [#4939](#4939)
`fix(cluster-api): resync capi-operator every minute`, `its 1 commit
picked, the last in cc8d6a4`
- [#5027](#5027) `fix(hack):
keep the backup stack in the vminstance selective install`, `its 1
commit picked, the last in 8ba8386`
- [#4955](#4955)
`fix(vm-disk): let CDI adopt the PVC a Velero restore recreates`, `its 2
commits picked, the last in 691d7bc`
- [#4985](#4985) `fix(etcd):
stop counting canceled watch streams as failed gRPC requests`, `its 1
commit picked, the last in 38e4089`
- [#5005](#5005)
`fix(tcp-balancer): cap HAProxy fds and threads by the pod's own
limits`, `its 1 commit picked, the last in 7a3d0b0`
- [#5071](#5071)
`fix(marketplace): do not install a connected repository's components on
connect`, `its 2 commits picked, the last in 25f8f11`
- [#5086](#5086)
`fix(tenant): stop the gateway release waiting on TenantGateway
listeners and certificates`, `its 1 commit picked, the last in
18b2249`
- [#5087](#5087)
`fix(controller): carry healthCheckExprs and waitStrategy to existing
releases`, `its 1 commit picked, the last in d2a452e`
- [#5088](#5088)
`fix(ingress-nginx): drop the admission-webhook policy a rebase brought
back`, `its 1 commit picked, the last in cf00b41`
- [#5142](#5142)
`fix(release): keep release bodies within GitHub's size limit`, `its 1
commit picked, the last in c740515`
- [#4917](#4917)
`fix(kubernetes): run Proxmox CCM v0.16.1 and pin the Proxmox images by
digest`, `its 2 commits picked, the last in 8ff6dca`
- [#4974](#4974)
`fix(kubernetes-nodes): plan autoscaler capacity on the cores the VM
actually gets`, `its 1 commit picked, the last in d68a163`
- [#4971](#4971) `fix(e2e):
keep the Proxmox tenant kubeconfig per run and remove it`, `its 2
commits picked, the last in 77227c5`
- [#4970](#4970) `fix(e2e):
read the KubernetesNodes pool release in the HelmRelease guard`, `its 3
commits picked, the last in 6b17a76`
- [#4972](#4972) `fix(e2e):
validate the leak allowlist against every suite layout`, `its 3 commits
picked, the last in 7ec550b`
- [#5082](#5082)
`fix(marketplace): pin a tapped repository to a digest instead of a
mutable tag`, `its 15 commits picked, the last in 9cff86f`
- [#4004](#4004)
`fix(dashboard): link a tenant row to its detail page`, `its 2 commits
picked, the last in bd5e0b5`
- [#5212](#5212)
`fix(cilium): pass the CiliumNode list to jq on stdin in the IPAM hook`,
`its 1 commit picked, the last in b28a2e2`
- [#5213](#5213)
`fix(kubernetes): move the OIDC users out of the bootstrap Job's
script`, `its 1 commit picked, the last in 02dd8e5`
- [#5208](#5208)
`fix(clickhouse): restore the archive a Backup was taken as`, `its 3
commits picked, the last in 9ded5c5`
- [#5230](#5230)
`fix(backups): re-render the default backup objects when the S3 endpoint
changes`, `its 8 commits picked, the last in 490eab5`
- [#5133](#5133)
`fix(securitygroups): project a membership-only group to a policy Cilium
accepts`, `its 1 commit picked, the last in b7c50a5`
- [#5091](#5091)
`fix(postgres): render the declared databases in a stable order`, `its 1
commit picked, the last in b658dff`
- [#5123](#5123)
`fix(mariadb-operator): derive the cluster name from the platform
domain`, `its 2 commits picked, the last in 17702dd`
- [#5151](#5151) `fix(redis):
run one Sentinel when Redis runs a single pod`, `its 5 commits picked,
the last in 5524a41`
- [#5124](#5124)
`fix(lineage, kubernetes): resolve CAPK node VMs to their cluster`, `its
2 commits picked, the last in c908560`
- [#5184](#5184) `fix(api):
read pool usage under the quota key the controller counts`, `its 1
commit picked, the last in 18c17af`
- [#5190](#5190)
`fix(platform): remove the dead registries value and patch-containerd
Secret`, `its 2 commits picked, the last in 617f889`
- [#5211](#5211)
`fix(postgres): fail a backup job as soon as its cnpg Backup fails`,
`its 1 commit picked, the last in 6645e21`
- [#5155](#5155)
`fix(postgres): refuse system databases and never drop them`, `its 1
commit picked, the last in 6f1bb80`
- [#5207](#5207)
`fix(mariadb): fail backup and restore jobs whose operator Job failed`,
`its 5 commits picked, the last in ef3de55`
- [#5024](#5024)
`fix(kubernetes, monitoring): send tenant cluster metrics to the
tenant's vminsert`, `its 1 commit picked, the last in 423a75b`

</details>

<details><summary>Rule 2, the title type is not `fix` or `security`, or
the scope is `deps`</summary>

- [#4845](#4845)
`docs(governance): welcome new contributors and link LF sanctions
guidance`, `type docs`
- [#4881](#4881)
`feat(bucket): add a ui flag to skip the s3manager web UI`, `type feat`
- [#4899](#4899) `chore: link
the FOSSA license scan for CLOMonitor`, `type chore`
- [#4898](#4898)
`feat(backups): back up Valkey with the Redis strategy`, `type feat`
- [#4940](#4940)
`docs(release): add changelog for v1.6.5`, `type docs`
- [#4861](#4861)
`feat(release): sign the release assets and attach a source SBOM`, `type
feat`
- [#3780](#3780) `docs: move
the operator guides out of docs/ to where readers find them`, `type
docs`
- [#4936](#4936)
`docs(release): check changelog claims against the code`, `type docs`
- [#4959](#4959)
`docs(release): point changelog check B at the Talos installer profile`,
`type docs`
- [#3777](#3777)
`docs(agents): define where a new document belongs`, `type docs`
- [#5025](#5025)
`test(harbor): pin the resources of the nginx proxy and the portal`,
`type test`
- [#5039](#5039)
`feat(keycloak): load additional CA files from Secret or ConfigMap
keys`, `type feat`
- [#4860](#4860)
`feat(kubernetes-nodes): trust a factory CA in the Proxmox template
script`, `type feat`
- [#5090](#5090) `ci(e2e):
end the Proxmox lane's SSH key with a newline`, `type ci`
- [#4969](#4969)
`test(kubernetes): pin RetryOnFailure on the Proxmox csi release`, `type
test`
- [#5000](#5000)
`test(kubernetes): name the Proxmox fixture's node pool
<cluster>-<pool>`, `type test`
- [#5168](#5168)
`docs(agents): steer agents away from Helm lookup`, `type docs`
- [#4997](#4997)
`feat(release): attach an SBOM to every release image`, `type feat`
- [#3440](#3440)
`ci(api-review-gate): publish the gate as a commit status`, `type ci`
- [#5034](#5034) `docs: move
the repo-only guides onto the website`, `type docs`
- [#5215](#5215)
`feat(monitoring-agents): export PVC labels from kube-state-metrics`,
`type feat`
- [#5217](#5217)
`feat(monitoring-agents): keep application identity in container log
streams`, `type feat`
- [#5171](#5171) `feat(api):
expose HelmRelease failure state on Application status`, `type feat`
- [#5237](#5237)
`feat(operator): let the platform source trust a private registry CA`,
`type feat`
- [#5106](#5106)
`feat(release): make backports automatic with backport-batch`, `type
feat`
- [#4983](#4983) `feat(api):
add the reservation evaluator for tenant quotas`, `type feat`
- [#5264](#5264)
`feat(release): keep a line's backport record and reports across batch
pull requests`, `type feat`
- [#5260](#5260) `ci(e2e):
skip final cleanup on disposable PR runners`, `type ci`
- [#5046](#5046)
`feat(backups): record the RDB snapshot location and size for Redis
backups`, `type feat`
- [#5214](#5214)
`feat(observability-proxy): serve tenant namespace logs through an
authorizing proxy`, `type feat`
- [#5282](#5282)
`test(release): isolate report deduplication from workflow attempts`,
`type test`

</details>

<details><summary>Rule 3, a breaking change</summary>

- [#4790](#4790)
`fix(platform)!: refuse a networking variant change on a live cluster`,
`! in the title`
- [#4796](#4796)
`fix(vm-disk)!: refuse a golden-image clone CDI would leave unbound`, `!
in the title`
- [#5085](#5085)
`fix(kubernetes, opensearch, foundationdb)!: restrict managed
application images`, `! in the title`
- [#4818](#4818)
`fix(keycloak)!: make the KMS proxy keep its keys across restarts`, `!
in the title`
- [#5167](#5167)
`fix(monitoring-agents)!: isolate merged log payload under
log_processed`, `! in the title`

</details>

<details><summary>Rule 4, touches platform migrations, `targetVersion`
or a CRD, or makes a sizeable API change</summary>

- [#4941](#4941)
`fix(backups): keep the backupstrategy-controller Helm release under the
Secret size limit`, `a sizeable API change (breaking-change: [v1alpha1]
spec.spec.template: field "metadata" was removed, and 5 more), a CRD`
- [#5223](#5223)
`fix(backups): report sizeBytes for MariaDB and CNPG backup artifacts`,
`a CRD`

</details>

```backport-batch
{"version":2,"line":"release-1.7","built":{"main":"76a34d3e53a14c39d8df63eec6271c2eaae5ffea","line":"3cd9d4c220b519972677c7ff756ae6c4db07ef4a","newer":"","policy":"76a34d3e53a14c39d8df63eec6271c2eaae5ffea","batch":"e27d6630a0008381f0bc105a87d117dfcc29680f","tool":"76a34d3e53a14c39d8df63eec6271c2eaae5ffea","run":"https://github.com/cozystack/cozystack/actions/runs/43209950053","at":"2026-11-12T11:33:16Z","options":{"noWorkflowFiles":true,"mode":"new"}},"report":"9ca0285a0bb30a09736f6a430878b3ce0d32a2811639861e80daa549f208d7b3","closing":null,"deferred":[],"dropped":[],"stale":[]}
```

```backport-batch-report
{"format":1,"id":"9ca0285a0bb30a09736f6a430878b3ce0d32a2811639861e80daa549f208d7b3","line":"release-1.7","built":{"main":"76a34d3e53a14c39d8df63eec6271c2eaae5ffea","line":"3cd9d4c220b519972677c7ff756ae6c4db07ef4a","newer":"","policy":"76a34d3e53a14c39d8df63eec6271c2eaae5ffea","batch":"e27d6630a0008381f0bc105a87d117dfcc29680f","tool":"76a34d3e53a14c39d8df63eec6271c2eaae5ffea","run":"https://github.com/cozystack/cozystack/actions/runs/43209950053","at":"2026-11-12T11:33:16Z","options":{"noWorkflowFiles":true,"mode":"new"}},"overrides":{"backport/include":[],"backport/skip":[4973,5083,5180]},"primary":{"picked":[5284],"missing":[],"deferred":[],"parked":[],"skipped":{"on-line":[3427,4004,4293,4756,4791,4815,4819,4830,4865,4866,4867,4888,4889,4914,4917,4929,4939,4955,4970,4971,4972,4974,4985,5005,5024,5027,5071,5082,5086,5087,5088,5091,5123,5124,5133,5142,5151,5155,5184,5190,5207,5208,5211,5212,5213,5230],"before-start":[],"type":[3440,3777,3780,4845,4860,4861,4881,4898,4899,4936,4940,4959,4969,4983,4997,5000,5025,5034,5039,5046,5090,5106,5168,5171,5214,5215,5217,5237,5260,5264,5282],"breaking":[4790,4796,4818,5085,5167],"touches":[4941,5223],"newer":[],"skip-label":[4973,5083,5180],"dropped":[]}},"overlays":{"dropped":[],"stale":[],"changed":[]},"diag":{},"other":[]}
```

### Screenshots

See the original pull requests.

### Downstream repositories

- [ ] 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:
- [ ] [cozystack/community](https://github.com/cozystack/community) -
follow-up:

### Release note

```release-note
fix(seaweedfs): bucket users granted together could get S3 keys that never work (403 InvalidAccessKeyId). COSI driver is bumped to v0.4.0, which writes each grant to per-identity IAM file.
```
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 area/uncategorized PR auto-labeler could not map title scope to a known area/*; please review kind/cleanup Categorizes issue or PR as related to cleanup of code, process, or technical debt size/XL This PR changes 500-999 lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants