17 Eigene Actions entwickeln: JavaScript Actions

Wenn Shell-Skripte nicht mehr ausreichen, kommen JavaScript Actions ins Spiel. Sie bieten vollen Zugriff auf das GitHub Actions Toolkit, können mit der GitHub API interagieren und komplexe Logik in einer echten Programmiersprache abbilden. In diesem Kapitel entwickeln wir JavaScript Actions von der ersten Zeile bis zum fertigen Release.

17.1 Warum eigene Actions?

GitHub Actions lebt von seinem Ökosystem: Über 20.000 Actions im Marketplace decken die meisten Standardaufgaben ab. Doch irgendwann stößt man an Grenzen:

Eigene Actions lösen diese Probleme. Sie kapseln Logik, sind versionierbar und können organisationsweit geteilt werden.

17.2 Die drei Action-Typen

GitHub Actions unterstützt drei Arten von Custom Actions:

Kriterium Composite JavaScript Docker
Sprache YAML + Shell JavaScript/TypeScript Beliebig
Startup-Zeit Sofort ~1 Sekunde 10-60 Sekunden
GitHub API Via gh CLI Native mit Toolkit Manuell
Dependencies Keine npm packages Container-Image
Debugging Eingeschränkt Volle IDE-Unterstützung Container-Logs
Portabilität Alle Runner Alle Runner Linux Runner

JavaScript Actions sind der Sweet Spot: schnell, mächtig und auf allen Plattformen lauffähig.

17.3 Anatomie einer JavaScript Action

Jede Action benötigt mindestens folgende Struktur:

my-action/
├── action.yml      # Metadaten und Konfiguration
├── index.js        # Einstiegspunkt
├── package.json    # Dependencies
└── node_modules/   # (wird mit ausgeliefert oder gebundelt)

17.3.1 Die action.yml

Die action.yml ist die Schnittstelle der Action zur Außenwelt:

name: 'PR Label Manager'
description: 'Setzt Labels auf Pull Requests basierend auf geänderten Dateien'
author: 'Platform Team'

branding:
  icon: 'tag'
  color: 'purple'

inputs:
  token:
    description: 'GitHub Token für API-Zugriff'
    required: true
  config-path:
    description: 'Pfad zur Label-Konfiguration'
    required: false
    default: '.github/labeler.yml'

outputs:
  labels-added:
    description: 'Kommaseparierte Liste der hinzugefügten Labels'
  labels-removed:
    description: 'Kommaseparierte Liste der entfernten Labels'

runs:
  using: 'node20'
  main: 'dist/index.js'

Das using: 'node20' definiert die Node.js-Version. Aktuell unterstützt GitHub node16 und node20, wobei node20 für neue Actions empfohlen wird.

17.3.2 Der Einstiegspunkt

// index.js
const core = require('@actions/core');
const github = require('@actions/github');

async function run() {
  try {
    // Inputs lesen
    const token = core.getInput('token', { required: true });
    const configPath = core.getInput('config-path');
    
    // Logik ausführen
    core.info(`Analysiere PR mit Config: ${configPath}`);
    
    // Outputs setzen
    core.setOutput('labels-added', 'bug,needs-review');
    
  } catch (error) {
    core.setFailed(`Action fehlgeschlagen: ${error.message}`);
  }
}

run();

17.4 Das Actions Toolkit

GitHub stellt ein offizielles Toolkit bereit, das die Action-Entwicklung drastisch vereinfacht:

Package Zweck
@actions/core Inputs, Outputs, Logging, Secrets
@actions/github Authentifizierter Octokit-Client
@actions/exec Externe Prozesse ausführen
@actions/io Dateioperationen (copy, move, find)
@actions/tool-cache Tools herunterladen und cachen
@actions/artifact Artefakte up-/downloaden
@actions/cache Dependency Caching
@actions/glob Glob Pattern Matching

Installation:

npm init -y
npm install @actions/core @actions/github

17.4.1 @actions/core im Detail

Das core-Package ist das Herzstück jeder JavaScript Action:

const core = require('@actions/core');

// === INPUTS ===
// Einfacher Input
const name = core.getInput('name');

// Required Input (wirft Fehler wenn leer)
const token = core.getInput('token', { required: true });

// Boolean Input
const dryRun = core.getBooleanInput('dry-run');

// Multiline Input (z.B. Liste von Pfaden)
const paths = core.getMultilineInput('paths');
// ['src/', 'tests/', 'docs/']

// === OUTPUTS ===
core.setOutput('result', 'success');
core.setOutput('count', 42);  // Wird zu String

// === LOGGING ===
core.debug('Nur sichtbar mit ACTIONS_STEP_DEBUG=true');
core.info('Normale Ausgabe');
core.notice('Hervorgehobene Info');
core.warning('Warnung, aber kein Fehler');
core.error('Fehler (stoppt Action nicht)');

// Mit Datei-Annotation (erscheint im PR)
core.warning('Deprecated API verwendet', {
  file: 'src/api.js',
  startLine: 42
});

// === GRUPPEN ===
core.startGroup('Installing dependencies');
// ... Output ...
core.endGroup();

// Oder als Wrapper
await core.group('Running tests', async () => {
  // ... async code ...
});

// === SECRETS MASKIEREN ===
const dynamicSecret = generateToken();
core.setSecret(dynamicSecret);  // Wird in allen Logs maskiert

// === FEHLERBEHANDLUNG ===
core.setFailed('Kritischer Fehler');  // Setzt Exit Code 1

// === EXPORT VARIABLES ===
core.exportVariable('MY_VAR', 'value');  // Für nachfolgende Steps
core.addPath('/custom/bin');  // Erweitert PATH

17.4.2 @actions/github für API-Zugriff

Das github-Package liefert einen vorkonfigurierten Octokit-Client und Kontext-Informationen:

const github = require('@actions/github');
const core = require('@actions/core');

async function run() {
  // Context enthält Event-Payload und Metadaten
  const { context } = github;
  
  console.log(`Event: ${context.eventName}`);
  console.log(`Repo: ${context.repo.owner}/${context.repo.repo}`);
  console.log(`Actor: ${context.actor}`);
  console.log(`SHA: ${context.sha}`);
  console.log(`Ref: ${context.ref}`);
  
  // Bei Pull Requests
  if (context.payload.pull_request) {
    console.log(`PR #${context.payload.pull_request.number}`);
    console.log(`Title: ${context.payload.pull_request.title}`);
  }
  
  // Authentifizierter API-Client
  const token = core.getInput('token', { required: true });
  const octokit = github.getOctokit(token);
  
  // API-Calls
  const { data: pr } = await octokit.rest.pulls.get({
    ...context.repo,
    pull_number: context.payload.pull_request.number
  });
  
  // Labels hinzufügen
  await octokit.rest.issues.addLabels({
    ...context.repo,
    issue_number: pr.number,
    labels: ['needs-review']
  });
}

Der Spread-Operator ...context.repo ist ein häufiges Pattern – er fügt automatisch owner und repo ein.

17.5 Eine vollständige Action entwickeln

Entwickeln wir eine praxisnahe Action: Ein PR Size Labeler, der Pull Requests basierend auf der Anzahl geänderter Zeilen mit Labels versieht.

17.5.1 Projektstruktur

pr-size-labeler/
├── action.yml
├── src/
│   ├── index.js
│   ├── labeler.js
│   └── config.js
├── dist/
│   └── index.js      # Gebundelte Version
├── package.json
├── package-lock.json
└── README.md

17.5.2 action.yml

name: 'PR Size Labeler'
description: 'Labels Pull Requests by size (XS, S, M, L, XL) based on changed lines'
author: 'DevOps Team'

branding:
  icon: 'maximize-2'
  color: 'orange'

inputs:
  token:
    description: 'GitHub Token with pull request write access'
    required: true
    default: ${{ github.token }}
  
  xs-max:
    description: 'Maximum lines for XS label'
    required: false
    default: '10'
  
  s-max:
    description: 'Maximum lines for S label'
    required: false
    default: '50'
  
  m-max:
    description: 'Maximum lines for M label'
    required: false
    default: '200'
  
  l-max:
    description: 'Maximum lines for L label'
    required: false
    default: '500'
  
  exclude-patterns:
    description: 'Glob patterns for files to exclude (one per line)'
    required: false
    default: |
      package-lock.json
      yarn.lock
      *.min.js
      *.min.css

outputs:
  size:
    description: 'The determined size (xs, s, m, l, xl)'
  
  total-lines:
    description: 'Total number of changed lines (excluding ignored files)'
  
  label-applied:
    description: 'The label that was applied'

runs:
  using: 'node20'
  main: 'dist/index.js'

17.5.3 package.json

{
  "name": "pr-size-labeler",
  "version": "1.0.0",
  "description": "Labels PRs by size",
  "main": "src/index.js",
  "scripts": {
    "build": "ncc build src/index.js -o dist",
    "test": "jest",
    "lint": "eslint src/"
  },
  "dependencies": {
    "@actions/core": "^1.10.1",
    "@actions/github": "^6.0.0",
    "minimatch": "^9.0.3"
  },
  "devDependencies": {
    "@vercel/ncc": "^0.38.1",
    "jest": "^29.7.0",
    "eslint": "^8.56.0"
  }
}

17.5.4 src/config.js

const core = require('@actions/core');

/**
 * Lädt und validiert die Action-Konfiguration
 */
function loadConfig() {
  const config = {
    thresholds: {
      xs: parseInt(core.getInput('xs-max'), 10),
      s: parseInt(core.getInput('s-max'), 10),
      m: parseInt(core.getInput('m-max'), 10),
      l: parseInt(core.getInput('l-max'), 10)
    },
    excludePatterns: core.getMultilineInput('exclude-patterns')
      .filter(line => line.trim() !== '')
  };
  
  // Validierung
  const { xs, s, m, l } = config.thresholds;
  
  if (xs >= s || s >= m || m >= l) {
    throw new Error(
      `Thresholds müssen aufsteigend sein: xs(${xs}) < s(${s}) < m(${m}) < l(${l})`
    );
  }
  
  for (const [name, value] of Object.entries(config.thresholds)) {
    if (isNaN(value) || value < 0) {
      throw new Error(`Ungültiger Threshold für ${name}: ${value}`);
    }
  }
  
  core.debug(`Config geladen: ${JSON.stringify(config, null, 2)}`);
  
  return config;
}

/**
 * Bestimmt die Größenkategorie basierend auf Zeilenanzahl
 */
function determineSize(lines, thresholds) {
  if (lines <= thresholds.xs) return 'xs';
  if (lines <= thresholds.s) return 's';
  if (lines <= thresholds.m) return 'm';
  if (lines <= thresholds.l) return 'l';
  return 'xl';
}

module.exports = { loadConfig, determineSize };

17.5.5 src/labeler.js

const github = require('@actions/github');
const core = require('@actions/core');
const { minimatch } = require('minimatch');

const SIZE_LABELS = ['size/xs', 'size/s', 'size/m', 'size/l', 'size/xl'];

/**
 * Berechnet die Anzahl geänderter Zeilen (ohne excluded files)
 */
async function calculateChangedLines(octokit, context, excludePatterns) {
  const { owner, repo } = context.repo;
  const pullNumber = context.payload.pull_request.number;
  
  core.info(`Lade geänderte Dateien für PR #${pullNumber}...`);
  
  // Pagination: PRs können viele Dateien haben
  const files = await octokit.paginate(
    octokit.rest.pulls.listFiles,
    {
      owner,
      repo,
      pull_number: pullNumber,
      per_page: 100
    }
  );
  
  core.info(`${files.length} Dateien gefunden`);
  
  let totalAdditions = 0;
  let totalDeletions = 0;
  let excludedFiles = 0;
  
  for (const file of files) {
    const isExcluded = excludePatterns.some(pattern => 
      minimatch(file.filename, pattern)
    );
    
    if (isExcluded) {
      core.debug(`Excluded: ${file.filename}`);
      excludedFiles++;
      continue;
    }
    
    totalAdditions += file.additions;
    totalDeletions += file.deletions;
    
    core.debug(`${file.filename}: +${file.additions} -${file.deletions}`);
  }
  
  const totalLines = totalAdditions + totalDeletions;
  
  core.info(`Änderungen: +${totalAdditions} -${totalDeletions} = ${totalLines} Zeilen`);
  core.info(`Excluded: ${excludedFiles} Dateien`);
  
  return totalLines;
}

/**
 * Aktualisiert die Size-Labels auf dem PR
 */
async function updateLabels(octokit, context, newSize) {
  const { owner, repo } = context.repo;
  const issueNumber = context.payload.pull_request.number;
  const newLabel = `size/${newSize}`;
  
  // Aktuelle Labels laden
  const { data: currentLabels } = await octokit.rest.issues.listLabelsOnIssue({
    owner,
    repo,
    issue_number: issueNumber
  });
  
  const currentLabelNames = currentLabels.map(l => l.name);
  const existingSizeLabels = currentLabelNames.filter(l => SIZE_LABELS.includes(l));
  
  core.debug(`Aktuelle Size-Labels: ${existingSizeLabels.join(', ') || 'keine'}`);
  
  // Bereits korrektes Label?
  if (existingSizeLabels.length === 1 && existingSizeLabels[0] === newLabel) {
    core.info(`Label ${newLabel} bereits gesetzt, keine Änderung nötig`);
    return newLabel;
  }
  
  // Alte Size-Labels entfernen
  for (const oldLabel of existingSizeLabels) {
    if (oldLabel !== newLabel) {
      core.info(`Entferne Label: ${oldLabel}`);
      await octokit.rest.issues.removeLabel({
        owner,
        repo,
        issue_number: issueNumber,
        name: oldLabel
      });
    }
  }
  
  // Neues Label hinzufügen (erstellt es auch, falls nicht existent)
  if (!existingSizeLabels.includes(newLabel)) {
    core.info(`Füge Label hinzu: ${newLabel}`);
    
    try {
      await octokit.rest.issues.addLabels({
        owner,
        repo,
        issue_number: issueNumber,
        labels: [newLabel]
      });
    } catch (error) {
      // Label existiert nicht? Erstellen!
      if (error.status === 404) {
        core.info(`Label ${newLabel} existiert nicht, erstelle es...`);
        await createSizeLabel(octokit, owner, repo, newSize);
        await octokit.rest.issues.addLabels({
          owner,
          repo,
          issue_number: issueNumber,
          labels: [newLabel]
        });
      } else {
        throw error;
      }
    }
  }
  
  return newLabel;
}

/**
 * Erstellt ein Size-Label mit passender Farbe
 */
async function createSizeLabel(octokit, owner, repo, size) {
  const colors = {
    xs: '3CBF00',  // Grün
    s: '5D9801',   // Hellgrün
    m: 'FBCA04',   // Gelb
    l: 'D93F0B',   // Orange
    xl: 'B60205'   // Rot
  };
  
  await octokit.rest.issues.createLabel({
    owner,
    repo,
    name: `size/${size}`,
    color: colors[size],
    description: `Pull Request Size: ${size.toUpperCase()}`
  });
}

module.exports = { calculateChangedLines, updateLabels };

17.5.6 src/index.js

const core = require('@actions/core');
const github = require('@actions/github');
const { loadConfig, determineSize } = require('./config');
const { calculateChangedLines, updateLabels } = require('./labeler');

async function run() {
  try {
    // Kontext prüfen
    const { context } = github;
    
    if (context.eventName !== 'pull_request' && 
        context.eventName !== 'pull_request_target') {
      core.setFailed(
        `Diese Action läuft nur auf pull_request Events, ` +
        `nicht auf '${context.eventName}'`
      );
      return;
    }
    
    if (!context.payload.pull_request) {
      core.setFailed('Keine Pull Request Daten im Event Payload');
      return;
    }
    
    core.info(`PR #${context.payload.pull_request.number}: ${context.payload.pull_request.title}`);
    
    // Konfiguration laden
    const config = loadConfig();
    
    // GitHub Client initialisieren
    const token = core.getInput('token', { required: true });
    const octokit = github.getOctokit(token);
    
    // Änderungen berechnen
    const totalLines = await calculateChangedLines(
      octokit, 
      context, 
      config.excludePatterns
    );
    
    // Größe bestimmen
    const size = determineSize(totalLines, config.thresholds);
    core.info(`Größe: ${size.toUpperCase()} (${totalLines} Zeilen)`);
    
    // Labels aktualisieren
    const appliedLabel = await updateLabels(octokit, context, size);
    
    // Outputs setzen
    core.setOutput('size', size);
    core.setOutput('total-lines', totalLines.toString());
    core.setOutput('label-applied', appliedLabel);
    
    // Summary schreiben
    await core.summary
      .addHeading('PR Size Analysis', 2)
      .addTable([
        ['Metric', 'Value'],
        ['Changed Lines', totalLines.toString()],
        ['Size Category', size.toUpperCase()],
        ['Label Applied', `\`${appliedLabel}\``]
      ])
      .addRaw(`\n\nThresholds: XS ≤${config.thresholds.xs}, S ≤${config.thresholds.s}, M ≤${config.thresholds.m}, L ≤${config.thresholds.l}, XL >${config.thresholds.l}`)
      .write();
    
    core.info(`✓ Action erfolgreich abgeschlossen`);
    
  } catch (error) {
    core.debug(error.stack);
    core.setFailed(`Action fehlgeschlagen: ${error.message}`);
  }
}

run();

17.6 Bundling mit ncc

JavaScript Actions müssen ihre Dependencies mitliefern. Zwei Optionen:

Option 1: node_modules committen – Funktioniert, aber bläht das Repository auf (oft 50+ MB)

Option 2: Bundling mit ncc – Kompiliert alles in eine einzige Datei (empfohlen)

npm install --save-dev @vercel/ncc
npx ncc build src/index.js -o dist

Das erzeugt dist/index.js mit allen Dependencies inlined. Die action.yml verweist auf diese Datei:

runs:
  using: 'node20'
  main: 'dist/index.js'

17.6.1 Build-Workflow

Automatisiere das Bundling bei jedem Push:

# .github/workflows/build.yml
name: Build Action

on:
  push:
    paths:
      - 'src/**'
      - 'package*.json'
  pull_request:
    paths:
      - 'src/**'
      - 'package*.json'

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Run linter
        run: npm run lint
      
      - name: Run tests
        run: npm test
      
      - name: Build
        run: npm run build
      
      - name: Check for changes
        id: diff
        run: |
          if git diff --quiet dist/; then
            echo "changed=false" >> $GITHUB_OUTPUT
          else
            echo "changed=true" >> $GITHUB_OUTPUT
          fi
      
      - name: Commit dist
        if: steps.diff.outputs.changed == 'true' && github.event_name == 'push'
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          git add dist/
          git commit -m "chore: rebuild dist"
          git push

17.7 TypeScript Actions

TypeScript bietet Typsicherheit und bessere IDE-Unterstützung. Die Struktur ändert sich minimal:

my-action/
├── action.yml
├── src/
│   └── index.ts
├── dist/
│   └── index.js
├── tsconfig.json
└── package.json

17.7.1 tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "lib": ["ES2022"],
    "outDir": "./lib",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "declaration": true,
    "resolveJsonModule": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "lib"]
}

17.7.2 TypeScript mit ncc

{
  "scripts": {
    "build": "tsc && ncc build lib/index.js -o dist",
    "watch": "tsc -w"
  },
  "devDependencies": {
    "@types/node": "^20.10.0",
    "typescript": "^5.3.0",
    "@vercel/ncc": "^0.38.1"
  }
}

17.7.3 Typisierter Code

// src/index.ts
import * as core from '@actions/core';
import * as github from '@actions/github';

interface Config {
  thresholds: {
    xs: number;
    s: number;
    m: number;
    l: number;
  };
  excludePatterns: string[];
}

type Size = 'xs' | 's' | 'm' | 'l' | 'xl';

function loadConfig(): Config {
  return {
    thresholds: {
      xs: parseInt(core.getInput('xs-max'), 10),
      s: parseInt(core.getInput('s-max'), 10),
      m: parseInt(core.getInput('m-max'), 10),
      l: parseInt(core.getInput('l-max'), 10)
    },
    excludePatterns: core.getMultilineInput('exclude-patterns')
      .filter((line): line is string => line.trim() !== '')
  };
}

function determineSize(lines: number, thresholds: Config['thresholds']): Size {
  if (lines <= thresholds.xs) return 'xs';
  if (lines <= thresholds.s) return 's';
  if (lines <= thresholds.m) return 'm';
  if (lines <= thresholds.l) return 'l';
  return 'xl';
}

async function run(): Promise<void> {
  try {
    const config = loadConfig();
    const token = core.getInput('token', { required: true });
    const octokit = github.getOctokit(token);
    
    // ... Rest der Logik
    
  } catch (error) {
    if (error instanceof Error) {
      core.setFailed(error.message);
    } else {
      core.setFailed('Ein unbekannter Fehler ist aufgetreten');
    }
  }
}

run();

17.8 Externe Prozesse ausführen

Manchmal muss eine Action externe Tools aufrufen. Das @actions/exec-Package macht das einfach:

const exec = require('@actions/exec');
const core = require('@actions/core');

async function runGitCommand() {
  // Einfacher Befehl
  await exec.exec('git', ['status']);
  
  // Output erfassen
  let output = '';
  let errors = '';
  
  const options = {
    listeners: {
      stdout: (data) => { output += data.toString(); },
      stderr: (data) => { errors += data.toString(); }
    },
    cwd: './subdir',           // Working Directory
    env: { ...process.env, MY_VAR: 'value' },  // Environment
    silent: true,              // Keine Ausgabe in Logs
    ignoreReturnCode: true     // Nicht bei Exit Code != 0 werfen
  };
  
  const exitCode = await exec.exec('git', ['log', '--oneline', '-5'], options);
  
  if (exitCode !== 0) {
    core.warning(`Git command failed: ${errors}`);
  }
  
  return output.trim().split('\n');
}

// Oder mit getExecOutput (einfacher)
async function getLastCommit() {
  const { stdout, stderr, exitCode } = await exec.getExecOutput(
    'git',
    ['log', '-1', '--format=%H'],
    { silent: true }
  );
  
  return stdout.trim();
}

17.9 Tool-Caching

Actions, die externe Tools herunterladen (wie setup-node oder setup-python), nutzen das Tool-Caching:

const tc = require('@actions/tool-cache');
const core = require('@actions/core');
const path = require('path');

async function setupTool() {
  const toolName = 'mytool';
  const version = '1.2.3';
  
  // Prüfen ob bereits gecached
  let toolPath = tc.find(toolName, version);
  
  if (!toolPath) {
    core.info(`${toolName} ${version} nicht im Cache, lade herunter...`);
    
    // Download
    const downloadUrl = `https://example.com/mytool-${version}.tar.gz`;
    const downloadPath = await tc.downloadTool(downloadUrl);
    
    // Entpacken
    const extractedPath = await tc.extractTar(downloadPath);
    
    // In Cache speichern
    toolPath = await tc.cacheDir(extractedPath, toolName, version);
    
    core.info(`${toolName} ${version} gecached in ${toolPath}`);
  } else {
    core.info(`${toolName} ${version} aus Cache geladen`);
  }
  
  // Zum PATH hinzufügen
  core.addPath(path.join(toolPath, 'bin'));
}

Das Tool wird nur beim ersten Mal heruntergeladen, danach aus dem Runner-Cache geladen.

17.10 Pre- und Post-Scripts

JavaScript Actions können Code vor und nach dem Haupt-Script ausführen:

# action.yml
runs:
  using: 'node20'
  pre: 'dist/pre.js'       # Läuft vor allen Steps im Job
  main: 'dist/index.js'    # Haupt-Action
  post: 'dist/post.js'     # Läuft nach allen Steps (auch bei Fehlern)
  post-if: 'always()'      # Bedingung für Post-Script

Typische Anwendungen:

// src/pre.js
const core = require('@actions/core');
const exec = require('@actions/exec');

async function pre() {
  core.info('Starting test database...');
  await exec.exec('docker', ['run', '-d', '--name', 'test-db', 'postgres:15']);
  
  // State für post-Script speichern
  core.saveState('container-started', 'true');
}

pre();
// src/post.js
const core = require('@actions/core');
const exec = require('@actions/exec');

async function post() {
  const containerStarted = core.getState('container-started');
  
  if (containerStarted === 'true') {
    core.info('Stopping test database...');
    await exec.exec('docker', ['rm', '-f', 'test-db']);
  }
}

post();

Der core.saveState() / core.getState() Mechanismus erlaubt Kommunikation zwischen pre, main und post Scripts.

17.11 Fehlerbehandlung und Debugging

17.11.1 Strukturierte Fehlerbehandlung

const core = require('@actions/core');

class ActionError extends Error {
  constructor(message, options = {}) {
    super(message);
    this.name = 'ActionError';
    this.file = options.file;
    this.line = options.line;
    this.recoverable = options.recoverable ?? false;
  }
}

async function run() {
  try {
    await riskyOperation();
  } catch (error) {
    if (error instanceof ActionError) {
      // Bekannter Fehler
      if (error.file) {
        core.error(error.message, { file: error.file, startLine: error.line });
      } else {
        core.error(error.message);
      }
      
      if (!error.recoverable) {
        core.setFailed(error.message);
      }
    } else if (error.status === 404) {
      // GitHub API 404
      core.setFailed('Resource nicht gefunden. Prüfe Berechtigungen des Tokens.');
    } else if (error.status === 403) {
      // Rate Limit oder fehlende Berechtigung
      core.setFailed('Zugriff verweigert. Token hat nicht genügend Berechtigungen.');
    } else {
      // Unerwarteter Fehler
      core.debug(error.stack);
      core.setFailed(`Unerwarteter Fehler: ${error.message}`);
    }
  }
}

17.11.2 Debug-Modus

Nutzer können Debug-Output aktivieren durch ein Repository Secret:

ACTIONS_STEP_DEBUG=true

Im Code:

const core = require('@actions/core');

// Nur bei aktiviertem Debug sichtbar
core.debug(`Token length: ${token.length}`);
core.debug(`Config: ${JSON.stringify(config, null, 2)}`);

// Prüfen ob Debug aktiv
if (core.isDebug()) {
  // Aufwändige Debug-Operationen
  const detailedInfo = await gatherDetailedInfo();
  core.debug(detailedInfo);
}

17.12 Unit Testing

Actions sollten testbar sein. Trenne Logik von GitHub-spezifischem Code:

// src/calculator.js - Reine Logik, keine GitHub-Dependencies
function determineSize(lines, thresholds) {
  if (lines <= thresholds.xs) return 'xs';
  if (lines <= thresholds.s) return 's';
  if (lines <= thresholds.m) return 'm';
  if (lines <= thresholds.l) return 'l';
  return 'xl';
}

module.exports = { determineSize };
// tests/calculator.test.js
const { determineSize } = require('../src/calculator');

const defaultThresholds = { xs: 10, s: 50, m: 200, l: 500 };

describe('determineSize', () => {
  test('returns xs for 0 lines', () => {
    expect(determineSize(0, defaultThresholds)).toBe('xs');
  });
  
  test('returns xs for exactly xs threshold', () => {
    expect(determineSize(10, defaultThresholds)).toBe('xs');
  });
  
  test('returns s for xs+1 lines', () => {
    expect(determineSize(11, defaultThresholds)).toBe('s');
  });
  
  test('returns xl for lines above l threshold', () => {
    expect(determineSize(501, defaultThresholds)).toBe('xl');
    expect(determineSize(10000, defaultThresholds)).toBe('xl');
  });
  
  test('works with custom thresholds', () => {
    const custom = { xs: 5, s: 10, m: 20, l: 50 };
    expect(determineSize(15, custom)).toBe('m');
  });
});

17.12.1 Mocking von @actions/core

// tests/index.test.js
const core = require('@actions/core');
const github = require('@actions/github');

// Mock @actions/core
jest.mock('@actions/core');
jest.mock('@actions/github');

describe('Action', () => {
  beforeEach(() => {
    jest.clearAllMocks();
    
    // Standard-Mocks
    core.getInput.mockImplementation((name) => {
      const inputs = {
        'token': 'fake-token',
        'xs-max': '10',
        's-max': '50',
        'm-max': '200',
        'l-max': '500'
      };
      return inputs[name] || '';
    });
    
    github.context = {
      eventName: 'pull_request',
      repo: { owner: 'test-owner', repo: 'test-repo' },
      payload: {
        pull_request: { number: 42, title: 'Test PR' }
      }
    };
  });
  
  test('sets output correctly', async () => {
    // ... test code ...
    
    expect(core.setOutput).toHaveBeenCalledWith('size', 'm');
  });
  
  test('fails on non-PR event', async () => {
    github.context.eventName = 'push';
    
    await require('../src/index');
    
    expect(core.setFailed).toHaveBeenCalledWith(
      expect.stringContaining('pull_request')
    );
  });
});

17.13 Veröffentlichung und Versionierung

17.13.1 Semantic Versioning

Actions sollten Semantic Versioning folgen:

17.13.2 Release-Workflow

# .github/workflows/release.yml
name: Release

on:
  push:
    tags:
      - 'v*.*.*'

jobs:
  release:
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
      
      - name: Install and build
        run: |
          npm ci
          npm run build
      
      - name: Get version
        id: version
        run: echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
      
      - name: Update major tag
        run: |
          VERSION=${{ steps.version.outputs.version }}
          MAJOR=$(echo $VERSION | cut -d. -f1)
          
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          
          git tag -fa $MAJOR -m "Update $MAJOR to $VERSION"
          git push origin $MAJOR --force
      
      - name: Create Release
        uses: softprops/action-gh-release@v1
        with:
          generate_release_notes: true
          body: |
            ## Installation
            
            ```yaml
            - uses: myorg/pr-size-labeler@${{ steps.version.outputs.version }}
            ```
            
            Oder mit Major-Tag für automatische Minor/Patch-Updates:
            
            ```yaml
            - uses: myorg/pr-size-labeler@$(echo ${{ steps.version.outputs.version }} | cut -d. -f1)
            ```

17.13.3 Marketplace-Veröffentlichung

Für den GitHub Marketplace braucht die action.yml zusätzliche Felder:

name: 'PR Size Labeler'  # Muss unique im Marketplace sein
description: 'Labels Pull Requests by size based on changed lines'
author: 'Your Name'

branding:
  icon: 'maximize-2'      # Lucide Icon Name
  color: 'orange'         # white, yellow, blue, green, orange, red, purple, gray-dark

Nach dem ersten Release kann die Action unter Settings → Releases → Publish to Marketplace veröffentlicht werden.

17.14 Verwendung der fertigen Action

# .github/workflows/pr-labeler.yml
name: PR Size Labeler

on:
  pull_request:
    types: [opened, synchronize]

jobs:
  label:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write
    steps:
      - name: Label PR by size
        uses: myorg/pr-size-labeler@v1
        with:
          token: ${{ secrets.GITHUB_TOKEN }}
          xs-max: '20'
          s-max: '100'
          m-max: '300'
          l-max: '800'
          exclude-patterns: |
            package-lock.json
            pnpm-lock.yaml
            **/*.generated.*

Die Action labelt jeden PR automatisch mit size/xs bis size/xl – ein kleines Feature, das die PR-Übersicht deutlich verbessert und als Grundlage für Review-Policies dienen kann.