Pattern-Based Git Trigger Engine & Poller

Cloud & Infrastructure intermediate Building with AI ~20 min read

Overview

Continuous deployment systems need a reliable way to detect when new code is pushed to a repository and decide whether that push should trigger a build. The simplest approach — trigger on every push to every branch — wastes resources and creates noise. Production deployment platforms need fine-grained control: deploy to staging when a branch matches release/*, deploy to production only on tags matching v*.*.*, and trigger preview environments for pull requests against main.

The deploy platform implements a pattern-based git trigger engine that solves this with a two-phase architecture. First, a git poller periodically fetches events from repository hosting APIs (GitHub, GitLab) using stored OAuth tokens, comparing the latest commit SHA against its last-seen checkpoint to detect new pushes. Second, a trigger matcher evaluates each detected event against a set of deploy environment rules — branch glob patterns, tag glob patterns, and PR regex rules — to determine which environments should receive a deployment.

This approach decouples the polling mechanism from the deployment logic, allowing each deploy environment to define its own trigger rules independently. The SHA checkpoint prevents duplicate deployments even if the poller runs more frequently than pushes occur, and environment-specific pattern matching ensures that the right code reaches the right environment without manual intervention.

Core Concepts

Deploy Environment Triggers

Each deploy environment (staging, production, preview) defines its own set of trigger patterns. A branch trigger uses glob patterns like release/* or main. A tag trigger uses patterns like v*.*.*. A PR trigger uses regex patterns to match pull request target branches. When a git event arrives, the engine tests it against every environment’s triggers and creates deployments only for matching environments.

SHA Checkpointing

The poller stores the last-seen commit SHA for each monitored repository. On each poll cycle, it fetches events newer than the checkpoint. After successfully processing events, it advances the checkpoint to the newest SHA. This ensures exactly-once trigger evaluation: even if the poller restarts or runs overlapping cycles, events are never processed twice. The checkpoint is stored persistently (typically in the database) so it survives process restarts.

Glob vs. Regex Pattern Matching

Branch and tag triggers use glob patterns because developers think in globs (feature/*, v1.*). PR triggers use full regex because PR matching often requires more complex logic (e.g., ^(main|develop)$ to match PRs targeting either branch). The engine normalizes both into a unified match interface, but the configuration surface uses the pattern type that’s most natural for each trigger type.

Repository Token Management

Each monitored repository has an associated OAuth or personal access token used to authenticate API calls to the hosting provider. The poller retrieves these tokens from secure storage before each poll cycle, supporting token rotation without poller restarts. If a token is revoked or expired, the poller logs the failure and skips that repository rather than halting all polling.

Environment-Scoped Deployments

When a trigger matches, the engine creates a deployment record scoped to the specific environment. This record captures the commit SHA, the branch or tag that triggered it, the environment name, and a timestamp. Downstream deployment executors consume these records to perform the actual build and deploy, decoupling trigger detection from deployment execution.

How It Works

The trigger engine operates as a periodic poller that fetches git events, evaluates them against environment-specific patterns, and creates deployment records for matches. The key data structures define the relationship between repositories, environments, and trigger rules.

Trigger Configuration Model

package trigger

import (
    "path/filepath"
    "regexp"
)

// Environment represents a deploy target with its trigger rules.
type Environment struct {
    Name           string
    BranchPatterns []string // glob patterns: "main", "release/*"
    TagPatterns    []string // glob patterns: "v*.*.*", "rc-*"
    PRTargetRegex  []string // regex: "^(main|develop)$"
    Enabled        bool
}

// GitEvent represents a detected change in a repository.
type GitEvent struct {
    RepoID    string
    Type      EventType // Push, Tag, PullRequest
    Ref       string    // branch name, tag name, or PR target branch
    CommitSHA string
    Timestamp int64
}

type EventType int

const (
    EventPush EventType = iota
    EventTag
    EventPullRequest
)

// Match checks if a git event matches this environment's trigger rules.
func (env *Environment) Match(event GitEvent) bool {
    if !env.Enabled {
        return false
    }

    switch event.Type {
    case EventPush:
        for _, pattern := range env.BranchPatterns {
            if matched, _ := filepath.Match(pattern, event.Ref); matched {
                return true
            }
        }
    case EventTag:
        for _, pattern := range env.TagPatterns {
            if matched, _ := filepath.Match(pattern, event.Ref); matched {
                return true
            }
        }
    case EventPullRequest:
        for _, pattern := range env.PRTargetRegex {
            if re, err := regexp.Compile(pattern); err == nil {
                if re.MatchString(event.Ref) {
                    return true
                }
            }
        }
    }

    return false
}

Git Poller with SHA Checkpointing

package trigger

import (
    "context"
    "log"
    "time"
)

// Poller fetches git events and evaluates triggers on a schedule.
type Poller struct {
    store        CheckpointStore
    repoClient   RepoClient
    environments []*Environment
    interval     time.Duration
}

// CheckpointStore persists the last-seen SHA per repository.
type CheckpointStore interface {
    GetLastSHA(ctx context.Context, repoID string) (string, error)
    SetLastSHA(ctx context.Context, repoID, sha string) error
}

// RepoClient fetches events from the git hosting API.
type RepoClient interface {
    FetchEventsSince(ctx context.Context, repoID, sinceCommit string) ([]GitEvent, error)
    GetRepoToken(ctx context.Context, repoID string) (string, error)
}

func (p *Poller) Run(ctx context.Context) error {
    ticker := time.NewTicker(p.interval)
    defer ticker.Stop()

    for {
        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-ticker.C:
            if err := p.poll(ctx); err != nil {
                log.Printf("poll cycle failed: %v", err)
            }
        }
    }
}

func (p *Poller) poll(ctx context.Context) error {
    repos, err := p.store.ListMonitoredRepos(ctx)
    if err != nil {
        return err
    }

    for _, repoID := range repos {
        lastSHA, err := p.store.GetLastSHA(ctx, repoID)
        if err != nil {
            log.Printf("skipping repo %s: %v", repoID, err)
            continue
        }

        events, err := p.repoClient.FetchEventsSince(ctx, repoID, lastSHA)
        if err != nil {
            log.Printf("fetch failed for %s: %v", repoID, err)
            continue
        }

        if len(events) == 0 {
            continue
        }

        for _, event := range events {
            for _, env := range p.environments {
                if env.Match(event) {
                    p.createDeployment(ctx, env, event)
                }
            }
        }

        // Advance checkpoint to the newest event's SHA.
        newestSHA := events[len(events)-1].CommitSHA
        if err := p.store.SetLastSHA(ctx, repoID, newestSHA); err != nil {
            log.Printf("checkpoint update failed for %s: %v", repoID, err)
        }
    }

    return nil
}

Deployment Record Creation

// Deployment represents a triggered deployment to a specific environment.
type Deployment struct {
    ID          string
    RepoID      string
    Environment string
    CommitSHA   string
    TriggerRef  string // the branch/tag/PR that triggered it
    TriggerType EventType
    CreatedAt   time.Time
    Status      DeployStatus
}

type DeployStatus int

const (
    DeployPending DeployStatus = iota
    DeployRunning
    DeploySuccess
    DeployFailed
)

func (p *Poller) createDeployment(ctx context.Context, env *Environment, event GitEvent) {
    deploy := &Deployment{
        ID:          generateID(),
        RepoID:      event.RepoID,
        Environment: env.Name,
        CommitSHA:   event.CommitSHA,
        TriggerRef:  event.Ref,
        TriggerType: event.Type,
        CreatedAt:   time.Now(),
        Status:      DeployPending,
    }

    if err := p.store.CreateDeployment(ctx, deploy); err != nil {
        log.Printf("failed to create deployment for %s/%s: %v",
            env.Name, event.CommitSHA[:8], err)
    }
}

Practical Applications

The deploy platform uses this trigger engine to power its deployment pipeline. When a developer pushes to a release/2.1 branch, the poller detects the push event, matches it against the staging environment’s release/* glob pattern, and creates a deployment record that the staging deploy executor picks up. Meanwhile, a push to main triggers the production environment, and a new pull request targeting main triggers a preview environment.

The SHA checkpoint mechanism is particularly important in the deploy platform because the poller runs on a 30-second interval. Without checkpointing, a push that arrives between two poll cycles could be detected twice if the poller queries overlapping time windows. The checkpoint ensures that each commit is evaluated exactly once, preventing duplicate deployments that would waste build resources and confuse deploy logs.

The pattern-based trigger configuration is stored in the database alongside environment definitions, allowing platform users to modify trigger rules through the deploy platform UI without restarting the poller. When a user changes a staging environment’s branch pattern from release/* to staging/*, the next poll cycle automatically uses the updated patterns.

Implementation Patterns

Debouncing Rapid Pushes

When a developer force-pushes multiple times in quick succession (common during rebase workflows), the poller may detect several events for the same branch. Creating a deployment for each would waste resources. Implement debouncing by grouping events by branch within a poll cycle and only creating a deployment for the latest SHA per branch.

func deduplicateByRef(events []GitEvent) []GitEvent {
    latest := make(map[string]GitEvent)
    for _, e := range events {
        key := e.Ref + "/" + string(rune(e.Type))
        if existing, ok := latest[key]; !ok || e.Timestamp > existing.Timestamp {
            latest[key] = e
        }
    }

    result := make([]GitEvent, 0, len(latest))
    for _, e := range latest {
        result = append(result, e)
    }
    return result
}

Webhook-First with Polling Fallback

Polling introduces inherent latency (up to the poll interval). For lower latency, accept webhooks from the git hosting provider as the primary trigger source, with the poller serving as a fallback that catches events missed by webhook delivery failures. The SHA checkpoint ensures no duplicates even when both paths detect the same event.

Pattern Validation at Configuration Time

Validate glob and regex patterns when the user saves the environment configuration, not at trigger evaluation time. An invalid regex in a PR trigger rule should be caught immediately, not silently fail to match during the next poll cycle. Compile and cache regex patterns to avoid repeated compilation overhead.

Common Pitfalls

Checkpoint Advancement Before Processing

If you advance the SHA checkpoint before all events are processed, a crash mid-processing loses events permanently. Always process all events and create all deployments before advancing the checkpoint. If deployment creation fails, leave the checkpoint at its previous value so the next poll cycle re-processes the events.

Glob Pattern Gotchas

Go’s filepath.Match does not support ** for recursive matching. The pattern feature/** won’t match feature/auth/login — it only matches single path segments. If nested branch names are common, use a more capable glob library or fall back to regex for branch matching.

Token Expiration During Long Poll Cycles

If the poller monitors many repositories, a single poll cycle can take minutes. An OAuth token that was valid at the start of the cycle may expire mid-cycle. Fetch tokens per-repository (not once per cycle) and handle 401 responses by refreshing the token and retrying before skipping the repository.

Missing Event Types

Git hosting APIs represent events differently. GitHub uses push events for both branch pushes and tag pushes, while GitLab separates them. The poller must normalize these into a consistent GitEvent model. Missing a normalization case means an entire class of triggers silently stops working.

Further Reading

Further Reading