Skip to content

feat: custom CI/CD step injection for consumer-specific workflows #16

@tsavo-at-pieces

Description

@tsavo-at-pieces

Problem

Currently, runtime_ci_tooling generates CI/CD workflow files from Mustache skeleton templates driven by .runtime_ci/config.json. While the system is highly configurable for common patterns (Dart SDK version, feature flags like proto, lfs, format_check, secrets, sub-packages, multi-platform matrices), there is no structured way for consumer repos to inject custom CI/CD steps into the generated workflows.

Some repos — such as runtime_isomorphic_ipc — have additional CI/CD requirements that fall outside the standard template (e.g., starting background services, setting up custom tool chains, running integration test suites, or deploying artifacts). Today, these repos must either:

  1. Manually edit the generated ci.yaml after every update --workflows — fragile and easy to lose on the next regeneration.
  2. Rely on the BEGIN USER / END USER hooks — which exist in ci.skeleton.yaml but are limited to only two fixed injection points (pre-test and post-test), are not configurable from config.json, and have no documentation or validation.

Prior Art: Existing BEGIN USER / END USER Hooks

The skeleton template (templates/github/workflows/ci.skeleton.yaml) already has four hook points (two per platform mode):

# --- BEGIN USER: pre-test ---
# --- END USER: pre-test ---

# (test step)

# --- BEGIN USER: post-test ---
# --- END USER: post-test ---

The WorkflowGenerator._preserveUserSections() method in lib/src/cli/utils/workflow_generator.dart parses these from the existing deployed ci.yaml and re-injects them after Mustache rendering:

final sectionPattern = RegExp(
  r'# --- BEGIN USER: (\S+) ---\n(.*?)# --- END USER: \1 ---',
  dotAll: true,
);

This is a good foundation but has several limitations:

  • Only two hook names (pre-test, post-test) — no hooks for pre-analyze, post-analyze, pre-checkout, post-setup, etc.
  • No config-driven injection — users must manually edit the generated YAML to fill the hooks; there is no way to define custom steps in config.json or a separate file.
  • No documentation — the hooks are not mentioned in README or config.json comments.
  • No validation — invalid YAML inside user sections silently produces broken workflows.
  • No composability — cannot combine multiple custom step sources (e.g., a shared org-level snippet + repo-specific snippet).

Proposed Solution

1. Expand hook points in the skeleton template

Add more injection points to ci.skeleton.yaml:

# --- BEGIN USER: post-checkout ---
# --- END USER: post-checkout ---

# (setup-dart, cache, pub get steps)

# --- BEGIN USER: post-setup ---
# --- END USER: post-setup ---

# (analyze step)

# --- BEGIN USER: post-analyze ---
# --- END USER: post-analyze ---

# --- BEGIN USER: pre-test ---
# --- END USER: pre-test ---

# (test step)

# --- BEGIN USER: post-test ---
# --- END USER: post-test ---

# --- BEGIN USER: finalize ---
# --- END USER: finalize ---

2. Config-driven step injection via config.json

Add a ci.custom_steps section to config.json that maps hook names to inline step definitions:

{
  "ci": {
    "custom_steps": {
      "post-checkout": [
        {
          "name": "Start IPC broker",
          "run": "docker compose -f test/docker-compose.yml up -d"
        }
      ],
      "post-test": [
        {
          "name": "Stop IPC broker",
          "run": "docker compose -f test/docker-compose.yml down"
        },
        {
          "name": "Upload coverage",
          "uses": "codecov/codecov-action@v4",
          "with": {
            "files": "coverage/lcov.info"
          }
        }
      ]
    }
  }
}

WorkflowGenerator._buildContext() would serialize these into valid YAML step blocks and inject them at the corresponding BEGIN USER / END USER markers — without requiring the user to manually edit the generated file.

3. File-based step injection via .runtime_ci/custom_steps/

For more complex steps, support loading from separate YAML files:

.runtime_ci/
  config.json
  custom_steps/
    pre-test.yaml
    post-test.yaml

Each file contains a list of GitHub Actions step objects:

# .runtime_ci/custom_steps/pre-test.yaml
- name: Start integration environment
  run: |
    ./scripts/setup-integration-env.sh
    sleep 5

- name: Verify environment health
  run: curl --fail http://localhost:8080/health

The generator would:

  1. Check ci.custom_steps in config.json (inline definitions).
  2. Check .runtime_ci/custom_steps/<hook-name>.yaml files (file-based definitions).
  3. Merge both (config.json steps first, file-based steps second).
  4. Inject into the corresponding hook point.
  5. Continue to preserve any manually-edited BEGIN USER / END USER content as a final fallback.

4. Validation

  • Validate that hook names in ci.custom_steps match known hook points.
  • Validate that step definitions have at least name and either run or uses.
  • Validate resulting YAML is syntactically correct (catch indentation issues early).
  • Add a manage_cicd validate subcommand or --dry-run flag to preview generated output.

5. Update init and update commands

  • init: When generating a fresh config.json, include an empty custom_steps: {} key with a comment explaining available hooks.
  • update --workflows: Read custom steps from config and/or files, inject them, then preserve any remaining manual user sections as before.
  • New flag: update --workflows --dry-run to preview the generated ci.yaml without writing it.

Example: runtime_isomorphic_ipc

{
  "ci": {
    "dart_sdk": "3.9.2",
    "features": { "format_check": true },
    "custom_steps": {
      "pre-test": [
        {
          "name": "Start IPC test broker",
          "run": "dart run tool/start_broker.dart &"
        },
        {
          "name": "Wait for broker",
          "run": "sleep 3"
        }
      ],
      "post-test": [
        {
          "name": "Collect broker logs",
          "if": "always()",
          "run": "cat /tmp/broker.log || true"
        }
      ]
    }
  }
}

Acceptance Criteria

  • Additional hook points added to ci.skeleton.yaml (post-checkout, post-setup, post-analyze, finalize)
  • ci.custom_steps config key supported in config.json with inline step definitions
  • .runtime_ci/custom_steps/<hook>.yaml file-based injection supported
  • Config-driven steps and file-based steps merge correctly (config first, files second)
  • Manual BEGIN USER / END USER edits continue to work as a fallback
  • WorkflowGenerator.validate() validates custom step definitions
  • update --workflows --dry-run flag for previewing output
  • init scaffolds empty custom_steps with documentation
  • Unit tests for step injection, merging, and validation
  • README/docs updated with hook point reference and examples

Benefits

  • Consumer repos can define custom CI/CD logic declaratively without touching generated files
  • Regeneration via update --workflows is safe — custom steps survive intact
  • Three layers of customization (config inline, file-based, manual edit) for different complexity levels
  • Existing BEGIN USER / END USER behavior is preserved as backward-compatible fallback
  • Validation catches errors before they hit GitHub Actions

Metadata

Metadata

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions