Skip to content

Build: support cloning private repos with token #12115

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 14 commits into from
May 14, 2025
1 change: 1 addition & 0 deletions readthedocs/api/v2/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ class Meta(ProjectSerializer.Meta):
"environment_variables",
"max_concurrent_builds",
"readthedocs_yaml_path",
"clone_token",
)


Expand Down
1 change: 1 addition & 0 deletions readthedocs/doc_builder/director.py
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,7 @@ def get_vcs_env_vars(self):
env = self.get_rtd_env_vars()
# Don't prompt for username, this requires Git 2.3+
env["GIT_TERMINAL_PROMPT"] = "0"
env["READTHEDOCS_GIT_CLONE_TOKEN"] = self.data.project.clone_token
return env

def get_rtd_env_vars(self):
Expand Down
1 change: 1 addition & 0 deletions readthedocs/doc_builder/environments.py
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,7 @@ def _escape_command(self, cmd):
not_escape_variables = (
"READTHEDOCS_OUTPUT",
"READTHEDOCS_VIRTUALENV_PATH",
"READTHEDOCS_GIT_CLONE_TOKEN",
"CONDA_ENVS_PATH",
"CONDA_DEFAULT_ENV",
)
Expand Down
5 changes: 1 addition & 4 deletions readthedocs/oauth/services/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class Service:
default_user_avatar_url = settings.OAUTH_AVATAR_USER_DEFAULT_URL
default_org_avatar_url = settings.OAUTH_AVATAR_ORG_DEFAULT_URL
supports_build_status = False
supports_clone_token = False

@classmethod
def for_project(cls, project):
Expand Down Expand Up @@ -328,7 +329,3 @@ def sync_repositories(self):

def sync_organizations(self):
raise NotImplementedError

def get_clone_token(self, project):
"""User services make use of SSH keys only for cloning."""
return None
25 changes: 19 additions & 6 deletions readthedocs/oauth/services/githubapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ class GitHubAppService(Service):
vcs_provider_slug = GITHUB_APP
allauth_provider = GitHubAppProvider
supports_build_status = True
supports_clone_token = True

def __init__(self, installation: GitHubAppInstallation):
self.installation = installation
Expand Down Expand Up @@ -462,17 +463,29 @@ def get_clone_token(self, project):
"""
Return a token for HTTP-based Git access to the repository.

The token is scoped to have read-only access to the content of the repository attached to the project.
The token expires after one hour (this is given by GitHub and can't be changed).

See:
- https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation
- https://docs.github.com/en/rest/apps/apps?apiVersion=2022-11-28#create-an-installation-access-token-for-an-app
"""
# NOTE: we can pass the repository_ids to get a token with access to specific repositories.
# We should upstream this feature to PyGithub.
# We can also pass a specific permissions object to get a token with specific permissions
# if we want to scope this token even more.
try:
access_token = self.gh_app_client.get_access_token(self.installation.installation_id)
return f"x-access-token:{access_token.token}"
# TODO: Use self.gh_app_client.get_access_token instead,
# once https://github.com/PyGithub/PyGithub/pull/3287 is merged.
_, response = self.gh_app_client.requester.requestJsonAndCheck(
"POST",
f"/app/installations/{self.installation.installation_id}/access_tokens",
headers=self.gh_app_client._get_headers(),
input={
"repository_ids": [int(project.remote_repository.remote_id)],
"permissions": {
"contents": "read",
},
},
)
token = response["token"]
return f"x-access-token:{token}"
Comment on lines +474 to +488
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the PR from pygithub hasn't been merged, so I went ahead and used the internal request object to make the raw request to get the token scoped a single repo.

except GithubException:
log.info(
"Failed to get clone token for project",
Expand Down
29 changes: 29 additions & 0 deletions readthedocs/projects/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -968,6 +968,7 @@ def vcs_repo(self, environment, version):
self,
version=version,
environment=environment,
use_token=bool(self.clone_token),
)
return repo

Expand Down Expand Up @@ -1398,6 +1399,29 @@ def get_subproject_candidates(self, user):
def organization(self):
return self.organizations.first()

@property
def clone_token(self) -> str | None:
"""
Return a HTTP-based Git access token to the repository.

.. note::

- A token is only returned for projects linked to a private repository.
- Only repositories granted access by a GitHub app installation will return a token.
"""
service_class = self.get_git_service_class()
if not service_class or not self.remote_repository.private:
return None

if not service_class.supports_clone_token:
return None

for service in service_class.for_project(self):
token = service.get_clone_token(self)
if token:
return token
return None


class APIProject(Project):
"""
Expand All @@ -1414,12 +1438,17 @@ class APIProject(Project):
"""

features = []
# This is a property in the original model, in order to
# be able to assign it a value in the constructor, we need to re-declare it
# as an attribute here.
clone_token = None

class Meta:
proxy = True

def __init__(self, *args, **kwargs):
self.features = kwargs.pop("features", [])
self.clone_token = kwargs.pop("clone_token", None)
environment_variables = kwargs.pop("environment_variables", {})
ad_free = not kwargs.pop("show_advertising", True)
# These fields only exist on the API return, not on the model, so we'll
Expand Down
1 change: 1 addition & 0 deletions readthedocs/projects/tasks/builds.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ def execute(self):
version=self.data.version,
environment={
"GIT_TERMINAL_PROMPT": "0",
"READTHEDOCS_GIT_CLONE_TOKEN": self.data.project.clone_token,
},
# Pass the api_client so that all environments have it.
# This is needed for ``readthedocs-corporate``.
Expand Down
Loading