From 1a93e6650bce61db3d79ba326ec6e80c82a57aec Mon Sep 17 00:00:00 2001 From: kina0630 Date: Sun, 14 Jun 2026 19:55:32 +0800 Subject: [PATCH 1/2] chore(deploy): genericize o6n deployment config out of public repo The repo is public; o6n-specific topology (mesh IP, internal registry host, namespace, secret names) had leaked into helm values, the deploy script, the woodpecker pipeline, and the runbooks. No secret values were exposed. - Replace helm/values-o6n-deepseek.yaml with a placeholder values-example.yaml - deploy script: support VALUES_CONTENT (inject real values from a Woodpecker secret via temp file, like KUBECONFIG_CONTENT); genericize defaults - .woodpecker/deploy.yml: source namespace/image-repo/values from secrets (k8s_namespace, image_repository, deploy_values); keep build-registry image - genericize phase2-k8s + woodpecker runbooks to placeholders; note real values live in the private SRE inventory + Woodpecker repo secrets Build-registry hostname in .woodpecker/ci.yml is structural and retained. Git history is intentionally not rewritten (topology only, no credentials). Co-Authored-By: Claude Fable 5 --- .woodpecker/deploy.yml | 12 +++-- coding-agent/docs/deployment/phase2-k8s.md | 29 +++++----- coding-agent/docs/deployment/woodpecker.md | 54 +++++++++++-------- ...-o6n-deepseek.yaml => values-example.yaml} | 24 +++++---- .../tests/deploy/test_deploy_script.py | 25 +++++++-- coding-agent/tests/deploy/test_helm_chart.py | 37 ++++++------- scripts/deploy-coding-agent-k8s.sh | 15 ++++-- 7 files changed, 118 insertions(+), 78 deletions(-) rename coding-agent/helm/{values-o6n-deepseek.yaml => values-example.yaml} (59%) diff --git a/.woodpecker/deploy.yml b/.woodpecker/deploy.yml index 54f7becfa..c11d2bb39 100644 --- a/.woodpecker/deploy.yml +++ b/.woodpecker/deploy.yml @@ -3,21 +3,25 @@ when: branch: main steps: - deploy-o6n: + deploy: + # Built by this repository's CI and pushed to the build registry. image: git.mesh.kinaz.me/kina/coding-agent-deploy-tools:kubectl-1.36.0-helm-3.17.3-python-3.12-slim environment: KUBECONFIG_CONTENT: from_secret: kubeconfig - IMAGE_REPOSITORY: git.mesh.kinaz.me/kina/coding-agent + IMAGE_REPOSITORY: + from_secret: image_repository IMAGE_TAG: ${CI_COMMIT_SHA} - K8S_NAMESPACE: coding-agent-deepseek + K8S_NAMESPACE: + from_secret: k8s_namespace K8S_DEPLOYMENT: coding-agent-coding-agent K8S_CONTAINER: coding-agent ROLLOUT_TIMEOUT: 420s ENABLE_POD_HEALTH_SMOKE: "1" HELM_RELEASE: coding-agent HELM_CHART_DIR: coding-agent/helm - HELM_VALUES: coding-agent/helm/values-o6n-deepseek.yaml + VALUES_CONTENT: + from_secret: deploy_values HELM_DEPLOY_MODE: dry-run HELM_DRIVER: configmap commands: diff --git a/coding-agent/docs/deployment/phase2-k8s.md b/coding-agent/docs/deployment/phase2-k8s.md index d29e658cc..9b7bb0a09 100644 --- a/coding-agent/docs/deployment/phase2-k8s.md +++ b/coding-agent/docs/deployment/phase2-k8s.md @@ -197,29 +197,32 @@ helm --kube-context upgrade --install coding-agent ./helm \ Use `--take-ownership` only when same-name resources have been confirmed to be the existing coding-agent deployment that should become Helm-managed. -## Existing o6n DeepSeek Values +## Example Values -`helm/values-o6n-deepseek.yaml` captures the non-secret runtime configuration -for the existing o6n DeepSeek instance: +`helm/values-example.yaml` captures the non-secret runtime configuration shape +with placeholders only: -- Forgejo registry image repository: `git.mesh.kinaz.me/kina/coding-agent` +- registry image repository: `//coding-agent` - namespace/release-compatible Service name through the chart fullname -- NodePort `30082` -- provider/model: `deepseek` / `deepseek-chat` -- runtime Secret references: `coding-agent-deepseek` and `coding-agent-langfuse` +- example NodePort `30080` +- provider/model placeholders for the target runtime +- runtime Secret references: `` and `` - API bearer token Secret reference: `coding-agent-coding-agent-api-key` key `api-key` -- Langfuse OTLP endpoint reference: `LANGFUSE_BASE_URL` from - `coding-agent-langfuse` -- a narrow NetworkPolicy egress exception for the mesh Langfuse endpoint +- observability endpoint reference from `` +- a narrow NetworkPolicy egress exception placeholder for the observability + endpoint The file must not contain token values. If these Secrets are managed by SealedSecrets in the SRE repo, keep using SealedSecrets for the Secret manifests and keep this chart values file limited to names and keys. -The Woodpecker o6n deploy script uses Helm server-side dry-run by default and -passes `--take-ownership` so the first apply can adopt the existing deployment -objects instead of failing on missing Helm ownership metadata. +Real values live in the private SRE inventory and Woodpecker repository secrets: +`deploy_values`, `kubeconfig`, `k8s_namespace`, and `image_repository`. + +The Woodpecker deploy script uses Helm server-side dry-run by default and passes +`--take-ownership` so the first apply can adopt the existing deployment objects +instead of failing on missing Helm ownership metadata. ## Health Checks diff --git a/coding-agent/docs/deployment/woodpecker.md b/coding-agent/docs/deployment/woodpecker.md index 6b0f722d1..4f1dbe96d 100644 --- a/coding-agent/docs/deployment/woodpecker.md +++ b/coding-agent/docs/deployment/woodpecker.md @@ -4,36 +4,40 @@ This repository uses Woodpecker for self-hosted CI/CD on the private mesh. The deployment pipeline intentionally keeps runtime provider credentials in Kubernetes Secrets. Woodpecker builds and deploys images, but it does not bake -DeepSeek, Langfuse, bearer tokens, or kube credentials into the image. +provider, observability, bearer token, or kube credentials into the image. + +Real values live in the private SRE inventory and Woodpecker repository secrets: +`deploy_values`, `kubeconfig`, `k8s_namespace`, and `image_repository`. ## Pipelines - `.woodpecker/ci.yml` runs lint, focused smoke tests, and builds the HTTP server image. - `.woodpecker/deploy.yml` is manual-only on `main` and runs the Helm deploy - script against the o6n DeepSeek values. It defaults to `HELM_DEPLOY_MODE=dry-run` - so the first pass uses Helm server-side dry-run to validate RBAC, ownership - takeover, and admission without mutating the cluster. + script against values supplied by the `deploy_values` Woodpecker secret. It + defaults to `HELM_DEPLOY_MODE=dry-run` so the first pass uses Helm server-side + dry-run to validate RBAC, ownership takeover, and admission without mutating + the cluster. -The manual deploy step uses the internal deploy-tools image built by +The manual deploy step uses the build-registry deploy-tools image built by `.woodpecker/ci.yml`: ```text -git.mesh.kinaz.me/kina/coding-agent-deploy-tools:kubectl-1.36.0-helm-3.17.3-python-3.12-slim +//coding-agent-deploy-tools:kubectl-1.36.0-helm-3.17.3-python-3.12-slim ``` The default image target is: ```text -git.mesh.kinaz.me/kina/coding-agent:${CI_COMMIT_SHA} +//coding-agent:${CI_COMMIT_SHA} ``` The deploy workflow updates: ```text -namespace: coding-agent-deepseek +namespace: release: coding-agent -values: coding-agent/helm/values-o6n-deepseek.yaml +values: VALUES_CONTENT from deploy_values deployment: coding-agent-coding-agent, when HELM_DEPLOY_MODE=apply ``` @@ -45,13 +49,16 @@ Configure these in Woodpecker repository secrets: registry_username registry_password kubeconfig +deploy_values +k8s_namespace +image_repository ``` `registry_username` and `registry_password` must be able to push -`git.mesh.kinaz.me/kina/coding-agent`. +`//coding-agent`. `kubeconfig` should be scoped to the target cluster and preferably to the -`coding-agent-deepseek` namespace. +`` namespace. Dry-run mode uses `helm upgrade --install --dry-run=server`, not local-only template rendering. It therefore needs the same Kubernetes API visibility and @@ -60,7 +67,7 @@ deploy script sets `HELM_DRIVER=configmap` by default so Helm stores release metadata in ConfigMaps instead of Secrets. Apply mode needs permission to manage the chart-owned resources in the -`coding-agent-deepseek` namespace: +`` namespace: - Deployment, Service, ConfigMap, PVC, ServiceAccount, and NetworkPolicy - ConfigMaps used by Helm release metadata @@ -76,28 +83,28 @@ switching from dry-run to apply. ## Existing Kubernetes Secrets -Keep runtime secrets in Kubernetes, not Woodpecker. The o6n values file contains -only Secret names and key names; Secret values should be supplied by ordinary -Kubernetes Secrets or SealedSecrets in the SRE repo. +Keep runtime secrets in Kubernetes, not this public repository. The deployment +values contain only Secret names and key names; Secret values should be supplied +by ordinary Kubernetes Secrets or SealedSecrets in the private SRE repo. ```bash -kubectl -n coding-agent-deepseek get secret coding-agent-deepseek -kubectl -n coding-agent-deepseek get secret coding-agent-langfuse +kubectl -n get secret +kubectl -n get secret ``` The deployment pipeline does not create or replace these secrets. -The o6n chart values expect: +The chart values expect: ```text coding-agent-coding-agent-api-key: api-key -coding-agent-deepseek: DEEPSEEK_API_KEY -coding-agent-langfuse: LANGFUSE_BASE_URL, LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY +: +: , , ``` -`LANGFUSE_BASE_URL` must be the Langfuse OTLP base endpoint accepted by the -Coding Agent exporter, for example a URL ending in `/api/public/otel`; it is not -the UI root URL unless the exporter endpoint is served there. +`` must be the OTLP base endpoint accepted by the +Coding Agent exporter; it is not the UI root URL unless the exporter endpoint is +served there. Verify key names from the cluster before switching apply mode on. Do not print or commit Secret values. @@ -121,6 +128,7 @@ ENABLE_POD_HEALTH_SMOKE HELM_RELEASE HELM_CHART_DIR HELM_VALUES +VALUES_CONTENT HELM_DEPLOY_MODE HELM_DRIVER ``` diff --git a/coding-agent/helm/values-o6n-deepseek.yaml b/coding-agent/helm/values-example.yaml similarity index 59% rename from coding-agent/helm/values-o6n-deepseek.yaml rename to coding-agent/helm/values-example.yaml index 089de62d4..64fae6241 100644 --- a/coding-agent/helm/values-o6n-deepseek.yaml +++ b/coding-agent/helm/values-example.yaml @@ -1,5 +1,5 @@ image: - repository: git.mesh.kinaz.me/kina/coding-agent + repository: //coding-agent tag: main pullPolicy: IfNotPresent @@ -13,20 +13,22 @@ server: cors: origins: - - http://100.123.220.136:30082 + - https:// service: type: NodePort port: 8080 - nodePort: 30082 + # Example only. Pick an available NodePort in the target cluster. + nodePort: 30080 agent: secretRef: - - coding-agent-deepseek - - coding-agent-langfuse + - + - config: - provider: deepseek - model: deepseek-chat + # Example OpenAI-compatible provider settings. Replace for the target runtime. + provider: openai + model: gpt-4.1 approvalPolicy: auto maxTurns: 30 subagentTimeout: 30.0 @@ -39,15 +41,15 @@ agent: observability: enabled: true backend: langfuse - endpointEnv: LANGFUSE_BASE_URL - publicKeyEnv: LANGFUSE_PUBLIC_KEY - secretKeyEnv: LANGFUSE_SECRET_KEY + endpointEnv: + publicKeyEnv: + secretKeyEnv: networkPolicy: extraEgress: - to: - ipBlock: - cidr: 100.123.220.136/32 + cidr: /32 ports: - protocol: TCP port: 443 diff --git a/coding-agent/tests/deploy/test_deploy_script.py b/coding-agent/tests/deploy/test_deploy_script.py index 1497f5579..f559424fe 100644 --- a/coding-agent/tests/deploy/test_deploy_script.py +++ b/coding-agent/tests/deploy/test_deploy_script.py @@ -61,9 +61,9 @@ def test_deploy_script_defaults_to_helm_dry_run_without_kubectl_mutation( assert result.returncode == 0, result.stderr calls = (tmp_path / "calls.log").read_text(encoding="utf-8") assert "helm upgrade --install coding-agent coding-agent/helm" in calls - assert "--namespace coding-agent-deepseek" in calls - assert "--values coding-agent/helm/values-o6n-deepseek.yaml" in calls - assert "--set image.repository=git.mesh.kinaz.me/kina/coding-agent" in calls + assert "--namespace coding-agent" in calls + assert "--values coding-agent/helm/values-example.yaml" in calls + assert "--set image.repository=//coding-agent" in calls assert "--set image.tag=test-sha" in calls assert "--take-ownership" in calls assert "--dry-run=server" in calls @@ -81,13 +81,28 @@ def test_deploy_script_apply_mode_waits_for_rollout_and_smokes_service( assert "helm upgrade --install coding-agent coding-agent/helm" in calls assert "--take-ownership" in calls assert "--dry-run" not in calls - assert "kubectl -n coding-agent-deepseek rollout status" in calls + assert "kubectl -n coding-agent rollout status" in calls assert ( - "curl -fsS http://coding-agent-coding-agent.coding-agent-deepseek.svc.cluster.local:8080/healthz" + "curl -fsS http://coding-agent-coding-agent.coding-agent.svc.cluster.local:8080/healthz" in calls ) +def test_deploy_script_uses_values_content_temp_file_when_present( + tmp_path: Path, +) -> None: + values_content = "image:\n repository: example.invalid/coding-agent\n" + result = _run_script(tmp_path, VALUES_CONTENT=values_content) + + assert result.returncode == 0, result.stderr + calls = (tmp_path / "calls.log").read_text(encoding="utf-8") + helm_call = next(line for line in calls.splitlines() if "helm upgrade" in line) + parts = helm_call.split() + values_path = Path(parts[parts.index("--values") + 1]) + assert values_path.read_text(encoding="utf-8") == values_content + assert "coding-agent/helm/values-example.yaml" not in helm_call + + def test_woodpecker_builds_deploy_tools_image_used_by_manual_deploy() -> None: ci = (ROOT / ".woodpecker" / "ci.yml").read_text(encoding="utf-8") deploy = (ROOT / ".woodpecker" / "deploy.yml").read_text(encoding="utf-8") diff --git a/coding-agent/tests/deploy/test_helm_chart.py b/coding-agent/tests/deploy/test_helm_chart.py index 4cb8c5115..9c6356e1f 100644 --- a/coding-agent/tests/deploy/test_helm_chart.py +++ b/coding-agent/tests/deploy/test_helm_chart.py @@ -131,7 +131,7 @@ def test_helm_chart_lints() -> None: None, "values-orbstack.yaml", "values-orbstack-kimi.yaml", - "values-o6n-deepseek.yaml", + "values-example.yaml", ], ) def test_helm_values_render(values_file: str | None) -> None: @@ -494,8 +494,8 @@ def test_helm_agent_base_url_override_is_rendered() -> None: assert _agent_toml(docs)["agent"]["base_url"] == "https://llm-proxy.example/v1" -def test_helm_o6n_deepseek_values_preserve_live_runtime_contract() -> None: - docs = _render("-f", str(CHART / "values-o6n-deepseek.yaml")) +def test_helm_example_values_preserve_template_runtime_contract() -> None: + docs = _render("-f", str(CHART / "values-example.yaml")) service = _service(docs) assert service["spec"]["type"] == "NodePort" @@ -504,14 +504,14 @@ def test_helm_o6n_deepseek_values_preserve_live_runtime_contract() -> None: "port": 8080, "targetPort": "http", "protocol": "TCP", - "nodePort": 30082, + "nodePort": 30080, } main = _container(docs) - assert main["image"] == "git.mesh.kinaz.me/kina/coding-agent:main" + assert main["image"] == "//coding-agent:main" assert main["envFrom"] == [ - {"secretRef": {"name": "coding-agent-deepseek"}}, - {"secretRef": {"name": "coding-agent-langfuse"}}, + {"secretRef": {"name": ""}}, + {"secretRef": {"name": ""}}, ] assert _env_var(main, "CODING_AGENT_API_KEY")["valueFrom"]["secretKeyRef"] == { "name": "coding-agent-coding-agent-api-key", @@ -519,37 +519,38 @@ def test_helm_o6n_deepseek_values_preserve_live_runtime_contract() -> None: } config = _agent_toml(docs) - assert config["agent"]["provider"] == "deepseek" - assert config["agent"]["model"] == "deepseek-chat" + assert config["agent"]["provider"] == "openai" + assert config["agent"]["model"] == "gpt-4.1" assert config["server"]["bearer_token_env"] == "CODING_AGENT_API_KEY" assert config["observability"] == { "enabled": True, "backend": "langfuse", - "endpoint_env": "LANGFUSE_BASE_URL", + "endpoint_env": "", "timeout_seconds": 2, - "public_key_env": "LANGFUSE_PUBLIC_KEY", - "secret_key_env": "LANGFUSE_SECRET_KEY", + "public_key_env": "", + "secret_key_env": "", } policy = _network_policy(docs) assert policy["spec"]["egress"][-1] == { - "to": [{"ipBlock": {"cidr": "100.123.220.136/32"}}], + "to": [{"ipBlock": {"cidr": "/32"}}], "ports": [{"protocol": "TCP", "port": 443}], } -def test_helm_o6n_deepseek_config_bootstraps_runtime( +def test_helm_example_config_bootstraps_runtime( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - docs = _render("-f", str(CHART / "values-o6n-deepseek.yaml")) + docs = _render("-f", str(CHART / "values-example.yaml")) config_path = tmp_path / "agent.toml" config_path.write_text(_agent_toml_text(docs), encoding="utf-8") monkeypatch.setenv( - "LANGFUSE_BASE_URL", "https://langfuse.example.test/api/public/otel" + "", + "https://observability.example.test/api/public/otel", ) - monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-test") - monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-test") + monkeypatch.setenv("", "pk-test") + monkeypatch.setenv("", "sk-test") pipeline, _ = create_agent( config_path=config_path, diff --git a/scripts/deploy-coding-agent-k8s.sh b/scripts/deploy-coding-agent-k8s.sh index c3472d74d..e51c0a31e 100755 --- a/scripts/deploy-coding-agent-k8s.sh +++ b/scripts/deploy-coding-agent-k8s.sh @@ -1,9 +1,9 @@ #!/usr/bin/env sh set -eu -IMAGE_REPOSITORY="${IMAGE_REPOSITORY:-git.mesh.kinaz.me/kina/coding-agent}" +IMAGE_REPOSITORY="${IMAGE_REPOSITORY:-//coding-agent}" IMAGE_TAG="${IMAGE_TAG:-${CI_COMMIT_SHA:-}}" -K8S_NAMESPACE="${K8S_NAMESPACE:-coding-agent-deepseek}" +K8S_NAMESPACE="${K8S_NAMESPACE:-coding-agent}" K8S_DEPLOYMENT="${K8S_DEPLOYMENT:-coding-agent-coding-agent}" K8S_CONTAINER="${K8S_CONTAINER:-coding-agent}" K8S_SERVICE="${K8S_SERVICE:-$K8S_DEPLOYMENT}" @@ -11,7 +11,7 @@ ROLLOUT_TIMEOUT="${ROLLOUT_TIMEOUT:-180s}" ENABLE_POD_HEALTH_SMOKE="${ENABLE_POD_HEALTH_SMOKE:-1}" HELM_RELEASE="${HELM_RELEASE:-coding-agent}" HELM_CHART_DIR="${HELM_CHART_DIR:-coding-agent/helm}" -HELM_VALUES="${HELM_VALUES:-coding-agent/helm/values-o6n-deepseek.yaml}" +HELM_VALUES="${HELM_VALUES:-coding-agent/helm/values-example.yaml}" HELM_DEPLOY_MODE="${HELM_DEPLOY_MODE:-dry-run}" HELM_DRIVER="${HELM_DRIVER:-configmap}" @@ -32,6 +32,13 @@ if [ -n "${KUBECONFIG_CONTENT:-}" ]; then export KUBECONFIG="$kubeconfig_file" fi +helm_values_file="$HELM_VALUES" +if [ -n "${VALUES_CONTENT:-}" ]; then + helm_values_file="$(mktemp)" + umask 077 + printf '%s' "$VALUES_CONTENT" > "$helm_values_file" +fi + export HELM_DRIVER if ! command -v helm >/dev/null 2>&1; then @@ -42,7 +49,7 @@ fi set -- \ upgrade --install "$HELM_RELEASE" "$HELM_CHART_DIR" \ --namespace "$K8S_NAMESPACE" \ - --values "$HELM_VALUES" \ + --values "$helm_values_file" \ --set "image.repository=${IMAGE_REPOSITORY}" \ --set "image.tag=${IMAGE_TAG}" \ --wait \ From 584e10c05ff8e60284ac6ce9664fad642b9fe0b5 Mon Sep 17 00:00:00 2001 From: kina0630 Date: Sun, 14 Jun 2026 20:08:41 +0800 Subject: [PATCH 2/2] fix(deploy): fail closed in apply mode on placeholder config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found apply mode would silently fall through to the genericized placeholders ( image, example values) if the Woodpecker secrets were unset/empty — asymmetric with the existing IMAGE_TAG hard-fail. Reject apply when IMAGE_REPOSITORY is still a placeholder or no real values source is provided; dry-run intentionally still tolerates the placeholders. Co-Authored-By: Claude Fable 5 --- .../tests/deploy/test_deploy_script.py | 32 ++++++++++++++++++- scripts/deploy-coding-agent-k8s.sh | 17 ++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/coding-agent/tests/deploy/test_deploy_script.py b/coding-agent/tests/deploy/test_deploy_script.py index f559424fe..3a4a44efd 100644 --- a/coding-agent/tests/deploy/test_deploy_script.py +++ b/coding-agent/tests/deploy/test_deploy_script.py @@ -74,7 +74,12 @@ def test_deploy_script_defaults_to_helm_dry_run_without_kubectl_mutation( def test_deploy_script_apply_mode_waits_for_rollout_and_smokes_service( tmp_path: Path, ) -> None: - result = _run_script(tmp_path, HELM_DEPLOY_MODE="apply") + result = _run_script( + tmp_path, + HELM_DEPLOY_MODE="apply", + IMAGE_REPOSITORY="example.invalid/coding-agent", + VALUES_CONTENT="image:\n repository: example.invalid/coding-agent\n", + ) assert result.returncode == 0, result.stderr calls = (tmp_path / "calls.log").read_text(encoding="utf-8") @@ -88,6 +93,31 @@ def test_deploy_script_apply_mode_waits_for_rollout_and_smokes_service( ) +def test_deploy_script_apply_mode_rejects_placeholder_image_repository( + tmp_path: Path, +) -> None: + # Guard: apply must not run against the genericized placeholder defaults. + result = _run_script(tmp_path, HELM_DEPLOY_MODE="apply") + + assert result.returncode == 2 + assert "apply requires a real IMAGE_REPOSITORY" in result.stderr + assert not (tmp_path / "calls.log").exists() + + +def test_deploy_script_apply_mode_rejects_example_values_without_content( + tmp_path: Path, +) -> None: + result = _run_script( + tmp_path, + HELM_DEPLOY_MODE="apply", + IMAGE_REPOSITORY="example.invalid/coding-agent", + ) + + assert result.returncode == 2 + assert "apply requires real values" in result.stderr + assert not (tmp_path / "calls.log").exists() + + def test_deploy_script_uses_values_content_temp_file_when_present( tmp_path: Path, ) -> None: diff --git a/scripts/deploy-coding-agent-k8s.sh b/scripts/deploy-coding-agent-k8s.sh index e51c0a31e..fa4902505 100755 --- a/scripts/deploy-coding-agent-k8s.sh +++ b/scripts/deploy-coding-agent-k8s.sh @@ -25,6 +25,23 @@ if [ "$HELM_DEPLOY_MODE" != "dry-run" ] && [ "$HELM_DEPLOY_MODE" != "apply" ]; t exit 2 fi +# Fail closed before mutating a cluster: apply must use real deploy values, +# not the genericized placeholders. dry-run intentionally tolerates them. +if [ "$HELM_DEPLOY_MODE" = "apply" ]; then + case "$IMAGE_REPOSITORY" in + *"<"*) + printf '%s\n' "apply requires a real IMAGE_REPOSITORY (got placeholder)" >&2 + exit 2 + ;; + esac + if [ -z "${VALUES_CONTENT:-}" ] \ + && [ "$HELM_VALUES" = "coding-agent/helm/values-example.yaml" ]; then + printf '%s\n' \ + "apply requires real values: set VALUES_CONTENT or HELM_VALUES" >&2 + exit 2 + fi +fi + if [ -n "${KUBECONFIG_CONTENT:-}" ]; then kubeconfig_file="$(mktemp)" umask 077