Git credentials
Two facts drive everything on this page, and neither is guessable:
- Cloning your own repository needs no credential. The framework checks it out for you, and the app KiCI installs already holds read access.
- Pushing always needs a credential you supply. The KiCI app holds read access only, so every push — including to the job’s own repository — needs one.
Declare credentials once, by name
Section titled “Declare credentials once, by name”Credentials are declared on the job as a named map. Every value is the name of
a secret, in <context>:<secret-name> form — never the credential itself:
job('release', { runsOn: 'linux', gitCredentials: { // `default` is used whenever a call names no credential default: { kind: 'app', appIdSecret: 'ci:ACME_APP_ID', installationIdSecret: 'ci:ACME_INSTALL_ID', privateKeySecret: 'ci:ACME_APP_KEY', }, forge: { kind: 'token', tokenSecret: 'ci:FORGE_PAT' }, vendor: { kind: 'ssh', privateKeySecret: 'ci:VENDOR_DEPLOY_KEY' }, }, steps: [build, tagAndPush],});Store the secrets first with kici-admin secret set. Pasting a private key
straight into the workflow is rejected when the workflow is defined, naming the
field — a key written into .kici/ would be committed to your repository.
Your checkout is read-only by default. Opening a write window is explicit:
step('tag', async ({ $, repo }) => { await $`git tag v${version}`; await repo.withWrite({ permissions: { contents: 'write' } }, async () => { await $`git push origin v${version}`; });});Inside the callback, git operations on that repository use a write credential. Outside it they do not, so an accidental push elsewhere in the job fails.
Pass credential: 'forge' to use a named entry instead of default.
KiCI never guesses the permission set. What a push needs depends on what is
being pushed — changing anything under .github/workflows/ additionally requires
workflows:
await repo.withWrite({ permissions: { contents: 'write', workflows: 'write' } }, async () => { await $`git push origin HEAD`;});If the app was not granted a permission you request, the forge refuses to issue the credential at all. The error names the repository and the permissions you asked for, before any git command runs — not at the end of a long build.
Clone more than one repository
Section titled “Clone more than one repository”A workflow often needs several repositories, not just its own. Mint one token that covers all of them, then clone each with it:
const { token } = await kici.git.github.getToken({ repositories: ['acme/app', 'acme/shared-lib'], permissions: { contents: 'read' },});
for (const repo of ['acme/app', 'acme/shared-lib']) { await $`git clone https://x-access-token:${token}@github.com/${repo}.git`;}A GitHub App token is issued per installation, so one call covers every repository you name. Each repository must be inside the app’s installation. If one is not, the forge refuses the whole request and the error names it.
The credential helper is installed on your own checkout only, so a repository you clone yourself does not inherit it. That is why this case mints a token rather than relying on the helper.
Call the forge API
Section titled “Call the forge API”gh does not read git credential helpers, so this is the one case that wants the
token as a value:
const { token } = await kici.git.github.getToken({ repositories: ['acme/app'], permissions: { contents: 'write' },});await $({ env: { ...process.env, GH_TOKEN: token } })`gh release create v${version}`;The token is masked in step logs. Prefer withWrite for git itself, which never
places a credential in the step environment.
Credentials that only exist at run time
Section titled “Credentials that only exist at run time”A credential fetched during the run — from a vault, or a cloud secret store via
the job’s OIDC identity — cannot be named ahead of time. Use the *Value half of
the pair, which says “this is the credential, not a name for one”:
gitCredentials: { default: { kind: 'token', tokenValue: fetchedAtRuntime } }To pass a derived credential to a later job, publish it with
ctx.setSecretOutput() and name it with the reserved needs: context — that
path is encrypted, scoped to the run, and deleted when the run ends:
const mint = job('mint', { runsOn: 'linux', run: async (ctx) => { const token = (await ctx.$`vault write -f auth/token/create`).stdout.trim(); ctx.setSecretOutput('FORGE_TOKEN', token); },});
const build = job('build', { runsOn: 'linux', needs: [mint], gitCredentials: { default: { kind: 'token', tokenSecret: 'needs:FORGE_TOKEN' } }, steps: [cloneAndPush],});Never put a credential in a regular job output: regular outputs are not masked, are stored, and are shown in the dashboard.
For a minted app token, prefer re-deriving over transporting — those expire after an hour, so one minted in an earlier job is often already dead by the time a later job reads it. Have the later job name the same secret, or mint its own.
What a job may ask for
Section titled “What a job may ask for”A credential is authorized against the workflow you wrote, not against the code running in the job. Three things must all hold before the orchestrator resolves one:
- The job declared it. The orchestrator records the job’s
gitCredentialsmap when it dispatches the job, and compares every request against that record. A request naming a credential the job did not declare is refused. This is why you passcredential: 'forge'— a name — rather than building a credential reference in step code. - The named context admits the run. A
prod:reference runs theprodcontext’s own protection rules first: its branch restrictions, itsminimumTrust, its approval requirement. A credential named from a branch the context does not allow is refused, and the git operation fails. The rule that refused it is named in your orchestrator’s log, not in the run — the orchestrator returns a fixed error to the job rather than describing its own configuration to code it does not trust. - The contributor is trusted. A run from an untrusted ref — a fork pull request — gets no declared credential at all. It still clones with the source credential, so the build runs; only the declared credentials are withheld. The reduced-privilege note on the run says so.
The context in a reference does not have to appear in the job’s contexts:
list. The reference names its own context, and that context’s rules are what
authorize it.
Generated jobs
Section titled “Generated jobs”A job produced by a dynamicJob generator has no entry in the lock file, so it
cannot declare credentials of its own. The generator declares them, and every
job it produces inherits that map:
dynamicJob('shards', { gitCredentials: { forge: { kind: 'token', tokenSecret: 'ci:FORGE_PAT' }, }, generate: async ({ ctx }) => ctx.event.payload.targets.map((target) => job(`publish-${target}`, { runsOn: 'linux', run: async ({ $, repo }) => { await repo.withWrite( { permissions: { contents: 'write' }, credential: 'forge' }, async () => { await $`git push origin HEAD`; }, ); }, }), ),});Three points follow from where the declaration lives:
- All generated jobs share one map. The generator is granted one ceiling, and every job it produces gets exactly that ceiling. Use a second generator when two sets of jobs need different credentials.
- A
gitCredentialsmap on a generated job is ignored. The generator’s declaration is committed source that KiCI reads from the lock file. A generated job’s own declaration would come from the code that produced it, which is what the authorization check above exists to be independent of. - The options form is required.
dynamicJob('shards', async () => …)— the bare function form — has nowhere to put the declaration. Pass{ generate, gitCredentials }instead;needsstays optional.
How it works, and why long jobs still push
Section titled “How it works, and why long jobs still push”An app token expires an hour after it is issued, and cannot be renewed. Rather
than capture one at checkout time, the agent installs a git credential helper on
the checkout: git asks it on every network operation, and it obtains a fresh
credential each time. A push at the end of a three-hour build works exactly as it
does at the start, and no credential is ever written into .git/config, into
git remote -v, or into the step’s environment.
Limits worth knowing
Section titled “Limits worth knowing”- Container jobs cannot use this yet. A container job runs git inside the container, which has no route to the credential service. Bare-metal jobs are unaffected.
- The reserved
needs:context is not resolvable yet on a deployed orchestrator; naming it produces a clear error rather than a wrong credential. - A credential reference built in step code is refused. The SDK takes a credential name; there is no way to pass a reference. Code that constructs one and sends it directly is rejected by the agent and, if it reaches the orchestrator, by the declaration check above.
- A write window is bounded by the repository and the callback, not the step. Steps running concurrently in the same job can push to the same repository while it is open. They cannot reach a different one.
- A credential you supply yourself cannot be narrowed. A personal access token or SSH key grants whatever it was created with, so a requested permission set is reported as unscoped rather than pretended to be enforced.
- Being allowed to push is not the same as the push succeeding. A branch protection rule or repository ruleset can still reject it.