CI/CD setup

Automate SteadyCron deployments with GitHub Actions — plan diffs on pull requests, apply on merge. Works with both YAML manifests and Terraform.

Running a plan on every pull request and applying on merge turns your IaC configuration into a GitOps workflow: reviewers see exactly what cron changes a PR introduces, and the main branch is always the source of truth.

The steadycron/action GitHub Action supports both IaC tools via the tool input:

  • yaml (default) — YAML manifest managed by the SteadyCron CLI
  • terraform — HCL resources managed by the Terraform provider

Step 1 — add the API key as a secret

In your GitHub repository, go to Settings → Secrets and variables → Actions and add:

NameValue
STEADYCRON_API_KEYYour SteadyCron API key (sc_...)

Add any other environment-specific secrets your manifest references (e.g. SLACK_WEBHOOK_URL).

For plan-only pipelines (read operations), you can create a separate read-only API key and use it exclusively in the PR workflow.


YAML manifest

Workflow 1 — plan diff on pull request

Post the plan output as a PR comment whenever a manifest file changes:

# .github/workflows/cron-plan.yml
name: Cron plan

on:
  pull_request:
    paths:
      - 'manifests/**'

jobs:
  plan:
    runs-on: ubuntu-latest
    permissions:
      pull-requests: write
    steps:
      - uses: actions/checkout@v4

      - uses: steadycron/action@v1
        with:
          command: plan
          manifest: manifests/production.yaml
          comment-on-pr: 'true'
        env:
          STEADYCRON_API_KEY: ${{ secrets.STEADYCRON_API_KEY }}
          SLACK_WEBHOOK_URL:  ${{ secrets.SLACK_WEBHOOK_URL }}

The action posts a comment like:

SteadyCron plan — manifests/production.yaml

  ~ weekly-digest    update  retries: 2 → 3
  + invoice-cron     create  http  0 17 * * 5

  1 to create · 1 to update · 0 to delete

Workflow 2 — apply on merge

Apply the manifest (with --prune) whenever the main branch is updated:

# .github/workflows/cron-apply.yml
name: Cron apply

on:
  push:
    branches:
      - main
    paths:
      - 'manifests/**'

jobs:
  apply:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: steadycron/action@v1
        with:
          command: apply
          manifest: manifests/production.yaml
          prune: 'true'
        env:
          STEADYCRON_API_KEY: ${{ secrets.STEADYCRON_API_KEY }}
          SLACK_WEBHOOK_URL:  ${{ secrets.SLACK_WEBHOOK_URL }}

Multiple environments

Manage separate staging and production manifests with a matrix:

jobs:
  apply:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        env: [staging, production]
    steps:
      - uses: actions/checkout@v4
      - uses: steadycron/action@v1
        with:
          command: apply
          manifest: manifests/${{ matrix.env }}.yaml
          prune: 'true'
        env:
          STEADYCRON_API_KEY: ${{ secrets[format('STEADYCRON_API_KEY_{0}', matrix.env)] }}

Validate on every push

Add a validate step to catch schema errors before plan runs:

- uses: steadycron/action@v1
  with:
    command: validate
    manifest: manifests/production.yaml
  env:
    STEADYCRON_API_KEY: ${{ secrets.STEADYCRON_API_KEY }}

validate exits non-zero on schema errors and does not require an API key, but passing one allows the server to validate against your account’s current limits.


Terraform

The same action handles Terraform workflows. Set tool: terraform and point working-directory at your .tf files — the action installs Terraform, runs terraform init, then plan or apply.

Workflow 1 — plan diff on pull request

# .github/workflows/cron-tf-plan.yml
name: Cron Terraform plan

on:
  pull_request:
    paths:
      - 'infra/steadycron/**'

jobs:
  plan:
    runs-on: ubuntu-latest
    permissions:
      pull-requests: write
    steps:
      - uses: actions/checkout@v4

      - uses: steadycron/action@v1
        with:
          tool: terraform
          command: plan
          working-directory: infra/steadycron
          comment-on-pr: 'true'
        env:
          STEADYCRON_API_KEY: ${{ secrets.STEADYCRON_API_KEY }}

The action posts a sticky comment summarising resource adds, changes, and destroys, with the full plan output collapsed in a <details> block.

Workflow 2 — apply on merge

# .github/workflows/cron-tf-apply.yml
name: Cron Terraform apply

on:
  push:
    branches:
      - main
    paths:
      - 'infra/steadycron/**'

jobs:
  apply:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: steadycron/action@v1
        with:
          tool: terraform
          command: apply
          working-directory: infra/steadycron
        env:
          STEADYCRON_API_KEY: ${{ secrets.STEADYCRON_API_KEY }}

Plan then apply in one job

Running plan and apply as separate steps in the same job ensures apply uses exactly the plan that was reviewed:

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: steadycron/action@v1
        id: plan
        with:
          tool: terraform
          command: plan
          working-directory: infra/steadycron
        env:
          STEADYCRON_API_KEY: ${{ secrets.STEADYCRON_API_KEY }}

      - if: steps.plan.outputs.has-drift == 'true'
        uses: steadycron/action@v1
        with:
          tool: terraform
          command: apply
          working-directory: infra/steadycron
        env:
          STEADYCRON_API_KEY: ${{ secrets.STEADYCRON_API_KEY }}

When a tfplan file is present from a prior plan step in the same job, apply uses it directly — no second plan is run and no -auto-approve flag is needed.

Remote state backend

Pass -backend-config lines (one per line) for runtime backend configuration:

- uses: steadycron/action@v1
  with:
    tool: terraform
    command: plan
    working-directory: infra/steadycron
    backend-config: |
      bucket=my-tfstate-bucket
      key=steadycron/terraform.tfstate
      region=eu-central-1
  env:
    STEADYCRON_API_KEY:       ${{ secrets.STEADYCRON_API_KEY }}
    AWS_ACCESS_KEY_ID:        ${{ secrets.AWS_ACCESS_KEY_ID }}
    AWS_SECRET_ACCESS_KEY:    ${{ secrets.AWS_SECRET_ACCESS_KEY }}

Action inputs

Shared

InputDescriptionDefault
toolyaml or terraformyaml
commandplan or apply (both tools); validate, sync (yaml only)plan
comment-on-prPost the plan output as a sticky PR commentfalse

YAML tool

InputDescriptionDefault
manifestPath to the manifest file or directory.
pruneDelete server resources not in the manifestfalse
namespaceAccount namespace (required with --prune across multiple namespaces)
cli-versionPin a specific CLI version, e.g. 1.4.0latest
env-filePath to a .env file for ${VAR} substitution

All ${ENV_VAR} placeholders in your manifest are read from the job’s environment — pass them via the env: block in your workflow step.

Terraform tool

InputDescriptionDefault
working-directoryDirectory containing .tf files.
terraform-versionPin a specific Terraform version, e.g. 1.8.0latest
backend-configNewline-separated -backend-config=key=value lines
var-filePath to a .tfvars file passed to plan/apply

Action outputs

OutputDescription
has-drift"true" when the plan detected changes (both tools)
plan-jsonRaw plan JSON — SteadyCron format (yaml) or terraform show -json (terraform)
plan-outputHuman-readable plan text (terraform only)

Least-privilege API keys

For plan-only workflows (PR comments), use a read-only API key that cannot make changes. Create a separate key in the dashboard with read scope only and store it as a separate secret (e.g. STEADYCRON_API_KEY_RO).

This limits the blast radius if the PR workflow secret is ever compromised.