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.
One hostname, and it does not go away
Section titled “One hostname, and it does not go away”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.
Logging in
Section titled “Logging in”The password is an access token — never your account password:
echo "$NEOSOURCE_TOKEN" | docker login neocr.io -u your-handle --password-stdinThe 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.
Push and pull
Section titled “Push and pull”docker build -t neocr.io/acme/api:v3 .docker push neocr.io/acme/api:v3docker pull neocr.io/acme/api:v3There 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:
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.
Which scope to mint
Section titled “Which scope to mint”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.
Why publish needs read as well
Section titled “Why publish needs read as well”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:writecannot 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.
Visibility
Section titled “Visibility”Each image namespace carries its own visibility, and that is what decides anonymous pull:
- public —
docker pullworks with no credential at all, the same waygit clonedoes 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.
With the job’s own token
Section titled “With the job’s own token”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: truerun 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/apiforacme/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.
With an access token
Section titled “With an access token”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-stdinFor 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.
See also
Section titled “See also”- Authentication and tokens — how scopes, resource locks, expiry, and rotation work.
- The job environment — why neosource jobs declare
packages:instead of choosing a base image.