Using gh-calab in CI for Internal and Private Repos
See also: Installing APM Packages and Primitives for the standard local developer install.
calab-ai/gh-calab is an INTERNAL visibility GitHub repository.
The default GITHUB_TOKEN that GitHub Actions mints for a consumer repo
does not have Contents: Read access to repositories outside the
consumer repo itself, even within the same organisation.
This causes the canonical install step to fail:
X Could not find extension 'calab-ai/gh-calab' on host github.com
##[error]Process completed with exit code 1.
This page documents the three canonical patterns to work around this. Choose the one that fits your security posture and use-case.
Why the default token cannot install the extension
When a GitHub Actions workflow runs in a consumer repo, GITHUB_TOKEN
is scoped to that repo only. gh extension install calab-ai/gh-calab
internally performs a git clone of calab-ai/gh-calab, which requires
Contents: Read on the extension repo.
Because gh-calab is INTERNAL, the consumer repo token never has that
permission — the clone fails with a “not found” error even though the
authenticated user can see the repo interactively.
Pattern A — Fine-grained PAT (recommended)
Create a fine-grained Personal Access Token (or organisation-level service account PAT) with:
| Permission | Scope |
|---|---|
| Repository access | calab-ai/gh-calab only |
| Contents | Read |
Store the PAT in the consumer repo (or at org level) as a secret named
GH_TOKEN_GH_CALAB, then override GH_TOKEN only for the install step:
# .github/workflows/apm-validate.yml (Pattern A)
name: apm-validate
on:
pull_request:
paths:
- 'apm.yml'
- 'apm.lock.yaml'
- '.github/copilot/**'
push:
branches: [main]
paths:
- 'apm.yml'
- 'apm.lock.yaml'
- '.github/copilot/**'
workflow_dispatch:
permissions:
contents: read
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install gh-calab extension
run: gh extension install calab-ai/gh-calab
env:
# Use a fine-grained PAT with Contents: Read on calab-ai/gh-calab
GH_TOKEN: ${{ secrets.GH_TOKEN_GH_CALAB }}
- name: Validate APM manifest
run: gh calab apm validate
env:
GH_TOKEN: ${{ github.token }}
- name: Check for lockfile drift
run: gh calab apm sync --check
env:
GH_TOKEN: ${{ secrets.GH_TOKEN_GH_CALAB }}Org-level PAT management
If you maintain many consumer repos, create the PAT once and expose it as
an organisation secret (Settings → Secrets and variables → Actions →
New organisation secret) so every repo picks it up automatically.
Name it consistently — GH_TOKEN_GH_CALAB is the org-wide convention.
Rotation: Fine-grained PATs expire. Set a calendar reminder to rotate before the expiry date and update the organisation secret in one place.
Pattern B — Clone and PATH the extension
If creating or managing a PAT is not feasible, clone gh-calab as a plain
git operation using a token that does have access (e.g. a deploy key
or an org-level classic PAT), then prepend the clone directory to PATH
so gh calab … resolves to the local copy:
# .github/workflows/apm-validate.yml (Pattern B)
name: apm-validate
on:
pull_request:
paths:
- 'apm.yml'
- 'apm.lock.yaml'
workflow_dispatch:
permissions:
contents: read
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Clone gh-calab and add to PATH
run: |
git clone https://x-access-token:${{ secrets.GH_TOKEN_GH_CALAB }}@github.com/calab-ai/gh-calab.git \
"$HOME/.local/gh-extensions/gh-calab"
echo "$HOME/.local/gh-extensions/gh-calab" >> "$GITHUB_PATH"
- name: Validate APM manifest
run: gh calab apm validate
env:
GH_TOKEN: ${{ github.token }}
- name: Check for lockfile drift
run: gh calab apm sync --check
env:
GH_TOKEN: ${{ secrets.GH_TOKEN_GH_CALAB }}Caveats: This pattern works because
gh-calabis a pure-shell extension with no compiled binary. If the extension graduates to a Go binary (see the gh-calab README), you will need Pattern A or a release tarball instead.
Pattern C — Pure-bash integrity check (lightweight, no PAT required)
This is the workaround the APM pilot used.
It replicates the core integrity checks from apm sync --check directly
in bash and Python, using only the files already checked out — no
gh extension install call needed.
Use this pattern when:
- You only need to verify manifest ↔ lockfile ↔ installed-file integrity.
- You cannot provision a PAT at all.
- You want a zero-dependency fast-path for PRs that only touch source files.
# .github/workflows/apm-validate.yml (Pattern C — no PAT)
name: apm-validate
on:
pull_request:
paths:
- 'apm.yml'
- 'apm.lock.yaml'
- '.github/copilot/**'
workflow_dispatch:
permissions:
contents: read
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Both apm.yml and apm.lock.yaml present
run: |
set -euo pipefail
test -f apm.yml || { echo "::error::apm.yml is missing"; exit 1; }
test -f apm.lock.yaml || { echo "::error::apm.lock.yaml missing — run 'gh calab apm resolve'"; exit 1; }
- name: apm.lock.yaml apm_manifest_sha256 matches apm.yml
run: |
set -euo pipefail
ACTUAL_SHA=$(tr -d '\r' < apm.yml | sha256sum | cut -d' ' -f1)
DECLARED_SHA=$(awk -F': *' '/^apm_manifest_sha256:/{gsub(/"/, "", $2); print $2; exit}' apm.lock.yaml)
if [ -z "$DECLARED_SHA" ]; then
echo "::error::apm.lock.yaml is missing apm_manifest_sha256"; exit 1
fi
if [ "$ACTUAL_SHA" != "$DECLARED_SHA" ]; then
echo "::error::apm_manifest_sha256 mismatch — run 'gh calab apm resolve'"
echo "::error::declared=$DECLARED_SHA actual=$ACTUAL_SHA"
exit 1
fi
echo "apm_manifest_sha256 OK: $ACTUAL_SHA"
- name: Every managed file matches its sha256 in apm.lock.yaml
run: |
python3 - <<'PY'
import hashlib, pathlib, sys, yaml
lock = yaml.safe_load(pathlib.Path('apm.lock.yaml').read_text(encoding='utf-8'))
errors = []
checked = 0
for dep in lock.get('resolved', []) or []:
for f in dep.get('files', []) or []:
target = pathlib.Path(f['target'])
expected = f['sha256']
if not target.is_file():
errors.append(f"missing managed file: {target}")
continue
actual = hashlib.sha256(target.read_bytes()).hexdigest()
if actual != expected:
errors.append(
f"hash drift: {target}\n expected={expected}\n actual ={actual}"
)
checked += 1
if errors:
print("::error::APM managed files drifted from apm.lock.yaml:")
for e in errors:
print(f"::error::{e}")
sys.exit(1)
print(f"Verified {checked} managed file(s) against apm.lock.yaml")
PYLimitations: Pattern C does not call
gh calab apm validate(schema and policy checks) or re-download files from the registry. It detects manifest ↔ lockfile SHA drift and installed-file hash drift only. Pair it with Pattern A or B if you need full validation.
Combining patterns
The three patterns are not mutually exclusive.
The calab-handbook pilot workflow combines Pattern C (always runs) with
the Pattern A full check as an opt-in step:
# Always: pure-bash integrity check (Pattern C)
- name: Manifest/lockfile/file integrity
run: |
# ... (see Pattern C steps above) ...
# Optional: full gh calab check when the PAT secret is present (Pattern A)
- name: gh calab apm sync --check (requires CALAB_GH_TOKEN secret)
if: ${{ env.CALAB_GH_TOKEN != '' }}
env:
GH_TOKEN: ${{ secrets.CALAB_GH_TOKEN }}
CALAB_GH_TOKEN: ${{ secrets.CALAB_GH_TOKEN }}
run: |
set -euo pipefail
gh extension install calab-ai/gh-calab
gh calab apm validate
gh calab apm sync --checkThe full working example is in
.github/workflows/apm-validate.yml
in this repository.
Decision guide
| Situation | Recommended pattern |
|---|---|
| Can provision a fine-grained PAT | A (full gh calab support) |
| Cannot provision a PAT, pure-shell extension is sufficient | B (clone + PATH) |
| Only need manifest/lockfile/file hash integrity | C (no PAT, no install) |
| Want maximum coverage with a fallback | A + C combined |