Skip to content
oss-kit
GitHubInstall

Container images

Container images are the one roster entry with no manifest and no lockfile, so they never appear on the present axis. That is deliberate rather than a hole: a Dockerfile, a compose.yaml, or a .devcontainer/ directory more often builds a test harness or a development environment than anything the repository distributes, so treating one as a signal would put containers in scope for most repositories that never ship an image. Detection is a registry push, never a Dockerfile.

Container images are shipped when something pushes one to a registry. On GitHub the push targets ghcr.io, in the form docker push ghcr.io/NAMESPACE/IMAGE_NAME:latest. On GitLab a job authenticates against $CI_REGISTRY and pushes to $CI_REGISTRY_IMAGE, which GitLab documents as resolving “to the address of the registry tied to this project”. A step doing either, or an image already published under the repository’s namespace, is the evidence.

Sources: Working with the Container registry, Build and push container images.

Three cases decide most arguments:

  • A build with no push ships nothing. A workflow that builds an image to run tests against, and never pushes it, leaves containers out of scope entirely.
  • An existing image can be invisible from the checkout. GitHub publishes an image as private by default and does not link it to a repository automatically, so a repository page showing no package is not proof that no image exists. Where no push step is in the workflows and no forge access is available to list packages, the shipped answer is unknown rather than no, and unknown is the state to report.
  • Detection stops at whether an image is pushed at all. Which of a project’s tags counts as a release and which is a rolling build artifact is a release-process question that oss-publish answers, and it changes nothing about whether the ecosystem is in scope.

Container images take the registry-push track. A push uploads built layers to a registry under a credential, which is the same shape as any package upload and is what assigns the track. The roster records "track": "registry-push" for container images and the release area’s preamble names them in its registry-push list.

Verified 2026-07-31 against https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry and https://docs.gitlab.com/user/packages/container_registry/build_and_push_images/.

Shields.io has one container version service and it reads Docker Hub: img.shields.io/docker/v/USER/REPO, with sort and arch query parameters. Nothing in its service list reads the GitHub Container Registry. Searching the shields.io repository for ghcr returns six files, all of them its own release plumbing under .github/, .devcontainer/, and doc/, and no service. So a project publishing to ghcr.io, which is where a repository inside this kit’s forge scope publishes, has no version endpoint to point a badge at.

Source: badges/shields, services/docker/docker-version.service.js and the absence of any ghcr service.

The strongest documented fallback is the tag badge, linked to the package page the registry gives the image:

[![version](https://img.shields.io/github/v/tag/OWNER/REPO?sort=semver&label=version)](https://github.com/OWNER/REPO/pkgs/container/IMAGE)

sort=semver is required, because the default sorts by tag date and returns whichever tag was committed last. label=version replaces the default tag, which reads as a git artifact.

An image published to Docker Hub instead of, or as well as, the forge registry does have an endpoint, and it is the better badge for that image: img.shields.io/docker/v/USER/REPO?sort=semver reports what the registry actually holds.

The forge fallback sits below both. It reports a git tag, and a git tag is not an image: a release that tagged and failed to push leaves the badge advertising an image nobody can pull, which is what R-DOC-04 rules out. A shields.io service reading the GHCR tag list would retire the gap, and so would publishing the same image to Docker Hub.

docker pull ghcr.io/NAMESPACE/IMAGE_NAME:TAG

GitHub documents the pull as docker pull ghcr.io/NAMESPACE/IMAGE_NAME, where NAMESPACE is the personal account or organization the image is scoped to. Pin the tag in the README rather than taking the default. latest is mutable by design, so a README showing it documents a moving target and cannot be checked against anything.

Show the version tag rather than the digest. The digest is what a deployment pins and what a reader should be told to pin, but a digest written into a README is wrong one release later and nobody edits a README for it. Name where the digests are published, which for the forge registry is the package page linked above, and let the reader read the current one.

Verified 2026-07-31 against Working with the Container registry, GitHub Tag badge, and services/docker/docker-version.service.js plus services/github/github-tag.service.js in badges/shields.

A container image has no manifest file in the repository, so there is nothing in a checkout to read. The declaration lives in the built image, in the OCI annotation org.opencontainers.image.licenses, whose definition is “License(s) under which contained software is distributed as an SPDX License Expression”. The value is an expression, so MIT OR Apache-2.0 is one annotation rather than two.

Three properties of annotations shape what can be concluded from one. They are optional: the spec says if there are no annotations the property is either absent or an empty map, so an image without the key is an image that declared nothing, not one that declared no license. They are advisory: a consumer must not error on an unknown key, so nothing enforces the value. And the spec names their intended homes as the image index, the image manifest, and the descriptor, so where the key ends up depends on which of those the build tooling wrote it to. The document also carries a back-compatibility section mapping these keys to their Label Schema equivalents, which is why the same names turn up as image labels in older tooling. Read the image that was actually pushed rather than assuming from the Dockerfile which of the two a build produced.

So the manifest side of R-COM-01 for a repository whose only published artifact is an image is that annotation, and the file side is the root license file. Where the repository ships an image and a package, both declarations are compared against the same file and they have to agree with each other as well.

The roster’s note for this ecosystem is worth keeping in view while scoring: a container ecosystem is detected by a registry push or an existing image, never by the presence of a Dockerfile, because a Dockerfile more often builds a test harness than a shipped image. A repository whose Dockerfile builds nothing anybody pulls has no image annotation to read and no finding to record.

Source: OCI image annotations.

Tidelift lists no platform for this ecosystem, so there is no tidelift value a container image can write.

GitHub’s own documentation for the funding file enumerates the accepted tidelift platform names. It names six, every one of them a language package registry; no container registry appears. That is why the roster at skills/oss-audit/ecosystems.json carries no tidelift key for this ecosystem.

Source: Displaying a sponsor button in your repository.

The gap is wider here than in the four other ecosystems with no platform name, because a container registry serves no project page for a funding link to sit on at all. The strongest documented fallback is entirely forge-level: the github, open_collective, liberapay, and custom keys in the funding file, which reach somebody who found the repository. Somebody who found only the image has the org.opencontainers.image.url and org.opencontainers.image.source annotations, which point back at the project, and nothing more direct.

No rule in STANDARD.md requires a funding file at all, so this gap costs a repository nothing when it is scored. A container registry growing project metadata that a funding link could live in is what would retire it, and no such surface exists today.

Verified 2026-07-31 against OCI image annotations and Displaying a sponsor button in your repository.

An image is not a language runtime, so the three sections below answer differently here than in the other ten files. Read this one alongside the file for whatever ecosystem the image wraps, because a repository that ships an image almost always also has a manifest for the code inside it.

The toolchain is the builder rather than a runtime. On GitHub Actions, docker/setup-buildx-action creates and boots a Buildx builder, docker/setup-qemu-action adds emulation for building against more platforms, and docker/build-push-action runs the build. On GitLab, the documented paths are Docker-in-Docker, socket binding, or a rootless builder such as BuildKit or Buildah; its Docker-in-Docker example runs an ordinary docker build inside a docker:*-cli image.

There is no manifest declaring a supported version range here, so the manifest-declared range R-CI-03 reads has no counterpart. What stands in its place is what the build actually varies over, and there are two axes, both observable in the repository rather than in a manifest.

Source: docker/build-push-action, GitLab, use Docker to build Docker images.

The first is the base image. FROM in the Dockerfile names it, and a project that claims to work on more than one base, an Alpine variant beside a Debian one, or a runtime version range matching what the code inside claims, gets a matrix over that tag with the tag passed in as a build argument. The second is the target platform, which platforms: on docker/build-push-action takes as a list and which builds in one invocation rather than through a job matrix.

Where the Dockerfile pins one base and the release pushes one platform, there is no range to cover and a single build is the whole of it. Say that rather than manufacturing a matrix, and note that the runtime version claim, where the image wraps a package, belongs to that package’s own file in this directory.

Sources: docker/build-push-action, docker/setup-buildx-action.

What is cached here is BuildKit layers, not a package manager’s downloads, and no lockfile keys it. BuildKit resolves cache against the build graph itself, so the equivalent of a stale key is a cache mount serving a layer whose inputs changed, and the controls are the backend and its mode rather than a key expression.

Docker documents four backends for a GitHub Actions build. The GitHub Actions cache backend is the one written for this environment:

- uses: docker/setup-buildx-action@v4 # oss-harden pins this to a commit SHA
- uses: docker/build-push-action@v7 # oss-harden pins this to a commit SHA
with:
cache-from: type=gha
cache-to: type=gha,mode=max

The registry backend, cache-from: type=registry,ref=user/app:buildcache with the matching cache-to, stores the cache as an image in a registry instead, which survives outside the forge’s cache retention and is what a build outside GitHub Actions uses. type=inline and type=local are the other two.

One version floor travels with the gha backend: since 2025-04-15 only version 2 of the GitHub Cache service API is supported, which needs Buildx v0.21.0 or later and BuildKit v0.20.0 or later. An older builder fails against it rather than falling back.

On GitLab, layer caching for a Docker-in-Docker build is documented separately, and GitLab also recommends the overlay2 storage driver over the default vfs, which copies the whole filesystem on each run.

Sources: Docker, cache backends in GitHub Actions, GitLab, use Docker to build Docker images.

For an image, the build is the first test: a RUN step that fails fails the build, so a Dockerfile that compiles and installs inside itself has already exercised that path by the time the image exists. That is why a container job needs no separate build check, and why the build has to run on pull requests rather than only on the push that publishes.

Testing the image’s behaviour is a second step, running the built image. GitLab’s own Docker-in-Docker example is exactly that shape, docker build -t my-docker-image . followed by docker run my-docker-image /script/to/run/tests. On GitHub Actions, load the built image into the runner’s daemon and run it the same way, rather than pushing first and testing what is already published.

The application’s own suite is not this file’s answer. It lives in whatever ecosystem the code inside the image belongs to, so route the test-suite question to that ecosystem’s file here, and treat a repository whose only test is that the image builds as having a build check rather than a suite.

Sources: GitLab, use Docker to build Docker images, docker/build-push-action.

Verified 2026-07-31 against https://github.com/docker/build-push-action, https://docs.docker.com/build/ci/github-actions/cache/, and https://docs.gitlab.com/ci/docker/using_docker_build/.

Dependabot’s docker value carries version updates and not security updates, and docker-compose behaves the same way. It updates image references in Dockerfiles, in Kubernetes manifests, and in Helm charts, parses image tags for semantic versioning, and where a tag carries a pre-release it proposes only versions with a matching pre-release label. Configuring a Helm entry also updates the Docker images the chart references.

Because there are no security updates for either value, a base image that goes known-bad reaches the repository as a scheduled version bump or not at all. Pair the updater with the scanning in the Vulnerability watch section rather than treating it as coverage.

On GitLab the Renovate managers are dockerfile and docker-compose.

A container build produces no lockfile, and no container build tool publishes a format for one.

Docker’s own build documentation, which is where a resolution file would be described if it existed, offers two pinning techniques instead and nothing that records a resolved set with hashes. The first is the base image digest: “By pinning your images to a digest, you’re guaranteed to always use the same image version, even if a publisher replaces the tag”, written as FROM alpine:3.21@sha256:.... The second is version pinning inside a RUN instruction, where “Version pinning forces the build to retrieve a particular version regardless of what’s in the cache”, written as package-foo=1.3.* in an apt-get install line. Neither is a hash-bearing record of the whole resolution, and the second pins a version rather than an artifact.

Source: Dockerfile best practices.

The strongest available substitute is both techniques together: every FROM pinned to a digest, which is the same immutable-reference requirement R-SEC-01 and R-SEC-06 already make of a workflow’s image: reference, and every package installed inside a RUN line version pinned. Record the built image’s own digest at release, since that digest is what identifies the artifact a consumer pulled.

That substitute sits below R-SEC-08, whose subject is the lockfile a package manager writes. A container build tool publishing a committed, hash-bearing resolution file is what would retire this gap. Until then, report the digest pins as the control this ecosystem actually has, and score the repository’s package manager ecosystems, which are the ones the image is built from, against R-SEC-08 on their own files.

What breaks the first time you pin a digest

Section titled “What breaks the first time you pin a digest”

The digest pin is what stands in for the lockfile here, so the three questions apply to it.

Pin the index digest, not the one your own machine resolved. A multi-platform image has two kinds of digest, and Docker separates them: “The manifest list digest identifies the overall multi-platform image”, while “Each platform-specific image has its own digest”. A maintainer who copies the digest their local pull reported takes the second, which is bound to one architecture, and a build for any other platform then has no image to start from. Read the digest with a command that names the reference rather than the local platform, and confirm the pin still builds for every platform the project publishes.

Runtime version is not a dimension. There is no resolver and no runtime whose version changes what a digest points at. A digest is the content address of the thing it names.

An updater finds digests by filename, and not every digest lives in one. Dependabot’s Docker file fetcher matches on dockerfile or containerfile in the name, case insensitively, plus YAML files. A digest written into a shell script, a Makefile, or a Justfile matches none of those and is never bumped, so it silently ages into an unpatched base image. Keep every base image digest in a file the updater matches, and list each directory holding one in the configuration.

Verified 2026-07-31 against Docker image digests and dependabot-core’s Docker file fetcher.

A container image contributes no source language, so this ecosystem never decides R-SEC-09 either way. What decides it is the source the repository holds, and the analyzer for that language is in the sibling file for its ecosystem. An image built from a repository whose only source is Dart is the Dart answer; an image built from a Go repository is the Go answer. Do not report a separate finding for the image.

Nothing on the forge watches a published image’s contents. The dependency graph’s supported ecosystems table has no container row, and the GitHub Advisory Database’s ecosystem list carries no operating system package ecosystem, so neither the packages inside a layer nor the base image itself reaches Dependabot alerts.

The watcher is therefore a scanner reading the image. osv-scanner scans a container image and extracts Alpine APK packages, Debian and Ubuntu dpkg or apt packages, Go binaries, Rust binaries built with cargo-auditable, Java uber jars, node modules, and Python wheels, which is the layer contents the forge cannot see.

Scan by digest rather than by tag. A registry tag is mutable by design, so a scan of latest records what that name resolved to at scan time and nothing about what a consumer pulls tomorrow. This is the same property R-SEC-13 states when it places a container registry tag outside its own scope and names the digest as the immutable identity of a published image.

Verified 2026-07-31 against Dependabot supported ecosystems and repositories, Dependency graph supported package ecosystems, GitHub Advisory Database, Dockerfile best practices, Renovate managers, and osv-scanner supported languages and lockfiles.

Concrete flow for the decisions SKILL.md makes, for a container image the project publishes. This is the one entry on the roster with no package manifest to detect it by: an image is detected by a registry push, never by a Dockerfile, because a Dockerfile more often builds a test harness than a shipped image.

Images stack rather than replace. A Go project can ship both a module and an image, and a Node project both a package and an image, so run this file alongside whichever other one the repository’s manifests select rather than instead of it.

Two positions in this file are not needed by the other ten. Step 4 says what an approval gate means for a project that pushes on merge, and Step 6 says why R-SEC-13 does not reach a registry tag.

Source: GitHub Docs, Publishing Docker images, Docker Docs, Build attestations, and GitLab Docs, Build and push container images.

Find the push, not the Dockerfile. The signals are a workflow or pipeline step that runs docker push, docker buildx build --push, or an action that pushes, and an image that already exists on the forge’s registry. A repository with a Dockerfile and no push publishes nothing and this file does not apply to it.

Collect, before changing anything: the registry host and the full image name; which tags the project pushes and which of them move, since that is what Step 4 turns on; whether the base images in the Dockerfile are referenced by tag or by digest; whether the push runs on merge, on a version tag, or both; and whether any long-lived registry credential is stored as a secret, which this skill’s changes should remove.

An image name on GitHub is ghcr.io/<owner>/<name>, conventionally ${{ github.repository }}. On GitLab it is $CI_REGISTRY_IMAGE, which resolves to the registry address tied to the project.

There is no registry-side trusted publisher form here. What takes its place is the forge’s own short-lived job token, which is already the thing trusted publishing exists to produce: a credential minted per run, scoped to the repository, with nothing stored between releases. Configuring this step means using that token and deleting anything else.

Authenticate with the automatic GITHUB_TOKEN, granting packages: write on the job that pushes and nothing above it:

permissions:
contents: read
packages: write
steps:
- uses: docker/login-action@v4 # oss-harden pins this to a commit SHA
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

No personal access token and no registry password needs to exist in the repository. If one does, remove it from the YAML now and tell the user to delete the secret once the new flow is verified.

GitLab CI/CD and the GitLab container registry

Section titled “GitLab CI/CD and the GitLab container registry”

Authenticate with the predefined registry variables, which GitLab injects per job:

build:
script:
- echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
- docker build --pull -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG" .
- docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG"

docker build --pull is GitLab’s own recommendation, so a stale cached base image does not silently ship. GitLab’s documentation also warns against building directly to latest because concurrent jobs collide, and against $CI_COMMIT_REF_NAME in a tag because image tags cannot contain forward slashes; $CI_COMMIT_REF_SLUG is the safe form for a branch.

Pushing to Docker Hub or any registry the forge does not issue a token for means a stored credential, because there is no job token to borrow. That is below R-PUB-02’s bar in the same way a stored registry token is anywhere else. Scope it to the one repository the project pushes, hold it in an environment gated by the approval in Step 4, and report the limitation rather than presenting it as equivalent to the forge-token flow above.

Write the hardened release workflow (Step 3)

Section titled “Write the hardened release workflow (Step 3)”

Pin every base image by digest in the Dockerfile, not by tag:

FROM node:24-bookworm-slim@sha256:<digest>

A tag on a base image moves, so a rebuild of the same commit produces a different image and neither the maintainer nor a consumer can tell. oss-harden owns pinning generally and this is the same rule applied one layer down.

Build and push with BuildKit’s own attestations turned on, and attest the pushed digest to the forge:

name: Release
on:
push:
tags:
- 'v*'
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
publish:
runs-on: ubuntu-latest
environment: release
permissions:
contents: read
packages: write
id-token: write
attestations: write
artifact-metadata: write
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/metadata-action@v6
id: meta
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- uses: docker/build-push-action@v7
id: push
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
provenance: mode=max
sbom: true
- uses: actions/attest@v4
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true

provenance: mode=max and sbom: true are BuildKit attestations, attached to the image in the registry as in-toto statements: the provenance records how the image was built, and the SBOM records what is inside it in SPDX. They are the reason this file needs no third-party SBOM generator, unlike every other reference in this directory. BuildKit adds minimal provenance by default; mode=max is what makes it useful, and provenance: false is what turns it off.

The actions/attest step is a second, different thing: a GitHub-signed attestation over the image digest, pushed to the registry beside it with push-to-registry: true. Keep both. BuildKit’s provenance is generated by the builder and GitHub’s is signed by the forge, so one is a claim by the build and the other is a claim about who ran it.

subject-digest comes from the push step’s digest output, which is why the push and the attestation cannot be split across jobs the way a package tarball can: the digest does not exist until the push completes. The whole job therefore holds packages: write and the attestation identity together, and the approval gate in Step 4 is what bounds that.

This is the one file in this directory whose release workflow carries no test job, and that is deliberate rather than an omission. Every other reference here writes one because it publishes a package the repository builds, and the tests are the project’s own. An image is stacked on top of that: it is a packaging of something the repository already publishes or already tests, so a test job here would rerun what oss-ci runs on every change with nothing new to catch. Where the image is the only thing the repository ships, put the tests in the CI workflow oss-ci owns and add needs: [test] here only if a smoke test against the built image is worth the extra minutes at release time. Say which of the two is the case rather than leaving a reader to notice the missing job.

oss-harden pins every uses: line above to a commit SHA. Three of the four actions here are third-party, from Docker rather than from the forge; vet them against their own repositories before adding them, and pin them harder than the first-party ones. On GitLab CI/CD, the login and push from Step 2 replace the first three steps, and the attestation step has no equivalent; Step 5 covers what that leaves.

Push on merge is the dominant image release model, and it reads like a permanent R-PUB-04 failure. It is not, and the distinction is what this section exists to state.

A mutable rolling tag pushed on merge, latest or a branch name or a commit SHA tag, is a build artifact and not a release. Nothing is versioned, the tag is expected to move, and R-PUB-04’s approval gate does not reach it. The release is the immutable, version-tagged, digest-identified image, and that push is what the gate covers. So a repository that pushes latest on every merge to the default branch and also pushes v1.2.3 from a tag needs the approval on the second job only, and the first can run unattended.

A project whose only published image is a mutable rolling tag has no release to gate. Say that to the maintainer plainly: R-PUB-04 has nothing to attach to, and the honest report is not applicable rather than passed or failed. What such a project is missing is not an approval gate but a release, and that is a versioning question oss-changelog owns rather than a publishing one.

Where there is a versioned push, pin its job to environment: release as above, and create that environment at https://github.com/<owner>/<repo>/settings/environments/new with required reviewers naming at least one person other than an automation account. Required reviewers work for public repositories on current GitHub plans; private or internal repositories need GitHub Enterprise Cloud. On GitLab Premium or Ultimate, use a protected environment with approval rules. No container registry offers a proof-of-presence gate of its own, so report R-PUB-04 as unmet when the forge plan provides no native gate.

Create it with the API rather than the form. Reviewers and the tag policy are both settable, so nothing here needs a browser.

ENV=release
GHUID=$(gh api user --jq .id)
gh api -X PUT "repos/{owner}/{repo}/environments/$ENV" \
-F wait_timer=0 \
-F prevent_self_review=false \
-f 'reviewers[][type]=User' -F "reviewers[][id]=$GHUID" \
-F 'deployment_branch_policy[protected_branches]=false' \
-F 'deployment_branch_policy[custom_branch_policies]=true'
gh api -X POST "repos/{owner}/{repo}/environments/$ENV/deployment-branch-policies" \
-f 'name=v*' -f type=tag

Three details decide whether that runs. gh api substitutes {owner} and {repo} from the checkout it runs in. Use -F for the booleans and the reviewer id, because -f sends every value as a string and the endpoint rejects a quoted boolean. Do not name the shell variable UID: zsh marks it read only, so the assignment fails before gh runs.

reviewers[][id] takes a numeric user or team id rather than a login. A team needs type=Team and that team’s id.

This is the one section in this directory where the registry-served answer exists and works. Both attestations from Step 3 are readable from the image itself.

Verify the GitHub attestation against the digest, not the tag, because a tag is not an identity:

gh attestation verify oci://<registry>/<image>@sha256:<digest> --repo <owner>/<repo>

--signer-workflow <owner>/<repo>/.github/workflows/release.yml pins which workflow the attestation must have come from, so an attestation produced by any other workflow in the repository fails. Read BuildKit’s own provenance with the Docker CLI:

docker buildx imagetools inspect <registry>/<image>:<version> --format '{{ json .Provenance.SLSA }}'

On GitLab CI/CD, neither half is available. GitLab Runner does generate SLSA provenance metadata when RUNNER_GENERATE_ARTIFACTS_METADATA is set, and it does not cover container images: it covers the job’s build artifacts, written as an in-toto statement in a JSON file beside them. BuildKit’s own provenance and sbom attestations still work, because they come from the builder rather than from the forge, so a GitLab pipeline using docker buildx build gets those and no forge-signed attestation. Report R-PUB-03 as met on GitHub through the forge attestation, and as partly met on GitLab through BuildKit’s provenance alone, with the missing signer identity named.

Source: GitLab Docs, Configure runners and Docker Docs, SLSA provenance attestations.

Describe and sign what the release attaches (Step 6)

Section titled “Describe and sign what the release attaches (Step 6)”

R-PUB-05 and R-PUB-06 are met by the image rather than by a forge release asset here, and the two BuildKit attestations from Step 3 are what meets them. sbom: true attaches an SPDX bill of materials to the image, covering what went into that exact digest, which is what R-PUB-05 asks for and what an SBOM generated from the source tree would not be. The GitHub attestation over the digest is what R-PUB-06 asks for. Neither is a file on a forge release, so read both rules against the image and say where the evidence lives, rather than reporting them unmet because the release page carries nothing.

R-SEC-13 does not reach this surface, and the reason matters. That rule binds a git tag: a repository ruleset or a protected tag stops a released tag being moved or deleted on the forge. A container registry tag is a different object living outside the forge, mutable by design, and no forge control reaches it, so v1.2.3 on a registry can be repointed at different bytes tomorrow no matter how the repository is configured. The immutable identity of a released image is its digest, and that is the identity to publish, to attest, and to tell consumers to pin.

Publish the digest where a consumer will find it. The release notes and the README install line should both carry <image>@sha256:<digest> alongside the human-readable tag, because a consumer who pulls by tag has no way to notice the tag moved.

Two registry-side controls exist and only one is on the roster’s forges. GitLab offers immutable container tags on Ultimate, generally available from GitLab 18.10, configured under Settings, Packages and registries, Container registry as up to five regex rules per project that prevent a matching tag from being updated or deleted; it also offers protected container tags, which restrict who may change a tag by role rather than preventing the change. Where the project is on a tier that has them, set an immutable rule matching the release tag pattern and report it. GHCR documents no equivalent, so on GitHub the digest is the whole answer.

Source: GitLab Docs, Immutable container tags.

Where the project also attaches a file to the forge release, such as an image tarball or a compiled binary built from the same source, that file is covered by the ordinary pattern: hash the assets from inside their directory, attest the manifest with subject-checksums, and upload both. Do it in a job after the publish, so the assets stay behind the approval gate.

Step 7 is in SKILL.md: read each R-PUB rule’s Check: line against what this file produced, and fix what fails before reporting done.

Verified 2026-07-31 against GitHub Docs, Publishing Docker images, the inputs and container example in actions/attest, README, Docker Docs, Build attestations, Docker Docs, SLSA provenance attestations, GitLab Docs, Build and push container images, GitLab Docs, Immutable container tags, GitLab Docs, Configure runners, and GitHub CLI manual, gh attestation verify.

No file in the repository states the released version. A container ecosystem is identified by a registry push rather than by a Dockerfile, and the version lives in the tag that push carries.

Three places can hold it, and a release is consistent when they agree. The git tag is one. The image tag pushed to the registry is the second, and where the workflow computes it from the git ref they are effectively one source, while a hardcoded tag in the workflow is a second source that silently stops matching. The third is the image itself: the OCI annotation org.opencontainers.image.version records the “version of the packaged software”, and org.opencontainers.image.revision records the source control revision it was built from. A build that sets both makes the released image self-describing, so a consumer holding only a digest can still say which release it is.

The changelog is the fourth thing to compare, and it is the only one of the four that a registry cannot answer for.

A registry constrains the tag string and says nothing about its meaning. An OCI tag is “a custom, human-readable pointer to a manifest”, at most 128 characters, matching [a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}. Any SemVer release version fits, with one exception: + is not in that character class, so SemVer build metadata cannot appear in a tag and a version carrying it has to be rewritten, conventionally with the + replaced by a -.

Two common tag habits are not versions and should not be read as ones. A rolling tag such as latest or a branch name names no version at all. A truncated tag such as 1 or 1.2 is moved forward as new patches ship, so it names a moving set of versions. Both are convenience pointers; the released version is the full one, and that is the tag a changelog heading corresponds to.

Major version in package identity (R-CHG-07)

Section titled “Major version in package identity (R-CHG-07)”

A container registry does not encode the major version in package identity. The repository name is stable across majors and the major lives in the tag, so R-CHG-07 does not reach this ecosystem. A published major-only tag such as 2 is one of the moving pointers above rather than a second identity.

The digest is the identity and the tag is a pointer at it, so withdrawal splits in two. Anything pinned as image@sha256:... is unaffected by anything done to tags, and the digest keeps resolving for as long as the manifest exists.

The OCI distribution specification defines deletion for both halves, a DELETE to /v2/<name>/manifests/<tag> for the tag and to /v2/<name>/manifests/<digest> for the manifest, and makes support optional: “Registries MAY implement deletion or they MAY disable it”, answering 400 Bad Request or 405 Method Not Allowed where it is off. So the first thing to establish is whether the registry in use implements it at all.

On the GitHub Container Registry it is implemented with limits. A specific version of a public package can be deleted only when that version has no more than 5,000 downloads, no public package can be deleted at all when any of its versions has more than 5,000 downloads, and a deleted version can be restored within 30 days.

One thing to rule out rather than reach for: never re-point a released version tag at a new build. A consumer who pulled by tag and a consumer who pulled by digest then hold different bytes under one version number, and no changelog can describe that release honestly.

[YANKED] on the changelog heading maps to deleting the version-tagged image where the registry allows it, and to publishing a fixed version and saying so where it does not. Name the digest in the entry, because for anyone who pinned one, that string is the only identifier the withdrawal is about.

Verified 2026-07-31 against OCI distribution specification, OCI image annotations, Working with the Container registry, and Deleting and restoring a package.