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.
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.
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.
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)
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.
// 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();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/githubDas 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 PATHDas 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.
Entwickeln wir eine praxisnahe Action: Ein PR Size Labeler, der Pull Requests basierend auf der Anzahl geänderter Zeilen mit Labels versieht.
pr-size-labeler/
├── action.yml
├── src/
│ ├── index.js
│ ├── labeler.js
│ └── config.js
├── dist/
│ └── index.js # Gebundelte Version
├── package.json
├── package-lock.json
└── README.md
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'{
"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"
}
}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 };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 };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();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 distDas erzeugt dist/index.js mit allen Dependencies
inlined. Die action.yml verweist auf diese Datei:
runs:
using: 'node20'
main: 'dist/index.js'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 pushTypeScript 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
{
"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"]
}{
"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"
}
}// 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();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();
}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.
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-ScriptTypische 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.
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}`);
}
}
}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);
}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');
});
});// 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')
);
});
});Actions sollten Semantic Versioning folgen:
# .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)
```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-darkNach dem ersten Release kann die Action unter Settings → Releases → Publish to Marketplace veröffentlicht werden.
# .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.