Advanced GitHub Actions: Path Filter, Concurrency, Cache, and GitOps Auto-Update
This post records how I upgraded a GitHub Actions pipeline from basic to actually usable: trigger design, path filters, concurrency, cache, then automatic CI updates to deployment.yaml to complete a GitOps closed loop. At the end, I compare Jenkins and CircleCI.
Event model design
Each of the three triggers has its own purpose:
|
|
| Trigger | When it fires | Purpose |
|---|---|---|
push |
merged into main | mainline CI, run after every merge |
pull_request |
PR opened or updated | validation before review, protect main |
workflow_dispatch |
manual run from GitHub UI | rerun hotfix, manual deploy with selected environment |
After workflow_dispatch adds inputs, GitHub UI shows a dropdown so you can select the environment when triggering. inputs supports four types: string, boolean, number, and choice.
Selective execution by changed files
Pushing a README should not trigger CI. A paths filter lets the workflow run only when meaningful files changed:
|
|
** matches subdirectories at any depth, while * matches only one level. **.go matches handlers/health.go and middleware/auth.go, while *.go matches only root-level .go files.
workflow_dispatch does not need paths; manual trigger always runs.
Guard conditions for jobs and steps
The goal of a PR is review, not going live. The deploy job should not run on PR trigger:
|
|
if controls whether this gate runs. github.event_name is a built-in GitHub Actions context, and its value maps to the trigger name that fired the run. pull_request is excluded, while test and build still run to confirm the PR is mergeable.
if can be set at two levels:
| Level | Effect | Example |
|---|---|---|
| job level | skip or run the whole job | deploy runs only on push |
| step level | skip or run a single step | run one step only in prod |
Common built-in functions at step level:
|
|
Run locking for deployment safety
sequenceDiagram
participant C1 as Commit A
participant C2 as Commit B
participant D as Deploy Job
C1->>D: trigger deploy (3 min)
C2->>D: trigger deploy (30s later)
Note over D: cancel-in-progress: true
cancel A, run B
sequenceDiagram
participant C1 as Commit A
participant C2 as Commit B
participant D as Deploy Job
C1->>D: trigger deploy (3 min)
C2->>D: trigger deploy (30s later)
Note over D: cancel-in-progress: true
cancel A, run B
sequenceDiagram
participant C1 as Commit A
participant C2 as Commit B
participant D as Deploy Job
C1->>D: trigger deploy (3 min)
C2->>D: trigger deploy (30s later)
Note over D: cancel-in-progress: true
cancel A, run BsequenceDiagram
participant C1 as Commit A
participant C2 as Commit B
participant D as Deploy Job
C1->>D: trigger deploy (3 min)
C2->>D: trigger deploy (30s later)
Note over D: cancel-in-progress: true
cancel A, run B
concurrency prevents jobs in the same group from running simultaneously:
|
|
group is the identity key, and github.ref is the branch name, so deploys on main branch share one lock.
cancel-in-progress: true |
cancel-in-progress: false |
|
|---|---|---|
| Behavior | newer run arrives, older one is canceled | newer run waits in queue |
| Best for | deployment (want latest version) | database migration (must not be interrupted) |
Put concurrency only on deploy job, not on test/build — every commit still needs a full test record.
Reusing downloaded dependencies between pipeline runs
Each runner starts clean, so dependencies download again every run. If go.sum has not changed, that redownload is pure waste.
actions/setup-go has built-in cache after v4:
|
|
cache: true automatically uses runner.os + go.sum hash as key. If go.sum is unchanged, cache hits and dependency download is skipped.
Cache key design rule: hash the files that determine cache content into the key.
| Scenario | What key includes |
|---|---|
| Go | runner.os + hashFiles('**/go.sum') |
| Node.js | runner.os + hashFiles('**/package-lock.json') |
| Python | runner.os + hashFiles('**/requirements.txt') |
Variable derived from branch or manual input
flowchart LR
A{trigger?} -->|workflow_dispatch| B[inputs.environment]
A -->|push to main| C[prod]
A -->|other branch| D[dev]
B --> E[ENV_TAG]
C --> E
D --> E
flowchart LR
A{trigger?} -->|workflow_dispatch| B[inputs.environment]
A -->|push to main| C[prod]
A -->|other branch| D[dev]
B --> E[ENV_TAG]
C --> E
D --> E
flowchart LR
A{trigger?} -->|workflow_dispatch| B[inputs.environment]
A -->|push to main| C[prod]
A -->|other branch| D[dev]
B --> E[ENV_TAG]
C --> E
D --> Eflowchart LR
A{trigger?} -->|workflow_dispatch| B[inputs.environment]
A -->|push to main| C[prod]
A -->|other branch| D[dev]
B --> E[ENV_TAG]
C --> E
D --> E
|
|
SHORT_SHA for cross-step variable reuse
Each run: block is an independent shell; variables cannot be passed directly across steps. GITHUB_ENV is a special GitHub Actions file, and variables written into it can be read by all subsequent steps:
|
|
${GITHUB_SHA::7} is bash substring syntax for first 7 characters. Docker push and deployment.yaml update both use the same SHORT_SHA, which keeps the tag consistent.
CI-driven manifest mutation and branch push
After CI build, automatically update image tag in k8s/base/deployment.yaml, push to a gitops branch, then ArgoCD detects the change and syncs:
|
|
sed uses | instead of / as the delimiter because image URL contains /, which avoids syntax confusion. Push to a separate gitops branch instead of pushing directly to main because main has branch protection rules. contents: write permission gives the runner write access to the repository.
Commit-hash labels compared with release numbers
| Git SHA | Semver (v1.2.3) |
|
|---|---|---|
| Uniqueness | naturally unique | needs manual maintenance |
| Traceability | maps directly to commit | needs extra mapping between tag and commit |
| Semantics | none | yes, you can see v1.3.0 vs v1.2.1 at a glance |
| Best for | internal services, CI/CD automation | external API, library |
Pipeline uses {env}-{sha} format (for example prod-7639a24) so environment and commit are visible immediately.
Jenkins vs GitHub Actions
|
|
| GitHub Actions | Jenkins | |
|---|---|---|
| Config file | .github/workflows/*.yml |
Jenkinsfile |
| Syntax | YAML | Groovy DSL |
| Job dependencies | needs: [test] |
stage runs sequentially by default |
| Branch constraint | if: github.event_name == 'push' |
when { branch 'main' } |
| Cleanup | step-level if: always() |
post { always {} } |
| Setup | cloud-hosted, no infra maintenance | maintain your own server |
| Integrations | deep GitHub integration | rich plugin ecosystem |
| Best for | cloud-native, open-source projects | enterprise, on-premise, complex pipelines |
Another hosted CI comparison
|
|
| Concept | GitHub Actions | CircleCI |
|---|---|---|
| Job dependencies | needs: [test] |
requires: [test] |
| Branch constraint | if: condition |
filters: branches: only: |
| Reusable packages | Marketplace actions | Orbs |
| Shared credentials across repos | per-repo secrets | Contexts (shared at org level) |
| Manual trigger + params | workflow_dispatch + UI |
API call + pipeline.parameters |
CircleCI’s core advantage is shared Contexts across repos — multiple repositories point to one Context, credentials are centrally managed, and one change applies everywhere. This advantage becomes clearly different only in medium-to-large enterprise multi-repo environments.