Skip to content

Container registry

neosource hosts container images alongside your code, on the same accounts and the same permissions. An image lives at:

neocr.io/{owner}/{name}:{tag}

There is nothing to enable and no separate registry account: the credential is an ordinary access token, and docker is the client.

The registry answers on exactly one host, neocr.io, and that host is permanent. An image reference embeds its host: neocr.io/acme/api:v3 is not a name you look up in a registry setting — it is baked into Dockerfiles, docker-compose.ymls, Kubernetes manifests and lockfiles, and it stays there long after anyone remembers where it came from. A registry host that stops answering breaks builds we cannot see and cannot fix, so neocr.io is not on a timer and will not be renamed.

The password is an access token — never your account password:

Terminal window
echo "$NEOSOURCE_TOKEN" | docker login neocr.io -u your-handle --password-stdin

The username is not checked. The token identifies you; the username is only what your credential helper files the entry under. Use your handle so the ~/.docker/config.json entry is readable by a human.

Behind that one command, docker runs the standard OCI token exchange: it probes /v2/, reads the Bearer challenge, and swaps your token for a short-lived JWT scoped to exactly the repository and actions it is about to use. You do not have to do anything with that — but it is why a leaked build log containing a registry bearer token exposes one repository for five minutes rather than your whole account.

Terminal window
docker build -t neocr.io/acme/api:v3 .
docker push neocr.io/acme/api:v3
Terminal window
docker pull neocr.io/acme/api:v3

There is no separate “create a registry” step, but push to the name of a repository that already exists. An image name that corresponds to no repository cannot accept an upload yet; the push fails on the first layer with 400 BLOB_UPLOAD_INVALID.

A push you are not allowed to make fails with 401 Unauthorized on the first layer instead, and without a message naming the problem — the token exchange hands out no scope for a name it will not let you write, so a misspelled owner, a repository you cannot reach, and a token missing repo:registry:write all look identical from the client. Check the owner and name first, the token’s scopes second.

To see what is published, ask the registry for the tag list:

Terminal window
curl -H "authorization: Bearer $NEOSOURCE_TOKEN" \
https://neocr.io/v2/acme/api/tags/list
{ "name": "acme/api", "tags": ["v3"] }

The registry is API-only today: there is no image browser in the web UI, and repository catalog listing (/v2/_catalog) is not implemented. tags/list on a namespace you can read is how you see what is there.

Create the token at /settings/developer for a personal one, or under Organization settings → Tokens for a workspace one owned by a service account. Both scope pickers have a Container registry group.

the job scopes
pull images (a deploy, a CI base image) repo:registry:read
build and publish images repo:registry:read and repo:registry:write
also remove old tags add repo:registry:delete

Restrict the token to the repositories it needs while you are there — a resource-locked token is refused every namespace outside its list.

A docker push asks the registry whether each layer is already stored before uploading it, and that check authorizes as a pull. A token holding only repo:registry:write starts an upload session fine and then gets a 401 on the first layer probe. Grant both.

Why publish does not include delete, or git

Section titled “Why publish does not include delete, or git”

This is the part worth understanding, because it is the reason the registry scopes exist separately at all:

  • repo:registry:write cannot delete. A publish credential that can also unpublish turns a leaked token into a way to remove releases people are already pulling. Deletion is its own scope, and write does not imply it.
  • repo:registry:* carries no git access. A token holding only the registry scopes cannot clone the repository, cannot push commits, and cannot read issues or settings. A build machine that publishes images does not need your source history, and now it does not get it.

The implication runs one way only. repo:write and repo:admin do carry the registry actions, so a broad token still publishes exactly as it always did — which is precisely why the narrow one is worth minting.

The workspace-scoped pair, workspace:registry:read / workspace:registry:write, is the same trade one level up: every image the workspace owns, still no git, and still no delete. Reach for it when one credential has to publish several of a workspace’s images rather than one repository’s — and note that only a workspace Admin or Owner holds them, so granting them to a member-role service account grants nothing at all.

Each image namespace carries its own visibility, and that is what decides anonymous pull:

  • public — docker pull works with no credential at all, the same way git clone does on a public repository.
  • anything else — a token on every request.

A namespace created by a push starts out with the visibility of the repository of the same name, or private when there is no such repository. From then on it is a property of the namespace, not of the repository: a private repository can hold a public image, and a public one can hold a private image. Changing it is not exposed in the web UI yet.

Push and delete always need a token, whatever the visibility.

Every job is already logged in to neocr.io with its own job token, so publishing the repository’s image needs no secret and no login step:

- run: docker build -t neocr.io/acme/api:${{ github.sha }} .
- run: docker push neocr.io/acme/api:${{ github.sha }}

The runner writes the credential before the first step, into the auth file $REGISTRY_AUTH_FILE that docker login, podman and buildah all share. Where the job’s docker is Docker itself rather than the platform shim, that file is $DOCKER_CONFIG/config.json, so a docker login and docker/login-action still land in the same place. A login step you already have keeps working: logging in with secrets.GITHUB_TOKEN (or secrets.NEOSOURCE_TOKEN, the same token) is redundant but harmless, and logging in with an access token replaces the job’s credential for that host, which is what you want when you push somewhere the job token cannot reach. Workflows written for GitHub, such as

- uses: docker/login-action@v3
with:
registry: neocr.io
username: ci
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
tags: neocr.io/acme/api:${{ github.sha }}
push: true

run unchanged.

A login whose password is empty, which is what a secret that is not defined expands to, prints the password is empty — is the secret set?. For neocr.io it is a warning and the job’s own credential stays in place; for any other registry the step fails with that message.

The job token is not a person’s credential and never borrows the authority of whoever started the run. What it reaches is fixed:

  • The repository’s own image — any image linked to the repository; a new one is named exactly like it (neocr.io/acme/api for acme/api). A trusted run pulls and pushes it, and may create it on the first push if the repository has no linked image yet. No other new image name can be created with the job token.
  • Everything else — other repositories’ images, images linked to no repository (deploy images), and every other workspace: what an anonymous client gets, so public images pull and nothing else does.
  • Pull requests from forks run untrusted: their job pulls public images only — the repository’s own image included, if it is private — and pushes nothing.
  • Never delete.
  • It ends with the job. When the job finishes, the token and every registry credential exchanged from it stop working.

Deploy images that belong to no repository need an access token, below. So do scheduled runs that push new layers: a run no person started cannot upload blobs with the job token (it can still tag a manifest whose layers are already stored).

Registry build caches (cache-from/cache-to: type=registry) are not supported yet; they wait for a change to how image tags are stored.

Store the token as a workspace CI secret and log in from a step. Use a workspace token rather than a personal one — it belongs to a service account instead of to whoever happens to have set the pipeline up:

- name: Log in to the registry
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login neocr.io -u ci --password-stdin

For every push after the image name exists, the token needs repo:registry:read + repo:registry:write and nothing else. If that pipeline also has to git push, that is a second, separate credential — keeping them apart is the whole point.