Skip to content
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