Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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
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])

### 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
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
For self-hosted GitHub installation use `GitHubActions(host, pages_url)` constructor
For self-hosted GitHub installation use the `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

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.

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/[email protected]/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:[email protected]/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