Skip to content
Skillv1.0.0

deploy

How deployment to Skyr works: pushing to the skyr git remote, environments and the deployment lifecycle, pushing a deployment for approval and approving or rejecting one, rolling a deployment back, ex

by skyr-cloud(0) 0 installs
Free
Sign in to install

Free account. Installing gives you the manifest plus copy-paste snippets.

See reviews

About

Imported from skyr-cloud/agent-plugin (skills/deploy/SKILL.md). Install upstream with npx skills add skyr-cloud/agent-plugin --skill deploy. Copyright stays with the author.

Deploying to Skyr

Skyr is a Git-native infrastructure orchestrator: a repository of SCL configuration is the deployable unit, and pushing it to a Skyr git remote is the deployment action. There is no separate plan/apply step and no deploy command — Skyr converges reality to whatever the pushed commit declares. This skill describes how that works: the lifecycle, what the first-party plugins can build (with complete examples), and how to observe and debug a rollout. Authoring the SCL itself — syntax, types, modules, Package.scle — is the scl skill's territory.

Examples use skyr.foo as the instance host; if the user's repository deploys to a different Skyr instance, substitute that instance's host.

Mental model

  • The hierarchy is Organization → Repository → Environment → Deployment. An environment is a git branch or tag (main, tag:v1.0); a deployment is one commit of that environment. Deployment QIDs spell the whole chain: acme/shop::main@a10fb43f… (/ org/repo, :: env, @ commit).
  • git push skyr main is the deploy. Skyr evaluates Main.scl at the repo root, builds the resource dependency graph, and creates/updates/destroys resources until reality matches. Pushing a new commit to the same branch rolls the environment forward; resources shared between old and new config are adopted (ownership transfers), not recreated.
  • An edge comes from using another resource's outputs — either building a resource's inputs from them, or gating its declaration on them (if (job.exitCodes["job"] == 0) Container.Pod({...})). There is no depends_on; both kinds are worked out for you, and SCL's with (a) B({...}) writes the edge explicitly where neither flow carries it (the scl skill covers it). Edges order creation and, reversed, teardown: nothing is destroyed while something depending on it is still there.
  • Every branch environment has two refs. a is the environment itself; deploy/a is where a deployment is pushed for approval — evaluated continuously against live state, applying nothing, until somebody approves it (see the approval section below). deploy/a follows a whenever nothing is pending, so after a git fetch skyr the diff skyr/a..skyr/deploy/a is exactly what is under review, and empty when nothing is. Branch names under deploy/ — and the bare name deploy — are reserved and cannot be environments; tags have no proposal ref.
  • Deleting a ref tears the environment down: git push skyr --delete feature-x destroys everything that environment owns, in dependency order. A durable resource — PersistentVolume is the first-party one — is the exception: its destruction is held until a person approves that particular deletion, and the teardown does not finish until somebody does (see "Durable resources: deletion waits for a person").
  • Rolling back is a first-class operation, not a git manoeuvre: every deployment records which one preceded it, and skyr deployments rollback <env> redeploys that predecessor's commit as a new deployment. A deployment with no recorded predecessor is inert, and rolling it back tears the environment down — see the rollback section below before running it.
  • Deleting a whole repository, organization, or account goes further than a ref: skyr repo delete, skyr org delete <org>, and skyr auth delete-account tear down every environment involved, then permanently erase the entity's data — one-way, no undo, gated behind typing the entity's exact name to confirm. The entity stays visible with a deleting status until the purge finishes; while deleting it accepts reads but no pushes or other changes. Deleted user and org names are retired for good; a deleted repository's name frees up for reuse.
  • Every resource has a region (a metro label like "stockholm"), part of its identity, defaulting to the repository's region. Region-placeable resources take a region: Str? input; changing it is destroy + create.

Wiring a repository

Deployment needs three things: the CLI (for inspection, not for deploying), an authenticated identity, and a git remote pointing at the instance.

  • CLI: curl -fsSL https://dl.skyr.cloud/install.sh | sh installs skyr to ~/.local/bin. skyr --version confirms.

  • Identity is a Skyr username plus a registered SSH key — the same key authenticates both git pushes and CLI sessions. skyr auth whoami shows the current session; skyr auth signin --username <u> --key <path> starts one. skyr auth signup --username <u> --email <e> --region <r> creates a brand-new account and registers the key — that mints a real account, so whether (and as whom) to sign up is the user's decision, not a setup step to run through.

  • Org and repo exist server-side before the first push: skyr org create acme --region stockholm, then skyr repo create shop (region defaults to the org's; a --deployment-role defaults to the org's all-access Super role).

  • Remote: plain SSH at the instance host, authenticated by the registered key, with your username as the SSH user — the scp-style remote that skyr repo create prints and the repository's web UI page shows (an ssh://<user>@<host>:<port>/<org>/<repo> URL instead when the instance's SSH endpoint is on a non-default port, which scp-style syntax cannot carry):

    git remote add skyr alice@skyr.foo:acme/shop
    git push skyr main

The CLI infers org/repo from the skyr remote (falling back to origin) and the environment from the current branch. Everything can be overridden with --org/--repo/--env flags or SKYR_ORG/SKYR_REPO/SKYR_ENV/ SKYR_API_URL env vars, and any command takes --format json for machine-readable output.

The deployment lifecycle

A deployment moves through these states:

  • Proposed — pushed for approval, not the environment's head, owning no resources. Compiled, tested and evaluated against live state on the ordinary cadence, but every effect it works out is recorded as a plan for a reviewer instead of being applied: nothing is created, updated, adopted or destroyed while a deployment is Proposed. One per environment. It leaves the state either by being approved — the same deployment becomes Desired — or by being discarded straight to Down, since it owns nothing to tear down. Listed as PROPOSED, held on somebody's decision. See the approval section below.
  • Desired — actively reconciled: evaluate config, create/update/adopt resources. Most deployments start here.
  • Up — converged and every resource is non-volatile; Skyr stops re-evaluating until the next push.
  • Lingering — superseded by a newer push; waits while the new deployment rolls out and adopts shared resources. It keeps serving until the successor converges, and not converged includes a resource the successor cannot yet declare — one whose inputs are still pending — not just one it is still creating. Being unable to state a resource is not the same as no longer wanting it, so the predecessor keeps it alive rather than the successor converging around the gap. The deployment log names each such resource (Waiting to declare <resource>: one of its inputs is still pending, once per stuck declaration, and only on a pass with nothing else in flight — while resources are being created, a declaration waiting its turn is ordinary ordering). Where Skyr can tell which read has not resolved, the line ends by naming it (… (waiting on <resource>)) — the resource to go look at. Where it cannot, the line falls back to listing everything the declaration reads (… (it reads <resource>, <resource>)), which is a hedge rather than a verdict: the culprit is one of them. A resource whose name is itself derived from another resource's output holds the rollout the same way, and is named by type while it waits (Waiting to declare a <type> resource: its own identity is still pending). A branch the successor cannot decide holds the rollout the same way, and for the sharper version of the same reason: a resource declared inside an if whose condition is still pending is never even attempted, so there is no declaration to name. The log says where the program stopped short instead (Waiting at <construct> in <module> (<span>): a pending value settled it, so a region that could declare resources went unexplored), naming the reads it is stuck behind on the same terms as a declaration's line. One unreadable value is one line however many regions it kept the pass out of — a gate in a frontend, the if inside the helper that gate calls — because they resolve together. A branch that could not have declared anything is not a hold at all: picking between two strings leaves nothing undeclared, so a ?? pending fence over a value you only interpolate is silent. Both rules cover every region a pending value skips rather than decides, not only a branch: an operator's right-hand side, an index expression, and the parts of an interpolated string after a pending one are held when they declare and silent when they do not. A declaration whose reads have settled is a dead end — nothing the deployment is doing can resolve it — and says so at warning severity whatever else is going on (Stuck declaring <resource>: … and nothing in flight can resolve it; Stuck at <construct> in <module> … for a branch), followed once by what the hold costs: while it lasts, resources removed from the program are kept rather than destroyed. Changing the program and pushing is the way out; the deployment keeps checking and repairing what it already owns in the meantime, and opens no incident. A stall is a hold, not a failure, and it is carried on the deployment as well as in the log — skyr deployments list counts holds in a HELD column and spells each one out beneath the table, and the API answers them on Deployment.status.holds. That is the surface to check when the log has scrolled past the moment the hold began. The same list carries the other waits that leave a healthy deployment unfinished: an unset, defaultless knob; a read of a resource another deployment has yet to create or update; and an unset knob in another deployment's environment, which waits on a person you may not be. Each names who is expected to clear it, and only the stalled declaration has nobody. The dead-end test asks the narrower set wherever Skyr has one: a declaration waiting on a read that is already final is stuck even while unrelated work is in flight. Where it cannot say which read it waits on, every resource the declaration reads has to have settled before the line is earned. Do not expect the Stuck declaring line for every dead end: a declaration deferred on a pending that reaches it with no resource behind it at all — a bare Std/Plugin.pending a frontend handed over without joining it to a read — keeps the ordinary Waiting to declare line, because Skyr cannot tell that apart from a value still on its way. A rollout that is not converging with only waiting lines in the log is that case; read the program rather than waiting for a warning.
  • Undesired — teardown: resources not adopted by the successor are destroyed in dependency order. A durable resource holds this short of Down: its destroy waits for a person to approve it, and the deployment carries a hold saying so while everything else comes down around it.
  • Down — nothing left; terminal.

A commit's own tests run before anything is applied. When the repository declares any (the scl skill covers writing them), Skyr runs them once for the commit — ahead of evaluating Main.scl at all, so a commit whose tests fail creates, updates and destroys nothing. On a push that means the deployment it would have replaced keeps serving untouched: the successor never adopts a resource and never starts the predecessor's teardown, so the old one sits in Lingering. On a fresh environment it means nothing is created. The blocked deployment stays Desired and retries on the ordinary backoff, still maintaining the resources it already owns — blocking withholds new work, never maintenance — until a commit whose tests pass supersedes it. skyr test runs the same cases locally and is what predicts the verdict; a repository with no test code is unaffected.

Two things legitimately keep a deployment in Desired forever — that is normal operation, not a stuck rollout:

  • Any volatile resource (a Container.Pod, DNS.Zone, or an HTTP.Resource with a check represents external state that can drift, so Skyr keeps reconciling).
  • A branch or tag pin in Package.scle dependencies: the deployment keeps following the foreign ref. Pin commit hashes to settle.

A deployment of only stable resources (keys, random values, artifacts, images) settles into Up.

Pushing for approval

A push to deploy/<env> proposes a deployment instead of deploying one: the environment's head does not move, and the proposal is evaluated continuously — same compile, same test gate, same reads of live resource state — recording what it would do rather than doing it. Approving promotes that same deployment.

git push skyr HEAD:deploy/main            # propose
git fetch skyr                            # then, the change under review:
git diff skyr/main..skyr/deploy/main
git push skyr :deploy/main                # withdraw it
skyr deployments approve main             # or: reject main
  • Two verbs, and neither implies the other. environment:PushForApproval on the environment gates any push to deploy/<env>, withdrawal included — deliberately not environment:Delete, since nothing real is deleted. environment:ApproveDeployment gates both approving and rejecting, and implies neither push nor delete authority. A first push through deploy/… into an environment that does not exist yet pairs its verb with environment:Create, as a first direct push does.
  • Protection is IAM, not a setting. There is no protected-environment flag and nothing badges one. An org protects prod-* by granting environment:Push on acme/shop::prod-* to nobody (or one release role) while granting PushForApproval broadly, and ApproveDeployment to reviewers. Grants only add — there are no deny rules — so a wildcard environment:Push on acme/shop::* elsewhere undoes it, and the verb matcher environment:Push* grants both verbs. Super bypasses all policy and can always push directly; the expressible posture is narrow day-to-day roles plus role:Assume for the wide one. Self-approval is allowed — four eyes means not granting both verbs to one role. Note that PushForApproval is not read-only: a proposer's objects are unpacked into the repository's object store even if the proposal is never approved.
  • What discards a pending proposal: any promotion of the head (a direct push, the API's deploy/commit mutations, a rollback, or the approval itself), a newer proposal, a rejection, a withdrawal, and deleting the environment — provided that deletion actually retires a deployment. Discarding goes straight to Down; a proposal owns nothing to tear down.
  • The review surface. The proposal's resource graph is the plan: nodes are certain (what approving does) or merely possible (a region fenced behind a pending value), and each says which transition it would make — create, update, keep, or destroy — with an empty set meaning "not decided", never "nothing happens". The plan can be clipped for size or have parts withheld for permission, and either voids any conclusion drawn from what is absent: while one is true, a resource missing from the plan is not one the proposal would leave alone. An empty plan means the pass never got as far as evaluating (compile error or failed tests), or has not reported yet, or the configuration declares nothing and there is nothing live for approving to strand — never "this does nothing".
  • The plan also says what approving would delete. A promoted deployment adopts what it declares and nothing else, and whatever the deployment it replaces still owns is torn down behind it — so the plan draws every resource the environment holds now that the proposed configuration names nowhere, as the resource it is with a destroy on it. That is deletion by absence, and which kind of node carries the destroy is what tells the two apart: on a declaration it is one the configuration asked for, on an existing resource it is the opposite — nothing asks for it, and the deletion is the whole of what the plan's silence about it amounts to. The review counts them apart from declared destroys and hedges the unsure ones ("to delete", "may be deleted"), and draws them in the graph with their dependency edges, so a doomed subtree hanging off a resource that stays reads as one.
  • A deletion is certain only where the plan can be believed silent. No declaration names the resource, so the reading is decided by the plan — and, in one case, by the resource's own type: nothing clipped for size, no region left unexplored, and every declaration whose name is still unsettled ruled out by its type. Clipping or an unexplored region hedges every deletion; an unsettled name hedges only the resources whose type it could still turn out to name, so certain and hedged deletions sit side by side in one plan. A configuration declaring nothing at all is the extreme: it reads as the whole environment marked for deletion, the most destructive approval there is — but only where the last report succeeded and is at least as recent as the plan, which is what keeps the equally empty plan a failed pass leaves behind from ever reading that way. A vouching report can lag by a pass, and in that window the same configuration shows as "No declarations" with no teardown on it; the next pass settles the vouching. And a plan that could not be read at all arrives empty and clipped, which is neither of those: it strands nothing, and is reported as clipped rather than as declaring anything.
  • What the deletions leave out. Sticky resources are never among them: they are marked to outlive whoever declared them (Artifact.File is one), so being left unnamed is no fate for them. A durable resource is named, but approving the deployment does not destroy it: the destroy that follows is held for an approval of its own (see "Durable resources: deletion waits for a person"). And the set is a floor on both surfaces, the counts and the graph alike — it is filtered by your resource:View one resource at a time, and one you may not see is dropped with no flag saying so, since that it exists at all is exactly what a denial withholds. The review's warning that part of the plan names resources you may not see is about what the plan names; its absence promises nothing about the deletions being complete.
  • The verdict does not block the decision. A proposal that fails its tests or does not compile opens the usual TestFailure/BadConfiguration incident and can still be approved; approving clears none of it. That is safe because approval skips nothing — the promoted deployment runs the same gate again, and a commit whose tests fail materializes nothing while the predecessor keeps serving. Read the verdict anyway: approving a failing proposal buys a held rollout.
  • Decisions bind to one exact deployment. The CLI resolves the environment's pending deployment, prints the commit, what deciding does, the holds and any open incident, then makes you type the environment's QID (--yes states it up front, and is required with no terminal). A newer proposal landing in between makes the decision refuse, saying what became of the one you decided about and to go and read the pending one. Both decisions are gated, unlike a rollback's restoring outcome, because the operand is somebody else's commit and is never in argv.
  • An environment can be born proposed — a first push to deploy/newenv gives it a deploy/newenv ref, no head, and nothing running. It exists (it is listed, it has a page) but has no current deployment. Clearing it takes a withdrawal, a rejection, or an API teardown: there is no newenv ref for git push skyr :newenv to delete, and a deletion that retires no deployment discards no review, so environment:Delete alone cannot clear it over Git.
  • Nobody is notified. A waiting review is found by looking — the environment's page, or skyr deployments list, where a proposal is an ordinary row in state PROPOSED with its hold (its RESTORES cell is blank: rollback lineage is recorded at promotion, and a proposal cannot be rolled back).

Durable resources: deletion waits for a person

Some resources hold data nothing in a configuration can put back, and those are durable: Skyr never destroys one on a reconcile pass's own authority. Container.PersistentVolume is the first-party durable type — its contents are the volume. Durability is a per-resource marker, answered on every transition like volatility and stickiness, so it is a fact about the resource rather than about its type; the web shows it as a marker on the resource. A volume that should not be held that way declares deletionProtection: false, and is then destroyed like any other resource; flipping the field is an ordinary in-place update that leaves the data alone and, like every update, withdraws a request already waiting on the volume. skyr resources list carries no durability marker — what it shows is where a deletion has got to: a DELETION column reading PENDING or APPROVED, empty for a resource holding none (--format json carries a deletion_approval object).

  • What a held destroy looks like. The first Destroy — from a teardown, from dropping the declaration, or from skyr resources delete — is recorded as a request and stops there. Nothing is destroyed, the resource's log says Awaiting approval to destroy, and everybody whose role may approve it is notified. Where the destroy came from a deployment — a teardown, or a program that dropped the declaration — that deployment keeps re-asking every pass and carries a hold: skyr deployments list counts it in HELD and spells it out ("Waiting for the deletion of <resource> to be approved …"), and the deployment page shows it under Health. That hold is why an Undesired deployment sits without reaching Down; it is not a failure and opens no incident. A skyr resources delete of a resource its deployment still declares is one request rather than a standing one — nothing re-asks for it and no hold is reported — but the request stands until somebody approves it or the resource is reconciled again.

  • How to approve it.

    skyr resources approve-deletion stockholm:Skyr/Container.PersistentVolume:data
    skyr resources approve-deletion Skyr/Container.PersistentVolume:data --yes

    It prints what is held (resource, environment, when it was first held, the declaring deployment) and makes you type the resource's full QID back; --yes states that up front and is required with no terminal. It refuses, sending nothing, when the resource is not there, when nothing is awaiting approval, and when the deletion was already approved — naming that first approver and when. The web has the same decision on the resource page and on the resource list; the API is approveResourceDeletion(resource: <QID>).

  • Approving and deleting are separate permissions. Approving needs resource:ApproveDeletion on the resource; it lifts the hold and destroys nothing, and it implies no resource:Delete. So skyr resources delete on a durable resource has to be run again after approval — the first run only requested it, the second one carries it out under the delete permission. (The web does that second call for you when the approver holds both; the CLI does not.) An approval is recorded once and never re-stamped — a second approver is told who decided it and when — and it outlives what it released: the tombstone says who approved the deletion and when.

  • Withdrawing is declaring it again. Any non-destroy transition — create, update, adopt, check — clears the request, approved or not, so pushing a configuration that still declares the resource is how to keep it. A deployment merely continuing to run withdraws nothing: an unchanged, non-volatile resource is given no transition at all, so it takes a push or a changed declaration. A later destroy needs a fresh approval; the cleared one is not redeemable.

  • It still works during a repo or org deletion. resource:ApproveDeletion is in the delete family, so the freeze a deleting entity is under lets it through — otherwise the cascade would wait forever on a decision nobody was allowed to make. It is never grantable to Anonymous.

Rolling back

Every deployment Skyr promotes records its rollback target: the deployment that was current immediately before it. A rollback redeploys that target's commit as a fresh deployment — no old deployment is revived — through the same compile → test → evaluate → converge path as any push.

skyr deployments rollback main
skyr deployments rollback main --reason "checkout error rate spiked after 3f2a1c"
skyr deployments rollback main --reason-file ./why.txt   # `-` reads stdin
  • It is ordinary safe supersession. The deployment being rolled back goes Lingering and keeps serving until the rollback deployment bootstraps. If the restored commit no longer compiles or its tests now fail, nothing is torn down and the serving deployment carries on. Pushing again recovers.
  • Rollbacks compose. A rollback result takes its commit from the target but inherits the target's own target, so repeated rollbacks walk back one deployment at a time. Deployed A -> B -> C: rolling back C gives B' (whose target is A), rolling back B' gives A' (no target).
  • A deployment with no target tears the environment down — the one dangerous outcome here. It does not fail and does not no-op: it destroys every resource in the environment, exactly as git push skyr --delete would. Three kinds of deployment have no target: an environment's first one, a rollback result that restored such a first one, and any deployment created before the instance recorded rollback lineage (it is never backfilled, so one ordinary push is what gives such an environment a target again). Check before acting: skyr deployments list prints none (teardown) in RESTORES for an inert deployment. The CLI prints the consequence and then makes you type the environment's QID for that outcome (--yes states the intent up front, and is required with no terminal to prompt on — a piped stdin, or one already spent on --reason-file -). The restoring outcome is deliberately ungated. The git ref survives a teardown, so pushing re-creates the environment.
  • The request binds to one deployment. The command takes an environment but resolves its current deployment and submits that exact one, so a push landing in between makes the rollback refuse and name what is current now. Retries are safe: the plan is recorded durably before anything happens, so a repeat, a crash, or a racing request all resolve to that same rollback.
  • Reasons are kept verbatim as permanent provenance, newlines included: at least one non-whitespace character, at most 512 characters. --reason and --reason-file conflict. Omitted, the recorded default names the asker — Manual rollback for a person, Rollback condition reached for Rollout.Rollback.
  • Permission is environment:Rollback on the environment, covering both outcomes, teardown included. It implies neither push nor delete authority and is never anonymous. A rollback from config runs on the deployment role, which must be granted the verb explicitly.
  • History answers all of it and skyr deployments list prints it: RESTORES (target, or none (teardown)), ROLLED BACK (-> <id> or torn down), ROLLBACK OF (this row is itself a rollback result), plus each reason verbatim beneath the table. Two reading rules: RESTORES is lineage, not an offer — it is filled in for Lingering and Down rows too, and only the current deployment can actually be rolled back; and a successor id is reserved before its row exists, so a reference to it can briefly resolve to nothing and is expected to appear on the next poll.

From the config itself. Rollout.Rollback is the same operation as a resource, so a deployment can roll itself back:

import Skyr/Rollout

// Whatever your code decides from — a knob, a version, or a pod's own
// `health` output (`.degraded`/`.failing` once that pod has worked at least
// once; pending before then, so a condition over it simply waits).
if (degraded)
    Rollout.Rollback({ reason: "Health check remained degraded" })
else
    nil

Declaring it is the request; there is nothing to read off it, and whether to roll back is ordinary control flow rather than an input. The record is required even when empty — Rollout.Rollback({}); Rollout.Rollback() does not compile, since SCL has no omittable arguments. Identity is this deployment plus the reason and nothing else, so: the same reason twice in one deployment is one request; two different reasons are two requests that the platform serializes to one recorded rollback; a reconcile retry asks for the same one; and the same reason in a later deployment is a new request — a condition still true after the rollback rolls back again, one step further. Every attempted reason lands in the deployment log even when it loses the race or is refused. The deployment role needs environment:Rollback, granted in the repository's own IAM.Policy.

This is the surface with no interlock, unlike the CLI and the web dialog: it asks nobody and runs on a reconciliation pass, so if the declaring deployment has no rollback target the declaration tears the environment down unattended. Check the environment's RESTORES before wiring one up, and note that each rollback leaves a deployment whose own target is one step further back — a condition still true afterwards walks the lineage down to the inert one.

From a pod to a public domain

The complete path from "a container runs" to "https://example.com serves it". Each stage is deployable on its own; later stages extend the same file.

Run a container

import Skyr/Container
import Std/Path

let image = Container.Image({
    name: "web",
    context: ./app,
    containerfile: Path.read(./app/Containerfile),
})

let pod = Container.Pod({
    name: "web",
    containers: #{ "web": {
        image: image.url,
        cpu: 500,          // millicores; hard limit and reservation
        memory: 268435456, // bytes (256 MiB); hard limit and reservation
    } },
})

Image builds from a directory in the repo and pushes to the instance registry; image.url is digest-pinned. Public images ("caddy:2") work directly as image values. containers is a name-keyed map (#{ "name": { ... } }); the key names the container in logs and, for containers that can finish, in the pod's exitCodes output. cpu and memory are required on every container — both are hard limits. Containers in the same pod share a network namespace, so siblings reach each other on localhost.

Running something other than the image's default. A container starts its image's own ENTRYPOINT/CMD unless it says otherwise. The optional execute field replaces exactly one of those two halves — .arguments([…]) keeps the entrypoint and replaces the CMD (the flag-passing case, which needs an image that has an ENTRYPOINT: on a CMD-only image the first argument is exec'd as the program and the container dies), .command([…]) replaces the entrypoint and drops the CMD (the whole command line — one image serving both a service and a migration, and the right choice for a CMD-only image). Nothing parses a shell, so a pipeline is .command(["/bin/sh", "-c", "a | b"]), and an empty argv is rejected. An argv takes no .secret(…) and is stored, logged and hashed verbatim — pass credentials through env. workingDirectory: Str? replaces the image's WORKDIR and must be an absolute path; a tenant container's rootfs is read-only (unless it sets privileged: true — see Privilege under Other capabilities), so the directory must exist in the image or be one of its mount paths. Both are part of the pod's identity, so changing either recreates the pod.

containers: #{ "migrate": {
    image: image.url,        // the same image the service runs
    cpu: 500,
    memory: 268435456,
    execute: .command(["/app/bin/migrate", "--yes"]),
    workingDirectory: "/app",
    restart: .onFailure,
} }

Narrowing the build context. Everything under context goes to the builder, so a COPY . . bakes in whatever is lying around — node_modules, a stray .env, a local build directory. The optional ignorefile input excludes paths, and like containerfile it takes the file's content, not a path to it: ignorefile: Path.read(./app/.containerignore), or the patterns written inline. The syntax is Docker's and podman's, not gitignore's — one pattern per line, # comments, anchored at the context root (node_modules excludes the top-level entry only, **/node_modules excludes it at any depth), * and ? never crossing a /, and a leading ! negating with the last matching pattern deciding (* followed by !dist ships dist/ and nothing else). There is no filename convention: a .containerignore sitting in the context is an ordinary file until something passes it as this input, so the patterns may live anywhere or come from any file. Excluded files never reach the builder at all, and a line that isn't a valid pattern fails the build naming it.

Open ports — how ingress works

Every pod gets a public, internet-routable IPv6 (pod.address). By default it is inert for ingress: the pod's firewall only accepts connections from internal networks the pod is attached to, and the IPv6 serves egress. Until the backend has allocated it, pod.address reads as pending, so resources built from it defer. Opening a port changes that:

let http = pod.Port({ port: 8080, public: true })
// http.address is "[<pod-ipv6>]:8080". Until the pod's address has been
// allocated it reads as pending, so resources built from it defer.

public: true opens the port to the internet — both on the pod's IPv6 and on any bound IPv4 address (next section). Without it the port only accepts traffic from attached internal networks.

protocols defaults to [.tcp]; .udp is the other member. One call is one opening, so a service that shares a port number across transports names them together rather than declaring the port twice:

pod.Port({ port: 443, protocols: [.tcp, .udp], public: true })  // HTTP/2 + HTTP/3
pod.Port({ port: 53, protocols: [.tcp, .udp] })                 // DNS

Order and repeats don't matter — the opening is the set — and an empty list is refused rather than treated as the default.

A stable public IPv4

The pod's IPv6 changes when the pod is replaced (its identity hashes its configuration). For a stable entry point, allocate an InternetAddress and route it to the pod:

let ip = Container.InternetAddress({ name: "front-door" })

let pod = Container.Pod({
    name: "web",
    containers: #{ "web": { image: image.url, cpu: 500, memory: 268435456 } },
    internetAddress: ip,
})

ip.address is a public IPv4 that survives pod replacement and redeploys. Like pod.address it reads as pending until the allocation lands. An address routes to exactly one pod at a time, anywhere in Skyr — two pods in one program naming it is an eval-time error, and a pod elsewhere trying to take an address another pod already holds fails the deploy, naming the holder. (Replacing a bound pod is fine: the successor in the same environment takes the claim over.) The claim is released when the bound pod stops naming the address or is deleted. Binding needs resource:BindInternetAddress on the address, and the pod must be in the address's region. The pod's firewall still applies: only public: true ports accept internet traffic on the address.

Serve public traffic on IPv6. A pod's public entry point is its IPv6 address, and a bound InternetAddress (public IPv4) is delivered to the pod translated to that IPv6. So a workload that must be reachable from the internet has to listen on IPv6 — bind [::] (or dual-stack), not only 0.0.0.0. A process listening on IPv4 0.0.0.0 alone will not receive public traffic, including traffic to a bound InternetAddress.

A custom domain

Skyr/DNS serves authoritative DNS for user-owned domains:

import Skyr/DNS

let zone = DNS.Zone({ domain: "example.com" })

zone.ARecord({ name: "@", addresses: [ip.address] })
zone.AAAARecord({ name: "@", addresses: [pod.address] })
zone.CNAMERecord({ name: "www", target: "example.com" })
  • The zone's nameservers output lists four hostnames under the instance apex. Delegating the domain to them at the registrar is what makes Skyr authoritative — the delegation itself is the proof of ownership. The volatile status output reports what public resolvers see as an enum atom: .delegated, .partial, .wrong, or .pending.
  • Record names are relative to the apex: "@" for the apex, "*" for a wildcard. Available types: ARecord, AAAARecord, CNAMERecord (not at the apex), ALIASRecord (apex-safe server-side alias), TXTRecord, MXRecord, SRVRecord, NSRecord (sub-delegation), CAARecord.
  • Bad inputs raise DNS.InvalidDnsInput at evaluation time: a name outside the grammar, a CNAME or NS at the apex, a CAA flags outside 0–255, and a ttl/defaultTtl that is not a whole number of seconds from 1 to 2147483647 — which rules out anything sub-second or fractional, and anything past roughly 68 years. Use a multiple of Time.second, Time.minute, Time.hour or Time.day. The raise fails at the offending call rather than at the plugin; skyr run surfaces it locally, and skyr check does not evaluate, so it does not. A calendar-month span (Time.month, Time.year) is refused earlier still: a TTL is a Time.Duration, a fixed length, and a calendar span is a Time.CalendarDuration — a type error, which skyr check does report. Records default to the zone's defaultTtl, itself 5 minutes unless set.
  • The AAAA record above tracks the pod's IPv6 because Skyr re-evaluates the config and updates the record when the pod is replaced. Anything outside Skyr should reference the stable IPv4 or the domain, never a pod IPv6.

TLS

Skyr routes TCP to the pod; it does not terminate TLS — certificates live in your containers. For public HTTPS the practical pattern is a Caddy sidecar: it obtains and renews ACME certificates automatically and reverse-proxies the app over localhost:

let caddyfile = Container.ephemeralVolume({
    files: #{ "Caddyfile": .literal("example.com\n\nreverse_proxy localhost:8080\n") },
})

let pod = Container.Pod({
    name: "web",
    internetAddress: ip,
    containers: #{
        "app": { image: image.url, cpu: 500, memory: 268435456 },
        "caddy": {
            image: "caddy:2",
            cpu: 250,
            memory: 134217728,
            mounts: #{ "/etc/caddy": { volume: caddyfile, readOnly: true } },
        },
    },
})

pod.Port({ port: 80, public: true })   // ACME HTTP-01 challenge + redirect
pod.Port({ port: 443, public: true })

(The domain must already resolve to the pod for ACME to succeed.) The zone's CAARecord can restrict which CAs may issue for the domain.

Alternatively, obtain the certificate itself declaratively with Skyr/PKI/ACME and mount the PEM into whatever terminates TLS — no in-container ACME client. DNS-01 composes with Skyr/DNS and is the only method that issues wildcards:

import Skyr/PKI
import Skyr/PKI/ACME

let key = PKI.ECDSAPrivateKey({ name: "web-tls" })

let account = ACME.Account({
    name: "prod",
    directoryUrl: "https://acme-v02.api.letsencrypt.org/directory",
    contacts: ["mailto:ops@example.com"],
    agreeToTermsOfService: true,
})

let cert = account.DNS01Certificate({
    privateKey: key.pem,   // the sealed key's Secret Version QID, never a PEM
    domains: ["example.com", "*.example.com"],
    zone: zone,   // the DNS.Zone above — it satisfies the ChallengeZone façade
})
// cert.certificate is pending until issued (status `.issued`), so anything
// mounting cert.certificate.pem is ordered after issuance. Skyr publishes the
// challenge records, drives validation, and renews automatically with no gap.

directoryUrl must be an https CA on the public internet: a deployed account refuses a loopback, private, link-local, or otherwise non-public directory (an internal CA is reachable only under skyr run, which hosts the plugin on your own machine). The same restriction applies to the hosts the challenge self-check reaches, so an HTTP-01 domain and a DNS-01 zone's nameservers have to resolve publicly — but there it shows up as a certificate that waits in its challenge state rather than one that fails, since an unreachable host is indistinguishable from a challenge that is not in place yet.

Certificates/chains are public PEM outputs; the private key stays sealed in the secrets vault — deliver it by seeding an ephemeral volume with .secret(key.pem) and mounting that volume into whatever terminates TLS:

let tls = Container.ephemeralVolume({
    files: #{
        "tls.crt": .literal("{cert.certificate.pem}{cert.certificate.chainPem}"),
        "tls.key": .secret(key.pem),
    },
})

// …inside the terminating container: reads /run/tls/tls.{crt,key}
mounts: #{ "/run/tls": { volume: tls, readOnly: true, userId: 1000 } }

A volume carrying any .secret is mounted owner-only (0700 root dir, 0600 files — an explicit permissions on the mount overrides the mode) owned by the mount's userId/groupId, defaulting to root — so name the uid the image's USER runs as, or the process cannot read its own key. Grants are explicit: the deployment role needs secret:View/Write/Delete on the key's and the ACME account's resource-scoped secrets (an IAM.Policy with wildcard objects like "<org>/<repo>::*" covers them — same stanza as the Secrets bullet below).

For private or internal chains, Skyr/PKI generates keys and signs CSRs in-config.

Private networking

Pods reach each other privately over a Network — a virtual layer-2 network with an RFC1918 CIDR (/16/30):

let net = Container.Network({ name: "app", cidr: "10.42.0.0/24" })

let api = Container.Pod({
    name: "api",
    containers: #{ "api": { image: image.url, cpu: 500, memory: 268435456 } },
    networks: #{ "app0": net },
})

// Internal DNS: resolvable as api.app.internal by attached pods.
net.dns.ARecord({
    name: "api",
    addresses: [api.networkAddresses["app0"] ?? ""],
})

networks is keyed by interface name (not eth0/lo). Each pod gets an inner IPv4 per attachment in networkAddresses, keyed the same way; every interface you attached is present, reading as pending until its address is allocated, so the record above defers instead of publishing an empty one. Attached pods can also open non-public ports to accept internal-only traffic. Internal DNS records require the network to have a name and resolve as <record>.<netname>.internal ("@" for the network apex). A pod may not attach two networks that share a name — the lookup would be ambiguous, so it's rejected at eval. Traffic on a Network never leaves the private plane and is not metered.

An internal DNS name belongs to whoever publishes it first: a name another environment already holds on the same network is not overwritten — the deploy fails, naming the holder — and only the holder can change or remove its own record. Removing it always works, even after the holder's grant is withdrawn; changing it goes through the grant. Attaching needs resource:AttachPodToNetwork on the network; publishing needs the separate resource:AttachDnsRecordToNetwork.

Routing between networks

A Network on its own is a closed island. Container.Router connects several of them and routes between them, with an optional stateful ACL. There is no peering resource — a router is a machine on each LAN:

let corp = Container.Network({ name: "corp", cidr: "10.42.0.0/24" })
let dmz = Container.Network({ name: "dmz", cidr: "10.43.0.0/24" })

Container.Router({
    name: "edge",
    networks: #{ "corp": corp, "dmz": dmz },
    defaultAction: .deny,
    rules: [
        { source: corp.cidr, destination: dmz.cidr, action: .allow },
    ],
})
  • Real addresses, no NAT. Pods reach peer-network pods at their own inner IPv4s and keep exactly the interfaces they declared. A deployed router draws an ordinary address on each member network — reported in addresses, keyed by the member labels, and answering ping and nothing else. A router's networks keys are labels, not interface names: no interface, no DNS meaning.
  • Member CIDRs must be disjoint. Overlapping members are an eval error; a member whose address space is already routed in the closure it joins fails the deploy, naming the CIDR and your own member label (never the foreign network). Two deploys racing into an overlap resolve deterministically — the earlier-established membership wins and the loser's space is routed nowhere.
  • Any member count is valid, zero and one included (a useful intermediate state while a handle or grant is pending). Members may live in different regions; a router takes no region input.
  • ACL: rules match top to bottom, first match wins, falling through to defaultAction (.allow by default; omit both and everything routes). Stateful — replies on established connections always pass, so rules govern who may initiate. source/destination are CIDR strings matched on real addresses ("0.0.0.0/0" = anything), and transit passing through is filtered by the same rules. The router is the only filter — there's no second, per-pod rule set to keep in step. It governs access, not exposure: denied traffic still crosses the private network before it's dropped at the destination's boundary, so an ACL is not a shield against volume.
  • Chaining is transitive. Two routers sharing a network route through each other, so joining a router gets you everything it routes, including onward routers — reachability exists only where a chain creates it. Source addresses can't be forged across a router; there's nothing to configure.
  • Updates are in place. Changing networks, rules or defaultAction reconverges the router on every node within seconds — surviving members keep their addresses and no pod is recreated; only a new name is a new router. The router is rebuilt as it reconverges, so expect a brief window where it forwards nothing. That makes the ACL the router owner's prompt kill switch.
  • DNS follows routability, not the ACL. A pod also resolves <record>.<netname>.internal on named networks it can reach — even through a deny-all router (a name isn't traffic). Routed names are qualified only (search domains stay the attached netnames), direct attachment shadows routing, and two routed networks sharing a netname resolve to NXDOMAIN. Give the networks of a shared fabric distinct names.
  • Membership needs resource:AttachRouterToNetwork on each member network, checked on every transition of the router.

Cross-org: the DMZ pattern. Never make your network a member of the other org's router. Org A declares a small transit network T and grants org B's deployment role AttachRouterToNetwork on T — the only cross-org grant. Each side then connects with a router it owns (A: its network + T; B: its network

  • T), and chaining carries traffic between them. Each side's ACL sits on its own router, so each keeps a kill switch that converges in seconds. Revoking the grant is lazy and freezes rather than evicts (the router's next transition fails; established routes stay), and a network owner who doesn't own the adjacent router has no lever short of deleting the network — which is exactly why each side brings its own router.

Under skyr run routers are emulated: same reachability, same ACL, same DNS closure, and member networks are isolated from each other absent a router. Four local-only divergences, none of which a deployment produces: every local pod also shares the skyr-run-default network (so isolation is faithful for member-network addresses, not for the shared ones behind them); DNS records are injected at pod creation while routes converge live, so a running local pod can hold a route to a network whose names it can't yet resolve; a local router's address on a member network is that network's gateway (the reserved first host) rather than a pool address, which is what addresses reports locally; and those addresses answer more than ping, since they are the bridge gateways local name resolution and internet egress already go through.

Sharing networks, volumes, and addresses across repos

Networks, persistent volumes, and internet addresses are shareable across repositories: the owner exports the handle its constructor returned, the consumer imports it via a Package.scle dependency (the scl skill covers imports). The identity strings on those handles are opaque and carry their owner — guessing one is not a route in, and a hand-assembled handle is refused where a pod attaches, mounts, or binds it.

Using a shared resource needs the read chain (repository:View + environment:View + resource:View) plus the use verb, whose object is the used resource's full QID. One policy in the owner's config covers it:

IAM.Policy({
    name: "app-uses-platform",
    subjects: ["acme/app::*:Skyr/IAM.Role:app-deployer"],
    verbs: ["repository:View", "environment:View", "resource:View",
            "resource:AttachPodToNetwork", "resource:MountVolume"],
    objects: ["acme/platform", "acme/platform::main",
              "acme/platform::*:Skyr/Container.Network:*",
              "acme/platform::*:Skyr/Container.PersistentVolume:uploads"],
})

The five use verbs are resource:AttachPodToNetwork, resource:AttachRouterToNetwork, resource:AttachDnsRecordToNetwork, resource:MountVolume, and resource:BindInternetAddress. (Two more verbs sit outside the standard family without being use verbs at all: resource:ForwardPodPort and resource:ExecInPod are checked when an operator reaches into a running pod, not at any transition — see "Watching a rollout".) Enforcement is uniform — your own environment's resources are checked too; it's invisible only because a repo's deployment role defaults to the org's Super role, which short-circuits within that org. A restricted role needs the grants for its own resources as well. A refusal names the verb and the object QID, plus the acting role when the deployment presented one.

Revocation is lazy: it bites on the holder's next transition, never detaching a running pod. There is no owner-initiated eviction, so a resource you share can be held by its current user until they release it — for a shared InternetAddress the owner's only forced remedy is delete + recreate, which changes the public IP; for a network someone else's router routes, the prompt lever belongs to whoever owns that router (its ACL), which is why cross-org connections are shaped so each side owns the router in front of its own network. Dropping an attachment/membership/mount/binding always succeeds, even after the grant is gone.

Restart, jobs, and scheduled runs

There is no Job or CronJob resource — run-to-completion and scheduling are per-container policy on an ordinary Container.Pod. Each container takes five optional policy fields:

  • restart.always (default: restart on any exit — crash recovery for services and sidecars), .onFailure (restart only on a non-zero exit; a zero exit is final — retry-until-success), or .never (any exit is final). Restarts run in place with automatic exponential backoff. The default means every pod gets crash recovery for free.
  • keepAliveBool, default true. The pod is reaped (node resources freed) once every keep-alive container has terminated. At least one container must keep the pod alive (else rejected at eval). A keepAlive: false sidecar is stopped when the pod reaps.
  • maxRetriesInt?, valid only with .onFailure; absent means retry forever, N permits N + 1 executions before the last failing exit is final.
  • timeoutTime.Duration?, a per-attempt kill budget. A Time.Duration is a fixed length (a multiple of Time.second/Time.day/…); a calendar-month span is a Time.CalendarDuration and does not type-check here.
  • probeProbe?, absent by default: how Skyr asks a container whether it is still serving, for a workload that can stop serving without exiting. { kind: .http({ port, path? }) } (2xx/3xx passes) or { kind: .tcp({ port }) } (a completed connection passes), plus optional initialDelay/interval/timeout/startupWindow (each a Time.Duration, so a fixed length, and positive) and failureThreshold (a positive count), each defaulted by Skyr when omitted. The check runs from inside the pod, so the port need not be one pod.Port opened — and usually should not be. One probe covers startup, readiness and liveness, because its role changes with the container's phase: before its first success it is readiness (a failure kills nothing, so a slow start is safe; a container still failing after startupWindow is restarted as hung), after it is liveness (failureThreshold failures in a row stop the container). Its first success is also what the pod's readiness waits for, which is what a ReplicaSet replica built from the pod stands on. A probe never restarts anything by a route of its own — it stops the container and restart decides, so a workload that hangs sick gets the same backoff, retry cap and crash-loop judgment as one that exits sick. Adding, changing or removing a probe recreates the pod. skyr run accepts one and does not execute it.

A job is just a pod whose container uses .onFailure/.never; job-ness is implied by configuration, never declared.

import Skyr/Container
import Std/Time

// A one-shot migration: retries until it exits 0, then the pod reaps.
let migrate = Container.Pod({
    name: "migrate",
    containers: #{ "migrate": {
        image: migrateImage.url,
        cpu: 500,
        memory: 268435456,
        restart: .onFailure,
        maxRetries: 5,
        timeout: Time.multiply(Time.minute, 10),
    } },
})

Completion surfaces as the pod's exitCodes: #{Str: Int} output — the final exit code of each container that can reach one, keyed by name. Only .onFailure and .never containers are keyed; a .always one restarts forever and has no key, so reading it yields nil. A key that exists is a pending value until that container's final termination, so you sequence and gate with plain control flow, keying the specific container:

// `serve` is created only once the migration exits cleanly; while the code is
// pending the `if` condition is pending, neither branch runs, and the pass says
// so — it is a branch that could have declared and did not, which holds the
// rollout exactly as a resource with a pending input does.
let serve = if (migrate.exitCodes["migrate"] == 0)
    Container.Pod({ name: "serve", containers: #{ "web": { image: webImage.url, cpu: 1000, memory: 536870912 } } })

The gate lives in the if condition, not the pod's inputs, so serve's identity stays stable — and gating on migrate is an edge all the same, so teardown destroys serve first and migrate outlives it. An else branch handles remediation on a final non-zero exit.

Two consequences of unkeyed .always containers, both quiet: a comparison against a missing key is nil == 0false immediately, taking the else branch rather than waiting, so a gate naming the wrong container looks like a gate whose condition is merely false (compare against nil when you mean "has it terminated"); and a fold over the whole map no longer wedges on pending, but over a pod of nothing but .always containers it folds an empty map, where List.all is vacuously true. Key the container you mean.

Cron is a job pod whose name embeds a tick value from Time.now (or Time.tick(interval, offset) for an offset schedule like "04:00 UTC daily"). Each new tick is a new resource identity, so the new-named pod is created and the previous destroyed:

let tick = Time.now(Time.hour)
Container.Pod({
    name: "hourly-{tick.epochMillis}",
    containers: #{ "report": { image: reportImage.url, cpu: 500, memory: 268435456, restart: .onFailure } },
})

Accepted limits: concurrency is Replace only (a tick boundary kills a still- running previous pod — keep timeout under the interval), missed ticks are skipped (no catch-up), a pod's recorded history evaporates one window later, and boundaries are UTC only. Completion is at-least-once, so jobs must be idempotent — a node death before completion is recorded re-runs the job. Full reference: curl -s https://skyr.foo/~docs/jobs.md.

Other capabilities

  • Volumes. Container.PersistentVolume({ name, size }) is region-scoped storage that outlives pods (min 8 MiB; a pod can only mount volumes from its own region). Container.ephemeralVolume({ files, size, name }) is scratch space living and dying with the pod, optionally seeded with files: #{ "<path relative to the mount root>": .literal("…") } — values are the same two arms as env (.literal(…)/.secret(qid)), and a bare string is a type error (see the Caddy example). Mounting a seeded volume is how files reach a container — there is no pod-level file input. Mount via a container's mounts dict keyed by absolute path: #{ "/data": { volume: v } }, with optional readOnly/subPath/permissions/userId/groupId; two containers mounting the same ephemeral volume must spell the same permissions and owner. Read-only mounts of seeded ephemeral content update in place when the content changes, unless the new content outgrows the disk the pod claimed (each non-empty file rounds up to a whole 4 KiB page, so adding one to a volum

Truncated - read the full file at https://github.com/skyr-cloud/agent-plugin/blob/ff3501267a45aea02eca9b1d2e478e549cd3a48b/skills/deploy/SKILL.md.

Use it

Copy one of these into your project. Installing also returns the manifest and these snippets.

yaml
targets:
  - https://api.opensmartroute.ai/api/v1/registry/skyr-cloud-agent-plugin-deploy/manifest   # or paste the manifest below

Manifest

An Open Capability Manifest: the router reads it to know what this does, what it costs and when to pick it.

skyr-cloud-agent-plugin-deploy.ocm.jsonjson
{
  "ocm": "1",
  "id": "skyr-cloud-agent-plugin-deploy",
  "kind": "skill",
  "name": "deploy",
  "description": "How deployment to Skyr works: pushing to the skyr git remote, environments and the deployment lifecycle, pushing a deployment for approval and approving or rejecting one, rolling a deployment back, exposing pods to the internet (ports, InternetAddress, DNS zones), private networking and routing between networks, sharing networks, volumes and addresses across repositories, first-party plugin capabilities, approving the held deletion of a durable resource such as a persistent volume, checking rollout status and incidents, and reaching into a running pod (port forwarding, running a command or a shell in a container). Use when deploying to Skyr, wiring a repository to a Skyr instance, exposing a service publicly, connecting private networks, protecting an environment behind approval, rolling back a bad deployment, approving a deletion a teardown is waiting on, debugging a rollout, or getting a shell inside a deployed container.",
  "publisher": "skyr-cloud",
  "version": "1.0.0",
  "capabilities": {
    "domains": [
      "coding"
    ],
    "tags": [
      "skill-md",
      "github"
    ],
    "languages": [
      "en"
    ]
  },
  "quality_prior": 0.6,
  "examples": [
    "How deployment to Skyr works: pushing to the skyr git remote, environments and the deployment lifecycle, pushing a deployment for approval and approving or rejecting one, rolling a deployment back, exposing pods to the internet (ports, InternetAddress, DNS zones), private networking and routing between networks, sharing networks, volumes and addresses across repositories, first-party plugin capabilities, approving the held deletion of a durable resource such as a persistent volume, checking rollout status and incidents, and reaching into a running pod (port forwarding, running a command or a shell in a container). Use when deploying to Skyr, wiring a repository to a Skyr instance, exposing a service publicly, connecting private networks, protecting an environment behind approval, rolling back a bad deployment, approving a deletion a teardown is waiting on, debugging a rollout, or getting a shell inside a deployed container."
  ],
  "primary": false,
  "metadata": {
    "source": {
      "provider": "github",
      "repository": "https://github.com/skyr-cloud/agent-plugin",
      "path": "skills/deploy/SKILL.md",
      "ref": "ff3501267a45aea02eca9b1d2e478e549cd3a48b",
      "url": "https://github.com/skyr-cloud/agent-plugin/blob/ff3501267a45aea02eca9b1d2e478e549cd3a48b/skills/deploy/SKILL.md",
      "key": "skyr-cloud/agent-plugin/skills/deploy/SKILL.md"
    }
  },
  "instructions": "# Deploying to Skyr\n\nSkyr is a Git-native infrastructure orchestrator: a repository of SCL\nconfiguration *is* the deployable unit, and pushing it to a Skyr git remote\n*is* the deployment action. There is no separate plan/apply step and no\ndeploy command — Skyr converges reality to whatever the pushed commit\ndeclares. This skill describes how that works: the lifecycle, what the\nfirst-party plugins can build (with complete examples), and how to observe\nand debug a rollout. Authoring the SCL itself — syntax, types, modules,\n`Package.scle` — is the `scl` skill's territory.\n\nExamples use `skyr.foo`",
  "cost": {
    "context_tokens": 23871
  }
}

Fetch it by URL: GET /api/v1/registry/skyr-cloud-agent-plugin-deploy/manifest?version=1.0.0

Reviews

Star ratings from people who tried it. One review per account; edit yours any time.

No reviews yet. Install it, try it, and be the first to rate it.