Skip to content
shellhacks.dev
Tooling

How to Use GitHub Releases for Script Distribution

Distribute a script with GitHub Releases: tags, notes, attached files and checksums, the gh CLI, an installer that verifies and update checks.

6 min read
Summarize in AI
Illustration for "How to Use GitHub Releases for Script Distribution": a terminal session, a request travelling to a server, a loop arrow.

A repository is for source, and users need a stable file they can download. GitHub Releases attach versioned artifacts to a tag, with notes, free hosting and predictable URLs. For scripts and small tools that is often all the distribution you need. The step before packaging is in publishing for Homebrew and apt, and the tag handling in git tags. The examples assume the gh CLI 2.60 and Bash 5.2.

Create a release

Shell
version=1.4.0
git tag -a "v$version" -m "backup $version"
git push origin "v$version"
tar -czf "backup-$version.tar.gz" backup man/backup.1 README.md LICENSE
sha256sum "backup-$version.tar.gz" backup > SHA256SUMS
gh release create "v$version" "backup-$version.tar.gz" backup SHA256SUMS \
  --title "backup $version" --notes-file CHANGES.md
gh release view "v$version" --json assets --jq '.assets[].name'
Text
SHA256SUMS
backup
backup-1.4.0.tar.gz

The tag marks the exact source; the files are the installable artifacts. Attach the script itself if it is a single file, so users can fetch it directly, and a tarball with documentation. --generate-notes builds notes from merged pull requests when you do not maintain a changelog by hand. Draft a release first with --draft to check it. The names and notes are examples.

URLs that users can rely on

Text
https://github.com/example/backup/releases/download/v1.4.0/backup            pinned: never changes
https://github.com/example/backup/releases/latest/download/backup           moves with each release
https://github.com/example/backup/archive/refs/tags/v1.4.0.tar.gz           source snapshot of the tag

Pinned URLs are stable and reproducible, which is what automation and Dockerfiles should use. The latest form is for humans and quick installs, and it makes a build change without a commit. GitHub-generated source archives are not guaranteed to be byte-identical forever, so attach your own tarball if you publish its checksum.

An installer that verifies

Shell
#!/usr/bin/env bash
set -euo pipefail
version=${1:-1.4.0}
base=https://github.com/example/backup/releases/download/v$version
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT

curl -fsSLo "$tmp/backup" "$base/backup"
curl -fsSLo "$tmp/SHA256SUMS" "$base/SHA256SUMS"
(cd "$tmp" && grep ' backup$' SHA256SUMS | sha256sum -c -)
install -m 0755 "$tmp/backup" "${PREFIX:-/usr/local}/bin/backup"
echo "installed backup $version"
Text
backup: OK
installed backup 1.4.0

Pinning a version, verifying the checksum and installing to a known path avoids the problems of piping a download into a shell (see reviewing a script before running it). A checksum fetched from the same place as the file protects against corruption, not tampering; for that, publish a signature (GPG, minisign or Sigstore) and verify it with a key distributed separately. Test the installer in a clean container, as in verifying downloads.

Automate with CI

YAML
on:
  push:
    tags: ['v*']
jobs:
  release:
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v4
      - run: make dist
      - run: gh release create "$GITHUB_REF_NAME" dist/* --generate-notes
        env:
          GH_TOKEN: ${{ github.token }}

Pushing a tag builds the artifacts and publishes the release, so the release is reproducible and never depends on one person's laptop. Give the job only the contents: write permission it needs. For compiled tools, GoReleaser does the whole flow including checksums and Homebrew formulas.

Updates

Shell
latest=$(curl -fsSL https://api.github.com/repos/example/backup/releases/latest | jq -r .tag_name)
[[ $latest == "v$VERSION" ]] || echo "newer version available: $latest"

A script can check the API for the newest tag and tell the user. The unauthenticated API is rate limited to 60 requests an hour per address, so cache the answer, as in caching command output, and never check on every run. Better still, point users to a package manager that handles upgrades.

What goes in a release

A release on a code hosting service is a tagged commit with attached files and notes. For a script distributed to users, the attachments are the script itself or an archive, a checksum file and ideally a signature. Name artifacts predictably, such as tool-1.4.0-linux-amd64.tar.gz, so an installer can compute the URL from the version and platform. Include a checksum file with SHA-256 sums for every artifact, and sign it with a key whose public part is published elsewhere, so a user can verify who produced it even if the hosting account is compromised. Write release notes that say what changed, in terms of what users see, and flag changes that break compatibility at the top.

Keep the notes in a changelog in the repository as well, since a release page is not the first place people look when they have the files and no network.

Stable download locations

Users paste URLs into scripts, documentation and configuration management, so the URLs must keep working. The hosting service provides a pattern that always points to the newest release for a given asset name, which suits install scripts that want the current version, and versioned URLs that never change, which suit pinned deployments. Do not delete or replace assets of a published release; if one is wrong, publish a new version and mark the old one as withdrawn in the notes. Replacing a file under the same URL breaks checksums that users recorded, and supply-chain tools treat it as suspicious. Mirrors and caches may serve the old file for a while, adding confusion.

Publish the install instructions with the checksum verification step included, not as an afterthought. The command a user copies should do the safe thing by default.

An installer that checks what it downloads

A one-line installer that pipes a download into a shell is convenient and risky. A better pattern downloads the script or the binary to a temporary file, downloads the checksum, verifies it with sha256sum -c, and only then installs. Show the script's content to users who want to read it, and keep it short and boring: detect the operating system and architecture, pick the right asset, verify, install to a directory on the path, print the version. Use set -eu, quote everything and refuse to run as root unless installing system-wide is the point. Fail with a clear message on unsupported platforms. The post on reviewing scripts before running them describes what to check, and following it for your own installer is the best test of whether it is reasonable.

Support an environment variable or a flag for the version and the destination, so users can pin the version in automation and install into a prefix they own.

Automating and updating

Create the release from CI when a tag is pushed: build the artifacts, compute checksums, sign them, create the release and upload the files. A tool like GoReleaser does this for Go projects, and a short script with the hosting service's command line client does it for scripts. Tests run on the tag before anything is published, and the job fails the release if a step fails, so a half-published release does not exist. Protect the tag and the release workflow with the same care as the source, since whoever can publish can reach every user. Provide an update path: a command that checks for a newer version and prints how to upgrade, without changing anything automatically. Package-manager distribution, through Homebrew, a deb or rpm repository, handles updates and verification for users, and is worth adding when the user base grows. Finally, keep old releases available, document the support policy and tell users how to report problems with a release, since the worst kind of bug report is the one that has nowhere to go.

Where this does not apply

Releases suit public or internal GitHub projects; other hosts have the same feature (GitLab Releases, Gitea). Large binaries have size limits per file, and private repositories require a token to download. For libraries, publish to the language's registry instead. See the GitHub Releases documentation and the gh release create reference.

Also inGit
Share

Keep reading

Get new posts by email

One email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.