Chapter 25Lesson 02~200 minutes

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.

Prerelease labBuild onceRelease assetDigestLocal registry

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_SHA remain 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.ATTEMPT and 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

  1. Record run ID/attempt, source SHA, tag target, release ID, asset IDs/digests and attestation ID before cleanup.
  2. For an ordinary disposable prerelease, delete only the exact release/tag: gh release delete "$TAG" --cleanup-tag --yes.
  3. 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.
  4. Remove .lab-registry/ locally. If you used optional GHCR, delete only the exact disposable package/version after preserving digest evidence.
  5. 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.

Next lesson

Release Automation, Tags, GitHub Releases, Packages, and Container Registries: Configuration, Design Patterns, and Trade-Offs

Continue with the next lesson to build on the current concepts, evidence, security boundaries, and operational practices.

Knowledge check

Why does the release job download rather than rebuild the subject?

Why use --verify-tag?

Why is the tamper exercise useful?

Does the local latest alias define immutable identity?

Which permission is deliberately absent from the mandatory lab?

Official references and version notes

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.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this address.