Deploy agent: Go CLI that packages a project's docker-compose + build contexts and uploads them to someones.computer for build & deploy
  • Go 95.4%
  • Shell 4.6%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Greyscale Eusebio bdbe60aac4
All checks were successful
Release / goreleaser (push) Successful in 2m57s
CI / build (push) Successful in 3m38s
Merge pull request 'Print where a deploy landed, not just that it did' (#45) from feature/deploy-progress-endpoints into main
2026-08-25 21:13:29 +00:00
.github Lock the release version to the platform's, not an independent counter (#44) 2026-08-25 17:44:22 +00:00
cmd/sc Gate sc on static analysis and a coverage ratchet (#26) 2026-08-14 21:00:46 +00:00
hack Gate sc on static analysis and a coverage ratchet (#26) 2026-08-14 21:00:46 +00:00
internal Print where a deploy landed, not just that it did 2026-08-25 23:05:17 +02:00
scripts Lock the release version to the platform's, not an independent counter (#44) 2026-08-25 17:44:22 +00:00
vendor Print where a deploy landed, not just that it did 2026-08-25 23:05:17 +02:00
.gitignore Rename binary soc -> sc 2026-07-23 13:34:11 +02:00
.golangci.yaml Gate sc on static analysis and a coverage ratchet (#26) 2026-08-14 21:00:46 +00:00
.goreleaser.yaml Consume the shared scclient module for the /cli client and credentials 2026-08-08 13:26:05 +02:00
coverage.floor Detect and forward a private image sc can't build, but can reach locally (#33) 2026-08-20 18:50:10 +00:00
go.mod Print where a deploy landed, not just that it did 2026-08-25 23:05:17 +02:00
go.sum Print where a deploy landed, not just that it did 2026-08-25 23:05:17 +02:00
README.md Lock the release version to the platform's, not an independent counter (#44) 2026-08-25 17:44:22 +00:00

sc — someones.computer deploy agent

A single static Go binary you run inside your project. It reads your local docker-compose.yml, packages every build: context into a compressed tarball, and uploads the compose file + contexts to someones.computer as a new immutable Deployment. The platform builds the contexts into images and reconciles them onto Docker Swarm.

Status: early. login stores a token and deploy packages the compose project + build contexts and POSTs them to /applications/{slug}/bundle, which the platform now accepts (contexts are cached and a pending Deployment is recorded). apps, logs and the start/stop toggles are in — see docs/agent-cli.md in the platform repo for the wire contract.

Install

curl -fsSL https://someones.computer/install.sh | sh

The platform serves the binaries, not this repo: every build is mirrored into someones.computer's object store, and https://someones.computer/install lists the archives and their checksums for every platform (macOS, Linux and Windows × amd64/arm64). The script picks the right one, verifies it against the checksum published with it, and drops it in /usr/local/bin — or ~/.local/bin when that needs root, which it will not ask for.

Two channels: stable is the latest v* tag, dev is the last push to main. ?channel=dev on either URL switches. See Releasing for what publishes them.

Updating

sc -U          # or --update, or `sc update`

sc replaces itself with the newest build on its own channel, and the channel is not a choice: a tagged build follows stable, a snapshot follows dev, and the version stamped into the binary says which it is. There is no flag and no config file for it.

The download is verified against the platform's published sha256 before anything on disk is touched, and every failure — a bad checksum, a lost connection, an unpackable archive — leaves the working binary exactly where it was. An install directory this user cannot write is reported with the command to run by hand; sc never escalates, for the same reason install.sh never does.

An unreleased local build (go build, version dev) belongs to neither channel and is refused rather than overwritten.

Build & use

go build -o sc ./cmd/sc

# Store a token for the endpoint (prints the page to mint one at, and offers
# to open it):
./sc login

# In a git checkout, that's all the configuration there is — the application
# is named after the repository directory and the deployment after the branch:
./sc deploy --dry-run     # inspect what would be shipped, upload nothing
./sc deploy               # upload a Deployment

# Or name them explicitly:
./sc deploy --app my-app --deployment staging

# Who is this endpoint signed in as, and with which token:
./sc whoami

# See what's out there — organizations, applications, deployments and the
# state each one is in:
./sc apps                 # the whole tree
./sc apps staging         # only what matches, with its ancestors

# Switch a deployment off and back on. Nothing is uploaded and nothing is
# rebuilt — the same images go back up:
./sc stop                 # tear down what this branch has running
./sc start                # put it back, following it until it's running
./sc start staging -d     # another deployment, without waiting

# Follow a running service's output. Ctrl-C to stop:
./sc logs web
./sc logs web --tail 0    # from now, replaying nothing

Following logs

sc logs <service> streams one service's stdout and stderr from the platform as it happens. The service is the name it has in your compose file; the application resolves exactly as deploy resolves it (--app, then .sc.yml, then the directory), so the two always mean the same application.

Log lines go to stdout and everything else to stderr, so redirecting collects the log and nothing else:

sc logs web > web.log     # only log lines land in the file

--tail N replays that much history before following (200 by default; 0 starts from now). The platform caps how many streams it holds open at once — a full pool answers immediately with how long to wait rather than queueing you behind somebody else's terminal.

Before it uploads

deploy checks the target first, so the two things a detected name can get wrong become questions rather than a failed upload:

$ sc deploy
No application "example-app" on https://someones.computer.
Create application "example-app" in "acme"? [Y/n] y
Created example-app — https://someones.computer/applications/019f…

$ sc deploy
"example-app" has no deployment called "feature-ui" yet (it has: main).
Start a new deployment "feature-ui"? [Y/n]

The second one matters because the default shape is one deployment per branch: without it, a branch you didn't mean to deploy from quietly starts a second line of revisions. A first-ever deployment isn't asked about — there is nothing for it to be a departure from.

Answers come from stdin, so printf 'y\n' | sc deploy works the same as typing it. Nothing to read from (CI, a closed stdin) is not a hang: it's an error that names the flag which would have answered — --yes accepts every prompt, and --org picks the organization up front when you belong to several.

Configuration

Every parameter can be set four ways, and they resolve in this order:

flag  >  environment  >  .sc.yml  >  detected default

Which platform it talks to

--endpoint names the platform, and --staging is a shorthand for the hosted staging one. It is a persistent flag, so it applies to every subcommand:

./sc --staging apps                          # list what's on staging
./sc deploy --staging                        # deploy there
./sc login --staging                         # and its own token — see below

The endpoint resolves highest-first:

Precedence Source Value
1 (wins) --endpoint as given
2 --staging https://staging.someones.computer
3 $SC_ENDPOINT as given
4 endpoint: in .sc.yml as given
5 built-in default https://someones.computer

--staging deliberately outranks $SC_ENDPOINT and the project file: a repo pinned to some other endpoint must not quietly pull a --staging deploy back off staging. Passing --staging and --endpoint together is an error rather than a precedence puzzle — for a flag whose job is "not production", silently picking a winner is the failure mode worth designing out.

Tokens are stored per endpoint, so staging needs its own sc login --staging. Without one, a deploy --staging prompts for a token inline rather than reusing the production one.

The deploy summary names the resolved endpoint, so --dry-run answers "where is this about to go?" before anything uploads.

.sc.yml

The per-project file. sc reads the nearest one, searching upwards from the working directory, so it works from a subdirectory of the repo; --config <path> points at a specific file instead. .sc.yaml is accepted too (having both in one directory is an error, not a coin toss). Every key is a flag name, and unknown keys are rejected rather than ignored — a typo should fail loudly, not deploy somewhere unexpected.

app: example-app          # --app
deployment: staging       # --deployment
endpoint: http://127.0.0.1:7500
token: someonescomputer_… # see the warning below
files:                    # -f/--file; `file:` is accepted as an alias,
  - compose.yaml          # and a bare string works instead of a list
  - compose.override.yaml
dry-run: false            # --dry-run
no-browser: false         # --no-browser

Relative files entries resolve against the file's own directory, not the working directory.

A token: in .sc.yml sits inside the build context, so it ships with the upload unless .dockerignore excludes it. sc warns when it uses one. Prefer $SC_TOKEN or the sc login store, and keep the file out of the context if you do commit a token to it.

Detected defaults

With nothing configured, sc infers from the checkout — this is what makes a bare sc deploy work:

Parameter Detected from Example
app the git repository's directory name, slugified ~/code/My Cool Appmy-cool-app
deployment the current branch, slugified feature/New UIfeature-new-ui

The repository root is used, so running from services/api/ gives the same answer as running from the root. Outside a checkout the working directory's name is used and there is no deployment name; on a detached HEAD there is no deployment name either (a commit id makes a poor one). Git is read directly — sc is a static binary and doesn't need git on PATH.

Environment variable Effect
SC_ENDPOINT default for --endpoint; --staging overrides it
SC_TOKEN token for non-interactive runs (CI)
SC_CONFIG_HOME / XDG_CONFIG_HOME where credentials.json lives

Authentication

Every upload is attributed to the user the token belongs to — nothing is anonymous. sc looks for a token in this order:

Source Use
--token one-off override
$SC_TOKEN CI
token: in .sc.yml a project pinned to one endpoint (see the warning above)
sc login store interactive machines

sc login prints the platform's Settings → API tokens page (<endpoint>/settings/api-tokens?label=<hostname>+(sc)), offers to open it in a browser, and reads the pasted token without echoing it. The label is a suggestion the page prefills its form with — this machine's short hostname, which is the one thing the browser cannot work out — so the token names the box it lives on rather than being "token 3" when someone comes to revoke it. A hostname of localhost, or none at all, suggests nothing.

Tokens are stored per endpoint in $SC_CONFIG_HOME/$XDG_CONFIG_HOME/sc/~/.config/sc/credentials.json, mode 0600, so a local stack and the hosted platform can each have their own. A deploy with no token configured runs the same prompt inline instead of failing.

./sc login --staging                          # the hosted staging platform
./sc login --endpoint http://127.0.0.1:7500   # a local stack
./sc login --no-browser                       # just print the URL
echo "$TOKEN" | ./sc login                    # non-interactive

deploy auto-discovers compose.yaml/docker-compose.yml (override with -f), parses it with compose-go, and packs each build: context into a deterministic, .dockerignore-aware .tar.gz.

While the upload is in flight, sc tumbles a die between a green S and a red C — the pips are the six dots of a braille cell:

[S⣦C] Uploading 1.4 MiB — 52%          bytes moving: an 8-step gauge
[S⠙C] Waiting for someones.computer    nothing to measure: ⠙⠸⠴⠦⠇⠋ tumbling
[S·C] Uploading 1.4 MiB — 52%          stalled 2s: a resting mark, brackets breathing
[S⠿C] Uploaded 1.4 MiB in 3.2s         done: the die landed, and this line stays

It only animates on a terminal; piped or in CI the label prints once and only the closing summary follows. $NO_COLOR drops the colour and keeps the animation. The gauge and the resting state need per-byte progress from the client, which isn't wired yet — see agent-cli.

CI/CD

  • .github/workflows/ci.yaml — on every push to main and every PR: go vet, go test, and a static cross-compile across linux, darwin, windows × amd64, arm64 (CGO_ENABLED=0).
  • .github/workflows/release.yaml — on a v* tag: GoReleaser builds the same matrix as static, stripped binaries (-s -w -trimpath), archives them (.tar.gz, .zip on Windows) with a checksums.txt, and publishes a release to this repo via the Forgejo/Gitea API.
  • .github/workflows/release-cadence.yml — cuts that v* tag itself, nightly, at the newest commit on main that ci.yaml reported green, so stable (what someones.computer's /install serves by default) cannot silently fall behind the way it has before — see the workflow's own header for why this exists and what it checks before tagging.

The version number is the platform's, not this repo's. someones.computer is one product with two shipped artifacts — the web app and this CLI — and they share one version between them: whatever v* is newest on Grey.ooo/someones.computer is what gets tagged here too, at whatever commit on this repo's own main is current when it catches up. A platform-only release cycle still gets a matching tag here, even with no CLI change behind it — sc --version and the app's own version should never disagree about what "someones.computer vX.Y.Z" means.

Releases are cut automatically. To cut one sooner:

scripts/cut-release.sh
scripts/cut-release.sh --dry-run

— or trigger release-cadence.yml's workflow_dispatch from the Forgejo UI. Both apply the same guards the nightly run does (previous tag actually released, next tag's commit actually green, forward-only) rather than a bare git tag && git push.

Requirements for the pipeline to run

  • A Forgejo Actions runner registered to this repo (or the Grey.ooo org) exposing the ubuntu-latest label.
  • The Actions unit enabled on the repo (Settings → Units).
  • The release job uses the auto-injected GITHUB_TOKEN (repo-scoped) as GITEA_TOKEN; no manual secret needed unless you publish cross-repo.

Releasing

.github/workflows/release.yaml runs goreleaser on every push to main and on every v* tag, and both ends up somewhere a user can reach:

Trigger goreleaser Forgejo release someones.computer channel
push to main --snapshot none dev0.0.1-snapshot-<sha>
v* tag release --clean yes, on this repo stable → the tag

There is deliberately no rolling dev tag: a snapshot version already names the commit it was built from, so a moving tag would add nothing and force-push something.

The mirror is scripts/publish-s3.sh — six PUTs signed with curl --aws-sigv4, no AWS CLI to install. It writes the archives and goreleaser's checksums.txt under releases/<version>/, then moves channels/<channel> last, so a half-finished upload leaves the channel on the previous version instead of on a set of 404s. The platform reads exactly those keys (docs/agent-cli.md in the platform repo).

The bucket, sc-builder, is this pipeline's alone, and the key below can reach nothing else on that object store — the platform's own keys have read on it and nothing more. Garage grants permissions per bucket and no finer, so sharing the platform's data bucket would have handed this repo's CI write access to every stored build context, which the build worker feeds to kaniko by object key.

Five settings on this repo, all configured; absent, the release still cuts and the mirror step warns, so a fork can tag one without the platform's credentials:

Variables SC_S3_ENDPOINT https://s3.grey.ooo
SC_S3_REGION us-east-1 — s3.grey.ooo reports this; signing with the wrong region is rejected outright
SC_S3_BUCKET sc-builder
Secrets SC_S3_ACCESS_KEY / SC_S3_SECRET_KEY the sc-builder-only Garage key

Layout

cmd/sc/           entry point
internal/cli/     cobra command tree (root, version, login, deploy, apps, logs,
                  update)
internal/config/  per-endpoint token store (0600 credentials.json) + .sc.yml
internal/detect/  app name from the repo directory, deployment from the branch
internal/bundle/  compose-go loading + deterministic context packing
internal/client/  multipart upload to /applications/{slug}/bundle, plus the
                  /cli/* JSON API (preflight, create application, list) and the
                  newline-delimited log stream
internal/ui/      transient terminal output (the [S⠙C] wait spinner)
internal/selfupdate/
                  sc -U: channel from the embedded version, /install/{channel}.json,
                  checksum verification, atomic replacement
scripts/          publish-s3.sh — mirrors a build to someones.computer
                  cut-release.sh — by-hand equivalent of release-cadence.yml
.github/          CI + release workflows
.goreleaser.yaml  static multi-arch build matrix

Gates

Same shape as the platform repo's, in Go terms — one definition each, so a laptop and CI cannot drift:

Check Command Is
Static analysis hack/lint.sh golangci-lint from a pinned image: staticcheck, govet, errcheck, unused, plus bodyclose, errorlint, gosec, gocritic, misspell. Configured in .golangci.yaml, no baseline — the gate is "no findings"
Coverage hack/coverage.sh a ratchet: every package's figure is recorded in coverage.floor, and a drop fails. --bump raises them
Tests go test ./... run by the coverage gate, so it is not a separate step

Those three files are duplicated verbatim in someones.computer_scclient and someones.computer_tray, and are meant to stay identical.

Why a ratchet and not 100%. The platform repo gates PHP at 100% because everything there is injectable. Go here is not — a main(), an exec path, a terminal draw. A fixed floor is a number nobody raises, which lets a package fall from 92% to it unnoticed; a ratchet only turns one way, and lowering a figure has to be argued for in the commit that does it.