Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
a99b9be
feat: add provider factory plus hetzner provider integration
crohr Feb 13, 2026
c906485
test: add provider factory and hetzner coverage, and make test target
crohr Feb 13, 2026
bee2b70
chore(dist): update bundled pullpreview binary
crohr Feb 13, 2026
10e47c8
chore(dist): rebuild linux amd64 pullpreview binary
crohr Feb 13, 2026
fb1dfde
ci: temporarily disable non-hetzner smoke jobs
crohr Feb 13, 2026
fdb6ba9
docs: update AGENTS with make test and make dist guidance
crohr Feb 13, 2026
7a7aa82
fix(hetzner): sanitize label values before create/list
crohr Feb 13, 2026
f3ef2ce
fix(hetzner): enforce hetzner label constraints
crohr Feb 13, 2026
8cd01de
docs: add make dist reminder and update wiki pointer
crohr Feb 13, 2026
77970c8
chore(dist): update bundled pullpreview binary
crohr Feb 13, 2026
548323a
ci: pin hetzner smoke server type to cpx11
crohr Feb 13, 2026
c45417b
ci: move hetzner smoke to ash region
crohr Feb 13, 2026
8f9e98f
ci: set hetzner smoke username to ubuntu
crohr Feb 13, 2026
d838d7d
fix(hetzner): root-auth and user-data key persistence
crohr Feb 13, 2026
eb8b387
chore(dist): update bundled pullpreview binary
crohr Feb 13, 2026
6644763
feat: route region and image via shared cli flags
crohr Feb 13, 2026
31437d9
chore(dist): update bundled pullpreview binary
crohr Feb 13, 2026
08cbdc2
fix(hetzner): use ash region for smoke job with cpx21
crohr Feb 13, 2026
6a8b22d
fix: preserve ssh key during setup access sync
crohr Feb 13, 2026
d0ce59b
chore(dist): update bundled pullpreview binary
crohr Feb 13, 2026
1b9c94c
fix(hetzner): validate ssh access on new server before returning
crohr Feb 13, 2026
e6f2625
chore(dist): update bundled pullpreview binary
crohr Feb 13, 2026
470d151
fix(hetzner): increase SSH access retry attempts to 10
crohr Feb 13, 2026
78645e7
chore(dist): update bundled pullpreview binary
crohr Feb 13, 2026
d83ef14
fix(hetzner): use fixed 15s SSH validation retry interval
crohr Feb 13, 2026
bcde801
chore(dist): update bundled pullpreview binary
crohr Feb 13, 2026
b5eb859
fix(hetzner): pass registry credentials into compose context
crohr Feb 13, 2026
09cd0a6
chore(dist): update bundled pullpreview binary
crohr Feb 13, 2026
3e7e33a
Clarify Hetzner default location and add providers wiki
crohr Feb 13, 2026
268afee
chore(dist): update bundled pullpreview binary
crohr Feb 13, 2026
9e1f897
Fix Hetzner firewall cleanup and re-enable Lightsail smoke jobs
crohr Feb 13, 2026
05d82ba
chore(dist): update bundled pullpreview binary
crohr Feb 13, 2026
e9dcf9a
Switch back to default DNS and rev2.click for hetzner
crohr Feb 13, 2026
735a249
feat(hetzner): require CA key and use certificate auth
crohr Feb 13, 2026
83858cf
chore(dist): update bundled pullpreview binary
crohr Feb 13, 2026
48f4a6f
ci: fix workflow indentation for hetzner smoke job
crohr Feb 13, 2026
a48bc9d
ci: restore pullpreview workflow job conditionals
crohr Feb 13, 2026
a513619
hetzner: validate SSH CA key and log pre-check status
crohr Feb 13, 2026
77070f4
chore(dist): update bundled pullpreview binary
crohr Feb 13, 2026
52adc90
ci: run go test on every push
crohr Feb 13, 2026
83f1b06
fix
crohr Feb 13, 2026
d07062b
fix
crohr Feb 13, 2026
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
31 changes: 28 additions & 3 deletions .github/workflows/pullpreview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,6 @@ jobs:
always_on: master,v6
app_path: ./examples/workflow-smoke
instance_type: micro
# only required if using custom domain for your preview environments
dns: preview.chunk.io
max_domain_length: 30
# Enable HTTPS preview URL through Caddy + Let's Encrypt.
proxy_tls: web:8080
Expand Down Expand Up @@ -105,7 +103,6 @@ jobs:
always_on: master,v6
app_path: ./examples/workflow-smoke
instance_type: micro
dns: preview.chunk.io
max_domain_length: 30
proxy_tls: web:8080
registries: docker://${{ secrets.GHCR_PAT }}@ghcr.io
Expand Down Expand Up @@ -144,3 +141,31 @@ jobs:
echo "::error::Unexpected response from ${PREVIEW_URL}"
printf '%s\n' "${response}"
exit 1

deploy_smoke_hetzner:
runs-on: ubuntu-slim
if: github.event_name == 'schedule' || github.event_name == 'push' || github.event.label.name == 'pullpreview' || contains(github.event.pull_request.labels.*.name, 'pullpreview')
timeout-minutes: 35
steps:
- uses: actions/checkout@v6

- name: Deploy smoke app on Hetzner
id: pullpreview
uses: "./"
with:
admins: "@collaborators/push"
always_on: master,v6
app_path: ./examples/workflow-smoke
provider: hetzner
region: ash
image: ubuntu-24.04
dns: rev2.click
instance_type: cpx21
max_domain_length: 30
# required here because the mysql image is private in GHCR
registries: docker://${{ secrets.GHCR_PAT }}@ghcr.io
proxy_tls: web:8080
ttl: 1h
env:
HCLOUD_TOKEN: "${{ secrets.HCLOUD_TOKEN }}"
HETZNER_CA_KEY: "${{ secrets.HETZNER_CA_KEY }}"
33 changes: 33 additions & 0 deletions .github/workflows/release-cli.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: release-cli

on:
push:
tags:
- "v*"

permissions:
contents: write

jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 0

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod

- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: ~> v2
args: release --clean
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"

21 changes: 21 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: Go tests

on:
push:

jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v6

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true

- name: Run tests
run: go test ./...
38 changes: 38 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
version: 2

project_name: pullpreview

builds:
- id: pullpreview
main: ./cmd/pullpreview
binary: pullpreview
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
ldflags:
- -s
- -w

archives:
- id: pullpreview
formats:
- tar.gz
format_overrides:
- goos: windows
formats:
- zip
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"

checksum:
name_template: "{{ .ProjectName }}_{{ .Version }}_checksums.txt"

release:
draft: false
prerelease: auto
mode: replace
73 changes: 55 additions & 18 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# PullPreview Action — Current Behavior (Go)
# PullPreview Action — AGENTS

This repository ships a GitHub Action implemented in Go.

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

## Providers
- Default provider: `lightsail`.
- Supported providers: `lightsail`, `hetzner`.
- Provider discovery is via `internal/providers` registrations.
- New Hetzner provider is implemented in `internal/providers/hetzner`.
- `providers` package uses typed environment config parsing and factory registration.

## Deploy behavior (`up`)
- Launches/restores Lightsail instance and waits for SSH.
- Uploads authorized keys.
- 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`.
- Deploys through Docker context to the remote engine.
- Executes `pre_script` inline over SSH before `docker compose up` (script must be self-contained).
- Optional automatic HTTPS proxying via Caddy + Let's Encrypt when `proxy_tls` is set.
- Format: `service:port` (for example `web:80`).
- Forces preview URL/output to HTTPS on port `443`.
- Opens firewall port `443` and suppresses firewall exposure for port `80`.
- Injects `pullpreview-proxy` service unless host port `443` is already published (then it logs a warning and skips proxy injection).
- Emits periodic heartbeat logs with:
- Launches/restores an instance via provider abstraction.
- Waits for SSH and runs provider-generated user-data.
- Uploads authorized SSH keys.
- Renders compose config, rewrites relative bind mounts under `app_path` to `/app/...`, and syncs only detected bind-mounted local paths via `rsync`.
- Deploys through Docker context on remote engine.
- Executes `pre_script` inline over SSH before `docker compose up`.
- Optional HTTPS via `proxy_tls` injects a Caddy sidecar and adjusts logging/port exposure.
- Emits heartbeat logs with:
- preview URL
- SSH command (`ssh user@ip`)
- authorized users info
- key-upload confirmation
- key upload status

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

## Action inputs/outputs
- Existing inputs are preserved.
- Additional input:
- `proxy_tls` (`service:port`, default empty)
- Provider-related inputs are:
- `provider`
- `region`
- `image`
- `instance_type`
- `proxy_tls`
- Hetzner uses shared inputs (`region`/`image` and `instance_type`) and `HCLOUD_TOKEN` credentials.
- Outputs:
- `url`
- `host`
- `username`

## Hetzner implementation notes
- File paths:
- `internal/providers/hetzner/hetzner.go`
- provider registration: `internal/providers/hetzner`
- shared user-data fallback remains in `internal/pullpreview/user_data.go`
- Hetzner custom user-data in `Provider.BuildUserData`
- Defaults:
- location: `nbg1`
- image: `ubuntu-24.04`
- server type: `cpx21`
- username: `root`
- SSH keys are cached for re-entry via `PULLPREVIEW_SSH_KEYS_CACHE_DIR`.
- `down` currently accepts both normalized instance names and compose context names (`pullpreview-*`) through normalization in `RunDown`.
- Lifecycle cleanup follows best-effort ordering in provider:
- recreate missing cache/server state when stale
- validate SSH, recreate instance if cache/validation fails
- destroy server before cleanup paths on failure

## Key directories
- `cmd/pullpreview`: CLI
- `internal/pullpreview`: core orchestration
- `internal/providers/lightsail`: Lightsail provider
- `internal/providers`: provider registry and concrete providers
- `internal/github`: GitHub API wrapper
- `internal/license`: license check client
- `dist/`: bundled Linux amd64 binary used by the action

## Repo-local skill
- `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.

## Review status (current branch)
- 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`).
- `up`, `down`, and `list` flows have been exercised.
- Follow-up cleanup items:
- tighten `RunDown` context-name parser to avoid stripping legitimate names that resemble context suffix format
- make create-failure cleanup continue best-effort cache/key cleanup if server delete fails
3 changes: 2 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
GO ?= mise exec -- go
GO_TEST ?= $(GO) test ./internal/providers ./internal/pullpreview ./internal/providers/hetzner
DIST_DIR := dist
BIN_NAME := pullpreview
GO_LDFLAGS ?= -s -w
Expand Down Expand Up @@ -74,4 +75,4 @@ rewrite:
echo "Rewrite complete. Force-push with: git push --force-with-lease origin $$current_branch"

test:
$(GO) test ./...
$(GO_TEST)
74 changes: 70 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ _always-on_ branches.
When triggered, it will:

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

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

### Step 2 — Instance is provisioned

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

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

Expand Down Expand Up @@ -122,9 +122,11 @@ All supported `with:` inputs from `action.yml`:
| `compose_files` | `docker-compose.yml` | Comma-separated Compose files passed to deploy. |
| `compose_options` | `--build` | Additional options appended to `docker compose up`. |
| `license` | `""` | PullPreview license key. |
| `instance_type` | `small` | Lightsail instance bundle (`nano`, `micro`, `small`, etc.). |
| `instance_type` | `small` | Provider-specific instance size (`small` for Lightsail, `cpx21` for Hetzner). |
| `region` | `` | Optional provider region/datacenter override (`AWS_REGION`/Hetzner location). If empty, provider defaults apply. |
| `image` | `ubuntu-24.04` | Instance image for Hetzner (provider-specific) and ignored for AWS. |
| `deployment_variant` | `""` | Optional short suffix to run multiple preview environments per PR (max 4 chars). |
| `provider` | `lightsail` | Cloud provider (currently Lightsail). |
| `provider` | `lightsail` | Cloud provider (`lightsail`, `hetzner`). |
| `registries` | `""` | Private registry credentials, e.g. `docker://user:password@ghcr.io`. |
| `proxy_tls` | `""` | Automatic HTTPS forwarding with Caddy + Let's Encrypt (`service:port`, e.g. `web:80`). |
| `pre_script` | `""` | Path to a local shell script (relative to `app_path`) executed inline over SSH before compose deploy (should be self-contained). |
Expand All @@ -135,7 +137,16 @@ Notes:
- `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`.
- `admins: "@collaborators/push"` uses GitHub API collaborators with push permission (first page, up to 100 users; warning is logged if more exist).
- SSH key fetches are cached between runs in the action cache.
- 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.
- `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.
- Generate a CA key once for your repository secret:

```bash
ssh-keygen -t rsa -b 3072 -m PEM -N "" -f hetzner_ca_key
```

- **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).
- 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.

## Example

Expand Down Expand Up @@ -185,6 +196,61 @@ jobs:
AWS_REGION: "us-east-1"
```

## Hetzner example

```yaml
# .github/workflows/pullpreview-hetzner.yml
name: PullPreview
on:
schedule:
- cron: "30 */4 * * *"
push:
branches:
- master
pull_request:
types: [labeled, unlabeled, synchronize, closed, reopened, opened]

jobs:
deploy_hetzner:
runs-on: ubuntu-slim
if: github.event_name == 'schedule' || github.event_name == 'push' || github.event.label.name == 'pullpreview' || contains(github.event.pull_request.labels.*.name, 'pullpreview')
timeout-minutes: 30
steps:
- uses: actions/checkout@v5
- uses: pullpreview/action@v6
with:
admins: "@collaborators/push"
always_on: master
app_path: ./examples/workflow-smoke
provider: hetzner
# optional Hetzner runtime options
instance_type: cpx21
image: ubuntu-24.04
region: nbg1
dns: preview.chunk.io
max_domain_length: 30
# Open HTTPS preview URL through Caddy + Let's Encrypt.
proxy_tls: web:8080
ttl: 1h
env:
HCLOUD_TOKEN: "${{ secrets.HCLOUD_TOKEN }}"
HETZNER_CA_KEY: "${{ secrets.HETZNER_CA_KEY }}"

```

## CLI usage (installed binary)

Pull the released CLI binary from GitHub Releases, install it in your PATH, then use:

```bash
pullpreview up examples/workflow-smoke --name pullpreview-local-smoke
pullpreview list
pullpreview list my-org/my-repo
pullpreview down --name pullpreview-local-smoke
```

For installation details and local validation instructions (including Hetzner env setup), see [wiki/CLI.md](wiki/CLI.md).

## Is this free?

The code for this Action is completely open source, and licensed under the
Expand Down
Loading