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.


Create a fine-grained Personal Access Token (or organisation-level service account PAT) with:

PermissionScope
Repository accesscalab-ai/gh-calab only
ContentsRead

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-calab is 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")
          PY

Limitations: 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 --check

The full working example is in .github/workflows/apm-validate.yml in this repository.


Decision guide

SituationRecommended pattern
Can provision a fine-grained PATA (full gh calab support)
Cannot provision a PAT, pure-shell extension is sufficientB (clone + PATH)
Only need manifest/lockfile/file hash integrityC (no PAT, no install)
Want maximum coverage with a fallbackA + C combined