Chapter 21Lesson 02~205 minutes

GitHub Packages, Container Registry, Package Permissions, Provenance, and Distribution: Guided Hands-On Workflow and Core Operations

This lesson turns the model into observable hosted state. You will create one disposable public repository, publish a tiny scratch container with the built-in token, record its digest, make the package public only after inspecting its contents, consume it from a separate workflow without registry credentials, and prove that read permission cannot publish.

Disposable GHCR labGITHUB_TOKENDigest verificationSecond workflowDenied write

Learning objectives

  • Create a free-compatible GHCR package with explicit packages:write permission.
  • Link the package to its source repository and inspect version/digest metadata.
  • Change only the disposable package to public visibility with explicit irreversibility warning.
  • Consume the image from a second workflow by immutable digest rather than mutable tag.
  • Preserve and interpret a deliberate package write denial under packages:read.

Mandatory path. GitHub.com + GitHub Free + a public disposable personal repository. The package payload contains only version and source SHA text. No PAT, external registry, cloud account, package manager credential, or production namespace is required. Local setup commands are labeled for Bash/Git Bash; PowerShell users can run the same gh/git operations and save the YAML files with their editor. The hosted workflow steps run Bash on Ubuntu.

1. Preflight: prove identity, namespace, and runner tooling before publication

gh auth status --hostname github.com
OWNER=$(gh api user --jq .login)
REPO="$OWNER/github-packages-lab"
IMAGE="ghcr.io/${OWNER,,}/github-packages-lab"

gh repo create "$REPO" --public   --description "Disposable Chapter 21 package distribution lab"   --add-readme

gh repo view "$REPO" --json nameWithOwner,visibility,defaultBranchRef,viewerPermission
echo "planned package=$IMAGE"

# Bash / Git Bash: create a local working copy for the workflow files.
gh repo clone "$REPO" github-packages-lab
cd github-packages-lab
mkdir -p .github/workflows

Use a fresh package name. If ghcr.io/OWNER/github-packages-lab already exists from an earlier experiment, do not blindly reuse it: an existing unlinked package can cause the new workflow’s GITHUB_TOKEN push to be denied. Either inspect and deliberately reconnect the existing disposable package or choose a new disposable suffix.

2. Predict the hosted state before running the publisher

Write down three predictions:

  1. The workflow repository will create/own a new GHCR package version under your account namespace; no Git branch or tag is created by the registry push.
  2. The first package visibility will be private even though the source repository is public.
  3. The job can push only because it grants packages: write; reducing that permission to read should block a later push.

These predictions make the lab causal rather than click-driven.

3. Create the publisher workflow: no checkout, no third-party action, no long-lived credential

Add .github/workflows/publish-package.yml to the disposable repository. The workflow synthesizes a scratch image directly on the runner so the only package content is a text payload. The OCI source label connects the package to the repository. GITHUB_TOKEN is passed to Docker only through standard input and is removed from the Docker client session during cleanup.

name: Publish GHCR lab image
on:
  workflow_dispatch:
    inputs:
      version:
        description: Version label for this disposable image
        required: true
        type: choice
        options: ["1.0.0", "2.0.0"]

permissions: {}

jobs:
  publish:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    env:
      GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      VERSION: ${{ inputs.version }}
    steps:
      - name: Build deterministic scratch image
        shell: bash
        run: |
          set -euo pipefail
          owner="${GITHUB_REPOSITORY_OWNER,,}"
          image="ghcr.io/$owner/github-packages-lab"
          mkdir -p image-context
          printf 'version=%s\nsource_sha=%s\n' "$VERSION" "$GITHUB_SHA" > image-context/payload.txt
          cat > image-context/Dockerfile <<'EOF'
          FROM scratch
          ARG SOURCE
          ARG VERSION
          LABEL org.opencontainers.image.source=$SOURCE
          LABEL org.opencontainers.image.description="Disposable Chapter 21 image"
          LABEL org.opencontainers.image.version=$VERSION
          COPY payload.txt /payload.txt
          EOF
          docker build             --build-arg SOURCE="https://github.com/$GITHUB_REPOSITORY"             --build-arg VERSION="$VERSION"             -t "$image:$VERSION" -t "$image:latest" image-context
          echo "IMAGE=$image" >> "$GITHUB_ENV"

      - name: Authenticate with job-scoped GITHUB_TOKEN
        shell: bash
        run: |
          set -euo pipefail
          printf '%s' "$GHCR_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin

      - name: Push version and latest labels
        shell: bash
        run: |
          set -euo pipefail
          docker push "$IMAGE:$VERSION"
          docker push "$IMAGE:latest"

      - name: Record immutable digest
        id: identity
        shell: bash
        run: |
          set -euo pipefail
          pull_log=$(docker pull "$IMAGE:$VERSION" 2>&1)
          printf '%s\n' "$pull_log"
          digest=$(printf '%s\n' "$pull_log" | sed -n 's/^Digest: //p' | tail -1)
          test -n "$digest"
          case "$digest" in sha256:*) ;; *) echo "Unexpected digest format" >&2; exit 1;; esac
          echo "digest=$digest" >> "$GITHUB_OUTPUT"
          {
            echo "## Published package identity"
            echo "- image: \`$IMAGE\`"
            echo "- version tag: \`$VERSION\`"
            echo "- source commit: \`$GITHUB_SHA\`"
            echo "- digest: \`$digest\`"
          } >> "$GITHUB_STEP_SUMMARY"

      - name: Logout
        if: always()
        run: docker logout ghcr.io >/dev/null 2>&1 || true

Save the YAML exactly as .github/workflows/publish-package.yml, then commit and push it to the repository default branch:

git add .github/workflows/publish-package.yml
git commit -m "Add Chapter 21 GHCR publisher"
git push

The workflow deliberately tags the same manifest with the selected semantic label and latest. That lets later lessons demonstrate why tags are convenient selectors but not immutable identity.

4. Publish version 1.0.0 and capture evidence

gh workflow run publish-package.yml -R "$REPO" -f version=1.0.0

gh run list -R "$REPO" --workflow publish-package.yml   --limit 3 --json databaseId,status,conclusion,headSha,event,createdAt

RUN_ID=$(gh run list -R "$REPO" --workflow publish-package.yml --limit 1 --json databaseId --jq '.[0].databaseId')
gh run watch "$RUN_ID" -R "$REPO" --exit-status
gh run view "$RUN_ID" -R "$REPO" --log

Record the sha256:… digest from the summary/log next to the run ID and headSha. The digest identifies registry content; the workflow SHA identifies source/control-plane revision. Keep both.

5. Inspect package linkage and permissions while it is still private

Open the package page from your profile’s Packages tab. Verify the package is linked to github-packages-lab, the version is present, and the source metadata points back to the repository. In Package settings, inspect whether access is inherited from the linked repository and note the repository under Manage Actions access.

Do not change access yet. The point is to observe that package state is a hosted resource distinct from repository visibility.

6. Security-sensitive but disposable: make only this synthetic package public

Irreversible visibility change. Once GitHub Packages makes a package public, GitHub does not allow changing it back to private. Perform this only because the package is disposable and contains no secrets, proprietary code, credentials, customer data, or internal base layers.

From the package’s settings, use the current Change visibility control and choose Public. Re-enter the package name when GitHub asks for confirmation. Verify from a signed-out browser or the package page that the package is public.

This step makes the next consumer genuinely credential-free and demonstrates the special GHCR rule that public container images support anonymous pull.

7. Create a second workflow that consumes the immutable digest

Add .github/workflows/consume-package.yml. The workflow accepts the digest you recorded and intentionally does not log in to GHCR. It validates both registry identity and the payload copied out of the scratch image.

name: Consume GHCR image by digest
on:
  workflow_dispatch:
    inputs:
      digest:
        description: Exact sha256 digest recorded by publisher
        required: true
        type: string
      expected_version:
        description: Expected payload version
        required: true
        type: choice
        options: ["1.0.0", "2.0.0"]

permissions:
  contents: read

jobs:
  verify:
    runs-on: ubuntu-latest
    env:
      DIGEST: ${{ inputs.digest }}
      EXPECTED_VERSION: ${{ inputs.expected_version }}
    steps:
      - name: Pull public image without registry credentials
        shell: bash
        run: |
          set -euo pipefail
          case "$DIGEST" in sha256:[0-9a-f][0-9a-f]*) ;; *) echo "Digest must start with sha256:" >&2; exit 2;; esac
          owner="${GITHUB_REPOSITORY_OWNER,,}"
          image="ghcr.io/$owner/github-packages-lab"
          docker pull "$image@$DIGEST"
          ref=$(docker image inspect --format '{{index .RepoDigests 0}}' "$image@$DIGEST")
          test "${ref#*@}" = "$DIGEST"
          cid=$(docker create "$image@$DIGEST")
          trap 'docker rm -f "$cid" >/dev/null 2>&1 || true' EXIT
          docker cp "$cid:/payload.txt" payload.txt
          grep -Fx "version=$EXPECTED_VERSION" payload.txt
          echo "verified_ref=$ref"
          echo "payload:"; cat payload.txt

Save, commit, and push the second workflow before dispatching it:

git add .github/workflows/consume-package.yml
git commit -m "Add digest-pinned GHCR consumer"
git push
gh workflow run consume-package.yml -R "$REPO"   -f digest='sha256:PASTE_THE_RECORDED_DIGEST'   -f expected_version=1.0.0

gh run list -R "$REPO" --workflow consume-package.yml   --limit 2 --json databaseId,conclusion,headSha,event

Expected observation: the public image pulls without a registry credential, the observed RepoDigest equals the supplied digest, and payload.txt says version=1.0.0. This is stronger evidence than “:latest happened to work.”

8. Prove read authorization is not publish authorization

Add a third manual workflow solely for a controlled failure. Save it as .github/workflows/package-denial.yml. The job authenticates with GITHUB_TOKEN but grants only packages: read. Pulling succeeds; pushing a new tag must fail.

name: Prove package write denial
on:
  workflow_dispatch:

permissions: {}

jobs:
  denied-write:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: read
    env:
      GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    steps:
      - name: Authenticate with read-only package permission
        shell: bash
        run: |
          set -euo pipefail
          printf '%s' "$GHCR_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin

      - name: Pull then deliberately attempt a forbidden push
        shell: bash
        run: |
          set -euo pipefail
          owner="${GITHUB_REPOSITORY_OWNER,,}"
          image="ghcr.io/$owner/github-packages-lab"
          docker pull "$image:latest"
          docker tag "$image:latest" "$image:denied-probe"
          set +e
          docker push "$image:denied-probe" > /tmp/push.log 2>&1
          rc=$?
          set -e
          tail -n 20 /tmp/push.log
          if [ "$rc" -eq 0 ]; then
            echo "ERROR: push unexpectedly succeeded" >&2
            exit 1
          fi
          echo "Expected denial observed; packages:read cannot publish a new tag."

      - name: Logout
        if: always()
        run: docker logout ghcr.io >/dev/null 2>&1 || true

Commit and push the permission probe, then dispatch it:

git add .github/workflows/package-denial.yml
git commit -m "Add package permission denial probe"
git push
gh workflow run package-denial.yml -R "$REPO"
RUN_ID=$(gh run list -R "$REPO" --workflow package-denial.yml --limit 1 --json databaseId --jq '.[0].databaseId')
gh run watch "$RUN_ID" -R "$REPO"
gh run view "$RUN_ID" -R "$REPO" --log

The job itself succeeds only if the inner Docker push fails as predicted. A representative registry message contains denied, unauthorized, or a package permission error. Preserve the exact message because it distinguishes authentication problems from missing write authorization.

9. Inspect package version metadata and tags

The package page is the simplest authority for this personal-account lab. For automation, the Packages REST API exposes container version IDs, digest-like version names, and tag arrays. A workflow/package-aware token can query:

gh api -H "X-GitHub-Api-Version: 2026-03-10"   "/user/packages/container/github-packages-lab/versions?per_page=100"   --jq '.[] | {id,name,tags:.metadata.container.tags,created_at}'

If your local gh credential lacks package-metadata access, do not create a broader token merely for this chapter. Use the package page or run the metadata query from a workflow whose GITHUB_TOKEN already has package access. Credential acquisition must be justified by the operation, not by a desire to make one command green.

10. Cleanup policy: inspect first; delete only after the checkpoint

Do not delete version 1.0.0 yet—the checkpoint needs it. For now, document the package name, version ID, digest, source SHA, visibility, and linked repository. Package deletion is destructive and may break consumers. GitHub’s Actions-driven delete/restore capability is still public preview, so this chapter uses the Package settings UI for mandatory cleanup after Lesson 5.

11. Challenge: choose the right surface

Your team needs three outputs: a JUnit XML file useful only for seven days, a downloadable signed installer for release 2.3.0, and an OCI image consumed by Kubernetes. Choose the appropriate GitHub surface for each and identify the identity you would record. A strong answer chooses an Actions artifact + run/digest, a Release asset + release/asset digest, and GHCR + immutable manifest digest respectively.

Knowledge check

Why does the publisher use packages:write even though the repository owner is an administrator?

Why make the synthetic package public only after inspecting it?

What proves the consumer got the intended image bytes?

Why can a logged-in Docker client still be denied on push?

Why does the mandatory lab avoid a PAT?

Summary

You published a real package using a job-scoped identity, captured an immutable digest, verified linkage/access, changed only disposable data to public visibility, consumed the exact bytes from another workflow, and preserved a deliberate denied write. Next, convert those mechanics into design rules for package scope, versioning, visibility, and external registries.

Next lesson

GitHub Packages, Container Registry, Package Permissions, Provenance, and Distribution: Configuration, Design Choices, and Tradeoffs

Official references

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
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.