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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
---
name: 34526-ghe-shorthand-cross-host-fallback-for-public-orgs
description: Route shorthand workflow specs under known public orgs to github.com from GHE contexts and emit actionable 404 hints
---

# ADR-34526: GHE Shorthand Cross-Host Fallback for Known Public Orgs

**Date**: 2026-05-24
**Status**: Draft
**Deciders**: Unknown (PR #34526)

---

## Part 1 — Narrative (Human-Friendly)

### Context

`gh aw add-wizard` accepts shorthand workflow specs of the form `owner/repo/workflow-name[@version]`. When the active GitHub host is a GitHub Enterprise (GHE) server, shorthand resolution previously routed all owners through the enterprise host. Public workflow source repositories such as `github/*`, `githubnext/agentics`, and `microsoft/*` exist only on github.com, so resolving them on a GHE host produced misleading 404 / ref-resolution errors that gave the user no path forward. The codebase already had a narrow special-case for `github/gh-aw` and `githubnext/agentics`, but the set of "always public" sources needed to widen, and the failure mode needed user-actionable remediation guidance instead of an opaque 404.

### Decision

We will route shorthand specs whose owner is `github`, `githubnext`, or `microsoft` to `https://github.com` even when the caller is configured against a GHE host, and we will emit a 404-aware cross-host hint that includes a concrete `gh aw add-wizard https://github.com/...` command whenever ref→SHA resolution fails on a non-github.com host. The same owner allowlist is applied in both `pkg/cli` and `pkg/parser` host-resolution paths to prevent split behavior between the CLI command surface and the parser. `add-wizard` long help is updated to state plainly that shorthand resolves on the enterprise host and that full `https://github.com/...` URLs should be used for public github.com sources.

### Alternatives Considered

#### Alternative 1: Configurable allowlist (no hardcoded orgs)

Expose the "always public" owner allowlist via a config file or environment variable so enterprise administrators could extend or override it. This was not chosen because the current set of public source orgs is small, well-known, and tied to first-party workflow libraries shipped with `gh-aw`; introducing a config surface would add validation, precedence, and documentation complexity for a feature that has only three known consumers today. The allowlist can be promoted to configuration later if a fourth public org or an enterprise-specific override becomes necessary.

#### Alternative 2: Probe-then-fallback (try GHE, then github.com on 404)

Attempt resolution against the configured GHE host first and automatically retry against github.com on 404. This was not chosen because it doubles request latency on the common error path, surfaces unrelated GHE outages as confusing fallback noise, and silently hides the fact that the user's spec was ambiguous about which host it targeted. Surfacing an actionable hint and requiring the user to either use a known public org or supply a full URL keeps host intent explicit.

#### Alternative 3: Reject shorthand outright on GHE hosts

Require full URLs for any non-GHE source whenever the active host is GHE. This was not chosen because it breaks ergonomics for the most common public sources (`github/*`, `githubnext/agentics`) that GHE users routinely consume, and would force every example, doc, and tutorial to be rewritten in URL form.

### Consequences

#### Positive
- GHE users can run `gh aw add-wizard github/...`, `githubnext/...`, and `microsoft/...` shorthand specs without manual URL construction.
- When shorthand fails on a non-github.com host, the error message now contains a copy-pasteable `gh aw add-wizard https://github.com/...` command, turning a dead-end 404 into a one-step remediation.
- CLI and parser host-resolution paths share the same allowlist, eliminating the prior risk of one surface routing to github.com while the other routed to GHE for the same spec.

#### Negative
- The set of "always public" orgs is hardcoded in two files (`pkg/cli/github.go`, `pkg/parser/github.go`); adding a fourth org requires a coordinated edit in both locations.
- GHE deployments that legitimately mirror `github/<repo>`, `githubnext/<repo>`, or `microsoft/<repo>` on their enterprise host can no longer resolve those shorthand specs against the GHE mirror — they must use a different owner or a full URL.
- The 404-hint heuristic relies on substring matching against error text (`"not found"`, `"http 404"`), which is brittle if the underlying GitHub API client changes its error formatting.

#### Neutral
- The hardcoded allowlist replaces the previous narrower hardcoded list (`github/gh-aw`, `githubnext/agentics`); the mechanism is unchanged, only the membership widened.
- The hint is suppressed when the resolved host is empty or already `github.com`, so behavior on public-GitHub-only deployments is unchanged.

---

## 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).

### Host Resolution for Shorthand Specs

1. Implementations **MUST** route shorthand workflow specs whose owner is `github`, `githubnext`, or `microsoft` to `https://github.com`, regardless of the configured GitHub Enterprise host.
2. Implementations **MUST NOT** route shorthand specs for owners outside the public-org allowlist to `https://github.com` when a GHE host is configured; such specs **MUST** continue to resolve against the configured host.
3. The public-org allowlist used by the CLI host-resolution path (`pkg/cli`) and the parser host-resolution path (`pkg/parser`) **MUST** be identical.
4. Implementations **SHOULD** log a diagnostic message identifying the public org whenever the github.com fallback is applied.

### Cross-Host Failure Hints

1. When ref→SHA resolution fails with an error whose text contains `not found` or `http 404` (case-insensitive), and the resolved host is non-empty and not `github.com`, implementations **MUST** emit a user-facing hint that:
1. names the host on which resolution was attempted, and
2. includes a concrete `gh aw add-wizard https://github.com/<owner>/<repo>/blob/<ref>/<workflow-path>` command using the original spec components.
2. Implementations **MUST NOT** emit the cross-host hint when the resolved host is `github.com` or when the resolved host is empty.
3. Implementations **MUST NOT** emit the cross-host hint for `nil` errors or for errors whose text does not match the 404 substrings above.
4. Implementations **MUST** normalize a leading slash from the workflow path before constructing the suggested URL so that the URL does not contain `blob/<ref>//<path>`.

### User-Facing Documentation

1. The `add-wizard` command long help **MUST** state that, in GitHub Enterprise repositories, shorthand specs resolve on the enterprise host and that full `https://github.com/...` URLs are required when sourcing public github.com workflows.

### 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/26372124288) workflow. The PR author must review, complete, and finalize this document before the PR can merge.*
3 changes: 3 additions & 0 deletions pkg/cli/add_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ The --dir flag allows you to specify the workflow directory (default: .github/wo
The --create-pull-request flag creates a pull request with the workflow changes.
The --force flag overwrites existing workflow files.
Note: In GitHub Enterprise repos, shorthand source specs resolve on your enterprise host by default.
For github/*, githubnext/*, and microsoft/* sources, shorthand resolves on github.com.
Use full https://github.com/... source URLs for other public github.com workflows.
Note: To create a new workflow from scratch, use the 'new' command instead.
Note: For guided interactive setup, use the 'add-wizard' command instead.`,
Args: func(cmd *cobra.Command, args []string) error {
Expand Down
9 changes: 9 additions & 0 deletions pkg/cli/add_command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,15 @@ func TestNewAddCommand(t *testing.T) {
assert.NotNil(t, stopAfterFlag, "Should have 'stop-after' flag")
}

func TestNewAddCommand_MentionsEnterpriseSourceResolution(t *testing.T) {
cmd := NewAddCommand(validateEngineStub)
require.NotNil(t, cmd)

assert.Contains(t, cmd.Long, "Note: In GitHub Enterprise repos, shorthand source specs resolve on your enterprise host by default.")
assert.Contains(t, cmd.Long, "For github/*, githubnext/*, and microsoft/* sources, shorthand resolves on github.com.")
assert.Contains(t, cmd.Long, "Use full https://github.com/... source URLs for other public github.com workflows.")
}

func TestAddWorkflows(t *testing.T) {
tests := []struct {
name string
Expand Down
3 changes: 3 additions & 0 deletions pkg/cli/add_wizard_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ Workflow specifications:
- Version can be tag, branch, or SHA (for remote workflows)
Note: Requires an interactive terminal. Use 'add' for CI/automation environments.
Note: In GitHub Enterprise repos, shorthand specs resolve on your enterprise host by default.
For github/*, githubnext/*, and microsoft/*, shorthand resolves on github.com.
Use full https://github.com/... URLs when sourcing other public github.com workflows.
Note: To create a new workflow from scratch, use the 'new' command instead.`,
Args: func(cmd *cobra.Command, args []string) error {
if len(args) < 1 {
Expand Down
3 changes: 3 additions & 0 deletions pkg/cli/add_wizard_command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,7 @@ func TestAddWizardCommand_UsesStandardThreePartWorkflowSpecWording(t *testing.T)
require.NotNil(t, cmd)

assert.Contains(t, cmd.Long, `Three parts: "owner/repo/workflow-name[@version]" (implicitly looks in workflows/ directory)`)
assert.Contains(t, cmd.Long, "shorthand specs resolve on your enterprise host by default.")
assert.Contains(t, cmd.Long, "For github/*, githubnext/*, and microsoft/*, shorthand resolves on github.com.")
assert.Contains(t, cmd.Long, "Use full https://github.com/... URLs when sourcing other public github.com workflows.")
}
146 changes: 73 additions & 73 deletions pkg/cli/compile_schedule_calendar_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -317,78 +317,78 @@ func TestDisplayScheduleCalendar_ContainsAllHourHeaders(t *testing.T) {
// 3. buildScheduleGrid registers at least one non-zero slot for the workflow.
// 4. displayScheduleCalendar produces output that contains the workflow name.
func TestFuzzyScheduleEndToEnd(t *testing.T) {
fuzzyExpressions := []struct {
fuzzyCron string
workflowID string
expectedHours int // how many distinct hour values we expect (1 for DAILY patterns)
}{
{"FUZZY:DAILY * * *", "ci-doctor", 1},
{"FUZZY:DAILY_WEEKDAYS * * *", "daily-planner", 1},
{"FUZZY:DAILY_AROUND:14:0 * * *", "weekly-audit", 1},
}

for _, tt := range fuzzyExpressions {
t.Run(fmt.Sprintf("%s/%s", tt.fuzzyCron, tt.workflowID), func(t *testing.T) {
// Step 1: scatter the fuzzy expression to a real cron string.
scatteredCron, err := parser.ScatterSchedule(tt.fuzzyCron, tt.workflowID)
require.NoError(t, err, "ScatterSchedule should not error for %s", tt.fuzzyCron)
require.NotEmpty(t, scatteredCron, "ScatterSchedule should return a non-empty cron")
require.False(t, strings.HasPrefix(scatteredCron, "FUZZY:"),
"scattered result must not be a fuzzy expression: %s", scatteredCron)

fields := strings.Fields(scatteredCron)
require.Len(t, fields, 5,
"scattered cron %q must have exactly 5 fields", scatteredCron)

// Step 2: parse the scattered cron with parseCronSchedule.
hours, daysOfWeek, err := parseCronSchedule(scatteredCron)
require.NoError(t, err,
"parseCronSchedule should accept scattered cron %q", scatteredCron)
assert.Len(t, hours, tt.expectedHours,
"expected %d distinct hour(s) for %s", tt.expectedHours, tt.fuzzyCron)
assert.NotEmpty(t, daysOfWeek,
"daysOfWeek should be non-empty for %s", tt.fuzzyCron)

// Step 3: buildScheduleGrid should register at least one slot.
lockName := tt.workflowID + ".lock.yml"
statsList := []*WorkflowStats{
{Workflow: lockName, Schedules: []string{scatteredCron}},
}
grid := buildScheduleGrid(statsList)
require.NotNil(t, grid, "buildScheduleGrid should return non-nil grid")
fuzzyExpressions := []struct {
fuzzyCron string
workflowID string
expectedHours int // how many distinct hour values we expect (1 for DAILY patterns)
}{
{"FUZZY:DAILY * * *", "ci-doctor", 1},
{"FUZZY:DAILY_WEEKDAYS * * *", "daily-planner", 1},
{"FUZZY:DAILY_AROUND:14:0 * * *", "weekly-audit", 1},
}

totalSlots := 0
for _, day := range grid {
for _, count := range day {
totalSlots += count
}
}
assert.Greater(t, totalSlots, 0,
"grid should contain at least one scheduled slot for %s", scatteredCron)

// Step 4: displayScheduleCalendar should produce output referencing the hour.
oldStderr := os.Stderr
r, w, pipeErr := os.Pipe()
require.NoError(t, pipeErr)
os.Stderr = w

displayScheduleCalendar(statsList)

w.Close()
os.Stderr = oldStderr

var buf bytes.Buffer
_, _ = buf.ReadFrom(r)
output := buf.String()

assert.Contains(t, output, "Schedule Heatmap",
"output should contain Schedule Heatmap header")
// The hour from the scattered cron should appear in the output.
for _, h := range hours {
hourStr := fmt.Sprintf("%02d", h)
assert.Contains(t, output, hourStr,
"output should contain hour %s from scattered cron %s", hourStr, scatteredCron)
}
})
}
for _, tt := range fuzzyExpressions {
t.Run(fmt.Sprintf("%s/%s", tt.fuzzyCron, tt.workflowID), func(t *testing.T) {
// Step 1: scatter the fuzzy expression to a real cron string.
scatteredCron, err := parser.ScatterSchedule(tt.fuzzyCron, tt.workflowID)
require.NoError(t, err, "ScatterSchedule should not error for %s", tt.fuzzyCron)
require.NotEmpty(t, scatteredCron, "ScatterSchedule should return a non-empty cron")
require.False(t, strings.HasPrefix(scatteredCron, "FUZZY:"),
"scattered result must not be a fuzzy expression: %s", scatteredCron)

fields := strings.Fields(scatteredCron)
require.Len(t, fields, 5,
"scattered cron %q must have exactly 5 fields", scatteredCron)

// Step 2: parse the scattered cron with parseCronSchedule.
hours, daysOfWeek, err := parseCronSchedule(scatteredCron)
require.NoError(t, err,
"parseCronSchedule should accept scattered cron %q", scatteredCron)
assert.Len(t, hours, tt.expectedHours,
"expected %d distinct hour(s) for %s", tt.expectedHours, tt.fuzzyCron)
assert.NotEmpty(t, daysOfWeek,
"daysOfWeek should be non-empty for %s", tt.fuzzyCron)

// Step 3: buildScheduleGrid should register at least one slot.
lockName := tt.workflowID + ".lock.yml"
statsList := []*WorkflowStats{
{Workflow: lockName, Schedules: []string{scatteredCron}},
}
grid := buildScheduleGrid(statsList)
require.NotNil(t, grid, "buildScheduleGrid should return non-nil grid")

totalSlots := 0
for _, day := range grid {
for _, count := range day {
totalSlots += count
}
}
assert.Positive(t, totalSlots,
"grid should contain at least one scheduled slot for %s", scatteredCron)

// Step 4: displayScheduleCalendar should produce output referencing the hour.
oldStderr := os.Stderr
r, w, pipeErr := os.Pipe()
require.NoError(t, pipeErr)
os.Stderr = w

displayScheduleCalendar(statsList)

w.Close()
os.Stderr = oldStderr

var buf bytes.Buffer
_, _ = buf.ReadFrom(r)
output := buf.String()

assert.Contains(t, output, "Schedule Heatmap",
"output should contain Schedule Heatmap header")
// The hour from the scattered cron should appear in the output.
for _, h := range hours {
hourStr := fmt.Sprintf("%02d", h)
assert.Contains(t, output, hourStr,
"output should contain hour %s from scattered cron %s", hourStr, scatteredCron)
}
})
}
}
45 changes: 45 additions & 0 deletions pkg/cli/fetch.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,12 @@ func resolveCommitSHAWithRetries(ctx context.Context, owner, repo, ref, workflow

if !isTransientSHAResolutionError(err) {
retryCommand := fmt.Sprintf("gh aw add %s/%s/%s@<40-char-sha>", owner, repo, workflowPath)
if hostHint, ok := hostResolutionHintForNotFound(owner, repo, ref, workflowPath, host, err); ok {
return "", fmt.Errorf(
"failed to resolve '%s' to commit SHA for '%s/%s'. Expected the GitHub API to return a commit SHA for the ref. %s: %w",
ref, owner, repo, hostHint, err,
)
}
return "", fmt.Errorf(
"failed to resolve '%s' to commit SHA for '%s/%s'. Expected the GitHub API to return a commit SHA for the ref. Try: %s: %w",
ref, owner, repo, retryCommand, err,
Expand Down Expand Up @@ -202,6 +208,45 @@ func resolveCommitSHAWithRetries(ctx context.Context, owner, repo, ref, workflow
)
}

// hostResolutionHintForNotFound returns a user-facing hint and whether it is applicable.
// hasHint is true only for 404-style resolution failures on non-github.com hosts.
func hostResolutionHintForNotFound(owner, repo, ref, workflowPath, explicitHost string, err error) (hint string, hasHint bool) {
if err == nil {
return "", false
}

errorText := strings.ToLower(err.Error())
if !strings.Contains(errorText, "http 404") && !strings.Contains(errorText, "status 404") {
return "", false
}

normalizedExplicitHost := normalizeHostForHint(explicitHost)
if normalizedExplicitHost != "" {
return "", false
}

resolvedHost := normalizeHostForHint(getGitHubHost())
if resolvedHost == "" || resolvedHost == "github.com" {
return "", false
}

trimmedPath := strings.TrimPrefix(workflowPath, "/")
fullURL := fmt.Sprintf("https://github.com/%s/%s/blob/%s/%s", owner, repo, ref, trimmedPath)
Comment thread
pelikhan marked this conversation as resolved.
return fmt.Sprintf(
"Shorthand specs resolved on %s. Try using a full github.com source URL instead (for example: gh aw add %s)",
resolvedHost, fullURL,
), true
}

func normalizeHostForHint(host string) string {
host = strings.TrimSpace(strings.ToLower(host))
host = strings.TrimPrefix(strings.TrimPrefix(host, "https://"), "http://")
if idx := strings.Index(host, "/"); idx >= 0 {
host = host[:idx]
}
return strings.TrimSuffix(host, "/")
}

// sleepForSHAResolutionRetry waits for the retry delay or context cancellation.
// It returns ctx.Err() when the context is cancelled before the delay elapses,
// otherwise nil when the delay completes normally.
Expand Down
Loading
Loading