Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
13 changes: 11 additions & 2 deletions .github/workflows/build-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,16 @@ on:
default: true
type: boolean

# Restrict default GITHUB_TOKEN permissions for all jobs (principle of least privilege)
permissions:
contents: read

jobs:
# Always run tests first with matrix strategy
test:
runs-on: ${{ matrix.os }}
permissions:
contents: read
strategy:
fail-fast: false
matrix:
Expand Down Expand Up @@ -123,7 +129,7 @@ jobs:

runs-on: ${{ matrix.os }}
permissions:
contents: write # Required for release uploads
contents: read # Checkout code; upload-artifact uses separate Actions API

steps:
- name: Checkout code
Expand Down Expand Up @@ -437,7 +443,8 @@ jobs:
name: pypi
url: https://pypi.org/p/apm-cli
permissions:
id-token: write # IMPORTANT: this permission is mandatory for trusted publishing
contents: read # Required for actions/checkout
id-token: write # IMPORTANT: this permission is mandatory for trusted publishing

steps:
- name: Checkout code
Expand Down Expand Up @@ -470,6 +477,8 @@ jobs:
runs-on: ubuntu-latest
needs: [test, build, integration-tests, release-validation, create-release, 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

steps:
- name: Extract SHA256 checksums from GitHub release
Expand Down
12 changes: 12 additions & 0 deletions src/apm_cli/deps/github_downloader.py
Original file line number Diff line number Diff line change
Expand Up @@ -593,6 +593,18 @@ def _download_github_file(self, dep_ref: DependencyReference, file_path: str, re
f"(tried refs: {ref}, {fallback_ref})"
)
elif e.response.status_code == 401 or e.response.status_code == 403:
# Token may lack SSO/SAML authorization for this org.
# Retry without auth — the repo might be public.
# Applies to github.com and GHES (custom domains can have public repos).
# Excluded: *.ghe.com (Enterprise Cloud Data Residency has no public repos).
if self.github_token and not host.endswith(".ghe.com"):
Comment thread
danielmeppiel marked this conversation as resolved.
Outdated
try:
unauth_headers = {'Accept': 'application/vnd.github.v3.raw'}
response = requests.get(api_url, headers=unauth_headers, timeout=30)
Comment thread
danielmeppiel marked this conversation as resolved.
response.raise_for_status()
return response.content
except requests.exceptions.HTTPError:
pass # Fall through to the original error
Comment thread
danielmeppiel marked this conversation as resolved.
error_msg = f"Authentication failed for {dep_ref.repo_url}. "
if not self.github_token:
error_msg += "This might be a private repository. Please set GITHUB_APM_PAT or GITHUB_TOKEN."
Expand Down
82 changes: 82 additions & 0 deletions tests/test_github_downloader.py
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,88 @@ def test_authentication_failure_handling(self):
# Would require mocking authentication failures
pass

def test_download_raw_file_saml_fallback_retries_without_token(self):
"""Test that download_raw_file retries without token on 401/403 (SAML/SSO)."""
with patch.dict(os.environ, {'GITHUB_APM_PAT': 'saml-blocked-token'}, clear=True):
downloader = GitHubPackageDownloader()
dep_ref = DependencyReference.parse('microsoft/some-public-repo/sub/dir')

# First call (with token) returns 401, second call (without token) returns 200
mock_response_401 = Mock()
mock_response_401.status_code = 401
mock_response_401.raise_for_status = Mock(
side_effect=__import__('requests').exceptions.HTTPError(response=mock_response_401)
)

Comment thread
danielmeppiel marked this conversation as resolved.
mock_response_200 = Mock()
mock_response_200.status_code = 200
mock_response_200.content = b'# SKILL.md content'
mock_response_200.raise_for_status = Mock()

with patch('apm_cli.deps.github_downloader.requests.get') as mock_get:
mock_get.side_effect = [mock_response_401, mock_response_200]

result = downloader.download_raw_file(dep_ref, 'sub/dir/SKILL.md', 'main')
assert result == b'# SKILL.md content'

# First call should include auth header
first_call_headers = mock_get.call_args_list[0][1].get('headers', {})
assert 'Authorization' in first_call_headers

# Second (retry) call should NOT include auth header
second_call_headers = mock_get.call_args_list[1][1].get('headers', {})
assert 'Authorization' not in second_call_headers

def test_download_raw_file_saml_fallback_not_used_for_ghe_cloud_dr(self):
"""Test that SAML fallback does NOT apply to *.ghe.com (no public repos)."""
with patch.dict(os.environ, {'GITHUB_APM_PAT': 'ghe-token'}, clear=True):
downloader = GitHubPackageDownloader()
dep_ref = DependencyReference.parse('company.ghe.com/owner/repo/sub/path')

mock_response_403 = Mock()
mock_response_403.status_code = 403
mock_response_403.raise_for_status = Mock(
side_effect=__import__('requests').exceptions.HTTPError(response=mock_response_403)
)

with patch('apm_cli.deps.github_downloader.requests.get') as mock_get:
mock_get.return_value = mock_response_403
mock_get.side_effect = __import__('requests').exceptions.HTTPError(response=mock_response_403)
Comment thread
danielmeppiel marked this conversation as resolved.
Outdated
Comment thread
danielmeppiel marked this conversation as resolved.
Outdated

with pytest.raises(RuntimeError, match="Authentication failed"):
downloader.download_raw_file(dep_ref, 'sub/path/file.md', 'main')

# Should only have been called once — no retry for *.ghe.com
assert mock_get.call_count == 1

def test_download_raw_file_saml_fallback_applies_to_ghes(self):
"""Test that SAML fallback DOES apply to GHES custom domains (can have public repos)."""
with patch.dict(os.environ, {'GITHUB_APM_PAT': 'ghes-token', 'GITHUB_HOST': 'github.mycompany.com'}, clear=True):
downloader = GitHubPackageDownloader()
dep_ref = DependencyReference.parse('github.mycompany.com/owner/repo/sub/path')

mock_response_401 = Mock()
mock_response_401.status_code = 401
mock_response_401.raise_for_status = Mock(
side_effect=__import__('requests').exceptions.HTTPError(response=mock_response_401)
)

mock_response_200 = Mock()
mock_response_200.status_code = 200
mock_response_200.content = b'# Public GHES content'
mock_response_200.raise_for_status = Mock()

with patch('apm_cli.deps.github_downloader.requests.get') as mock_get:
mock_get.side_effect = [mock_response_401, mock_response_200]

result = downloader.download_raw_file(dep_ref, 'sub/path/SKILL.md', 'main')
assert result == b'# Public GHES content'

# Should have retried without auth
assert mock_get.call_count == 2
second_call_headers = mock_get.call_args_list[1][1].get('headers', {})
assert 'Authorization' not in second_call_headers

Comment thread
danielmeppiel marked this conversation as resolved.
Outdated
def test_repository_not_found_handling(self):
"""Test handling of repository not found errors."""
# Would require mocking 404 errors
Expand Down
Loading