Building and Distributing a Multi-Platform Binary via GitHub Releases — A Practical Deep Dive

Introduction

Modern software distribution is no longer tied exclusively to package managers such as APT or YUM. While those ecosystems provide structured and dependency-aware installations, they do not cover every environment.

In practice, there is always a need for a universal distribution channel — something that works:

  • outside managed environments
  • inside containers
  • in air-gapped or restricted systems
  • across multiple architectures

This is where GitHub Releases become extremely powerful.

In this article, we walk through a complete, production-grade workflow for:

  • building multi-platform binaries
  • creating structured release notes
  • publishing a release via API
  • uploading assets programmatically
  • generating a changelog
  • validating installation in a clean environment

The goal is not just to “make it work”, but to understand why each step exists.

https://github.com/faustobranco/git-semver

https://github.com/faustobranco/git-semver/releases


Versioning as the Source of Truth

Everything starts with versioning.

git tag -a v0.2.1 -m "Release v0.2.1"
git push origin v0.2.1

A Git tag is more than a label — it is:

  • an immutable reference
  • a release boundary
  • the anchor for distribution

GitHub uses this tag to bind:

  • source code snapshots
  • release metadata
  • binary artifacts

Without a tag, there is no reproducible release.


Building Multi-Platform Binaries

Unlike package managers, GitHub Releases distribute raw binaries. This means compatibility must be handled at build time.

GOOS=linux GOARCH=amd64 go build -ldflags "-X main.version=v0.2.1" -o git-semver-linux-amd64
GOOS=linux GOARCH=arm64 go build -ldflags "-X main.version=v0.2.1" -o git-semver-linux-arm64
GOOS=darwin GOARCH=amd64 go build -ldflags "-X main.version=v0.2.1" -o git-semver-darwin-amd64
GOOS=darwin GOARCH=arm64 go build -ldflags "-X main.version=v0.2.1" -o git-semver-darwin-arm64

chmod +x git-semver-*

Why this matters

  • GitHub does not resolve dependencies → you must ship ready-to-run binaries
  • Multi-platform support must be explicit
  • Embedding the version (-ldflags) ensures runtime traceability

This is equivalent to what packaging systems do implicitly — but here, you control it directly.


Release Notes: Human-Facing Documentation

Release notes are not optional — they are the interface between your code and its users.

mkdir releases
vi releases/v0.2.1.md

Example:

## git-semver v0.2.1

### Features
- Add JSON output support
- Improve commit analysis with regex

### Improvements
- Better handling of repositories without tags
- Optional push flag behavior

### Fixes
- Correct version bump logic
- Clean CLI output for automation

Why separate file-based notes?

  • versioned alongside code
  • reusable (GitHub, GitLab, docs, etc.)
  • avoids manual editing in UI

This mirrors the same discipline used in infrastructure-as-code.


Creating the Release via API

GitHub treats releases as structured objects tied to tags.

BODY=$(cat releases/v0.2.1.md)

curl -X POST \
  -H "Authorization: Bearer $GITHUB_TOKEN" \
  -H "Accept: application/vnd.github+json" \
  https://api.github.com/repos/faustobranco/git-semver/releases \
  -d "$(jq -n \
    --arg tag "v0.2.1" \
    --arg name "git-semver v0.2.1" \
    --arg body "$BODY" \
    '{
      tag_name: $tag,
      name: $name,
      body: $body,
      draft: false,
      prerelease: false
    }')"

Why use jq?

Handling JSON with multiline content is error-prone.

jq ensures:

  • correct escaping
  • preservation of line breaks
  • valid JSON structure

This avoids common issues such as broken formatting or malformed requests.


Uploading Binary Assets

Unlike GitLab (which separates storage and release metadata), GitHub attaches binaries directly to the release.

Then upload each binary:

GITHUB_OWNER="faustobranco"
GITHUB_REPO="git-semver"
RELEASE_VERSION="v0.2.1"

FILES=(
  "git-semver-linux-amd64"
  "git-semver-linux-arm64"
  "git-semver-darwin-amd64"
  "git-semver-darwin-arm64"
)

echo "Getting release ID..."

RELEASE_ID=$(curl -s \
  -H "Authorization: Bearer $GITHUB_TOKEN" \
  https://api.github.com/repos/$GITHUB_OWNER/$GITHUB_REPO/releases/tags/$RELEASE_VERSION \
  | jq -r '.id')

echo "Release ID: $RELEASE_ID"

for FILENAME in "${FILES[@]}"; do
  echo "Uploading $FILENAME..."

  curl -s -X POST \
    -H "Authorization: Bearer $GITHUB_TOKEN" \
    -H "Content-Type: application/octet-stream" \
    --data-binary @"$FILENAME" \
    "https://uploads.github.com/repos/$GITHUB_OWNER/$GITHUB_REPO/releases/$RELEASE_ID/assets?name=$FILENAME"

  echo "Done"
done

Why this approach?

  • GitHub Releases act as both metadata and storage
  • no separate registry is required
  • each asset is version-scoped via the release

This is simpler than GitLab, but less flexible for enterprise scenarios.


Generating a Changelog

A changelog provides a structured history of changes across versions.

Raw extraction:

LAST_TAG=v0.2.0
NEW_TAG=v0.2.1

echo "## [$NEW_TAG] - $(date +%F)" > CHANGELOG.tmp
git log $LAST_TAG..HEAD --pretty=format:"- %s" >> CHANGELOG.tmp

Refined version:

## [v0.2.1] - 2026-03-26

### Added
- 

### Fixed
- fix: build version/tag v0.2.1
- fix: fix json return%

Why not rely only on release notes?

  • release notes are contextual
  • changelog is historical

Together they provide:

  • per-release detail
  • long-term traceability

Updating Release Notes (Post-Creation)

If needed, release notes can be updated:

RELEASE_ID=$(curl -s \
  -H "Authorization: Bearer $GITHUB_TOKEN" \
  https://api.github.com/repos/faustobranco/git-semver/releases/tags/v0.2.1 \
  | jq -r '.id')

BODY=$(cat releases/v0.2.1.md)

curl -X PATCH \
  -H "Authorization: Bearer $GITHUB_TOKEN" \
  -H "Accept: application/vnd.github+json" \
  https://api.github.com/repos/faustobranco/git-semver/releases/$RELEASE_ID \
  -d "$(jq -n --arg body "$BODY" '{body: $body}')"

This reinforces the idea that releases are mutable metadata over immutable artifacts.


Testing in a Clean Environment

A release is only valid if it works outside your development environment.

docker run -it --rm -v $(pwd):/work ubuntu:22.04 bash

Inside the container:

apt update && apt install -y curl

curl -L \
  -o git-semver \
  https://github.com/faustobranco/git-semver/releases/download/v0.2.1/git-semver-linux-amd64

chmod +x git-semver
./git-semver --version

Why this matters

  • validates real-world usability
  • ensures no hidden dependencies
  • confirms correct architecture targeting

This is equivalent to testing packages in a clean VM — but faster and reproducible.


Distribution Model: Where GitHub Fits

At this point, you have three distribution layers:

ChannelPurpose
APTDebian-based systems
YUMRHEL-based systems
GitHub ReleasesUniversal fallback

GitHub becomes the lowest common denominator:

  • no package manager required
  • works anywhere with curl
  • ideal for containers and CI environments

Conclusion

By the end of this workflow, you have built a complete release system that:

  • is version-driven
  • supports multiple platforms
  • provides structured documentation
  • enables automated distribution
  • works in any environment

GitHub Releases are not a replacement for package managers — but they are a critical complement.

They fill the gap where structured ecosystems cannot reach, providing a simple, reliable, and universal way to distribute software.


If you’ve already implemented APT and YUM repositories, this approach completes the picture — giving you a fully layered distribution strategy, from enterprise-grade packaging down to raw binary delivery.

Leave a Reply

Your email address will not be published. Required fields are marked *