Skip to content

CI Token Scoping

This example shows how to issue short-lived, repo-scoped tokens for CI jobs so that each job gets the minimum access it needs. This limits the blast radius if a token is leaked in build logs or artifacts.

githosted has two token types:

PrefixScopeUse case
gw_Workspace-wideAdmin scripts, provisioning, full access
gr_Specific reposCI jobs, agents, read-only dashboards

A workspace token can create repo tokens. A repo token can only access repos in its allowlist.

import { Client } from "@githosted/sdk";
const admin = new Client({
token: process.env.GITHOSTED_TOKEN, // workspace token (gw_...)
});
/**
* Called by your CI orchestrator before dispatching a job.
* Returns a short-lived token scoped to a single repo.
*/
async function issueJobToken(repoSlug: string): Promise<string> {
const result = await admin.createToken({
name: `ci-${Date.now()}`,
kind: "repo",
permission: "write",
repoAllowlist: [repoSlug],
ttlHours: 1,
});
return result.token; // "gr_xxxx..." — returned only at creation time
}

The job receives only a repo-scoped token. It cannot access other repos in the workspace.

import { Client } from "@githosted/sdk";
// CI environment variables set by the orchestrator
const repoSlug = process.env.REPO_SLUG!; // e.g. "deploy-configs"
const jobToken = process.env.JOB_TOKEN!; // gr_... scoped to this repo
async function ciJob() {
const client = new Client({ token: jobToken });
const repo = client.repo(repoSlug);
// Read the current deployment config
const configFile = await repo.read("environments/production.json");
const config = JSON.parse(configFile.content);
// Update the image tag
config.image = process.env.NEW_IMAGE_TAG;
// Write with expectedHead so we don't clobber a concurrent deploy
await repo.write(
"environments/production.json",
JSON.stringify(config, null, 2),
{
message: `Deploy ${process.env.NEW_IMAGE_TAG}`,
expectedHead: configFile.headSha,
},
);
console.log("Deployment config updated");
}
ciJob().catch((err) => {
console.error("CI job failed:", err.message);
process.exit(1);
});

A wrapper action that provisions a scoped token and passes it to the job step:

.github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Provision scoped token
id: token
run: |
# Call your token-issuing endpoint
TOKEN=$(curl -s -X POST \
-H "Authorization: Bearer ${{ secrets.GITHOSTED_ADMIN_TOKEN }}" \
https://your-api.example.com/ci/token \
-d '{"repo": "deploy-configs", "ttl": "1h"}')
echo "job_token=$TOKEN" >> "$GITHUB_OUTPUT"
- name: Update deployment config
env:
JOB_TOKEN: ${{ steps.token.outputs.job_token }}
REPO_SLUG: deploy-configs
NEW_IMAGE_TAG: ${{ github.sha }}
run: npx tsx scripts/update-deploy-config.ts

For monitoring dashboards that only need to read state:

import { Client } from "@githosted/sdk";
// A repo token with read-only permissions
const client = new Client({
token: process.env.DASHBOARD_TOKEN, // gr_... read-only
});
async function fetchDeploymentStatus() {
const repo = client.repo("deploy-configs");
const envs = await repo.ls("environments");
const statuses = await Promise.all(
envs
.filter((e) => e.type === "file")
.map(async (e) => {
const file = await repo.read(`environments/${e.name}`);
const config = JSON.parse(file.content);
return {
environment: e.name.replace(".json", ""),
image: config.image,
updatedAt: file.headSha,
};
}),
);
return statuses;
}
  • Prefer repo-scoped tokens (gr_) over workspace tokens (gw_) for CI. A leaked repo token can only access repos in its allowlist, not your entire workspace.
  • Use short TTLs. Set ttlHours to match your job timeout (e.g., 1). Tokens that auto-expire do not need manual cleanup.
  • Combine with expectedHead for safe concurrent deploys. If two CI jobs race to update the same config, the loser gets StaleHeadError instead of silently overwriting.