shipit-deploy 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 shipit contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,174 @@
1
+ Metadata-Version: 2.4
2
+ Name: shipit-deploy
3
+ Version: 0.2.0
4
+ Summary: Self-hosted, language-agnostic auto deploy: git push -> build -> health check -> rollback
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/desnarong/shipit
7
+ Project-URL: Issues, https://github.com/desnarong/shipit/issues
8
+ Project-URL: Changelog, https://github.com/desnarong/shipit/blob/main/CHANGELOG.md
9
+ Keywords: deploy,auto-deploy,docker,ci-cd,self-hosted,webhook,devops
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: System Administrators
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Software Development :: Build Tools
19
+ Classifier: Topic :: System :: Software Distribution
20
+ Requires-Python: >=3.11
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Dynamic: license-file
24
+
25
+ # 🚢 shipit
26
+
27
+ [![CI](https://github.com/desnarong/shipit/actions/workflows/ci.yml/badge.svg)](https://github.com/desnarong/shipit/actions/workflows/ci.yml)
28
+ [![PyPI](https://img.shields.io/pypi/v/shipit-deploy)](https://pypi.org/project/shipit-deploy/)
29
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
30
+
31
+ Self-hosted, open-source auto deploy that works with **any language**.
32
+ `git push` → webhook → clone → build (Docker) → run → health check → **auto rollback** on failure.
33
+
34
+ - Zero dependencies (Python 3.11+ stdlib only) + Docker + git
35
+ - Auto-detects Node, Python, Go, Rust, Java (Maven/Gradle), PHP, Ruby, .NET, Elixir, static sites
36
+ - Python start command is inferred for Django (gunicorn), FastAPI (uvicorn) and Flask (gunicorn)
37
+ - **Any other language:** put a `Dockerfile` in your repo – it always takes priority
38
+ - GitHub / Gitea / Forgejo (HMAC) and GitLab (token) webhooks
39
+ - Private repos via token from an env var (never written to disk)
40
+ - **Safe deploys:** the new version starts beside the live one and must pass its health check *before* the swap; a bad release never touches production
41
+ - Automatic rollback if it fails right after the swap, or manually with `shipit rollback <app>`
42
+ - Slack / Discord / Mattermost notifications, deploy history, automatic pruning of old images
43
+ - `shipit status <app>` and `shipit logs <app> -f`
44
+
45
+ ## Install on a Linux server (one command)
46
+
47
+ ```bash
48
+ curl -fsSL https://raw.githubusercontent.com/desnarong/shipit/main/install.sh | sudo bash
49
+ ```
50
+
51
+ Installs git, Python and Docker if missing, puts shipit in `/opt/shipit`, creates a `shipit` user and a
52
+ systemd service that listens for webhooks on port 9000. Read the script first if you like - it is short.
53
+ Options: `--dry-run`, `--no-docker`, `--no-service`, `--from-git`.
54
+
55
+ Then deploy and enable auto deploy:
56
+
57
+ ```bash
58
+ sudo -u shipit SHIPIT_CONFIG=/var/lib/shipit/shipit.toml shipit up https://github.com/you/your-app.git
59
+ shipit hook your-app --url http://YOUR-SERVER-IP:9000 # registers the GitHub webhook for you (needs `gh`)
60
+ ```
61
+
62
+ ## Quick start (any machine with Docker)
63
+
64
+ ```bash
65
+ pip install shipit-deploy # the command is `shipit`
66
+ # or the latest from GitHub: pip install git+https://github.com/desnarong/shipit.git
67
+ shipit up https://github.com/you/your-app.git
68
+ ```
69
+
70
+ That's it. `shipit up` picks the default branch, a free port and the right Dockerfile (or writes one),
71
+ builds, starts the app, health-checks it and prints the URL. No config file to write -
72
+ it creates `shipit.toml` for you. Add `--domain app.example.com` for HTTPS + zero-downtime releases (needs Caddy).
73
+
74
+ **Turn on auto deploy** (every push redeploys): run `shipit serve`, then register the webhook:
75
+ `shipit hook myapp --url http://YOUR-SERVER:9000` (GitHub, uses the `gh` CLI), or add the URL and secret
76
+ that `shipit up` printed to your repo by hand (Settings -> Webhooks). Apps added with `shipit up`
77
+ are picked up by a running `shipit serve` automatically - no restart.
78
+
79
+ Other commands:
80
+
81
+ ```bash
82
+ shipit list # apps, status, URLs
83
+ shipit hook myapp --url URL # register the GitHub push webhook
84
+ shipit deploy myapp # redeploy now
85
+ shipit rollback myapp # back to the previous release
86
+ shipit logs myapp -f # follow logs
87
+ shipit status myapp # current release + history
88
+ shipit doctor # check docker / git / caddy / secrets
89
+ shipit detect ./my-project # preview the generated Dockerfile
90
+ ```
91
+
92
+ The webhook secret is generated for you (kept in `.shipit/webhook-secret`); set `SHIPIT_SECRET` to use your own.
93
+ If your Dockerfile has a single `EXPOSE`, shipit uses that port automatically.
94
+
95
+ Add a webhook in your repo: URL `http://your-server:9000/hook/myapp`, content type
96
+ `application/json`, secret = `SHIPIT_SECRET` (GitLab: put it in "Secret token").
97
+ Only pushes to the configured `branch` deploy.
98
+
99
+ ## Config (`shipit.toml`)
100
+
101
+ | key | meaning |
102
+ |---|---|
103
+ | `name` | app name (`a-z 0-9 _ . -`), also the container name |
104
+ | `repo`, `branch` | git source |
105
+ | `port` / `host_port` | container port / published port |
106
+ | `health_path` | HTTP path polled after start (`""` = only check it stays running) |
107
+ | `start` | start command for generated Dockerfiles |
108
+ | `dockerfile` | custom Dockerfile path inside the repo |
109
+ | `pre_deploy` | command run once in the new image before release (e.g. `python manage.py migrate`); failure aborts and the live version stays |
110
+ | `env_file` | host env file passed to the container |
111
+ | `token_env` | env var holding a git token (private https repos) |
112
+ | `secret_env` | env var with this app's webhook secret |
113
+ | `notify_env` | env var holding a Slack/Discord/Mattermost webhook URL |
114
+ | `keep_images` | old images to keep (default 5; current + previous always kept) |
115
+ | `build_args` | table of Docker build args |
116
+
117
+ ## Zero-downtime with Caddy
118
+
119
+ Set `domain` on an app and shipit switches to **blue/green** deploys behind [Caddy](https://caddyserver.com):
120
+
121
+ ```toml
122
+ [server]
123
+ caddy_admin = "http://127.0.0.1:2019" # default
124
+
125
+ [[app]]
126
+ name = "myapp"
127
+ domain = "app.example.com" # DNS must point at this server
128
+ host_port = 9000 # slots use 127.0.0.1:9000 (blue) and :9001 (green)
129
+ ```
130
+
131
+ A release starts the idle slot, waits for its health check, flips the Caddy route, waits
132
+ `drain_seconds`, then removes the old slot - no gap, and HTTPS certificates come from Caddy automatically.
133
+ If the health check or the Caddy update fails, the live version is untouched.
134
+
135
+ Requirements: Caddy running with its admin API enabled (the default). **shipit owns the Caddy config** -
136
+ it pushes a full config on every release and on `shipit serve` start, so don't hand-edit that Caddy instance.
137
+ Apps without `domain` keep the simple mode (brief swap gap).
138
+
139
+ ## Run it as a service (systemd)
140
+
141
+ ```ini
142
+ [Service]
143
+ Environment=SHIPIT_SECRET=change-me
144
+ WorkingDirectory=/opt/shipit
145
+ ExecStart=/usr/local/bin/shipit serve
146
+ Restart=always
147
+ ```
148
+
149
+ Put nginx/Caddy in front for HTTPS and for routing domains to each app's `host_port`.
150
+
151
+ ## Security notes
152
+
153
+ - The webhook endpoint rejects anything without a valid signature/token.
154
+ - Deploying runs `docker build` on code from your repo – only point it at repos you trust.
155
+ - Run `shipit serve` behind HTTPS; the host running it controls Docker, so keep it locked down.
156
+
157
+ ## Limitations (v0.1)
158
+
159
+ Single server, single container per app. Without `domain` there is a brief gap while the port is handed over.
160
+ PRs welcome – ideas: multi-server, PR preview deploys, web UI, Traefik/nginx backends.
161
+
162
+ ## Develop
163
+
164
+ ```bash
165
+ python -m unittest discover -s tests
166
+ ```
167
+
168
+ End-to-end tests deploy real sample apps with Docker (needs Docker + git, takes a few minutes):
169
+
170
+ ```bash
171
+ SHIPIT_E2E=1 python -m unittest tests.test_e2e -v
172
+ ```
173
+
174
+ Contributions welcome - see [CONTRIBUTING.md](CONTRIBUTING.md). Security issues: [SECURITY.md](SECURITY.md). MIT licensed.
@@ -0,0 +1,150 @@
1
+ # 🚢 shipit
2
+
3
+ [![CI](https://github.com/desnarong/shipit/actions/workflows/ci.yml/badge.svg)](https://github.com/desnarong/shipit/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/shipit-deploy)](https://pypi.org/project/shipit-deploy/)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
6
+
7
+ Self-hosted, open-source auto deploy that works with **any language**.
8
+ `git push` → webhook → clone → build (Docker) → run → health check → **auto rollback** on failure.
9
+
10
+ - Zero dependencies (Python 3.11+ stdlib only) + Docker + git
11
+ - Auto-detects Node, Python, Go, Rust, Java (Maven/Gradle), PHP, Ruby, .NET, Elixir, static sites
12
+ - Python start command is inferred for Django (gunicorn), FastAPI (uvicorn) and Flask (gunicorn)
13
+ - **Any other language:** put a `Dockerfile` in your repo – it always takes priority
14
+ - GitHub / Gitea / Forgejo (HMAC) and GitLab (token) webhooks
15
+ - Private repos via token from an env var (never written to disk)
16
+ - **Safe deploys:** the new version starts beside the live one and must pass its health check *before* the swap; a bad release never touches production
17
+ - Automatic rollback if it fails right after the swap, or manually with `shipit rollback <app>`
18
+ - Slack / Discord / Mattermost notifications, deploy history, automatic pruning of old images
19
+ - `shipit status <app>` and `shipit logs <app> -f`
20
+
21
+ ## Install on a Linux server (one command)
22
+
23
+ ```bash
24
+ curl -fsSL https://raw.githubusercontent.com/desnarong/shipit/main/install.sh | sudo bash
25
+ ```
26
+
27
+ Installs git, Python and Docker if missing, puts shipit in `/opt/shipit`, creates a `shipit` user and a
28
+ systemd service that listens for webhooks on port 9000. Read the script first if you like - it is short.
29
+ Options: `--dry-run`, `--no-docker`, `--no-service`, `--from-git`.
30
+
31
+ Then deploy and enable auto deploy:
32
+
33
+ ```bash
34
+ sudo -u shipit SHIPIT_CONFIG=/var/lib/shipit/shipit.toml shipit up https://github.com/you/your-app.git
35
+ shipit hook your-app --url http://YOUR-SERVER-IP:9000 # registers the GitHub webhook for you (needs `gh`)
36
+ ```
37
+
38
+ ## Quick start (any machine with Docker)
39
+
40
+ ```bash
41
+ pip install shipit-deploy # the command is `shipit`
42
+ # or the latest from GitHub: pip install git+https://github.com/desnarong/shipit.git
43
+ shipit up https://github.com/you/your-app.git
44
+ ```
45
+
46
+ That's it. `shipit up` picks the default branch, a free port and the right Dockerfile (or writes one),
47
+ builds, starts the app, health-checks it and prints the URL. No config file to write -
48
+ it creates `shipit.toml` for you. Add `--domain app.example.com` for HTTPS + zero-downtime releases (needs Caddy).
49
+
50
+ **Turn on auto deploy** (every push redeploys): run `shipit serve`, then register the webhook:
51
+ `shipit hook myapp --url http://YOUR-SERVER:9000` (GitHub, uses the `gh` CLI), or add the URL and secret
52
+ that `shipit up` printed to your repo by hand (Settings -> Webhooks). Apps added with `shipit up`
53
+ are picked up by a running `shipit serve` automatically - no restart.
54
+
55
+ Other commands:
56
+
57
+ ```bash
58
+ shipit list # apps, status, URLs
59
+ shipit hook myapp --url URL # register the GitHub push webhook
60
+ shipit deploy myapp # redeploy now
61
+ shipit rollback myapp # back to the previous release
62
+ shipit logs myapp -f # follow logs
63
+ shipit status myapp # current release + history
64
+ shipit doctor # check docker / git / caddy / secrets
65
+ shipit detect ./my-project # preview the generated Dockerfile
66
+ ```
67
+
68
+ The webhook secret is generated for you (kept in `.shipit/webhook-secret`); set `SHIPIT_SECRET` to use your own.
69
+ If your Dockerfile has a single `EXPOSE`, shipit uses that port automatically.
70
+
71
+ Add a webhook in your repo: URL `http://your-server:9000/hook/myapp`, content type
72
+ `application/json`, secret = `SHIPIT_SECRET` (GitLab: put it in "Secret token").
73
+ Only pushes to the configured `branch` deploy.
74
+
75
+ ## Config (`shipit.toml`)
76
+
77
+ | key | meaning |
78
+ |---|---|
79
+ | `name` | app name (`a-z 0-9 _ . -`), also the container name |
80
+ | `repo`, `branch` | git source |
81
+ | `port` / `host_port` | container port / published port |
82
+ | `health_path` | HTTP path polled after start (`""` = only check it stays running) |
83
+ | `start` | start command for generated Dockerfiles |
84
+ | `dockerfile` | custom Dockerfile path inside the repo |
85
+ | `pre_deploy` | command run once in the new image before release (e.g. `python manage.py migrate`); failure aborts and the live version stays |
86
+ | `env_file` | host env file passed to the container |
87
+ | `token_env` | env var holding a git token (private https repos) |
88
+ | `secret_env` | env var with this app's webhook secret |
89
+ | `notify_env` | env var holding a Slack/Discord/Mattermost webhook URL |
90
+ | `keep_images` | old images to keep (default 5; current + previous always kept) |
91
+ | `build_args` | table of Docker build args |
92
+
93
+ ## Zero-downtime with Caddy
94
+
95
+ Set `domain` on an app and shipit switches to **blue/green** deploys behind [Caddy](https://caddyserver.com):
96
+
97
+ ```toml
98
+ [server]
99
+ caddy_admin = "http://127.0.0.1:2019" # default
100
+
101
+ [[app]]
102
+ name = "myapp"
103
+ domain = "app.example.com" # DNS must point at this server
104
+ host_port = 9000 # slots use 127.0.0.1:9000 (blue) and :9001 (green)
105
+ ```
106
+
107
+ A release starts the idle slot, waits for its health check, flips the Caddy route, waits
108
+ `drain_seconds`, then removes the old slot - no gap, and HTTPS certificates come from Caddy automatically.
109
+ If the health check or the Caddy update fails, the live version is untouched.
110
+
111
+ Requirements: Caddy running with its admin API enabled (the default). **shipit owns the Caddy config** -
112
+ it pushes a full config on every release and on `shipit serve` start, so don't hand-edit that Caddy instance.
113
+ Apps without `domain` keep the simple mode (brief swap gap).
114
+
115
+ ## Run it as a service (systemd)
116
+
117
+ ```ini
118
+ [Service]
119
+ Environment=SHIPIT_SECRET=change-me
120
+ WorkingDirectory=/opt/shipit
121
+ ExecStart=/usr/local/bin/shipit serve
122
+ Restart=always
123
+ ```
124
+
125
+ Put nginx/Caddy in front for HTTPS and for routing domains to each app's `host_port`.
126
+
127
+ ## Security notes
128
+
129
+ - The webhook endpoint rejects anything without a valid signature/token.
130
+ - Deploying runs `docker build` on code from your repo – only point it at repos you trust.
131
+ - Run `shipit serve` behind HTTPS; the host running it controls Docker, so keep it locked down.
132
+
133
+ ## Limitations (v0.1)
134
+
135
+ Single server, single container per app. Without `domain` there is a brief gap while the port is handed over.
136
+ PRs welcome – ideas: multi-server, PR preview deploys, web UI, Traefik/nginx backends.
137
+
138
+ ## Develop
139
+
140
+ ```bash
141
+ python -m unittest discover -s tests
142
+ ```
143
+
144
+ End-to-end tests deploy real sample apps with Docker (needs Docker + git, takes a few minutes):
145
+
146
+ ```bash
147
+ SHIPIT_E2E=1 python -m unittest tests.test_e2e -v
148
+ ```
149
+
150
+ Contributions welcome - see [CONTRIBUTING.md](CONTRIBUTING.md). Security issues: [SECURITY.md](SECURITY.md). MIT licensed.
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "shipit-deploy"
7
+ version = "0.2.0"
8
+ description = "Self-hosted, language-agnostic auto deploy: git push -> build -> health check -> rollback"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.11"
12
+ dependencies = []
13
+ keywords = ["deploy", "auto-deploy", "docker", "ci-cd", "self-hosted", "webhook", "devops"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "Intended Audience :: System Administrators",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Topic :: Software Development :: Build Tools",
24
+ "Topic :: System :: Software Distribution",
25
+ ]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/desnarong/shipit"
29
+ Issues = "https://github.com/desnarong/shipit/issues"
30
+ Changelog = "https://github.com/desnarong/shipit/blob/main/CHANGELOG.md"
31
+
32
+ [project.scripts]
33
+ shipit = "shipit.cli:main"
34
+
35
+ [tool.setuptools]
36
+ packages = ["shipit"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,189 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+ import sys
6
+ import tomllib
7
+ from pathlib import Path
8
+
9
+ from . import __version__
10
+ from .config import LiveConfig, load
11
+ from .deploy import Deployer
12
+ from .detect import detect
13
+ from .easy import app_name_from_repo, append_app, default_branch, free_port, get_secret, webhook_secret
14
+ from .proxy import sync as _proxy_sync
15
+
16
+ def resync_proxy(cfg) -> None:
17
+ """Re-apply Caddy routes at startup (e.g. after Caddy restarted)."""
18
+ if not any(a.domain for a in cfg.apps.values()):
19
+ return
20
+ try:
21
+ _proxy_sync(cfg, {n: Deployer(a, cfg.state_dir).state() for n, a in cfg.apps.items()})
22
+ except OSError as e:
23
+ print(f"[shipit] warning: could not sync Caddy: {e}", file=sys.stderr)
24
+
25
+
26
+ SAMPLE = '''[server]
27
+ port = 9000
28
+ state_dir = ".shipit"
29
+
30
+ [[app]]
31
+ name = "myapp"
32
+ repo = "https://github.com/you/myapp.git"
33
+ branch = "main"
34
+ port = 8080 # port inside the container
35
+ host_port = 8080 # port on this machine
36
+ health_path = "/"
37
+ # token_env = "GIT_TOKEN" # private repos
38
+ # secret_env = "MYAPP_SECRET" # webhook secret (default: SHIPIT_SECRET)
39
+ # notify_env = "SLACK_URL" # env var with a Slack/Discord webhook URL
40
+ # pre_deploy = "python manage.py migrate" # runs in the new image before release
41
+ # domain = "app.example.com" # zero-downtime via Caddy (blue/green on host_port and host_port+1)
42
+ # keep_images = 5
43
+ # env_file = "/etc/myapp.env"
44
+ # start = "gunicorn app:app -b 0.0.0.0:8080"
45
+ '''
46
+
47
+
48
+ def cmd_up(a) -> int:
49
+ """Zero-config: register a repo, deploy it now, and explain how to enable auto deploy."""
50
+ path = Path(a.config)
51
+ raw = tomllib.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
52
+ existing = raw.get("app", [])
53
+ name = a.name or app_name_from_repo(a.repo)
54
+ if any(x["name"] == name for x in existing):
55
+ print(f"app {name!r} already exists - redeploy it with: shipit deploy {name}", file=sys.stderr)
56
+ return 2
57
+ taken = {p for x in existing for p in (x.get("host_port", 8080), x.get("host_port", 8080) + 1)}
58
+ host_port = free_port(taken)
59
+ fields = {"name": name, "repo": a.repo, "branch": a.branch or default_branch(a.repo),
60
+ "port": a.port, "host_port": host_port, "domain": a.domain}
61
+ append_app(path, fields)
62
+ cfg = load(path)
63
+ print(f"[shipit] added {name!r} to {path} (branch {fields['branch']}, port {host_port})", flush=True)
64
+ ok = Deployer(cfg.apps[name], cfg.state_dir, cfg).deploy()
65
+ if not ok:
66
+ print(f"\nDeploy failed. Fix the problem, then retry with: shipit deploy {name}", file=sys.stderr)
67
+ return 1
68
+ url = f"https://{a.domain}" if a.domain else f"http://localhost:{host_port}"
69
+ print(f"\n{name} is live: {url}\n")
70
+ print("Turn on auto deploy (every push to the branch redeploys):")
71
+ print(" 1. run: shipit serve")
72
+ print(f" 2. GitHub repos, automatic: shipit hook {name} --url http://<this-server>:{cfg.listen_port}")
73
+ print(f" or by hand: Settings -> Webhooks -> http://<this-server>:{cfg.listen_port}/hook/{name}")
74
+ print(f" content type: application/json secret: {get_secret(cfg.state_dir)}")
75
+ return 0
76
+
77
+
78
+ def cmd_hook(a, cfg) -> int:
79
+ """Register the GitHub webhook for an app (needs the `gh` CLI, logged in)."""
80
+ from . import hooks
81
+ app = cfg.apps[a.app]
82
+ url = hooks.hook_url(a.url, app.name)
83
+ secret = webhook_secret(app, cfg.state_dir)
84
+ try:
85
+ result = hooks.register(app.repo, url, secret)
86
+ except (ValueError, RuntimeError, OSError) as e:
87
+ print(f"could not register automatically: {e}", file=sys.stderr)
88
+ print(f"Add it by hand: URL {url}, content type application/json, secret: {secret}", file=sys.stderr)
89
+ return 1
90
+ print(f"webhook {result}: pushes to {app.branch} will now redeploy {app.name} ({url})")
91
+ return 0
92
+
93
+
94
+ def cmd_list(cfg) -> int:
95
+ for app in cfg.apps.values():
96
+ d = Deployer(app, cfg.state_dir, cfg)
97
+ url = f"https://{app.domain}" if app.domain else f"http://localhost:{app.host_port}"
98
+ state = "running" if d._running(d._live_name()) else "stopped"
99
+ print(f"{app.name:<20} {state:<8} {url:<34} {d.state().get('current', '-')}")
100
+ return 0
101
+
102
+
103
+ def main(argv: list[str] | None = None) -> int:
104
+ p = argparse.ArgumentParser(prog="shipit", description="Self-hosted auto deploy for any language.")
105
+ p.add_argument("--version", action="version", version=__version__)
106
+ p.add_argument("-c", "--config", default=os.environ.get("SHIPIT_CONFIG", "shipit.toml"))
107
+ sub = p.add_subparsers(dest="cmd", required=True)
108
+ sub.add_parser("init", help="write a sample shipit.toml")
109
+ d = sub.add_parser("detect", help="show detected language / print generated Dockerfile")
110
+ d.add_argument("path", nargs="?", default=".")
111
+ d.add_argument("--port", type=int, default=8080)
112
+ for name, h in (("deploy", "deploy an app now"), ("rollback", "roll back to previous release")):
113
+ s = sub.add_parser(name, help=h)
114
+ s.add_argument("app")
115
+ st = sub.add_parser("status", help="show current release and recent history")
116
+ st.add_argument("app")
117
+ lg = sub.add_parser("logs", help="show container logs")
118
+ lg.add_argument("app")
119
+ lg.add_argument("-n", "--tail", type=int, default=100)
120
+ lg.add_argument("-f", "--follow", action="store_true")
121
+ sub.add_parser("doctor", help="check docker/git/caddy/secrets before deploying")
122
+ up = sub.add_parser("up", help="zero-config: deploy a git repo right now")
123
+ up.add_argument("repo", help="git URL of the project")
124
+ up.add_argument("--name")
125
+ up.add_argument("--branch", help="default: the repo's default branch")
126
+ up.add_argument("--port", type=int, help="port the app listens on in the container (usually auto-detected)")
127
+ up.add_argument("--domain", help="serve at this domain via Caddy with zero-downtime releases")
128
+ hk = sub.add_parser("hook", help="register the GitHub push webhook for an app (uses gh)")
129
+ hk.add_argument("app")
130
+ hk.add_argument("--url", required=True, help="public URL of this server, e.g. https://deploy.example.com or http://1.2.3.4:9000")
131
+ sub.add_parser("list", help="list apps and their status")
132
+ sub.add_parser("serve", help="run the webhook server")
133
+ a = p.parse_args(argv)
134
+
135
+ try:
136
+ if a.cmd == "up":
137
+ return cmd_up(a)
138
+ if a.cmd == "init":
139
+ f = Path(a.config)
140
+ if f.exists():
141
+ print(f"{f} already exists", file=sys.stderr)
142
+ return 1
143
+ f.write_text(SAMPLE, encoding="utf-8")
144
+ print(f"wrote {f}")
145
+ return 0
146
+ if a.cmd == "detect":
147
+ r = detect(a.path, a.port)
148
+ print(f"# language: {r.lang}\n{r.dockerfile}")
149
+ return 0
150
+ cfg = load(a.config)
151
+ if a.cmd == "hook":
152
+ if a.app not in cfg.apps:
153
+ print(f"unknown app {a.app!r}; known: {', '.join(cfg.apps)}", file=sys.stderr)
154
+ return 2
155
+ return cmd_hook(a, cfg)
156
+ if a.cmd == "list":
157
+ return cmd_list(cfg)
158
+ if a.cmd == "doctor":
159
+ from . import doctor
160
+ return doctor.run(cfg)
161
+ if a.cmd == "serve":
162
+ from .webhook import make_server
163
+ resync_proxy(cfg)
164
+ srv = make_server(LiveConfig(a.config))
165
+ print(f"[shipit] listening on {cfg.listen_host}:{cfg.listen_port} (POST /hook/<app>)", flush=True)
166
+ try:
167
+ srv.serve_forever()
168
+ except KeyboardInterrupt:
169
+ pass
170
+ return 0
171
+ if a.app not in cfg.apps:
172
+ print(f"unknown app {a.app!r}; known: {', '.join(cfg.apps)}", file=sys.stderr)
173
+ return 2
174
+ dep = Deployer(cfg.apps[a.app], cfg.state_dir, cfg)
175
+ if a.cmd == "status":
176
+ print(dep.status())
177
+ return 0
178
+ if a.cmd == "logs":
179
+ dep.logs(a.tail, a.follow)
180
+ return 0
181
+ ok = dep.deploy() if a.cmd == "deploy" else dep.rollback()
182
+ return 0 if ok else 1
183
+ except (ValueError, OSError) as e:
184
+ print(f"error: {e}", file=sys.stderr)
185
+ return 2
186
+
187
+
188
+ if __name__ == "__main__":
189
+ sys.exit(main())
@@ -0,0 +1,90 @@
1
+ """Load shipit.toml."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ import threading
6
+ import tomllib
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+
10
+ DOMAIN_RE = re.compile(r"^[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$")
11
+ NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_.-]{0,62}$")
12
+
13
+
14
+ @dataclass
15
+ class App:
16
+ name: str
17
+ repo: str
18
+ branch: str = "main"
19
+ port: int = 8080 # port the app listens on inside the container
20
+ host_port: int = 8080 # port published on this machine
21
+ health_path: str = "/" # HTTP path polled after start; "" = just check container is running
22
+ health_timeout: int = 60
23
+ start: str | None = None # override start command (used by generated Dockerfiles)
24
+ dockerfile: str | None = None # path inside repo; default: ./Dockerfile, else auto-generated
25
+ env_file: str | None = None # path on the host
26
+ token_env: str | None = None # env var holding a git token for private https repos
27
+ secret_env: str | None = None # env var with the webhook secret for this app
28
+ pre_deploy: str | None = None # command run once in the new image before release (e.g. migrations); failure aborts
29
+ domain: str | None = None # set to serve via Caddy with zero-downtime blue/green (needs Caddy)
30
+ notify_env: str | None = None # env var holding a Slack/Discord/Mattermost-style webhook URL
31
+ drain_seconds: int = 5 # proxy mode: wait before removing the old slot
32
+ keep_images: int = 5 # old images kept on disk (current + previous are always kept)
33
+ build_args: dict[str, str] = field(default_factory=dict)
34
+
35
+
36
+ @dataclass
37
+ class Config:
38
+ state_dir: Path
39
+ listen_host: str
40
+ listen_port: int
41
+ caddy_admin: str
42
+ apps: dict[str, App]
43
+
44
+
45
+ def load(path: str | Path) -> Config:
46
+ data = tomllib.loads(Path(path).read_text(encoding="utf-8"))
47
+ server = data.get("server", {})
48
+ apps: dict[str, App] = {}
49
+ for raw in data.get("app", []):
50
+ app = App(**raw)
51
+ if not NAME_RE.match(app.name):
52
+ raise ValueError(f"invalid app name {app.name!r} (use a-z 0-9 _ . -)")
53
+ if app.domain and not DOMAIN_RE.match(app.domain):
54
+ raise ValueError(f"invalid domain {app.domain!r}")
55
+ if app.name in apps:
56
+ raise ValueError(f"duplicate app {app.name!r}")
57
+ apps[app.name] = app
58
+ if not apps:
59
+ raise ValueError("no [[app]] entries in config")
60
+ return Config(
61
+ # relative state_dir is relative to the config file, not the current directory
62
+ state_dir=(Path(path).resolve().parent / server.get("state_dir", ".shipit")).resolve(),
63
+ listen_host=server.get("host", "0.0.0.0"),
64
+ listen_port=int(server.get("port", 9000)),
65
+ caddy_admin=server.get("caddy_admin", "http://127.0.0.1:2019"),
66
+ apps=apps,
67
+ )
68
+
69
+
70
+ class LiveConfig:
71
+ """Re-reads the config file when it changes, so `shipit up` can add apps while `serve` runs."""
72
+
73
+ def __init__(self, path: str | Path):
74
+ self.path = Path(path)
75
+ self._mtime: int | None = None
76
+ self._cfg: Config | None = None
77
+ self._lock = threading.Lock()
78
+ self.get()
79
+
80
+ def get(self) -> Config:
81
+ with self._lock:
82
+ try:
83
+ mtime = self.path.stat().st_mtime_ns
84
+ if mtime != self._mtime:
85
+ self._cfg = load(self.path)
86
+ self._mtime = mtime
87
+ except (OSError, ValueError):
88
+ if self._cfg is None:
89
+ raise # first load must succeed; later bad edits keep the last good config
90
+ return self._cfg