A Friday job was green. The same commit failed on Monday. Did the base image move while the repository stayed still?
You can lose an hour blaming the test diff. The moving part is often the tag after image:. node:22 and python:3.12-slim are names, not snapshots. A registry can publish new bytes under that same name overnight.
This lab is for the person who maintains one service repo on GitLab CI and reviews agent-written YAML. You will resolve a public image to a digest, choose index versus platform, and reject a job file that still floats.
What actually moved
A tag points at a manifest. That manifest can be a single image or a multi-arch index. The index digest and the linux/amd64 digest are different objects. Pin the wrong one and the job fails on the runner you have, or it succeeds on your laptop and fails in CI.
The executor matters too. A Docker or Kubernetes executor pulls image:. A shell executor does not. If the job log never mentions a pull, a digest pin will not change that job's root filesystem. Check the executor before you edit YAML.
GitLab Runner accepts an image reference that includes @sha256: plus 64 hex characters. Confirm the current keyword rules in the CI/CD YAML reference before you depend on a syntax detail. Pull policy still applies, but a digest names content. pull_policy: always on a floating tag re-fetches whatever the registry serves today. if-not-present on a floating tag can keep yesterday's layers on a busy runner. Neither policy is a pin.
What you need on the bench
You need three things, and none of them is a secret.
- The image line from
.gitlab-ci.yml, including the registry host if it is not Docker Hub. - The runner architecture you actually schedule, often
linux/amd64. - A host that can reach the public registry, with
craneorskopeoinstalled.
Do not paste registry passwords, deploy tokens, or CI_JOB_TOKEN into a chat to make this easier. Public images do not need them. Private images belong on a trusted runner, not in a draft session.
Six steps, in order
Work one image at a time. A monorepo with twelve images is twelve pins, not one heroic rewrite.
1. Copy the reference, unchanged
Take the string exactly as the job uses it.
# Before. Floating tag. Not a snapshot.
test:
image: python:3.12-slim
script:
- python -m pytest -q
If the line is already name@sha256:…, stop. Your job is to verify that digest still exists, not to invent a newer tag.
2. Classify the executor
Open one recent job log for that job name. Search for a pull or a line that names the image the executor used. If the log shows a digest, copy it into your notes. That digest is what ran. It may differ from the tag you remember.
If the log shows a shell executor and no pull, write "image key unused" in the review and skip the pin for that job. Editing the YAML image line would not change the root filesystem.
3. Resolve both digests
On a machine with crane, run the inspect. This block is a proposal until you run it. This article does not report a measured digest.
# Proposed. Not executed for this article.
ref='python:3.12-slim'
platform='linux/amd64'
crane digest "$ref"
crane digest --platform "$platform" "$ref"
crane manifest "$ref" | head -c 400
echo
The first command prints the digest of the tag's current manifest, often an index. The second prints the digest for one platform. If they match, you are looking at a single-platform image. If they differ, you must choose.
No crane? skopeo can answer the same question.
# Proposed alternative. Not executed here.
ref='python:3.12-slim'
skopeo inspect --raw "docker://${ref}" | head -c 400
echo
skopeo inspect --format '{{.Digest}}' "docker://${ref}"
skopeo inspect without override flags reports the index or the default manifest, depending on the image. Add --override-os and --override-arch when you need the platform blob. Read the tool's current --help before you script flags you have not tried.
4. Choose the digest on purpose
Use the index digest when runners may be mixed architecture and the registry publishes a manifest list. Use the platform digest when every runner for this job is one architecture and you want that rootfs only.
Write the choice in the merge request in one line: "pinned index" or "pinned linux/amd64". A digest with no sentence is how the next reviewer swaps it back to a tag.
5. Rewrite the image line
Keep the tag as a human hint, then add the digest. Docker and GitLab both accept name:tag@sha256:…. The tag is not what gets pulled once the digest is present. The digest wins.
# After. Tag is a hint. Digest is the pin.
# Confirm the digest on your bench. Do not copy a sample hash.
test:
image: python:3.12-slim@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
script:
- python -m pytest -q
That hash is a placeholder shape, not a real Python image. Never commit a digest you did not resolve yourself.
6. Add a reject rule in review
A pin rots if the next edit deletes @sha256:. Keep a small checker in the repo and run it before you push.
The checker to commit
Save this as scripts/reject-floating-images.sh. It is a proposal, not a result from a live pipeline.
#!/usr/bin/env bash
# Reject image: lines that lack a sha256 digest.
# Proposal. Not executed for this article.
set -euo pipefail
file="${1:-.gitlab-ci.yml}"
fail=0
# Matches a one-line image: ref without @sha256.
# It will not understand every YAML alias. That limit is intentional.
while IFS= read -r line; do
case "$line" in
\#*) continue ;;
esac
if printf '%s\n' "$line" | grep -Eq '^[[:space:]]*image:[[:space:]]*["'"'"']?[A-Za-z0-9._/-]+(:[^[:space:]@#'"'"']+)?["'"'"']?[[:space:]]*$'; then
echo "floating image: $line"
fail=1
fi
done < "$file"
if [ "$fail" -ne 0 ]; then
echo "pin each image as name:tag@sha256:<64 hex> or justify the exception"
exit 1
fi
echo "no floating image lines in $file"
The regex is narrow on purpose. It ignores image: maps that set name: on the next line. Extend it only after you see that form in your file.
# This form needs a second check on the name: key.
job:
image:
name: python:3.12-slim
pull_policy: if-not-present
For that form, grep the name: values under image: blocks, or convert them to one line before you trust the script. Do not pretend a regex is a YAML parser.
Which pin to keep
| Situation | Record this | Leave the tag only if | Refuse |
|---|---|---|---|
| Public multi-arch image, mixed runners | Index digest from crane digest
|
Never, for a test you must reproduce | A model-invented hash |
| Single-arch runners, you need that rootfs | crane digest --platform linux/amd64 |
Never, if Monday already drifted | Pinning arm64 while the runner is amd64 |
| Shell executor, log shows no pull | Note that image is unused |
The line is documentation only | Spending the review on a no-op pin |
| Private registry | Digest resolved with a local credential | A short-lived branch, with an expiry comment | Pasting the credential into a chat |
| Image rebuilt in this same pipeline | Digest passed from the build job | You intend to track main and say so |
Pinning latest "just for now" |
Read the table left to right. If the refuse column matches, stop the edit.
Where a draft model fits, and where it does not
You can ask MonkeyCode's free model access to sketch the YAML rewrite and to explain a crane error string. You can use the free server to run the inspect against a public image, if that host can install crane or skopeo and reach the registry. Disclosure: This article was prepared as part of MonkeyCode's product outreach.
The model does not know the digest the registry will serve when you read this. Treat any hash it prints as untrusted text. The free server session is a shell you control for the probe, not a record of what GitLab pulled last Friday. Evidence is the command output you saved, next to the job log digest if the executor printed one.
A useful prompt is narrow. Paste one image line and the two digests you already resolved. Ask for a unified diff of that line only. Discard extra jobs, invented cache keys, and any claim about quotas or runner size.
If the free server cannot reach the registry, do not debug around it with a guessed digest. Move the same two commands to a host that can.
Limits to say in the review
A pin freezes bytes. It does not freeze apt mirrors inside an image that runs apt-get update during the job. It does not freeze a later docker pull in script:. Scan script: and before_script: for second pulls.
A pin does not select a runner. Tags, resource groups, and protected runners still decide where the job lands. If that runner's architecture disagrees with a platform pin, the pull fails. That failure is the pin working.
Digest availability is a registry property. A project can delete an old blob. Your job then fails closed, which is what you want, but you need a human path to bump the pin. Write that path down: who may update digests, and which job must stay green first.
This checker misses YAML anchors, !reference, and includes. If you use include:, run the checker on the merged config from CI Lint, not only on the root file. The lint result is the contract the pipeline sees.
This article states no registry rate limit, runner version, or support window. Those change. Check the current GitLab Runner docs and your registry status page when a pull fails for transport reasons rather than a digest mismatch.
Who should not run this lab
Skip it when the image is built and pushed by the same pipeline you are editing, and the next job must consume that fresh digest through a dotenv report or an artifact. A hand pin would freeze the previous build. Pass the digest through the pipeline instead.
Skip it when policy forbids third-party shells. Resolve the digest on an approved workstation. Do not route registry credentials through a model to save time.
Skip it when you cannot name the runner architecture. Pinning a platform you guessed is a new outage. Read one job log first.
Skip it for a one-off playground that you will delete today. A floating tag is fine when reproducibility is not the point. Say that in the README so nobody hardens it by accident.
After the pin
Rerun the checker. Push the one-line image change. Open the new job log and confirm the executor reports the digest you wrote. If the log digest and the YAML digest disagree, trust the log and stop the merge.
The next time a green commit fails after a quiet weekend, start from that image line. Resolve it again on a free server before you let a model rewrite the job. The digest you record is the artifact. The rewritten YAML is only a guess until the log agrees.
Top comments (0)