Skip to content

CI/CD — tag-based releases on 3-tier Cloud Foundry ​

Spexso ships a Flexso-standard GitLab CI/CD setup that target projects adopt with /spexso-cicd-init (or during /spexso-setup). It implements trunk-based development with tag-based releases: every merge request is tested, main deploys continuously to DEV, and a release is an immutable vX.Y.Z tag on main whose pipeline re-runs the gate, deploys TST automatically and PRD behind a manual gate.

Two modes: central-managed and project-managed ​

The Products department runs many products off one flow, so the pipeline is a shared template, not a copy that drifts per repo:

Mode.gitlab-ci.yml in the productUpdates
central-managed (default)~30 lines: include: the shared template on a pinned major ref + this product's variablescentral fixes and new gates arrive on the next pipeline
project-managedthe full pipeline, copied innone — every improvement is re-applied by hand

/spexso-cicd-init asks which one and records it in .spexso/config.json:

json
"cicd": { "mode": "central", "templateProject": "flexso/spexso-ci-templates", "templateRef": "v1" }

Both modes consume the same file — scaffold/cicd/gitlab-ci.yml.template is copied into the repo for project-managed, and is what the shared repo publishes as /sap-cf-product.yml for central-managed. There is no second copy in spexso to keep in sync.

Two rules make central mode safe:

  • Pin a major ref (v1), never main. An unpinned central template breaks every product at once; a breaking change becomes v2 plus a migration note.
  • Prefer variables over job overrides. GitLab merges includes by job name, so a product can redefine one job — and that job then stops receiving central fixes. It stays visible in the repo, which makes drift a deliberate choice rather than an accident.

The quality gate ​

test runs on every MR and on main; release-gate runs the same script on the release tag, and build waits for whichever of the two exists (needs with optional: true). prepare-release needs test, so a release cannot be cut from a red main.

yaml
TEST_COMMAND: "npm test"       # = testCommand  in .spexso/config.json
STATIC_CHECKS: "npx ui5lint"   # = staticChecks in .spexso/config.json
JOURNEY_COMMAND: ""            # OPA5/wdi5 — scheduled pipeline only

The gate commands mirror .spexso/config.json, so the pipeline runs exactly what the builder runs between GREEN and COMMIT and what /spexso-finish runs as its final gate — one definition, three consumers. The suite that gates a merge is the unit + service suite from test.md; OPA5/wdi5 journeys run in a scheduled pipeline via JOURNEY_COMMAND, never in the gate.

What /spexso-cicd-init generates ​

FilePurpose
.gitlab-ci.ymlThe pipeline: build → release → deploy
CICD-SETUP.mdDeveloper checklist (spaces, tokens, protected tags, release how-to)
.versionrc.jsoncommit-and-tag-version config: which files carry the version
scripts/mta-version-updater.jsBumps the top-level version: in mta.yaml
scripts/manifest-version-updater.jsBumps sap.app.applicationVersion.version in the UI5 manifest

It also installs commit-and-tag-version as a devDependency. That tool was chosen over semantic-release deliberately: semantic-release cuts a release on every merge to main, while Flexso wants deliberate, manually triggered releases (2–3 planned releases per year with late feature selection).

Trigger model ​

EventPipeline jobs
Push to an open MR targeting maintest + build — no deploy
Merge to maintest + build + deploy-dev (auto) + prepare-release (manual)
Tag vX.Y.Z pushedrelease-gate + build + create-release + deploy-tst (auto) + deploy-prd (manual gate)
Scheduled pipeline on mainjourneys (when JOURNEY_COMMAND is set)

MR pipelines deliberately do not deploy: parallel MRs into one DEV space overwrite each other, and DEV should show exactly what is on main.

One .mtar is built per tag pipeline and promoted TST → PRD without a rebuild — what QA validates is byte-for-byte what production gets.

The release flow ​

  1. A developer triggers the manual prepare-release job on main (optionally with RELEASE_AS=1.4.0 to pick the version). The job releases the tip of main, not the commit of the pipeline it runs in: pushes with -o ci.skip (the docs commits /spexso-finish pushes) start no pipeline, so the newest pipeline can sit behind main. The tag pipeline's release-gate tests the tip before anything deploys.
  2. The job runs commit-and-tag-version: bumps the version in package.json, mta.yaml, and manifest.json (kept in sync via .versionrc.json), regenerates CHANGELOG.md from the Conventional Commits since the last tag, commits chore(release): 1.4.0, tags v1.4.0, and pushes both to main using the RELEASE_TOKEN project access token.
  3. The tag pipeline creates the GitLab Release (notes = newest changelog section), deploys TST automatically, and waits at the PRD gate.
  4. After QA sign-off on TST, someone clicks deploy-prd.

The bump commit lands on main directly — there are no release branches and no back-merges. The GitLab Environments page shows which tag runs where; the Release object records what was in it.

Versioning and changelog ​

  • Semantic Versioning; the bump is inferred from Conventional Commits (fix: → patch, feat: → minor, BREAKING CHANGE → major), overridable with RELEASE_AS.
  • The squash-merged MR titles from /spexso-finish are the commit messages on main, and finish derives the type from the issue's type:: label — so the changelog and bump are correct without extra discipline.
  • CHANGELOG.md documents what is in the code since the last tag, including dark (flag-off) features. What is live is decided by the flag config in mta_ext/prd.mtaext.

Feature flags via mtaext ​

Per-environment config — including feature flags — lives in MTA extension descriptors (mta_ext/dev|tst|prd.mtaext), auto-detected by the deploy jobs (cf deploy … -e mta_ext/<env>.mtaext). Risky or release-uncertain features merge to main behind a flag that is off in prd.mtaext; late feature selection is an mtaext edit, not a git operation. Flags are cleaned up once the feature is stable in production.

Variable strategy ​

Group-level GitLab CI/CD variables (set once by the platform admin):

VariablePurpose
CF_DEPLOY_USERService-account CF user (Masked + Protected)
CF_DEPLOY_PWDService-account CF password (Masked + Protected)
CF_ORIGINFlexso IDP origin (e.g. af0uogd3r-platform)

Project-level GitLab CI/CD variable (per project, created during setup):

VariablePurpose
RELEASE_TOKENProject access token, scope write_repository, Masked + Protected — lets prepare-release push the bump commit + tag

In .gitlab-ci.yml (developer-editable, no GitLab Settings access needed): CF_API_*, CF_ORG_*, CF_SPACE_* per environment, plus MTAR_NAME. Defaults ship with Flexso-typical values (region eu10-004, orgs flexso-extensions-ca-{dev,tst,prd}); CF_SPACE_* ships as TODO-set-your-space.

Guardrails ​

  • Protected tags on v* are a hard prerequisite: protected variables (CF credentials, RELEASE_TOKEN) are only injected into pipelines on protected refs, so an unprotected tag pipeline cannot deploy. Protection also makes release tags immutable and restricts who may create them.
  • Never tag manually — always via prepare-release. The version exists in three files and only commit-and-tag-version may touch them.
  • production should be a protected environment; deploy-prd is always a manual gate and blocking (allow_failure: false — pinned explicitly, because when: manual alone would default it to non-blocking).
  • Smoke check after every deploy: when APP_URL_<ENV> is set, the deploy job curls that URL with retries and fails if the app does not answer. Without it, "deploy succeeded" is a statement about cf deploy, not about the app.
  • Hotfixes are a documented manual procedure (branch from the tag, run prepare-release on the hotfix/* branch, cherry-pick the fix back to main) — spexso ships no tooling for them, and using the flow requires protecting hotfix/* branches. See the generated CICD-SETUP.md.

ABAP variant (abapGit repositories) ​

When /spexso-cicd-init detects an abapGit-serialized repo (.abapgit.xml or *.abap under src/), it generates a static-quality pipeline instead of the MTA deploy flow: abaplint on every MR (version-pinned @abaplint/cli, JUnit report so findings appear as MR test annotations, error findings block the merge) plus a tunable abaplint.json (Cloud syntax + clean-core-oriented rules by default; classic ABAP projects switch syntax.version to their release). An optional commented ATC stage covers BTP ABAP environment via project "Piper"'s abapEnvironmentRunATCCheck — ATC (ABAP_CLEAN_CORE_DEVELOPMENT / cloud-readiness variants) is the authoritative clean-core gate; abaplint is the fast in-repo pre-check. Deployment (abapGit pull / gCTS) stays outside the pipeline.

What stays out of scope ​

  • Monorepos with multiple mta.yaml files (one pipeline per repo).
  • Non-CF runtimes (Kyma, ABAP on-prem, Integration Suite) — for ABAP repos the lint/ATC gate above applies, not a deploy flow.
  • Generating mta.yaml itself (CAP: cds add mta).
  • Runtime feature-flag toggling without redeploy (possible later via GitLab Feature Flags / Unleash; mtaext-per-env is the deliberate simple start).
  • /spexso-cicd-init — slash command that runs the generation. See commands.
  • Setup CI/CD scenario — developer walkthrough.
  • Release CI/CD design spec — the source specification lives in the repo at docs/superpowers/specs/2026-07-02-release-cicd-design.md (internal working artifact, not part of this site).