Run tests on every PR. Auto-deploy on every push to main. One YAML file in your repo. Free for public repos; 2,000 minutes/month free for private. Twenty minutes from "I push to GitHub" to "the live site is updated."
npm test / pytest / cargo test / ./gradlew test can run.Create the directory and a workflow file in your repo:
mkdir -p .github/workflows
touch .github/workflows/ci.yml
Every file ending in .yml here is a separate workflow. You can have many (CI, deploy, scheduled jobs, etc.).
Paste this into .github/workflows/ci.yml (adjust for your language):
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- run: npm ci
- run: npm test
Commit, push. Go to your repo's Actions tab on github.com. You'll see the workflow run, with live logs.
runs-on: macos-latest + xcodebuild testIn README.md:

Green when tests pass, red when they fail. Visible on the repo home page; good signal for contributors.
Never commit API keys or tokens. Store them as Encrypted Secrets:
Repo page β Settings β Secrets and variables β Actions β New repository secret. Add e.g. VERCEL_TOKEN, AWS_ACCESS_KEY_ID, SSH_PRIVATE_KEY.
Use in a workflow as ${{ secrets.VERCEL_TOKEN }}:
- run: vercel deploy --prod --token=${{ secrets.VERCEL_TOKEN }}
Secrets are decrypted only at job runtime; never logged. Secrets docs β.
Add a second job (or workflow) that only runs on main:
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- run: npm ci
- run: npm run build
# Pick ONE of these depending on where you deploy:
# === Option A: Vercel ===
- uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
vercel-args: "--prod"
# === Option B: Self-hosted VPS via SSH ===
# - uses: appleboy/ssh-action@v1
# with:
# host: ${{ secrets.SERVER_HOST }}
# username: deploy
# key: ${{ secrets.SSH_PRIVATE_KEY }}
# script: cd /var/www/myapp && ./deploy.sh
# === Option C: AWS S3 + CloudFront ===
# - uses: aws-actions/configure-aws-credentials@v4
# with:
# aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
# aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
# aws-region: us-east-1
# - run: aws s3 sync ./dist s3://my-bucket --delete
Uncomment whichever destination matches your setup. Push to main β workflow runs β site is updated. Watch in the Actions tab.
Mobile builds need a Mac runner (iOS) or an Android SDK setup (Android). GitHub provides both.
iOS / TestFlight (combine with the iOS re-upload tutorial):
jobs:
ios:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- run: xcodebuild -scheme MyApp -configuration Release archive \
-archivePath build/MyApp.xcarchive
- run: xcodebuild -exportArchive \
-archivePath build/MyApp.xcarchive \
-exportPath build \
-exportOptionsPlist export.plist
- run: xcrun altool --upload-app -f build/MyApp.ipa -t ios \
--apiKey ${{ secrets.ASC_KEY_ID }} \
--apiIssuer ${{ secrets.ASC_ISSUER_ID }}
Android / Play Store via fastlane β:
jobs:
android:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: "17" }
- run: ./gradlew bundleRelease
- uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
packageName: com.example.myapp
releaseFiles: app/build/outputs/bundle/release/app-release.aab
track: internal
The cache: "npm" line in setup-node above already caches ~/.npm between runs β typically cuts CI time in half. Equivalents exist for every language: cache: "pip", cache: "gradle", cache: "cargo".
For finer control, use actions/cache β directly. But the per-language helpers are usually enough.
Save minutes by skipping workflows that don't need to run:
on:
push:
branches: [main]
paths:
- "src/**" # only when source changes
- "!docs/**" # skip when only docs change
- ".github/**"
Helps when you have multiple workflows (frontend deploy + backend deploy + docs build) but a given commit only touches one area.
Run on a schedule, no push needed:
on:
schedule:
- cron: "0 6 * * *" # daily at 06:00 UTC
workflow_dispatch: # also lets you trigger manually from the UI
Useful for: nightly backups, syncing data from an external API, cleaning up old records. crontab.guru β for cron syntax help.
~/.ssh/authorized_keys on the server, or it's the wrong format. Use ssh-keygen -t ed25519, paste the public key to server, the private key to GitHub Secrets.setup-node step. Every job starts on a clean runner.on: trigger matches your branch name (main vs master).echo "$MY_SECRET" in scripts. Use ::add-mask:: if you must.