From 3f6689e999ca468776a925b2ebe00a978d411b01 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 06:18:35 +0000 Subject: [PATCH 01/11] Initial plan From 73d539c002c98ffd29193debf4386333d382f2c3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 06:45:11 +0000 Subject: [PATCH 02/11] feat: auto-generate side-repo maintenance workflows for SideRepoOps pattern - Detect SideRepoOps patterns from checkout configs with current: true - Generate agentics-maintenance-.yml for each static target repo - Add GH_AW_TARGET_REPO_SLUG support to close_expired_* and create_labels scripts - Include close-expired-entities, apply_safe_outputs, create_labels, validate jobs - Use custom token from checkout config for target repo authentication - Add unit tests for collectSideRepoTargets, sanitizeRepoForFilename, and workflow generation Agent-Logs-Url: https://github.com/github/gh-aw/sessions/dc073fff-9a15-4d12-93e4-a16cdc65fdc9 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../setup/js/close_expired_discussions.cjs | 12 +- actions/setup/js/close_expired_issues.cjs | 12 +- .../setup/js/close_expired_pull_requests.cjs | 12 +- actions/setup/js/create_labels.cjs | 14 +- pkg/workflow/maintenance_workflow.go | 430 +++++++++++++++++- pkg/workflow/maintenance_workflow_test.go | 279 ++++++++++++ 6 files changed, 744 insertions(+), 15 deletions(-) diff --git a/actions/setup/js/close_expired_discussions.cjs b/actions/setup/js/close_expired_discussions.cjs index 0b78b9dbf1e..6b632a9e850 100644 --- a/actions/setup/js/close_expired_discussions.cjs +++ b/actions/setup/js/close_expired_discussions.cjs @@ -90,8 +90,16 @@ async function hasExpirationComment(github, discussionId) { } async function main() { - const owner = context.repo.owner; - const repo = context.repo.repo; + // Resolve owner/repo — use GH_AW_TARGET_REPO_SLUG when set (SideRepoOps pattern). + let owner, repo; + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug && targetRepoSlug.includes("/")) { + [owner, repo] = targetRepoSlug.split("/", 2); + core.info(`Using target repository from GH_AW_TARGET_REPO_SLUG: ${owner}/${repo}`); + } else { + owner = context.repo.owner; + repo = context.repo.repo; + } // Get workflow metadata for footer const { workflowName, workflowId, runUrl } = getWorkflowMetadata(owner, repo); diff --git a/actions/setup/js/close_expired_issues.cjs b/actions/setup/js/close_expired_issues.cjs index 454168ffaae..53c9c51abd8 100644 --- a/actions/setup/js/close_expired_issues.cjs +++ b/actions/setup/js/close_expired_issues.cjs @@ -47,8 +47,16 @@ async function closeIssue(github, owner, repo, issueNumber) { } async function main() { - const owner = context.repo.owner; - const repo = context.repo.repo; + // Resolve owner/repo — use GH_AW_TARGET_REPO_SLUG when set (SideRepoOps pattern). + let owner, repo; + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug && targetRepoSlug.includes("/")) { + [owner, repo] = targetRepoSlug.split("/", 2); + core.info(`Using target repository from GH_AW_TARGET_REPO_SLUG: ${owner}/${repo}`); + } else { + owner = context.repo.owner; + repo = context.repo.repo; + } // Get workflow metadata for footer const { workflowName, workflowId, runUrl } = getWorkflowMetadata(owner, repo); diff --git a/actions/setup/js/close_expired_pull_requests.cjs b/actions/setup/js/close_expired_pull_requests.cjs index db4559fd2bf..655beb15359 100644 --- a/actions/setup/js/close_expired_pull_requests.cjs +++ b/actions/setup/js/close_expired_pull_requests.cjs @@ -46,8 +46,16 @@ async function closePullRequest(github, owner, repo, prNumber) { } async function main() { - const owner = context.repo.owner; - const repo = context.repo.repo; + // Resolve owner/repo — use GH_AW_TARGET_REPO_SLUG when set (SideRepoOps pattern). + let owner, repo; + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug && targetRepoSlug.includes("/")) { + [owner, repo] = targetRepoSlug.split("/", 2); + core.info(`Using target repository from GH_AW_TARGET_REPO_SLUG: ${owner}/${repo}`); + } else { + owner = context.repo.owner; + repo = context.repo.repo; + } // Get workflow metadata for footer const { workflowName, workflowId, runUrl } = getWorkflowMetadata(owner, repo); diff --git a/actions/setup/js/create_labels.cjs b/actions/setup/js/create_labels.cjs index 1e659bf527f..70a174a2056 100644 --- a/actions/setup/js/create_labels.cjs +++ b/actions/setup/js/create_labels.cjs @@ -86,8 +86,18 @@ async function main() { core.info(`Found ${allLabels.size} unique label(s) in safe-outputs: ${[...allLabels].join(", ")}`); - // Fetch all existing labels from the repository - const { owner, repo } = context.repo; + // Fetch all existing labels from the repository. + // When GH_AW_TARGET_REPO_SLUG is set (SideRepoOps pattern), create labels in that + // repository instead of the execution context repository. + let owner, repo; + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug && targetRepoSlug.includes("/")) { + [owner, repo] = targetRepoSlug.split("/", 2); + core.info(`Using target repository from GH_AW_TARGET_REPO_SLUG: ${owner}/${repo}`); + } else { + owner = context.repo.owner; + repo = context.repo.repo; + } let existingLabels; try { existingLabels = await github.paginate(github.rest.issues.listLabelsForRepo, { diff --git a/pkg/workflow/maintenance_workflow.go b/pkg/workflow/maintenance_workflow.go index 5ee22a5483c..c3fe858dcaa 100644 --- a/pkg/workflow/maintenance_workflow.go +++ b/pkg/workflow/maintenance_workflow.go @@ -197,6 +197,14 @@ func GenerateMaintenanceWorkflow(workflowDataList []*WorkflowData, workflowDir s } } + // Get the setup action reference (local or remote based on mode). + // Use the first available WorkflowData's ActionResolver to enable SHA pinning. + // Computed early so it is available in the !hasExpires path for side-repo workflows. + var resolver ActionSHAResolver + if len(workflowDataList) > 0 && workflowDataList[0].ActionResolver != nil { + resolver = workflowDataList[0].ActionResolver + } + if !hasExpires { maintenanceLog.Print("No workflows use expires field, skipping maintenance workflow generation") @@ -210,7 +218,9 @@ func GenerateMaintenanceWorkflow(workflowDataList []*WorkflowData, workflowDir s maintenanceLog.Print("Maintenance workflow deleted successfully") } - return nil + // Even without expires, side-repo targets still need maintenance workflows + // for safe_outputs, create_labels, and validate operations. + return generateAllSideRepoMaintenanceWorkflows(workflowDataList, workflowDir, version, actionMode, actionTag, runsOnValue, resolver, false, 0) } maintenanceLog.Printf("Generating maintenance workflow for expired discussions, issues, and pull requests (minimum expires: %d hours)", minExpires) @@ -302,12 +312,6 @@ jobs: steps: `) - // Get the setup action reference (local or remote based on mode) - // Use the first available WorkflowData's ActionResolver to enable SHA pinning - var resolver ActionSHAResolver - if len(workflowDataList) > 0 && workflowDataList[0].ActionResolver != nil { - resolver = workflowDataList[0].ActionResolver - } setupActionRef := ResolveSetupActionReference(actionMode, version, actionTag, resolver) // Add checkout step only in dev/script mode (for local action paths) @@ -719,6 +723,418 @@ jobs: } maintenanceLog.Print("Maintenance workflow generated successfully") + + // Generate side-repo maintenance workflows for any SideRepoOps targets detected. + if err := generateAllSideRepoMaintenanceWorkflows(workflowDataList, workflowDir, version, actionMode, actionTag, runsOnValue, resolver, hasExpires, minExpiresDays); err != nil { + return err + } + + return nil +} + +// SideRepoTarget represents a target repository inferred from a checkout block +// with current: true in a compiled workflow. It is used to generate a +// side-repo-specific agentics-maintenance workflow. +type SideRepoTarget struct { + // Repository is the static owner/repo slug of the target (e.g. "my-org/main-repo"). + // Expression-based repositories (containing "${{") are excluded. + Repository string + + // GitHubToken is the token expression used to authenticate against the target + // repository, e.g. "${{ secrets.GH_AW_MAIN_REPO_TOKEN }}". Empty when the + // checkout config does not specify a custom token. + GitHubToken string +} + +// collectSideRepoTargets scans all compiled workflow data and returns the unique +// SideRepoTarget entries inferred from checkout blocks with current: true. +// Only checkouts with a static (non-expression) repository string are included. +func collectSideRepoTargets(workflowDataList []*WorkflowData) []SideRepoTarget { + seen := make(map[string]bool) + var targets []SideRepoTarget + for _, wd := range workflowDataList { + for _, checkout := range wd.CheckoutConfigs { + if !checkout.Current { + continue + } + repo := checkout.Repository + if repo == "" || strings.Contains(repo, "${{") { + // Skip empty repositories and expression-based (dynamic) ones. + continue + } + if seen[repo] { + continue + } + seen[repo] = true + targets = append(targets, SideRepoTarget{ + Repository: repo, + GitHubToken: checkout.GitHubToken, + }) + } + } + maintenanceLog.Printf("Detected %d side-repo target(s) from checkout configs", len(targets)) + return targets +} + +// sanitizeRepoForFilename converts an "owner/repo" slug into a string safe for +// use as part of a filename, replacing "/" with "-" and any remaining +// non-alphanumeric characters (except "-", "_", ".") with "-". +func sanitizeRepoForFilename(repo string) string { + var sb strings.Builder + for _, r := range strings.ReplaceAll(repo, "/", "-") { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' { + sb.WriteRune(r) + } else { + sb.WriteRune('-') + } + } + return sb.String() +} + +// effectiveSideRepoToken returns the GitHub token expression to use for the +// side-repo maintenance workflow. It prefers the token from the checkout config; +// when none is set it falls back to a conventional secret name. +func effectiveSideRepoToken(checkout SideRepoTarget) string { + if checkout.GitHubToken != "" { + return checkout.GitHubToken + } + return "${{ secrets.GH_AW_GITHUB_TOKEN }}" +} + +// generateAllSideRepoMaintenanceWorkflows detects SideRepoOps targets and +// generates a per-target maintenance workflow for each unique static repository. +func generateAllSideRepoMaintenanceWorkflows( + workflowDataList []*WorkflowData, + workflowDir string, + version string, + actionMode ActionMode, + actionTag string, + runsOnValue string, + resolver ActionSHAResolver, + hasExpires bool, + minExpiresDays int, +) error { + targets := collectSideRepoTargets(workflowDataList) + if len(targets) == 0 { + return nil + } + + // Track which side-repo maintenance files we (re-)generate so we can later + // identify and clean up stale files from previous runs if the target repos change. + generatedFiles := make(map[string]bool) + + for _, target := range targets { + slug := sanitizeRepoForFilename(target.Repository) + filename := "agentics-maintenance-" + slug + ".yml" + generatedFiles[filename] = true + outPath := filepath.Join(workflowDir, filename) + + maintenanceLog.Printf("Generating side-repo maintenance workflow: %s → %s", target.Repository, filename) + if err := generateSideRepoMaintenanceWorkflow(target, outPath, version, actionMode, actionTag, runsOnValue, resolver, hasExpires, minExpiresDays); err != nil { + return fmt.Errorf("failed to generate side-repo maintenance workflow for %s: %w", target.Repository, err) + } + fmt.Fprintf(os.Stderr, " Generated side-repo maintenance workflow: %s\n", filename) + } + + return nil +} + +// generateSideRepoMaintenanceWorkflow generates a workflow_call-based maintenance +// workflow that targets an external repository detected via the SideRepoOps pattern. +// The generated workflow mirrors agentics-maintenance.yml but authenticates against +// the target repository using the token from the checkout config and sets +// GH_AW_TARGET_REPO_SLUG for all cross-repo operations. +func generateSideRepoMaintenanceWorkflow( + target SideRepoTarget, + outPath string, + version string, + actionMode ActionMode, + actionTag string, + runsOnValue string, + resolver ActionSHAResolver, + hasExpires bool, + minExpiresDays int, +) error { + token := effectiveSideRepoToken(target) + repoSlug := target.Repository + + var yaml strings.Builder + + customInstructions := `Alternative regeneration methods: + make recompile + +Or use the gh-aw CLI directly: + ./gh-aw compile --validate --verbose + +This workflow is generated for the SideRepoOps target repository "` + repoSlug + `". +It is invoked via workflow_call from the hosting repository's agentics-maintenance.yml +and runs maintenance operations against the target repository.` + + header := GenerateWorkflowHeader("", "pkg/workflow/maintenance_workflow.go", customInstructions) + yaml.WriteString(header) + + yaml.WriteString(`name: Agentic Maintenance (` + repoSlug + `) + +on: + workflow_dispatch: + inputs: + operation: + description: 'Optional maintenance operation to run' + required: false + type: choice + default: '' + options: + - '' + - 'safe_outputs' + - 'create_labels' + - 'validate' + run_url: + description: 'Run URL or run ID to replay safe outputs from (e.g. https://github.com/owner/repo/actions/runs/12345 or 12345). Required when operation is safe_outputs.' + required: false + type: string + default: '' + workflow_call: + inputs: + operation: + description: 'Optional maintenance operation to run (safe_outputs, create_labels, validate)' + required: false + type: string + default: '' + run_url: + description: 'Run URL or run ID to replay safe outputs from (e.g. https://github.com/owner/repo/actions/runs/12345 or 12345). Required when operation is safe_outputs.' + required: false + type: string + default: '' + outputs: + applied_run_url: + description: 'The run URL that safe outputs were applied from' + value: ${{ jobs.apply_safe_outputs.outputs.run_url }} + +permissions: {} + +jobs: +`) + + setupActionRef := ResolveSetupActionReference(actionMode, version, actionTag, resolver) + + // Add close-expired-entities job only when any workflow uses expires. + if hasExpires { + var cronSchedule, scheduleDesc string + if minExpiresDays > 0 { + cronSchedule, scheduleDesc = generateMaintenanceCron(minExpiresDays) + } else { + cronSchedule, scheduleDesc = "37 0 * * *", "Daily" + } + + closeExpiredCondition := buildNotForkAndScheduled() + yaml.WriteString(` close-expired-entities: + if: ${{ ` + RenderCondition(closeExpiredCondition) + ` }} + runs-on: ` + runsOnValue + ` + permissions: + discussions: write + issues: write + pull-requests: write + # Runs on schedule: ` + cronSchedule + ` (` + scheduleDesc + `) + steps: +`) + + if actionMode == ActionModeDev || actionMode == ActionModeScript { + yaml.WriteString(" - name: Checkout actions folder\n") + yaml.WriteString(" uses: " + GetActionPin("actions/checkout") + "\n") + yaml.WriteString(" with:\n") + yaml.WriteString(" sparse-checkout: |\n") + yaml.WriteString(" actions\n") + yaml.WriteString(" persist-credentials: false\n\n") + } + + yaml.WriteString(` - name: Setup Scripts + uses: ` + setupActionRef + ` + with: + destination: ${{ runner.temp }}/gh-aw/actions + + - name: Close expired discussions + uses: ` + GetActionPin("actions/github-script") + ` + env: + GH_AW_TARGET_REPO_SLUG: "` + repoSlug + `" + with: + github-token: ` + token + ` + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/close_expired_discussions.cjs'); + await main(); + + - name: Close expired issues + uses: ` + GetActionPin("actions/github-script") + ` + env: + GH_AW_TARGET_REPO_SLUG: "` + repoSlug + `" + with: + github-token: ` + token + ` + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/close_expired_issues.cjs'); + await main(); + + - name: Close expired pull requests + uses: ` + GetActionPin("actions/github-script") + ` + env: + GH_AW_TARGET_REPO_SLUG: "` + repoSlug + `" + with: + github-token: ` + token + ` + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/close_expired_pull_requests.cjs'); + await main(); +`) + } + + // Add apply_safe_outputs job for workflow_dispatch/workflow_call with operation == 'safe_outputs' + yaml.WriteString(` + apply_safe_outputs: + if: ${{ ` + RenderCondition(buildDispatchOperationCondition("safe_outputs")) + ` }} + runs-on: ` + runsOnValue + ` + permissions: + actions: read + contents: write + discussions: write + issues: write + pull-requests: write + outputs: + run_url: ${{ steps.record.outputs.run_url }} + steps: + - name: Setup Scripts + uses: ` + setupActionRef + ` + with: + destination: ${{ runner.temp }}/gh-aw/actions + + - name: Check admin/maintainer permissions + uses: ` + GetActionPin("actions/github-script") + ` + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_team_member.cjs'); + await main(); + + - name: Apply Safe Outputs + uses: ` + GetActionPin("actions/github-script") + ` + env: + GH_TOKEN: ` + token + ` + GH_AW_RUN_URL: ${{ inputs.run_url }} + GH_AW_TARGET_REPO_SLUG: "` + repoSlug + `" + with: + github-token: ` + token + ` + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/apply_safe_outputs_replay.cjs'); + await main(); + + - name: Record outputs + id: record + run: echo "run_url=${{ inputs.run_url }}" >> "$GITHUB_OUTPUT" +`) + + // Add create_labels job for workflow_dispatch/workflow_call with operation == 'create_labels' + yaml.WriteString(` + create_labels: + if: ${{ ` + RenderCondition(buildDispatchOperationCondition("create_labels")) + ` }} + runs-on: ` + runsOnValue + ` + permissions: + contents: read + issues: write + steps: + - name: Checkout repository + uses: ` + GetActionPin("actions/checkout") + ` + with: + persist-credentials: false + + - name: Setup Scripts + uses: ` + setupActionRef + ` + with: + destination: ${{ runner.temp }}/gh-aw/actions + + - name: Check admin/maintainer permissions + uses: ` + GetActionPin("actions/github-script") + ` + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_team_member.cjs'); + await main(); + +`) + + yaml.WriteString(generateInstallCLISteps(actionMode, version, actionTag, resolver)) + yaml.WriteString(` - name: Create missing labels in target repository + uses: ` + GetActionPin("actions/github-script") + ` + env: + GH_AW_CMD_PREFIX: ` + getCLICmdPrefix(actionMode) + ` + GH_AW_TARGET_REPO_SLUG: "` + repoSlug + `" + with: + github-token: ` + token + ` + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/create_labels.cjs'); + await main(); +`) + + // Add validate_workflows job for workflow_dispatch/workflow_call with operation == 'validate' + validateRunsOnValue := FormatRunsOn(nil, "ubuntu-latest") + yaml.WriteString(` + validate_workflows: + if: ${{ ` + RenderCondition(buildDispatchOperationCondition("validate")) + ` }} + runs-on: ` + validateRunsOnValue + ` + permissions: + contents: read + issues: write + steps: + - name: Checkout repository + uses: ` + GetActionPin("actions/checkout") + ` + with: + persist-credentials: false + + - name: Setup Scripts + uses: ` + setupActionRef + ` + with: + destination: ${{ runner.temp }}/gh-aw/actions + + - name: Check admin/maintainer permissions + uses: ` + GetActionPin("actions/github-script") + ` + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_team_member.cjs'); + await main(); + +`) + + yaml.WriteString(generateInstallCLISteps(actionMode, version, actionTag, resolver)) + yaml.WriteString(` - name: Validate workflows and file issue on findings + uses: ` + GetActionPin("actions/github-script") + ` + env: + GH_AW_CMD_PREFIX: ` + getCLICmdPrefix(actionMode) + ` + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/run_validate_workflows.cjs'); + await main(); +`) + + content := yaml.String() + maintenanceLog.Printf("Writing side-repo maintenance workflow to %s", outPath) + if err := os.WriteFile(outPath, []byte(content), 0644); err != nil { + return fmt.Errorf("failed to write side-repo maintenance workflow: %w", err) + } return nil } diff --git a/pkg/workflow/maintenance_workflow_test.go b/pkg/workflow/maintenance_workflow_test.go index 912ff5ffdd4..5005c26c0eb 100644 --- a/pkg/workflow/maintenance_workflow_test.go +++ b/pkg/workflow/maintenance_workflow_test.go @@ -827,3 +827,282 @@ func TestGenerateMaintenanceWorkflow_RepoConfig(t *testing.T) { } }) } + +func TestCollectSideRepoTargets(t *testing.T) { + tests := []struct { + name string + workflows []*WorkflowData + expectedRepos []string + }{ + { + name: "no workflows returns empty", + workflows: nil, + expectedRepos: nil, + }, + { + name: "workflow without checkout returns empty", + workflows: []*WorkflowData{ + {Name: "wf", CheckoutConfigs: nil}, + }, + expectedRepos: nil, + }, + { + name: "checkout without current:true is ignored", + workflows: []*WorkflowData{ + {Name: "wf", CheckoutConfigs: []*CheckoutConfig{ + {Repository: "org/repo", Current: false}, + }}, + }, + expectedRepos: nil, + }, + { + name: "checkout with current:true and static repo is detected", + workflows: []*WorkflowData{ + {Name: "wf", CheckoutConfigs: []*CheckoutConfig{ + {Repository: "my-org/main-repo", Current: true, GitHubToken: "${{ secrets.GH_AW_MAIN_REPO_TOKEN }}"}, + }}, + }, + expectedRepos: []string{"my-org/main-repo"}, + }, + { + name: "expression-based repository is skipped", + workflows: []*WorkflowData{ + {Name: "wf", CheckoutConfigs: []*CheckoutConfig{ + {Repository: "${{ inputs.target_repo }}", Current: true}, + }}, + }, + expectedRepos: nil, + }, + { + name: "empty repository is skipped", + workflows: []*WorkflowData{ + {Name: "wf", CheckoutConfigs: []*CheckoutConfig{ + {Repository: "", Current: true}, + }}, + }, + expectedRepos: nil, + }, + { + name: "duplicate repos across workflows are deduplicated", + workflows: []*WorkflowData{ + {Name: "wf1", CheckoutConfigs: []*CheckoutConfig{ + {Repository: "my-org/main-repo", Current: true}, + }}, + {Name: "wf2", CheckoutConfigs: []*CheckoutConfig{ + {Repository: "my-org/main-repo", Current: true}, + }}, + }, + expectedRepos: []string{"my-org/main-repo"}, + }, + { + name: "multiple distinct repos are all detected", + workflows: []*WorkflowData{ + {Name: "wf1", CheckoutConfigs: []*CheckoutConfig{ + {Repository: "org/repo-a", Current: true}, + }}, + {Name: "wf2", CheckoutConfigs: []*CheckoutConfig{ + {Repository: "org/repo-b", Current: true}, + }}, + }, + expectedRepos: []string{"org/repo-a", "org/repo-b"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + targets := collectSideRepoTargets(tt.workflows) + + var got []string + for _, tgt := range targets { + got = append(got, tgt.Repository) + } + + if len(got) != len(tt.expectedRepos) { + t.Errorf("expected %d targets, got %d: %v", len(tt.expectedRepos), len(got), got) + return + } + for i, repo := range tt.expectedRepos { + if got[i] != repo { + t.Errorf("target[%d]: expected %q, got %q", i, repo, got[i]) + } + } + }) + } +} + +func TestSanitizeRepoForFilename(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"my-org/main-repo", "my-org-main-repo"}, + {"org/repo", "org-repo"}, + {"my.org/my_repo", "my.org-my_repo"}, + {"owner/repo-name.git", "owner-repo-name.git"}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := sanitizeRepoForFilename(tt.input) + if got != tt.expected { + t.Errorf("sanitizeRepoForFilename(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} + +func TestGenerateSideRepoMaintenanceWorkflow(t *testing.T) { + t.Run("generates file for static side-repo target", func(t *testing.T) { + tmpDir := t.TempDir() + workflowDataList := []*WorkflowData{ + { + Name: "side-repo-workflow", + CheckoutConfigs: []*CheckoutConfig{ + { + Repository: "my-org/target-repo", + Current: true, + GitHubToken: "${{ secrets.GH_AW_TARGET_TOKEN }}", + }, + }, + SafeOutputs: &SafeOutputsConfig{ + CreateIssues: &CreateIssuesConfig{ + Expires: 48, + }, + }, + }, + } + + err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + // The standard hosting-repo maintenance should be generated (has expires). + if _, statErr := os.Stat(filepath.Join(tmpDir, "agentics-maintenance.yml")); statErr != nil { + t.Errorf("Expected standard agentics-maintenance.yml to exist") + } + + // The side-repo maintenance should also be generated. + sideFile := filepath.Join(tmpDir, "agentics-maintenance-my-org-target-repo.yml") + content, err := os.ReadFile(sideFile) + if err != nil { + t.Fatalf("Expected side-repo maintenance file %s to exist: %v", sideFile, err) + } + + contentStr := string(content) + if !strings.Contains(contentStr, "my-org/target-repo") { + t.Errorf("Side-repo maintenance should reference target repo, got:\n%s", contentStr[:200]) + } + if !strings.Contains(contentStr, "${{ secrets.GH_AW_TARGET_TOKEN }}") { + t.Errorf("Side-repo maintenance should use custom token, got:\n%s", contentStr[:200]) + } + if !strings.Contains(contentStr, "GH_AW_TARGET_REPO_SLUG") { + t.Errorf("Side-repo maintenance should set GH_AW_TARGET_REPO_SLUG, got:\n%s", contentStr[:200]) + } + if !strings.Contains(contentStr, "workflow_call") { + t.Errorf("Side-repo maintenance should have workflow_call trigger, got:\n%s", contentStr[:200]) + } + if !strings.Contains(contentStr, "apply_safe_outputs") { + t.Errorf("Side-repo maintenance should include apply_safe_outputs job, got:\n%s", contentStr[:200]) + } + if !strings.Contains(contentStr, "create_labels") { + t.Errorf("Side-repo maintenance should include create_labels job, got:\n%s", contentStr[:200]) + } + }) + + t.Run("no side-repo file generated when no current checkout", func(t *testing.T) { + tmpDir := t.TempDir() + workflowDataList := []*WorkflowData{ + { + Name: "normal-workflow", + SafeOutputs: &SafeOutputsConfig{ + CreateIssues: &CreateIssuesConfig{ + Expires: 48, + }, + }, + }, + } + + err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + // Only standard maintenance should exist. + entries, _ := os.ReadDir(tmpDir) + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), "agentics-maintenance-") { + t.Errorf("Unexpected side-repo maintenance file: %s", entry.Name()) + } + } + }) + + t.Run("side-repo generated without expires uses safe_outputs and create_labels only", func(t *testing.T) { + tmpDir := t.TempDir() + workflowDataList := []*WorkflowData{ + { + Name: "side-repo-no-expires", + CheckoutConfigs: []*CheckoutConfig{ + { + Repository: "org/no-expires-repo", + Current: true, + }, + }, + // No expires configured — standard maintenance won't be generated. + }, + } + + err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + // Standard maintenance should NOT be generated (no expires). + if _, statErr := os.Stat(filepath.Join(tmpDir, "agentics-maintenance.yml")); !os.IsNotExist(statErr) { + t.Errorf("Standard agentics-maintenance.yml should not exist when no expires") + } + + // Side-repo maintenance should be generated. + sideFile := filepath.Join(tmpDir, "agentics-maintenance-org-no-expires-repo.yml") + content, err := os.ReadFile(sideFile) + if err != nil { + t.Fatalf("Expected side-repo maintenance file to exist: %v", err) + } + contentStr := string(content) + + // Should use fallback token when none specified. + if !strings.Contains(contentStr, "GH_AW_GITHUB_TOKEN") { + t.Errorf("Side-repo maintenance should use fallback token GH_AW_GITHUB_TOKEN, got:\n%s", contentStr[:200]) + } + // Should NOT include close-expired-entities (no expires). + if strings.Contains(contentStr, "close-expired-entities") { + t.Errorf("Side-repo maintenance should NOT include close-expired-entities when no expires, got:\n%s", contentStr[:300]) + } + }) + + t.Run("expression-based repository does not generate side-repo maintenance", func(t *testing.T) { + tmpDir := t.TempDir() + workflowDataList := []*WorkflowData{ + { + Name: "dynamic-repo-workflow", + CheckoutConfigs: []*CheckoutConfig{ + { + Repository: "${{ inputs.target_repo }}", + Current: true, + }, + }, + }, + } + + err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + entries, _ := os.ReadDir(tmpDir) + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), "agentics-maintenance-") { + t.Errorf("Unexpected side-repo maintenance file for dynamic repo: %s", entry.Name()) + } + } + }) +} From f97856b18a55354eefe44874d9a17141070697b4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 06:48:49 +0000 Subject: [PATCH 03/11] fix: use const with ternary destructuring for owner/repo resolution in JS scripts Agent-Logs-Url: https://github.com/github/gh-aw/sessions/dc073fff-9a15-4d12-93e4-a16cdc65fdc9 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/close_expired_discussions.cjs | 6 +----- actions/setup/js/close_expired_issues.cjs | 6 +----- actions/setup/js/close_expired_pull_requests.cjs | 6 +----- actions/setup/js/create_labels.cjs | 6 +----- 4 files changed, 4 insertions(+), 20 deletions(-) diff --git a/actions/setup/js/close_expired_discussions.cjs b/actions/setup/js/close_expired_discussions.cjs index 6b632a9e850..3d0bb520e13 100644 --- a/actions/setup/js/close_expired_discussions.cjs +++ b/actions/setup/js/close_expired_discussions.cjs @@ -91,14 +91,10 @@ async function hasExpirationComment(github, discussionId) { async function main() { // Resolve owner/repo — use GH_AW_TARGET_REPO_SLUG when set (SideRepoOps pattern). - let owner, repo; const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + const [owner, repo] = targetRepoSlug && targetRepoSlug.includes("/") ? targetRepoSlug.split("/", 2) : [context.repo.owner, context.repo.repo]; if (targetRepoSlug && targetRepoSlug.includes("/")) { - [owner, repo] = targetRepoSlug.split("/", 2); core.info(`Using target repository from GH_AW_TARGET_REPO_SLUG: ${owner}/${repo}`); - } else { - owner = context.repo.owner; - repo = context.repo.repo; } // Get workflow metadata for footer diff --git a/actions/setup/js/close_expired_issues.cjs b/actions/setup/js/close_expired_issues.cjs index 53c9c51abd8..3bafe58349c 100644 --- a/actions/setup/js/close_expired_issues.cjs +++ b/actions/setup/js/close_expired_issues.cjs @@ -48,14 +48,10 @@ async function closeIssue(github, owner, repo, issueNumber) { async function main() { // Resolve owner/repo — use GH_AW_TARGET_REPO_SLUG when set (SideRepoOps pattern). - let owner, repo; const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + const [owner, repo] = targetRepoSlug && targetRepoSlug.includes("/") ? targetRepoSlug.split("/", 2) : [context.repo.owner, context.repo.repo]; if (targetRepoSlug && targetRepoSlug.includes("/")) { - [owner, repo] = targetRepoSlug.split("/", 2); core.info(`Using target repository from GH_AW_TARGET_REPO_SLUG: ${owner}/${repo}`); - } else { - owner = context.repo.owner; - repo = context.repo.repo; } // Get workflow metadata for footer diff --git a/actions/setup/js/close_expired_pull_requests.cjs b/actions/setup/js/close_expired_pull_requests.cjs index 655beb15359..1c81887ded2 100644 --- a/actions/setup/js/close_expired_pull_requests.cjs +++ b/actions/setup/js/close_expired_pull_requests.cjs @@ -47,14 +47,10 @@ async function closePullRequest(github, owner, repo, prNumber) { async function main() { // Resolve owner/repo — use GH_AW_TARGET_REPO_SLUG when set (SideRepoOps pattern). - let owner, repo; const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + const [owner, repo] = targetRepoSlug && targetRepoSlug.includes("/") ? targetRepoSlug.split("/", 2) : [context.repo.owner, context.repo.repo]; if (targetRepoSlug && targetRepoSlug.includes("/")) { - [owner, repo] = targetRepoSlug.split("/", 2); core.info(`Using target repository from GH_AW_TARGET_REPO_SLUG: ${owner}/${repo}`); - } else { - owner = context.repo.owner; - repo = context.repo.repo; } // Get workflow metadata for footer diff --git a/actions/setup/js/create_labels.cjs b/actions/setup/js/create_labels.cjs index 70a174a2056..adcdc28e8d0 100644 --- a/actions/setup/js/create_labels.cjs +++ b/actions/setup/js/create_labels.cjs @@ -89,14 +89,10 @@ async function main() { // Fetch all existing labels from the repository. // When GH_AW_TARGET_REPO_SLUG is set (SideRepoOps pattern), create labels in that // repository instead of the execution context repository. - let owner, repo; const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + const [owner, repo] = targetRepoSlug && targetRepoSlug.includes("/") ? targetRepoSlug.split("/", 2) : [context.repo.owner, context.repo.repo]; if (targetRepoSlug && targetRepoSlug.includes("/")) { - [owner, repo] = targetRepoSlug.split("/", 2); core.info(`Using target repository from GH_AW_TARGET_REPO_SLUG: ${owner}/${repo}`); - } else { - owner = context.repo.owner; - repo = context.repo.repo; } let existingLabels; try { From 77616ab532c0faeb51028ef9b2508395c3cdd187 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 06:54:54 +0000 Subject: [PATCH 04/11] fix: extract isCrossRepo variable to avoid duplicate condition checks in JS scripts Agent-Logs-Url: https://github.com/github/gh-aw/sessions/dc073fff-9a15-4d12-93e4-a16cdc65fdc9 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/close_expired_discussions.cjs | 5 +++-- actions/setup/js/close_expired_issues.cjs | 5 +++-- actions/setup/js/close_expired_pull_requests.cjs | 5 +++-- actions/setup/js/create_labels.cjs | 5 +++-- pkg/workflow/maintenance_workflow_test.go | 16 ++++++++-------- 5 files changed, 20 insertions(+), 16 deletions(-) diff --git a/actions/setup/js/close_expired_discussions.cjs b/actions/setup/js/close_expired_discussions.cjs index 3d0bb520e13..618fe8166a7 100644 --- a/actions/setup/js/close_expired_discussions.cjs +++ b/actions/setup/js/close_expired_discussions.cjs @@ -92,8 +92,9 @@ async function hasExpirationComment(github, discussionId) { async function main() { // Resolve owner/repo — use GH_AW_TARGET_REPO_SLUG when set (SideRepoOps pattern). const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - const [owner, repo] = targetRepoSlug && targetRepoSlug.includes("/") ? targetRepoSlug.split("/", 2) : [context.repo.owner, context.repo.repo]; - if (targetRepoSlug && targetRepoSlug.includes("/")) { + const isCrossRepo = Boolean(targetRepoSlug && targetRepoSlug.includes("/")); + const [owner, repo] = isCrossRepo ? targetRepoSlug.split("/", 2) : [context.repo.owner, context.repo.repo]; + if (isCrossRepo) { core.info(`Using target repository from GH_AW_TARGET_REPO_SLUG: ${owner}/${repo}`); } diff --git a/actions/setup/js/close_expired_issues.cjs b/actions/setup/js/close_expired_issues.cjs index 3bafe58349c..5f18377d65a 100644 --- a/actions/setup/js/close_expired_issues.cjs +++ b/actions/setup/js/close_expired_issues.cjs @@ -49,8 +49,9 @@ async function closeIssue(github, owner, repo, issueNumber) { async function main() { // Resolve owner/repo — use GH_AW_TARGET_REPO_SLUG when set (SideRepoOps pattern). const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - const [owner, repo] = targetRepoSlug && targetRepoSlug.includes("/") ? targetRepoSlug.split("/", 2) : [context.repo.owner, context.repo.repo]; - if (targetRepoSlug && targetRepoSlug.includes("/")) { + const isCrossRepo = Boolean(targetRepoSlug && targetRepoSlug.includes("/")); + const [owner, repo] = isCrossRepo ? targetRepoSlug.split("/", 2) : [context.repo.owner, context.repo.repo]; + if (isCrossRepo) { core.info(`Using target repository from GH_AW_TARGET_REPO_SLUG: ${owner}/${repo}`); } diff --git a/actions/setup/js/close_expired_pull_requests.cjs b/actions/setup/js/close_expired_pull_requests.cjs index 1c81887ded2..9c0dd116648 100644 --- a/actions/setup/js/close_expired_pull_requests.cjs +++ b/actions/setup/js/close_expired_pull_requests.cjs @@ -48,8 +48,9 @@ async function closePullRequest(github, owner, repo, prNumber) { async function main() { // Resolve owner/repo — use GH_AW_TARGET_REPO_SLUG when set (SideRepoOps pattern). const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - const [owner, repo] = targetRepoSlug && targetRepoSlug.includes("/") ? targetRepoSlug.split("/", 2) : [context.repo.owner, context.repo.repo]; - if (targetRepoSlug && targetRepoSlug.includes("/")) { + const isCrossRepo = Boolean(targetRepoSlug && targetRepoSlug.includes("/")); + const [owner, repo] = isCrossRepo ? targetRepoSlug.split("/", 2) : [context.repo.owner, context.repo.repo]; + if (isCrossRepo) { core.info(`Using target repository from GH_AW_TARGET_REPO_SLUG: ${owner}/${repo}`); } diff --git a/actions/setup/js/create_labels.cjs b/actions/setup/js/create_labels.cjs index adcdc28e8d0..0778906ba78 100644 --- a/actions/setup/js/create_labels.cjs +++ b/actions/setup/js/create_labels.cjs @@ -90,8 +90,9 @@ async function main() { // When GH_AW_TARGET_REPO_SLUG is set (SideRepoOps pattern), create labels in that // repository instead of the execution context repository. const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - const [owner, repo] = targetRepoSlug && targetRepoSlug.includes("/") ? targetRepoSlug.split("/", 2) : [context.repo.owner, context.repo.repo]; - if (targetRepoSlug && targetRepoSlug.includes("/")) { + const isCrossRepo = Boolean(targetRepoSlug && targetRepoSlug.includes("/")); + const [owner, repo] = isCrossRepo ? targetRepoSlug.split("/", 2) : [context.repo.owner, context.repo.repo]; + if (isCrossRepo) { core.info(`Using target repository from GH_AW_TARGET_REPO_SLUG: ${owner}/${repo}`); } let existingLabels; diff --git a/pkg/workflow/maintenance_workflow_test.go b/pkg/workflow/maintenance_workflow_test.go index 5005c26c0eb..a1c3b07c1b9 100644 --- a/pkg/workflow/maintenance_workflow_test.go +++ b/pkg/workflow/maintenance_workflow_test.go @@ -990,22 +990,22 @@ func TestGenerateSideRepoMaintenanceWorkflow(t *testing.T) { contentStr := string(content) if !strings.Contains(contentStr, "my-org/target-repo") { - t.Errorf("Side-repo maintenance should reference target repo, got:\n%s", contentStr[:200]) + t.Errorf("Side-repo maintenance should reference target repo, got content length %d", len(contentStr)) } if !strings.Contains(contentStr, "${{ secrets.GH_AW_TARGET_TOKEN }}") { - t.Errorf("Side-repo maintenance should use custom token, got:\n%s", contentStr[:200]) + t.Errorf("Side-repo maintenance should use custom token, got content length %d", len(contentStr)) } if !strings.Contains(contentStr, "GH_AW_TARGET_REPO_SLUG") { - t.Errorf("Side-repo maintenance should set GH_AW_TARGET_REPO_SLUG, got:\n%s", contentStr[:200]) + t.Errorf("Side-repo maintenance should set GH_AW_TARGET_REPO_SLUG, got content length %d", len(contentStr)) } if !strings.Contains(contentStr, "workflow_call") { - t.Errorf("Side-repo maintenance should have workflow_call trigger, got:\n%s", contentStr[:200]) + t.Errorf("Side-repo maintenance should have workflow_call trigger, got content length %d", len(contentStr)) } if !strings.Contains(contentStr, "apply_safe_outputs") { - t.Errorf("Side-repo maintenance should include apply_safe_outputs job, got:\n%s", contentStr[:200]) + t.Errorf("Side-repo maintenance should include apply_safe_outputs job, got content length %d", len(contentStr)) } if !strings.Contains(contentStr, "create_labels") { - t.Errorf("Side-repo maintenance should include create_labels job, got:\n%s", contentStr[:200]) + t.Errorf("Side-repo maintenance should include create_labels job, got content length %d", len(contentStr)) } }) @@ -1071,11 +1071,11 @@ func TestGenerateSideRepoMaintenanceWorkflow(t *testing.T) { // Should use fallback token when none specified. if !strings.Contains(contentStr, "GH_AW_GITHUB_TOKEN") { - t.Errorf("Side-repo maintenance should use fallback token GH_AW_GITHUB_TOKEN, got:\n%s", contentStr[:200]) + t.Errorf("Side-repo maintenance should use fallback token GH_AW_GITHUB_TOKEN, got content length %d", len(contentStr)) } // Should NOT include close-expired-entities (no expires). if strings.Contains(contentStr, "close-expired-entities") { - t.Errorf("Side-repo maintenance should NOT include close-expired-entities when no expires, got:\n%s", contentStr[:300]) + t.Errorf("Side-repo maintenance should NOT include close-expired-entities when no expires, got content length %d", len(contentStr)) } }) From 7d54b11026fb509d4c18828891e1a427a135c792 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 07:00:48 +0000 Subject: [PATCH 05/11] fix: use regex validation for GH_AW_TARGET_REPO_SLUG format and order-independent test comparison Agent-Logs-Url: https://github.com/github/gh-aw/sessions/dc073fff-9a15-4d12-93e4-a16cdc65fdc9 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/close_expired_discussions.cjs | 3 ++- actions/setup/js/close_expired_issues.cjs | 3 ++- actions/setup/js/close_expired_pull_requests.cjs | 3 ++- actions/setup/js/create_labels.cjs | 3 ++- pkg/workflow/maintenance_workflow_test.go | 11 ++++++++--- 5 files changed, 16 insertions(+), 7 deletions(-) diff --git a/actions/setup/js/close_expired_discussions.cjs b/actions/setup/js/close_expired_discussions.cjs index 618fe8166a7..490efb63818 100644 --- a/actions/setup/js/close_expired_discussions.cjs +++ b/actions/setup/js/close_expired_discussions.cjs @@ -91,8 +91,9 @@ async function hasExpirationComment(github, discussionId) { async function main() { // Resolve owner/repo — use GH_AW_TARGET_REPO_SLUG when set (SideRepoOps pattern). + // The slug must be in exact "owner/repo" format (one slash, non-empty on both sides). const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - const isCrossRepo = Boolean(targetRepoSlug && targetRepoSlug.includes("/")); + const isCrossRepo = Boolean(targetRepoSlug && /^[^/]+\/[^/]+$/.test(targetRepoSlug)); const [owner, repo] = isCrossRepo ? targetRepoSlug.split("/", 2) : [context.repo.owner, context.repo.repo]; if (isCrossRepo) { core.info(`Using target repository from GH_AW_TARGET_REPO_SLUG: ${owner}/${repo}`); diff --git a/actions/setup/js/close_expired_issues.cjs b/actions/setup/js/close_expired_issues.cjs index 5f18377d65a..c1218554348 100644 --- a/actions/setup/js/close_expired_issues.cjs +++ b/actions/setup/js/close_expired_issues.cjs @@ -48,8 +48,9 @@ async function closeIssue(github, owner, repo, issueNumber) { async function main() { // Resolve owner/repo — use GH_AW_TARGET_REPO_SLUG when set (SideRepoOps pattern). + // The slug must be in exact "owner/repo" format (one slash, non-empty on both sides). const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - const isCrossRepo = Boolean(targetRepoSlug && targetRepoSlug.includes("/")); + const isCrossRepo = Boolean(targetRepoSlug && /^[^/]+\/[^/]+$/.test(targetRepoSlug)); const [owner, repo] = isCrossRepo ? targetRepoSlug.split("/", 2) : [context.repo.owner, context.repo.repo]; if (isCrossRepo) { core.info(`Using target repository from GH_AW_TARGET_REPO_SLUG: ${owner}/${repo}`); diff --git a/actions/setup/js/close_expired_pull_requests.cjs b/actions/setup/js/close_expired_pull_requests.cjs index 9c0dd116648..2946a565e27 100644 --- a/actions/setup/js/close_expired_pull_requests.cjs +++ b/actions/setup/js/close_expired_pull_requests.cjs @@ -47,8 +47,9 @@ async function closePullRequest(github, owner, repo, prNumber) { async function main() { // Resolve owner/repo — use GH_AW_TARGET_REPO_SLUG when set (SideRepoOps pattern). + // The slug must be in exact "owner/repo" format (one slash, non-empty on both sides). const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - const isCrossRepo = Boolean(targetRepoSlug && targetRepoSlug.includes("/")); + const isCrossRepo = Boolean(targetRepoSlug && /^[^/]+\/[^/]+$/.test(targetRepoSlug)); const [owner, repo] = isCrossRepo ? targetRepoSlug.split("/", 2) : [context.repo.owner, context.repo.repo]; if (isCrossRepo) { core.info(`Using target repository from GH_AW_TARGET_REPO_SLUG: ${owner}/${repo}`); diff --git a/actions/setup/js/create_labels.cjs b/actions/setup/js/create_labels.cjs index 0778906ba78..8c33e0850d9 100644 --- a/actions/setup/js/create_labels.cjs +++ b/actions/setup/js/create_labels.cjs @@ -89,8 +89,9 @@ async function main() { // Fetch all existing labels from the repository. // When GH_AW_TARGET_REPO_SLUG is set (SideRepoOps pattern), create labels in that // repository instead of the execution context repository. + // The slug must be in exact "owner/repo" format (one slash, non-empty on both sides). const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - const isCrossRepo = Boolean(targetRepoSlug && targetRepoSlug.includes("/")); + const isCrossRepo = Boolean(targetRepoSlug && /^[^/]+\/[^/]+$/.test(targetRepoSlug)); const [owner, repo] = isCrossRepo ? targetRepoSlug.split("/", 2) : [context.repo.owner, context.repo.repo]; if (isCrossRepo) { core.info(`Using target repository from GH_AW_TARGET_REPO_SLUG: ${owner}/${repo}`); diff --git a/pkg/workflow/maintenance_workflow_test.go b/pkg/workflow/maintenance_workflow_test.go index a1c3b07c1b9..bb7eb5cde91 100644 --- a/pkg/workflow/maintenance_workflow_test.go +++ b/pkg/workflow/maintenance_workflow_test.go @@ -921,9 +921,14 @@ func TestCollectSideRepoTargets(t *testing.T) { t.Errorf("expected %d targets, got %d: %v", len(tt.expectedRepos), len(got), got) return } - for i, repo := range tt.expectedRepos { - if got[i] != repo { - t.Errorf("target[%d]: expected %q, got %q", i, repo, got[i]) + // Use a set-based comparison so the test is not sensitive to ordering. + gotSet := make(map[string]bool, len(got)) + for _, r := range got { + gotSet[r] = true + } + for _, repo := range tt.expectedRepos { + if !gotSet[repo] { + t.Errorf("expected target %q not found in results %v", repo, got) } } }) From 884feb1622994766aaac32d2c4f8c637f3cb5c87 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:04:02 +0000 Subject: [PATCH 06/11] docs(adr): add draft ADR-26382 for auto-generated side-repo maintenance workflows --- ...enerate-side-repo-maintenance-workflows.md | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 docs/adr/26382-auto-generate-side-repo-maintenance-workflows.md diff --git a/docs/adr/26382-auto-generate-side-repo-maintenance-workflows.md b/docs/adr/26382-auto-generate-side-repo-maintenance-workflows.md new file mode 100644 index 00000000000..0c4644f9fc1 --- /dev/null +++ b/docs/adr/26382-auto-generate-side-repo-maintenance-workflows.md @@ -0,0 +1,90 @@ +# ADR-26382: Auto-Generate Side-Repo Maintenance Workflows for SideRepoOps Pattern + +**Date**: 2026-04-15 +**Status**: Draft +**Deciders**: pelikhan, Copilot + +--- + +## Part 1 — Narrative (Human-Friendly) + +### Context + +gh-aw supports a SideRepoOps pattern where a workflow hosted in one repository (`current: true` checkout) operates against a separate target repository. Previously, the target repository's maintenance workflow — responsible for replaying safe outputs, creating labels, and closing expired entities — had to be created manually and re-synchronized by hand on every `gh aw upgrade` cycle. This manual process was error-prone, required detailed knowledge of the gh-aw internals, and was frequently left out of sync with the hosting repository's generated workflows. With the SideRepoOps pattern seeing broader adoption, a sustainable automated solution became necessary. + +### Decision + +We will automatically detect SideRepoOps targets at compile time by scanning all `WorkflowData` entries for checkout configurations with `current: true` and a static (non-expression) `repository` field, then generate a per-target `agentics-maintenance-.yml` workflow alongside the standard `agentics-maintenance.yml`. The generated side-repo maintenance workflow is pre-wired with the checkout config's custom token (falling back to `${{ secrets.GH_AW_GITHUB_TOKEN }}`) and sets `GH_AW_TARGET_REPO_SLUG` on every cross-repo job, enabling all maintenance operations to act against the target repository without any manual configuration. Expression-based repository values (e.g. `${{ inputs.target_repo }}`) are excluded since no static filename can be derived. + +### Alternatives Considered + +#### Alternative 1: Manual Workflow Authoring (Status Quo) + +The existing approach required repository owners to author and maintain a custom side-repo maintenance workflow by hand. While this gave maximum flexibility, it imposed ongoing maintenance burden and required users to track internal implementation changes across upgrades. It was rejected because it directly contradicts gh-aw's stated goal of eliminating boilerplate agentic workflow management. + +#### Alternative 2: On-Demand CLI Command + +A dedicated CLI command (e.g. `gh aw generate-side-repo-maintenance --repo owner/repo`) could generate the file when explicitly invoked. This was rejected because it requires users to know about the feature, remember to run it after changes, and re-run it after every upgrade — preserving the core problem of manual synchronization. Auto-detection at compile time guarantees the files stay in sync without user intervention. + +#### Alternative 3: Dynamic Workflow Invocation via `workflow_call` Parameters + +Instead of a per-target file, a single parameterized `agentics-maintenance-side-repo.yml` could accept the target slug as an input. This was rejected because: (a) it requires callers to supply the slug explicitly, (b) cross-repo token selection cannot be resolved statically without a per-target file, and (c) GitHub Actions does not support dynamically choosing secrets by name at runtime. + +### Consequences + +#### Positive +- Side-repo maintenance workflows are always in sync with the hosting repo's compile output; no manual re-synchronization required after upgrades. +- Correct token selection is handled automatically: the generated workflow uses the same GitHub token declared in the checkout config. +- `GH_AW_TARGET_REPO_SLUG` is injected on every cross-repo job, enabling all maintenance JavaScript actions to operate against the right repository without code changes. +- The side-repo workflow is generated even when no `expires` configuration exists, ensuring `safe_outputs` and `create_labels` operations are always available. + +#### Negative +- Only workflows with a static (literal string) `repository` field in their checkout config generate a side-repo maintenance workflow; expression-based targets (e.g. `${{ inputs.target_repo }}`) are silently skipped with a log message. +- Each unique static target produces a separate workflow file, which may grow the `.github/workflows/` directory noticeably in repositories with many side-repo targets. +- The `GH_AW_TARGET_REPO_SLUG` environment variable is now load-bearing for cross-repo operations; misconfiguration or accidental override in calling workflows could misdirect operations. + +#### Neutral +- The `GenerateMaintenanceWorkflow` function's control flow is modified: the early-return path for the no-`expires` case now calls `generateAllSideRepoMaintenanceWorkflows` before returning, which is a behavioural change for consumers relying on "no file output when no expires." +- Four JavaScript maintenance scripts (`close_expired_discussions.cjs`, `close_expired_issues.cjs`, `close_expired_pull_requests.cjs`, `create_labels.cjs`) now branch on `GH_AW_TARGET_REPO_SLUG` to resolve `owner`/`repo`, affecting all execution contexts — including non-SideRepoOps runs where the variable is absent (falls back to `context.repo` as before). + +--- + +## Part 2 — Normative Specification (RFC 2119) + +> The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**, **SHOULD**, **SHOULD NOT**, **RECOMMENDED**, **MAY**, and **OPTIONAL** in this section are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119). + +### Side-Repo Target Detection + +1. Implementations **MUST** treat a checkout configuration as a SideRepoOps target if and only if `current` is `true` and `repository` is a non-empty string that does not contain `${{`. +2. Implementations **MUST NOT** generate a side-repo maintenance workflow for checkout configurations where `repository` contains a GitHub Actions expression (i.e. the string `${{`). +3. Implementations **MUST** deduplicate targets by `repository` slug so that at most one maintenance workflow file is generated per unique target repository, regardless of how many compiled workflows reference it. +4. Implementations **SHOULD** emit a log message for each skipped expression-based repository to aid debugging. + +### Workflow File Naming + +1. Implementations **MUST** derive the side-repo maintenance workflow filename as `agentics-maintenance-.yml`, where `` is the `owner/repo` string with `/` replaced by `-` and any character outside `[a-zA-Z0-9\-_.]` replaced by `-`. +2. Implementations **MUST** write the generated file to the same directory as the standard `agentics-maintenance.yml` (the `workflowDir` parameter). +3. Implementations **MUST NOT** overwrite an existing `agentics-maintenance.yml` (the standard hosting-repo workflow) when generating side-repo maintenance files. + +### Generated Workflow Content + +1. Implementations **MUST** include `workflow_call` and `workflow_dispatch` triggers in every generated side-repo maintenance workflow. +2. Implementations **MUST** set the `GH_AW_TARGET_REPO_SLUG` environment variable to the static `owner/repo` slug on every job step that performs operations against the target repository. +3. Implementations **MUST** use the `github-token` value from the checkout configuration as the GitHub token for cross-repo job steps; when no token is configured, implementations **MUST** fall back to `${{ secrets.GH_AW_GITHUB_TOKEN }}`. +4. Implementations **MUST** include `apply_safe_outputs`, `create_labels`, and `validate_workflows` jobs in every generated side-repo maintenance workflow, regardless of whether `expires` is configured. +5. Implementations **MUST** include the `close-expired-entities` job only when `hasExpires` is `true` for the workflow set. +6. Implementations **SHOULD** include a human-readable comment in the generated workflow identifying the target repository and the fact that the file is auto-generated. + +### Cross-Repo JavaScript Action Resolution + +1. JavaScript maintenance scripts that operate on GitHub resources **MUST** resolve `owner` and `repo` from `GH_AW_TARGET_REPO_SLUG` when that environment variable is set and matches the pattern `/^[^/]+\/[^/]+$/`; otherwise they **MUST** fall back to `context.repo.owner` and `context.repo.repo`. +2. Implementations **MUST NOT** split `GH_AW_TARGET_REPO_SLUG` on more than one `/` (i.e. `split("/", 2)` semantics are required). +3. Implementations **SHOULD** emit an informational log message when `GH_AW_TARGET_REPO_SLUG` is used, identifying the resolved `owner/repo`. + +### Conformance + +An implementation is considered conformant with this ADR if it satisfies all **MUST** and **MUST NOT** requirements above. Failure to meet any **MUST** or **MUST NOT** requirement constitutes non-conformance. + +--- + +*This is a DRAFT ADR generated by the [Design Decision Gate](https://github.com/github/gh-aw/actions/runs/24455822765) workflow. The PR author must review, complete, and finalize this document before the PR can merge.* From 9c625373286c16e4ae4e774c75d8e8a126541bd2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:18:37 +0000 Subject: [PATCH 07/11] refactor: extract resolveExecutionOwnerRepo helper in repo_helpers.cjs for cross-repo parsing Agent-Logs-Url: https://github.com/github/gh-aw/sessions/b6e9b78c-2911-4790-afd2-19c256e942ed Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../setup/js/close_expired_discussions.cjs | 11 ++---- actions/setup/js/close_expired_issues.cjs | 11 ++---- .../setup/js/close_expired_pull_requests.cjs | 11 ++---- actions/setup/js/create_labels.cjs | 10 ++---- actions/setup/js/repo_helpers.cjs | 22 ++++++++++++ actions/setup/js/repo_helpers.test.cjs | 35 +++++++++++++++++++ 6 files changed, 69 insertions(+), 31 deletions(-) diff --git a/actions/setup/js/close_expired_discussions.cjs b/actions/setup/js/close_expired_discussions.cjs index 490efb63818..597d6b48461 100644 --- a/actions/setup/js/close_expired_discussions.cjs +++ b/actions/setup/js/close_expired_discussions.cjs @@ -5,6 +5,7 @@ const { executeExpiredEntityCleanup } = require("./expired_entity_main_flow.cjs" const { generateExpiredEntityFooter } = require("./generate_footer.cjs"); const { sanitizeContent } = require("./sanitize_content.cjs"); const { getWorkflowMetadata } = require("./workflow_metadata_helpers.cjs"); +const { resolveExecutionOwnerRepo } = require("./repo_helpers.cjs"); /** * Add comment to a GitHub Discussion using GraphQL @@ -90,14 +91,8 @@ async function hasExpirationComment(github, discussionId) { } async function main() { - // Resolve owner/repo — use GH_AW_TARGET_REPO_SLUG when set (SideRepoOps pattern). - // The slug must be in exact "owner/repo" format (one slash, non-empty on both sides). - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - const isCrossRepo = Boolean(targetRepoSlug && /^[^/]+\/[^/]+$/.test(targetRepoSlug)); - const [owner, repo] = isCrossRepo ? targetRepoSlug.split("/", 2) : [context.repo.owner, context.repo.repo]; - if (isCrossRepo) { - core.info(`Using target repository from GH_AW_TARGET_REPO_SLUG: ${owner}/${repo}`); - } + const { owner, repo } = resolveExecutionOwnerRepo(); + core.info(`Operating on repository: ${owner}/${repo}`); // Get workflow metadata for footer const { workflowName, workflowId, runUrl } = getWorkflowMetadata(owner, repo); diff --git a/actions/setup/js/close_expired_issues.cjs b/actions/setup/js/close_expired_issues.cjs index c1218554348..c0eb96693d8 100644 --- a/actions/setup/js/close_expired_issues.cjs +++ b/actions/setup/js/close_expired_issues.cjs @@ -5,6 +5,7 @@ const { executeExpiredEntityCleanup } = require("./expired_entity_main_flow.cjs" const { generateExpiredEntityFooter } = require("./generate_footer.cjs"); const { sanitizeContent } = require("./sanitize_content.cjs"); const { getWorkflowMetadata } = require("./workflow_metadata_helpers.cjs"); +const { resolveExecutionOwnerRepo } = require("./repo_helpers.cjs"); /** * Add comment to a GitHub Issue using REST API @@ -47,14 +48,8 @@ async function closeIssue(github, owner, repo, issueNumber) { } async function main() { - // Resolve owner/repo — use GH_AW_TARGET_REPO_SLUG when set (SideRepoOps pattern). - // The slug must be in exact "owner/repo" format (one slash, non-empty on both sides). - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - const isCrossRepo = Boolean(targetRepoSlug && /^[^/]+\/[^/]+$/.test(targetRepoSlug)); - const [owner, repo] = isCrossRepo ? targetRepoSlug.split("/", 2) : [context.repo.owner, context.repo.repo]; - if (isCrossRepo) { - core.info(`Using target repository from GH_AW_TARGET_REPO_SLUG: ${owner}/${repo}`); - } + const { owner, repo } = resolveExecutionOwnerRepo(); + core.info(`Operating on repository: ${owner}/${repo}`); // Get workflow metadata for footer const { workflowName, workflowId, runUrl } = getWorkflowMetadata(owner, repo); diff --git a/actions/setup/js/close_expired_pull_requests.cjs b/actions/setup/js/close_expired_pull_requests.cjs index 2946a565e27..1fd9973a43b 100644 --- a/actions/setup/js/close_expired_pull_requests.cjs +++ b/actions/setup/js/close_expired_pull_requests.cjs @@ -5,6 +5,7 @@ const { executeExpiredEntityCleanup } = require("./expired_entity_main_flow.cjs" const { generateExpiredEntityFooter } = require("./generate_footer.cjs"); const { sanitizeContent } = require("./sanitize_content.cjs"); const { getWorkflowMetadata } = require("./workflow_metadata_helpers.cjs"); +const { resolveExecutionOwnerRepo } = require("./repo_helpers.cjs"); /** * Add comment to a GitHub Pull Request using REST API @@ -46,14 +47,8 @@ async function closePullRequest(github, owner, repo, prNumber) { } async function main() { - // Resolve owner/repo — use GH_AW_TARGET_REPO_SLUG when set (SideRepoOps pattern). - // The slug must be in exact "owner/repo" format (one slash, non-empty on both sides). - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - const isCrossRepo = Boolean(targetRepoSlug && /^[^/]+\/[^/]+$/.test(targetRepoSlug)); - const [owner, repo] = isCrossRepo ? targetRepoSlug.split("/", 2) : [context.repo.owner, context.repo.repo]; - if (isCrossRepo) { - core.info(`Using target repository from GH_AW_TARGET_REPO_SLUG: ${owner}/${repo}`); - } + const { owner, repo } = resolveExecutionOwnerRepo(); + core.info(`Operating on repository: ${owner}/${repo}`); // Get workflow metadata for footer const { workflowName, workflowId, runUrl } = getWorkflowMetadata(owner, repo); diff --git a/actions/setup/js/create_labels.cjs b/actions/setup/js/create_labels.cjs index 8c33e0850d9..554e59cb6bc 100644 --- a/actions/setup/js/create_labels.cjs +++ b/actions/setup/js/create_labels.cjs @@ -3,6 +3,7 @@ const { getErrorMessage } = require("./error_helpers.cjs"); const { ERR_SYSTEM } = require("./error_codes.cjs"); +const { resolveExecutionOwnerRepo } = require("./repo_helpers.cjs"); /** * Generate a deterministic pastel hex color string from a label name. @@ -89,13 +90,8 @@ async function main() { // Fetch all existing labels from the repository. // When GH_AW_TARGET_REPO_SLUG is set (SideRepoOps pattern), create labels in that // repository instead of the execution context repository. - // The slug must be in exact "owner/repo" format (one slash, non-empty on both sides). - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - const isCrossRepo = Boolean(targetRepoSlug && /^[^/]+\/[^/]+$/.test(targetRepoSlug)); - const [owner, repo] = isCrossRepo ? targetRepoSlug.split("/", 2) : [context.repo.owner, context.repo.repo]; - if (isCrossRepo) { - core.info(`Using target repository from GH_AW_TARGET_REPO_SLUG: ${owner}/${repo}`); - } + const { owner, repo } = resolveExecutionOwnerRepo(); + core.info(`Operating on repository: ${owner}/${repo}`); let existingLabels; try { existingLabels = await github.paginate(github.rest.issues.listLabelsForRepo, { diff --git a/actions/setup/js/repo_helpers.cjs b/actions/setup/js/repo_helpers.cjs index 1b9ea222862..f3e77c1d54c 100644 --- a/actions/setup/js/repo_helpers.cjs +++ b/actions/setup/js/repo_helpers.cjs @@ -300,6 +300,27 @@ function validateTargetRepo(repo, defaultRepo, allowedRepos) { return validateRepo(repo, defaultRepo, allowedRepos); } +/** + * Resolve the execution owner/repo pair for maintenance scripts. + * + * In the SideRepoOps pattern a hosting repo manages workflows that operate on a + * separate target repository. When `GH_AW_TARGET_REPO_SLUG` is set to a valid + * "owner/repo" slug the maintenance scripts should operate against that repo + * instead of the workflow execution context (`context.repo`). + * + * @returns {{ owner: string, repo: string }} + */ +function resolveExecutionOwnerRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + const parsed = parseRepoSlug(targetRepoSlug); + if (parsed) { + return parsed; + } + } + return { owner: context.repo.owner, repo: context.repo.repo }; +} + module.exports = { parseAllowedRepos, getDefaultTargetRepo, @@ -309,4 +330,5 @@ module.exports = { parseRepoSlug, resolveTargetRepoConfig, resolveAndValidateRepo, + resolveExecutionOwnerRepo, }; diff --git a/actions/setup/js/repo_helpers.test.cjs b/actions/setup/js/repo_helpers.test.cjs index 0063791d970..9699b89a8c2 100644 --- a/actions/setup/js/repo_helpers.test.cjs +++ b/actions/setup/js/repo_helpers.test.cjs @@ -548,3 +548,38 @@ describe("repo_helpers", () => { }); }); }); + +describe("resolveExecutionOwnerRepo", () => { + beforeEach(() => { + vi.resetModules(); + delete process.env.GH_AW_TARGET_REPO_SLUG; + global.context = { repo: { owner: "ctx-owner", repo: "ctx-repo" } }; + }); + + it("should fall back to context.repo when env var is not set", async () => { + const { resolveExecutionOwnerRepo } = await import("./repo_helpers.cjs"); + const result = resolveExecutionOwnerRepo(); + expect(result).toEqual({ owner: "ctx-owner", repo: "ctx-repo" }); + }); + + it("should use GH_AW_TARGET_REPO_SLUG when set to a valid slug", async () => { + process.env.GH_AW_TARGET_REPO_SLUG = "my-org/target-repo"; + const { resolveExecutionOwnerRepo } = await import("./repo_helpers.cjs"); + const result = resolveExecutionOwnerRepo(); + expect(result).toEqual({ owner: "my-org", repo: "target-repo" }); + }); + + it("should fall back to context.repo when slug is malformed (no slash)", async () => { + process.env.GH_AW_TARGET_REPO_SLUG = "malformed-slug"; + const { resolveExecutionOwnerRepo } = await import("./repo_helpers.cjs"); + const result = resolveExecutionOwnerRepo(); + expect(result).toEqual({ owner: "ctx-owner", repo: "ctx-repo" }); + }); + + it("should fall back to context.repo when slug has multiple slashes", async () => { + process.env.GH_AW_TARGET_REPO_SLUG = "a/b/c"; + const { resolveExecutionOwnerRepo } = await import("./repo_helpers.cjs"); + const result = resolveExecutionOwnerRepo(); + expect(result).toEqual({ owner: "ctx-owner", repo: "ctx-repo" }); + }); +}); From 6bc1ab392f25e31aa341d105b99ec920b6614726 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:39:54 +0000 Subject: [PATCH 08/11] fix: address all code review comments on side-repo maintenance workflow generation Agent-Logs-Url: https://github.com/github/gh-aw/sessions/8c645f08-4a39-40f2-af2e-bf9e0385b09a Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/repo_helpers.cjs | 8 +- actions/setup/js/repo_helpers.test.cjs | 10 +- pkg/workflow/maintenance_workflow.go | 112 ++++++++++++++++------ pkg/workflow/maintenance_workflow_test.go | 92 ++++++++++++++++++ 4 files changed, 187 insertions(+), 35 deletions(-) diff --git a/actions/setup/js/repo_helpers.cjs b/actions/setup/js/repo_helpers.cjs index f3e77c1d54c..7215e8989f1 100644 --- a/actions/setup/js/repo_helpers.cjs +++ b/actions/setup/js/repo_helpers.cjs @@ -308,15 +308,19 @@ function validateTargetRepo(repo, defaultRepo, allowedRepos) { * "owner/repo" slug the maintenance scripts should operate against that repo * instead of the workflow execution context (`context.repo`). * + * Throws when `GH_AW_TARGET_REPO_SLUG` is present but not in exact "owner/repo" format + * to prevent silently operating against the wrong repository. + * * @returns {{ owner: string, repo: string }} */ function resolveExecutionOwnerRepo() { const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; if (targetRepoSlug) { const parsed = parseRepoSlug(targetRepoSlug); - if (parsed) { - return parsed; + if (!parsed) { + throw new Error(`Invalid GH_AW_TARGET_REPO_SLUG: "${targetRepoSlug}". Expected exact "owner/repo" format (one slash, non-empty on both sides).`); } + return parsed; } return { owner: context.repo.owner, repo: context.repo.repo }; } diff --git a/actions/setup/js/repo_helpers.test.cjs b/actions/setup/js/repo_helpers.test.cjs index 9699b89a8c2..daf1d801fc6 100644 --- a/actions/setup/js/repo_helpers.test.cjs +++ b/actions/setup/js/repo_helpers.test.cjs @@ -569,17 +569,15 @@ describe("resolveExecutionOwnerRepo", () => { expect(result).toEqual({ owner: "my-org", repo: "target-repo" }); }); - it("should fall back to context.repo when slug is malformed (no slash)", async () => { + it("should throw when slug is malformed (no slash)", async () => { process.env.GH_AW_TARGET_REPO_SLUG = "malformed-slug"; const { resolveExecutionOwnerRepo } = await import("./repo_helpers.cjs"); - const result = resolveExecutionOwnerRepo(); - expect(result).toEqual({ owner: "ctx-owner", repo: "ctx-repo" }); + expect(() => resolveExecutionOwnerRepo()).toThrow(/Invalid GH_AW_TARGET_REPO_SLUG/); }); - it("should fall back to context.repo when slug has multiple slashes", async () => { + it("should throw when slug has multiple slashes", async () => { process.env.GH_AW_TARGET_REPO_SLUG = "a/b/c"; const { resolveExecutionOwnerRepo } = await import("./repo_helpers.cjs"); - const result = resolveExecutionOwnerRepo(); - expect(result).toEqual({ owner: "ctx-owner", repo: "ctx-repo" }); + expect(() => resolveExecutionOwnerRepo()).toThrow(/Invalid GH_AW_TARGET_REPO_SLUG/); }); }); diff --git a/pkg/workflow/maintenance_workflow.go b/pkg/workflow/maintenance_workflow.go index c3fe858dcaa..7b1a99739b6 100644 --- a/pkg/workflow/maintenance_workflow.go +++ b/pkg/workflow/maintenance_workflow.go @@ -749,9 +749,13 @@ type SideRepoTarget struct { // collectSideRepoTargets scans all compiled workflow data and returns the unique // SideRepoTarget entries inferred from checkout blocks with current: true. // Only checkouts with a static (non-expression) repository string are included. +// When the same repository appears multiple times, a non-empty GitHubToken is +// preferred over an empty one so that the generated workflow uses the custom +// token rather than falling back to GH_AW_GITHUB_TOKEN. func collectSideRepoTargets(workflowDataList []*WorkflowData) []SideRepoTarget { - seen := make(map[string]bool) - var targets []SideRepoTarget + // Use a map to accumulate the best token seen for each slug. + tokenByRepo := make(map[string]string) + var order []string // preserve first-seen order for stable output for _, wd := range workflowDataList { for _, checkout := range wd.CheckoutConfigs { if !checkout.Current { @@ -762,16 +766,23 @@ func collectSideRepoTargets(workflowDataList []*WorkflowData) []SideRepoTarget { // Skip empty repositories and expression-based (dynamic) ones. continue } - if seen[repo] { - continue + existing, seen := tokenByRepo[repo] + if !seen { + order = append(order, repo) + tokenByRepo[repo] = checkout.GitHubToken + } else if existing == "" && checkout.GitHubToken != "" { + // Upgrade to a non-empty token when one is encountered later. + tokenByRepo[repo] = checkout.GitHubToken } - seen[repo] = true - targets = append(targets, SideRepoTarget{ - Repository: repo, - GitHubToken: checkout.GitHubToken, - }) } } + targets := make([]SideRepoTarget, 0, len(order)) + for _, repo := range order { + targets = append(targets, SideRepoTarget{ + Repository: repo, + GitHubToken: tokenByRepo[repo], + }) + } maintenanceLog.Printf("Detected %d side-repo target(s) from checkout configs", len(targets)) return targets } @@ -815,12 +826,9 @@ func generateAllSideRepoMaintenanceWorkflows( minExpiresDays int, ) error { targets := collectSideRepoTargets(workflowDataList) - if len(targets) == 0 { - return nil - } - // Track which side-repo maintenance files we (re-)generate so we can later - // identify and clean up stale files from previous runs if the target repos change. + // Track which side-repo maintenance files we (re-)generate so we can identify + // and remove stale files from previous runs when target repos are renamed or removed. generatedFiles := make(map[string]bool) for _, target := range targets { @@ -836,6 +844,30 @@ func generateAllSideRepoMaintenanceWorkflows( fmt.Fprintf(os.Stderr, " Generated side-repo maintenance workflow: %s\n", filename) } + // Remove stale side-repo maintenance workflows that are no longer referenced. + entries, err := os.ReadDir(workflowDir) + if err != nil { + return fmt.Errorf("failed to read workflow directory %s for stale side-repo maintenance workflow cleanup: %w", workflowDir, err) + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if !strings.HasPrefix(name, "agentics-maintenance-") || !strings.HasSuffix(name, ".yml") { + continue + } + if generatedFiles[name] { + continue + } + stalePath := filepath.Join(workflowDir, name) + maintenanceLog.Printf("Removing stale side-repo maintenance workflow: %s", name) + if err := os.Remove(stalePath); err != nil { + return fmt.Errorf("failed to remove stale side-repo maintenance workflow %s: %w", stalePath, err) + } + fmt.Fprintf(os.Stderr, " Removed stale side-repo maintenance workflow: %s\n", name) + } + return nil } @@ -867,13 +899,28 @@ Or use the gh-aw CLI directly: ./gh-aw compile --validate --verbose This workflow is generated for the SideRepoOps target repository "` + repoSlug + `". -It is invoked via workflow_call from the hosting repository's agentics-maintenance.yml -and runs maintenance operations against the target repository.` +It can be triggered via workflow_dispatch or called via workflow_call to run maintenance +operations (safe-outputs replay, label creation, validation, expired-entity cleanup) +against the target repository.` header := GenerateWorkflowHeader("", "pkg/workflow/maintenance_workflow.go", customInstructions) yaml.WriteString(header) - yaml.WriteString(`name: Agentic Maintenance (` + repoSlug + `) + // Pre-compute cron schedule values (needed in both the on: section and the + // close-expired-entities job comment when hasExpires is true). + var cronSchedule, scheduleDesc string + if hasExpires { + if minExpiresDays > 0 { + cronSchedule, scheduleDesc = generateMaintenanceCron(minExpiresDays) + } else { + cronSchedule, scheduleDesc = "37 0 * * *", "Daily" + } + } + + // Build the `on:` triggers. A schedule trigger is added when at least one + // workflow uses `expires`, because the close-expired-entities job's condition + // (`buildNotForkAndScheduled`) also matches scheduled runs. + onSection := `name: Agentic Maintenance (` + repoSlug + `) on: workflow_dispatch: @@ -909,23 +956,23 @@ on: applied_run_url: description: 'The run URL that safe outputs were applied from' value: ${{ jobs.apply_safe_outputs.outputs.run_url }} - +` + if hasExpires { + onSection += ` schedule: + - cron: "` + cronSchedule + `" # ` + scheduleDesc + ` (based on minimum expires: ` + strconv.Itoa(minExpiresDays) + ` days) +` + } + onSection += ` permissions: {} jobs: -`) +` + yaml.WriteString(onSection) setupActionRef := ResolveSetupActionReference(actionMode, version, actionTag, resolver) // Add close-expired-entities job only when any workflow uses expires. if hasExpires { - var cronSchedule, scheduleDesc string - if minExpiresDays > 0 { - cronSchedule, scheduleDesc = generateMaintenanceCron(minExpiresDays) - } else { - cronSchedule, scheduleDesc = "37 0 * * *", "Daily" - } - closeExpiredCondition := buildNotForkAndScheduled() yaml.WriteString(` close-expired-entities: if: ${{ ` + RenderCondition(closeExpiredCondition) + ` }} @@ -1004,7 +1051,18 @@ jobs: outputs: run_url: ${{ steps.record.outputs.run_url }} steps: - - name: Setup Scripts +`) + + if actionMode == ActionModeDev || actionMode == ActionModeScript { + yaml.WriteString(" - name: Checkout actions folder\n") + yaml.WriteString(" uses: " + GetActionPin("actions/checkout") + "\n") + yaml.WriteString(" with:\n") + yaml.WriteString(" sparse-checkout: |\n") + yaml.WriteString(" actions\n") + yaml.WriteString(" persist-credentials: false\n\n") + } + + yaml.WriteString(` - name: Setup Scripts uses: ` + setupActionRef + ` with: destination: ${{ runner.temp }}/gh-aw/actions diff --git a/pkg/workflow/maintenance_workflow_test.go b/pkg/workflow/maintenance_workflow_test.go index bb7eb5cde91..46bf34213ef 100644 --- a/pkg/workflow/maintenance_workflow_test.go +++ b/pkg/workflow/maintenance_workflow_test.go @@ -933,6 +933,27 @@ func TestCollectSideRepoTargets(t *testing.T) { } }) } + + t.Run("non-empty token is preferred when same repo appears multiple times", func(t *testing.T) { + workflows := []*WorkflowData{ + {Name: "wf1", CheckoutConfigs: []*CheckoutConfig{ + // First appearance has no token. + {Repository: "my-org/shared-repo", Current: true, GitHubToken: ""}, + }}, + {Name: "wf2", CheckoutConfigs: []*CheckoutConfig{ + // Second appearance provides a token — should win. + {Repository: "my-org/shared-repo", Current: true, GitHubToken: "${{ secrets.SHARED_TOKEN }}"}, + }}, + } + + targets := collectSideRepoTargets(workflows) + if len(targets) != 1 { + t.Fatalf("expected 1 target, got %d", len(targets)) + } + if targets[0].GitHubToken != "${{ secrets.SHARED_TOKEN }}" { + t.Errorf("expected non-empty token to win, got %q", targets[0].GitHubToken) + } + }) } func TestSanitizeRepoForFilename(t *testing.T) { @@ -1110,4 +1131,75 @@ func TestGenerateSideRepoMaintenanceWorkflow(t *testing.T) { } } }) + + t.Run("side-repo with expires includes schedule trigger", func(t *testing.T) { + tmpDir := t.TempDir() + workflowDataList := []*WorkflowData{ + { + Name: "side-repo-with-expires", + CheckoutConfigs: []*CheckoutConfig{ + {Repository: "org/expires-repo", Current: true}, + }, + SafeOutputs: &SafeOutputsConfig{ + CreateIssues: &CreateIssuesConfig{Expires: 48}, + }, + }, + } + + err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + sideFile := filepath.Join(tmpDir, "agentics-maintenance-org-expires-repo.yml") + content, err := os.ReadFile(sideFile) + if err != nil { + t.Fatalf("Expected side-repo maintenance file to exist: %v", err) + } + contentStr := string(content) + + if !strings.Contains(contentStr, "schedule:") { + t.Errorf("Side-repo maintenance with expires should include a schedule trigger, got content length %d", len(contentStr)) + } + if !strings.Contains(contentStr, "cron:") { + t.Errorf("Side-repo maintenance with expires should include a cron expression, got content length %d", len(contentStr)) + } + }) + + t.Run("stale side-repo maintenance workflow is removed on recompile", func(t *testing.T) { + tmpDir := t.TempDir() + + // Simulate a stale file from a previous run. + staleName := "agentics-maintenance-old-org-old-repo.yml" + stalePath := filepath.Join(tmpDir, staleName) + if err := os.WriteFile(stalePath, []byte("stale"), 0644); err != nil { + t.Fatalf("Failed to create stale file: %v", err) + } + + // Current run has a different target repo. + workflowDataList := []*WorkflowData{ + { + Name: "new-workflow", + CheckoutConfigs: []*CheckoutConfig{ + {Repository: "new-org/new-repo", Current: true}, + }, + }, + } + + err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + // Stale file should have been removed. + if _, statErr := os.Stat(stalePath); !os.IsNotExist(statErr) { + t.Errorf("Stale side-repo maintenance file %s should have been removed", staleName) + } + + // The new file should exist. + newFile := filepath.Join(tmpDir, "agentics-maintenance-new-org-new-repo.yml") + if _, statErr := os.Stat(newFile); statErr != nil { + t.Errorf("New side-repo maintenance file should exist: %v", statErr) + } + }) } From 0a3a930b654cf4842728120a36add7a1a81c684b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:45:52 +0000 Subject: [PATCH 09/11] fix: address secondary code review suggestions from parallel_validation Agent-Logs-Url: https://github.com/github/gh-aw/sessions/8c645f08-4a39-40f2-af2e-bf9e0385b09a Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/maintenance_workflow.go | 9 +++++-- pkg/workflow/maintenance_workflow_test.go | 31 +++++++++++++++++++++-- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/pkg/workflow/maintenance_workflow.go b/pkg/workflow/maintenance_workflow.go index 7b1a99739b6..5709ee3af31 100644 --- a/pkg/workflow/maintenance_workflow.go +++ b/pkg/workflow/maintenance_workflow.go @@ -754,8 +754,10 @@ type SideRepoTarget struct { // token rather than falling back to GH_AW_GITHUB_TOKEN. func collectSideRepoTargets(workflowDataList []*WorkflowData) []SideRepoTarget { // Use a map to accumulate the best token seen for each slug. + // Order slice preserves first-seen repository discovery order for stable output; + // tokens may be upgraded to non-empty values from later occurrences. tokenByRepo := make(map[string]string) - var order []string // preserve first-seen order for stable output + var order []string for _, wd := range workflowDataList { for _, checkout := range wd.CheckoutConfigs { if !checkout.Current { @@ -908,12 +910,15 @@ against the target repository.` // Pre-compute cron schedule values (needed in both the on: section and the // close-expired-entities job comment when hasExpires is true). + // When minExpiresDays is 0 (e.g. expiry expressed in hours < 24) we use a + // daily fallback — the same cron generated for > 4-day expiries. var cronSchedule, scheduleDesc string if hasExpires { if minExpiresDays > 0 { cronSchedule, scheduleDesc = generateMaintenanceCron(minExpiresDays) } else { - cronSchedule, scheduleDesc = "37 0 * * *", "Daily" + // minExpiresDays == 0 means expiry < 1 day; use a conservative daily default. + cronSchedule, scheduleDesc = generateMaintenanceCron(5) // 5 days → daily cron } } diff --git a/pkg/workflow/maintenance_workflow_test.go b/pkg/workflow/maintenance_workflow_test.go index 46bf34213ef..55ad4c32b95 100644 --- a/pkg/workflow/maintenance_workflow_test.go +++ b/pkg/workflow/maintenance_workflow_test.go @@ -954,6 +954,31 @@ func TestCollectSideRepoTargets(t *testing.T) { t.Errorf("expected non-empty token to win, got %q", targets[0].GitHubToken) } }) + + t.Run("multiple repos preserve first-seen discovery order", func(t *testing.T) { + workflows := []*WorkflowData{ + {Name: "wf1", CheckoutConfigs: []*CheckoutConfig{ + {Repository: "org/first-repo", Current: true}, + }}, + {Name: "wf2", CheckoutConfigs: []*CheckoutConfig{ + {Repository: "org/second-repo", Current: true}, + }}, + {Name: "wf3", CheckoutConfigs: []*CheckoutConfig{ + {Repository: "org/third-repo", Current: true}, + }}, + } + + targets := collectSideRepoTargets(workflows) + if len(targets) != 3 { + t.Fatalf("expected 3 targets, got %d", len(targets)) + } + wantOrder := []string{"org/first-repo", "org/second-repo", "org/third-repo"} + for i, want := range wantOrder { + if targets[i].Repository != want { + t.Errorf("targets[%d] = %q, want %q", i, targets[i].Repository, want) + } + } + }) } func TestSanitizeRepoForFilename(t *testing.T) { @@ -1134,6 +1159,7 @@ func TestGenerateSideRepoMaintenanceWorkflow(t *testing.T) { t.Run("side-repo with expires includes schedule trigger", func(t *testing.T) { tmpDir := t.TempDir() + // Expires: 48 hours = 2 days → generateMaintenanceCron(2) = "37 */6 * * *" workflowDataList := []*WorkflowData{ { Name: "side-repo-with-expires", @@ -1161,8 +1187,9 @@ func TestGenerateSideRepoMaintenanceWorkflow(t *testing.T) { if !strings.Contains(contentStr, "schedule:") { t.Errorf("Side-repo maintenance with expires should include a schedule trigger, got content length %d", len(contentStr)) } - if !strings.Contains(contentStr, "cron:") { - t.Errorf("Side-repo maintenance with expires should include a cron expression, got content length %d", len(contentStr)) + // 48 hours = 2 days → generateMaintenanceCron(2) returns "37 */6 * * *" (Every 6 hours) + if !strings.Contains(contentStr, "37 */6 * * *") { + t.Errorf("Side-repo maintenance with 2-day expires should use 6-hour cron, got content:\n%s", contentStr[:min(500, len(contentStr))]) } }) From 7ad9368ec8a83f9c1bb36df510500be3d2c615df Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:59:12 +0000 Subject: [PATCH 10/11] feat: use fuzzy cron scheduling to scatter side-repo maintenance workflows Agent-Logs-Url: https://github.com/github/gh-aw/sessions/bd7ad416-3b66-44fe-a087-aeef004f7cfc Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/maintenance_workflow.go | 51 ++++++++++++-- pkg/workflow/maintenance_workflow_test.go | 83 +++++++++++++++++++++-- 2 files changed, 123 insertions(+), 11 deletions(-) diff --git a/pkg/workflow/maintenance_workflow.go b/pkg/workflow/maintenance_workflow.go index 5709ee3af31..2a9d66bc7b1 100644 --- a/pkg/workflow/maintenance_workflow.go +++ b/pkg/workflow/maintenance_workflow.go @@ -2,6 +2,7 @@ package workflow import ( "fmt" + "hash/fnv" "os" "path/filepath" "strconv" @@ -109,6 +110,43 @@ func generateMaintenanceCron(minExpiresDays int) (string, string) { return fmt.Sprintf("%d %d * * *", minute, 0), "Daily" } +// sideRepoCronSeed derives a deterministic 64-bit seed from a repository slug +// using FNV-1a hashing. The seed is used to scatter cron offsets across +// multiple side-repo maintenance workflows so they don't all fire at once. +func sideRepoCronSeed(repoSlug string) uint64 { + h := fnv.New64a() + _, _ = h.Write([]byte(repoSlug)) + return h.Sum64() +} + +// generateSideRepoMaintenanceCron generates a scattered cron schedule for a +// side-repo maintenance workflow. The minute (and start hour for sub-daily +// schedules) are derived deterministically from the repository slug so that +// multiple side-repos are spread across the clock face rather than all firing +// at the same moment. +func generateSideRepoMaintenanceCron(repoSlug string, minExpiresDays int) (string, string) { + seed := sideRepoCronSeed(repoSlug) + // Derive a deterministic minute in 0-59 from the seed. + minute := int(seed % 60) + + if minExpiresDays <= 1 { + // Every 2 hours — vary the starting minute only. + return fmt.Sprintf("%d */2 * * *", minute), "Every 2 hours" + } else if minExpiresDays == 2 { + // Every 6 hours — vary the starting hour within the 6-hour window. + startHour := int((seed >> 8) % 6) + return fmt.Sprintf("%d %d,%d,%d,%d * * *", minute, startHour, startHour+6, startHour+12, startHour+18), "Every 6 hours" + } else if minExpiresDays <= 4 { + // Every 12 hours — vary the starting hour within the 12-hour window. + startHour := int((seed >> 8) % 12) + return fmt.Sprintf("%d %d,%d * * *", minute, startHour, startHour+12), "Every 12 hours" + } + + // Daily — vary the hour of day (0-23) to spread load. + hour := int((seed >> 8) % 24) + return fmt.Sprintf("%d %d * * *", minute, hour), "Daily" +} + // GenerateMaintenanceWorkflow generates the agentics-maintenance.yml workflow // if any workflows use the expires field for discussions or issues. // When repoConfig is non-nil and repoConfig.MaintenanceDisabled is true the @@ -910,16 +948,17 @@ against the target repository.` // Pre-compute cron schedule values (needed in both the on: section and the // close-expired-entities job comment when hasExpires is true). - // When minExpiresDays is 0 (e.g. expiry expressed in hours < 24) we use a - // daily fallback — the same cron generated for > 4-day expiries. + // Uses fuzzy scheduling: minute and hour offsets are derived from the repo + // slug hash so that multiple side-repo workflows are scattered across the + // clock face instead of all firing at the same time. var cronSchedule, scheduleDesc string if hasExpires { - if minExpiresDays > 0 { - cronSchedule, scheduleDesc = generateMaintenanceCron(minExpiresDays) - } else { + effectiveDays := minExpiresDays + if effectiveDays == 0 { // minExpiresDays == 0 means expiry < 1 day; use a conservative daily default. - cronSchedule, scheduleDesc = generateMaintenanceCron(5) // 5 days → daily cron + effectiveDays = 5 } + cronSchedule, scheduleDesc = generateSideRepoMaintenanceCron(repoSlug, effectiveDays) } // Build the `on:` triggers. A schedule trigger is added when at least one diff --git a/pkg/workflow/maintenance_workflow_test.go b/pkg/workflow/maintenance_workflow_test.go index 55ad4c32b95..3cc20ba47c8 100644 --- a/pkg/workflow/maintenance_workflow_test.go +++ b/pkg/workflow/maintenance_workflow_test.go @@ -3,6 +3,7 @@ package workflow import ( + "fmt" "os" "path/filepath" "strings" @@ -1001,6 +1002,73 @@ func TestSanitizeRepoForFilename(t *testing.T) { } } +func TestGenerateSideRepoMaintenanceCron(t *testing.T) { + t.Run("is deterministic for the same slug", func(t *testing.T) { + cron1, desc1 := generateSideRepoMaintenanceCron("org/repo", 10) + cron2, desc2 := generateSideRepoMaintenanceCron("org/repo", 10) + if cron1 != cron2 || desc1 != desc2 { + t.Errorf("expected deterministic output, got %q/%q and %q/%q", cron1, desc1, cron2, desc2) + } + }) + + t.Run("different repos produce different cron expressions", func(t *testing.T) { + repos := []string{"org/repo-a", "org/repo-b", "another-org/service", "myorg/tooling"} + seen := make(map[string]string) + for _, repo := range repos { + cron, _ := generateSideRepoMaintenanceCron(repo, 10) + if existing, ok := seen[cron]; ok { + // Collisions are theoretically possible but should be rare for distinct slugs. + t.Logf("cron collision between %q and %q: %s", repo, existing, cron) + } + seen[cron] = repo + } + }) + + t.Run("minute is in valid range 0-59", func(t *testing.T) { + slugs := []string{"a/b", "owner/repo", "my-org/my-repo", "x/y"} + for _, slug := range slugs { + for _, days := range []int{0, 1, 2, 3, 5, 10, 30} { + cron, _ := generateSideRepoMaintenanceCron(slug, days) + // Extract the minute field (first token). + parts := strings.Fields(cron) + if len(parts) < 5 { + t.Errorf("invalid cron %q for slug=%q days=%d", cron, slug, days) + continue + } + var min int + if _, err := fmt.Sscanf(parts[0], "%d", &min); err != nil { + t.Errorf("failed to parse minute from cron %q: %v", cron, err) + continue + } + if min < 0 || min > 59 { + t.Errorf("minute %d out of range [0,59] for slug=%q days=%d", min, slug, days) + } + } + } + }) + + t.Run("frequency tier matches minExpiresDays", func(t *testing.T) { + slug := "test/repo" + cases := []struct { + days int + descContain string + }{ + {1, "Every 2 hours"}, + {2, "Every 6 hours"}, + {3, "Every 12 hours"}, + {4, "Every 12 hours"}, + {5, "Daily"}, + {30, "Daily"}, + } + for _, tc := range cases { + _, desc := generateSideRepoMaintenanceCron(slug, tc.days) + if desc != tc.descContain { + t.Errorf("days=%d: expected desc %q, got %q", tc.days, tc.descContain, desc) + } + } + }) +} + func TestGenerateSideRepoMaintenanceWorkflow(t *testing.T) { t.Run("generates file for static side-repo target", func(t *testing.T) { tmpDir := t.TempDir() @@ -1159,12 +1227,13 @@ func TestGenerateSideRepoMaintenanceWorkflow(t *testing.T) { t.Run("side-repo with expires includes schedule trigger", func(t *testing.T) { tmpDir := t.TempDir() - // Expires: 48 hours = 2 days → generateMaintenanceCron(2) = "37 */6 * * *" + // Expires: 48 hours = 2 days → generateSideRepoMaintenanceCron("org/expires-repo", 2) + repoSlug := "org/expires-repo" workflowDataList := []*WorkflowData{ { Name: "side-repo-with-expires", CheckoutConfigs: []*CheckoutConfig{ - {Repository: "org/expires-repo", Current: true}, + {Repository: repoSlug, Current: true}, }, SafeOutputs: &SafeOutputsConfig{ CreateIssues: &CreateIssuesConfig{Expires: 48}, @@ -1187,10 +1256,14 @@ func TestGenerateSideRepoMaintenanceWorkflow(t *testing.T) { if !strings.Contains(contentStr, "schedule:") { t.Errorf("Side-repo maintenance with expires should include a schedule trigger, got content length %d", len(contentStr)) } - // 48 hours = 2 days → generateMaintenanceCron(2) returns "37 */6 * * *" (Every 6 hours) - if !strings.Contains(contentStr, "37 */6 * * *") { - t.Errorf("Side-repo maintenance with 2-day expires should use 6-hour cron, got content:\n%s", contentStr[:min(500, len(contentStr))]) + // 48 hours = 2 days → generateSideRepoMaintenanceCron returns the fuzzy 6-hour cron. + expectedCron, _ := generateSideRepoMaintenanceCron(repoSlug, 2) + if !strings.Contains(contentStr, expectedCron) { + t.Errorf("Side-repo maintenance with 2-day expires should use cron %q, got content:\n%s", expectedCron, contentStr[:min(500, len(contentStr))]) } + // Verify the cron is different from the fixed minute used by the main workflow (37). + // (For this particular slug the minute should not be 37 — but the real assertion is + // that the expected fuzzy value is present, which we already checked above.) }) t.Run("stale side-repo maintenance workflow is removed on recompile", func(t *testing.T) { From 007a21ae837578c7cac9ebbbceb3e380b9b1c95c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:32:17 +0000 Subject: [PATCH 11/11] test: add integration tests for side-repo maintenance workflow generation Agent-Logs-Url: https://github.com/github/gh-aw/sessions/5287e268-8f33-4e18-8a6e-8d4de8729ed8 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../side_repo_maintenance_integration_test.go | 282 ++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 pkg/workflow/side_repo_maintenance_integration_test.go diff --git a/pkg/workflow/side_repo_maintenance_integration_test.go b/pkg/workflow/side_repo_maintenance_integration_test.go new file mode 100644 index 00000000000..dda9d21e4ef --- /dev/null +++ b/pkg/workflow/side_repo_maintenance_integration_test.go @@ -0,0 +1,282 @@ +//go:build integration + +package workflow + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// compileSideRepoWorkflow parses a workflow markdown file and returns the +// resulting workflowData plus a temp directory, so callers can then invoke +// GenerateMaintenanceWorkflow and inspect side-repo maintenance files. +func compileSideRepoWorkflow(t *testing.T, content string) ([]*WorkflowData, string) { + t.Helper() + tmpDir, err := os.MkdirTemp("", "side-repo-maint-test-*") + require.NoError(t, err, "create temp dir") + t.Cleanup(func() { os.RemoveAll(tmpDir) }) + + workflowPath := filepath.Join(tmpDir, "test-workflow.md") + require.NoError(t, os.WriteFile(workflowPath, []byte(content), 0644), "write workflow file") + + compiler := NewCompiler() + // ParseWorkflowFile populates CheckoutConfigs, SafeOutputs, and Name — + // exactly the fields examined by GenerateMaintenanceWorkflow. + workflowData, err := compiler.ParseWorkflowFile(workflowPath) + require.NoError(t, err, "parse workflow data") + + return []*WorkflowData{workflowData}, tmpDir +} + +// TestSideRepoMaintenanceWorkflowGenerated_EndToEnd verifies that compiling a +// workflow with a SideRepoOps checkout generates a side-repo maintenance file +// with the expected top-level structure. +func TestSideRepoMaintenanceWorkflowGenerated_EndToEnd(t *testing.T) { + workflowContent := `--- +on: + workflow_dispatch: +permissions: + contents: read + issues: read + pull-requests: read +engine: copilot +checkout: + repository: my-org/target-repo + current: true + github-token: ${{ secrets.GH_AW_TARGET_TOKEN }} +--- + +# Side-Repo Test Workflow + +This workflow operates on a separate repository. +` + + workflowDataList, tmpDir := compileSideRepoWorkflow(t, workflowContent) + + err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil) + require.NoError(t, err, "generate maintenance workflow") + + sideRepoFile := filepath.Join(tmpDir, "agentics-maintenance-my-org-target-repo.yml") + content, err := os.ReadFile(sideRepoFile) + require.NoError(t, err, "side-repo maintenance file should have been created") + + contentStr := string(content) + + // Workflow name reflects target repo. + assert.Contains(t, contentStr, "my-org/target-repo", + "generated workflow should reference the target repo slug") + + // Must have workflow_dispatch trigger. + assert.Contains(t, contentStr, "workflow_dispatch:", + "generated workflow should include workflow_dispatch trigger") + + // Must have workflow_call trigger. + assert.Contains(t, contentStr, "workflow_call:", + "generated workflow should include workflow_call trigger") + + // Must have apply_safe_outputs job. + assert.Contains(t, contentStr, "apply_safe_outputs:", + "generated workflow should include apply_safe_outputs job") + + // Must have create_labels job. + assert.Contains(t, contentStr, "create_labels:", + "generated workflow should include create_labels job") + + // GH_AW_TARGET_REPO_SLUG must be wired with the correct slug. + assert.Contains(t, contentStr, `GH_AW_TARGET_REPO_SLUG: "my-org/target-repo"`, + "GH_AW_TARGET_REPO_SLUG should be set to the target repo slug") + + // Custom token should appear in the generated file. + assert.Contains(t, contentStr, "secrets.GH_AW_TARGET_TOKEN", + "custom github-token should appear in the generated workflow") +} + +// TestSideRepoMaintenanceWorkflowWithExpires_EndToEnd verifies that when the +// workflow uses safe-output expiry, the side-repo file includes a schedule +// trigger with a fuzzy cron expression (not minute :00 or the fixed :37). +func TestSideRepoMaintenanceWorkflowWithExpires_EndToEnd(t *testing.T) { + workflowContent := `--- +on: + workflow_dispatch: +permissions: + contents: read + issues: read + pull-requests: read +engine: copilot +checkout: + repository: corp/infra-tools + current: true +safe-outputs: + create-issue: + expires: 14 +--- + +# Expires Test Workflow + +Create issues that expire after 14 days. +` + + workflowDataList, tmpDir := compileSideRepoWorkflow(t, workflowContent) + + err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil) + require.NoError(t, err, "generate maintenance workflow") + + sideRepoFile := filepath.Join(tmpDir, "agentics-maintenance-corp-infra-tools.yml") + content, err := os.ReadFile(sideRepoFile) + require.NoError(t, err, "side-repo maintenance file should have been created") + contentStr := string(content) + + // Must have a schedule trigger when expires is set. + assert.Contains(t, contentStr, "schedule:", + "side-repo maintenance should include schedule trigger when expires is set") + + // close-expired-entities job must be present. + assert.Contains(t, contentStr, "close-expired-entities:", + "side-repo maintenance should include close-expired-entities job when expires is set") + + // The cron expression should be present; extract it and verify it is valid. + expectedCron, _ := generateSideRepoMaintenanceCron("corp/infra-tools", 14) + assert.Contains(t, contentStr, expectedCron, + "cron expression should match the fuzzy-scheduled value for corp/infra-tools") + + // The cron minute must not be 0 or 37 (fixed values to avoid pile-up). + // We verify by checking the actual expected value contains neither ":00" nor ":37". + minute := strings.Fields(expectedCron)[0] + assert.NotEqual(t, "0", minute, + "fuzzy cron should not fire at minute 0 (likely collision with defaults)") +} + +// TestSideRepoMaintenanceWorkflowFallbackToken_EndToEnd verifies that when no +// custom token is specified in the checkout config, the generated workflow falls +// back to GH_AW_GITHUB_TOKEN. +func TestSideRepoMaintenanceWorkflowFallbackToken_EndToEnd(t *testing.T) { + workflowContent := `--- +on: + workflow_dispatch: +permissions: + contents: read + issues: read + pull-requests: read +engine: copilot +checkout: + repository: acme/shared-services + current: true +--- + +# No-token side-repo workflow. +` + + workflowDataList, tmpDir := compileSideRepoWorkflow(t, workflowContent) + + err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil) + require.NoError(t, err, "generate maintenance workflow") + + sideRepoFile := filepath.Join(tmpDir, "agentics-maintenance-acme-shared-services.yml") + content, err := os.ReadFile(sideRepoFile) + require.NoError(t, err, "side-repo maintenance file should have been created") + contentStr := string(content) + + // Fallback token should be referenced. + assert.Contains(t, contentStr, "GH_AW_GITHUB_TOKEN", + "should fall back to GH_AW_GITHUB_TOKEN when no custom token is specified") +} + +// TestNoSideRepoMaintenanceForExpressionRepository_EndToEnd verifies that +// expression-based repository values do not produce a side-repo maintenance file. +func TestNoSideRepoMaintenanceForExpressionRepository_EndToEnd(t *testing.T) { + workflowContent := `--- +on: + workflow_dispatch: + inputs: + target_repo: + description: Target repository + required: true +permissions: + contents: read +engine: copilot +checkout: + repository: ${{ inputs.target_repo }} + current: true +--- + +# Dynamic repository workflow. +` + + workflowDataList, tmpDir := compileSideRepoWorkflow(t, workflowContent) + + err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil) + require.NoError(t, err, "generate maintenance workflow") + + // No side-repo file should be created because the repository is an expression. + entries, err := os.ReadDir(tmpDir) + require.NoError(t, err) + for _, e := range entries { + assert.False(t, + strings.HasPrefix(e.Name(), "agentics-maintenance-") && e.Name() != "agentics-maintenance.yml", + "no side-repo maintenance file should be generated for expression-based repositories, got: %s", e.Name()) + } +} + +// TestSideRepoMaintenanceFuzzyScheduleScattered_EndToEnd verifies that two +// different side-repo targets receive distinct cron expressions (scattered). +func TestSideRepoMaintenanceFuzzyScheduleScattered_EndToEnd(t *testing.T) { + // Compile two separate workflows for different side-repo targets. + makeContent := func(repo string) string { + return `--- +on: + workflow_dispatch: +permissions: + contents: read + issues: read + pull-requests: read +engine: copilot +checkout: + repository: ` + repo + ` + current: true +safe-outputs: + create-issue: + expires: 30 +--- + +# Scattered cron test. +` + } + + repoA := "company/repo-alpha" + repoB := "company/repo-beta" + + cronA, _ := generateSideRepoMaintenanceCron(repoA, 30) + cronB, _ := generateSideRepoMaintenanceCron(repoB, 30) + + // Verify the crons are actually different (they should be; if they collide that + // would be a surprising FNV-1a collision and the test would rightly flag it). + assert.NotEqual(t, cronA, cronB, + "different side-repo targets should get different cron expressions to avoid simultaneous runs") + + // Compile both and verify each generated file contains its own cron. + for _, tc := range []struct { + repo string + cron string + }{ + {repoA, cronA}, + {repoB, cronB}, + } { + t.Run(tc.repo, func(t *testing.T) { + wdl, tmpDir := compileSideRepoWorkflow(t, makeContent(tc.repo)) + require.NoError(t, GenerateMaintenanceWorkflow(wdl, tmpDir, "v1.0.0", ActionModeDev, "", false, nil)) + + slug := sanitizeRepoForFilename(tc.repo) + sideFile := filepath.Join(tmpDir, "agentics-maintenance-"+slug+".yml") + fileContent, err := os.ReadFile(sideFile) + require.NoError(t, err, "side-repo file should exist for %s", tc.repo) + + assert.Contains(t, string(fileContent), tc.cron, + "generated file for %s should contain cron %s", tc.repo, tc.cron) + }) + } +}