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 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) Hacker News @@ -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