GitHub Actions I Set Up on Every Project

Most engineers using GitHub Actions daily know how to write a basic workflow — checkout, install dependencies, run tests, deploy. That covers 80% of what they need. The other 20% is a set of features that solve specific, recurring pain points — slow CI, queued runs piling up, duplicated workflow code across repos. I learned each of these one at a time, when the problem they solve became annoying enough to investigate.
Here's the full set, in the order I typically add them to a project.
1. Dependency Caching
Without caching, every CI run installs dependencies from scratch. For a Python project with 50 packages, that's 30–60 seconds of install time on every push. Multiply by a team of five pushing multiple times a day and it adds up fast.
- name: Cache pip dependencies
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Install dependencies
run: pip install -r requirements.txt
The key is a hash of your requirements file. When the file changes, the hash changes, the cache misses, and a fresh install runs. When requirements haven't changed, the cache hits and the install step is skipped entirely — typically saving 30–90 seconds per run.
restore-keys is the fallback: if the exact key misses (say, you added one package), it falls back to the last cache with the ${{ runner.os }}-pip- prefix rather than starting cold. Partial cache hit beats a full cold install.
Same pattern works for Node.js (~/.npm), Go modules (~/go/pkg/mod), and Docker layer caching.
2. Concurrency Groups
Rapid pushes to the same branch queue up pipeline runs. If you push three times in quick succession, you're waiting for three sequential CI runs — even though only the last one matters.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
Add this at the top level of any workflow file. When a new run starts on the same branch, the in-progress run is cancelled. Only the latest run completes. CI stays responsive, the queue doesn't build up, and you're not paying for compute running pipelines that are already superseded.
For production deploys where you don't want cancellation mid-deploy:
concurrency:
group: deploy-prod
cancel-in-progress: false # queue, don't cancel
3. Matrix Builds
Testing across multiple Python versions, Node versions, or operating systems normally means duplicating job definitions. Matrix builds collapse that into one:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.11", "3.12", "3.13"]
fail-fast: false # don't cancel other versions if one fails
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- run: pip install -r requirements.txt
- run: pytest tests/
Three jobs run in parallel, each on a different Python version. Total CI time is the slowest single job, not the sum of all three. fail-fast: false lets all versions run to completion even if one fails — useful when you want to see the full compatibility picture rather than stopping at the first failure.
Matrix variables can be anything — environment names, database versions, cloud regions. It's not just for language versions.
4. Reusable Workflows
Teams with multiple repos tend to duplicate CI logic — the same lint, test, and build steps copy-pasted across every repository. Reusable workflows fix this: define the logic once, call it from anywhere.
# .github/workflows/shared-test.yml (in a central repo or the same repo)
on:
workflow_call:
inputs:
python-version:
type: string
default: "3.12"
secrets:
GCP_SA:
required: true
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}
- run: pip install -r requirements.txt
- run: pytest tests/
# .github/workflows/ci.yml (in any consuming repo)
jobs:
test:
uses: your-org/shared-workflows/.github/workflows/shared-test.yml@main
with:
python-version: "3.12"
secrets:
GCP_SA: ${{ secrets.GCP_SA }}
Update the shared workflow once and every repo that calls it gets the update automatically. For a team running five or six services, this eliminates a significant amount of CI maintenance overhead.
5. Job Outputs
Jobs in a workflow don't share state by default. If your build job creates a Docker image tag, the deploy job needs that tag — without job outputs, you'd have to hardcode it or pass it through an artifact file.
jobs:
build:
runs-on: ubuntu-latest
outputs:
image-tag: ${{ steps.tag.outputs.tag }}
steps:
- uses: actions/checkout@v4
- name: Generate image tag
id: tag
run: echo "tag=${{ github.sha }}" >> $GITHUB_OUTPUT
- name: Build and push
run: |
docker build -t gcr.io/your-project/api:${{ steps.tag.outputs.tag }} .
docker push gcr.io/your-project/api:${{ steps.tag.outputs.tag }}
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: Deploy to Cloud Run
run: |
gcloud run deploy pulsecart-api \
--image gcr.io/your-project/api:${{ needs.build.outputs.image-tag }} \
--region us-central1
The image-tag output flows from the build job to the deploy job cleanly. No artifacts, no hardcoding, no environment variable gymnastics.
6. Manual Triggers with Inputs
push and pull_request triggers cover most cases. For on-demand deploys to a specific environment — "deploy this to staging right now" — workflow_dispatch with typed inputs is the right tool:
on:
workflow_dispatch:
inputs:
environment:
description: "Target environment"
type: choice
options: [dev, staging, prod]
required: true
version:
description: "Image tag to deploy (leave blank for latest)"
type: string
required: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy
run: |
TAG=${{ inputs.version || github.sha }}
gcloud run deploy pulsecart-api \
--image gcr.io/your-project/api:$TAG \
--region us-central1 \
--project ${{ inputs.environment == 'prod' && secrets.PROD_PROJECT || secrets.DEV_PROJECT }}
Triggerable from the GitHub Actions UI, the GitHub CLI (gh workflow run), or the API. The choice input type renders as a dropdown in the UI — no free-text mistakes on environment names.
None of these are advanced features. They're in the documentation. They just don't come up naturally until you hit the problem they solve — slow CI, queued runs, duplicated YAML, jobs that can't talk to each other. Worth adding to your defaults before the pain arrives rather than after.





