jq ve yq Derinlemesine: Komut Satırında JSON ve YAML Sorgulamak, Dönüştürmek ve Düzenlemek
jq olmayan bir alanı hata değil null olarak döndürür ve boş sonuçta da 0 ile çıkar; CI'da koşul olarak kullandığın her filter'a -e ekle. Bookstore.Api, kubectl, dotnet list package ve GitHub API çıktılarıyla select, group_by, reduce inputs, --arg/--argjson ve @csv'yi; yq ile image tag'ini yorumları koruyarak güncellemeyi, iki farklı yq'yu ve Windows tırnaklamasını gerçek çıktılarla gösteriyoruz.
Sürüm notu: Bu yazıdaki örnekler jq 1.8.2, yq (mikefarah) 4.54.3, .NET 10 (SDK 10.0.404), PowerShell 7.7, kubectl 1.37 (yerel bir kind cluster'ına karşı), curl ve actionlint 1.7.12 ile çalıştırılmıştır. Karşılaştırma için yq 3.4.1 (container image) ve Python'daki yq paketi 4.4.5 de denenmiştir.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) API'sinden ödenmiş siparişleri çeken iki komut yazdık. Bir CI script'i bu çıktıya bakıp "ödenmiş sipariş yoksa dur" diyecek:
$ curl -s http://127.0.0.1:52606/api/orders | jq -r '.[] | select(.status == "paid") | .orderNumber'
$ curl -s http://127.0.0.1:52606/api/orders | jq -r '.[].ordernumber'
İkisi de ne yazar ve exit code'ları ne olur? Script, ödenmiş sipariş olmadığını bu exit code'dan anlayabilir mi?
Filter Zinciri: .[], select, map
jq'ya giriş Terminal ve Command Line serisinin modern CLI araçları yazısında yapıldı; burada derinliğe iniyoruz. Örnekler, Bookstore.Api'nin bu seri için sadeleştirilmiş, veritabanı yerine bellekteki veriyle çalışan hâline karşı çalışıyor. API'yi ayrı bir terminalde başlat:
$ dotnet run --project src/Bookstore.Api --urls http://127.0.0.1:52606
jq'da her şey bir filter: girdi alır, sıfır veya daha fazla çıktı üretir; | bir filter'ın çıktısını sonrakine verir. .[] bir array'in elemanlarını tek tek akıtır, select(condition) koşulu sağlamayanları düşürür, map(f) ise [.[] | f]'nin kısaltması, yani sonucu yine array olarak toplar. curl tarafı API Araçları serisinin curl yazısında:
$ curl -s http://127.0.0.1:52606/api/books | jq '.[0]'
{
"isbn": "9780441013593",
"title": "Dune",
"author": "Frank Herbert",
"price": 24.90,
"stock": 12
}
$ curl -s http://127.0.0.1:52606/api/books | jq -c 'map(select(.stock > 0) | {isbn, title})'
[{"isbn":"9780441013593","title":"Dune"},{"isbn":"9780547928227","title":"The Hobbit"},{"isbn":"9780141439549","title":"Middlemarch"}]
$ curl -s http://127.0.0.1:52606/api/books | jq '[.[] | .price * .stock] | add'
489.8499999999999
Son satır serinin ilk yazısındaki kuralı hatırlatıyor: jq 1.8 bir sayının orijinal metnini saklar, ama aritmetik yaptığın anda double kullanır. Stok değeri gibi para hesaplarını jq'da yapma, sonucu raporlamak için bile yuvarlaman gerekir.
null, Boş Çıktı ve -e ile Exit Code
Tahminin cevabı:
$ curl -s http://127.0.0.1:52606/api/orders | jq -r '.[] | select(.status == "paid") | .orderNumber'; echo "exit: $?"
exit: 0
$ curl -s http://127.0.0.1:52606/api/orders | jq -r '.[].ordernumber'; echo "exit: $?"
null
null
null
exit: 0
$ curl -s http://127.0.0.1:52606/api/orders | jq -e -r '.[] | select(.status == "paid") | .orderNumber'; echo "exit: $?"
exit: 4
$ curl -s http://127.0.0.1:52606/api/orders | jq -e -r '.[].ordernumber'; echo "exit: $?"
null
null
null
exit: 1
Birinci komut hiçbir şey yazmadı, çünkü status değeri "Paid", "paid" değil; karşılaştırma büyük/küçük harfe duyarlı. İkinci komut alan adındaki yazım hatası yüzünden üç kez null yazdı, çünkü jq olmayan bir alanı hata değil null olarak döndürür. İkisinin de exit code'u 0; script bir sorun olduğunu anlayamaz. -e (--exit-status) exit code'u sonuca bağlar: son çıktı null veya false ise 1, hiç çıktı yoksa 4. CI'da koşul olarak kullandığın her jq komutuna -e ekle. Bir değerin yokluğunu bilerek karşılamak istediğinde // operatörü işe yarar: .status // "Unknown", sol taraf null veya false ise sağ tarafı döndürür.
group_by, reduce, --arg ve --argjson
Sipariş toplamları ve duruma göre gruplama:
$ curl -s http://127.0.0.1:52606/api/orders | jq -c '.[] | {orderNumber, total: ([.items[] | .quantity * .unitPrice] | add)}'
{"orderNumber":"BS-1001","total":49.8}
{"orderNumber":"BS-1002","total":37.15}
{"orderNumber":"BS-1003","total":560.25}
$ curl -s http://127.0.0.1:52606/api/orders | jq -c 'group_by(.status) | map({status: .[0].status, count: length})'
[{"status":"AwaitingApproval","count":1},{"status":"Paid","count":1},{"status":"Shipped","count":1}]
group_by(.status) array'i sıralayıp aynı değere sahip elemanları alt array'lere ayırır; ardından gelen map her grubu tek bir object'e indirir. Dışarıdan değer almak için iki seçenek var ve farkları önemli:
$ curl -s http://127.0.0.1:52606/api/books | jq --arg min 15 'map(select(.price >= $min)) | length'
0
$ curl -s http://127.0.0.1:52606/api/books | jq --argjson min 15 'map(select(.price >= $min)) | length'
3
$ curl -s http://127.0.0.1:52606/api/books | jq --arg isbn 9780547928227 -r '.[] | select(.isbn == $isbn) | .title'
The Hobbit
--arg değeri her zaman string olarak verir; price bir number olduğu için 24.90 >= "15" karşılaştırması jq'nun tip sıralamasında (number'lar string'lerden önce gelir) hep false ve sonuç 0. --argjson değeri JSON olarak parse eder ve doğru sonucu verir. ISBN (kitapların uluslararası standart numarası) gibi string bir alanla karşılaştırırken ise --arg doğru seçim. Değeri filter'ın içine string birleştirerek gömme; hem tırnaklama sorunları çıkar hem de değer filter'ın parçası olarak yorumlanır.
Çıktı Biçimleri: -r, @csv, @tsv ve Environment Variable'lar
-r string'leri tırnaksız yazar; @csv ve @tsv bir array'i satıra çevirir:
$ curl -s http://127.0.0.1:52606/api/books | jq -r '.[] | [.isbn, .title, .price] | @csv'
"9780441013593","Dune",24.90
"9780141439587","Emma",14.50
"9780547928227","The Hobbit",19.90
"9780141439549","Middlemarch",17.25
$ curl -s http://127.0.0.1:52606/api/books | jq -r '.[] | [.title, .stock] | @tsv'
Dune 12
Emma 0
The Hobbit 7
Middlemarch 3
@csv string'leri tırnak içine aldı, sayıları olduğu gibi bıraktı; 24.90'ın sonundaki sıfır da korundu, çünkü değer üzerinde aritmetik yapılmadı. Bookstore'un back office'i için hızlı bir stok raporu bu kadar. Daha pratik bir örnek, serinin dördüncü yazısındaki JSON ayar dosyasından .NET'in anlayacağı environment variable'ları üretmek:
$ jq -r 'paths(scalars) as $p | "\($p | map(tostring) | join("__"))=\(getpath($p))"' settings/shipping.json
Shipping__DefaultCountry=NO
Shipping__Cutoff=12:30
Shipping__Express=true
Shipping__MaxWeightGrams=1000
Shipping__ApiVersion=1.10
Shipping__Carriers__0=standard
Shipping__Carriers__1=express
$ jq -c '.Shipping | with_entries(select(.value | type != "array"))' settings/shipping.json
{"DefaultCountry":"NO","Cutoff":"12:30","Express":true,"MaxWeightGrams":1000,"ApiVersion":"1.10"}
paths(scalars) her yaprak değerin yolunu (["Shipping", "Carriers", 0] gibi) üretir, getpath o yoldaki değeri okur, join("__") da .NET'in hiyerarşi ayırıcısını kurar. Çıktıyı bir Compose env_file'ına ya da Kubernetes manifest'ine koyabilirsin. with_entries ise bir object'in key-value çiftleri üzerinde çalışır; burada array olan alanları dışarıda bıraktı.
Büyük Dosyalar: inputs, -c ve JSON Lines
--slurp (-s) bütün girdiyi tek bir array'de toplar; küçük dosyalar için rahat, ama dosyanın tamamını belleğe alır. Serinin ilk yazısında anlattığımız JSON Lines dosyalarında daha iyi yol -n ile inputs: jq girdiyi kendisi okumaz, inputs her çağrıda bir sonraki değeri verir ve reduce bunları tek tek toplar:
$ curl -s http://127.0.0.1:52606/api/orders | jq -c '.[]' > orders.jsonl
$ jq -n 'reduce inputs as $order (0; . + ($order.items | map(.quantity) | add))' orders.jsonl
29
-c her değeri tek satıra yazdığı için API yanıtını JSON Lines'a çevirdi; reduce inputs ise bütün siparişlerdeki toplam kitap adedini bellekte bir array tutmadan hesapladı. Uzun bir filter'ı komut satırına yazmak yerine dosyaya koymak da mümkün:
# Duruma göre sipariş toplamları; tırnaklama gerekmediği için Windows shell'lerinde de çalışır
map({status, total: ([.items[] | .quantity * .unitPrice] | add)})
| group_by(.status)
| map({status: .[0].status, orders: length, total: (map(.total) | add)})
$ curl -s http://127.0.0.1:52606/api/orders | jq -f order-totals.jq
[
{
"status": "AwaitingApproval",
"orders": 1,
"total": 560.25
},
{
"status": "Paid",
"orders": 1,
"total": 49.8
},
{
"status": "Shipped",
"orders": 1,
"total": 37.15
}
]
-f ile verilen filter dosyasında yorum satırları kullanabilirsin ve en önemlisi, shell'e göre değişen tırnaklama sorununu tamamen ortadan kaldırır.
Gerçek Kaynaklar: kubectl, dotnet, GitHub API ve PowerShell
jq'nun asıl kullanım yeri başka araçların JSON çıktıları. Bookstore'un manifest'ini yerel kind cluster'ına uyguladık; image registry'de olmadığı için pod'lar ayağa kalkmıyor. Hangi pod'un neden beklediğini tek satırda bulalım:
$ kubectl apply -f deploy/k8s/api.yaml
configmap/api-config unchanged
deployment.apps/api unchanged
service/api unchanged
$ kubectl get pods -o json | jq -r '.items[] | [.metadata.name, (.status.containerStatuses[0].state | keys[0]), (.status.containerStatuses[0].state.waiting.reason // "-")] | @tsv'
api-79559dd97f-jlw5n waiting ImagePullBackOff
api-79559dd97f-wsf52 waiting ImagePullBackOff
$ kubectl get deployment api -o json | jq -c '{desired: .spec.replicas, ready: (.status.readyReplicas // 0)}'
{"desired":2,"ready":0}
kubectl'in kendi -o jsonpath çıktısı basit alanlar için yeterli; koşul, varsayılan değer (//) ya da biçimlendirme gerektiğinde jq daha rahat. Aynı yaklaşım .NET SDK'sının JSON çıktısıyla da çalışıyor:
$ dotnet list src/Bookstore.Api package --outdated --format json | jq -r '.projects[].frameworks[]?.topLevelPackages[]? | "\(.id) \(.resolvedVersion) -> \(.latestVersion)"'
Microsoft.AspNetCore.OpenApi 10.0.15 -> 11.0.0
GitHub'ın REST (HTTP üzerinde kaynak odaklı API tarzı) API'si de JSON döndürüyor. Kimlik doğrulama gerektirmeyen bir sorgu: jq'nun son sürümü ve Windows binary'leri:
$ curl -s https://api.github.com/repos/jqlang/jq/releases/latest | jq -r '.tag_name, (.assets[].name | select(test("windows")))'
jq-1.8.2
jq-windows-amd64.exe
jq-windows-arm64.exe
jq-windows-i386.exe
gh api aynı işi kimliği doğrulanmış olarak yapar ve --jq seçeneğiyle jq syntax'ını kendi içinde çalıştırır; jq'nun ayrıca kurulu olması gerekmez. Azure CLI ise --query ile farklı bir sorgu dili olan JMESPath'i (JSON için başka bir sorgu dili) kullanıyor; az çıktısını -o json ile alıp jq'ya da verebilirsin. Bu ikisini bu yazı için çalıştırmadım.
Windows'ta jq'ya alternatif PowerShell'in kendisi: Invoke-RestMethod ve ConvertFrom-Json JSON'u object'lere çevirir, filtreleme Where-Object ile yapılır. Bir tuzağı var:
$ pwsh -NoProfile -Command '$books = Invoke-RestMethod http://127.0.0.1:52606/api/books; $books | Where-Object stock -eq 0 | Select-Object -ExpandProperty title'
Emma
$ pwsh -NoProfile -Command '$orders = Invoke-RestMethod http://127.0.0.1:52606/api/orders; ConvertTo-Json -InputObject $orders -Compress' | cut -c1-150
WARNING: Resulting JSON is truncated as serialization has exceeded the set depth of 2.
[{"orderNumber":"BS-1001","customerName":"Ada","status":"Paid","items":["@{isbn=9780441013593; quantity=2; unitPrice=24.9}"]},{"orderNumber":"BS-1002"
ConvertTo-Json'un varsayılan derinliği 2. Siparişlerin içindeki kalemler üçüncü seviyede kaldığı için JSON yerine @{isbn=...} biçiminde string'e döndü ve yalnızca bir uyarı basıldı. JSON'u PowerShell'de geri yazarken -Depth'i her zaman açıkça ver.
yq: YAML'ı Sorgulamak ve Yerinde Düzenlemek
mikefarah/yq, jq'ya çok benzeyen bir syntax'ı YAML (girintiye dayalı veri formatı), JSON, XML, TOML (tipli bir config formatı) ve INI (bölümlü, eski bir config formatı) üzerinde çalıştırıyor. Okuma ve format dönüşümü:
$ yq 'select(.kind == "Deployment") | .spec.template.spec.containers[0].image' deploy/k8s/api.yaml
bookstore-api:1.10.0
$ yq -o json -I0 'select(.kind == "Service") | .spec.ports' deploy/k8s/api.yaml
[{"name":"http","port":80,"targetPort":"http"}]
$ yq -p json -o yaml '.Shipping' ../labs/04-config/settings/shipping.json
DefaultCountry: NO
Cutoff: 12:30
Express: true
MaxWeightGrams: 1000
ApiVersion: "1.10"
Carriers:
- standard
- express
Son komuta dikkat: JSON'dan YAML üretirken yq NO ve 12:30'u tırnaksız yazdı, çünkü YAML 1.2'de ikisi de string. 1.10'u ise tırnakladı, çünkü tırnaksız hâli float olurdu. Bu çıktıyı serinin ikinci yazısında gördüğümüz YAML 1.1 araçlarından biri (PyYAML, kubectl) okursa Norveç yine false olur. yq'nun ürettiği YAML'ı başka bir araca vereceksen o aracın kurallarına göre kontrol et.
yq'nun en sık kullanıldığı iş, bir release pipeline'ında manifest'teki image tag'ini güncellemek. Önce -i olmadan çalıştırıp diff ile neyin değişeceğine bakmak iyi bir alışkanlık:
$ TAG=1.11.0 yq '(select(.kind == "Deployment") | .spec.template.spec.containers[] | select(.name == "api") | .image) = "bookstore-api:" + strenv(TAG)' deploy/k8s/api.yaml | diff deploy/k8s/api.yaml -
37c37
< image: bookstore-api:1.10.0
---
> image: bookstore-api:1.11.0
$ TAG=1.11.0 yq -i '(select(.kind == "Deployment") | .spec.template.spec.containers[] | select(.name == "api") | .image) = "bookstore-api:" + strenv(TAG)' deploy/k8s/api.yaml
$ grep -n -B1 "image:" deploy/k8s/api.yaml
36- # the release pipeline updates this tag with yq
37: image: bookstore-api:1.11.0
Yalnızca image satırı değişti. Çok dokümanlı dosyanın --- ayırıcıları, ConfigMap'teki | block'u ve image'ın üstündeki yorum olduğu gibi kaldı; yq düzenleme yaparken yorumları ve biçimi korumaya çalışıyor. strenv(TAG) değeri environment variable'dan string olarak okuyor, böylece 1.10 gibi bir tag sayıya dönmüyor. GitOps akışında bu commit'i bir pipeline yapar ve Argo CD cluster'ı repoya göre günceller; bu akış GitOps ve Argo CD serisinin konusu.
Aynı Adlı İki yq ve Sürüm Farkları
"yq" adında iki farklı araç var ve sürüm numaraları bile benziyor. Bu yazıdaki mikefarah/yq Go ile yazılmış, tek binary. Python'daki yq paketi (kislyuk/yq) ise YAML'ı JSON'a çevirip gerçek jq'ya veren bir sarmalayıcı:
$ python -m venv .venv-pyyq && .venv-pyyq/bin/pip install -q --disable-pip-version-check yq==4.4.5
$ .venv-pyyq/bin/yq --version
yq 4.4.5
jq-1.8.2
$ yq --version
yq (https://github.com/mikefarah/yq/) version v4.54.3
$ .venv-pyyq/bin/yq -r 'select(.kind == "Deployment") | .spec.template.spec.containers[0].image' ../../bookstore/deploy/k8s/api.yaml
bookstore-api:1.11.0
İkisi de "yq 4" diye kendini tanıtıyor. Python'daki yq jq syntax'ını birebir kullanır ama varsayılan olarak JSON yazar (YAML için -y), jq'nun kurulu olmasını gerektirir ve yorumları korumaz. Bir CI script'i "yq" yazdığında hangisinin çalıştığını yq --version ile kontrol et. mikefarah/yq'nun kendi içinde de bir kırılma var: v3'te komutlar yq r, yq w gibi alt komutlar ve nokta ile ayrılmış yollardı, v4'te jq benzeri expression'lar geldi:
$ docker run --rm --platform linux/amd64 -v "$PWD":/workdir mikefarah/yq:3 yq r -d1 deploy/k8s/api.yaml 'spec.template.spec.containers[0].image'
bookstore-api:1.11.0
$ yq r -d1 deploy/k8s/api.yaml 'spec.template.spec.containers[0].image' 2>&1 | head -2
Error: unknown shorthand flag: 'd' in -d1
Usage:
İnternetteki eski bir örneği v4 ile çalıştırınca açıklayıcı olmayan bir flag hatası alırsın. Dokümana bakarken sürümü kontrol et; v4 içinde de yeni operatörler ekleniyor, CI'da yq sürümünü sabitle.
CI'da jq ile Koşul
Bookstore'un CI workflow'unda build'den sonra bir adım, NuGet bağımlılıklarında bilinen bir güvenlik açığı varsa job'ı durduruyor:
name: ci
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.14"
- run: pip install yamllint==1.38.0 check-jsonschema==0.39.0 json5==0.17.3
# -f github her bulguyu pull request'te bir annotation'a çevirir
- run: yamllint -f github .
- name: Validate settings against Bookstore's schema
run: >-
check-jsonschema --force-filetype json5
--schemafile schema/bookstore-settings.schema.json
src/Bookstore.Api/appsettings*.json
- name: Validate Compose and workflow files
run: |
check-jsonschema --builtin-schema vendor.compose-spec compose.yaml
check-jsonschema --builtin-schema vendor.github-workflows .github/workflows/*.yml
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v6
with:
dotnet-version: 10.0.x
- name: Build
run: |
dotnet restore src/Bookstore.Api
dotnet build src/Bookstore.Api -c Release --no-restore
- name: Fail on vulnerable packages
run: |
dotnet list src/Bookstore.Api package --vulnerable --format json > packages.json
# -e: sonuç false ise exit code 1 olur ve step başarısız olur
jq -e '[.projects[].frameworks[]?.topLevelPackages[]?] | length == 0' packages.json
Adımı yerelde aynı komutlarla denedik:
$ dotnet list src/Bookstore.Api package --vulnerable --format json > packages.json
$ jq -e '[.projects[].frameworks[]?.topLevelPackages[]?] | length == 0' packages.json; echo "exit: $?"
true
exit: 0
$ actionlint .github/workflows/ci.yml && echo "actionlint: no findings"
actionlint: no findings
$ kubectl delete -f deploy/k8s/api.yaml
configmap "api-config" deleted from default namespace
deployment.apps "api" deleted from default namespace
service "api" deleted from default namespace
Açık bir paket yokken sonuç true ve exit code 0. Bir paket listede görünürse ifade false olur, -e exit code'u 1 yapar ve step kırmızıya döner. [...]? içindeki soru işaretleri önemli: --vulnerable çıktısında açık yoksa frameworks alanı hiç olmayabiliyor; ? olmasa jq "null üzerinde iterate edilemez" hatası verirdi. GitHub Actions'ta jq ile bir değeri bir sonraki step'e taşımak için çıktıyı GITHUB_OUTPUT'a yazmak gerekiyor; bu mekanizma GitHub Actions serisinin expression ve context yazısında.
Üretimde Dikkat
Script'lerde jq ve yq sürümünü sabitle; jq 1.8'de tonumber, indices gibi bazı fonksiyonların davranışı değişti, yq'da her sürümde yeni operatörler geliyor. CI'da koşul olan her filter'da -e kullan. Yerinde düzenleme (yq -i) yapan pipeline'larda önce diff ile değişikliği log'a yaz; beklenmeyen bir değişiklik commit'lenmeden fark edilir. Kullanıcıdan gelen değeri filter'a her zaman --arg, --argjson ya da strenv() ile ver.
Sık Düşülen Tuzaklar
Windows'ta tırnaklama en sık takılınan yer. jq manual'ına göre cmd.exe'de filter'ı çift tırnağa alıp içerideki çift tırnakları ters bölüyle kaçırman, PowerShell'de tek tırnak kullanıp içerideki çift tırnakları yine kaçırman gerekiyor. Aynı komut bash, cmd ve PowerShell'de üç farklı yazılıyor; ekip içinde paylaşılan filter'ları -f ile dosyaya koymak bu sorunu bitirir.
İkinci tuzak null'ın sessizce geçmesi: alan adındaki bir yazım hatası jq'da hata değil null, -r ile birlikte ekranda null kelimesi, bir shell değişkeninde ise "null" string'i olur. Üçüncüsü yq sürüm karışıklığı: v3 örnekleri, Python yq ile mikefarah yq'nun karıştırılması. Dördüncüsü yq'nun ürettiği YAML'ın YAML 1.1 araçlarında farklı okunabilmesi.
Küçük Bir Deneme
★☆☆ Tahmindeki iki komutu düzelt: status karşılaştırmasını büyük/küçük harften bağımsız yap (ascii_downcase) ve -e ekle. Hangi exit code'ları alıyorsun?
★★☆ order-totals.jq'ya her durum için ortalama sipariş tutarını ekle ve sonucu @csv ile bir rapora çevir. Tutarları iki ondalığa yuvarlamak için jq 1.8'deki hangi fonksiyonları kullanabilirsin?
★★★ Bir release script'i yaz: deploy/k8s/api.yaml'daki image tag'ini ve app.kubernetes.io/version label'ını aynı sürüme güncellesin, değişikliği diff ile göstersin ve label'ın sonuçta string kaldığını yq '... | tag' ile doğrulasın.
Seri burada bitiyor. Altı yazıda JSON'un spec'inin boş bıraktıklarını, YAML'ın iki sürümü arasındaki farkları, multiline string ve anchor'ları, TOML, INI ve .env'in parser'dan parser'a değişen kurallarını, JSON Schema ile doğrulamayı ve jq ile yq'yu gördük. Ortak ders tek cümle: bir config dosyasının anlamı, onu okuyan araca bağlı; doğrulamayı o araçla, mümkünse bir şemayla ve CI'da yap. Bu serideki dosyalar, Bookstore'un DevOps serilerinde yazdığın her compose.yaml, workflow ve manifest için bir kontrol listesi olarak kullanılabilir.