Promote an app to production¶
Request promotion of a sandbox app: the platform raises a PR with the evidence record attached, and a human approver merges it. Promotion is the only path to production — there is no direct deploy.
Preconditions¶
- A sandbox app whose App CR carries
spec.repoRef.url. This is what the flow actually reads: a bare App CR created by hand has no per-app repo, and the promote call fails with HTTP 502 before any PR is raised. The builder path sets it. - All four governance fields decided:
dataScopes,owner,expiry, andpurpose. Purpose is never defaulted — a request without it gets HTTP 422 and nothing happens. - For the approval half: membership of the
platform-approversKeycloak group (the platform-approver group).
Steps¶
1. Decide the purpose — honestly¶
purpose.decisionsAboutPeople drives the AI Act triage marker on the
estate and in the evidence record. Set it truthfully; Tend surfaces the
flag, it never adjudicates conformance.
2. Send the promotion request¶
The operator's promote API is the single entry point (make promote is the
quickstart wrapper for it, currently still a stub — call the API directly):
kubectl -n tend-system port-forward deploy/tend-operator 8082:8082 &
curl -s -X POST \
"http://localhost:8082/apps/expense-helper/promote?namespace=tend-system" \
-H "Content-Type: application/json" \
-d '{
"dataScopes": [
{"name":"synthetic","sourceType":"synthetic","access":"readwrite"}
],
"owner": "alice",
"expiry": "2026-12-31T00:00:00Z",
"purpose": {"category":"internal-tool","decisionsAboutPeople":true}
}'
An incomplete request is refused before any git work — no PR, state unchanged:
A complete request returns 202 with the platform-raised PR:
{"app":"expense-helper","namespace":"tend-system","state":"promotion-pending",
"prUrl":"http://gitea…/pulls/1","snapshotRef":"<commit>","decision":"pending"}
3. Review and decide (approver)¶
Open status.promotion.prUrl in Gitea. The PR carries the evidence record
(evidence.json) and requests the platform-approver group as reviewer.
The operator does not watch the pull request
Merging or closing the PR does not move status.state on its own. The
operator arms only a push webhook and has no PR-status client, so recording
the decision back onto the App CR is a separate, explicit step today. Reporting
the outcome is what moves the state:
# approve
kubectl annotate app expense-helper -n tend-system --overwrite \
tendtech.io/promotion-decision=approved \
tendtech.io/promotion-approver=bob
# reject
kubectl annotate app expense-helper -n tend-system --overwrite \
tendtech.io/promotion-decision=rejected \
tendtech.io/promotion-reason="scopes too broad"
The operator lifts that annotation into status.promotion and moves the state.
Closing the loop automatically — so that the merge is the approval, as
ADR-0004 intends — is open work.
Verify it worked¶
kubectl get app expense-helper -n tend-system \
-o jsonpath='{.status.state}'
# promotion-pending → production (once the decision is reported)
kubectl get app expense-helper -n tend-system -o \
jsonpath='{.status.promotion.decision} {.status.promotion.approver}'
# approved <approver> (status.promotion.decidedAt carries the timestamp)
The decision also appears in the evidence record under promotions
(Query an app's evidence).
Snapshot semantics¶
The PR is opened from the sandbox branch HEAD at promote time, fixed as
status.promotion.snapshotRef. Sandbox iteration continues unaffected —
later commits do not move the open PR. What the approver reviews is exactly
what was promoted.
Production rollout execution is deferred — fail closed
Today, approval flips status.state to production and records the
decision; the deploy-execution last mile (pre-creating the labelled
production namespace, writing the per-app production Argo CD
Application, and syncing it — task T035b) is deferred. The path is
fail-closed: the production ApplicationSet sets CreateNamespace=false,
so nothing can roll out into an ungoverned namespace in the meantime.
See App lifecycle for why promotion is a PR,
and the App CRD reference for the
status.promotion fields.