Automated Versioning and Deploy

GAIA automates the full release-to-deploy pipeline: derive a version from your commit history (or calendar, or a manual prompt), bump every version-carrying file in one atomic operation, then deploy affected components in dependency order with health gates between each stack. This tutorial walks through three release-strategy scenarios, a multi-stack ordered deploy with health gates, and a partial-deploy recovery scenario. It finishes with the affected-set data contract that ties CI, commit trailers, and the deploy pipeline together.

Prerequisites

You need a project-config.yaml with a release section and at least one entry in stacks. Read Configuring CI Pipelines and Environments & Promotion first if you have not set those up yet.

Overview

The release and deploy pipeline has two stages:

  1. Release -- resolve the next version (using the configured strategy), bump every file listed in release.version_files[], commit, tag, push, and create a GitHub Release. The /gaia-release command orchestrates this.
  2. Deploy -- after the release merge, the promotion trigger fires, resolves which components changed (the affected set), and dispatches per-environment deploys in dependency order with health gates. The /gaia-deploy command orchestrates this.

The two stages are independent: you can run /gaia-release without deploying (for libraries), or run /gaia-deploy without a release (for manual version management). When both are configured, the promotion trigger chains them automatically after a merge to the promotion branch.

Release strategies

The release.strategy key in your project-config.yaml controls how the next version is determined. Three strategies are available:

Strategy How the version is derived Best for
conventional-commits Classifies commits since the last v* tag using the Conventional Commits spec. feat = minor, fix = patch, BREAKING CHANGE or ! suffix = major. The highest-precedence bump wins. When no qualifying commits exist, the release exits cleanly with no version bump. Libraries, APIs, frameworks that follow semver strictly.
manual Prompts the user for the target version. No commit-derivation is performed -- the user supplies patch, minor, major, or an explicit X.Y.Z. Projects with non-standard versioning, or teams that want explicit control over every version number.
calendar Derives a CalVer version from the current date: YYYY.MM.PATCH where PATCH auto-increments based on existing tags for the current month. SaaS products, documentation sites, projects that ship on a regular cadence.

When release.strategy is absent from your config, the behavior defaults to manual.

Scenario 1: Conventional commits

Configuration

A Node.js API that follows Conventional Commits. The version lives in package.json and a plain-text VERSION file.

# .gaia/config/project-config.yaml
release:
  strategy: conventional-commits
  version_files:
    - package.json
    - VERSION

Walkthrough

  1. Verify you are on main. /gaia-release refuses to run on any other branch.
  2. The strategy resolver runs. It reads release.strategy: conventional-commits from config, classifies all commits since the last v* tag, and emits a machine-readable result.
  3. Example: three commits since v1.2.3.
    fix(auth): handle expired tokens gracefully
    feat(api): add bulk-import endpoint
    fix(docs): correct API example
    The resolver classifies: fix (patch), feat (minor), fix (patch). The highest-precedence bump is minor.
  4. Dry-run preview. The version-bump script runs with --dry-run first, printing the planned changes without writing:
    {
      "old_version": "1.2.3",
      "new_version": "1.3.0",
      "bump_type": "minor",
      "bumped": [
        { "file": "package.json", "format": "json", "old": "1.2.3", "new": "1.3.0" },
        { "file": "VERSION", "format": "text", "old": "1.2.3", "new": "1.3.0" }
      ]
    }
  5. Execute the bump. The script writes both files atomically -- either all listed files are bumped, or none are. On success, it emits the same JSON summary.
  6. Commit, tag, push, GitHub Release. /gaia-release stages exactly the files from the bumped[] array, commits with a conventional message (chore(release): bump version to v1.3.0), creates an annotated tag v1.3.0, pushes both, and creates a GitHub Release.

No releasable changes

When all commits since the last tag are non-qualifying (e.g., only chore: or docs: commits), the resolver emits bump=none. The release exits cleanly (exit 0) with a "no releasable changes" message. No version bump, no tag, no release.

Scenario 2: Manual versioning

Configuration

A mobile app where the product team controls version numbers explicitly.

# .gaia/config/project-config.yaml
release:
  strategy: manual
  version_files:
    - package.json
    - ios/App/Info.plist
    - android/app/build.gradle

Walkthrough

  1. The strategy resolver emits strategy=manual. No commit classification happens.
  2. You provide the bump specifier. /gaia-release prompts you: supply patch, minor, major, or an explicit version like 2.0.0.
  3. Dry-run, then execute. Same two-phase process as Scenario 1 -- preview with --dry-run, then execute.
    {
      "old_version": "1.4.2",
      "new_version": "2.0.0",
      "bump_type": "2.0.0",
      "bumped": [
        { "file": "package.json", "format": "json", "old": "1.4.2", "new": "2.0.0" },
        { "file": "ios/App/Info.plist", "format": "json", "old": "1.4.2", "new": "2.0.0" },
        { "file": "android/app/build.gradle", "format": "json", "old": "1.4.2", "new": "2.0.0" }
      ]
    }
  4. Commit, tag, push, GitHub Release. Identical to Scenario 1.

When to use manual

Choose manual when your version numbers carry product-level meaning (e.g., marketing versions, app store submission versions) that cannot be derived from commit messages alone.

Scenario 3: Calendar versioning

Configuration

A SaaS platform that ships on a regular cadence and uses CalVer.

# .gaia/config/project-config.yaml
release:
  strategy: calendar
  version_files:
    - plugin.json
    - VERSION

Walkthrough

  1. The strategy resolver derives the version from today's date. Format: YYYY.MM.PATCH. If no tags exist for the current month, PATCH starts at 0. If v2026.06.0 already exists, the next version is 2026.06.1.
  2. The version is passed directly to the bump script. Unlike conventional-commits, no commit classification is needed. The resolver emits the full version string.
    {
      "old_version": "2026.05.3",
      "new_version": "2026.06.0",
      "bump_type": "2026.06.0",
      "bumped": [
        { "file": "plugin.json", "format": "json", "old": "2026.05.3", "new": "2026.06.0" },
        { "file": "VERSION", "format": "text", "old": "2026.05.3", "new": "2026.06.0" }
      ]
    }
  3. Commit, tag, push, GitHub Release. Identical to the other strategies.

When to use calendar

Choose calendar when your users expect time-based versions (e.g., documentation sites, SaaS platforms, internal tools with regular release trains). The auto-incrementing PATCH handles multiple releases within the same month.

Per-component deploy

When your project has multiple stacks, /gaia-deploy deploys them in dependency order using the deploy_order field on each stack. A health check runs after each stack deploys; the next stack in order does not begin until the previous stack's health check passes.

Two deploy modes are available:

Mode Behavior on health-check failure
strict (default) Halt all downstream stacks immediately. No further deploys happen. Exit code 1.
best-effort Mark the failing stack HOLD. Already-deployed stacks stay live. Downstream dependents are marked SKIPPED. Emit a PARTIAL-DEPLOY verdict with a per-component status table. Exit code 3.

Scenario 4: Ordered multi-stack deploy with health gates

Configuration

A microservices project with three stacks deployed in a specific order: the database migration runs first, then the API, then the frontend.

# .gaia/config/project-config.yaml
stacks:
  - name: db-migrate
    path: packages/db
    language: typescript
    deploy_order: 1
    health_check:
      command: "pg_isready -h localhost -p 5432"
      timeout: 30

  - name: api
    path: packages/api
    language: typescript
    deploy_order: 2
    health_check:
      command: "curl -sf http://localhost:3000/health"
      timeout: 60
    post_deploy_smoke:
      command: "npm run smoke:api"
      timeout: 120

  - name: web
    path: packages/web
    language: typescript
    deploy_order: 3
    health_check:
      command: "curl -sf http://localhost:8080/health"
      timeout: 30
    post_deploy_smoke:
      command: "npx playwright test --project=smoke"
      timeout: 180

Deploy sequence (strict mode)

  1. db-migrate (deploy_order: 1) deploys first. After deploy, its health check runs: pg_isready -h localhost -p 5432. Timeout: 30 seconds.
  2. api (deploy_order: 2) deploys next -- only after db-migrate's health check passes. After deploy, its health check runs: curl -sf http://localhost:3000/health. If healthy, its smoke test runs: npm run smoke:api.
  3. web (deploy_order: 3) deploys last. Health check, then smoke test.

What happens when a health check fails (strict mode)

If the api health check fails (non-zero exit or timeout exceeded), all downstream stacks (web) are skipped. The deploy exits with code 1 and a failure diagnostic. The skill suggests consulting your rollback plan but does not execute a rollback automatically.

Stacks without deploy_order

Stacks that do not set deploy_order are deployed after all ordered stacks, in alphabetical order by name. This ensures deterministic behavior without requiring every stack to specify an order.

Scenario 5: Partial-deploy recovery (best-effort mode)

What best-effort mode does differently

In best-effort mode, a health-check failure does not halt everything. Instead:

  1. The failing stack is marked HOLD.
  2. Already-deployed stacks remain live.
  3. Downstream stacks that depend on the failing stack are marked SKIPPED.
  4. The deploy emits a PARTIAL-DEPLOY composite verdict and writes a per-component status table.

Example: api health check fails

Using the same three-stack configuration from Scenario 4, but with --mode best-effort:

Stack Status Explanation
db-migrate DEPLOYED Deployed and healthy.
api HOLD Deployed, but health check failed. Marked HOLD.
web SKIPPED Not deployed. Skipped because upstream (api) is in HOLD.

Composite verdict: PARTIAL-DEPLOY. Exit code: 3.

Version-manifest snapshot and crash recovery

Before the first component deploys, best-effort mode writes a version-manifest snapshot recording each component's pre-deploy version. If a crash or interrupt occurs mid-deploy, a restart reads the snapshot and resumes from the last incomplete component rather than redeploying components that already succeeded. On successful completion, the snapshot is archived.

Recovering from a HOLD

After diagnosing and fixing the failing stack:

  1. Fix the root cause (e.g., the api health endpoint).
  2. Re-run /gaia-deploy --env staging --version v1.3.0 --mode best-effort.
  3. The crash-recovery logic reads the version manifest and resumes from the api stack, then continues to web.

The affected-set data contract

The deploy pipeline needs to know which components changed in a given CI run. The affected-set resolver answers this question using a three-channel fallback chain. It always resolves to something -- the deploy pipeline never silently deploys nothing.

The resolver (resolve-affected-set.sh) emits a JSON object naming both the affected stacks and which resolution channel succeeded:

{"stacks":["api","web"],"channel":"ci-artifact"}

The channel field is one of: ci-artifact, commit-trailer, or full-deploy. Consumers can log this value for observability without parsing the resolution logic themselves.

Channel 1: CI artifact (primary)

The selective-test pipeline's plan job writes a JSON file and uploads it as a GitHub Actions artifact named affected-set. The file is named affected-set.json and contains a single stacks array:

// Selective deploy -- only api and web changed
{"stacks":["api","web"]}

// Full deploy (escalation from stale dependency graph)
{"stacks":["*"]}

// Docs-only change -- no deploy needed
{"stacks":[]}

Rules:

  • stacks is a JSON array of strings. Each string is a stack name declared in project-config.yaml under stacks[].name.
  • The wildcard sentinel ["*"] means "all stacks" (full deploy).
  • An empty array [] means "no stacks affected" -- no deploy needed.

The promotion trigger downloads this artifact and passes its path to the resolver via --artifact <path>. If the download fails (artifact expired, manual trigger), the resolver transparently falls through to the next channel.

Channel 2: Commit trailer (secondary)

When the CI artifact is unavailable (manual deploy, workflow re-run, or cross-workflow trigger), the resolver falls back to parsing a commit trailer from the HEAD commit message. Two trailer names are accepted:

Affected-Set: ["api","worker"]
Affected-Components: ["web"]

The trailer value must be a JSON array of stack-name strings. The resolver reads the first matching trailer (Affected-Set preferred, then Affected-Components). Invalid or absent trailers cause the resolver to fall through to the safety net.

When to use commit trailers

Commit trailers are useful when you need to manually control which components deploy -- for example, during a hotfix where CI did not run the full selective-test pipeline, or when deploying from a manually-triggered workflow.

Channel 3: Full-deploy fallback (safety net)

When neither the CI artifact nor a commit trailer is available, the resolver emits the full-deploy sentinel -- every component deploys. This guarantees that the deploy pipeline never silently deploys nothing.

When --config points to a valid project-config.yaml, the resolver enumerates all stacks[].name entries from the config and lists them explicitly. Without a config file, it emits the wildcard sentinel ["*"].

// With config -- all named stacks
{"stacks":["db-migrate","api","web"],"channel":"full-deploy"}

// Without config -- wildcard
{"stacks":["*"],"channel":"full-deploy"}

Configuration reference

This section collects every configuration field covered in this tutorial. All fields live in .gaia/config/project-config.yaml.

Release configuration

# .gaia/config/project-config.yaml
release:
  strategy: conventional-commits   # or: manual | calendar (default: manual)
  version_files:
    - package.json                 # JSON with a top-level "version" key
    - plugin.json                  # Also JSON
    - VERSION                      # Plain-text file: bare semver string
Field Type Default Description
release.strategy string manual How the next version is derived: conventional-commits, manual, or calendar.
release.version_files string[] (none) File paths relative to the project root. Each file is bumped by the version-bump script. Supported formats: JSON with a top-level "version" key, or a plain-text file containing only a semver string.

Releasing: the auto-prepared release PR (and the token it needs)

On a push to the final promotion branch, release.yml runs in prepare mode: it computes the next version, bumps the version_files + CHANGELOG on a release/vX.Y.Z branch, pushes that branch, and opens a chore(release): vX.Y.Z pull request. Merging that PR fires release.yml in publish mode, which cuts the tag and the GitHub Release.

The release PR open requires a token that may not be the default. The built-in GITHUB_TOKEN is blocked from creating PRs unless the repository enables Settings → Actions → General → Workflow permissions → "Allow GitHub Actions to create and approve pull requests." When that setting is off and no PAT is configured, prepare mode still pushes the release/vX.Y.Z branch but cannot open the PR — it writes a prominent job-summary notice with a one-click "open PR" link, and a human must open + merge it to ship. To make releases fully hands-off, configure one of:

  • a RELEASE_PAT repository secret (a PAT with repo scope) — release.yml prefers it over GITHUB_TOKEN and it works regardless of the org setting; or
  • enable the "Allow GitHub Actions to create and approve pull requests" workflow permission above.

Which command ships the artifact: release.yml vs /gaia-publish

For a project whose distribution.release_workflow is set and whose channel is a path-based marketplace (the workflow itself cuts the tag + Release), the release_workflow is the publish — driven by the merged release PR above. /gaia-publish is then the post-hoc await-and-verify gate (pre-publish CI gate → confirm the release-workflow outcome → post-publish registry probe → verdict), not a second, competing publish trigger. For a direct registry channel (npm, pypi, app stores) with no release_workflow, /gaia-publish dispatches the channel adapter directly. Never drive both as independent publishers of the same version. /gaia-deploy correctly HALTs for kind: branch-only environments and points you to /gaia-publish.

Per-stack deploy configuration

# .gaia/config/project-config.yaml
stacks:
  - name: api
    path: packages/api
    language: typescript
    deploy_order: 2                # Lower deploys first; absent = after all ordered stacks (alpha)
    health_check:
      command: "curl -sf http://localhost:3000/health"
      timeout: 60                  # Seconds; default 30
    post_deploy_smoke:
      command: "npm run smoke:api"
      timeout: 120                 # Seconds; default 60
Field Type Default Description
stacks[].deploy_order integer (none) Controls the order in which stacks deploy. Lower values deploy first. Stacks without deploy_order deploy after all ordered stacks, in alphabetical order by name.
stacks[].health_check.command string (none) Shell command to execute after deploying the stack. Exit code 0 = healthy, non-zero = unhealthy. Must pass before the next stack in deploy_order begins.
stacks[].health_check.timeout integer 30 Maximum seconds to wait for the health-check command. Timeout exceeded = treated as failure.
stacks[].post_deploy_smoke.command string (none) Shell command to execute as a post-deploy smoke test. Runs after the health check passes (or immediately after deploy when no health check is configured).
stacks[].post_deploy_smoke.timeout integer 60 Maximum seconds to wait for the smoke command.

Validating your configuration

After editing release or deploy configuration, validate:

/gaia-config-validate

Invalid field types, unknown strategy values, or missing required fields produce a validation error with the exact JSONPath location. Fix the error and re-validate.

What to read next