From 6e47732d10f93b8061888a883e7764aaeecf8664 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 16:35:10 +0000 Subject: [PATCH 1/9] Initial plan From 5b257983454196710c251eaf5ed3354d34cb22ef Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 16:45:54 +0000 Subject: [PATCH 2/9] Add setup-cli action with SHA resolution and gh extension install Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .gitattributes | 1 + Makefile | 11 +- actions/README.md | 6 + actions/setup-cli/README.md | 180 +++++++++++ actions/setup-cli/action.yml | 25 ++ actions/setup-cli/install.sh | 497 ++++++++++++++++++++++++++++++ actions/setup-cli/install_test.sh | 190 ++++++++++++ install-gh-aw.sh | 120 ++++++++ 8 files changed, 1029 insertions(+), 1 deletion(-) create mode 100644 actions/setup-cli/README.md create mode 100644 actions/setup-cli/action.yml create mode 100755 actions/setup-cli/install.sh create mode 100755 actions/setup-cli/install_test.sh 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..0887000226 --- /dev/null +++ b/actions/setup-cli/README.md @@ -0,0 +1,180 @@ +# Setup gh-aw CLI Action + +This GitHub Action installs the `gh-aw` CLI extension for a specific version. It supports both release tags and commit SHAs that resolve to releases. + +## Features + +- ✅ **Version validation**: Ensures the specified version exists as a release +- ✅ **SHA resolution**: Automatically resolves long commit SHAs to their corresponding release tags +- ✅ **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 (Release Tag) + +```yaml +- name: Install gh-aw + uses: githubnext/gh-aw/actions/setup-cli@main + with: + version: v0.37.18 +``` + +### Using Commit SHA + +```yaml +- name: Install gh-aw from SHA + uses: githubnext/gh-aw/actions/setup-cli@main + with: + version: 0c77d05a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q # Must be a long SHA that resolves to a release +``` + +### 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. Can be either: + +- **Release tag**: e.g., `v0.37.18`, `v0.37.0` +- **Long commit SHA**: 40-character hexadecimal SHA that corresponds to a release (e.g., `0c77d05a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q`) + +If a commit SHA is provided, the action will automatically resolve it to the corresponding release tag. + +## Outputs + +### `installed-version` + +The version tag that was actually installed (useful when providing a SHA as input). + +## How It Works + +1. **Version validation**: Checks if the input is a release tag or long SHA +2. **SHA resolution**: If a long SHA is provided, resolves it to the corresponding release tag +3. **Release verification**: Validates that the release exists on GitHub +4. **Primary installation method**: Attempts to install using `gh extension install githubnext/gh-aw` +5. **Fallback method**: If primary method fails, downloads the binary directly from GitHub releases +6. **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 specified SHA doesn't resolve to any release +- 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 +``` + +### Install from SHA and Use Output + +```yaml +- name: Install gh-aw + id: install + uses: githubnext/gh-aw/actions/setup-cli@main + with: + version: ${{ github.sha }} # Assuming this SHA corresponds to a release + +- 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 + +### "Could not resolve SHA to any release" + +The provided commit SHA doesn't correspond to any published release. Only SHAs from release commits can be used. + +### "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..a4b04f9091 --- /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 or long commit SHA that resolves to a release)' + 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..d6ad2edc26 --- /dev/null +++ b/actions/setup-cli/install.sh @@ -0,0 +1,497 @@ +#!/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 +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 +} + +# Function to check if version looks like a long SHA (40 hex characters) +is_long_sha() { + local ver=$1 + if [[ $ver =~ ^[0-9a-f]{40}$ ]]; then + return 0 + else + return 1 + fi +} + +# Function to resolve SHA to release tag +resolve_sha_to_release() { + local sha=$1 + print_info "Resolving commit SHA $sha to release tag..." + + # Fetch all releases + if ! RELEASES=$(fetch_release_data "https://api.github.com/repos/$REPO/releases?per_page=100"); then + print_error "Failed to fetch releases to resolve SHA" + return 1 + fi + + # Extract target_commitish and tag_name, find matching release + local found_tag="" + local current_tag="" + local current_commit="" + + # Parse JSON line by line looking for tag_name and target_commitish + while IFS= read -r line; do + if echo "$line" | grep -q '"tag_name"'; then + current_tag=$(echo "$line" | sed -E 's/.*"tag_name": *"([^"]+)".*/\1/') + elif echo "$line" | grep -q '"target_commitish"'; then + current_commit=$(echo "$line" | sed -E 's/.*"target_commitish": *"([^"]+)".*/\1/') + # Check if this commit matches our SHA (full or prefix match) + if [ "$current_commit" = "$sha" ] || [[ "$sha" == "$current_commit"* ]]; then + found_tag="$current_tag" + break + fi + fi + done <<< "$RELEASES" + + if [ -n "$found_tag" ]; then + print_success "Resolved SHA $sha to release tag: $found_tag" + echo "$found_tag" + return 0 + else + print_error "Could not resolve SHA $sha to any release" + print_info "The SHA must correspond to a published release" + return 1 + fi +} + +# Get version (use provided version or fetch latest) +# VERSION is already set from argument parsing +REPO="githubnext/gh-aw" + +# If version is provided and looks like a long SHA, resolve it to a release tag +if [ -n "$VERSION" ] && is_long_sha "$VERSION"; then + ORIGINAL_SHA="$VERSION" + print_info "Detected long SHA format: $VERSION" + if ! VERSION=$(resolve_sha_to_release "$VERSION"); then + print_error "Failed to resolve SHA to release tag" + exit 1 + fi + print_info "Resolved to release tag: $VERSION" +fi + +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..3aa5fcf4b4 --- /dev/null +++ b/actions/setup-cli/install_test.sh @@ -0,0 +1,190 @@ +#!/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: Test is_long_sha function +test_is_long_sha() { + echo "" + echo "Test 2: Test is_long_sha function" + + # Extract and test the is_long_sha function + cat > /tmp/test_sha_func.sh << 'FUNC_EOF' +is_long_sha() { + local ver=$1 + if [[ $ver =~ ^[0-9a-f]{40}$ ]]; then + return 0 + else + return 1 + fi +} + +# Test valid long SHA +if is_long_sha "53a14809f3234d628d47864d48170c48e5bb25b9"; then + echo "PASS1" +else + echo "FAIL1" +fi + +# Test version tag (should fail) +if is_long_sha "v0.37.18"; then + echo "FAIL2" +else + echo "PASS2" +fi + +# Test short SHA (should fail) +if is_long_sha "abc123"; then + echo "FAIL3" +else + echo "PASS3" +fi + +# Test uppercase SHA (should fail) +if is_long_sha "53A14809F3234D628D47864D48170C48E5BB25B9"; then + echo "FAIL4" +else + echo "PASS4" +fi +FUNC_EOF + + results=$(bash /tmp/test_sha_func.sh) + if echo "$results" | grep -q "FAIL"; then + print_result "is_long_sha function validation" "FAIL" + echo "$results" + else + print_result "is_long_sha function validation" "PASS" + fi + + rm -f /tmp/test_sha_func.sh +} + +# Test 3: Verify script is executable +test_executable() { + echo "" + echo "Test 3: 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 4: 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 6: Verify SHA resolution function +test_sha_resolution() { + echo "" + echo "Test 6: Verify SHA resolution function" + + if grep -q "resolve_sha_to_release" "$SCRIPT_PATH"; then + print_result "Script includes SHA resolution function" "PASS" + else + print_result "Script missing SHA resolution function" "FAIL" + fi +} + +# Test 7: Verify release validation +test_release_validation() { + echo "" + echo "Test 7: 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 +} + +# Run all tests +echo "=========================================" +echo "Testing setup-cli action install.sh" +echo "=========================================" + +test_script_syntax +test_is_long_sha +test_executable +test_input_version +test_gh_install +test_sha_resolution +test_release_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/install-gh-aw.sh b/install-gh-aw.sh index 16f9eccdfe..d6ad2edc26 100755 --- a/install-gh-aw.sh +++ b/install-gh-aw.sh @@ -11,13 +11,25 @@ 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 +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" @@ -201,10 +213,72 @@ fetch_release_data() { return 1 } +# Function to check if version looks like a long SHA (40 hex characters) +is_long_sha() { + local ver=$1 + if [[ $ver =~ ^[0-9a-f]{40}$ ]]; then + return 0 + else + return 1 + fi +} + +# Function to resolve SHA to release tag +resolve_sha_to_release() { + local sha=$1 + print_info "Resolving commit SHA $sha to release tag..." + + # Fetch all releases + if ! RELEASES=$(fetch_release_data "https://api.github.com/repos/$REPO/releases?per_page=100"); then + print_error "Failed to fetch releases to resolve SHA" + return 1 + fi + + # Extract target_commitish and tag_name, find matching release + local found_tag="" + local current_tag="" + local current_commit="" + + # Parse JSON line by line looking for tag_name and target_commitish + while IFS= read -r line; do + if echo "$line" | grep -q '"tag_name"'; then + current_tag=$(echo "$line" | sed -E 's/.*"tag_name": *"([^"]+)".*/\1/') + elif echo "$line" | grep -q '"target_commitish"'; then + current_commit=$(echo "$line" | sed -E 's/.*"target_commitish": *"([^"]+)".*/\1/') + # Check if this commit matches our SHA (full or prefix match) + if [ "$current_commit" = "$sha" ] || [[ "$sha" == "$current_commit"* ]]; then + found_tag="$current_tag" + break + fi + fi + done <<< "$RELEASES" + + if [ -n "$found_tag" ]; then + print_success "Resolved SHA $sha to release tag: $found_tag" + echo "$found_tag" + return 0 + else + print_error "Could not resolve SHA $sha to any release" + print_info "The SHA must correspond to a published release" + return 1 + fi +} + # Get version (use provided version or fetch latest) # VERSION is already set from argument parsing REPO="githubnext/gh-aw" +# If version is provided and looks like a long SHA, resolve it to a release tag +if [ -n "$VERSION" ] && is_long_sha "$VERSION"; then + ORIGINAL_SHA="$VERSION" + print_info "Detected long SHA format: $VERSION" + if ! VERSION=$(resolve_sha_to_release "$VERSION"); then + print_error "Failed to resolve SHA to release tag" + exit 1 + fi + print_info "Resolved to release tag: $VERSION" +fi + if [ -z "$VERSION" ]; then print_info "No version specified, fetching latest release information from GitHub..." @@ -234,6 +308,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 +490,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 From 1d48626672624275e61f4d5662c4050be3bcb905 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 16:56:55 +0000 Subject: [PATCH 3/9] Add test example workflow for setup-cli action Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- examples/test-setup-cli.md | 45 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 examples/test-setup-cli.md diff --git a/examples/test-setup-cli.md b/examples/test-setup-cli.md new file mode 100644 index 0000000000..5ee4de9bed --- /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 +# It shows both tag and SHA-based installation + +jobs: + test-tag-installation: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install gh-aw using tag + uses: ./actions/setup-cli + with: + version: v0.37.18 + + - name: Verify installation + run: | + gh aw version + gh aw --help + + test-sha-installation: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + # This demonstrates SHA resolution + # Note: The SHA must correspond to a published release + - name: Install gh-aw using SHA + id: install + uses: ./actions/setup-cli + with: + version: "53a14809f3234d628d47864d48170c48e5bb25b9" # Corresponds to a release + + - name: Verify installation and check output + run: | + echo "Installed version: ${{ steps.install.outputs.installed-version }}" + gh aw version From 64ac0cc293ca33b14cb21d5d90bddc01483a32b2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 16:58:24 +0000 Subject: [PATCH 4/9] Fix SHA comparison logic and improve example comments Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup-cli/install.sh | 3 ++- examples/test-setup-cli.md | 5 +++-- install-gh-aw.sh | 3 ++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/actions/setup-cli/install.sh b/actions/setup-cli/install.sh index d6ad2edc26..c87d8e5db0 100755 --- a/actions/setup-cli/install.sh +++ b/actions/setup-cli/install.sh @@ -246,7 +246,8 @@ resolve_sha_to_release() { elif echo "$line" | grep -q '"target_commitish"'; then current_commit=$(echo "$line" | sed -E 's/.*"target_commitish": *"([^"]+)".*/\1/') # Check if this commit matches our SHA (full or prefix match) - if [ "$current_commit" = "$sha" ] || [[ "$sha" == "$current_commit"* ]]; then + # Supports both full SHA and short SHA prefix + if [ "$current_commit" = "$sha" ] || [[ "$current_commit" == "$sha"* ]]; then found_tag="$current_tag" break fi diff --git a/examples/test-setup-cli.md b/examples/test-setup-cli.md index 5ee4de9bed..2bb346a966 100644 --- a/examples/test-setup-cli.md +++ b/examples/test-setup-cli.md @@ -32,12 +32,13 @@ jobs: uses: actions/checkout@v4 # This demonstrates SHA resolution - # Note: The SHA must correspond to a published release + # Note: Replace this SHA with a commit that corresponds to a published release + # You can find release SHAs at: https://github.com/githubnext/gh-aw/releases - name: Install gh-aw using SHA id: install uses: ./actions/setup-cli with: - version: "53a14809f3234d628d47864d48170c48e5bb25b9" # Corresponds to a release + version: "53a14809f3234d628d47864d48170c48e5bb25b9" # Example SHA (update as needed) - name: Verify installation and check output run: | diff --git a/install-gh-aw.sh b/install-gh-aw.sh index d6ad2edc26..c87d8e5db0 100755 --- a/install-gh-aw.sh +++ b/install-gh-aw.sh @@ -246,7 +246,8 @@ resolve_sha_to_release() { elif echo "$line" | grep -q '"target_commitish"'; then current_commit=$(echo "$line" | sed -E 's/.*"target_commitish": *"([^"]+)".*/\1/') # Check if this commit matches our SHA (full or prefix match) - if [ "$current_commit" = "$sha" ] || [[ "$sha" == "$current_commit"* ]]; then + # Supports both full SHA and short SHA prefix + if [ "$current_commit" = "$sha" ] || [[ "$current_commit" == "$sha"* ]]; then found_tag="$current_tag" break fi From 3fef50ee75105584acf924b0582bfbb344f6c1d4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 18:45:11 +0000 Subject: [PATCH 5/9] Remove SHA support, keep only release tag support per feedback Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup-cli/README.md | 41 ++++++------------- actions/setup-cli/action.yml | 2 +- actions/setup-cli/install.sh | 63 ---------------------------- actions/setup-cli/install_test.sh | 68 +++---------------------------- examples/test-setup-cli.md | 23 +++++------ install-gh-aw.sh | 63 ---------------------------- 6 files changed, 30 insertions(+), 230 deletions(-) diff --git a/actions/setup-cli/README.md b/actions/setup-cli/README.md index 0887000226..c0b8a96f57 100644 --- a/actions/setup-cli/README.md +++ b/actions/setup-cli/README.md @@ -1,18 +1,17 @@ # Setup gh-aw CLI Action -This GitHub Action installs the `gh-aw` CLI extension for a specific version. It supports both release tags and commit SHAs that resolve to releases. +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 -- ✅ **SHA resolution**: Automatically resolves long commit SHAs to their corresponding release tags - ✅ **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 (Release Tag) +### Basic Usage ```yaml - name: Install gh-aw @@ -21,15 +20,6 @@ This GitHub Action installs the `gh-aw` CLI extension for a specific version. It version: v0.37.18 ``` -### Using Commit SHA - -```yaml -- name: Install gh-aw from SHA - uses: githubnext/gh-aw/actions/setup-cli@main - with: - version: 0c77d05a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q # Must be a long SHA that resolves to a release -``` - ### Complete Workflow Example ```yaml @@ -59,27 +49,23 @@ jobs: ### `version` (required) -The version of gh-aw to install. Can be either: +The version of gh-aw to install. Must be a release tag. - **Release tag**: e.g., `v0.37.18`, `v0.37.0` -- **Long commit SHA**: 40-character hexadecimal SHA that corresponds to a release (e.g., `0c77d05a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q`) - -If a commit SHA is provided, the action will automatically resolve it to the corresponding release tag. ## Outputs ### `installed-version` -The version tag that was actually installed (useful when providing a SHA as input). +The version tag that was actually installed. ## How It Works -1. **Version validation**: Checks if the input is a release tag or long SHA -2. **SHA resolution**: If a long SHA is provided, resolves it to the corresponding release tag -3. **Release verification**: Validates that the release exists on GitHub -4. **Primary installation method**: Attempts to install using `gh extension install githubnext/gh-aw` -5. **Fallback method**: If primary method fails, downloads the binary directly from GitHub releases -6. **Verification**: Ensures the installed binary works correctly +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. **Verification**: Ensures the installed binary works correctly ## Requirements @@ -92,7 +78,6 @@ The action will fail if: - No version is provided - The specified release tag doesn't exist -- The specified SHA doesn't resolve to any release - The binary download fails - The downloaded binary is not executable or doesn't work @@ -115,14 +100,14 @@ The action will fail if: version: v0.37.18 ``` -### Install from SHA and Use Output +### Use Output ```yaml - name: Install gh-aw id: install uses: githubnext/gh-aw/actions/setup-cli@main with: - version: ${{ github.sha }} # Assuming this SHA corresponds to a release + version: v0.37.18 - name: Show installed version run: | @@ -153,9 +138,9 @@ jobs: Verify the release exists at: https://github.com/githubnext/gh-aw/releases -### "Could not resolve SHA to any release" +### "Release X does not exist" -The provided commit SHA doesn't correspond to any published release. Only SHAs from release commits can be used. +Verify the release exists at: https://github.com/githubnext/gh-aw/releases ### "gh extension install failed" diff --git a/actions/setup-cli/action.yml b/actions/setup-cli/action.yml index a4b04f9091..48f62448c3 100644 --- a/actions/setup-cli/action.yml +++ b/actions/setup-cli/action.yml @@ -4,7 +4,7 @@ author: 'GitHub Next' inputs: version: - description: 'Version to install (release tag like v0.37.18 or long commit SHA that resolves to a release)' + description: 'Version to install (release tag like v0.37.18)' required: true outputs: diff --git a/actions/setup-cli/install.sh b/actions/setup-cli/install.sh index c87d8e5db0..b8cc3e5aee 100755 --- a/actions/setup-cli/install.sh +++ b/actions/setup-cli/install.sh @@ -213,73 +213,10 @@ fetch_release_data() { return 1 } -# Function to check if version looks like a long SHA (40 hex characters) -is_long_sha() { - local ver=$1 - if [[ $ver =~ ^[0-9a-f]{40}$ ]]; then - return 0 - else - return 1 - fi -} - -# Function to resolve SHA to release tag -resolve_sha_to_release() { - local sha=$1 - print_info "Resolving commit SHA $sha to release tag..." - - # Fetch all releases - if ! RELEASES=$(fetch_release_data "https://api.github.com/repos/$REPO/releases?per_page=100"); then - print_error "Failed to fetch releases to resolve SHA" - return 1 - fi - - # Extract target_commitish and tag_name, find matching release - local found_tag="" - local current_tag="" - local current_commit="" - - # Parse JSON line by line looking for tag_name and target_commitish - while IFS= read -r line; do - if echo "$line" | grep -q '"tag_name"'; then - current_tag=$(echo "$line" | sed -E 's/.*"tag_name": *"([^"]+)".*/\1/') - elif echo "$line" | grep -q '"target_commitish"'; then - current_commit=$(echo "$line" | sed -E 's/.*"target_commitish": *"([^"]+)".*/\1/') - # Check if this commit matches our SHA (full or prefix match) - # Supports both full SHA and short SHA prefix - if [ "$current_commit" = "$sha" ] || [[ "$current_commit" == "$sha"* ]]; then - found_tag="$current_tag" - break - fi - fi - done <<< "$RELEASES" - - if [ -n "$found_tag" ]; then - print_success "Resolved SHA $sha to release tag: $found_tag" - echo "$found_tag" - return 0 - else - print_error "Could not resolve SHA $sha to any release" - print_info "The SHA must correspond to a published release" - return 1 - fi -} - # Get version (use provided version or fetch latest) # VERSION is already set from argument parsing REPO="githubnext/gh-aw" -# If version is provided and looks like a long SHA, resolve it to a release tag -if [ -n "$VERSION" ] && is_long_sha "$VERSION"; then - ORIGINAL_SHA="$VERSION" - print_info "Detected long SHA format: $VERSION" - if ! VERSION=$(resolve_sha_to_release "$VERSION"); then - print_error "Failed to resolve SHA to release tag" - exit 1 - fi - print_info "Resolved to release tag: $VERSION" -fi - if [ -z "$VERSION" ]; then print_info "No version specified, fetching latest release information from GitHub..." diff --git a/actions/setup-cli/install_test.sh b/actions/setup-cli/install_test.sh index 3aa5fcf4b4..63e88c6434 100755 --- a/actions/setup-cli/install_test.sh +++ b/actions/setup-cli/install_test.sh @@ -44,66 +44,10 @@ test_script_syntax() { fi } -# Test 2: Test is_long_sha function -test_is_long_sha() { - echo "" - echo "Test 2: Test is_long_sha function" - - # Extract and test the is_long_sha function - cat > /tmp/test_sha_func.sh << 'FUNC_EOF' -is_long_sha() { - local ver=$1 - if [[ $ver =~ ^[0-9a-f]{40}$ ]]; then - return 0 - else - return 1 - fi -} - -# Test valid long SHA -if is_long_sha "53a14809f3234d628d47864d48170c48e5bb25b9"; then - echo "PASS1" -else - echo "FAIL1" -fi - -# Test version tag (should fail) -if is_long_sha "v0.37.18"; then - echo "FAIL2" -else - echo "PASS2" -fi - -# Test short SHA (should fail) -if is_long_sha "abc123"; then - echo "FAIL3" -else - echo "PASS3" -fi - -# Test uppercase SHA (should fail) -if is_long_sha "53A14809F3234D628D47864D48170C48E5BB25B9"; then - echo "FAIL4" -else - echo "PASS4" -fi -FUNC_EOF - - results=$(bash /tmp/test_sha_func.sh) - if echo "$results" | grep -q "FAIL"; then - print_result "is_long_sha function validation" "FAIL" - echo "$results" - else - print_result "is_long_sha function validation" "PASS" - fi - - rm -f /tmp/test_sha_func.sh -} - -# Test 3: Verify script is executable +# Test 2: Verify script is executable test_executable() { echo "" - echo "Test 3: Verify script is executable" + echo "Test 2: Verify script is executable" if [ -x "$SCRIPT_PATH" ]; then print_result "Script is executable" "PASS" @@ -112,7 +56,7 @@ test_executable() { fi } -# Test 4: Verify INPUT_VERSION support +# Test 3: Verify INPUT_VERSION support test_input_version() { echo "" echo "Test 4: Verify INPUT_VERSION environment variable support" @@ -150,10 +94,10 @@ test_sha_resolution() { fi } -# Test 7: Verify release validation +# Test 5: Verify release validation test_release_validation() { echo "" - echo "Test 7: Verify release validation" + echo "Test 5: Verify release validation" if grep -q "Validating release.*exists" "$SCRIPT_PATH"; then print_result "Script includes release validation" "PASS" @@ -168,11 +112,9 @@ echo "Testing setup-cli action install.sh" echo "=========================================" test_script_syntax -test_is_long_sha test_executable test_input_version test_gh_install -test_sha_resolution test_release_validation # Summary diff --git a/examples/test-setup-cli.md b/examples/test-setup-cli.md index 2bb346a966..eb29d74052 100644 --- a/examples/test-setup-cli.md +++ b/examples/test-setup-cli.md @@ -6,41 +6,40 @@ on: workflow_dispatch: # This is a test workflow to demonstrate the setup-cli action -# It shows both tag and SHA-based installation jobs: - test-tag-installation: + test-installation: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - - name: Install gh-aw using tag + - 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-sha-installation: + test-matrix: runs-on: ubuntu-latest + strategy: + matrix: + version: [v0.37.18, v0.37.17] steps: - name: Checkout uses: actions/checkout@v4 - # This demonstrates SHA resolution - # Note: Replace this SHA with a commit that corresponds to a published release - # You can find release SHAs at: https://github.com/githubnext/gh-aw/releases - - name: Install gh-aw using SHA - id: install + - name: Install gh-aw version ${{ matrix.version }} uses: ./actions/setup-cli with: - version: "53a14809f3234d628d47864d48170c48e5bb25b9" # Example SHA (update as needed) + version: ${{ matrix.version }} - - name: Verify installation and check output + - name: Verify installation run: | - echo "Installed version: ${{ steps.install.outputs.installed-version }}" gh aw version diff --git a/install-gh-aw.sh b/install-gh-aw.sh index c87d8e5db0..b8cc3e5aee 100755 --- a/install-gh-aw.sh +++ b/install-gh-aw.sh @@ -213,73 +213,10 @@ fetch_release_data() { return 1 } -# Function to check if version looks like a long SHA (40 hex characters) -is_long_sha() { - local ver=$1 - if [[ $ver =~ ^[0-9a-f]{40}$ ]]; then - return 0 - else - return 1 - fi -} - -# Function to resolve SHA to release tag -resolve_sha_to_release() { - local sha=$1 - print_info "Resolving commit SHA $sha to release tag..." - - # Fetch all releases - if ! RELEASES=$(fetch_release_data "https://api.github.com/repos/$REPO/releases?per_page=100"); then - print_error "Failed to fetch releases to resolve SHA" - return 1 - fi - - # Extract target_commitish and tag_name, find matching release - local found_tag="" - local current_tag="" - local current_commit="" - - # Parse JSON line by line looking for tag_name and target_commitish - while IFS= read -r line; do - if echo "$line" | grep -q '"tag_name"'; then - current_tag=$(echo "$line" | sed -E 's/.*"tag_name": *"([^"]+)".*/\1/') - elif echo "$line" | grep -q '"target_commitish"'; then - current_commit=$(echo "$line" | sed -E 's/.*"target_commitish": *"([^"]+)".*/\1/') - # Check if this commit matches our SHA (full or prefix match) - # Supports both full SHA and short SHA prefix - if [ "$current_commit" = "$sha" ] || [[ "$current_commit" == "$sha"* ]]; then - found_tag="$current_tag" - break - fi - fi - done <<< "$RELEASES" - - if [ -n "$found_tag" ]; then - print_success "Resolved SHA $sha to release tag: $found_tag" - echo "$found_tag" - return 0 - else - print_error "Could not resolve SHA $sha to any release" - print_info "The SHA must correspond to a published release" - return 1 - fi -} - # Get version (use provided version or fetch latest) # VERSION is already set from argument parsing REPO="githubnext/gh-aw" -# If version is provided and looks like a long SHA, resolve it to a release tag -if [ -n "$VERSION" ] && is_long_sha "$VERSION"; then - ORIGINAL_SHA="$VERSION" - print_info "Detected long SHA format: $VERSION" - if ! VERSION=$(resolve_sha_to_release "$VERSION"); then - print_error "Failed to resolve SHA to release tag" - exit 1 - fi - print_info "Resolved to release tag: $VERSION" -fi - if [ -z "$VERSION" ]; then print_info "No version specified, fetching latest release information from GitHub..." From 46995e98c3ed67681ff15b8c137766a406057bb5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 18:52:57 +0000 Subject: [PATCH 6/9] Enable checksum validation and add integration test Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup-cli/README.md | 4 +- actions/setup-cli/install.sh | 1 + actions/setup-cli/install_test.sh | 26 +-- install-gh-aw.sh | 1 + pkg/cli/setup_cli_action_integration_test.go | 175 +++++++++++++++++++ 5 files changed, 194 insertions(+), 13 deletions(-) create mode 100644 pkg/cli/setup_cli_action_integration_test.go diff --git a/actions/setup-cli/README.md b/actions/setup-cli/README.md index c0b8a96f57..3c07295307 100644 --- a/actions/setup-cli/README.md +++ b/actions/setup-cli/README.md @@ -5,6 +5,7 @@ This GitHub Action installs the `gh-aw` CLI extension for a specific version usi ## 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 @@ -65,7 +66,8 @@ The version tag that was actually installed. 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. **Verification**: Ensures the installed binary works correctly +5. **Checksum verification**: Downloads and verifies SHA256 checksums for the binary +6. **Binary verification**: Ensures the installed binary works correctly ## Requirements diff --git a/actions/setup-cli/install.sh b/actions/setup-cli/install.sh index b8cc3e5aee..7902b5625b 100755 --- a/actions/setup-cli/install.sh +++ b/actions/setup-cli/install.sh @@ -18,6 +18,7 @@ VERSION="" 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 diff --git a/actions/setup-cli/install_test.sh b/actions/setup-cli/install_test.sh index 63e88c6434..3601d32284 100755 --- a/actions/setup-cli/install_test.sh +++ b/actions/setup-cli/install_test.sh @@ -82,18 +82,6 @@ test_gh_install() { fi } -# Test 6: Verify SHA resolution function -test_sha_resolution() { - echo "" - echo "Test 6: Verify SHA resolution function" - - if grep -q "resolve_sha_to_release" "$SCRIPT_PATH"; then - print_result "Script includes SHA resolution function" "PASS" - else - print_result "Script missing SHA resolution function" "FAIL" - fi -} - # Test 5: Verify release validation test_release_validation() { echo "" @@ -106,6 +94,19 @@ test_release_validation() { 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" @@ -116,6 +117,7 @@ test_executable test_input_version test_gh_install test_release_validation +test_checksum_validation # Summary echo "" diff --git a/install-gh-aw.sh b/install-gh-aw.sh index b8cc3e5aee..7902b5625b 100755 --- a/install-gh-aw.sh +++ b/install-gh-aw.sh @@ -18,6 +18,7 @@ VERSION="" 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 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)") + } +} From 8efae96a22c11df3a9351e4ea470189e1083c0a0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 19:15:52 +0000 Subject: [PATCH 7/9] Use actions/setup-cli in release mode for copilot-setup-steps.yml Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/copilot_setup.go | 125 +++++++++++++++++++++++++++++----- pkg/cli/copilot_setup_test.go | 102 +++++++++++++++++++++++++-- pkg/cli/init.go | 12 +++- pkg/cli/init_mcp_test.go | 5 +- 4 files changed, 220 insertions(+), 24 deletions(-) diff --git a/pkg/cli/copilot_setup.go b/pkg/cli/copilot_setup.go index 905a623130..80cc76ddd9 100644 --- a/pkg/cli/copilot_setup.go +++ b/pkg/cli/copilot_setup.go @@ -7,11 +7,76 @@ 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) string { + if actionMode.IsRelease() { + // Use the actions/setup-cli action in release mode + 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: Checkout repository + uses: actions/checkout@v4 + - name: Install gh-aw extension + uses: githubnext/gh-aw/actions/setup-cli@main + with: + version: v0.37.18 + - name: Verify gh-aw installation + run: gh aw 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 +128,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) error { + copilotSetupLog.Printf("Creating copilot-setup-steps.yml with action mode: %s", actionMode) // Create .github/workflows directory if it doesn't exist workflowsDir := filepath.Join(".github", "workflows") @@ -86,10 +151,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 +173,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); err != nil { return fmt.Errorf("failed to inject extension install step: %w", err) } @@ -126,7 +194,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)), 0600); err != nil { return fmt.Errorf("failed to write copilot-setup-steps.yml: %w", err) } copilotSetupLog.Printf("Created file: %s", setupStepsPath) @@ -135,12 +203,30 @@ 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) error { + var installStep, checkoutStep CopilotWorkflowStep + + 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: "githubnext/gh-aw/actions/setup-cli@main", + With: map[string]any{ + "version": "v0.37.18", + }, + } + } 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 +241,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..1c9ad15ebe 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) 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) 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) 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) if err != nil { t.Fatalf("ensureCopilotSetupSteps() failed: %v", err) } @@ -539,3 +540,96 @@ 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 + err = ensureCopilotSetupSteps(false, workflow.ActionModeRelease) + 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 + if !strings.Contains(contentStr, "actions/setup-cli@main") { + t.Error("Expected copilot-setup-steps.yml to use actions/setup-cli action in release mode") + } + + // 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) + 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") + } +} diff --git a/pkg/cli/init.go b/pkg/cli/init.go index c68075f4db..fcfc73c168 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); 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); 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..3573f5bbf0 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); 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); err != nil { t.Fatalf("ensureCopilotSetupSteps() returned error: %v", err) } From efe9402e0501cae7742aefb25ed49158b9e37221 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 19:42:27 +0000 Subject: [PATCH 8/9] Use GetVersion() for action tag and version parameter Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/copilot_setup.go | 36 +++++++++++++++++++++++------------ pkg/cli/copilot_setup_test.go | 24 ++++++++++++++--------- pkg/cli/init.go | 4 ++-- pkg/cli/init_mcp_test.go | 4 ++-- 4 files changed, 43 insertions(+), 25 deletions(-) diff --git a/pkg/cli/copilot_setup.go b/pkg/cli/copilot_setup.go index 80cc76ddd9..8cd683dc14 100644 --- a/pkg/cli/copilot_setup.go +++ b/pkg/cli/copilot_setup.go @@ -14,10 +14,16 @@ import ( var copilotSetupLog = logger.New("cli:copilot_setup") // generateCopilotSetupStepsYAML generates the copilot-setup-steps.yml content based on action mode -func generateCopilotSetupStepsYAML(actionMode workflow.ActionMode) string { +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 `name: "Copilot Setup Steps" + return fmt.Sprintf(`name: "Copilot Setup Steps" # This workflow configures the environment for GitHub Copilot Agent with gh-aw MCP server on: @@ -40,12 +46,12 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - name: Install gh-aw extension - uses: githubnext/gh-aw/actions/setup-cli@main + uses: githubnext/gh-aw/actions/setup-cli%s with: - version: v0.37.18 + version: %s - name: Verify gh-aw installation run: gh aw version -` +`, actionRef, version) } // Default (dev/script mode): use curl to download install script @@ -128,8 +134,8 @@ type Workflow struct { } // ensureCopilotSetupSteps creates or updates .github/workflows/copilot-setup-steps.yml -func ensureCopilotSetupSteps(verbose bool, actionMode workflow.ActionMode) error { - copilotSetupLog.Printf("Creating copilot-setup-steps.yml with action mode: %s", actionMode) +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") @@ -173,7 +179,7 @@ func ensureCopilotSetupSteps(verbose bool, actionMode workflow.ActionMode) error // Inject the extension install step copilotSetupLog.Print("Injecting extension install step into existing file") - if err := injectExtensionInstallStep(&workflow, actionMode); err != nil { + if err := injectExtensionInstallStep(&workflow, actionMode, version); err != nil { return fmt.Errorf("failed to inject extension install step: %w", err) } @@ -194,7 +200,7 @@ func ensureCopilotSetupSteps(verbose bool, actionMode workflow.ActionMode) error return nil } - if err := os.WriteFile(setupStepsPath, []byte(generateCopilotSetupStepsYAML(actionMode)), 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) @@ -203,9 +209,15 @@ func ensureCopilotSetupSteps(verbose bool, actionMode workflow.ActionMode) error } // injectExtensionInstallStep injects the gh-aw extension install and verification steps into an existing workflow -func injectExtensionInstallStep(workflow *Workflow, actionMode workflow.ActionMode) error { +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{ @@ -214,9 +226,9 @@ func injectExtensionInstallStep(workflow *Workflow, actionMode workflow.ActionMo } installStep = CopilotWorkflowStep{ Name: "Install gh-aw extension", - Uses: "githubnext/gh-aw/actions/setup-cli@main", + Uses: fmt.Sprintf("githubnext/gh-aw/actions/setup-cli%s", actionRef), With: map[string]any{ - "version": "v0.37.18", + "version": version, }, } } else { diff --git a/pkg/cli/copilot_setup_test.go b/pkg/cli/copilot_setup_test.go index 1c9ad15ebe..abd21f4cbf 100644 --- a/pkg/cli/copilot_setup_test.go +++ b/pkg/cli/copilot_setup_test.go @@ -157,7 +157,7 @@ func TestEnsureCopilotSetupSteps(t *testing.T) { } // Call the function - err = ensureCopilotSetupSteps(tt.verbose, workflow.ActionModeDev) + err = ensureCopilotSetupSteps(tt.verbose, workflow.ActionModeDev, "dev") if (err != nil) != tt.wantErr { t.Errorf("ensureCopilotSetupSteps() error = %v, wantErr %v", err, tt.wantErr) @@ -259,7 +259,7 @@ func TestInjectExtensionInstallStep(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := injectExtensionInstallStep(tt.workflow, workflow.ActionModeDev) + err := injectExtensionInstallStep(tt.workflow, workflow.ActionModeDev, "dev") if (err != nil) != tt.wantErr { t.Errorf("injectExtensionInstallStep() error = %v, wantErr %v", err, tt.wantErr) @@ -417,7 +417,7 @@ func TestEnsureCopilotSetupStepsFilePermissions(t *testing.T) { t.Fatalf("Failed to change to temp directory: %v", err) } - err = ensureCopilotSetupSteps(false, workflow.ActionModeDev) + err = ensureCopilotSetupSteps(false, workflow.ActionModeDev, "dev") if err != nil { t.Fatalf("ensureCopilotSetupSteps() failed: %v", err) } @@ -517,7 +517,7 @@ func TestEnsureCopilotSetupStepsDirectoryCreation(t *testing.T) { } // Call function when .github/workflows doesn't exist - err = ensureCopilotSetupSteps(false, workflow.ActionModeDev) + err = ensureCopilotSetupSteps(false, workflow.ActionModeDev, "dev") if err != nil { t.Fatalf("ensureCopilotSetupSteps() failed: %v", err) } @@ -560,7 +560,8 @@ func TestEnsureCopilotSetupSteps_ReleaseMode(t *testing.T) { } // Call function with release mode - err = ensureCopilotSetupSteps(false, workflow.ActionModeRelease) + testVersion := "v1.2.3" + err = ensureCopilotSetupSteps(false, workflow.ActionModeRelease, testVersion) if err != nil { t.Fatalf("ensureCopilotSetupSteps() failed: %v", err) } @@ -574,9 +575,14 @@ func TestEnsureCopilotSetupSteps_ReleaseMode(t *testing.T) { contentStr := string(content) - // Verify it uses actions/setup-cli - if !strings.Contains(contentStr, "actions/setup-cli@main") { - t.Error("Expected copilot-setup-steps.yml to use actions/setup-cli action in release mode") + // 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 @@ -609,7 +615,7 @@ func TestEnsureCopilotSetupSteps_DevMode(t *testing.T) { } // Call function with dev mode - err = ensureCopilotSetupSteps(false, workflow.ActionModeDev) + err = ensureCopilotSetupSteps(false, workflow.ActionModeDev, "dev") if err != nil { t.Fatalf("ensureCopilotSetupSteps() failed: %v", err) } diff --git a/pkg/cli/init.go b/pkg/cli/init.go index fcfc73c168..1b132ee774 100644 --- a/pkg/cli/init.go +++ b/pkg/cli/init.go @@ -81,7 +81,7 @@ func InitRepositoryInteractive(verbose bool, rootCmd CommandProvider) error { initLog.Printf("Using action mode for copilot-setup-steps.yml: %s", actionMode) // Create copilot-setup-steps.yml - if err := ensureCopilotSetupSteps(verbose, actionMode); 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) } @@ -616,7 +616,7 @@ func InitRepository(verbose bool, mcp bool, campaign bool, tokens bool, engine s initLog.Printf("Using action mode for copilot-setup-steps.yml: %s", actionMode) // Create copilot-setup-steps.yml - if err := ensureCopilotSetupSteps(verbose, actionMode); 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 3573f5bbf0..36d621c052 100644 --- a/pkg/cli/init_mcp_test.go +++ b/pkg/cli/init_mcp_test.go @@ -262,7 +262,7 @@ jobs: } // Call ensureCopilotSetupSteps - if err := ensureCopilotSetupSteps(false, workflow.ActionModeDev); err != nil { + if err := ensureCopilotSetupSteps(false, workflow.ActionModeDev, "dev"); err != nil { t.Fatalf("ensureCopilotSetupSteps() returned error: %v", err) } @@ -343,7 +343,7 @@ jobs: } // Call ensureCopilotSetupSteps - if err := ensureCopilotSetupSteps(false, workflow.ActionModeDev); err != nil { + if err := ensureCopilotSetupSteps(false, workflow.ActionModeDev, "dev"); err != nil { t.Fatalf("ensureCopilotSetupSteps() returned error: %v", err) } From f7c885c68db151eccf37d493afbb98644e35bd4a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 19:53:33 +0000 Subject: [PATCH 9/9] Add comprehensive tests for create/update copilot setup file Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/copilot_setup_test.go | 426 ++++++++++++++++++++++++++++++++++ 1 file changed, 426 insertions(+) diff --git a/pkg/cli/copilot_setup_test.go b/pkg/cli/copilot_setup_test.go index abd21f4cbf..2711f0ffb5 100644 --- a/pkg/cli/copilot_setup_test.go +++ b/pkg/cli/copilot_setup_test.go @@ -639,3 +639,429 @@ func TestEnsureCopilotSetupSteps_DevMode(t *testing.T) { 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) + } +}