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 os
from githosted import Client
admin = Client(token=os.environ["GITHOSTED_ADMIN_TOKEN"]) # workspace token (gw_)
def issue_job_token(repo_slug: str) -> str:
"""Create a short-lived token scoped to a single repo."""
result = admin.create_token(
f"ci-{int(time.time())}",
kind="repo",
permission="write",
repo_allowlist=[repo_slug],
ttl_hours=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 json
import os
from githosted import Client
# CI environment variables set by the orchestrator
repo_slug = os.environ["REPO_SLUG"] # e.g. "deploy-configs"
job_token = os.environ["JOB_TOKEN"] # gr_... scoped to this repo
def ci_job():
client = Client(token=job_token)
repo = client.repo(repo_slug)
# Read the current deployment config
config_file = repo.read("environments/production.json")
config = json.loads(config_file.content)
# Update the image tag
config["image"] = os.environ["NEW_IMAGE_TAG"]
# Write with expected_head so we don't clobber a concurrent deploy
repo.write(
"environments/production.json",
json.dumps(config, indent=2),
f"Deploy {os.environ['NEW_IMAGE_TAG']}",
expected_head=config_file.head_sha,
)
print("Deployment config updated")
if __name__ == "__main__":
ci_job()

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: python scripts/update_deploy_config.py

For monitoring dashboards that only need to read state:

import json
import os
from githosted import Client
# A repo token with read-only permissions
client = Client(token=os.environ["DASHBOARD_TOKEN"]) # gr_... read-only
def fetch_deployment_status():
repo = client.repo("deploy-configs")
envs = repo.ls("environments")
statuses = []
for e in envs:
if e.type == "file":
file = repo.read(f"environments/{e.name}")
config = json.loads(file.content)
statuses.append(
{
"environment": e.name.replace(".json", ""),
"image": config["image"],
"updated_at": file.head_sha,
}
)
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 ttl_hours to match your job timeout (e.g., 1). Tokens that auto-expire do not need manual cleanup.
  • Combine with expected_head for safe concurrent deploys. If two CI jobs race to update the same config, the loser gets StaleHeadError instead of silently overwriting.