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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Virtual subdirectory and raw-file packages now resolve from self-hosted Git services (Gitea, Gogs) via raw URL with API v1/v3 fallback. (#587)
- `shared/apm.md` gh-aw shared workflow exposes a `target:` import input (default `all`) so consumer workflows can ship slim, single-harness bundles instead of always packing every layout. (#1184)
- If you use the `gh` CLI, APM is now zero-config for private GitHub packages on github.com, `*.ghe.com`, and GHES: APM uses your active `gh auth login` token (`gh auth token --hostname <host>`) before falling back to `git credential fill`. Silently skipped when `gh` is not installed or not logged in for the host. (#630)

### Fixed

Expand Down
43 changes: 25 additions & 18 deletions docs/src/content/docs/getting-started/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@ APM resolves tokens per `(host, org)` pair. For each dependency, it walks a reso

1. **Per-org env var** — `GITHUB_APM_PAT_{ORG}` (GitHub-like hosts — not ADO)
2. **Global env vars** — `GITHUB_APM_PAT` → `GITHUB_TOKEN` → `GH_TOKEN` (any host)
3. **Git credential helper** — `git credential fill` (any host except ADO)
3. **GitHub CLI active account** — `gh auth token --hostname <host>` (GitHub-like hosts; silently skipped if `gh` is not installed or not logged in)
4. **Git credential helper** — `git credential fill` (any host except ADO)

If the global token doesn't work for the target host, APM automatically retries with git credential helpers. If nothing matches, APM attempts unauthenticated access (works for public repos on github.com).
Steps 1 and 2 cover the four token-priority rows in the table below (priorities 1-4). The numbering above collapses the three global env vars (`GITHUB_APM_PAT`, `GITHUB_TOKEN`, `GH_TOKEN`) into a single resolution step.

If the global token doesn't work for the target host, APM next tries the active `gh` CLI account before falling back to git credential helpers. If nothing matches, APM attempts unauthenticated access (works for public repos on github.com).

Results are cached per-process — the same `(host, org)` pair is resolved once.

Expand All @@ -30,7 +33,8 @@ All token-bearing requests use HTTPS. Tokens are never sent over unencrypted con
| 2 | `GITHUB_APM_PAT` | Any host | Falls back to git credential helpers if rejected |
| 3 | `GITHUB_TOKEN` | Any host | Shared with GitHub Actions |
| 4 | `GH_TOKEN` | Any host | Set by `gh auth login` |
| 5 | `git credential fill` | Per-host | System credential manager, `gh auth`, OS keychain |
| 5 | `gh auth token --hostname <host>` | GitHub-like hosts | Active `gh auth login` account |
| 6 | `git credential fill` | Per-host | System credential manager, `gh auth`, OS keychain |

For Azure DevOps, APM resolves credentials in this order: `ADO_APM_PAT` env var, then a Microsoft Entra ID (AAD) bearer token from the Azure CLI (`az`). See [Azure DevOps](#azure-devops) below.

Expand Down Expand Up @@ -213,10 +217,10 @@ When authentication fails, APM prints a targeted diagnostic instead of a generic

| Package source | Host | Auth behavior | Fallback |
|---|---|---|---|
| `org/repo` (bare) | `default_host()` | Global env vars → credential fill | Unauth for public repos |
| `github.com/org/repo` | github.com | Global env vars → credential fill | Unauth for public repos |
| `contoso.ghe.com/org/repo` | *.ghe.com | Global env vars → credential fill | Auth-only (no public repos) |
| GHES via `GITHUB_HOST` | ghes.company.com | Global env vars → credential fill | Unauth for public repos |
| `org/repo` (bare) | `default_host()` | Global env vars → `gh auth token` → credential fill | Unauth for public repos |
| `github.com/org/repo` | github.com | Global env vars → `gh auth token` → credential fill | Unauth for public repos |
| `contoso.ghe.com/org/repo` | *.ghe.com | Global env vars → `gh auth token` → credential fill | Auth-only (no public repos) |
| GHES via `GITHUB_HOST` | ghes.company.com | Global env vars → `gh auth token` → credential fill | Unauth for public repos |
| `dev.azure.com/org/proj/repo` | ADO | `ADO_APM_PAT` -> AAD bearer via `az` | Auth-only |
| Artifactory registry proxy | custom FQDN | `PROXY_REGISTRY_TOKEN` | Error if `PROXY_REGISTRY_ONLY=1` |

Expand Down Expand Up @@ -312,21 +316,24 @@ flowchart TD
B -->|GITHUB_APM_PAT_ORG| C[Use per-org token]
B -->|Not set| D{Global env var?}
D -->|GITHUB_APM_PAT / GITHUB_TOKEN / GH_TOKEN| E[Use global token]
D -->|Not set| F{Git credential fill?}
F -->|Found| G[Use credential]
F -->|Not found| H[No token]
D -->|Not set| F{gh auth token?<br/>GitHub-like hosts only}
F -->|Found| G[Use gh token]
F -->|Not found| H{Git credential fill?}
H -->|Found| J[Use credential]
H -->|Not found| K[No token]

E --> I{try_with_fallback}
C --> I
G --> I
H --> I

I -->|Token works| J[Success]
I -->|Token fails| K{Credential-fill fallback}
K -->|Found credential| J
K -->|No credential| L{Host has public repos?}
L -->|Yes| M[Try unauthenticated]
L -->|No| N[Auth error with actionable message]
J --> I
K --> I

I -->|Token works| L[Success]
I -->|Token fails| M{Fallback credentials}
M -->|gh or git credential found| L
M -->|No credential| N{Host has public repos?}
N -->|Yes| O[Try unauthenticated]
N -->|No| P[Auth error with actionable message]
```

### Git credential helper not found
Expand Down
4 changes: 4 additions & 0 deletions docs/src/content/docs/getting-started/quick-start.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ This is where it gets interesting. Install a package and watch what happens:
apm install microsoft/apm-sample-package#v1.0.0
```

:::tip[Already use the gh CLI?]
If you are logged in with `gh auth login`, APM is already authenticated for private GitHub packages on github.com, `*.ghe.com`, and GHES -- no env vars to set.
:::

APM downloads the package, resolves its dependencies, and deploys files directly into the directories your AI tools already watch:

```
Expand Down
7 changes: 5 additions & 2 deletions packages/apm-guide/.apm/skills/apm-usage/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,12 @@ APM checks these sources in order, using the first valid token found:
| 2 | `GITHUB_APM_PAT` | Global | Falls back to git credential if rejected |
| 3 | `GITHUB_TOKEN` | Global | Shared with GitHub Actions |
| 4 | `GH_TOKEN` | Global | Set by `gh auth login` |
| 5 | `git credential fill` | Per-host | System credential manager |
| 5 | `gh auth token --hostname <host>` | GitHub-like hosts | Active `gh auth login` account |
| 6 | `git credential fill` | Per-host | System credential manager |
| -- | None | -- | Unauthenticated (public GitHub repos only) |

APM checks the active `gh` CLI account before invoking OS credential helpers. This reduces ambiguous multi-account prompts on hosts like github.com. If the `gh` CLI is not installed or no account is active, APM skips this step silently and continues to `git credential fill`.

## Per-org setup

Use per-org tokens when accessing packages across multiple organizations:
Expand Down Expand Up @@ -143,7 +146,7 @@ apm install --verbose owner/repo/path#v1.2.0
# Diagnose the auth chain -- shows which token source is used
apm install --verbose your-org/package

# Increase git credential timeout (default 30s, max 180s)
# Increase git credential timeout (default 60s, max 180s)
export APM_GIT_CREDENTIAL_TIMEOUT=120
```

Expand Down
44 changes: 36 additions & 8 deletions src/apm_cli/core/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,8 @@ def try_with_fallback(

When the resolved token comes from a global env var and fails
(e.g. a github.com PAT tried on ``*.ghe.com``), the method
retries with ``git credential fill`` before giving up.
retries with ``gh auth token`` and then ``git credential fill``
before giving up.
"""
auth_ctx = self.resolve(host, org, port=port)
host_info = auth_ctx.host_info
Expand All @@ -355,21 +356,41 @@ def _log(msg: str) -> None:
verbose_callback(msg)

def _try_credential_fallback(exc: Exception) -> T:
"""Retry with git-credential-fill when an env-var token fails."""
if auth_ctx.source in ("git-credential-fill", "none"):
"""Retry the operation when the originally-resolved token fails.

Walks the secondary chain in order: gh CLI (GitHub-like hosts;
internal guard short-circuits unsupported hosts), then
``git credential fill``. Sources already obtained from a
secondary chain (``gh-auth-token``, ``git-credential-fill``,
``none``) skip retry to avoid double-invocation.
"""
if auth_ctx.source in ("gh-auth-token", "git-credential-fill", "none"):
raise exc
# ADO uses ADO_APM_PAT + AAD bearer fallback; credential fill is out of scope.
if host_info.kind == "ado":
raise exc
_log(
f"Token from {auth_ctx.source} failed, trying git credential fill "
f"for {host_info.display_name}"
f"Token from {auth_ctx.source} failed for {host_info.display_name}; "
"trying secondary credential sources"
)
_log(f"trying gh auth token for {host_info.display_name}")
gh_token = self._token_manager.resolve_credential_from_gh_cli(host_info.host)
if gh_token:
_log(f"gh auth token resolved a credential for {host_info.display_name}")
return operation(
gh_token,
self._build_git_env(gh_token, scheme="basic", host_kind=host_info.kind),
)
_log(f"trying git credential fill for {host_info.display_name}")
cred = self._token_manager.resolve_credential_from_git(
host_info.host, port=host_info.port
)
if cred:
Comment thread
awakecoding marked this conversation as resolved.
return operation(cred, self._build_git_env(cred))
_log(f"git credential fill resolved a credential for {host_info.display_name}")
return operation(
cred,
self._build_git_env(cred, scheme="basic", host_kind=host_info.kind),
)
raise exc

# ADO bearer fallback machinery (PAT was tried first; bearer is the safety net)
Expand Down Expand Up @@ -641,7 +662,8 @@ def _resolve_token(self, host_info: HostInfo, org: str | None) -> tuple[str | No
2. Global env vars ``GITHUB_APM_PAT`` -> ``GITHUB_TOKEN`` -> ``GH_TOKEN``
(any host -- if the token is wrong for the target host,
``try_with_fallback`` retries with git credentials)
3. Git credential helper (any host except ADO)
3. gh CLI active account (GitHub-like hosts only)
4. Git credential helper (any host except ADO)

Resolution order (ADO):
1. ``ADO_APM_PAT`` env var -> scheme ``"basic"``
Expand Down Expand Up @@ -688,7 +710,13 @@ def _resolve_token(self, host_info: HostInfo, org: str | None) -> tuple[str | No
source = self._identify_env_source(purpose)
return token, source, "basic"

# 3. Git credential helper (not for ADO)
# 3. gh CLI active account (eligibility gated inside the call;
# unsupported hosts return None instantly without a subprocess)
gh_token = self._token_manager.resolve_credential_from_gh_cli(host_info.host)
if gh_token:
return gh_token, "gh-auth-token", "basic"

# 4. Git credential helper (not for ADO)
if host_info.kind not in ("ado",):
credential = self._token_manager.resolve_credential_from_git(
host_info.host, port=host_info.port
Expand Down
90 changes: 86 additions & 4 deletions src/apm_cli/core/token_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,28 @@
- GITHUB_TOKEN: User-scoped PAT for GitHub Models API access

Platform Token Selection:
- GitHub: GITHUB_APM_PAT -> GITHUB_TOKEN -> GH_TOKEN -> git credential helpers
- GitHub: GITHUB_APM_PAT -> GITHUB_TOKEN -> GH_TOKEN -> gh auth token -> git credential helpers
- Azure DevOps: ADO_APM_PAT

Runtime Requirements:
- Codex CLI: Uses GITHUB_TOKEN (must be user-scoped for GitHub Models)
"""

import logging
import os
import subprocess
import sys
from typing import Dict, Optional, Tuple # noqa: F401, UP035

from apm_cli.utils.github_host import (
default_host,
is_azure_devops_hostname,
is_github_hostname,
is_valid_fqdn,
)

logger = logging.getLogger(__name__)


def _format_credential_host(host: str, port: int | None) -> str:
"""Embed a custom port into the git credential ``host`` field.
Expand Down Expand Up @@ -93,6 +103,24 @@ def _is_valid_credential_token(token: str) -> bool:
return False
return True

@staticmethod
def _supports_gh_cli_host(host: str | None) -> bool:
"""Return True when *host* should use gh CLI fallback."""
if not host:
return False
if is_github_hostname(host):
return True

configured_host = default_host().lower()
host_lower = host.lower()
if host_lower != configured_host:
return False
if configured_host == "github.com" or configured_host.endswith(".ghe.com"):
return False
if is_azure_devops_hostname(configured_host):
return False
return is_valid_fqdn(configured_host)

# `git credential fill` may invoke OS credential helpers that show
# interactive dialogs (e.g. Windows Credential Manager account picker).
# The 60s default prevents false negatives on slow helpers.
Expand Down Expand Up @@ -159,6 +187,52 @@ def resolve_credential_from_git(host: str, port: int | None = None) -> str | Non
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
return None

@staticmethod
def resolve_credential_from_gh_cli(host: str | None) -> str | None:
"""Resolve a token from the active gh CLI account for *host*.

Uses ``gh auth token --hostname <host>`` as a non-interactive fallback
before invoking OS credential helpers that may display UI.

Eligibility is gated by :meth:`_supports_gh_cli_host` so all callers
share one path: hosts the gh CLI does not support (None/empty, ADO,
unrelated FQDNs) return ``None`` immediately without spawning a
subprocess. A non-zero exit, invalid output, missing ``gh`` binary,
or timeout all return ``None``; ``stderr`` is debug-logged on
non-zero exit so ``--verbose`` users can see why the call missed.
"""
if not GitHubTokenManager._supports_gh_cli_host(host):
return None
try:
result = subprocess.run(
["gh", "auth", "token", "--hostname", host],
capture_output=True,
text=True,
encoding="utf-8",
timeout=GitHubTokenManager._get_credential_timeout(),
stdin=subprocess.DEVNULL,
env={
**os.environ,
"GH_PROMPT_DISABLED": "1",
"GH_NO_UPDATE_NOTIFIER": "1",
},
)
if result.returncode != 0:
logger.debug(
"gh auth token failed for %s: %s",
host,
(result.stderr or "").strip()[:200],
)
return None

token = result.stdout.strip()
if token and GitHubTokenManager._is_valid_credential_token(token):
return token
return None
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
logger.debug("gh auth token errored for %s: %s", host, exc)
return None

def setup_environment(self, env: dict[str, str] | None = None) -> dict[str, str]:
"""Set up complete token environment for all runtimes.

Expand Down Expand Up @@ -214,9 +288,10 @@ def get_token_with_credential_fallback(
"""Get token for a purpose, falling back to git credential helpers.

Tries environment variables first (via get_token_for_purpose), then
queries the git credential store as a last resort. Results are cached
per ``(host, port)`` to avoid repeated subprocess calls while keeping
same-host-different-port credentials separate.
checks the active gh CLI account, then queries the git credential
store as a last resort. Results are cached per ``(host, port)`` to
avoid repeated subprocess calls while keeping same-host-different-port
credentials separate.

Args:
purpose: Token purpose ('modules', etc.)
Expand All @@ -237,6 +312,13 @@ def get_token_with_credential_fallback(
if cache_key in self._credential_cache:
return self._credential_cache[cache_key]

gh_token = None
if self._supports_gh_cli_host(host):
gh_token = self.resolve_credential_from_gh_cli(host)
if gh_token:
self._credential_cache[cache_key] = gh_token
return gh_token

credential = self.resolve_credential_from_git(host, port=port)
self._credential_cache[cache_key] = credential
return credential
Expand Down
Loading
Loading