Release Automation, Tags, GitHub Releases, Packages, and Container Registries: Guided Hands-On Workflow
Create a disposable prerelease from exact source, promote the same built bytes, inspect release state, and simulate immutable package publication without external registry risk.
Learning objectives
- Build one deterministic release subject from the exact workflow source SHA and preserve its digest.
- Move the same bytes from an Actions artifact into a narrowly privileged prerelease job without rebuilding.
- Create a unique disposable Git tag/release and read back release ID/state/asset metadata.
- Simulate versioned package publication locally without creating a persistent external package.
- Preserve evidence before cleanup and understand what changes if release immutability is enabled.
1. Disposable scenario and safety boundary
Use a throwaway public repository such as
gha-release-lab. The live part creates one uniquely
named prerelease and tag; it does not push to GHCR or another
package registry. The package/registry portion is a local filesystem
simulation. This keeps the core exercise free and makes cleanup
exact.
Mutation warning: creating/deleting tags and releases changes repository state. Confirm the repository name before running the publishing job. Never reuse this workflow unchanged in a production repository.
2. Preflight: inspect before granting write authority
gh repo view --json nameWithOwner,visibility,defaultBranchRef
git status --short
git rev-parse HEAD
gh release list --limit 10
gh api repos/{owner}/{repo}/actions/permissions/workflow --jq .
No cloud credential, PAT, registry password or production secret is
required. The publishing job uses the repository-scoped
GITHUB_TOKEN; write permissions exist only on that job.
3. Build once, then publish the same bytes
The build job has read-only repository permission. It checks that
checkout HEAD equals GITHUB_SHA, creates deterministic
content, records SHA-256, and uploads the
dist/ directory. The release job downloads the artifact
and refuses publication if the bytes no longer match the digest
passed from the build job.
name: Disposable prerelease lab
on:
workflow_dispatch:
permissions: {}
jobs:
build:
name: Build exact release subject
runs-on: ubuntu-24.04
permissions:
contents: read
outputs:
source_sha: ${{ steps.subject.outputs.source_sha }}
subject_sha256: ${{ steps.subject.outputs.subject_sha256 }}
asset_name: ${{ steps.subject.outputs.asset_name }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- id: subject
shell: bash
run: |
set -euo pipefail
test "$(git rev-parse HEAD)" = "$GITHUB_SHA"
mkdir -p dist
asset="release-demo-${GITHUB_SHA::12}.txt"
printf 'release-lab\nsource=%s\npayload=hello-release\n' "$GITHUB_SHA" > "dist/$asset"
digest=$(sha256sum "dist/$asset" | awk '{print $1}')
echo "source_sha=$GITHUB_SHA" >> "$GITHUB_OUTPUT"
echo "subject_sha256=$digest" >> "$GITHUB_OUTPUT"
echo "asset_name=$asset" >> "$GITHUB_OUTPUT"
printf '%s %s\n' "$digest" "$asset" > dist/SHA256SUMS
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: release-subject-${{ github.run_id }}-${{ github.run_attempt }}
path: dist/
retention-days: 7
release:
name: Publish unique prerelease
needs: build
runs-on: ubuntu-24.04
permissions:
contents: write
id-token: write
attestations: write
artifact-metadata: write
env:
GH_TOKEN: ${{ github.token }}
SOURCE_SHA: ${{ needs.build.outputs.source_sha }}
EXPECTED_SHA256: ${{ needs.build.outputs.subject_sha256 }}
ASSET_NAME: ${{ needs.build.outputs.asset_name }}
TAG: v0.0.0-lab.${{ github.run_id }}.${{ github.run_attempt }}
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-subject-${{ github.run_id }}-${{ github.run_attempt }}
path: dist
- name: Verify promoted bytes before publication
shell: bash
run: |
set -euo pipefail
actual=$(sha256sum "dist/$ASSET_NAME" | awk '{print $1}')
test "$actual" = "$EXPECTED_SHA256"
echo "verified sha256=$actual"
gh --version
- uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
id: provenance
with:
subject-path: dist/${{ env.ASSET_NAME }}
- name: Create exact tag and prerelease
shell: bash
run: |
set -euo pipefail
gh api -X POST "repos/$GITHUB_REPOSITORY/git/refs" -f ref="refs/tags/$TAG" -f sha="$SOURCE_SHA"
gh release create "$TAG" "dist/$ASSET_NAME" "dist/SHA256SUMS" --verify-tag --prerelease --latest=false --title "Disposable release $TAG" --notes "Source: $SOURCE_SHA; subject sha256: $EXPECTED_SHA256"
- name: Read release evidence back
shell: bash
run: |
set -euo pipefail
gh api "repos/$GITHUB_REPOSITORY/releases/tags/$TAG" --jq '{id,tag_name,target_commitish,draft,prerelease,published_at,assets:[.assets[]|{id,name,size,digest}]}'
echo "attestation_id=${{ steps.provenance.outputs.attestation-id }}"
The attestation is generated only after the release job verifies the
final bytes it will publish. The release job then creates a unique
tag pointing at the exact source SHA and uses
--verify-tag so GitHub CLI cannot silently invent a tag
on a different default-branch tip.
4. Why the permissions are split by job
| Job | Permissions | Reason |
|---|---|---|
| Build | contents: read |
Read exact source; no publishing authority. |
| Release | contents: write |
Create tag and GitHub Release. |
| Release |
id-token: write +
attestations: write +
artifact-metadata: write
|
Create GitHub artifact provenance for the final subject. |
| Neither | packages: write |
No package/GHCR publication occurs in the mandatory lab. |
5. Expected observations
-
Run identity and exact
GITHUB_SHAremain fixed across the build and release jobs. - The release subject SHA-256 before upload and after download is identical.
-
The tag uses the unique form
v0.0.0-lab.RUN_ID.ATTEMPTand targets the build source SHA. - The release is published as a prerelease and explicitly not marked latest.
- GitHub returns a release ID plus asset records; current API responses can include asset digest metadata.
- The provenance action returns an attestation ID/URL/bundle path for the subject.
6. Faithful local package/registry simulation
A registry adds two ideas beyond a Release: a namespace/version record and a mutable alias. Simulate those without external publication by refusing to overwrite a version directory while allowing an alias file to change.
set -euo pipefail
version='1.0.0-lab'
root='.lab-registry/com.example/demo'
mkdir -p "$root/versions" "$root/aliases"
test ! -e "$root/versions/$version" || { echo 'version already exists'; exit 1; }
mkdir "$root/versions/$version"
cp dist/release-demo-*.txt "$root/versions/$version/payload.txt"
sha256sum "$root/versions/$version/payload.txt" > "$root/versions/$version/SHA256SUMS"
digest=$(awk '{print $1}' "$root/versions/$version/SHA256SUMS")
printf '%s\n' "$digest" > "$root/aliases/latest"
find "$root" -type f -maxdepth 4 -print -exec cat {} \;
The version directory models an immutable package version; the
latest file models a mutable tag/alias. A consumer that
requires exact content should select the recorded digest/version,
not trust the alias.
7. Optional GHCR path — explicit publishing authority
If you deliberately want a disposable GHCR extension, add a separate
job with packages: write, log in to
ghcr.io using github.actor and
GITHUB_TOKEN, push a uniquely versioned image, and
record the returned OCI digest. Do not push latest from
pull requests, and do not use a classic PAT when the
repository-scoped token is sufficient.
permissions:
contents: read
packages: write
steps:
- name: Login only in the publishing job
env:
REGISTRY_TOKEN: ${{ github.token }}
run: echo "$REGISTRY_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin
- run: |
image="ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/release-lab:${GITHUB_SHA}"
docker build -t "$image" .
docker push "$image"
docker buildx imagetools inspect "$image"
The optional snippet creates persistent external package state and may be subject to package visibility/storage/billing policy. Use only an explicitly disposable namespace and clean it up according to the package policy.
8. Intentional failure: prove digest drift stops promotion
Before the “Verify promoted bytes” step, temporarily add
printf tamper >> "dist/$ASSET_NAME". The job must
fail before tag/release creation because the recomputed SHA-256
differs. Preserve that failed run ID and log; then remove the
tampering line and create a new attempt or run. Do not update the
expected digest to make the modified bytes pass.
9. Read back GitHub-owned state after success
tag='v0.0.0-lab.RUN_ID.ATTEMPT'
gh release view "$tag" --json databaseId,tagName,isDraft,isPrerelease,isLatest,publishedAt,url
gh api "repos/{owner}/{repo}/releases/tags/$tag" --jq '{id,tag_name,target_commitish,draft,prerelease,assets}'
git ls-remote --tags origin "refs/tags/$tag"
If release immutability is enabled, also run
gh release verify "$tag" and
gh release verify-asset "$tag" ./downloaded-asset.
Those commands verify GitHub’s automatic immutable-release
attestation; they are distinct from the explicit artifact provenance
generated earlier.
10. Cleanup and rollback
- Record run ID/attempt, source SHA, tag target, release ID, asset IDs/digests and attestation ID before cleanup.
-
For an ordinary disposable prerelease, delete only the exact
release/tag:
gh release delete "$TAG" --cleanup-tag --yes. - If release immutability was enabled, understand that a deleted immutable release retires its tag name; do not plan to reuse it. A superseding unique version is usually clearer.
-
Remove
.lab-registry/locally. If you used optional GHCR, delete only the exact disposable package/version after preserving digest evidence. - Delete the throwaway repository when the exercise is complete.
11. Challenge: choose the correct layer
A team asks for “rollback” by rebuilding commit A and republishing it over version 1.2.3. Identify the wrong layer. The correct answer is release/package identity: select the already verified 1.2.3 bytes/digest or publish a new superseding version according to ecosystem policy; do not manufacture different bytes under the old immutable identity.
12. Lesson summary
The guided workflow makes publication auditable: exact source enters, one artifact is built, digest verification survives the job boundary, publication gets the narrow authority it needs, and release/registry state is read back independently.
Knowledge check
Why does the release job download rather than rebuild the subject?
To preserve build-once semantics: the same bytes that passed validation are promoted.
Why use --verify-tag?
It prevents the CLI from creating a missing tag implicitly at an unintended default-branch state.
Why is the tamper exercise useful?
It proves the release gate depends on the recorded subject digest, not merely on workflow control flow.
Does the local latest alias define immutable
identity?
No. It models a mutable pointer; the version/digest record is the durable identity.
Which permission is deliberately absent from the mandatory lab?
packages: write, because no external
package/registry publication is required.
Official references and version notes
- About releases — GitHub Releases are based on Git tags and add release metadata/assets around a versioned source point.
- Managing releases — Current release creation, editing and deletion behavior.
- Immutable releases — Current tag/asset immutability and automatic release-attestation behavior.
- GHCR — Current GitHub Container Registry authentication, package linking and digest guidance.
- GitHub Packages permissions — Repository/granular package permissions and GitHub Actions access.
- GITHUB_TOKEN authentication — Least-privilege token use in workflows.
- gh release create — Current CLI release creation, --verify-tag and immutable-release handling.
- gh release verify — Verification of cryptographically signed immutable-release attestations.
- gh release verify-asset — Verify a local asset against the release attestation and digest.
- actions/attest v4.2.2 — Immutable attestation action revision referenced by optional provenance examples.
- actions/upload-artifact v7.0.1 — Immutable transfer action used by build job.
- actions/download-artifact v8.0.1 — Immutable transfer action used by release job.
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this
address.