Example Workflows¶
Complete examples of GitHub Actions built with github-action-toolkit.
Table of Contents¶
Simple Greeter Action¶
A basic action that greets users and demonstrates input/output handling.
action.py¶
"""Simple greeting action."""
from github_action_toolkit import (
get_user_input,
set_output,
info,
notice,
JobSummary,
)
def main():
# Get input
name = get_user_input('name') or 'World'
greeting_type = get_user_input('greeting') or 'Hello'
# Create greeting
greeting = f"{greeting_type}, {name}!"
# Output to console
info(f"Creating greeting for {name}...")
notice(greeting, title='Greeting Created')
# Set output for other steps
set_output('greeting', greeting)
set_output('name', name)
# Create job summary
summary = JobSummary()
summary.add_heading('Greeting Action', 1)
summary.add_quote(greeting)
summary.add_raw(f'\nGreeted: **{name}**')
summary.write()
info('Action completed successfully!')
return 0
if __name__ == '__main__':
raise SystemExit(main())
action.yml¶
name: 'Greeter'
description: 'Greets someone'
inputs:
name:
description: 'Who to greet'
required: false
default: 'World'
greeting:
description: 'Type of greeting'
required: false
default: 'Hello'
outputs:
greeting:
description: 'The full greeting message'
name:
description: 'The name that was greeted'
runs:
using: 'composite'
steps:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: pip install github-action-toolkit
shell: bash
- name: Run action
run: python ${{ github.action_path }}/action.py
shell: bash
Workflow Usage¶
name: Test Greeter
on: [push]
jobs:
greet:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Greet
id: greet
uses: ./
with:
name: 'GitHub'
greeting: 'Hello'
- name: Show greeting
run: echo "${{ steps.greet.outputs.greeting }}"
Code Linter Action¶
An action that runs a linter and creates annotations for issues found.
action.py¶
"""Python code linter action."""
import subprocess
import re
from pathlib import Path
from github_action_toolkit import (
get_user_input,
get_user_input_as,
set_output,
info,
warning,
error,
group,
JobSummary,
)
def run_linter(path: str) -> tuple[int, str]:
"""Run pylint and capture output."""
result = subprocess.run(
['pylint', path],
capture_output=True,
text=True,
)
return result.returncode, result.stdout
def parse_pylint_output(output: str) -> list[dict]:
"""Parse pylint output into structured data."""
issues = []
pattern = r'^(.+?):(\d+):(\d+): ([EWC]\d+): (.+)$'
for line in output.splitlines():
match = re.match(pattern, line)
if not match:
continue
file, line_num, col, code, message = match.groups()
issues.append({
'file': file,
'line': int(line_num),
'col': int(col),
'code': code,
'message': message,
'severity': 'error' if code.startswith('E') else 'warning'
})
return issues
def create_annotations(issues: list[dict]):
"""Create GitHub annotations for issues."""
for issue in issues:
func = error if issue['severity'] == 'error' else warning
func(
issue['message'],
file=issue['file'],
line=issue['line'],
col=issue['col'],
title=f"Lint {issue['code']}"
)
def create_summary(issues: list[dict]):
"""Create job summary with lint results."""
summary = JobSummary()
summary.add_heading('Lint Results', 1)
errors = [i for i in issues if i['severity'] == 'error']
warnings = [i for i in issues if i['severity'] == 'warning']
# Summary table
summary.add_table([
['Severity', 'Count'],
['Errors', str(len(errors))],
['Warnings', str(len(warnings))],
['Total', str(len(issues))],
])
# Details
if errors:
summary.add_separator()
summary.add_heading('Errors', 2)
for err in errors[:10]: # Show first 10
summary.add_raw(
f"- **{err['file']}:{err['line']}** - {err['message']}\n"
)
if len(errors) > 10:
summary.add_raw(f"\n*...and {len(errors) - 10} more errors*\n")
summary.write()
def main():
# Get inputs
path = get_user_input('path') or '.'
fail_on_error = get_user_input_as('fail-on-error', bool, default_value=True)
info(f'Linting path: {path}')
# Run linter
with group('Running Pylint'):
returncode, output = run_linter(path)
info(f'Pylint finished with code {returncode}')
# Parse results
with group('Processing Results'):
issues = parse_pylint_output(output)
info(f'Found {len(issues)} issues')
# Create annotations
create_annotations(issues)
# Create summary
create_summary(issues)
# Set outputs
errors = sum(1 for i in issues if i['severity'] == 'error')
warnings = sum(1 for i in issues if i['severity'] == 'warning')
set_output('errors', str(errors))
set_output('warnings', str(warnings))
set_output('total-issues', str(len(issues)))
# Fail if configured
if fail_on_error and errors > 0:
error(f'Linting failed with {errors} errors', title='Lint Failed')
return 1
info('Linting complete!')
return 0
if __name__ == '__main__':
raise SystemExit(main())
action.yml¶
name: 'Python Linter'
description: 'Lint Python code and create annotations'
inputs:
path:
description: 'Path to lint'
required: false
default: '.'
fail-on-error:
description: 'Fail the action if errors are found'
required: false
default: 'true'
outputs:
errors:
description: 'Number of errors found'
warnings:
description: 'Number of warnings found'
total-issues:
description: 'Total issues found'
runs:
using: 'composite'
steps:
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- run: pip install pylint github-action-toolkit
shell: bash
- run: python ${{ github.action_path }}/action.py
shell: bash
Test Reporter Action¶
Reports test results with rich formatting and annotations.
action.py¶
"""Test reporter action."""
import json
from pathlib import Path
from github_action_toolkit import (
get_user_input,
get_user_input_as,
set_output,
info,
error,
group,
JobSummary,
JobSummaryTemplate,
GitHubArtifacts,
)
def load_test_results(file_path: str) -> dict:
"""Load test results from JSON file."""
return json.loads(Path(file_path).read_text())
def create_annotations(failures: list[dict]):
"""Create annotations for test failures."""
for failure in failures:
error(
failure['message'],
file=failure['file'],
line=failure['line'],
title=f"Test Failed: {failure['name']}"
)
def main():
# Get inputs
results_file = get_user_input('results-file') or 'test-results.json'
upload_artifacts = get_user_input_as('upload-artifacts', bool, default_value=True)
info(f'Loading test results from {results_file}')
# Load results
with group('Loading Results'):
results = load_test_results(results_file)
info(f"Tests: {results['total']}, "
f"Passed: {results['passed']}, "
f"Failed: {results['failed']}")
# Create annotations for failures
if results['failures']:
with group('Creating Annotations'):
create_annotations(results['failures'])
# Create summary using template
with group('Creating Summary'):
summary = JobSummaryTemplate.test_report(
title='Test Results',
passed=results['passed'],
failed=results['failed'],
skipped=results.get('skipped', 0),
duration=results['duration']
)
# Add failure details
if results['failures']:
summary.add_separator()
summary.add_heading('Failed Tests', 2)
for failure in results['failures'][:5]: # First 5
summary.add_details(
f"❌ {failure['name']}",
f"```\n{failure['traceback']}\n```"
)
summary.write()
# Upload artifacts
if upload_artifacts and Path('htmlcov').exists():
with group('Uploading Artifacts'):
artifacts = GitHubArtifacts()
artifacts.upload_artifact(
name='test-results',
paths=['htmlcov/', results_file],
retention_days=30
)
info('Uploaded test results and coverage')
# Set outputs
set_output('passed', str(results['passed']))
set_output('failed', str(results['failed']))
set_output('status', 'success' if results['failed'] == 0 else 'failure')
# Fail if tests failed
if results['failed'] > 0:
error(f"{results['failed']} tests failed", title='Tests Failed')
return 1
info('All tests passed!')
return 0
if __name__ == '__main__':
raise SystemExit(main())
Deployment Action¶
Complete deployment workflow with validation and rollback.
action.py¶
"""Deployment action with validation."""
from github_action_toolkit import (
get_user_input,
get_user_input_as,
set_output,
set_env,
info,
warning,
error,
group,
JobSummary,
CancellationHandler,
EventPayload,
)
from github_action_toolkit.exceptions import CancellationRequested
def validate_inputs() -> dict:
"""Validate deployment inputs."""
environment = get_user_input('environment')
if environment not in ['dev', 'staging', 'production']:
raise ValueError(f"Invalid environment: {environment}")
version = get_user_input('version')
if not version:
raise ValueError("version is required")
dry_run = get_user_input_as('dry-run', bool, default_value=False)
return {
'environment': environment,
'version': version,
'dry_run': dry_run,
}
def deploy(config: dict) -> dict:
"""Run deployment."""
info(f"Deploying version {config['version']} "
f"to {config['environment']}")
if config['dry_run']:
warning('Dry run mode - no actual deployment', title='Dry Run')
return {'status': 'dry-run', 'url': None}
# Actual deployment logic here
deploy_url = f"https://{config['environment']}.example.com"
return {
'status': 'success',
'url': deploy_url,
}
def create_deployment_summary(config: dict, result: dict):
"""Create deployment summary."""
summary = JobSummary()
summary.add_heading('Deployment Summary', 1)
# Get event info
event = EventPayload()
summary.add_table([
['Item', 'Value'],
['Environment', config['environment']],
['Version', config['version']],
['Status', result['status']],
['Deployed By', event.actor],
['Commit', event.sha[:8]],
])
if result['url']:
summary.add_separator()
summary.add_link('View Application', result['url'])
if config['dry_run']:
summary.add_separator()
summary.add_quote('⚠️ This was a dry run. No actual deployment occurred.')
summary.write()
def main():
# Setup cancellation handling
cancellation = CancellationHandler()
def cleanup():
warning('Deployment cancelled - running cleanup')
# Cleanup logic
cancellation.register(cleanup)
cancellation.enable()
try:
# Validate inputs
with group('Validation'):
config = validate_inputs()
info('✓ All inputs valid')
# Deploy
with group('Deployment'):
result = deploy(config)
info(f"✓ Deployment {result['status']}")
# Create summary
with group('Summary'):
create_deployment_summary(config, result)
# Set outputs
set_output('status', result['status'])
if result['url']:
set_output('url', result['url'])
set_env('DEPLOY_URL', result['url'])
info('Deployment complete!')
return 0
except CancellationRequested:
error('Deployment cancelled', title='Cancelled')
return 1
except Exception as e:
error(f'Deployment failed: {e}', title='Deployment Error')
return 1
if __name__ == '__main__':
raise SystemExit(main())
Multi-Step Pipeline¶
Complex pipeline with multiple stages and caching.
action.py¶
"""Multi-stage build and test pipeline."""
from pathlib import Path
from github_action_toolkit import (
get_user_input_as,
set_output,
info,
error,
group,
JobSummary,
GitHubCache,
GitHubArtifacts,
)
def stage_setup(cache: GitHubCache) -> bool:
"""Setup stage with caching."""
with group('Setup'):
# Try to restore cache
cache_hit = cache.restore_cache(
paths=['.venv'],
key='deps-v1',
)
if not cache_hit:
info('Installing dependencies...')
# Install logic
cache.save_cache(paths=['.venv'], key='deps-v1')
else:
info('✓ Using cached dependencies')
return True
def stage_build() -> bool:
"""Build stage."""
with group('Build'):
info('Compiling source...')
# Build logic
info('✓ Build successful')
return True
def stage_test() -> dict:
"""Test stage."""
with group('Test'):
info('Running tests...')
# Test logic
results = {'passed': 42, 'failed': 0}
info(f"✓ Tests: {results['passed']} passed")
return results
def stage_package(artifacts: GitHubArtifacts) -> bool:
"""Package stage."""
with group('Package'):
info('Creating distribution packages...')
# Package logic
# Upload artifacts
artifacts.upload_artifact(
name='dist',
paths=['dist/'],
)
info('✓ Package uploaded')
return True
def create_pipeline_summary(stages: dict):
"""Create summary of all stages."""
summary = JobSummary()
summary.add_heading('Pipeline Summary', 1)
# Stage status
rows = [['Stage', 'Status', 'Duration']]
for stage_name, stage_data in stages.items():
status = '✓' if stage_data['success'] else '✗'
rows.append([
stage_name.title(),
status,
stage_data['duration']
])
summary.add_table(rows)
summary.write()
def main():
skip_tests = get_user_input_as('skip-tests', bool, default_value=False)
cache = GitHubCache()
artifacts = GitHubArtifacts()
stages = {}
try:
# Setup
stages['setup'] = {
'success': stage_setup(cache),
'duration': '1.2s'
}
# Build
stages['build'] = {
'success': stage_build(),
'duration': '3.5s'
}
# Test (optional)
if not skip_tests:
test_results = stage_test()
stages['test'] = {
'success': test_results['failed'] == 0,
'duration': '8.3s'
}
if test_results['failed'] > 0:
error(f"{test_results['failed']} tests failed")
return 1
# Package
stages['package'] = {
'success': stage_package(artifacts),
'duration': '2.1s'
}
# Summary
create_pipeline_summary(stages)
set_output('status', 'success')
info('✓ Pipeline complete!')
return 0
except Exception as e:
error(f'Pipeline failed: {e}', title='Pipeline Error')
set_output('status', 'failure')
return 1
if __name__ == '__main__':
raise SystemExit(main())
Conditional Execution¶
Action with conditional logic based on event type.
action.py¶
"""Conditional action based on event type."""
from github_action_toolkit import (
info,
warning,
group,
JobSummary,
EventPayload,
)
def handle_pull_request(event: EventPayload):
"""Handle pull request events."""
with group('Pull Request Handler'):
pr_number = event.get_pr_number()
info(f'Processing PR #{pr_number}')
# PR-specific logic
info(f'Head ref: {event.head_ref}')
info(f'Base ref: {event.base_ref}')
changed_files = event.get_changed_files()
if changed_files:
info(f'Changed files: {", ".join(changed_files[:5])}')
def handle_push(event: EventPayload):
"""Handle push events."""
with group('Push Handler'):
info(f'Processing push to {event.ref}')
info(f'Commit: {event.sha[:8]}')
# Push-specific logic
def handle_release(event: EventPayload):
"""Handle release events."""
with group('Release Handler'):
info('Processing release event')
# Release-specific logic
def main():
event = EventPayload()
info(f'Event type: {event.event_name}')
# Conditional execution
if event.is_pr():
handle_pull_request(event)
elif event.event_name == 'push':
handle_push(event)
elif event.event_name == 'release':
handle_release(event)
else:
warning(
f"Unsupported event type: {event.event_name}",
title='Unsupported Event'
)
return 1
# Create summary
summary = JobSummary()
summary.add_heading('Event Summary', 1)
summary.add_table([
['Property', 'Value'],
['Event', event.event_name],
['Actor', event.actor],
['Repository', event.repository],
['Ref', event.ref],
])
summary.write()
info('Action complete!')
return 0
if __name__ == '__main__':
raise SystemExit(main())
Complete Example: Build and Test Action¶
Here’s a complete example combining multiple patterns:
"""
Complete action that builds, tests, and reports results.
"""
from pathlib import Path
from github_action_toolkit import (
get_user_input,
get_user_input_as,
set_output,
info,
warning,
error,
group,
JobSummary,
GitHubCache,
GitHubArtifacts,
)
def main():
# Get inputs
with group('Configuration'):
python_version = get_user_input('python-version') or '3.11'
coverage_threshold = get_user_input_as(
'coverage-threshold',
float,
default_value=80.0
)
upload_artifacts = get_user_input_as(
'upload-artifacts',
bool,
default_value=True
)
info(f'Python version: {python_version}')
info(f'Coverage threshold: {coverage_threshold}%')
# Try cache
with group('Cache'):
cache = GitHubCache()
cache_hit = cache.restore_cache(
paths=['.venv'],
key=f'deps-{python_version}'
)
if not cache_hit:
info('Installing dependencies...')
# Install logic here
cache.save_cache(paths=['.venv'], key=f'deps-{python_version}')
# Build
with group('Build'):
try:
# Build logic here
info('Build successful')
set_output('build-status', 'success')
except Exception as e:
error(f'Build failed: {e}', title='Build Error')
set_output('build-status', 'failure')
return 1
# Test
with group('Test'):
results = run_tests() # Your test logic
set_output('tests-passed', str(results['passed']))
set_output('tests-failed', str(results['failed']))
# Create summary
summary = JobSummary()
summary.add_heading('Build & Test Results', 1)
summary.add_table([
['Metric', 'Value'],
['Build Status', '✓ Success'],
['Tests Passed', str(results['passed'])],
['Tests Failed', str(results['failed'])],
['Coverage', f"{results['coverage']:.1f}%"],
])
if results['coverage'] < coverage_threshold:
warning(
f"Coverage {results['coverage']:.1f}% is below "
f"threshold {coverage_threshold}%",
title='Low Coverage'
)
summary.add_quote(
f"⚠️ Coverage below threshold: "
f"{results['coverage']:.1f}% < {coverage_threshold}%"
)
summary.write()
# Upload artifacts
if upload_artifacts:
with group('Artifacts'):
artifacts = GitHubArtifacts()
artifacts.upload_artifact(
name='test-results',
paths=['test-results/'],
retention_days=30
)
info('Uploaded test results')
return 0
if __name__ == '__main__':
raise SystemExit(main())
Python Test Reporter Action¶
Complete test reporting action that runs pytest, parses results, creates annotations, and generates comprehensive job summaries with coverage information.
test_reporter_action.py¶
"""
Example: Python Test Reporter Action
This action runs pytest, parses the results, creates annotations for failures,
and generates a comprehensive job summary with coverage information.
"""
import json
import subprocess
from pathlib import Path
from github_action_toolkit import (
JobSummary,
error,
get_user_input_as,
group,
info,
set_output,
warning,
)
def run_pytest(coverage: bool = True) -> tuple[int, str]:
"""Run pytest with optional coverage."""
cmd = ['pytest', '--json-report', '--json-report-file=test-report.json']
if coverage:
cmd.extend(['--cov', '--cov-report=json'])
result = subprocess.run(cmd, capture_output=True, text=True)
return result.returncode, result.stdout
def parse_test_results(report_file: Path) -> dict:
"""Parse pytest JSON report."""
if not report_file.exists():
return {
'total': 0,
'passed': 0,
'failed': 0,
'skipped': 0,
'duration': '0s',
'failures': [],
}
data = json.loads(report_file.read_text())
failures = []
for test in data.get('tests', []):
if test['outcome'] == 'failed':
# Extract file and line from nodeid
nodeid = test['nodeid']
if '::' in nodeid:
file_path = nodeid.split('::')[0]
failures.append(
{
'name': test['nodeid'],
'message': test.get('call', {}).get('longrepr', 'Test failed'),
'file': file_path,
}
)
return {
'total': data['summary']['total'],
'passed': data['summary'].get('passed', 0),
'failed': data['summary'].get('failed', 0),
'skipped': data['summary'].get('skipped', 0),
'duration': f"{data['duration']:.2f}s",
'failures': failures,
}
def parse_coverage_report(coverage_file: Path) -> dict[str, float]:
"""Parse coverage.json report."""
if not coverage_file.exists():
return {}
data = json.loads(coverage_file.read_text())
files_coverage = {}
for file_path, file_data in data.get('files', {}).items():
# Calculate coverage percentage
covered = file_data['summary']['covered_lines']
total = file_data['summary']['num_statements']
if total > 0:
percentage = (covered / total) * 100
files_coverage[file_path] = percentage
return files_coverage
def create_test_annotations(failures: list[dict]):
"""Create GitHub annotations for test failures."""
for failure in failures:
error(
failure['message'],
file=failure.get('file', ''),
title=f"Test Failed: {failure['name']}",
)
def coverage_badge(percentage: float) -> str:
"""Return emoji badge for coverage level."""
if percentage >= 90:
return '🟢'
elif percentage >= 75:
return '🟡'
else:
return '🔴'
def create_summary(results: dict, coverage_data: dict[str, float], threshold: float):
"""Create comprehensive job summary."""
summary = JobSummary()
# Test Results Header
summary.add_heading('Test Report', 1)
# Overall Stats
summary.add_table(
[
['Metric', 'Value'],
['Total Tests', str(results['total'])],
['✓ Passed', str(results['passed'])],
['✗ Failed', str(results['failed'])],
['⊘ Skipped', str(results['skipped'])],
['Duration', results['duration']],
]
)
# Test Failures
if results['failures']:
summary.add_separator()
summary.add_heading('Failed Tests', 2)
for failure in results['failures'][:10]: # Show first 10
summary.add_details(
f"✗ {failure['name']}",
f"**File:** {failure['file']}\n\n{failure['message']}",
)
if len(results['failures']) > 10:
summary.add_raw(
f"\n*...and {len(results['failures']) - 10} more failures*\n"
)
# Coverage Report
if coverage_data:
summary.add_separator()
summary.add_heading('Code Coverage', 2)
# Overall coverage
overall_coverage = sum(coverage_data.values()) / len(coverage_data)
rows = [['File', 'Coverage', 'Status']]
for file, coverage in sorted(coverage_data.items()):
badge = coverage_badge(coverage)
rows.append([file, f'{coverage:.1f}%', badge])
summary.add_table(rows)
# Coverage threshold check
if overall_coverage < threshold:
summary.add_separator()
summary.add_quote(
f"⚠️ Coverage ({overall_coverage:.1f}%) is below "
f"threshold ({threshold}%)"
)
else:
summary.add_separator()
summary.add_quote(
f"✓ Coverage ({overall_coverage:.1f}%) meets "
f"threshold ({threshold}%)"
)
summary.write()
def main():
"""Main action entry point."""
# Get configuration
coverage_enabled = get_user_input_as('coverage', bool, default_value=True)
coverage_threshold = get_user_input_as('coverage-threshold', float, default_value=80.0)
fail_on_error = get_user_input_as('fail-on-error', bool, default_value=True)
info(f'Running tests with coverage: {coverage_enabled}')
if coverage_enabled:
info(f'Coverage threshold: {coverage_threshold}%')
# Run tests
with group('Running Tests'):
returncode, output = run_pytest(coverage=coverage_enabled)
if returncode == 0:
info('✓ All tests passed')
else:
warning('Some tests failed')
# Parse results
with group('Processing Results'):
results = parse_test_results(Path('test-report.json'))
info(
f"Results: {results['passed']} passed, "
f"{results['failed']} failed, "
f"{results['skipped']} skipped"
)
coverage_data = {}
if coverage_enabled:
coverage_data = parse_coverage_report(Path('coverage.json'))
if coverage_data:
overall = sum(coverage_data.values()) / len(coverage_data)
info(f'Overall coverage: {overall:.1f}%')
# Create annotations
if results['failures']:
with group('Creating Annotations'):
create_test_annotations(results['failures'])
# Create summary
with group('Creating Summary'):
create_summary(results, coverage_data, coverage_threshold)
# Set outputs
set_output('total', str(results['total']))
set_output('passed', str(results['passed']))
set_output('failed', str(results['failed']))
set_output('skipped', str(results['skipped']))
if coverage_data:
overall = sum(coverage_data.values()) / len(coverage_data)
set_output('coverage', f'{overall:.1f}')
# Determine exit code
if fail_on_error and results['failed'] > 0:
error(f"{results['failed']} tests failed", title='Tests Failed')
return 1
if coverage_data and overall < coverage_threshold:
error(
f"Coverage {overall:.1f}% below threshold {coverage_threshold}%",
title='Coverage Too Low',
)
return 1
info('✓ Tests completed successfully')
return 0
if __name__ == '__main__':
raise SystemExit(main())
Docker Build and Push Action¶
Complete Docker workflow that builds images, scans for vulnerabilities, and pushes to container registries with proper tagging and security.
docker_build_action.py¶
"""
Example: Docker Build and Push Action
This action builds a Docker image, scans it for vulnerabilities, and pushes it
to a container registry with proper tagging.
"""
import os
import re
import subprocess
from pathlib import Path
from github_action_toolkit import (
EventPayload,
JobSummary,
add_mask,
error,
get_user_input,
get_user_input_as,
group,
info,
set_output,
warning,
)
def validate_image_name(image: str) -> bool:
"""Validate Docker image name format."""
pattern = r'^[a-z0-9]+(?:[._-][a-z0-9]+)*(?:/[a-z0-9]+(?:[._-][a-z0-9]+)*)*$'
return bool(re.match(pattern, image.lower()))
def get_image_tags(event: EventPayload, tag_prefix: str) -> list[str]:
"""Generate appropriate tags based on the event."""
tags = []
# Always add commit SHA tag
tags.append(f'{tag_prefix}:{event.sha[:8]}')
# Add branch/PR tags
if event.is_pr():
pr_number = event.get_pr_number()
if pr_number:
tags.append(f'{tag_prefix}:pr-{pr_number}')
elif event.ref.startswith('refs/heads/'):
branch = event.ref.replace('refs/heads/', '')
# Clean branch name for tag
clean_branch = re.sub(r'[^a-z0-9._-]', '-', branch.lower())
tags.append(f'{tag_prefix}:{clean_branch}')
# Add 'latest' for main/master
if branch in ['main', 'master']:
tags.append(f'{tag_prefix}:latest')
return tags
def docker_login(registry: str, username: str, password: str):
"""Login to Docker registry."""
add_mask(password) # Mask password from logs
result = subprocess.run(
['docker', 'login', registry, '-u', username, '--password-stdin'],
input=password.encode(),
capture_output=True,
)
if result.returncode != 0:
raise RuntimeError(f'Docker login failed: {result.stderr.decode()}')
info(f'✓ Logged into {registry}')
def docker_build(dockerfile: Path, context: Path, image_tag: str, build_args: dict) -> str:
"""Build Docker image."""
cmd = ['docker', 'build', '-f', str(dockerfile), '-t', image_tag]
# Add build args
for key, value in build_args.items():
cmd.extend(['--build-arg', f'{key}={value}'])
cmd.append(str(context))
info(f'Building image: {image_tag}')
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f'Docker build failed:\n{result.stderr}')
# Get image ID
result = subprocess.run(
['docker', 'images', '-q', image_tag], capture_output=True, text=True
)
image_id = result.stdout.strip()
info(f'✓ Built image: {image_id[:12]}')
return image_id
def docker_push(image_tag: str):
"""Push Docker image to registry."""
info(f'Pushing {image_tag}...')
result = subprocess.run(['docker', 'push', image_tag], capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f'Docker push failed:\n{result.stderr}')
info(f'✓ Pushed {image_tag}')
def scan_image(image_tag: str) -> dict:
"""Scan image for vulnerabilities (example using trivy)."""
info(f'Scanning {image_tag} for vulnerabilities...')
result = subprocess.run(
['trivy', 'image', '--format', 'json', '--quiet', image_tag],
capture_output=True,
text=True,
)
if result.returncode != 0:
warning(f'Image scan completed with warnings')
return {}
# Parse scan results (simplified)
# In production, parse the JSON and extract vulnerability counts
info('✓ Security scan complete')
return {'critical': 0, 'high': 2, 'medium': 5, 'low': 10}
def create_summary(image: str, tags: list[str], image_id: str, scan_results: dict):
"""Create job summary for the build."""
summary = JobSummary()
summary.add_heading('Docker Build Summary', 1)
# Build info
summary.add_table(
[
['Property', 'Value'],
['Image', image],
['Image ID', image_id[:12]],
['Tags', str(len(tags))],
]
)
# Tags
summary.add_separator()
summary.add_heading('Image Tags', 2)
for tag in tags:
summary.add_raw(f'- `{tag}`\n')
# Security scan
if scan_results:
summary.add_separator()
summary.add_heading('Security Scan', 2)
summary.add_table(
[
['Severity', 'Count'],
['Critical', str(scan_results.get('critical', 0))],
['High', str(scan_results.get('high', 0))],
['Medium', str(scan_results.get('medium', 0))],
['Low', str(scan_results.get('low', 0))],
]
)
if scan_results.get('critical', 0) > 0:
summary.add_separator()
summary.add_quote('⚠️ Critical vulnerabilities detected!')
summary.write()
def main():
"""Main action entry point."""
# Get inputs
image_name = get_user_input('image-name')
if not image_name:
error('image-name is required', title='Missing Input')
return 1
if not validate_image_name(image_name):
error(
f'Invalid image name: {image_name}. '
'Must contain only lowercase letters, numbers, and separators.',
title='Invalid Input',
)
return 1
registry = get_user_input('registry') or 'docker.io'
dockerfile = Path(get_user_input('dockerfile') or 'Dockerfile')
context = Path(get_user_input('context') or '.')
push_enabled = get_user_input_as('push', bool, default_value=True)
scan_enabled = get_user_input_as('scan', bool, default_value=True)
# Get credentials (mask them)
username = get_user_input('username')
password = get_user_input('password')
if password:
add_mask(password)
# Validate files exist
if not dockerfile.exists():
error(f'Dockerfile not found: {dockerfile}', title='File Not Found')
return 1
if not context.exists():
error(f'Build context not found: {context}', title='Directory Not Found')
return 1
info(f'Building {image_name} from {dockerfile}')
# Get event info for tagging
event = EventPayload()
full_image_name = f'{registry}/{image_name}'
tags = get_image_tags(event, full_image_name)
info(f'Will create {len(tags)} tags')
# Build arguments
build_args = {
'BUILD_DATE': event.sha,
'VCS_REF': event.sha[:8],
}
image_id = None
scan_results = {}
try:
# Build image
with group('Building Image'):
image_id = docker_build(dockerfile, context, tags[0], build_args)
# Tag with all tags
with group('Tagging Image'):
for tag in tags[1:]:
subprocess.run(['docker', 'tag', tags[0], tag], check=True)
info(f'✓ Tagged as {tag}')
# Security scan
if scan_enabled:
with group('Security Scan'):
scan_results = scan_image(tags[0])
# Push to registry
if push_enabled:
if not username or not password:
warning(
'Username/password not provided, skipping push',
title='Push Skipped',
)
else:
with group('Pushing to Registry'):
docker_login(registry, username, password)
for tag in tags:
docker_push(tag)
else:
info('Push disabled, skipping')
# Create summary
with group('Creating Summary'):
create_summary(full_image_name, tags, image_id or '', scan_results)
# Set outputs
set_output('image', full_image_name)
set_output('tags', ','.join(tags))
if image_id:
set_output('image-id', image_id)
info('✓ Docker build and push complete')
return 0
except Exception as e:
error(f'Action failed: {e}', title='Build Failed')
return 1
if __name__ == '__main__':
raise SystemExit(main())
Contributing Examples¶
Have a great example? Contribute it!
Fork the repository
Add your example documentation to this page
Document it clearly with comments
Submit a pull request
See Contributing for guidelines.