Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ permissions:

jobs:
release:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ concurrency:
jobs:
pytest:
name: pytest (Python ${{ matrix.python }})
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 15
strategy:
fail-fast: false
Expand All @@ -38,7 +38,7 @@ jobs:

shellcheck:
name: shellcheck
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand All @@ -50,7 +50,7 @@ jobs:

package:
name: the wheel works on its own
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand Down
69 changes: 68 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,74 @@
# Changelog

Versions are git tags (`vX.Y.Z`); projects install one with
`uv tool install git+ssh://git@github.com/hybridinteract/deployctl@vX.Y.Z`.
`uv tool install git+https://github.com/hybridinteract/deployctl@vX.Y.Z`.

## 0.13.0

A second person can deploy the same project: its configuration travels as one encrypted
file, and everyone's own access stays their own.

**Share the configuration**
- `deployctl config export` writes every environment's shared files — settings and secrets,
everything the project needs to run — to one file, encrypted with a passphrase (AES-256-GCM,
scrypt; a changed byte or a wrong passphrase opens nothing). It lands in `config/exports/`,
owner-only and ignored by git through its own `.gitignore`; the last 5 are kept.
`--generate-passphrase` makes a strong one and prints it once.
- `deployctl config import` loads one — the file given, or the one waiting in
`config/imports/`. It refuses a file made for another repository, and a configuration that
differs from the one here (naming the differing keys, never values) unless `--force`;
`--preview` shows the plan and writes nothing.
- `deployctl config` is now a group: `config show` (plain `deployctl config` still works),
`config export`, `config import`.
- Every panel Save, `config import --force` and `migrate-config --apply` first copies
`config/` into `~/.deployctl/config-backups/<project>/` (the last 20).

**Each person's access is their own**
- The registry login (`REGISTRY_USER`/`REGISTRY_TOKEN`) moves to **`config/local.env`**:
this machine's, loaded over the shared files, never exported, never uploaded to GitHub, and
never listed as an environment. It holds only those keys; anything else there is ignored
and `validate` says so. A login still in a shared file is a `validate` and `ci doctor`
warning, and `ci sync-config` refuses to upload it.
- **CI logs the hosts in with its own short-lived `GITHUB_TOKEN`** (the deploy workflow
grants `packages: read`), not with anyone's token. **Every host is logged out of the
registry once its images are pulled** — no credential outlives a deploy, CI's or a
laptop's.
- `deploy status --json` says why a host cannot be reached: `denied` (this machine's ssh key
is not on it) or `unreachable`.
- `ci doctor` shows your role on the repository; a secrets list GitHub refuses to a
non-admin is a warning, not a failure; "not logged in" and "no access to this repository"
are told apart; and it checks that `ci.yml`'s deploy job grants `packages: read`.

**Panel**
- A project with no configuration on this machine opens on a **start page with the Import
dialog open**: file and passphrase, a preview, then the full panel seconds later.
- Configure → **Share or back up this configuration**: Export (a download, with the
passphrase shown once) and Import.
- **Your access on this machine** (Setup, and Operate when something is missing): your
registry login, your ssh key on each server — with your public key and the command to add
it when the server refuses it — and your GitHub role.
- It says which project it serves: `deployctl webui` prints it, tabs are titled
`deployctl · <project> · <env>`, and a busy port held by **another project's** panel is
named as such instead of "open it" (`~/.deployctl/panels/<port>.json`).
- `deployctl webui` starts in a clone with no configuration instead of refusing.

**Also**
- The generated workflows and this repository's own run on `ubuntu-24.04`, not
`ubuntu-latest` (which GitHub moves to Ubuntu 26 on 2026-10-19).
- Install commands use `git+https://` — the repository is public.
- New dependency: `cryptography`.

**Upgrading a project**

```bash
uv tool install --force git+https://github.com/hybridinteract/deployctl@v0.13.0
deployctl migrate-config --apply # your registry login → config/local.env
deployctl ci init --env production --force # CI's own registry login, ubuntu-24.04
deployctl ci doctor --env production # prints the lines ci.yml's deploy job needs
deployctl ci sync-config --env production # the config without anyone's token
```

Then commit the workflows and `ci.yml`, and merge. Until then CI deploys as before.

## 0.12.0

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ every detail spelled out, plus a troubleshooting table:
### 1 · Install the tool

```bash
uv tool install git+ssh://git@github.com/hybridinteract/deployctl@v0.12.0
uv tool install git+https://github.com/hybridinteract/deployctl@v0.13.0
deployctl --version
```

Expand Down Expand Up @@ -131,7 +131,7 @@ Everything above is a command. Use these for scripting, CI, or when you want the
invocation in your shell history:

```bash
uv tool install git+ssh://git@github.com/hybridinteract/deployctl@v0.12.0 # once per machine
uv tool install git+https://github.com/hybridinteract/deployctl@v0.13.0 # once per machine
deployctl init --mode single # or --mode cluster; creates deploy/
$EDITOR deploy/config/common.env # project name, domain, image repo
$EDITOR deploy/config/production.env # hosts, database, TLS
Expand Down
75 changes: 75 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Roadmap

Work that is decided but not done yet. Each item says why it matters and where the change
goes, so it can be picked up cold. When one ships, move it to the CHANGELOG and delete it
here.

---

## Next

### Encrypted configuration in git — design first

**Why.** Since 0.13 a project's configuration travels as one encrypted file
(`config export` / `import`), but a person still passes it on by hand, and two people's
copies can drift until one of them runs `ci status`. Committing the configuration to the
repository — keys readable, each **value** encrypted, the file openable by every listed
person's key and by CI's — would make git the single source of truth: a teammate's access is
adding their public key and re-encrypting; a config change is a pull request whose diff names
the keys that changed and never their values; CI holds one decryption key instead of the
whole config, so `ci sync-config` disappears.

**Decide before building.**
- The tool: SOPS with age keys (dotenv format built in; a Go binary each machine and CI
needs), dotenvx (keeps `.env`, encrypts values; a Node tool), or age implemented in Python
with `cryptography` (no new binary; format of our own).
- How the panel's Save writes an encrypted file, and how `config/local.env` stays outside it.
- The migration from `config/` + the `DEPLOYCTL_CONFIG` secret, project by project.
- What it costs: encrypted values stay in git history for good — a leaked private key means
rotating every secret it could open.

### Add and remove a person's ssh key from the panel and CLI

**Why.** A teammate's ssh key is added by hand today
(`ssh deploy@host 'cat >> ~/.ssh/authorized_keys' < key.pub`), on every host, and removed by
hand when they leave. The panel's *Your access* card already shows the key that needs adding.
`deployctl server keys` / `add-key` / `remove-key` would list, add (every host, the jump host,
proven like `ci setup-key` proves the CI key) and remove them — the human counterpart of
`ci setup-key`.

---

## Later

### `ci doctor` flags deploy keys that can write
With automatic deploys on, anything that can push to the deploy branch can deploy to
production. A **read-write deploy key** on the repository is exactly that, and nothing
reports it — influen still had one (`deploy-v1`) from its previous tooling after moving to
deployctl. `ci doctor` should list the repository's deploy keys (`gh repo deploy-key list`)
and warn on any with write access.

### The flaky lock-cancel test
`test_released_when_the_run_is_cancelled` fails about 1 run in 6 under heavy load: a race
between SIGTERM and `wait` in bash 3.2 (macOS), in the test harness rather than in the lock
itself. A separate session was started to fix it; check whether it landed before starting
again.

### Test client deprecation
Every panel test run warns *"Using `httpx` with `starlette.testclient` is deprecated; install
`httpx2` instead."* Move the dev dependency before a Starlette release removes the old path.

### Registries other than ghcr.io in the tag picker
"Recent tags from the registry" lists tags only for ghcr.io (`cli/registry.py`). Other
registries work, but the operator types the tag.

---

## 1.0.0

After a second project has gone from nothing to deploying on every merge through the panel
(salescrm) — the proof that nothing here is influen-specific.

## 2.0.0

- Remove `IMAGE_TAG` in `config/` (deprecated since 0.10.0, honoured with a warning;
`deployctl migrate-config` removes it).
16 changes: 13 additions & 3 deletions docs/05-QUICKSTART.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,22 @@ optional and nothing here is ambiguous — run the blocks in order, top to botto
> 2. **A container image in a registry.** → Don't have one? Step 4 creates the CI workflow
> that builds it.

> **Joining a project someone already deploys?** Skip the rest of this page:
>
> 1. Install the tool (Step 1 below) and clone the application's repository.
> 2. Ask whoever runs the project for an export (panel: Configure → *Share or back up* →
> **Export**) and its passphrase, sent separately.
> 3. `deployctl webui` inside the clone — it opens on **Import**. Pick the file, type the
> passphrase, check the preview, import.
> 4. Work through **Your access on this machine** (Setup tab): your own registry login, your
> ssh key on the servers (send them your public key), and `gh auth login`.

---

## Step 1 · Install the tool

```bash
uv tool install git+ssh://git@github.com/hybridinteract/deployctl@v0.12.0
uv tool install git+https://github.com/hybridinteract/deployctl@v0.13.0
deployctl --version
```

Expand Down Expand Up @@ -127,7 +137,7 @@ Deploy the change). Deploying from this machine is still there, folded under
Every button above is a command. Use these for scripting, CI, or an audit trail:

```bash
uv tool install git+ssh://git@github.com/hybridinteract/deployctl@v0.12.0 # once per machine
uv tool install git+https://github.com/hybridinteract/deployctl@v0.13.0 # once per machine
deployctl init --mode single --env production
$EDITOR deploy/config/common.env # project name, domain, image repo
$EDITOR deploy/config/production.env # hosts, database, TLS
Expand Down Expand Up @@ -158,7 +168,7 @@ run, without touching a host.

| Symptom | Do this |
|---|---|
| `deployctl: command not found` | Step 1 — `uv tool install git+ssh://git@github.com/hybridinteract/deployctl@v0.12.0`, and make sure uv's tool directory is on your PATH (`uv tool update-shell`). |
| `deployctl: command not found` | Step 1 — `uv tool install git+https://github.com/hybridinteract/deployctl@v0.13.0`, and make sure uv's tool directory is on your PATH (`uv tool update-shell`). |
| Commands act on the wrong project | They use the nearest `deploy/` above the current directory. Check with `deployctl envs`, or pass `--project-dir`. |
| `no environments configured` | Step 2 has not been run. |
| Panel says `port 8765 is already in use` | `deployctl webui --restart` |
Expand Down
9 changes: 8 additions & 1 deletion docs/10-SINGLE-SERVER.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,19 @@ horizontal scale. Read **[00-CONCEPTS.md](00-CONCEPTS.md)** first if you have no
deployctl init --mode single --env production
```

It creates `deploy/` in your repository. Fill in the three files. `deploy/config/common.env`:
It creates `deploy/` in your repository. Fill in the files. `deploy/config/common.env`:

```sh
PROJECT_NAME=myapp
BASE_DOMAIN=example.com
IMAGE_REPO=ghcr.io/your-org/myapp
```

`deploy/config/local.env` — **yours, on this machine only**: your registry login, used when
you deploy from here and to list image tags. Never exported, never uploaded to GitHub; CI
logs in with its own token, and everyone who deploys keeps their own:

```sh
REGISTRY_USER=your-github-user
REGISTRY_TOKEN=ghp_… # classic PAT, read:packages only
```
Expand Down
6 changes: 6 additions & 0 deletions docs/20-CLUSTER.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,12 @@ deployctl init --mode cluster --env production
PROJECT_NAME=myapp
BASE_DOMAIN=example.com
IMAGE_REPO=ghcr.io/your-org/myapp
```

`config/local.env` — yours, on this machine only (never exported or uploaded; CI logs in
with its own token):

```sh
REGISTRY_USER=your-github-user
REGISTRY_TOKEN=ghp_… # classic PAT, read:packages only
```
Expand Down
26 changes: 19 additions & 7 deletions docs/30-OPERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,12 @@ Never hand-edit `generated/` or `config/secrets.<env>.env`. The first is rebuilt
`config/` by step 2 and is safe to delete; the second is minted once and reused so that
regenerating never invalidates live sessions.

Everything an operator owns lives in `config/`, and it is gitignored — so back it up (an
encrypted copy, a password manager, a secret store). If it is lost, **do not re-create it
and deploy**: the new render would carry new secrets and blank API keys, and doctor refuses
Everything an operator owns lives in `config/`, and it is gitignored — so keep a copy off
this machine: `deployctl config export` (or Configure → *Share or back up* in the panel)
writes it as one encrypted file, fit for a password manager, and `deployctl config import`
restores it. Every Save, import and migration also snapshots `config/` into
`~/.deployctl/config-backups/` first. If it is lost anyway, **do not re-create it and
deploy**: the new render would carry new secrets and blank API keys, and doctor refuses
exactly that (see *"this deploy would CHANGE secrets"* under Troubleshooting). Recover the
values from the host's live `.env.<env>` instead.

Expand Down Expand Up @@ -358,8 +361,14 @@ server-side state that deployctl never overwrites, so nothing repairs it automat

### `doctor` fails: cannot resolve the image
In order of likelihood: the tag does not exist (`deployctl image tags`); the package is
private and `REGISTRY_TOKEN` is missing, expired, or not SSO-authorized for the org; CI has
not published anything yet.
private and your `REGISTRY_TOKEN` (`config/local.env`) is missing, expired, or not
SSO-authorized for the org; CI has not published anything yet.

**In CI**, the hosts log in with the run's own `GITHUB_TOKEN`, which can read the image only
when the job calling the deploy grants `packages: read` — `deployctl ci doctor` checks your
`ci.yml` and prints the lines to add — and the package lets the repository in: the package's
settings → *Manage Actions access* lists the repository (automatic for images the
repository's own workflow published).

### `doctor` fails: architecture mismatch
The image was built for a different CPU than the host — classically an `arm64` image built
Expand Down Expand Up @@ -466,8 +475,11 @@ or add an exclusion in `scripts/common/remote.sh::push_artifacts`.

- **Prune old images** on the hosts occasionally; every release leaves one behind:
`deployctl deploy shell <host>` then `docker image prune -a --filter until=720h`.
- **Rotate the registry token** when someone leaves: update `REGISTRY_TOKEN` in
`config/common.env`; the next deploy logs in with the new one.
- **When someone leaves:** remove their ssh key from the deploy user's `authorized_keys` on
every host and their access to the repository. Their registry token was their own
(`config/local.env` on their machine) and never reached GitHub or a server — the hosts are
logged out after every pull — so there is nothing of theirs to rotate there; rotate the
shared secrets they could read (database passwords, API keys) if the departure calls for it.
- **Rotate application secrets** deliberately, knowing the cost:
`deployctl setup --env <env> --rotate-secrets`, then
`deployctl deploy update --env <env> --allow-secret-change` (doctor refuses a changed
Expand Down
25 changes: 25 additions & 0 deletions docs/35-CONTINUOUS-DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,31 @@ replaces it on every host. The public half is marked `deployctl-ci@<owner/repo>/
installed with `restrict` (no forwarding, no PTY). The deploy user is in the `docker` group,
which is root-equivalent on the host: treat the key as root.

### The registry login

CI never uses a person's token. The deploy job hands the hosts the run's own
`GITHUB_TOKEN` — short-lived, read-only for packages — and every host is logged out again
once its image is pulled, so no registry credential outlives a run, CI's or a laptop's. Each
person who deploys by hand keeps their own login in `config/local.env`, which is never part
of the uploaded config: `ci sync-config` refuses while a registry token still sits in a
shared file (`deployctl migrate-config --apply` moves it).

Two conditions, both checked by `ci doctor`:

- **The job that calls the deploy grants `packages: read`.** A called workflow gets no more
than its caller allows, and `ci.yml` is your file, so `ci init` never rewrites it — add
under its `deploy:` job:

```yaml
permissions:
contents: read
packages: read
```

- **The package lets the repository in** — automatic for an image the repository's own
workflow published; otherwise the package's settings → *Manage Actions access* → add the
repository.

### Reaching the hosts

GitHub's runners connect from a large, changing set of addresses. Three ways in:
Expand Down
4 changes: 2 additions & 2 deletions docs/40-ADOPTING-A-NEW-PROJECT.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ say so rather than forking the tool.
## 1. Install, then initialise the repository

```bash
uv tool install git+ssh://git@github.com/hybridinteract/deployctl@v0.12.0 # once per machine
uv tool install git+https://github.com/hybridinteract/deployctl@v0.13.0 # once per machine
cd /path/to/new-project
deployctl init --mode single --env production
```
Expand Down Expand Up @@ -293,7 +293,7 @@ deploy, restore.
## 8. Upgrading deployctl later

```bash
uv tool install --force git+ssh://git@github.com/hybridinteract/deployctl@<new-tag>
uv tool install --force git+https://github.com/hybridinteract/deployctl@<new-tag>
deployctl selftest
deployctl validate --env <env>
deployctl deploy update --env <env> --tag <running-tag> --dry-run
Expand Down
Loading
Loading