Package Sources
Every time you run a rototo command, or load config from an app, the very first question is the same: where is the package? That answer is a package source - a short string that tells rototo where to go and get your config.
The nice part is that it's one string, and it works the same everywhere. The
app-config you type after rototo lint is the same thing you put in
ROTOTO_PACKAGE_SOURCE for your running service, and the same thing you hand to
Package.load(...) in code. Learn it once and you're done.
This page walks through every shape that string can take.
The shortest one: a folder on your machine
Most of the time, especially while you're editing, the package is just a folder sitting on your disk. So the source is just the path to that folder:
rototo lint app-config
rototo lint ./packages/checkout
rototo lint /home/me/work/runtime-config
Relative or absolute, both are fine. No prefix, no ceremony - if it looks like a path, rototo treats it as a path.
And if you don't pass anything at all, rototo is helpful about it: it looks in
the current folder, then the one above it, and so on, until it finds a
rototo-package.toml. So when you're already standing inside your package, you
can just say:
rototo lint
If you ever want to be explicit that it's a local path - say, in a script where
you'd rather not leave it to guessing - you can spell it out with file://:
rototo lint file:///home/me/work/runtime-config
It means exactly the same thing as the plain path. One small catch: the
file:// form is just a folder, so it doesn't take any of the #... extras
described below. If you find yourself wanting those, you probably want a git
source instead.
Loading straight from a git repo
Here's where it gets useful. Your config lives in git - that's the whole point -
so you can point rototo straight at the repo without cloning it yourself first.
You do that by sticking git+ in front of the repo URL:
# over HTTPS
rototo lint git+https://github.com/acme/runtime-config.git
# over SSH
rototo lint git+ssh://git@github.com/acme/runtime-config.git
# even a local bare repo
rototo lint git+file:///srv/git/runtime-config.git
rototo fetches the repo into a temporary spot, reads the package, and cleans up after itself. You don't manage the checkout.
Two things you'll almost always want to add: which version and which folder.
That's what the part after the # is for.
The bit after the #: version and folder
A git source can carry two extra details, written as #ref:subdir:
- the ref - a branch, a tag, or a full commit. This is the "which version" part.
- the subdir - the folder inside the repo where the package actually lives. Repos often hold more than one thing, so this says "the package is in here".
Put together:
rototo lint git+https://github.com/acme/runtime-config.git#main:packages/checkout
Read that as: the runtime-config repo, on main, and the package is in
packages/checkout.
You don't have to supply both. The colon is the divider, so:
#main- just a version. (Package is at the repo root.)#main:packages/checkout- version and folder.#:packages/checkout- just a folder, on whatever the repo's default is. (Note the leading colon: nothing before it means "no specific ref".)
A quick word on which ref to pick, because it changes how things behave later:
- A branch like
mainkeeps moving - point at it and you'll pick up newer reviewed commits over time. - A tag is a friendly name for a fixed release.
- A full commit is nailed down forever - perfectly reproducible, but it never changes, so a long-running service watching it will never see anything new.
That last point matters for refreshing services: a moving ref is how config reaches a running fleet without redeploying; a pinned ref is how you guarantee an exact, reproducible build.
Loading a packaged-up archive over HTTPS
Pulling from git is great for small setups, but once you've got a real fleet,
having every instance clone from GitHub on a schedule gets fragile - now your
config depends on GitHub being up and on fetch behaving at scale. So for
production, the usual move is to pack the package into a single .tar.gz file,
drop it on an object store behind a CDN, and load that:
rototo lint https://config.acme.com/rototo/checkout/prod/current.tar.gz
(You build that archive with rototo package, which writes a deterministic
tarball you can upload.)
An archive is already a self-contained, fixed thing, so there's no "ref" to pick
- a
.tar.gzis whatever it is. The only extra you can add is the folder inside it, using the leading-colon form:
rototo lint https://config.acme.com/rototo/checkout/prod/current.tar.gz#:packages/checkout
Because an archive is just a file at a URL, the URL is what decides whether it moves or stays put:
- a URL addressed by content, like
.../sha256:0f4c...b91.tar.gz, always points at the exact same bytes - safe to cache hard, perfect for reproducible releases. - a "channel" URL like
.../prod/current.tar.gzis meant to be re-pointed at a newer archive when you promote a release, so it should be cached only briefly.
One thing rototo won't do: plain http://
You can load over https://, but not plain http://. Same goes for git:
git+https:// and git+ssh:// are fine, git+http:// is not. This isn't an
oversight - config is exactly the kind of thing you don't want a network snoop
or man-in-the-middle messing with, so the unencrypted forms are turned off on
purpose. If you try one, rototo tells you to use https:// instead.
Private archives need a token
If an https:// archive is private, rototo sends a bearer token with the
fetch. One important boundary first: this covers archive sources only. Git
sources (git+https://, git+ssh://) authenticate through git itself - your
SSH keys, credential helpers, or gh auth - and rototo stays out of that.
The simple case is one private archive host:
# as a flag
rototo lint https://config.acme.com/checkout/current.tar.gz --package-token "$TOKEN"
# or as an environment variable
ROTOTO_PACKAGE_TOKEN="$TOKEN" rototo lint https://config.acme.com/checkout/current.tar.gz
A bare token like that binds to a single archive origin: the first host the load fetches an archive from. That's deliberate. A composed package can pull archives from more than one host, and a token minted for one of them must not be broadcast to the others. If a load with a bare token touches a second origin, it fails and tells you to scope your tokens.
Scoping is the general form. An entry is PREFIX=TOKEN, where the prefix is an
https:// URL prefix:
rototo lint ./overlay \
--package-token "https://config.acme.com/team-a=$TEAM_A_TOKEN" \
--package-token "https://archives.example.net=$PARTNER_TOKEN"
The environment variable takes the same entries, separated by whitespace:
ROTOTO_PACKAGE_TOKEN="https://config.acme.com/team-a=$TEAM_A_TOKEN https://archives.example.net=$PARTNER_TOKEN"
The rules are small and worth knowing:
- Longest matching prefix wins. A token for
https://config.acme.com/team-abeats one forhttps://config.acme.comwhen the archive lives under/team-a. Prefixes match at path boundaries:/teamnever covers/teammate. - No match means anonymous. A request to a host you didn't scope a token for carries no credential at all - a secret never goes somewhere you didn't name.
- The
https://spelling is what makes an entry scoped, never the=sign. A base64 token that happens to end in=padding is still one bare token. - A bare token must stand alone. Mixing it with scoped entries, or passing two bare tokens, is an error - there'd be no unambiguous answer for who gets what.
- Tokens never follow a cross-origin redirect. If an archive host redirects to a different host, the request continues without the Authorization header.
In the SDKs, the same two shapes exist as load options: a single token string, or a map from URL prefixes to tokens.
One habit worth keeping: when CI checks that production can really load the released package, use the same authenticated source production will use. That way you find out about a permissions problem in CI, not at 2am.
When one package builds on another
A package can stand on top of other packages - shared defaults, a common set of
condition variables, that sort of thing. It does that by listing other sources in its
rototo-package.toml, and here's the part that matters for this page: those
parent sources use the exact same grammar as everything above. A parent can
be a local folder, a git repo with #ref:subdir, or an HTTPS archive.
Relative paths in that list are resolved against the package doing the extending, so a package and the parents it leans on can travel together. The mechanics of how packages combine live in the package format reference; what belongs on this page is how the sources in that list behave.
The rules for sources in extends
- Every format works. Local paths,
file://,git+https://,git+ssh://,git+file://, andhttps://archives, including the#ref:subdirand#:subdirfragments. If you can type it as a package argument, you can list it inextends. - Relative paths resolve against the extending package, not against
wherever you happened to run the command. That's what lets a repo carry
extends = ["../shared-config"]and load the same way from CI, your laptop, or an app. - A remote package can't reach into your filesystem. Once a package has
been staged from a git or archive source, its
extendsentries may only be relative paths inside its own checkout or other remote sources. An absolute path, afile://entry, agit+file://entry, or a../that climbs out of the checkout fails with "escapes a staged package". A fetched package naming/etcor your home directory is not a composition feature; it's a problem. - Remote extending remote is the cross-team shape. A team publishes its base package at a git ref or archive URL; your package extends it by that source. Pin the parent to an immutable ref (a commit hash or a content-addressed archive) when you want reproducible builds - the same child commit then always composes against the same parent bytes.
- Chains have limits. Extends can nest, and parents can have parents, up to a depth of 32. A cycle (a package that ends up extending itself through any chain) fails the load with the cycle spelled out.
The whole grammar on one page
If you just want the cheat sheet:
| You want to load… | Write it like this |
|---|---|
| A folder on disk | app-config, ./pkg, /abs/path |
| Nearest package above you | (omit the source entirely) |
| A folder, spelled out | file:///abs/path |
| A git repo | git+https://…, git+ssh://…, git+file://… |
| A git repo, specific version | git+https://…#main |
| A git repo, version + folder | git+https://…#main:packages/checkout |
| A git repo, folder only | git+https://…#:packages/checkout |
| An HTTPS archive | https://…/current.tar.gz |
| An HTTPS archive, folder inside | https://…/current.tar.gz#:packages/checkout |
And the things rototo will refuse: plain http://, git+http://, and a #...
fragment on a file:// source.