diff --git a/components/skills/github-actions-dev/SKILL.md b/components/skills/github-actions-dev/SKILL.md index 6637244d..87dad2f6 100644 --- a/components/skills/github-actions-dev/SKILL.md +++ b/components/skills/github-actions-dev/SKILL.md @@ -11,57 +11,36 @@ Guide for developing custom GitHub Actions - the reusable units that are called ## Action Types -| Type | Best For | Runtime | -|------|----------|---------| -| **JavaScript/TypeScript** | Fast startup, GitHub API integration | Node.js 20 | -| **Docker** | Custom environments, any language | Container | -| **Composite** | Orchestrating other actions | None (YAML) | +| Type | Best For | Runtime | See Also | +|------|----------|---------|----------| +| **JavaScript/TypeScript** | Fast startup, GitHub API integration | Node.js 20 | [Project Setup](references/project-setup.md) | +| **Docker** | Custom environments, any language | Container | [Docker Guide](references/docker-actions.md) | +| **Composite** | Orchestrating other actions | None (YAML) | [Composite Guide](references/composite-actions.md) | -## Project Structure +## Quick Start -### JavaScript/TypeScript Action - -``` -my-action/ -├── action.yml # Action metadata -├── src/ -│ ├── main.ts # Entry point -│ ├── input.ts # Input parsing -│ └── utils.ts # Helpers -├── dist/ -│ └── index.js # Bundled output (committed) -├── __tests__/ -│ └── main.test.ts # Tests -├── package.json -├── tsconfig.json -└── README.md +### JavaScript Action +```bash +mkdir my-action && cd my-action +npm init -y +npm install @actions/core @actions/github +# See detailed setup: references/project-setup.md ``` ### Docker Action - -``` -my-docker-action/ -├── action.yml -├── Dockerfile -├── entrypoint.sh -└── README.md -``` - -### Composite Action - -``` -my-composite-action/ -├── action.yml # Contains all steps -└── README.md +```bash +mkdir my-docker-action && cd my-docker-action +# Create Dockerfile and action.yml +# See: references/docker-actions.md ``` -## action.yml Reference +## action.yml Essentials -### JavaScript/TypeScript Action +Basic structure for all action types: ```yaml name: 'My Action' -description: 'Does something useful' +description: 'What it does' author: 'Your Name' branding: @@ -72,483 +51,265 @@ inputs: token: description: 'GitHub token' required: true - config-path: - description: 'Path to config file' - required: false - default: '.github/config.yml' outputs: result: - description: 'The result of the action' - artifact-url: - description: 'URL to uploaded artifact' + description: 'Action result' runs: - using: 'node20' - main: 'dist/index.js' - post: 'dist/cleanup.js' # Optional cleanup - post-if: 'always()' # When to run cleanup + using: 'node20' # or 'docker' or 'composite' + main: 'dist/index.js' # Entry point ``` -### Docker Action - -```yaml -name: 'My Docker Action' -description: 'Runs in a container' +Complete reference: [Action Metadata](references/action-metadata.md) -inputs: - args: - description: 'Arguments to pass' - required: true +## Core Development Patterns -runs: - using: 'docker' - image: 'Dockerfile' - args: - - ${{ inputs.args }} - env: - CUSTOM_VAR: 'value' -``` +### Module System -### Composite Action +| Pattern | Usage | Example | +|---------|-------|---------| +| **Toolkit Imports** | Standard action libraries | `import * as core from '@actions/core'` | +| **ES Modules** | Modern JavaScript | `import { readFile } from 'fs/promises'` | +| **CommonJS** | TypeScript compilation | `module: 'commonjs'` in tsconfig | -```yaml -name: 'My Composite Action' -description: 'Combines multiple steps' +See [Toolkit API Reference](references/toolkit-api.md) for complete module documentation. -inputs: - node-version: - description: 'Node.js version' - default: '20' +### Error Handling -outputs: - cache-hit: - description: 'Whether cache was hit' - value: ${{ steps.cache.outputs.cache-hit }} +| Pattern | When to Use | Implementation | +|---------|-------------|----------------| +| **Input Validation** | Required parameters | `core.getInput('token', { required: true })` | +| **Try-Catch** | Async operations | Wrap main logic, call `core.setFailed()` | +| **Graceful Degradation** | Optional features | Continue with warnings vs failing | -runs: - using: 'composite' - steps: - - uses: actions/setup-node@v4 - with: - node-version: ${{ inputs.node-version }} - - - id: cache - uses: actions/cache@v4 - with: - path: node_modules - key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }} - - - run: npm ci - shell: bash - if: steps.cache.outputs.cache-hit != 'true' +```typescript +try { + const result = await performAction(); + core.setOutput('result', result); +} catch (error) { + core.setFailed(error instanceof Error ? error.message : 'Unknown error'); +} ``` -## JavaScript/TypeScript Development - -### Setup +### Concurrency -```bash -# Initialize project -mkdir my-action && cd my-action -npm init -y - -# Install action toolkit -npm install @actions/core @actions/github @actions/exec @actions/io @actions/cache +| Pattern | GitHub Actions Context | Example | +|---------|------------------------|---------| +| **Async/Await** | All toolkit APIs are async | `await octokit.rest.issues.get()` | +| **Parallel Operations** | Independent API calls | `Promise.all([getIssue(), getPR()])` | +| **Sequential Flow** | Dependent operations | `await step1(); await step2();` | -# Dev dependencies -npm install -D typescript @types/node @vercel/ncc jest @types/jest ts-jest +```typescript +// Parallel execution for independent tasks +await core.group('Parallel operations', async () => { + await Promise.all([ + checkIssues(), + updatePR(), + uploadArtifact() + ]); +}); ``` -### package.json +### Metaprogramming -```json -{ - "name": "my-action", - "version": "1.0.0", - "main": "dist/index.js", - "scripts": { - "build": "ncc build src/main.ts -o dist --source-map --license licenses.txt", - "test": "jest", - "all": "npm run build && npm test" - } -} -``` +GitHub Actions provides powerful metaprogramming capabilities for dynamic workflow generation: -### tsconfig.json +| Pattern | Purpose | Example | +|---------|---------|---------| +| **Expression Functions** | Runtime evaluation | `${{ fromJson(steps.data.outputs.config) }}` | +| **Matrix Strategies** | Dynamic job generation | Generate jobs from API data | +| **Context Injection** | Runtime inspection | `${{ github.event.pull_request.title }}` | +| **Reusable Workflows** | Macro-like abstractions | Template workflows with parameters | +| **Code Generation** | TypeScript action builders | Generate action.yml from schema | -```json -{ - "compilerOptions": { - "target": "ES2022", - "module": "commonjs", - "lib": ["ES2022"], - "outDir": "./lib", - "rootDir": "./src", - "strict": true, - "noImplicitAny": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true - }, - "exclude": ["node_modules", "dist", "__tests__"] -} +```yaml +# Dynamic matrix from API +strategy: + matrix: + include: ${{ fromJson(steps.get-matrix.outputs.matrix) }} + +# Context-based conditionals +if: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.title, 'feat') }} + +# Expression functions +run: | + CONFIG='${{ toJson(github.event.client_payload) }}' + ESCAPED='${{ toJson(env.USER_INPUT) }}' ``` -### Main Entry Point - +**Advanced Metaprogramming:** ```typescript -// src/main.ts -import * as core from '@actions/core'; -import * as github from '@actions/github'; +// TypeScript action that generates other actions +import { generateActionMetadata } from './generator'; -async function run(): Promise { - try { - // Get inputs - const token = core.getInput('token', { required: true }); - const configPath = core.getInput('config-path'); +const schema = await readSchema('action-schema.json'); +const actionYml = generateActionMetadata(schema); +await writeFile('action.yml', actionYml); +``` - // Debug logging (only visible with ACTIONS_STEP_DEBUG) - core.debug(`Config path: ${configPath}`); +See [Metaprogramming Patterns](references/metaprogramming.md) for complete examples. - // Get GitHub context - const { owner, repo } = github.context.repo; - core.info(`Running on ${owner}/${repo}`); +### Zero/Default Handling - // Create authenticated client - const octokit = github.getOctokit(token); +| Pattern | GitHub Actions Context | Implementation | +|---------|------------------------|----------------| +| **Input Defaults** | action.yml defaults | `default: '.github/config.yml'` | +| **Runtime Defaults** | Code-level defaults | `core.getInput('timeout') || '30'` | +| **Empty Handling** | Missing inputs/outputs | Check for empty strings, not undefined | - // Do work... - const result = await doWork(octokit, configPath); +```typescript +const timeout = parseInt(core.getInput('timeout') || '30', 10); +const files = core.getMultilineInput('files').filter(f => f.trim()); +``` - // Set outputs - core.setOutput('result', result); +### Serialization - // Export variable for subsequent steps - core.exportVariable('MY_ACTION_RESULT', result); +| Data Type | GitHub Actions Pattern | Usage | +|-----------|------------------------|-------| +| **JSON Objects** | Inputs/outputs | `core.setOutput('data', JSON.stringify(obj))` | +| **Multiline Strings** | File contents | `core.getMultilineInput()` | +| **Base64** | Binary data | Encode before setting output | - } catch (error) { - if (error instanceof Error) { - core.setFailed(error.message); - } - } -} +```typescript +// Complex data serialization +const results = { passed: 10, failed: 2, files: ['a.js', 'b.js'] }; +core.setOutput('test-results', JSON.stringify(results)); -run(); +// Reading complex input +const config = JSON.parse(core.getInput('config') || '{}'); ``` -### Action Toolkit APIs - -```typescript -import * as core from '@actions/core'; -import * as github from '@actions/github'; -import * as exec from '@actions/exec'; -import * as io from '@actions/io'; -import * as cache from '@actions/cache'; - -// --- @actions/core --- -// Inputs -const required = core.getInput('name', { required: true }); -const optional = core.getInput('name'); // Empty string if not set -const multiline = core.getMultilineInput('items'); -const boolean = core.getBooleanInput('flag'); - -// Outputs -core.setOutput('key', 'value'); - -// Logging -core.debug('Debug message'); // Only with ACTIONS_STEP_DEBUG -core.info('Info message'); -core.notice('Notice annotation'); -core.warning('Warning annotation'); -core.error('Error annotation'); - -// Grouping -core.startGroup('Group name'); -core.info('Inside group'); -core.endGroup(); - -// Or with async -await core.group('Group name', async () => { - await someAsyncWork(); -}); - -// Masking secrets -core.setSecret(sensitiveValue); - -// Failure -core.setFailed('Action failed'); - -// --- @actions/github --- -// Context -const { owner, repo } = github.context.repo; -const sha = github.context.sha; -const ref = github.context.ref; -const actor = github.context.actor; -const eventName = github.context.eventName; -const payload = github.context.payload; - -// Octokit client -const octokit = github.getOctokit(token); -const { data: issue } = await octokit.rest.issues.get({ - owner, - repo, - issue_number: 1 -}); +### Build/Tooling -// --- @actions/exec --- -// Run command -const exitCode = await exec.exec('npm', ['install']); +| Tool | Purpose | Configuration | +|------|---------|---------------| +| **TypeScript** | Type safety | `tsconfig.json` with Node.js targets | +| **ncc** | Bundling | Single file for distribution | +| **Jest** | Testing | Unit and integration tests | -// Capture output -let output = ''; -await exec.exec('git', ['rev-parse', 'HEAD'], { - listeners: { - stdout: (data) => { output += data.toString(); } +```json +{ + "scripts": { + "build": "ncc build src/main.ts -o dist --source-map", + "test": "jest", + "all": "npm run build && npm test" } -}); - -// --- @actions/io --- -// File operations -await io.mkdirP('/path/to/dir'); -await io.cp('src', 'dest', { recursive: true }); -await io.mv('old', 'new'); -await io.rmRF('/path/to/remove'); -const toolPath = await io.which('node', true); // Throws if not found - -// --- @actions/cache --- -// Cache dependencies -const paths = ['node_modules']; -const key = `node-${process.env.RUNNER_OS}-${hashFiles('package-lock.json')}`; -const restoreKeys = [`node-${process.env.RUNNER_OS}-`]; - -const cacheKey = await cache.restoreCache(paths, key, restoreKeys); -if (!cacheKey) { - // Cache miss, install deps - await exec.exec('npm', ['ci']); - await cache.saveCache(paths, key); } ``` -## Docker Action Development - -### Dockerfile - -```dockerfile -FROM node:20-alpine - -LABEL maintainer="Your Name " -LABEL com.github.actions.name="My Docker Action" -LABEL com.github.actions.description="Description" -LABEL com.github.actions.icon="check-circle" -LABEL com.github.actions.color="green" - -COPY entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh - -ENTRYPOINT ["/entrypoint.sh"] -``` - -### entrypoint.sh - -```bash -#!/bin/sh -l - -# Inputs are passed as environment variables -# INPUT_ in uppercase -echo "Token: $INPUT_TOKEN" -echo "Config: $INPUT_CONFIG_PATH" - -# Do work... -RESULT="success" - -# Set output (write to $GITHUB_OUTPUT) -echo "result=$RESULT" >> $GITHUB_OUTPUT +See [Build Configuration](references/build-tooling.md) for complete setup. -# Set environment variable for subsequent steps -echo "MY_VAR=value" >> $GITHUB_ENV -``` - -## Testing Actions +### Testing -### Unit Tests +| Test Type | Scope | Implementation | +|-----------|-------|----------------| +| **Unit Tests** | Individual functions | Jest with mocked toolkit | +| **Integration Tests** | Full action | Real GitHub workflows | +| **Local Testing** | Development | Act for local execution | ```typescript -// __tests__/main.test.ts -import * as core from '@actions/core'; -import * as github from '@actions/github'; -import { run } from '../src/main'; - -// Mock the toolkit +// Unit test with mocked toolkit jest.mock('@actions/core'); -jest.mock('@actions/github'); - -describe('action', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); +const mockSetOutput = core.setOutput as jest.Mock; - it('sets output on success', async () => { - // Arrange - (core.getInput as jest.Mock).mockImplementation((name: string) => { - if (name === 'token') return 'fake-token'; - return ''; - }); - - // Act - await run(); - - // Assert - expect(core.setOutput).toHaveBeenCalledWith('result', expect.any(String)); - expect(core.setFailed).not.toHaveBeenCalled(); - }); - - it('fails when token missing', async () => { - (core.getInput as jest.Mock).mockImplementation(() => { - throw new Error('Input required: token'); - }); - - await run(); - - expect(core.setFailed).toHaveBeenCalledWith('Input required: token'); - }); +test('sets correct output', async () => { + await run(); + expect(mockSetOutput).toHaveBeenCalledWith('result', 'success'); }); ``` -### Integration Testing +See [Testing Guide](references/testing.md) for comprehensive examples. -```yaml -# .github/workflows/test.yml -name: Test Action - -on: - push: - branches: [main] - pull_request: - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Run action - id: test - uses: ./ - with: - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Verify output - run: | - if [ "${{ steps.test.outputs.result }}" != "expected" ]; then - echo "Unexpected output" - exit 1 - fi +## Development Workflow + +### 1. Setup +```bash +# Quick start +npx create-action my-action +# or manual setup - see references/project-setup.md ``` -### Local Testing with Act +### 2. Development +```bash +npm run build # Bundle with ncc +npm test # Run test suite +npm run all # Build + test +``` +### 3. Testing ```bash -# Test the action locally +# Local testing with act act -j test -s GITHUB_TOKEN="$(gh auth token)" - -# With specific event -act push -j test ``` -## Publishing to Marketplace - -### Requirements - -1. Public repository -2. `action.yml` in repository root -3. README.md with documentation -4. Semantic versioning with tags - -### Release Process - +### 4. Publishing ```bash -# Tag release -git tag -a v1.0.0 -m "Release v1.0.0" +git tag v1.0.0 git push origin v1.0.0 - -# Create major version tag (for users: uses: org/action@v1) -git tag -fa v1 -m "Update v1 tag" -git push origin v1 --force +# Create GitHub release +# Check "Publish to Marketplace" ``` -### Release Workflow +See [Publishing Guide](references/publishing.md) for complete release process. -```yaml -# .github/workflows/release.yml -name: Release - -on: - release: - types: [published] - -jobs: - release: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Update major version tag - run: | - VERSION=${GITHUB_REF#refs/tags/} - MAJOR=${VERSION%%.*} - git tag -fa $MAJOR -m "Update $MAJOR tag" - git push origin $MAJOR --force -``` +## Common Gotchas -## Best Practices +### Input/Output Issues +- Empty inputs return `""`, not `undefined` +- Outputs must be strings (use `JSON.stringify()` for objects) +- Required inputs throw if missing, optional inputs return empty string -### Input Validation +### Bundle Management +- Always commit `dist/` folder for JavaScript actions +- Use `ncc` to create single-file bundles +- Don't bundle `node_modules` in Docker actions -```typescript -function validateInputs(): Config { - const token = core.getInput('token', { required: true }); - if (!token.startsWith('ghp_') && !token.startsWith('ghs_')) { - throw new Error('Invalid token format'); - } +### Permissions +- Default `GITHUB_TOKEN` has limited permissions +- Use `permissions:` in workflow to grant specific access +- Personal tokens need explicit scopes - const timeout = parseInt(core.getInput('timeout') || '30', 10); - if (isNaN(timeout) || timeout < 1 || timeout > 300) { - throw new Error('Timeout must be between 1 and 300'); - } +## Quick Reference - return { token, timeout }; -} -``` +### Essential Commands +```bash +# Toolkit APIs +npm install @actions/core @actions/github @actions/exec @actions/io -### Error Handling +# Development +npm run build && npm test +act -j test -```typescript -try { - await run(); -} catch (error) { - if (error instanceof Error) { - // Add error annotation to file if available - core.error(error.message, { - file: 'src/main.ts', - startLine: 10 - }); - core.setFailed(error.message); - } else { - core.setFailed('An unexpected error occurred'); - } -} +# Release +git tag v1.0.0 && git push origin v1.0.0 ``` -### Idempotency - -Design actions to be safely re-run: -- Check if work already done before doing it -- Use conditional creation (if not exists) -- Clean up partial state on failure +### File Structure +``` +my-action/ +├── action.yml # Action metadata +├── src/main.ts # Entry point +├── dist/index.js # Bundled output (committed) +├── __tests__/ # Test files +└── package.json # Dependencies +``` ## See Also -- Reference: [toolkit-api.md](references/toolkit-api.md) -- Reference: [publishing.md](references/publishing.md) -- Assets: [templates/](assets/) +### Reference Documentation +- [Toolkit API Reference](references/toolkit-api.md) - Complete `@actions/*` package documentation +- [Publishing Guide](references/publishing.md) - Marketplace publication process +- [Project Setup](references/project-setup.md) - Detailed project initialization +- [Testing Guide](references/testing.md) - Comprehensive testing strategies +- [Build Tooling](references/build-tooling.md) - TypeScript, bundling, CI configuration +- [Metaprogramming Patterns](references/metaprogramming.md) - Dynamic workflow generation + +### Action Type Guides +- [Action Metadata](references/action-metadata.md) - Complete action.yml reference +- [Docker Actions](references/docker-actions.md) - Container-based actions +- [Composite Actions](references/composite-actions.md) - YAML-based action orchestration + +### Examples +- [Templates](assets/templates/) - Project templates and examples +- [Sample Actions](assets/examples/) - Real-world action implementations \ No newline at end of file diff --git a/components/skills/github-actions-dev/references/action-metadata.md b/components/skills/github-actions-dev/references/action-metadata.md new file mode 100644 index 00000000..f1ff9f9a --- /dev/null +++ b/components/skills/github-actions-dev/references/action-metadata.md @@ -0,0 +1,700 @@ +# GitHub Actions Metadata Reference + +Complete reference for action.yml configuration and metadata. + +## action.yml Structure + +```yaml +name: string # Required +description: string # Required (max 125 chars for Marketplace) +author: string # Recommended +branding: object # Required for Marketplace +inputs: object # Optional +outputs: object # Optional +runs: object # Required +``` + +## Basic Metadata + +### Name and Description + +```yaml +name: 'My Action' # Required, unique name +description: 'Does something useful' # Required, brief description +author: 'Your Name ' # Recommended +``` + +### Branding (Marketplace) + +```yaml +branding: + icon: 'check-circle' # Feather icon name + color: 'green' # Theme color +``` + +**Available Colors:** +- `white` +- `yellow` +- `blue` +- `green` +- `orange` +- `red` +- `purple` +- `gray-dark` + +**Popular Icons:** +- `check-circle`, `check`, `check-square` +- `alert-circle`, `alert-triangle`, `info` +- `activity`, `trending-up`, `bar-chart` +- `code`, `terminal`, `file-text` +- `package`, `archive`, `download` +- `git-branch`, `git-commit`, `git-merge` +- `shield`, `lock`, `key` +- `settings`, `tool`, `sliders` + +## Input Configuration + +### Input Properties + +```yaml +inputs: + input-name: + description: string # Required + required: boolean # Optional, default false + default: string # Optional + deprecationMessage: string # Optional +``` + +### Input Examples + +```yaml +inputs: + # Required string input + token: + description: 'GitHub token for API access' + required: true + + # Optional with default + config-path: + description: 'Path to configuration file' + required: false + default: '.github/config.yml' + + # Boolean input (handled in code) + dry-run: + description: 'Run in dry-run mode without making changes' + required: false + default: 'false' + + # Multiline input + files: + description: | + List of files to process (one per line) + Supports glob patterns + required: false + + # Deprecated input + old-token: + description: 'DEPRECATED: Use token instead' + required: false + deprecationMessage: 'old-token is deprecated. Use token instead.' + + # Complex input with validation hint + timeout: + description: 'Timeout in seconds (1-3600)' + required: false + default: '300' + + # Enum-style input + log-level: + description: 'Log level (debug, info, warn, error)' + required: false + default: 'info' +``` + +### Input Types and Validation + +GitHub Actions inputs are always strings. Validation happens in code: + +```typescript +// Boolean inputs +const dryRun = core.getBooleanInput('dry-run'); // Handles 'true', 'True', 'TRUE' + +// Number inputs +const timeout = parseInt(core.getInput('timeout') || '300', 10); +if (isNaN(timeout) || timeout < 1 || timeout > 3600) { + throw new Error('Invalid timeout'); +} + +// Enum inputs +const logLevel = core.getInput('log-level'); +if (!['debug', 'info', 'warn', 'error'].includes(logLevel)) { + throw new Error('Invalid log level'); +} + +// Multiline inputs +const files = core.getMultilineInput('files').filter(f => f.trim()); + +// JSON inputs +const config = JSON.parse(core.getInput('config') || '{}'); +``` + +## Output Configuration + +### Output Properties + +```yaml +outputs: + output-name: + description: string # Required + value: string # For composite actions only +``` + +### Output Examples + +```yaml +outputs: + # Simple result + result: + description: 'The result of the action (success, failure, skipped)' + + # Detailed results as JSON + details: + description: 'Detailed results as JSON string' + + # File path output + artifact-path: + description: 'Path to generated artifact' + + # URL output + report-url: + description: 'URL to generated report' + + # Count/metrics + files-processed: + description: 'Number of files processed' + + # Boolean result (as string) + cache-hit: + description: 'Whether cache was hit (true/false)' +``` + +### Setting Outputs in Code + +```typescript +// Simple string outputs +core.setOutput('result', 'success'); +core.setOutput('files-processed', files.length.toString()); + +// Complex data as JSON +const details = { processed: files.length, errors: errors.length }; +core.setOutput('details', JSON.stringify(details)); + +// Boolean outputs (as strings) +core.setOutput('cache-hit', cacheHit ? 'true' : 'false'); + +// File paths (use absolute paths) +const artifactPath = path.resolve('./dist/artifact.zip'); +core.setOutput('artifact-path', artifactPath); +``` + +## Runs Configuration + +### JavaScript/TypeScript Actions + +```yaml +runs: + using: 'node20' # Required: node16, node18, node20 + main: 'dist/index.js' # Required: entry point + pre: 'dist/setup.js' # Optional: setup script + pre-if: 'always()' # Optional: when to run pre + post: 'dist/cleanup.js' # Optional: cleanup script + post-if: 'always()' # Optional: when to run post +``` + +**Node.js Versions:** +- `node20` - **Recommended** (Current LTS) +- `node16` - **Deprecated** (EOL October 2024) + +### Docker Actions + +```yaml +runs: + using: 'docker' + image: 'Dockerfile' # Or 'docker://image:tag' + args: + - ${{ inputs.arg1 }} + - ${{ inputs.arg2 }} + entrypoint: '/entrypoint.sh' # Optional: override entrypoint + env: # Optional: environment variables + CUSTOM_VAR: 'value' + INPUT_VAR: ${{ inputs.input-name }} + pre-entrypoint: 'setup.sh' # Optional: setup script + post-entrypoint: 'cleanup.sh' # Optional: cleanup script +``` + +### Composite Actions + +```yaml +runs: + using: 'composite' + steps: + - name: Step name + run: echo "Hello" + shell: bash + + - name: Use another action + uses: actions/checkout@v4 + with: + token: ${{ inputs.token }} + + - name: Conditional step + if: ${{ inputs.dry-run != 'true' }} + run: | + echo "Running real operation" + shell: bash + + - name: Set output + id: result + run: echo "value=success" >> $GITHUB_OUTPUT + shell: bash +``` + +## Pre/Post Scripts + +### JavaScript Actions with Lifecycle + +```yaml +runs: + using: 'node20' + main: 'dist/main.js' + pre: 'dist/pre.js' # Runs before main + pre-if: 'always()' # Or 'runner.os == "Windows"' + post: 'dist/post.js' # Runs after main + post-if: 'always()' # Or 'success()' or 'failure()' +``` + +### Pre/Post Examples + +```typescript +// dist/pre.js - Setup script +import * as core from '@actions/core'; +import * as exec from '@actions/exec'; + +async function setup(): Promise { + core.info('Setting up environment...'); + + // Install dependencies + await exec.exec('npm', ['install', '-g', 'some-tool']); + + // Save state for main script + core.saveState('setup-completed', 'true'); +} + +setup().catch(error => { + core.setFailed(error instanceof Error ? error.message : 'Setup failed'); +}); +``` + +```typescript +// dist/post.js - Cleanup script +import * as core from '@actions/core'; +import * as io from '@actions/io'; + +async function cleanup(): Promise { + core.info('Cleaning up...'); + + const setupCompleted = core.getState('setup-completed'); + if (setupCompleted === 'true') { + // Clean up temporary files + await io.rmRF('/tmp/action-temp'); + } + + core.info('Cleanup completed'); +} + +cleanup().catch(error => { + core.warning(`Cleanup failed: ${error instanceof Error ? error.message : 'Unknown error'}`); +}); +``` + +## Complex Examples + +### Full-Featured JavaScript Action + +```yaml +name: 'Comprehensive Action' +description: 'A comprehensive GitHub Action with all features' +author: 'Your Name ' + +branding: + icon: 'settings' + color: 'blue' + +inputs: + # Authentication + token: + description: 'GitHub token for API access' + required: true + default: ${{ github.token }} + + # Configuration + config-path: + description: 'Path to configuration file' + required: false + default: '.github/action-config.yml' + + # Behavior flags + dry-run: + description: 'Run in dry-run mode without making changes' + required: false + default: 'false' + + verbose: + description: 'Enable verbose logging' + required: false + default: 'false' + + # Numeric inputs + timeout: + description: 'Timeout in seconds (1-3600)' + required: false + default: '300' + + parallel-jobs: + description: 'Number of parallel jobs (1-10)' + required: false + default: '1' + + # List inputs + include-patterns: + description: | + File patterns to include (one per line) + Supports glob patterns like **/*.js + required: false + + exclude-patterns: + description: 'File patterns to exclude (one per line)' + required: false + + # Enum inputs + log-level: + description: 'Log level (debug, info, warn, error)' + required: false + default: 'info' + + output-format: + description: 'Output format (json, yaml, table)' + required: false + default: 'json' + + # Complex inputs + matrix-config: + description: 'Matrix configuration as JSON string' + required: false + default: '{}' + +outputs: + # Status outputs + result: + description: 'Overall result (success, failure, partial, skipped)' + + exit-code: + description: 'Exit code (0 for success, non-zero for failure)' + + # Metrics outputs + files-processed: + description: 'Number of files processed' + + files-changed: + description: 'Number of files changed' + + processing-time: + description: 'Total processing time in seconds' + + # Data outputs + results-summary: + description: 'Summary of results as JSON string' + + changed-files: + description: 'List of changed files (one per line)' + + # Artifact outputs + report-path: + description: 'Path to generated report file' + + artifact-url: + description: 'URL to uploaded artifact (if applicable)' + +runs: + using: 'node20' + main: 'dist/index.js' + pre: 'dist/setup.js' + pre-if: 'always()' + post: 'dist/cleanup.js' + post-if: 'always()' +``` + +### Docker Action with Complex Setup + +```yaml +name: 'Docker Analysis Action' +description: 'Analyze code using custom Docker environment' +author: 'Your Name' + +branding: + icon: 'package' + color: 'green' + +inputs: + source-path: + description: 'Path to source code' + required: true + default: '.' + + analysis-type: + description: 'Type of analysis (security, quality, performance)' + required: false + default: 'quality' + + output-format: + description: 'Output format (json, sarif, junit)' + required: false + default: 'json' + + config: + description: 'Analysis configuration as JSON' + required: false + default: '{}' + +outputs: + report-path: + description: 'Path to analysis report' + + violations-count: + description: 'Number of violations found' + + severity-breakdown: + description: 'Breakdown of violations by severity as JSON' + +runs: + using: 'docker' + image: 'Dockerfile' + args: + - ${{ inputs.source-path }} + - ${{ inputs.analysis-type }} + - ${{ inputs.output-format }} + env: + ANALYSIS_CONFIG: ${{ inputs.config }} + GITHUB_TOKEN: ${{ inputs.token }} +``` + +### Composite Action with Multiple Steps + +```yaml +name: 'Setup Development Environment' +description: 'Setup complete development environment with tools and dependencies' +author: 'Your Team' + +branding: + icon: 'tool' + color: 'purple' + +inputs: + node-version: + description: 'Node.js version to install' + required: false + default: '20' + + python-version: + description: 'Python version to install' + required: false + default: '3.11' + + install-tools: + description: 'Comma-separated list of additional tools to install' + required: false + + cache-key-suffix: + description: 'Additional cache key suffix' + required: false + +outputs: + node-version: + description: 'Installed Node.js version' + value: ${{ steps.node.outputs.node-version }} + + python-version: + description: 'Installed Python version' + value: ${{ steps.python.outputs.python-version }} + + cache-hit: + description: 'Whether dependency cache was hit' + value: ${{ steps.cache.outputs.cache-hit }} + + tools-installed: + description: 'List of additional tools installed' + value: ${{ steps.tools.outputs.installed }} + +runs: + using: 'composite' + steps: + - name: Setup Node.js + id: node + uses: actions/setup-node@v4 + with: + node-version: ${{ inputs.node-version }} + cache: 'npm' + + - name: Setup Python + id: python + uses: actions/setup-python@v4 + with: + python-version: ${{ inputs.python-version }} + cache: 'pip' + + - name: Cache dependencies + id: cache + uses: actions/cache@v4 + with: + path: | + ~/.npm + ~/.cache/pip + ~/.local + key: dev-env-${{ runner.os }}-${{ inputs.node-version }}-${{ inputs.python-version }}-${{ inputs.cache-key-suffix }}-${{ hashFiles('package-lock.json', 'requirements.txt') }} + restore-keys: | + dev-env-${{ runner.os }}-${{ inputs.node-version }}-${{ inputs.python-version }}-${{ inputs.cache-key-suffix }} + dev-env-${{ runner.os }}-${{ inputs.node-version }}-${{ inputs.python-version }} + + - name: Install Node.js dependencies + if: steps.cache.outputs.cache-hit != 'true' + run: | + if [ -f package-lock.json ]; then + npm ci + elif [ -f package.json ]; then + npm install + fi + shell: bash + + - name: Install Python dependencies + if: steps.cache.outputs.cache-hit != 'true' + run: | + if [ -f requirements.txt ]; then + pip install -r requirements.txt + fi + shell: bash + + - name: Install additional tools + id: tools + if: inputs.install-tools != '' + run: | + IFS=',' read -ra TOOLS <<< "${{ inputs.install-tools }}" + INSTALLED="" + for tool in "${TOOLS[@]}"; do + tool=$(echo "$tool" | xargs) # Trim whitespace + if [ ! -z "$tool" ]; then + echo "Installing $tool..." + npm install -g "$tool" + if [ -z "$INSTALLED" ]; then + INSTALLED="$tool" + else + INSTALLED="$INSTALLED,$tool" + fi + fi + done + echo "installed=$INSTALLED" >> $GITHUB_OUTPUT + shell: bash + + - name: Verify installations + run: | + echo "Node.js: $(node --version)" + echo "npm: $(npm --version)" + echo "Python: $(python --version)" + echo "pip: $(pip --version)" + shell: bash +``` + +## Validation and Testing + +### action.yml Validation + +```bash +# Validate YAML syntax +yamllint action.yml + +# Use GitHub's action validator +actionlint action.yml + +# Test with act +act -j test --action ./action.yml +``` + +### Schema Validation + +```yaml +# Use JSON Schema for validation +{ + "$schema": "https://raw.githubusercontent.com/SchemaStore/schemastore/master/src/schemas/json/github-action.json" +} +``` + +## Common Patterns + +### Input Validation in action.yml + +```yaml +# Don't put validation in action.yml - do it in code +inputs: + timeout: + description: 'Timeout in seconds (1-3600)' # Document constraints + required: false + default: '300' + # No built-in validation available +``` + +### Dynamic Defaults + +```yaml +inputs: + token: + description: 'GitHub token' + required: true + default: ${{ github.token }} # Use GitHub context + + branch: + description: 'Target branch' + required: false + default: ${{ github.ref_name }} # Current branch +``` + +### Conditional Inputs + +```yaml +# Can't make inputs conditionally required in action.yml +# Handle in code instead +inputs: + deploy-key: + description: 'Deploy key (required if token not provided)' + required: false + + token: + description: 'GitHub token (required if deploy-key not provided)' + required: false +``` + +```typescript +// Conditional validation in code +const token = core.getInput('token'); +const deployKey = core.getInput('deploy-key'); + +if (!token && !deployKey) { + throw new Error('Either token or deploy-key must be provided'); +} +``` + +## Cross-References + +- [Project Setup](project-setup.md) - Creating action.yml during setup +- [Publishing Guide](publishing.md) - Marketplace requirements for metadata +- [Composite Actions](composite-actions.md) - Composite-specific metadata +- [Docker Actions](docker-actions.md) - Docker-specific metadata \ No newline at end of file diff --git a/components/skills/github-actions-dev/references/build-tooling.md b/components/skills/github-actions-dev/references/build-tooling.md new file mode 100644 index 00000000..19b02999 --- /dev/null +++ b/components/skills/github-actions-dev/references/build-tooling.md @@ -0,0 +1,711 @@ +# GitHub Actions Build Tooling + +Complete guide to TypeScript compilation, bundling, and CI configuration for GitHub Actions. + +## TypeScript Configuration + +### tsconfig.json Options + +```json +{ + "compilerOptions": { + "target": "ES2022", // Modern JavaScript features + "lib": ["ES2022"], // Standard library + "module": "commonjs", // Required for @actions/* packages + "outDir": "./lib", // Intermediate output + "rootDir": "./src", // Source directory + "moduleResolution": "node", // Node.js resolution + "allowSyntheticDefaultImports": true, // Import compatibility + "esModuleInterop": true, // Module interop + "resolveJsonModule": true, // Import JSON files + "skipLibCheck": true, // Skip .d.ts checking + "forceConsistentCasingInFileNames": true, // Case sensitivity + + // Strict type checking + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictPropertyInitialization": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "alwaysStrict": true, + + // Additional checks + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true, + "noUncheckedIndexedAccess": true, + "noFallthroughCasesInSwitch": true, + + // Emit options + "declaration": true, // Generate .d.ts files + "declarationMap": true, // Generate .d.ts.map files + "sourceMap": true, // Generate source maps + "removeComments": false, // Preserve comments in output + + // Experimental + "experimentalDecorators": true, // If using decorators + "emitDecoratorMetadata": true // If using reflect-metadata + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "dist", + "lib", + "**/*.test.ts", + "**/*.spec.ts" + ] +} +``` + +### TypeScript Build Scripts + +```json +{ + "scripts": { + "build:ts": "tsc", + "build:ts:watch": "tsc --watch", + "build:ts:clean": "tsc --build --clean", + "type-check": "tsc --noEmit", + "type-check:watch": "tsc --noEmit --watch" + } +} +``` + +## Bundling with ncc + +### Why Bundle? + +GitHub Actions require a single entry point. Bundling: +- Eliminates `node_modules` dependency +- Reduces cold start time +- Simplifies distribution +- Enables tree shaking + +### ncc Configuration + +```json +{ + "scripts": { + "build": "ncc build src/main.ts -o dist --source-map --license licenses.txt", + "build:watch": "ncc build src/main.ts -o dist --source-map --license licenses.txt --watch", + "build:minify": "ncc build src/main.ts -o dist --minify --license licenses.txt" + } +} +``` + +### Advanced ncc Options + +```bash +# Basic build +ncc build src/main.ts -o dist + +# With source maps (recommended for debugging) +ncc build src/main.ts -o dist --source-map + +# With license file generation +ncc build src/main.ts -o dist --license licenses.txt + +# Minified build (for production) +ncc build src/main.ts -o dist --minify + +# External dependencies (don't bundle) +ncc build src/main.ts -o dist --external aws-sdk + +# Target specific Node.js version +ncc build src/main.ts -o dist --target es2020 + +# Generate stats +ncc build src/main.ts -o dist --stats-out stats.json + +# Cache builds +ncc build src/main.ts -o dist --cache .ncc-cache +``` + +### Bundle Analysis + +```bash +# Generate bundle statistics +ncc build src/main.ts --stats-out stats.json + +# Analyze bundle size +du -h dist/index.js + +# Check what's included +cat licenses.txt + +# View stats +cat stats.json | jq '.modules | length' +cat stats.json | jq '.modules | sort_by(.size) | reverse | .[0:10]' +``` + +### Bundle Optimization + +```typescript +// webpack.config.js (if using webpack instead of ncc) +module.exports = { + target: 'node', + entry: './src/main.ts', + output: { + filename: 'index.js', + path: path.resolve(__dirname, 'dist') + }, + module: { + rules: [ + { + test: /\.ts$/, + use: 'ts-loader', + exclude: /node_modules/ + } + ] + }, + resolve: { + extensions: ['.ts', '.js'] + }, + optimization: { + minimize: true + }, + externals: { + // Don't bundle large dependencies + '@aws-sdk/client-s3': 'commonjs @aws-sdk/client-s3' + } +}; +``` + +## Package.json Scripts + +### Complete Script Set + +```json +{ + "scripts": { + // Building + "build": "npm run build:ts && npm run build:bundle", + "build:ts": "tsc", + "build:bundle": "ncc build lib/main.js -o dist --source-map --license licenses.txt", + "build:watch": "concurrently \"npm run build:ts:watch\" \"npm run build:bundle:watch\"", + "build:clean": "rm -rf lib dist", + + // Development + "dev": "npm run build:watch", + "type-check": "tsc --noEmit", + + // Testing + "test": "jest", + "test:watch": "jest --watch", + "test:coverage": "jest --coverage", + "test:ci": "jest --ci --coverage --watchAll=false", + + // Linting & Formatting + "lint": "eslint src/**/*.ts", + "lint:fix": "eslint src/**/*.ts --fix", + "format": "prettier --write src/**/*.ts", + "format:check": "prettier --check src/**/*.ts", + + // Quality checks + "check": "npm run format:check && npm run lint && npm run type-check", + "fix": "npm run format && npm run lint:fix", + + // Full pipeline + "all": "npm run fix && npm run build && npm test", + "ci": "npm run check && npm run build && npm run test:ci", + + // Release + "prepare": "npm run all", + "prepublishOnly": "npm run build" + } +} +``` + +### Concurrent Development + +```bash +# Install concurrently for parallel tasks +npm install -D concurrently + +# Watch both TypeScript and bundling +"dev": "concurrently \"tsc --watch\" \"ncc build lib/main.js -o dist --watch\"" + +# Multiple watchers +"dev:all": "concurrently -n \"TS,Bundle,Test\" \"tsc --watch\" \"ncc build lib/main.js -o dist --watch\" \"jest --watch\"" +``` + +## ESLint Configuration + +### .eslintrc.js + +```javascript +module.exports = { + parser: '@typescript-eslint/parser', + plugins: ['@typescript-eslint', 'jest', 'import'], + extends: [ + 'eslint:recommended', + '@typescript-eslint/recommended', + '@typescript-eslint/recommended-requiring-type-checking', + 'plugin:jest/recommended', + 'plugin:import/recommended', + 'plugin:import/typescript' + ], + parserOptions: { + ecmaVersion: 2022, + sourceType: 'module', + project: './tsconfig.json' + }, + rules: { + // TypeScript specific + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + '@typescript-eslint/explicit-function-return-type': 'error', + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/prefer-nullish-coalescing': 'error', + '@typescript-eslint/prefer-optional-chain': 'error', + '@typescript-eslint/strict-boolean-expressions': 'error', + + // Import rules + 'import/order': ['error', { + groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'], + 'newlines-between': 'always' + }], + 'import/no-unresolved': 'error', + + // General + 'prefer-const': 'error', + 'no-var': 'error', + 'no-console': 'warn', + 'eqeqeq': 'error', + 'no-eval': 'error', + 'no-implied-eval': 'error' + }, + env: { + node: true, + es2022: true, + jest: true + }, + settings: { + 'import/resolver': { + typescript: true, + node: true + } + } +}; +``` + +### ESLint Ignore + +```bash +# .eslintignore +node_modules/ +dist/ +lib/ +coverage/ +*.min.js +``` + +## Prettier Configuration + +### .prettierrc + +```json +{ + "semi": true, + "trailingComma": "es5", + "singleQuote": true, + "printWidth": 80, + "tabWidth": 2, + "useTabs": false, + "bracketSpacing": true, + "arrowParens": "avoid", + "endOfLine": "lf", + "quoteProps": "as-needed" +} +``` + +### .prettierignore + +```bash +node_modules/ +dist/ +lib/ +coverage/ +package-lock.json +*.min.js +CHANGELOG.md +``` + +## Git Hooks with Husky + +### Setup + +```bash +# Install husky and lint-staged +npm install -D husky lint-staged + +# Initialize husky +npx husky install + +# Add prepare script +npm pkg set scripts.prepare="husky install" +``` + +### Pre-commit Hook + +```bash +# .husky/pre-commit +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npx lint-staged +``` + +### Lint-staged Configuration + +```json +{ + "lint-staged": { + "src/**/*.{ts,tsx}": [ + "prettier --write", + "eslint --fix", + "git add" + ], + "*.{md,json,yml,yaml}": [ + "prettier --write", + "git add" + ] + } +} +``` + +### Commit Message Hook + +```bash +# .husky/commit-msg +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npx commitlint --edit $1 +``` + +```json +{ + "devDependencies": { + "@commitlint/cli": "^17.0.0", + "@commitlint/config-conventional": "^17.0.0" + }, + "commitlint": { + "extends": ["@commitlint/config-conventional"] + } +} +``` + +## CI/CD Configuration + +### GitHub Actions Workflow + +```yaml +# .github/workflows/ci.yml +name: CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [18, 20] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Type check + run: npm run type-check + + - name: Lint + run: npm run lint + + - name: Format check + run: npm run format:check + + - name: Build + run: npm run build + + - name: Test + run: npm run test:ci + + - name: Upload coverage + if: matrix.node-version == 20 + uses: codecov/codecov-action@v3 + with: + file: ./coverage/lcov.info + + - name: Check dist is up to date + run: | + if [ -n "$(git status --porcelain dist/)" ]; then + echo "dist/ is not up to date. Run 'npm run build' and commit changes." + exit 1 + fi + + integration-test: + needs: test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Test action + uses: ./ + with: + token: ${{ secrets.GITHUB_TOKEN }} + dry-run: true +``` + +### Dependabot Configuration + +```yaml +# .github/dependabot.yml +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 5 + reviewers: + - "your-username" + commit-message: + prefix: "deps" + include: "scope" +``` + +## Build Optimization + +### Bundle Size Optimization + +```bash +# Analyze what's in the bundle +npx ncc build src/main.ts --stats-out stats.json +cat stats.json | jq '.modules | map({name: .name, size: .size}) | sort_by(.size) | reverse | .[0:20]' + +# Use webpack-bundle-analyzer alternative for ncc +npm install -D source-map-explorer +npx source-map-explorer dist/index.js dist/index.js.map +``` + +### External Dependencies + +```bash +# Don't bundle large dependencies +ncc build src/main.ts -o dist --external @aws-sdk/client-s3 +ncc build src/main.ts -o dist --external sharp +``` + +### Tree Shaking + +```typescript +// Use specific imports instead of barrel imports +// ❌ Bad: imports entire library +import * as _ from 'lodash'; + +// ✅ Good: imports only what's needed +import { get } from 'lodash/get'; +import { isArray } from 'lodash/isArray'; + +// ❌ Bad: barrel import +import { someFunction } from './utils'; + +// ✅ Good: direct import +import { someFunction } from './utils/someFunction'; +``` + +### Build Caching + +```bash +# Cache TypeScript builds +"build:ts": "tsc --incremental" + +# Cache ncc builds +"build:bundle": "ncc build src/main.ts -o dist --cache .ncc-cache" + +# Cache in CI +- name: Cache TypeScript + uses: actions/cache@v4 + with: + path: | + tsconfig.tsbuildinfo + .ncc-cache + key: ${{ runner.os }}-build-${{ hashFiles('package-lock.json') }} +``` + +## Development Tools + +### VS Code Configuration + +```json +// .vscode/settings.json +{ + "typescript.preferences.includePackageJsonAutoImports": "auto", + "typescript.suggest.autoImports": true, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": true, + "source.organizeImports": true + }, + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "files.exclude": { + "dist": true, + "lib": true, + "coverage": true, + "node_modules": true + }, + "search.exclude": { + "dist": true, + "lib": true, + "coverage": true + } +} +``` + +### Recommended Extensions + +```json +// .vscode/extensions.json +{ + "recommendations": [ + "esbenp.prettier-vscode", + "dbaeumer.vscode-eslint", + "ms-vscode.vscode-typescript-next", + "orta.vscode-jest", + "bradlc.vscode-tailwindcss", + "github.vscode-github-actions" + ] +} +``` + +### Debug Configuration + +```json +// .vscode/launch.json +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Debug Action", + "type": "node", + "request": "launch", + "program": "${workspaceFolder}/lib/main.js", + "env": { + "INPUT_TOKEN": "ghp_fake_token_for_debugging", + "INPUT_CONFIG_PATH": ".github/config.yml", + "GITHUB_REPOSITORY": "owner/repo", + "GITHUB_ACTOR": "debug-user" + }, + "outFiles": ["${workspaceFolder}/lib/**/*.js"], + "sourceMaps": true + } + ] +} +``` + +## Performance Monitoring + +### Build Time Analysis + +```bash +# Time each step +"build:time": "npm run build:ts && time npm run build:bundle" + +# Profile TypeScript compilation +"build:ts:profile": "tsc --generateTrace trace" + +# Analyze trace +npx analyze-trace trace +``` + +### Bundle Size Monitoring + +```yaml +# .github/workflows/bundle-size.yml +name: Bundle Size + +on: [pull_request] + +jobs: + bundle-size: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - run: npm ci + - run: npm run build + + - name: Check bundle size + run: | + SIZE=$(stat -f%z dist/index.js) + echo "Bundle size: ${SIZE} bytes" + if [ ${SIZE} -gt 1048576 ]; then # 1MB + echo "Bundle size too large!" + exit 1 + fi +``` + +## Troubleshooting + +### Common Build Issues + +```bash +# TypeScript compilation errors +npx tsc --noEmit --pretty + +# ESLint errors +npx eslint src/ --fix + +# Bundle errors +DEBUG=ncc npx ncc build src/main.ts -o dist + +# Module resolution issues +npm ls @actions/core +npm audit fix +``` + +### Debug Bundle Contents + +```bash +# Extract and inspect bundle +mkdir -p debug-bundle +cd debug-bundle +node -e "console.log(require('../dist/index.js'))" + +# Check for missing dependencies +node dist/index.js +``` + +## Cross-References + +- [Project Setup](project-setup.md) - Initial build configuration +- [Testing Guide](testing.md) - CI/CD test integration +- [Publishing Guide](publishing.md) - Release build process +- [Toolkit API Reference](toolkit-api.md) - APIs affecting build size \ No newline at end of file diff --git a/components/skills/github-actions-dev/references/docker-actions.md b/components/skills/github-actions-dev/references/docker-actions.md new file mode 100644 index 00000000..ef58b6d2 --- /dev/null +++ b/components/skills/github-actions-dev/references/docker-actions.md @@ -0,0 +1,885 @@ +# Docker Actions Reference + +Complete guide to building GitHub Actions that run in Docker containers. + +## Overview + +Docker actions provide a consistent execution environment and support any programming language. They're ideal when you need: + +- Specific system dependencies +- Non-Node.js languages (Python, Go, Rust, etc.) +- Complex environment setup +- Binary tools or compiled programs + +## Basic Docker Action + +### Project Structure + +``` +my-docker-action/ +├── action.yml +├── Dockerfile +├── entrypoint.sh +├── src/ +│ └── main.py # Or any language +├── requirements.txt # Language-specific deps +└── README.md +``` + +### action.yml + +```yaml +name: 'My Docker Action' +description: 'Runs analysis in custom container' +author: 'Your Name' + +branding: + icon: 'package' + color: 'blue' + +inputs: + source-path: + description: 'Path to source code' + required: true + default: '.' + + config: + description: 'Configuration as JSON' + required: false + default: '{}' + +outputs: + result: + description: 'Analysis result' + +runs: + using: 'docker' + image: 'Dockerfile' + args: + - ${{ inputs.source-path }} + - ${{ inputs.config }} +``` + +### Dockerfile + +```dockerfile +FROM python:3.11-slim + +LABEL maintainer="Your Name " +LABEL com.github.actions.name="My Docker Action" +LABEL com.github.actions.description="Runs analysis in custom container" +LABEL com.github.actions.icon="package" +LABEL com.github.actions.color="blue" + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + git \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Create app directory +WORKDIR /app + +# Copy requirements and install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy source code +COPY src/ ./src/ + +# Copy and make entrypoint executable +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# Set entrypoint +ENTRYPOINT ["/entrypoint.sh"] +``` + +### entrypoint.sh + +```bash +#!/bin/sh + +set -e + +# Inputs are available as environment variables +# INPUT_ in uppercase with dashes converted to underscores +SOURCE_PATH="$INPUT_SOURCE_PATH" +CONFIG="$INPUT_CONFIG" + +echo "Processing source at: $SOURCE_PATH" +echo "Configuration: $CONFIG" + +# Run your tool +python /app/src/main.py "$SOURCE_PATH" "$CONFIG" + +# Capture result +RESULT=$? + +# Set outputs (write to $GITHUB_OUTPUT) +if [ $RESULT -eq 0 ]; then + echo "result=success" >> $GITHUB_OUTPUT +else + echo "result=failure" >> $GITHUB_OUTPUT +fi + +# Set environment variables for subsequent steps +echo "ANALYSIS_COMPLETE=true" >> $GITHUB_ENV + +exit $RESULT +``` + +### src/main.py + +```python +#!/usr/bin/env python3 +import json +import os +import sys +from pathlib import Path + +def main(): + if len(sys.argv) != 3: + print("Usage: main.py ", file=sys.stderr) + sys.exit(1) + + source_path = Path(sys.argv[1]) + config_json = sys.argv[2] + + try: + config = json.loads(config_json) + except json.JSONDecodeError: + print(f"Invalid JSON config: {config_json}", file=sys.stderr) + sys.exit(1) + + print(f"Analyzing source at: {source_path}") + print(f"Config: {config}") + + # Your analysis logic here + files_processed = 0 + for file_path in source_path.rglob("*.py"): + print(f"Processing: {file_path}") + files_processed += 1 + + print(f"Processed {files_processed} Python files") + + # Write additional outputs to files that can be read by subsequent steps + with open(os.environ.get('GITHUB_WORKSPACE', '.') + '/analysis-results.json', 'w') as f: + json.dump({ + 'files_processed': files_processed, + 'config_used': config, + 'timestamp': str(Path(__file__).stat().st_mtime) + }, f, indent=2) + +if __name__ == "__main__": + main() +``` + +## Advanced Docker Patterns + +### Multi-Stage Builds + +```dockerfile +# Build stage +FROM golang:1.21-alpine AS builder + +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o analyzer ./cmd/analyzer + +# Runtime stage +FROM alpine:3.18 + +RUN apk --no-cache add ca-certificates git +WORKDIR /root/ + +# Copy binary from build stage +COPY --from=builder /app/analyzer . + +# Copy entrypoint +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] +``` + +### Using Pre-built Images + +```yaml +# action.yml using public image +runs: + using: 'docker' + image: 'docker://node:20-alpine' + entrypoint: '/entrypoint.sh' + args: + - ${{ inputs.script }} +``` + +```yaml +# action.yml using private registry +runs: + using: 'docker' + image: 'docker://ghcr.io/your-org/your-action:v1.0.0' + args: + - ${{ inputs.config }} +``` + +### Complex Environment Setup + +```dockerfile +FROM ubuntu:22.04 + +# Prevent interactive prompts +ENV DEBIAN_FRONTEND=noninteractive + +# Install multiple language runtimes +RUN apt-get update && apt-get install -y \ + curl \ + git \ + build-essential \ + python3 \ + python3-pip \ + nodejs \ + npm \ + openjdk-11-jdk \ + && rm -rf /var/lib/apt/lists/* + +# Install specific tools +RUN curl -fsSL https://get.docker.com | sh +RUN pip3 install --no-cache-dir \ + pylint \ + black \ + mypy + +RUN npm install -g \ + eslint \ + prettier \ + @typescript-eslint/parser + +# Install custom tools +COPY install-tools.sh /tmp/ +RUN chmod +x /tmp/install-tools.sh && /tmp/install-tools.sh + +WORKDIR /workspace + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] +``` + +## Input and Output Handling + +### Environment Variables + +```bash +#!/bin/sh +# entrypoint.sh + +# GitHub Actions automatically converts inputs to environment variables: +# input-name -> INPUT_INPUT_NAME +# kebab-case -> UPPER_SNAKE_CASE + +TOKEN="$INPUT_TOKEN" +CONFIG_PATH="$INPUT_CONFIG_PATH" +DRY_RUN="$INPUT_DRY_RUN" +FILE_LIST="$INPUT_FILE_LIST" + +# Boolean handling +if [ "$DRY_RUN" = "true" ]; then + echo "Running in dry-run mode" +fi + +# Multiline input handling +echo "$FILE_LIST" | while IFS= read -r file; do + if [ -n "$file" ]; then + echo "Processing file: $file" + fi +done +``` + +### Complex Input Processing + +```python +import os +import json + +def get_input(name: str, required: bool = False, default: str = ""): + """Get input from environment variable.""" + env_var = f"INPUT_{name.upper().replace('-', '_')}" + value = os.environ.get(env_var, default) + + if required and not value: + raise ValueError(f"Input '{name}' is required") + + return value + +def get_boolean_input(name: str, default: bool = False) -> bool: + """Get boolean input from environment variable.""" + value = get_input(name).lower() + return value in ('true', '1', 'yes', 'on') + +def get_multiline_input(name: str) -> list[str]: + """Get multiline input as list.""" + value = get_input(name) + return [line.strip() for line in value.split('\n') if line.strip()] + +def get_json_input(name: str, default: dict = None) -> dict: + """Get JSON input.""" + if default is None: + default = {} + + value = get_input(name) + if not value: + return default + + try: + return json.loads(value) + except json.JSONDecodeError as e: + raise ValueError(f"Invalid JSON in input '{name}': {e}") + +# Usage +config = get_json_input('config') +files = get_multiline_input('files') +dry_run = get_boolean_input('dry-run') +``` + +### Setting Outputs + +```bash +#!/bin/sh +# Setting outputs from shell + +# Simple outputs +echo "result=success" >> $GITHUB_OUTPUT +echo "files-processed=42" >> $GITHUB_OUTPUT + +# Multiline outputs +cat >> $GITHUB_OUTPUT << 'EOF' +summary<> $GITHUB_OUTPUT +``` + +```python +import os +import json + +def set_output(name: str, value: str): + """Set action output.""" + with open(os.environ['GITHUB_OUTPUT'], 'a') as f: + f.write(f"{name}={value}\n") + +def set_multiline_output(name: str, value: str): + """Set multiline action output.""" + delimiter = f"EOF_{name.upper()}" + with open(os.environ['GITHUB_OUTPUT'], 'a') as f: + f.write(f"{name}<<{delimiter}\n{value}\n{delimiter}\n") + +def set_json_output(name: str, value: dict): + """Set JSON action output.""" + set_output(name, json.dumps(value)) + +# Usage +set_output('result', 'success') +set_multiline_output('summary', """ +Analysis Results: +- Files processed: 42 +- Issues found: 3 +""") +set_json_output('details', {'files': 42, 'issues': 3}) +``` + +### Environment Variables for Subsequent Steps + +```bash +# Set environment variable for subsequent steps +echo "ANALYSIS_VERSION=1.2.3" >> $GITHUB_ENV +echo "RESULT_PATH=/tmp/results.json" >> $GITHUB_ENV + +# Multiline environment variable +cat >> $GITHUB_ENV << 'EOF' +ANALYSIS_SUMMARY< Result<(), Box> { + let config_json = env::var("INPUT_CONFIG").unwrap_or_else(|_| "{}".to_string()); + let source_path = env::var("INPUT_SOURCE_PATH").unwrap_or_else(|_| ".".to_string()); + + let config: Value = serde_json::from_str(&config_json) + .map_err(|e| format!("::error::Invalid JSON config: {}", e))?; + + println!("::group::Processing Rust files"); + + let mut files_processed = 0; + if let Ok(entries) = std::fs::read_dir(&source_path) { + for entry in entries.flatten() { + if let Some(path) = entry.path().to_str() { + if path.ends_with(".rs") { + println!("Processing: {}", path); + files_processed += 1; + } + } + } + } + + println!("::endgroup::"); + + // Set outputs + if let Ok(output_file) = env::var("GITHUB_OUTPUT") { + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(output_file)?; + + writeln!(file, "files-processed={}", files_processed)?; + writeln!(file, "result=success")?; + } + + println!("Processed {} Rust files", files_processed); + Ok(()) +} +``` + +## Security Considerations + +### Safe Input Handling + +```bash +#!/bin/sh +# entrypoint.sh with secure input handling + +# Validate inputs before using them +validate_input() { + local value="$1" + local pattern="$2" + + if ! echo "$value" | grep -E "^${pattern}$" >/dev/null; then + echo "::error::Invalid input: $value" + exit 1 + fi +} + +# Validate file paths +SOURCE_PATH="$INPUT_SOURCE_PATH" +validate_input "$SOURCE_PATH" "[a-zA-Z0-9/_.-]+" + +# Validate enum inputs +LOG_LEVEL="$INPUT_LOG_LEVEL" +validate_input "$LOG_LEVEL" "(debug|info|warn|error)" + +# Escape shell arguments +safe_command() { + # Use printf to safely pass arguments + printf '%s\0' "$@" | xargs -0 command_to_run +} +``` + +### Minimal Container Surface + +```dockerfile +# Use distroless or minimal base images +FROM gcr.io/distroless/static:nonroot + +# Or use minimal Alpine +FROM alpine:3.18 +RUN apk --no-cache add ca-certificates \ + && adduser -D -s /bin/sh action +USER action + +# Copy only what you need +COPY --from=builder --chown=action:action /app/binary /usr/local/bin/ +``` + +### Secret Handling + +```bash +# Never log sensitive inputs +if [ -n "$INPUT_TOKEN" ]; then + echo "Token provided: [REDACTED]" + # Use token safely without logging it +fi + +# Mask secrets in GitHub Actions +echo "::add-mask::$SENSITIVE_VALUE" +``` + +## Performance Optimization + +### Layer Caching + +```dockerfile +# Order commands for optimal caching +FROM python:3.11-slim + +# Install system dependencies first (changes rarely) +RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* + +# Copy and install requirements (changes less frequently than code) +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy source code last (changes most frequently) +COPY src/ ./src/ +``` + +### Multi-Architecture Builds + +```dockerfile +# Support multiple architectures +FROM --platform=$BUILDPLATFORM python:3.11-slim + +ARG TARGETPLATFORM +ARG BUILDPLATFORM + +RUN echo "Building on $BUILDPLATFORM for $TARGETPLATFORM" + +# Platform-specific optimizations +RUN case "$TARGETPLATFORM" in \ + "linux/amd64") echo "Optimizing for amd64" ;; \ + "linux/arm64") echo "Optimizing for arm64" ;; \ + *) echo "Using generic optimizations" ;; \ + esac +``` + +### Build Optimization + +```bash +# .github/workflows/docker-build.yml +- name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + cache-from: type=gha + cache-to: type=gha,mode=max +``` + +## Testing Docker Actions + +### Local Testing + +```bash +# Build the Docker image +docker build -t my-action . + +# Test with sample inputs +docker run --rm \ + -e INPUT_SOURCE_PATH="." \ + -e INPUT_CONFIG='{"pattern": "*.py"}' \ + -e GITHUB_OUTPUT=/tmp/output \ + -v $(pwd):/workspace \ + -w /workspace \ + my-action + +# Test with act +act -j test --action . +``` + +### Test Dockerfile + +```dockerfile +# Dockerfile.test +FROM my-action:latest + +# Add test dependencies +RUN pip install pytest + +# Copy test files +COPY tests/ ./tests/ + +# Run tests +CMD ["pytest", "tests/"] +``` + +### Integration Testing + +```yaml +# .github/workflows/test-docker.yml +name: Test Docker Action + +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build action + run: docker build -t test-action . + + - name: Test action + id: test + uses: ./ + with: + source-path: './test-data' + config: '{"verbose": true}' + + - name: Verify outputs + run: | + echo "Result: ${{ steps.test.outputs.result }}" + if [ "${{ steps.test.outputs.result }}" != "success" ]; then + exit 1 + fi +``` + +## Debugging Docker Actions + +### Debug Mode + +```dockerfile +# Add debug mode support +FROM python:3.11-slim + +# Install debugging tools +RUN apt-get update && apt-get install -y \ + strace \ + gdb \ + procps \ + && rm -rf /var/lib/apt/lists/* + +COPY debug-entrypoint.sh /debug-entrypoint.sh +RUN chmod +x /debug-entrypoint.sh + +# Use debug entrypoint if DEBUG=1 +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +CMD ["/bin/sh", "-c", "if [ \"$DEBUG\" = \"1\" ]; then /debug-entrypoint.sh; else /entrypoint.sh; fi"] +``` + +```bash +# debug-entrypoint.sh +#!/bin/sh + +echo "=== DEBUG MODE ===" +echo "Environment variables:" +env | sort + +echo "=== File system ===" +ls -la / +ls -la /workspace || true + +echo "=== Running action ===" +/entrypoint.sh +``` + +### Logging and Monitoring + +```python +import logging +import os + +# Set up logging based on GitHub Actions environment +log_level = os.environ.get('INPUT_LOG_LEVEL', 'info').upper() +logging.basicConfig( + level=getattr(logging, log_level), + format='%(asctime)s - %(levelname)s - %(message)s' +) + +logger = logging.getLogger(__name__) + +def github_log(level: str, message: str): + """Log in GitHub Actions format.""" + print(f"::{level}::{message}") + +# Usage +logger.info("Processing started") +github_log("debug", "Debug information") +github_log("warning", "Something might be wrong") +``` + +## Cross-References + +- [Action Metadata](action-metadata.md) - Docker-specific metadata options +- [Project Setup](project-setup.md) - Setting up Docker action projects +- [Testing Guide](testing.md) - Testing strategies for Docker actions +- [Publishing Guide](publishing.md) - Publishing Docker actions \ No newline at end of file diff --git a/components/skills/github-actions-dev/references/metaprogramming.md b/components/skills/github-actions-dev/references/metaprogramming.md new file mode 100644 index 00000000..e27fd39e --- /dev/null +++ b/components/skills/github-actions-dev/references/metaprogramming.md @@ -0,0 +1,529 @@ +# GitHub Actions Metaprogramming Patterns + +Advanced patterns for dynamic workflow generation and runtime code manipulation in GitHub Actions. + +## Overview + +GitHub Actions metaprogramming enables dynamic workflow generation, runtime inspection, and macro-like abstractions. This goes beyond simple conditionals to true code generation and runtime manipulation. + +## Expression Functions + +GitHub provides built-in functions for runtime evaluation and data transformation. + +### Core Expression Functions + +```yaml +# JSON manipulation +fromJson: ${{ fromJson('{"key": "value"}') }} +toJson: ${{ toJson(github.event) }} + +# String functions +contains: ${{ contains('hello world', 'hello') }} +startsWith: ${{ startsWith(github.ref, 'refs/tags/') }} +endsWith: ${{ endsWith(github.ref, '/main') }} +format: ${{ format('Hello {0}!', github.actor) }} +join: ${{ join(github.event.commits.*.message, '\n') }} + +# Object property access +github.event.pull_request.title +env.NODE_VERSION + +# Hash functions +hashFiles: ${{ hashFiles('**/package-lock.json') }} +``` + +### Advanced Expression Patterns + +```yaml +# Dynamic job configuration +strategy: + matrix: + # Generate matrix from API response + include: ${{ fromJson(steps.get-matrix.outputs.matrix) }} + # Or from file content + node-version: ${{ fromJson(steps.read-config.outputs.versions) }} + +# Complex conditionals +if: > + ${{ + github.event_name == 'pull_request' && + contains(github.event.pull_request.title, 'feat') && + !contains(github.event.pull_request.labels.*.name, 'skip-ci') + }} + +# Dynamic environment variables +env: + DEPLOY_ENV: > + ${{ + github.ref == 'refs/heads/main' && 'production' || + github.ref == 'refs/heads/staging' && 'staging' || + 'development' + }} + +# Escape user input (security critical) +run: | + INPUT='${{ toJson(github.event.client_payload.message) }}' + echo "Safe input: $INPUT" +``` + +## Dynamic Matrix Generation + +### API-Driven Matrix + +Generate build matrices from external APIs: + +```yaml +jobs: + get-matrix: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.matrix.outputs.matrix }} + steps: + - id: matrix + run: | + # Fetch supported Node.js versions from API + MATRIX=$(curl -s https://api.example.com/node-versions | jq -c '{node: .}') + echo "matrix=$MATRIX" >> $GITHUB_OUTPUT + + build: + needs: get-matrix + strategy: + matrix: + include: ${{ fromJson(needs.get-matrix.outputs.matrix) }} + runs-on: ubuntu-latest + steps: + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} +``` + +### File-Based Matrix + +Generate matrix from repository files: + +```yaml +jobs: + discover-packages: + outputs: + packages: ${{ steps.packages.outputs.packages }} + steps: + - uses: actions/checkout@v4 + - id: packages + run: | + PACKAGES=$(find . -name package.json -not -path "*/node_modules/*" | \ + jq -R -s -c 'split("\n")[:-1] | map(. | split("/")[1])') + echo "packages=$PACKAGES" >> $GITHUB_OUTPUT + + test: + needs: discover-packages + strategy: + matrix: + package: ${{ fromJson(needs.discover-packages.outputs.packages) }} + steps: + - run: npm test --workspace=${{ matrix.package }} +``` + +## Context Injection + +### GitHub Context Inspection + +Access runtime metadata for dynamic behavior: + +```yaml +- name: Analyze event context + run: | + echo "Event: ${{ github.event_name }}" + echo "Actor: ${{ github.actor }}" + echo "Ref: ${{ github.ref }}" + + # Pull request context + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + echo "PR Number: ${{ github.event.pull_request.number }}" + echo "PR Title: ${{ github.event.pull_request.title }}" + echo "Changed Files: ${{ join(github.event.pull_request.changed_files, ', ') }}" + fi + + # Issue context + if [[ "${{ github.event_name }}" == "issues" ]]; then + echo "Issue: ${{ github.event.issue.number }}" + echo "Action: ${{ github.event.action }}" + fi + +# Context-based job skipping +- name: Skip on bot PRs + if: ${{ github.actor != 'dependabot[bot]' && github.actor != 'renovate[bot]' }} + run: echo "Running for human contributor" +``` + +### Custom Context Variables + +```yaml +env: + # Generate build metadata + BUILD_INFO: > + ${{ + format('{{ + "sha": "{0}", + "ref": "{1}", + "actor": "{2}", + "timestamp": "{3}" + }}', github.sha, github.ref, github.actor, github.event.head_commit.timestamp) + }} + +- name: Use build context + run: | + BUILD_JSON='${{ env.BUILD_INFO }}' + echo "Build SHA: $(echo $BUILD_JSON | jq -r .sha)" +``` + +## Reusable Workflows as Macros + +### Template Workflow Pattern + +Create macro-like abstractions with reusable workflows: + +```yaml +# .github/workflows/deploy-template.yml +name: Deploy Template + +on: + workflow_call: + inputs: + environment: + required: true + type: string + app-name: + required: true + type: string + config-override: + required: false + type: string + default: '{}' + secrets: + deploy-token: + required: true + +jobs: + deploy: + environment: ${{ inputs.environment }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Prepare config + id: config + run: | + BASE_CONFIG='{"app": "${{ inputs.app-name }}", "env": "${{ inputs.environment }}"}' + OVERRIDE='${{ inputs.config-override }}' + + if [[ "$OVERRIDE" != '{}' ]]; then + MERGED=$(echo "$BASE_CONFIG $OVERRIDE" | jq -s '.[0] * .[1]') + else + MERGED="$BASE_CONFIG" + fi + + echo "config=$MERGED" >> $GITHUB_OUTPUT + + - name: Deploy + run: | + CONFIG='${{ steps.config.outputs.config }}' + echo "Deploying with config: $CONFIG" +``` + +```yaml +# Usage in another workflow +jobs: + deploy-staging: + uses: ./.github/workflows/deploy-template.yml + with: + environment: staging + app-name: my-app + config-override: '{"replicas": 2}' + secrets: + deploy-token: ${{ secrets.STAGING_TOKEN }} + + deploy-production: + uses: ./.github/workflows/deploy-template.yml + with: + environment: production + app-name: my-app + config-override: '{"replicas": 5, "cdn": true}' + secrets: + deploy-token: ${{ secrets.PROD_TOKEN }} +``` + +## Code Generation in Actions + +### TypeScript Action Generator + +Generate action metadata from TypeScript schemas: + +```typescript +// src/generator.ts +import { writeFile } from 'fs/promises'; +import { compile } from 'json-schema-to-typescript'; + +interface ActionSchema { + name: string; + description: string; + inputs: Record; + outputs: Record; +} + +interface InputSchema { + description: string; + required?: boolean; + default?: string; + type: 'string' | 'boolean' | 'number'; +} + +async function generateActionMetadata(schema: ActionSchema): Promise { + const inputs = Object.entries(schema.inputs).map(([key, input]) => { + return ` ${key}:\n description: '${input.description}'\n required: ${input.required ?? false}${ + input.default ? `\n default: '${input.default}'` : '' + }`; + }).join('\n'); + + const outputs = Object.entries(schema.outputs).map(([key, output]) => { + return ` ${key}:\n description: '${output.description}'`; + }).join('\n'); + + return `name: '${schema.name}' +description: '${schema.description}' +author: 'Generated Action' + +inputs: +${inputs} + +outputs: +${outputs} + +runs: + using: 'node20' + main: 'dist/index.js' +`; +} + +// Usage +const schema: ActionSchema = { + name: 'Dynamic Test Runner', + description: 'Runs tests based on changed files', + inputs: { + 'test-pattern': { + description: 'Test file pattern', + required: false, + default: '**/*.test.js', + type: 'string' + }, + 'parallel': { + description: 'Run tests in parallel', + required: false, + default: 'true', + type: 'boolean' + } + }, + outputs: { + 'test-results': { + description: 'JSON test results' + } + } +}; + +const actionYml = await generateActionMetadata(schema); +await writeFile('action.yml', actionYml); +``` + +### Dynamic Workflow Generation + +Generate workflows from configuration: + +```typescript +// scripts/generate-workflows.ts +interface WorkflowConfig { + name: string; + triggers: string[]; + jobs: JobConfig[]; +} + +interface JobConfig { + name: string; + steps: StepConfig[]; + matrix?: Record; +} + +function generateWorkflow(config: WorkflowConfig): string { + const triggers = config.triggers.map(t => ` ${t}:`).join('\n'); + const jobs = config.jobs.map(generateJob).join('\n\n'); + + return `name: ${config.name} + +on: +${triggers} + +jobs: +${jobs}`; +} + +function generateJob(job: JobConfig): string { + const matrix = job.matrix ? + ` strategy:\n matrix:\n${Object.entries(job.matrix) + .map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`) + .join('\n')}\n` : ''; + + const steps = job.steps.map((step, i) => + ` - ${step.uses ? `uses: ${step.uses}` : `run: ${step.run}`}` + ).join('\n'); + + return ` ${job.name}: + runs-on: ubuntu-latest +${matrix} steps: +${steps}`; +} + +// Generate CI workflows for multiple services +const services = ['api', 'web', 'worker']; +services.forEach(service => { + const workflow = generateWorkflow({ + name: `${service} CI`, + triggers: ['push', 'pull_request'], + jobs: [{ + name: 'test', + steps: [ + { uses: 'actions/checkout@v4' }, + { uses: 'actions/setup-node@v4', with: { 'node-version': '20' } }, + { run: `npm ci --workspace=${service}` }, + { run: `npm test --workspace=${service}` } + ] + }] + }); + + writeFileSync(`.github/workflows/${service}.yml`, workflow); +}); +``` + +## Runtime Code Analysis + +### Change Detection Patterns + +```yaml +- name: Detect changes + id: changes + run: | + # Get changed files + CHANGED_FILES=$(git diff --name-only ${{ github.event.before }} ${{ github.sha }}) + + # Analyze change patterns + HAS_API_CHANGES=$(echo "$CHANGED_FILES" | grep -E '^api/' && echo 'true' || echo 'false') + HAS_UI_CHANGES=$(echo "$CHANGED_FILES" | grep -E '^ui/' && echo 'true' || echo 'false') + HAS_DOCS_CHANGES=$(echo "$CHANGED_FILES" | grep -E '\.(md|rst)$' && echo 'true' || echo 'false') + + # Set conditional outputs + echo "api-changed=$HAS_API_CHANGES" >> $GITHUB_OUTPUT + echo "ui-changed=$HAS_UI_CHANGES" >> $GITHUB_OUTPUT + echo "docs-changed=$HAS_DOCS_CHANGES" >> $GITHUB_OUTPUT + +- name: Test API + if: ${{ steps.changes.outputs.api-changed == 'true' }} + run: npm test:api + +- name: Test UI + if: ${{ steps.changes.outputs.ui-changed == 'true' }} + run: npm test:ui + +- name: Build docs + if: ${{ steps.changes.outputs.docs-changed == 'true' }} + run: npm run docs:build +``` + +### Dependency Analysis + +```yaml +- name: Analyze dependencies + id: deps + run: | + # Extract dependencies from package.json files + DEPS=$(find . -name package.json -not -path "*/node_modules/*" -exec jq -r '.dependencies // {} | keys | .[]' {} \; | sort -u) + + # Check for security issues + SECURITY_DEPS=$(echo "$DEPS" | grep -E '(lodash|moment|request)' || echo '') + + # Generate test matrix + MATRIX=$(echo "$DEPS" | jq -R -s -c 'split("\n")[:-1] | map(select(length > 0)) | {dep: .}') + + echo "security-deps=$SECURITY_DEPS" >> $GITHUB_OUTPUT + echo "matrix=$MATRIX" >> $GITHUB_OUTPUT + +- name: Security warning + if: ${{ steps.deps.outputs.security-deps != '' }} + run: | + echo "::warning::Security-sensitive dependencies found: ${{ steps.deps.outputs.security-deps }}" +``` + +## Best Practices + +### Security Considerations + +```yaml +# ❌ NEVER: Direct interpolation of user input +run: echo "Hello ${{ github.event.issue.title }}" # Injection risk + +# ✅ ALWAYS: Use toJson() to escape +run: | + TITLE='${{ toJson(github.event.issue.title) }}' + echo "Hello $TITLE" + +# ✅ Environment variables for complex data +env: + ISSUE_TITLE: ${{ github.event.issue.title }} +run: echo "Hello $ISSUE_TITLE" +``` + +### Performance Optimization + +```yaml +# Conditional job execution +jobs: + expensive-job: + if: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-expensive-tests') }} + +# Cache generated matrices +- id: matrix-cache + uses: actions/cache@v4 + with: + path: .matrix-cache + key: matrix-${{ hashFiles('**/package.json') }} + +- id: matrix + run: | + if [[ -f .matrix-cache/matrix.json ]]; then + MATRIX=$(cat .matrix-cache/matrix.json) + else + MATRIX=$(generate-matrix) + mkdir -p .matrix-cache + echo "$MATRIX" > .matrix-cache/matrix.json + fi + echo "matrix=$MATRIX" >> $GITHUB_OUTPUT +``` + +### Debugging Patterns + +```yaml +- name: Debug context + if: ${{ runner.debug == '1' }} + run: | + echo "GitHub Context:" + echo '${{ toJson(github) }}' | jq . + + echo "Environment:" + env | sort + + echo "Matrix Context:" + echo '${{ toJson(matrix) }}' | jq . +``` + +## Cross-References + +- [Core Development Patterns](../SKILL.md#core-development-patterns) - Basic patterns +- [Toolkit API Reference](toolkit-api.md) - Core action APIs +- [Testing Guide](testing.md) - Testing metaprogramming patterns +- [Build Tooling](build-tooling.md) - Code generation in build process \ No newline at end of file diff --git a/components/skills/github-actions-dev/references/project-setup.md b/components/skills/github-actions-dev/references/project-setup.md new file mode 100644 index 00000000..5de036a9 --- /dev/null +++ b/components/skills/github-actions-dev/references/project-setup.md @@ -0,0 +1,621 @@ +# GitHub Actions Project Setup + +Complete guide to setting up GitHub Actions development projects from scratch. + +## Quick Start Templates + +### Official Template (Recommended) + +```bash +# Use GitHub's official TypeScript template +npx create-action my-action +cd my-action +npm install +npm run all +``` + +### Manual Setup + +```bash +mkdir my-action && cd my-action +npm init -y +npm install @actions/core @actions/github @actions/exec @actions/io +npm install -D typescript @types/node @vercel/ncc jest @types/jest ts-jest +``` + +## Project Structure + +``` +my-action/ +├── action.yml # Action metadata +├── src/ +│ ├── main.ts # Entry point +│ ├── input.ts # Input validation +│ ├── action.ts # Core logic +│ └── utils.ts # Helper functions +├── dist/ +│ └── index.js # Bundled output (must commit) +├── __tests__/ +│ ├── main.test.ts # Unit tests +│ └── integration.test.ts # Integration tests +├── .github/ +│ └── workflows/ +│ └── test.yml # CI workflow +├── package.json +├── tsconfig.json +├── jest.config.js +├── .gitignore +├── README.md +└── LICENSE +``` + +## Configuration Files + +### package.json + +```json +{ + "name": "my-action", + "version": "1.0.0", + "description": "GitHub Action description", + "main": "dist/index.js", + "scripts": { + "build": "ncc build src/main.ts -o dist --source-map --license licenses.txt", + "test": "jest", + "lint": "eslint src/**/*.ts", + "format": "prettier --write src/**/*.ts", + "all": "npm run format && npm run lint && npm run build && npm test" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/owner/my-action.git" + }, + "keywords": ["actions", "github"], + "author": "Your Name", + "license": "MIT", + "dependencies": { + "@actions/core": "^1.10.1", + "@actions/github": "^6.0.0", + "@actions/exec": "^1.1.1", + "@actions/io": "^1.1.3" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@typescript-eslint/eslint-plugin": "^6.0.0", + "@typescript-eslint/parser": "^6.0.0", + "@vercel/ncc": "^0.38.0", + "eslint": "^8.0.0", + "eslint-plugin-jest": "^27.0.0", + "jest": "^29.0.0", + "prettier": "^3.0.0", + "ts-jest": "^29.0.0", + "typescript": "^5.0.0" + } +} +``` + +### tsconfig.json + +```json +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "commonjs", + "outDir": "./lib", + "rootDir": "./src", + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictPropertyInitialization": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true, + "noUncheckedIndexedAccess": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules", "**/*.test.ts"] +} +``` + +### jest.config.js + +```javascript +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src/', '/__tests__/'], + testMatch: ['**/*.test.ts'], + collectCoverageFrom: [ + 'src/**/*.ts', + '!src/**/*.d.ts', + '!src/main.ts' // Entry point usually just calls run() + ], + coverageReporters: ['text', 'lcov', 'html'], + coverageDirectory: 'coverage', + clearMocks: true, + resetMocks: true, + restoreMocks: true +}; +``` + +### .eslintrc.js + +```javascript +module.exports = { + parser: '@typescript-eslint/parser', + plugins: ['@typescript-eslint', 'jest'], + extends: [ + 'eslint:recommended', + '@typescript-eslint/recommended', + 'plugin:jest/recommended' + ], + parserOptions: { + ecmaVersion: 2022, + sourceType: 'module' + }, + rules: { + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + '@typescript-eslint/explicit-function-return-type': 'error', + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/prefer-nullish-coalescing': 'error', + '@typescript-eslint/prefer-optional-chain': 'error', + 'prefer-const': 'error', + 'no-var': 'error' + }, + env: { + node: true, + es2022: true, + jest: true + } +}; +``` + +### .prettierrc + +```json +{ + "semi": true, + "trailingComma": "none", + "singleQuote": true, + "printWidth": 80, + "tabWidth": 2, + "useTabs": false +} +``` + +### .gitignore + +```gitignore +# Dependencies +node_modules/ + +# TypeScript build outputs +lib/ +*.tsbuildinfo + +# Coverage reports +coverage/ + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo + +# OS files +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Temporary folders +tmp/ +temp/ + +# DON'T ignore dist/ - it must be committed for JavaScript actions! +# dist/ ← This line should be commented out or removed +``` + +## Source Code Templates + +### src/main.ts (Entry Point) + +```typescript +import * as core from '@actions/core'; +import { run } from './action'; + +// Entry point for the action +async function main(): Promise { + try { + await run(); + } catch (error) { + // Fail the action with an error message + if (error instanceof Error) { + core.setFailed(error.message); + } else { + core.setFailed('An unexpected error occurred'); + } + } +} + +// Don't call main() directly if we're in test mode +if (require.main === module) { + main(); +} + +export { main }; +``` + +### src/input.ts (Input Validation) + +```typescript +import * as core from '@actions/core'; + +export interface ActionInputs { + token: string; + configPath: string; + timeout: number; + dryRun: boolean; + files: string[]; +} + +export function getInputs(): ActionInputs { + const token = core.getInput('token', { required: true }); + if (!token.match(/^gh[ps]_[a-zA-Z0-9]{36,255}$/)) { + throw new Error('Invalid GitHub token format'); + } + + const configPath = core.getInput('config-path') || '.github/config.yml'; + + const timeoutStr = core.getInput('timeout') || '300'; + const timeout = parseInt(timeoutStr, 10); + if (isNaN(timeout) || timeout < 1 || timeout > 3600) { + throw new Error('Timeout must be between 1 and 3600 seconds'); + } + + const dryRun = core.getBooleanInput('dry-run'); + + const files = core + .getMultilineInput('files') + .filter(f => f.trim().length > 0); + + return { + token, + configPath, + timeout, + dryRun, + files + }; +} +``` + +### src/action.ts (Core Logic) + +```typescript +import * as core from '@actions/core'; +import * as github from '@actions/github'; +import { getInputs, ActionInputs } from './input'; +import { performWork } from './utils'; + +export async function run(): Promise { + const inputs = getInputs(); + + core.info(`Starting action with config: ${inputs.configPath}`); + core.debug(`Dry run mode: ${inputs.dryRun}`); + + // Get GitHub context + const { owner, repo } = github.context.repo; + const octokit = github.getOctokit(inputs.token); + + // Perform the main work + await core.group('Performing main action', async () => { + const result = await performWork(octokit, inputs); + + // Set outputs + core.setOutput('result', result.status); + core.setOutput('details', JSON.stringify(result.details)); + + // Export variables for subsequent steps + core.exportVariable('ACTION_RESULT', result.status); + + core.info(`Action completed with result: ${result.status}`); + }); +} +``` + +### src/utils.ts (Helper Functions) + +```typescript +import * as core from '@actions/core'; +import { getOctokit } from '@actions/github'; +import { ActionInputs } from './input'; + +export interface ActionResult { + status: 'success' | 'failure' | 'skipped'; + details: Record; +} + +export async function performWork( + octokit: ReturnType, + inputs: ActionInputs +): Promise { + core.debug('Starting work with inputs'); + + if (inputs.dryRun) { + core.info('Dry run mode - skipping actual work'); + return { + status: 'skipped', + details: { reason: 'dry-run' } + }; + } + + try { + // Perform actual work here + core.info('Performing actual work...'); + + // Example API call + const { data: repo } = await octokit.rest.repos.get({ + owner: github.context.repo.owner, + repo: github.context.repo.repo + }); + + return { + status: 'success', + details: { + repoName: repo.name, + filesProcessed: inputs.files.length + } + }; + } catch (error) { + core.error(`Work failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + throw error; + } +} + +export function validateConfig(config: unknown): boolean { + // Add configuration validation logic + return typeof config === 'object' && config !== null; +} +``` + +## CI Workflow Setup + +### .github/workflows/test.yml + +```yaml +name: Test Action + +on: + push: + branches: [main, develop] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [18, 20] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Run tests + run: npm test + + - name: Build + run: npm run build + + - name: Test action + id: test + uses: ./ + with: + token: ${{ secrets.GITHUB_TOKEN }} + config-path: '__tests__/fixtures/config.yml' + dry-run: true + + - name: Verify output + run: | + echo "Result: ${{ steps.test.outputs.result }}" + if [ "${{ steps.test.outputs.result }}" != "skipped" ]; then + echo "Expected 'skipped' in dry-run mode" + exit 1 + fi + + integration: + runs-on: ubuntu-latest + needs: test + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + + steps: + - uses: actions/checkout@v4 + + - name: Test action (real run) + uses: ./ + with: + token: ${{ secrets.GITHUB_TOKEN }} + dry-run: false +``` + +### .github/workflows/release.yml + +```yaml +name: Release + +on: + release: + types: [published] + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run all + + - name: Update major version tag + run: | + VERSION=${GITHUB_REF#refs/tags/} + MAJOR=${VERSION%%.*} + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # Update or create major version tag + git tag -fa "v$MAJOR" -m "Update v$MAJOR tag to $VERSION" + git push origin "v$MAJOR" --force +``` + +## Common Setup Issues + +### Bundle Size + +The `dist/` folder must be committed but can get large: + +```bash +# Check bundle size +ls -la dist/ +du -h dist/ + +# Optimize bundle +npm run build -- --minify + +# Analyze what's in the bundle +npx ncc build src/main.ts --source-map --stats-out stats.json +``` + +### TypeScript Compilation + +```bash +# Check TypeScript compilation +npx tsc --noEmit + +# Watch mode during development +npx tsc --watch --noEmit +``` + +### Testing Setup + +```bash +# Run tests with coverage +npm test -- --coverage + +# Run specific test file +npm test -- main.test.ts + +# Run tests in watch mode +npm test -- --watch +``` + +## Development Workflow + +1. **Initial Setup** + ```bash + npx create-action my-action + cd my-action + ``` + +2. **Development Cycle** + ```bash + # Make changes to src/ + npm run build # Bundle for testing + npm test # Run test suite + npm run all # Full check (format, lint, build, test) + ``` + +3. **Testing** + ```bash + # Local testing with act + act -j test -s GITHUB_TOKEN="$(gh auth token)" + + # Debug action + act -j test --verbose + ``` + +4. **Release** + ```bash + # Commit dist/ changes + git add dist/ + git commit -m "Build for release" + + # Create and push tag + git tag -a v1.0.0 -m "Release v1.0.0" + git push origin v1.0.0 + ``` + +## Cross-References + +- [Action Metadata](action-metadata.md) - Complete action.yml reference +- [Toolkit API Reference](toolkit-api.md) - Core action APIs +- [Testing Guide](testing.md) - Comprehensive testing strategies +- [Publishing Guide](publishing.md) - Marketplace publication \ No newline at end of file diff --git a/components/skills/github-actions-dev/references/testing.md b/components/skills/github-actions-dev/references/testing.md new file mode 100644 index 00000000..f4bd70d7 --- /dev/null +++ b/components/skills/github-actions-dev/references/testing.md @@ -0,0 +1,833 @@ +# GitHub Actions Testing Guide + +Comprehensive testing strategies for GitHub Actions development. + +## Testing Pyramid + +``` + E2E Tests (GitHub Workflows) + ████████████████████████████ + Integration Tests (Act/Local) + ████████████████████████████████████ + Unit Tests (Jest with Mocks) +████████████████████████████████████████████ +``` + +### Test Types by Scope + +| Test Type | What to Test | Tools | Frequency | +|-----------|--------------|-------|-----------| +| **Unit Tests** | Individual functions, input validation | Jest + Mocks | Every commit | +| **Integration Tests** | Full action with real filesystem | Jest + Temporary directories | PR/Release | +| **Local E2E Tests** | Action in real GitHub environment | Act | Before release | +| **GitHub E2E Tests** | Action in production | GitHub workflows | Release/Deploy | + +## Unit Testing + +### Test Setup + +```typescript +// __tests__/setup.ts +import { jest } from '@jest/globals'; + +// Clear all mocks between tests +beforeEach(() => { + jest.clearAllMocks(); + jest.resetModules(); + + // Reset environment + delete process.env.GITHUB_REPOSITORY; + delete process.env.GITHUB_ACTOR; + delete process.env.INPUT_TOKEN; +}); + +// Mock GitHub context +jest.mock('@actions/github', () => ({ + context: { + repo: { owner: 'test-owner', repo: 'test-repo' }, + actor: 'test-actor', + sha: 'abc123', + ref: 'refs/heads/main', + payload: {} + }, + getOctokit: jest.fn() +})); +``` + +### Input Validation Tests + +```typescript +// __tests__/input.test.ts +import * as core from '@actions/core'; +import { getInputs } from '../src/input'; + +jest.mock('@actions/core'); +const mockCore = core as jest.Mocked; + +describe('Input validation', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getInputs', () => { + it('should return valid inputs', () => { + mockCore.getInput.mockImplementation((name: string) => { + const inputs: Record = { + 'token': 'ghp_1234567890abcdef1234567890abcdef12345678', + 'config-path': '.github/my-config.yml', + 'timeout': '120' + }; + return inputs[name] || ''; + }); + + mockCore.getBooleanInput.mockReturnValue(false); + mockCore.getMultilineInput.mockReturnValue(['file1.js', 'file2.js']); + + const inputs = getInputs(); + + expect(inputs).toEqual({ + token: 'ghp_1234567890abcdef1234567890abcdef12345678', + configPath: '.github/my-config.yml', + timeout: 120, + dryRun: false, + files: ['file1.js', 'file2.js'] + }); + }); + + it('should throw on invalid token format', () => { + mockCore.getInput.mockImplementation((name: string) => { + if (name === 'token') return 'invalid-token'; + return ''; + }); + + expect(() => getInputs()).toThrow('Invalid GitHub token format'); + }); + + it('should throw on invalid timeout', () => { + mockCore.getInput.mockImplementation((name: string) => { + const inputs: Record = { + 'token': 'ghp_1234567890abcdef1234567890abcdef12345678', + 'timeout': '3700' // Too large + }; + return inputs[name] || ''; + }); + + expect(() => getInputs()).toThrow('Timeout must be between 1 and 3600 seconds'); + }); + + it('should handle empty file list', () => { + mockCore.getInput.mockImplementation((name: string) => { + if (name === 'token') return 'ghp_1234567890abcdef1234567890abcdef12345678'; + return ''; + }); + mockCore.getBooleanInput.mockReturnValue(false); + mockCore.getMultilineInput.mockReturnValue(['', ' ', 'file1.js', '']); + + const inputs = getInputs(); + + expect(inputs.files).toEqual(['file1.js']); + }); + }); +}); +``` + +### Action Logic Tests + +```typescript +// __tests__/action.test.ts +import * as core from '@actions/core'; +import * as github from '@actions/github'; +import { run } from '../src/action'; +import * as input from '../src/input'; +import * as utils from '../src/utils'; + +jest.mock('@actions/core'); +jest.mock('@actions/github'); +jest.mock('../src/input'); +jest.mock('../src/utils'); + +const mockCore = core as jest.Mocked; +const mockGithub = github as jest.Mocked; +const mockInput = input as jest.Mocked; +const mockUtils = utils as jest.Mocked; + +describe('Action', () => { + beforeEach(() => { + jest.clearAllMocks(); + + // Default mocks + mockInput.getInputs.mockReturnValue({ + token: 'ghp_test', + configPath: '.github/config.yml', + timeout: 300, + dryRun: false, + files: ['test.js'] + }); + + mockGithub.getOctokit.mockReturnValue({} as any); + + mockUtils.performWork.mockResolvedValue({ + status: 'success', + details: { processed: 1 } + }); + }); + + it('should complete successfully', async () => { + await run(); + + expect(mockCore.setOutput).toHaveBeenCalledWith('result', 'success'); + expect(mockCore.setOutput).toHaveBeenCalledWith( + 'details', + JSON.stringify({ processed: 1 }) + ); + expect(mockCore.exportVariable).toHaveBeenCalledWith('ACTION_RESULT', 'success'); + expect(mockCore.setFailed).not.toHaveBeenCalled(); + }); + + it('should handle dry run mode', async () => { + mockInput.getInputs.mockReturnValue({ + token: 'ghp_test', + configPath: '.github/config.yml', + timeout: 300, + dryRun: true, + files: ['test.js'] + }); + + mockUtils.performWork.mockResolvedValue({ + status: 'skipped', + details: { reason: 'dry-run' } + }); + + await run(); + + expect(mockCore.setOutput).toHaveBeenCalledWith('result', 'skipped'); + }); + + it('should fail gracefully on error', async () => { + const error = new Error('Test error'); + mockUtils.performWork.mockRejectedValue(error); + + await expect(run()).rejects.toThrow('Test error'); + }); +}); +``` + +### Utility Function Tests + +```typescript +// __tests__/utils.test.ts +import * as github from '@actions/github'; +import { performWork, validateConfig } from '../src/utils'; +import { ActionInputs } from '../src/input'; + +jest.mock('@actions/github'); + +describe('Utils', () => { + const mockOctokit = { + rest: { + repos: { + get: jest.fn() + } + } + } as any; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('performWork', () => { + const inputs: ActionInputs = { + token: 'ghp_test', + configPath: '.github/config.yml', + timeout: 300, + dryRun: false, + files: ['test.js'] + }; + + it('should return success on normal operation', async () => { + mockOctokit.rest.repos.get.mockResolvedValue({ + data: { name: 'test-repo' } + }); + + const result = await performWork(mockOctokit, inputs); + + expect(result).toEqual({ + status: 'success', + details: { + repoName: 'test-repo', + filesProcessed: 1 + } + }); + }); + + it('should handle dry run mode', async () => { + const dryRunInputs = { ...inputs, dryRun: true }; + + const result = await performWork(mockOctokit, dryRunInputs); + + expect(result).toEqual({ + status: 'skipped', + details: { reason: 'dry-run' } + }); + expect(mockOctokit.rest.repos.get).not.toHaveBeenCalled(); + }); + + it('should throw on API error', async () => { + const error = new Error('API Error'); + mockOctokit.rest.repos.get.mockRejectedValue(error); + + await expect(performWork(mockOctokit, inputs)).rejects.toThrow('API Error'); + }); + }); + + describe('validateConfig', () => { + it('should validate valid config', () => { + expect(validateConfig({ key: 'value' })).toBe(true); + expect(validateConfig({})).toBe(true); + }); + + it('should reject invalid config', () => { + expect(validateConfig(null)).toBe(false); + expect(validateConfig(undefined)).toBe(false); + expect(validateConfig('string')).toBe(false); + expect(validateConfig(123)).toBe(false); + }); + }); +}); +``` + +### Main Entry Point Tests + +```typescript +// __tests__/main.test.ts +import * as core from '@actions/core'; +import { main } from '../src/main'; +import * as action from '../src/action'; + +jest.mock('@actions/core'); +jest.mock('../src/action'); + +const mockCore = core as jest.Mocked; +const mockAction = action as jest.Mocked; + +describe('Main entry point', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should complete successfully', async () => { + mockAction.run.mockResolvedValue(); + + await main(); + + expect(mockAction.run).toHaveBeenCalledOnce(); + expect(mockCore.setFailed).not.toHaveBeenCalled(); + }); + + it('should handle Error objects', async () => { + const error = new Error('Test error'); + mockAction.run.mockRejectedValue(error); + + await main(); + + expect(mockCore.setFailed).toHaveBeenCalledWith('Test error'); + }); + + it('should handle non-Error objects', async () => { + mockAction.run.mockRejectedValue('String error'); + + await main(); + + expect(mockCore.setFailed).toHaveBeenCalledWith('An unexpected error occurred'); + }); +}); +``` + +## Integration Testing + +### Filesystem Integration + +```typescript +// __tests__/integration.test.ts +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as os from 'os'; +import { run } from '../src/action'; + +describe('Integration Tests', () => { + let tempDir: string; + + beforeEach(async () => { + // Create temporary directory for each test + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'action-test-')); + }); + + afterEach(async () => { + // Clean up + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('should process real files', async () => { + // Setup test files + const configFile = path.join(tempDir, 'config.yml'); + await fs.writeFile(configFile, 'key: value\n'); + + const testFile = path.join(tempDir, 'test.js'); + await fs.writeFile(testFile, 'console.log("hello");\n'); + + // Mock environment + process.env.INPUT_TOKEN = 'ghp_1234567890abcdef1234567890abcdef12345678'; + process.env.INPUT_CONFIG_PATH = configFile; + process.env.INPUT_FILES = testFile; + process.env.INPUT_DRY_RUN = 'false'; + + // Mock GitHub APIs + jest.mock('@actions/github', () => ({ + context: { + repo: { owner: 'test-owner', repo: 'test-repo' } + }, + getOctokit: () => ({ + rest: { + repos: { + get: () => Promise.resolve({ + data: { name: 'test-repo' } + }) + } + } + }) + })); + + // Run action + await expect(run()).resolves.not.toThrow(); + }); +}); +``` + +### GitHub API Integration + +```typescript +// __tests__/github-integration.test.ts +import { getOctokit } from '@actions/github'; +import { performWork } from '../src/utils'; + +describe('GitHub API Integration', () => { + // Only run these tests if we have a real token + const token = process.env.GITHUB_TOKEN; + const skipMessage = 'Skipping GitHub API tests (no GITHUB_TOKEN)'; + + it('should interact with real GitHub API', async () => { + if (!token) { + console.log(skipMessage); + return; + } + + const octokit = getOctokit(token); + const inputs = { + token, + configPath: '.github/config.yml', + timeout: 300, + dryRun: false, + files: ['package.json'] + }; + + // Test with a known public repository + const result = await performWork(octokit, inputs); + + expect(result.status).toMatch(/success|skipped/); + expect(result.details).toBeDefined(); + }); +}); +``` + +## Local E2E Testing with Act + +### Act Configuration + +```json +// .actrc +{ + "platform": "ubuntu-latest=node:20-buster-slim", + "artifact-server-path": "/tmp/artifacts", + "env-file": ".env.test" +} +``` + +### Test Environment + +```bash +# .env.test +GITHUB_TOKEN=ghp_your_test_token_here +INPUT_CONFIG_PATH=__tests__/fixtures/config.yml +INPUT_DRY_RUN=true +``` + +### Act Test Commands + +```bash +# Test the action locally +act -j test + +# Test with specific event +act push -j test + +# Test with custom input +act -j test -s GITHUB_TOKEN="$(gh auth token)" \ + --input token="$(gh auth token)" \ + --input config-path="test-config.yml" + +# Debug mode +act -j test --verbose + +# Test with custom workflow +act -W .github/workflows/test-local.yml +``` + +### Local Test Workflow + +```yaml +# .github/workflows/test-local.yml +name: Local Test + +on: push + +jobs: + test-local: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Test action + uses: ./ + with: + token: ${{ secrets.GITHUB_TOKEN }} + config-path: '__tests__/fixtures/config.yml' + files: | + package.json + src/main.ts + dry-run: true + + - name: Validate outputs + run: | + echo "Testing output validation..." + # Add output validation logic +``` + +## GitHub E2E Testing + +### Test Matrix Workflows + +```yaml +# .github/workflows/test-matrix.yml +name: Test Matrix + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + node-version: [18, 20] + include: + - os: ubuntu-latest + test-type: 'full' + - os: windows-latest + test-type: 'basic' + - os: macos-latest + test-type: 'basic' + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Test action + id: test + uses: ./ + with: + token: ${{ secrets.GITHUB_TOKEN }} + dry-run: true + + - name: Verify output + shell: bash + run: | + if [ "${{ steps.test.outputs.result }}" != "skipped" ]; then + echo "Expected 'skipped' in dry-run mode" + exit 1 + fi +``` + +### Real Environment Tests + +```yaml +# .github/workflows/integration.yml +name: Integration Tests + +on: + workflow_dispatch: + schedule: + - cron: '0 2 * * *' # Daily at 2 AM + +jobs: + real-test: + runs-on: ubuntu-latest + environment: testing + + steps: + - uses: actions/checkout@v4 + + - name: Test with real data + uses: ./ + with: + token: ${{ secrets.GITHUB_TOKEN }} + config-path: '.github/test-config.yml' + dry-run: false + + - name: Verify real results + run: | + # Add verification logic for real execution + echo "Verifying real test results..." +``` + +## Mock Strategies + +### Core API Mocking + +```typescript +// __tests__/mocks/core.ts +export const mockCore = { + getInput: jest.fn(), + getBooleanInput: jest.fn(), + getMultilineInput: jest.fn(), + setOutput: jest.fn(), + setFailed: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + warning: jest.fn(), + error: jest.fn(), + exportVariable: jest.fn(), + group: jest.fn().mockImplementation(async (name: string, fn: () => Promise) => { + return await fn(); + }) +}; +``` + +### GitHub API Mocking + +```typescript +// __tests__/mocks/github.ts +export const mockOctokit = { + rest: { + repos: { + get: jest.fn().mockResolvedValue({ + data: { name: 'mock-repo', private: false } + }), + listLanguages: jest.fn().mockResolvedValue({ + data: { JavaScript: 1000, TypeScript: 2000 } + }) + }, + issues: { + createComment: jest.fn().mockResolvedValue({ + data: { id: 123, html_url: 'https://github.com/owner/repo/issues/1#issuecomment-123' } + }) + } + } +}; + +export const mockGithubContext = { + repo: { owner: 'test-owner', repo: 'test-repo' }, + actor: 'test-actor', + sha: 'abc123def456', + ref: 'refs/heads/main', + workflow: 'Test', + job: 'test', + runId: 123456, + runNumber: 42, + payload: { + pull_request: { + number: 1, + title: 'Test PR' + } + } +}; +``` + +## Test Data Management + +### Fixtures + +```typescript +// __tests__/fixtures/index.ts +export const testConfigs = { + valid: { + name: 'Test Config', + settings: { + timeout: 300, + retries: 3 + } + }, + invalid: { + // Missing required fields + settings: {} + } +}; + +export const testEvents = { + pullRequest: { + number: 1, + title: 'Test PR', + body: 'Test description', + head: { sha: 'abc123' }, + base: { sha: 'def456' } + }, + push: { + commits: [ + { message: 'feat: add new feature', id: 'abc123' } + ] + } +}; +``` + +### Test Factories + +```typescript +// __tests__/factories/inputs.ts +export function createTestInputs(overrides: Partial = {}): ActionInputs { + return { + token: 'ghp_1234567890abcdef1234567890abcdef12345678', + configPath: '.github/config.yml', + timeout: 300, + dryRun: false, + files: ['test.js'], + ...overrides + }; +} + +export function createMockOctokit(overrides: any = {}): any { + return { + rest: { + repos: { + get: jest.fn().mockResolvedValue({ + data: { name: 'test-repo' } + }) + } + }, + ...overrides + }; +} +``` + +## Test Coverage + +### Coverage Configuration + +```json +// package.json +{ + "scripts": { + "test:coverage": "jest --coverage", + "test:watch": "jest --watch", + "test:ci": "jest --ci --coverage --watchAll=false" + }, + "jest": { + "collectCoverageFrom": [ + "src/**/*.ts", + "!src/**/*.d.ts", + "!src/main.ts" + ], + "coverageThreshold": { + "global": { + "branches": 80, + "functions": 80, + "lines": 80, + "statements": 80 + } + } + } +} +``` + +### Coverage Reports + +```bash +# Generate HTML coverage report +npm run test:coverage + +# Check coverage without tests +npx jest --coverage --passWithNoTests + +# Coverage for specific files +npx jest --coverage --collectCoverageFrom="src/utils.ts" +``` + +## Debugging Tests + +### Debug Configuration + +```json +// .vscode/launch.json +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Debug Jest Tests", + "type": "node", + "request": "launch", + "program": "${workspaceFolder}/node_modules/.bin/jest", + "args": ["--runInBand"], + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen" + }, + { + "name": "Debug Specific Test", + "type": "node", + "request": "launch", + "program": "${workspaceFolder}/node_modules/.bin/jest", + "args": ["--runInBand", "${relativeFile}"], + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen" + } + ] +} +``` + +### Test Debugging Tips + +```typescript +// Add debug output in tests +describe('Debug test', () => { + it('should debug', () => { + console.log('Debug info:', JSON.stringify(data, null, 2)); + + // Use debugger + debugger; + + // Add temporary assertions + expect(true).toBe(true); + }); +}); + +// Run single test +// npm test -- --testNamePattern="should debug" + +// Run with verbose output +// npm test -- --verbose + +// Run in debug mode +// node --inspect-brk node_modules/.bin/jest --runInBand +``` + +## Cross-References + +- [Project Setup](project-setup.md) - Initial testing configuration +- [Toolkit API Reference](toolkit-api.md) - APIs to mock in tests +- [Publishing Guide](publishing.md) - Release testing strategies +- [Build Tooling](build-tooling.md) - CI/CD test integration \ No newline at end of file