diff --git a/.github/workflows/pullpreview.yml b/.github/workflows/pullpreview.yml
index b273021..62f1dc6 100644
--- a/.github/workflows/pullpreview.yml
+++ b/.github/workflows/pullpreview.yml
@@ -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
@@ -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
@@ -154,7 +152,6 @@ jobs:
uses: "./"
with:
admins: "@collaborators/push"
- always_on: master,v6
app_path: ./examples/workflow-smoke
provider: hetzner
region: ash
diff --git a/AGENTS.md b/AGENTS.md
index 3e5d3eb..cbb87b5 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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:
@@ -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"`:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5608d60..249caa4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/README.md b/README.md
index 2e37fbe..ae6d7c2 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
#
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.
[](https://github.com/pullpreview/action/actions/workflows/pullpreview.yml)
@@ -8,14 +8,13 @@ A GitHub Action that starts live environments for your pull requests and branche
## 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
@@ -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
@@ -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. |
@@ -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
@@ -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
@@ -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
diff --git a/action.yml b/action.yml
index 39ac66a..2d24ce7 100644
--- a/action.yml
+++ b/action.yml
@@ -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"
@@ -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 }}" \
diff --git a/cmd/pullpreview/main.go b/cmd/pullpreview/main.go
index 9b8970c..34241c3 100644
--- a/cmd/pullpreview/main.go
+++ b/cmd/pullpreview/main.go
@@ -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)
@@ -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,
diff --git a/dist/pullpreview-linux-amd64 b/dist/pullpreview-linux-amd64
index 150f394..bf902e6 100755
Binary files a/dist/pullpreview-linux-amd64 and b/dist/pullpreview-linux-amd64 differ
diff --git a/internal/providers/hetzner/hetzner.go b/internal/providers/hetzner/hetzner.go
index e5a9980..a51c361 100644
--- a/internal/providers/hetzner/hetzner.go
+++ b/internal/providers/hetzner/hetzner.go
@@ -229,6 +229,8 @@ type Provider struct {
caSigner ssh.Signer
caPublicKey string
sshKeysCacheDir string
+ sshRetryCount int
+ sshRetryDelay time.Duration
logger *pullpreview.Logger
}
@@ -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
}
@@ -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++ {
@@ -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)
diff --git a/internal/providers/hetzner/hetzner_test.go b/internal/providers/hetzner/hetzner_test.go
index 7510b82..39a8f06 100644
--- a/internal/providers/hetzner/hetzner_test.go
+++ b/internal/providers/hetzner/hetzner_test.go
@@ -8,6 +8,7 @@ import (
"path/filepath"
"strings"
"testing"
+ "time"
"github.com/hetznercloud/hcloud-go/v2/hcloud"
"github.com/pullpreview/action/internal/pullpreview"
@@ -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 {
@@ -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 {
diff --git a/internal/pullpreview/github_sync.go b/internal/pullpreview/github_sync.go
index 6bc4816..fe468e0 100644
--- a/internal/pullpreview/github_sync.go
+++ b/internal/pullpreview/github_sync.go
@@ -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 {
@@ -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)
@@ -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
@@ -511,7 +501,6 @@ const (
actionBranchDown actionType = "branch_down"
actionPRUp actionType = "pr_up"
actionPRPush actionType = "pr_push"
- actionBranchPush actionType = "branch_push"
)
type deploymentStatus string
@@ -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
}
@@ -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
-}
diff --git a/internal/pullpreview/github_sync_test.go b/internal/pullpreview/github_sync_test.go
index 8161ec2..e355029 100644
--- a/internal/pullpreview/github_sync_test.go
+++ b/internal/pullpreview/github_sync_test.go
@@ -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)
}
}
@@ -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)
@@ -541,7 +541,7 @@ func TestRunGithubSyncFromEnvironmentRunsDownForBranchPushWithoutAlwaysOn(t *tes
}
}
-func TestClearDanglingDeploymentsDestroysInstancesNotLinkedToActivePROrAlwaysOnBranch(t *testing.T) {
+func TestClearDanglingDeploymentsDestroysInstancesNotLinkedToActivePR(t *testing.T) {
client := &fakeGitHub{
issues: []*gh.Issue{
{
@@ -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)
}
@@ -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)
}
}
diff --git a/internal/pullpreview/types.go b/internal/pullpreview/types.go
index aad3e93..7c79f45 100644
--- a/internal/pullpreview/types.go
+++ b/internal/pullpreview/types.go
@@ -112,7 +112,6 @@ type ListOptions struct {
type GithubSyncOptions struct {
AppPath string
Label string
- AlwaysOn []string
DeploymentVariant string
TTL string
Context context.Context
diff --git a/wiki b/wiki
index c7f26a9..cbebeb1 160000
--- a/wiki
+++ b/wiki
@@ -1 +1 @@
-Subproject commit c7f26a94fb460df6e7b5b97abc86160dee14d28e
+Subproject commit cbebeb1df8a3e57b5e8feff4993fba41e4235224