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.
1. Generate a dedicated API key
Section titled “1. Generate a dedicated API key”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.
2. Write the YAML file
Section titled “2. Write the YAML file”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: falseschedule— 5-field cron syntax, validated the same waynorc jobs adddoes.command— the shell command to run.enabled— optional, defaults totrue.
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
deleteon the nextplan/apply— full desired-state semantics.
Command reference
Section titled “Command reference”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).
plannever makes a write call. It’s always safe to run, including before your account has the feature enabled.applycalls a dedicated sync endpoint that creates, updates, and deletes jobs to match the file, then prints one line per operation (okorFAILED — <reason>) — a partial failure (e.g. one job over your plan limit) doesn’t abort the rest.
Exit codes (same for both subcommands):
| Code | Meaning |
|---|---|
0 | Clean — plan: no diff; apply: every operation succeeded |
1 | plan: diff is non-empty (unreviewed drift); apply: at least one operation failed |
2 | Validation 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 applydeletions are immediate hard deletes with no in-app undo. There is no soft-delete/trash for gitops-applied deletes — onceapplyruns, the job row is gone. The only recovery path isgit revertthe commit that removed it from the file, then re-runapply.
Example GitHub Actions workflow
Section titled “Example GitHub Actions workflow”Post a diff comment on every PR, and apply on merge to your default branch:
On
NORC_API_KEYin theplanjob. This workflow usespull_request(notpull_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 planalso never executes a job’scommand; it only reads/diffs. If your repo accepts PRs from forks and you wantplanto run without exposing the key to those runs at all, dropNORC_API_KEYfrom theplanjob and skip the live-diff comment for fork PRs, or gate it behind a maintainer-triggeredworkflow_dispatch.
name: gitops-syncon: 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.yamlapply 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.