# CI/CD mit GitHub Actions

Jede Änderung automatisch testen und Breaking Changes im Pull Request stoppen.

Source: https://learn.datacontract.com/de/ci-cd/

Deine Kontrakte werden nur getestet, wenn jemand `datacontract test` ausführt. Irgendwann vergisst es jemand, und ein kaputter Kontrakt rutscht durch.
In diesem Kapitel übernimmt GitHub Actions das für dich: Jeder Push lintet und testet alle Kontrakte, und jeder Pull Request wird vor dem Merge auf **Breaking Changes** geprüft.

**You will learn:**
- Kontrakte, Datenprodukte und SQL in deinem Fork versionieren
- ODCS- und ODPS-Dateien bei jedem Push linten
- Alle Kontrakte in der Pipeline gegen eine Datenbank testen
- Breaking Changes in Pull Requests mit `datacontract breaking` stoppen

> **Contracts as Code**
>
> Ein Kontrakt ist eine YAML-Datei in Git. Er wird also behandelt wie Code: Reviews, Pull Requests und automatische Checks.
> Die Pipeline prüft drei Dinge:
>
> 1. **Syntax:** Ist jede Datei gültiges ODCS oder ODPS? (`lint`)
> 2. **Realität:** Passen die Daten zum Kontrakt? (`ci`)
> 3. **Kompatibilität:** Bricht eine Änderung die Konsumenten? (`breaking`)
>
> Je früher ein Problem auffällt, desto günstiger ist es zu beheben. Das nennt man *Shift Left*.

## Arbeit pushen

### Committe deine Arbeit in deinen Fork

Das Workshop-Repository ignoriert die Dateien, die du in den Übungen erstellst. In deinem Fork sind sie dein Quellcode.
Öffne `.gitignore` und **lösche den letzten Block**: den Kommentar `# files created during the exercises` und die fünf Zeilen darunter.

Deine Views sollten schon in `sql/sku_sales_input.sql` und `sql/sku_sales_per_year.sql` liegen (aus [Consumer-driven Contracts](https://learn.datacontract.com/de/consumer-driven/)). Die Pipeline wendet sie in dieser Reihenfolge an.

Committen und pushen:

macOS / Linux:

```bash
git add .gitignore sql/ *.odcs.yaml *.odps.yaml
git commit -m "Add data contracts, data products, and views"
git push
```

Windows (PowerShell):

```powershell
git add .gitignore sql/ *.odcs.yaml *.odps.yaml
git commit -m "Add data contracts, data products, and views"
git push
```

Output:

```text
[main 6ee37e4] Add data contracts, data products, and views
 9 files changed, 601 insertions(+)
 create mode 100644 orders.odps.yaml
 create mode 100644 orders_v1.odcs.yaml
 …
 create mode 100644 sql/sku_sales_per_year.sql
Enumerating objects: 14, done.
Counting objects: 100% (14/14), done.
Delta compression using up to 18 threads
Compressing objects: 100% (11/11), done.
Writing objects: 100% (12/12), 5.32 KiB | 5.32 MiB/s, done.
Total 12 (delta 1), reused 0 (delta 0), pack-reused 0 (from 0)
To https://github.com/<your-username>/learn.datacontract.com.git
   18f0f52..6ee37e4  main -> main
```

> **Warning**
>
> Dein Fork ist öffentlich. Committe niemals einen API-Key. Prüfe mit `git diff .env`, dass `.env` nur die Workshop-Zugangsdaten enthält.

### GitHub Actions im Fork aktivieren

GitHub deaktiviert Workflows in Forks standardmäßig.
Öffne den Tab **Actions** deines Forks auf GitHub und klicke auf **I understand my workflows, go ahead and enable them**.

## Bei jedem Push linten

### Workflow anlegen

Lege die Datei `.github/workflows/datacontract.yml` an:

```yaml title=.github/workflows/datacontract.yml
name: Data Contracts

on:
  push:
    branches: [main]
  pull_request:

env:
  COLUMNS: 200 # wider tables in the logs

jobs:
  test:
    name: Lint and test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7

      - uses: astral-sh/setup-uv@v10.2.0

      - name: Install the CLIs
        run: |
          uv tool install --python 3.11 'datacontract-cli[postgres]==1.2.2'
          uv tool install --python 3.11 'dataproduct-cli==0.2.0'

      - name: Lint data contracts
        run: |
          for file in *.odcs.yaml; do
            datacontract lint "$file"
          done

      - name: Lint data products
        run: |
          for file in *.odps.yaml; do
            dataproduct lint "$file"
          done
```

Committe und pushe die Datei, dann öffne den Tab **Actions**. Nach etwa einer Minute ist der Run grün.

### Den Lint brechen

Ändere in `orders_v1.odcs.yaml` den `logicalType` von `order_id` auf `text`. Das ist kein ODCS-Logical-Type.
Pushe und öffne den fehlgeschlagenen Run. Das Log nennt das ungültige Feld und listet die erlaubten Werte.

Mach die Änderung rückgängig und pushe erneut.

## Gegen die Datenbank testen

Linten prüft die Syntax. Jetzt prüfst du die Realität: Die Pipeline startet die Workshop-Datenbank, legt deine Views an und testet jeden Kontrakt.

### Datenbank-Tests ergänzen

Ergänze diese Schritte am Ende des Jobs `test`:

```yaml
      - name: Start the database
        run: docker compose up -d --wait

      - name: Create the views
        run: |
          docker compose exec -T postgres psql -U workshop -d workshop -v ON_ERROR_STOP=1 < sql/sku_sales_input.sql
          docker compose exec -T postgres psql -U workshop -d workshop -v ON_ERROR_STOP=1 < sql/sku_sales_per_year.sql

      - name: Test data contracts
        run: datacontract ci *.odcs.yaml
```

`datacontract ci` ist `datacontract test` für Pipelines. Es testet mehrere Kontrakte auf einmal, schreibt eine Zusammenfassung auf die Run-Seite und annotiert fehlgeschlagene Checks an der Kontraktdatei.
Die Datenbank-Zugangsdaten kommen aus der Datei `.env` in deinem Repository.

Pushe, öffne den Run und scrolle nach unten zur **Summary**.

![Der Workflow-Lauf im Actions-Tab: beide Jobs, ausgelöst durch einen Push auf main](https://learn.datacontract.com/screenshots/ci-run-overview.webp)
![Die Step Summary von datacontract ci: alle vier Kontrakte bestanden, darunter jeder einzelne Check](https://learn.datacontract.com/screenshots/ci-step-summary.webp)

### Einen Test fehlschlagen lassen

Ändere in `sku_sales_per_year.odcs.yaml` den `physicalType` von `total_quantity` auf `integer` und pushe.
Der Run schlägt fehl. Finde die Annotation: Die Spalte ist `bigint`, nicht `integer`.

Mach die Änderung rückgängig und pushe erneut.

> **Platzhalter für Staging und Produktion**
>
> Die Datenbank in der Pipeline ist ein Platzhalter. In einem echten Setup testet die Pipeline vor jedem Deployment eine Staging-Umgebung.
> Ein zeitgesteuerter Workflow (`on: schedule:` mit Cron-Ausdruck) testet regelmäßig die Produktion, denn Daten können auch ohne Code-Änderung kaputtgehen.

## Breaking Changes stoppen

Ein Breaking Change im Kontrakt bricht die Konsumenten. Die Pipeline soll ihn vor dem Merge abfangen.
Die Idee: Jeder Kontrakt im Pull Request wird mit seiner Version auf dem Base-Branch verglichen.

### datacontract breaking lokal ausprobieren

Vergleiche zwei Kontrakte auf deinem Rechner:

macOS / Linux:

```bash
datacontract breaking orders_v1.odcs.yaml orders_v2.odcs.yaml
```

Windows (PowerShell):

```powershell
datacontract breaking orders_v1.odcs.yaml orders_v2.odcs.yaml
```

Output:

```text
Summary
[ 11 Warning ]  [ 6 Info ]
╭──────────┬─────────┬───────────────────────────────────────────────────────────╮
│ Severity │ Change  │ Field                                                     │
├──────────┼─────────┼───────────────────────────────────────────────────────────┤
│ INFO     │ Updated │ id                                                        │
│ WARNING  │ Updated │ schema.line_items.properties.lines_item_id.quality.[1]    │
│ INFO     │ Added   │ schema.line_items.properties.quantity                     │
│ WARNING  │ Updated │ schema.line_items.properties.sku.quality.[1]              │
│ …        │         │                                                           │
│ WARNING  │ Updated │ schema.orders.quality.[2]                                 │
│ INFO     │ Updated │ servers.postgres                                          │
│ INFO     │ Updated │ version                                                   │
╰──────────┴─────────┴───────────────────────────────────────────────────────────╯

Details
…
```

Das neue Feld `quantity` ist `INFO`. Die geänderten SQL-Quality-Queries sind `WARNING`s: einen Blick wert, aber nicht brechend. Der Exit-Code ist `0`.
Vergleiche jetzt in die andere Richtung, als würdest du `quantity` wieder entfernen:

macOS / Linux:

```bash
datacontract breaking orders_v2.odcs.yaml orders_v1.odcs.yaml
echo $?
```

Windows (PowerShell):

```powershell
datacontract breaking orders_v2.odcs.yaml orders_v1.odcs.yaml
$LASTEXITCODE
```

Output:

```text
Summary
[ 1 Error ]  [ 11 Warning ]  [ 5 Info ]
╭──────────┬─────────┬───────────────────────────────────────────────────────────╮
│ Severity │ Change  │ Field                                                     │
├──────────┼─────────┼───────────────────────────────────────────────────────────┤
│ INFO     │ Updated │ id                                                        │
│ WARNING  │ Updated │ schema.line_items.properties.lines_item_id.quality.[1]    │
│ ERROR    │ Removed │ schema.line_items.properties.quantity                     │
│ …        │         │                                                           │
│ INFO     │ Updated │ version                                                   │
╰──────────┴─────────┴───────────────────────────────────────────────────────────╯

Details
…
1
```

Das entfernte Feld ist ein `ERROR`, und der Exit-Code ist `1`. Genau das lässt eine Pipeline fehlschlagen.

### Breaking-Change-Check ergänzen

Ergänze einen zweiten Job im Workflow. Er läuft nur für Pull Requests:

```yaml
  breaking-changes:
    name: Breaking changes
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with:
          fetch-depth: 0 # the base branch is needed for the comparison

      - uses: astral-sh/setup-uv@v10.2.0

      - name: Install the CLI
        run: uv tool install --python 3.11 'datacontract-cli[postgres]==1.2.2'

      - name: Compare with the base branch
        env:
          BASE: origin/${{ github.base_ref }}
        run: |
          status=0
          # every contract on the base branch must stay compatible (new contracts have nothing to compare)
          for file in $(git ls-tree --name-only "$BASE" | grep '\.odcs\.yaml$'); do
            if [ ! -f "$file" ]; then
              echo "::error file=$file::Data contract $file was removed"
              status=1
              continue
            fi
            git show "$BASE:$file" > "$RUNNER_TEMP/$file"
            if ! datacontract breaking "$RUNNER_TEMP/$file" "$file"; then
              echo "::error file=$file::Breaking change in $file. Release a new major version instead."
              status=1
            fi
          done
          exit $status
```

Die `::error`-Zeilen sind GitHub-Annotationen. Sie erscheinen im Pull Request.
Committe und pushe auf `main`.

### Pull Request mit Breaking Change öffnen

Jetzt bist du das Orders-Team und willst eine Spalte „aufräumen“. Lege einen Branch an und entferne das Feld `customer_id` aus dem Schema `orders` in `orders_v2.odcs.yaml`:

macOS / Linux:

```bash
git switch -c remove-customer-id
# orders_v2.odcs.yaml bearbeiten: customer_id entfernen
git commit -am "Remove customer_id"
git push -u origin remove-customer-id
```

Windows (PowerShell):

```powershell
git switch -c remove-customer-id
# orders_v2.odcs.yaml bearbeiten: customer_id entfernen
git commit -am "Remove customer_id"
git push -u origin remove-customer-id
```

Output:

```text
Switched to a new branch 'remove-customer-id'
[remove-customer-id 86465a7] Remove customer_id
 1 file changed, 10 deletions(-)
Enumerating objects: 5, done.
Counting objects: 100% (5/5), done.
Delta compression using up to 18 threads
Compressing objects: 100% (3/3), done.
Writing objects: 100% (3/3), 305 bytes | 305.00 KiB/s, done.
Total 3 (delta 2), reused 0 (delta 0), pack-reused 0 (from 0)
remote:
remote: Create a pull request for 'remove-customer-id' on GitHub by visiting:
remote:      https://github.com/<your-username>/learn.datacontract.com/pull/new/remove-customer-id
remote:
To https://github.com/<your-username>/learn.datacontract.com.git
 * [new branch]      remove-customer-id -> remove-customer-id
branch 'remove-customer-id' set up to track 'origin/remove-customer-id'.
```

Öffne einen Pull Request auf GitHub.

> **Wähle deinen Fork als Base**
>
> Bei einem Fork schlägt GitHub das ursprüngliche Workshop-Repository als Base vor. Ändere **base repository** auf `<dein-username>/learn.datacontract.com` und die Base auf `main`.

Der Check **Breaking changes** schlägt fehl, mit einer Annotation an `orders_v2.odcs.yaml`.

![Der Pull Request: Der Check Breaking changes schlägt fehl, Lint and test bleibt grün](https://learn.datacontract.com/screenshots/ci-pr-checks.webp)
![Das Job-Log: datacontract breaking meldet das entfernte customer_id als ERROR](https://learn.datacontract.com/screenshots/ci-breaking-log.webp)

### Stattdessen kompatibel ändern

Stelle `customer_id` wieder her. Ergänze stattdessen eine `description` oder einen neuen Tag. Pushe auf denselben Branch.
Der Check wird grün, und du kannst mergen.

Eine Änderung, an die sich Konsumenten anpassen müssen, braucht eine neue Major-Version: einen neuen Kontrakt, wie `orders_v2` für `orders_v1` in [Evolution von Datenkontrakten](https://learn.datacontract.com/de/evolution/).

**Solution: .github/workflows/datacontract.yml**

```yaml title=datacontract.yml
# Put this file at .github/workflows/datacontract.yml in your fork.
name: Data Contracts

on:
  push:
    branches: [main]
  pull_request:

env:
  # wider tables in the logs of the Data Contract CLI
  COLUMNS: 200

jobs:
  test:
    name: Lint and test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7

      - uses: astral-sh/setup-uv@v10.2.0

      - name: Install the CLIs
        run: |
          uv tool install --python 3.11 'datacontract-cli[postgres]==1.2.2'
          uv tool install --python 3.11 'dataproduct-cli==0.2.0'

      - name: Lint data contracts
        run: |
          for file in *.odcs.yaml; do
            datacontract lint "$file"
          done

      - name: Lint data products
        run: |
          for file in *.odps.yaml; do
            dataproduct lint "$file"
          done

      - name: Start the database
        run: docker compose up -d --wait

      - name: Create the views
        run: |
          docker compose exec -T postgres psql -U workshop -d workshop -v ON_ERROR_STOP=1 < sql/sku_sales_input.sql
          docker compose exec -T postgres psql -U workshop -d workshop -v ON_ERROR_STOP=1 < sql/sku_sales_per_year.sql

      - name: Test data contracts
        # the database credentials come from the .env file in the repository
        run: datacontract ci *.odcs.yaml

      # Part D: publish the test results to Entropy Data.
      # Add your API key as repository secret ENTROPY_DATA_API_KEY, then replace the step above with:
      #
      # - name: Test data contracts
      #   run: datacontract ci *.odcs.yaml --publish https://api.entropy-data.com/api/test-results
      #   env:
      #     ENTROPY_DATA_API_KEY: ${{ secrets.ENTROPY_DATA_API_KEY }}
      #
      # Contracts linked to semantic concepts (Part D) are resolved on the Entropy Data host:
      # add --no-inline-references if the pipeline can't reach it.

  breaking-changes:
    name: Breaking changes
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with:
          fetch-depth: 0 # the base branch is needed for the comparison

      - uses: astral-sh/setup-uv@v10.2.0

      - name: Install the CLI
        run: uv tool install --python 3.11 'datacontract-cli[postgres]==1.2.2'

      - name: Compare with the base branch
        env:
          BASE: origin/${{ github.base_ref }}
        run: |
          status=0
          # every contract on the base branch must stay compatible (new contracts have nothing to compare)
          for file in $(git ls-tree --name-only "$BASE" | grep '\.odcs\.yaml$'); do
            if [ ! -f "$file" ]; then
              echo "::error file=$file::Data contract $file was removed"
              status=1
              continue
            fi
            git show "$BASE:$file" > "$RUNNER_TEMP/$file"
            if ! datacontract breaking "$RUNNER_TEMP/$file" "$file"; then
              echo "::error file=$file::Breaking change in $file. Release a new major version instead."
              status=1
            fi
          done
          exit $status
```

**Quick check:** Ein Pull Request entfernt customer_id aus orders_v2, die Spalte existiert in der Datenbank aber weiterhin. Welcher Check lässt die Pipeline fehlschlagen?

- datacontract lint
- datacontract ci
- datacontract breaking gegen den Base-Branch (correct)
- Keiner, der Kontrakt ist ja weiterhin gültig

`lint` prüft die Syntax, `ci` prüft den Kontrakt gegen die Daten. Nur `breaking` vergleicht die neue Version mit dem Base-Branch und schlägt bei der entfernten Spalte fehl (ERROR).

## Bonus

- **Checks verpflichtend machen:** Gehe in deinem Fork zu **Settings → Rules → Rulesets** und fordere die Checks **Lint and test** und **Breaking changes** für `main` an. Jetzt lässt sich kein Breaking Change mehr mergen.
- **Consumer-driven Contracts in der Pipeline des Producers:** Dein Consumer-Kontrakt `orders_v2.consumer_sku_sales.odcs.yaml` läuft in derselben Pipeline. In einem echten Setup testet das Orders-Team die Kontrakte aller seiner Konsumenten. Ein fehlschlagender Check zeigt dann genau, wen eine Änderung bricht.
- **Changelog lesen:** `datacontract changelog orders_v1.odcs.yaml orders_v2.odcs.yaml` listet alle Änderungen, nicht nur die brechenden.
- **Testergebnisse veröffentlichen:** In [Teil D](https://learn.datacontract.com/de/publish/) kannst du die Ergebnisse aus der Pipeline an Entropy Data senden. Der Referenz-Workflow in der Lösung enthält den Schritt als Kommentar.

### Bonus: Tests mit Airflow einplanen

CI testet einen Kontrakt, wenn er sich ändert. Die Daten ändern sich aber täglich, also brauchen Produktionstests auch einen Zeitplan. Laufen deine Pipelines in Apache Airflow, bringt der [Data Contract Provider für Airflow](https://github.com/datacontract/airflow-provider-datacontract) einen `DataContractTestOperator` mit: Er führt `datacontract test` als Quality Gate im DAG aus und lässt den Task fehlschlagen, wenn der Kontrakt verletzt ist. So stoppen schlechte Daten, bevor sie nachgelagerte Tasks erreichen.

```python title=dags/orders_contract.py
from datetime import datetime
from airflow.sdk import dag
from datacontract_provider.operators.datacontract import DataContractTestOperator

@dag(schedule="0 2 * * *", start_date=datetime(2026, 1, 1), catchup=False)
def orders_contract():
    DataContractTestOperator(
        task_id="test_orders_v2",
        data_contract_file="orders_v2.odcs.yaml",
        server="Orders",
        server_conn_id="workshop_postgres",  # Airflow-Connection mit den Datenbank-Zugangsdaten
    )

orders_contract()
```

Installation mit `pip install "airflow-provider-datacontract[postgres]"`. Connections, das Veröffentlichen von Ergebnissen und die Ergebnisansicht beschreibt die [Scheduling-Doku für Airflow](https://docs.datacontract.com/scheduling/airflow) der Data Contract CLI. Kein Airflow? Ein `schedule:`-Trigger in GitHub Actions macht dasselbe, siehe die [Scheduling-Doku für GitHub Actions](https://docs.datacontract.com/scheduling/github-actions).
