Skip to content

Commit 1b3e792

Browse files
authored
Merge pull request #147 from lordmauve/lordmauve/issue146
2 parents 4a01636 + 55eb9e1 commit 1b3e792

File tree

13 files changed

+550
-58
lines changed

13 files changed

+550
-58
lines changed

.github/workflows/ci.yml

Lines changed: 7 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,41 +3,29 @@ name: CI
33
on:
44
pull_request:
55
push:
6-
branches:
7-
- "main"
8-
tags:
9-
- "*"
106

117
jobs:
128
build:
139
strategy:
1410
matrix:
15-
include:
16-
- python_version: "3.8"
17-
script: tests
18-
- python_version: "3.9"
19-
script: tests
20-
- python_version: "3.10"
21-
script: tests
22-
- python_version: "3.11"
23-
script: tests
24-
- python_version: "3.12"
25-
script: tests
11+
python_version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
12+
script: [tests]
13+
fail-fast: false
2614

2715
name: "py${{ matrix.python_version }} / ${{ matrix.script }}"
2816
runs-on: ubuntu-latest
2917

3018
steps:
31-
- uses: actions/checkout@v2
19+
- uses: actions/checkout@v4
3220

3321
- name: Set up Python
3422
id: setup-python
35-
uses: actions/setup-python@v2
23+
uses: actions/setup-python@v5
3624
with:
3725
python-version: ${{ matrix.python_version }}
3826

3927
- name: Pip, Pre-commit & Poetry caches
40-
uses: actions/cache@v2
28+
uses: actions/cache@v4
4129
with:
4230
path: |
4331
~/.cache/
@@ -53,6 +41,7 @@ jobs:
5341
run: poetry run scripts/${{ matrix.script }}
5442
env:
5543
PYTEST_ADDOPTS: "--cov-report=xml"
44+
GITHUB_TOKEN: ${{ github.token }}
5645

5746
report-status:
5847
name: success

.github/workflows/publish.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,16 @@ jobs:
1010
name: Publish package to PyPI
1111
runs-on: ubuntu-latest
1212
steps:
13-
- uses: actions/checkout@v2
13+
- uses: actions/checkout@v4
1414

1515
- name: Set up Python
1616
id: setup-python
17-
uses: actions/setup-python@v2
17+
uses: actions/setup-python@v5
1818
with:
1919
python-version: ${{ matrix.python_version }}
2020

2121
- name: Pip, Pre-commit & Poetry caches
22-
uses: actions/cache@v2
22+
uses: actions/cache@v4
2323
with:
2424
path: |
2525
~/.cache/
@@ -32,7 +32,7 @@ jobs:
3232
run: poetry install
3333

3434
- name: Wait for tests to succeed
35-
uses: fountainhead/action-wait-for-check@v1.0.0
35+
uses: fountainhead/action-wait-for-check@v1.2.0
3636
id: wait-for-ci
3737
with:
3838
token: ${{ secrets.GITHUB_TOKEN }}

.readthedocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ version: 2
77

88
sphinx:
99
fail_on_warning: true
10+
configuration: docs/conf.py
1011

1112
build:
1213
os: ubuntu-lts-latest

CHANGELOG.rst

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,4 @@ This documentation itself is "drinking its own champagne": it uses
66

77
.. changelog::
88
:changelog-url: https://sphinx-github-changelog.readthedocs.io/en/stable/#changelog
9-
:github: https://github.com/peopledoc/sphinx-github-changelog/releases/
109
:pypi: https://pypi.org/project/sphinx-github-changelog/

README.rst

Lines changed: 62 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,6 @@ In your Sphinx documentation ``conf.py``:
5151
"sphinx_github_changelog",
5252
]
5353
54-
# Provide a GitHub API token:
55-
# Pass the SPHINX_GITHUB_CHANGELOG_TOKEN environment variable to your build
56-
# OR
57-
# You can retrieve your token any other way you want, but of course, please
58-
# don't commit secrets to git, especially on a public repository
59-
sphinx_github_changelog_token = ...
60-
6154
In your documentation:
6255

6356
.. code-block:: restructuredtext
@@ -67,6 +60,12 @@ In your documentation:
6760
:github: https://github.com/you/your-project/releases/
6861
:pypi: https://pypi.org/project/your-project/
6962
63+
or more minimally (but not necessarily recommended):
64+
65+
.. code-block:: restructuredtext
66+
67+
.. changelog::
68+
7069
7170
See the end result for this project on ReadTheDocs__.
7271

@@ -139,27 +138,68 @@ draft GitHub Release and press "Publish Release". That's it.
139138
Reference documentation
140139
=======================
141140

141+
Automatic Configuration
142+
-----------------------
143+
144+
The extension can automatically detect the GitHub repository URL from your
145+
git remotes in this order:
146+
147+
1. ``upstream`` remote
148+
2. ``origin`` remote
149+
150+
The GraphQL API and GitHub root URL are derived from this URL.
151+
152+
If for any reason, you'd rather provide the repository explicitly (e.g. the doc
153+
repo doesn't match the repo you're releasing from, or anything else), you can
154+
define the ``:github:`` attribute to the directive. See directive_ for
155+
details.
156+
157+
158+
Authentication
159+
--------------
160+
161+
The extension uses the GitHub GraphQL API to retrieve the changelog. This
162+
requires authentication using a GitHub API token.
163+
164+
However if you use git over HTTPS, or the ``gh`` CLI, you probably already have a
165+
suitable token, which ``sphinx-github-changelog`` will automatically use.
166+
167+
In CI like GitHub Actions you can pass a token explicitly as an environment
168+
variable:
169+
170+
.. code-block:: yaml
171+
172+
- name: Build documentation
173+
run: make html
174+
env:
175+
SPHINX_GITHUB_CHANGELOG_TOKEN: ${{ github.token }}
176+
177+
In remaining cases you may need to create a personal access token. If the
178+
repository is public, the token doesn't need any special access (you can
179+
uncheck eveything). For private and internal repositories, the token must
180+
have ``repo`` scope (classic tokens) or ``contents: read`` access (fine-grained
181+
tokens).
182+
183+
Pass the token as the ``SPHINX_GITHUB_CHANGELOG_TOKEN`` environment variable.
184+
You can also set the token as ``sphinx_github_changelog_token`` in ``conf.py``
185+
but you should never commit secrets such as this.
186+
187+
142188
Extension options (``conf.py``)
143189
-------------------------------
144190

145-
- ``sphinx_github_changelog_token``: GitHub API token.
146-
If the repository is public, the token doesn't need any special access (you
147-
can uncheck eveything). If the repository is private, you'll need to give
148-
your token enough access to read the releases. Defaults to the value of the
149-
environment variable ``SPHINX_GITHUB_CHANGELOG_TOKEN``. If no value is
150-
provided, the build will still pass but the changelog will not be built, and
151-
a link to the ``changelog-url`` will be displayed (if provided).
191+
- ``sphinx_github_changelog_token``: GitHub API token, if needed.
152192

153-
- ``sphinx_github_changelog_root_repo`` (optional): Root url to the repository,
154-
defaults to "https://github.com/". Useful if you're using a self-hosted GitHub
155-
instance.
193+
Two options are accepted for backwards compatibility, but are likely detected
194+
automatically from the ``:github:`` parameter to the directive:
156195

157-
- ``sphinx_github_changelog_graphql_url`` (optional): Url to graphql api, defaults
158-
to "https://api.github.com/graphql". Useful if you're using a self-hosted GitHub
159-
instance.
196+
- ``sphinx_github_changelog_root_repo`` (optional): Root URL to the repository.
197+
- ``sphinx_github_changelog_graphql_url`` (optional): URL to GraphQL API.
160198

161199
.. _ReadTheDocs: https://readthedocs.org/
162200

201+
.. _directive:
202+
163203
Directive
164204
---------
165205

@@ -173,7 +213,8 @@ Directive
173213
Attributes
174214
~~~~~~~~~~
175215

176-
- ``github`` (**required**): URL to the releases page of the repository.
216+
- ``github`` (optional): URL to the releases page of the repository.
217+
If not provided, auto‑detected from your git remote, as described above.
177218
- ``changelog-url`` (optional): URL to the built version of your changelog.
178219
``sphinx-github-changelog`` will display a link to your built changelog if the GitHub
179220
token is not provided (hopefully, this does not happen in your built documentation)

sphinx_github_changelog/changelog.py

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
# https://github.com/tk0miya/docutils-stubs/issues/33
99
from docutils.parsers.rst import Directive, directives # type: ignore
1010

11+
from . import credentials, urls
12+
1113

1214
class ChangelogError(Exception):
1315
pass
@@ -46,10 +48,39 @@ def compute_changelog(
4648
root_url: Optional[str] = None,
4749
graphql_url: Optional[str] = None,
4850
) -> List[nodes.Node]:
51+
52+
if options.get("github"):
53+
# If a github URL is explicitly provided, validate that it is in
54+
# the correct format i.e. refers to the /releases page.
55+
github_url = options["github"]
56+
root_url = root_url or urls.get_root_url(github_url)
57+
owner_repo = extract_github_repo_name(github_url, root_url)
58+
else:
59+
# If no github URL is provided, try to get it from the git remote
60+
# and validate that it is in the correct format.
61+
remote_url = urls.get_default_github_url()
62+
parsed_repo = remote_url and urls.parse_github_repo_from_url(remote_url)
63+
if not parsed_repo:
64+
raise ChangelogError(
65+
"No :github: release URL provided and unable to determine it from "
66+
"git remotes. Please provide a GitHub release URL in the format "
67+
"(https://github.com/:owner/:repo/releases)"
68+
)
69+
owner_repo = parsed_repo
70+
github_url = f"{remote_url}/releases"
71+
72+
# If graphql_url is not provided, derive from github_url
73+
if not graphql_url:
74+
graphql_url = urls.get_github_graphql_url(github_url)
75+
76+
# If token is not provided, try to get it from helpers
77+
if not token:
78+
host = urls.get_github_host_from_url(github_url)
79+
token = credentials.get_github_token(host)
80+
4981
if not token:
5082
return no_token(changelog_url=options.get("changelog-url"))
5183

52-
owner_repo = extract_github_repo_name(url=options["github"], root_url=root_url)
5384
releases = extract_releases(
5485
owner_repo=owner_repo, token=token, graphql_url=graphql_url
5586
)
@@ -65,9 +96,18 @@ def compute_changelog(
6596

6697
def no_token(changelog_url: Optional[str]) -> List[nodes.Node]:
6798
par = nodes.paragraph()
68-
par += nodes.Text("Changelog was not built because ")
99+
par += nodes.Text(
100+
"Changelog was not built because no GitHub authentication token was found. "
101+
"An access token can be provided using the "
102+
)
103+
par += nodes.literal("", "SPHINX_GITHUB_CHANGELOG_TOKEN")
104+
par += nodes.Text(" environment variable or the ")
69105
par += nodes.literal("", "sphinx_github_changelog_token")
70-
par += nodes.Text(" parameter is missing in the documentation configuration.")
106+
par += nodes.Text(" parameter in ")
107+
par += nodes.literal("", "conf.py")
108+
par += nodes.Text(
109+
", or it can be automatically located from a configured git credential helper."
110+
)
71111
result: List[nodes.Node] = [nodes.warning("", par)]
72112

73113
if changelog_url:
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""
2+
Credential helpers for sphinx-github-changelog.
3+
4+
Provides functions to obtain a GitHub token using environment variables,
5+
git credential helpers, or the GitHub CLI.
6+
"""
7+
8+
import os
9+
import subprocess
10+
from contextlib import suppress
11+
from typing import Optional
12+
13+
14+
def get_token_from_env(host: str = "github.com") -> Optional[str]:
15+
"""Get a GitHub token from the SPHINX_GITHUB_CHANGELOG_TOKEN env var.
16+
17+
Return None if the environment variable is not set.
18+
"""
19+
return os.environ.get("SPHINX_GITHUB_CHANGELOG_TOKEN")
20+
21+
22+
def is_github_token(token: str) -> bool:
23+
"""Check if the given string appears to be a GitHub token.
24+
25+
See
26+
https://github.blog/changelog/2021-03-31-authentication-token-format-updates-are-generally-available/
27+
for the prefixes that indicate a GitHub token.
28+
29+
As of 2025-05-08 the prefixes are `ghp_`, `gho_`, `ghu_`, and `ghs_`. We
30+
use a generic check for `gh?_` to allow for future prefixes.
31+
"""
32+
return token.startswith("gh") and token[3] == "_"
33+
34+
35+
def get_token_from_git_credential(host: str = "github.com") -> Optional[str]:
36+
"""
37+
Get a GitHub access token using git's credential helper.
38+
39+
>>> token = get_token_from_git_credential('example.com')
40+
>>> token is None or isinstance(token, str)
41+
True
42+
"""
43+
with suppress(subprocess.CalledProcessError, FileNotFoundError):
44+
resp = subprocess.check_output(
45+
["git", "credential", "fill"],
46+
input=f"protocol=https\nhost={host}\n",
47+
text=True,
48+
)
49+
for ln in resp.splitlines():
50+
key, eq, value = ln.partition("=")
51+
if key == "password" and is_github_token(value):
52+
return value
53+
return None
54+
55+
56+
def get_token_from_gh_cli(host: str = "github.com") -> Optional[str]:
57+
"""Get a GitHub token using the GitHub CLI (gh auth token)."""
58+
with suppress(subprocess.CalledProcessError, FileNotFoundError):
59+
token = subprocess.check_output(
60+
["gh", "auth", "token", f"--hostname={host}"], text=True
61+
).strip()
62+
if token:
63+
return token
64+
return None
65+
66+
67+
def get_github_token(host: str = "github.com") -> Optional[str]:
68+
"""
69+
Try to obtain a GitHub token using several mechanisms in order.
70+
71+
1. Environment variable
72+
2. git credential helper
73+
3. gh CLI
74+
75+
Returns None if no token is found.
76+
"""
77+
return (
78+
get_token_from_env(host)
79+
or get_token_from_git_credential(host)
80+
or get_token_from_gh_cli(host)
81+
)

0 commit comments

Comments
 (0)