When I started using GitHub, I treated it as a place to store code. Years later, I realized it's also a build server, a release manager, a deployment platform, and an automation engine, all included in the same repo.
This is the article I wish I had read at the beginning. First, a quick tour of the commands worth knowing. Then the main event: GitHub Actions and everything you can automate with it.
Part 1: GitHub essentials (the short version)
Git commands I use every day
# Start from a remote repo
git clone https://github.com/user/repo.git
# Work on a feature branch (never directly on main)
git switch -c feature/login-page
# See what changed
git status
git diff
# Stage and commit
git add -p # stage interactively, hunk by hunk
git commit -m "feat: add login page"
# Push and set upstream
git push -u origin feature/login-page
# Stay up to date
git pull --rebase origin main
Git commands I wish I had learned earlier
# Undo the last commit but keep your changes
git reset --soft HEAD~1
# Temporarily save work in progress
git stash push -m "wip: login form"
git stash pop
# Find which commit introduced a bug (binary search!)
git bisect start
git bisect bad # current commit is broken
git bisect good v1.2.0 # this tag was fine
# Git checks out commits for you to test, then:
git bisect reset
# Recover "lost" commits after a bad reset or rebase
git reflog
# Work on two branches at once, in two folders
git worktree add ../repo-hotfix hotfix/urgent
# Tag a release
git tag -a v1.0.0 -m "First stable release"
git push origin v1.0.0
The GitHub CLI (gh): a game changer
The GitHub CLI lets you do almost everything from the terminal.
gh auth login # authenticate once
gh repo create my-app --public --clone
gh pr create --fill # open a PR from your current branch
gh pr list # list open PRs
gh pr checkout 42 # test someone's PR locally
gh pr merge 42 --squash --delete-branch
gh issue create --title "Bug: login fails" --body "Steps to reproduce..."
gh release create v1.0.0 --generate-notes
gh run list # list recent workflow runs
gh run watch # follow a run live
gh run view --log-failed # show logs of failed steps only
gh workflow run deploy.yml # trigger a workflow manually
The last four commands are the ones that connect to the second part of this article.
Repo features worth enabling early
-
Branch protection rules: require PR reviews and passing status checks before merging into
main. -
CODEOWNERS: automatically request reviews from the right people. -
Issue and PR templates (
.github/ISSUE_TEMPLATE/,.github/pull_request_template.md). - Dependabot: automatic dependency update PRs.
- Secret scanning and push protection: catches leaked keys before they hit the repo.
Part 2: GitHub Actions, the real superpower
GitHub Actions is GitHub's built-in automation platform. You describe what should happen in a YAML file, and GitHub runs it on its own servers in response to events (a push, a pull request, a tag, a schedule, a button click...).
Public repositories get free minutes, and private repositories get a monthly free quota depending on your plan. Check the current limits in the GitHub docs.
The vocabulary in 60 seconds
| Concept | Meaning |
|---|---|
| Workflow | A YAML file in .github/workflows/
|
| Event / trigger | What starts the workflow (push, pull_request, schedule...) |
| Job | A group of steps running on the same machine (jobs run in parallel by default) |
| Step | A single shell command (run) or a reusable action (uses) |
| Runner | The machine that executes a job (ubuntu-latest, windows-latest, macos-latest, or self-hosted) |
| Action | A reusable building block, found on the Marketplace |
Your first workflow
Create .github/workflows/ci.yml:
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run lint
- run: npm test
Commit, push, open the Actions tab. That's it: every push and every PR now runs your tests.
Tip: Combine this with branch protection and you can block merging until this workflow is green.
What you can automate with GitHub Actions
1. Continuous Integration with a build matrix
Test on several versions and operating systems at once:
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
node: [20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: npm
- run: npm ci
- run: npm test
That's 4 jobs from about 15 lines. The same idea works for Python, Java, Go, Rust, .NET and more.
2. Automatic releases
Option A: Release when you push a tag
name: Release
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run build
- run: zip -r dist.zip dist
- name: Create GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "${{ github.ref_name }}" dist.zip \
--generate-notes \
--title "${{ github.ref_name }}"
Now releasing is just:
git tag v1.2.0 && git push origin v1.2.0
GitHub builds your project, attaches the artifact, and generates release notes from merged PRs.
Option B: Fully automated versioning
Tools like release-please or semantic-release read your commit messages (Conventional Commits: feat:, fix:, feat!:) and automatically:
- bump the version,
- update the
CHANGELOG.md, - open a "Release PR" (release-please) that you merge to publish.
name: release-please
on:
push:
branches: [main]
permissions:
contents: write
pull-requests: write
jobs:
release-please:
runs-on: ubuntu-latest
steps:
- uses: googleapis/release-please-action@v4
with:
release-type: node
Publish a package to npm
name: Publish to npm
on:
release:
types: [published]
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # enables provenance
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
registry-url: https://registry.npmjs.org
- run: npm ci
- run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
The same pattern works for PyPI, crates.io, NuGet, Maven Central, RubyGems and others.
3. Deploy a web app
Static site or SPA to GitHub Pages (free)
Works for React, Vue, Vite, Astro, Docusaurus, Hugo and more. In your repo, go to Settings → Pages → Source: GitHub Actions, then:
name: Deploy to GitHub Pages
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run build
- uses: actions/upload-pages-artifact@v3
with:
path: dist
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4
Vercel, Netlify, Cloudflare Pages, Azure, Firebase...
Most hosting platforms offer a CLI or an official action, and they all follow the same pattern: build, then deploy with a token stored in Secrets. Example with the Vercel CLI:
name: Deploy to Vercel
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm i -g vercel
- run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
- run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}
- run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }}
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
Your own server over SSH
name: Deploy to VPS
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy over SSH
env:
SSH_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
HOST: ${{ secrets.SSH_HOST }}
USER: ${{ secrets.SSH_USER }}
run: |
mkdir -p ~/.ssh
echo "$SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H "$HOST" >> ~/.ssh/known_hosts
ssh "$USER@$HOST" "cd /var/www/app && git pull && npm ci && npm run build && pm2 restart app"
Cloud providers without long-lived secrets (OIDC)
Instead of storing AWS/Azure/GCP keys as secrets, GitHub can give your workflow a short-lived token via OpenID Connect. Example for AWS:
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-deploy
aws-region: eu-west-3
- run: aws s3 sync ./dist s3://my-bucket --delete
No stored access key means nothing to leak or rotate.
4. Build and push Docker images
GitHub also hosts a container registry (GHCR):
name: Docker
on:
push:
tags: ["v*"]
permissions:
contents: read
packages: write
jobs:
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/metadata-action@v5
id: meta
with:
images: ghcr.io/${{ github.repository }}
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
5. Deployment environments with manual approval
Define environments (staging, production) in Settings → Environments. You can add required reviewers, wait timers, and environment-specific secrets.
jobs:
deploy-staging:
runs-on: ubuntu-latest
environment: staging
steps:
- run: echo "Deploying to staging..."
deploy-production:
needs: deploy-staging
runs-on: ubuntu-latest
environment: production # pauses until a reviewer approves
steps:
- run: echo "Deploying to production..."
Production deployments now wait for a human click. Very useful in teams.
6. Scheduled jobs (cron) and manual triggers
name: Nightly
on:
schedule:
- cron: "0 3 * * *" # every day at 03:00 UTC
workflow_dispatch: # adds a "Run workflow" button
inputs:
environment:
description: "Target environment"
type: choice
options: [staging, production]
default: staging
jobs:
nightly:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: ./scripts/nightly-report.sh
env:
TARGET: ${{ inputs.environment || 'staging' }}
Use it for backups, link checkers, data scraping, report generation, or dependency audits. You can also run it from the terminal with gh workflow run nightly.yml -f environment=production.
7. Automate your pull requests and issues
Actions can react to almost anything that happens in your repo.
name: PR automation
on:
pull_request:
types: [opened, reopened]
permissions:
pull-requests: write
contents: read
jobs:
label:
runs-on: ubuntu-latest
steps:
# Labels PRs based on the files changed (configured in .github/labeler.yml)
- uses: actions/labeler@v5
- name: Welcome comment
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh pr comment ${{ github.event.pull_request.number }} \
--repo ${{ github.repository }} \
--body "Thanks for the PR! A maintainer will review it soon."
More ideas:
- Auto-close stale issues with
actions/stale. - Auto-merge Dependabot PRs when tests pass.
- Enforce PR title conventions (Conventional Commits).
- Post preview URLs as a comment on every PR.
- Run a code formatter and push the fix.
8. Security scanning
-
CodeQL: GitHub's code analysis, enabled in a few clicks or with
github/codeql-action. -
Dependency review:
actions/dependency-review-actionblocks PRs that add vulnerable dependencies. - Trivy, Snyk, Semgrep and other scanners all have ready-to-use actions.
9. Speed and cost: caching, artifacts, concurrency
# Cancel outdated runs when you push again
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Cache anything (here: pip packages)
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: pip-${{ runner.os }}-${{ hashFiles('requirements.txt') }}
- run: pip install -r requirements.txt && pytest --junitxml=report.xml
# Keep files from the run (test reports, builds, screenshots)
- uses: actions/upload-artifact@v4
if: always()
with:
name: test-report
path: report.xml
10. Reuse your workflows
Copy-pasting YAML across 20 repos gets painful fast. Two tools fix this.
Reusable workflow (called from other workflows):
# .github/workflows/reusable-test.yml (in a shared repo)
on:
workflow_call:
inputs:
node-version:
type: string
default: "22"
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
- run: npm ci && npm test
# In any other repo
jobs:
call-tests:
uses: my-org/shared-workflows/.github/workflows/reusable-test.yml@main
with:
node-version: "20"
Composite action (a mini-action made of steps, in action.yml) and even custom JavaScript or Docker actions if you need full control. You can publish them to the Marketplace.
Best practices (learned the hard way)
-
Least privilege for the token. Set
permissions:explicitly at the top of each workflow instead of relying on defaults. -
Never print secrets, and never run untrusted code with access to them. Be careful with
pull_request_target. -
Pin third-party actions, ideally to a full commit SHA (
uses: owner/action@<sha>), and let Dependabot update them:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
-
Avoid injecting untrusted input into
run:. Pass values throughenv:rather than interpolating${{ github.event.pull_request.title }}directly in a shell command. -
Use
concurrencyto cancel duplicate runs and avoid overlapping deployments. -
Set
timeout-minuteson jobs so a stuck job doesn't burn your minutes. -
Keep workflows small. Split by concern (CI, release, deploy) and reuse with
workflow_call. -
Test locally with
actwhen iterating on a workflow, and usegh run view --log-failedto debug quickly. -
Check the latest versions of the actions you use (
actions/checkout,setup-node, etc.), since major versions move regularly.
Where to go from here
If you're just starting, this is a good progression:
- Add a CI workflow (lint + tests) to one of your repos.
- Turn on branch protection so main requires a green build.
- Automate a release on tag push.
- Automate deployment (GitHub Pages is the easiest place to start).
- Add Dependabot and a security scan.
- Extract repeated logic into reusable workflows.
Once these are in place, your repo stops being just a folder of code. It becomes a small, automated delivery pipeline, and you'll wonder how you ever shipped without it.
What's the coolest thing you've automated with GitHub Actions? Share it in the comments, I'm always looking for new ideas.
Top comments (0)