

Possedere repo, CI e registry dal primo giorno: la checklist del fondatore per lo sviluppo affidato all'esterno
La maggior parte dei progetti affidati all’esterno non fallisce sul codice. Fallisce il giorno in cui il fornitore smette di rispondere.
Ho una piccola agenzia di software, e buona parte del nostro lavoro in questi anni è stato raccogliere prodotti lasciati a metà da un altro team. Lo schema si ripete con una regolarità noiosa. Il codice sta in un repository che il fondatore può leggere, ma l’immagine è costruita a partire da una base che vive nel registry privato del vecchio fornitore. La CI gira sotto l’account personale di qualcuno. La produzione scarica :latest da un namespace in cui nessuno, dalla parte del cliente, riesce ad autenticarsi. Tutto funziona finché non funziona più, e a quel punto nessuno è in grado di ricostruire niente.
Questo articolo è la checklist che oggi consegno ai fondatori prima del primo commit. Non è teoria. Ogni comando e ogni output qui sotto sono stati eseguiti su una normale macchina Debian 13 con Docker 26.1 (l’ambiente esatto è in fondo), contro un piccolo repository dimostrativo. Puoi riprodurre tutto in una decina di minuti.
La regola: un estraneo con le tue credenziali deve poter pubblicare
La prova che uso è semplice. Dai a un ingegnere competente che non ha mai incontrato il fornitore:
- accesso in lettura al repository,
- accesso in scrittura al tuo container registry,
- nient’altro.
Riesce a clonare, costruire, eseguire e pubblicare esattamente il servizio che sta in produzione? Se la risposta è «dovrebbe chiedere qualcosa all’agenzia», il prodotto non è ancora tuo. Tutto quello che segue serve a trasformare quella risposta in un sì, e a dimostrarlo con uno script invece che con una promessa.
1. Il repository vive nella tua organizzazione
Sembra ovvio. Ed è anche la voce che trovo disattesa più spesso.
Il repository deve stare sotto un’organizzazione controllata dall’azienda, con almeno due persone dell’azienda come proprietari. Gli ingegneri del fornitore vengono invitati come membri o collaboratori esterni. Non creano il repository nella loro organizzazione per «trasferirlo più avanti». Più avanti non arriva mai, oppure arriva durante una controversia.
Metti un file CODEOWNERS dal primo giorno, così la proprietà è scritta nel repository e non in un allegato contrattuale:
# Client-side engineers own every path. The vendor team is added as reviewers, not owners.* @acme-corp/platform-owners/.github/ @acme-corp/platform-owners/Dockerfile @acme-corp/platform-ownersCODEOWNERS ha effetto solo quando la protezione del branch richiede la revisione del code owner: attivala anche su main. Se il tuo host git è GitHub, puoi controllare chi possiede davvero le cose con la CLI (lanciali sulla tua organizzazione; l’output dipende dal tuo account):
gh api repos/acme-corp/handoff-demo --jq '.owner.login, .owner.type'gh api "orgs/acme-corp/members?role=admin" --jq '.[].login'Il primo comando deve stampare il nome della tua organizzazione e Organization, il secondo deve elencare persone sul tuo libro paga.
2. Fissa le immagini base per digest, non per tag
I tag si spostano. python:3.12-slim oggi e python:3.12-slim fra otto mesi sono byte diversi. Per un progetto del fine settimana va benissimo, per una consegna è pessimo: il nuovo team non ha modo di capire se un difetto è suo o dell’immagine base.
Fissa il digest e lascia un commento che spieghi perché:
# Base image pinned by digest: a rebuild next year uses the same bytes.FROM python:3.12-slim@sha256:05cda9777409a9c3ffddd94a4c476b79f0769a0b4857f0c7ed9226b6800b0d6fPer trovare il digest di ciò che hai già scaricato:
docker images --digests pythonREPOSITORY TAG DIGEST IMAGE ID CREATED SIZEpython 3.12-slim sha256:05cda9777409a9c3ffddd94a4c476b79f0769a0b4857f0c7ed9226b6800b0d6f df18428df8a0 17 hours ago 119MBAggiornare la base diventa così una pull request di una riga, sottoposta a revisione, invece di un effetto collaterale invisibile della ricostruzione. Strumenti come Dependabot o Renovate possono aprirle al posto tuo.
3. Niente immagini base dal registry privato del fornitore
Questa è quella che fa più male, quindi ecco com’è fatta quando va storta. Un tipico Dockerfile «dell’era del fornitore»:
FROM ghcr.io/example-vendor-agency/python-base:latestCOPY app/ ./app/CMD ["python", "app/server.py"]Sui portatili del fornitore questo si costruisce, perché loro sono autenticati. Su una macchina pulita senza le loro credenziali, questo è l’output vero (ridotto alle righe che contano):
$ docker build -t acme/handoff-demo:vendor-era .#2 [internal] load metadata for ghcr.io/example-vendor-agency/python-base:latest#2 ERROR: failed to authorize: failed to fetch anonymous token: unexpected status from GET request to https://ghcr.io/token?scope=repository%3Aexample-vendor-agency%2Fpython-base%3Apull&service=ghcr.io: 403 Forbidden------Dockerfile:1-------------------- 1 | >>> FROM ghcr.io/example-vendor-agency/python-base:latest 2 | COPY app/ ./app/ 3 | CMD ["python", "app/server.py"]--------------------ERROR: failed to solve: ghcr.io/example-vendor-agency/python-base:latest: failed to resolve source metadata for ghcr.io/example-vendor-agency/python-base:latest: failed to authorize: failed to fetch anonymous token: unexpected status from GET request to https://ghcr.io/token?scope=repository%3Aexample-vendor-agency%2Fpython-base%3Apull&service=ghcr.io: 403 Forbiddenexit code: 1(example-vendor-agency è un namespace di esempio, quindi il 403 qui sopra è reale, ma è lo stesso errore che otterresti da un namespace privato a cui non hai accesso.)
Se il fornitore ha un’immagine base irrobustita a cui tiene, va benissimo. Viene copiata nel tuo registry, il suo Dockerfile entra nel tuo repository, e le tue immagini si costruiscono a partire da lì.
4. Ogni immagine dice da dove viene
Quando qualcosa si rompe alle due di notte, la prima domanda è «quale commit sta girando?». Le etichette OCI rispondono senza dover ricostruire la cronologia delle chat:
ARG GIT_SHA=unknownARG SOURCE_URL=unknown
LABEL org.opencontainers.image.source=$SOURCE_URL \ org.opencontainers.image.revision=$GIT_SHA \ org.opencontainers.image.title="handoff-demo"Poi chiunque abbia l’immagine può fare così:
docker image inspect localhost:5000/acme/handoff-demo:203d8f1 \ --format '{{ index .Config.Labels "org.opencontainers.image.source" }} @ {{ index .Config.Labels "org.opencontainers.image.revision" }}'file:///tmp/client-git/acme/handoff-demo.git @ 203d8f1Nella CI vera la sorgente è l’URL del tuo repository e la revisione è lo SHA completo del commit. Anche GHCR usa org.opencontainers.image.source per collegare un pacchetto al suo repository.
Già che sei nel Dockerfile, fai girare il processo come utente non privilegiato. Costa due righe ed è un rilievo in meno nella prima revisione di sicurezza dopo la consegna:
RUN useradd --system --uid 10001 --no-create-home appUSER 100015. Il registry è nel tuo account
La produzione deve scaricare le immagini da un registry di proprietà dell’azienda: GHCR sotto la tua organizzazione, ECR nel tuo account AWS, Artifact Registry nel tuo progetto GCP, oppure un registry self-hosted. La CI del fornitore ci scrive con una credenziale a perimetro ristretto che hai emesso tu e che puoi revocare.
Per la dimostrazione ho usato l’immagine ufficiale registry:2 al posto del «registry del cliente»:
$ docker run -d --name client-registry -p 5000:5000 registry:2be33ebd056ea7ac90d49ebd0a348e568c62fbc51739ad42ee1dd159665b591b5Dopo una push, l’API del registry mostra esattamente ciò che possiedi:
$ curl -s http://localhost:5000/v2/_catalog{"repositories":["acme/handoff-demo"]}$ curl -s http://localhost:5000/v2/acme/handoff-demo/tags/list{"name":"acme/handoff-demo","tags":["12d12c4","203d8f1"]}Nota che i tag sono SHA di commit, non latest. Rilascia per digest o per tag SHA, così tornare indietro è una modifica di una parola.
6. La CI è un file nel repository, e gira sotto l’organizzazione
La configurazione della CI appartiene al repository, e la pipeline gira sotto l’account dell’organizzazione, non sotto quello personale di un ingegnere del fornitore. I segreti vivono nell’archivio dei segreti dell’organizzazione. Ecco il workflow del repository dimostrativo, per GitHub Actions:
name: build-and-push
on: push: branches: [main] pull_request:
permissions: contents: read packages: write
jobs: image: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4
- name: Log in to the client's registry if: github.event_name == 'push' uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build (and push on main) uses: docker/build-push-action@v6 with: context: . push: ${{ github.event_name == 'push' }} build-args: | GIT_SHA=${{ github.sha }} SOURCE_URL=${{ github.server_url }}/${{ github.repository }} tags: ghcr.io/${{ github.repository }}:${{ github.sha }}Due dettagli contano per la proprietà. Usa il GITHUB_TOKEN integrato, quindi non c’è nessun token personale a lunga vita da ereditare. E pubblica su ghcr.io/${{ github.repository }}, quindi l’immagine finisce sotto chi possiede il repository, cioè tu.
Prima di fidarmi di un workflow lo passo al linter:
$ actionlint .github/workflows/build.yml && echo "actionlint: no issues"actionlint: no issues7. Dimostralo: il controllo di consegna
Le checklist marciscono. Uno script no, finché lo esegui. Questa è la versione che uso per i servizi piccoli. Clona in una directory temporanea, punta Docker a una configurazione vuota perché nessun login salvato possa aiutare, poi costruisce, esegue, sonda, ispeziona e fa un giro completo dell’immagine attraverso il tuo registry.
#!/usr/bin/env bash# handoff-check.sh: can someone who is NOT the vendor rebuild, run and ship this repo?# Usage: ./handoff-check.sh <git-url-or-path> <registry/namespace>set -euo pipefail
REPO="$1"REGISTRY="$2"WORK="$(mktemp -d)"export DOCKER_CONFIG="$WORK/docker-config" # empty config: no saved vendor loginsmkdir -p "$DOCKER_CONFIG"
echo "== 1. Fresh clone"git clone --quiet "$REPO" "$WORK/src"cd "$WORK/src"SHA="$(git rev-parse --short HEAD)"echo "HEAD is $SHA"
echo "== 2. Secrets that should never be in git"if git ls-files | grep -E '(\.env$|\.pem$|\.key$|id_rsa)'; then echo "FAIL: secret-looking files are tracked"; exit 1else echo "OK: no tracked .env/.pem/.key files"fi
echo "== 3. Unpinned base images"if grep -E '^FROM ' Dockerfile | grep -v '@sha256:'; then echo "WARN: base image not pinned by digest"else echo "OK: every FROM is pinned by digest"fi
echo "== 4. Build with no registry credentials"docker build --quiet \ --build-arg GIT_SHA="$SHA" \ --build-arg SOURCE_URL="$REPO" \ -t "$REGISTRY/handoff-demo:$SHA" .
echo "== 5. Run and probe"CID="$(docker run -d -p 18080:8080 "$REGISTRY/handoff-demo:$SHA")"for i in $(seq 1 20); do curl -fsS http://127.0.0.1:18080/healthz 2>/dev/null && break sleep 0.5doneechoecho "runs as uid: $(docker exec "$CID" id -u)"docker rm -f "$CID" >/dev/null
echo "== 6. Provenance labels"docker image inspect "$REGISTRY/handoff-demo:$SHA" \ --format '{{ index .Config.Labels "org.opencontainers.image.source" }} @ {{ index .Config.Labels "org.opencontainers.image.revision" }}'
echo "== 7. Push to the client's registry and pull back by digest"docker push --quiet "$REGISTRY/handoff-demo:$SHA"DIGEST="$(docker image inspect "$REGISTRY/handoff-demo:$SHA" --format '{{ index .RepoDigests 0 }}')"docker rmi --force "$REGISTRY/handoff-demo:$SHA" >/dev/nulldocker pull --quiet "$DIGEST"echo "pulled $DIGEST"
rm -rf "$WORK"echo "== handoff-check passed"Eseguito contro il repository dimostrativo (un bare repository che fa le veci dell’host git del cliente) e il registry locale:
$ ./handoff-check.sh file:///tmp/client-git/acme/handoff-demo.git localhost:5000/acme== 1. Fresh cloneHEAD is 203d8f1== 2. Secrets that should never be in gitOK: no tracked .env/.pem/.key files== 3. Unpinned base imagesOK: every FROM is pinned by digest== 4. Build with no registry credentialssha256:60d46686c0cc427edffe8ad32ccc944001ce775a7605a20a90830505c39a6ab1== 5. Run and probe{"status": "ok", "version": "203d8f1"}runs as uid: 10001== 6. Provenance labelsfile:///tmp/client-git/acme/handoff-demo.git @ 203d8f1== 7. Push to the client's registry and pull back by digestlocalhost:5000/acme/handoff-demo:203d8f1localhost:5000/acme/handoff-demo@sha256:566f323ad28e0c8a27cc5b074b48aba0b0ebb5e35754100eb9417d2397a91546pulled localhost:5000/acme/handoff-demo@sha256:566f323ad28e0c8a27cc5b074b48aba0b0ebb5e35754100eb9417d2397a91546== handoff-check passed
Qualche limite, detto con onestà. Il controllo sui segreti intercetta solo i nomi di file evidenti: per il contenuto serve uno scanner vero come gitleaks o trufflehog. Il passo 4 scarica comunque l’immagine base pubblica in forma anonima, ed è esattamente il punto: basi pubbliche e il tuo registry, nient’altro. E contro un registry vero il passo di push andrebbe eseguito con una credenziale di CI emessa dalla tua organizzazione, non senza nulla.
Chiedo ai fornitori di trasformare questo script (o la loro versione) in un job di CI settimanale. Il giorno in cui diventa rosso è il giorno in cui scopri una dipendenza dal portatile di qualcuno, invece del giorno in cui quel qualcuno se ne va.
La checklist del fondatore
Incollala nel capitolato:
- Repository creato nell’organizzazione dell’azienda il primo giorno; almeno due proprietari interni;
CODEOWNERScommittato e reso vincolante dalla protezione del branch. - Ogni
FROMfissato per digest; immagini base solo da registry pubblici o dal registry dell’azienda. - Immagini etichettate con
org.opencontainers.image.sourceeorg.opencontainers.image.revision; container eseguiti senza privilegi di root. - Container registry nel cloud o nell’account dell’azienda; immagini marcate con lo SHA del commit; rilasci in produzione per SHA o per digest.
- CI definita nel repository, in esecuzione sotto l’organizzazione, con segreti a perimetro organizzazione o con il token integrato della piattaforma.
- Un controllo di consegna che cloni, costruisca, esegua e pubblichi senza nessuna credenziale del fornitore, eseguito in CI a cadenza fissa.
- Un elenco scritto di ogni account esterno da cui il prodotto dipende (DNS, posta, pagamenti, cloud), ciascuno con un responsabile interno all’azienda.
Niente di tutto questo rallenta un buon fornitore. Un fornitore che oppone resistenza ti sta dicendo qualcosa.
Ambiente usato per questo articolo
docker client 26.1.5+dfsg1 / server 26.1.5+dfsg1 (storage driver: vfs)git version 2.47.3actionlint 1.7.12Linux 6.12.94+ x86_64PRETTY_NAME="Debian GNU/Linux 13 (trixie)"Umair Abbas è il fondatore di CodeFlamme, un’agenzia di software di Lahore, in Pakistan, che realizza prodotti web, mobile e AI in cui il cliente possiede repository, CI e cloud dal primo giorno.
Traduzione italiana dell’articolo originale in inglese, Own the Repo, CI and Container Registry on Day One. Comandi, output e script sono riprodotti invariati.
← Back to blog