Skip to content
23 changes: 23 additions & 0 deletions AUDIT_OPEN.md
Original file line number Diff line number Diff line change
Expand Up @@ -2726,3 +2726,26 @@ outside this repository.
and OpenBao. Rollback now warns, naming the keys, when `.env` or
`secrets/` changed after the target release was recorded, and says to
redeploy. Not covered: OpenBao rotations (not observable from the host).

## 2026-10-20 — git sources (D4): deferred surface

Landed in PR #44: `git:` code sources for Teploy-home apps, per-app
credentials age-encrypted outside /deployments, the single `trigger poll`
admitter, and the minutely systemd timer. Deliberately deferred (documented
in README/CHANGELOG):

- **Submodules**: `git.submodules: true` is refused at validation; a
repository carrying `.gitmodules` fails the build explicitly.
Credential-scoped submodule fetching needs per-submodule authorization
decisions (a submodule URL is attacker-chosen in a PR).
- **Static + git**: static Teploy-home apps with `git:` stay refused
(static deploys still need a code source — `build_remote` on a git
checkout is unbuilt plumbing).
- **Non-privileged git.repository changes**: switching to a `file://`
repository IS privileged (reads server-local paths); switching between
remote repositories is not — a non-admin editor can repoint an app at
any reachable remote and build its code. The build runs server-side
with no host mounts by default, so this is app-code authority, not host
authority; revisit if deploy hooks or host binds interact.
- **Timer auto-install**: offered best-effort after `config source key`
only (`--no-timer` skips); `teploy setup` does not install it yet.
53 changes: 52 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,59 @@ All notable changes to teploy are documented here. Format follows [Keep a Change

### Added

- Teploy-home apps can build from a git repository: a `git:` block
(`repository`, `branch`, `paths`, `poll`, `submodules`) in the config,
`image:` becoming optional when it is set (`git-source-v1`; contracts
corpus rev 16 adds `source-show-envelope`, `source-key-envelope` and
`trigger-poll-envelope`). Works with GitHub, GitLab, Forgejo/Gitea,
Cursor Origin and any git host over SSH or HTTPS. `context:` and
`dockerfile:` are relative to the repository root; `build_secrets:`
passes age-store values to the build as `docker build --secret`
(values never enter the config, image layers, argv or logs).
- `teploy config source key --app X --host S [--type ssh|token]`
generates an ed25519 deploy key on the server (public half printed)
or stores an HTTPS token read from stdin. Credentials live
age-encrypted outside `/deployments` (Caddy cannot read them) and are
used via `GIT_SSH_COMMAND` / `GIT_ASKPASS` — never in URLs,
`.git/config` or command lines. `config source host-key` pins the
forge's SSH host key (`ssh-keyscan` on the server, fingerprint shown,
optional `--expected-fingerprint` verification); fetches run with
`StrictHostKeyChecking=yes` against it. `config source show --json`
(no secrets) and `config source remove` round out the surface.
- `teploy trigger poll` is the one admitter for git sources: dedup by
(app, ref, commit), a per-ref watermark with an ancestry check on the
server-side mirror, one recorded refusal per force-push (refused until
`teploy trigger reset --app X --ref refs/heads/<branch>`), changed
paths computed on the server and filtered by `git.paths` (defaulting
to `context:`), a failed commit retried at most twice, and the newest
admitted head always deployed (`trigger-poll-v1`). `--all` polls every
due app for the server timer; `--now --hint <sha>` is the webhook wake
(the remote head wins over the hint). `teploy trigger retry --app X`
re-arms a failed commit.
- `teploy trigger install-timer --host S` installs an idempotent systemd
timer running `trigger poll --all` every minute (each app honors its
own `git.poll` interval from admissions timestamps; nothing per-app in
crontab); it is also offered after `config source key`. `teploy doctor`
gains a `git-trigger` check: the timer is present and its resident
binary supports `trigger poll` whenever any app has a `git:` source.
- Deploying a Teploy-home app with `git:` set (`teploy deploy --app X`)
builds the branch head from the mirror under the app lock and records
the commit as the release's `source_revision` (release provenance and
the deploy event); a manual deploy honors the same force-push refusal
and advances the watermark.
- New event kinds `trigger.admit`/`trigger.refuse` detail `reason`, and
`source.connect` records credential lifecycle (type and fingerprint
only).

### Notes

- Static Teploy-home apps with `git:` remain refused (static deploys
still need a code source); submodules are refused pending
implementation; `file://` repositories are accepted as a privileged
config change (same-server sources).

- One backup target per server, read at run time (`backup-target-v1`;
contracts corpus rev 14). `teploy server backup-target set --host S
contracts corpus rev 15). `teploy server backup-target set --host S
--bucket B [--region] [--endpoint] [--prefix]` stores it at
`/etc/teploy/backup-target.json` (0600, outside `/deployments`;
`~/.config/teploy/` fallback for a non-root deploy user, said so on
Expand Down
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,60 @@ local TLS/static-build paths are refused too: the app deploys to the server
that stores its config. Folder deploys of a Teploy-home app are refused, and
so are deploys by teploy CLIs older than this feature.

### Code from a git repository

A Teploy-home app can build from a git repository instead of a pre-built
image — GitHub, GitLab, Forgejo/Gitea, Cursor Origin, or any git host over
SSH or HTTPS:

```yaml
app: blog
domain: blog.example.com
git:
repository: git@github.com:me/blog.git # or https://github.com/me/blog.git
branch: main
paths: ["web/**"] # optional monorepo filter
poll: 5m # or "off": webhook wakes only
# context: ./web # optional, relative to the repo root
# dockerfile: Dockerfile # optional, relative to the context
build_secrets: [NPM_TOKEN] # age-store keys -> docker build --secret
```

Connect it once per app:

```
teploy config source key --app blog --host prod # ed25519 deploy key:
# prints the public half to register
teploy config source key --app blog --host prod --type token < token-file
teploy config source host-key --app blog --host prod # pin the forge's SSH host key
teploy config source show --app blog --host prod --json # public state, no secrets
teploy config source remove --app blog --host prod
```

The credential never leaves the server: keys and tokens are age-encrypted
outside `/deployments` (a compromised Caddy cannot read them), SSH fetches
use `StrictHostKeyChecking=yes` against the pinned host key, and HTTPS
tokens travel via `GIT_ASKPASS` — never in a URL, `.git/config` or a
command line.

`teploy trigger poll` is the single admitter: it checks the branch with the
app's credential, dedups by (app, ref, commit), requires new heads to
descend from the deployed watermark (a force-push is refused, once, until
`teploy trigger reset --app blog --ref refs/heads/main`), filters changed
paths by `git.paths` (defaulting to `context:`), retries a failed commit at
most twice, and always deploys the newest admitted head. `teploy deploy
--app blog --host prod` builds the head from the server-side mirror under
the same rules. A systemd timer runs the poll for every app:

```
teploy trigger install-timer --host prod # teploy-trigger-poll.timer, every minute
teploy trigger poll --app blog --host prod --now # manual / webhook wake
teploy trigger retry --app blog --host prod # re-arm a failed commit
```

Static apps cannot use `git:` yet (static deploys still need a code
source), and submodules are refused pending implementation.

### Secrets and env
```
teploy env set KEY=value # set env var
Expand Down
5 changes: 5 additions & 0 deletions contracts/MANIFEST.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ Neutron/Nucleus dependency and a public mirror.

| Corpus rev | Emitting CLI | Machine Interface | Notes |
|---|---|---|---|
| 16 | main (server-authority spec P3 / plan D4, git sources) | 2 | Additive. New artifacts source-show-envelope (`config source show --json`: repository/branch/poll, public credential facts — type, deploy-key public half + fingerprint, token username, pinned host-key fingerprints — and the per-ref admission state), source-key-envelope (`config source key --json` for ssh/token and `host-key --json`) and trigger-poll-envelope (`trigger poll --json`: one result object per app — ref, head, admitted, reason vocabulary incl. force-push/retry-limit/not-due, deployed_release), all fixtures GENERATED from the real DTOs. version-handshake advertises `git-source-v1` (git: code source, per-app credentials outside /deployments, build_secrets) and `trigger-poll-v1` (the one admitter + reset/retry/install-timer) (regenerated mi2.json). No MI bump. |
| 15 | main (server-authority spec D3: backup target) | 2 | Additive. One backup target per server, stored outside /deployments with age-encrypted credentials and read at run time (`server backup-target set/show/unset`; `backup-target-v1`). New artifacts backup-target-envelope, backup-list-envelope, backup-create-envelope, backup-restore-envelope — fixtures GENERATED from the real DTO encoders (`backup list --json` now reports parsed rows {id, kind, accessory, size_bytes, created_at, key} plus schedules discovered from teploy cron markers; the old bare-array-of-raw-`aws s3 ls`-lines `backup list --json` shape is gone — it was never a corpus artifact and pinned no consumer). `backup create/restore --json` envelopes; restore quiesces the app under its fenced lock (backup-json-v1). version-handshake advertises `backup-target-v1` and `backup-json-v1` (regenerated mi2.json). No MI bump. |
| 14 | main (server-authority plan D2, folderless reads) | 2 | Additive. New artifacts releases-envelope (`teploy releases --app X --host S --json`: container release records newest first with current/rollback_available (+reason); static apps list retained releases — both via the envelope, the folder-static legacy bare array is unchanged), rollback-result (`teploy rollback [--to X] --json`: {app, server, from, to, ok}; failures keep the error envelope) and redeploy-result (`teploy redeploy [--json]`: {app, server, home, release, image, ok}). Fixtures GENERATED from the real DTOs. version-handshake advertises `secret-stdin-v1` (secret set KEY --stdin; folderless secret set/get/list/rm/rotate), `releases-json-v1`, `rollback-json-v1`, `redeploy-v1` (fresh deploy of the current release's recorded image, env re-resolved) and `env-config-guard-v1` (env set/unset refuse keys a Teploy-home config declares) (regenerated mi2.json). No MI bump. || 13 | main (server-authority spec P2 support) | 2 | Additive. New artifact config-merge-envelope (`teploy config merge --base <commit> --file - --json`: merged config + conflicting keys, read-only), fixture GENERATED from the real configMergeDTO. version-handshake advertises `config-merge-v1`, `config-unprivileged-v1` (`config ... --unprivileged` refuses host-reaching changes) and `deploy-home-v1` (`deploy --app X --image T --home teploy` creates a new Teploy-home app) (regenerated mi2.json). No MI bump. |
| 13 | main (server-authority spec P2 support) | 2 | Additive. New artifact config-merge-envelope (`teploy config merge --base <commit> --file - --json`: merged config + conflicting keys, read-only), fixture GENERATED from the real configMergeDTO. version-handshake advertises `config-merge-v1`, `config-unprivileged-v1` (`config ... --unprivileged` refuses host-reaching changes) and `deploy-home-v1` (`deploy --app X --image T --home teploy` creates a new Teploy-home app) (regenerated mi2.json). No MI bump. |
| 12 | main (server-authority spec P1 tail) | 2 | Additive. release-record gains optional `source_sha256` (folder-home deploys store the deployed teploy.yml, detected secrets masked, as meta/<hash>.source.yml 0600) with a new valid fixture. version-handshake advertises `lock-wait-v1` (global `--wait <duration>`; lock refusals name the holder) (regenerated mi2.json). `config-home-v1` now also covers `config home teploy --from-server`, `init --from-server` and `template install --home teploy`. DR bundles are schema 2 (config home + server data); they are not a wire artifact of this corpus. No MI bump. |
| 11 | main (server-authority spec P1, config home) | 2 | Additive. New artifacts config-envelope (`teploy config get|home|set --json`: home folder/teploy, config commit, applied_commit of the running release, content) and config-history-envelope (`config history --json`), fixtures GENERATED from the real DTOs (teploy-home with config changed since deploy, folder-home, two-commit history). app-list-envelope gains optional `undeployed[]` (`--include-undeployed`, v3 tombstones) with a new valid fixture; release-record gains optional `config_commit` and `actor` with a new valid fixture. version-handshake advertises `config-home-v1` and `undeployed-list-v1` (regenerated mi2.json). State schema v3 (v2 + `tombstone`) marks Teploy-home apps; it is server state, not a wire artifact, and older CLIs refuse it by design. No MI bump. |
| 10 | main (server-authority spec P1, events) | 2 | Additive. New artifact events-envelope: `teploy events --json` (per-app server event history, `/deployments/.teploy/apps/<app>/events.jsonl`), schema + one valid fixture GENERATED from the real `writeEventsJSON` encoder (started/succeeded deploy pair + key-names-only env.set). version-handshake advertises `events-v1` and `actor-flag-v1` (regenerated mi2.json). No MI bump. |
Expand Down Expand Up @@ -45,6 +47,9 @@ Neutron/Nucleus dependency and a public mirror.
| config-envelope | yes (rev 11) | valid x2 (teploy-home, folder-home; real configDTO) | teploy-cli |
| config-history-envelope | yes (rev 11) | valid (real configHistoryDTO) | teploy-cli |
| config-merge-envelope | yes (rev 13) | valid (real configMergeDTO) | teploy-cli |
| source-show-envelope | yes (rev 16) | valid x2 (connected, no-source; real sourceShowDTO) | teploy-cli |
| source-key-envelope | yes (rev 16) | valid x2 (ssh, token; real sourceKeyDTO) | teploy-cli |
| trigger-poll-envelope | yes (rev 16) | valid x3 (deployed, force-push-refusal, not-due; real gitsource.PollResult) | teploy-cli |
| backup-target-envelope | yes (rev 15) | valid x2 (configured, unconfigured; real backupTargetDTO) | teploy-cli |
| backup-list-envelope | yes (rev 15) | valid x2 (app, legacy-schedule; real backupListDTO) | teploy-cli |
| backup-create-envelope | yes (rev 15) | valid x2 (volumes, accessory; real backupCreateDTO) | teploy-cli |
Expand Down
9 changes: 9 additions & 0 deletions contracts/fixtures/source-key-envelope/valid/ssh.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"machine_interface": 2,
"host": "192.0.2.10",
"app": "blog",
"type": "ssh",
"public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPmM9PmN0c9Jl0uR0aX1w2y3z4a5b6c7d8e9f0g1h2 teploy-deploy-blog",
"fingerprint": "256 SHA256:5kO5b5VqPm0s9R8tT1uV2wX3yZ4a5B6c7D8e9F0g1H2 teploy-deploy-blog (ED25519)",
"created": true
}
8 changes: 8 additions & 0 deletions contracts/fixtures/source-key-envelope/valid/token.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"machine_interface": 2,
"host": "192.0.2.10",
"app": "shop",
"type": "token",
"username": "x-access-token",
"created": true
}
22 changes: 22 additions & 0 deletions contracts/fixtures/source-show-envelope/valid/connected.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"machine_interface": 2,
"host": "192.0.2.10",
"app": "blog",
"repository": "git@github.com:useteploy/blog.git",
"branch": "main",
"poll": "5m",
"credential": {
"type": "ssh",
"public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPmM9PmN0c9Jl0uR0aX1w2y3z4a5b6c7d8e9f0g1h2 teploy-deploy-blog",
"fingerprint": "256 SHA256:5kO5b5VqPm0s9R8tT1uV2wX3yZ4a5B6c7D8e9F0g1H2 teploy-deploy-blog (ED25519)",
"known_host_fingerprints": [
"256 SHA256:u6nB4D9Jl0uR0aX1w2y3z4a5b6c7d8e9f0g1h2i3J4k5 no comment (ED25519)"
]
},
"trigger": {
"ref": "refs/heads/main",
"watermark": "3d9c1f0b8a7e6d5c4b3a2f1e0d9c8b7a6f5e4d3c",
"last_head": "3d9c1f0b8a7e6d5c4b3a2f1e0d9c8b7a6f5e4d3c",
"last_deployed_release": "3d9c1f0e8a7e"
}
}
5 changes: 5 additions & 0 deletions contracts/fixtures/source-show-envelope/valid/no-source.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"machine_interface": 2,
"host": "192.0.2.10",
"app": "legacy"
}
10 changes: 10 additions & 0 deletions contracts/fixtures/trigger-poll-envelope/valid/deployed.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"machine_interface": 2,
"app": "blog",
"host": "192.0.2.10",
"ref": "refs/heads/main",
"head": "3d9c1f0b8a7e6d5c4b3a2f1e0d9c8b7a6f5e4d3c",
"admitted": true,
"reason": "deployed",
"deployed_release": "3d9c1f0e8a7e"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"machine_interface": 2,
"app": "blog",
"host": "192.0.2.10",
"ref": "refs/heads/main",
"head": "9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b",
"admitted": false,
"reason": "force-push"
}
7 changes: 7 additions & 0 deletions contracts/fixtures/trigger-poll-envelope/valid/not-due.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"machine_interface": 2,
"app": "blog",
"ref": "refs/heads/main",
"admitted": false,
"reason": "not-due"
}
2 changes: 2 additions & 0 deletions contracts/fixtures/version-handshake/valid/mi2.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"env-set-stdin",
"error-envelope",
"events-v1",
"git-source-v1",
"health-modes",
"kv-set-stdin",
"lock-wait-v1",
Expand All @@ -34,6 +35,7 @@
"server-update",
"template-var-stdin",
"trigger-convergence-v1",
"trigger-poll-v1",
"undeployed-list-v1"
],
"machine_interface": 2,
Expand Down
18 changes: 18 additions & 0 deletions contracts/schema/source-key-envelope.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://teploy.github.io/contracts/schema/source-key-envelope.schema.json",
"title": "`teploy config source key --json` / `host-key --json`: the credential just created or pinned (public facts only, rev 14)",
"type": "object",
"required": ["machine_interface", "host", "app", "type", "created"],
"properties": {
"machine_interface": {"type": "integer", "minimum": 1},
"host": {"type": "string", "minLength": 1},
"app": {"type": "string", "minLength": 1},
"type": {"type": "string", "enum": ["ssh", "token", "host-key", "none"]},
"public_key": {"type": "string", "description": "The generated deploy key's public half (ssh only)."},
"fingerprint": {"type": "string"},
"username": {"type": "string", "description": "Username the token is sent as (token only)."},
"created": {"type": "boolean", "description": "False when an equivalent credential already existed and was kept."},
"host_key_fingerprints": {"type": "array", "items": {"type": "string"}, "description": "Pinned host keys (host-key only)."}
}
}
39 changes: 39 additions & 0 deletions contracts/schema/source-show-envelope.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://teploy.github.io/contracts/schema/source-show-envelope.schema.json",
"title": "`teploy config source show --json`: an app's git source public state (no secrets, rev 14)",
"type": "object",
"required": ["machine_interface", "host", "app"],
"properties": {
"machine_interface": {"type": "integer", "minimum": 1},
"host": {"type": "string", "minLength": 1},
"app": {"type": "string", "minLength": 1},
"repository": {"type": "string", "minLength": 1, "description": "The git: repository URL, empty when the app has no git source."},
"branch": {"type": "string"},
"poll": {"type": "string", "description": "Effective poll interval (\"5m\" default) or \"off\"."},
"credential": {
"type": "object",
"required": ["type"],
"properties": {
"type": {"type": "string", "enum": ["ssh", "token"]},
"public_key": {"type": "string", "description": "The deploy key's public half (ssh only)."},
"fingerprint": {"type": "string", "description": "ssh-keygen -lf form."},
"username": {"type": "string", "description": "Username sent with the token (token only)."},
"known_host_fingerprints": {"type": "array", "items": {"type": "string"}}
},
"additionalProperties": false
},
"trigger": {
"type": "object",
"required": ["ref"],
"properties": {
"ref": {"type": "string", "pattern": "^refs/heads/"},
"watermark": {"type": "string", "description": "Last admitted commit for the ref (the ancestry bar)."},
"last_head": {"type": "string"},
"refused_head": {"type": "string", "description": "Non-empty while a force-push is refused (until `trigger reset`)."},
"last_deployed_release": {"type": "string"}
},
"additionalProperties": false
}
}
}
Loading
Loading