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.
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:
- 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.
- The first package visibility will be private even though the source repository is public.
-
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?
GITHUB_TOKEN authority is explicitly constrained per job. Human repository administration does not automatically grant a job package write permission.
Why make the synthetic package public only after inspecting it?
Changing package visibility to public is irreversible. Inspection confirms that only intentionally public disposable bytes and metadata will be exposed.
What proves the consumer got the intended image bytes?
The exact sha256 registry digest, independently observed after pulling, plus the expected payload/version. A mutable tag alone is insufficient.
Why can a logged-in Docker client still be denied on push?
Authentication proves token identity; packages:read and package-side access do not authorize publishing. Write requires both effective token permission and package authorization.
Why does the mandatory lab avoid a PAT?
The linked workflow can use short-lived GITHUB_TOKEN, and the public consumer can pull anonymously. Creating a long-lived package credential would add risk without teaching a necessary capability.
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.
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.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.