Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 0 additions & 3 deletions .github/workflows/pullpreview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ jobs:
uses: "./"
with:
admins: "@collaborators/push"
always_on: master,v6
app_path: ./examples/workflow-smoke
instance_type: micro
max_domain_length: 30
Expand Down Expand Up @@ -100,7 +99,6 @@ jobs:
uses: "./"
with:
admins: "@collaborators/push"
always_on: master,v6
app_path: ./examples/workflow-smoke
instance_type: micro
max_domain_length: 30
Expand Down Expand Up @@ -154,7 +152,6 @@ jobs:
uses: "./"
with:
admins: "@collaborators/push"
always_on: master,v6
app_path: ./examples/workflow-smoke
provider: hetzner
region: ash
Expand Down
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ This repository ships a GitHub Action implemented in Go.
- `make dist`
- `mise exec -- go test ./...`
- `mise exec -- go run ./cmd/pullpreview up examples/example-app`
- Run `make test` before any push.
- Changelog updates are maintained in GitHub Releases; `CHANGELOG.md` does not need to be amended for routine release notes.
- 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:
Expand Down Expand Up @@ -55,7 +57,6 @@ Supported commands:

## GitHub sync behavior (`github-sync`)
- Handles PR labeled/opened/reopened/synchronize/unlabeled/closed events.
- Handles push events for `always_on` branches.
- Handles scheduled cleanup of dangling labeled preview instances.
- Updates marker-based PR status comments.
- For `admins: "@collaborators/push"`:
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
The changelog is published on the project releases page and is the canonical source for release notes: https://github.com/pullpreview/action/releases

## v6.0.0

### Breaking changes
Expand Down
17 changes: 5 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,21 +1,20 @@
# <img width="25" height="25" alt="pullpreview" src="https://github.com/user-attachments/assets/3aeb0f94-cac5-44b2-9f8e-abdb12be9cfe" /> PullPreview

A GitHub Action that starts live environments for your pull requests and branches.
A GitHub Action that starts live environments for your pull requests.

[![pullpreview](https://github.com/pullpreview/action/actions/workflows/pullpreview.yml/badge.svg)](https://github.com/pullpreview/action/actions/workflows/pullpreview.yml)
<a href="https://news.ycombinator.com/item?id=23221471"><img src="https://img.shields.io/badge/Hacker%20News-83-%23FF6600" alt="Hacker News"></a>

## Spin environments in one click

Once installed in your repository, this action is triggered any time a change
is made to Pull Requests labelled with the `pullpreview` label, or one of the
_always-on_ branches.
is made to Pull Requests labelled with the `pullpreview` label.

When triggered, it will:

1. Check out the repository code
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)
3. Continuously deploy the specified pull requests using your docker-compose file(s)
4. Report the preview instance URL in the GitHub UI

It is designed to be the **no-nonsense, cheap, and secure** alternative to
Expand Down Expand Up @@ -61,9 +60,7 @@ Preview environments that:
docker-compose, it can be deployed to preview environments with PullPreview.

- can be **started and destroyed easily**: You can manage preview environments
by adding or removing the `pullpreview` label on your Pull Requests. You can
set specific branches as always on, for instance to continuously deploy your
master branch.
by adding or removing the `pullpreview` label on your Pull Requests.

- are **cheap too run**: Preview environments are launched on AWS Lightsail
instances, which are both very cheap (10USD per month, proratized to the
Expand Down Expand Up @@ -110,7 +107,6 @@ All supported `with:` inputs from `action.yml`:
| Input | Default | Description |
| --- | --- | --- |
| `app_path` | `.` | Path to your application containing Docker Compose files (relative to `${{ github.workspace }}`). |
| `always_on` | `""` | Comma-separated branch names that should always be deployed. |
| `dns` | `my.preview.run` | DNS suffix used for generated preview hostnames. Built-in alternatives: `rev1.click` through `rev9.click` (see note below). |
| `max_domain_length` | `62` | Maximum generated FQDN length (cannot exceed 62 for Let's Encrypt). |
| `label` | `pullpreview` | Label that triggers preview deployments. |
Expand Down Expand Up @@ -150,7 +146,7 @@ ssh-keygen -t rsa -b 3072 -m PEM -N "" -f hetzner_ca_key

## Example

Workflow file with the `master` branch always on:
Workflow file for pullpreview-driven deployments:

```yaml
# .github/workflows/pullpreview.yml
Expand All @@ -176,8 +172,6 @@ jobs:
with:
# Those GitHub users will have SSH access to the servers
admins: crohr,other-github-user
# A preview environment will always exist for the main branch
always_on: main
# Use the cidrs option to restrict access to the live environments to specific IP ranges
cidrs: "0.0.0.0/0"
# PullPreview will use those 2 files when running docker-compose up
Expand Down Expand Up @@ -220,7 +214,6 @@ jobs:
- uses: pullpreview/action@v6
with:
admins: "@collaborators/push"
always_on: master
app_path: ./examples/workflow-smoke
provider: hetzner
# optional Hetzner runtime options
Expand Down
4 changes: 0 additions & 4 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,6 @@ inputs:
description: "The path to your application containing a docker-compose file"
default: "."
required: false
always_on:
description: "List of always-on branches, irrespective of whether they correspond to an open Pull Request, comma-separated"
default: ""
dns:
description: "Which DNS suffix to use"
default: "my.preview.run"
Expand Down Expand Up @@ -164,7 +161,6 @@ runs:
--label "${{ inputs.label }}" \
--ports "${{ inputs.ports }}" \
--default-port "${{ inputs.default_port }}" \
--always-on "${{ inputs.always_on }}" \
--instance-type "${{ inputs.instance_type }}" \
--region "${{ inputs.region }}" \
--image "${{ inputs.image }}" \
Expand Down
2 changes: 0 additions & 2 deletions cmd/pullpreview/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,6 @@ func runGithubSync(ctx context.Context, args []string, logger *pullpreview.Logge
verbose := fs.Bool("verbose", false, "Enable verbose mode")
label := fs.String("label", "pullpreview", "Label to use for triggering preview deployments")
deploymentVariant := fs.String("deployment-variant", "", "Deployment variant (4 chars max)")
alwaysOn := fs.String("always-on", "", "List of branches to always deploy")
ttl := fs.String("ttl", "infinite", "Maximum time to live for deployments (e.g. 10h, 5d, infinite)")
commonFlags := registerCommonFlags(fs)
leadingPath, parseArgs := splitLeadingPositional(args)
Expand All @@ -136,7 +135,6 @@ func runGithubSync(ctx context.Context, args []string, logger *pullpreview.Logge
opts := pullpreview.GithubSyncOptions{
AppPath: appPath,
Label: *label,
AlwaysOn: splitCommaList(*alwaysOn),
DeploymentVariant: *deploymentVariant,
TTL: *ttl,
Context: ctx,
Expand Down
Binary file modified dist/pullpreview-linux-amd64
Binary file not shown.
16 changes: 14 additions & 2 deletions internal/providers/hetzner/hetzner.go
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,8 @@ type Provider struct {
caSigner ssh.Signer
caPublicKey string
sshKeysCacheDir string
sshRetryCount int
sshRetryDelay time.Duration
logger *pullpreview.Logger
}

Expand Down Expand Up @@ -263,6 +265,8 @@ func newProviderWithContext(ctx context.Context, cfg Config, logger *pullpreview
caSigner: caSigner,
caPublicKey: caPublicKey,
sshKeysCacheDir: cfg.SSHKeysCacheDir,
sshRetryCount: defaultHetznerSSHRetries,
sshRetryDelay: defaultHetznerSSHInterval,
logger: logger,
}, nil
}
Expand Down Expand Up @@ -511,7 +515,15 @@ func (p *Provider) createServer(name string, opts pullpreview.LaunchOptions) (pu

func (p *Provider) validateSSHAccessWithRetry(server *hcloud.Server, privateKey, certKey string, attempts int) error {
if attempts <= 0 {
attempts = 1
if p.sshRetryCount > 0 {
attempts = p.sshRetryCount
} else {
attempts = 1
}
}
delay := p.sshRetryDelay
if delay <= 0 {
delay = defaultHetznerSSHInterval
}
var lastErr error
for i := 0; i < attempts; i++ {
Expand All @@ -524,7 +536,7 @@ func (p *Provider) validateSSHAccessWithRetry(server *hcloud.Server, privateKey,
if p.logger != nil {
p.logger.Warnf("SSH access validation failed for %q (attempt %d/%d): %v", strings.TrimSpace(server.Name), i+1, attempts, lastErr)
}
time.Sleep(defaultHetznerSSHInterval)
time.Sleep(delay)
}
}
return fmt.Errorf("ssh access validation failed for %q after %d attempts: %w", strings.TrimSpace(server.Name), attempts, lastErr)
Expand Down
5 changes: 5 additions & 0 deletions internal/providers/hetzner/hetzner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"

"github.com/hetznercloud/hcloud-go/v2/hcloud"
"github.com/pullpreview/action/internal/pullpreview"
Expand Down Expand Up @@ -502,6 +503,8 @@ func TestHetznerLaunchLifecycleRecreateWhenCacheMissing(t *testing.T) {
}
return []byte("ok"), nil
}
provider.sshRetryCount = 1
provider.sshRetryDelay = 1 * time.Millisecond

_, err := provider.Launch(instance, pullpreview.LaunchOptions{})
if err != nil {
Expand Down Expand Up @@ -579,6 +582,8 @@ func TestHetznerCreateLifecycleRecreateWhenSSHPrecheckFails(t *testing.T) {
runSSHCommand = func(context.Context, string, string, string, string) ([]byte, error) {
return nil, fmt.Errorf("ssh unavailable")
}
provider.sshRetryCount = 1
provider.sshRetryDelay = 1 * time.Millisecond

_, err := provider.Launch(instance, pullpreview.LaunchOptions{})
if err == nil {
Expand Down
30 changes: 3 additions & 27 deletions internal/pullpreview/github_sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,12 +196,6 @@ func clearDanglingDeployments(repo string, opts GithubSyncOptions, provider Prov
logger.Warnf("[clear_dangling_deployments] Unable to remove %s label for PR#%d: %v", opts.Label, number, err)
}
}
alwaysOn := map[string]struct{}{}
alwaysOnNormalized := map[string]struct{}{}
for _, branch := range uniqueStrings(opts.AlwaysOn) {
alwaysOn[branch] = struct{}{}
alwaysOnNormalized[NormalizeName(branch)] = struct{}{}
}
activeInstanceNames := []string{}
removedInstanceNames := []string{}
for _, inst := range instances {
Expand All @@ -223,12 +217,8 @@ func clearDanglingDeployments(repo string, opts GithubSyncOptions, provider Prov
detail = fmt.Sprintf("PR#%s not active/labeled", ref.PRNumber)
}
} else {
_, exact := alwaysOn[ref.Branch]
_, normalized := alwaysOnNormalized[ref.BranchNormalized]
if !exact && !normalized {
dangling = true
detail = fmt.Sprintf("branch %q not always_on", ref.Branch)
}
dangling = true
detail = fmt.Sprintf("branch %q not linked to an active PR", ref.Branch)
}
if !dangling {
activeInstanceNames = append(activeInstanceNames, inst.Name)
Expand Down Expand Up @@ -482,7 +472,7 @@ func (g *GithubSync) Sync() error {
}
_ = g.updateGitHubStatus(statusDestroyed, "")
g.writeStepSummary(statusDestroyed, action, "", nil)
case actionPRUp, actionPRPush, actionBranchPush:
case actionPRUp, actionPRPush:
_ = g.updateGitHubStatus(statusDeploying, "")
instance := g.buildInstance()
var upInstance *Instance
Expand Down Expand Up @@ -511,7 +501,6 @@ const (
actionBranchDown actionType = "branch_down"
actionPRUp actionType = "pr_up"
actionPRPush actionType = "pr_push"
actionBranchPush actionType = "branch_push"
)

type deploymentStatus string
Expand All @@ -526,10 +515,6 @@ const (

func (g *GithubSync) guessAction() actionType {
if g.prNumber() == 0 {
branch := strings.TrimPrefix(g.ref(), "refs/heads/")
if containsString(g.opts.AlwaysOn, branch) {
return actionBranchPush
}
return actionBranchDown
}

Expand Down Expand Up @@ -1151,12 +1136,3 @@ func instanceToCommon(inst *Instance) CommonOptions {
PreScript: inst.PreScript,
}
}

func containsString(list []string, value string) bool {
for _, v := range list {
if v == value {
return true
}
}
return false
}
21 changes: 10 additions & 11 deletions internal/pullpreview/github_sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,12 +200,12 @@ func TestGuessActionFromPushFixtureWithPR(t *testing.T) {
}
}

func TestGuessActionFromSoloPushAlwaysOn(t *testing.T) {
func TestGuessActionFromSoloPush(t *testing.T) {
event := loadFixtureEvent(t, "github_event_push_solo_organization.json")
client := &fakeGitHub{latestSHA: event.HeadCommit.ID}
sync := newSync(event, GithubSyncOptions{Label: "pullpreview", AlwaysOn: []string{"dev"}, Common: CommonOptions{}}, client, fakeProvider{running: true})
if got := sync.guessAction(); got != actionBranchPush {
t.Fatalf("guessAction()=%s, want %s", got, actionBranchPush)
sync := newSync(event, GithubSyncOptions{Label: "pullpreview", Common: CommonOptions{}}, client, fakeProvider{running: true})
if got := sync.guessAction(); got != actionBranchDown {
t.Fatalf("guessAction()=%s, want %s", got, actionBranchDown)
}
}

Expand Down Expand Up @@ -503,7 +503,7 @@ func TestRenderStepSummaryForDeployedState(t *testing.T) {
}
}

func TestRunGithubSyncFromEnvironmentRunsDownForBranchPushWithoutAlwaysOn(t *testing.T) {
func TestRunGithubSyncFromEnvironmentRunsDownForBranchPush(t *testing.T) {
t.Setenv("PULLPREVIEW_TEST", "1")
event := loadFixtureEvent(t, "github_event_push_solo_organization.json")
path := writeFixtureToTempEventFile(t, event)
Expand Down Expand Up @@ -541,7 +541,7 @@ func TestRunGithubSyncFromEnvironmentRunsDownForBranchPushWithoutAlwaysOn(t *tes
}
}

func TestClearDanglingDeploymentsDestroysInstancesNotLinkedToActivePROrAlwaysOnBranch(t *testing.T) {
func TestClearDanglingDeploymentsDestroysInstancesNotLinkedToActivePR(t *testing.T) {
client := &fakeGitHub{
issues: []*gh.Issue{
{
Expand Down Expand Up @@ -576,15 +576,14 @@ func TestClearDanglingDeploymentsDestroysInstancesNotLinkedToActivePROrAlwaysOnB
logger.base = log.New(&logs, "", 0)

err := clearDanglingDeployments("org/repo", GithubSyncOptions{
Label: "pullpreview-custom",
AlwaysOn: []string{"main"},
Label: "pullpreview-custom",
}, provider, client, logger)
if err != nil {
t.Fatalf("clearDanglingDeployments() error: %v", err)
}

sort.Strings(destroyed)
wantDestroyed := []string{"gh-1-branch-feature-x", "gh-1-pr-11"}
wantDestroyed := []string{"gh-1-branch-feature-x", "gh-1-branch-main", "gh-1-pr-11"}
if strings.Join(destroyed, ",") != strings.Join(wantDestroyed, ",") {
t.Fatalf("unexpected destroyed instances: got=%v want=%v", destroyed, wantDestroyed)
}
Expand All @@ -598,10 +597,10 @@ func TestClearDanglingDeploymentsDestroysInstancesNotLinkedToActivePROrAlwaysOnB
t.Fatalf("expected closed PR label cleanup for PR#11, got %v", client.removedLabelPRs)
}
logOutput := logs.String()
if !strings.Contains(logOutput, "Active instances: gh-1-branch-main, gh-1-pr-10") {
if !strings.Contains(logOutput, "Active instances: gh-1-pr-10") {
t.Fatalf("missing active instances report in logs: %s", logOutput)
}
if !strings.Contains(logOutput, "Dangling removed: gh-1-branch-feature-x, gh-1-pr-11") {
if !strings.Contains(logOutput, "Dangling removed: gh-1-branch-feature-x, gh-1-branch-main, gh-1-pr-11") {
t.Fatalf("missing dangling removed report in logs: %s", logOutput)
}
}
Expand Down
1 change: 0 additions & 1 deletion internal/pullpreview/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,6 @@ type ListOptions struct {
type GithubSyncOptions struct {
AppPath string
Label string
AlwaysOn []string
DeploymentVariant string
TTL string
Context context.Context
Expand Down
2 changes: 1 addition & 1 deletion wiki
Submodule wiki updated from c7f26a to cbebeb