Plan a deploy without git clone
Stagely plans each stagelet deploy from a few files that it reads through the GitHub API at one commit. The control plane never runs git on your repository.
Before Stagely deploys a pull request as a stagelet, the control plane makes a plan. The plan needs .stagely.yml, the Compose files that it names, and every file that those Compose files reference through include, extends and env_file. The obvious way to get these files is git clone. The control plane never runs git on your repository. This post explains why, what it reads instead, and where the full source goes.
What a plan needs
A plan checks three things. .stagely.yml must be valid. Each Compose file must stay inside the Compose subset. The public service that .stagely.yml names must exist in the Compose files.
For most repositories, these checks read a few small text files. A clone writes the whole tree to disk, and by default it also fetches the whole history. Its time and disk use grow with the repository, and the deploy waits for it.
Why the control plane does not run git
The control plane holds the private key of the Stagely GitHub App. With this key, it can ask GitHub for a token for any installation of the app. GitHub calls this key “the single most valuable secret for a GitHub App” in its guide to private keys.
For the control plane, your repository’s content is untrusted input. Anyone who can push to a pull request decides what its files contain. A clone makes git parse that content and write it to disk. Git is a large program, and its clone path has had remote code execution bugs.
CVE-2024-32002 is one example. A crafted repository with submodules could make git write files into a .git/ directory instead of the submodule’s working tree. The attacker could put a hook there, and git ran that hook while the clone was still in progress. The git project rated the bug critical, with a CVSS score of 9.0. Git 2.39.4, 2.40.2, 2.41.1, 2.42.2, 2.43.4, 2.44.1 and 2.45.1 fixed it.
That bug needed a recursive clone on a case-insensitive file system with symbolic links, so it did not affect every system. But it shows the risk. If a clone ran code in the control plane, that code could read the App private key and get tokens for the repositories of every account. A git update fixes the known bugs. It does not fix the next one. So the control plane does not run git on customer repositories at all.
Read only the files the plan needs
The control plane reads each file through the GitHub API, at one commit:
- It asks GitHub for the pull request and takes the SHA of its head commit.
- It uses an installation token that works only on that one repository, with read access to contents and pull requests. The installation token API takes the repositories and the permissions in the request, and GitHub refuses anything wider than the installation has.
- It reads
.stagely.ymlthrough the contents API, with the commit SHA asref. The file goes into memory, not to disk. - It reads each Compose file and each file that they reference in the same way.
The commit SHA keeps the plan consistent. A branch can move while the plan runs, but a SHA can’t. Every read names the same SHA, so all plan inputs come from one commit. The agent later builds that same SHA, so the build starts from the files that the plan checked.
Every path stays inside the repository
Every path in a plan comes from your files, so the control plane checks each one before it reads anything. The plan fails, with an error that names the reference, when:
- a path points outside the repository, such as
../../secrets.env, - a path is a URL, such as a remote
include, - a path uses a variable, such as
${STAGE}.env.
Each read goes through the contents API with a token for this one repository, so a path never reaches the control plane’s own disk. Two more limits bound the plan: it reads at most 32 files, and references nest at most 4 levels deep.
include, extends and env_file
The control plane resolves references the way Compose does, but without Compose and without a file system. Relative paths in the top-level files resolve against the directory of the first Compose file. An included file resolves its own references against its own directory, or against its project_directory. extends.file resolves against the file that names it, and the extended service must exist in the target file. An env_file entry with required: false can be missing. Any other missing file fails the plan with an error that says the file does not exist at this commit.
Every Compose file that the control plane reaches goes through the Compose subset check. The check refuses settings that give a container access to its machine, such as privileged, host namespaces, bind mounts and the Docker socket. It also refuses build options that reach the shared build worker, such as ssh and network: host. A variable in a field that the check reads fails the plan too, because the plan can’t know its value. Compose rules has the full list, and the .stagely.yml reference shows how each reference resolves.
This approach has a cost. Each file is one API request, and each request counts against your installation’s GitHub API rate limit.
Where the full source goes
The build needs the full source, and the control plane never gets it. The customer agent (stagely-agent) in your cloud gets it.
When the agent takes a deploy job, Stagely asks GitHub for a short-lived token for that one job. The token works only on the one repository and can only read its contents. GitHub expires it after one hour at most. The agent uses the token to download the commit’s tarball:
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(c.APIURL, "/")+"/repos/"+c.Repository+"/tarball/"+c.SHA, nil)
This line is in internal/agent/build/build.go. The agent writes the tarball to a temporary file on the agent host, and it refuses a tarball larger than 512 MiB. Then it copies the tarball to a build worker, unpacks it there and deletes it:
steps := [][]string{
{"sudo", "rm", "-rf", dir},
{"sudo", "mkdir", "-p", dir},
{"sudo", "tar", "-xzf", remoteTarball, "-C", dir, "--strip-components=1"},
{"rm", "-f", remoteTarball},
}
These steps are in Upload. After the agent unpacks the source, it refuses a symbolic link that resolves outside the source directory. The build worker resolves the Compose project with docker compose config as the user nobody. The agent checks the resolved project against the Compose subset again, now with every variable filled in. Then the worker builds the images and pushes them to your registry, and the agent records the digest of each image. When the build ends, the agent deletes the source from the worker.
The build worker never gets the GitHub token, because the agent downloads the tarball itself. The stagelet’s machine gets neither the source nor a token. It gets a bundle: the resolved Compose project, with each built service pinned to its image digest. The control plane keeps that bundle for resets, so it holds the resolved Compose model and the contents of your env_file files.
What the control plane holds
For each deploy, the control plane reads a few files in memory, at one commit, with a token for one repository. It runs no git, keeps no checkout and runs no code from your repository. The full source goes only to the agent host and a build worker in your cloud, and only while the build runs. Pull request previews in your own cloud shows what runs where, and the Security model lists the credential at each boundary.