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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  workflow_dispatch:
    inputs:
      environment:
        description: 'Target environment'
        required: true
        default: 'dev'
        type: choice
        options: [dev, prod]
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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
on:
  push:
    branches: [main]
    paths:
      - '**.go'
      - 'Dockerfile'
      - 'go.mod'
      - 'go.sum'
      - '.github/workflows/**'
  pull_request:
    branches: [main]
    paths:
      - '**.go'
      - 'Dockerfile'
      - 'go.mod'
      - 'go.sum'
      - '.github/workflows/**'

** 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:

1
2
3
deploy:
  needs: build
  if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'

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:

1
2
3
4
if: success()    # all previous steps passed (default)
if: failure()    # any previous step failed
if: always()     # runs regardless — cleanup, notifications
if: cancelled()  # workflow was cancelled

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 B

concurrency prevents jobs in the same group from running simultaneously:

1
2
3
4
deploy:
  concurrency:
    group: deploy-${{ github.ref }}
    cancel-in-progress: true

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:

1
2
3
4
- uses: actions/setup-go@v6
  with:
    go-version-file: go.mod
    cache: true

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 --> E
1
2
env:
  ENV_TAG: ${{ inputs.environment || (github.ref == 'refs/heads/main' && 'prod') || 'dev' }}

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:

1
2
3
4
5
6
7
- name: Set SHORT_SHA
  run: echo "SHORT_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV

- name: Docker push to AR
  run: |
    docker build -t ${GCP_REGION}-docker.pkg.dev/${GCP_PROJECT_ID}/go-api/go-api:${ENV_TAG}-${SHORT_SHA} .
    docker push ${GCP_REGION}-docker.pkg.dev/${GCP_PROJECT_ID}/go-api/go-api:${ENV_TAG}-${SHORT_SHA}

${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:

1
2
3
4
5
6
7
8
9
- name: Update image tag
  run: |
    sed -i "s|image: .*-docker.pkg.dev/.*/go-api/go-api:.*|image: us-east1-docker.pkg.dev/${GCP_PROJECT_ID}/go-api/go-api:${ENV_TAG}-${SHORT_SHA}|" k8s/base/deployment.yaml
    git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
    git config user.name "github-actions[bot]"
    git checkout -b gitops/update-image-${SHORT_SHA}
    git add k8s/base/deployment.yaml
    git commit -m "chore: update image tag to ${ENV_TAG}-${SHORT_SHA}"
    git push origin gitops/update-image-${SHORT_SHA}

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
pipeline {
    agent any
    environment {
        GCP_REGION = 'us-east1'
    }
    stages {
        stage('Test') {
            steps { sh 'go test ./...' }
        }
        stage('Build') {
            steps { sh 'go build ./...' }
        }
        stage('Deploy') {
            when { branch 'main' }
            steps { sh 'docker build -t go-api .' }
        }
    }
    post {
        failure { echo 'Pipeline failed' }
        always { cleanWs() }
    }
}
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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# .circleci/config.yml
version: 2.1

orbs:
  go: circleci/go@1.11

workflows:
  ci:
    jobs:
      - test
      - build:
          requires: [test]
      - deploy:
          requires: [build]
          context: gcp-prod
          filters:
            branches:
              only: main

jobs:
  test:
    docker:
      - image: cimg/go:1.23
    steps:
      - checkout
      - go/load-cache
      - run: go test ./...
      - go/save-cache
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.

References