Lock in the SA-object token toggle (off by default, on only for kubernetes
auth), complementing the pod-level assertions.
Assisted-By: Claude AI
Signed-off-by: Kirill Ilin <stitch14@yandex.ru>
Address review: the proxy pod ran under the namespace default ServiceAccount,
which the keycloak StatefulSet also uses. Binding a Vault Kubernetes-auth role
to default would hand the keycloak pod itself Transit access, defeating the
isolation the proxy exists to provide. Add a dedicated keycloak-kms-proxy
ServiceAccount, run the proxy pod under it, and mount its token only in the
kubernetes auth mode (off otherwise, to minimise the credential surface).
Docs name the SA to bind the Vault role to.
Assisted-By: Claude AI
Signed-off-by: Kirill Ilin <stitch14@yandex.ru>
Extend encryption.kms.vault.auth with "kubernetes" (recommended): the
proxy logs in with its own ServiceAccount token, so no credential is
stored in a Secret. "approle" and "token" remain. Render-time
validation requires kubernetes.role and rejects unknown methods;
helm-unittest covers the new path and the mutual absence of the other
modes' env vars.
Assisted-By: Claude AI
Signed-off-by: Kirill Ilin <stitch14@yandex.ru>
The notContains checks matched whole array elements, so a name-only
content never equaled the valueFrom entry and the assertion could never
fail. Add any:true so "AppRole stores no static token" and, symmetrically,
"token mode renders no AppRole vars" are actually verified (confirmed by
mutation-testing the template).
Assisted-By: Claude AI
Signed-off-by: Kirill Ilin <stitch14@yandex.ru>
The keycloak-kms-proxy Vault Transit backend only supported a static
token. Add an encryption.kms.vault.auth switch — "token" (default,
unchanged) or "approle" — wiring KKP_VAULT_ROLE_ID / KKP_VAULT_SECRET_ID
/ KKP_VAULT_APPROLE_MOUNT from a Secret. With AppRole the proxy logs in
and re-authenticates on demand, so no long-lived token is stored.
Requires a keycloak-kms-proxy image with AppRole support (> v0.1.0).
Assisted-By: Claude AI
Signed-off-by: Kirill Ilin <stitch14@yandex.ru>
## What this PR does
Adds the OpenSSF Scorecard badge to the README, next to the existing
OpenSSF Best Practices badge. The `scorecard.yml` workflow already
publishes results to the public securityscorecards.dev dataset, so the
badge surfaces the project's live supply-chain security score directly
in the README.
### Release note
```release-note
docs(readme): add OpenSSF Scorecard badge
```
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Documentation**
* Added an OpenSSF Scorecard badge to the README, linking to the
repository’s security scorecard page.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## What this PR does
Restores the two OpenSSF Scorecard checks that dropped the aggregate
score below our target, and adds a durable gate so they cannot silently
regress again.
**Root cause.** 15 of 17 workflows already declare a read-only top-level
token and pin their actions by SHA. Two did not — `build-main.yaml` and
`ui-test.yaml` (the latter added by the in-tree console move) — and that
was enough to zero out two checks:
- **Token-Permissions** is scored on the weakest workflow, so a single
workflow with an undeclared (default-write) top-level token drops the
whole check to `0`.
- **Pinned-Dependencies** counted the six tag-pinned action refs as
unpinned.
**Fixes**
- `build-main.yaml`, `ui-test.yaml`: add a top-level read-only
`permissions` block (job-level `packages: write` kept where the build
cache needs it) and pin every action to the SHA already used elsewhere
in the tree.
- `.github/zizmor.yml` + `.github/workflows/zizmor.yml` +
`.pre-commit-config.yaml`: add [zizmor](https://docs.zizmor.sh/) as a
gate. It runs on the PR diff (pre-commit) and tree-wide on every
workflow-touching PR, failing on any reintroduced unpinned action or
missing/overly-broad permissions block. Scope is intentionally limited
to the two Scorecard-relevant audits (`unpinned-uses`,
`excessive-permissions`), which are clean tree-wide as of this PR; the
remaining audits carry pre-existing debt and are disabled with a
documented TODO to burn them down and re-enable one at a time.
- `scorecard.yml`: wire an optional `SCORECARD_TOKEN` secret. The
default `GITHUB_TOKEN` cannot read branch-protection settings, so the
Branch-Protection check is currently scored on partial data. The token
is a no-op until the secret is added by a maintainer.
- `internal/fluxshardoperator/config_fuzz_test.go`: native Go fuzz
targets for the shard config parsers (round-trip and range invariants,
not just no-panic), giving the Scorecard Fuzzing check a real signal on
a hot-path parser.
**Verification**
- `zizmor --offline .github/workflows/` → *No findings to report* (gate
green).
- `go test ./internal/fluxshardoperator/ -run '^Fuzz'` passes; a 15s
live fuzz of `FuzzParsePinnedTenants` ran 10.5k execs with no crashers.
Follow-ups (tracked separately, not in this PR): merge Renovate's
digest-pinning PRs to lift Pinned-Dependencies further, tighten branch
protection on `main` and release branches, upgrade the OpenSSF Best
Practices badge, and sign releases.
### Release note
```release-note
ci: pin GitHub Actions by SHA and scope workflow token permissions to restore the OpenSSF Scorecard Token-Permissions and Pinned-Dependencies checks, and add a zizmor gate (pre-commit + PR workflow) that blocks future regressions
```
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* Added automated security scanning for workflow changes via a dedicated
CI workflow, along with a matching pre-commit hook.
* **Bug Fixes**
* Hardened CI workflows with workflow-level read-only token permissions
and pinned GitHub Action versions for more reliable execution.
* Improved OpenSSF Scorecard configuration to better handle
branch-protection checks when a token secret is available.
* **Tests**
* Added Go fuzz tests to ensure shard parsing/formatting remains
non-panicking and preserves round-trip/contract behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Adds the OpenSSF Scorecard badge next to the existing OpenSSF Best
Practices badge. The scorecard.yml workflow already publishes results
to the public securityscorecards.dev dataset, so the badge reflects the
project's live supply-chain security score.
Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Timur Tukaev <timur.tukaev@aenix.io>
## What this PR does
Bumps the LINSTOR storage stack to current upstream, picking up the
published security fixes the issue enumerates:
- `linstor-server` (piraeus-server) `1.33.2 → 1.33.3` (latest 1.33
patch; v1.34.0 is rc-only). Pulls in the 1.33.3 DRBD fixes (clone-status
check before connection states arrive; the secondary-after-mkfs path is
now guarded upstream too) plus refreshed Debian packages. Note: 1.33.3
also disables DRBD volume shrink upstream (it never worked correctly) —
no effect on cozystack, which does not expose volume shrink.
- `linstor-csi` `v1.10.6 → v1.11.2`. Its go.mod requires Go ≥ 1.26, so
the CSI builder moves `golang:1.25 → golang:1.26` — the right lever for
the Go stdlib / `moby/spdystream` CVEs.
Both cozystack images are rebuilt multi-arch (linux/amd64 + linux/arm64)
and pinned by digest in `values.yaml`. All five piraeus-server patches
re-apply cleanly against v1.33.3 in the Dockerfile glob order, and both
linstor-csi patches re-apply against v1.11.2.
Adds helm-unittest coverage (the package shipped none) asserting the
controller / CSI / satellite images stay digest-pinned
(`<image>:<tag>@sha256:<64-hex>`), version-agnostic so it survives the
release-prepare re-tag.
**Verified on a dev cluster:** a rolling restart of all satellites,
controller, and CSI to 1.33.3 / v1.11.2 kept every node Online with zero
faulty resources; a replicated PVC provisioned, mkfs'd, and reached
UpToDate across 3 nodes with no DRBD "device held open" abort (the
cozystack secondary-after-mkfs patch handled the path). The csi v1.11.2
"no RWX without DRBD layer" change is a no-op for cozystack, which
always uses the DRBD layer.
Closes#2881
### Release note
```release-note
chore(linstor): bump linstor-server to 1.33.3 and linstor-csi to v1.11.2
Upgrade impact: an empty tenant Kubernetes worker `nodeGroups[*].storageClass` now defaults to the application-level (DRBD `replicated`) storageClass instead of the management-cluster default, because linstor-csi v1.11.2 rejects ReadWriteMany volumes on a non-DRBD storage class. This changes the rendered worker template, so existing tenant clusters that relied on the empty-storageClass default roll their worker MachineDeployments once on the first reconcile after upgrade — workers are reprovisioned on the application storageClass and container images are re-pulled. The per-nodeGroup override and the both-empty cluster-default escape hatch are preserved.
```
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Chores**
* Updated LINSTOR Server to **v1.33.3** and LINSTOR CSI to **v1.11.2**
(image tags/digest pins refreshed)
* Updated the LINSTOR CSI build toolchain to **Go 1.26**
* Added a **test** make target to run Helm unit tests
* **Documentation**
* Updated the LINSTOR Server patches README to reference **v1.33.3**
* **Tests**
* Added an image-pinning validation suite to confirm controller and
satellite images use **sha256** digests
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Worker-disk storageClass default (companion to the bump)
linstor-csi v1.11.2 rejects ReadWriteMany volumes that are not on a
DRBD-layer storage class. Worker VMs live-migrate, so their disks need
RWX — the access mode comes from the chosen StorageClass's CDI
StorageProfile (a DRBD class yields RWX), it is not set on the worker
DataVolume by the chart. With the bump, a worker nodeGroup left on a
non-DRBD cluster-default StorageClass would fail provisioning, so an
empty per-nodeGroup storageClass now defaults to the application
storageClass (replicated/DRBD) instead of the cluster default. The
per-nodeGroup override and the both-empty cluster-default escape-hatch
are preserved.
Upgrade impact: the empty-storageClass default ships on `md0`, and the
KubevirtMachineTemplate name is content-addressed over the rendered
worker body, so this flip renames the template and triggers a one-time
CAPI rolling replacement of worker pools on the first reconcile after
upgrade. Two hash-pin tests guard the rename so it stays an intentional,
reviewed change. linstor-csi v1.11.2 also defaults DRBD two-primaries to
kubevirt-only — a no-op for cozystack, where kubevirt is the target.
## What this PR does
Implements the immutable-tag + rc-promotion flow from #2677. A **stable
release becomes a renamed release-candidate**: the bytes shipped as
`vX.Y.Z` are bit-for-bit the bytes built and e2e-tested as
`vX.Y.Z-rc.N`. No tag is ever force-moved, and stable is never rebuilt —
it is *promoted* by retagging the rc's existing images.
Layered onto the build matrix from #2937/#2983 (this PR is **stacked on
`refactor/build-matrix-2937`** and must merge after it).
## The five force-retag sites, removed
| Site | Before | After |
|---|---|---|
| `tags.yaml` api/apps/v1alpha1 tag | `git tag -f` / `push -f` |
write-once (create-if-absent, fail if it would move) |
| `tags.yaml` release-X.Y.Z branch | `git branch -f` / `push -f` |
compare-before-force (no-op if unchanged; staging branch only) |
| `pull-requests-release.yaml` stable tag | `git tag -f` / `push -f` |
write-once at the PR merge commit (force impossible by construction) |
| `pull-requests-release.yaml` maintenance branch | `updateRef
force:true` | fast-forward-only |
| `auto-release.yaml` patch tags | delete-recreate (cron) | **workflow
deleted** — stable only via explicit promote |
## Version decoupling (the enabler)
The operator baked its version into the image at build time, so an rc
image self-reported the rc string — blocking retag-promotion. It now
reads `COZYSTACK_VERSION` from the environment (threaded via
`cozystackOperator.platformVersion` → Deployment env, stamped by `make
manifests`), falling back to the build-time value when unset. The same
image bits can report any release name. The only runtime reader is the
telemetry metric `cozy_cluster_info{cozystack_version=...}`.
## Promotion flow
`promote-rc.yaml` (`workflow_dispatch`, `rc_tag=vX.Y.Z-rc.N`):
1. Validate the rc release exists and the stable tag does not.
2. `hack/promote-retag.sh` reads the rc's digest-pinned image refs from
`packages/*/*/values.yaml` and `skopeo copy`s each — **by digest** — to
`:vX.Y.Z` and `:latest`, verifying with `skopeo inspect`.
3. Rewrite the cosmetic `-rc.N` substring in vendored tags to the stable
version (the `@sha256` wins regardless), restamp the version-stamped
assets (operator manifests, cozypkg, openapi; the heavy Talos assets are
copied verbatim from the rc draft), open a `release-X.Y.Z` PR.
4. Merging the PR reuses the existing release-PR e2e and
`pull-requests-release.yaml` finalize to cut the **write-once** stable
tag at the merge commit and publish the release. Squash is disallowed
(the tag needs a real merge commit).
`nightly.yaml` cuts write-once `*-nightly.<date>` tags (gated by
`NIGHTLY_ENABLED`); `retention.yaml` keeps the newest 14 per line
(dry-run by default).
## Validation
**Local end-to-end — the core claim is proven, not asserted.** The whole
design rests on "copy-by-digest to a new tag preserves the digest, so
stable == the e2e-tested rc, bit-for-bit." This was exercised against a
real registry (`ttl.sh`) with real `skopeo`:
- Pushed a multi-arch image under a `:v1.4.0-rc.2` tag, built a temp
tree exercising all three `values.yaml` digest shapes (single `image:`
string, split `repository`/`tag`/`digest` map, and the
`platformSourceRef` OCI artifact), then ran the actual
`hack/promote-retag.sh v1.4.0`.
- **Result: `:v1.4.0-rc.2`, `:v1.4.0`, and `:latest` all resolve to the
identical digest** (`sha256:fd8d9aa6…`), across all 17 platform
manifests. The script's own `skopeo inspect` post-check passed, and an
independent re-inspection confirmed it.
Local validation caught (and this PR fixes) two bugs that only surface
against a real registry — never in static lint:
1. `skopeo copy --multi-arch all --all` — mutually exclusive flags,
fatal error; every retag would have failed on CI. Now `--multi-arch
all`.
2. The same digest was retagged twice when an image appeared in two
value shapes; the ref set is now deduped on the canonical `repo@digest`.
**Other local checks:** `go test ./pkg/version` + `go build`/`vet`;
`helm template` renders `COZYSTACK_VERSION` in all 3 operator variants
(omitted when unset); `make manifests` stamps the version into the
install assets; `shellcheck` clean on `promote-retag.sh`; `actionlint`
clean and `act -l` resolves the job graph on all workflows; the rc-tag
parser and nightly version-math unit-tested (accept/reject +
minor-vs-patch bumps).
**Still CI-only (cannot be exercised offline):** the full workflow
runtime — github-script API calls, OCIR auth, the rc-release /
staging-branch preconditions, the nightly `base_ref` push mechanics, and
self-hosted runners. The *logic* inside the steps is unit-tested; the
*orchestration* is not. Treat it as unproven until the workflows run.
## Out of scope / required follow-up (needs repo admin — not doable from
a PR)
- Tag-protection rules: `v*` and `api/apps/v1alpha1/*` create-only by
the CI app, no delete/update; "limit branches/tags updated in a single
push" = 1.
- Enable `NIGHTLY_ENABLED` (and `RETENTION_APPLY` when ready) repo
variables.
- Registry-side pruning of `*-nightly.*` image tags (no OCIR delete
parity yet — tracked TODO in `retention.yaml`).
## Release note
```release-note
NONE
```
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* Added nightly publishing (mirror-by-digest, disk build, e2e
validation) and nightly retention pruning.
* Added RC-to-stable promotion via digest retagging (no rebuild).
* Operator/installer and console now expose version/platform metadata,
including runtime override via `COZYSTACK_VERSION`.
* **Bug Fixes**
* Enforced write-once tag behavior and fast-forward-only maintenance
updates across release workflows.
* Made nightly mirroring/selection and retention pruning more selective
and safer.
* **Tests**
* Added Bats coverage for nightly mirroring and RC retagging, plus new
Helm/unit checks.
* **Documentation**
* Updated the release model to center RC promotion and tag immutability.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## What this PR does
Pins the remaining floating version tags in the `build-main` and
`ui-test` workflows to immutable commit SHAs, keeping the original tag
in a trailing comment.
This is a supply-chain hardening change: a mutable tag such as `@v4` can
be force-repointed (by a compromised or malicious upstream) to arbitrary
code, whereas a commit SHA is immutable. Most workflows in this
repository were already SHA-pinned — this closes the last gap so that
every third-party action is referenced by digest.
Actions pinned in this PR:
- `build-main.yaml`: `actions/checkout`, `docker/login-action`,
`docker/setup-buildx-action`
- `ui-test.yaml`: `actions/checkout`, `pnpm/action-setup`,
`actions/setup-node`
Each SHA is the exact commit the tag currently resolves to, so there is
**no functional change**. The trailing `# vX` comment records the
original tag; Renovate's `github-actions` manager understands this
format and can keep the pins updated automatically.
> Note: `.github/renovate.json` currently sets `enabledManagers:
["gomod"]`, so the `github-actions` manager is not active yet. For
Renovate to auto-maintain these (and the other already-pinned) action
digests, that manager needs to be enabled — suggested as a separate
follow-up.
### Screenshots
N/A — CI-only change, no user-facing or UI impact.
### Release note
```release-note
NONE
```
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Chores**
* Updated CI workflows to use fixed, version-pinned action releases for
build and UI test steps.
* This helps make automated builds and tests more consistent and
reliable over time.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Pin the remaining floating version tags in the build-main and ui-test
workflows to immutable commit SHAs, preserving the original tag in a
trailing comment so Renovate can keep the pins up to date.
Supply-chain hardening against tag hijacking/mutation; no functional
change — each SHA is the commit the tag currently resolves to.
Assisted-By: Claude
Signed-off-by: Andrei Kvapil <andrei.kvapil@aenix.io>
Review feedback (non-blocking):
- .pre-commit-config.yaml pinned zizmor at v1.22.0 while the CI gate installs
1.26.1; the zizmorcore/zizmor-pre-commit rev is the zizmor version 1:1, so the
gap meant local and CI runs could differ. Align the hook to v1.26.1.
- ui-test.yaml's checkout did not set persist-credentials: false, unlike the
other two workflows hardened here (build-main.yaml, zizmor.yml). Set it so the
token is not left in .git/config for the later pnpm install step to read.
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
Assisted-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
## Summary
Aligns the security documentation with controls that are already in
place, and adds the two coordinated-disclosure items flagged during the
Incubation due-diligence review (cncf/toc#1916). Documentation only — no
code or workflow changes.
## What
- **`docs/security/self-assessment.md`**
- DCO: corrected to *enforced by an automated DCO check on every PR* (it
already is).
- Private intake: **GitHub Private Vulnerability Reporting** (enabled),
with a `MAINTAINERS.md` fallback.
- OpenSSF Best Practices: **passing badge achieved 2026-04-01**, working
toward silver.
- Advisory history and incident-response paragraphs reworded to match
the real (GHSA-based) process.
- **`SECURITY.md`**
- Route reports to Private Vulnerability Reporting first,
`MAINTAINERS.md` as fallback (no longer points at `CODEOWNERS`).
- New *Disclosure timeline*: a **90-day** coordinated-disclosure window
plus a CVSS v3.1 remediation-target table.
- **`.github/CODEOWNERS`**
- Header documents that this file encodes the area-scoped **Reviewer**
role from the Contributor Ladder, intentionally distinct from the
`MAINTAINERS.md` governance roster, and that security reporting is not
routed through it.
- **`docs/security/incident-response.md`** (new)
- Public incident runbook: roles, an 8-step lifecycle, and
severity/timeline references — each step linked to the existing
mechanism it relies on.
## Why
The self-assessment was written conservatively; several items it listed
as gaps are in fact already implemented (DCO enforcement, private
vulnerability reporting, OpenSSF passing badge). This corrects those
statements and adds the committed disclosure window and a public
incident-response summary, so the self-assessment accurately reflects
the project's controls for the DD review.
Bring the security documentation in line with controls that are already
in place, and add the two coordinated-disclosure items flagged during the
incubation due-diligence review.
- self-assessment: DCO is enforced by an automated check; private intake
is GitHub Private Vulnerability Reporting; OpenSSF passing badge achieved
2026-04-01 (working toward silver); GHSA/PVR framing for advisory history;
incident-response now summarized publicly.
- SECURITY.md: route reports to Private Vulnerability Reporting with a
MAINTAINERS.md fallback; add a 90-day coordinated-disclosure window and a
CVSS-based remediation-target table.
- CODEOWNERS: document that it encodes the area-scoped Reviewer role, which
is intentionally distinct from the MAINTAINERS.md governance roster, and
that security reporting is not routed through this file.
- Add docs/security/incident-response.md as the public incident runbook.
Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: tym83 <6355522@gmail.com>
## Summary
Adds the CNCF security self-assessment for Cozystack, required for the
Incubation due-diligence review (cncf/toc#1916). The document was
drafted
by the maintainers and previously lived in a gist; this lands it in-tree
next to the existing design-level threat model.
## What
- `docs/security/self-assessment.md` — trust boundaries (T0–T4 actors
and
actions), security functions & features (critical / security-relevant),
project compliance, secure development practices, security issue
resolution, and an appendix with adopters, case studies, and known gaps.
## Why
The DD triage on the Incubation application flagged the Security
Self-Assessment as a required, blocking item. Keeping it in-repo —
rather
than a personal gist — makes it discoverable and maintainable alongside
`docs/security/threat-model.md`.
Add the CNCF security self-assessment required for the Incubation due
diligence review, alongside the existing design-level threat model. It
documents the trust boundaries, actors/actions, security functions,
secure development practices, and known gaps.
Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: tym83 <6355522@gmail.com>
Native Go fuzzing over the flag parsers in config.go, asserting the
invariants that matter rather than just no-panic:
- ParseShardIndex round-trips with ShardName (accepted input is the canonical
name of the returned index; index is non-negative).
- ShardName round-trips back through ParseShardIndex for every valid index.
- ParseShardCount: a non-auto success yields count >= 1; auto yields 0.
- ParsePinnedTenants: every entry in a success has a non-empty key mapped to a
valid shard name.
Adds an OSS-Fuzz-detectable fuzzing signal for the OpenSSF Scorecard Fuzzing
check while covering parsers that sit on the sharding hot path.
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
Assisted-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
build-main.yaml and ui-test.yaml (the latter added by the in-tree console
move) landed without following the repo's workflow conventions: no top-level
permissions block and actions referenced by moving tags. Together they zero out
two OpenSSF Scorecard checks the rest of the tree already satisfies:
- Token-Permissions: scored on the weakest workflow, so one workflow with an
undeclared (default-write) top-level token drops the whole check to 0.
- Pinned-Dependencies: the six tag-pinned action refs count as unpinned.
Fix both files (top-level read-only token; job-level packages:write kept where
needed; all action refs pinned to the SHA already used elsewhere in the tree)
and add a durable gate so it cannot recur:
- .github/zizmor.yml enables the two Scorecard-relevant audits (unpinned-uses,
excessive-permissions), clean tree-wide as of this commit. The remaining
audits carry pre-existing debt and are disabled with a TODO to burn down and
re-enable one at a time.
- .pre-commit-config.yaml runs zizmor (offline) on changed workflows.
- .github/workflows/zizmor.yml runs the same audit tree-wide on every PR that
touches a workflow, failing on any reintroduced regression.
Also wire scorecard.yml to an optional SCORECARD_TOKEN secret: the default
GITHUB_TOKEN cannot read branch-protection settings, so the Branch-Protection
check is scored on partial data. The token is a no-op until the secret is added.
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
Assisted-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
## What this PR does
Closes two coverage gaps found while shadow-observing the
API-owner-review gate (#3167) against real PRs in the first hours after
merge:
1. **CRD discovery was gated on directory name** (`crds/`,
`definitions/`, `manifests/`), so a CRD shipped inside a chart's
`templates/` dir — a valid, existing convention when the CRD is wrapped
in a `{{- if }}` guard — was invisible to the gate. This was not
hypothetical: `#3149` (cozyplane) adds four brand-new CRDs (`VPC`,
`VPCBinding`, `VPCPeering`, `Port`) exactly this way, and none of them
tripped a review requirement.
2. **Groups served only by an external aggregated apiserver had no
representation at all.** The same `#3149` also registers
`sdn.cozystack.io` via a `kind: APIService` pointing at a prebuilt image
built outside this repository — no CRD, no vendored Go types, nothing to
diff.
### Fix
- CRD/APIService discovery is now content-based: every `.yaml`/`.yml`
file under `packages/` and `internal/` is a candidate, and whether it's
kept depends on whether it parses into the right `kind` with a
first-party group — not which directory it sits in.
- Both parsers now tolerate a Helm directive line (`{{- if }}`, `{{- end
}}`, a comment block) sharing a `---`-delimited document with
otherwise-valid YAML, rather than dropping the whole document. This
matters because the closing `{{- end }}` of a wrapping conditional has
no `---` before it, so it lands in the same document as the manifest it
closes.
- Added a fourth resource source, `SourceAPIService`, for `kind:
APIService` manifests registering a first-party group. Like the existing
static Go-backed groups, these carry no checked-in schema, so they
participate only in new-group / new-resource / removal detection — never
a fabricated breaking-change diff against a schema this repo can't see.
Verified against the real `feat/cozyplane` branch: the gate now reports
all four new CRDs, plus the removal of the old
`securitygroups`/`APIService` registration for the same group — a full
API-surface migration that was previously invisible end to end.
### Release note
```release-note
fix(ci): the API-owner-review gate now discovers CRDs and APIService registrations by content instead of directory name, closing a gap where a CRD or an externally-served aggregated API group could ship without triggering the required review.
```
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* Discovery now includes APIService registrations, enabling detection of
groups/versions exposed only via aggregated APIService manifests
(including merging versions from multiple templates).
* **Bug Fixes**
* Improved YAML discovery to tolerate mixed/templated multi-document
streams: non-CRD/non-APIService documents no longer stop discovery, and
embedded template directives are handled more reliably.
* File processing is more resilient during snapshot loading (includes a
safety cap on YAML size).
* **Tests**
* Added coverage for APIService discovery, templated neighbor documents,
and correct exclusion of third-party manifests.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## What this PR does
Adds `docs/security/threat-model.md`, a design-level threat model for
Cozystack. It documents the actors, the actions that cross trust
boundaries, and the security goals and non-goals, organized by trust
boundary (T0–T4) following the CNCF TAG-Security Actors/Actions model.
It records the usage and multitenancy model (managed-services backend vs
direct dashboard/API access; combined hard multitenancy for tenant VMs
and Kubernetes clusters, soft multitenancy for managed data services),
the primary trust-boundary invariant (tenant RBAC grants no HelmRelease
or arbitrary core writes; the aggregated API server fixes the chart
reference server-side), and the two admission enforcement points (core
kube-apiserver vs `cozystack-api`). Hostname integrity covers both the
Gateway API path and the default legacy-Ingress path
(`cozystack-ingress-hostname-policy`, #3200). Every architectural claim
cites a source file so the model can be re-verified as the code evolves.
This is the in-repo companion to the project's CNCF TAG-Security
self-assessment, which references it.
### Screenshots
N/A — documentation only.
### Release note
```release-note
docs(security): add a design-level threat model (docs/security/threat-model.md)
```
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Documentation**
* Added a security threat model for the platform.
* Clarified system boundaries, trust tiers, and the main actors
involved.
* Documented key security goals, non-goals, and important enforcement
points.
* Described expected behavior for tenant actions, hostname handling, and
cross-boundary access.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Document the platform's actors, boundary-crossing actions, and security
goals/non-goals, organized by trust boundary (T0-T4) per the CNCF
TAG-Security Actors/Actions model. Records the primary trust-boundary
invariant (tenant RBAC grants no HelmRelease/core writes; the aggregated
API server fixes the chart reference server-side) and the two admission
enforcement points (core kube-apiserver vs cozystack-api). Every claim
cites a source file so the model can be re-verified as the code evolves.
Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
<!-- Thank you for making a contribution! Here are some tips for you:
- Use Conventional Commits for the PR title: `type(scope): description`
- Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore
- Scopes are not an exhaustive list — pick the most specific scope for
the change and extend the list when a genuinely new area appears.
Examples:
- System components: dashboard, platform, operator, cilium, kube-ovn,
linstor, fluxcd, cluster-api
- Managed apps: postgres, mariadb, redis, kafka, clickhouse,
virtual-machine, kubernetes
- Development and maintenance: api, hack, tests, ci, docs, maintenance
- Breaking changes: append `!` after type/scope (`feat(api)!: ...`) or
add a `BREAKING CHANGE:` footer
- If it's a work in progress, consider creating this PR as a draft.
- Don't hesistate to ask for opinion and review in the community chats,
even if it's still a draft.
- Add the label `backport` if it's a bugfix that needs to be backported
to a previous version.
-->
## What this PR does
### Screenshots
<!-- REQUIRED for UI changes: attach screenshots or screen recordings
demonstrating
the visual impact of your changes. PRs with UI changes without
screenshots will not be merged. -->
### Release note
<!-- Write a release note:
- Explain what has changed internally and for users.
- Start with the same `type(scope):` prefix as in the PR title
- Follow the guidelines at
https://github.com/kubernetes/community/blob/master/contributors/guide/release-notes.md.
-->
```release-note
```
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* Added support for the newer etcd snapshot workflow
(BackupClass/BackupJob/RestoreJob) with in-place restore.
* Updated etcd chart/operator packaging and migration flow for the
v1alpha2 operator release.
* **Bug Fixes**
* Switched readiness/condition handling to **Available**.
* Improved waits to cover all etcd member pods during restore and
scenario steps.
* **Documentation**
* Refreshed etcd backup/restore docs and example scenarios to match the
v1alpha2 APIs and parameters.
* **Tests**
* Updated e2e coverage to validate v1alpha2 operator runtime contracts
and replaced legacy backup-block tests with a snapshot round-trip test.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Address non-blocking review feedback from lexfrei on
packages/system/dashboard/Makefile:34.
When COZYSTACK_VERSION is empty (a local build with no matching git tag) the
build-arg baked a bare `APP_VERSION=v` into the console bundle. Match the guard
the root Makefile already uses for platformVersion so an empty version yields an
empty APP_VERSION instead of "v". Dev-only cosmetics — the config.json path
overrides the displayed version at deploy time — but it removes the mismatch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
Address review feedback from lexfrei on .github/workflows/promote-rc.yaml:200.
The "Prepare stable branch" step built the release-X.Y.Z branch with
`git checkout -B` + `git commit -s` (a fresh, timestamp-dependent SHA every run)
and pushed it with a plain non-force push. On a re-dispatch — retrying after a
later step failed, or promoting a newer rc to the same version — the new commit
is a sibling of the already-pushed one (same parent, different SHA), so the push
is rejected non-fast-forward and promotion wedges until the branch is deleted by
hand. That contradicts both the validate step (which tolerates a leftover draft
precisely so a newer rc can be re-promoted) and the workflow header's
"overwritable branch, nothing to wedge a later re-promotion".
Reuse the compare-before-force pattern from tags.yaml's Create release branch
step: skip when the remote already matches, force-push + log when the branch
genuinely moves. It is a mutable staging branch, so overwriting it is correct.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
- nightly.yaml: pass the registry tokens to `skopeo login` over stdin
(--password-stdin) instead of `-p` on argv, matching promote-rc/finalize and
keeping the token out of the process list.
- dashboard configmap: require digits-then-dot (^v?[0-9]+\.) when deriving the
displayed platform version from the console image tag. The old ^v?[0-9] let a
tagless, port-carrying ref (ghcr.io:443/.../console, whose last ":"-segment is
"443/.../console") pass as a version. Verified: v1.5.0 / 1.5.0 still surface;
the port ref and :latest no longer do.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
The tag workflow assumed the old hand-pushed-stable flow in several places that
break (or misbehave) under rc-promotion:
- Drop PUBLISH_FLOATING from the Build step. This job now only ever builds a
prerelease (a stable tag skips via its pre-existing draft, or is rejected —
below), so moving :latest here would track the newest rc, and an rc on an old
line would drag :latest backwards. :latest belongs to promotion (finalize).
- Publish rc releases. tags.yaml created the rc release as a draft and nothing
ever published it, so the community could not see the rc page or download its
nocloud-amd64.raw.xz — defeating the rc's stated purpose. Publish it
(prerelease:true, make_latest:false) once assets are uploaded; rc tags are
write-once, nothing to protect with draft status.
- Reject hand-pushed stable tags. A stable vX.Y.Z with no pre-existing draft is
a hand-push down the legacy full-rebuild path (full rebuild, moved :latest,
rogue release PR). Fail fast with a message pointing at promote-rc.yaml. This
replaces two now-unreachable steps that shared the same gate: the submodule
Go-module tag (moved to finalize in the previous commit) and "Create pull
request if not exists" (promote-rc owns PR creation).
- Gate generate-changelog and update-website-docs on stable (ref_name has no
'-'), so each rc no longer opens a changelog-vX.Y.Z-rc.N PR that never merges
and an "update managed apps for vX.Y.Z-rc.N" website PR.
- Paginate the release lookups (check_release, draft-reuse): a promote-created
draft can sit past the default first 30 and be missed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
branch-review on #3184 measured a real regression: switching CRD/
APIService discovery to content-based scanning made every file in
packages/+internal a candidate, and each was split into documents and
fully unmarshaled twice — once per parser — inflating a full snapshot
load from ~0.5s to ~3.4s.
Split each file's documents once and probe each document's kind once
before deciding which shape to try, so the common case (a document
that is neither a CRD nor an APIService — the overwhelming majority
of what the wider walk now sees) pays for at most one parse attempt
instead of two. Add a raw substring fast-reject, at both the
per-document and whole-file level, ahead of any YAML parsing: neither
kind this package cares about can appear without its own name
appearing as a literal substring first.
Also fixes two smaller gaps flagged in the same review: an untrusted
PR head checkout could point discovery at an arbitrarily large file
with no size guard (capped at 8 MiB), and a group's merged APIService
resource only recorded the first contributing file's path, dropping
the others from the report.
Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Timofei Larkin <lllamnyp@gmail.com>
Shadow-observation of the merged gate found a real miss: a CRD shipped
inside a chart's templates/ dir (guarded by a Helm {{- if }}) was
invisible because discovery was gated on directory name (crds/,
definitions/, manifests/), and a group served only by an external
aggregated apiserver (kind: APIService, no CRD, no vendored Go types)
had no representation at all.
Switch CRD/APIService discovery from directory-name to content
sniffing, and tolerate Helm directive lines sharing a document with
otherwise-valid YAML (the closing `{{- end }}` of a wrapping
conditional has no leading `---`, so it lands in the same document as
the manifest). Add a fourth source, SourceAPIService, reusing the
existing empty-schema mechanism so a first-party group's first
APIService appearance requires review without pretending to diff a
schema that lives outside this repository.
Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Timofei Larkin <lllamnyp@gmail.com>
Promotion mutated the registry at dispatch time — retagging ~37 images to
vX.Y.Z (+:latest), publishing the stable cozy-installer chart — before the
promote PR's e2e ran and before any merge. An abandoned promotion then left
stable-named bytes for a version that never shipped, and the (correct) image
write-once check wedged any later re-promotion to the same version.
Move every irreversible registry side effect into pull-requests-release.yaml
finalize, which runs only after the promote PR merged (= after its e2e passed),
making promotion atomic. promote-rc.yaml now only stages the release-X.Y.Z
branch, drafts the stable release, and opens the PR; an abandoned promotion
leaves nothing but an overwritable draft + branch.
Folded into the same restructure:
- Cut the api/apps/v1alpha1/vX.Y.Z Go-module tag in finalize, at the stable
tag's commit. It was gated in tags.yaml on `release_exists==false &&
is_stable`, unsatisfiable under promotion (the draft always pre-exists), so
promoted stables never got their submodule tag — a regression for Go
consumers. Finalize is where the stable tag is born, so it cuts the matching
submodule tag write-once (stable only).
- Gate :latest by max-semver. promote-retag.sh now moves :latest only when
MOVE_LATEST=1 (default off); finalize passes it the same make_latest decision
the release gets, so a patch on an older line moves neither the release's
`latest` nor the images' :latest. Added MOVE_LATEST bats coverage.
- Stamp platformVersion into the published stable chart's default values
(uncommitted, at package time) so `helm --install --version X.Y.Z` reports the
stable version in telemetry instead of the rc version baked into the operator
image. Not committed to the branch — that would leak platformVersion=vX.Y.Z
onto main and mis-stamp the next rc.
- promote-rc: serialize all promotions (static concurrency group, not per-rc);
label the PR full-e2e so the full suite runs; scope the rc->stable sed to the
depth-2 values.yaml glob plus the kubernetes .tag files (the build's own write
set); tolerate a leftover draft so re-promotion is not wedged.
- finalize: assert the PR author is cozystack-ci[bot], and run on an ephemeral
oracle-vm runner (needs skopeo/helm).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
The console config ConfigMap stripped the leading v (trimPrefix "v") while
the operator's COZYSTACK_VERSION and the vX.Y.Z release tags keep it, so the
dashboard displayed 1.4.0 where the operator reported v1.4.0. Keep the tag
verbatim, and build the baked VITE_APP_VERSION fallback as v$(COZYSTACK_
VERSION) too so the deploy-time and build-time version strings stay
consistent with each other and with the operator.
Assisted-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
The COZYSTACK_VERSION env test pinned talos and hosted but not generic,
leaving the generic branch of the endpoint-env interleave (KUBERNETES_
SERVICE_HOST/PORT from cozystack.apiServerHost/Port) unguarded. Add a
generic + platformVersion case so a refactor can't silently drop
COZYSTACK_VERSION or mis-render the generic endpoint block.
Assisted-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
collect_refs scans "$TREE"/*/*/values.yaml (depth-2), but the host rewrite
used `find "$TREE" -name values.yaml` (any depth). A values.yaml nested
deeper than group/pkg would then have its image host rewritten to GHCR
without its image ever being mirrored there, leaving a dangling ref. Rewrite
over the same depth-2 glob so the rewritten set is exactly the mirrored set
(this also matches the existing --dry-run message).
Assisted-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
The first-ever `oras push` of cozystack-nocloud creates the GHCR package,
but without org.opencontainers.image.source it is created unlinked to the
repo, and the follow-up `oras tag … nightly` can 403 permission_denied:
write_package until someone links it by hand. Set the annotation on push so
the package is linked from the start (matches the --label the image builds
already carry).
Assisted-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
Addresses two blockers that stop a stable release from being cut by the
promote flow (both reproduced against a real registry in review).
B1 — wrong registry: the workflow forced env.REGISTRY to the OCIR CI
registry and logged in only to OCIR, but release-tag images (rc and
stable) are built and pushed to GHCR by tags.yaml (hack/common-envs.mk
default). promote-retag.sh filters refs by the $REGISTRY/ prefix, so with
REGISTRY=OCIR every GHCR ref was dropped, the selection went empty, and
the retag step exited 1 before doing anything. Point the job at
ghcr.io/cozystack/cozystack (matching the script default) and log in to
GHCR via --password-stdin (also resolves the token-on-argv nit).
B2 — missing chart: promote-retag.sh only retags image refs found in
values.yaml, and the cozy-installer OCI chart is a separate artifact in no
values.yaml. make build (which pushes the chart) is gated on a
not-yet-existing release, so a promoted stable never published
cozy-installer:X.Y.Z — the documented `helm --install … --version X.Y.Z`
404'd with manifest-unknown. Repackage the chart from the restamped stable
tree (no rebuild — it references the retagged images + packages artifact by
digest) and helm push it, mirroring the nightly flow. Adds helm to the job.
Assisted-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
The backup round-trip test could wedge for 20 min and then report a bare
EtcdSnapshot phase=Started with no root cause: dump_diagnostics dumped the
(healthy) EtcdCluster and the operator log, but the snapshot agent runs in
its own Job Pod, so the etcd-read / S3-upload error lives only in that Pod's
log and in the BackupJob/EtcdSnapshot status.
- dump_diagnostics now dumps the whole backup chain: BackupJob/Backup and
EtcdSnapshot describe, each snapshot Job's describe + Pod logs (selected by
the operator's etcd-operator.cozystack.io/cluster label), and recent
namespace events (the primary signal when a hung Pod logs nothing).
- wait_for_field gains an optional terminal-failure value; steps 04/05 pass
Failed so the BackupJob/RestoreJob waits return immediately on a terminal
phase instead of polling to the timeout — which also keeps the snapshot
Job Pod within its 600s TTL so its log is still readable at dump time.
Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Timofei Larkin <lllamnyp@gmail.com>
The v1alpha2 operator sets Available=True early (reason ClusterDiscovered)
as soon as the seed member forms the cluster, then scales the remaining
members up one at a time. Gating the runtime-contracts test on Available
raced the member-Pod count assertion and saw only 1/3 Pods, failing the
run. Gate on status.readyMembers=3 instead — the count of members whose
EtcdMember Ready condition is True (and the /scale status path), which
only reaches 3 once every member Pod is Running and serving. Bump the
wait timeout to 300s to cover sequential one-at-a-time rollout.
Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Timofei Larkin <lllamnyp@gmail.com>
Address review feedback from coderabbitai on hack/e2e-apps/etcd.bats: read the container name, metrics port, and Pod IP with the strict member selector (name+instance+managed-by) plus a Running-phase filter instead of the broad app.kubernetes.io/name=etcd, so .items[0] cannot resolve to a non-member or not-yet-running Pod. Also assert a non-empty Pod IP before the metrics probe.
Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Andrey Kolkov <androndo@gmail.com>
Address review feedback from gemini-code-assist on packages/system/etcd-operator/templates/deployment.yaml: add an explicit seccompProfile (RuntimeDefault) to the controller-manager pod securityContext.
Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Andrey Kolkov <androndo@gmail.com>
kubernetes-previous.bats intermittently fails on
`kubectl wait deploy --for=condition=available` with
`Error from server: etcdserver: leader changed` even when the target
Deployment is milliseconds away from becoming Ready. Observed in run
28772259934 on head `5f0bec622`: three of the four deployments
already reported `condition met`, the fourth (the Kamaji tenant CP
Deployment) was ~200ms from Ready per the crust-gather snapshot
captured 20s later (`readyReplicas: 2`), and the wait exited
non-zero on the FIRST etcd-side error it saw on the watch channel.
Cause: the e2e sandbox management cluster is a 3-node kind on Talos
VMs with a 3-instance etcd HA cluster. By the time
kubernetes-previous starts (after kubernetes-latest,
kubernetes-oidc-customconfig, kubernetes-oidc-system, plus the
storageclass-fallback probe), accumulated CDI+DRBD IO pressure and
lingering "still attached to node" PVCs from prior tests push etcd
fsync latency high enough to trip heartbeat timeouts on the leader.
The election that follows resolves in seconds, but `kubectl wait`
sees the server-side error mid-watch and exits — clients would
normally reconnect, but `wait` treats a watch-channel error as
terminal.
Wrap the four management-cluster `kubectl wait` calls in
run_kubernetes_test with a retry helper that:
- Retries only on a curated allowlist of server-side transients
(`etcdserver: leader changed`, `etcdserver: request timed out`,
`the server was unable to return a response in the time
allotted`).
- Never swallows legitimate errors (NotFound, --timeout expired,
permission denied, etc.).
- Caps at 3 attempts with a 5s backoff so a persistently-broken
etcd still surfaces loudly.
Untouched:
- `kubectl wait namespace tenant-test --timeout=20s` at line 308 —
short-lived, hits mgmt but the object always exists ~instantly.
- `kubectl wait deployment ... --kubeconfig tenantkubeconfig-...` at
line 509 — waits on the TENANT apiserver's own etcd, a different
failure domain (single-instance kine or CNPG-backed via Kamaji,
not the 3-instance mgmt etcd that flapped).
- Other bats files with mgmt-cluster `kubectl wait` — out of scope
for this PR; if the same flake reappears there a follow-up can
lift the helper into a shared library.
Signed-off-by: Ivan Okhotnikov <xorokhotnikov@gmail.com>
## Problem
The **PR Auto-Label** workflow (`.github/workflows/pr-labeler.yaml`)
triggers on `pull_request_target` with `types: [opened, edited,
reopened, synchronize]`.
The `edited` action fires on every PR title **and body** edit.
Long-lived bot PRs — notably Renovate — repeatedly edit their own
description on schedule (rebase checkbox, changelog tables,
dependency-dashboard state). Each body edit re-runs the `label` job
against an **unchanged commit**.
Example: on #2998 (a Renovate PR) the `label` job has run **~48 times**,
all on the same head SHA, from Jun 22 through today. It shows up on the
/checks page as a wall of duplicate labeler runs.
## Change
Drop `edited` from the trigger types:
```yaml
on:
pull_request_target:
types: [opened, reopened, synchronize]
```
## Trade-off
Labels derived from the PR title are re-applied on the next push
(`synchronize`) or on reopen. A **title-only** edit made between pushes
will no longer trigger an immediate relabel — an acceptable trade for
eliminating the constant bot-driven churn. (Title edits are rare and the
next push catches up.)
No behavior change for the common case: label-on-open and label-on-push
both still work.
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Chores**
* Improved pull request labeling behavior so labels are refreshed on new
commits or when a PR is reopened, while avoiding unnecessary updates
from body-only edits.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
The audience-list assertion used \`grep -qF "- tenant-test-..."\`.
Because the pattern begins with a dash, grep treats the leading \`-\`
as an option flag, aborting the test with \`grep: invalid option --
' '\` before the actual match ever runs. Insert an explicit \`--\`
end-of-options separator so the YAML list-item pattern is passed as a
pattern rather than a flag.
Custom-config counterpart is unaffected — its grep patterns do not
start with a dash.
Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>