Skip to main content
T2 chain · engineering / devrel — the release-day ritual as one file: history in, a typed notes object out, the CHANGELOG updated in place with nika:edit, the team pinged with the headline.

The job

Every release someone copy-pastes git log into a doc, rewrites it, pastes it into the CHANGELOG, then announces it. Four manual steps, four chances to drift. Here the git range is an input, the notes are a schema-typed object, and the announcement quotes the same headline the CHANGELOG got.

The shape

The file

t2-release-notes.nika.yaml
nika: v1
workflow: release-notes
description: "git log → typed release notes → CHANGELOG insert → team ping"

model: mock/echo            # swap for mistral/mistral-large

vars:
  since_tag: "v0.80.0"

secrets:
  team_webhook:
    source: env
    key: TEAM_WEBHOOK_URL
    egress:                       # sanction the one send · the secret IS the URL
      - to: "nika:notify"
        host_from_self: true

tasks:
  - id: history
    exec:
      command: "git log ${{ vars.since_tag }}..HEAD --oneline --no-merges"

  - id: notes
    depends_on: [history]
    infer:
      prompt: |
        Write release notes from these commits ·
        ${{ tasks.history.output }}
        Tone · plain, direct, no marketing fluff.
      schema:
        type: object
        required: [headline, body]
        properties:
          headline: { type: string }
          breaking: { type: array, items: { type: string } }
          body: { type: string }

  - id: changelog
    depends_on: [notes]
    invoke:
      tool: "nika:edit"
      args:
        path: "./CHANGELOG.md"
        find: "# Changelog"
        replace: |
          # Changelog

          ## ${{ vars.since_tag }}..HEAD · ${{ tasks.notes.output.headline }}

          ${{ tasks.notes.output.body }}

  - id: announce
    depends_on: [notes, changelog]
    invoke:
      tool: "nika:notify"
      args:
        channel: webhook
        target: "${{ secrets.team_webhook }}"
        message: "Release notes ready · ${{ tasks.notes.output.headline }}"
        severity: info

outputs:
  headline: ${{ tasks.notes.output.headline }}
  body: ${{ tasks.notes.output.body }}

How it works

1

The range is a var, not a hardcode

${{ vars.since_tag }}..HEAD — next release you change one input, or pass it at run time.
2

Typed notes · headline + breaking + body

The schema means ${{ tasks.notes.output.headline }} is a real field downstream — in the CHANGELOG insert AND in the ping. One source, two consumers, no drift.
3

Edit-in-place, not overwrite

nika:edit finds the # Changelog heading and replaces it with itself + the new section — the file’s history stays intact below.

Constructs you just used

ConstructWhereReference
exec: with var interpolationhistoryThe 4 verbs
infer.schema:notesThe 4 verbs
nika:edit find/replacechangelogBuiltins
nika:notify + secrets:announceBuiltins

Make it yours

  • Gate the announce behind a human: add a nika:prompt task between changelog and announce — the pattern is in Invoice chaser.
  • Add breaking: to the ping when size(tasks.notes.output.breaking) > 0.
  • Render a public version too: a second infer task with a “user-facing tone” prompt, writing docs/changelog/.

Next · SEO content brief

Chained fetch extractions — sitemap mode, then article mode — and CEL indexing into a binding.