Releases and downloads (forge)
Helpers that check for, download and deploy releases, tags and branches from GitHub, GitLab and Codeberg.
Source: lib/forge.func.
It is loaded through lib/tools.func, see how helpers reach a script.
Almost every app script uses two functions from this file: check_for_gh_release
in update_script and fetch_and_deploy_gh_release in both the install and the
update path. The GitLab and Codeberg variants are the same implementation with a
different forge name.
Concepts shared by all functions
The version stamp
Every deploy helper records what it installed in $HOME/.<app>, where <app> is
the app name in lower case with spaces removed ("Crafty-Controller" becomes
~/.crafty-controller). Every check_for_* helper compares that file with
upstream. Use the same app name in both calls, otherwise the check never matches.
fetch_and_deploy_release additionally writes $HOME/.<app>.pending when it
starts. The marker holds the version the update started from and is removed by the
engine's exit handler only when the script exits 0. While it exists,
check_for_release reports an update again ("the last run did not finish, doing
it again") and the deploy runs again even if the stamp already matches.
Variables the helpers set
| Variable | Set by | Content |
|---|---|---|
CHECK_UPDATE_RELEASE | every check_for_* that returns 0 | The version to install: the raw upstream tag (v1.2.3 stays v1.2.3), or the short commit SHA for check_for_gh_branch |
CS_PREVIOUS_VERSION | check_for_release, fetch_and_deploy_release (exported) | The version before this update, empty on a fresh install |
TOOLS_GH_REL_JSON | fetch_and_deploy_gh_release (exported) | Path of a temp file with the raw GitHub API response of the last release lookup |
Tokens and hosts
| Variable | Used for |
|---|---|
GITHUB_TOKEN | Sent as Authorization: Bearer to the GitHub API |
var_github_token | Copied into GITHUB_TOKEN by prompt_for_github_token when GITHUB_TOKEN is empty |
GITLAB_TOKEN | Sent as PRIVATE-TOKEN to GitLab |
GITLAB_URL | GitLab instance, default https://gitlab.com |
CODEBERG_TOKEN | Sent as Authorization: token by the release functions (codeberg_api_call does not send it) |
Rate limits
The GitHub API allows a limited number of unauthenticated calls per address. When
it answers 403 to github_api_call (there also 429) or to a deploy helper:
- For a release lookup (
/releases/latestor/releases/tags/<tag>), the helper reads the same release fromgithub.comwithout the API and printsGitHub API rate limit reached; read the release from github.com instead. A token avoids this: var_github_token. This fallback is not used with a tag prefix, nor for "latest" withGH_INCLUDE_PRERELEASE=1. - Otherwise it waits (2 s, then 4 s) and retries, three attempts in total.
- Then it calls
prompt_for_github_token. With a token the counters start over. - Without a token it returns 22.
In the deploy helpers GitLab and Codeberg (status 429) get the retries but neither the fallback nor the prompt.
The check_for_* helpers do none of this. A rate-limited check prints
github: rate limit reached. Raise it with: export GITHUB_TOKEN=ghp_your_token
and returns 22 at once.
Clean installs
The deploy helpers copy the new files over the target directory. Old files that no longer exist upstream stay unless you ask for a wipe:
| Variable | Effect |
|---|---|
CLEAN_INSTALL=1 | Empties the target directory after the archive has been downloaded and extracted, then copies the new files. Dotfiles are removed too. Mount points inside the target are left alone. A failed download or extraction leaves the target as it was. |
CLEAN_INSTALL_KEEP="a b/c" | Space-separated paths relative to the target that survive the wipe |
Anything else you need to keep (an .env, uploads) has to be saved first, see
create_backup and restore_backup in system helpers.
From ct/adventurelog.sh:
CLEAN_INSTALL=1 CLEAN_INSTALL_KEEP="backend/server/media" fetch_and_deploy_gh_release "adventurelog" "seanmorley15/adventurelog" "tarball"Check for an update
check_for_gh_release, check_for_gl_release, check_for_codeberg_release
Report whether upstream has a release that differs from the installed one. The
three are wrappers around check_for_release with the forge filled in.
check_for_gh_release <app> <owner/repo> [pinned_version] [pin_reason] [tag_prefix]
check_for_gl_release <app> <namespace/project> [pinned_version] [pin_reason] [tag_prefix]
check_for_codeberg_release <app> <owner/repo> [pinned_version] [pin_reason] [tag_prefix]| Argument / variable | Default | Meaning |
|---|---|---|
app | required | Name of the version stamp ~/.<app> |
repo | required | Repository path on the forge |
pinned_version | empty | A tag to stay on. The check then only offers this tag, never a newer one |
pin_reason | empty | Text shown when the pin holds an update back |
tag_prefix | empty | Only releases whose tag starts with this string count |
GH_INCLUDE_PRERELEASE | 0 | 1 also counts pre-releases (all three forges read this name) |
| token and host variables | See Tokens and hosts |
Which release is "the latest":
- With a pin, the pinned tag is requested directly.
- Without pin, prefix and pre-releases, the release the maintainer marked as latest on the forge is used.
- Otherwise up to 100 releases are listed, drafts and pre-releases are dropped (on GitLab: upcoming releases), and the highest version wins.
The installed version counts as current when it equals that release, or when both look like dotted versions and the installed one is already ahead.
If ~/.<app> is missing and /opt holds exactly one *_version.txt file, that
file is moved to ~/.<app> (migration from old installs).
| Return | Meaning |
|---|---|
| 0 | Update available, CHECK_UPDATE_RELEASE is set |
| 1 | No update, or the pin holds it back |
| 6 | The forge host does not resolve |
| 7 | Connection failed after three attempts |
| 22 | API error (authentication, rate limit, unexpected status, malformed response) |
| 250 | No stable release found, or the pinned version does not exist upstream |
Scripts use it only as a condition. From ct/listenarr.sh:
if GH_INCLUDE_PRERELEASE=1 check_for_gh_release "listenarr" "Listenarrs/Listenarr"; thenWith a pin and a reason, from ct/affine.sh:
if check_for_gh_release "affine_app" "toeverything/AFFiNE" "${RELEASE}" "each release is tested individually before the version is updated. Please do not open issues for this"; thenOn another GitLab instance, from ct/tor-snowflake.sh:
if GITLAB_URL="https://gitlab.torproject.org" check_for_gl_release "tor-snowflake" "tpo/anti-censorship/pluggable-transports/snowflake"; thenCodeberg, from ct/forgejo.sh:
if check_for_codeberg_release "forgejo" "forgejo/forgejo"; thencheck_for_release
The implementation behind the three wrappers, with the forge as first argument.
check_for_release <github|gitlab|codeberg> <app> <repo> [pinned_version] [pin_reason] [tag_prefix]Arguments, variables and return codes are those of the wrappers above. An unknown forge name returns 22. No script in ProxmoxVE calls it directly; they use the wrappers.
check_for_gh_tag
The same check for GitHub repositories that publish tags but no releases.
check_for_gh_tag <app> <owner/repo> [prefix]| Argument | Default | Meaning |
|---|---|---|
app | required | Name of the version stamp |
repo | required | GitHub repository |
prefix | empty | Passed to get_latest_gh_tag |
Returns 0 with CHECK_UPDATE_RELEASE set to the tag when it differs from the
stamp, 1 when it matches, 22 when the tag lookup failed. From ct/drawdb.sh:
if check_for_gh_tag "drawdb" "drawdb-io/drawdb"; thencheck_for_gh_branch
The same check for apps deployed from a branch: compares the short commit SHA in
the stamp with the branch tip, using git ls-remote.
check_for_gh_branch <app> <owner/repo> [branch]| Argument | Default | Meaning |
|---|---|---|
app | required | Name of the version stamp |
repo | required | GitHub repository |
branch | the repository's default branch | Branch to compare with |
| Return | Meaning |
|---|---|
| 0 | New commits, 7-character SHA in CHECK_UPDATE_RELEASE |
| 1 | Up to date |
| 6 | github.com does not resolve |
| 22 | The ref could not be read |
Installs git if it is missing. From ct/vane.sh:
if check_for_gh_branch "searxng" "searxng/searxng" "master"; thenDeploy a release
fetch_and_deploy_gh_release, fetch_and_deploy_gl_release, fetch_and_deploy_codeberg_release
Download a release and put it in place, then write the version stamp. Wrappers
around fetch_and_deploy_release.
fetch_and_deploy_gh_release <app> <owner/repo> [mode] [version] [target] [asset_pattern] [tag_prefix]
fetch_and_deploy_gl_release <app> <namespace/project> [mode] [version] [target] [asset_pattern] [tag_prefix]
fetch_and_deploy_codeberg_release <app> <owner/repo> [mode] [version] [target] [asset_pattern] [tag_prefix]| Argument | Default | Meaning |
|---|---|---|
app | last part of repo | Name of the stamp, default target directory, and file name in singlefile mode |
repo | required | Repository path on the forge |
mode | tarball | See the modes below |
version | latest | latest or an exact upstream tag |
target | /opt/<app> | Directory the files go to |
asset_pattern | empty | Shell glob matched against the asset's file name. Required for prebuild and singlefile, optional for binary |
tag_prefix | empty | Only releases whose tag starts with this string, e.g. when one repository publishes several products |
| Mode | What is fetched | What happens with it |
|---|---|---|
tarball (alias source) | The source archive of the tag | Extracted, the content of its top-level directory is copied into target |
binary | A .deb asset: first the one matching asset_pattern, then one naming the host architecture (amd64/x86_64 or arm64/aarch64), then any .deb | Installed with apt install, falling back to dpkg -i. Nothing is written to target |
prebuild | The asset matching asset_pattern (.zip, .tar.*, .tgz, .txz, .7z) | Extracted into target. A single top-level directory in the archive is stripped |
singlefile | The asset matching asset_pattern | Saved as <target>/<app> and made executable (not for .jar files) |
| Variable | Default | Meaning |
|---|---|---|
CLEAN_INSTALL, CLEAN_INSTALL_KEEP | off | See Clean installs. Applies to tarball and prebuild |
GH_INCLUDE_PRERELEASE | 0 | 1 allows pre-releases when version is latest |
FORCE_UPDATE | unset | 1 deploys even when the stamp already matches |
USE_ORIGINAL_FILENAME | false | true keeps the asset's own file name in singlefile mode |
DPKG_FORCE_CONFOLD, DPKG_FORCE_CONFNEW | unset | 1 passes --force-confold or --force-confnew to dpkg in binary mode |
var_appversion | unset | Overrides version, but only for the call whose app matches the script's own app name |
TMPDIR | /var/tmp | Where the download and extraction are staged |
| token and host variables | See Tokens and hosts |
Behaviour worth knowing:
- The call is skipped with
<app> <version> already installedwhen the stamp matches the selected release and the target directory exists (inbinarymode the stamp alone is enough), unlessFORCE_UPDATE=1or a.pendingmarker exists. - The stamp stores the tag without a leading
vwhen a digit follows it. - If the selected release has no matching asset (
binary,prebuild,singlefile), the last 15 releases are searched for one that has. On a terminal the user is askedUse version <tag> instead? [Y/n] (auto-yes in 60s); without a terminal the older release is used. Codeberg has no such search.
| Return | Meaning |
|---|---|
| 0 | Deployed, or already installed |
| 6 | The forge host does not resolve |
| 22 | API error (authentication, rate limit, unexpected status) |
| 65 | Unknown forge or mode, or a missing asset pattern |
| 100 | The .deb could not be installed, or jq could not be installed |
| 250 | No release or tag found, or the download failed |
| 251 | Extraction failed |
| 252 | No matching asset, or the archive was empty or could not be copied |
A failed connection to the API returns curl's own exit code.
One example per mode:
# tarball, ct/2fauth.sh
fetch_and_deploy_gh_release "2fauth" "Bubka/2FAuth" "tarball"
# binary, install/authelia-install.sh
fetch_and_deploy_gh_release "authelia" "authelia/authelia" "binary"
# prebuild, ct/listenarr.sh
GH_INCLUDE_PRERELEASE=1 CLEAN_INSTALL=1 fetch_and_deploy_gh_release "listenarr" "Listenarrs/Listenarr" "prebuild" "latest" "/opt/listenarr" "listenarr-*-linux-x64.zip"
# singlefile, install/argus-install.sh
fetch_and_deploy_gh_release "Argus" "release-argus/Argus" "singlefile" "latest" "/opt/argus" "Argus*linux-$(arch_resolve)"With a tag prefix, from ct/deemix.sh:
CLEAN_INSTALL=1 fetch_and_deploy_gh_release "deemix" "bambanah/deemix" "tarball" "latest" "/opt/deemix" "" "deemix-webui@"Keeping the user's config files of a .deb, from ct/caddy.sh:
DPKG_FORCE_CONFOLD=1 fetch_and_deploy_gh_release "caddy" "caddyserver/caddy" "binary" "latest" "/opt/caddy" "caddy_*_linux_$(arch_resolve).deb"Keeping the asset's file name, from ct/komga.sh:
USE_ORIGINAL_FILENAME="true" fetch_and_deploy_gh_release "komga-org" "gotson/komga" "singlefile" "latest" "/opt/komga" "komga*.jar"GitLab, from ct/storyteller.sh:
CLEAN_INSTALL=1 CLEAN_INSTALL_KEEP="data" fetch_and_deploy_gl_release "storyteller" "storyteller-platform/storyteller" "tarball" "latest" "/opt/storyteller" "" "web-v2"Codeberg, from ct/forgejo.sh:
fetch_and_deploy_codeberg_release "forgejo" "forgejo/forgejo" "singlefile" "latest" "/opt/forgejo" "forgejo-*-linux-$(arch_resolve)"fetch_and_deploy_release
The implementation behind the three wrappers, with the forge as first argument.
fetch_and_deploy_release <github|gitlab|codeberg> <app> <repo> [mode] [version] [target] [asset_pattern] [tag_prefix]Everything else is as described above; an unknown forge returns 65. No script in ProxmoxVE calls it directly.
fetch_and_deploy_gh_tag
Deploy the source archive of a GitHub tag, for repositories without releases.
fetch_and_deploy_gh_tag <app> <owner/repo> [tag] [target]| Argument / variable | Default | Meaning |
|---|---|---|
app | required | Stamp name and default target |
repo | required | GitHub repository |
tag | latest | A tag name, or latest for the first tag GitHub lists |
target | /opt/<app> | Target directory |
CLEAN_INSTALL, CLEAN_INSTALL_KEEP | off | See Clean installs |
Skipped with is already up-to-date when the stamp equals the tag. The stamp holds
the tag exactly as upstream names it.
| Return | Meaning |
|---|---|
| 0 | Deployed or already up to date |
| 7 | Download failed |
| 250 | The latest tag could not be determined |
| 251 | Extraction failed |
From install/drawdb-install.sh:
fetch_and_deploy_gh_tag "drawdb" "drawdb-io/drawdb" "latest" "/opt/drawdb"fetch_and_deploy_gl_tag
The same for a GitLab tag, with an optional glob to choose among tags.
fetch_and_deploy_gl_tag <app> <namespace/project> [tag_pattern] [target]| Argument / variable | Default | Meaning |
|---|---|---|
app | required | Stamp name and default target |
repo | required | GitLab project path |
tag_pattern | latest | latest, a glob containing * or ? (resolved with get_latest_gl_tag), or an exact tag |
target | /opt/<app> | Target directory |
GITLAB_TOKEN, GITLAB_URL | See Tokens and hosts | |
CLEAN_INSTALL, CLEAN_INSTALL_KEEP | off | See Clean installs |
Return codes are those of fetch_and_deploy_gh_tag. No script in ProxmoxVE calls
it at the moment.
fetch_and_deploy_gh_branch
Deploy a GitHub repository from a branch, for projects without releases or tags.
fetch_and_deploy_gh_branch <app> <owner/repo> [branch] [target]| Argument / variable | Default | Meaning |
|---|---|---|
app | required | Stamp name and default target |
repo | required | GitHub repository |
branch | the repository's default branch | Branch to deploy |
target | /opt/<app> | Target directory |
CLEAN_INSTALL | off | 1 replaces the target with a fresh clone even when it already is a checkout |
CLEAN_INSTALL_KEEP | empty | Paths that survive when the target is emptied |
If the target already is a git checkout it is updated in place (git fetch --depth 1, git reset --hard, git clean -fd). Otherwise the repository is
cloned next to the target, the target is emptied and the clone copied in, so a
failed clone leaves the target untouched. The stamp receives the 7-character
commit SHA.
Returns 0 on success, 65 when app or repo is missing, 22 for any git or copy
failure. From install/vane-install.sh:
fetch_and_deploy_gh_branch "searxng" "searxng/searxng" "master"fetch_and_deploy_from_url
Download an archive or .deb from any URL and extract or install it. The type is
detected from the file content with file, not from the URL.
fetch_and_deploy_from_url <url> [directory]| Argument / variable | Default | Meaning |
|---|---|---|
url | required | Download URL |
directory | empty | Where to extract. Required for archives, unused for a .deb |
CLEAN_INSTALL, CLEAN_INSTALL_KEEP | off | See Clean installs |
Compressed tarballs, plain tar, zip and 7z are extracted (a single top-level
directory is stripped); a Debian package is installed with apt install, falling
back to dpkg -i. No version stamp is written.
| Return | Meaning |
|---|---|
| 0 | Extracted or installed |
| 65 | Missing URL, missing directory, or an unsupported file type |
| 100 | The .deb could not be installed |
| 250 | Download failed |
| 251 | Extraction failed |
| 252 | Temp directory could not be created, or the archive was empty or could not be copied |
From ct/fileflows.sh (archive) and ct/foldingathome.sh (.deb):
fetch_and_deploy_from_url "https://fileflows.com/downloads/ff-latest.tar.xz" "/opt/fileflows"
fetch_and_deploy_from_url "https://download.foldingathome.org/releases/public/fah-client/$(arch_resolve "debian-10-64bit" "debian-stable-arm64")/release/latest.deb"Look up a version
These print a version on stdout and change nothing. Use them when a script needs the number itself, for example to build a download URL.
get_latest_github_release
Print the tag of the release GitHub marks as latest.
get_latest_github_release <owner/repo> [strip_v]| Argument / variable | Default | Meaning |
|---|---|---|
repo | required | GitHub repository |
strip_v | true | Remove a leading v |
GITHUB_TOKEN | unset | See Tokens and hosts |
Returns 0, 22 when the API call failed, 250 when the response held no tag. From
ct/actualbudget.sh:
RELEASE=$(get_latest_github_release "actualbudget/actual")get_latest_gh_tag
Print the latest tag of a GitHub repository.
get_latest_gh_tag <owner/repo> [prefix]| Argument | Default | Meaning |
|---|---|---|
repo | required | GitHub repository |
prefix | empty | With a prefix, all tags starting with it are fetched and the highest by version sort is printed. Without, the first tag GitHub lists is printed |
The tag is printed as is, including the prefix. Returns 0, 22 when the API call
failed, 250 when no tag was found, 100 when jq could not be installed. From
install/safebucket-install.sh:
GARAGE_VERSION=$(get_latest_gh_tag "deuxfleurs-org/garage" "v")get_latest_codeberg_release
Print the tag of the first release in Codeberg's list that is neither a draft nor a pre-release.
get_latest_codeberg_release <owner/repo> [strip_v]strip_v defaults to true. Returns 0, 22, 250 or 100 as above. From
ct/autocaliweb.sh:
RELEASE=$(get_latest_codeberg_release "gelbphoenix/autocaliweb")get_latest_gitlab_release
Print the highest-versioned tag among the 100 most recent GitLab releases.
get_latest_gitlab_release <namespace/project> [strip_v]strip_v defaults to true and removes a leading v only when a digit follows.
Reads GITLAB_TOKEN and GITLAB_URL. Returns 0, 22, 250 or 100 as above. No
script in ProxmoxVE calls it at the moment.
get_latest_gl_tag
Print the latest GitLab repository tag, optionally limited by a glob.
get_latest_gl_tag <namespace/project> [pattern]| Argument / variable | Default | Meaning |
|---|---|---|
repo | required | GitLab project path |
pattern | empty | Shell glob such as web-v*. With a pattern the highest matching tag by version sort is printed, without one the first listed tag |
GITLAB_TOKEN, GITLAB_URL | See Tokens and hosts |
Tags containing alpha, beta or rc are always skipped. Returns 0, 22 when the
API did not answer 200, 250 when nothing matched. Used by
fetch_and_deploy_gl_tag.
extract_version_from_json
Print one field of a release JSON document.
extract_version_from_json <json> [field] [strip_v]| Argument | Default | Meaning |
|---|---|---|
json | required | The JSON text itself, not a file name |
field | tag_name | Field to read |
strip_v | true | Remove a leading v |
Returns 250 when the field is empty. Used by get_latest_github_release.
Raw API access and tokens
github_api_call
GET a GitHub API URL with authentication, retries and the rate-limit handling described above.
github_api_call <url> [output_file]| Argument / variable | Default | Meaning |
|---|---|---|
url | required | Full API URL |
output_file | /dev/stdout | Where the response body goes |
GITHUB_TOKEN | unset | Bearer token |
Returns 0 on HTTP 200, otherwise 22 after printing the reason (401, rate limit,
404, no response, other status). A 401 offers prompt_for_github_token first.
From install/victoriametrics-install.sh:
github_api_call "https://api.github.com/repos/VictoriaMetrics/VictoriaLogs/releases/latest" "$vl_release_json"codeberg_api_call
GET a Codeberg API URL with three attempts.
codeberg_api_call <url> [output_file]output_file defaults to /dev/stdout. Returns 0 on HTTP 200, otherwise 22. No
token is sent. Used by get_latest_codeberg_release.
prompt_for_github_token
Obtain a GitHub token after an authentication or rate-limit failure.
prompt_for_github_token| Variable | Meaning |
|---|---|
GITHUB_TOKEN | Exported by this function when a token was accepted |
var_github_token | Used without asking when GITHUB_TOKEN is empty |
Without a terminal on stdin it only tries var_github_token. On a terminal it
asks Would you like to enter a GitHub Personal Access Token (PAT)? [y/N] and
repeats the input until validate_github_token accepts the token. Returns 0 when
it set GITHUB_TOKEN (from var_github_token or from the input), 1 otherwise,
also when GITHUB_TOKEN was already set and there is no terminal. Scripts do not
call it; the GitHub helpers do.
validate_github_token
Check a token against https://api.github.com/user.
validate_github_token [token]token defaults to GITHUB_TOKEN.
| Return | Meaning |
|---|---|
| 0 | Valid. Any status other than 200 and 401 is also treated as valid, with a warning |
| 1 | HTTP 401, invalid or expired |
| 2 | Valid classic token without public_repo or repo scope |
| 3 | No token given, or the request failed |
Internal functions
Names starting with _ are not part of the interface for scripts. They can change
without notice.
| Function | Role |
|---|---|
_forge_descriptor | Per-forge data: API base, endpoints, headers, rate-limit status |
_forge_release_json | Fetches and selects the release object for a deploy |
_forge_designated_release | Asks the forge for the release marked as latest |
_forge_http_get, _forge_curl | GET with status mapping and retries |
_forge_explain_failure | Reproduces a failed connection and prints what it points to (DNS, IPv6, TLS, proxy) |
_forge_stable_filter | jq predicate for "not a draft, not a pre-release" |
_forge_highest_version, _forge_same_shape, _forge_is_current | Version ordering used by the checks |
_forge_strip_v | Removes a leading v only when a digit follows |
_forge_previous_version | Reads the .pending marker or the stamp |
_forge_asset_urls, _forge_source_url | Asset and source archive URLs per forge |
_forge_scan_older, _gh_scan_older_releases, _gl_scan_older_releases | Search older releases for a matching asset |
_forge_is_script_app | Decides whether var_appversion applies to a call |
_forge_keep_raw | Keeps the raw GitHub response for TOOLS_GH_REL_JSON |
_forge_require_jq | Makes sure jq is available |
_forge_workdir | Creates the staging directory under TMPDIR or /var/tmp |
_github_release_noapi | Reads a release from github.com when the API is rate limited |
_download_source_tarball | Downloads a source archive and verifies the gzip stream, three attempts |
_deploy_source_tarball, _deploy_unpacked_archive | Extract and copy into the target |
_cs_clean_target_dir | Empties a target directory for CLEAN_INSTALL |