Commit Graph
5128 Commits
Author SHA1 Message Date
Ivan Okhotnikov 0ae65f4dbe fix(apps/kubernetes): address gemini review — dig-based nil-safety + portable base64
Four findings from the gemini re-review:

  - `.Values.oidc.customConfig.secretRef.name` and
    `.Values.oidc.customConfig.config` — replaced direct chained
    access with `dig "customConfig" "…" "" .Values.oidc` so a CR that
    explicitly nulls `customConfig` doesn't nil-panic. `.Values.oidc`
    itself has a kubebuilder default of `{}` so it stays reachable.

  - `.Values.controlPlane.apiServer.extraArgs` in the collision guard
    — wrapped with `| default list` so an explicit null renders as an
    empty range rather than a nil deref. (`dig` on `.Values` itself
    doesn't work because it's `chartutil.Values`, not
    `map[string]interface{}`.)

  - `base64 -w 0` in the OIDC kubeconfig patch — replaced with
    `base64 | tr -d '\n'`. Both work on the current alpine/k8s image
    (BusyBox 1.37 supports `-w`), but the pipe form is portable back
    to older BusyBox releases and to macOS/BSD base64 (which does not
    know `-w`).

Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
2026-07-06 09:25:07 +03:00
Ivan Okhotnikov b85cefc3e2 fix(apps/kubernetes): extend --oidc-* guard to catch --authentication-config too
Same class of upgrade regression: a customer who wired their own
structured AuthenticationConfiguration by hand via
`controlPlane.apiServer.extraArgs: [--authentication-config=...]`
would hit a duplicate-flag scenario the moment they toggle
`spec.oidc.mode` — kube-apiserver would receive two conflicting
--authentication-config= arguments and the last-wins behaviour is
undefined between apiserver versions.

Add a second render-time guard that fails when a legacy
--authentication-config= is present in the user-supplied extraArgs
AND spec.oidc.mode is not None, pointing at the CustomConfig
migration path (customConfig.config or customConfig.secretRef.name).

Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
2026-07-06 09:25:07 +03:00
Ivan Okhotnikov 9c73b6ef10 fix(apps/kubernetes): render-fail when spec.oidc.mode collides with legacy --oidc-* extraArgs
Reviewer flagged a real customer-upgrade regression: the previous
values.yaml docstring on `controlPlane.apiServer.extraArgs` documented
`--oidc-issuer-url`/`--oidc-client-id` as a valid usage. An existing
customer who wired OIDC by hand through that passthrough (following
the doc) would see their tenant kube-apiserver BOOT-FAIL the moment
they toggle `spec.oidc.mode` to `System` or `CustomConfig`:
kube-apiserver refuses to boot with both `--authentication-config`
(injected by the new spec.oidc) and any legacy `--oidc-*` flag —
`--authentication-config cannot be used with any of --oidc-*`.

Add a render-time guard: when `spec.oidc.mode != None`, scan the
user-supplied `controlPlane.apiServer.extraArgs` for any flag with
prefix `--oidc-` and fail with a clear message pointing at the
`spec.oidc.mode: CustomConfig` migration path (they can carry the
same issuer/audience inside `customConfig.config`).

When `spec.oidc.mode: None` the passthrough still works — customers
who manage OIDC entirely by hand keep working until they opt into
the new selector explicitly.

Also update the values.yaml docstring on `extraArgs` to steer OIDC
users to `spec.oidc.mode` and warn against the collision.

Two new helm-unittest cases: the collision fails, and mode=None with
legacy flags renders normally (backward compat).

Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
2026-07-06 09:25:07 +03:00
Ivan Okhotnikov d3a5bf745f docs(oidc): clarify — chart requests email scope explicitly (not a realm-default)
Adversarial review flag: the previous rewording implied the cozy realm
has `email` in its client-scope defaults, which is misleading — the
per-cluster client my chart creates lists only `groups` and the
per-cluster audience scope as defaults. The `email` claim reaches the
token because the chart-generated `kubectl oidc-login` block passes
`--oidc-extra-scope=email` explicitly, not because of realm-side
default-scope hygiene.

State the actual mechanic: `email` is a built-in OIDC scope available
in every conformant issuer, requested by the client at auth time via
the kubeconfig's exec block. No realm-side configuration required.
For `CustomConfig`, note that the operator should verify their BYO
issuer emits `email` when the client requests the `email` scope
(every conformant provider does).

Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
2026-07-06 09:25:07 +03:00
Ivan Okhotnikov 0bf26182da fix(apps/kubernetes,docs): explicit --oidc-extra-scope=email + doc alignment
Two follow-ups from the final adversarial review:

  - The chart-generated OIDC kubeconfig exec block requested only
    `--oidc-extra-scope=groups`. On a Keycloak realm where `email` is
    not a default client scope, kubectl-oidc-login would fetch a token
    that lacks the `email` claim — apiserver would then fail to
    identify the user, causing a silent auth failure. Add
    `--oidc-extra-scope=email` explicitly so the flow does not depend
    on realm-level default-scope hygiene.
  - `docs/oidc-tenant.md` still said the `User:` subject "must match
    the `preferred_username` claim emitted by the issuer" — obsolete
    after the claim switch. Rewrite to say `email`, and add a note
    that the chart requests the `email` scope explicitly.

Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
2026-07-06 09:25:07 +03:00
Ivan Okhotnikov fe3cfef90a fix(apps/kubernetes): switch AuthenticationConfiguration username claim to email
Real bug caught by an end-to-end OIDC login test on dev3: apiserver
accepts the JWT (auth succeeds) but the RBAC check fails with 403
because the CRB `User:` subject was `alice-oidc@test3.example` (from
`users[].email`) but the apiserver-visible identity was `alice-oidc`
(from `preferred_username`, which in the cozy realm is the Keycloak
*username*, not the email).

The design proposal example used `claim: preferred_username`, but
the operator-facing `users[].email` field explicitly asks for the
email address. In the cozy realm — verified — `preferred_username`
is the Keycloak username (`kvaps`, `i.okhotnikov`), so the two do
NOT coincide. Every user whose Keycloak username differs from their
email would silently 403 after a successful OIDC handshake.

Switch the AuthenticationConfiguration `claimMappings.username.claim`
to `email`, aligning the apiserver-visible identity with the
operator-declared `users[].email`. Every conformant OIDC provider
emits `email` — cozy realm does (verified), and any BYO
`CustomConfig` issuer that follows OIDC does too.

The switch is invisible to installers where `preferred_username`
already equals the email (a common Keycloak setup); for installers
where it doesn't, this fix is required for RBAC to work at all.

Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
2026-07-06 09:25:07 +03:00
Ivan Okhotnikov 7257f8723e fix(apps/kubernetes): grant OIDC Secret get verb — kubectl patch --type=merge reads before writing
Caught by dev3 Job runtime: `kubectl patch --type=merge` fetches the
current object before computing the merged state, so the Role needs
`get` in addition to `patch` on the specific Secret name. Without it
the Job fails with `secrets "..." is forbidden: ... cannot get
resource "secrets"` and the OIDC kubeconfig stays on its placeholder.

Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
2026-07-06 09:25:07 +03:00
Ivan Okhotnikov 15efa44e7a fix(apps/kubernetes): scope Keycloak cleanup delete verb to the specific per-cluster client + scope
Tighten the release Role — the `delete` verb on `keycloakclients` and
`keycloakclientscopes` now carries `resourceNames` naming exactly the
per-cluster client and its audience scope. Without the scope, the SA
could delete any Keycloak object in the tenant namespace.

Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
2026-07-06 09:25:07 +03:00
Ivan Okhotnikov de60efbd15 fix(apps/kubernetes): normalize Job also clears stale extraArgs and extraVolumeMounts
Follow-up to the KCP cleanup extension: Kamaji's SSA field manager
takes ownership of `spec.apiServer.extraArgs` and
`spec.apiServer.extraVolumeMounts` too, not just
`spec.deployment.extraVolumes`. The previous normalize patch only
replaced extraVolumes; the two remaining fields still carried the
`--authentication-config=` flag and the corresponding mount even
after mode was toggled to None.

Extend the merge patch to REPLACE all three fields at once, sourced
from Helm values so user-supplied entries in
`controlPlane.apiServer.extra{Args,VolumeMounts,Volumes}` remain
intact — only the chart-owned OIDC-only entries drop off.

Verified on dev3: with the extended patch, mode=System → mode=None
leaves the tenant KCP with `extraArgs: []`,
`extraVolumeMounts: []`, and `deployment.extraVolumes` back to just
`talos-ca`, `talos-tls-cert` (or those + operator-supplied extras).

Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
2026-07-06 09:25:07 +03:00
Ivan Okhotnikov 2705bfeba8 fix(apps/kubernetes): normalize Job force-deletes ALL chart-owned OIDC objects at mode=None
Follow-up to the KCP normalize commit: Helm-controller's SSA-based
apply also does not delete namespaced Secrets and cozy-realm
Keycloak resources when the new manifest omits them — the resources
stay as ghost owner-labeled-by-Helm objects. The dashboard picks
them up (via the cozyrds `secrets.include` list) and shows the
placeholder kubeconfig even for clusters where OIDC was explicitly
turned off.

Extend the `mode=None` normalize path to also `kubectl delete`:
  - `<release>-oidc-kubeconfig` Secret
  - `<release>-oidc-authn-config` Secret
  - `<namespace>-<release>` KeycloakClient in the cozy realm
  - `<namespace>-<release>-audience` KeycloakClientScope

The delete verbs are added to the release-owned Role scoped to the
specific resourceNames (Secrets) and the API group (Keycloak — the
EDP operator does not support resourceNames on cluster-scoped-ish
CRDs). No new privileges beyond what the chart itself would need to
render these objects.

Together with the previous KCP-normalize commit, `mode=System →
mode=None` now leaves ZERO chart-owned OIDC leftovers.

Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
2026-07-06 09:25:07 +03:00
Ivan Okhotnikov 0f60f0703a fix(apps/kubernetes): bootstrap Job normalizes stale KCP OIDC volume when mode=None
Kamaji's controller takes SSA field-ownership on
`KamajiControlPlane.spec.deployment.extraVolumes` when it syncs the
resource back from the TenantControlPlane, and Helm's subsequent
apply (with the same field-manager but a manifest that omits the
OIDC volume) then no longer removes it — the tenant apiserver Pod
keeps an unmounted-but-declared `authentication-config` volume
pointing at a Secret the chart already reaped. Harmless at runtime
(the volume is not mounted after the extraVolumeMounts fix), but the
CRD gets dirty and the operator wants zero leftovers.

Fix: the OIDC bootstrap Job is now rendered for ALL modes (not only
`System`/`CustomConfig`). When `mode=None` the Job takes the
normalize path — a single `kubectl patch --type=merge` that replaces
`spec.deployment.extraVolumes` with the chart baseline (talos-ca +
talos-tls-cert). We verified end-to-end that `kubectl patch
--type=merge` on this array REPLACES it deterministically (the
earlier Helm patch didn't).

For `mode=System`/`CustomConfig` the Job continues to apply
per-user ClusterRoleBindings inside the tenant cluster, prune stale
ones labelled by this release, and patch the pre-rendered
`<release>-oidc-kubeconfig` Secret with a real kubeconfig.

The Role gains `patch` on the specific `kamajicontrolplanes/<release>`
so the normalize path has just enough privilege; nothing else new.

The pre-delete cleanup Job stays conditional on `mode != None` — no
CRBs to clean up when OIDC was never enabled.

Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
2026-07-06 09:25:07 +03:00
Ivan Okhotnikov f87f773399 fix(apps/kubernetes): always emit KCP extraArgs/extraVolumeMounts + rm -f for BusyBox
Two real bugs caught by the dev3 e2e test — both would have shipped
without live-cluster validation:

  - `rm --force` in the bootstrap and cleanup Job scripts is rejected
    by BusyBox `rm` (the alpine/k8s image uses BusyBox coreutils, not
    GNU). The bootstrap Job on dev3 died at the prune step with
    `rm: unrecognized option: force`. Switch to `rm -f` (works on
    both GNU and BusyBox). Also swap
    `grep --line-regexp --quiet --fixed-strings` for `grep -x -q -F`
    for the same reason.

  - Kamaji's `KamajiControlPlane` is a CRD, and Helm applies
    JSON-merge-patch to CRDs. That patch does not remove array-typed
    fields the new manifest simply omits — it leaves the live array
    untouched. Toggling `oidc.mode: System` → `None` therefore left
    the tenant kube-apiserver with a dangling `--authentication-config`
    flag pointing at a Secret the chart had already reaped, and the
    apiserver would refuse to start on the next kubelet restart.
    Fix: always render `extraArgs` and `extraVolumeMounts` on the
    KamajiControlPlane, emitting `[]` on the off-path so the patch
    REPLACES the stale array. Verified end-to-end on dev3.

Adds two helm-unittest cases: the mode=None regression, and an
adjustment of the baseline default-values test.

Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
2026-07-06 09:25:07 +03:00
Ivan Okhotnikov 5325468c96 fix(apps/kubernetes,e2e): address bot-review findings on Phase 1 rewrite
Three items from the gemini-code-assist and coderabbitai passes that
survived the Phase-1 redesign:

  - `oidc-rbac-job.yaml` (gemini HIGH): pre-delete cleanup uses
    `kubectl --request-timeout=30s` in addition to `--timeout=30s`
    on the delete verb, so an unreachable tenant apiserver cannot
    stall the pre-delete hook on the HTTP round-trip itself. The
    `|| echo WARNING` fallback still keeps a truly unreachable
    cluster from blocking `helm uninstall`.

  - `oidc-keycloak.yaml` + `_helpers.tpl` (gemini LOW): switch
    `_cluster.{oidc-enabled,root-host}` reads to `dig` with an empty
    map default so a missing `_cluster` key nil-safely emits an
    empty string and hits the platform-OIDC guard instead of a
    template panic on `.Values._cluster`.

  - `hack/e2e-apps/kubernetes-oidc-{system,customconfig}.bats`
    (coderabbit MAJOR): add an existence-poll `timeout ... until
    kubectl get secret ... ; do sleep ...; done` before the
    `kubectl wait secret --for=jsonpath` line. Without it a runner
    that reaches the wait before the HR has rendered the Secret
    gets an immediate `NotFound` and the test flakes.

Adds one helm-unittest case covering the nil-safe `_cluster` path.

Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
2026-07-06 09:25:07 +03:00
Ivan Okhotnikov fa1fdd8c0e fix(apps/kubernetes): drop EDP-Keycloak-unknown fields from KeycloakClient
Self-review fix caught by deploying to dev3: the EDP Keycloak operator
CRD v1 does not declare `standardFlow` (the canonical field is
`standardFlowEnabled`, default true), and `webUrl` is meaningful only
for clients with a real web UI (not for the kubectl oidc-login flow
which is keyed only by `redirectUris`).

Both lines violated the CRD's openAPIV3Schema and Helm refused the
install with `field not declared in schema`. Dropping the empty
`webUrl` and relying on `standardFlowEnabled`'s default (true) keeps
the manifest minimal and CRD-clean. helm-unittest did not catch this
because it does not validate templates against live CRD schemas.

Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
2026-07-06 09:25:07 +03:00
Ivan Okhotnikov 073c0c717a fix(apps/kubernetes): drop unused get verb from OIDC bootstrap Role
Self-review fix: the Role granted `["get", "patch"]` on the OIDC
kubeconfig Secret, but the bootstrap Job script never issues `kubectl
get` against the management apiserver — it only `kubectl patch`es the
pre-rendered Secret. The tenant-side `kubectl get clusterrolebindings`
uses the mounted admin kubeconfig, which is a separate trust path.

Tightens the SA's permission surface to exactly what the script uses.

Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
2026-07-06 09:25:07 +03:00
Ivan Okhotnikov b6fba8cabf docs(oidc): fix Secret name + CRB hash separator in the operator guide
Self-review fixes for two factual errors:

  - The "How a user logs in" example named the Secret
    `prod-oidc-kubeconfig`, but the cozystack-api prefixes the
    cluster name with `kubernetes-` when materialising the
    `HelmRelease`, so the actual Secret is
    `kubernetes-prod-oidc-kubeconfig`. The NOTES.txt template was
    already correct; only the doc example was off.
  - The CRB hash formula was described as `<release>:<email>` but
    the code uses `<release>-<email>` as the SHA input.

Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
2026-07-06 09:25:07 +03:00
Ivan Okhotnikov 0247a17d33 test(e2e): add CustomConfig bats coverage; align filenames with design
Self-review fix: the design proposal explicitly lists TWO bats files:
`kubernetes-oidc-system.bats` and `kubernetes-oidc-customconfig.bats`.
The first revision shipped only one combined file; this commit splits
them.

The CustomConfig bats covers what System cannot: the
operator-supplied AuthenticationConfiguration is plumbed onto the
KamajiControlPlane, the resulting Secret carries the BYO issuer (and
emphatically NOT a `cozy` issuer), nothing lands in the `cozy` realm,
and no OIDC kubeconfig Secret is pre-rendered (the operator
distributes their own).

The System bats header is updated to call out the deferred parts
(in-tenant CRBs / view-vs-admin RBAC behavior / browser-flow login)
that need a full Kamaji bringup, which is already covered for the
cluster path by `kubernetes-latest.bats`.

Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
2026-07-06 09:25:07 +03:00
Ivan Okhotnikov 5fcb40f07e fix(apps/kubernetes): switch audience mapper to included.client.audience
Self-review fix: the KeycloakClientScope rendered for the per-cluster
audience used `included.custom.audience` (raw string), but the
project convention — set in
`packages/system/keycloak-configure/templates/configure-kk.yaml` for
the legacy global `kubernetes-client` scope — is
`included.client.audience` (refers to a clientId in the same realm).

Both keys make Keycloak emit the audience claim with the same value
in our setup, but the `client.audience` form is the idiomatic one
when the audience IS a registered client in the realm.

Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
2026-07-06 09:25:07 +03:00
Ivan Okhotnikov d13e2c586f fix(apps/kubernetes): align OIDC mount + path with the design spec
Self-review fix: the design proposal specifies
`name: authentication-config` and
`mountPath: /etc/kubernetes/authentication-config`, but the previous
revision had renamed both to `cozy-oidc-authn-config` and
`/etc/kubernetes/cozy-oidc` to dodge a collision with the volume
name used in `apiserver_authentication_test.yaml`. That test was
demonstrating the GENERIC user-supplied `extra*` passthrough — not
the OIDC use case — so rename the test fixture instead and restore
the design-spec names on the chart side.

The reservation guard against user-supplied volumes reusing
`authentication-config` only fires when `spec.oidc.mode` is not
None, so clusters with OIDC off retain the previous freedom to name
their own volumes anything.

Renames in `apiserver_authentication_test.yaml`: the user-supplied
volume name `authentication-config` becomes `tenant-byo-mount`; the
Secret name `tenant-authentication-config` and the apiserver flag
`--authentication-config=` are untouched.

Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
2026-07-06 09:25:07 +03:00
Ivan Okhotnikov 48fb4fe520 fix(apps/kubernetes): release-own the OIDC SA/Role/RoleBinding + pre-render the kubeconfig Secret
Self-review fixes for four linked lifecycle issues:

  - The post-install Job created `<release>-oidc-kubeconfig` via
    `kubectl apply` from inside the script; the resulting Secret had
    no Helm release labels and was orphaned on `helm uninstall`. Now
    a release-owned placeholder Secret is rendered up front in
    `oidc-kubeconfig-secret.yaml` and the Job patches its `data`
    field at runtime (the tenant CA still has to come from the
    Kamaji-issued admin kubeconfig, which appears asynchronously).
  - The ServiceAccount/Role/RoleBinding for the bootstrap Job were
    hook resources with `helm.sh/hook-delete-policy: before-hook-creation`,
    so they leaked on `helm uninstall`. They are now regular release
    resources (no hook annotations) and are reaped by `helm uninstall`
    alongside everything else. Both the post-install bootstrap Job
    and the pre-delete cleanup Job find a working SA.
  - The previous design tried to add `hook-succeeded` to the SA, but
    that would delete the SA right after the bootstrap hook
    succeeded — leaving the pre-delete cleanup Job with no SA to
    schedule. Release-owned resources sidestep this race entirely.
  - The Role had `verbs: ["create"]` on `secrets` without
    `resourceNames` (Kubernetes RBAC cannot scope `create` by name).
    With the placeholder Secret pre-rendered the Job only needs
    `get`/`patch` on the specific name — the broad `create` rule is
    removed.

Also tightens the script: `set -euo pipefail` (was `set -eu`, which
silently swallowed `kubectl get` failures inside command
substitutions) and uses `kubectl patch --type=merge` against the
existing Secret instead of `kubectl create --dry-run | kubectl apply`.

Adds three helm-unittest cases for the placeholder Secret across all
three modes.

Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
2026-07-06 09:25:07 +03:00
Ivan Okhotnikov b7c553e653 docs(oidc): Phase-1 operator guide + chart NOTES
Add the operator-facing guide for the new selector — what the three
modes mean, what the chart provisions, how a user logs in, what the
failure modes are, and what is intentionally out of scope (per-tenant
realms, federation, cross-cluster SSO). The architectural rationale
points back to cozystack/community#24.

Update the chart NOTES.txt to print the OIDC kubeconfig retrieval
snippet, the per-cluster client/issuer values, and the listed
`users[]` roster — only when OIDC is on, otherwise the previous
admin-kubeconfig hint is unchanged.

Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
2026-07-06 09:25:07 +03:00
Ivan Okhotnikov daf8009c5e test(e2e): add Phase-1 OIDC render-side bats coverage
Validate on a live cluster that:
  - the new `spec.oidc.*` fields are admitted by the Kubernetes CRD;
  - cozystack-api accepts the updated schema and renders a HelmRelease;
  - the KamajiControlPlane gains `--authentication-config` and the
    chart-owned `cozy-oidc-authn-config` mount and volume;
  - the AuthenticationConfiguration Secret carries the cozy realm
    issuer URL and the per-cluster audience;
  - per-cluster `KeycloakClient` + audience `KeycloakClientScope`
    land in the cozy realm (with the EDP operator present).

The full end-to-end RBAC flow (Job → CRBs inside the tenant cluster)
needs a Kamaji bringup, which kubernetes-{latest,previous}.bats
already cover for the cluster path; the OIDC browser flow is
explicitly out of scope per the design proposal. This file covers
only what the render-side regression budget can pay for.

Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
2026-07-06 09:25:07 +03:00
Ivan Okhotnikov 5bf7219f73 test(apps/kubernetes): helm-unittest cases for OIDC modes
Cover the three branches of the new selector API:

  - `mode: None` (default) — every OIDC template is empty, the
    KamajiControlPlane has no extra args and the standard two talos
    volumes, the bootstrap Job is not rendered.
  - `mode: System` — per-cluster `KeycloakClient` + audience scope
    in the `cozy` realm, namespace-prefixed clientId, PKCE attribute
    on the client, soft-render when the EDP Keycloak operator CRDs
    are absent, the AuthenticationConfiguration Secret pinned to the
    cozy issuer, the chart-owned `--authentication-config` flag and
    `cozy-oidc-authn-config` mount injected into the KCP, the
    bootstrap Job carrying `policy.cozystack.io/allow-to-apiserver`,
    and one ClusterRoleBinding per `users[]` entry with the right
    ClusterRole (`cluster-admin` vs `view`).
  - `mode: CustomConfig` — inline config written verbatim; secretRef
    path mounted on the KCP without a chart-owned Secret; no
    Keycloak objects emitted; mutual-exclusion and missing-source
    guards both hard-fail.

Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
2026-07-06 09:25:07 +03:00
Ivan Okhotnikov 0d70d5ff3e feat(system/kubernetes-rd): expose OIDC kubeconfig Secret to the dashboard
Add `kubernetes-<name>-oidc-kubeconfig` to the Kubernetes
ApplicationDefinition's `spec.secrets.include[].resourceNames` so
the dashboard surfaces the OIDC kubeconfig to tenant viewers
alongside the existing admin kubeconfig.

The Secret is produced by the post-install,post-upgrade bootstrap
Job in `apps/kubernetes` when `spec.oidc.mode: System` is set.

Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
2026-07-06 09:25:07 +03:00
Ivan Okhotnikov c5295d8bad feat(apps/kubernetes): bootstrap Job applies per-user CRBs and writes OIDC kubeconfig Secret
Add a post-install,post-upgrade Helm-hook Job that reconciles the
operator-supplied `users[]` list into the tenant cluster:

  - For every entry, apply a ClusterRoleBinding labelled by the
    release (managed-by=cozystack-oidc, instance=<release>).
    `role: admin` maps to `cluster-admin`; `role: view` maps to
    `view`. The subject is the literal `email` value, matched
    against the issuer's `preferred_username` claim.
  - Prune any orphan CRBs with the same labels — removing a user
    from `users[]` revokes their kubectl access on the next loop.

For `mode: System` the same Job also writes
`<release>-oidc-kubeconfig` on the management cluster: a ready
kubeconfig with the per-cluster apiserver endpoint, the tenant CA
extracted from the Kamaji-issued admin kubeconfig, and a
`kubectl oidc-login` exec block keyed to the per-cluster client.

A pre-delete cleanup Job removes the labelled CRBs before the
control plane is torn down (best-effort: the CRBs disappear with
the tenant cluster anyway).

Pod is labelled `policy.cozystack.io/allow-to-apiserver: "true"`
so Cilium lets it reach the konnectivity tunnel.

ServiceAccount/Role/RoleBinding are minimum-privilege — only the
specific admin-kubeconfig and OIDC kubeconfig Secrets are listed.

Renders nothing for `mode: None`.

Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
2026-07-06 09:25:07 +03:00
Ivan Okhotnikov cef556ba4c feat(apps/kubernetes): wire structured AuthenticationConfiguration
Render a per-release Secret carrying the structured kube-apiserver
`AuthenticationConfiguration` (apiserver.config.k8s.io/v1beta1) and
mount it into the KamajiControlPlane.

For `mode: System` the Secret is chart-owned: one JWT entry pointing
at the `cozy` realm issuer with the per-cluster audience, plus the
standard `preferred_username`/`groups` claim mappings. For `mode:
CustomConfig` the chart either writes the operator-supplied inline
config into a Secret, or — when `secretRef.name` is set — mounts an
existing operator-owned Secret directly (mutually exclusive; the
chart fails the render if neither or both are set).

The `--authentication-config=` flag, mount, and volume on the
KamajiControlPlane are kept orthogonal to the user-supplied
`controlPlane.apiServer.extra*` knobs: the OIDC additions are
appended, and the chart-managed volume name `cozy-oidc-authn-config`
is reserved (only when `mode` is not None) so user-supplied volumes
cannot collide.

Switches from the legacy `--oidc-*` flag form to the file form so
multiple issuers, inline private-CA PEM, and future Phase-2 issuers
extend the same Secret without rewiring the chart.

Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
2026-07-06 09:25:07 +03:00
Ivan Okhotnikov 83980c8c47 feat(apps/kubernetes): render per-cluster Keycloak client + audience scope
For `oidc.mode: System`, emit a per-cluster public `KeycloakClient`
plus a per-cluster `KeycloakClientScope` inside the platform `cozy`
realm. The scope carries an `oidc-audience-mapper` that pins
`id_token.aud` to the per-cluster clientId, so a token minted for
cluster A is rejected by cluster B's apiserver.

The clientId is namespace-prefixed (`<ns>-<release>`) to stay unique
across the realm — two clusters with the same name in different
tenants would otherwise collide.

The client is `public: true`, redirects locked to localhost:8000 /
localhost:18000 (the kubelogin/oidc-login defaults), with PKCE
required via the standard Keycloak attribute. `defaultClientScopes`
includes both `groups` and the per-cluster audience scope.

A render-time guard fails fast when `oidc.mode: System` is set but
`_cluster.oidc-enabled` is not `"true"` — the Keycloak operator CRDs
are otherwise absent and the chart would silently no-op.

Nothing is rendered for `mode: CustomConfig` or `mode: None`.

Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
2026-07-06 09:25:07 +03:00
Ivan Okhotnikov 5950450758 feat(apps/kubernetes): add Kubernetes.spec.oidc selector API
Introduce a flat selector — `mode: System | CustomConfig | None` —
plus a `users[]` list and a `customConfig` block for tenant-supplied
AuthenticationConfiguration. Default `None` preserves today's
behavior.

The `System` mode trusts the platform `cozy` realm via a per-cluster
public client; `CustomConfig` accepts an inline `AuthenticationConfiguration`
or a Secret reference (mutually exclusive). The `users[]` list drives
per-user ClusterRoleBindings inside the tenant cluster, with two
roles: `admin` and `view`.

Regenerates deepcopy, JSON schema, README, and the kubernetes-rd
cozyrds embedded schema.

Implements Phase 1 API of the per-tenant OIDC design proposal
(cozystack/community#24).

Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
2026-07-06 09:25:07 +03:00
myasnikovdaniil e28681afb4 fix(kubevirt-instancetypes): restore persistent EFI/TPM state (#3154)
## What this PR does

Reverts #3006, restoring upstream's persistent EFI/TPM firmware state
for the `windows.11` / `windows.2k22` / `windows.2k25` preferences (and
their `.virtio` variants).

#3006 dropped persistent EFI/TPM to "unblock live-migration", on the
assumption that an RWO Filesystem backend-storage PVC pins the VM to its
node. That assumption was outdated: KubeVirt has live-migrated
RWO-Filesystem backend storage since v1.4 (kubevirt/kubevirt#12629) — on
migration it creates a fresh target state PVC and copies the small state
blob. So persistent-EFI/TPM VMs on the default `replicated` storage
live-migrate fine; the regression described in #3005 was a misdiagnosis.

Verified on a KubeVirt v1.8.2 cluster: a persistent-EFI VM with an RWO
Filesystem backend PVC reports `LiveMigratable=True`, and the
copy-on-target backend PVC is created when a migration starts. KubeVirt
maintainers confirmed the same upstream.

This restores persistent firmware state so in-guest Microsoft UEFI CA /
Secure Boot enrollment survives reboots and migrations.

### Release note

```release-note
fix(kubevirt-instancetypes): restore persistent EFI/TPM state for Windows preferences — RWO-Filesystem backend storage already live-migrates (KubeVirt v1.4+), so persistence no longer needs to be stripped
```


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

* **Bug Fixes**
* Updated Windows instance type preferences to keep TPM persistence
enabled, improving compatibility and reducing storage-related issues.
* Ensured firmware and TPM persistence settings are preserved during
instance type preference generation for more reliable outcomes.

* **Chores**
* Streamlined the preference generation step so instance type templates
are produced consistently without removing persistence configuration.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-06 11:07:37 +05:00
Myasnikov DaniilandClaude Opus 4.8 e82c03a715 ci(pr-labeler): drop 'edited' trigger to stop bot PR re-run churn
The PR Auto-Label workflow triggered on pull_request_target 'edited',
which fires on every PR title *and body* edit. Long-lived bot PRs such
as Renovate repeatedly edit their description on schedule, causing the
label job to re-run dozens of times against an unchanged commit (e.g.
~48 runs on #2998, all on the same head SHA).

Drop 'edited' so the job only runs on opened/reopened/synchronize.
Labels derived from the PR title are re-applied on the next push or on
reopen. Title-only edits between pushes no longer trigger a relabel,
which is an acceptable trade for eliminating the churn.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-07-06 10:24:32 +05:00
Myasnikov DaniilandClaude Opus 4.8 f9ac1bf9cf docs(release): pin nightly examples to the full immutable tag
nightly.yaml publishes each build under 0.0.0-nightly.<date>.<run-id>.<attempt>
(the run-id + attempt suffix makes every nightly tag immutable), but the docs
still showed the old date-only 0.0.0-nightly.<YYYYMMDD> form in the mirror-stage
description and both the helm-install and oras-pull pin examples. Update them to
the full form; the floating `nightly` example is unchanged.

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>
2026-07-06 10:24:00 +05:00
Myasnikov DaniilandClaude Opus 4.8 d51a053a8b fix(ci): harden promote-rc validation and asset restamping
Two issues flagged in review on promote-rc.yaml:

- The "Validate rc is promotable" github-script called core.setFailed()
  but kept running: a missing rc release then hit `rcRel.assets` and a
  missing disk asset hit `disk.id`, throwing a TypeError that masked the
  intended failure message. Return immediately after each setFailed so the
  clean diagnostic is what surfaces.

- "Create draft stable release and upload assets" ran `make upload_assets`
  without COZYSTACK_VERSION. upload_assets depends on `manifests`, which
  re-renders the operator manifests; with no version it blanks the
  platformVersion, silently undoing the stable stamp the Restamp step just
  applied. Pass COZYSTACK_VERSION=${STABLE_VERSION} so the re-render
  re-stamps the stable version.

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>
2026-07-06 10:23:51 +05:00
Myasnikov Daniil 0e14b34164 docs(release): broaden section heading to cover all release workflows
"What CI does on a tag push" only described one of the four triggers;
promote-rc.yaml uses manual dispatch and pull-requests-release.yaml fires
on PR merge. Rename to "What CI does during the release process" so
maintainers find the right entry point regardless of which workflow they
are debugging.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-07-06 10:03:58 +05:00
Myasnikov DaniilandClaude Opus 4.8 ba6cfbf300 docs(release): reconcile the stable-tag flow to promotion-only
Address review feedback from coderabbitai on docs/release.md: the Regular and
Patch sections still told maintainers to push vX.Y.Z to start the build, while
the rest of the doc said the stable tag is created write-once at the merge
commit and stable comes only from rc promotion (with a 'can also push directly'
escape hatch). Rewrite both sections to the single promotion model — push an rc,
promote it via promote-rc.yaml, merge the promote PR, finalize creates the
write-once tag — drop the direct-push escape hatch, and add promote-rc.yaml to
the workflow list.

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>
2026-07-06 10:03:58 +05:00
Myasnikov DaniilandClaude Opus 4.8 64f2d7ebee ci(release): don't let the rebuild guard skip stable follow-up jobs
Address review feedback from coderabbitai on .github/workflows/tags.yaml: the
single `skip` output meant 'a release already exists' but was reused to gate
update-website-docs, so a promoted stable tag (whose draft promote-rc already
created) skipped the website-docs job, not just the rebuild. Rename the output
to release_exists to reflect its real meaning and drop it from the
update-website-docs gate so a promotion still publishes docs (the rebuild steps
inside prepare-release stay gated on it).

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>
2026-07-06 10:03:57 +05:00
Myasnikov DaniilandClaude Opus 4.8 2e7f14acb7 test(version): clear ambient COZYSTACK_VERSION in the unset case
Address review feedback from coderabbitai on pkg/version/version_test.go: the
env-unset case only skipped t.Setenv, so a COZYSTACK_VERSION inherited from the
test runner would make it skip the baked-fallback path it is meant to cover.
Unset the variable (restoring it on cleanup) for that case.

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>
2026-07-06 10:03:56 +05:00
Myasnikov DaniilandClaude Opus 4.8 5db84e7c9c fix(hack): refuse to move an existing stable image tag
Address review feedback from coderabbitai on hack/promote-retag.sh: the loop
copied each rc digest onto ${repo}:${STABLE} unconditionally, so a partial
prior run or a manual push could mutate already-released stable bytes before
the GitHub release/tag guards catch it. Inspect the destination first — no-op
when it already points at the rc digest (idempotent re-run), fail when it
points elsewhere. :latest stays mutable.

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>
2026-07-06 10:03:56 +05:00
Myasnikov DaniilandClaude Opus 4.8 a4dfee8017 ci(release): only swallow non-fast-forward errors on branch update
Address review feedback from coderabbitai on
.github/workflows/pull-requests-release.yaml: the fast-forward catch block
treated every updateRef failure as a recoverable warning, so an auth, API, or
malformed-ref error would silently leave the maintenance branch stale. Warn
only on the 422 non-fast-forward rejection and fail the job otherwise.

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>
2026-07-06 10:03:55 +05:00
Myasnikov DaniilandClaude Opus 4.8 137d720b9e ci(release): don't persist credentials on the rc staging checkout
Address review feedback from coderabbitai on .github/workflows/promote-rc.yaml:
the rc staging checkout left the app token in .git/config even though the
Prepare stable branch step re-injects it via 'git remote set-url'. Set
persist-credentials: false to drop the redundant exposure.

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>
2026-07-06 10:03:55 +05:00
Myasnikov DaniilandClaude Opus 4.8 9c236ec404 ci(release): reject a pre-existing stable git tag before promotion
Address review feedback from coderabbitai on .github/workflows/promote-rc.yaml:
the validate step only checked for an existing stable release, so a stray
refs/tags/vX.Y.Z (partial run or manual push) would slip through and fail later
in finalize — after promote-retag had already retagged images. Also fail early
when the stable git tag already exists.

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>
2026-07-06 10:03:54 +05:00
Myasnikov DaniilandClaude Opus 4.8 a5ecd54535 ci(release): pin and scope the promote-rc app token
Address review feedback from coderabbitai on .github/workflows/promote-rc.yaml:
actions/create-github-app-token was used at the floating @v1 and without a
repositories restriction, so the minted token carried owner-wide access. Pin it
to the SHA the rest of the repo uses and scope it to this repository.

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>
2026-07-06 10:03:54 +05:00
Myasnikov DaniilandClaude Opus 4.8 0e4d550780 ci(release): don't persist checkout credentials in nightly jobs
Address review feedback from coderabbitai on .github/workflows/nightly.yaml:
actions/checkout writes the token into .git/config by default, and these jobs
copy the checkout into /tmp/$SANDBOX_NAME before running repo targets. Set
persist-credentials: false on all three source checkouts so the token is not
carried into the sandbox copies.

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>
2026-07-06 10:03:54 +05:00
Myasnikov DaniilandClaude Opus 4.8 14c684ad5b ci(release): make each nightly tag immutable
Address review feedback from coderabbitai on .github/workflows/nightly.yaml:
the version was 0.0.0-nightly.<date>, so a same-day re-dispatch or re-run
republished every image, chart and artifact under the same tag — the dated
nightly was not write-once. Append the run id and attempt so each build gets a
distinct, immutable tag, and widen retention.yaml's selector regex to match the
extra numeric components (still excluding the floating :nightly and any release
tag).

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>
2026-07-06 10:03:53 +05:00
Myasnikov DaniilandClaude Opus 4.8 b6b7e43eab ci(release): merge paginated GHCR version pages before pruning
Address review feedback from coderabbitai on .github/workflows/retention.yaml:
gh api --paginate with --jq runs the filter once per page and emits one JSON
array per page, so total/drop_ids miscount once a package's versions span more
than one page. Pipe the raw pages through 'jq -s add' to merge them into a
single array before counting and slicing.

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>
2026-07-06 10:03:52 +05:00
Myasnikov DaniilandClaude Opus 4.8 d233288d37 ci(release): pin nightly source artifact to a digest
Address review feedback from coderabbitai on .github/workflows/nightly.yaml:
the resolve step read cozystack-packages:main and the pull step re-fetched
the mutable :main tag, so a concurrent build-main push could check out commit
A while mirroring tree B. Resolve the :main digest once, derive the revision
sha from it, and pull the baked tree by that same digest.

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>
2026-07-06 10:03:52 +05:00
Myasnikov DaniilandClaude Opus 4.8 9314540a8c ci(release): pin actions/github-script to a SHA in promote-rc
Match the repo-wide SHA pin main applies to every github-script use, so
the new promote-rc workflow doesn't regress the Scorecard pinned-dependencies
check.

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>
2026-07-06 10:03:51 +05:00
Myasnikov DaniilandClaude Opus 4.8 17002d0d78 feat(dashboard): report platform version at deploy time
The console bakes VITE_APP_VERSION into its bundle at build time, so a
promote-by-retag image (which is retagged, never rebuilt) would keep
reporting the rc version in the UI after promotion to stable.

Inject the version at deploy time instead: the chart derives it from the
console image tag (build-stamped vX.Y.Z, rewritten rc->stable on promotion
by promote-rc.yaml) and writes it into the existing console config.json
ConfigMap, which the SPA already fetches. App.tsx now prefers
config.version and falls back to the baked VITE_APP_VERSION when absent
(dev / non-version tags like :latest).

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>
2026-07-06 10:03:51 +05:00
Myasnikov DaniilandClaude Opus 4.8 f30f31526a docs(release): document the GHCR nightly mirror + install path
Replace the nightly-rc-tag description with the new model: a nightly is
an installable copy of main on GHCR (mirror OCIR->GHCR by digest +
re-published packages artifact, cozy-installer chart and nocloud disk),
validated by the full e2e suite, with no GitHub release and no Go-module
tag. Document the helm install path and the dated/floating tag scheme,
and update the Tag-immutability section and workflow link list.

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>
2026-07-06 10:03:50 +05:00
Myasnikov DaniilandClaude Opus 4.8 af5d3dd8b6 feat(release): prune GHCR nightlies; drop nightly git-tag cutter
Nightlies are now GHCR OCI tags (0.0.0-nightly.<date>), not git tags or
GitHub releases, so:

- retention.yaml now prunes old GHCR container-package versions via the
  Packages API instead of git tags/releases. Scoped to versions whose
  tags are ALL 0.0.0-nightly.<YYYYMMDD>, so the floating :nightly tag,
  release tags (vX.Y.Z, latest, rc, main) and untagged versions are
  never touched. Keeps newest 14 per package; dry-run by default.
- nightly-rc.yaml is removed: it cut nightly *git tags* that drove a
  release/e2e path we no longer use. The nightly.yaml workflow replaces
  it with the mirror + full-e2e flow.

actionlint clean.

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>
2026-07-06 10:03:50 +05:00
Myasnikov DaniilandClaude Opus 4.8 c1634be861 feat(release): add nightly workflow (mirror main to GHCR + full e2e)
Scheduled nightly that publishes an installable nightly of main without
rebuilding it, then runs the full e2e suite against the published bits:

- mirror: resolves the exact commit cozystack-packages:main was built
  from, runs nightly-mirror.sh to copy every cozystack-owned component
  image OCIR->GHCR by digest, re-publishes the rewritten packages
  artifact and the cozy-installer chart to GHCR (0.0.0-nightly.<date>
  + floating :nightly).
- build-disk: assembles the Talos nocloud disk from the upstream imager
  (no cozystack-image dependency, no rebuild) and publishes it as a GHCR
  OCI artifact (cozystack-nocloud) via oras, so a nightly is installable
  on real hardware, not just e2e.
- e2e: stages the GHCR install closure (rewritten tree + chart pinned to
  the GHCR packages artifact + the disk) and runs the FULL app suite (no
  Test Impact Analysis - a nightly has no diff to scope against).

No GitHub release and no api/apps/v1alpha1 Go-module tag are created.
Gated by the NIGHTLY_ENABLED repo variable. actionlint clean.

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>
2026-07-06 10:03:50 +05:00