Registry Sync API
JuliaHub keeps its package server up to date by periodically synchronizing every configured registry — the public General registry and any private registries — with its upstream Git repository. By default a synchronization cycle runs roughly every seven minutes, and administrators can give individual registries their own schedule (see Sync schedule).
Two REST endpoints let you work with synchronization from outside the web interface:
| Method | Path | Purpose |
|---|---|---|
POST | /app/sync/start | Request a synchronization now, optionally for specific registries |
GET | /app/sync/status | Report when each kind of synchronization last ran, and the state of each registry |
The typical uses are:
- Triggering a sync from CI, so that a package version you just registered into a private registry is installable immediately instead of after the next scheduled cycle.
- Monitoring, so that a registry which has stopped synchronizing — for example because a Git credential expired — raises an alert instead of going unnoticed.
Replace juliahub.example.com in the examples below with the hostname of your JuliaHub instance.
Authenticating
Both endpoints accept a bearer token in the Authorization header:
Authorization: Bearer <token>Either of these works as the token:
- A long-term token, generated under Token configuration. This is usually the better choice for CI and monitoring jobs, because the job does not need to mint a fresh token on every run.
- A JuliaHub ID token, as printed in
JULIAHUB_ID_TOKENbyjh auth envor stored asid_tokeninauth.toml. These are short-lived — see Authenticating on the registration API page for how to obtain one from a pipeline.
A token acts as the user it belongs to, with that user's full JuliaHub access. Store it in your CI system's secret storage (GitLab CI/CD variables, GitHub Actions secrets), never in the repository, and mark it masked/protected so it is not written to build logs.
Who can call these endpoints
Both endpoints belong to the package.syncstatus API set. By default it is granted to all users, so any JuliaHub account can read the synchronization status and request a sync — administrator rights are not required.
Administrators can restrict it from Admin → API Access Control — see API Access Control. An account that has been denied access gets a 403 whose message reads User must be an admin to access route "/app/sync/start"; despite the wording, the fix is to grant the account the package.syncstatus API set, not to make it an administrator.
Triggering a sync
POST /app/sync/start asks the platform to run a synchronization cycle as soon as possible.
To sync specific registries, pass their names in a JSON body:
curl -sS -X POST https://juliahub.example.com/app/sync/start \
-H "Authorization: Bearer $JULIAHUB_TOKEN" \
-H "Content-Type: application/json" \
-d '{"registries": ["MyCompanyRegistry"]}'To run a cycle for every registry that is due, omit the body:
curl -sS -X POST https://juliahub.example.com/app/sync/start \
-H "Authorization: Bearer $JULIAHUB_TOKEN"| Field | Required | Description |
|---|---|---|
registries | no | Array of registry names, exactly as they appear as keys of registries in the status response. Omit it, or pass an empty array, to sync without naming specific registries. |
Send registries in the JSON body and do not add a query string to the URL. When a query string is present, its parameters are used instead of the JSON body.
A successful call returns:
{
"success": true,
"message": ""
}If the request could not be queued, success is false and message reads Could not start package sync. This is returned with HTTP status 200, so check success rather than relying on the status code alone.
What a triggered sync does
- It is asynchronous. A
200with"success": truemeans the request was queued, not that the sync has finished. The platform picks the request up within a few seconds; the sync itself then takes as long as a normal cycle. To wait for it, poll the status endpoint. - Named registries skip their schedule. A registry named in
registriesis synced even if its own sync schedule would not otherwise allow it yet. Registries that are due on their own schedule are synced in the same cycle as well. - Disabled registries are never synced, even if named.
- It never runs two cycles at once. If a sync is already running, the request waits and runs when the current cycle finishes.
- A triggered sync does not bypass a cooldown period or pickup window. A new version held back by a registry's cooldown period is fetched, but is not served until the hold expires.
In 26.4 and earlier, at most one sync request can be waiting at a time. A request made while another is already waiting is accepted but has no effect, including its list of registries — so if one job asks for RegistryA and a second job asks for RegistryB before the first request has started, only RegistryA is guaranteed a sync. If you trigger syncs from several pipelines, either trigger without a registries list, or check that sync_request_queued is false in the status response first.
Later releases queue every request and combine their registry lists.
Checking sync status
GET /app/sync/status reports the state of synchronization across the platform. It takes no parameters and always returns every registry.
curl -sS https://juliahub.example.com/app/sync/status \
-H "Authorization: Bearer $JULIAHUB_TOKEN"Synchronization runs in three phases, each with its own timestamps:
- Package sync (
basic_sync_*) — fetches registries and makes new package versions available. This is the one that matters for "can I install the version I just released?". - Documentation generation (
docs_sync_*) — builds or fetches package documentation. - Vulnerability database sync (
osv_sync_*) — refreshes security advisories used by Vulnerability Analysis.
Timestamps are ISO 8601 strings in UTC, for example 2026-09-22T10:04:41.512+00:00, or null if that phase has never run.
Top-level fields
| Field | Description |
|---|---|
success | true if the status could be read. |
running | Whether any phase is currently in progress. |
basic_sync_running, docs_sync_running, osv_sync_running | Whether that phase is currently in progress. |
basic_sync_time_start, basic_sync_time_finish | When package sync last started, and when it last completed successfully. |
docs_sync_time_start, docs_sync_time_finish | The same, for documentation generation. |
osv_sync_time_start, osv_sync_time_finish | The same, for the vulnerability database sync. Present from JuliaHub 26.4. |
latest | basic_sync_time_finish as a human-readable age, such as "12 minutes ago", or "None". Kept for backwards compatibility; use basic_sync_time_finish in scripts. |
sync_request_queued | Whether a triggered sync is waiting to start. |
registries | An object with one entry per configured registry, keyed by registry name. Its contents depend on the JuliaHub version — see below. |
A *_time_finish value is only updated when that phase completes successfully. A failed cycle leaves it unchanged, so its age is a reliable measure of how stale the platform is.
Per-registry fields
JuliaHub 26.4 and earlier
Each entry in registries has:
| Field | Description |
|---|---|
basic_sync_last_update | When this registry was last synced. |
basic_sync_next_update | The earliest time this registry is next due to sync on its schedule. |
{
"success": true,
"running": false,
"basic_sync_running": false,
"docs_sync_running": false,
"osv_sync_running": false,
"basic_sync_time_start": "2026-09-22T10:00:03.118+00:00",
"basic_sync_time_finish": "2026-09-22T10:04:41.512+00:00",
"docs_sync_time_start": "2026-09-22T10:04:42.020+00:00",
"docs_sync_time_finish": "2026-09-22T10:05:30.884+00:00",
"osv_sync_time_start": "2026-09-22T10:05:31.301+00:00",
"osv_sync_time_finish": "2026-09-22T10:05:47.090+00:00",
"latest": "12 minutes ago",
"sync_request_queued": false,
"registries": {
"General": {
"basic_sync_last_update": "2026-09-22T10:04:41.512+00:00",
"basic_sync_next_update": "2026-09-22T10:16:47.090+00:00"
},
"MyCompanyRegistry": {
"basic_sync_last_update": "2026-09-22T10:04:41.512+00:00",
"basic_sync_next_update": "2026-09-22T10:16:47.090+00:00"
}
}
}In these versions, basic_sync_last_update is tracked separately only for a registry that has its own sync schedule. For a registry without one it is simply basic_sync_time_finish — the same value for every such registry — so it cannot tell you that one particular registry has stopped updating.
Later releases
Releases after 26.4 report what each registry is actually serving, and how far behind its upstream repository it is. Each entry in registries has:
| Field | Description |
|---|---|
enabled | Whether the registry is enabled. |
mirrored_at | When JuliaHub last fetched this registry from its upstream repository. Updated on every fetch, even when nothing had changed. |
synced_commit | The registry commit JuliaHub is currently serving, as {sha, time, message}. |
upstream_commit | The latest registry commit JuliaHub has fetched, as {sha, time, message}. |
pending_commits | Number of fetched commits not yet being served. Normally 0; it is non-zero while a cooldown period or pickup window is holding new versions back. |
hold_reason | Why fetched commits are not being served yet: "cooldown" or "pickup_window". Absent when nothing is held. |
held_until | When the current hold is expected to end. Absent when nothing is held. |
cooldown_hours | The registry's cooldown period. Present only when a cooldown is configured. |
sync_schedule | The registry's own sync schedule — days, start_hour, end_hour, timezone and interval_sec — if it has one. |
pickup_window | The registry's pickup window — days, start_hour, end_hour and timezone — if it has one. |
Fields with no value are omitted rather than returned as null. The top level of the response additionally includes auto_sync, whether scheduled synchronization is enabled, and default_sync_interval_sec, the default interval between cycles.
{
"registries": {
"MyCompanyRegistry": {
"enabled": true,
"mirrored_at": "2026-09-22T10:01:10.204+00:00",
"synced_commit": {
"sha": "4f1c2a9e0b7d...",
"time": "2026-09-22T09:58:00+00:00",
"message": "New version: MyPackage v0.2.0"
},
"upstream_commit": {
"sha": "4f1c2a9e0b7d...",
"time": "2026-09-22T09:58:00+00:00",
"message": "New version: MyPackage v0.2.0"
},
"pending_commits": 0
}
}
}Monitoring for stale registries
The status response does not report sync errors directly. A sync that fails — because a registry's Git credential expired, a registry host is unreachable, or a registry fails validation — simply stops the timestamps from advancing. Monitor for staleness rather than for errors.
A package sync cycle covers all due registries together, and if any one of them cannot be fetched the whole cycle fails. A single broken private registry therefore also stops General and every other registry from updating. basic_sync_time_finish stops advancing in that case, which makes it the most important value to alert on.
A reasonable set of checks, run on a schedule:
basic_sync_time_finishis recent. Alert if it is older than a few multiples of the sync interval — for the default seven-minute interval, an hour is a comfortable threshold. If any registry has a sync schedule that only allows syncs at certain times, set the threshold to cover the longest gap in that schedule.- A cycle is not stuck. Alert if
basic_sync_runningstaystruefor much longer than a normal cycle takes. - On releases after 26.4, each registry is current. Alert if a registry's
mirrored_atis old, or ifpending_commitsis non-zero whilehold_reasonis absent — that is, new commits have been fetched but are not being served, and no configured hold explains it.
The following script implements the first check. It exits non-zero when package sync is stale, so a scheduled CI job running it fails, and your CI system's normal failure notifications become the alert:
#!/bin/sh
set -eu
: "${JULIAHUB_HOST:?}" "${JULIAHUB_TOKEN:?}"
MAX_AGE_MINUTES="${MAX_AGE_MINUTES:-60}"
status=$(curl -sS --fail-with-body \
"https://$JULIAHUB_HOST/app/sync/status" \
-H "Authorization: Bearer $JULIAHUB_TOKEN")
echo "$status" | jq -r --argjson max "$((MAX_AGE_MINUTES * 60))" -e '
# "2026-09-22T10:04:41.512+00:00" -> seconds since the epoch
def epoch: sub("\\.[0-9]+"; "") | sub("\\+00:00$"; "Z") | fromdateiso8601;
.basic_sync_time_finish as $finish
| if $finish == null then
"Package sync has never completed.\n" | halt_error(1)
elif (now - ($finish | epoch)) > $max then
"Package sync is stale: last completed at \($finish).\n" | halt_error(1)
else
"Package sync last completed at \($finish)."
end
'In GitLab CI, run it from a scheduled pipeline:
check-juliahub-sync:
image: alpine:latest
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"
variables:
JULIAHUB_HOST: juliahub.example.com
MAX_AGE_MINUTES: "60"
before_script:
- apk add --no-cache curl jq
script:
- ./check-juliahub-sync.shwith JULIAHUB_TOKEN defined as a masked CI/CD variable.
Syncing a registry from CI
When a registration pull request is merged into a private registry, JuliaHub does not see the new version until the registry's next sync. To make it available immediately, trigger a sync from a pipeline that runs on pushes to the registry repository's default branch.
The job below triggers a sync of one registry, then waits until the sync has completed and fails if it does not complete in time.
Waiting for a triggered sync to finish
To tell when a triggered sync has finished, record basic_sync_time_finish before triggering and wait until it changes. It only ever moves forward, so any change means a cycle has completed since you triggered the sync. On releases after 26.4 you can instead wait for the registry's own mirrored_at to change.
GitLab CI
This job belongs in the CI configuration of the registry repository. It assumes the token is available as a masked CI/CD variable named JULIAHUB_TOKEN.
sync-juliahub:
stage: deploy
image: alpine:latest
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
variables:
JULIAHUB_HOST: juliahub.example.com
REGISTRY_NAME: MyCompanyRegistry
before_script:
- apk add --no-cache curl jq
script:
- |
status_url="https://$JULIAHUB_HOST/app/sync/status"
auth="Authorization: Bearer $JULIAHUB_TOKEN"
before=$(curl -sS --fail-with-body "$status_url" -H "$auth" \
| jq -r '.basic_sync_time_finish // ""')
echo "Last completed sync: ${before:-never}"
curl -sS --fail-with-body -X POST \
"https://$JULIAHUB_HOST/app/sync/start" \
-H "$auth" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg r "$REGISTRY_NAME" '{registries: [$r]}')" \
| jq -e '.success' > /dev/null
echo "Sync of $REGISTRY_NAME requested."
for _ in $(seq 1 60); do
sleep 15
after=$(curl -sS "$status_url" -H "$auth" \
| jq -r '.basic_sync_time_finish // ""')
if [ -n "$after" ] && [ "$after" != "$before" ]; then
echo "Sync completed at $after."
exit 0
fi
done
echo "Timed out waiting for the sync to complete." >&2
exit 1The same steps work in GitHub Actions or any other CI system; only the trigger condition and the way secrets are provided differ. See the registration API examples for the equivalent GitHub Actions structure.
A triggered sync completes as soon as the platform has fetched the registry, but a new version held back by a cooldown period or pickup window is still not installable until the hold ends. On releases after 26.4, hold_reason and held_until in the status response show when that is.
Troubleshooting
403 User must be an admin to access route ...
The account the token belongs to has been denied the package.syncstatus API set. See Who can call these endpoints.
"success": false with Could not start package sync
The platform could not queue the request. Retry after a short wait; if it keeps failing, contact JuliaHub support.
The sync was requested but basic_sync_time_finish never advances
The sync is starting but not completing. The usual causes are:
- An expired or revoked Git credential on one of the registries. Check the credentials under Admin → Settings → Credentials and the registry's configuration under Admin → Settings → Registries. Because a failure in one registry fails the whole cycle, check every registry, not only the one you asked to sync.
- A registry host that is unreachable from the platform.
On a self-managed installation, the logs of the package sync service show which registry is failing.
My registry was synced but the new version is not installable
Check that the registration pull request was merged — registration only opens the pull request. Then, on releases after 26.4, check pending_commits and hold_reason for the registry: a cooldown period or pickup window may be holding the version back deliberately.