Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

### Added

* Support self-hosted GitHub instances. ([#2755])

Comment on lines +10 to +11
Copy link
Collaborator

@fingolfin fingolfin Nov 20, 2025

Choose a reason for hiding this comment

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

This now needs to be moved up (it currently is in the 1.16.0 section but that version has already been released)

### Changed

* Created a warning for when the search index size is too big (500Kib). ([#2423], [#2753])
Expand Down Expand Up @@ -2139,6 +2143,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
[#2748]: https://github.com/JuliaDocs/Documenter.jl/issues/2748
[#2750]: https://github.com/JuliaDocs/Documenter.jl/issues/2750
[#2753]: https://github.com/JuliaDocs/Documenter.jl/issues/2753
[#2755]: https://github.com/JuliaDocs/Documenter.jl/issues/2755
[JuliaLang/julia#36953]: https://github.com/JuliaLang/julia/issues/36953
[JuliaLang/julia#38054]: https://github.com/JuliaLang/julia/issues/38054
[JuliaLang/julia#39841]: https://github.com/JuliaLang/julia/issues/39841
Expand Down
68 changes: 51 additions & 17 deletions src/deployconfig.jl
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,9 @@ end

Implementation of `DeployConfig` for deploying from GitHub Actions.

For self-hosted GitHub installation use `GitHubActions(host, pages_url)` constructor
to specify the host name and a **full path** to the GitHub pages location.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is the "full path" referring to pages_url ? I find that confusing, a path is not an URL; maybe clarify the text?

Copy link
Author

@tz-lom tz-lom Nov 3, 2025

Choose a reason for hiding this comment

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

  • review this description, seems that API update is not reflected here (is there constructor that takes host as argument?)


The following environment variables influences the build
when using the `GitHubActions` configuration:

Expand All @@ -311,19 +314,48 @@ when using the `GitHubActions` configuration:
- `GITHUB_TOKEN` or `DOCUMENTER_KEY`: used for authentication with GitHub,
see the manual section for [GitHub Actions](@ref) for more information.

- `GITHUB_API_URL`: specifies the GitHub API URL, which generally is `https://api.github.com`,
but may be different for self-hosted GitHub instances.

- `GITHUB_ACTOR`: name of the person or app that initiated the workflow; this is used to construct
API calls.

The `GITHUB_*` variables are set automatically on GitHub Actions, see the
[documentation](https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/store-information-in-variables#default-environment-variables).
"""
struct GitHubActions <: DeployConfig
github_repository::String
github_event_name::String
github_ref::String
github_host::String
github_api::String
github_pages_url::String
end

function GitHubActions()
github_repository = get(ENV, "GITHUB_REPOSITORY", "") # "JuliaDocs/Documenter.jl"
github_event_name = get(ENV, "GITHUB_EVENT_NAME", "") # "push", "pull_request" or "cron" (?)
github_ref = get(ENV, "GITHUB_REF", "") # "refs/heads/$(branchname)" for branch, "refs/tags/$(tagname)" for tags
return GitHubActions(github_repository, github_event_name, github_ref)
github_api = get(ENV, "GITHUB_API_URL", "") # https://api.github.com

# Compute GitHub Pages URL from repository
parts = split(github_repository, "/")
github_pages_url = if length(parts) == 2
owner, repo = parts
"https://$(owner).github.io/$(repo)/"
Copy link
Collaborator

@fingolfin fingolfin Oct 30, 2025

Choose a reason for hiding this comment

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

Of course you are just moving this around, and that's already a good step; so what I say below is not a change request, but a general thought (perhaps we should record it it into an issue)

Instead of hardcoding the pages URL like that (which won't if e.g. a custom domain name is used, as e.g. Documenter.jl does) one could also query the pages URL from the GitHub API. E.g. if the gh tool is installed:

$ gh api repos/JuliaDocs/Documenter.jl --jq '.homepage'
https://documenter.juliadocs.org

But of course one also do that without, using just the github_api:

curl -s https://api.github.com/repos/JuliaDocs/Documenter.jl | jq -r '.homepage'

Obviously in Julia we'd use the JSON module, not jq, to parse this data.

Copy link
Author

Choose a reason for hiding this comment

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

That would be great, however I can't find any public API that will tell me where github pages are located.
homepage is not reliable and can point somewhere else
There is private api /repos/{owner}/{repo}/pages but it requires authentication.

Copy link
Collaborator

Choose a reason for hiding this comment

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

If this is running in CI, the GITHUB_TOKEN might have sufficient permissions?

Copy link
Author

Choose a reason for hiding this comment

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

that's an interesting idea, I need to try this on my setup if that will work

else
""
end

return GitHubActions(github_repository, github_event_name, github_ref, "github.com", github_api, github_pages_url)
Copy link
Member

Choose a reason for hiding this comment

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

If we hard-code github_host here, it feels like we should also hard-code the API URL? I also have a minor security concern here, where this could allow someone redirect the API calls somewhere (including the token) by somehow attacking the GITHUB_API_URL environment variable (for normal GitHub-hosted repos).

Suggested change
github_api = get(ENV, "GITHUB_API_URL", "") # https://api.github.com
# Compute GitHub Pages URL from repository
parts = split(github_repository, "/")
github_pages_url = if length(parts) == 2
owner, repo = parts
"https://$(owner).github.io/$(repo)/"
else
""
end
return GitHubActions(github_repository, github_event_name, github_ref, "github.com", github_api, github_pages_url)
# Compute GitHub Pages URL from repository
parts = split(github_repository, "/")
github_pages_url = if length(parts) == 2
owner, repo = parts
"https://$(owner).github.io/$(repo)/"
else
""
end
return GitHubActions(github_repository, github_event_name, github_ref, "github.com", "https://api.github.com", github_pages_url)

end

function GitHubActions(host, pages_url)
github_repository = get(ENV, "GITHUB_REPOSITORY", "") # "JuliaDocs/Documenter.jl"
github_event_name = get(ENV, "GITHUB_EVENT_NAME", "") # "push", "pull_request" or "cron" (?)
github_ref = get(ENV, "GITHUB_REF", "") # "refs/heads/$(branchname)" for branch, "refs/tags/$(tagname)" for tags
github_api = get(ENV, "GITHUB_API_URL", "") # https://api.github.com
Copy link
Member

Choose a reason for hiding this comment

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

Is GITHUB_API_URL something that is configurable for a self-hosted instance, or can we assume it's api.$(host)?

There's also GITHUB_SERVER_URL, which could potentially be used for determining host automatically? Or would that not be reliable and/or two automagical?

Copy link
Author

Choose a reason for hiding this comment

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

I don't think we can assume anything about it, I don't know exactly if it is configurable but I would expect that it is as some orgs have strict rules about domain names

I need to check GITHUB_SERVER_URL , for some reason I haven't used that, don't know if it is because we don't have it or because I've missed it

if something like that exists I would prefer to used it indeed instead of specifying it manually

Copy link
Collaborator

Choose a reason for hiding this comment

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

Note that GITHUB_API_URL is mentioned in the GitHub manual as a variable they set in GitHub Action workflows.

And in the corresponding enterprise docs at https://docs.github.com/en/enterprise-server@3.18/actions/reference/workflows-and-actions/variables the example value given is http(s)://HOSTNAME/api/v3 -- so no, we can't just assume it is api.$(host) (I assume that's the URL one gest if one enables "subdomain isolation")

return GitHubActions(github_repository, github_event_name, github_ref, host, github_api, pages_url)
end

# Check criteria for deployment
Expand Down Expand Up @@ -393,7 +425,7 @@ function deploy_folder(
all_ok &= pr_ok
println(io, "- $(marker(pr_ok)) ENV[\"GITHUB_REF\"] corresponds to a PR number")
if pr_ok
pr_origin_matches_repo = verify_github_pull_repository(cfg.github_repository, pr_number)
pr_origin_matches_repo = verify_github_pull_repository(cfg, pr_number)
all_ok &= pr_origin_matches_repo
println(io, "- $(marker(pr_origin_matches_repo)) PR originates from the same repository")
end
Expand Down Expand Up @@ -452,7 +484,7 @@ end

authentication_method(::GitHubActions) = env_nonempty("DOCUMENTER_KEY") ? SSH : HTTPS
function authenticated_repo_url(cfg::GitHubActions)
return "https://$(ENV["GITHUB_ACTOR"]):$(ENV["GITHUB_TOKEN"])@github.com/$(cfg.github_repository).git"
return "https://$(ENV["GITHUB_ACTOR"]):$(ENV["GITHUB_TOKEN"])@$(cfg.github_host)/$(cfg.github_repository).git"
end

function version_tag_strip_build(tag; tag_prefix = "")
Expand All @@ -469,7 +501,7 @@ function version_tag_strip_build(tag; tag_prefix = "")
return "$s0$s1$s2$s3$s4"
end

function post_status(::GitHubActions; type, repo::String, subfolder = nothing, kwargs...)
function post_status(cfg::GitHubActions; type, repo::String, subfolder = nothing, kwargs...)
try # make this non-fatal and silent
# If we got this far it usually means everything is in
# order so no need to check everything again.
Expand All @@ -489,17 +521,18 @@ function post_status(::GitHubActions; type, repo::String, subfolder = nothing, k
sha = get(ENV, "GITHUB_SHA", nothing)
end
sha === nothing && return
return post_github_status(type, repo, sha, subfolder)
catch
@debug "Failed to post status"
return post_github_status(cfg, type, repo, sha, subfolder)
catch e
@debug "Failed to post status" e
end
end

function post_github_status(type::S, deploydocs_repo::S, sha::S, subfolder = nothing) where {S <: String}

function post_github_status(cfg::GitHubActions, type::S, deploydocs_repo::S, sha::S, subfolder = nothing) where {S <: String}
try
Sys.which("curl") === nothing && return
## Extract owner and repository name
m = match(r"^github.com\/(.+?)\/(.+?)(.git)?$", deploydocs_repo)
m = match(Regex("^(?:https?://)?$(cfg.github_host)\\/(.+?)\\/(.+?)(.git)?\$"), deploydocs_repo)
m === nothing && return
owner = String(m.captures[1])
repo = String(m.captures[2])
Expand All @@ -517,9 +550,9 @@ function post_github_status(type::S, deploydocs_repo::S, sha::S, subfolder = not
json["description"] = "Documentation build in progress"
elseif type == "success"
json["description"] = "Documentation build succeeded"
target_url = "https://$(owner).github.io/$(repo)/"
if subfolder !== nothing
target_url *= "$(subfolder)/"
target_url = cfg.github_pages_url
if !isempty(target_url) && subfolder !== nothing
target_url = rstrip(target_url, '/') * "/$(subfolder)/"
end
json["target_url"] = target_url
elseif type == "error"
Expand All @@ -530,18 +563,19 @@ function post_github_status(type::S, deploydocs_repo::S, sha::S, subfolder = not
error("unsupported type: $type")
end
push!(cmd.exec, "-d", JSON.json(json))
push!(cmd.exec, "https://api.github.com/repos/$(owner)/$(repo)/statuses/$(sha)")
push!(cmd.exec, "$(cfg.github_api)/repos/$(owner)/$(repo)/statuses/$(sha)")
# Run the command (silently)
io = IOBuffer()
res = run(pipeline(cmd; stdout = io, stderr = devnull))
@debug "Response of curl POST request" response = String(take!(io))
catch
@debug "Failed to post status"
catch e
@debug "Failed to post status" exception = e
end
return nothing
end

function verify_github_pull_repository(repo, prnr)
function verify_github_pull_repository(cfg::GitHubActions, prnr)
repo = cfg.github_repository
github_token = get(ENV, "GITHUB_TOKEN", nothing)
if github_token === nothing
@warn "GITHUB_TOKEN is missing, unable to verify if PR comes from destination repository -- assuming it doesn't."
Expand All @@ -552,7 +586,7 @@ function verify_github_pull_repository(repo, prnr)
push!(cmd.exec, "-H", "Authorization: token $(github_token)")
push!(cmd.exec, "-H", "User-Agent: Documenter.jl")
push!(cmd.exec, "--fail")
push!(cmd.exec, "https://api.github.com/repos/$(repo)/pulls/$(prnr)")
push!(cmd.exec, "$(cfg.github_api)/repos/$(repo)/pulls/$(prnr)")
try
# Run the command (silently)
response = run_and_capture(cmd)
Expand Down
14 changes: 11 additions & 3 deletions src/utilities/Remotes.jl
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,11 @@ function repofile(remote::Remote, ref, filename, linerange = nothing)
return fileurl(remote, ref, filename, isnothing(linerange) ? nothing : Int(first(linerange)):Int(last(linerange)))
end


const GITHUB_HOST = "github.com"

"""
GitHub(user :: AbstractString, repo :: AbstractString)
GitHub(user :: AbstractString, repo :: AbstractString, [host :: AbstractString])
GitHub(remote :: AbstractString)
Copy link
Member

Choose a reason for hiding this comment

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

I took the liberty of removing host for the single-argument method. I think it would be better if it stays single-argument. If we want to add support for overriding the host, then let's figure out some syntax for the string, like github.selfhosted:Org/Repo.jl, which we can regex.


Represents a remote Git repository hosted on GitHub. The repository is identified by the
Expand All @@ -117,16 +120,21 @@ makedocs(

The single-argument constructor assumes that the user and repository parts are separated by
a slash (e.g. `JuliaDocs/Documenter.jl`).

A `host` can be provided to point to the location of the self-hosted GitHub installation.
"""
struct GitHub <: Remote
user::String
repo::String
host::String

GitHub(user::AbstractString, repo::AbstractString, host::AbstractString = GITHUB_HOST) = new(user, repo, host)
end
function GitHub(remote::AbstractString)
user, repo = split(remote, '/')
return GitHub(user, repo)
return GitHub(user, repo, GITHUB_HOST)
end
repourl(remote::GitHub) = "https://github.com/$(remote.user)/$(remote.repo)"
repourl(remote::GitHub) = "https://$(remote.host)/$(remote.user)/$(remote.repo)"
function fileurl(remote::GitHub, ref::AbstractString, filename::AbstractString, linerange)
url = "$(repourl(remote))/blob/$(ref)/$(filename)"
isnothing(linerange) && return url
Expand Down
111 changes: 110 additions & 1 deletion test/deployconfig.jl
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,28 @@ end
)
@test !d.all_ok
end
# Self-hosted GitHub installation
# Regular tag build with GITHUB_TOKEN
withenv(
"GITHUB_EVENT_NAME" => "push",
"GITHUB_REPOSITORY" => "JuliaDocs/Documenter.jl",
"GITHUB_REF" => "refs/tags/v1.2.3",
"GITHUB_ACTOR" => "github-actions",
"GITHUB_TOKEN" => "SGVsbG8sIHdvcmxkLg==",
"DOCUMENTER_KEY" => nothing,
) do
cfg = Documenter.GitHubActions("github.selfhosted", "pages.selfhosted/something/JuliaDocs/Documenter.jl")
d = Documenter.deploy_folder(
cfg; repo = "github.selfhosted/JuliaDocs/Documenter.jl.git",
devbranch = "master", devurl = "dev", push_preview = true
)
@test d.all_ok
@test d.subfolder == "v1.2.3"
@test d.repo == "github.selfhosted/JuliaDocs/Documenter.jl.git"
@test d.branch == "gh-pages"
@test Documenter.authentication_method(cfg) === Documenter.HTTPS
@test Documenter.authenticated_repo_url(cfg) === "https://github-actions:SGVsbG8sIHdvcmxkLg==@github.selfhosted/JuliaDocs/Documenter.jl.git"
end
end
end

Expand Down Expand Up @@ -1396,7 +1418,7 @@ end
"CI" => "woodpecker",
"GITHUB_REPOSITORY" => nothing
) do
@test_throws KeyError cfg = Documenter.auto_detect_deploy_system()
@test_throws KeyError cfg = Documenter.auto_detect_deploy_system()
end
# Drone compatibility ends post-1.0.0
withenv(
Expand Down Expand Up @@ -1494,3 +1516,90 @@ end
@test length(r.stdout) > 0
end
end

@testset "post_status" begin
if Sys.which("curl") === nothing
@warn "'curl' binary not found, skipping related tests."
else
@testset "Default GitHubActions push" begin
buffer = IOBuffer()
logger = SimpleLogger(buffer, Logging.Debug)
with_logger(logger) do
withenv(
"GITHUB_EVENT_NAME" => "push",
"GITHUB_REPOSITORY" => "JuliaDocs/Documenter.jl",
"GITHUB_REF" => "refs/tags/v1.2.3",
"GITHUB_ACTOR" => "github-actions",
"GITHUB_SHA" => "407d4b94",
"GITHUB_TOKEN" => "SGVsbG8sIHdvcmxkLg==",
"GITHUB_API_URL" => "badurl://api.github.com" # use bad url protocol to trigger CURL failure
) do
cfg = Documenter.GitHubActions()
Documenter.post_status(cfg; type = "success", repo = "github.com/JuliaDocs/Documenter.jl")
end
end
logged = read(seek(buffer, 0), String)
@test occursin(r"""`curl -sX POST -H 'Authorization: token SGVsbG8sIHdvcmxkLg==' -H 'User-Agent: Documenter.jl' -H 'Content-Type: application/json' -d '{.+?}' badurl://api.github.com/repos/JuliaDocs/Documenter.jl/statuses/407d4b94`""", logged)
@test occursin(r"""`.+?{.*?\"target_url":"https://JuliaDocs.github.io/Documenter.jl/".*?}'.+?`""", logged)
@test occursin(r"""`.+?{.*?\"context\":\"documenter/deploy\".*?}'.+?`""", logged)
@test occursin(r"""`.+?{.*?\"description\":\"Documentation build succeeded\".*?}'.+?`""", logged)
@test occursin(r"""`.+?{.*?\"state\":\"success\".*?}'.+?`""", logged)
end

@testset "Default GitHubActions pull_request" begin
buffer = IOBuffer()
logger = SimpleLogger(buffer, Logging.Debug)
with_logger(logger) do
mktemp() do path, io
write(io, """{"pull_request":{"head":{"sha":"407d4b94"}}}""")
close(io)
withenv(
"GITHUB_EVENT_NAME" => "pull_request",
"GITHUB_EVENT_PATH" => path,
"GITHUB_REPOSITORY" => "JuliaDocs/Documenter.jl",
"GITHUB_REF" => "refs/tags/v1.2.3",
"GITHUB_ACTOR" => "github-actions",
"GITHUB_TOKEN" => "SGVsbG8sIHdvcmxkLg==",
"GITHUB_API_URL" => "badurl://api.github.com" # use bad url protocol to trigger CURL failure
) do
cfg = Documenter.GitHubActions()
Documenter.post_status(cfg; type = "success", repo = "github.com/JuliaDocs/Documenter.jl")
end
end
end
logged = read(seek(buffer, 0), String)
@test occursin(r"""`curl -sX POST -H 'Authorization: token SGVsbG8sIHdvcmxkLg==' -H 'User-Agent: Documenter.jl' -H 'Content-Type: application/json' -d '{.+?}' badurl://api.github.com/repos/JuliaDocs/Documenter.jl/statuses/407d4b94`""", logged)
@test occursin(r"""`.+?{.*?\"target_url":"https://JuliaDocs.github.io/Documenter.jl/".*?}'.+?`""", logged)
@test occursin(r"""`.+?{.*?\"context\":\"documenter/deploy\".*?}'.+?`""", logged)
@test occursin(r"""`.+?{.*?\"description\":\"Documentation build succeeded\".*?}'.+?`""", logged)
@test occursin(r"""`.+?{.*?\"state\":\"success\".*?}'.+?`""", logged)
end

@testset "Self-hosted GitHubActions" begin
buffer = IOBuffer()
logger = SimpleLogger(buffer, Logging.Debug)
with_logger(logger) do
withenv(
"GITHUB_EVENT_NAME" => "push",
"GITHUB_REPOSITORY" => "JuliaDocs/Documenter.jl",
"GITHUB_REF" => "refs/tags/v1.2.3",
"GITHUB_ACTOR" => "github-actions",
"GITHUB_SHA" => "407d4b94",
"GITHUB_TOKEN" => "SGVsbG8sIHdvcmxkLg==",
"GITHUB_API_URL" => "badurl://api.github.selfhosted" # use bad url protocol to trigger CURL failure
) do
cfg = Documenter.GitHubActions("github.selfhosted", "pages.selfhosted/pages/JuliaDocs/Documenter.jl")
Documenter.post_status(cfg; type = "success", repo = "github.selfhosted/JuliaDocs/Documenter.jl")
end
end
logged = read(seek(buffer, 0), String)
@test occursin(r"""`curl -sX POST -H 'Authorization: token SGVsbG8sIHdvcmxkLg==' -H 'User-Agent: Documenter.jl' -H 'Content-Type: application/json' -d '{.+?}' badurl://api.github.selfhosted/repos/JuliaDocs/Documenter.jl/statuses/407d4b94`""", logged)
@test occursin(r"""`.+?{.*?\"target_url\":\"pages.selfhosted/pages/JuliaDocs/Documenter.jl\".*?}'.+?`""", logged)
@test occursin(r"""`.+?{.*?\"context\":\"documenter/deploy\".*?}'.+?`""", logged)
@test occursin(r"""`.+?{.*?\"description\":\"Documentation build succeeded\".*?}'.+?`""", logged)
@test occursin(r"""`.+?{.*?\"state\":\"success\".*?}'.+?`""", logged)

end

end
end
Loading