At 09:14, your CI system can build a binary from commit 8f3c2a1; at 10:02, GitHub can show a release for that same version; and at 10:07, a user can download an asset that was uploaded after a rebuild. If your pipeline treats those three events as one thing, you can publish a valid-looking release whose downloadable file does not match the code reviewers approved.
The useful mental model is not “we released v2.4.0.” It is “we created a Git reference, pointed it at a specific commit, built files from that commit, and then published a GitHub record that links people to those files.” Those actions may happen seconds apart in a healthy pipeline, or days apart during an incident response.
There are three separate records to manage
A Git tag is part of the Git repository. A GitHub release is metadata stored by GitHub and associated with a tag. Release assets are uploaded files attached to that GitHub release record. They are related, but they do not have the same identity, storage model, or lifecycle.
| Thing | What it identifies | Typical example | Main failure mode |
|---|---|---|---|
| Git tag | A named Git reference to a commit or tag object | v2.4.0 points to commit 8f3c2a1 |
The tag is moved, deleted, or created from the wrong commit |
| GitHub release | A GitHub publication record tied to a tag name | Release notes, title, draft state, prerelease state | The release is published late, against the wrong tag, or with misleading notes |
| Release asset | A downloadable file attached to the release | tool_2.4.0_linux_amd64.tar.gz |
The file was built from a different checkout or uploaded manually |
GitHub’s documentation describes releases as being based on Git tags, which is correct but incomplete for automation design. “Based on” does not mean GitHub has copied the tag object into the release, frozen every downstream artifact, or guaranteed that a file uploaded later came from the tagged commit.
Your pipeline should therefore record and verify one immutable-looking value at every stage: the peeled commit SHA behind the tag. For an annotated tag, that is the commit reached after resolving the tag object. For a lightweight tag, it is the commit the ref points to directly.
Why the dates differ—and why that is normal
A tag date and a release date answer different questions. The tag date answers when Git metadata was created, while the release timestamp answers when GitHub’s release record was created or published. A release created three days after a tag is not inherently suspicious; it may be a deliberate draft-review workflow or a delayed publication after release-candidate testing.
The distinction becomes more important with annotated versus lightweight tags. An annotated tag is a Git object with tagger information and a message. A lightweight tag is only a ref name pointing at another Git object, so it has no independent tag object and no independent tagger timestamp.
git tag -a v2.4.0 -m "Release v2.4.0" git show v2.4.0 git rev-list -n 1 v2.4.0
The first command creates an annotated tag. The second lets you inspect the tag object and its tagger information. The third resolves the tag to the commit your build should use. Do not mistake the commit’s author date or committer date for the time the release was published.
For a GitHub release, distinguish at least two business events: creating a draft and publishing it. A draft may exist while release notes are edited and assets are checked. Publishing makes that release available to consumers who follow GitHub releases or query release endpoints. That timestamp can reasonably be later than the tag timestamp.
Walk through a release pipeline from commit to download
Assume your repository has a command-line tool and you want to publish v2.4.0. A maintainer has merged the final changes to main, and CI has already passed on commit 8f3c2a1. The release workflow should make four explicit decisions rather than letting GitHub infer them.
- Create an annotated tag locally from the reviewed commit:
git tag -a v2.4.0 8f3c2a1 -m "Release v2.4.0". - Push that exact tag:
git push origin v2.4.0. - Run a release workflow that checks out
v2.4.0, resolves its commit SHA, tests it, and builds every distributable file. - Create the GitHub release using the existing tag, then upload the files and a checksum manifest generated in the same job.
The decision that prevents the most damage is step 4: the publishing command must require that the tag already exists. If a release command is allowed to create a missing tag automatically, a typo such as v2.4.O can silently produce a new ref and a new release record.
Keep the commit SHA in the job log and, ideally, include it in generated provenance text or release notes. Version names are useful for humans; the 40-character object ID is what lets you prove which source tree produced a file.
Choose the event that owns publication
For a conventional versioned product, use a tag push as the release trigger. It establishes a clear boundary: merging to main proves code passed integration checks; creating v2.4.0 declares that one specific commit is intended for distribution.
A GitHub release publication event is better as a downstream notification than as the first build trigger. If publishing a release starts a build, the release record can become visible before the assets exist. A user or install script querying the latest release during that gap can receive an incomplete result.
| Trigger | Best use | Tradeoff |
|---|---|---|
push for v* tags |
Build, test, checksum, and publish one versioned release | Requires disciplined tag creation and tag rules |
| Manual workflow dispatch | Rehearsals, recovery, or controlled republishing | Must ask for and verify the exact tag name |
| Release publication | Notify docs, deployment, or announcement systems | Too late to be the authoritative source-build event |
The practical rule is simple: trigger the build from the Git object, and trigger announcements from the GitHub publication event. That separates reproducibility from communication.
Use a workflow that refuses to invent a tag
This GitHub Actions outline starts when a version tag is pushed. It checks out that tag, builds distribution files, writes checksums, and uses the GitHub CLI’s --verify-tag option so the command fails instead of manufacturing a missing tag.
name: publish-release
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.ref }}
- name: Verify the tagged commit
run: |
TAG="${GITHUB_REF_NAME}"
COMMIT="$(git rev-list -n 1 "$TAG")"
echo "tag=$TAG"
echo "commit=$COMMIT"
git status --short
test -z "$(git status --porcelain)"
- name: Test and build
run: |
make test
make dist
sha256sum dist/* > dist/SHA256SUMS
- name: Create release and upload assets
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "$GITHUB_REF_NAME" dist/* \
--verify-tag \
--generate-notes \
--title "$GITHUB_REF_NAME"
This is intentionally a single publish job. Splitting build and upload into separate workflows is possible, but it introduces another handoff: an artifact name, artifact identifier, or external storage location must now be tied back to the same tag commit. Do that only when you need platform-specific runners, long-running builds, or explicit approval gates.
Release assets are distribution files, not build evidence by themselves
A release asset such as tool_2.4.0_darwin_arm64.tar.gz is a file attached to GitHub’s release record. It is not a Git blob in your repository, and cloning the tag will not retrieve it. This matters when a customer reports a bad binary: inspecting the tag alone cannot prove which exact archive they downloaded.
Publish a checksum file beside every set of assets. For a small project, SHA256SUMS is enough to make accidental corruption and mistaken file selection visible.
sha256sum -c SHA256SUMS
For a release with Linux, macOS, and Windows archives, generate the manifest after all three files exist and before uploading any of them. Then upload the archives and the manifest in one publishing operation. If the workflow fails halfway through, delete the incomplete draft or retry the same files; do not rebuild from an unpinned branch and upload replacement files under the same version without investigating.
The tradeoff nobody mentions is support cost. Once users automate downloads from a GitHub release, an asset filename becomes an API. Renaming tool_linux_amd64.tar.gz to tool-x86_64-unknown-linux-gnu.tar.gz may be clearer, but it can break Dockerfiles, bootstrap scripts, and internal package mirrors. Add a compatibility file or publish a migration window when names must change.
Protect the tag before relying on it
A tag only provides a stable release boundary if people and automation cannot casually replace it. Git refs are mutable by design: a user with sufficient repository access can delete a tag and recreate the same name pointing to another commit. An annotated tag is better release metadata than a lightweight tag, but it is not a protection mechanism.
GitHub’s older tag protection rules were migrated toward repository rulesets. For a pattern such as v*, configure a repository ruleset that restricts who can create, update, or delete matching release tags. Give that authority to a release-maintainer group or a dedicated release workflow identity rather than every developer with write access.
- Require annotated, signed tags if your organization has a signing policy.
- Restrict creation and modification of tags matching
v*. - Keep prerelease tags separate, for example
v2.5.0-rc.1, if they have different access rules. - Require CI to verify the resolved tag commit before publishing assets.
- Do not let an asset-upload retry silently create a replacement tag.
Rules reduce accidental retagging, but they do not replace verification. Your pipeline should still print the resolved commit SHA and ensure the checkout is clean before building. Protection is the prevention layer; the SHA is the audit layer.
Design for consumers that ask for “latest”
GitHub lets users subscribe to release notifications without subscribing to every repository update, which is one reason releases are a useful communication layer. But consumers do not all retrieve software the same way. A developer may clone v2.4.0; a shell script may query GitHub for the latest release; another user may copy an asset URL from documentation.
Those clients make different trust decisions. A tag-based installer trusts the Git reference. A “latest release” installer trusts the GitHub release record and its ordering. An asset URL trusts that the release still contains a file at the expected name.
For production automation, prefer an explicit version and checksum:
VERSION=v2.4.0
curl -LO "https://github.com/OWNER/REPO/releases/download/${VERSION}/tool_2.4.0_linux_amd64.tar.gz"
curl -LO "https://github.com/OWNER/REPO/releases/download/${VERSION}/SHA256SUMS"
sha256sum -c SHA256SUMS
“Latest” is acceptable for a developer convenience command, nightly build, or opt-in update channel. It is a poor default for a deployment that must be reproducible six months later. The second-order consequence is incident recovery: if production only records “latest,” you may know when it changed but not what exact file your servers fetched.
Handle drafts, retries, and hotfixes deliberately
Draft releases are useful when a team wants to inspect release notes and assets before publication. They are dangerous when the pipeline treats a draft as proof that publishing succeeded. Make the state transition explicit: create the draft, upload all expected files, verify checksums, then publish.
For retries, decide whether your release is repairable or immutable before an outage forces the decision. A failed upload of one Windows archive should normally be retried from the same tag and the same build inputs. A discovered source-code defect should result in v2.4.1, not a new binary hidden behind the existing v2.4.0 name.
That rule makes support conversations tractable. “Download v2.4.1” is unambiguous. “Download v2.4.0 again because we replaced it Tuesday” creates cache problems, checksum mismatches, and impossible-to-reproduce bug reports.
Use a hotfix release when the source changes. Use a retry when the source is unchanged and the failure was in packaging or transport. Record the resolved commit SHA in both cases so the distinction is visible without reading CI logs.
Apply this checklist to one repository this week
Pick the repository where users download binaries, packages, scripts, or compiled plugins. Then trace its current release process from a merge commit to a user’s downloaded file. You are looking for one answer at each boundary, not a more elaborate workflow.
- Choose one version pattern, such as
v2.4.0, and reserve it for release tags. - Create annotated tags from reviewed commits and confirm the resolved commit with
git rev-list -n 1 TAG. - Add a repository ruleset for the release-tag pattern so tag creation, updates, and deletion are restricted.
- Trigger the build from the tag push, not from a release note edit or a branch head.
- Make the publish step require an existing tag with
gh release create --verify-tag. - Build assets from the checked-out tag, upload
SHA256SUMS, and keep asset filenames stable. - Use the GitHub release event for announcements and user-facing notifications after assets are complete.
- Document whether a failed release is retried from identical inputs or superseded by a new version.
If you can answer “which commit produced this file?” for every asset in your latest release, your pipeline has the right foundation. If you cannot, start with the tag-to-commit check; it is the smallest change that turns a version label into a release boundary.