ADR-063: GitLab Package Registry for versioned cross-repo contract packages¶
| Status | Accepted |
| Date | 2026-05-13 |
| Deciders | CTO, Head of Platform Engineering, Head of Architecture |
| Affects repos | bank-core, bank-kyc, bank-aml, bank-payments, bank-credit, bank-risk-platform, bank-platform, bank-app |
Two separate gaps exist in the current CI build system that are best resolved together:
Gap 1 — build artifacts stored in a custom S3 system.
All four CI templates (lambda, iac, risk-platform, frontend)
contain ~40 lines of custom bash that uploads Lambda zips, dbt manifests,
and DCM output to bank-artefacts-{env} S3 buckets keyed by commit SHA.
A second pass re-tags objects integration-passed=true after smoke tests.
This is hand-rolled artifact versioning that duplicates what GitLab's
Package Registry provides natively — with worse ergonomics and requiring
AWS credentials to browse or download.
Gap 2 — no mechanism for cross-repo TypeScript contract consumption.
ADR-062 introduced contract/ directories. SD06 (bank-risk-platform) has
already created typed event and SSM contracts (MOD-039, MOD-085, etc.) with
comments naming cross-repo consumers in bank-aml, bank-kyc, bank-credit, and
bank-app. Those consumers currently have no compile-time access to those types.
Both gaps resolve to the same answer: GitLab Package Registry.
Decision¶
Use the GitLab Package Registry as the single artifact store for the platform. Replace the custom S3 artifact upload code in all CI templates with native Package Registry uploads. Publish TypeScript contract packages as npm packages in the same registry.
All pipelines currently deploy to the dev stage only (STAGE: dev
hardcoded in every template; DEPLOY_STAGE:-dev default in every module CI
file). The web trigger allows explicit override for future environment
promotion. This is correct and does not change.
What changes and what does not¶
Removed: S3 artifact upload¶
The following sections are removed from all four CI templates:
- Pre-deploy artefact upload — bash that fetches the artefacts bucket
name from SSM and calls
aws s3api put-objectfor Lambda zips, dbt manifests, and DCM output, tagging themintegration-passed=false. - Post-smoke retag — bash that calls
aws s3api put-object-taggingto flip the tag tointegration-passed=true.
The quality gate is replaced by publish-stage presence: a build artifact
does not exist in the Package Registry until the publish stage runs, and
that stage only runs after the deploy job (including smoke tests) succeeds.
Absence means not green. Presence means green.
Removed: IaC sst-outputs.json upload¶
iac.gitlab-ci.yml currently uploads ${MODULE_DIR}/.sst/outputs.json to
S3. This is environment-specific deploy state used for debugging. When
uat/prod arrives, IaC modules must re-deploy per environment — a dev run
cannot be promoted. Pulumi state is already versioned in the S3 Pulumi
backend. The sst-outputs.json upload is dropped without replacement. The
cross-module runtime contract for IaC outputs is AWS SSM parameters, not a
CI artifact.
Retained: Pulumi S3 state backend¶
pulumi login "s3://bank-pulumi-state-${STAGE}?region=ap-southeast-2" is
not affected. This is Pulumi's own state store, not the artifact system.
Added: publish stage¶
A publish stage is added after smoke in all four CI templates:
Added: Lambda / dbt / DCM generic package upload¶
After a successful deploy and smoke test, build artifacts are published as GitLab Generic Packages:
mod-NNN-publish-artifacts:
stage: publish
needs: [mod-NNN-deploy]
script:
- |
VERSION="${CI_COMMIT_SHORT_SHA}"
# Lambda zip (if built)
if [ -f "/tmp/mod-NNN-lambda.zip" ]; then
curl --fail --header "JOB-TOKEN: ${CI_JOB_TOKEN}" \
--upload-file "/tmp/mod-NNN-lambda.zip" \
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/${MODULE_ID}-lambda/${VERSION}/${MODULE_ID}-lambda.zip"
fi
# dbt manifest (if present)
if [ -f "${MODULE_DIR}/target/manifest.json" ]; then
curl --fail --header "JOB-TOKEN: ${CI_JOB_TOKEN}" \
--upload-file "${MODULE_DIR}/target/manifest.json" \
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/${MODULE_ID}-dbt/${VERSION}/manifest.json"
fi
rules:
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
The version is CI_COMMIT_SHORT_SHA. This gives a browsable, immutable
record of every successfully-tested build without manual version management.
When uat/prod promotion is introduced, the deploy job for those environments
will pull the artifact from the Package Registry by SHA rather than
rebuilding from source — ensuring the identical binary that passed dev tests
is promoted.
Added: npm contract package publish¶
Each module's contract/ directory that has cross-repo consumers is
published as an npm package to the GitLab npm Package Registry. This is a
separate job in the publish stage, scoped to fire only when contract files
change (per ADR-062 changes: pattern):
mod-NNN-publish-contracts:
stage: publish
needs: [mod-NNN-deploy]
script:
- |
CONTRACTS_DIR="${MODULE_DIR}/contract"
if [ ! -f "${CONTRACTS_DIR}/package.json" ]; then
echo "No contract/package.json — skipping"
exit 0
fi
printf "@${NPM_SCOPE}:registry=${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/npm/\n\
//${CI_SERVER_HOST}/api/v4/projects/${CI_PROJECT_ID}/packages/npm/:_authToken=${CI_JOB_TOKEN}\n" \
>> "${CONTRACTS_DIR}/.npmrc"
pnpm --dir "${CONTRACTS_DIR}" run build:types
pnpm --dir "${CONTRACTS_DIR}" publish --no-git-checks --access restricted
rules:
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
changes:
- MOD-NNN-name/contract/**/*
NPM_SCOPE is set per-template (e.g. bank-risk-platform, bank-platform).
Contract package structure¶
Each publishable contract/ directory requires:
contract/package.json
{
"name": "@bank-{repo}/mod-NNN-contracts",
"version": "1.0.0",
"type": "module",
"exports": {
"./events": {
"types": "./dist/events/index.d.ts",
"default": "./dist/events/index.js"
},
"./ssm": {
"types": "./dist/ssm/index.d.ts",
"default": "./dist/ssm/index.js"
}
},
"files": ["dist"],
"scripts": {
"build:types": "tsc --project tsconfig.build.json"
},
"devDependencies": {
"typescript": "^5.4.0"
}
}
contract/tsconfig.build.json
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"declaration": true,
"noEmit": false
},
"include": ["events/**/*.ts", "ssm/**/*.ts", "dbt/**/*.ts"]
}
The build compiles re-exports and their transitive source types into
standalone .d.ts + .js files. Consumers never import from src/.
Contract package versioning¶
- Start at
1.0.0when the module reaches Deployed. - Additive change (new export, new SSM key, new optional field): bump minor.
- Breaking change (rename, remove, or incompatible type change): bump major, coordinated with all consuming teams before merge.
- Version is in
contract/package.json. Developers bump it in the same commit as the contract change. The CIpublishjob reads it — no auto-increment.
Consumer setup¶
In each repository that consumes a contract package:
.npmrc at repo root (not committed with token; token injected in CI):
@bank-risk-platform:registry=https://gitlab.com/api/v4/packages/npm/
@bank-platform:registry=https://gitlab.com/api/v4/packages/npm/
@bank-core:registry=https://gitlab.com/api/v4/packages/npm/
//gitlab.com/api/v4/packages/npm/:_authToken=${GITLAB_TOKEN}
For local development, set GITLAB_TOKEN to a personal access token with
read_package_registry scope. In CI, GITLAB_TOKEN is set to
${CI_JOB_TOKEN}.
package.json in the consuming module:
Import:
Phase 1 publishers (SD06 — bank-risk-platform, contracts only)¶
| Package | Consumers |
|---|---|
@bank-risk-platform/mod-039-contracts |
bank-aml (MOD-016/017), bank-kyc (MOD-010), bank-app (insight feed) |
@bank-risk-platform/mod-040-contracts |
bank-app (churn feed) |
@bank-risk-platform/mod-085-contracts |
bank-credit (MOD-115/116), bank-payments (FX, v2) |
@bank-risk-platform/mod-032-contracts |
bank-platform (observability) |
@bank-risk-platform/mod-035-contracts |
bank-platform (observability) |
@bank-risk-platform/mod-098-contracts |
bank-platform (FinOps) |
Phase 2 publishers (SD07 — bank-platform)¶
bank-platform does the template changes (enabling the publish stage for all repos) and implements ADR-062 contract directories for its own deployed modules in the same change set. These two workstreams are combined because bank-platform owns the CI templates and can self-prove the pattern before other repos adopt it. SD06 can complete contract publish wiring once the templates are updated.
What does not change¶
packages/shared/,packages/shared-iac/remainworkspace:*. These are intra-repo implementation libraries, not typed contracts.- The ADR-062
changes:cascade trigger is the mechanism for re-running downstream deploy jobs. Publishing a contract package is a separate outcome of the same change — it does not replace the cascade. - Bank-wiki records the
contract:field on dependency items. It has no runtime role in package distribution.
Consequences¶
Positive
- Removes ~160 lines of custom S3 bash across four templates.
- Single artifact store with a native UI, retention policies, and no AWS
permission surface to maintain.
- Cross-repo TypeScript type safety for event and SSM contracts.
- Build reproducibility: the exact binary that passed dev tests can be
promoted to uat/prod by SHA reference without rebuilding.
- CI_JOB_TOKEN handles both publish and install auth — no extra secrets.
Negative / trade-offs
- Contract package.json boilerplate per module. Mitigated by a generator
script or copy-paste template in the ADR-062 handoff.
- Generic package downloads require the GitLab API, not a standard npm/pip
command. For Lambda promotion this is one extra curl per env.
- Version bump discipline required for contract packages. Missing a bump on a
breaking change silently overwrites the published package. A CI lint check
(diff contract/package.json version on MR) can catch this.
All ADRs
Compiled 2026-05-22 from source/entities/adrs/ADR-063.yaml