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.
Token hierarchy
Section titled “Token hierarchy”githosted has two token types:
| Prefix | Scope | Use case |
|---|---|---|
gw_ | Workspace-wide | Admin scripts, provisioning, full access |
gr_ | Specific repos | CI jobs, agents, read-only dashboards |
A workspace token can create repo tokens. A repo token can only access repos in its allowlist.
Issuing a repo token for a CI job
Section titled “Issuing a repo token for a CI job”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}CI job using a scoped token
Section titled “CI job using a scoped token”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 orchestratorconst 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);});Orchestrator pattern: GitHub Actions
Section titled “Orchestrator pattern: GitHub Actions”A wrapper action that provisions a scoped token and passes it to the job step:
name: Deployon: 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.tsRead-only token for dashboards
Section titled “Read-only token for dashboards”For monitoring dashboards that only need to read state:
import { Client } from "@githosted/sdk";
// A repo token with read-only permissionsconst 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;}Key points
Section titled “Key points”- 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
ttlHoursto match your job timeout (e.g.,1). Tokens that auto-expire do not need manual cleanup. - Combine with
expectedHeadfor safe concurrent deploys. If two CI jobs race to update the same config, the loser getsStaleHeadErrorinstead of silently overwriting.