GitHub Actions #
GitHub Actions adalah platform CI/CD yang terintegrasi langsung dengan repository GitHub. Untuk tim yang sudah menggunakan GitHub, integrasi ini sangat alami — workflow didefinisikan sebagai file YAML di direktori .github/workflows/, dipicu oleh event Git (push, pull_request, merge), dan berjalan di runner yang disediakan GitHub atau runner self-hosted milik sendiri. Artikel ini membahas pola integrasi Ansible dengan GitHub Actions yang komprehensif dan aman, dengan fokus pada struktur workflow yang scalable untuk tim yang mengelola banyak role dan banyak environment.
Anatomi GitHub Actions untuk Ansible #
Sebelum masuk ke workflow spesifik, pahami dulu struktur dasarnya. GitHub Actions punya empat konsep yang harus kita kuasai: event (apa yang memicu workflow), job (unit kerja yang berjalan di runner sendiri), step (perintah individual dalam job), dan action (potongan kode reusable). Untuk Ansible, kebanyakan job adalah kumpulan step yang memanggil ansible-playbook atau ansible-lint.
flowchart LR
subgraph Trigger["Event"]
E1["push ke main"]
E2["pull_request"]
E3["workflow_call"]
E4["schedule cron"]
end
subgraph Job1["Job: lint"]
J1S1["actions/checkout@v4"]
J1S2["setup-python@v5"]
J1S3["install ansible + ansible-lint"]
J1S4["ansible-lint --profile production"]
J1S1 --> J1S2 --> J1S3 --> J1S4
end
subgraph Job2["Job: molecule-test"]
J2S1["matrix: common, nginx, postgresql, docker"]
J2S2["molecule test untuk setiap role"]
J1S4 --> J2S1 --> J2S2
end
subgraph Job3["Job: build-image"]
J3S1["docker/metadata-action"]
J3S2["docker/build-push-action"]
J3S3["outputs: image_tag"]
J2S2 --> J3S1 --> J3S2 --> J3S3
end
subgraph Job4["Job: deploy-staging"]
J4S1["setup Ansible"]
J4S2["setup SSH key dari secrets"]
J4S3["ansible-playbook deploy.yml"]
J4S4["smoke test staging"]
J3S3 --> J4S1 --> J4S2 --> J4S3 --> J4S4
end
subgraph Job5["Job: deploy-production"]
J5S1["approval gate"]
J5S2["setup SSH + vault"]
J5S3["ansible-playbook deploy.yml"]
J5S4["health check"]
J4S4 --> J5S1 --> J5S2 --> J5S3 --> J5S4
end
E1 --> Job1
E2 --> Job1
E3 --> Job1
E4 --> Job3
Diagram di atas menunjukkan pola yang akan kita bangun: trigger event memicu lint job, hasilnya jadi gate untuk molecule test job, lalu build, lalu deploy-staging, dan akhirnya deploy-production yang memerlukan approval. Perhatikan arah panah — setiap needs: di YAML adalah satu panah dependency. Job tanpa needs: berjalan paralel; job dengan needs: menunggu predecessor-nya selesai.
Selalu pisahkan job berdasarkan fungsi, bukan berdasarkan environment. Joblinttidak peduli di environment mana deploy akan terjadi — ia hanya validasi kode. Jobdeploy-stagingtidak peduli lint pass atau tidak — ia hanya butuh artifact. Pemisahan ini membuat workflow lebih mudah di-reuse dan di-debug.
Workflow Dasar: Lint dan Syntax Check #
Setiap pull request harus melewati validasi sebelum bisa di-merge. Workflow lint adalah gate pertama yang paling murah dijalankan — kalau ini gagal, tidak perlu lanjut ke test atau build.
# .github/workflows/validate.yml
name: Validate Ansible
on:
pull_request:
paths:
- '**.yml'
- '**.yaml'
- '**.j2'
- 'roles/**'
- 'playbooks/**'
- 'inventory/**'
jobs:
lint:
name: Ansible Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Cache pip packages
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: pip-${{ hashFiles('requirements.txt', 'requirements-dev.txt') }}
restore-keys: pip-
- name: Install Ansible dan lint
run: |
pip install ansible ansible-lint
ansible-galaxy install -r requirements.yml
- name: Syntax check semua playbook
run: |
for playbook in playbooks/*.yml; do
echo "Checking: $playbook"
ansible-playbook "$playbook" --syntax-check \
-i inventory/staging/ \
-e @tests/test-vars.yml
done
- name: Jalankan ansible-lint
run: ansible-lint --profile production
Tiga hal penting di workflow ini: filter paths memastikan workflow hanya trigger saat file Ansible berubah (tidak jalan jika kita hanya mengedit README), cache pip mempercepat iterasi (install Ansible + ansible-lint butuh 1-2 menit tanpa cache, 10-15 detik dengan cache), dan syntax check manual untuk semua playbook memastikan YAML-nya valid sebelum ansible-lint bahkan dijalankan.
Molecule Test dengan Matrix Parallel #
Molecule test untuk satu role butuh waktu 3-5 menit. Jika kita memiliki 10 role, test sequential butuh 30-50 menit. Dengan matrix, semuanya jalan paralel dan selesai dalam 5-7 menit total.
# .github/workflows/molecule.yml
name: Molecule Test
on:
pull_request:
paths:
- 'roles/**'
jobs:
molecule:
name: Test Role — ${{ matrix.role }}
runs-on: ubuntu-latest
strategy:
matrix:
role:
- common
- nginx
- postgresql
- docker
fail-fast: false # Lanjutkan test role lain meski satu gagal
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Cache pip
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: molecule-${{ matrix.role }}-${{ hashFiles('**/requirements*.txt') }}
- name: Install test dependencies
run: |
pip install ansible molecule molecule-plugins[docker] pytest-testinfra
ansible-galaxy install -r requirements.yml
- name: Jalankan Molecule test
run: |
cd roles/${{ matrix.role }}
molecule test
env:
PY_COLORS: '1'
ANSIBLE_FORCE_COLOR: '1'
MOLECULE_DISTRO: ubuntu2204
- name: Upload test results
uses: actions/upload-artifact@v4
if: failure()
with:
name: molecule-logs-${{ matrix.role }}
path: roles/${{ matrix.role }}/molecule/default/*.log
Perhatikan fail-fast: false — tanpa flag ini, kalau satu role gagal, GitHub akan cancel semua job matrix yang masih berjalan. Untuk Molecule, kita ingin tetap melihat hasil role lain supaya mengetahui apakah masalahnya menyerang seluruh sistem (semua role gagal dengan error yang sama) atau spesifik (hanya role ini yang bermasalah). Cache key yang unik per role mencegah role A menggunakan dependency cache dari role B yang mungkin sudah di-update.
Jangan lupa if: failure() di step upload-artifact. Tanpa kondisi ini, artifact akan di-upload setiap kali — membanjiri storage dan menambah waktu job. Hanya upload saat test gagal, supaya developer bisa download log tanpa harus re-run workflow untuk reproduce.
Deployment Multi-Environment dengan Approval #
Workflow deploy adalah tempat di mana semua komponen pipeline bertemu. Build job menghasilkan image, deploy-staging job menarik image itu ke staging, dan deploy-production job mem-promote-nya ke production — dengan approval manual di antara keduanya.
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
tags: ['v*.*.*']
jobs:
build:
name: Build dan Push Image
runs-on: ubuntu-latest
outputs:
image_tag: ${{ steps.tag.outputs.tag }}
steps:
- uses: actions/checkout@v4
- name: Generate tag
id: tag
run: echo "tag=${GITHUB_SHA::8}" >> $GITHUB_OUTPUT
- name: Login ke registry
uses: docker/login-action@v3
with:
registry: registry.company.com
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build dan push
uses: docker/build-push-action@v5
with:
push: true
tags: |
registry.company.com/myapp:${{ steps.tag.outputs.tag }}
registry.company.com/myapp:latest
deploy-staging:
name: Deploy ke Staging
needs: build
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v4
- name: Setup Ansible
run: pip install ansible && ansible-galaxy install -r requirements.yml
- name: Setup credentials
run: |
install -d -m 700 ~/.ssh
echo "${{ secrets.STAGING_SSH_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
echo "${{ secrets.VAULT_PASS_STAGING }}" > /tmp/.vault_pass
chmod 600 /tmp/.vault_pass
- name: Deploy ke staging
run: |
ansible-playbook -i inventory/staging/ playbooks/deploy.yml \
-e "app_version=${{ needs.build.outputs.image_tag }}" \
--vault-password-file /tmp/.vault_pass \
--private-key ~/.ssh/deploy_key
- name: Smoke test staging
run: |
sleep 15
curl -f https://staging.company.com/health
curl -f https://staging.company.com/api/version
- name: Cleanup credentials
if: always()
run: rm -f ~/.ssh/deploy_key /tmp/.vault_pass
deploy-production:
name: Deploy ke Production
needs: [build, deploy-staging]
runs-on: ubuntu-latest
environment:
name: production # Set required reviewers di GitHub Settings → Environments
url: https://app.company.com
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
steps:
- uses: actions/checkout@v4
- name: Setup Ansible
run: pip install ansible && ansible-galaxy install -r requirements.yml
- name: Setup credentials
run: |
install -d -m 700 ~/.ssh
echo "${{ secrets.PROD_SSH_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
echo "${{ secrets.VAULT_PASS_PROD }}" > /tmp/.vault_pass
chmod 600 /tmp/.vault_pass
- name: Deploy ke production
run: |
ansible-playbook -i inventory/production/ playbooks/deploy.yml \
-e "app_version=${{ needs.build.outputs.image_tag }}" \
--vault-password-file /tmp/.vault_pass \
--private-key ~/.ssh/deploy_key
- name: Verifikasi production
run: |
sleep 30
curl -f https://app.company.com/health
- name: Cleanup
if: always()
run: rm -f ~/.ssh/deploy_key /tmp/.vault_pass
Tiga hal yang sering terlewat di workflow di atas: outputs.image_tag di build job diteruskan ke deploy job lewat needs.build.outputs.image_tag — ini menjamin image yang di-deploy ke staging sama persis dengan yang di-deploy ke production. if: always() di step cleanup memastikan credential dihapus meski job gagal (kalau tidak ada kondisi ini, SSH key bisa tertinggal di runner). Environment production di-set dengan required reviewers di GitHub Settings, jadi deploy ke production selalu menunggu approval dari orang yang ditunjuk.
Approval di GitHub Environments punya dua mode: required reviewers (deploy menunggu N orang menyetujui) dan wait timer (deploy otomatis setelah X menit). Kombinasi keduanya cocok untuk skenario di mana deploy ke production ingin di-deliberate tapi tidak boleh di-block indefinitely — set wait timer 60 menit, kalau tidak ada yang approve dalam waktu itu, pipeline mati dan harus di-trigger ulang.
Trigger Strategy: Push vs PR vs Schedule #
Pemilihan trigger yang tepat menentukan feedback loop developer. Trigger yang terlalu sering membuat runner rebutan dan biaya membengkak; trigger yang terlalu jarang membuat bug ketahuan terlambat.
| Trigger | Kapan Pakai | Contoh | Catatan |
|---|---|---|---|
pull_request |
Validasi kode sebelum merge | lint, syntax check, unit test, molecule | Jalan di fork tidak punya akses ke secret; pakai pull_request_target hati-hati |
push ke branch tertentu |
Deploy otomatis per branch | push ke main → deploy staging | Hindari push ke ** — bisa trigger ribuan workflow |
push ke tag |
Release deployment | tag v*.*.* → deploy production |
Cocok untuk versioning semantik |
workflow_run |
Chain ke workflow lain | CD workflow tunggu CI selesai | Cara resmi untuk integrasi CI → CD tanpa coupling langsung |
schedule |
Pemeliharaan berkala | nightly security scan, dependency update | Pakai cron expression; jalan di commit main terakhir |
workflow_dispatch |
Trigger manual dari UI/CLI | deploy emergency, re-run dengan input | Bisa diberi input kustom untuk fleksibilitas |
repository_dispatch |
Trigger dari API eksternal | event dari monitoring tool, alert manager | Butuh konfigurasi webhook di repo settings |
flowchart TD
A{"Apakah ingin memvalidasi<br/>kode?"} -- Ya --> B["pull_request"]
A -- Tidak --> C{"Apakah ingin men-deploy<br/>secara otomatis?"}
C -- Ya --> D{"Berdasarkan branch atau tag?"}
D -- Branch --> E["push ke branch spesifik"]
D -- Tag --> F["push ke tag v*"]
C -- Tidak --> G{"Apakah membutuhkan chain<br/>dari workflow lain?"}
G -- Ya --> H["workflow_run"]
G -- Tidak --> I{"Apakah ada jadwal<br/>tertentu?"}
I -- Ya --> J["schedule cron"]
I -- Tidak --> K{"Apakah memicu dari<br/>eksternal atau UI?"}
K -- UI/CLI --> L["workflow_dispatch"]
K -- API eksternal --> M["repository_dispatch"]
Untuk Ansible, kombinasi yang biasanya paling efektif:pull_requestuntuk lint + molecule,push ke mainuntuk deploy staging, danpush ke tag v*.*.*untuk deploy production (di luar approval gate). Schedule weekly untuk dependency update scan. Tambahkanworkflow_dispatchuntuk emergency hotfix deployment yang tidak menunggu pipeline otomatis.
Reusable Workflow #
Untuk organisasi yang punya banyak repository dengan pola deployment mirip, reusable workflow adalah pengubah permainan. Tulis satu workflow, pakai di mana-mana.
# .github/workflows/reusable-deploy.yml (di shared repository)
name: Reusable Deploy
on:
workflow_call: # Bisa dipanggil dari workflow lain
inputs:
environment:
required: true
type: string
image_tag:
required: true
type: string
playbook:
required: false
type: string
default: playbooks/deploy.yml
secrets:
ssh_key:
required: true
vault_password:
required: true
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
steps:
- uses: actions/checkout@v4
- name: Deploy
run: |
pip install ansible && ansible-galaxy install -r requirements.yml
echo "${{ secrets.ssh_key }}" > /tmp/deploy_key
echo "${{ secrets.vault_password }}" > /tmp/.vault_pass
chmod 600 /tmp/deploy_key /tmp/.vault_pass
ansible-playbook -i inventory/${{ inputs.environment }}/ \
${{ inputs.playbook }} \
-e "app_version=${{ inputs.image_tag }}" \
--vault-password-file /tmp/.vault_pass \
--private-key /tmp/deploy_key
rm -f /tmp/deploy_key /tmp/.vault_pass
# Di repository aplikasi — panggil reusable workflow
jobs:
deploy-staging:
uses: company/shared-workflows/.github/workflows/reusable-deploy.yml@main
with:
environment: staging
image_tag: ${{ needs.build.outputs.tag }}
secrets:
ssh_key: ${{ secrets.STAGING_SSH_KEY }}
vault_password: ${{ secrets.VAULT_PASS_STAGING }}
Perhatikan secrets: inherit di caller — semua secret dari caller tersedia di reusable workflow sebagai input. Ini menghindari hardcoding nama secret di shared workflow (yang harus tahu nama secret tiap aplikasi). Caller cukup mapping: secret lokal STAGING_SSH_KEY → input ssh_key di shared workflow. Tag @main (atau SHA spesifik untuk production-grade) menentukan versi shared workflow yang dipakai.
Jangan pin reusable workflow ke branchmaindi repository shared kalau tim lain akan pakai. Tag SHA lebih aman:company/shared-workflows/.github/workflows/reusable-deploy.yml@a1b2c3d4. Dengan SHA, perubahan di shared workflow tidak langsung break semua caller; perlu update SHA secara eksplisit.
Self-Hosted Runner untuk Akses Private Network #
GitHub-hosted runner berjalan di infrastruktur GitHub yang tidak punya akses ke jaringan internal kita. Untuk melakukan deployment ke managed node di VPC private, kita membutuhkan self-hosted runner yang berjalan di dalam jaringan yang sama.
# roles/github-runner/tasks/main.yml
---
- name: Download GitHub Actions runner
get_url:
url: "https://github.com/actions/runner/releases/download/v{{ runner_version }}/actions-runner-linux-x64-{{ runner_version }}.tar.gz"
dest: /home/github-runner/actions-runner.tar.gz
- name: Extract runner
unarchive:
src: /home/github-runner/actions-runner.tar.gz
dest: /home/github-runner/
remote_src: true
- name: Konfigurasi runner
command: >
/home/github-runner/config.sh
--url https://github.com/{{ github_org }}
--token {{ vault_runner_token }}
--name {{ inventory_hostname }}
--labels self-hosted,production-network
--unattended
become_user: github-runner
- name: Install dan jalankan runner sebagai service
command: /home/github-runner/svc.sh install
become: true
Perhatikan label self-hosted,production-network — label ini adalah cara workflow menargetkan runner spesifik. Di workflow, tambahkan runs-on: [self-hosted, production-network] agar job hanya jalan di runner dengan label tersebut. Ini mencegah job tanpa sengaja jalan di GitHub-hosted runner (yang tidak punya akses ke network internal).
GitHub-Hosted vs Self-Hosted Runner #
| Aspek | GitHub-Hosted | Self-Hosted |
|---|---|---|
| Biaya | Gratis untuk public repo, quota menit untuk private | Hanya biaya infrastruktur sendiri (VM, network) |
| Network access | Internet only, tidak bisa ke private VPC | Akses penuh ke jaringan internal kita |
| Tools pre-installed | Lengkap (Docker, Node, Python, dsb. versi standar) | Hanya yang kita instal sendiri |
| Performance | Standar, kadang antri saat peak | Konsisten, tidak ada antrian |
| Customization | Terbatas (ada hosted runner image customization untuk Enterprise) | Penuh: OS, tool, hardware |
| Security boundary | Isolasi penuh dari infrastruktur kita | Runner kita = trusted compute; jangan jalankan job dari fork PR tanpa filter |
| Maintenance | GitHub yang maintain | Kita yang melakukan pemeliharaan (OS update, security patch) |
| Cocok untuk | public repo, testing, open source, workflow tanpa akses internal | Deploy ke on-prem, private VPC, integrasi dengan internal tool |
Anti-Pattern yang Harus Dihindari #
Tiga anti-pattern yang paling sering muncul di workflow GitHub Actions untuk Ansible:
1. Secret di Echo Langsung ke Log #
# ANTI-PATTERN: secret di-spread ke environment tanpa filter
jobs:
deploy:
steps:
- name: Setup credentials
run: |
echo "VAULT_PASS=${{ secrets.VAULT_PASS_PROD }}" >> $GITHUB_ENV
echo "SSH_KEY=${{ secrets.PROD_SSH_KEY }}" >> $GITHUB_ENV
ansible-playbook -i inventory/prod/ deploy.yml
# ANTI-PATTERN: secret disimpan di GITHUB_ENV dan otomatis
# di-mask di log... jika ditulis dengan benar.
# Tapi kalau ada step berikutnya yang echo "koneksi ke $SSH_KEY"
# atau print env, GitHub mungkin tidak bisa mask semuanya.
# BENAR: secret langsung ditulis ke file, tidak pernah di-echo
jobs:
deploy:
steps:
- name: Setup credentials
run: |
mkdir -p ~/.ssh
printf '%s' "${{ secrets.PROD_SSH_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
printf '%s' "${{ secrets.VAULT_PASS_PROD }}" > /tmp/.vault_pass
chmod 600 /tmp/.vault_pass
- name: Deploy
run: |
ansible-playbook -i inventory/prod/ deploy.yml \
--vault-password-file /tmp/.vault_pass \
--private-key ~/.ssh/deploy_key
- name: Cleanup
if: always()
run: rm -f ~/.ssh/deploy_key /tmp/.vault_pass
# BENAR: secret hanya ditulis sebagai file dengan permission 600.
# Tidak pernah di-print, tidak pernah di-set sebagai env var
# yang bisa bocor lewat step lain. Cleanup dengan if: always()
# memastikan file dihapus meski job gagal.
2. Pin Action ke Branch main
#
# ANTI-PATTERN: pakai action langsung dari branch main
jobs:
build:
steps:
- uses: actions/checkout@main # Bisa berubah sewaktu-waktu
- uses: actions/setup-python@main # Breaking change bisa muncul besok
- uses: docker/build-push-action@main # Supply chain attack bisa undetected
# BENAR: pin action ke tag atau SHA immutable
jobs:
build:
steps:
- uses: actions/checkout@v4 # Tag stabil
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # SHA
- uses: actions/setup-python@v5
- uses: docker/build-push-action@v5
# BENAR: SHA adalah gold standard untuk security-sensitive
# workflow. Tag bisa di-force-push atau di-override oleh
# maintainer; SHA adalah cryptographic commitment ke kode
# tertentu dan tidak bisa diubah tanpa GitHub noticing.
3. Workflow Tidak Bisa Di-Reuse #
# ANTI-PATTERN: copy-paste workflow di setiap repository
# File yang sama ada di 8 repository dengan perbedaan kecil
# di environment name, secret name, dan playbook path
# repo-a/.github/workflows/deploy.yml
on:
push:
branches: [main]
jobs:
deploy-prod:
runs-on: ubuntu-latest
environment: production
steps:
- run: |
echo "${{ secrets.PROD_SSH_KEY_A }}" > /tmp/key
ansible-playbook -i inventory/prod/ deploy.yml \
-e "app_version=$GITHUB_SHA" --private-key /tmp/key
rm -f /tmp/key
# BENAR: reusable workflow + caller per repo
# shared-workflows/.github/workflows/deploy-ansible.yml
on:
workflow_call:
inputs:
environment: {required: true, type: string}
image_tag: {required: true, type: string}
secrets:
ssh_key: {required: true}
vault_password: {required: true}
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
steps:
- uses: actions/checkout@v4
- run: |
echo "${{ secrets.ssh_key }}" > /tmp/key
echo "${{ secrets.vault_password }}" > /tmp/.vault_pass
chmod 600 /tmp/key /tmp/.vault_pass
ansible-playbook -i inventory/${{ inputs.environment }}/ \
deploy.yml -e "app_version=${{ inputs.image_tag }}" \
--vault-password-file /tmp/.vault_pass \
--private-key /tmp/key
rm -f /tmp/key /tmp/.vault_pass
# repo-a/.github/workflows/deploy.yml — cuma 6 baris!
jobs:
deploy:
uses: org/shared-workflows/.github/workflows/deploy-ansible.yml@v1
with:
environment: production
image_tag: ${{ needs.build.outputs.tag }}
secrets:
ssh_key: ${{ secrets.PROD_SSH_KEY_A }}
vault_password: ${{ secrets.VAULT_PASS_A }}
# BENAR: satu tempat untuk update deployment logic. Bug fix di
# shared workflow langsung bisa di-roll out ke semua caller dengan
# update tag version. Tidak perlu sinkronisasi manual 8 repository.
Ringkasan #
- Pisahkan workflow:
validate.ymluntuk PR,molecule.ymluntuk test role,deploy.ymluntuk deployment — satu workflow fokus pada satu tujuan.environmentdengan required reviewers di GitHub Settings adalah cara termudah untuk memerlukan approval sebelum deploy ke production.- Gunakan
matrixuntuk menjalankan Molecule test semua role secara paralel — jauh lebih cepat dari test satu per satu.fail-fast: falsesupaya semua role tetap di-test meski satu gagal.needs:+outputs:untuk meneruskan nilai antar job — image tag yang dihasilkan build job harus tersedia di deploy job vianeeds.build.outputs.image_tag.- Reusable workflow (
workflow_call) untuk berbagi konfigurasi deployment antar repository — satu perubahan di shared workflow langsung berlaku untuk semua aplikasi. Pin ke SHA untuk security.- Self-hosted runner untuk akses ke managed node di private network — runner berjalan di dalam jaringan yang sama dengan server target.
- Pin action ke SHA untuk security-sensitive workflow — tag bisa berubah, SHA adalah cryptographic commitment.
- Cleanup credential dengan
rm -fdanif: always()di setiap job yang menggunakan secret — pastikan tidak ada credential tertinggal meski job gagal.