Skip to content

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-object for Lambda zips, dbt manifests, and DCM output, tagging them integration-passed=false.
  • Post-smoke retag — bash that calls aws s3api put-object-tagging to flip the tag to integration-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:

stages:
  - install
  - validate
  - test
  - build
  - deploy
  - smoke
  - notify
  - publish

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.0 when 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 CI publish job 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:

{
  "devDependencies": {
    "@bank-risk-platform/mod-039-contracts": "^1.0.0"
  }
}

Import:

import type { CustomerRiskScoreUpdatedDetail }
  from "@bank-risk-platform/mod-039-contracts/events";

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/ remain workspace:*. 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