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
33 changes: 30 additions & 3 deletions .github/workflows/build-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -554,11 +554,38 @@ jobs:
./dist/apm-windows-x86_64.zip
./dist/apm-windows-x86_64.zip.sha256

# GH-AW Compatibility Gate — validates the released binary works in the
# exact flow GitHub Agentic Workflows uses (isolated install + pack, no token).
# Gates publish-pypi and update-homebrew so broken versions don't reach stable distribution.
gh-aw-compat:
name: GH-AW Compatibility
needs: [create-release]
if: github.ref_type == 'tag'
runs-on: ubuntu-24.04
steps:
- name: Install and pack with apm-action
uses: microsoft/apm-action@v1
id: pack
with:
dependencies: |
- microsoft/apm-sample-package
isolated: 'true'
pack: 'true'
archive: 'true'
target: claude
apm-version: ${{ github.ref_name }}
working-directory: /tmp/gh-aw-compat-test

- name: Verify bundle
run: |
test -f "${{ steps.pack.outputs.bundle-path }}"
echo "✅ GH-AW compatibility test passed"

# Publish to PyPI (only stable releases from public repo)
publish-pypi:
name: Publish to PyPI
runs-on: ubuntu-latest
needs: [test, build, integration-tests, release-validation, create-release]
needs: [test, build, integration-tests, release-validation, create-release, gh-aw-compat]
Comment on lines +557 to +588
if: github.ref_type == 'tag' && needs.create-release.outputs.is_private_repo != 'true' && needs.create-release.outputs.is_prerelease != 'true'
environment:
name: pypi
Expand Down Expand Up @@ -596,7 +623,7 @@ jobs:
update-homebrew:
name: Update Homebrew Formula
runs-on: ubuntu-latest
needs: [test, build, integration-tests, release-validation, create-release, publish-pypi]
needs: [test, build, integration-tests, release-validation, create-release, gh-aw-compat, publish-pypi]
if: github.ref_type == 'tag' && needs.create-release.outputs.is_private_repo != 'true' && needs.create-release.outputs.is_prerelease != 'true'
permissions:
contents: read
Expand Down Expand Up @@ -661,7 +688,7 @@ jobs:
update-scoop:
name: Update Scoop Bucket
runs-on: ubuntu-latest
needs: [test, build, integration-tests, release-validation, create-release, publish-pypi]
needs: [test, build, integration-tests, release-validation, create-release, gh-aw-compat, publish-pypi]
# TODO: Enable once downstream repository and secrets are configured (see #88)
if: false && github.ref_type == 'tag' && needs.create-release.outputs.is_private_repo != 'true' && needs.create-release.outputs.is_prerelease != 'true'
permissions:
Expand Down
62 changes: 61 additions & 1 deletion scripts/test-release-validation.sh
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,59 @@ test_basic_commands() {
log_success "Basic commands work"
}

# GH-AW compatibility test - replicates the exact flow gh-aw uses:
# 1. Install a public package in isolated mode (no token)
# 2. Pack for Claude target with archive
# This catches regressions like v0.8.1 where credential fill garbage
# broke public repo cloning in tokenless environments.
test_ghaw_compat() {
log_test "GH-AW Compatibility: tokenless isolated install + pack"

local ghaw_dir="ghaw-compat-test"
mkdir -p "$ghaw_dir"

# Run in a subshell with NO GitHub tokens — simulates gh-aw activation
# where apm-action does not pass GITHUB_TOKEN to the subprocess.
(
unset GITHUB_TOKEN GITHUB_APM_PAT GH_TOKEN 2>/dev/null || true
cd "$ghaw_dir"

echo "Running: apm install microsoft/apm-sample-package --isolated (no token)"
"$BINARY_PATH" install microsoft/apm-sample-package --isolated
local install_exit=$?
if [[ $install_exit -ne 0 ]]; then
echo "apm install failed with exit code $install_exit"
exit 1
fi

echo "Running: apm pack --target claude --archive"
"$BINARY_PATH" pack --target claude --archive
local pack_exit=$?
if [[ $pack_exit -ne 0 ]]; then
echo "apm pack failed with exit code $pack_exit"
exit 1
fi

# Verify a bundle was produced
if ls *.tar.gz 1>/dev/null 2>&1 || ls *.zip 1>/dev/null 2>&1; then
echo "Bundle archive produced successfully"
else
echo "No bundle archive found"
exit 1
fi
)
local subshell_exit=$?

rm -rf "$ghaw_dir" 2>/dev/null || true

if [[ $subshell_exit -ne 0 ]]; then
log_error "GH-AW compatibility test failed — public repo install/pack broken without token"
return 1
fi

log_success "GH-AW compatibility: tokenless install + pack works"
}

# Main test runner - follows exact README flow
main() {
echo "APM CLI Release Validation - Binary Isolation Testing"
Expand Down Expand Up @@ -370,7 +423,7 @@ echo ""
echo "Binary found and executable: $BINARY_PATH"

local tests_passed=0
local tests_total=5 # Prerequisites, basic commands, runtime setup, 2 hero scenarios
local tests_total=6 # Prerequisites, basic commands, gh-aw compat, runtime setup, 2 hero scenarios
local dependency_tests_run=false

# Add dependency tests to total if available and GITHUB token is present
Expand Down Expand Up @@ -401,6 +454,12 @@ echo ""
log_error "Basic commands test failed"
fi

if test_ghaw_compat; then
((tests_passed++))
else
log_error "GH-AW compatibility test failed"
fi

if test_runtime_setup; then
((tests_passed++))
else
Expand Down Expand Up @@ -448,6 +507,7 @@ echo ""
echo " 1. Prerequisites (GITHUB_TOKEN) ✅"
echo " 2. Binary accessibility ✅"
echo " 3. Runtime setup (copilot) ✅"
echo " 4. GH-AW compatibility (tokenless install + pack) ✅"
echo ""
echo " HERO SCENARIO 1: 30-Second Zero-Config ✨"
echo " - Run virtual package directly ✅"
Expand Down
24 changes: 22 additions & 2 deletions src/apm_cli/core/token_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,24 @@ def __init__(self, preserve_existing: bool = True):
self.preserve_existing = preserve_existing
self._credential_cache: Dict[str, Optional[str]] = {}

@staticmethod
def _is_valid_credential_token(token: str) -> bool:
"""Validate that a credential-fill token looks like a real credential.

Rejects garbage values that can appear when GIT_ASKPASS or credential
helpers return prompt text instead of actual tokens.
"""
if not token:
return False
if len(token) > 1024:
return False
if any(c in token for c in (' ', '\t', '\n', '\r')):
return False
prompt_fragments = ('Password for', 'Username for', 'password for', 'username for')
if any(fragment in token for fragment in prompt_fragments):
return False
return True

@staticmethod
def resolve_credential_from_git(host: str) -> Optional[str]:
"""Resolve a credential from the git credential store.
Expand All @@ -71,15 +89,17 @@ def resolve_credential_from_git(host: str) -> Optional[str]:
capture_output=True,
text=True,
timeout=5,
env={**os.environ, 'GIT_TERMINAL_PROMPT': '0', 'GIT_ASKPASS': 'echo'},
env={**os.environ, 'GIT_TERMINAL_PROMPT': '0', 'GIT_ASKPASS': ''},
)
if result.returncode != 0:
return None

for line in result.stdout.splitlines():
if line.startswith('password='):
token = line[len('password='):]
return token if token else None
if token and GitHubTokenManager._is_valid_credential_token(token):
return token
return None
return None
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
return None
Expand Down
112 changes: 112 additions & 0 deletions tests/test_token_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,118 @@ def test_git_terminal_prompt_disabled(self):
call_env = mock_run.call_args.kwargs['env']
assert call_env['GIT_TERMINAL_PROMPT'] == '0'

def test_git_askpass_set_to_empty(self):
"""GIT_ASKPASS is set to empty string (not 'echo') to prevent prompt echo."""
mock_result = MagicMock(returncode=0, stdout="password=tok\n")
with patch('subprocess.run', return_value=mock_result) as mock_run:
GitHubTokenManager.resolve_credential_from_git('github.com')
call_env = mock_run.call_args.kwargs['env']
assert call_env['GIT_ASKPASS'] == ''

def test_rejects_password_prompt_as_token(self):
"""Rejects 'Password for ...' prompt text echoed back by GIT_ASKPASS."""
mock_result = MagicMock(
returncode=0,
stdout="password=Password for 'https://github.com': \n",
)
with patch('subprocess.run', return_value=mock_result):
assert GitHubTokenManager.resolve_credential_from_git('github.com') is None

def test_rejects_username_prompt_as_token(self):
"""Rejects 'Username for ...' prompt text."""
mock_result = MagicMock(
returncode=0,
stdout="password=Username for 'https://github.com': \n",
)
with patch('subprocess.run', return_value=mock_result):
assert GitHubTokenManager.resolve_credential_from_git('github.com') is None

def test_rejects_token_with_spaces(self):
"""Rejects tokens containing spaces (likely prompt garbage)."""
mock_result = MagicMock(
returncode=0,
stdout="password=some garbage token value\n",
)
with patch('subprocess.run', return_value=mock_result):
assert GitHubTokenManager.resolve_credential_from_git('github.com') is None

def test_rejects_token_with_tabs(self):
"""Rejects tokens containing tab characters."""
mock_result = MagicMock(
returncode=0,
stdout="password=some\ttoken\n",
)
with patch('subprocess.run', return_value=mock_result):
assert GitHubTokenManager.resolve_credential_from_git('github.com') is None

def test_rejects_excessively_long_token(self):
"""Rejects tokens longer than 1024 characters."""
mock_result = MagicMock(
returncode=0,
stdout=f"password={'x' * 1025}\n",
)
with patch('subprocess.run', return_value=mock_result):
assert GitHubTokenManager.resolve_credential_from_git('github.com') is None

def test_accepts_valid_ghp_token(self):
"""Accepts a normal GitHub PAT (ghp_ prefix)."""
mock_result = MagicMock(
returncode=0,
stdout="password=ghp_abcdefghijk1234567890abcdefghijk1234\n",
)
with patch('subprocess.run', return_value=mock_result):
token = GitHubTokenManager.resolve_credential_from_git('github.com')
assert token == 'ghp_abcdefghijk1234567890abcdefghijk1234'

def test_accepts_valid_gho_token(self):
"""Accepts a GitHub OAuth token (gho_ prefix)."""
mock_result = MagicMock(
returncode=0,
stdout="password=gho_abc123def456\n",
)
with patch('subprocess.run', return_value=mock_result):
token = GitHubTokenManager.resolve_credential_from_git('github.com')
assert token == 'gho_abc123def456'


class TestIsValidCredentialToken:
"""Test _is_valid_credential_token validation."""

def test_empty_string_invalid(self):
assert not GitHubTokenManager._is_valid_credential_token('')

def test_none_coerced_invalid(self):
"""None would fail the truthiness check (caller already guards this)."""
assert not GitHubTokenManager._is_valid_credential_token('')

Comment on lines +214 to +217
def test_whitespace_only_invalid(self):
assert not GitHubTokenManager._is_valid_credential_token(' ')

def test_normal_pat_valid(self):
assert GitHubTokenManager._is_valid_credential_token('ghp_abc123')

def test_over_1024_chars_invalid(self):
assert not GitHubTokenManager._is_valid_credential_token('a' * 1025)

def test_exactly_1024_chars_valid(self):
assert GitHubTokenManager._is_valid_credential_token('a' * 1024)

def test_password_for_prompt_invalid(self):
assert not GitHubTokenManager._is_valid_credential_token(
"Password for 'https://github.com': "
)

def test_username_for_prompt_invalid(self):
assert not GitHubTokenManager._is_valid_credential_token(
"Username for 'https://github.com': "
)

def test_newline_in_token_invalid(self):
assert not GitHubTokenManager._is_valid_credential_token('tok\nen')

def test_tab_in_token_invalid(self):
assert not GitHubTokenManager._is_valid_credential_token('tok\ten')


class TestGetTokenWithCredentialFallback:
"""Test get_token_with_credential_fallback method."""
Expand Down
Loading