Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1008,6 +1008,50 @@ jobs:

echo "✅ **SUCCESS**: All local relative import paths were preserved by gh aw update" >> $GITHUB_STEP_SUMMARY

integration-update-target-repo:
name: Integration Update - Target Repo
if: ${{ needs.changes.outputs.has_changes == 'true' }}
needs:
- changes
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}-integration-update-target-repo
cancel-in-progress: true
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Set up Go
id: setup-go
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
with:
go-version-file: go.mod
cache: true

- name: Download dependencies
run: go mod download

- name: Verify dependencies
run: go mod verify

- name: Build gh-aw binary
run: make build

- name: Run gh aw update against githubnext/agentic-ops
env:
GH_TOKEN: ${{ github.token }}
run: |
mkdir -p /tmp/test-update-target-repo-workspace
cd /tmp/test-update-target-repo-workspace
git init -q
git config user.email "test@example.com"
git config user.name "Test"

/home/runner/work/gh-aw/gh-aw/gh-aw update --repo githubnext/agentic-ops --no-compile --verbose

integration-unauthenticated-add:
name: Integration Unauthenticated Add (Public Repo)
if: ${{ needs.changes.outputs.has_changes == 'true' }}
Expand Down
114 changes: 113 additions & 1 deletion pkg/cli/update_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,24 @@ package cli

import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"

"github.com/github/gh-aw/pkg/console"
"github.com/github/gh-aw/pkg/constants"
"github.com/github/gh-aw/pkg/gitutil"
"github.com/github/gh-aw/pkg/logger"
"github.com/spf13/cobra"
)

var updateLog = logger.New("cli:update_command")

const updateTargetRepoCheckoutDir = ".github/aw/updates"

// NewUpdateCommand creates the update command
func NewUpdateCommand(validateEngine func(string) error) *cobra.Command {
cmd := &cobra.Command{
Expand Down Expand Up @@ -48,6 +55,7 @@ Examples:
` + string(constants.CLIExtensionPrefix) + ` update --no-compile # Update without regenerating lock files
` + string(constants.CLIExtensionPrefix) + ` update --no-redirect # Refuse workflows that use redirect frontmatter
` + string(constants.CLIExtensionPrefix) + ` update --dir custom/workflows # Update workflows in custom directory
` + string(constants.CLIExtensionPrefix) + ` update --repo owner/repo # Update workflows in another repository
` + string(constants.CLIExtensionPrefix) + ` update --create-pull-request # Update and open a pull request
` + string(constants.CLIExtensionPrefix) + ` update --cool-down 0 # Disable cooldown and apply all pending releases immediately
` + string(constants.CLIExtensionPrefix) + ` update --cool-down 3d # Apply a custom 3-day cooldown period`,
Expand All @@ -68,6 +76,7 @@ Examples:
prFlagAlias, _ := cmd.Flags().GetBool("pr")
createPR := createPRFlag || prFlagAlias
coolDownStr, _ := cmd.Flags().GetString("cool-down")
targetRepo, _ := cmd.Flags().GetString("repo")

if err := validateEngine(engineOverride); err != nil {
return err
Expand All @@ -78,7 +87,7 @@ Examples:
return fmt.Errorf("invalid --cool-down value: %w", err)
}

if createPR {
if createPR && targetRepo == "" {
if err := PreflightCheckForCreatePR(verbose); err != nil {
return err
}
Expand All @@ -101,6 +110,10 @@ Examples:
CoolDown: coolDown,
}

if targetRepo != "" {
return runUpdateForTargetRepo(cmd.Context(), targetRepo, opts, createPR, verbose)
}

if err := RunUpdateWorkflows(cmd.Context(), opts); err != nil {
return err
}
Expand All @@ -126,6 +139,7 @@ Examples:
cmd.Flags().Bool("disable-security-scanner", false, "Disable security scanning of workflow markdown content")
cmd.Flags().Bool("no-compile", false, "Skip recompiling workflows (do not modify lock files)")
cmd.Flags().Bool("no-redirect", false, "Refuse updates when redirect frontmatter is present")
addRepoFlag(cmd)
cmd.Flags().Bool("create-pull-request", false, "Create a pull request with the update changes")
cmd.Flags().Bool("pr", false, "Alias for --create-pull-request")
cmd.Flags().String("cool-down", "7d", "Cooldown period before applying a new release (e.g. 7d, 24h, 0 to disable). Does not apply to actions/* or github/* repositories")
Expand Down Expand Up @@ -177,3 +191,101 @@ func RunUpdateWorkflows(ctx context.Context, opts UpdateWorkflowsOptions) error
updateLog.Printf("Update process complete: had_error=%v", firstErr != nil)
return firstErr
}

func runUpdateForTargetRepo(ctx context.Context, targetRepo string, opts UpdateWorkflowsOptions, createPR bool, verbose bool) error {
gitRoot, err := gitutil.FindGitRoot()
if err != nil {
return fmt.Errorf("--repo requires running inside a git repository: %w", err)
}

updatesDir, err := ensureUpdateTargetRepoGitignore(gitRoot)
if err != nil {
return err
}

checkoutDir := filepath.Join(updatesDir, sanitizeRepoPath(targetRepo))
if err := shallowCloneTargetRepo(ctx, targetRepo, checkoutDir); err != nil {
Comment on lines +206 to +207
return err
}

if verbose {
fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Checked out "+targetRepo+" at "+checkoutDir))
}

originalDir, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to read current directory: %w", err)
}
defer func() {
_ = os.Chdir(originalDir)
}()

if err := os.Chdir(checkoutDir); err != nil {
return fmt.Errorf("failed to change directory to checkout %s: %w", checkoutDir, err)
}

if createPR {
if err := PreflightCheckForCreatePR(verbose); err != nil {
return err
}
}

if err := RunUpdateWorkflows(ctx, opts); err != nil {
return err
}

if createPR {
prBody := "This PR updates agentic workflows from their source repositories."
_, err := CreatePRWithChanges("update-workflows", "chore: update workflows",
"Update workflows from source", prBody, verbose)
return err
}
return nil
}

func ensureUpdateTargetRepoGitignore(gitRoot string) (string, error) {
updatesDir := filepath.Join(gitRoot, updateTargetRepoCheckoutDir)
if err := os.MkdirAll(updatesDir, constants.DirPermPublic); err != nil {
return "", fmt.Errorf("failed to create %s: %w", updateTargetRepoCheckoutDir, err)
}

gitignorePath := filepath.Join(updatesDir, ".gitignore")
if _, err := os.Stat(gitignorePath); err == nil {
return updatesDir, nil
} else if !errors.Is(err, os.ErrNotExist) {
return "", fmt.Errorf("failed to stat %s: %w", gitignorePath, err)
}

const gitignoreContent = `# Ignore checked-out repositories used by 'gh aw update --repo'
*

# Keep this file in version control
!.gitignore
`
if err := os.WriteFile(gitignorePath, []byte(gitignoreContent), constants.FilePermSensitive); err != nil {
return "", fmt.Errorf("failed to write %s: %w", gitignorePath, err)
}
return updatesDir, nil
}

func shallowCloneTargetRepo(ctx context.Context, repo, destination string) error {
if err := os.RemoveAll(destination); err != nil {
return fmt.Errorf("failed to clean previous checkout %s: %w", destination, err)
}

cmd := exec.CommandContext(ctx, "gh", "repo", "clone", repo, destination, "--", "--depth=1")
output, err := cmd.CombinedOutput()
if err != nil {
trimmed := strings.TrimSpace(string(output))
if trimmed == "" {
return fmt.Errorf("failed to shallow clone %s: %w", repo, err)
}
return fmt.Errorf("failed to shallow clone %s: %w: %s", repo, err, trimmed)
}
return nil
}

func sanitizeRepoPath(repo string) string {
replacer := strings.NewReplacer("/", "__", "\\", "__", ":", "__", "@", "__")
return replacer.Replace(repo)
}
16 changes: 16 additions & 0 deletions pkg/cli/update_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ func TestUpdateCommand_HelpText(t *testing.T) {
assert.Contains(t, outputStr, "no-merge", "Help should document --no-merge flag")
assert.Contains(t, outputStr, "no-redirect", "Help should document --no-redirect flag")
assert.Contains(t, outputStr, "disable-security-scanner", "Help should document --disable-security-scanner flag")
assert.Contains(t, outputStr, "repo", "Help should document --repo flag")
assert.Contains(t, outputStr, "3-way merge", "Help should explain merge behavior")

// Should reference upgrade for other features
Expand All @@ -236,6 +237,21 @@ func TestUpdateCommand_HelpText(t *testing.T) {
assert.NotContains(t, outputStr, "--dry-run", "Help should not mention removed --dry-run flag")
}

// TestUpdateCommand_RepoFlag verifies that --repo is recognized.
func TestUpdateCommand_RepoFlag(t *testing.T) {
setup := setupUpdateIntegrationTest(t)
defer setup.cleanup()

// Use an invalid repo slug to avoid network calls while still validating flag parsing.
cmd := exec.Command(setup.binaryPath, "update", "--repo", "not-a-valid-slug", "--verbose")
cmd.Dir = setup.tempDir
output, err := cmd.CombinedOutput()
outputStr := string(output)

assert.Error(t, err, "Command should fail for invalid repo slug")
assert.NotContains(t, outputStr, "unknown flag", "The --repo flag should be recognized")
}

// --- Merge Behavior Integration Tests ---

// TestUpdateCommand_MergeIsDefault verifies that merge is the default behavior
Expand Down