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.

Scope: Airframe, Glidepath, Tower and Backstage, Apron, AutopilotBasis: origin/main of every repo, the live dev clusterStatus: proposal, for decision
Feasible · 6 phases · about 24 weeks

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:

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.

81GitHub resource templates in Airframe compositions
88of them live on the dev cluster today (24 repos, 64 files)
15Glidepath call sites with inline GitHub REST
24Backstage backend files calling the GitHub API
10places the owner jfillman is a literal

What is coupled today

Where GitHub is load-bearing today

Where GitHub is load-bearing todayFour product zones, each calling GitHub.com and GHCR. Airframe composes 81 provider-upjet-github managed-resource templates, Repository and RepositoryFile, 88 of them live, highlighted as load-bearing, and has the owner jfillman as a literal in ten templates. Glidepath has fifteen places, thirteen catalog Tasks, a StepAction and a notify Job, calling the GitHub REST API for pull requests, comments, check runs and file contents, and a broker that mints one-repo GitHub App tokens valid for an hour. Tower and Backstage identify an app by its github.com/project-slug annotation, highlighted as load-bearing, and have an image client that refuses any registry but GHCR. Apron and Argo CD hold repository credentials as GitHub App credentials matched by the github.com/jfillman URL prefix, and the merge gate is a GitHub ruleset that trusts only the Pipelines as Code app integration id. All four share one vendor, one rate-limit bucket, one identity and one plan tier.MRS + URLSREST + APPREST + OAUTHGIT + APPAIRFRAME81 GitHub MR templates88 live · Repository · FileOwner is a literaljfillman in 10 templatesGLIDEPATH15 places call GitHubPRs · comments · checks · filesGitHub App brokerone-repo tokens, 1 hourTOWER + BACKSTAGEApp id = GitHub sluggithub.com/project-slugGHCR-only image clientother registries refusedAPRON + ARGO CDRepo creds by URLApp creds · github.com/jfillmanRuleset is the gatePaC app integration_idGitHub.com and GHCRone vendor · one rate-limit bucket · one identity · one plan tierTekton, Argo CD, ESO and cosign were never the problem. Three things are.LEGENDLoad-bearingA credential pathA literal or a driverThe vendorCalls
Figure 1 · Today GitHub is mostly an accident of where Hangar was built: a literal here, a curl there. In three places it is part of the design: the Crossplane resources that write to repositories, the GitHub App that mints CI tokens, and the annotation Tower uses to decide who owns an app.
WhereCouplingEvidenceKind
AirframeRepos and files are repo.github.m.upbound.io Repository and RepositoryFile resourcescompositions/*/templates/render-github-resources/, 9 compositionsload-bearing
AirframeOwner literal; repo URLs and the Go module path built as github.com/<owner>/<app>$platformOwner := "jfillman", _shared/render-github-resources/tekton-cicd-child.yaml, goapplicationmechanical
AirframeContainer App registry defaults to ghcr.ioxrds/azurecontainerapptarget.yamlmechanical
Glidepath13 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, reviewsopen-release-pr, comment-pr-check-result, deliver-onboarding-files, verify-commit-signature and 11 moremechanical, wide
GlidepathBroker mints GitHub App installation tokens for one repotoken-review-interceptor /github-installation-token, catalog/lib/github-app.shload-bearing
GlidepathImage path derived from the GitHub ownerdeliver-onboarding-files.yaml: image_repo="ghcr.io/${GITHUB_OWNER}/${APP_NAME}"; previews ghcr.io/<owner>/<app>-prmechanical
GlidepathMerge gate is a GitHub ruleset that trusts checks from the PaC app (integration_id 4461953)docs/admin/github-ruleset.md, hack/apply-guardrail-ruleset.shpolicy
TowerApp identity is github.com/project-slug, parsed with split('/')towerAuthz.ts, releaseApprovers.ts, catalogApps.ts, TowerPage.tsxload-bearing
TowerImage client accepts only GHCR; prune and the Images tab use the GitHub Packages APIghcr.ts ("only ghcr.io images are supported"), imagePrune.ts, classifyGhcrVersionmechanical
TowerCommit, author and file links hardcoded to https://github.com/activityRowRenderers.tsx, useEnvLifecycle.ts, ownChart.ts and 5 moremechanical
BackstageGitHub OAuth sign-in; only integrations.github; ingestor publishPhase.target: githubapp-config.production.yaml, app-config.yamlpolicy
ApronArgo CD repo creds are GitHub App creds for the https://github.com/jfillman prefix; ApplicationSet allows only https://api.github.com01-argocd-platform/repo-creds/, argocd-apps-install/application.yamlmechanical
AutopilotAgent images on ghcr.io/jfillmanagents/*.yamlmechanical

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

Two contracts, many driversThree callers, Airframe compositions (repositories, seed files and webhooks), Glidepath tasks (pull requests, statuses and image pushes) and the Tower backend (pull requests, links, images and pruning), call Hangar contracts, never a vendor API. The SCM contract covers repositories, files, hooks, the merge gate, pull requests, statuses and tokens; the registry contract covers OCI push, pull, digest, referrers, list and delete. Each contract has a set of drivers. Forge drivers: GitHub, built today, then GitLab for gitlab.com and self-managed in phase 3, and Bitbucket Cloud and Bitbucket Data Center in phase 4. Registry drivers: GHCR, built today, then the GitLab Container Registry and Harbor, the neutral default, in phase 1, and ECR, ACR and Artifactory on demand.CONTRACT ONLYWHO CALLSHANGAR CONTRACTSAirframe compositionsrepos · seed files · hooksGlidepath tasksPRs · statuses · image pushTower backendPRs · links · images · pruneSCM contractrepos · files · hooks · merge gate · PRs · statuses · tokensRegistry contractOCI push · pull · digest · referrers · list · deleteFORGEForge driversGitHubbuilt today · driver 1GitLab · .com and self-managedphase 3Bitbucket Cloudphase 4Bitbucket Data Centerphase 4REGISTRYRegistry driversGHCRbuilt todayGitLab Container Registryphase 1Harborphase 1 · neutral defaultECR · ACR · Artifactoryby demandTwo choices, made separately. A Bitbucket shop still needs a registry.LEGENDCallerHangar-owned contractDriver setPlanned driver
Figure 2 · The shape The forge and the registry are two independent choices, and today they are welded together: the image path is ghcr.io/<GitHub owner>/<app> and the pull credential is a GitHub token. Hangar owns the two contracts; each vendor is a driver behind one of them.

The SCM contract has two faces over one 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

OptionForAgainst
A. A vendor provider per forgeNo 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 GlidepathNo 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

OperationCallerGitHubGitLabBitbucket CloudBitbucket DC
RepositoryAirframereposprojectsrepositoriesprojects/repos
Seed or manage a fileAirframecontents PUTcommits with actionssrc POSTbrowse PUT
WebhookAirframenone (app-level)project hookrepo webhookrepo webhook
Merge gateApron, Glidepathruleset, checks by appprotected branch, pipelines must succeed, approval rulesbranch restrictions, merge checksbranch permissions, required builds
Open, find, update PRGlidepath, Towerpullsmerge_requestspullrequestspull-requests
Upsert a commentGlidepathissue commentsMR notesPR commentsPR comments
Status per gatePaC, Glidepathcheck runscommit statusesbuild statusesbuild statuses
Read a file at a refGlidepath, Towercontentsfiles rawsrcraw
Compare, PR for a commitGlidepath, Towercompare, commits/pullscompare, commits/merge_requestsdiffstat, commit/pullrequestscompare
Short-lived tokenBrokerApp installation, 1 hproject access token, by dateOAuth client credentials, 2 hrepo 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

CapabilityStandardGHCRGitLab CRHarborECR
Push, pull, digest, tagsOCI Distributionsame code, every registry
Authbearer challengePAT for a tokendeploy or CI token for a JWTrobot account12 h login token
cosign signaturestag or referrerstagtagtag and referrerstag and referrers
List versions with datesvendorGitHub Packages APIregistry APIHarbor APIDescribeImages
DeletevendorPackages API, classic PATregistry APIHarbor APIBatchDeleteImage
Retentionvendornone; Tower prunescleanup policiesretention ruleslifecycle 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

A short-lived token on every forgeA pipeline pod sends its projected ServiceAccount token, audience scm, to the scm broker in platform-system, which checks it with a TokenReview and checks the caller owns a Repository CR for the repo. The broker signs with a forge credential that never leaves platform-system: a GitHub App key, a GitLab group token or a Bitbucket OAuth secret. It mints a short-lived token, which differs per forge: a GitHub installation token for one repo for an hour, built today; planned, a GitLab project access token for one day, a Bitbucket Cloud OAuth token for two hours that covers the whole workspace, and a Bitbucket Data Center HTTP token for one repo for a day. The broker also rotates, daily, the provider secret Pipelines as Code needs on GitLab and Bitbucket.PLATFORM-SYSTEMREQUESTMINTSSIGNS WITHROTATES DAILYPipeline podprojected SA token, aud scmscm brokerTokenReview + Repository CR checkForge credentialApp key · group token · OAuth secretPaC provider secretGitLab and Bitbucket onlyPER FORGEWhat comes backGitHubinstallation · 1h · one repoGitLabproject token · 1 dayBitbucket CloudOAuth · 2h · workspaceBitbucket DCHTTP token · 1 dayThe weakest forge sets the floor. Write the difference down, never hide it.LEGENDCallerBrokerCredentialShort-lived tokenRotation
Figure 3 · Credentials Hangar's rule is that a pipeline never holds a standing credential: it proves who it is and gets a token for one repository. The broker keeps that rule on every forge. What it cannot keep is the lifetime and the scope, and those differ.
PathGitHubGitLabBitbucket CloudBitbucket DC
CI write tokenApp installation, 1 h, one repoproject access token, expires next day, one project, revoked when the run endsOAuth client credentials, 2 h, whole workspacerepo HTTP token, 1 day, one repo
PaC trigger and statusGitHub App, nothing storedwebhook secret plus a token in a Secret named by the Repository CR; the broker rotates it daily and Tower shows its age
Control planeGitHub App (ends the shared PAT bucket)group access tokenworkspace access tokenproject HTTP token
Argo CD, read onlyGitHub App repo credsgroup deploy tokenworkspace access tokenproject HTTP token, read
Tower backendGitHub App (a PAT today)group access tokenworkspace access tokenHTTP 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

One release, start to finish, on GitLabSequence diagram across five lifelines: developer, GitLab, Glidepath, registry and Argo CD. The developer pushes and opens a merge request; GitLab sends a project webhook to Glidepath; Glidepath pushes the image and its signature to the registry and posts a commit status back. The developer merges; GitLab sends a tag event; Glidepath opens a release merge request on the gitops repo; GitLab sends the merge request event and Glidepath posts a status per gate. The developer approves and merges through the protected branch, the merge gate, highlighted. Argo CD pulls the gitops repo and pulls the image by digest from the registry.PUSH + MRWEBHOOKIMAGE + SIGSTATUSMERGETAG EVENTRELEASE MRMR EVENTGATE STATUSESAPPROVE + MERGEPULL GITOPS REPOPULL BY DIGESTDevelopersigned commitsGitLabapp + gitops-<app>GlidepathPaC + Tekton + brokerRegistryGitLab CR or HarborArgo CDthe target clusterLEGENDEvent or callStatus or pullThe merge gateUnchanged engine
Figure 4 · The proof This is the acceptance test for phase 3: a new Skyport service, scaffolded from Tower, built, signed, released to a Flight environment and rolled back, with no GitHub anywhere in the path. Every step already exists on GitHub; the only new parts are the drivers.

What changes, product by product

Airframe

  1. Every repo.github.m.upbound.io template becomes scm.hangar.io; the symlinked _shared partials keep the diff small.
  2. Owner, host and registry come from the cluster registry entry (already read through ExtraResources), never a literal.
  3. Optional spec.source (forge, namespace) on app XRDs, defaulted from platform config; CEL checks the namespace shape per forge.
  4. A Webhook per repo on forges without app-level webhooks; the Go module path follows the source host.

Glidepath

  1. The scm CLI replaces curl, one Task per PR, each with fixtures. ADR-0026: the SCM contract.
  2. platformIdentity.githubOwner becomes platformIdentity.source; image-repo comes from registry config.
  3. The broker gains /scm-token and the PaC secret rotator; PaC Repository CRs gain git_provider off GitHub.
  4. github-ruleset.md becomes merge-gate.md; scm gate apply renders it from releaseGuardrails.

Tower and Backstage

  1. ScmClient and RegistryClient; today's GitHub code becomes the first driver, unchanged.
  2. Authorization and approvers read hangar.io/app; links come from hangar.io/source-location.
  3. Integrations, scaffolder publish actions and the ingestor publish target per forge; sign-in through Dex.
  4. Each app shows its forge and the merge-gate level the driver reports.

Apron, Autopilot, Hangar

  1. cluster.yaml gains scm.forge, scm.namespace and registry.*; the script renders repo creds, the PaC secret and the allowed SCM providers per forge.
  2. provider-github is removed after phase 2.
  3. Agent images come from registry config.
  4. 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

Moving 88 live resources without recreating a repoFive steps in a row. Step 1, freeze the old: every provider-upjet-github managed resource goes to Observe-only with an Orphan deletion policy. Step 2, adopt by name, highlighted: a new scm.hangar.io resource with the same external-name observes the existing repository or file. Step 3, canary one app, highlighted: one application moves to a copied Composition. Step 4, cut over: flip the apiVersion in every composition, tag and pin. Step 5, retire the old: delete the orphaned resources and remove the provider. Two exit checks are measured throughout: zero commits to any repository across steps 1 to 3, checked by diffing every repo, and GitHub API calls at or below today, sampled from the rate-limit endpoint every 30 seconds.STEP 1Freeze the oldObserve · OrphanSTEP 2Adopt by namesame external-nameSTEP 3Canary one appcopied CompositionSTEP 4Cut overflip apiVersion, pinSTEP 5Retire the oldremove the providerWHAT MUST STAY TRUE, MEASUREDZero commits to any repositorydiff every repo across steps 1 to 3API calls at or below todaysample /rate_limit every 30 secondsAdopt, never recreate. A cluster can be rebuilt; a repository's history cannot.LEGENDWhere it can go wrongMechanicalExit check
Figure 5 · Migration The GitHub path has to move onto the new contract before GitLab can use it, and the old provider has already destroyed real files twice. So the move is adoption by name, one app first, and every step is measured against the repositories themselves.

Roadmap

Twenty-four weeks, GitHub first, then GitLab, then Bitbucket

Twenty-four weeks, GitHub first, then GitLab, then BitbucketGantt chart of six phases over twenty-four weeks. P0, weeks 1 to 4, seams on GitHub only: config instead of the jfillman literal, the scm CLI replacing inline curl, and Tower's ScmClient with a new identity annotation. P1, weeks 4 to 6, registry independence: the image repository from config and a Harbor or GitLab Container Registry lane. P2, weeks 6 to 10, provider-hangar-scm: the provider with its GitHub driver, then adopting all 88 live managed resources and dropping the upjet provider, an exit test. P3, weeks 10 to 15, GitLab end to end: drivers and broker, then Pipelines as Code, Argo CD, Backstage and Dex, then a Skyport app on GitLab, an exit test. P4, weeks 16 to 21, Bitbucket Cloud then Bitbucket Data Center. P5, weeks 21 to 24, mixed fleet: per-app forge choice, rate budgets, docs, business rules and the scorecard.123456789101112131415161718192021222324WEEKP0P1P2P3P4P5P0 · SEAMS, GITHUB ONLYConfig, not jfillmanscm CLI replaces curlTower ScmClient, new annotationP1 · REGISTRY INDEPENDENCEImage repo from configHarbor or GitLab CR laneP2 · PROVIDER-HANGAR-SCMProvider, GitHub driverAdopt 88 MRs, drop upjetP3 · GITLAB END TO ENDGitLab drivers + brokerPaC, Argo, Backstage, DexSkyport app on GitLabP4 · BITBUCKETBitbucket CloudBitbucket Data CenterP5 · MIXED FLEETPer-app forge, rate budgetsDocs, rules, scorecardLEGENDExit test, verified liveWorkstreamPhase exit
Figure 6 · Roadmap Durations are estimates for one person; the order is the point. Nothing reaches GitLab until the GitHub path runs on the new contracts with no change anyone can see, so the risky migration happens on the forge where Hangar already has live traffic to measure.
P0

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.

P1

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.

P2

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.

P3

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.

P4

Bitbucket. Cloud first, then Data Center, with Harbor.

Exit, live: the phase 3 test on Bitbucket Cloud.

P5

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

D1

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.

D2

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.

D3

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.

D4

The second registry

GitLab Container Registry for the GitLab lane (no infrastructure), Harbor as the documented neutral default, run in CI.

D5

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.

D6

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.

D7

A name for the SCM component

Yours to choose, in the aviation vocabulary. Working name: hangar-scm.

Risks

What I checked