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

GitHub Actions'ta Expression, Context ve Variable'lar: if, outputs ve GITHUB_ENV

Mert Özen 8 Kas 2026 11 dk 4 görüntülenme
GitHub Actions'ta Expression, Context ve Variable'lar: if, outputs ve GITHUB_ENV

${{ }} içindeki expression'lar context'lerden (github, env, vars, steps, needs, inputs…) değer okur, loose karşılaştırma yapar ve if: koşullarını belirler. if içinde ${{ }} dışında metin bırakmanın neden her zaman doğru sayıldığını, contains/fromJSON/hashFiles/case fonksiyonlarını, GITHUB_OUTPUT ve GITHUB_ENV ile veri taşımayı, GITHUB_STEP_SUMMARY ile test tablosunu ve always ile !cancelled farkını act çıktılarıyla gösteriyoruz.

Sürüm notu: Bu yazıdaki örnekler act 0.2.97, actionlint 1.7.12, Docker 29.2, .NET 10 (SDK 10.0.403), xUnit v3 4.0 ve actions/setup-dotnet@v6 ile denenmiştir. Workflow'lar act ile yerelde koşuldu; GitHub'da koşulmadı.

Önce Tahmin Et

Bookstore'un CI'ında (Continuous Integration) bir "Publish" step'i var ve yalnız main'de koşması gerekiyor. Alan koşulu şöyle yazmış:

jobs:
  guess:
    runs-on: ubuntu-latest
    steps:
      - name: Publish (broken condition)
        if: ${{ github.ref }} == 'refs/heads/main'
        run: echo "publishing from ${{ github.ref }}"

      - name: Publish (correct condition)
        if: github.ref == 'refs/heads/main'
        run: echo "publishing from ${{ github.ref }}"

Ada feature/coupon-codes branch'inden bir pull request açıyor. Hangi step koşar: biri, ikisi, hiçbiri?

$ act pull_request -e pr-opened.json
[expressions/guess] ⭐ Run Main Publish (broken condition)
[expressions/guess]   | publishing from refs/pull/42/merge
[expressions/guess]   ✅  Success - Main Publish (broken condition) [72.855417ms]
[expressions/guess] 🏁  Job succeeded

"Bozuk" olan koştu, doğru olan atlandı. İlk koşulda ${{ github.ref }} kısmı hesaplanıp yerine yazılıyor ve geriye refs/pull/42/merge == 'refs/heads/main' diye bir string kalıyor. Bu artık bir karşılaştırma değil, boş olmayan bir metin; boş olmayan string de doğru sayılıyor. actionlint bunu tek satırda söylüyor:

.github/workflows/expressions.yml:12:13: if: condition "${{ github.ref }} == 'refs/heads/main'" is always evaluated to true because extra characters are around ${{ }} [if-cond]

29 Ocak 2026 changelog'una göre GitHub da artık bu durumu Visual Studio Code (VS Code) eklentisinde ve web editöründe işaretliyor, run'a da bir annotation ekliyor. Yine de en ucuz yer push'tan önce: ya bütün ifadeyi ${{ }} içine al, ya da if:'te hiç kullanma. Bu yazıda bu syntax'ın arkasındaki dili açıyoruz: literal'lar, operatörler, context'ler, fonksiyonlar ve step'ler arasında veri taşımanın yolları.

${{ }} Syntax'ı ve Tip Dönüşümleri

${{ }} içindeki her şey GitHub'ın expression dilidir; ilgili key kullanılmadan önce (step'te, step çalışmadan hemen önce) hesaplanır ve sonucu YAML'daki (YAML Ain't Markup Language) değerin yerine yazılır. Literal tipleri boolean, null, number ve string. String'ler tek tırnakla yazılır; çift tırnak hata verir, tek tırnağı kaçırmak için iki tane yazarsın ('It''s'). Koşullarda false, 0, -0, "", '' ve null yanlış, geri kalan her şey doğru sayılır. Tahmin bölümündeki hatanın kökü bu cümle.

Karşılaştırmalar "loose": tipler farklıysa GitHub iki tarafı da sayıya çevirmeye çalışır. Ayrıca string karşılaştırmaları büyük/küçük harfe duyarsız. Dokümantasyondaki kuralları act'te denedik:

echo "equality   : ${{ 'Main' == 'main' }} ${{ '1' == 1 }} ${{ '' == 0 }} ${{ 'abc' == 0 }}"
[expressions/version]   | equality   : true true true false

'Main' == 'main' doğru, çünkü harf duyarlılığı yok. '1' == 1 doğru, çünkü '1' sayıya çevrilince 1. Boş string sayıya çevrilince 0 olduğu için '' == 0 da doğru. 'abc' ise NaN'a (Not a Number) dönüşüyor ve hiçbir şeye eşit değil. Bunun pratik sonucu şu: bir output'u '0' ile karşılaştırıyorsan ve output boş geldiyse karşılaştırma yine doğru çıkar.

if: içinde ${{ }} yazmak isteğe bağlı, çünkü GitHub if:'i her zaman expression olarak değerlendirir. Bir istisna var: ifade ! ile başlıyorsa ${{ }} zorunlu, çünkü ! YAML'da özel bir karakter. if: ${{ !cancelled() }} bu yüzden böyle yazılıyor.

Context'ler: github, env, vars, secrets, job, steps, runner, matrix, needs, inputs

Expression'lar değerlerini context'lerden okur. Bu seride en çok kullanacaklarımız:

ContextNe taşırÖrnek
githubEvent, repository, ref, SHA (Secure Hash Algorithm), run numarasıgithub.ref, github.event_name, github.run_number
envWorkflow, job ve step seviyesinde tanımlı environment variable'larenv.BOOKSTORE_VERSION
varsRepository, organization ve environment seviyesindeki configuration variable'larıvars.API_BASE_URL
secretsSecret'lar ve GITHUB_TOKENsecrets.GITHUB_TOKEN
jobÇalışan job'ın durumu, service container'larıjob.status, job.services.postgres.ports
stepsAynı job'daki önceki step'lerin output'u ve sonucusteps.tests.outcome
runnerJob'ı çalıştıran makinerunner.os, runner.arch, runner.temp
matrixMatrix job'ında bu kombinasyonun değerlerimatrix.dotnet
needsBağımlı olunan job'ların output'u ve sonucuneeds.version.outputs.version
inputsworkflow_dispatch ve workflow_call input'larıinputs.dry-run

Her context her yerde kullanılamaz. Örneğin runner ancak job bir makineye atandıktan sonra vardır, bu yüzden job seviyesindeki if:'te kullanılamaz; steps yalnız aynı job içinde anlamlıdır. Dokümantasyonda her workflow key'i için hangi context'lerin geçerli olduğunu gösteren bir tablo var, actionlint de yanlış yerde kullanılan context'i yakalıyor. vars ve secrets arasındaki fark ve secret'ların neden if:'te doğrudan kullanılamadığı sekizinci yazının konusu.

Fonksiyonlar: contains, startsWith, format, fromJSON, hashFiles ve case

Expression dilinde küçük ama işe yarar bir fonksiyon seti var. act'te çalıştırdığımız step:

      - name: Functions
        run: |
          echo "startsWith : ${{ startsWith(github.ref, 'refs/heads/') }}"
          echo "contains   : ${{ contains(fromJSON('["push", "pull_request"]'), github.event_name) }}"
          echo "format     : ${{ format('bookstore-{0}-{1}', runner.os, runner.arch) }}"
          echo "equality   : ${{ 'Main' == 'main' }} ${{ '1' == 1 }} ${{ '' == 0 }} ${{ 'abc' == 0 }}"
[expressions/version]   | startsWith : true
[expressions/version]   | contains   : true
[expressions/version]   | format     : bookstore-Linux-ARM64
[expressions/version]   | equality   : true true true false

startsWith, endsWith ve contains büyük/küçük harfe duyarsız ve argümanlarını string'e çeviriyor. contains(fromJSON('[...]'), x) kalıbı uzun || zincirlerinin yerine geçiyor. fromJSON string'i gerçek bir değere çeviriyor: bir job output'undan matrix üretmek ya da 'true' string'ini boolean'a, '30''u sayıya çevirmek için kullanılır. format, {0} gibi yer tutucularla string kurar. runner.arch burada ARM64 çıktı, çünkü act'i Apple Silicon bir makinede arm64 container'la çalıştırdık; GitHub'ın standart ubuntu-latest'inde X64 görürsün.

hashFiles verilen glob'a uyan dosyaların SHA-256 özetini döner ve cache key'lerinin temel taşıdır: hashFiles('api/**/packages.lock.json') lock dosyaları değişmedikçe aynı kalır. Cache'i .NET CI yazısında kullanacağız.

29 Ocak 2026'da dile case(pred1, val1, pred2, val2, ..., default) eklendi; sırayla koşulları dener ve ilk doğru olanın değerini, hiçbiri tutmazsa son argümanı döner: ${{ case(github.ref == 'refs/heads/main', 'production', 'development') }}. Eskiden bunun için cond && 'a' || 'b' hilesi kullanılırdı ve 'a' boş string olduğunda yanlış sonuç verirdi. Bir uyarı: actionlint 1.7.12 case'i tanıyor ama act 0.2.97 henüz tanımıyor. Aynı workflow'u act'e verdiğimizde şu hatayla durdu:

Error: workflow is not valid. 'expressions.yml': Line: 9 Column 5: Failed to match job-factory: Line: 26 Column 9: Failed to match run-step: Line: 27 Column 14: Unknown Function Call case

Bu yüzden act ile denediğimiz sürümden case satırını çıkardık; GitHub'da çalıştırmadığımız için orada da denenmedi, davranışı dokümantasyondan aktarıyoruz.

Step'ler Arasında Veri Taşımak: GITHUB_OUTPUT ve GITHUB_ENV

Bir step'in ürettiği değeri sonraki step'lere ve başka job'lara taşımak için runner iki dosya veriyor:

  version:
    runs-on: ubuntu-latest
    outputs:
      version: ${{ steps.ver.outputs.version }}
    steps:
      - name: Compute version
        id: ver
        run: |
          version="1.4.${{ github.run_number }}"
          echo "version=$version" >> "$GITHUB_OUTPUT"
          echo "BOOKSTORE_VERSION=$version" >> "$GITHUB_ENV"

      - name: Read it back
        run: |
          echo "step output : ${{ steps.ver.outputs.version }}"
          echo "env variable: $BOOKSTORE_VERSION"
          echo "env context : ${{ env.BOOKSTORE_VERSION }}"
[expressions/version] ⭐ Run Main Compute version
[expressions/version]   ⚙  ::set-env:: BOOKSTORE_VERSION=1.4.1
[expressions/version]   ⚙  ::set-output:: version=1.4.1
[expressions/version] ⭐ Run Main Read it back
[expressions/version]   | step output : 1.4.1
[expressions/version]   | env variable: 1.4.1
[expressions/version]   | env context : 1.4.1

$GITHUB_OUTPUT'a yazılan name=value satırı step'in output'u olur ve steps.<id>.outputs.<name> ile okunur; bunun için step'e id vermek şart. Job'ın outputs: bölümü bu değeri job dışına açar, böylece needs.version.outputs.version ile başka job'lar da okuyabilir. Dokümantasyona göre job başına output toplamı en fazla 1 MB, bir workflow run'daki tüm output'lar en fazla 50 MB. Output'lar her zaman string'dir.

$GITHUB_ENV'e yazılan değişken ise job'ın sonraki tüm step'lerinde normal bir environment variable olarak görünür, env context'inden de okunabilir. Önemli ayrıntı: yazan step'in kendisi yeni değeri görmez, yalnız sonrakiler görür. GITHUB_* ve RUNNER_* ile başlayan varsayılan değişkenlerin üzerine yazamazsın. Çok satırlı değer gerekiyorsa name<<EOF ... EOF syntax'ı var; dokümantasyonun uyarısıyla, delimiter değerin içinde tek başına bir satır olarak geçmemeli.

Eski workflow'larda echo "::set-output name=version::1.4.1" görürsen, bu artık deprecated olan eski yol; act log'undaki ::set-output:: satırı ise act'in kendi gösterimi. Eski workflow'ları güncellemeyi serinin son yazısında konuşacağız.

Job Summary: GITHUB_STEP_SUMMARY ile Test Sonucu Tablosu

Test sonuçlarını yüzlerce satırlık log'un içinde aramak yerine run'ın özet sayfasında bir tablo olarak görmek mümkün. Her step'e ayrı bir GITHUB_STEP_SUMMARY dosyası verilir; içine yazdığın GitHub Flavored Markdown, job bitince run'ın özet sayfasında birleştirilip gösterilir. xUnit v3'e TRX (Visual Studio Test Results) raporu ürettirip onu Markdown'a çeviren küçük bir script yazdık:

# Bir veya daha fazla TRX dosyasından Markdown özet tablosu yazar
import glob
import sys
import xml.etree.ElementTree as ET

ns = {"t": "http://microsoft.com/schemas/VisualStudio/TeamTest/2010"}
print("| Project | Total | Passed | Failed | Skipped |")
print("|---|---:|---:|---:|---:|")
for path in sorted(glob.glob(sys.argv[1], recursive=True)):
    root = ET.parse(path).getroot()
    c = root.find("t:ResultSummary/t:Counters", ns).attrib
    name = root.find("t:TestDefinitions/t:UnitTest", ns).attrib["storage"].split("/")[-1]
    skipped = int(c["total"]) - int(c["executed"])
    print(f"| {name} | {c['total']} | {c['passed']} | {c['failed']} | {skipped} |")
for path in sorted(glob.glob(sys.argv[1], recursive=True)):
    root = ET.parse(path).getroot()
    for r in root.findall("t:Results/t:UnitTestResult[@outcome='Failed']", ns):
        print(f"\n:x: `{r.attrib['testName']}`")
  test:
    needs: version
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7

      - uses: actions/setup-dotnet@v6
        with:
          global-json-file: global.json

      - name: Unit tests
        id: tests
        run: >
          dotnet test --project api/tests/Bookstore.Api.Tests -c Release
          --results-directory TestResults --report-xunit-trx

      - name: Test summary
        if: ${{ !cancelled() }}
        run: |
          {
            echo "### Bookstore ${{ needs.version.outputs.version }} unit tests"
            python3 .github/scripts/trx-summary.py 'TestResults/**/*.trx'
          } | tee -a "$GITHUB_STEP_SUMMARY"

tee -a ile aynı içerik hem log'a hem özet dosyasına gidiyor, böylece act'te de görebiliyoruz. Ada'nın kargo ücretini değiştirip testleri unuttuğu branch'te act'in yakaladığı özet:

[expressions/test   ]   ⚙  Summary - ### Bookstore 1.4.1 unit tests
| Project | Total | Passed | Failed | Skipped |
|---|---:|---:|---:|---:|
| Bookstore.Api.Tests.dll | 6 | 4 | 2 | 0 |

:x: `Bookstore.Api.Tests.OrderPricingTests.Small_order_pays_shipping`

:x: `Bookstore.Api.Tests.OrderPricingTests.Discount_can_bring_order_below_free_shipping`

GitHub'da bu Markdown run sayfasında gerçek bir tablo olarak görünür. Pull request'e bakan kişi log açmadan hangi testin kırıldığını görür. Hazır action'lar da (test reporter'lar) aynı dosyaya yazar; mekanizma bu.

Status Fonksiyonları: success, failure, always ve cancelled

Bir step başarısız olduğunda sonraki step'ler varsayılan olarak atlanır, çünkü her step'in görünmez varsayılan koşulu success(). Özet step'imiz test kırıldığında da koşmalı; bu yüzden koşulu !cancelled(). Workflow'un sonuna iki step daha ekledik:

      - name: Only when tests failed
        if: ${{ failure() && steps.tests.outcome == 'failure' }}
        run: echo "tests failed, outcome=${{ steps.tests.outcome }}"

      - name: Always (even after cancel)
        if: ${{ always() }}
        run: echo "job status so far = ${{ job.status }}"
[expressions/test   ] ⭐ Run Main Only when tests failed
[expressions/test   ]   | tests failed, outcome=failure
[expressions/test   ] ⭐ Run Main Always (even after cancel)
[expressions/test   ]   | job status so far = failure
[expressions/test   ] 🏁  Job failed

Yeşil koşuda "Only when tests failed" atlandı, "Always" ise job status so far = success yazdı. failure() önceki step'lerden biri başarısızsa doğru döner; steps.tests.outcome ile hangi step'in başarısız olduğunu daraltabilirsin. outcome ile conclusion farkı da burada önemli: continue-on-error: true olan bir step başarısız olursa outcome failure, conclusion ise success olur.

always() ile !cancelled() arasındaki fark ancak biri run'ı iptal ettiğinde ortaya çıkıyor: always() iptalden sonra bile koşar, !cancelled() koşmaz. Dokümantasyon, kritik bir hata alabilecek işler için (örneğin kaynak kodu çekmek) always() yerine !cancelled() öneriyor; aksi hâlde iptal edilen bir run timeout'a kadar asılı kalabiliyor. Bizim kuralımız: temizlik ve rapor için !cancelled(), gerçekten "ne olursa olsun" gereken çok hafif işler için always(). act'te iptali simüle etmedik; bu fark dokümantasyondan.

Üretimde Dikkat

Expression hataları derleme hatası vermez, yanlış davranış üretir: tahmin bölümündeki gibi her pull request'te koşan bir publish ya da hiç koşmayan bir cleanup. Bu yüzden workflow'larda actionlint'i pre-commit hook'una ya da CI'ın ilk job'ına koymak, birkaç saniyelik bir maliyetle bu sınıf hataların neredeyse tamamını yakalar.

Output'ları küçük tut. Bir job'dan diğerine dosya taşımak için output değil artifact kullan; output'lar sürüm numarası, image tag'i, "değişti mi" bayrağı gibi kısa değerler için. Job summary'nin de bir sınırı var; binlerce satırlık bir test raporunu oraya basmak yerine yalnız başarısız testleri ve sayıları yaz, tam raporu artifact olarak yükle.

Atlanan job'ları anlamak da kolaylaştı: aynı changelog'la birlikte bir job if: yüzünden atlandığında job log'unda koşulun hem yazıldığı hâli hem de context değerleri yerine konmuş hâli görünüyor. act'te aynı bilgiyi -v ile alıyorsun.

Bir de görünürlük: job summary sayfası, pull request'teki kırmızı çarpıya tıklayan kişinin ilk gördüğü yer. Oraya "ne kırıldı, nerede, nasıl yerelde tekrarlarım" sorularının cevabını koymak, ekibin CI'a güvenini doğrudan artırır.

Sık Düşülen Tuzaklar

if:'te ${{ }} dışında metin bırakmak. Koşul string olur ve her zaman doğru sayılır. actionlint [if-cond] kuralıyla yakalıyor.

always() ile iptal edilmiş run'larda da koşmak. Uzun bir deploy'u iptal ettin, ama always() ile korunan "notify" ya da "upload" step'leri yine koştu. Çoğu durumda istediğin !cancelled().

Output'u sayı sanmak. steps.x.outputs.count > 10 loose karşılaştırma yüzünden çoğu zaman çalışır gibi görünür, ama boş output 0'a döner. Sayısal karşılaştırma için fromJSON(steps.x.outputs.count) kullan ve boş gelme ihtimalini ayrıca kontrol et.

Expression'ı doğrudan run:'a gömmek. ${{ github.event.pull_request.title }} gibi kullanıcı kontrolündeki bir değer, step çalışmadan önce script'in metnine yapıştırılır; başlığa yazılan bir "; curl ... senin shell'inde çalışır. Değeri env: ile verip "$TITLE" olarak okumak bu riski kapatır. Script injection'ı güvenlik yazısında ayrıntılı göreceğiz.

GITHUB_ENV'i aynı step'te okumak. Yazdığın değer yalnız sonraki step'lerde görünür.

Küçük Bir Deneme

★☆☆ version job'ına github.sha'nın ilk 7 karakterini içeren bir short-sha output'u ekle (ipucu: shell'de ${GITHUB_SHA::7}). test job'ında özet başlığına ekle.

★★☆ if: ${{ steps.tests.outputs.failed }} != '0' gibi bilerek bozuk bir koşul yaz, actionlint'in uyarısını oku, sonra fromJSON ile doğru hâlini kur.

★★★ trx-summary.py'ı birden fazla test projesini (unit ve integration) ayrı satırlarda gösterecek şekilde dene ve her başarısız test için hata mesajının ilk satırını da yazdır. Özetin 50 satırı geçmemesi için bir sınır koy.

Bu yazıda iki job arasında needs ile bir output taşıdık. Bir sonraki yazıda job'ları gerçek bir grafa dönüştüreceğiz: bağımlılıklar, matrix ile çoklu kombinasyonlar, concurrency ile eski run'ları iptal etmek, timeout'lar ve PostgreSQL'i service container olarak ayağa kaldıran integration test job'ı.