github-actions-workflows
SKILL.md
GitHub Actions Workflows Skill
Create production-ready GitHub Actions workflows with security best practices, reusable patterns, and multi-cloud integration (Hetzner/K3s, Azure).
When to Use This Skill
- Creating new CI/CD workflows from scratch
- Building reusable workflow templates
- Building and pushing container images to GHCR
- Deploying to Hetzner Cloud VPS via K3s (SSH + kubectl)
- Setting up Azure deployments with OIDC
- Implementing security best practices in workflows
- Configuring multi-language CI pipelines
- Troubleshooting workflow issues
Core Workflow Structure
name: Workflow Name
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch: # Manual trigger
permissions:
contents: read # Always use least privilege
jobs:
job-name:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
# Additional steps...
Security Checklist
Mandatory Security Practices
- Pin actions to full SHA (not tags)
- Declare permissions explicitly (never use default
write-all) - Use OIDC for cloud authentication (no stored credentials)
- Never echo secrets to logs
- Use environments for deployment protection
- Enable Dependabot for action updates
Permissions Reference
permissions:
contents: read # Read repository contents
pull-requests: write # Comment on PRs
id-token: write # OIDC authentication (Azure/AWS/GCP)
packages: write # Push to GitHub Packages
actions: read # Read workflow artifacts
security-events: write # Upload security scanning results
GHCR (GitHub Container Registry) Patterns
Build and Push to GHCR
permissions:
contents: read
packages: write
jobs:
build-push:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Log in to GHCR
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
with:
images: ghcr.io/${{ github.repository }}/my-app
tags: |
type=sha,prefix=
type=ref,event=branch
type=semver,pattern={{version}}
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
uses: docker/build-push-action@263435318d21b8e681c14492fe198e19c816612b # v6.18.0
with:
context: .
file: apps/my-app/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
Monorepo Docker Build (Build Arg Pattern)
# Building from monorepo root with --build-arg
- name: Build and push API image
uses: docker/build-push-action@263435318d21b8e681c14492fe198e19c816612b # v6.18.0
with:
context: . # Monorepo root for workspace dependency resolution
file: apps/easyfactu-api/Dockerfile
push: true
tags: ghcr.io/${{ github.repository }}/easyfactu-api:${{ github.sha }}
build-args: |
MODULE_NAME=easyfactu_api
cache-from: type=gha
cache-to: type=gha,mode=max
Multi-Platform Build
- name: Set up QEMU
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
- name: Build and push (multi-platform)
uses: docker/build-push-action@263435318d21b8e681c14492fe198e19c816612b # v6.18.0
with:
context: .
file: apps/my-app/Dockerfile
push: true
platforms: linux/amd64,linux/arm64
tags: ghcr.io/${{ github.repository }}/my-app:${{ github.sha }}
GHCR Image Cleanup
# Clean up old untagged images to stay within GHCR free tier
- name: Delete old container images
uses: actions/delete-package-versions@e5bc658cc4c965c472ez4d6f4c01e47f6869ed # v5.0.0
with:
package-name: my-app
package-type: container
min-versions-to-keep: 5
delete-only-untagged-versions: true
Hetzner Cloud / K3s Deployment Patterns
SSH-Based Deployment to K3s
Deploy to a Hetzner VPS running K3s via SSH and kubectl:
permissions:
contents: read
packages: write
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Log in to GHCR
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@263435318d21b8e681c14492fe198e19c816612b # v6.18.0
with:
context: .
file: apps/easyfactu-api/Dockerfile
push: true
tags: ghcr.io/${{ github.repository }}/easyfactu-api:${{ github.sha }}
- name: Configure SSH
run: |
mkdir -p ~/.ssh
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H $SERVER_IP >> ~/.ssh/known_hosts
env:
SSH_PRIVATE_KEY: ${{ secrets.K3S_SSH_PRIVATE_KEY }}
SERVER_IP: ${{ secrets.K3S_SERVER_IP }}
- name: Deploy to K3s
run: |
ssh root@$SERVER_IP "
kubectl set image deployment/easyfactu-api \
api=ghcr.io/${{ github.repository }}/easyfactu-api:${{ github.sha }} \
-n easyfactu
kubectl rollout status deployment/easyfactu-api -n easyfactu --timeout=120s
"
env:
SERVER_IP: ${{ secrets.K3S_SERVER_IP }}
- name: Cleanup SSH key
if: always()
run: rm -f ~/.ssh/id_ed25519
Kustomize-Based K3s Deployment
- name: Deploy with Kustomize
run: |
ssh root@$SERVER_IP "
cd /opt/k8s-manifests &&
git pull origin main &&
kubectl apply -k infra/k8s/overlays/prod
"
env:
SERVER_IP: ${{ secrets.K3S_SERVER_IP }}
Remote kubectl via Kubeconfig
- name: Set up kubeconfig
run: |
mkdir -p ~/.kube
echo "$KUBECONFIG_CONTENT" > ~/.kube/config
chmod 600 ~/.kube/config
# Replace localhost with the actual server IP
sed -i "s/127.0.0.1/$SERVER_IP/" ~/.kube/config
env:
KUBECONFIG_CONTENT: ${{ secrets.K3S_KUBECONFIG }}
SERVER_IP: ${{ secrets.K3S_SERVER_IP }}
- name: Deploy via remote kubectl
run: |
kubectl set image deployment/easyfactu-api \
api=ghcr.io/${{ github.repository }}/easyfactu-api:${{ github.sha }} \
-n easyfactu
kubectl rollout status deployment/easyfactu-api -n easyfactu --timeout=120s
GHCR Pull Secret for K3s
# Create or update GHCR pull secret on the K3s cluster
- name: Update GHCR pull secret
run: |
ssh root@$SERVER_IP "
kubectl create secret docker-registry ghcr-credentials \
--namespace=easyfactu \
--docker-server=ghcr.io \
--docker-username=${{ github.actor }} \
--docker-password=$GHCR_TOKEN \
--dry-run=client -o yaml | kubectl apply -f -
"
env:
SERVER_IP: ${{ secrets.K3S_SERVER_IP }}
GHCR_TOKEN: ${{ secrets.GHCR_PULL_TOKEN }}
Terraform Apply for Hetzner Infrastructure
jobs:
terraform:
runs-on: ubuntu-latest
environment: production
defaults:
run:
working-directory: infra/terraform/environments/prod
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup Terraform
uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2
- name: Terraform Init
run: terraform init
- name: Terraform Plan
run: terraform plan -out=tfplan -no-color
env:
TF_VAR_hcloud_token: ${{ secrets.HCLOUD_TOKEN }}
TF_VAR_ssh_public_key: ${{ secrets.SSH_PUBLIC_KEY }}
TF_VAR_admin_ip: ${{ secrets.ADMIN_IP }}
- name: Terraform Apply
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: terraform apply -auto-approve tfplan
env:
TF_VAR_hcloud_token: ${{ secrets.HCLOUD_TOKEN }}
TF_VAR_ssh_public_key: ${{ secrets.SSH_PUBLIC_KEY }}
TF_VAR_admin_ip: ${{ secrets.ADMIN_IP }}
Hetzner/K3s Secrets Reference
| Secret | Description |
|---|---|
K3S_SSH_PRIVATE_KEY |
Ed25519 private key for SSH to K3s server |
K3S_SERVER_IP |
Public IPv4 of the Hetzner VPS |
K3S_KUBECONFIG |
Kubeconfig from /etc/rancher/k3s/k3s.yaml |
HCLOUD_TOKEN |
Hetzner Cloud API token (for Terraform) |
GHCR_PULL_TOKEN |
PAT with read:packages scope for K3s image pulls |
Azure OIDC Authentication Pattern
permissions:
id-token: write # REQUIRED for OIDC
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment: production # Uses environment-specific secrets
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Azure Login (OIDC - No Secrets!)
uses: azure/login@a65d910e8af852a8061c627c456678983e180302 # v2.2.0
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
Reusable Workflow Template
Callable Workflow (reusable-deploy.yml)
name: Reusable Deploy
on:
workflow_call:
inputs:
environment:
required: true
type: string
app-name:
required: true
type: string
secrets:
AZURE_CLIENT_ID:
required: true
AZURE_TENANT_ID:
required: true
AZURE_SUBSCRIPTION_ID:
required: true
outputs:
url:
description: 'Deployment URL'
value: ${{ jobs.deploy.outputs.webapp-url }}
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
outputs:
webapp-url: ${{ steps.deploy.outputs.webapp-url }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: azure/login@a65d910e8af852a8061c627c456678983e180302 # v2.2.0
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Deploy
id: deploy
uses: azure/webapps-deploy@4bca689e4c7129e55925cd7f1751cb9c4ac29b30 # v3.0.2
with:
app-name: ${{ inputs.app-name }}
Caller Workflow
name: Deploy Production
on:
push:
branches: [main]
jobs:
deploy:
uses: ./.github/workflows/reusable-deploy.yml
with:
environment: production
app-name: my-app-prod
secrets:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
permissions:
id-token: write
contents: read
Language-Specific CI Patterns
Python (UV)
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: '3.12'
- uses: astral-sh/setup-uv@6b9c6063abd6010835644d4c2e1bef4cf5cd0fca # v6.0.1
- run: uv sync
- run: uv run pytest
- run: uv run mypy src/
- run: uv run ruff check src/
Node.js
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm test
- run: npm run build
.NET
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
with:
dotnet-version: '8.0.x'
- run: dotnet restore
- run: dotnet build --no-restore
- run: dotnet test --no-build
Java (Maven)
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: '21'
distribution: 'temurin'
cache: 'maven'
- run: mvn -B package --file pom.xml
Supply Chain Security
Pin Actions to SHA
# ❌ Vulnerable - tags can be moved
- uses: actions/checkout@v4
# ✅ Secure - immutable reference
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
Dependabot Configuration
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
commit-message:
prefix: "ci"
Vulnerability Scanning
- name: Run Trivy
uses: aquasecurity/trivy-action@6c175e9c4083a92bbca2f9724c8a5e33bc2d97a5 # v0.30.0
with:
scan-type: 'fs'
severity: 'CRITICAL,HIGH'
exit-code: '1'
Common Patterns
Matrix Strategy
strategy:
matrix:
python-version: ['3.11', '3.12']
os: [ubuntu-latest, windows-latest]
fail-fast: false
Caching
- uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: ~/.cache/uv
key: ${{ runner.os }}-uv-${{ hashFiles('**/uv.lock') }}
restore-keys: ${{ runner.os }}-uv-
Conditional Execution
jobs:
deploy-prod:
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
Artifacts
# Upload
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: build-output
path: dist/
# Download
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: build-output
Azure Deployment Patterns
App Service
- uses: azure/webapps-deploy@4bca689e4c7129e55925cd7f1751cb9c4ac29b30 # v3.0.2
with:
app-name: ${{ inputs.app-name }}
package: ./dist
Container Apps
- run: |
az acr login --name ${{ vars.ACR_NAME }}
docker build -t ${{ vars.ACR_NAME }}.azurecr.io/app:${{ github.sha }} .
docker push ${{ vars.ACR_NAME }}.azurecr.io/app:${{ github.sha }}
- uses: azure/container-apps-deploy-action@5f5f4c56ca90376e3cfbd76ba8fe8533c784e655 # v2.0.0
with:
containerAppName: ${{ vars.APP_NAME }}
resourceGroup: ${{ vars.RESOURCE_GROUP }}
imageToDeploy: ${{ vars.ACR_NAME }}.azurecr.io/app:${{ github.sha }}
Azure Functions
- uses: azure/functions-action@fd80521afbba9a2a76a99ba1acc07f61571f14b6 # v1.5.2
with:
app-name: ${{ vars.FUNCTION_APP_NAME }}
package: .
Troubleshooting
| Issue | Solution |
|---|---|
| OIDC fails | Add permissions: id-token: write |
| GHCR push denied | Add permissions: packages: write |
| GHCR pull fails on K3s | Verify ghcr-credentials secret exists in namespace |
| SSH connection refused | Check Hetzner firewall allows port 22 from runner IP |
| kubectl timeout | Increase --timeout, check pod resources |
| K3s rollout stuck | Run kubectl rollout undo to revert |
| Secret not found | Check environment configuration |
| Action version error | Pin to full SHA |
| Cache miss | Verify cache key pattern |
| Permission denied | Review permissions block |
Guidelines
- Always pin actions to full commit SHA with version comment
- Use OIDC for Azure authentication, never store credentials
- Use SSH keys (Ed25519) for Hetzner/K3s deployment
- Build and push images to GHCR (free for public and private repos)
- Declare minimum required permissions explicitly
- Use environments for deployment protection rules
- Enable Dependabot for action updates
- Always clean up SSH keys in an
if: always()step - Use
kubectl rollout statusto verify deployments - Test workflows locally with
actwhen possible - Add inline comments explaining non-obvious configurations
Bundled Resources
This skill includes ready-to-use templates and guides:
Workflow Templates
- ci-python-uv.yml - Python CI with UV, pytest, mypy, ruff
- ghcr-build-push.yml - Build and push Docker images to GHCR
- deploy-k3s.yml - Build, push to GHCR, and deploy to K3s via SSH
- reusable-k3s-deploy.yml - Reusable K3s deployment workflow
- reusable-azure-deploy.yml - OIDC-based Azure App Service deployment
- deploy-caller.yml - Multi-environment deployment orchestration (Azure)
Guides
- Security Checklist - Comprehensive workflow security review
- Azure OIDC Setup Guide - Step-by-step passwordless authentication setup
- Hetzner K3s Deployment Guide - SSH and GHCR setup for K3s deployments
Weekly Installs
1
Repository
franciscosanche…factu-esFirst Seen
12 days ago
Security Audits
Installed on
mcpjam1
claude-code1
junie1
windsurf1
zencoder1
crush1