diff --git a/.gitattributes b/.gitattributes index 5f86556d4c..e317bedefc 100644 --- a/.gitattributes +++ b/.gitattributes @@ -9,6 +9,7 @@ pkg/workflow/js/*.js linguist-generated=true pkg/workflow/js/*.cjs linguist-generated=true pkg/workflow/sh/*.sh linguist-generated=true actions/*/index.js linguist-generated=true +actions/setup-cli/install.sh linguist-generated=true specs/artifacts.md linguist-generated=true merge=ours # Use bd merge for beads JSONL files diff --git a/Makefile b/Makefile index 3f42909823..0af921b626 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,7 @@ all: build # Build the binary, run make deps before this .PHONY: build -build: sync-templates sync-action-pins +build: sync-templates sync-action-pins sync-action-scripts go build $(LDFLAGS) -o $(BINARY_NAME) ./cmd/gh-aw # Build for all platforms @@ -612,6 +612,14 @@ sync-action-pins: echo "⚠ Warning: .github/aw/actions-lock.json does not exist yet"; \ fi +# Sync action scripts +.PHONY: sync-action-scripts +sync-action-scripts: + @echo "Syncing install-gh-aw.sh to actions/setup-cli/install.sh..." + @cp install-gh-aw.sh actions/setup-cli/install.sh + @chmod +x actions/setup-cli/install.sh + @echo "✓ Action scripts synced successfully" + # Recompile all workflow files .PHONY: recompile recompile: sync-templates build @@ -737,6 +745,7 @@ help: @echo " install - Install binary locally" @echo " sync-templates - Sync templates from .github to pkg/cli/templates (runs automatically during build)" @echo " sync-action-pins - Sync actions-lock.json from .github/aw to pkg/workflow/data (runs automatically during build)" + @echo " sync-action-scripts - Sync install-gh-aw.sh to actions/setup-cli/install.sh (runs automatically during build)" @echo " update - Update GitHub Actions and workflows, sync action pins, and rebuild binary" @echo " fix - Apply automatic codemod-style fixes to workflow files (depends on build)" @echo " recompile - Recompile all workflow files (runs init, depends on build)" diff --git a/actions/README.md b/actions/README.md index 519bab4772..4fb41a11d6 100644 --- a/actions/README.md +++ b/actions/README.md @@ -23,6 +23,12 @@ Copies workflow script files to the agent environment. This action embeds all ne [Documentation](./setup/README.md) +### setup-cli + +Installs the gh-aw CLI extension for a specific version. Supports both release tags and commit SHAs that resolve to releases. + +[Documentation](./setup-cli/README.md) + ### noop Processes noop safe output - a fallback output type that logs messages for transparency without taking any GitHub API actions. diff --git a/actions/setup-cli/README.md b/actions/setup-cli/README.md new file mode 100644 index 0000000000..3c07295307 --- /dev/null +++ b/actions/setup-cli/README.md @@ -0,0 +1,167 @@ +# Setup gh-aw CLI Action + +This GitHub Action installs the `gh-aw` CLI extension for a specific version using release tags. + +## Features + +- ✅ **Version validation**: Ensures the specified version exists as a release +- ✅ **Checksum verification**: Validates SHA256 checksums for downloaded binaries +- ✅ **Automatic fallback**: Tries `gh extension install` first, falls back to direct download if needed +- ✅ **Cross-platform**: Works on Linux, macOS, Windows, and FreeBSD +- ✅ **Multi-architecture**: Supports amd64, arm64, 386, and arm architectures + +## Usage + +### Basic Usage + +```yaml +- name: Install gh-aw + uses: githubnext/gh-aw/actions/setup-cli@main + with: + version: v0.37.18 +``` + +### Complete Workflow Example + +```yaml +name: Test gh-aw + +on: [push] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install gh-aw + uses: githubnext/gh-aw/actions/setup-cli@main + with: + version: v0.37.18 + + - name: Verify installation + run: | + gh aw version + gh aw --help +``` + +## Inputs + +### `version` (required) + +The version of gh-aw to install. Must be a release tag. + +- **Release tag**: e.g., `v0.37.18`, `v0.37.0` + +## Outputs + +### `installed-version` + +The version tag that was actually installed. + +## How It Works + +1. **Version validation**: Validates the input is a valid release tag +2. **Release verification**: Validates that the release exists on GitHub +3. **Primary installation method**: Attempts to install using `gh extension install githubnext/gh-aw` +4. **Fallback method**: If primary method fails, downloads the binary directly from GitHub releases +5. **Checksum verification**: Downloads and verifies SHA256 checksums for the binary +6. **Binary verification**: Ensures the installed binary works correctly + +## Requirements + +- GitHub CLI (`gh`) must be available (pre-installed on GitHub Actions runners) +- `curl` must be available (pre-installed on GitHub Actions runners) + +## Error Handling + +The action will fail if: + +- No version is provided +- The specified release tag doesn't exist +- The binary download fails +- The downloaded binary is not executable or doesn't work + +## Platform Support + +| OS | Architectures | +|----|---------------| +| Linux | amd64, arm64, 386, arm | +| macOS | amd64, arm64 | +| FreeBSD | amd64, arm64, 386 | +| Windows | amd64, arm64, 386 | + +## Examples + +### Install Specific Version + +```yaml +- uses: githubnext/gh-aw/actions/setup-cli@main + with: + version: v0.37.18 +``` + +### Use Output + +```yaml +- name: Install gh-aw + id: install + uses: githubnext/gh-aw/actions/setup-cli@main + with: + version: v0.37.18 + +- name: Show installed version + run: | + echo "Installed version: ${{ steps.install.outputs.installed-version }}" +``` + +### Matrix Testing Across Versions + +```yaml +jobs: + test: + strategy: + matrix: + version: [v0.37.18, v0.37.17, v0.37.16] + runs-on: ubuntu-latest + steps: + - uses: githubnext/gh-aw/actions/setup-cli@main + with: + version: ${{ matrix.version }} + + - name: Test workflow compilation + run: gh aw compile workflow.md +``` + +## Troubleshooting + +### "Release X does not exist" + +Verify the release exists at: https://github.com/githubnext/gh-aw/releases + +### "Release X does not exist" + +Verify the release exists at: https://github.com/githubnext/gh-aw/releases + +### "gh extension install failed" + +The action automatically falls back to direct download when `gh extension install` fails. Check the action logs for details. + +## Development + +This action is part of the gh-aw repository. The `install.sh` script is generated during the build process by copying from the root `install-gh-aw.sh` file. + +### Building + +The installation script is copied during the build process: + +```bash +make build # Copies install-gh-aw.sh to actions/setup-cli/install.sh +``` + +The generated `install.sh` file is marked as `linguist-generated=true` in `.gitattributes`. + +## License + +This action is part of the gh-aw project and follows the same license terms. diff --git a/actions/setup-cli/action.yml b/actions/setup-cli/action.yml new file mode 100644 index 0000000000..48f62448c3 --- /dev/null +++ b/actions/setup-cli/action.yml @@ -0,0 +1,25 @@ +name: 'Setup gh-aw CLI' +description: 'Install gh-aw CLI extension for a specific version' +author: 'GitHub Next' + +inputs: + version: + description: 'Version to install (release tag like v0.37.18)' + required: true + +outputs: + installed-version: + description: 'The version that was installed' + +runs: + using: 'composite' + steps: + - name: Install gh-aw CLI + shell: bash + env: + INPUT_VERSION: ${{ inputs.version }} + run: ${{ github.action_path }}/install.sh + +branding: + icon: 'download' + color: 'purple' diff --git a/actions/setup-cli/install.sh b/actions/setup-cli/install.sh new file mode 100755 index 0000000000..7902b5625b --- /dev/null +++ b/actions/setup-cli/install.sh @@ -0,0 +1,436 @@ +#!/bin/bash + +# Script to download and install gh-aw binary for the current OS and architecture +# Supports: Linux, macOS (Darwin), FreeBSD, Windows (Git Bash/MSYS/Cygwin) +# Usage: ./install-gh-aw.sh [version] +# If no version is specified, it will fetch and use the latest release +# Note: Checksum validation is currently skipped by default (will be enabled in future releases) +# Example: ./install-gh-aw.sh v1.0.0 + +set -e # Exit on any error + +# Parse arguments +SKIP_CHECKSUM=true # Default to true until checksums are available in releases +TRY_GH_INSTALL=false # Whether to try gh extension install first +VERSION="" + +# Check if INPUT_VERSION is set (GitHub Actions context) +if [ -n "$INPUT_VERSION" ]; then + VERSION="$INPUT_VERSION" + TRY_GH_INSTALL=true # In GitHub Actions, try gh install first + SKIP_CHECKSUM=false # Enable checksum validation in GitHub Actions +fi + +for arg in "$@"; do + case $arg in + --skip-checksum) + SKIP_CHECKSUM=true + shift + ;; + --gh-install) + TRY_GH_INSTALL=true + shift + ;; + *) + if [ -z "$VERSION" ]; then + VERSION="$arg" + fi + ;; + esac +done + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Function to print colored output +print_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +print_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +print_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Check if HOME is set +if [ -z "$HOME" ]; then + print_error "HOME environment variable is not set. Cannot determine installation directory." + exit 1 +fi + +# Check if curl is available +if ! command -v curl &> /dev/null; then + print_error "curl is required but not installed. Please install curl first." + exit 1 +fi + +# Check if jq is available (optional, we'll use grep/sed as fallback) +HAS_JQ=false +if command -v jq &> /dev/null; then + HAS_JQ=true +fi + +# Check if sha256sum or shasum is available (for checksum verification) +HAS_CHECKSUM_TOOL=false +CHECKSUM_CMD="" +if command -v sha256sum &> /dev/null; then + HAS_CHECKSUM_TOOL=true + CHECKSUM_CMD="sha256sum" +elif command -v shasum &> /dev/null; then + HAS_CHECKSUM_TOOL=true + CHECKSUM_CMD="shasum -a 256" +fi + +if [ "$SKIP_CHECKSUM" = false ] && [ "$HAS_CHECKSUM_TOOL" = false ]; then + print_warning "Neither sha256sum nor shasum is available. Checksum verification will be skipped." + print_warning "To suppress this warning, use --skip-checksum flag." + SKIP_CHECKSUM=true +fi + +# Determine OS and architecture +OS=$(uname -s) +ARCH=$(uname -m) + +# Normalize OS name +case $OS in + Linux) + OS_NAME="linux" + ;; + Darwin) + OS_NAME="darwin" + ;; + FreeBSD) + OS_NAME="freebsd" + ;; + MINGW*|MSYS*|CYGWIN*) + OS_NAME="windows" + ;; + *) + print_error "Unsupported operating system: $OS" + print_info "Supported operating systems: Linux, macOS (Darwin), FreeBSD, Windows" + exit 1 + ;; +esac + +# Normalize architecture name +case $ARCH in + x86_64|amd64) + ARCH_NAME="amd64" + ;; + aarch64|arm64) + ARCH_NAME="arm64" + ;; + armv7l|armv7) + ARCH_NAME="arm" + ;; + i386|i686) + ARCH_NAME="386" + ;; + *) + print_error "Unsupported architecture: $ARCH" + print_info "Supported architectures: x86_64/amd64, aarch64/arm64, armv7l/arm, i386/i686" + exit 1 + ;; +esac + +# Construct platform string +PLATFORM="${OS_NAME}-${ARCH_NAME}" + +# Add .exe extension for Windows +if [ "$OS_NAME" = "windows" ]; then + BINARY_NAME="gh-aw.exe" +else + BINARY_NAME="gh-aw" +fi + +print_info "Detected OS: $OS -> $OS_NAME" +print_info "Detected architecture: $ARCH -> $ARCH_NAME" +print_info "Platform: $PLATFORM" + +# Function to fetch release data with fallback for invalid token and retry logic +fetch_release_data() { + local url=$1 + local max_retries=3 + local retry_delay=2 + local use_auth=false + + # Try with authentication if GH_TOKEN is set + if [ -n "$GH_TOKEN" ]; then + use_auth=true + fi + + # Retry loop + for attempt in $(seq 1 $max_retries); do + local curl_args=("-s" "-f") + + # Add auth header if using authentication + if [ "$use_auth" = true ]; then + curl_args+=("-H" "Authorization: Bearer $GH_TOKEN") + fi + + print_info "Fetching release data (attempt $attempt/$max_retries)..." >&2 + + # Make the API call + local response + response=$(curl "${curl_args[@]}" "$url" 2>/dev/null) + local exit_code=$? + + # Success + if [ $exit_code -eq 0 ] && [ -n "$response" ]; then + echo "$response" + return 0 + fi + + # If this was the first attempt with auth and it failed, try without auth + if [ "$attempt" -eq 1 ] && [ "$use_auth" = true ]; then + print_warning "API call with GH_TOKEN failed. Retrying without authentication..." >&2 + print_warning "Your GH_TOKEN may be incompatible (typically SSO) with this request." >&2 + use_auth=false + # Don't count this as a retry attempt, just switch auth mode + continue + fi + + # If we haven't exhausted retries, wait and try again + if [ "$attempt" -lt "$max_retries" ]; then + print_warning "Fetch attempt $attempt failed (exit code: $exit_code). Retrying in ${retry_delay}s..." >&2 + sleep $retry_delay + retry_delay=$((retry_delay * 2)) + else + print_error "Failed to fetch release data after $max_retries attempts" >&2 + fi + done + + return 1 +} + +# Get version (use provided version or fetch latest) +# VERSION is already set from argument parsing +REPO="githubnext/gh-aw" + +if [ -z "$VERSION" ]; then + print_info "No version specified, fetching latest release information from GitHub..." + + if ! LATEST_RELEASE=$(fetch_release_data "https://api.github.com/repos/$REPO/releases/latest"); then + print_error "Failed to fetch latest release information from GitHub API" + print_info "You can specify a version directly: ./install-gh-aw.sh v1.0.0" + exit 1 + fi + + if [ "$HAS_JQ" = true ]; then + # Use jq for JSON parsing + VERSION=$(echo "$LATEST_RELEASE" | jq -r '.tag_name') + RELEASE_NAME=$(echo "$LATEST_RELEASE" | jq -r '.name') + else + # Fallback to grep/sed + VERSION=$(echo "$LATEST_RELEASE" | grep '"tag_name"' | sed -E 's/.*"tag_name": *"([^"]+)".*/\1/') + RELEASE_NAME=$(echo "$LATEST_RELEASE" | grep '"name"' | sed -E 's/.*"name": *"([^"]+)".*/\1/') + fi + + if [ -z "$VERSION" ] || [ "$VERSION" = "null" ]; then + print_error "Failed to parse latest release information" + exit 1 + fi + + print_info "Latest release: $RELEASE_NAME ($VERSION)" +else + print_info "Using specified version: $VERSION" +fi + +# Validate that the release exists +print_info "Validating release $VERSION exists..." +if ! RELEASE_CHECK=$(fetch_release_data "https://api.github.com/repos/$REPO/releases/tags/$VERSION"); then + print_error "Release $VERSION does not exist in $REPO" + print_info "Please check the releases at: https://github.com/$REPO/releases" + exit 1 +fi +print_success "Release $VERSION validated" + +# Try gh extension install if requested (and gh is available) +if [ "$TRY_GH_INSTALL" = true ] && command -v gh &> /dev/null; then + print_info "Attempting to install gh-aw using 'gh extension install'..." + + # Try to install using gh + if gh extension install "$REPO" --force 2>&1 | tee /tmp/gh-install.log; then + # Verify the installation succeeded + if gh aw version &> /dev/null; then + INSTALLED_VERSION=$(gh aw version 2>&1 | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' | head -1) + print_success "Successfully installed gh-aw using gh extension install" + print_info "Installed version: $INSTALLED_VERSION" + + # Set output for GitHub Actions + if [ -n "${GITHUB_OUTPUT}" ]; then + echo "installed_version=${VERSION}" >> "${GITHUB_OUTPUT}" + fi + + exit 0 + else + print_warning "gh extension install completed but verification failed" + print_info "Falling back to manual installation..." + fi + else + print_warning "gh extension install failed, falling back to manual installation..." + if [ -f /tmp/gh-install.log ]; then + cat /tmp/gh-install.log + fi + fi +elif [ "$TRY_GH_INSTALL" = true ]; then + print_info "gh CLI not available, proceeding with manual installation..." +fi + +# Construct download URL and paths +DOWNLOAD_URL="https://github.com/$REPO/releases/download/$VERSION/$PLATFORM" +CHECKSUMS_URL="https://github.com/$REPO/releases/download/$VERSION/checksums.txt" +if [ "$OS_NAME" = "windows" ]; then + DOWNLOAD_URL="${DOWNLOAD_URL}.exe" +fi +INSTALL_DIR="$HOME/.local/share/gh/extensions/gh-aw" +BINARY_PATH="$INSTALL_DIR/$BINARY_NAME" +CHECKSUMS_PATH="$INSTALL_DIR/checksums.txt" + +print_info "Download URL: $DOWNLOAD_URL" +print_info "Installation directory: $INSTALL_DIR" + +# Create the installation directory if it doesn't exist +if [ ! -d "$INSTALL_DIR" ]; then + print_info "Creating installation directory..." + mkdir -p "$INSTALL_DIR" +fi + +# Check if binary already exists +if [ -f "$BINARY_PATH" ]; then + print_warning "Binary '$BINARY_PATH' already exists. It will be overwritten." +fi + +# Download the binary with retry logic +print_info "Downloading gh-aw binary..." +MAX_RETRIES=3 +RETRY_DELAY=2 + +for attempt in $(seq 1 $MAX_RETRIES); do + if curl -L -f -o "$BINARY_PATH" "$DOWNLOAD_URL"; then + print_success "Binary downloaded successfully" + break + else + if [ "$attempt" -eq "$MAX_RETRIES" ]; then + print_error "Failed to download binary from $DOWNLOAD_URL after $MAX_RETRIES attempts" + print_info "Please check if the version and platform combination exists in the releases." + exit 1 + else + print_warning "Download attempt $attempt failed. Retrying in ${RETRY_DELAY}s..." + sleep $RETRY_DELAY + RETRY_DELAY=$((RETRY_DELAY * 2)) + fi + fi +done + +# Download and verify checksums if not skipped +if [ "$SKIP_CHECKSUM" = false ]; then + print_info "Downloading checksums file..." + CHECKSUMS_DOWNLOADED=false + + for attempt in $(seq 1 $MAX_RETRIES); do + if curl -L -f -o "$CHECKSUMS_PATH" "$CHECKSUMS_URL" 2>/dev/null; then + CHECKSUMS_DOWNLOADED=true + print_success "Checksums file downloaded successfully" + break + else + if [ "$attempt" -eq "$MAX_RETRIES" ]; then + print_warning "Failed to download checksums file after $MAX_RETRIES attempts" + print_warning "Checksum verification will be skipped for this version." + print_info "This may occur for older releases that don't include checksums." + break + else + print_warning "Checksum download attempt $attempt failed. Retrying in 2s..." + sleep 2 + fi + fi + done + + # Verify checksum if we downloaded it successfully + if [ "$CHECKSUMS_DOWNLOADED" = true ]; then + print_info "Verifying binary checksum..." + + # Determine the expected filename in the checksums file + EXPECTED_FILENAME="$PLATFORM" + if [ "$OS_NAME" = "windows" ]; then + EXPECTED_FILENAME="${PLATFORM}.exe" + fi + + # Extract the expected checksum from the checksums file + EXPECTED_CHECKSUM=$(grep "$EXPECTED_FILENAME" "$CHECKSUMS_PATH" | awk '{print $1}') + + if [ -z "$EXPECTED_CHECKSUM" ]; then + print_warning "Checksum for $EXPECTED_FILENAME not found in checksums file" + print_warning "Checksum verification will be skipped." + else + # Compute the actual checksum of the downloaded binary + ACTUAL_CHECKSUM=$($CHECKSUM_CMD "$BINARY_PATH" | awk '{print $1}') + + if [ "$ACTUAL_CHECKSUM" = "$EXPECTED_CHECKSUM" ]; then + print_success "Checksum verification passed!" + print_info "Expected: $EXPECTED_CHECKSUM" + print_info "Actual: $ACTUAL_CHECKSUM" + else + print_error "Checksum verification failed!" + print_error "Expected: $EXPECTED_CHECKSUM" + print_error "Actual: $ACTUAL_CHECKSUM" + print_error "The downloaded binary may be corrupted or tampered with." + print_info "To skip checksum verification, use: ./install-gh-aw.sh $VERSION --skip-checksum" + rm -f "$BINARY_PATH" + exit 1 + fi + fi + + # Clean up checksums file + rm -f "$CHECKSUMS_PATH" + fi +else + print_warning "Checksum verification skipped (--skip-checksum flag used)" +fi + +# Make it executable +print_info "Making binary executable..." +chmod +x "$BINARY_PATH" + +# Verify the binary +print_info "Verifying binary..." +if "$BINARY_PATH" --help > /dev/null 2>&1; then + print_success "Binary is working correctly!" +else + print_error "Binary verification failed. The downloaded file may be corrupted or incompatible." + exit 1 +fi + +# Show file info +FILE_SIZE=$(ls -lh "$BINARY_PATH" | awk '{print $5}') +print_success "Installation complete!" +print_info "Binary location: $BINARY_PATH" +print_info "Binary size: $FILE_SIZE" +print_info "Version: $VERSION" + +# Show usage info +print_info "" +print_info "You can now use gh-aw with the gh CLI:" +print_info " gh aw --help" +print_info " gh aw version" + +# Show version +print_info "" +print_info "Running gh-aw version check..." +"$BINARY_PATH" version + +# Set output for GitHub Actions +if [ -n "${GITHUB_OUTPUT}" ]; then + echo "installed_version=${VERSION}" >> "${GITHUB_OUTPUT}" +fi diff --git a/actions/setup-cli/install_test.sh b/actions/setup-cli/install_test.sh new file mode 100755 index 0000000000..3601d32284 --- /dev/null +++ b/actions/setup-cli/install_test.sh @@ -0,0 +1,134 @@ +#!/bin/bash +# Test script for install.sh in setup-cli action +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT_PATH="$SCRIPT_DIR/install.sh" + +# Color codes for output +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Test counters +TESTS_RUN=0 +TESTS_PASSED=0 +TESTS_FAILED=0 + +# Print test result +print_result() { + local test_name="$1" + local result="$2" + + TESTS_RUN=$((TESTS_RUN + 1)) + + if [ "$result" = "PASS" ]; then + echo -e "${GREEN}✓ PASS${NC}: $test_name" + TESTS_PASSED=$((TESTS_PASSED + 1)) + else + echo -e "${RED}✗ FAIL${NC}: $test_name" + TESTS_FAILED=$((TESTS_FAILED + 1)) + fi +} + +# Test 1: Script syntax is valid +test_script_syntax() { + echo "" + echo "Test 1: Verify script syntax" + + if bash -n "$SCRIPT_PATH" 2>/dev/null; then + print_result "Script syntax is valid" "PASS" + else + print_result "Script has syntax errors" "FAIL" + fi +} + +# Test 2: Verify script is executable +test_executable() { + echo "" + echo "Test 2: Verify script is executable" + + if [ -x "$SCRIPT_PATH" ]; then + print_result "Script is executable" "PASS" + else + print_result "Script is not executable" "FAIL" + fi +} + +# Test 3: Verify INPUT_VERSION support +test_input_version() { + echo "" + echo "Test 4: Verify INPUT_VERSION environment variable support" + + # Check if script references INPUT_VERSION + if grep -q "INPUT_VERSION" "$SCRIPT_PATH"; then + print_result "Script supports INPUT_VERSION" "PASS" + else + print_result "Script does not support INPUT_VERSION" "FAIL" + fi +} + +# Test 5: Verify gh extension install attempt +test_gh_install() { + echo "" + echo "Test 5: Verify gh extension install logic" + + # Check if script has gh extension install logic + if grep -q "gh extension install" "$SCRIPT_PATH"; then + print_result "Script includes gh extension install attempt" "PASS" + else + print_result "Script missing gh extension install logic" "FAIL" + fi +} + +# Test 5: Verify release validation +test_release_validation() { + echo "" + echo "Test 5: Verify release validation" + + if grep -q "Validating release.*exists" "$SCRIPT_PATH"; then + print_result "Script includes release validation" "PASS" + else + print_result "Script missing release validation" "FAIL" + fi +} + +# Test 6: Verify checksum validation logic +test_checksum_validation() { + echo "" + echo "Test 6: Verify checksum validation" + + # Check if script has checksum validation logic + if grep -q "SKIP_CHECKSUM.*false" "$SCRIPT_PATH" && grep -q "sha256sum\|shasum" "$SCRIPT_PATH"; then + print_result "Script includes checksum validation" "PASS" + else + print_result "Script missing checksum validation" "FAIL" + fi +} + +# Run all tests +echo "=========================================" +echo "Testing setup-cli action install.sh" +echo "=========================================" + +test_script_syntax +test_executable +test_input_version +test_gh_install +test_release_validation +test_checksum_validation + +# Summary +echo "" +echo "=========================================" +echo "Test Summary" +echo "=========================================" +echo "Tests run: $TESTS_RUN" +echo -e "${GREEN}Tests passed: $TESTS_PASSED${NC}" +if [ $TESTS_FAILED -gt 0 ]; then + echo -e "${RED}Tests failed: $TESTS_FAILED${NC}" + exit 1 +else + echo -e "${GREEN}All tests passed!${NC}" +fi diff --git a/examples/test-setup-cli.md b/examples/test-setup-cli.md new file mode 100644 index 0000000000..eb29d74052 --- /dev/null +++ b/examples/test-setup-cli.md @@ -0,0 +1,45 @@ +--- +name: Test setup-cli action +engine: copilot + +on: + workflow_dispatch: + +# This is a test workflow to demonstrate the setup-cli action + +jobs: + test-installation: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install gh-aw using release tag + id: install + uses: ./actions/setup-cli + with: + version: v0.37.18 + + - name: Verify installation + run: | + echo "Installed version: ${{ steps.install.outputs.installed-version }}" + gh aw version + gh aw --help + + test-matrix: + runs-on: ubuntu-latest + strategy: + matrix: + version: [v0.37.18, v0.37.17] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install gh-aw version ${{ matrix.version }} + uses: ./actions/setup-cli + with: + version: ${{ matrix.version }} + + - name: Verify installation + run: | + gh aw version diff --git a/install-gh-aw.sh b/install-gh-aw.sh index 16f9eccdfe..7902b5625b 100755 --- a/install-gh-aw.sh +++ b/install-gh-aw.sh @@ -11,13 +11,26 @@ set -e # Exit on any error # Parse arguments SKIP_CHECKSUM=true # Default to true until checksums are available in releases +TRY_GH_INSTALL=false # Whether to try gh extension install first VERSION="" + +# Check if INPUT_VERSION is set (GitHub Actions context) +if [ -n "$INPUT_VERSION" ]; then + VERSION="$INPUT_VERSION" + TRY_GH_INSTALL=true # In GitHub Actions, try gh install first + SKIP_CHECKSUM=false # Enable checksum validation in GitHub Actions +fi + for arg in "$@"; do case $arg in --skip-checksum) SKIP_CHECKSUM=true shift ;; + --gh-install) + TRY_GH_INSTALL=true + shift + ;; *) if [ -z "$VERSION" ]; then VERSION="$arg" @@ -234,6 +247,47 @@ else print_info "Using specified version: $VERSION" fi +# Validate that the release exists +print_info "Validating release $VERSION exists..." +if ! RELEASE_CHECK=$(fetch_release_data "https://api.github.com/repos/$REPO/releases/tags/$VERSION"); then + print_error "Release $VERSION does not exist in $REPO" + print_info "Please check the releases at: https://github.com/$REPO/releases" + exit 1 +fi +print_success "Release $VERSION validated" + +# Try gh extension install if requested (and gh is available) +if [ "$TRY_GH_INSTALL" = true ] && command -v gh &> /dev/null; then + print_info "Attempting to install gh-aw using 'gh extension install'..." + + # Try to install using gh + if gh extension install "$REPO" --force 2>&1 | tee /tmp/gh-install.log; then + # Verify the installation succeeded + if gh aw version &> /dev/null; then + INSTALLED_VERSION=$(gh aw version 2>&1 | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' | head -1) + print_success "Successfully installed gh-aw using gh extension install" + print_info "Installed version: $INSTALLED_VERSION" + + # Set output for GitHub Actions + if [ -n "${GITHUB_OUTPUT}" ]; then + echo "installed_version=${VERSION}" >> "${GITHUB_OUTPUT}" + fi + + exit 0 + else + print_warning "gh extension install completed but verification failed" + print_info "Falling back to manual installation..." + fi + else + print_warning "gh extension install failed, falling back to manual installation..." + if [ -f /tmp/gh-install.log ]; then + cat /tmp/gh-install.log + fi + fi +elif [ "$TRY_GH_INSTALL" = true ]; then + print_info "gh CLI not available, proceeding with manual installation..." +fi + # Construct download URL and paths DOWNLOAD_URL="https://github.com/$REPO/releases/download/$VERSION/$PLATFORM" CHECKSUMS_URL="https://github.com/$REPO/releases/download/$VERSION/checksums.txt" @@ -375,3 +429,8 @@ print_info " gh aw version" print_info "" print_info "Running gh-aw version check..." "$BINARY_PATH" version + +# Set output for GitHub Actions +if [ -n "${GITHUB_OUTPUT}" ]; then + echo "installed_version=${VERSION}" >> "${GITHUB_OUTPUT}" +fi diff --git a/pkg/cli/copilot_setup.go b/pkg/cli/copilot_setup.go index 905a623130..8cd683dc14 100644 --- a/pkg/cli/copilot_setup.go +++ b/pkg/cli/copilot_setup.go @@ -7,11 +7,82 @@ import ( "strings" "github.com/githubnext/gh-aw/pkg/logger" + "github.com/githubnext/gh-aw/pkg/workflow" "github.com/goccy/go-yaml" ) var copilotSetupLog = logger.New("cli:copilot_setup") +// generateCopilotSetupStepsYAML generates the copilot-setup-steps.yml content based on action mode +func generateCopilotSetupStepsYAML(actionMode workflow.ActionMode, version string) string { + // Determine the action reference - use version tag in release mode, @main in dev mode + actionRef := "@main" + if actionMode.IsRelease() && version != "" && version != "dev" { + actionRef = "@" + version + } + + if actionMode.IsRelease() { + // Use the actions/setup-cli action in release mode + return fmt.Sprintf(`name: "Copilot Setup Steps" + +# This workflow configures the environment for GitHub Copilot Agent with gh-aw MCP server +on: + workflow_dispatch: + push: + paths: + - .github/workflows/copilot-setup-steps.yml + +jobs: + # The job MUST be called 'copilot-setup-steps' to be recognized by GitHub Copilot Agent + copilot-setup-steps: + runs-on: ubuntu-latest + + # Set minimal permissions for setup steps + # Copilot Agent receives its own token with appropriate permissions + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Install gh-aw extension + uses: githubnext/gh-aw/actions/setup-cli%s + with: + version: %s + - name: Verify gh-aw installation + run: gh aw version +`, actionRef, version) + } + + // Default (dev/script mode): use curl to download install script + return `name: "Copilot Setup Steps" + +# This workflow configures the environment for GitHub Copilot Agent with gh-aw MCP server +on: + workflow_dispatch: + push: + paths: + - .github/workflows/copilot-setup-steps.yml + +jobs: + # The job MUST be called 'copilot-setup-steps' to be recognized by GitHub Copilot Agent + copilot-setup-steps: + runs-on: ubuntu-latest + + # Set minimal permissions for setup steps + # Copilot Agent receives its own token with appropriate permissions + permissions: + contents: read + + steps: + - name: Install gh-aw extension + run: | + curl -fsSL https://raw.githubusercontent.com/githubnext/gh-aw/refs/heads/main/install-gh-aw.sh | bash + - name: Verify gh-aw installation + run: gh aw version +` +} + const copilotSetupStepsYAML = `name: "Copilot Setup Steps" # This workflow configures the environment for GitHub Copilot Agent with gh-aw MCP server @@ -63,8 +134,8 @@ type Workflow struct { } // ensureCopilotSetupSteps creates or updates .github/workflows/copilot-setup-steps.yml -func ensureCopilotSetupSteps(verbose bool) error { - copilotSetupLog.Print("Creating copilot-setup-steps.yml") +func ensureCopilotSetupSteps(verbose bool, actionMode workflow.ActionMode, version string) error { + copilotSetupLog.Printf("Creating copilot-setup-steps.yml with action mode: %s, version: %s", actionMode, version) // Create .github/workflows directory if it doesn't exist workflowsDir := filepath.Join(".github", "workflows") @@ -86,10 +157,13 @@ func ensureCopilotSetupSteps(verbose bool) error { return fmt.Errorf("failed to read existing copilot-setup-steps.yml: %w", err) } - // Check if the extension install step is already present (quick check) + // Check if the extension install step is already present (check for both modes) contentStr := string(content) - if strings.Contains(contentStr, "install-gh-aw.sh") || - (strings.Contains(contentStr, "Install gh-aw extension") && strings.Contains(contentStr, "curl -fsSL")) { + hasLegacyInstall := strings.Contains(contentStr, "install-gh-aw.sh") || + (strings.Contains(contentStr, "Install gh-aw extension") && strings.Contains(contentStr, "curl -fsSL")) + hasActionInstall := strings.Contains(contentStr, "actions/setup-cli") + + if hasLegacyInstall || hasActionInstall { copilotSetupLog.Print("Extension install step already exists, skipping update") if verbose { fmt.Fprintf(os.Stderr, "Skipping %s (already has gh-aw extension install step)\n", setupStepsPath) @@ -105,7 +179,7 @@ func ensureCopilotSetupSteps(verbose bool) error { // Inject the extension install step copilotSetupLog.Print("Injecting extension install step into existing file") - if err := injectExtensionInstallStep(&workflow); err != nil { + if err := injectExtensionInstallStep(&workflow, actionMode, version); err != nil { return fmt.Errorf("failed to inject extension install step: %w", err) } @@ -126,7 +200,7 @@ func ensureCopilotSetupSteps(verbose bool) error { return nil } - if err := os.WriteFile(setupStepsPath, []byte(copilotSetupStepsYAML), 0600); err != nil { + if err := os.WriteFile(setupStepsPath, []byte(generateCopilotSetupStepsYAML(actionMode, version)), 0600); err != nil { return fmt.Errorf("failed to write copilot-setup-steps.yml: %w", err) } copilotSetupLog.Printf("Created file: %s", setupStepsPath) @@ -135,12 +209,36 @@ func ensureCopilotSetupSteps(verbose bool) error { } // injectExtensionInstallStep injects the gh-aw extension install and verification steps into an existing workflow -func injectExtensionInstallStep(workflow *Workflow) error { - // Define the extension install and verify steps to inject - installStep := CopilotWorkflowStep{ - Name: "Install gh-aw extension", - Run: "curl -fsSL https://raw.githubusercontent.com/githubnext/gh-aw/refs/heads/main/install-gh-aw.sh | bash", +func injectExtensionInstallStep(workflow *Workflow, actionMode workflow.ActionMode, version string) error { + var installStep, checkoutStep CopilotWorkflowStep + + // Determine the action reference - use version tag in release mode, @main in dev mode + actionRef := "@main" + if actionMode.IsRelease() && version != "" && version != "dev" { + actionRef = "@" + version } + + if actionMode.IsRelease() { + // In release mode, use the actions/setup-cli action + checkoutStep = CopilotWorkflowStep{ + Name: "Checkout repository", + Uses: "actions/checkout@v4", + } + installStep = CopilotWorkflowStep{ + Name: "Install gh-aw extension", + Uses: fmt.Sprintf("githubnext/gh-aw/actions/setup-cli%s", actionRef), + With: map[string]any{ + "version": version, + }, + } + } else { + // In dev/script mode, use curl to download install script + installStep = CopilotWorkflowStep{ + Name: "Install gh-aw extension", + Run: "curl -fsSL https://raw.githubusercontent.com/githubnext/gh-aw/refs/heads/main/install-gh-aw.sh | bash", + } + } + verifyStep := CopilotWorkflowStep{ Name: "Verify gh-aw installation", Run: "gh aw version", @@ -155,11 +253,18 @@ func injectExtensionInstallStep(workflow *Workflow) error { // Insert the extension install and verify steps at the beginning insertPosition := 0 - // Insert both steps at the determined position - newSteps := make([]CopilotWorkflowStep, 0, len(job.Steps)+2) + // Prepare steps to insert based on mode + var stepsToInsert []CopilotWorkflowStep + if actionMode.IsRelease() { + stepsToInsert = []CopilotWorkflowStep{checkoutStep, installStep, verifyStep} + } else { + stepsToInsert = []CopilotWorkflowStep{installStep, verifyStep} + } + + // Insert steps at the determined position + newSteps := make([]CopilotWorkflowStep, 0, len(job.Steps)+len(stepsToInsert)) newSteps = append(newSteps, job.Steps[:insertPosition]...) - newSteps = append(newSteps, installStep) - newSteps = append(newSteps, verifyStep) + newSteps = append(newSteps, stepsToInsert...) newSteps = append(newSteps, job.Steps[insertPosition:]...) job.Steps = newSteps diff --git a/pkg/cli/copilot_setup_test.go b/pkg/cli/copilot_setup_test.go index 128d6b19df..2711f0ffb5 100644 --- a/pkg/cli/copilot_setup_test.go +++ b/pkg/cli/copilot_setup_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/githubnext/gh-aw/pkg/testutil" + "github.com/githubnext/gh-aw/pkg/workflow" "github.com/goccy/go-yaml" ) @@ -156,7 +157,7 @@ func TestEnsureCopilotSetupSteps(t *testing.T) { } // Call the function - err = ensureCopilotSetupSteps(tt.verbose) + err = ensureCopilotSetupSteps(tt.verbose, workflow.ActionModeDev, "dev") if (err != nil) != tt.wantErr { t.Errorf("ensureCopilotSetupSteps() error = %v, wantErr %v", err, tt.wantErr) @@ -258,7 +259,7 @@ func TestInjectExtensionInstallStep(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := injectExtensionInstallStep(tt.workflow) + err := injectExtensionInstallStep(tt.workflow, workflow.ActionModeDev, "dev") if (err != nil) != tt.wantErr { t.Errorf("injectExtensionInstallStep() error = %v, wantErr %v", err, tt.wantErr) @@ -416,7 +417,7 @@ func TestEnsureCopilotSetupStepsFilePermissions(t *testing.T) { t.Fatalf("Failed to change to temp directory: %v", err) } - err = ensureCopilotSetupSteps(false) + err = ensureCopilotSetupSteps(false, workflow.ActionModeDev, "dev") if err != nil { t.Fatalf("ensureCopilotSetupSteps() failed: %v", err) } @@ -516,7 +517,7 @@ func TestEnsureCopilotSetupStepsDirectoryCreation(t *testing.T) { } // Call function when .github/workflows doesn't exist - err = ensureCopilotSetupSteps(false) + err = ensureCopilotSetupSteps(false, workflow.ActionModeDev, "dev") if err != nil { t.Fatalf("ensureCopilotSetupSteps() failed: %v", err) } @@ -539,3 +540,528 @@ func TestEnsureCopilotSetupStepsDirectoryCreation(t *testing.T) { t.Error("Expected copilot-setup-steps.yml to be created") } } + +// TestEnsureCopilotSetupSteps_ReleaseMode tests that release mode uses the actions/setup-cli action +func TestEnsureCopilotSetupSteps_ReleaseMode(t *testing.T) { + // Create temporary directory + tmpDir := t.TempDir() + + // Change to temp directory + originalDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current directory: %v", err) + } + defer func() { + _ = os.Chdir(originalDir) + }() + + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to change to temp directory: %v", err) + } + + // Call function with release mode + testVersion := "v1.2.3" + err = ensureCopilotSetupSteps(false, workflow.ActionModeRelease, testVersion) + if err != nil { + t.Fatalf("ensureCopilotSetupSteps() failed: %v", err) + } + + // Read generated file + setupStepsPath := filepath.Join(".github", "workflows", "copilot-setup-steps.yml") + content, err := os.ReadFile(setupStepsPath) + if err != nil { + t.Fatalf("Failed to read copilot-setup-steps.yml: %v", err) + } + + contentStr := string(content) + + // Verify it uses actions/setup-cli with the correct version tag + if !strings.Contains(contentStr, "actions/setup-cli@v1.2.3") { + t.Errorf("Expected copilot-setup-steps.yml to use actions/setup-cli@v1.2.3 in release mode, got:\n%s", contentStr) + } + + // Verify it uses the correct version in the with parameter + if !strings.Contains(contentStr, "version: v1.2.3") { + t.Errorf("Expected copilot-setup-steps.yml to have version: v1.2.3, got:\n%s", contentStr) + } + + // Verify it has checkout step + if !strings.Contains(contentStr, "actions/checkout@v4") { + t.Error("Expected copilot-setup-steps.yml to have checkout step in release mode") + } + + // Verify it doesn't use curl/install-gh-aw.sh + if strings.Contains(contentStr, "install-gh-aw.sh") || strings.Contains(contentStr, "curl -fsSL") { + t.Error("Expected copilot-setup-steps.yml to NOT use curl method in release mode") + } +} + +// TestEnsureCopilotSetupSteps_DevMode tests that dev mode uses curl install method +func TestEnsureCopilotSetupSteps_DevMode(t *testing.T) { + // Create temporary directory + tmpDir := t.TempDir() + + // Change to temp directory + originalDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current directory: %v", err) + } + defer func() { + _ = os.Chdir(originalDir) + }() + + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to change to temp directory: %v", err) + } + + // Call function with dev mode + err = ensureCopilotSetupSteps(false, workflow.ActionModeDev, "dev") + if err != nil { + t.Fatalf("ensureCopilotSetupSteps() failed: %v", err) + } + + // Read generated file + setupStepsPath := filepath.Join(".github", "workflows", "copilot-setup-steps.yml") + content, err := os.ReadFile(setupStepsPath) + if err != nil { + t.Fatalf("Failed to read copilot-setup-steps.yml: %v", err) + } + + contentStr := string(content) + + // Verify it uses curl method + if !strings.Contains(contentStr, "install-gh-aw.sh") { + t.Error("Expected copilot-setup-steps.yml to use install-gh-aw.sh in dev mode") + } + + // Verify it doesn't use actions/setup-cli + if strings.Contains(contentStr, "actions/setup-cli") { + t.Error("Expected copilot-setup-steps.yml to NOT use actions/setup-cli in dev mode") + } +} + +// TestEnsureCopilotSetupSteps_CreateWithReleaseMode tests creating a new file with release mode +func TestEnsureCopilotSetupSteps_CreateWithReleaseMode(t *testing.T) { + tmpDir := t.TempDir() + originalDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current directory: %v", err) + } + defer func() { _ = os.Chdir(originalDir) }() + + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to change to temp directory: %v", err) + } + + // Create new file with release mode and specific version + testVersion := "v2.0.0" + err = ensureCopilotSetupSteps(false, workflow.ActionModeRelease, testVersion) + if err != nil { + t.Fatalf("ensureCopilotSetupSteps() failed: %v", err) + } + + setupStepsPath := filepath.Join(".github", "workflows", "copilot-setup-steps.yml") + content, err := os.ReadFile(setupStepsPath) + if err != nil { + t.Fatalf("Failed to read copilot-setup-steps.yml: %v", err) + } + + contentStr := string(content) + + // Verify release mode characteristics + if !strings.Contains(contentStr, "actions/setup-cli@v2.0.0") { + t.Errorf("Expected action reference with version tag @v2.0.0, got:\n%s", contentStr) + } + if !strings.Contains(contentStr, "version: v2.0.0") { + t.Errorf("Expected version parameter v2.0.0, got:\n%s", contentStr) + } + if !strings.Contains(contentStr, "actions/checkout@v4") { + t.Errorf("Expected checkout step in release mode") + } +} + +// TestEnsureCopilotSetupSteps_CreateWithDevMode tests creating a new file with dev mode +func TestEnsureCopilotSetupSteps_CreateWithDevMode(t *testing.T) { + tmpDir := t.TempDir() + originalDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current directory: %v", err) + } + defer func() { _ = os.Chdir(originalDir) }() + + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to change to temp directory: %v", err) + } + + // Create new file with dev mode + err = ensureCopilotSetupSteps(false, workflow.ActionModeDev, "dev") + if err != nil { + t.Fatalf("ensureCopilotSetupSteps() failed: %v", err) + } + + setupStepsPath := filepath.Join(".github", "workflows", "copilot-setup-steps.yml") + content, err := os.ReadFile(setupStepsPath) + if err != nil { + t.Fatalf("Failed to read copilot-setup-steps.yml: %v", err) + } + + contentStr := string(content) + + // Verify dev mode characteristics + if !strings.Contains(contentStr, "curl -fsSL") { + t.Errorf("Expected curl command in dev mode") + } + if !strings.Contains(contentStr, "install-gh-aw.sh") { + t.Errorf("Expected install-gh-aw.sh reference in dev mode") + } + if strings.Contains(contentStr, "actions/setup-cli") { + t.Errorf("Did not expect actions/setup-cli in dev mode") + } + if strings.Contains(contentStr, "actions/checkout") { + t.Errorf("Did not expect checkout step in dev mode") + } +} + +// TestEnsureCopilotSetupSteps_UpdateExistingWithReleaseMode tests updating an existing file with release mode +func TestEnsureCopilotSetupSteps_UpdateExistingWithReleaseMode(t *testing.T) { + tmpDir := t.TempDir() + originalDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current directory: %v", err) + } + defer func() { _ = os.Chdir(originalDir) }() + + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to change to temp directory: %v", err) + } + + // Create .github/workflows directory + workflowsDir := filepath.Join(".github", "workflows") + if err := os.MkdirAll(workflowsDir, 0755); err != nil { + t.Fatalf("Failed to create workflows directory: %v", err) + } + + // Write existing workflow without gh-aw install step + existingContent := `name: "Copilot Setup Steps" +on: workflow_dispatch +jobs: + copilot-setup-steps: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Some other step + run: echo "test" +` + setupStepsPath := filepath.Join(workflowsDir, "copilot-setup-steps.yml") + if err := os.WriteFile(setupStepsPath, []byte(existingContent), 0644); err != nil { + t.Fatalf("Failed to write existing workflow: %v", err) + } + + // Update with release mode + testVersion := "v3.0.0" + err = ensureCopilotSetupSteps(false, workflow.ActionModeRelease, testVersion) + if err != nil { + t.Fatalf("ensureCopilotSetupSteps() failed: %v", err) + } + + // Read updated file + content, err := os.ReadFile(setupStepsPath) + if err != nil { + t.Fatalf("Failed to read updated file: %v", err) + } + + contentStr := string(content) + + // Verify release mode injection + if !strings.Contains(contentStr, "actions/setup-cli@v3.0.0") { + t.Errorf("Expected injected action with @v3.0.0 tag, got:\n%s", contentStr) + } + if !strings.Contains(contentStr, "version: v3.0.0") { + t.Errorf("Expected version: v3.0.0 parameter, got:\n%s", contentStr) + } + if !strings.Contains(contentStr, "actions/checkout@v4") { + t.Errorf("Expected checkout step to be injected") + } + // Verify original step is preserved + if !strings.Contains(contentStr, "Some other step") { + t.Errorf("Expected original step to be preserved") + } +} + +// TestEnsureCopilotSetupSteps_UpdateExistingWithDevMode tests updating an existing file with dev mode +func TestEnsureCopilotSetupSteps_UpdateExistingWithDevMode(t *testing.T) { + tmpDir := t.TempDir() + originalDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current directory: %v", err) + } + defer func() { _ = os.Chdir(originalDir) }() + + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to change to temp directory: %v", err) + } + + // Create .github/workflows directory + workflowsDir := filepath.Join(".github", "workflows") + if err := os.MkdirAll(workflowsDir, 0755); err != nil { + t.Fatalf("Failed to create workflows directory: %v", err) + } + + // Write existing workflow without gh-aw install step + existingContent := `name: "Copilot Setup Steps" +on: workflow_dispatch +jobs: + copilot-setup-steps: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Some other step + run: echo "test" +` + setupStepsPath := filepath.Join(workflowsDir, "copilot-setup-steps.yml") + if err := os.WriteFile(setupStepsPath, []byte(existingContent), 0644); err != nil { + t.Fatalf("Failed to write existing workflow: %v", err) + } + + // Update with dev mode + err = ensureCopilotSetupSteps(false, workflow.ActionModeDev, "dev") + if err != nil { + t.Fatalf("ensureCopilotSetupSteps() failed: %v", err) + } + + // Read updated file + content, err := os.ReadFile(setupStepsPath) + if err != nil { + t.Fatalf("Failed to read updated file: %v", err) + } + + contentStr := string(content) + + // Verify dev mode injection + if !strings.Contains(contentStr, "curl -fsSL") { + t.Errorf("Expected curl command in dev mode") + } + if !strings.Contains(contentStr, "install-gh-aw.sh") { + t.Errorf("Expected install-gh-aw.sh in dev mode") + } + if strings.Contains(contentStr, "actions/setup-cli") { + t.Errorf("Did not expect actions/setup-cli in dev mode") + } + // Verify original step is preserved + if !strings.Contains(contentStr, "Some other step") { + t.Errorf("Expected original step to be preserved") + } +} + +// TestEnsureCopilotSetupSteps_SkipsUpdateWhenActionExists tests that update is skipped when action already exists +func TestEnsureCopilotSetupSteps_SkipsUpdateWhenActionExists(t *testing.T) { + tmpDir := t.TempDir() + originalDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current directory: %v", err) + } + defer func() { _ = os.Chdir(originalDir) }() + + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to change to temp directory: %v", err) + } + + // Create .github/workflows directory + workflowsDir := filepath.Join(".github", "workflows") + if err := os.MkdirAll(workflowsDir, 0755); err != nil { + t.Fatalf("Failed to create workflows directory: %v", err) + } + + // Write existing workflow WITH actions/setup-cli (release mode) + existingContent := `name: "Copilot Setup Steps" +on: workflow_dispatch +jobs: + copilot-setup-steps: + runs-on: ubuntu-latest + steps: + - uses: githubnext/gh-aw/actions/setup-cli@v1.0.0 + with: + version: v1.0.0 +` + setupStepsPath := filepath.Join(workflowsDir, "copilot-setup-steps.yml") + if err := os.WriteFile(setupStepsPath, []byte(existingContent), 0644); err != nil { + t.Fatalf("Failed to write existing workflow: %v", err) + } + + // Attempt to update - should skip + err = ensureCopilotSetupSteps(false, workflow.ActionModeRelease, "v2.0.0") + if err != nil { + t.Fatalf("ensureCopilotSetupSteps() failed: %v", err) + } + + // Read file - should be unchanged + content, err := os.ReadFile(setupStepsPath) + if err != nil { + t.Fatalf("Failed to read file: %v", err) + } + + contentStr := string(content) + + // Verify file was not modified (still has v1.0.0) + if !strings.Contains(contentStr, "v1.0.0") { + t.Errorf("Expected file to remain unchanged with v1.0.0") + } + if strings.Contains(contentStr, "v2.0.0") { + t.Errorf("File should not have been updated to v2.0.0") + } +} + +// TestEnsureCopilotSetupSteps_SkipsUpdateWhenCurlExists tests that update is skipped when curl install exists +func TestEnsureCopilotSetupSteps_SkipsUpdateWhenCurlExists(t *testing.T) { + tmpDir := t.TempDir() + originalDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current directory: %v", err) + } + defer func() { _ = os.Chdir(originalDir) }() + + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to change to temp directory: %v", err) + } + + // Create .github/workflows directory + workflowsDir := filepath.Join(".github", "workflows") + if err := os.MkdirAll(workflowsDir, 0755); err != nil { + t.Fatalf("Failed to create workflows directory: %v", err) + } + + // Write existing workflow WITH curl install (dev mode) + existingContent := `name: "Copilot Setup Steps" +on: workflow_dispatch +jobs: + copilot-setup-steps: + runs-on: ubuntu-latest + steps: + - name: Install gh-aw extension + run: curl -fsSL https://raw.githubusercontent.com/githubnext/gh-aw/refs/heads/main/install-gh-aw.sh | bash +` + setupStepsPath := filepath.Join(workflowsDir, "copilot-setup-steps.yml") + if err := os.WriteFile(setupStepsPath, []byte(existingContent), 0644); err != nil { + t.Fatalf("Failed to write existing workflow: %v", err) + } + + // Attempt to update - should skip + err = ensureCopilotSetupSteps(false, workflow.ActionModeDev, "dev") + if err != nil { + t.Fatalf("ensureCopilotSetupSteps() failed: %v", err) + } + + // Verify file content matches expected (should be unchanged) + content, err := os.ReadFile(setupStepsPath) + if err != nil { + t.Fatalf("Failed to read file: %v", err) + } + + if string(content) != existingContent { + t.Errorf("Expected file to remain unchanged") + } +} + +// TestInjectExtensionInstallStep_ReleaseMode tests injecting in release mode with version +func TestInjectExtensionInstallStep_ReleaseMode(t *testing.T) { + wf := &Workflow{ + Jobs: map[string]WorkflowJob{ + "copilot-setup-steps": { + Steps: []CopilotWorkflowStep{ + {Name: "Existing step", Run: "echo test"}, + }, + }, + }, + } + + testVersion := "v4.5.6" + err := injectExtensionInstallStep(wf, workflow.ActionModeRelease, testVersion) + if err != nil { + t.Fatalf("injectExtensionInstallStep() failed: %v", err) + } + + job := wf.Jobs["copilot-setup-steps"] + + // Should have 4 steps: checkout, install, verify, existing + if len(job.Steps) != 4 { + t.Fatalf("Expected 4 steps (checkout, install, verify, existing), got %d", len(job.Steps)) + } + + // Verify checkout step + if job.Steps[0].Name != "Checkout repository" { + t.Errorf("First step should be checkout, got: %s", job.Steps[0].Name) + } + if job.Steps[0].Uses != "actions/checkout@v4" { + t.Errorf("Checkout should use actions/checkout@v4, got: %s", job.Steps[0].Uses) + } + + // Verify install step + if job.Steps[1].Name != "Install gh-aw extension" { + t.Errorf("Second step should be install, got: %s", job.Steps[1].Name) + } + expectedUses := "githubnext/gh-aw/actions/setup-cli@v4.5.6" + if job.Steps[1].Uses != expectedUses { + t.Errorf("Install should use %s, got: %s", expectedUses, job.Steps[1].Uses) + } + if version, ok := job.Steps[1].With["version"]; !ok || version != testVersion { + t.Errorf("Install step should have version: %s in with, got: %v", testVersion, job.Steps[1].With) + } + + // Verify verify step + if job.Steps[2].Name != "Verify gh-aw installation" { + t.Errorf("Third step should be verify, got: %s", job.Steps[2].Name) + } + + // Verify original step is preserved + if job.Steps[3].Name != "Existing step" { + t.Errorf("Fourth step should be existing step, got: %s", job.Steps[3].Name) + } +} + +// TestInjectExtensionInstallStep_DevMode tests injecting in dev mode +func TestInjectExtensionInstallStep_DevMode(t *testing.T) { + wf := &Workflow{ + Jobs: map[string]WorkflowJob{ + "copilot-setup-steps": { + Steps: []CopilotWorkflowStep{ + {Name: "Existing step", Run: "echo test"}, + }, + }, + }, + } + + err := injectExtensionInstallStep(wf, workflow.ActionModeDev, "dev") + if err != nil { + t.Fatalf("injectExtensionInstallStep() failed: %v", err) + } + + job := wf.Jobs["copilot-setup-steps"] + + // Should have 3 steps: install, verify, existing (no checkout in dev mode) + if len(job.Steps) != 3 { + t.Fatalf("Expected 3 steps (install, verify, existing), got %d", len(job.Steps)) + } + + // Verify install step uses curl + if job.Steps[0].Name != "Install gh-aw extension" { + t.Errorf("First step should be install, got: %s", job.Steps[0].Name) + } + if !strings.Contains(job.Steps[0].Run, "curl -fsSL") { + t.Errorf("Install should use curl, got: %s", job.Steps[0].Run) + } + if !strings.Contains(job.Steps[0].Run, "install-gh-aw.sh") { + t.Errorf("Install should reference install-gh-aw.sh, got: %s", job.Steps[0].Run) + } + + // Verify verify step + if job.Steps[1].Name != "Verify gh-aw installation" { + t.Errorf("Second step should be verify, got: %s", job.Steps[1].Name) + } + + // Verify original step is preserved + if job.Steps[2].Name != "Existing step" { + t.Errorf("Third step should be existing step, got: %s", job.Steps[2].Name) + } +} diff --git a/pkg/cli/init.go b/pkg/cli/init.go index c68075f4db..1b132ee774 100644 --- a/pkg/cli/init.go +++ b/pkg/cli/init.go @@ -76,8 +76,12 @@ func InitRepositoryInteractive(verbose bool, rootCmd CommandProvider) error { if copilotMcp { initLog.Print("Configuring GitHub Copilot Agent MCP integration") + // Detect action mode for setup steps generation + actionMode := workflow.DetectActionMode(GetVersion()) + initLog.Printf("Using action mode for copilot-setup-steps.yml: %s", actionMode) + // Create copilot-setup-steps.yml - if err := ensureCopilotSetupSteps(verbose); err != nil { + if err := ensureCopilotSetupSteps(verbose, actionMode, GetVersion()); err != nil { initLog.Printf("Failed to create copilot-setup-steps.yml: %v", err) return fmt.Errorf("failed to create copilot-setup-steps.yml: %w", err) } @@ -607,8 +611,12 @@ func InitRepository(verbose bool, mcp bool, campaign bool, tokens bool, engine s if mcp { initLog.Print("Configuring GitHub Copilot Agent MCP integration") + // Detect action mode for setup steps generation + actionMode := workflow.DetectActionMode(GetVersion()) + initLog.Printf("Using action mode for copilot-setup-steps.yml: %s", actionMode) + // Create copilot-setup-steps.yml - if err := ensureCopilotSetupSteps(verbose); err != nil { + if err := ensureCopilotSetupSteps(verbose, actionMode, GetVersion()); err != nil { initLog.Printf("Failed to create copilot-setup-steps.yml: %v", err) return fmt.Errorf("failed to create copilot-setup-steps.yml: %w", err) } diff --git a/pkg/cli/init_mcp_test.go b/pkg/cli/init_mcp_test.go index f5e32bda3e..36d621c052 100644 --- a/pkg/cli/init_mcp_test.go +++ b/pkg/cli/init_mcp_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/githubnext/gh-aw/pkg/testutil" + "github.com/githubnext/gh-aw/pkg/workflow" ) func TestInitRepository_WithMCP(t *testing.T) { @@ -261,7 +262,7 @@ jobs: } // Call ensureCopilotSetupSteps - if err := ensureCopilotSetupSteps(false); err != nil { + if err := ensureCopilotSetupSteps(false, workflow.ActionModeDev, "dev"); err != nil { t.Fatalf("ensureCopilotSetupSteps() returned error: %v", err) } @@ -342,7 +343,7 @@ jobs: } // Call ensureCopilotSetupSteps - if err := ensureCopilotSetupSteps(false); err != nil { + if err := ensureCopilotSetupSteps(false, workflow.ActionModeDev, "dev"); err != nil { t.Fatalf("ensureCopilotSetupSteps() returned error: %v", err) } diff --git a/pkg/cli/setup_cli_action_integration_test.go b/pkg/cli/setup_cli_action_integration_test.go new file mode 100644 index 0000000000..31d532566c --- /dev/null +++ b/pkg/cli/setup_cli_action_integration_test.go @@ -0,0 +1,175 @@ +//go:build integration + +package cli + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// TestSetupCLIAction tests the setup-cli action's install.sh script +func TestSetupCLIAction(t *testing.T) { + // Get project root + wd, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get working directory: %v", err) + } + projectRoot := filepath.Join(wd, "..", "..") + installScript := filepath.Join(projectRoot, "actions", "setup-cli", "install.sh") + + // Verify script exists + if _, err := os.Stat(installScript); os.IsNotExist(err) { + t.Fatalf("install.sh script not found at: %s", installScript) + } + + // Verify script is executable + info, err := os.Stat(installScript) + if err != nil { + t.Fatalf("Failed to stat install.sh: %v", err) + } + if info.Mode()&0111 == 0 { + t.Errorf("install.sh is not executable") + } + + // Test script syntax + t.Run("script_syntax_valid", func(t *testing.T) { + cmd := exec.Command("bash", "-n", installScript) + if err := cmd.Run(); err != nil { + t.Errorf("Script has syntax errors: %v", err) + } + }) + + // Test that script can fetch latest version when INPUT_VERSION is not provided + t.Run("can_fetch_latest_without_input_version", func(t *testing.T) { + // This test would actually try to fetch from GitHub API + // We just verify the script doesn't immediately fail + content, err := os.ReadFile(installScript) + if err != nil { + t.Fatalf("Failed to read install.sh: %v", err) + } + // Verify script has fallback to fetch latest + if !strings.Contains(string(content), "No version specified") || !strings.Contains(string(content), "fetching latest") { + t.Errorf("Script should support fetching latest release when no version is provided") + } + }) + + // Test INPUT_VERSION environment variable support + t.Run("supports_input_version_env", func(t *testing.T) { + content, err := os.ReadFile(installScript) + if err != nil { + t.Fatalf("Failed to read install.sh: %v", err) + } + if !strings.Contains(string(content), "INPUT_VERSION") { + t.Errorf("Script does not support INPUT_VERSION environment variable") + } + }) + + // Test gh extension install logic exists + t.Run("has_gh_extension_install_logic", func(t *testing.T) { + content, err := os.ReadFile(installScript) + if err != nil { + t.Fatalf("Failed to read install.sh: %v", err) + } + if !strings.Contains(string(content), "gh extension install") { + t.Errorf("Script does not include gh extension install logic") + } + }) + + // Test release validation logic exists + t.Run("has_release_validation", func(t *testing.T) { + content, err := os.ReadFile(installScript) + if err != nil { + t.Fatalf("Failed to read install.sh: %v", err) + } + if !strings.Contains(string(content), "Validating release") { + t.Errorf("Script does not include release validation") + } + }) + + // Test checksum validation is enabled for GitHub Actions + t.Run("checksum_enabled_for_github_actions", func(t *testing.T) { + content, err := os.ReadFile(installScript) + if err != nil { + t.Fatalf("Failed to read install.sh: %v", err) + } + // Check that SKIP_CHECKSUM is set to false when INPUT_VERSION is set + if !strings.Contains(string(content), "SKIP_CHECKSUM=false") { + t.Errorf("Script does not enable checksum validation for GitHub Actions context") + } + }) + + // Test that script is synced from install-gh-aw.sh + t.Run("synced_from_install_gh_aw", func(t *testing.T) { + installGhAwScript := filepath.Join(projectRoot, "install-gh-aw.sh") + + installContent, err := os.ReadFile(installScript) + if err != nil { + t.Fatalf("Failed to read install.sh: %v", err) + } + + installGhAwContent, err := os.ReadFile(installGhAwScript) + if err != nil { + t.Fatalf("Failed to read install-gh-aw.sh: %v", err) + } + + // They should be identical + if string(installContent) != string(installGhAwContent) { + t.Errorf("install.sh is not synced with install-gh-aw.sh. Run 'make sync-action-scripts'") + } + }) +} + +// TestSetupCLIActionYAML tests the action.yml file structure +func TestSetupCLIActionYAML(t *testing.T) { + wd, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get working directory: %v", err) + } + projectRoot := filepath.Join(wd, "..", "..") + actionYAML := filepath.Join(projectRoot, "actions", "setup-cli", "action.yml") + + // Verify action.yml exists + if _, err := os.Stat(actionYAML); os.IsNotExist(err) { + t.Fatalf("action.yml not found at: %s", actionYAML) + } + + // Read and validate action.yml content + content, err := os.ReadFile(actionYAML) + if err != nil { + t.Fatalf("Failed to read action.yml: %v", err) + } + + contentStr := string(content) + + // Verify required fields + requiredFields := []string{ + "name:", + "description:", + "inputs:", + "version:", + "required: true", + "outputs:", + "installed-version:", + "runs:", + "using: 'composite'", + } + + for _, field := range requiredFields { + if !strings.Contains(contentStr, field) { + t.Errorf("action.yml missing required field: %s", field) + } + } + + // Verify version input is required + if !strings.Contains(contentStr, "required: true") { + t.Errorf("version input should be required") + } + + // Verify no SHA mention (only release tags) + if strings.Contains(strings.ToLower(contentStr), "sha") && !strings.Contains(contentStr, "SHA256") { + t.Errorf("action.yml should not mention SHA support (only release tags)") + } +}