StageSet Controller

stageset-controller is a Flux controller for ordered, gated, multi-stage delivery — roll a release out one stage at a time, prove each stage healthy before the next begins, and gate the whole thing on schedules, approvals, and error budgets. It is GitOps-native: every stage reads a Flux source, and the whole release is reconciled continuously, drift-corrected, and pruned.

Flux’s kustomize-controller and helm-controller apply an artifact in one shot. That fits most releases, but not one that has to happen in sequence: install the CRDs before the operator that needs them, run a database migration before the app that reads the new schema, hold a production rollout until the canary is healthy, freeze changes during business hours.

A StageSet describes a release as an ordered list of stages. Each stage applies a Flux source — a GitRepository, OCIRepository, Bucket, or an ExternalArtifact (including one rendered on the fly by a producer like JaaS ) — waits for it to become healthy, and only then lets the next stage begin. Between stages, run typed actions (a migration Job, an HTTP gate, a wait-for-condition), gate rollouts behind update windows , and run version-aware migrations when you cross a release boundary. Everything is reconciled continuously, drift-corrected, and pruned from a precise per-stage inventory.

What you can do with it

Sequence and gate every step

Roll out safely

Source-agnostic and Flux-native

Built for platform teams

What a StageSet looks like

A single StageSet can express an entire gated release — ordered stages, a producer-rendered source, typed actions, promotion gates, versioned migrations, an error-budget freeze, and rollback — all declaratively:

apiVersion: stages.metio.wtf/v1
kind: StageSet
metadata:
  name: payments
  namespace: payments
spec:
  serviceAccountName: payments-deployer    # every apply runs as this tenant SA

  decryption:                              # decrypt SOPS-encrypted Secrets in-line
    provider: sops
    secretRef:
      name: sops-age

  rollbackOnFailure: true                  # restore the last-good revision on failure

  # track the deployed version from the app's own label, and run a one-off
  # migration when a rollout crosses the 2.0.0 boundary
  version:
    fromObject:
      stage: application
      kind: Deployment
      name: payments-api
  migrations:
    - name: ledger-schema-2-0
      from: ">=1.0.0, <2.0.0"              # current version this applies to
      to: "2.0.0"                          # the exact boundary it crosses
      stage: application
      actions:
        - name: migrate-ledger
          job:
            sourceRef:
              name: payments-migrations

  # new revisions roll out only outside the Friday-evening freeze ...
  updateWindows:
    - type: Deny
      schedule: "0 17 * * FRI"
      duration: 60h
      timeZone: Europe/Berlin

  # ... and only while the service is within its SLO error budget
  errorBudget:
    source:
      prometheus:
        address: http://prometheus.monitoring:9090
        query: slo:period_error_budget_remaining:ratio{sloth_service="payments"}
    freezeThreshold: "0"                    # freeze once the budget is overspent
    resumeThreshold: "0.05"                 # resume only after it recovers

  stages:
    # 1 ── shared infrastructure: CRDs, namespaces, RBAC
    - name: infrastructure
      sourceRef:
        name: payments-infra               # an ExternalArtifact (the default kind)
      readyChecks:
        checks:                            # gate on the CRD being Established
          - apiVersion: apiextensions.k8s.io/v1
            kind: CustomResourceDefinition
            name: ledgers.payments.example

    # 2 ── the application, rendered on the fly from Jsonnet by JaaS
    - name: application
      sourceRef:
        kind: JsonnetSnippet               # a producer, resolved to its ExternalArtifact
        apiVersion: jaas.metio.wtf/v1
        name: payments-api
      actions:
        pre:
          - name: db-migrate               # runs before the manifests are applied
            job:
              sourceRef:
                name: payments-migrations
        post:
          - name: smoke-test               # stage is Ready only if this passes
            http:
              url: https://payments.internal/healthz
              expectedStatus: [200]
      promotion:
        soak: 15m                          # advance only if it stays healthy ...
        analysis:                          # ... and the 5xx rate stays low
          checks:
            - name: error-rate
              source:
                prometheus:
                  address: http://prometheus.monitoring:9090
                  query: sum(rate(http_requests_total{app="payments",code=~"5.."}[1m]))/sum(rate(http_requests_total{app="payments"}[1m]))
              threshold:
                max: "0.01"                # ≤ 1% 5xx

    # 3 ── production, promoted only after a human confirms
    - name: prod
      sourceRef:
        name: payments-prod
      promotion:
        requireManualPromotion: true

Stages run top to bottom: infrastructure must be Ready before application is touched, the migration runs as the version crosses 2.0.0, application soaks and is checked against its error rate before prod begins, and prod waits for a manual promotion. New revisions roll out only outside the Friday freeze and while the SLO is in budget, and a failed run is rolled back — all reconciled continuously, with drift corrected and removed objects pruned. Only spec.stages is required; everything else is optional, so the Quickstart starts from a single stage.

Where to go next

stageset-controller handles the delivery end and composes with two adjacent projects, each useful on its own:

JOI and JaaS are not required — a stage reads straight from a GitRepository, OCIRepository, or Bucket, or from any ExternalArtifact, whatever produced it.