Skip to content

feat: git code sources for Teploy-home apps and the single poll admitter (D4) - #44

Merged
im-tyler merged 7 commits into
mainfrom
feat/git-source-trigger
Oct 20, 2026
Merged

im-tyler merged 7 commits into
mainfrom
feat/git-source-trigger

Conversation

@im-tyler

Copy link
Copy Markdown
Contributor

What

Decision D4 of the P3 plan (CLI side): Teploy-home apps can build from a
git repository — GitHub, GitLab, Forgejo/Gitea, Cursor Origin, or any git
host over SSH/HTTPS — with one server-side admitter.

  • Config: git: {repository, branch, paths, poll, submodules} (Teploy
    home only; folder loader refuses it). With git: set, image: becomes
    optional (and setting both is refused). context:/dockerfile: are
    relative to the repo root. build_secrets: [KEY] passes age-store values
    as docker build --secret — never in config, layers, argv or logs.
  • Credentials (teploy config source key --app … --host … [--type ssh|token]): ed25519 deploy key generated on the server (public
    half printed, private half age-encrypted outside /deployments at
    /etc/teploy/apps/<app> as root, ~/.config/teploy/apps otherwise), or
    an HTTPS token read from stdin (used via GIT_ASKPASS, never in
    URLs/.git/config/argv). config source host-key pins the forge's SSH
    host key (ssh-keyscan server-side, fingerprints shown,
    --expected-fingerprint verification); fetches run
    StrictHostKeyChecking=yes against it. The age identity is tried at
    /etc/teploy/age-key then /deployments/.age-key (aligns with W2's
    move). config source show --json (no secrets) and remove.
  • Build path: per-app bare mirror
    (/deployments/.teploy/apps/<app>/mirror.git, code only) → fetch with
    the app credential → git archive <commit> into the attempt build dir →
    existing builder. Watermark refs keep ancestry-checkable commits
    reachable across force-pushes. Submodules off: .gitmodules present →
    explicit error (submodules: true refused at validation).
  • One admitter (teploy trigger poll --app X --host S [--now] [--hint <sha>], --all for the timer): fenced app lock; ls-remote with
    the credential; hint only wakes (remote head wins); dedup by
    (app, ref, commit); per-(app,ref) watermark + ancestry check on the
    mirror; force-push → ONE recorded refusal per (ref, head), refused until
    teploy trigger reset --app X --ref R; changed paths computed on the
    server (git diff --name-only) filtered by git.paths defaulting to
    context:; failed commit retried at most 2×, then only a new commit,
    teploy trigger retry, or a redeploy; superseding always deploys the
    newest admitted head. Records under
    /deployments/.teploy/apps/<app>/admissions/ (flock), events
    trigger.admit/trigger.refuse with {ref, commit, reason}.
    --json → {app, ref, head, admitted, reason?, deployed_release?}.
  • Timer: teploy trigger install-timer --host S installs an
    idempotent systemd pair running /deployments/.bin/teploy trigger poll --all every minute (per-app intervals honored via admissions
    timestamps); offered after config source key (best-effort);
    doctor gains a git-trigger check (timer present, resident binary
    supports trigger poll). Nothing per-app in crontab.
  • Deploy from config: teploy deploy --app X with git: builds the
    branch head from the mirror under the lock, records the commit as
    source_revision (release provenance + deploy event), honors the same
    force-push refusal, and advances the watermark.
  • Tokens git-source-v1, trigger-poll-v1; contracts corpus rev 14
    (source-show-envelope, source-key-envelope, trigger-poll-envelope,
    regenerated version-handshake); events: trigger.admit reason detail,
    new source.connect kind.

Deliberate deviations (documented in README/CHANGELOG): static
Teploy-home apps with git: stay refused; git.submodules: true is
refused pending implementation; file:// repositories are accepted as a
privileged config change (same-server sources; also what the tests
use); image: + git: together is refused rather than choosing one.

Testing

  • go vet ./..., go test -race ./... green locally.
  • New integration tests drive the real server-side helper over a
    LocalExecutor against local bare-repo fixtures: poll admit/dedup,
    supersede + stale hint, interval/poll: off, force-push refusal +
    reset, path filter, retry budget + retry, repository change,
    branch loss, manual-deploy watermark, path matcher, config/confighome
    validation, build --secret command shape, doctor check, contracts
    goldens.
  • Live check on infra-home with the w4probe throwaway app (cleaned up
    after): ssh deploy-key generation, host-key pin, poll admit + deploy
    (whoami image), second commit poll, force-push refusal + reset, path
    filter, timer install/uninstall.

🤖 Generated with Claude Code

im-tyler and others added 7 commits October 19, 2026 21:08
…ll} and build_secrets

A Teploy-home app may build from a git repository instead of a pre-built
image: git: names the repository (ssh://, scp-style, https://, or file://
as a privileged same-server source), the branch the admitter polls, an
optional monorepo path filter, and the poll interval (>= 1m, or "off").
With git: set, image: becomes optional — and setting both is refused, as
is git: in a folder app (the folder loader rejects it), static + git,
submodules: true (not implemented), embedded URL passwords, and unsafe
transports (http://, git://). build_secrets: names age-store keys the
repository build passes as docker build --secret.

confighome.Load drops the image-required rule when git: is set and
keeps refusing static apps with a code source. Changing to or from a
file:// repository counts as a privileged change (it reads server-local
paths with the deploy user's rights).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mitter

One server-side python helper (over the app's executor, like the
confighome/events helpers) owns everything git:

- credentials per app, age-encrypted OUTSIDE /deployments
      (/etc/teploy/apps/<app> as root, ~/.config/teploy/apps otherwise):
      an ed25519 deploy key (public half + fingerprint out, private half
      age-encrypted, idempotent until --rotate), an HTTPS token read
      from stdin (never argv, used via GIT_ASKPASS, credential helpers
      disabled), and the forge host key pinned via ssh-keyscan (with
      optional --expected-fingerprint verification). Fetches run with
      StrictHostKeyChecking=yes against that pin; the age identity is
      tried at /etc/teploy/age-key then /deployments/.age-key (W2's
      move) and decrypted keys live in a 0600 tmpfs file for exactly
      one git invocation.
- a per-app bare mirror under .teploy/apps/<app>/mirror.git holding
      only code, with watermark refs (refs/teploy/wm/<branch>) keeping
      ancestry-checkable commits reachable across force-pushes, and
      git archive export of the exact admitted commit plus remote
      Dockerfile/submodule detection.
- admissions state under admissions/ (flock'd, atomic): Poll is the one
      admitter — dedup by (app, ref, commit), per-ref watermark with an
      ancestry check, ONE recorded refusal per force-push (refused until
      ResetRef), changed paths diffed on the server and filtered by
      git.paths defaulting to context:, failed commits retried at most
      twice (RetryClear re-arms), the newest admitted head always
      deployed, repository changes voiding stale watermarks, and
      manual-deploy helpers (ResolveHead, RefusedHead,
      RecordManualDeploy) so  follows the same
      rules.

Integration tests drive the real helper over a LocalExecutor against
file:// bare-repo fixtures: admit/dedup/supersede+hint, interval and
poll: off, force-push refusal + reset, path filter, retry budget,
repository change, branch loss, watermark recording, and the pure path
matcher.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…imer, git builds

- teploy config source key --type ssh|token (token from stdin),
  host-key (pin + verify), show --json (public facts only) and remove;
  creating a credential offers the server poll timer (best-effort).
- teploy trigger poll (--app | --all) [--now] [--hint] is the one
  admitter's surface: it holds the app's fenced lock, refuses nothing
  quietly (JSON reason vocabulary: deployed, up-to-date, not-due,
  poll-off, no-relevant-changes, force-push, retry-limit,
  branch-missing, failed, locked), and deploys through the normal
  engine. trigger reset clears a ref's watermark; trigger retry re-arms
  a failed commit; install-timer/uninstall-timer manage an idempotent
  systemd pair (teploy-trigger-poll.{service,timer}) running
  'trigger poll --all' every minute as the connecting user, with the
  resident binary proven to support 'trigger poll' first (or a dev
  upload via --binary).
- Deploying a Teploy-home app with git: (teploy deploy --app ...) now
  resolves the branch head under the lock, exports the commit from the
  mirror into the attempt's build dir, builds server-side (build_secrets
  decrypted to 0600 files outside the context, mounted with
  DOCKER_BUILDKIT --secret, removed right after), records the commit as
  source_revision, honors force-push refusals and advances the
  watermark; poll reuses the same locked path. remove --purge forgets
  the credential (it lives outside /deployments).
- events: trigger.admit gains a reason detail; new source.connect kind
  (action/type/fingerprint). doctor gains a git-trigger check (timer
  present + binary capable when git: apps exist). Capability tokens
  git-source-v1 and trigger-poll-v1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
New artifacts (schemas + GENERATED fixtures from the real DTOs):
source-show-envelope (config source show --json), source-key-envelope
(config source key --json, ssh + token) and trigger-poll-envelope
(trigger poll --json result: one object per app, reason vocabulary).
version-handshake advertises git-source-v1 and trigger-poll-v1
(regenerated mi2.json). README gains a "Code from a git repository"
section (works with GitHub, GitLab, Forgejo/Gitea, Cursor Origin, any
git host over SSH/HTTPS; static + git still refused; submodules
pending) and the CHANGELOG records the feature.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nary

A server that never ran autodeploy or heal has no /deployments/.bin, and
installServerBinary's flock on <destination>.install.lock then fails
with ENOENT — found in the infra-home live check (install-timer on a
fresh server). mkdir -p the directory first for both the release
download and the --binary dev upload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ic+git, repository authority)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rigger

D4 corpus renumbered to rev 16 (D2 = 14, D3 = 15). Restores the rev 13
MANIFEST row (config-merge-envelope, v0.1.44) that the D3/D2 merge commit
cd981fe dropped from main, and corrects the D3 CHANGELOG entry to rev 15.
Hand-resolved: doctor check lists and tests (git-trigger alongside
backup-cron and age-identity), capability tokens, event-kind allowlist,
contracts golden test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@im-tyler
im-tyler merged commit 85a0202 into main Oct 20, 2026
1 check passed
@im-tyler im-tyler mentioned this pull request Oct 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant