Skip to content

Push images from CI

A job on a hosted runner can push container images to your organization’s private registry. You do not create a token or store a secret for this. The runner carries a one-job ticket; the job exchanges it for a short-lived registry credential, pushes, and the credential is gone when the job ends. The pushed image is then captured, signed and scanned like any other push.

  • Hosted runners are on for your organization, and Let CI jobs push images to this organisation’s registry is on in the Settings tab of CI / Pipelines. An Admin changes these; they are on by default.
  • The person who triggered the run may push to the registry (the Create registry permission, which Members have by default). Sencai asks for the permission of that person, not of the repository. See which runs get a credential.
  • The image name is git.sencai.space/<organization>/<image>. The Access panel on the registry page shows the exact Image prefix.

Every hosted job has two values in its environment. SENCAI_CI_TICKET is the ticket and SENCAI_CI_API is the address to exchange it at. Add a step like this before you build, and write the registry host literally, exactly as shown by the Access panel:

name: build-image
on: [push]
jobs:
image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Log in to the Sencai registry
run: |
resp="$(mktemp)"
printf 'header = "Authorization: Sencai-CI-Ticket %s"\n' "$SENCAI_CI_TICKET" \
| curl -sS --fail-with-body --retry 10 --retry-delay 2 --retry-all-errors \
--config - -X POST -o "$resp" "$SENCAI_CI_API/ci/registry-credentials" \
|| { echo "The registry did not give this job a credential:" >&2; cat "$resp" >&2; rm -f "$resp"; exit 1; }
user="$(jq -r .username "$resp")"
pw="$(jq -r .password "$resp")"
rm -f "$resp"
echo "::add-mask::$pw"
printf '%s' "$pw" | docker login git.sencai.space -u "$user" --password-stdin
- name: Build and push
run: |
docker build -t git.sencai.space/acme/api:${{ github.sha }} .
docker push git.sencai.space/acme/api:${{ github.sha }}

What the login step does:

  • It sends the ticket in a request header that curl reads from standard input, so the ticket is not part of the command line. The credential comes back in a response file that is deleted as soon as it is read, is masked in the log, and goes to docker login through standard input, again off the command line.
  • It retries for up to about twenty seconds. A job asks for its credential in the first seconds of its life, before Sencai has finished recording which job the runner took; the answer in that moment is “not yet” and a retry gets it.
  • If Sencai refuses, the step prints the reason (see When a job does not get a credential) instead of failing without a message.
  • It needs curl (7.76 or later) and jq in the job image. If yours lacks them, install them in an earlier step. The Settings tab shows a similar step with your registry host filled in, and a Copy button.

Replace acme/api with your own organization and image. Keep the host written in the workflow, not taken from a variable someone can change: a variable that pointed elsewhere would send the credential to that host.

The run’s own automatic token (github.token) cannot push images. You need the exchange above.

A credential is issued only when all of these hold:

Requirement Detail
The job runs on a hosted runner of your organization The ticket belongs to one runner, and the runner to one organization
Hosted CI and registry push are on Both switches are on in the Settings tab, and the platform has them on
The run has an event that may get a credential By default push, workflow_dispatch, schedule, release and create. An Admin can choose a narrower list in the Settings tab
The run is not from a fork Pull requests from forks never get a credential
The person behind the run may push The member who triggered it holds the registry Create permission now

Pull requests from the same repository get a credential only if an Admin turned on Allow pull requests from the same repository and kept pull_request in the list. Runs of the pull_request_target event never get one, because they run with your secrets on code that a pull request’s author chose.

A run with no Sencai member behind it - started by a deploy key, a mirror sync, or the schedule of a member who has since left - gets nothing unless an Admin switched on Also allow runs with no Sencai member behind them. An explicit deny for a member is never overridden by that switch.

  • It can push images to your organization’s registry, and nothing else. It does not work for any other organization’s registry.
  • It belongs to this job alone. Sencai creates a temporary registry login for the job; it appears on the image page as the pusher, with a name that begins sencai-a-.
  • A job can request it up to three times; a new request replaces the previous credential.
  • It ends with the job. When the job finishes, fails, times out, is stopped, or its runner is lost, the credential is deleted and its login is switched off, which also ends registry sessions the job had already opened. If a runner disappears without notice, Sencai removes the credential within minutes.
  • Turning Let CI jobs push images to this organisation’s registry off, or turning hosted runners off, revokes credentials that are open right now.

The step prints the answer. The code in it says why:

Code Meaning What to do
ci_job_not_identified_yet Sencai has not recorded which job the runner took yet Nothing; the step retries
ci_registry_push_not_allowed Hosted CI or registry push is off for the organization, or the organization is not active An Admin checks the Settings tab
ci_registry_push_disabled Registry push from CI is not switched on in this environment Use the CI push credential instead
ci_fork_pull_request The run is a pull request from a fork Build forks without pushing
ci_event_not_allowed The run’s event is not in the allowed list An Admin adds the event, or push from a different trigger
ci_trigger_not_permitted The person behind the run may not push, or there is no member behind it and the opt-in is off Give that person the Create registry permission, or use the opt-in in the Settings tab
ci_credential_limit The job already asked for the most credentials it may have Log in once per job and keep using that login; the latest credential stays valid
ci_ticket_invalid, ci_ticket_expired The ticket is unknown, or its job has ended or is being stopped Run the workflow again; do not copy a ticket out of a job
ci_ticket_wrong_origin The ticket was presented from outside the platform’s CI network Use it only inside the job
ci_rate_limited, ci_broker_unavailable Too many requests, or a temporary problem Retry; if it persists, contact support

Pushes from CI are handled by the same steps as every other push, described in What happens on every push: Sencai records the exact image content, signs it and scans it.

On the image page, a version pushed by a hosted job has a line below the push time that begins “Built by” and names the CI, the repository, the run number, the job and the first seven characters of the commit. The same run and commit are recorded in the signed payload, so a signature you verify later tells you which run built the image. If a push cannot be tied to exactly one live job, the page says “Pushed by” a CI credential and that no running job could be matched.

A push is signed only if Sencai can match it to the job that made it, while that job was running, and the person who triggered the run still may push. A push with a credential used after its job ended, from another job, or by a person who lost the permission, is refused: the version shows the refused state and is never signed, and the members with access to security findings are notified.