Skip to content

GitOps schedule sync

norc gitops lets you declare a fleet’s full job set as a YAML file in your git repo and have CI diff and apply it — instead of editing jobs one at a time via vault, mobile, desktop, AI Studio, or norc jobs add/rm. Git history is the audit trail: every change to your crontabs is a commit, reviewable in a PR before it ships.

This is a launch-v1 feature gated behind a feature flag your account may not have yet — if norc gitops apply returns a 403 from the server, the feature isn’t enabled for your account.

Generate a norc API key: vault → Settings → API Keys → Generate key.

Use a dedicated key for CI, not your personal one. A norc API key is unscoped — it grants full CRUD on jobs, machines, and run history for your entire account, not just gitops-managed jobs. Generating a separate key for CI means you can revoke it independently (rotate a leaked CI secret, offboard a CI provider) without touching the key you use personally.

Store it as a repo or environment secret named NORC_API_KEY — never commit it to the file the workflow reads.

One file per repo, with a top-level machines map keyed by machine name (matched against your account’s existing machines by exact name — an unknown machine name is a hard validation error, norc gitops never creates machines). Each machine’s value is a map keyed by job name:

machines:
web-01:
deploy-cleanup:
schedule: "0 3 * * *"
command: "find /tmp -mtime +7 -delete"
enabled: true
backup-db:
schedule: "0 */6 * * *"
command: "pg_dump ..."
enabled: true
worker-02:
rotate-logs:
schedule: "0 0 * * *"
command: "logrotate /etc/logrotate.conf"
enabled: false
  • schedule — 5-field cron syntax, validated the same way norc jobs add does.
  • command — the shell command to run.
  • enabled — optional, defaults to true.

A job’s identity is (machine name, job name). Renaming a job in the file is a delete of the old name plus a create of the new name — there’s no separate rename tracking, this is correct GitOps behavior (Terraform-style desired state).

Don’t mix manually-managed and gitops-managed jobs on the same machine without importing the manual ones into the file first. Any live job not present in the file shows up as a delete on the next plan/apply — full desired-state semantics.

norc gitops plan --file <path> [--json] diff the file against live jobs (read-only)
norc gitops apply --file <path> apply the diff (create/update/delete to match the file)

Both require an API key, resolved from NORC_API_KEY in the environment (no --key prompt — CI has no tty, so a missing key is an immediate error, not an interactive prompt).

  • plan never makes a write call. It’s always safe to run, including before your account has the feature enabled.
  • apply calls a dedicated sync endpoint that creates, updates, and deletes jobs to match the file, then prints one line per operation (ok or FAILED — <reason>) — a partial failure (e.g. one job over your plan limit) doesn’t abort the rest.

Exit codes (same for both subcommands):

CodeMeaning
0Clean — plan: no diff; apply: every operation succeeded
1plan: diff is non-empty (unreviewed drift); apply: at least one operation failed
2Validation or config error — bad YAML, invalid schedule/command, unknown machine name, missing API key, or a network/auth failure talking to the vault API

--json (valid on plan only) prints the structured diff (creates/ updates/deletes) instead of the human-readable summary, for CI glue like actions/github-script to consume.

gitops apply deletions are immediate hard deletes with no in-app undo. There is no soft-delete/trash for gitops-applied deletes — once apply runs, the job row is gone. The only recovery path is git revert the commit that removed it from the file, then re-run apply.

Post a diff comment on every PR, and apply on merge to your default branch:

On NORC_API_KEY in the plan job. This workflow uses pull_request (not pull_request_target), so GitHub withholds repo secrets from any run triggered by a fork PR by default — the key is only present for PRs from branches within the same repo. norc gitops plan also never executes a job’s command; it only reads/diffs. If your repo accepts PRs from forks and you want plan to run without exposing the key to those runs at all, drop NORC_API_KEY from the plan job and skip the live-diff comment for fork PRs, or gate it behind a maintainer-triggered workflow_dispatch.

.github/workflows/gitops-sync.yml
name: gitops-sync
on:
pull_request:
types: [opened, synchronize]
push:
branches: [main] # your default branch
permissions:
contents: read
jobs:
plan:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write # only needed for the PR-comment step below
steps:
- uses: actions/checkout@v7
with: { persist-credentials: false }
- uses: actions/setup-node@v7
with: { node-version: 24 }
- run: npm install -g norc-cli@0.1.9
- name: norc gitops plan
id: plan
env:
NORC_API_KEY: ${{ secrets.NORC_API_KEY }}
run: |
# exit 1 (diff exists) is expected here — don't fail the job on it,
# only on exit 2 (validation/config error)
set +e
norc gitops plan --file jobs.yaml --json > plan.json
code=$?
set -e
echo "exit_code=$code" >> "$GITHUB_OUTPUT"
# Don't exit here — a code-2 validation failure still needs the
# PR-comment step below to run so the error is visible on the PR.
# The job is failed by the dedicated step after the comment posts.
- name: Post plan as PR comment
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const plan = fs.readFileSync('plan.json', 'utf8');
const exitCode = '${{ steps.plan.outputs.exit_code }}';
const body = exitCode === '2'
? `**norc gitops plan failed validation:**\n\`\`\`\n${plan}\n\`\`\``
: `**norc gitops plan:**\n\`\`\`json\n${plan}\n\`\`\``;
const marker = '<!-- norc-gitops-plan -->';
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number,
});
const existing = comments.find((c) => c.body.includes(marker));
const commentBody = `${marker}\n${body}`;
if (existing) {
await github.rest.issues.updateComment({ owner: context.repo.owner, repo: context.repo.repo, comment_id: existing.id, body: commentBody });
} else {
await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, body: commentBody });
}
- name: Fail on validation error
if: steps.plan.outputs.exit_code == 2
run: exit 1
apply:
if: github.event_name == 'push'
runs-on: ubuntu-latest
concurrency:
group: gitops-apply-${{ github.ref }}
cancel-in-progress: false
steps:
- uses: actions/checkout@v7
with: { persist-credentials: false }
- uses: actions/setup-node@v7
with: { node-version: 24 }
- run: npm install -g norc-cli@0.1.9
- name: norc gitops apply
env:
NORC_API_KEY: ${{ secrets.NORC_API_KEY }}
run: norc gitops apply --file jobs.yaml

apply is idempotent — running it twice with no file change in between computes an empty diff and sends no operations, so it’s safe to re-run a failed workflow.