Automating releases with a service client
This guide shows how a pipeline or another machine caller drives Stella Ops releases without a human session: it registers an OAuth service client in Authority, obtains tokens with the client-credentials grant, and calls the Release Orchestrator API or the stella release commands. Policy gates, the scanner-pass floor and deployment preflight are enforced by the server for these callers exactly as for a person. The only override is a signed, audited gate exception that needs the release:gate-bypass scope and an operator-signed decision; a pipeline client should not hold that scope.
Implementation status. Everything below exists in the shipped source. Two limits matter for automation:
stella releasecovers approval decisions, promote, deploy, rollback and show. Creating a release, listing releases and marking a release ready are API calls only; the CLI has no command for them (src/Cli/StellaOps.Cli/Commands/ReleaseCommandGroup.cs).- There is no MCP server or other agent-specific interface. An automated agent uses the same OAuth client and HTTP API as any other caller.
1. What the caller needs
| Item | Value |
|---|---|
| Authority token endpoint | /connect/token on the installation’s gateway, which forwards /connect to Authority |
| Release API base | https://<stella-host>/api/v1/release-orchestrator |
| Grant type | client_credentials |
| Audience | api://release-orchestrator (Release Orchestrator also accepts stellaops) |
| Tenant | the tenant the releases belong to; sent as tenant on the token request and as the X-StellaOps-TenantId header on API calls |
Scopes, from StellaOpsScopes.cs, mapped to the Release Orchestrator policies that require them (ReleaseOrchestratorPolicies):
| Scope | Lets the caller |
|---|---|
release:read | list and read releases and their components (every release route requires it) |
release:write | create a release and mark it ready |
release:publish | promote, deploy and roll back a release, and approve or reject an approval |
Promote, deploy and roll back also succeed without release:publish when the caller holds Deployer access to both the release and the target environment through resource grants, or an accepted Operate & Deploy share grant for that release and environment. A pipeline client normally uses the scope.
Grant only what the pipeline does. A pipeline that only creates releases and asks for promotion needs release:read, release:write and release:publish; a read-only reporter needs release:read. There is no separate deploy scope.
2. Register a dedicated client
Use one confidential client per pipeline, so its secret can be rotated and its grants audited on their own. An administrator registers it through the Authority console admin API (POST /console/admin/clients). The call needs a fresh administrator session carrying ui.admin and authority:clients.write, and the X-StellaOps-TenantId header. An administrator can only register a client for their own tenant, with scopes their own token carries.
POST /console/admin/clients HTTP/1.1
Host: <stella-host>
Authorization: Bearer <fresh administrator token>
X-StellaOps-TenantId: <tenant>
Content-Type: application/json
{
"clientId": "ci-release-pipeline",
"displayName": "CI release pipeline",
"grantTypes": ["client_credentials"],
"scopes": ["release:read", "release:write", "release:publish"],
"tenant": "<tenant>",
"requireClientSecret": true,
"audiences": ["api://release-orchestrator"]
}
The answer is 201 Created with the client summary; 409 means the client id exists. Rotate the secret with POST /console/admin/clients/<clientId>/rotate (client secret rotation). Keep the secret in the pipeline’s secret store; never in a repository or a command line.
Installations seed a broad automation client, stellaops-cli-automation, for local and bootstrap use. Its scope list is far wider than a release pipeline needs and it serves two tenants, so production pipelines should use a dedicated client as above.
3. Obtain a token
Send a form-encoded (application/x-www-form-urlencoded) request to the token endpoint from section 1:
| Field | Value |
|---|---|
grant_type | client_credentials |
client_id | the client id, for example ci-release-pipeline |
client_secret | the secret, read from the pipeline’s secret store |
tenant | the tenant the client works in |
scope | the scopes the run needs, space-separated, for example release:read release:write release:publish |
The answer carries access_token, token_type (Bearer) and expires_in. The token’s stellaops:tenant claim names the selected tenant and its aud claim lists the client’s audiences.
Authority checks, in order (ClientCredentialsHandlers.cs): the client exists and is enabled, allows client_credentials, and presents its secret; the requested tenant is assigned to the client (otherwise invalid_request, “Requested tenant is not assigned to this client.”); and every requested scope is allowed (otherwise invalid_scope). A request without tenant uses the client’s default tenant; a client assigned to several tenants without a default must name one (“Tenant selection is required for this client.”). A request without scope receives every scope the client is allowed. Each refusal is 400 with an OAuth error body, for example:
{
"error": "invalid_scope",
"error_description": "Scope 'release:gate-bypass' is not allowed for this client."
}
4. Drive a release through the API
Every call carries Authorization: Bearer <token> and X-StellaOps-TenantId: <tenant>.
| Step | Request | Scope | Success | Refusals |
|---|---|---|---|---|
| Create | POST /api/v1/release-orchestrator/releases with name, version, optional targetEnvironment, components[] (each with name, imageRef/digest, tag, version) | release:write | 201, Location of the new release (Draft) | 409 same name and version exists |
| Read | GET /api/v1/release-orchestrator/releases/{id}; list with GET /api/v1/release-orchestrator/releases | release:read | 200 | 404 |
| Ready | POST /api/v1/release-orchestrator/releases/{id}/ready | release:write | 200 | 409 release_not_in_draft, release_has_no_components |
| Promote | POST /api/v1/release-orchestrator/releases/{id}/promote with targetEnvironment and optional justification | release:publish | 200 with the approval summary; an auto-approved promotion triggers its deployment | see below |
| Deploy | POST /api/v1/release-orchestrator/releases/{id}/deploy | release:publish | 202, Location: /api/v1/release-orchestrator/deployments/{id} | see below |
| Roll back | POST /api/v1/release-orchestrator/releases/{id}/rollback | release:publish | 200 with the release, status rolled_back | 404, 403 |
Pin components by digest. A release shows each component’s sha256 digest beside its version.
A rollback records the release as rolled_back and clears its current environment. It does not dispatch a deployment of an earlier release; promote and deploy that release to restore it.
5. How a gate refusal reaches the caller
Gates are evaluated by the server; the caller sees the outcome in the response body and decides whether to stop or wait. No field of these requests turns a gate off.
| Response | Body | Meaning |
|---|---|---|
403 | {"error":"forbidden","reason":"deploy_access_denied","releaseRole":...,"environmentRole":...,"requiredRole":"deployer",...} | promote or deploy without release:publish, where resource grants apply: the caller lacks Deployer on the release, the environment or both |
403 | {"error":"forbidden","message":"release:publish or an Operate & Deploy share grant is required."} | promote, deploy or rollback without release:publish or a share grant |
403 | {"error":"forbidden","message":"Authorization failed: missing required claim","requiredClaimType":"scope","requiredClaimValue":"release:write",...} | a route policy refused the token, for example create or ready without release:write |
409 | {"blocked":true,"reason":"target_environment_no_targets",...} | the target environment has no deployment targets |
409 | {"blocked":true,"reason":<release-truth code>,"message":...} | release truth (approval, gate decision or verified evidence) is missing; promote and deploy fail closed |
409 | {"blocked":true,"reason":<policy id>,"message":...,"approvalId":...,"policyId":...} | a policy gate denied the promotion |
409 | {"blocked":true,"waiting":true,"reason":...,"gateResults":[...],"approval":{...}} | a gate is waiting (for example on SBOM results); poll the release or the approval and retry |
403 | {"error":"blocked","floor":"vulnerability-scan","digest":...,"scanAction":...,"retryable":...} | the deploy-time scanner-pass floor refused a component |
403 | {"error":"blocked","reason":"preflight_failed","gate":"deploy-preflight","preflight":{...}} | deployment preflight failed |
A promotion that needs human approval returns 200 with a pending approval; a person, or a caller holding release:publish, approves it (stella release approve <approval-id>).
6. The same flow with stella release
The CLI authenticates with client credentials when no username or API key is configured (AuthorityTokenUtilities.cs):
export STELLAOPS_AUTHORITY_URL=https://<stella-host>
export STELLAOPS_AUTHORITY_CLIENT_ID=ci-release-pipeline
export STELLAOPS_AUTHORITY_CLIENT_SECRET="$(cat "$CLIENT_SECRET_FILE")"
export STELLAOPS_AUTHORITY_TENANT=<tenant>
export STELLAOPS_AUTHORITY_SCOPE="release:read release:write release:publish"
export STELLAOPS_RELEASE_ORCHESTRATOR_URL=https://<stella-host>
stella release show <release-id> --json
stella release promote <release-id> --to <environment> --reason "pipeline 1234"
stella release approve <approval-id> --comment "approved by pipeline policy"
stella release deploy <release-id>
stella release rollback <release-id>
promote sends --to as the target environment and --reason as the justification. deploy and rollback accept --reason, but the server request carries no reason field. Each command prints the server’s refusal (section 5) and exits non-zero. Signed operator decisions (--sign and its key options) are required only where the installation enables them (operator decision signing).
