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 osfrom 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 timeCI 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 jsonimport osfrom githosted import Client
# CI environment variables set by the orchestratorrepo_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()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: python scripts/update_deploy_config.pyRead-only token for dashboards
Section titled “Read-only token for dashboards”For monitoring dashboards that only need to read state:
import jsonimport osfrom githosted import Client
# A repo token with read-only permissionsclient = 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 statusesKey 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
ttl_hoursto match your job timeout (e.g.,1). Tokens that auto-expire do not need manual cleanup. - Combine with
expected_headfor safe concurrent deploys. If two CI jobs race to update the same config, the loser getsStaleHeadErrorinstead of silently overwriting.