AGPL-3.0-or-later · forever.

showcase · tier 4 · research

Release train

Tests, lint and audit run in parallel. Only if all three are green does a human sign the GO; then it holds for the window, ships, and verifies prod reports the new version.

No green gates, no departure. No human GO, no departure. It ships on time or not at all, and the journal records which. · conformance-gated in nika-spec ↗ · re-proven at every push

  • 16tasksthe plan falls out of the bindings
  • 13wavesparallel by construction
  • 2verbs exercisedinvoke · exec
  • T4the tierresearch

The whole file

16 tasks · 13 waves

The plan falls out of the bindings, nothing is scheduled by hand.

release-train.nika.yamlsource
nika: v1workflow:  id: release-train  description: "parallel gates → one verdict → human GO → hold until the window → ship · verify · record"run:  # Pin the clock out loud. Every `timeout:` and every absolute `until:` below  # rides it; leaving it undeclared is the honest default (the ambient system  # clock) but never a stated choice, and the checker says so ([run-clock]).  # `clock: virtual` swaps in a simulated clock for tests (F-P3).  clock: systeminputs:  version:    type: string    required: true    description: "The version you intend to ship · gate 1 checks it against ./VERSION"  depart:    type: bool    default: false    description: "Actually run the release script. OFF: the train rehearses and records that it stayed."  hold_for:    type: string    default: "15m"    description: "How far out the departure window sits, measured from the moment the departure is SIGNED"permits:  exec: ["./scripts/release.sh"]  # ONE program · the departure, and nothing else  tools: ["nika:assert", "nika:date", "nika:emit", "nika:fetch", "nika:jq", "nika:prompt", "nika:read", "nika:wait"]  net:    # ONE host, exact — the post-ship version probe and nothing else. This train    # deliberately owns no webhook: see the `record` task for why an    # always-fires task must never be the one that leaves the machine.    http: ["api.example.com"]  fs:    # Three files, read-only, named one by one. The gates below are the release    # checks a spec repo can actually run; in a Rust shop they are `cargo test`    # / `clippy` / `audit` under `exec:` with `capture: structured`, and the    # `permits.exec` list grows instead. The WAVE and the fold are the lesson —    # the identity of the three checks is yours to choose.    read:      - "./VERSION"      - "./CHANGELOG.md"      - "./schemas/workflow.schema.json"tasks:  t0:    invoke:      tool: "nika:date"      args: { op: now }  # ── the gate wave · three checks, one wave, no shared state ──  declared:    invoke:      tool: "nika:read"      args: { path: "./VERSION" }  notes:    invoke:      tool: "nika:read"      args: { path: "./CHANGELOG.md" }  schema:    invoke:      tool: "nika:read"      args: { path: "./schemas/workflow.schema.json" }  # ── fold · three answers become one board, computed, not narrated ──  board:    with:      declared: ${{ tasks.declared.output }}      notes: ${{ tasks.notes.output }}      schema: ${{ tasks.schema.output }}    invoke:      tool: "nika:jq"      args:        # The four inputs arrive as an ARRAY and are destructured by name, so        # the expression reads like the checklist it is. Building the verdict in        # jq rather than in a model keeps it deterministic and free.        input:          - "${{ with.declared }}"          - "${{ with.notes }}"          - "${{ with.schema }}"          - "${{ inputs.version }}"        expression: |          . as [$declared, $notes, $schema, $asked]          | { version_matches: (($declared | rtrimstr("\n")) == $asked),              notes_present:   ($notes  | test("\\[" + $asked + "\\]")),              schema_parses:   (try ($schema | fromjson | has("$id")) catch false) }          | . + { all_green: (.version_matches and .notes_present and .schema_parses) }  gates_green:    with:      board: ${{ tasks.board.output }}    invoke:      tool: "nika:assert"      args:        # A RED board stops the train here: everything downstream is cancelled,        # and `record` below still lands the fact that it never left.        condition: "${{ with.board.all_green }}"        message: "A release gate is RED: the train does not depart · ${{ with.board }}"  t1:    after:      gates_green: success           # state, no data · the clock stops on a green board    invoke:      tool: "nika:date"      args: { op: now }  gate_time:    with:      t0: ${{ tasks.t0.output }}      t1: ${{ tasks.t1.output }}    invoke:      tool: "nika:date"      args:        # `start:`/`end:` want real ISO timestamps — the literal "now" is not a        # value here and fails NIKA-BUILTIN-DATE-001. Take the reading in a task,        # then pass it as data; that is also what makes the run replayable.        op: diff        start: "${{ with.t0 }}"        end: "${{ with.t1 }}"        unit: minutes  # ── the human signs the departure ──  conductor:    with:      gate_time: ${{ tasks.gate_time.output }}      board: ${{ tasks.board.output }}    invoke:      # Blocking · no `default:`. The alternative shape is a `default: false`      # prompt (see templates/human-gated-ship): fail-closed, never pauses, and      # a yes can then only come from a TTY. This file blocks instead so the      # decision can also be handed over — `--resume <trace> --answer      # conductor=true` — which is how a release actually gets signed when the      # person who ran it is not the person who approves it. Either way the      # checker leaves a [headless-prompt] hint: that is the price of a gate.      tool: "nika:prompt"      args:        message: |          Board · ${{ with.board }}          Green in ${{ with.gate_time }} min.          Ship ${{ inputs.version }} · departure window opens in ${{ inputs.hold_for }}?          (depart armed: ${{ inputs.depart }})  # ── hold until the window · an absolute instant, computed AFTER the signature ──  #  # The window opens `hold_for` from the moment the departure was SIGNED, which  # is why this reading is taken here and not reused from `t1`. Measured, and  # the reason this task exists: the run pauses at the prompt, so `t1` is  # minutes or hours old by the time a human answers — resuming with `t1+ 15m`  # produced `NIKA-BUILTIN-WAIT-002 · until: … is in the past` on the very  # first resume. An absolute instant must be computed from a reading taken  # after the last thing that can block. (Resume it a SECOND time and this  # reading is itself a cache hit — `--from signed_at` re-takes it.)  signed_at:    with:      signed: ${{ tasks.conductor.output }}    when: ${{ with.signed == true }}    invoke:      tool: "nika:date"      args: { op: now }  window:    with:      at: ${{ tasks.signed_at.output }}    when: ${{ with.at != null }}    invoke:      tool: "nika:date"      args:        op: add        base: "${{ with.at }}"        duration: "${{ inputs.hold_for }}"  hold:    with:      window: ${{ tasks.window.output }}    when: ${{ with.window != null }}    invoke:      # `until:` is an INSTANT, not a nap: restart the run and it still targets      # the same moment, where a `duration:` would start counting again. The      # instant is computed from `t1` above precisely so this file cannot rot —      # a literal `until: "2026-07-28T09:00:00Z"` is correct for one day and      # then fails NIKA-BUILTIN-WAIT-002 · the timestamp is in the past.      tool: "nika:wait"      args:        until: "${{ with.window }}"        timeout: "48h"             # `timeout:` caps an absolute wait · until-only  # ── the irreversible step · armed, and only after the hold ──  ship:    after:      hold: success    with:      depart: ${{ inputs.depart }}    when: ${{ with.depart == true }}   # dry-run by default · a rehearsal ships nothing    exec:      # argv, never a shell string: `${{ inputs.version }}` is substituted as ONE      # argument and cannot break out into a second command.      #      # Default capture (stdout) on purpose. `capture: structured` would turn a      # non-zero exit into DATA and this task would report success even when the      # release script died — which is exactly what `after: {ship: success}`      # below must not be lied to about. The checker flags that shape when      # nothing reads `exit_code` ([swallowed-exit]); here the honest answer is      # not to read the code but to let a failed release FAIL, NIKA-EXEC-001.      command: ["./scripts/release.sh", "${{ inputs.version }}"]    timeout: "30m"  verify:    after:      ship: success                  # never probe prod for a release that did not go out    invoke:      tool: "nika:fetch"      args:        url: "https://api.example.com/v1/version"        mode: jq        jq: ".version"    retry:      # A GET is idempotent, so retrying it replays nothing — the checker warns      # [retry-effects] when you retry something that is not.      max_attempts: 5      backoff_strategy: exponential      backoff_ms: 5000  live:    with:      version_live: ${{ tasks.verify.output == inputs.version }}   # the whole check crosses as ONE boundary expression    invoke:      tool: "nika:assert"      args:        condition: "${{ with.version_live }}"        message: "Prod does not report the shipped version: investigate before announcing"  # ── the departure record · lands on EVERY outcome ──  #  # `after: {live: terminal}` admits success · failure · skipped · cancelled, so  # this runs when the train departed, when a gate was red, and when nobody  # armed it. `nika:emit` is the right verb for a record that must never fail:  # it crosses no boundary, needs no host, and cannot be refused mid-flight.  #  # It is also the only verb that can safely sit here, and that is a rule worth  # keeping: AN ALWAYS-FIRES TASK MUST NOT BE THE ONE THAT LEAVES THE MACHINE.  # A `terminal` edge admits `cancelled`, so the task is reachable along a path  # where the human never answered — no gate can dominate it, and the moment  # such a task can egress, NIKA-SEC-009 fires (measured: hanging a  # `nika:notify` here turns this file RED, and moving the prompt to the root  # does not save it). If you want the outcome on a webhook, put the send under  # the signature — `t4-ceo-monday-brief` shows that shape — and leave the  # unconditional record to `emit`.  record:    after:      live: terminal    with:      outcome: ${{ tasks.live.status }}   # observe the verdict · same pass-set as the after edge    invoke:      tool: "nika:emit"      args:        event_type: "release.train.departed"        payload:          version: "${{ inputs.version }}"          departed: "${{ inputs.depart }}"          # The engine's word, not a paraphrase: `cancelled` here means the          # verification never got to run, which is the truth on a rehearsal and          # on a red board alike. `departed:` above is what disambiguates them.          status: "${{ with.outcome }}"outputs:  board:    value: ${{ tasks.board.output }}    description: "The gate board · one boolean per check plus the verdict that stopped or released the train"  shipped: ${{ tasks.verify.output }}

nika inspect · engine 0.106.1 · vendored graph, never re-derived

17 declared edges · 13 waves · the plan falls out of the bindings