Hangar · Portability plan · October 15, 2026 · Proposal, nothing built
Any forge, any registry
A plan to run Hangar on GitLab and Bitbucket as well as GitHub, and to publish images to any OCI registry as well as GHCR, without changing what Hangar is.
The engine is already neutral. Three things are not.
Tekton, Pipelines as Code, Argo CD, External Secrets, Crossplane and cosign all work with GitLab and Bitbucket today.
GitHub is wired into Hangar in about a hundred small places (a literal owner, a curl to
api.github.com, a hardcoded link), and in three places it is part of the design:
- The control plane writes to repositories through provider-upjet-github. 81 templates in 9 compositions, 88 live resources on the dev cluster. The GitLab provider has no file resource and no maintained Bitbucket Cloud provider exists, and this provider caused the September data loss and rate-limit storms.
- CI tokens are GitHub App installation tokens. The never-persisted credential model is built on a GitHub feature that GitLab and Bitbucket do not have in the same form.
- An app is identified by
github.com/project-slug. Tower authorization (ACC-05), release approvers and the catalog app list all key on it, and it assumes a one-segment owner that GitLab subgroups break.
Recommendation. Introduce two Hangar-owned contracts, one for the forge and one for the registry, with each vendor as a driver behind one of them. Move the existing GitHub path onto the contracts first, with no visible change (phases 0 to 2, which also removes the RepositoryFile bug class on GitHub). Then add GitLab as the proof (phase 3) and Bitbucket after it (phase 4). The forge and the registry become separate choices, because a Bitbucket shop has no registry of its own.
jfillman is a literalWhat is coupled today
Where GitHub is load-bearing today
| Where | Coupling | Evidence | Kind |
|---|---|---|---|
| Airframe | Repos and files are repo.github.m.upbound.io Repository and RepositoryFile resources | compositions/*/templates/render-github-resources/, 9 compositions | load-bearing |
| Airframe | Owner literal; repo URLs and the Go module path built as github.com/<owner>/<app> | $platformOwner := "jfillman", _shared/render-github-resources/tekton-cicd-child.yaml, goapplication | mechanical |
| Airframe | Container App registry defaults to ghcr.io | xrds/azurecontainerapptarget.yaml | mechanical |
| Glidepath | 13 Tasks, 1 StepAction and the preview notify Job call api.github.com with curl: pulls, issue comments, check runs, contents, compare, commit to PR, update-branch, reviews | open-release-pr, comment-pr-check-result, deliver-onboarding-files, verify-commit-signature and 11 more | mechanical, wide |
| Glidepath | Broker mints GitHub App installation tokens for one repo | token-review-interceptor /github-installation-token, catalog/lib/github-app.sh | load-bearing |
| Glidepath | Image path derived from the GitHub owner | deliver-onboarding-files.yaml: image_repo="ghcr.io/${GITHUB_OWNER}/${APP_NAME}"; previews ghcr.io/<owner>/<app>-pr | mechanical |
| Glidepath | Merge gate is a GitHub ruleset that trusts checks from the PaC app (integration_id 4461953) | docs/admin/github-ruleset.md, hack/apply-guardrail-ruleset.sh | policy |
| Tower | App identity is github.com/project-slug, parsed with split('/') | towerAuthz.ts, releaseApprovers.ts, catalogApps.ts, TowerPage.tsx | load-bearing |
| Tower | Image client accepts only GHCR; prune and the Images tab use the GitHub Packages API | ghcr.ts ("only ghcr.io images are supported"), imagePrune.ts, classifyGhcrVersion | mechanical |
| Tower | Commit, author and file links hardcoded to https://github.com/ | activityRowRenderers.tsx, useEnvLifecycle.ts, ownChart.ts and 5 more | mechanical |
| Backstage | GitHub OAuth sign-in; only integrations.github; ingestor publishPhase.target: github | app-config.production.yaml, app-config.yaml | policy |
| Apron | Argo CD repo creds are GitHub App creds for the https://github.com/jfillman prefix; ApplicationSet allows only https://api.github.com | 01-argocd-platform/repo-creds/, argocd-apps-install/application.yaml | mechanical |
| Autopilot | Agent images on ghcr.io/jfillman | agents/*.yaml | mechanical |
Already neutral and staying as is: Tekton and Pipelines as Code (GitLab, Bitbucket Cloud and Data Center are supported
providers), Argo CD (any git host; PR generators for GitLab and Bitbucket), the registry-credentials
ClusterExternalSecret (a plain dockerconfigjson), cosign and Rekor, Apron's scm.host and scm.owner
in cluster.yaml, and the terasky ingestor, whose publish step already supports gitlab,
bitbucket and bitbucketcloud targets.
Principles
Separate the forge from the registry
Choose them independently. The image path comes from registry config, never from the forge owner.
Hangar names things
scm.hangar.io resources, hangar.io/app identity, an scm CLI. Vendors are drivers.
GitHub moves first
Nothing reaches GitLab until the GitHub path runs on the contracts with no change a user can see.
Capabilities, not the lowest common denominator
A driver declares what it can enforce. Tower shows the real level per app.
Write the credential delta down
Every forge gets a row in the credentials table. Weaker is allowed; silent is not.
Adopt, never recreate
Every migration step observes existing repos by name. No step may commit or delete.
Target architecture
Two contracts, many drivers
The SCM contract has two faces over one Go library:
- Declarative:
provider-hangar-scm, a native Crossplane provider servingscm.hangar.io/v1alpha1Repository,RepositoryFile,WebhookandMergeGate. A ProviderConfig per forge instance (github,gitlab) picks the driver, so each composition keeps one shape and only itsproviderConfigRefchanges.RepositoryFile.spec.modeisseed(create once if absent, never update or delete: today's scaffold rule, written into the API instead of into management policies) ormanaged(the platform-owned identity files in tenants repos). - Imperative: an
scmCLI in the Glidepath toolbox image (scm pr open|find|comment,scm status,scm contents get,scm compare,scm pr-for-commit,scm gate apply) that replaces the inlinecurl, and a broker endpoint/scm-tokenbeside today's/github-installation-token. - Tower gets a TypeScript
ScmClientandRegistryClientin the Backstage backend, with drivers built on@backstage/integration, which already resolves credentials for GitLab, Bitbucket Cloud and Bitbucket Server. It runs the same recorded conformance fixtures as the Go library.
The registry contract is mostly plain OCI. Push, pull, digest, tags, referrers and cosign signatures work against any
compliant registry with the standard bearer-token challenge, so the GHCR-specific token exchange in ghcr.ts goes. Only
listing versions with dates and deleting them are vendor APIs, so only those need drivers. Config is
registry.host and registry.namespace; the image path is <host>/<namespace>/<app>
and previews add -pr.
Identity. Add hangar.io/app (the app's Hangar name), hangar.io/forge and
hangar.io/source-location. Tower authorization reads hangar.io/app; GitHub apps keep
github.com/project-slug for stock Backstage plugins. A repo's namespace may have several segments (GitLab subgroups,
a Bitbucket workspace and project).
Sign-in. Move Backstage from GitHub OAuth to OIDC through Dex, which already runs in the cluster and has GitHub, GitLab and Bitbucket Cloud connectors (LDAP or SAML for Data Center shops). Users still match catalog Users. This also moves the architecture review's SSO gate forward.
Decision: how the control plane writes to a forge
| Option | For | Against |
|---|---|---|
| A. A vendor provider per forge | No new code to own for GitHub and GitLab. | provider-gitlab has Project, Hook, ProtectedBranch and AccessToken but no file resource. No maintained Bitbucket Cloud provider. Three composition variants per app type. Keeps the known RepositoryFile bugs. |
| B. Move file writes into Glidepath | No reconciler writing to repos at all. | Loses continuous reconcile for the tenants-repo identity files (Create and Update today), and Airframe would need Glidepath to exist before it can scaffold. |
| recommended C. provider-hangar-scm | One behaviour on every forge. Compositions change only the apiVersion and providerConfigRef. Treats only a 404 as gone, keeps the observed external-name, never writes on adopt: the three provider bugs, fixed by design. | Hangar owns a provider (Go, crossplane-runtime), about 4 weeks with the GitHub driver and tests. Offset by retiring provider-upjet-github, whose upstream fixes are still unpublished. |
The SCM contract, operation by operation
| Operation | Caller | GitHub | GitLab | Bitbucket Cloud | Bitbucket DC |
|---|---|---|---|---|---|
| Repository | Airframe | repos | projects | repositories | projects/repos |
| Seed or manage a file | Airframe | contents PUT | commits with actions | src POST | browse PUT |
| Webhook | Airframe | none (app-level) | project hook | repo webhook | repo webhook |
| Merge gate | Apron, Glidepath | ruleset, checks by app | protected branch, pipelines must succeed, approval rules | branch restrictions, merge checks | branch permissions, required builds |
| Open, find, update PR | Glidepath, Tower | pulls | merge_requests | pullrequests | pull-requests |
| Upsert a comment | Glidepath | issue comments | MR notes | PR comments | PR comments |
| Status per gate | PaC, Glidepath | check runs | commit statuses | build statuses | build statuses |
| Read a file at a ref | Glidepath, Tower | contents | files raw | src | raw |
| Compare, PR for a commit | Glidepath, Tower | compare, commits/pulls | compare, commits/merge_requests | diffstat, commit/pullrequests | compare |
| Short-lived token | Broker | App installation, 1 h | project access token, by date | OAuth client credentials, 2 h | repo HTTP token, by days |
Resource names are the shape to build against. Check each endpoint against the vendor's API reference when its driver is written; the Bitbucket Data Center column in particular is unverified.
The registry contract
| Capability | Standard | GHCR | GitLab CR | Harbor | ECR |
|---|---|---|---|---|---|
| Push, pull, digest, tags | OCI Distribution | same code, every registry | |||
| Auth | bearer challenge | PAT for a token | deploy or CI token for a JWT | robot account | 12 h login token |
| cosign signatures | tag or referrers | tag | tag | tag and referrers | tag and referrers |
| List versions with dates | vendor | GitHub Packages API | registry API | Harbor API | DescribeImages |
| Delete | vendor | Packages API, classic PAT | registry API | Harbor API | BatchDeleteImage |
| Retention | vendor | none; Tower prunes | cleanup policies | retention rules | lifecycle policies |
Bitbucket has no container registry, so a Bitbucket fleet must pick one. Harbor is the recommended neutral default: self-hosted, robot accounts, cosign and referrers, replication, and Trivy, the scanner Glidepath already runs.
Credentials, forge by forge
A short-lived token on every forge
| Path | GitHub | GitLab | Bitbucket Cloud | Bitbucket DC |
|---|---|---|---|---|
| CI write token | App installation, 1 h, one repo | project access token, expires next day, one project, revoked when the run ends | OAuth client credentials, 2 h, whole workspace | repo HTTP token, 1 day, one repo |
| PaC trigger and status | GitHub App, nothing stored | webhook secret plus a token in a Secret named by the Repository CR; the broker rotates it daily and Tower shows its age | ||
| Control plane | GitHub App (ends the shared PAT bucket) | group access token | workspace access token | project HTTP token |
| Argo CD, read only | GitHub App repo creds | group deploy token | workspace access token | project HTTP token, read |
| Tower backend | GitHub App (a PAT today) | group access token | workspace access token | HTTP token |
Bitbucket Cloud removed app passwords on July 28, 2026; use API tokens or access tokens. Plan tiers change what can be enforced (GitLab approval rules and group webhooks, Bitbucket Cloud merge-check enforcement); check current vendor pricing before you choose a plan for the lab.
The proof: one release on GitLab
One release, start to finish, on GitLab
What changes, product by product
Airframe
- Every
repo.github.m.upbound.iotemplate becomesscm.hangar.io; the symlinked_sharedpartials keep the diff small. - Owner, host and registry come from the cluster registry entry (already read through ExtraResources), never a literal.
- Optional
spec.source(forge, namespace) on app XRDs, defaulted from platform config; CEL checks the namespace shape per forge. - A
Webhookper repo on forges without app-level webhooks; the Go module path follows the source host.
Glidepath
- The
scmCLI replacescurl, one Task per PR, each with fixtures. ADR-0026: the SCM contract. platformIdentity.githubOwnerbecomesplatformIdentity.source;image-repocomes from registry config.- The broker gains
/scm-tokenand the PaC secret rotator; PaC Repository CRs gaingit_provideroff GitHub. github-ruleset.mdbecomesmerge-gate.md;scm gate applyrenders it fromreleaseGuardrails.
Tower and Backstage
ScmClientandRegistryClient; today's GitHub code becomes the first driver, unchanged.- Authorization and approvers read
hangar.io/app; links come fromhangar.io/source-location. - Integrations, scaffolder publish actions and the ingestor publish target per forge; sign-in through Dex.
- Each app shows its forge and the merge-gate level the driver reports.
Apron, Autopilot, Hangar
cluster.yamlgainsscm.forge,scm.namespaceandregistry.*; the script renders repo creds, the PaC secret and the allowed SCM providers per forge.- provider-github is removed after phase 2.
- Agent images come from registry config.
- Business rules PPL-01, PPL-02, ACC-05, GATE-03 and GATE-06 are reworded per forge when each lands (
docs/policy/business-rules.md).
Moving the GitHub fleet first
Moving 88 live resources without recreating a repo
Roadmap
Twenty-four weeks, GitHub first, then GitLab, then Bitbucket
Seams, GitHub only. No api.github.com or ghcr.io literal outside a driver, enforced by a CI ratchet; no owner literal in compositions; Tower reads hangar.io/app.
Exit, live: a Skyport release and rollback behave exactly as before; GitHub calls per hour at or below the baseline.
Registry independence. Image path from config; a second registry lane.
Exit, live: one Skyport app builds, signs, passes the provenance gate, deploys, lists and prunes from a non-GHCR registry.
provider-hangar-scm. GitHub driver, then adopt all 88 live resources and uninstall provider-upjet-github.
Exit, live: zero commits during adoption; an injected 403 rate limit neither deletes nor rewrites a file.
GitLab end to end. Drivers, broker, PaC, Argo CD, Backstage, Dex.
Exit, live: figure 4 runs with a rollback; the merge gate refuses a red gate; broker tokens are revoked after each run.
Bitbucket. Cloud first, then Data Center, with Harbor.
Exit, live: the phase 3 test on Bitbucket Cloud.
Mixed fleet. Per-app forge, rate budgets per forge in Grafana, docs, business rules, scorecard.
Exit, live: one fleet with apps on two forges.
Decisions for you
How the control plane writes to a forge
Recommend option C, provider-hangar-scm, in Go. The Python preference for composition functions does not apply to a provider.
Order of forges
GitLab, then Bitbucket Cloud, then Data Center. GitLab has the closest feature set and its own registry; Data Center needs a licensed server for the lab.
Where the GitLab lab lives
A gitlab.com Free group. The prod cluster has no CPU to spare for self-managed GitLab. Free has no required approvals, so the two-person rule stays "Off here", as on GitHub Free today.
The second registry
GitLab Container Registry for the GitLab lane (no infrastructure), Harbor as the documented neutral default, run in CI.
Accept the credential differences
Accept, with revoke-at-end and daily rotation, and Tower showing token age. Bitbucket Cloud's workspace-wide token is the one to watch.
One forge per fleet, or per app
Fleet default through phase 4; the per-app field is in the schema from phase 0 but enabled in phase 5.
A name for the SCM component
Yours to choose, in the aviation vocabulary. Working name: hangar-scm.
Risks
- Rate limits. Bitbucket Cloud's hourly API budget is far smaller than GitHub's, and the old fleet once burned 16,000 calls an hour in a loop. Seed files are never re-read after the scaffold ledger marks them done; every driver uses conditional reads where the forge supports them; a per-forge budget metric trips a breaker.
- Nested namespaces break every
owner/repoparse: Tower'ssplit('/'), the broker's name match inverifyAppOwnsRepo, Glidepath'sgit-urlhandling. Phase 0 fixes them on GitHub. - Webhooks per repo mean more objects and more secrets; each is an
scm.hangar.ioWebhook with its own rotated secret. - Test matrix. Four forges and four registries. Recorded conformance fixtures run on every PR; one live lane per forge runs nightly.
- Plan tiers change what is enforced. Tower reports the level the driver measured, never an assumed one.
- Owning a provider is code to maintain, set against retiring one that needed a fleet-wide pause.
What I checked
- origin/main of airframe, glidepath, tower, backstage, apron and hangar, and autopilot's main; counts from
git grepover deployable files (compositions, not their example output). - The live dev cluster, read only: 24 Repository and 64 RepositoryFile resources from provider-upjet-github, none paused; none on the prod cluster.
- The installed terasky ingestor source: its publish step handles
github,gitlab,bitbucket(Server) andbitbucketcloud. - Vendor sources: Pipelines as Code providers (Tekton announcement, March 2026), the PaC GitLab token Secret (Red Hat OpenShift Pipelines 1.20), provider-gitlab's resource list (no file resource), Bitbucket Cloud's app password removal (Atlassian).
- Not verified: plan-tier features per vendor, Bitbucket rate limits and Data Center endpoints, GitLab conditional-read coverage. Nothing was built or changed on any cluster.