R2K/ Levels/ Level 1 · Identify
Stamp the image with an identity — OCI standard + R2K spec + vendor extension. Three namespaces, three roles. Any scanner reads them in seconds.
Three namespaces, each with its own prefix in the same Dockerfile. Don't hard-code values —
every field that changes per build (commit / branch / tag / build-time / version) goes through an ARG that accepts a build-arg.
# syntax=docker/dockerfile:1.7
# ─── Build args (injected by CI, decided at build time) ───
ARG VERSION
ARG COMMIT_SHA
ARG GIT_BRANCH
ARG GIT_TAG=""
ARG BUILD_TIME
FROM nginx:1.27-alpine
# ─── Group 1 · OCI standard (every container tool understands it) ───
LABEL org.opencontainers.image.title="acme-api" \
org.opencontainers.image.version="${VERSION}" \
org.opencontainers.image.revision="${COMMIT_SHA}" \
org.opencontainers.image.created="${BUILD_TIME}" \
org.opencontainers.image.source="https://github.com/acme/acme-api" \
org.opencontainers.image.vendor="Acme Inc." \
org.opencontainers.image.licenses="Apache-2.0"
# ─── Group 2 · R2K spec (consumed by the R2K toolchain) ───
LABEL dev.releaseasknowledge.version="1.0" \
dev.releaseasknowledge.level="1" \
dev.releaseasknowledge.commit="${COMMIT_SHA}" \
dev.releaseasknowledge.branch="${GIT_BRANCH}" \
dev.releaseasknowledge.tag="${GIT_TAG}" \
dev.releaseasknowledge.build-time="${BUILD_TIME}" \
dev.releaseasknowledge.repo="https://github.com/acme/acme-api"
# ─── Group 3 · Vendor extension (reverse-DNS, your own business fields) ───
LABEL com.acme.case_type="api" \
com.acme.build_id="${COMMIT_SHA}"
# ─── Whatever you already had ───
COPY ./dist /usr/share/nginx/html
EXPOSE 80
Three rules:
org.opencontainers.image.* for OCI — written for every container tool (Trivy / Cosign / docker inspect read it natively)dev.releaseasknowledge.* for R2K — written for the R2K toolchain (CLI / scanner / diff plugin)com.<yourco>.*) for your own fields — keeps the standard namespace clean
Every "truth" comes from git: commit / branch / tag,
plus a build time from date -u.
Do not maintain a separate VERSION.txt for humans to bump — it becomes a second source of truth that will eventually disagree with git.
#!/usr/bin/env bash
set -euo pipefail
# Everything from git — never from a hand-maintained file
VERSION="${VERSION:-$(git describe --tags --always --dirty)}"
COMMIT_SHA="$(git rev-parse HEAD)"
GIT_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
GIT_TAG="$(git tag --points-at HEAD || true)"
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
docker build \
--build-arg VERSION="${VERSION}" \
--build-arg COMMIT_SHA="${COMMIT_SHA}" \
--build-arg GIT_BRANCH="${GIT_BRANCH}" \
--build-arg GIT_TAG="${GIT_TAG}" \
--build-arg BUILD_TIME="${BUILD_TIME}" \
-t acme-api:"${VERSION}" \
-t acme-api:"${COMMIT_SHA:0:12}" \
.
GitHub Actions equivalent (paste into .github/workflows/build.yml):
name: build
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # full git history needed for tags
- name: Compute build args
id: meta
run: |
echo "version=$(git describe --tags --always)" >> $GITHUB_OUTPUT
echo "commit=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
echo "branch=${GITHUB_REF_NAME}" >> $GITHUB_OUTPUT
echo "tag=$(git tag --points-at HEAD)" >> $GITHUB_OUTPUT
echo "build_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $GITHUB_OUTPUT
- uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ghcr.io/acme/acme-api:${{ steps.meta.outputs.version }}
build-args: |
VERSION=${{ steps.meta.outputs.version }}
COMMIT_SHA=${{ steps.meta.outputs.commit }}
GIT_BRANCH=${{ steps.meta.outputs.branch }}
GIT_TAG=${{ steps.meta.outputs.tag }}
BUILD_TIME=${{ steps.meta.outputs.build_time }}
Once the image hits the registry, any tool can pull every LABEL with two HTTP calls, ~7 KB total — no need to pull the full image.
Locally, docker inspect is fastest; for production verification use oras / crane straight against the registry.
# See whether every LABEL landed
$ docker inspect acme-api:1.2.3 \
--format '{{json .Config.Labels}}' | jq .
# Filter only the R2K labels
$ docker inspect acme-api:1.2.3 \
--format '{{json .Config.Labels}}' \
| jq 'with_entries(select(.key | startswith("dev.releaseasknowledge.")))'
Output (excerpt):
{
"dev.releaseasknowledge.build-time": "2026-05-10T14:32:11Z",
"dev.releaseasknowledge.commit": "a1b2c3d4e5f67890abcdef1234567890abcdef12",
"dev.releaseasknowledge.branch": "main",
"dev.releaseasknowledge.tag": "v1.2.3",
"dev.releaseasknowledge.level": "1",
"dev.releaseasknowledge.version": "1.0",
"dev.releaseasknowledge.repo": "https://github.com/acme/acme-api",
"org.opencontainers.image.revision": "a1b2c3d4e5f67890abcdef1234567890abcdef12",
"org.opencontainers.image.created": "2026-05-10T14:32:11Z",
"org.opencontainers.image.title": "acme-api",
"org.opencontainers.image.version": "v1.2.3"
}# oras pulls the image config straight from the registry (~7 KB · ~50 ms)
$ oras manifest fetch-config --pretty \
registry.acme.com/acme-api:1.2.3
# Or with crane
$ crane config registry.acme.com/acme-api:1.2.3 | jq .config.Labels
# Trivy can sweep an entire registry — ~1 minute for 1000 images
$ trivy image --format json --list-all-pkgs \
registry.acme.com/acme-api:1.2.3 \
| jq .Metadata.ImageConfig.config.Labels
com.yourco.*) — keeps your domain out of the standard namespace→ Once achieved: publishable in eng blog / citeable in RFP / badge-able in README