Skip to content

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, and purpose. Purpose is never defaulted — a request without it gets HTTP 422 and nothing happens.
  • For the approval half: membership of the platform-approvers Keycloak 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:

{"error":"missing required promotion fields","missingFields":["purpose"]}

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.