24 Repository-Automatisierung – Code-Modifikation durch Workflows

Workflows können nicht nur Code testen und deployen – sie können auch Code ändern. Automatisches Code-Formatting, Version-Bumps, Changelog-Generierung: All das sind Operationen, die Workflows ausführen und zurück ins Repository committen können.

Dieses Kapitel zeigt, wie Workflows zum aktiven Teilnehmer im Repository werden. Wir behandeln Git-Operationen aus Workflows, Semantic Versioning mit automatischen Version-Bumps, Conventional Commits als Basis für Changelog-Generierung und die Integration all dieser Komponenten in einen Release-Workflow.

Am Ende haben wir einen vollautomatischen Release-Prozess für badge-gen: Code wird formatiert, Version wird gebumpt, Changelog wird generiert, GitHub Release wird erstellt – alles durch einen Workflow.

24.1 Git-Operationen aus Workflows

24.1.1 Das Fundament: GITHUB_TOKEN Permissions

Um Dateien zu ändern und zu committen, braucht der Workflow Schreibrechte:

jobs:
  auto-format:
    runs-on: ubuntu-latest
    permissions:
      contents: write  # Erlaubt git push
    steps:
      # ...

Wichtig: Ohne contents: write schlägt git push mit Permission Denied fehl.

24.1.2 Manuelle Git-Operationen

Der einfachste Ansatz: Standard-Git-Befehle im Workflow.

- name: Configure Git
  run: |
    git config user.name "GitHub Actions Bot"
    git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

- name: Format code
  run: |
    black src/
    ruff check --fix .

- name: Commit changes
  run: |
    git add .
    git commit -m "style: auto-format code" || echo "No changes to commit"
    git push

Problem: Was, wenn keine Änderungen vorliegen? git commit schlägt fehl → Workflow bricht ab.

Lösung: || echo "No changes" fängt Exit-Code 1 ab. Aber: Workflow zeigt trotzdem als “erfolgreich” an, obwohl kein Commit passiert ist.

24.1.3 Besserer Ansatz: Dedicated Actions

stefanzweifel/git-auto-commit-action – der De-facto-Standard für Auto-Commits:

- uses: stefanzweifel/git-auto-commit-action@v5
  with:
    commit_message: "style: auto-format code"
    file_pattern: "*.py *.md"

Was die Action macht:

  1. Prüft, ob Änderungen vorliegen (git status)
  2. Wenn ja: git add + git commit + git push
  3. Wenn nein: Überspringt alles (kein Fehler)
  4. Setzt Author/Committer automatisch auf GitHub Actions Bot

Output:

- uses: stefanzweifel/git-auto-commit-action@v5
  id: auto-commit
  with:
    commit_message: "style: auto-format code"

- name: Check if committed
  run: |
    if [[ "${{ steps.auto-commit.outputs.changes_detected }}" == "true" ]]; then
      echo "Changes were committed"
    else
      echo "No changes detected"
    fi

24.1.4 Security: Infinite Loop Prevention

Problem: Workflow wird durch Push getriggert → macht Änderung → pusht → triggert Workflow → …

GitHub’s eingebauter Schutz: Commits mit GITHUB_TOKEN triggern keine weiteren Workflows.

Exception: Wenn ein Workflow einen anderen Workflow explizit triggern soll (z.B. Deployment nach Auto-Format), braucht man einen Personal Access Token (PAT).

- uses: actions/checkout@v4
  with:
    token: ${{ secrets.PAT }}  # Statt GITHUB_TOKEN

- uses: stefanzweifel/git-auto-commit-action@v5
  with:
    commit_message: "style: auto-format code"

Commits mit PAT triggern Workflows. Daher: [skip ci] im Commit-Message nutzen:

commit_message: "style: auto-format code [skip ci]"

[skip ci] (oder [ci skip]) verhindert Workflow-Trigger. Siehe auch: skip-checks: true im Commit-Body.

24.1.5 Pull Requests vs. Direct Push

Use-Case 1: Auto-Format auf PRs

on:
  pull_request:
    branches: [main]

jobs:
  format:
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.head_ref }}  # PR-Branch auschecken
      
      - name: Format code
        run: black src/
      
      - uses: stefanzweifel/git-auto-commit-action@v5
        with:
          commit_message: "style: auto-format code"

Wichtig: ref: ${{ github.head_ref }} checkt den PR-Branch aus (nicht den Merge-Commit). Ohne das würde der Commit auf einem detached HEAD landen.

Use-Case 2: Auto-Format auf main-Branch

on:
  push:
    branches: [main]

jobs:
  format:
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v4
      
      - name: Format code
        run: black src/
      
      - uses: stefanzweifel/git-auto-commit-action@v5
        with:
          commit_message: "style: auto-format code [skip ci]"

Hier ist [skip ci] kritisch – sonst endlos-Loop.

24.1.6 Praxis-Beispiel: Auto-Format für badge-gen

name: Auto-Format

on:
  pull_request:
  push:
    branches: [main, develop]

jobs:
  format:
    runs-on: ubuntu-latest
    permissions:
      contents: write
    
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.head_ref || github.ref }}
      
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      
      - name: Install formatters
        run: pip install black ruff
      
      - name: Format Python code
        run: |
          black src/ tests/
          ruff check --fix src/ tests/
      
      - uses: stefanzweifel/git-auto-commit-action@v5
        with:
          commit_message: "style: auto-format with black and ruff"
          file_pattern: "*.py"

Workflow:

  1. PR wird geöffnet → Workflow läuft
  2. Code wird formatiert
  3. Wenn Änderungen: Automatischer Commit auf PR-Branch
  4. Developer sieht Commit im PR, kann reviewen

24.2 Conventional Commits – die Basis für Automation

Semantic Versioning (Major.Minor.Patch) braucht Informationen über Art der Änderung. Conventional Commits kodiert diese Information im Commit-Message.

24.2.1 Format-Spezifikation

<type>[optional scope]: <description>

[optional body]

[optional footer(s)]

Beispiele:

feat: add SVG export functionality
fix: correct badge width calculation
docs: update installation instructions
refactor: simplify color parsing logic
perf: optimize image rendering
test: add tests for badge validation
build: update dependencies
ci: add caching to workflow
chore: update .gitignore

Scopes (optional):

feat(api): add REST endpoint for badge generation
fix(cli): handle missing config file gracefully
docs(readme): add usage examples

Breaking Changes:

feat!: redesign CLI interface

BREAKING CHANGE: --output flag renamed to --out

24.2.2 Konvention → Semantic Version

Commit Type Version Bump
fix:, perf: Patch (0.0.X)
feat: Minor (0.X.0)
BREAKING CHANGE: oder ! Major (X.0.0)
docs:, style:, test:, chore: Keine

Beispiel:

v1.2.3  (aktuell)
↓
feat: add new badge style
↓
v1.3.0  (Minor bump)
↓
fix: correct color parsing
↓
v1.3.1  (Patch bump)
↓
feat!: redesign API
↓
v2.0.0  (Major bump)

24.2.3 Tooling für Conventional Commits

1. Commitizen – Interaktiver Commit-Helper

npm install -g commitizen cz-conventional-changelog

# In Repository
echo '{ "path": "cz-conventional-changelog" }' > .czrc

# Statt git commit
git cz

Commitizen führt durch einen Wizard:

? Select the type of change: (Use arrow keys)
❯ feat:     A new feature
  fix:      A bug fix
  docs:     Documentation only changes
  style:    Changes that do not affect the meaning of the code
  refactor: A code change that neither fixes a bug nor adds a feature
  perf:     A code change that improves performance
  test:     Adding missing tests

2. commitlint – Commit-Message-Linting

npm install --save-dev @commitlint/cli @commitlint/config-conventional

# .commitlintrc.js
module.exports = {
  extends: ['@commitlint/config-conventional']
};

# Pre-commit Hook (Husky)
npx husky add .husky/commit-msg 'npx commitlint --edit $1'

Effekt: Commit wird abgelehnt, wenn Message nicht dem Standard folgt.

git commit -m "added feature"
# ⧗ input: added feature
# ✖ subject may not be empty [subject-empty]
# ✖ type may not be empty [type-empty]

3. In GitHub Actions validieren

- uses: wagoid/commitlint-github-action@v6

Prüft alle Commits im PR gegen Conventional Commits.

24.3 Automatisches Versioning

24.3.1 Semantic Version aus Git-History berechnen

Prinzip: Letzter Tag + Commits seit Tag = Nächste Version

paulhatch/semantic-version – Berechnet Version, erstellt aber keinen Tag:

- uses: paulhatch/semantic-version@v5.4.0
  id: version
  with:
    tag_prefix: "v"
    major_pattern: "(BREAKING CHANGE:|!:)"
    minor_pattern: "feat:"
    version_format: "${major}.${minor}.${patch}"

- name: Show version
  run: echo "Next version: ${{ steps.version.outputs.version }}"

Outputs:

Use-Case: Version in Build einbetten (Docker-Tag, Package-Metadata) vor tatsächlichem Tag.

24.3.2 Dry-Run Pattern: Version berechnen, später taggen

Problem: Version soll in Artefakt eingebaut werden, aber Tag erst nach erfolgreichem Build.

Lösung:

jobs:
  calculate-version:
    runs-on: ubuntu-latest
    outputs:
      version: ${{ steps.semver.outputs.version }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # Alle Tags brauchen
      
      - uses: paulhatch/semantic-version@v5.4.0
        id: semver
        with:
          tag_prefix: "v"
          major_pattern: "BREAKING CHANGE:"
          minor_pattern: "feat:"
  
  build:
    needs: calculate-version
    runs-on: ubuntu-latest
    steps:
      - name: Build with version
        run: |
          VERSION=${{ needs.calculate-version.outputs.version }}
          echo "Building version $VERSION"
          # Docker build, Package build, etc.
  
  tag:
    needs: [calculate-version, build]
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v4
      
      - name: Create tag
        run: |
          VERSION=${{ needs.calculate-version.outputs.version }}
          git tag "v$VERSION"
          git push origin "v$VERSION"

Workflow:

  1. Version wird berechnet
  2. Build nutzt Version
  3. Wenn Build erfolgreich → Tag wird erstellt
  4. Wenn Build fehlschlägt → Kein Tag (keine Version “verbrannt”)

24.3.3 Version in Dateien schreiben

Use-Case: pyproject.toml, package.json, Cargo.toml mit Version updaten.

Manuell:

- name: Update version in pyproject.toml
  run: |
    VERSION=${{ steps.semver.outputs.version }}
    sed -i "s/^version = .*/version = \"$VERSION\"/" pyproject.toml

Mit Action (TriPSs/conventional-changelog-action):

- uses: TriPSs/conventional-changelog-action@v5
  with:
    github-token: ${{ github.token }}
    version-file: './pyproject.toml'
    version-path: 'tool.poetry.version'

Findet automatisch die Version-Zeile in pyproject.toml und updatet sie.

24.4 Changelog-Generierung

24.4.1 Aus Conventional Commits generieren

requarks/changelog-action – Generiert Changelog aus Commit-Messages:

- uses: requarks/changelog-action@v1
  id: changelog
  with:
    token: ${{ github.token }}
    fromTag: v1.2.0
    toTag: v1.3.0
    excludeTypes: "build,chore"

Output: ${{ steps.changelog.outputs.changes }}

## Features

- feat: add SVG export (#42)
- feat(api): new REST endpoint (#45)

## Bug Fixes

- fix: correct badge width (#44)
- fix(cli): handle missing config (#46)

24.4.2 Template-basierte Generierung

Eigenes Template:

- uses: requarks/changelog-action@v1
  with:
    token: ${{ github.token }}
    tag: ${{ github.ref_name }}
    writeToFile: true

CHANGELOG.md wird aktualisiert:

# Changelog

## [1.3.0] - 2025-01-15

### Features

- Add SVG export functionality
- New REST API endpoint

### Bug Fixes

- Correct badge width calculation
- Handle missing config file gracefully

## [1.2.0] - 2024-12-10
...

24.4.3 Conventional Changelog Action (All-in-One)

TriPSs/conventional-changelog-action – Macht alles: Version bump, Changelog, Tag, Commit:

- uses: TriPSs/conventional-changelog-action@v5
  id: changelog
  with:
    github-token: ${{ github.token }}
    output-file: "CHANGELOG.md"
    skip-version-file: false
    version-file: "pyproject.toml"
    version-path: "tool.poetry.version"
    git-message: "chore(release): {version}"
    tag-prefix: "v"

Was passiert:

  1. Liest Commits seit letztem Tag
  2. Berechnet neue Version (basierend auf Conventional Commits)
  3. Updated pyproject.toml mit neuer Version
  4. Generiert/Updated CHANGELOG.md
  5. Committed beide Dateien
  6. Erstellt Git-Tag
  7. Pusht alles

Outputs:

24.4.4 Changelog in GitHub Release

- uses: TriPSs/conventional-changelog-action@v5
  id: changelog
  with:
    github-token: ${{ github.token }}

- uses: ncipollo/release-action@v1
  if: ${{ steps.changelog.outputs.skipped == 'false' }}
  with:
    token: ${{ github.token }}
    tag: ${{ steps.changelog.outputs.tag }}
    name: Release ${{ steps.changelog.outputs.tag }}
    body: ${{ steps.changelog.outputs.clean_changelog }}

24.5 Praxis: Vollautomatischer Release-Workflow

Kombinieren wir alles für badge-gen.

24.5.1 Workflow-Struktur

24.5.2 Implementierung

name: Release

on:
  push:
    branches: [main]
  workflow_dispatch:

jobs:
  release:
    runs-on: ubuntu-latest
    permissions:
      contents: write
    
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # Für Tag-History
      
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      
      # Tests ausführen
      - name: Install dependencies
        run: |
          pip install --upgrade pip
          pip install -e .[dev]
      
      - name: Run tests
        run: pytest
      
      # Version bump + Changelog
      - uses: TriPSs/conventional-changelog-action@v5
        id: changelog
        with:
          github-token: ${{ github.token }}
          output-file: "CHANGELOG.md"
          skip-version-file: false
          version-file: "pyproject.toml"
          version-path: "tool.poetry.version"
          git-message: "chore(release): {version} [skip ci]"
          tag-prefix: "v"
          preset: "conventionalcommits"
      
      # Build nur wenn Version gebumpt wurde
      - name: Build distribution
        if: ${{ steps.changelog.outputs.skipped == 'false' }}
        run: |
          pip install build
          python -m build
      
      # GitHub Release mit Artifacts
      - name: Create GitHub Release
        if: ${{ steps.changelog.outputs.skipped == 'false' }}
        uses: ncipollo/release-action@v1
        with:
          token: ${{ github.token }}
          tag: ${{ steps.changelog.outputs.tag }}
          name: Release ${{ steps.changelog.outputs.tag }}
          body: ${{ steps.changelog.outputs.clean_changelog }}
          artifacts: "dist/*"
          draft: false
          prerelease: false

24.5.3 Was passiert bei Push zu main?

Szenario 1: Commits mit feat: oder fix:

  1. Workflow startet
  2. Tests laufen durch
  3. conventional-changelog-action analysiert Commits:
  4. pyproject.toml wird updated: version = "1.3.0"
  5. CHANGELOG.md wird generiert/updated
  6. Beides wird committed: "chore(release): 1.3.0 [skip ci]"
  7. Tag v1.3.0 wird erstellt
  8. Package wird gebaut (dist/badge-gen-1.3.0.tar.gz)
  9. GitHub Release wird erstellt mit Changelog als Body
  10. Build-Artefakte werden an Release angehängt

Szenario 2: Commits nur mit docs: oder chore:

  1. Workflow startet
  2. Tests laufen durch
  3. conventional-changelog-action analysiert Commits:
  4. Build-Step wird übersprungen (Condition nicht erfüllt)
  5. Kein Release wird erstellt

24.5.4 Erweitert: Pre-Release für develop-Branch

on:
  push:
    branches: [main, develop]

jobs:
  release:
    runs-on: ubuntu-latest
    permissions:
      contents: write
    
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      
      # ... Tests ...
      
      - uses: TriPSs/conventional-changelog-action@v5
        id: changelog
        with:
          github-token: ${{ github.token }}
          output-file: "CHANGELOG.md"
          version-file: "pyproject.toml"
          version-path: "tool.poetry.version"
          git-message: "chore(release): {version} [skip ci]"
          tag-prefix: "v"
          preset: "conventionalcommits"
          pre-release: ${{ github.ref != 'refs/heads/main' }}
          pre-release-identifier: "beta"
      
      # ... Build + Release ...
      
      - name: Create GitHub Release
        if: ${{ steps.changelog.outputs.skipped == 'false' }}
        uses: ncipollo/release-action@v1
        with:
          token: ${{ github.token }}
          tag: ${{ steps.changelog.outputs.tag }}
          name: ${{ github.ref == 'refs/heads/main' && 'Release' || 'Pre-Release' }} ${{ steps.changelog.outputs.tag }}
          body: ${{ steps.changelog.outputs.clean_changelog }}
          artifacts: "dist/*"
          prerelease: ${{ github.ref != 'refs/heads/main' }}

Versioning: - main: v1.3.0, v1.3.1, v2.0.0 - develop: v1.3.0-beta.1, v1.3.0-beta.2, v1.3.1-beta.1

24.6 Edge-Cases und Best Practices

24.6.1 Problem: Concurrency bei parallelen Pushes

Symptom: Zwei Workflows laufen gleichzeitig, beide versuchen dieselbe Version zu erstellen.

Lösung: Concurrency-Control

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: false  # Wichtig: false, nicht true

Wichtig: cancel-in-progress: false bedeutet: Zweiter Run wartet bis erster fertig ist. So wird sichergestellt, dass Versionen sequenziell gebumpt werden.

24.6.2 Problem: Protected Branches blockieren Auto-Commits

Symptom: Branch Protection Rule verlangt Review → Auto-Commit schlägt fehl.

Lösung 1: Bot-User aus Branch Protection ausnehmen

Settings → Branches → Edit Rule → ☐ Include administrators

Lösung 2: GitHub App statt GITHUB_TOKEN

GitHub Apps können Branch Protection Rules umgehen (mit korrekten Permissions).

- uses: tibdex/github-app-token@v2
  id: generate-token
  with:
    app_id: ${{ secrets.APP_ID }}
    private_key: ${{ secrets.APP_PRIVATE_KEY }}

- uses: TriPSs/conventional-changelog-action@v5
  with:
    github-token: ${{ steps.generate-token.outputs.token }}

24.6.3 Problem: Commits triggern weitere Workflows trotz [skip ci]

Symptom: Obwohl [skip ci] im Message, laufen Workflows weiter.

Ursache: [skip ci] funktioniert nur für denselben Workflow. Andere Workflows ignorieren es.

Lösung: if Condition in anderen Workflows

jobs:
  test:
    if: "!contains(github.event.head_commit.message, '[skip ci]')"
    runs-on: ubuntu-latest
    # ...

24.6.4 Problem: Version-File in falscher Struktur

Symptom: version-path findet Version nicht in pyproject.toml.

Beispiel pyproject.toml:

[tool.poetry]
name = "badge-gen"
version = "1.2.3"

Korrekte version-path:

version-file: "pyproject.toml"
version-path: "tool.poetry.version"

Für package.json:

version-file: "package.json"
version-path: "version"

Für Cargo.toml:

version-file: "Cargo.toml"
version-path: "package.version"

24.6.5 Best Practice: Pre-Commit Hooks für Conventional Commits

Lokale Validierung verhindert, dass Non-Conventional Commits ins Repository kommen.

# .husky/commit-msg
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

npx --no-install commitlint --edit $1

Team-Setup:

{
  "scripts": {
    "prepare": "husky install"
  },
  "devDependencies": {
    "husky": "^8.0.0",
    "@commitlint/cli": "^18.0.0",
    "@commitlint/config-conventional": "^18.0.0"
  }
}

Nach npm install sind Hooks automatisch aktiv.

24.6.6 Best Practice: Semantic Release für Maximale Automation

Für Projekte mit hohem Release-Aufkommen: semantic-release

- uses: cycjimmy/semantic-release-action@v4
  with:
    branches: |
      [
        'main',
        {
          name: 'beta',
          prerelease: true
        }
      ]
  env:
    GITHUB_TOKEN: ${{ github.token }}

semantic-release macht alles:

Nachteile:

Wann nutzen:

24.7 Vergleich: Verschiedene Automation-Levels

Ansatz Version Bump Changelog Tag Release Komplexität
Manuell ✋ Hand ✋ Hand ✋ Hand ✋ Hand 😊 Niedrig
Semi-Auto 🤖 Auto ✋ Hand 🤖 Auto ✋ Hand 😐 Mittel
Conventional Changelog Action 🤖 Auto 🤖 Auto 🤖 Auto ✋ Hand 🙂 Mittel
Semantic Release 🤖 Auto 🤖 Auto 🤖 Auto 🤖 Auto 🤔 Hoch

Empfehlung für badge-gen: Conventional Changelog Action (Sweet Spot: viel Automation, wenig Komplexität).

24.8 Security-Überlegungen

24.8.1 1. Auto-Commits auf Pull Requests

Risiko: PR von Fork → Workflow läuft mit GITHUB_TOKEN → kann Code committen → Malicious Code injection.

Mitigation: pull_request_target NICHT verwenden für Auto-Commits auf PRs von Forks.

Sicher:

on:
  pull_request:  # Nur für PRs im eigenen Repo

Unsicher:

on:
  pull_request_target:  # Läuft auch für Forks mit Secrets!

24.8.2 2. Token-Scope minimieren

GITHUB_TOKEN: Nur contents: write wenn nötig. Nicht workflow-level, sondern job-level:

jobs:
  release:
    permissions:
      contents: write
  
  test:
    permissions:
      contents: read  # Nur lesen

24.8.3 3. Signed Commits (Advanced)

GitHub Actions Bot Commits sind nicht GPG-signiert. Für höhere Sicherheit:

- uses: crazy-max/ghaction-import-gpg@v6
  with:
    gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
    passphrase: ${{ secrets.GPG_PASSPHRASE }}
    git_user_signingkey: true
    git_commit_gpgsign: true

- name: Commit with signature
  run: |
    git commit -S -m "chore(release): $VERSION"

Commits erscheinen als “Verified” auf GitHub.