4444 options :
4545 - recreate
4646 - restart
47+ tenant_secrets_json :
48+ description : >-
49+ JSON object of per-tenant secrets to inject as env vars before the
50+ CLI runs, e.g. {"OPENAI_API_KEY":"sk-...","LLMTRACE_UPSTREAM_URL":"https://..."}.
51+ The CLI's ${VAR} substitution resolves these into the tenant config.
52+ Values are masked in step logs via ::add-mask::, but workflow_dispatch
53+ input VALUES are visible in the run UI to anyone with actions:read.
54+ For production multi-tenant use, trigger via repository_dispatch with
55+ client_payload (event_type: tenant-lifecycle) — those payloads are
56+ NOT shown in the run UI. See README for the migration path.
57+ required : false
58+ type : string
59+ default : " {}"
60+
61+ # Production trigger: client_payload is not visible in the run UI.
62+ # POST /repos/{owner}/{repo}/dispatches with:
63+ # {"event_type": "tenant-lifecycle",
64+ # "client_payload": {"tenant_id": "...", "action": "provision",
65+ # "config_json": "{...}",
66+ # "tenant_secrets": {"OPENAI_API_KEY": "sk-..."}}}
67+ repository_dispatch :
68+ types :
69+ - tenant-lifecycle
4770
4871permissions :
4972 contents : read
@@ -73,76 +96,161 @@ jobs:
7396 python3 -m pip install --upgrade pip
7497 python3 -m pip install basilica-sdk PyYAML
7598
76- - name : Validate inputs
99+ - name : Resolve inputs (workflow_dispatch or repository_dispatch)
77100 env :
78- ACTION : ${{ inputs.action }}
79- CONFIG_PATH : ${{ inputs.config_path }}
80- CONFIG_JSON : ${{ inputs.config_json }}
81- PROXY_ID : ${{ inputs.proxy_instance_id }}
82- DASHBOARD_ID : ${{ inputs.dashboard_instance_id }}
101+ EVENT_NAME : ${{ github.event_name }}
102+ WF_TENANT_ID : ${{ inputs.tenant_id }}
103+ WF_ACTION : ${{ inputs.action }}
104+ WF_CONFIG_PATH : ${{ inputs.config_path }}
105+ WF_CONFIG_JSON : ${{ inputs.config_json }}
106+ WF_PROXY_ID : ${{ inputs.proxy_instance_id }}
107+ WF_DASHBOARD_ID : ${{ inputs.dashboard_instance_id }}
108+ WF_STRATEGY : ${{ inputs.strategy }}
109+ WF_SECRETS : ${{ inputs.tenant_secrets_json }}
110+ RD_PAYLOAD : ${{ toJson(github.event.client_payload) }}
111+ run : |
112+ set -euo pipefail
113+ python3 - <<'PY'
114+ import json, os, pathlib
115+ event = os.environ.get("EVENT_NAME", "")
116+ if event == "workflow_dispatch":
117+ tenant_id = os.environ.get("WF_TENANT_ID", "")
118+ action = os.environ.get("WF_ACTION", "")
119+ cfg_path = os.environ.get("WF_CONFIG_PATH", "")
120+ cfg_json = os.environ.get("WF_CONFIG_JSON", "")
121+ proxy_id = os.environ.get("WF_PROXY_ID", "")
122+ dash_id = os.environ.get("WF_DASHBOARD_ID", "")
123+ strategy = os.environ.get("WF_STRATEGY", "")
124+ secrets_json = os.environ.get("WF_SECRETS", "{}") or "{}"
125+ try:
126+ secrets = json.loads(secrets_json)
127+ except json.JSONDecodeError as exc:
128+ raise SystemExit(f"::error::tenant_secrets_json is invalid JSON: {exc}")
129+ elif event == "repository_dispatch":
130+ payload = json.loads(os.environ.get("RD_PAYLOAD", "{}") or "{}")
131+ tenant_id = payload.get("tenant_id", "")
132+ action = payload.get("action", "")
133+ cfg_path = payload.get("config_path", "")
134+ cfg_json = payload.get("config_json", "")
135+ if cfg_json and not isinstance(cfg_json, str):
136+ cfg_json = json.dumps(cfg_json)
137+ proxy_id = payload.get("proxy_instance_id", "")
138+ dash_id = payload.get("dashboard_instance_id", "")
139+ strategy = payload.get("strategy", "recreate")
140+ secrets = payload.get("tenant_secrets", {}) or {}
141+ else:
142+ raise SystemExit(f"::error::unsupported event {event!r}")
143+
144+ if not isinstance(secrets, dict):
145+ raise SystemExit("::error::tenant_secrets must be a JSON object")
146+
147+ env_path = pathlib.Path(os.environ["GITHUB_ENV"])
148+ with env_path.open("a") as fh:
149+ fh.write(f"TENANT_ID={tenant_id}\n")
150+ fh.write(f"ACTION={action}\n")
151+ fh.write(f"CFG_PATH={cfg_path}\n")
152+ fh.write(f"PROXY_ID={proxy_id}\n")
153+ fh.write(f"DASHBOARD_ID={dash_id}\n")
154+ fh.write(f"STRATEGY={strategy}\n")
155+ # config_json may contain newlines; use heredoc form
156+ if cfg_json:
157+ fh.write(f"CFG_JSON<<__LIFECYCLE_EOF__\n{cfg_json}\n__LIFECYCLE_EOF__\n")
158+
159+ pathlib.Path("/tmp/tenant_secrets.json").write_text(json.dumps(secrets))
160+ print(f"resolved: tenant_id={tenant_id} action={action} secret_keys={sorted(secrets.keys())}")
161+ PY
162+
163+ - name : Inject tenant secrets
164+ env :
165+ SECRETS_PATH : /tmp/tenant_secrets.json
166+ run : |
167+ set -euo pipefail
168+ python3 - <<'PY'
169+ import json, os, pathlib, secrets as _secrets
170+ data = json.loads(pathlib.Path(os.environ["SECRETS_PATH"]).read_text() or "{}")
171+ if not data:
172+ print("no tenant secrets to inject")
173+ raise SystemExit(0)
174+ env_path = pathlib.Path(os.environ["GITHUB_ENV"])
175+ with env_path.open("a") as fh:
176+ for key, value in data.items():
177+ if not isinstance(key, str) or not key:
178+ raise SystemExit(f"::error::secret key must be a non-empty string, got {key!r}")
179+ value = "" if value is None else str(value)
180+ # Mask FIRST so any later log line containing the value is redacted.
181+ print(f"::add-mask::{value}")
182+ # Random per-value delimiter — paranoid guard against a secret
183+ # that happens to contain a static EOF token.
184+ delim = f"__SEC_{_secrets.token_hex(8)}__"
185+ if delim in value:
186+ raise SystemExit(f"::error::generated delimiter collided with secret value for {key}")
187+ fh.write(f"{key}<<{delim}\n{value}\n{delim}\n")
188+ print(f"injected {key} ({len(value)} chars)")
189+ PY
190+
191+ - name : Validate inputs
83192 run : |
84193 set -euo pipefail
85194 if [[ -z "${BASILICA_API_TOKEN:-}" ]]; then
86195 echo "::error::BASILICA_API_TOKEN secret is not configured"
87196 exit 2
88197 fi
89- case "${ACTION}" in
198+ if [[ -z "${TENANT_ID:-}" ]]; then
199+ echo "::error::tenant_id is required"
200+ exit 2
201+ fi
202+ case "${ACTION:-}" in
90203 provision)
91- if [[ -z "${CONFIG_PATH }" && -z "${CONFIG_JSON }" ]]; then
204+ if [[ -z "${CFG_PATH:- }" && -z "${CFG_JSON:- }" ]]; then
92205 echo "::error::action=provision requires config_path or config_json"
93206 exit 2
94207 fi
95208 ;;
96209 update)
97- if [[ -z "${CONFIG_PATH }" && -z "${CONFIG_JSON }" ]]; then
210+ if [[ -z "${CFG_PATH:- }" && -z "${CFG_JSON:- }" ]]; then
98211 echo "::error::action=update requires config_path or config_json"
99212 exit 2
100213 fi
101- if [[ -z "${PROXY_ID}" || -z "${DASHBOARD_ID}" ]]; then
214+ if [[ -z "${PROXY_ID:- }" || -z "${DASHBOARD_ID:- }" ]]; then
102215 echo "::error::action=update requires proxy_instance_id and dashboard_instance_id"
103216 exit 2
104217 fi
105218 ;;
219+ status|deprovision)
220+ :
221+ ;;
222+ *)
223+ echo "::error::unknown or missing action: ${ACTION:-<empty>}"
224+ exit 2
225+ ;;
106226 esac
107227
108228 - name : Run lifecycle action
109229 id : run
110- env :
111- TENANT_ID : ${{ inputs.tenant_id }}
112- ACTION : ${{ inputs.action }}
113- CONFIG_PATH : ${{ inputs.config_path }}
114- CONFIG_JSON : ${{ inputs.config_json }}
115- PROXY_ID : ${{ inputs.proxy_instance_id }}
116- DASHBOARD_ID : ${{ inputs.dashboard_instance_id }}
117- STRATEGY : ${{ inputs.strategy }}
118230 run : |
119231 set -euo pipefail
120232 args=("--tenant-id" "${TENANT_ID}")
121233 case "${ACTION}" in
122234 provision)
123- if [[ -n "${CONFIG_JSON }" ]]; then
124- args+=("--config-json" "${CONFIG_JSON }")
235+ if [[ -n "${CFG_JSON:- }" ]]; then
236+ args+=("--config-json" "${CFG_JSON }")
125237 else
126- args+=("--config" "${CONFIG_PATH }")
238+ args+=("--config" "${CFG_PATH }")
127239 fi
128240 ;;
129241 update)
130242 args+=("--strategy" "${STRATEGY}")
131243 args+=("--proxy-instance-id" "${PROXY_ID}")
132244 args+=("--dashboard-instance-id" "${DASHBOARD_ID}")
133- if [[ -n "${CONFIG_JSON }" ]]; then
134- args+=("--config-json" "${CONFIG_JSON }")
245+ if [[ -n "${CFG_JSON:- }" ]]; then
246+ args+=("--config-json" "${CFG_JSON }")
135247 else
136- args+=("--config" "${CONFIG_PATH }")
248+ args+=("--config" "${CFG_PATH }")
137249 fi
138250 ;;
139251 status|deprovision)
140- [[ -n "${PROXY_ID}" ]] && args+=("--proxy-instance-id" "${PROXY_ID}")
141- [[ -n "${DASHBOARD_ID}" ]] && args+=("--dashboard-instance-id" "${DASHBOARD_ID}")
142- ;;
143- *)
144- echo "::error::unknown action ${ACTION}"
145- exit 2
252+ [[ -n "${PROXY_ID:-}" ]] && args+=("--proxy-instance-id" "${PROXY_ID}")
253+ [[ -n "${DASHBOARD_ID:-}" ]] && args+=("--dashboard-instance-id" "${DASHBOARD_ID}")
146254 ;;
147255 esac
148256
@@ -166,9 +274,6 @@ jobs:
166274
167275 - name : Write step summary
168276 if : always()
169- env :
170- ACTION : ${{ inputs.action }}
171- TENANT_ID : ${{ inputs.tenant_id }}
172277 run : |
173278 set -euo pipefail
174279 {
0 commit comments