Mert Özen Her Satırda Daha İleri
DevOps GitHub Actions — #09

GitHub Actions Environment'ları: Protection Rule, Approval ve Deployment Geçmişi

Mert Özen 8 Kas 2026 13 dk 2 görüntülenme
GitHub Actions Environment'ları: Protection Rule, Approval ve Deployment Geçmişi

Environment, bir job'ı runner'a göndermeden önce protection rule'ları (required reviewer, wait timer, branch/tag policy) uygulayan, secret'ları yalnızca o job'a açan ve deployment geçmişi tutan adlandırılmış hedeftir. Bookstore'un staging/production deploy workflow'u, private repository plan kısıtları, deployment: false, environment başına concurrency ve queue: max; act ve actionlint'in nerede durduğu gerçek çıktılarla.

Sürüm notu: Bu yazıdaki örnekler act 0.2.97, actionlint 1.7.12, Docker 29.2, .NET 10, actions/checkout@v7 ve actions/download-artifact@v8 ile denenmiştir. Plan kısıtları ve yeni özellikler Kasım 2026 itibarıyla GitHub dokümantasyonundan doğrulanmıştır.

Önce Tahmin Et

Bookstore'un deploy workflow'unda staging job'ı concurrency: deploy-staging grubunda çalışıyor, cancel-in-progress kapalı. Takım arkadaşın üç dakika içinde main'e arka arkaya üç commit push'luyor: A, B ve C. A'nın deploy'u sürerken B ve C sıraya giriyor. Sonunda staging'e kaç deploy gider: üç mü, iki mi, bir mi?

İkinci soru: production job'ında environment adını yanlış yazmışsın, environment: prodcution. Repository'de böyle bir environment yok. Workflow hata mı verir, yoksa production'ın required reviewer kuralını atlayarak deploy mu eder?

Environment Nedir: Bir İsim, Kurallar ve Secret'lar

Önceki yazılarda CI (Continuous Integration) tarafını kurduk: build, test, artifact. Şimdi o artifact'ı bir yere götürmemiz gerekiyor ve "bir yer" tek değil. Bookstore.Api önce staging'e, sonra production'a gidiyor. İkisinin adresi farklı, credential'ları farklı, kimin deploy edebileceği farklı. GitHub Actions'ta bu farkları taşıyan kavram environment: repository ayarlarında tanımlanan, adı olan bir hedef. Bir job environment: ile bu hedefe bağlandığında üç şey olur: job runner'a gönderilmeden önce environment'ın protection rule'ları değerlendirilir, environment'a ait secret ve variable'lar yalnızca bu job'a açılır ve GitHub bir deployment kaydı oluşturup environment'ın geçmişine yazar.

Seride ortak kullandığımız deploy workflow'u, 07'deki api-publish artifact'ını iki environment'a taşıyor:

name: deploy

on:
  push:
    branches: [main]
    tags: ['v*']
  workflow_dispatch:

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-dotnet@v6
        with:
          global-json-file: global.json
      - run: dotnet publish api/src/Bookstore.Api -c Release -o out/api
      - uses: actions/upload-artifact@v7
        with:
          name: api-publish
          path: out/api

  deploy-staging:
    needs: build
    runs-on: ubuntu-latest
    environment:
      name: staging
      url: ${{ vars.APP_URL }}
    concurrency:
      group: deploy-staging
      cancel-in-progress: false
    steps:
      - uses: actions/checkout@v7
      - uses: actions/download-artifact@v8
        with:
          name: api-publish
          path: out/api
      - name: Deploy
        env:
          APP_URL: ${{ vars.APP_URL }}
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
        run: ./deploy/deploy.sh staging out/api

  deploy-production:
    needs: deploy-staging
    if: startsWith(github.ref, 'refs/tags/v')
    runs-on: ubuntu-latest
    environment:
      name: production
      url: ${{ vars.APP_URL }}
    concurrency:
      group: deploy-production
      cancel-in-progress: false
    steps:
      - uses: actions/checkout@v7
      - uses: actions/download-artifact@v8
        with:
          name: api-publish
          path: out/api
      - name: Deploy
        env:
          APP_URL: ${{ vars.APP_URL }}
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
        run: ./deploy/deploy.sh production out/api

Dikkat et: iki deploy job'ı aynı secret adını (DEPLOY_TOKEN) ve aynı variable adını (APP_URL) kullanıyor. Değerler environment'tan geliyor; staging job'ı staging'in token'ını, production job'ı production'ın token'ını görüyor. YAML'de (YAML Ain't Markup Language) if: env == 'prod' gibi dallanmalar yok. "Aynı paket, farklı configuration" ilkesi burada da geçerli: artifact bir kez üretiliyor, iki kez deploy ediliyor. Deploy script'i bu yazıda bilerek yer tutucu; gerçek hedefleri sonraki iki yazıda Azure App Service ve kendi Linux sunucumuz olarak kuracağız:

#!/usr/bin/env bash
# Yer tutucu deploy: gerçek hedefler 10. ve 11. yazıda
set -euo pipefail
environment="$1"
package_dir="$2"
: "${DEPLOY_TOKEN:?DEPLOY_TOKEN is empty for $environment}"
files=$(find "$package_dir" -type f | wc -l | tr -d ' ')
echo "Deploying $files files to $environment at $APP_URL"
echo "Token length: ${#DEPLOY_TOKEN}"

${DEPLOY_TOKEN:?...} satırı küçük ama değerli bir sigorta: secret boşsa script anlamlı bir mesajla düşer. Environment secret'ı tanımlanmamış bir job'da ${{ secrets.DEPLOY_TOKEN }} hata vermez, boş string döner; bu kontrol olmadan deploy aracı token'sız çağrılıp çok daha belirsiz bir hata üretir.

act ile Yerelde Koşmak ve Nerede Durduğu

Workflow'u önce actionlint'ten geçirdim (hata yok), sonra act ile main'e push simülasyonu yaptım. act, environment'ı tanır ama protection rule, approval ve deployment kaydı onun dünyasında yok; secret ve variable'ları da komut satırından tek bir havuz olarak alır:

act push -W .github/workflows/deploy.yml \
  --var APP_URL=https://staging.bookstore.example \
  --secret DEPLOY_TOKEN=stg-0123456789

[deploy/build]   ✅  Success - Main actions/upload-artifact@v4 [1.102363958s]
[deploy/build] 🏁  Job succeeded
[deploy/deploy-staging]   ✅  Success - Main actions/download-artifact@v4 [365.754917ms]
[deploy/deploy-staging]   | Deploying 13 files to staging at https://staging.bookstore.example
[deploy/deploy-staging]   | Token length: 14
[deploy/deploy-staging]   ✅  Success - Main Deploy [65.118958ms]
[deploy/deploy-staging] 🏁  Job succeeded

Çıktıda artifact action'larının @v4 göründüğünü fark etmişsindir. act 0.2.97'nin yerleşik artifact server'ı upload-artifact@v7 ve download-artifact@v8'in yeni isteklerini henüz tanımıyor (v7, CreateArtifact isteğine mime_type alanı ekledi ve server unknown field "mime_type" diye reddediyor; act deposunda açık issue #6022). Yerel koşularda bu iki satırı v4'e çeviren bir kopya üzerinde çalıştım; workflow dosyasının kendisi v7/v8'de kalıyor. main push'unda production job'ı if: koşulu yüzünden hiç başlamadı.

Şimdi tag push'unu deneyelim. act'e ref alanı refs/tags/v1.4.0 olan bir event dosyası veriyorum ve bu kez production değerlerini geçiyorum:

act push -W .github/workflows/deploy.yml -e tag-event.json \
  --var APP_URL=https://bookstore.example \
  --secret DEPLOY_TOKEN=prod-abcdefghijklmnop

[deploy/deploy-staging]   | Deploying 13 files to staging at https://bookstore.example
[deploy/deploy-staging]   | Token length: 21
[deploy/deploy-staging] 🏁  Job succeeded
[deploy/deploy-production]   | Deploying 13 files to production at https://bookstore.example
[deploy/deploy-production]   | Token length: 21
[deploy/deploy-production] 🏁  Job succeeded

İlk satıra bak: staging job'ı production adresini ve production token'ını aldı. act'te environment kapsamı olmadığı için iki job aynı havuzdan beslendi. GitHub'da bu olmaz; ama bu çıktı environment'ın asıl değerini gösteriyor: aynı isimli secret'ın hangi değere çözüleceğine workflow değil, job'ın bağlandığı environment karar veriyor. Bu yazıdaki protection rule, approval ve deployment geçmişi davranışları act ile koşulamadığı için GitHub dokümantasyonuna dayanıyor ve workflow'lar GitHub'da koşulmadı.

Protection Rule'lar: Required Reviewer, Wait Timer ve Branch Policy

Environment'ın asıl gücü, job'ı runner'a göndermeden önce uygulanan kurallarda. Dokümantasyon bunu açıkça söylüyor: environment'a referans veren bir job, bütün deployment protection rule'ları geçmeden runner'a gönderilmez. Yani approval bekleyen bir job runner dakikası harcamaz.

Required reviewer: en fazla altı kullanıcı veya team seçersin, birinin onayı yeter. "Prevent self-review" işaretlenirse deploy'u tetikleyen kişi kendi deploy'unu onaylayamaz; production için bunu açmamak, kuralı tek kişilik bir formaliteye çevirir. Onay bekleyen run'da reviewer, Actions sekmesinde "Review deployments" ile onaylar veya reddeder.

Wait timer: job tetiklendikten sonra 1 ile 43.200 dakika (30 gün) arası bekleme. Bekleme süresi faturalanan süreye sayılmaz. Staging'e deploy'dan sonra production'ı 15 dakika bekletmek, staging'deki alarmları görmek için ucuz bir tampon.

Deployment branches and tags: üç seçenek var. "No restriction", "Protected branches only" ve "Selected branches and tags". Sonuncusunda branch ve tag için ayrı ayrı isim pattern'leri tanımlarsın; pattern'ler Ruby'nin File.fnmatch kurallarıyla eşleşir ve * karakteri /'yi geçmez. Bookstore'da production yalnızca v* tag'lerinden deploy edilebiliyor. Workflow'daki if: koşulu bir kolaylık; asıl güvence bu policy, çünkü YAML'i değiştiren biri if:'i silebilir ama environment ayarını yalnızca repository admin'i değiştirebilir.

Admin bypass: varsayılan olarak repository admin'leri kuralları atlayıp deploy'u zorlayabilir. "Allow administrators to bypass configured protection rules" kutusunu kaldırırsan admin de beklemek zorunda kalır.

Bu ayarları ekrandan tıklamak yerine kodla yönetmek istersen REST (Representational State Transfer) API'si (Application Programming Interface) var. Production için tek çağrı:

curl -L -X PUT \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  https://api.github.com/repos/OWNER/bookstore/environments/production \
  -d '{
  "wait_timer": 15,
  "prevent_self_review": true,
  "reviewers": [{ "type": "Team", "id": 1234567 }],
  "deployment_branch_policy": { "protected_branches": false, "custom_branch_policies": true }
}'

curl -L -X POST \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  https://api.github.com/repos/OWNER/bookstore/environments/production/deployment-branch-policies \
  -d '{ "name": "v*", "type": "tag" }'

Bu çağrılar dokümantasyondaki body şemasına göre yazıldı, token kullanmamak için çalıştırılmadı. protected_branches ile custom_branch_policies birbirinin tersi olmak zorunda; ikisini aynı değere ayarlarsan 422 döner. Branch policy eklemek için de önce custom_branch_policies: true gerekiyor, yoksa 404 alırsın.

Plan Kısıtları: Private Repository'de Ne Var, Ne Yok

Environment'ları denemeye başlamadan bunu bilmek gerekiyor, çünkü ayar ekranında bazı seçenekler görünmeyebilir:

ÖzellikPublic repositoryPrivate repository
Environment tanımlamakTüm planlarPro, Team, Enterprise
Deployment branch ve tag policyTüm planlarPro, Team, Enterprise
Environment variable'larıTüm planlarPro, Team, Enterprise
Environment secret'larıTüm planlarPro, Team, Enterprise (Free'de yok)
Required reviewer, wait timer, admin bypassTüm planlarYalnızca Enterprise
Custom protection ruleTüm planlarYalnızca Enterprise

En çok şaşırtan satır beşincisi: Team planındaki bir şirketin private repository'sinde production için required reviewer tanımlayamazsın. Bu durumda elindeki araçlar branch/tag policy ve ruleset'lerle korunan branch'ler (Git & GitHub serisinin Rulesets yazısı). Bir repository public'ten private'a çevrilirse tanımlı protection rule'lar ve environment secret'ları yok sayılır; tekrar public yapınca geri gelir.

Environment Secret'ları, Variable'ları ve Öncelik

08. yazıda secret'ları repository ve organization seviyesinde gördük. Environment üçüncü ve en dar seviye. Aynı isim birden fazla seviyede varsa en dar olan kazanır: environment, repository'yi; repository, organization'ı ezer. Bookstore'da APP_URL ve DEPLOY_TOKEN yalnızca environment seviyesinde tanımlı, böylece "yanlış seviyeden gelen değer" ihtimali kalmıyor.

İki ayrıntı önemli. Birincisi, environment secret'ları yalnızca o environment'a referans veren job'a açılır ve approval gerekiyorsa onay gelene kadar job onları göremez. Production token'ını bir PR (pull request) workflow'u ya da test job'ı hiçbir yoldan okuyamaz. İkincisi, self-hosted runner kullanıyorsan environment secret'ı ayrı bir izolasyon sağlamaz; runner makinesine erişen biri için repository secret'ından farkı yok. Variable'lar vars context'iyle okunur ve secret değil, düz configuration'dır; log'da maskelenmez.

Deployment Geçmişi ve Environment URL'i

Environment kullanan her job bir deployment kaydı oluşturur. Repository ana sayfasındaki "Deployments" bölümü ve /deployments sayfası bu kayıtlardan beslenir: hangi commit, hangi environment'a, kim tarafından, ne zaman gitti ve şu an hangisi aktif. Production'da bir şey bozulduğunda "en son hangi SHA (Secure Hash Algorithm) gitti?" sorusunun cevabı tek tıkta.

url: alanı bu kaydın environment_url değeri olur ve run özetinde, PR'daki deployment kutusunda tıklanabilir link olarak görünür. Değer expression olabilir; deploy adımı gerçek adresi üretiyorsa (ör. her PR için ayrı preview ortamı) step output'undan okunur: url: ${{ steps.deploy.outputs.app_url }}. Environment'ın name: alanı da expression kabul eder ama daha az context ile: yalnızca github, inputs, vars, needs, strategy ve matrix. workflow_dispatch input'undan environment seçmek bu yüzden mümkün.

deployment: false ile Deploy Etmeden Environment Kullanmak

Her environment kullanımı deploy değil. Bookstore'da her sabah production'ın health endpoint'ini production'a ait bir token ile kontrol eden bir job var. Bu job production secret'ına ihtiyaç duyuyor, ama her çalışması deployment geçmişine "production'a deploy edildi" diye düşerse geçmiş anlamsızlaşır. Mart 2026'da gelen deployment: false tam bunun için:

name: prod-smoke

on:
  schedule:
    - cron: '0 6 * * *'
      timezone: "Europe/Istanbul"
  workflow_dispatch:

permissions:
  contents: read

jobs:
  smoke:
    runs-on: ubuntu-latest
    environment:
      name: production
      deployment: false
    steps:
      - name: Check health
        env:
          APP_URL: ${{ vars.APP_URL }}
          SMOKE_TOKEN: ${{ secrets.SMOKE_TOKEN }}
        run: |
          curl --fail --silent --show-error \
            -H "Authorization: Bearer $SMOKE_TOKEN" "$APP_URL/health"

Job production'ın secret ve variable'larını kullanıyor, ama deployment kaydı oluşmuyor. Aynı changelog'la gelen timezone sayesinde cron artık UTC (Coordinated Universal Time) yerine IANA (Internet Assigned Numbers Authority) saat dilimi adıyla yazılabiliyor; job İstanbul saatiyle 06:00'da koşuyor.

Kısıt: deployment: false, custom deployment protection rule ile birlikte kullanılamaz; custom rule'lar bir deployment nesnesi bekliyor. Dokümantasyonun andığı tek istisna bu; environment'a referans veren job'ın runner'a gitmeden önce bütün protection rule'ları geçmesi gerektiği kuralı duruyor. Yani production'da reviewer zorunluysa sabah smoke test'i de onay bekleyecektir. Bu durumda smoke test için ayrı, reviewer'sız bir production-readonly environment'ı ve ona özel, yalnızca okuma yetkili bir token daha temiz bir çözüm.

Araçlar bu yeniliğe aynı hızda yetişmedi. actionlint 1.7.12 dosyayı sorunsuz kabul ediyor, act 0.2.97 ise şemasında bu alanı tanımadığı için workflow'u hiç başlatmıyor:

Error: workflow is not valid. 'prod-smoke.yml': Line: 14 Column 5: Failed to match job-factory: Line: 16 Column 7: Failed to match string: Line: 16 Column 7: Expected a scalar got mapping
Line: 16 Column 7: Failed to match job-environment-mapping: Line: 17 Column 7: Unknown Property deployment

Yani bu workflow yalnızca actionlint ile doğrulandı. Yerelde denemek istersen deployment: false satırını geçici olarak çıkarman gerekiyor.

Üst Üste Deploy'u Engellemek: Environment Başına Concurrency

Tahminin ilk sorusuna gelelim. concurrency grubunda aynı anda tek bir run çalışır ve varsayılan olarak tek bir run bekleyebilir. Yeni gelen run, bekleyen run'ı iptal edip onun yerine geçer. A çalışırken B sıraya girer; C gelince B iptal edilir, C bekler. Staging'e iki deploy gider: A ve C. B'nin kodu C'nin içinde olduğu için çoğu zaman sorun değil, hatta istenen davranış: staging'e ara commit'leri tek tek göndermenin anlamı yok.

cancel-in-progress: true olsaydı C geldiğinde A da yarıda kesilirdi. Build ve test için iyi bir varsayılan, deploy için tehlikeli: yarıda kalan bir migration veya dosya kopyalaması environment'ı tutarsız bırakabilir. Deploy job'larında cancel-in-progress: false yaz, varsayılanı bile olsa açık yaz; dosyayı okuyan kişi kararın bilinçli olduğunu görsün.

Grup adını environment adına bağlamak da önemli. Workflow seviyesinde ${{ github.workflow }}-${{ github.ref }} gibi bir grup, farklı branch'lerden gelen iki deploy'un aynı staging'e paralel gitmesini engellemez. Grup "hedef" üzerinden tanımlanmalı: deploy-staging, deploy-production. Grup adları büyük/küçük harf duyarsız; Prod ile prod aynı gruptur.

Peki her deploy'un sırayla gitmesi gerekiyorsa? Örneğin her tag'in production'da kaydı olsun istiyorsun. Mayıs 2026'dan beri queue: max ile bir grupta 100'e kadar run bekleyebiliyor; kuyruk dolarsa fazlası iptal ediliyor ve cancel-in-progress: true ile birlikte kullanılamıyor. actionlint 1.7.12 bu alanı henüz tanımıyor:

    concurrency:
      group: deploy-production
      queue: max

.github/workflows/deploy.yml:56:7: unexpected key "queue" for "concurrency" section. expected one of "cancel-in-progress", "group" [syntax-check]

act de aynı alanı Unknown Property queue diye reddediyor. Kullanacaksan lint adımında bu kuralı bilinçli olarak atlaman ya da actionlint'in yeni sürümünü beklemen gerekecek.

Üretimde Dikkat

Production environment'ını workflow'dan önce oluştur. Workflow var olmayan bir environment'a referans verirse GitHub onu kuralsız ve secret'sız olarak kendisi oluşturur. Tahminin ikinci sorusunun cevabı bu: prodcution adında yeni, korumasız bir environment doğar ve job beklemeden koşar. Secret'lar boş geleceği için deploy büyük ihtimalle token hatasıyla düşer; ama bu senin şansın, güvencen değil. Environment adlarını PR review'da kontrol et ve deploy script'lerinde boş secret'ı açıkça yakala.

Reviewer'ı kişi değil team olarak ekle. Tek kişi tatildeyken production kilitlenmesin. Altı reviewer sınırı team'leri tek kayıt sayar.

Wait timer'ı alarm süresine göre seç. Staging'in hata oranı metriği beş dakikalık pencereyle hesaplanıyorsa beş dakikalık wait timer hiçbir şey görmez.

Deployment geçmişini rollback aracı olarak kullan. Production'daki son başarılı deployment'ın SHA'sı, geri dönüş için yeniden çalıştıracağın run'ı gösterir. Rollback stratejisinin kendisi ilerideki Deployment Stratejileri serisinin konusu.

Ortamı silmeden önce iki kez düşün. Environment silindiğinde secret'ları ve protection rule'ları da silinir; onu bekleyen job'lar otomatik olarak fail olur.

Sık Düşülen Tuzaklar

Workflow'daki if: koşulunu güvenlik sanmak. Production'ı yalnızca tag'den deploy etmenin güvencesi environment'ın deployment tag policy'si; if: yalnızca gürültüyü azaltır.

Private repository'de Team planıyla required reviewer aramak. Bu ayar private repository'de yalnızca Enterprise'ta var; ekranda göremiyorsan eksik olan izin değil, plan.

Header'ı tek satırlık run:'a yazmak. İlk denememde smoke test'i run: curl ... -H "Authorization: Bearer $SMOKE_TOKEN" ... diye tek satır yazmıştım; actionlint could not parse as YAML: mapping values are not allowed in this context dedi. Tırnaklanmamış bir YAML değerinde : dizisi yeni bir key başlatır. Çözüm run: | ile blok yazmak.

Secret'ı if: içinde kontrol etmeye çalışmak. if: secrets.DEPLOY_TOKEN != '' çalışmaz; secret'ı step'in env:'ine al ve script içinde kontrol et.

Deploy job'ında cancel-in-progress: true. Build için doğru olan, deploy için yarım kalmış bir environment demek.

act çıktısını environment davranışının kanıtı saymak. act'te staging job'ı production secret'ını rahatça okudu; yerel koşu, kapsam ve approval davranışı hakkında hiçbir şey kanıtlamaz.

Küçük Bir Deneme

★☆☆ deploy-staging job'ının url: alanını, deploy step'inin $GITHUB_OUTPUT'a yazdığı bir app_url output'undan okuyacak şekilde değiştir. act ile koşup run özetinde değerin geldiğini doğrula.

★★☆ workflow_dispatch'e type: environment tipinde bir target input'u ekle ve tek bir deploy job'ını environment: ${{ inputs.target }} ile yaz. Hangi context'lerin name: içinde kullanılabildiğini actionlint'e sorarak gör: secrets denediğinde ne diyor?

★★★ Kendi public test repository'nde production environment'ını REST API ile oluştur: prevent self-review açık, tek reviewer, v* tag policy. Ardından main'den manuel bir production deploy'u tetikle ve run'ın hangi mesajla reddedildiğini not al.

Environment'lar "kim, nereye, ne zaman" sorusunu çözdü; ama DEPLOY_TOKEN hâlâ bir yerde saklanan uzun ömürlü bir secret. Bir sonraki yazıda bu secret'ı tamamen ortadan kaldırıyoruz: OIDC (OpenID Connect) ile Azure'a client secret'sız giriş yapıp Bookstore'u App Service'e, staging slot'u ve swap ile deploy edeceğiz.