Skip to content

Commit b0421fb

Browse files
authored
feat: multi-provider architecture and Hetzner support (#122)
* feat: add provider factory plus hetzner provider integration * test: add provider factory and hetzner coverage, and make test target * chore(dist): update bundled pullpreview binary * chore(dist): rebuild linux amd64 pullpreview binary * ci: temporarily disable non-hetzner smoke jobs * docs: update AGENTS with make test and make dist guidance * fix(hetzner): sanitize label values before create/list * fix(hetzner): enforce hetzner label constraints * docs: add make dist reminder and update wiki pointer * chore(dist): update bundled pullpreview binary * ci: pin hetzner smoke server type to cpx11 * ci: move hetzner smoke to ash region * ci: set hetzner smoke username to ubuntu * fix(hetzner): root-auth and user-data key persistence * chore(dist): update bundled pullpreview binary * feat: route region and image via shared cli flags * chore(dist): update bundled pullpreview binary * fix(hetzner): use ash region for smoke job with cpx21 * fix: preserve ssh key during setup access sync * chore(dist): update bundled pullpreview binary * fix(hetzner): validate ssh access on new server before returning * chore(dist): update bundled pullpreview binary * fix(hetzner): increase SSH access retry attempts to 10 * chore(dist): update bundled pullpreview binary * fix(hetzner): use fixed 15s SSH validation retry interval * chore(dist): update bundled pullpreview binary * fix(hetzner): pass registry credentials into compose context * chore(dist): update bundled pullpreview binary * Clarify Hetzner default location and add providers wiki * chore(dist): update bundled pullpreview binary * Fix Hetzner firewall cleanup and re-enable Lightsail smoke jobs * chore(dist): update bundled pullpreview binary * Switch back to default DNS and rev2.click for hetzner * feat(hetzner): require CA key and use certificate auth * chore(dist): update bundled pullpreview binary * ci: fix workflow indentation for hetzner smoke job * ci: restore pullpreview workflow job conditionals * hetzner: validate SSH CA key and log pre-check status * chore(dist): update bundled pullpreview binary * ci: run go test on every push
1 parent a5658d1 commit b0421fb

29 files changed

Lines changed: 3312 additions & 78 deletions

.github/workflows/pullpreview.yml

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,6 @@ jobs:
3333
always_on: master,v6
3434
app_path: ./examples/workflow-smoke
3535
instance_type: micro
36-
# only required if using custom domain for your preview environments
37-
dns: preview.chunk.io
3836
max_domain_length: 30
3937
# Enable HTTPS preview URL through Caddy + Let's Encrypt.
4038
proxy_tls: web:8080
@@ -105,7 +103,6 @@ jobs:
105103
always_on: master,v6
106104
app_path: ./examples/workflow-smoke
107105
instance_type: micro
108-
dns: preview.chunk.io
109106
max_domain_length: 30
110107
proxy_tls: web:8080
111108
registries: docker://${{ secrets.GHCR_PAT }}@ghcr.io
@@ -144,3 +141,31 @@ jobs:
144141
echo "::error::Unexpected response from ${PREVIEW_URL}"
145142
printf '%s\n' "${response}"
146143
exit 1
144+
145+
deploy_smoke_hetzner:
146+
runs-on: ubuntu-slim
147+
if: github.event_name == 'schedule' || github.event_name == 'push' || github.event.label.name == 'pullpreview' || contains(github.event.pull_request.labels.*.name, 'pullpreview')
148+
timeout-minutes: 35
149+
steps:
150+
- uses: actions/checkout@v6
151+
152+
- name: Deploy smoke app on Hetzner
153+
id: pullpreview
154+
uses: "./"
155+
with:
156+
admins: "@collaborators/push"
157+
always_on: master,v6
158+
app_path: ./examples/workflow-smoke
159+
provider: hetzner
160+
region: ash
161+
image: ubuntu-24.04
162+
dns: rev2.click
163+
instance_type: cpx21
164+
max_domain_length: 30
165+
# required here because the mysql image is private in GHCR
166+
registries: docker://${{ secrets.GHCR_PAT }}@ghcr.io
167+
proxy_tls: web:8080
168+
ttl: 1h
169+
env:
170+
HCLOUD_TOKEN: "${{ secrets.HCLOUD_TOKEN }}"
171+
HETZNER_CA_KEY: "${{ secrets.HETZNER_CA_KEY }}"

.github/workflows/release-cli.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: release-cli
2+
3+
on:
4+
push:
5+
tags:
6+
- "v*"
7+
8+
permissions:
9+
contents: write
10+
11+
jobs:
12+
release:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- name: Checkout
16+
uses: actions/checkout@v5
17+
with:
18+
fetch-depth: 0
19+
20+
- name: Set up Go
21+
uses: actions/setup-go@v5
22+
with:
23+
go-version-file: go.mod
24+
25+
- name: Run GoReleaser
26+
uses: goreleaser/goreleaser-action@v6
27+
with:
28+
distribution: goreleaser
29+
version: ~> v2
30+
args: release --clean
31+
env:
32+
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
33+

.github/workflows/test.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
name: Go tests
2+
3+
on:
4+
push:
5+
6+
jobs:
7+
test:
8+
runs-on: ubuntu-latest
9+
timeout-minutes: 15
10+
steps:
11+
- name: Checkout
12+
uses: actions/checkout@v6
13+
14+
- name: Set up Go
15+
uses: actions/setup-go@v5
16+
with:
17+
go-version-file: go.mod
18+
cache: true
19+
20+
- name: Run tests
21+
run: go test ./...

.goreleaser.yaml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
version: 2
2+
3+
project_name: pullpreview
4+
5+
builds:
6+
- id: pullpreview
7+
main: ./cmd/pullpreview
8+
binary: pullpreview
9+
env:
10+
- CGO_ENABLED=0
11+
goos:
12+
- linux
13+
- darwin
14+
- windows
15+
goarch:
16+
- amd64
17+
- arm64
18+
ldflags:
19+
- -s
20+
- -w
21+
22+
archives:
23+
- id: pullpreview
24+
formats:
25+
- tar.gz
26+
format_overrides:
27+
- goos: windows
28+
formats:
29+
- zip
30+
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
31+
32+
checksum:
33+
name_template: "{{ .ProjectName }}_{{ .Version }}_checksums.txt"
34+
35+
release:
36+
draft: false
37+
prerelease: auto
38+
mode: replace

AGENTS.md

Lines changed: 55 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# PullPreview Action — Current Behavior (Go)
1+
# PullPreview Action — AGENTS
22

33
This repository ships a GitHub Action implemented in Go.
44

@@ -11,9 +11,12 @@ This repository ships a GitHub Action implemented in Go.
1111
## Go Tooling
1212
- Go commands should be run via `mise` for toolchain consistency.
1313
- Examples:
14+
- `make test`
15+
- `make dist`
1416
- `mise exec -- go test ./...`
1517
- `mise exec -- go run ./cmd/pullpreview up examples/example-app`
16-
- `make dist`
18+
- Always run `make dist` before pushing source changes so the bundled CLI binary stays in sync.
19+
- `make dist` builds the prebuilt Linux binary under `dist/` and auto-commits only that directory via the repo’s `dist-commit` target.
1720
- Dist workflow:
1821
- Commit source changes first.
1922
- Run `make dist` afterwards.
@@ -29,22 +32,26 @@ Supported commands:
2932
- `pullpreview list org/repo`
3033
- `pullpreview github-sync path/to/app`
3134

35+
## Providers
36+
- Default provider: `lightsail`.
37+
- Supported providers: `lightsail`, `hetzner`.
38+
- Provider discovery is via `internal/providers` registrations.
39+
- New Hetzner provider is implemented in `internal/providers/hetzner`.
40+
- `providers` package uses typed environment config parsing and factory registration.
41+
3242
## Deploy behavior (`up`)
33-
- Launches/restores Lightsail instance and waits for SSH.
34-
- Uploads authorized keys.
35-
- Renders compose config, rewrites relative bind mounts under `app_path` to `/app/...`, and syncs only those bind-mounted local paths to the server via `rsync`.
36-
- Deploys through Docker context to the remote engine.
37-
- Executes `pre_script` inline over SSH before `docker compose up` (script must be self-contained).
38-
- Optional automatic HTTPS proxying via Caddy + Let's Encrypt when `proxy_tls` is set.
39-
- Format: `service:port` (for example `web:80`).
40-
- Forces preview URL/output to HTTPS on port `443`.
41-
- Opens firewall port `443` and suppresses firewall exposure for port `80`.
42-
- Injects `pullpreview-proxy` service unless host port `443` is already published (then it logs a warning and skips proxy injection).
43-
- Emits periodic heartbeat logs with:
43+
- Launches/restores an instance via provider abstraction.
44+
- Waits for SSH and runs provider-generated user-data.
45+
- Uploads authorized SSH keys.
46+
- Renders compose config, rewrites relative bind mounts under `app_path` to `/app/...`, and syncs only detected bind-mounted local paths via `rsync`.
47+
- Deploys through Docker context on remote engine.
48+
- Executes `pre_script` inline over SSH before `docker compose up`.
49+
- Optional HTTPS via `proxy_tls` injects a Caddy sidecar and adjusts logging/port exposure.
50+
- Emits heartbeat logs with:
4451
- preview URL
4552
- SSH command (`ssh user@ip`)
4653
- authorized users info
47-
- key-upload confirmation
54+
- key upload status
4855

4956
## GitHub sync behavior (`github-sync`)
5057
- Handles PR labeled/opened/reopened/synchronize/unlabeled/closed events.
@@ -54,27 +61,57 @@ Supported commands:
5461
- For `admins: "@collaborators/push"`:
5562
- loads collaborators from GitHub REST API with `affiliation=all` + `permission=push`
5663
- uses only the first page (up to 100 users)
57-
- emits a warning if additional pages exist
64+
- emits warning if additional pages exist
5865
- fetches each admin's SSH public keys via GitHub API and forwards keys to the instance
5966
- uses local key cache directory (`PULLPREVIEW_SSH_KEYS_CACHE_DIR`) to avoid refetching keys across runs
6067
- Always posts/updates marker-based PR status comments per environment/job with building/ready/error/destroyed state and preview URL.
6168

6269
## Action inputs/outputs
6370
- Existing inputs are preserved.
64-
- Additional input:
65-
- `proxy_tls` (`service:port`, default empty)
71+
- Provider-related inputs are:
72+
- `provider`
73+
- `region`
74+
- `image`
75+
- `instance_type`
76+
- `proxy_tls`
77+
- Hetzner uses shared inputs (`region`/`image` and `instance_type`) and `HCLOUD_TOKEN` credentials.
6678
- Outputs:
6779
- `url`
6880
- `host`
6981
- `username`
7082

83+
## Hetzner implementation notes
84+
- File paths:
85+
- `internal/providers/hetzner/hetzner.go`
86+
- provider registration: `internal/providers/hetzner`
87+
- shared user-data fallback remains in `internal/pullpreview/user_data.go`
88+
- Hetzner custom user-data in `Provider.BuildUserData`
89+
- Defaults:
90+
- location: `nbg1`
91+
- image: `ubuntu-24.04`
92+
- server type: `cpx21`
93+
- username: `root`
94+
- SSH keys are cached for re-entry via `PULLPREVIEW_SSH_KEYS_CACHE_DIR`.
95+
- `down` currently accepts both normalized instance names and compose context names (`pullpreview-*`) through normalization in `RunDown`.
96+
- Lifecycle cleanup follows best-effort ordering in provider:
97+
- recreate missing cache/server state when stale
98+
- validate SSH, recreate instance if cache/validation fails
99+
- destroy server before cleanup paths on failure
100+
71101
## Key directories
72102
- `cmd/pullpreview`: CLI
73103
- `internal/pullpreview`: core orchestration
74-
- `internal/providers/lightsail`: Lightsail provider
104+
- `internal/providers`: provider registry and concrete providers
75105
- `internal/github`: GitHub API wrapper
76106
- `internal/license`: license check client
77107
- `dist/`: bundled Linux amd64 binary used by the action
78108

79109
## Repo-local skill
80110
- `skills/pullpreview-demo-flow/SKILL.md`: repeatable end-to-end demo capture workflow (PR open/label/deploy/view deployment/unlabel/destroy) with strict screenshot requirements and fixed demo PR title.
111+
112+
## Review status (current branch)
113+
- Live provider validation has been run against Hetzner using `.env` with `HCLOUD_TOKEN` plus CLI/action values (`--region nbg1`, `instance_type cpx21`, `--image ubuntu-24.04`).
114+
- `up`, `down`, and `list` flows have been exercised.
115+
- Follow-up cleanup items:
116+
- tighten `RunDown` context-name parser to avoid stripping legitimate names that resemble context suffix format
117+
- make create-failure cleanup continue best-effort cache/key cleanup if server delete fails

Makefile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
GO ?= mise exec -- go
2+
GO_TEST ?= $(GO) test ./internal/providers ./internal/pullpreview ./internal/providers/hetzner
23
DIST_DIR := dist
34
BIN_NAME := pullpreview
45
GO_LDFLAGS ?= -s -w
@@ -74,4 +75,4 @@ rewrite:
7475
echo "Rewrite complete. Force-push with: git push --force-with-lease origin $$current_branch"
7576

7677
test:
77-
$(GO) test ./...
78+
$(GO_TEST)

README.md

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ _always-on_ branches.
1414
When triggered, it will:
1515

1616
1. Check out the repository code
17-
2. Provision a cheap AWS Lightsail instance, with docker and docker-compose set up
17+
2. Provision a preview instance (Lightsail by default, or Hetzner with `provider: hetzner`), with docker and docker-compose set up
1818
3. Continuously deploy the specified pull requests and branches, using your docker-compose file(s)
1919
4. Report the preview instance URL in the GitHub UI
2020

@@ -30,7 +30,7 @@ Adding the label triggers the deployment. A PR comment appears immediately with
3030

3131
### Step 2 — Instance is provisioned
3232

33-
PullPreview creates (or restores) a Lightsail instance and waits for SSH access.
33+
PullPreview creates (or restores) a preview instance and waits for SSH access.
3434

3535
<img src="img/02-deploying.png">
3636

@@ -122,9 +122,11 @@ All supported `with:` inputs from `action.yml`:
122122
| `compose_files` | `docker-compose.yml` | Comma-separated Compose files passed to deploy. |
123123
| `compose_options` | `--build` | Additional options appended to `docker compose up`. |
124124
| `license` | `""` | PullPreview license key. |
125-
| `instance_type` | `small` | Lightsail instance bundle (`nano`, `micro`, `small`, etc.). |
125+
| `instance_type` | `small` | Provider-specific instance size (`small` for Lightsail, `cpx21` for Hetzner). |
126+
| `region` | `` | Optional provider region/datacenter override (`AWS_REGION`/Hetzner location). If empty, provider defaults apply. |
127+
| `image` | `ubuntu-24.04` | Instance image for Hetzner (provider-specific) and ignored for AWS. |
126128
| `deployment_variant` | `""` | Optional short suffix to run multiple preview environments per PR (max 4 chars). |
127-
| `provider` | `lightsail` | Cloud provider (currently Lightsail). |
129+
| `provider` | `lightsail` | Cloud provider (`lightsail`, `hetzner`). |
128130
| `registries` | `""` | Private registry credentials, e.g. `docker://user:password@ghcr.io`. |
129131
| `proxy_tls` | `""` | Automatic HTTPS forwarding with Caddy + Let's Encrypt (`service:port`, e.g. `web:80`). |
130132
| `pre_script` | `""` | Path to a local shell script (relative to `app_path`) executed inline over SSH before compose deploy (should be self-contained). |
@@ -135,7 +137,16 @@ Notes:
135137
- `proxy_tls` forces URL/output/comment links to HTTPS on port `443`, injects a Caddy proxy service, and suppresses firewall exposure for port `80`. **When using `proxy_tls`, it is strongly recommended to set `dns` to a [custom domain](https://github.com/pullpreview/action/wiki/Using-a-custom-domain) or one of the built-in `revN.click` alternatives** to avoid hitting shared Let's Encrypt rate limits on `my.preview.run`.
136138
- `admins: "@collaborators/push"` uses GitHub API collaborators with push permission (first page, up to 100 users; warning is logged if more exist).
137139
- SSH key fetches are cached between runs in the action cache.
140+
- For Hetzner, configure credentials and defaults via action inputs and environment: `HCLOUD_TOKEN` (required), `HETZNER_CA_KEY` (required), optional `region` and `image` (`region` defaults to `nbg1`, `image` defaults to `ubuntu-24.04`). `instance_type` defaults to `cpx21` when provider is Hetzner.
141+
- `HETZNER_CA_KEY` must be an SSH private key (RSA or Ed25519) for the instance-access CA. PullPreview signs a per-run ephemeral login key with this CA key and uses SSH certificates (`...-cert.pub`) instead of reusing a persistent private key across runs.
142+
- Generate a CA key once for your repository secret:
143+
144+
```bash
145+
ssh-keygen -t rsa -b 3072 -m PEM -N "" -f hetzner_ca_key
146+
```
147+
138148
- **Let's Encrypt rate limits**: Let's Encrypt allows a maximum of [50 certificates per registered domain per week](https://letsencrypt.org/docs/rate-limits/#new-certificates-per-registered-domain). If you use `proxy_tls` and hit this limit on the default `my.preview.run` domain, switch to one of the built-in alternatives: `rev1.click`, `rev2.click`, ... `rev9.click`. Set `dns: rev1.click` in your workflow inputs. You can also use a [custom domain](https://github.com/pullpreview/action/wiki/Using-a-custom-domain).
149+
- For local CLI runs, set `HCLOUD_TOKEN` and `HETZNER_CA_KEY` (for example via `.env`) when using `provider: hetzner` to avoid relying on action inputs.
139150

140151
## Example
141152

@@ -185,6 +196,61 @@ jobs:
185196
AWS_REGION: "us-east-1"
186197
```
187198
199+
## Hetzner example
200+
201+
```yaml
202+
# .github/workflows/pullpreview-hetzner.yml
203+
name: PullPreview
204+
on:
205+
schedule:
206+
- cron: "30 */4 * * *"
207+
push:
208+
branches:
209+
- master
210+
pull_request:
211+
types: [labeled, unlabeled, synchronize, closed, reopened, opened]
212+
213+
jobs:
214+
deploy_hetzner:
215+
runs-on: ubuntu-slim
216+
if: github.event_name == 'schedule' || github.event_name == 'push' || github.event.label.name == 'pullpreview' || contains(github.event.pull_request.labels.*.name, 'pullpreview')
217+
timeout-minutes: 30
218+
steps:
219+
- uses: actions/checkout@v5
220+
- uses: pullpreview/action@v6
221+
with:
222+
admins: "@collaborators/push"
223+
always_on: master
224+
app_path: ./examples/workflow-smoke
225+
provider: hetzner
226+
# optional Hetzner runtime options
227+
instance_type: cpx21
228+
image: ubuntu-24.04
229+
region: nbg1
230+
dns: preview.chunk.io
231+
max_domain_length: 30
232+
# Open HTTPS preview URL through Caddy + Let's Encrypt.
233+
proxy_tls: web:8080
234+
ttl: 1h
235+
env:
236+
HCLOUD_TOKEN: "${{ secrets.HCLOUD_TOKEN }}"
237+
HETZNER_CA_KEY: "${{ secrets.HETZNER_CA_KEY }}"
238+
239+
```
240+
241+
## CLI usage (installed binary)
242+
243+
Pull the released CLI binary from GitHub Releases, install it in your PATH, then use:
244+
245+
```bash
246+
pullpreview up examples/workflow-smoke --name pullpreview-local-smoke
247+
pullpreview list
248+
pullpreview list my-org/my-repo
249+
pullpreview down --name pullpreview-local-smoke
250+
```
251+
252+
For installation details and local validation instructions (including Hetzner env setup), see [wiki/CLI.md](wiki/CLI.md).
253+
188254
## Is this free?
189255

190256
The code for this Action is completely open source, and licensed under the

0 commit comments

Comments
 (0)