Skip to content

CI from a repo

Your pipelines live in the repo (.cryo/ci.yaml), versioned with the code; cryosleep fetches one at the commit you name and runs it. Run a pipeline on demand with a single command, or wire a push to run one automatically.

The quickest path needs no canvas. Store a token that can read the repo as a bearer credential, then run any pipeline from it on demand:

Terminal window
cryo submit --repo-path .cryo/deploy.yaml \
--repo me/private-app \
--repo-credential gh-read \
--ref main

The server reads the file out of the repo at that ref and runs it, so the run builds the definition that commit shipped with rather than whatever was on your disk. A manual submit gets the same CI_TRIGGER_* environment a push would (see what a run sees), so a cloning job can check out the ref.

Naming the repo every time is not boilerplate you can configure away. See where the repo comes from.

To run a pipeline on each push, catch the push with a graph on the canvas that routes to the right pipeline - one file on main, another on every other branch. The shape:

trigger (webhook, github) -> switch (by ref) -> workflow .cryo/main.yaml
\-> workflow .cryo/branch.yaml
  • A webhook trigger owns the endpoint GitHub posts to. The github manifest verifies each delivery and shapes the push into fields like ref and sha.
  • A switch reads the branch and routes to a named case.
  • A workflow node fetches a pipeline from the repo at the pushed commit and runs it.

One push, two runs. The graph is the run the push starts: it decides which pipeline applies. The workflow node then spawns that pipeline as a child run with its own history, nested under the graph run in the UI. So the graph is the routing layer and the pipeline in your repo is the work; you will see both in the runs list.

You do not add an event-hook for this. Publishing the graph mints the endpoint the trigger node owns, and it shows up on the Connections page named graph.<graph-name>.<node-id> - that entry is your CI hook, and deleting it there unpicks the wiring. The Add hook button on that page is the other shape: a standalone hook that emits <name>.* on the event bus for a handler or a bus trigger to pick up, which is what you want when several unrelated graphs react to one sender. For catching a push and routing it, the trigger node is the whole ingress.

Open the canvas, create a graph, and pick Start from a template -> CI from a repo. That drops in the four nodes below; edit the paths to match your repo.

{
"nodes": [
{ "id": "on_push", "type": "trigger",
"config": { "transport": "webhook", "manifest": "github", "event": "push" } },
{ "id": "branch", "type": "switch",
"config": {
"cases": [{ "name": "main", "when": "input.ref == \"refs/heads/main\"" }],
"default": "other"
} },
{ "id": "run_main", "type": "workflow",
"config": { "from_repo": { "path": ".cryo/main.yaml",
"repo": { "$expr": "input.repo" },
"credential": "gh-read",
"ref": { "$expr": "input.sha" } } } },
{ "id": "run_branch", "type": "workflow",
"config": { "from_repo": { "path": ".cryo/branch.yaml",
"repo": { "$expr": "input.repo" },
"credential": "gh-read",
"ref": { "$expr": "input.sha" } } } }
],
"edges": [
{ "from": "on_push", "to": "branch" },
{ "from": "branch", "to": "run_main", "when": "main" },
{ "from": "branch", "to": "run_branch", "when": "other" }
]
}

The github manifest normalizes the push, so the switch reads input.ref (refs/heads/main, refs/heads/feature-x) and input.sha (the pushed commit). repo comes from the same envelope, which is why the two from_repo: nodes read input.repo rather than hard-coding a name: the graph builds whichever repo pushed, and gh-read is a stored credential that can read it. Pin repo to a literal only when a node should always fetch from one specific repo regardless of who triggered it. The switch picks a case and the matching edge carries the run, so exactly one workflow node fires - when: names the case on the edge rather than repeating the test on each target, and an edge naming a case the switch doesn’t declare is refused when you save. ref: { "$expr": "input.sha" } pins each fetch to the pushed commit, so a push builds its own code.

Two ways: let cryosleep create the webhook on the repo, or paste it in yourself. The first needs a forge token, so start there.

The whole path, from the stored token to the filled-in form:

  1. Store the forge token. Open Connections in the top nav and add a bearer credential holding a token with permission to manage webhooks. On GitHub that is admin:repo_hook; on Gitea / Forgejo, write:repository. Name it something you’ll recognise in a dropdown - forge-admin below.

    The connections page holding a bearer credential named forge-admin - the forge token cryosleep uses to create the webhook on your behalf. Only its name and kind are shown; the token itself is never displayed again.
  2. Publish the graph. That mints the endpoint. Select the on_push node on the canvas: its inspector now shows an ingest URL, the signing secret (once), and a register on forge button.

    A published webhook trigger's inspector: the ingest URL a forge posts to, the signing secret shown once at publish, and the register on forge button that creates the hook for you.
  3. Register on forge. The button opens a short form. Pick the forge, enter the repo as owner/name, and choose the credential from step 1. cryosleep creates the webhook on the repo - nothing to paste.

    The register on forge form with github selected, asking for the repo as owner/name and the bearer credential holding the admin token.

    Gitea and Forgejo ask for one more thing: the instance API base, since there’s no single address to assume. It’s your instance URL plus /api/v1.

    The same form with gitea selected: it now also asks for the instance API base, e.g. https://gitea.example.com/api/v1, which forgejo needs too and github does not.

Re-registering is idempotent: it updates the existing hook rather than adding a second one, which is what you want after rotating a secret.

The API base has to be an https address that resolves to a public host, since registering sends your forge token to it. A forge on a private network is reachable only on a self-hosted deployment, where the operator names it in CRYOSLEEP_FORGE_API_ALLOWLIST.

Publish the graph, then copy the ingest URL and secret from the on_push trigger’s inspector - the same panel pictured in step 2 above. In your repo’s Settings -> Webhooks, add a webhook with that URL as the payload URL, content type application/json, the secret, and the push event. No forge token is involved, so this is the path when you can’t or won’t hand one over.

The secret is shown once. A re-publish keeps the same one, so you only see it again if you rotate.

Either way, deliveries are verified against the secret (X-Hub-Signature-256 HMAC) and deduped on the signed body, so a redelivered push resolves to the event the first one created rather than starting a second run.

Push something. You should get two runs: the graph, and the pipeline it routed to nested under it. If nothing appears at all, the delivery didn’t reach the endpoint or failed verification - the forge’s own webhook page shows the response code it got.

Redeliver is for a delivery that failed, not for a re-test. Because the dedup is on the body, redelivering a push that already succeeded returns the same event and starts nothing - it looks like nothing happened, and nothing is meant to. Redeliver when the forge shows a non-2xx response: no run was created then, so the retry makes one. To exercise the path again, push again.

Point several repos at the same trigger and you run one CI graph for all of them - no per-repo copy. Each push carries the repo as its subject, so the run knows which repo it’s building (it’s in the input), and any cancel_on: [pr.closed] in that run correlates to that repo’s PRs automatically. Ten repos, one graph, and a closing PR on one never cancels a build for another.

There is no “connected repo”. A project doesn’t belong to a repo, and nothing stores which one it builds. That surprises people once, and then explains the rest of this page, so it’s worth a minute.

The repo reaches a run one of two ways, and both of them carry it in.

A push carries it. A delivery arrives at a webhook you registered. What that webhook stores is an event type and how a delivery proves itself - the shared secret, the signature scheme - and nothing about a repo. The manifest shapes the incoming body into an envelope with repo, ref, sha, sender and paths, and that envelope becomes the run’s input. So the repo a push builds is whatever the delivery said it was, read back out as ${{ input.repo }} or $CI_TRIGGER_REPO. One webhook happily serves many repos; the run knows which one because the payload told it.

A request names it. cryo submit --repo-path and a from_repo: node both take repo and credential explicitly. Nothing is looked up, because there is nothing to look up.

What a project does store is credentials by name, webhooks, handlers, schedules and graphs. A credential named gh-read is a token you can point at any repo it can read - it isn’t bound to one, which is why --repo and --repo-credential are separate flags rather than one setting.

The practical consequence: pointing CI at a different repo is a change to the request or the payload, never to project configuration. There is no page to visit and nothing to migrate.

Each run carries what started it as its canonical input:

  • ${{ input.<field> }} in a pipeline - the trigger envelope as an expression scope. Uncapped: ${{ input.payload.head_commit.message }} reaches into a full push body.
  • $CRYO_INPUT_FILE in any job, shell step or script: alike - a file holding that same envelope, whole, whatever its size.
  • CI_TRIGGER_* env vars for the scalar fields, so a shell can branch without parsing JSON: EVENT, SHA, SHORT_SHA, REF, REPO, SENDER, FORGE. A field that isn’t a scalar (paths, payload) has no flat form and stays in the file.

A job that clones the repo can check out the exact pushed commit:

Terminal window
git clone "$CI_TRIGGER_REPO" app && cd app
git checkout "$CI_TRIGGER_SHA"

When templating a shell body, read the env var inside the script ("$CI_TRIGGER_REF" is quoted by the shell) rather than splicing ${{ env.CI_TRIGGER_* }} into the script text, where a branch or sender name would land unescaped.

If you drive CI straight from a YAML pipeline instead of a graph, its top-level if: is evaluated server-side at ingestion, before any agent starts. When it’s false the push is acknowledged and nothing runs:

if: 'event.ref == "refs/heads/main" || event.ref.startsWith("refs/heads/release/")'

The trigger fields available to if: are in the YAML reference. A manual submit has no trigger predicate and always runs.

Push and cryo submit --repo-path are two ways to start the same run. Neither reads a stored setting: the push carries its repo in the delivery, the submit names one, and either way the definition comes out of the repo rather than off the server.