diff --git a/.github/workflows/_release-cli.yml b/.github/workflows/_release-cli.yml new file mode 100644 index 0000000..2bfd60b --- /dev/null +++ b/.github/workflows/_release-cli.yml @@ -0,0 +1,20 @@ +name: CLI Ops +on: + push: + paths: + - 'cli/**' + - 'scripts/**' + - '.github/workflows/_release-cli.yml' + workflow_dispatch: + +jobs: + release: + uses: ./.github/workflows/npm-release-ops.yml + with: + registry_url: https://registry.npmjs.org/ + build_command: "" # CLI doesn't need build + test_command: "npm test" + working_directory: "cli" + environment: "Master" + secrets: + npm_token: ${{ secrets.npm_token }} diff --git a/.github/workflows/docker-ops.yml b/.github/workflows/docker-ops.yml index 2a87dc6..213cd9c 100644 --- a/.github/workflows/docker-ops.yml +++ b/.github/workflows/docker-ops.yml @@ -5,47 +5,47 @@ on: workflow_call: inputs: image_name: - description: "Primary identifier; must be lowercase, URL-safe, and matches your registry repository name" + description: "Docker image name (lowercase, URL-safe)" required: true type: string gcp_region: - description: "GCP region for Artifact Registry (Required if publishing to GCP)" + description: "GCP: Region for Artifact Registry (Required if publishing to GCP)" required: false type: string gcp_project_id: - description: "GCP project ID (Required if publishing to GCP)" + description: "GCP: Project ID (Required if publishing to GCP)" required: false type: string gcp_repo: - description: "GCP Artifact Registry repository name (Required if publishing to GCP)" + description: "GCP: Artifact Registry repository name (Required if publishing to GCP)" required: false type: string gcp_workload_identity_provider: - description: "GCP Workload Identity Provider (format: projects/NUM/locations/global/workloadIdentityPools/POOL/providers/PROV) (Required if publishing to GCP)" + description: "GCP: Workload Identity Provider (format: projects/NUM/locations/global/workloadIdentityPools/POOL/providers/PROV) (Required if publishing to GCP)" required: false type: string gcp_service_account: - description: "GCP Service Account email for Workload Identity Federation (Required if publishing to GCP)" + description: "GCP: Service Account email for Workload Identity Federation (Required if publishing to GCP)" required: false type: string acr_registry: - description: "Azure Container Registry name (e.g., myregistry.azurecr.io) (Required if publishing to ACR)" + description: "ACR: Registry name (e.g., myregistry.azurecr.io) (Required if publishing to ACR)" required: false type: string acr_repository: - description: "ACR repository name (Required if publishing to ACR)" + description: "ACR: Repository name (Required if publishing to ACR)" required: false type: string azure_client_id: - description: "Azure Client ID for OIDC authentication (Required if publishing to ACR)" + description: "ACR: Azure Client ID for OIDC authentication (Required if publishing to ACR)" required: false type: string azure_tenant_id: - description: "Azure Tenant ID for OIDC authentication (Required if publishing to ACR)" + description: "ACR: Azure Tenant ID for OIDC authentication (Required if publishing to ACR)" required: false type: string azure_subscription_id: - description: "Azure Subscription ID for OIDC authentication (Required if publishing to ACR)" + description: "ACR: Azure Subscription ID for OIDC authentication (Required if publishing to ACR)" required: false type: string release_branch: @@ -59,12 +59,12 @@ on: default: "./Dockerfile" type: string build_platforms: - description: "Platforms to build for (comma-separated, e.g., linux/amd64,linux/arm64)" + description: "Platforms to build for (e.g., linux/amd64,linux/arm64). Note: multi-platform is primarily used for Docker Hub." required: false default: "linux/amd64,linux/arm64" type: string version_config_path: - description: "Path to GitVersion config file (only used if package.json is absent)" + description: "Path to GitVersion config file (used if package.json NOT defined)" required: false default: "ci/git-version.yml" type: string @@ -89,23 +89,23 @@ on: default: "true" type: string docker_login: - description: "Docker Hub username (Required if publishing to Docker Hub)" + description: "Docker Hub: Username (Required if publishing to Docker Hub)" required: false type: string docker_org: - description: "Docker Hub organization name (Required if publishing to Docker Hub)" + description: "Docker Hub: Organization name (Required if publishing to Docker Hub)" required: false type: string docker_repo: - description: "Docker Hub repository name (Required if publishing to Docker Hub)" + description: "Docker Hub: Repository name (Required if publishing to Docker Hub)" required: false type: string secrets: docker_token: - description: "Docker Hub token for authentication (Required if publishing to Docker Hub)" + description: "Docker Hub: Token for authentication (Required if publishing to Docker Hub)" required: false slack_webhook_url: - description: "Slack webhook URL for notifications (Optional, for release notifications)" + description: "Slack: Webhook URL for notifications (Optional, for release notifications)" required: false jobs: diff --git a/.github/workflows/npm-release-ops.yml b/.github/workflows/npm-release-ops.yml new file mode 100644 index 0000000..279e14d --- /dev/null +++ b/.github/workflows/npm-release-ops.yml @@ -0,0 +1,223 @@ +name: NPM Release + +on: + workflow_call: + inputs: + node_version: + description: "Node.js: Version to use (default: 20)" + required: false + default: "20" + type: string + build_command: + description: "Build: Command to run before publishing (default: npm run build, set to empty to skip)" + required: false + default: "npm run build" + type: string + test_command: + description: "Test: Command to run before building (default: npm test, set to empty to skip)" + required: false + default: "npm test" + type: string + registry_url: + description: "NPM: Registry URL (default: https://registry.npmjs.org/)" + required: false + default: "https://registry.npmjs.org/" + type: string + provenance: + description: "NPM: Enable provenance for the release (default: true)" + required: false + default: true + type: boolean + release_branch: + description: "Branch: Deployment branch that triggers releases (default: latest)" + required: false + default: "latest" + type: string + enable_gh_release: + description: "GitHub: Whether to create a GitHub release (default: true)" + required: false + default: true + type: boolean + check_version_bump: + description: "NPM: Whether to check if the version is already published (default: true)" + required: false + default: true + type: boolean + working_directory: + description: "Common: Directory containing package.json" + required: false + type: string + default: "." + environment: + description: "GitHub: Environment to use for the release job (Optional)" + required: false + type: string + secrets: + npm_token: + description: "NPM: Token for publishing (Optional if OIDC/Keyless is configured)" + required: false + slack_webhook_url: + description: "Slack: Webhook URL for notifications (Optional)" + required: false + +jobs: + config: + runs-on: ubuntu-latest + outputs: + current_branch: ${{ steps.branches.outputs.branch_name }} + is_release_branch: ${{ steps.branches.outputs.is_release_branch }} + release_version: ${{ steps.package_version.outputs.version }} + package_name: ${{ steps.package_version.outputs.name }} + has_slack_webhook: ${{ steps.check_slack.outputs.has_webhook }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Get Package Info + id: package_version + run: | + VERSION=$(node -p "require('./package.json').version") + NAME=$(node -p "require('./package.json').name") + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "name=$NAME" >> $GITHUB_OUTPUT + echo "๐Ÿ“ฆ Package: $NAME@$VERSION" + working-directory: ${{ inputs.working_directory }} + + - name: Determine Branch Information + id: branches + run: | + BRANCH_NAME=${GITHUB_REF#refs/heads/} + echo "branch_name=${BRANCH_NAME}" >> $GITHUB_OUTPUT + + IS_RELEASE_BRANCH=$([[ "${BRANCH_NAME}" == "${{ inputs.release_branch }}" ]] && echo "true" || echo "false") + echo "is_release_branch=${IS_RELEASE_BRANCH}" >> $GITHUB_OUTPUT + echo "๐Ÿ“Œ Branch: ${BRANCH_NAME} (Release: ${IS_RELEASE_BRANCH})" + + - name: Check Slack Webhook + id: check_slack + run: | + if [ -n "${{ secrets.slack_webhook_url }}" ]; then + echo "has_webhook=true" >> $GITHUB_OUTPUT + else + echo "has_webhook=false" >> $GITHUB_OUTPUT + fi + + test: + needs: config + runs-on: ubuntu-latest + if: inputs.test_command != '' + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ inputs.node_version }} + cache: 'npm' + cache-dependency-path: ${{ inputs.working_directory }}/package-lock.json + + - name: Install dependencies + run: npm ci + working-directory: ${{ inputs.working_directory }} + + - name: Run Tests + run: ${{ inputs.test_command }} + working-directory: ${{ inputs.working_directory }} + + release: + needs: [config, test] + # Only run release on the designated release branch + if: | + always() && + needs.config.outputs.is_release_branch == 'true' && + (needs.test.result == 'success' || needs.test.result == 'skipped') + runs-on: ubuntu-latest + environment: ${{ inputs.environment }} + permissions: + contents: write + id-token: write # Required for provenance + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ inputs.node_version }} + registry-url: ${{ inputs.registry_url }} + cache: 'npm' + cache-dependency-path: ${{ inputs.working_directory }}/package-lock.json + + - name: Publish to NPM + env: + NODE_AUTH_TOKEN: ${{ secrets.npm_token }} + REGISTRY_URL: ${{ inputs.registry_url }} + PROVENANCE: ${{ inputs.provenance }} + BUILD_COMMAND: ${{ inputs.build_command }} + CHECK_VERSION_BUMP: ${{ inputs.check_version_bump }} + NPM_TAG: ${{ inputs.release_branch }} + WORKING_DIR: ${{ inputs.working_directory }} + run: ./scripts/npm-publish.sh + + - name: Parse Changelog + if: inputs.enable_gh_release == true + id: changelog + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const version = '${{ needs.config.outputs.release_version }}'; + let description = 'Release ' + version; + + if (fs.existsSync('./changes.md')) { + const content = fs.readFileSync('./changes.md', 'utf8'); + const lines = content.split('\n'); + const startMarker = `### ${version}`; + let found = false; + let body = []; + + for (const line of lines) { + if (line.startsWith(startMarker)) { + found = true; + continue; + } + if (found) { + if (line.startsWith('### ')) break; + body.push(line); + } + } + if (body.length > 0) { + description = body.join('\n').trim(); + } + } + core.setOutput('description', description); + + - name: Create GitHub Release + if: inputs.enable_gh_release == true + uses: softprops/action-gh-release@v2 + with: + name: "Release ${{ needs.config.outputs.release_version }}" + tag_name: "v${{ needs.config.outputs.release_version }}" + body: ${{ steps.changelog.outputs.description }} + token: ${{ github.token }} + + - name: Notify Slack + if: always() && needs.config.outputs.has_slack_webhook == 'true' + uses: slackapi/slack-github-action@v1.26.0 + with: + payload: | + { + "text": "NPM Release for ${{ needs.config.outputs.package_name }}: ${{ job.status == 'success' && 'โœ… SUCCESS' || 'โŒ FAILED' }}", + "attachments": [ + { + "color": "${{ job.status == 'success' && '#36a64f' || '#ec000c' }}", + "fields": [ + { "title": "Version", "value": "${{ needs.config.outputs.release_version }}", "short": true }, + { "title": "Commit", "value": "${{ github.sha }}", "short": true } + ] + } + ] + } + env: + SLACK_WEB_HOOK_URL: ${{ secrets.slack_webhook_url }} diff --git a/.github/workflows/wp-gh-release-ops.yml b/.github/workflows/wp-gh-release-ops.yml index cc293bd..58fc9e3 100644 --- a/.github/workflows/wp-gh-release-ops.yml +++ b/.github/workflows/wp-gh-release-ops.yml @@ -5,15 +5,16 @@ on: workflow_call: inputs: tag: - description: 'Release tag (e.g. 1.2.3a)' + description: 'GitHub: Release tag (e.g. 1.2.3a)' required: true + default: '1.2.3' type: string version: - description: 'Release version (e.g. 1.2.3), default: latest' + description: 'WordPress: Release version (e.g. 1.2.3), default: latest' required: false type: string prerelease: - description: 'Pre-release version (e.g. RC1, beta, etc...)' + description: 'GitHub: Pre-release version (e.g. RC1, beta, etc...)' required: false type: string diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1688015 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Node.js +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* +.npm/ + +# OS +.DS_Store +Thumbs.db + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Project +cli/test/ux-temp/ +SETUP-*.md + +# CLI bundled assets (copied during publish) +cli/.github/ +cli/docs/ +cli/examples/ diff --git a/README.md b/README.md index eb28578..5211072 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,66 @@ # Reusable Workflows -A collection of reusable GitHub Actions workflows providing standardized CI/CD patterns. +Production-ready GitHub Actions workflows for CI/CD. Self-contained, configurable, and designed for both public and private repositories. -## Scope +## Quick Start -Workflows in this repository are: +1. **Choose workflow** from [Available Workflows](#available-workflows) +2. **Copy manifest example** to `.github/workflows/{name}.yml` in your repository +3. **Modify pipeline configuration** to match your needs - see [Docs](docs) for all options, required permissions, and supported triggers +4. **Ensure secrets/vars** are configured - follow setup instructions in Docs +5. **Push and run** - enjoy! -- Designed to be consumed via `workflow_call` -- Self-contained and free of internal or proprietary dependencies -- Configurable through explicit inputs and secrets -- Suitable for use in public and private repositories +**๐Ÿ’ก Pro Tip:** Use the CLI for interactive configuration: + +```bash +npm install -g @udx/reusable-workflows +reusable-workflows +``` + +Generates `.github/workflows/{template}.yml` + `SETUP-{template}.md` with step-by-step instructions. ## Available Workflows -| Workflow | Description | Documentation | -| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------- | -| **[docker-ops.yml](.github/workflows/docker-ops.yml)** | Build, scan, and publish Docker images to Docker Hub and GCP Artifact Registry with security scanning. | [Docs](docs/docker-ops.md) ยท [Examples](examples/docker-ops.yml) | -| **[wp-gh-release-ops.yml](.github/workflows/wp-gh-release-ops.yml)** | Generate and publish WordPress plugin release on GitHub. | [Docs](docs/wp-gh-release-ops.md) ยท [Examples](examples/wp-gh-release-ops.yml) | +| Workflow | Description | Docs | Example | +| ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | -------------------------------------------- | +| **[docker-ops](.github/workflows/docker-ops.yml)** | Build, scan, and publish Docker images to multiple registries (Docker Hub, GCP, ACR) with security scanning and SBOM | [๐Ÿ“– Docs](docs/docker-ops.md) | [๐Ÿ“‹ Example](examples/docker-ops.yml) | +| **[wp-gh-release-ops](.github/workflows/wp-gh-release-ops.yml)** | Generate and publish WordPress plugin releases on GitHub | [๐Ÿ“– Docs](docs/wp-gh-release-ops.md) | [๐Ÿ“‹ Example](examples/wp-gh-release-ops.yml) | + +## Features + +- **Reusable** - Designed for `workflow_call` consumption +- **Self-contained** - No internal or proprietary dependencies +- **Configurable** - Explicit inputs and secrets +- **Documented** - Complete setup guides and examples +- **AI-friendly** - Structured metadata for LLM parsing +- **CLI-enabled** - Interactive workflow generation + +## Templates packaging + +Each template is structured as follows: + +- **Workflow file** (`.github/workflows/`) - Template definition with inputs/secrets +- **Documentation** (`docs/`) - Setup guides, configuration options, troubleshooting +- **Examples** (`examples/`) - Real-world usage patterns with variable/secret patterns + +## Development + +### Adding a Template +To add a new reusable workflow: +1. Create your workflow in `.github/workflows/`. +2. Prefix internal repository workflows with `_` to hide them from the CLI generator. +3. Add a setup guide in `docs/` and an usage example in `examples/`. +4. Ensure your workflow inputs follow the standard registry-prefix naming convention in descriptions (e.g., `Docker Hub: Image Name`). + +### Internal Infrastructure +Infrastructure workflows (CLI release, tests, etc.) are marked with a `_` prefix and are excluded from the public CLI generator. The primary entry point is [`_release-cli.yml`](file:///Users/jonyfq/git/udx/reusable-workflows/.github/workflows/_release-cli.yml). + +## Releasing + +1. **Versioning**: The repository uses Semantic Versioning. +2. **Automated Release**: Pushing to `master` triggers the [`_release-cli.yml`](file:///Users/jonyfq/git/udx/reusable-workflows/.github/workflows/_release-cli.yml) workflow. +3. **Artifacts**: This automatically bumps the CLI version, creates a GitHub Release, and tags the repository. ## License -This project is licensed under the MIT License - see the [LICENSE](https://github.com/udx/reusable-workflows/blob/master/LICENSE) file for details. +MIT License - see [LICENSE](https://github.com/udx/reusable-workflows/blob/master/LICENSE) diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000..d2055bf --- /dev/null +++ b/cli/README.md @@ -0,0 +1,72 @@ +# Reusable Workflows CLI + +A minimal-friction CLI to integrate and update GitHub Actions workflows based on the [udx/reusable-workflows](https://github.com/udx/reusable-workflows) templates. + +## Usage + +Run the CLI in the root of your repository to select, configure, and update workflows. + +### On-the-fly +Always runs the latest version without installation: +```bash +npx @udx/reusable-workflows [template-id] [options] +``` + +### Global Installation +For frequent use as a local command: +```bash +npm install -g @udx/reusable-workflows +reusable-workflows [template-id] [options] +``` + +## Features + +- **Smart Auto-Detection**: Scans `.github/workflows` to pre-fill existing configurations. +- **Dynamic Presets**: Automatically extracts configuration presets (e.g., GCR, ACR) from example files. +- **Fast-Path Flow**: Skips redundant prompts and moves straight to a manifest preview. +- **One-Glance Preview**: Review the generated YAML in your terminal before writing. +- **Non-Interactive Mode**: Full support for headless environments and CI/CD scripts. +- **Asset-Driven Testing**: Built-in test suite verifies CLI output against source templates and examples. + +## Options + +- `[template-id]`: Optional positional argument to skip selection (e.g., `docker-ops`). +- `-n, --non-interactive`: Use detected/default values without prompting. +- `-p, --preset [name]`: Apply a specific configuration preset (e.g., `GCR`, `Docker Hub`). +- `-r, --ref [version]`: Pin the reusable workflow to a specific Git ref/version. +- `-o, --output [path]`: Specify custom destination for the manifest. +- `-h, --help`: Show usage instructions. + +## Metadata Architecture + +The CLI parses a three-layer structure to provide a dynamic UI: +1. **Workflow (.yml)**: Defines inputs with registry-prefixed metadata. +2. **Docs (.md)**: Provides the generator flow and setup instructions. +3. **Example (.yml)**: Acts as the base template for the generated manifest. + +## Development + +### Local Testing +To test the CLI during development: +```bash +# Install dependencies +npm install + +# Run tests +npm test + +# Run UX test suite +../scripts/test-cli-ux.sh + +# Run locally in a target repo +node index.js +``` + +## Releasing +Publishing is automated via GitHub Actions: +- **Trigger**: Merge to `master`. +- **Workflow**: Uses [`npm-release-ops`](file:///Users/jonyfq/git/udx/reusable-workflows/.github/workflows/npm-release-ops.yml) with `working_directory: cli`. +- **Manual**: To publish from local (if authorized): + ```bash + ../scripts/npm-publish.sh # Run from /cli folder + ``` diff --git a/cli/changes.md b/cli/changes.md new file mode 100644 index 0000000..9a36f37 --- /dev/null +++ b/cli/changes.md @@ -0,0 +1,42 @@ +# Changelog + +### 1.1.6 +- **Fix**: Smart Prompt Skipping (skips inputs with defaults to reduce friction). +- **Fix**: Preset Group Pre-selection (automatically selects and preserves preset-related component groups). +- **Fix**: Improved Interactive Registry Selection (allows adding registries to a preset base). + +### 1.1.5 +- **Feat**: Asset-Driven UX Testing suite (verifies CLI against repository templates/examples). +- **Fix**: Normalized preset matching (matches `multi-registry` to `Multi Registry`). +- **Fix**: Trigger sanitization (removes `workflow_call` from generated manifests). +- **Fix**: Non-interactive mode now correctly selects groups with required inputs. +- **Refactor**: Unified test structure under `cli/test/cases/_fixtures`. + +### 1.1.4 +- **Feat**: Introduced specific Docker presets: `Docker Hub`, `GCR`, `ACR`, and `Multi Registry`. +- **Refactor**: Standardized all example configurations to use GitHub `vars` and `secrets`. +- **Chore**: Updated metadata descriptions for `build_platforms` and `version_config_path`. + +### 1.1.3 +- **Feat**: Implemented marker-free Auto-Preset detection via `TemplateLoader`. +- **Feat**: Smart prompt skipping (CLI skips questions if values are detected or provided by preset). +- **Refactor**: `FileGenerator` now constructs manifests from scratch for cleaner YAML output. +- **Chore**: Removed legacy "AI Prompt" comments from all assets. + +### 1.1.2 +- **Fix**: Bundled templates with NPM package for reliable global installation. +- **Fix**: Improved repo root detection for global CLI usage. + +### 1.1.0 +- **Feat**: Added OIDC (Keyless) publishing support. +- **Feat**: Added GitHub Environment support (configured for `Master` environment). +- **Feat**: Added `--ref` and `-r` flags for pinning workflow versions. +- **Feat**: Added `--output` and `-o` flags for custom manifest destination. +- **Refactor**: Flattened repository structure with `_` prefix for internal workflows. +- **Chore**: Standardized metadata across all templates. + +### 1.0.0 +- Initial release of the Reusable Workflows CLI. +- Interactive workflow generation. +- Existing configuration auto-detection. +- Preview manifest before writing. diff --git a/cli/config.js b/cli/config.js new file mode 100644 index 0000000..0a6bb80 --- /dev/null +++ b/cli/config.js @@ -0,0 +1,42 @@ +// ============================================================================ +// CLI CONFIGURATION - Adjust these as needed +// ============================================================================ + +export const CLI_CONFIG = { + // Template components - each template MUST have all 3 + components: { + workflow: { dir: '.github/workflows', ext: '.yml' }, + docs: { dir: 'docs', ext: '.md' }, + example: { dir: 'examples', ext: '.yml' } + }, + + // Metadata extraction patterns + metadata: { + // Extract short description from docs: + docsShortDescription: //, + + // Extract presets from examples: ## PRESET: Name + presetPattern: /##\s*PRESET:\s*(.+)/, + + // Group inputs by prefix pattern: "Prefix: Description" + inputPrefixPattern: /^([A-Z][A-Za-z\s]+):\s/, + + // Variable/secret patterns in examples + variablePattern: /\$\{\{\s*vars\.(\w+)\s*\}\}/, + secretPattern: /\$\{\{\s*secrets\.(\w+)\s*\}\}/ + }, + + // UI configuration + ui: { + boxWidth: 50, + pageSize: 15, + icons: { + main: '๐Ÿš€', + config: '๐Ÿ“', + components: '๐Ÿ“ฆ', + group: '๐Ÿ”ง', + complete: 'โœ“', + steps: '๐Ÿ“‹' + } + } +}; diff --git a/cli/index.js b/cli/index.js new file mode 100644 index 0000000..01e32b8 --- /dev/null +++ b/cli/index.js @@ -0,0 +1,342 @@ +#!/usr/bin/env node + +import { existsSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import inquirer from 'inquirer'; +import chalk from 'chalk'; + +import { CLI_CONFIG } from './config.js'; +import { TemplateLoader } from './lib/template-loader.js'; +import { InputGrouper } from './lib/input-grouper.js'; +import { UI } from './lib/ui.js'; +import { FileGenerator } from './lib/file-generator.js'; +import { ConfigDetector } from './lib/config-detector.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +/** + * Main workflow generator orchestrator + * Coordinates template loading, input grouping, and user prompts + */ +class WorkflowGenerator { + constructor() { + this.config = CLI_CONFIG; + + // Support both local development and bundled npm package + // Local: __dirname is /cli, templates are in .. + // Bundled: templates are copied into /cli + const bundledWorkflows = join(__dirname, this.config.components.workflow.dir); + this.repoRoot = existsSync(bundledWorkflows) ? __dirname : join(__dirname, '..'); + + // Initialize modules + this.templateLoader = new TemplateLoader(this.repoRoot, this.config); + this.inputGrouper = new InputGrouper(this.config); + this.ui = new UI(this.config); + this.fileGenerator = new FileGenerator(this.config); + this.configDetector = new ConfigDetector(); + + // Load templates + this.templates = this.templateLoader.loadTemplates(); + } + + async run() { + const args = process.argv.slice(2); + const isHelp = args.includes('--help') || args.includes('-h'); + const isNonInteractive = args.includes('--non-interactive') || args.includes('-n'); + + // Support positional template selection: reusable-workflows + const positionalTemplateId = args.find(arg => !arg.startsWith('-')); + + // Support custom output path: --output or -o + const outputIdx = args.findIndex(arg => arg === '--output' || arg === '-o'); + const customOutputPath = outputIdx !== -1 ? args[outputIdx + 1] : null; + + // Support reference pinning: --ref or -r + const refIdx = args.findIndex(arg => arg === '--ref' || arg === '-r'); + const cliRef = refIdx !== -1 ? args[refIdx + 1] : null; + + // Support preset selection: --preset or -p + const presetIdxCmd = args.findIndex(arg => arg === '--preset' || arg === '-p'); + const cliPresetName = presetIdxCmd !== -1 ? args[presetIdxCmd + 1] : null; + + if (isHelp) { + this.ui.printHelp(); + return; + } + + const icons = this.config.ui.icons; + + // Header + this.ui.printHeader(); + + // Template selection + let templateId = positionalTemplateId; + if (!templateId) { + const result = await inquirer.prompt([ + { + type: 'list', + name: 'templateId', + message: 'Select workflow template:', + pageSize: this.config.ui.pageSize, + choices: this.ui.buildTemplateChoices(this.templates) + } + ]); + templateId = result.templateId; + } + + const template = this.templates.find(t => t.id === templateId); + template.inputs = this.templateLoader.parseWorkflowInputs(template); + template.secrets = this.templateLoader.parseWorkflowSecrets(template); + const inputs = template.inputs; + + // Auto-detection phase (existing files) + const detectedValues = this.configDetector.detectExistingConfig(templateId); + if (Object.keys(detectedValues).length > 0) { + console.log(chalk.green(`\nโœจ Auto-detected existing configuration from .github/workflows\n`)); + } + + // Preset selection + let presetValues = {}; + if (template.presets && template.presets.length > 0) { + if (cliPresetName) { + const normalize = (s) => s.toLowerCase().replace(/[-_]/g, ' ').trim(); + const search = normalize(cliPresetName); + const preset = template.presets.find(p => normalize(p.id).includes(search) || normalize(p.name).includes(search)); + if (preset) { + presetValues = { ...preset.values, ...preset.secrets }; + console.log(chalk.green(`\nโœ… Applied preset: ${preset.name}\n`)); + } else { + console.warn(chalk.yellow(`\nโš  Preset "${cliPresetName}" not found. Falling back to manual configuration.\n`)); + } + } else if (Object.keys(detectedValues).length === 0 && !isNonInteractive) { + this.ui.printSectionHeader(this.config.ui.icons.group, 'Configuration Preset'); + const { presetIdx } = await inquirer.prompt([ + { + type: 'list', + name: 'presetIdx', + message: 'Choose a starting configuration:', + choices: [ + ...template.presets.map((p, i) => ({ name: p.name, value: i })), + { name: 'Custom (Manual Setup)', value: -1 } + ] + } + ]); + + if (presetIdx !== -1) { + const preset = template.presets[presetIdx]; + presetValues = { ...preset.values, ...preset.secrets }; + console.log(chalk.green(`\nโœ… Applied preset: ${preset.name}\n`)); + } + } + } + + // Merge detected and preset values + const mergedDefaults = { ...presetValues, ...detectedValues }; + + // Configuration + const groupedInputs = this.inputGrouper.groupInputsByPrefix(inputs); + + // Version / Ref selection (fixed to master / cliRef) + const versionRef = cliRef || 'master'; + + // Common inputs + this.ui.printSectionHeader(icons.config, 'Configuration'); + // If we have a preset, we use a "smart skip" strategy: only ask for required fields that have NO default or detected/preset value + const commonAnswers = await this.promptInputs(groupedInputs.common, mergedDefaults, isNonInteractive, !!cliPresetName || (template.presets && template.presets.length > 0)); + // Optional component groups + const prefixGroups = Object.keys(groupedInputs).filter(key => key !== 'common'); + let groupAnswers = {}; + + // Identify pre-selected groups (from preset or detection) + const initialGroups = prefixGroups.filter(key => + groupedInputs[key].some(input => mergedDefaults[input.name] !== undefined) + ); + + let selectedGroups = initialGroups; + + if (prefixGroups.length > 0) { + if (!isNonInteractive) { + this.ui.printSectionHeader(icons.components, 'Optional Components'); + + const result = await inquirer.prompt([ + { + type: 'checkbox', + name: 'selectedGroups', + message: 'Select components to configure:', + choices: prefixGroups.map(key => ({ + name: groupedInputs[key][0].prefix, + value: key, + checked: selectedGroups.includes(key) + })) + } + ]); + + selectedGroups = result.selectedGroups; + } else { + // In non-interactive mode, select groups that have: + // 1. Detected values + // 2. OR required inputs (to ensure they get defaults/fallbacks) + selectedGroups = prefixGroups.filter(key => + groupedInputs[key].some(input => + mergedDefaults[input.name] !== undefined || input.required + ) + ); + } + + for (const groupKey of selectedGroups) { + const groupInputs = groupedInputs[groupKey]; + const groupName = groupInputs[0].prefix; + + // Skip group if all inputs are already provided (e.g. by preset) + const missingInputs = groupInputs.filter(input => mergedDefaults[input.name] === undefined); + if (missingInputs.length === 0) continue; + + if (!isNonInteractive) { + this.ui.printSectionHeader(icons.group, `${groupName} Configuration`); + } + const answers = await this.promptInputs(groupInputs, mergedDefaults, isNonInteractive, true); + groupAnswers = { ...groupAnswers, ...answers }; + } + } + + const allAnswers = { ...commonAnswers, ...groupAnswers }; + // console.log('DEBUG allAnswers:', JSON.stringify(allAnswers, null, 2)); + + // Preview and Confirmation + if (!isNonInteractive) { + this.ui.printSectionHeader(icons.complete, 'Preview Manifest'); + const manifestContent = this.fileGenerator.previewManifest(template, allAnswers); + console.log(chalk.gray('---')); + console.log(chalk.white(manifestContent)); + console.log(chalk.gray('---')); + + const { confirmAction, generateSetupDoc } = await inquirer.prompt([ + { + type: 'confirm', + name: 'confirmAction', + message: 'Proceed to generate/update workflow manifest?', + default: true + }, + { + type: 'confirm', + name: 'generateSetupDoc', + message: 'Do you also need a setup guide (SETUP-*.md)?', + default: false, + when: (ans) => ans.confirmAction + } + ]); + + if (!confirmAction) { + console.log(chalk.red('\nโœ— Generation cancelled')); + return { cancelled: true }; + } + var finalGenerateSetup = generateSetupDoc; + } else { + console.log(chalk.green(`\n๐Ÿš€ Non-interactive mode: applying detected/default values...`)); + var finalGenerateSetup = false; // Usually not needed in scripts + } + + // Generate files + const result = await this.fileGenerator.generateFiles( + template, + allAnswers, + selectedGroups, + finalGenerateSetup, + customOutputPath, + versionRef + ); + + if (result.cancelled) { + return { cancelled: true }; + } + + // Results + this.ui.printSectionHeader(icons.complete, 'Generation Complete'); + console.log(chalk.green('โœ“ Generated: ') + chalk.white(result.workflowFile)); + console.log(chalk.green('โœ“ Generated: ') + chalk.white(result.setupFile)); + + this.ui.printSectionHeader(icons.steps, 'Next Steps'); + console.log(chalk.white(' 1. Review ') + chalk.cyan(result.workflowFile)); + console.log(chalk.white(' 2. Follow ') + chalk.cyan(result.setupFile) + chalk.white(' to configure GitHub secrets')); + console.log(chalk.white(' 3. Commit and push to trigger workflow')); + + this.ui.printFooter(); + + return { templateId, answers: allAnswers, files: result }; + } + + async promptInputs(inputs, detectedValues = {}, isNonInteractive = false, skipDefaults = false) { + if (!inputs || inputs.length === 0) { + return {}; + } + + // In non-interactive mode, we skip all prompts and use detected/default values + if (isNonInteractive) { + const fallbackAnswers = {}; + inputs.forEach(input => { + if (detectedValues[input.name] === undefined) { + fallbackAnswers[input.name] = input.default || ''; + } + }); + return { ...detectedValues, ...fallbackAnswers }; + } + + const questions = inputs + .filter(input => { + // Skip if already detected/preset + if (detectedValues[input.name] !== undefined) return false; + + // Skip if it has a default and we are in "smart skip" mode + if (skipDefaults && input.default !== undefined) return false; + + return true; + }) + .map(input => { + const message = input.name === 'image_name' + ? 'Enter Docker image name (e.g. my-app) - used for all registry tags:' + : this.inputGrouper.extractPrompt(input.description); + + const question = { + type: 'input', + name: input.name, + message: message, + default: input.default || '' + }; + + if (input.required) { + question.validate = (val) => { + if (val && val.length > 0) return true; + return 'This field is required'; + }; + } + + return question; + }); + + const answers = await inquirer.prompt(questions); + + // Merge detected values back in + return { ...detectedValues, ...answers }; + } +} + +// ============================================================================ +// MAIN +// ============================================================================ +async function main() { + try { + const generator = new WorkflowGenerator(); + await generator.run(); + } catch (error) { + if (error.isTtyError) { + console.error(chalk.red('Prompt couldn\'t be rendered in the current environment')); + } else { + console.error(chalk.red('Error:'), error.message); + } + process.exit(1); + } +} + +main(); diff --git a/cli/lib/config-detector.js b/cli/lib/config-detector.js new file mode 100644 index 0000000..99696aa --- /dev/null +++ b/cli/lib/config-detector.js @@ -0,0 +1,51 @@ +import { readFileSync, readdirSync, existsSync } from 'fs'; +import { join } from 'path'; +import yaml from 'js-yaml'; + +/** + * Scans the current repository for existing reuseable workflow usages + * to auto-detect configuration values and pre-fill answers. + */ +export class ConfigDetector { + constructor() { + this.workflowDir = join(process.cwd(), '.github/workflows'); + } + + /** + * Scans for a specific template usage in the current repository + * @param {string} templateId - The ID of the template (e.g., 'docker-ops') + * @returns {Object} - Detected configuration values + */ + detectExistingConfig(templateId) { + if (!existsSync(this.workflowDir)) { + return {}; + } + + const detectedConfig = {}; + const files = readdirSync(this.workflowDir).filter(f => f.endsWith('.yml') || f.endsWith('.yaml')); + + for (const file of files) { + try { + const content = readFileSync(join(this.workflowDir, file), 'utf8'); + const manifest = yaml.load(content); + + // Scan all jobs in the manifest + for (const jobConfig of Object.values(manifest.jobs || {})) { + if (jobConfig.uses && jobConfig.uses.includes(`${templateId}.yml`)) { + // Found usage of this template + if (jobConfig.with) { + Object.assign(detectedConfig, jobConfig.with); + } + // We also want to capture which secrets are used, though they are usually patterns like ${{ secrets.X }} + // but we can at least show what was there. + break; + } + } + } catch (e) { + // Skip unparseable files + } + } + + return detectedConfig; + } +} diff --git a/cli/lib/file-generator.js b/cli/lib/file-generator.js new file mode 100644 index 0000000..2fc279e --- /dev/null +++ b/cli/lib/file-generator.js @@ -0,0 +1,195 @@ +import { writeFileSync, existsSync, mkdirSync, readFileSync } from 'fs'; +import { join, dirname } from 'path'; +import yaml from 'js-yaml'; +import inquirer from 'inquirer'; +import chalk from 'chalk'; + +/** + * Generates workflow manifest and setup documentation + */ +export class FileGenerator { + constructor(config) { + this.config = config; + } + + async generateFiles(template, answers, selectedGroups = [], generateSetup = false, customOutputPath = null, ref = 'master') { + const workflowDir = join(process.cwd(), '.github/workflows'); + const workflowFile = customOutputPath + ? (customOutputPath.endsWith('.yml') || customOutputPath.endsWith('.yaml') ? customOutputPath : join(customOutputPath, `${template.id}.yml`)) + : join(workflowDir, `${template.id}.yml`); + const setupFile = join(process.cwd(), `SETUP-${template.id}.md`); + + // Ensure destination directory exists + const finalDir = dirname(workflowFile); + if (!existsSync(finalDir)) { + mkdirSync(finalDir, { recursive: true }); + } + + // Generate workflow manifest + const workflowContent = this.generateWorkflowManifest(template, answers, ref); + writeFileSync(workflowFile, workflowContent, 'utf8'); + + // Generate setup documentation only if requested + if (generateSetup) { + const setupContent = this.generateSetupDoc(template, answers, selectedGroups); + writeFileSync(setupFile, setupContent, 'utf8'); + } + + return { + workflowFile, + setupFile: generateSetup ? setupFile : null, + cancelled: false + }; + } + + previewManifest(template, answers, ref = 'master') { + return this.generateWorkflowManifest(template, answers, ref); + } + + generateWorkflowManifest(template, answers, ref = 'master') { + // Determine triggers (exclude workflow_call) + let triggers = { ...template.defaultTriggers }; + delete triggers.workflow_call; + + // Fallback to defaults if no valid triggers left + if (Object.keys(triggers).length === 0) { + triggers = { push: { branches: ['main'] }, workflow_dispatch: null }; + } + + // Distinguish between inputs and secrets + const workflowSecrets = template.secrets || {}; + const withValues = {}; + const secretValues = {}; + + for (const [key, value] of Object.entries(answers)) { + if (value !== undefined) { + if (workflowSecrets[key]) { + secretValues[key] = value; + } else { + withValues[key] = value; + } + } + } + + const manifest = { + name: template.name, + on: triggers, + permissions: { + contents: 'write', + 'id-token': 'write' + }, + jobs: { + release: { + uses: `udx/reusable-workflows/.github/workflows/${template.id}.yml@${ref}`, + with: withValues, + secrets: Object.keys(secretValues).length > 0 ? secretValues : undefined + } + } + }; + + // Remove empty sections + if (Object.keys(manifest.jobs.release.with).length === 0) delete manifest.jobs.release.with; + if (!manifest.jobs.release.secrets) delete manifest.jobs.release.secrets; + + return yaml.dump(manifest, { + lineWidth: -1, + noRefs: true, + quotingType: '"', + forceQuotes: false + }); + } + + generateSetupDoc(template, answers, selectedGroups) { + const docsContent = readFileSync(template.docsPath, 'utf8'); + + let setup = `# Setup Guide: ${template.name}\n\n`; + setup += `This guide will help you configure the workflow in your repository.\n\n`; + + setup += `## 1. Workflow File\n\n`; + setup += `The workflow manifest has been generated at:\n`; + setup += `\`\`\`\n.github/workflows/${template.id}.yml\n\`\`\`\n\n`; + + setup += `## 2. Configuration Summary\n\n`; + setup += `Your configuration:\n\n`; + for (const [key, value] of Object.entries(answers)) { + setup += `- **${key}**: \`${value}\`\n`; + } + setup += `\n`; + + if (selectedGroups.length > 0) { + setup += `## 3. Selected Components\n\n`; + setup += `You have enabled the following optional components:\n\n`; + selectedGroups.forEach(group => { + setup += `- ${group}\n`; + }); + setup += `\n`; + } + + setup += `## ${selectedGroups.length > 0 ? '4' : '3'}. GitHub Secrets & Variables\n\n`; + setup += `Configure the following in your repository settings:\n\n`; + setup += `**Settings โ†’ Secrets and variables โ†’ Actions**\n\n`; + + // Extract secrets/vars from docs + const secretsSection = this.extractSecretsFromDocs(docsContent); + if (secretsSection) { + setup += secretsSection + '\n'; + } + + setup += `## ${selectedGroups.length > 0 ? '5' : '4'}. Permissions\n\n`; + setup += `Ensure your workflow has the required permissions:\n\n`; + + const permissionsSection = this.extractPermissionsFromDocs(docsContent); + if (permissionsSection) { + setup += permissionsSection + '\n'; + } + + setup += `## ${selectedGroups.length > 0 ? '6' : '5'}. Complete Documentation\n\n`; + setup += `For detailed information, see: [\`docs/${template.id}.md\`](docs/${template.id}.md)\n`; + + return setup; + } + + extractSecretsFromDocs(docsContent) { + // Look for secrets/configuration section + const lines = docsContent.split('\n'); + let inSecretsSection = false; + let secretsContent = ''; + + for (const line of lines) { + if (line.match(/^##\s+(Secrets|Configuration|Inputs)/i)) { + inSecretsSection = true; + continue; + } + if (inSecretsSection && line.match(/^##\s+/)) { + break; + } + if (inSecretsSection) { + secretsContent += line + '\n'; + } + } + + return secretsContent.trim() || 'See documentation for required secrets and variables.'; + } + + extractPermissionsFromDocs(docsContent) { + // Look for permissions section + const lines = docsContent.split('\n'); + let inPermissionsSection = false; + let permissionsContent = ''; + + for (const line of lines) { + if (line.match(/^##\s+Permissions/i)) { + inPermissionsSection = true; + continue; + } + if (inPermissionsSection && line.match(/^##\s+/)) { + break; + } + if (inPermissionsSection) { + permissionsContent += line + '\n'; + } + } + + return permissionsContent.trim() || 'See documentation for required permissions.'; + } +} diff --git a/cli/lib/input-grouper.js b/cli/lib/input-grouper.js new file mode 100644 index 0000000..a28f171 --- /dev/null +++ b/cli/lib/input-grouper.js @@ -0,0 +1,59 @@ +/** + * Groups workflow inputs by prefix pattern + * Dynamically discovers prefixes from input descriptions (e.g., "Docker Hub:", "GCP:", "ACR:") + * No hardcoded domain logic + */ +export class InputGrouper { + constructor(config) { + this.config = config; + } + + groupInputsByPrefix(inputs) { + const groups = { common: [] }; + const pattern = this.config.metadata.inputPrefixPattern; + + // First pass: discover unique prefixes + for (const [name, config] of Object.entries(inputs)) { + const desc = config.description || ''; + const match = desc.match(pattern); + + if (match) { + const prefix = match[1].trim(); + const groupKey = prefix.toLowerCase().replace(/\s+/g, '-'); + if (!groups[groupKey]) { + groups[groupKey] = []; + } + } + } + + // Second pass: assign inputs to groups + for (const [name, config] of Object.entries(inputs)) { + const desc = config.description || ''; + const match = desc.match(pattern); + + if (match) { + const prefix = match[1].trim(); + const groupKey = prefix.toLowerCase().replace(/\s+/g, '-'); + groups[groupKey].push({ name, prefix, ...config }); + } else { + groups.common.push({ name, ...config }); + } + } + + return groups; + } + + extractPrompt(description) { + const pattern = this.config.metadata.inputPrefixPattern; + let prompt = description; + + // Remove prefix if present + const match = prompt.match(pattern); + if (match) { + prompt = prompt.substring(match[0].length).trim(); + } + + // Return the rest of the description as the prompt + return prompt.trim(); + } +} diff --git a/cli/lib/template-loader.js b/cli/lib/template-loader.js new file mode 100644 index 0000000..7de00e9 --- /dev/null +++ b/cli/lib/template-loader.js @@ -0,0 +1,227 @@ +import { readFileSync, readdirSync, existsSync } from 'fs'; +import { join, basename } from 'path'; +import yaml from 'js-yaml'; +import chalk from 'chalk'; + +/** + * Loads and validates workflow templates + * Each template MUST have all 3 components: workflow.yml + docs.md + example.yml + */ +export class TemplateLoader { + constructor(repoRoot, config) { + this.repoRoot = repoRoot; + this.config = config; + } + + loadTemplates() { + const workflowsDir = join(this.repoRoot, this.config.components.workflow.dir); + const docsDir = join(this.repoRoot, this.config.components.docs.dir); + const examplesDir = join(this.repoRoot, this.config.components.example.dir); + + if (!existsSync(workflowsDir)) { + throw new Error(`Workflows directory not found: ${workflowsDir}`); + } + + const workflowFiles = readdirSync(workflowsDir) + .filter(file => file.endsWith(this.config.components.workflow.ext)) + .filter(file => !file.startsWith('_')); + + const templates = []; + + for (const file of workflowFiles) { + const templateId = basename(file, this.config.components.workflow.ext); + const workflowPath = join(workflowsDir, file); + const docsPath = join(docsDir, `${templateId}${this.config.components.docs.ext}`); + const examplesPath = join(examplesDir, `${templateId}${this.config.components.example.ext}`); + + // Validate: All 3 components must exist + if (!existsSync(docsPath)) { + console.warn(chalk.yellow(`โš  Skipping ${templateId}: missing ${this.config.components.docs.dir}/${templateId}${this.config.components.docs.ext}`)); + continue; + } + if (!existsSync(examplesPath)) { + console.warn(chalk.yellow(`โš  Skipping ${templateId}: missing ${this.config.components.example.dir}/${templateId}${this.config.components.example.ext}`)); + continue; + } + + try { + const workflowContent = readFileSync(workflowPath, 'utf8'); + const workflow = yaml.load(workflowContent); + + if (workflow.on && workflow.on.workflow_call) { + const name = workflow.name || templateId; + const description = this.extractDocsDescription(docsPath); + + templates.push({ + id: templateId, + name, + description, + workflowPath, + docsPath, + examplesPath, + presets: this.extractPresets(examplesPath), + defaultTriggers: this.extractExampleTriggers(examplesPath) + }); + } + } catch (error) { + console.warn(chalk.yellow(`โš  Could not parse ${file}: ${error.message}`)); + } + } + + if (templates.length === 0) { + throw new Error('No valid templates found. Each template requires: workflow.yml + docs.md + example.yml'); + } + + return templates; + } + + extractDocsDescription(docsPath) { + try { + const content = readFileSync(docsPath, 'utf8'); + const match = content.match(this.config.metadata.docsShortDescription); + if (match) { + return match[1].trim(); + } + + // Fallback: first non-header line + const lines = content.split('\n'); + let foundTitle = false; + for (const line of lines) { + if (line.startsWith('# ')) { + foundTitle = true; + continue; + } + if (foundTitle && line.trim() && !line.startsWith('#') && !line.startsWith('```') && !line.startsWith(' + +Build, scan, and publish Docker images to multiple registries with security scanning and SBOM generation. Reusable workflow for building, scanning, and publishing Docker images to multiple registries. @@ -10,6 +13,47 @@ Reusable workflow for building, scanning, and publishing Docker images to multip - Slack notifications - Automatic versioning (package.json or GitVersion) +## CLI Generator + +Generate workflow configuration interactively: + +```bash +npm install -g @udx/reusable-workflows +reusable-workflows +``` + +### Flow + +1. **Select template** โ†’ docker-ops +2. **Common inputs** โ†’ Image name, release branch, dockerfile path, build platforms +3. **Select registries** โ†’ Docker Hub, GCP, ACR (multi-select) +4. **Registry configuration** โ†’ Prompted for selected registries only +5. **Output** โ†’ `.github/workflows/docker-ops.yml` + `SETUP-docker-ops.md` + +### Registry Prompts + +**Docker Hub:** +- Username โ†’ `${{ vars.DOCKER_USERNAME }}` +- Organization +- Repository +- Secret: `DOCKER_TOKEN` + +**GCP Artifact Registry:** +- Region +- Project ID +- Repository name +- Workload Identity Provider +- Service account email +- Permissions: `id-token: write` + +**Azure Container Registry:** +- Registry (e.g., myregistry.azurecr.io) +- Repository +- Client ID +- Tenant ID +- Subscription ID +- Permissions: `id-token: write` + ## Quick Start ```yaml @@ -391,54 +435,6 @@ with: - Only works on release branches - Requires `security-events: write` permission (auto-granted) -## AI Implementation Prompt - -Use this template with AI coding assistants to generate a complete workflow configuration: - -``` -Act as a DevSecOps Engineer. Use the udx/reusable-workflows/.github/workflows/docker-ops.yml@master -template to create a Docker release workflow for my containerized application. - -Requirements: -1. Check the Dependency & Permission Matrix in docs/docker-ops.md -2. Include all mandatory inputs for [Docker Hub/GCP/ACR] based on my target registry -3. Use build_args with {{version}} placeholder for version injection -4. Ensure proper OIDC permissions for cloud registries (id-token: write) -5. Configure for release branch: [main/master/latest] - -Project context: -- Docker image name: [your-image-name] -- Target registries: [Docker Hub/GCP Artifact Registry/Azure Container Registry] -- Dockerfile location: [./Dockerfile or custom path] -- Build arguments: [any ARG values needed in Dockerfile] -- Multi-platform builds: [linux/amd64, linux/arm64, or custom] - -Verify that all mandatory inputs from the matrix are included and properly configured. -``` - -**Example usage:** - -``` -Act as a DevSecOps Engineer. Use the udx/reusable-workflows/.github/workflows/docker-ops.yml@master -template to create a Docker release workflow for my containerized application. - -Requirements: -1. Check the Dependency & Permission Matrix in docs/docker-ops.md -2. Include all mandatory inputs for GCP based on my target registry -3. Use build_args with {{version}} placeholder for version injection -4. Ensure proper OIDC permissions for cloud registries (id-token: write) -5. Configure for release branch: main - -Project context: -- Docker image name: my-api-service -- Target registries: GCP Artifact Registry -- Dockerfile location: ./Dockerfile -- Build arguments: APP_VERSION={{version}}, BUILD_DATE={{branch}} -- Multi-platform builds: linux/amd64,linux/arm64 - -Verify that all mandatory inputs from the matrix are included and properly configured. -``` - ## Best Practices 1. Use semantic versioning in `package.json` or GitVersion config diff --git a/docs/npm-release-ops.md b/docs/npm-release-ops.md new file mode 100644 index 0000000..0e27657 --- /dev/null +++ b/docs/npm-release-ops.md @@ -0,0 +1,61 @@ +# NPM Release Workflow + + +A reusable workflow to publish npm packages to a registry with security best practices, optional build/test steps, and GitHub release generation. + +## CLI Generator + +### Flow + +1. **Select template** โ†’ npm-release +2. **Setup Node** โ†’ Node version, registry URL +3. **Execution options** โ†’ Build command, test command, provenance +4. **Secrets configuration** โ†’ `NPM_TOKEN` + +### Registry Prompts + +**NPM:** +- Registry URL โ†’ `https://registry.npmjs.org/` +- Provenance โ†’ `true` +- Secret: `NPM_TOKEN` + +## Setup Guide + +### 1. Configure NPM Token +To publish packages, you need an automation token from your npm registry. + +- **npmjs.com**: Go to Access Tokens โ†’ Generate New Token โ†’ Automation. +- **Organization Scopes**: Ensure the token has `read-write` access to the target package. + +### 2. Add Secret to GitHub +Add the generated token as a secret in your repository or organization: +- Name: `NPM_TOKEN` +- Value: *[your-token]* + +### 3. Local Usage +You can use the common release script locally for testing or manual releases: + +```bash +# Set required environment variables +export NPM_TOKEN=your_token +export BUILD_COMMAND="npm run build" + +# Run the script +./scripts/npm-publish.sh +``` + +### 4. GitHub Actions Usage +Call this workflow from your release pipeline (see [example](../examples/npm-release-ops.yml)). + +## Workflow Inputs + +| Input | Description | Default | +|-------|-------------|---------| +| `node_version` | Node.js: Version to use | `20` | +| `build_command` | Build: Command to run before publishing | `npm run build` | +| `registry_url` | NPM: Registry URL | `https://registry.npmjs.org/` | +| `provenance` | NPM: Enable provenance | `true` | +| `release_branch`| Branch: Deployment branch that triggers releases | `latest` | + +## Security and Provenance +This workflow enables **NPM Provenance** by default. This links your published package to the specific GitHub Action run that created it, providing a verifiable chain of custody for your users. diff --git a/docs/wp-gh-release-ops.md b/docs/wp-gh-release-ops.md index cda2842..68051fc 100644 --- a/docs/wp-gh-release-ops.md +++ b/docs/wp-gh-release-ops.md @@ -1,4 +1,5 @@ # Publish WP Plugin Release on GitHub Workflow + Reusable workflow for generating and publishing WordPress plugin release on GitHub. @@ -8,6 +9,23 @@ Reusable workflow for generating and publishing WordPress plugin release on GitH - Parse changelog from changes.md depending on the version - SBOM generation +## CLI Generator + +### Flow + +1. **Select template** โ†’ wp-gh-release-ops +2. **WordPress setup** โ†’ Plugin version +3. **GitHub setup** โ†’ Release tag, pre-release options + +### Registry Prompts + +**WordPress:** +- Release version + +**GitHub:** +- Release tag +- Pre-release version + ## Quick Start ```yaml diff --git a/examples/docker-ops.yml b/examples/docker-ops.yml index f0df54c..0a759e3 100644 --- a/examples/docker-ops.yml +++ b/examples/docker-ops.yml @@ -7,123 +7,83 @@ on: jobs: # =========================================================================== - # Example 1: Minimal - GitHub Release Only + # Docker Hub: docker-hub + # USE CASE: Build, scan, and publish to Docker Hub + GitHub Releases # =========================================================================== - # USE CASE: Build and scan Docker image, create GitHub release with SBOM - # REQUIRED: Only image_name - # PUBLISHES TO: GitHub Releases only (no external registries) - # AI NOTE: Use this when you only need CI/CD without registry publishing - - # minimal: - # uses: udx/reusable-workflows/.github/workflows/docker-ops.yml@master - # with: - # image_name: my-app + docker-hub: + uses: udx/reusable-workflows/.github/workflows/docker-ops.yml@master + with: + image_name: ${{ vars.IMAGE_NAME }} + docker_login: ${{ vars.DOCKER_USERNAME }} + docker_org: ${{ vars.DOCKER_ORG }} + docker_repo: ${{ vars.DOCKER_REPO }} + secrets: + docker_token: ${{ secrets.DOCKER_TOKEN }} # =========================================================================== - # Example 2: Docker Hub Publishing + # GCP: gcr + # USE CASE: Publish to GCP Artifact Registry using OIDC (Keyless) # =========================================================================== - # USE CASE: Publish to Docker Hub with version and latest tags - # REQUIRED: image_name, docker_login, docker_org, docker_repo, docker_token secret - # PUBLISHES TO: Docker Hub + GitHub Releases - # AI NOTE: Check Dependency Matrix in docs/docker-ops.md for all mandatory inputs - - # docker-hub: - # uses: udx/reusable-workflows/.github/workflows/docker-ops.yml@master - # with: - # image_name: my-app - # docker_login: myusername - # docker_org: myorg - # docker_repo: my-app - # secrets: - # docker_token: ${{ secrets.DOCKER_TOKEN }} + gcr: + permissions: + id-token: write + contents: write + uses: udx/reusable-workflows/.github/workflows/docker-ops.yml@master + with: + image_name: ${{ vars.IMAGE_NAME }} + gcp_region: ${{ vars.GCP_REGION }} + gcp_project_id: ${{ vars.GCP_PROJECT_ID }} + gcp_repo: ${{ vars.GCP_REPO }} + gcp_workload_identity_provider: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }} + gcp_service_account: ${{ vars.GCP_SERVICE_ACCOUNT }} # =========================================================================== - # Example 3: Multi-Registry (Docker Hub + GCP + ACR + Slack) + # Azure: acr + # USE CASE: Publish to Azure Container Registry using OIDC (Keyless) # =========================================================================== - # USE CASE: Enterprise deployment to multiple cloud registries with notifications - # REQUIRED: All inputs for each target registry (see Dependency Matrix in docs/docker-ops.md) - # PUBLISHES TO: Docker Hub + GCP Artifact Registry + Azure Container Registry + GitHub Releases - # SECURITY: Requires id-token: write permission for GCP and ACR OIDC authentication - # AI NOTE: All IDs below are fake examples - replace with your actual values + acr: + permissions: + id-token: write + contents: write + uses: udx/reusable-workflows/.github/workflows/docker-ops.yml@master + with: + image_name: ${{ vars.IMAGE_NAME }} + acr_registry: ${{ vars.ACR_REGISTRY }} + acr_repository: ${{ vars.ACR_REPOSITORY }} + azure_client_id: ${{ vars.AZURE_CLIENT_ID }} + azure_tenant_id: ${{ vars.AZURE_TENANT_ID }} + azure_subscription_id: ${{ vars.AZURE_SUBSCRIPTION_ID }} + # =========================================================================== + # All: all + # USE CASE: Publish to Docker Hub, GCP, and Azure simultaneously + # =========================================================================== multi-registry: permissions: - id-token: write # Required for GCP and Azure OIDC authentication - contents: write # Required for GitHub releases + id-token: write + contents: write uses: udx/reusable-workflows/.github/workflows/docker-ops.yml@master with: - image_name: my-app - release_branch: main - - # Docker Hub Configuration - # Required: docker_login, docker_org, docker_repo, docker_token secret + image_name: ${{ vars.IMAGE_NAME }} + # Docker Hub docker_login: ${{ vars.DOCKER_USERNAME }} - docker_org: myorg - docker_repo: my-app - - # GCP Artifact Registry (Workload Identity Federation - Keyless Auth) - # Required: All 5 inputs below - # Setup: See docs/docker-ops.md "GCP Workload Identity Federation Setup" - gcp_region: us-central1 - gcp_project_id: my-gcp-project - gcp_repo: docker-images - gcp_workload_identity_provider: projects/123456789/locations/global/workloadIdentityPools/github/providers/github-provider - gcp_service_account: github-actions@my-gcp-project.iam.gserviceaccount.com - - # Azure Container Registry (OIDC - Keyless Auth) - # Required: All 5 inputs below - # Setup: See docs/docker-ops.md "Azure OIDC Setup" - # Note: IDs below are fake examples - replace with your actual Azure values - acr_registry: myregistry.azurecr.io - acr_repository: my-app - azure_client_id: 12345678-90ab-cdef-1234-567890abcdef - azure_tenant_id: 87654321-fedc-ba09-8765-4321fedcba09 - azure_subscription_id: a1b2c3d4-e5f6-7890-abcd-ef1234567890 - - # Optional: Custom build settings - dockerfile_path: ./Dockerfile - build_platforms: linux/amd64,linux/arm64 - # Build args support placeholders: {{version}} = release version, {{branch}} = branch name - build_args: APP_VERSION={{version}},BUILD_DATE={{branch}},ENV=production - + docker_org: ${{ vars.DOCKER_ORG }} + docker_repo: ${{ vars.DOCKER_REPO }} + # GCP + gcp_region: ${{ vars.GCP_REGION }} + gcp_project_id: ${{ vars.GCP_PROJECT_ID }} + gcp_repo: ${{ vars.GCP_REPO }} + gcp_workload_identity_provider: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }} + gcp_service_account: ${{ vars.GCP_SERVICE_ACCOUNT }} + # Azure + acr_registry: ${{ vars.ACR_REGISTRY }} + acr_repository: ${{ vars.ACR_REPOSITORY }} + azure_client_id: ${{ vars.AZURE_CLIENT_ID }} + azure_tenant_id: ${{ vars.AZURE_TENANT_ID }} + azure_subscription_id: ${{ vars.AZURE_SUBSCRIPTION_ID }} secrets: docker_token: ${{ secrets.DOCKER_TOKEN }} slack_webhook_url: ${{ secrets.SLACK_WEBHOOK_URL }} -# =========================================================================== -# Quick Reference -# =========================================================================== -# -# AI-Friendly Configuration: -# - See Dependency & Permission Matrix in docs/docker-ops.md -# - Matrix shows all mandatory inputs/secrets/roles per registry type -# - Use AI Implementation Prompt template in docs for workflow generation -# -# Versioning: -# - Auto-detected from package.json or GitVersion (ci/git-version.yml) -# - No manual version input needed -# -# Placeholders in build_args: -# - {{version}} = release version (e.g., 1.2.3) -# - {{branch}} = current branch name (e.g., main, feature/xyz) -# - Example: build_args: APP_VERSION={{version}},BRANCH={{branch}} -# -# Authentication Setup: -# - DOCKER_TOKEN: Personal access token from https://hub.docker.com/settings/security -# - GCP: Workload Identity Federation (keyless OIDC) -# * Requires: id-token: write permission -# * Service account needs: roles/artifactregistry.writer -# * Setup guide: docs/docker-ops.md "GCP Workload Identity Federation Setup" -# - Azure: OIDC with federated credentials (keyless) -# * Requires: id-token: write permission -# * Service principal needs: AcrPush role -# * Security options: Strict (single branch) or Flexible (wildcard) -# * Setup guide: docs/docker-ops.md "Azure OIDC Setup" -# - SLACK_WEBHOOK_URL: Optional, from https://api.slack.com/messaging/webhooks -# -# Changelog: -# - Create changes.md with version entries (### 1.2.0) -# - Or auto-generated from git commits since last tag -# + # Full Documentation: # - See docs/docker-ops.md for complete setup guides, troubleshooting, and best practices -# - AI Implementation Prompt template available for generating workflow configurations diff --git a/examples/npm-release-ops.yml b/examples/npm-release-ops.yml new file mode 100644 index 0000000..00f031a --- /dev/null +++ b/examples/npm-release-ops.yml @@ -0,0 +1,22 @@ +name: Release Package + +on: + push: + branches: + - latest + +jobs: + standard-release: + permissions: + contents: write + id-token: write + uses: udx/reusable-workflows/.github/workflows/npm-release-ops.yml@master + with: + node_version: "20" + build_command: "npm run build" + test_command: "npm test" + release_branch: "latest" + provenance: "true" + secrets: + npm_token: ${{ secrets.NPM_TOKEN }} + slack_webhook_url: ${{ secrets.SLACK_WEBHOOK_URL }} diff --git a/examples/wp-gh-release-ops.yml b/examples/wp-gh-release-ops.yml index 26f0464..b9c1977 100644 --- a/examples/wp-gh-release-ops.yml +++ b/examples/wp-gh-release-ops.yml @@ -17,7 +17,7 @@ permissions: contents: write jobs: - release: + plugin-release: uses: udx/reusable-workflows/.github/workflows/wp-gh-release-ops.yml@master with: tag: ${{ github.event.inputs.tag }} diff --git a/scripts/npm-publish.sh b/scripts/npm-publish.sh new file mode 100755 index 0000000..ac55e44 --- /dev/null +++ b/scripts/npm-publish.sh @@ -0,0 +1,91 @@ +#!/bin/bash +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +NC='\033[0m' # No Color + +echo -e "${GREEN}๐Ÿš€ Starting NPM release process...${NC}" + +# Switch to working directory if specified +if [ -n "$WORKING_DIR" ]; then + echo -e "๐Ÿ“‚ Switching to directory: ${YELLOW}${WORKING_DIR}${NC}" + cd "$WORKING_DIR" +fi + +# Check for package.json +if [ ! -f "package.json" ]; then + echo -e "${RED}โŒ Error: package.json not found in the current directory.${NC}" + exit 1 +fi + +# Extract version and name from package.json +PACKAGE_NAME=$(node -p "require('./package.json').name") +PACKAGE_VERSION=$(node -p "require('./package.json').version") + +if [ -z "$PACKAGE_NAME" ] || [ -z "$PACKAGE_VERSION" ]; then + echo -e "${RED}โŒ Error: Could not extract name or version from package.json.${NC}" + exit 1 +fi + +echo -e "๐Ÿ“ฆ Package: ${YELLOW}${PACKAGE_NAME}${NC}" +echo -e "๐Ÿท๏ธ Version: ${YELLOW}${PACKAGE_VERSION}${NC}" + +# Optional version check +if [ "$CHECK_VERSION_BUMP" != "false" ]; then + echo -e "๐Ÿ” Checking if version ${PACKAGE_VERSION} is already published..." + # npm view returns exit code 1 if package or version doesn't exist + if npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version >/dev/null 2>&1; then + echo -e "${RED}โŒ Error: Version ${PACKAGE_VERSION} is already published to the registry.${NC}" + echo -e "${YELLOW}Please bump the version in package.json before releasing.${NC}" + exit 1 + else + echo -e "${GREEN}โœ… Version ${PACKAGE_VERSION} is new. Proceeding...${NC}" + fi +fi + +# Build project if command provided +if [ -n "$BUILD_COMMAND" ]; then + echo -e "๐Ÿ—๏ธ Running build: ${YELLOW}${BUILD_COMMAND}${NC}" + eval "$BUILD_COMMAND" +fi + +# Bundle templates if we are in the cli directory +if [ -d "../.github/workflows" ] && [ "$(basename "$(pwd)")" == "cli" ]; then + echo -e "๐Ÿ“ฆ Bundling templates into CLI package..." + cp -r ../.github ./ + cp -r ../docs ./ + cp -r ../examples ./ + # We want to remove the _ prefixed files from the bundled .github/workflows + find .github/workflows -name "_*" -delete +fi + +# Publish to NPM +echo -e "๐Ÿšข Publishing to NPM..." +PUBLISH_ARGS=() + +if [ -n "$REGISTRY_URL" ]; then + PUBLISH_ARGS+=("--registry" "$REGISTRY_URL") +fi + +if [ "$PROVENANCE" == "true" ]; then + PUBLISH_ARGS+=("--provenance") +fi + +if [ -n "$PUBLISH_TAG" ]; then + PUBLISH_ARGS+=("--tag" "$PUBLISH_TAG") +fi + +# If NPM_TOKEN is provided, ensure it's used (though npm usually picks it up from env or .npmrc) +# The orchestration layer (GH Action) should handle .npmrc setup for NODE_AUTH_TOKEN +npm publish "${PUBLISH_ARGS[@]}" + +# Cleanup bundled assets +if [ -d "../.github/workflows" ] && [ "$(basename "$(pwd)")" == "cli" ]; then + echo -e "๐Ÿงน Cleaning up bundled templates..." + rm -rf .github docs examples +fi + +echo -e "${GREEN}โœ… Successfully published ${PACKAGE_NAME}@${PACKAGE_VERSION}${NC}" diff --git a/scripts/test-cli-ux.sh b/scripts/test-cli-ux.sh new file mode 100755 index 0000000..5c1b926 --- /dev/null +++ b/scripts/test-cli-ux.sh @@ -0,0 +1,16 @@ +#!/bin/bash +set -e + +# Reusable Workflows CLI Asset-Driven UX Test Runner +# Verifies CLI behavior by comparing output against source templates and examples + +REPO_ROOT=$(pwd) +CLI_DIR="$REPO_ROOT/cli" + +echo -e "\033[0;34m๐Ÿš€ Running Asset-Driven UX Tests...\033[0m" + +# Ensure dependencies are available (though they should be if CLI works) +cd "$CLI_DIR" +node test/run-ux.js + +echo -e "\n\033[0;32mโœจ UX Test Suite Execution Complete\033[0m"