
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:

```yaml
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:

```yaml
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:

```yaml
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:

```yaml
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

```mermaid
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<br/>cancel A, run B
```

`concurrency` prevents jobs in the same group from running simultaneously:

```yaml
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:

```yaml
- 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

```mermaid
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
```

```yaml
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:

```yaml
- 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:

```yaml
- 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

```groovy
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

```yaml
# .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

- [GitHub Actions Workflow Syntax](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions)
- [GitHub Actions Contexts](https://docs.github.com/en/actions/learn-github-actions/contexts)
- [GitHub Actions GITHUB_ENV](https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-environment-variable)
- [actions/setup-go cache](https://github.com/actions/setup-go#caching-dependency-files-and-build-outputs)
- [Jenkins Pipeline Documentation](https://www.jenkins.io/doc/book/pipeline/)
- [CircleCI Configuration Reference](https://circleci.com/docs/configuration-reference/)
- [CircleCI Contexts](https://circleci.com/docs/contexts/)
- [Semantic Versioning](https://semver.org/)
