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 product | Updates |
|---|---|---|
| central-managed (default) | ~30 lines: include: the shared template on a pinned major ref + this product's variables | central fixes and new gates arrive on the next pipeline |
| project-managed | the full pipeline, copied in | none — every improvement is re-applied by hand |
/spexso-cicd-init asks which one and records it in .spexso/config.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), nevermain. An unpinned central template breaks every product at once; a breaking change becomesv2plus 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.
TEST_COMMAND: "npm test" # = testCommand in .spexso/config.json
STATIC_CHECKS: "npx ui5lint" # = staticChecks in .spexso/config.json
JOURNEY_COMMAND: "" # OPA5/wdi5 — scheduled pipeline onlyThe 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
| File | Purpose |
|---|---|
.gitlab-ci.yml | The pipeline: build → release → deploy |
CICD-SETUP.md | Developer checklist (spaces, tokens, protected tags, release how-to) |
.versionrc.json | commit-and-tag-version config: which files carry the version |
scripts/mta-version-updater.js | Bumps the top-level version: in mta.yaml |
scripts/manifest-version-updater.js | Bumps 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
| Event | Pipeline jobs |
|---|---|
Push to an open MR targeting main | test + build — no deploy |
Merge to main | test + build + deploy-dev (auto) + prepare-release (manual) |
Tag vX.Y.Z pushed | release-gate + build + create-release + deploy-tst (auto) + deploy-prd (manual gate) |
Scheduled pipeline on main | journeys (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
- A developer triggers the manual
prepare-releasejob onmain(optionally withRELEASE_AS=1.4.0to pick the version). The job releases the tip ofmain, not the commit of the pipeline it runs in: pushes with-o ci.skip(the docs commits/spexso-finishpushes) start no pipeline, so the newest pipeline can sit behindmain. The tag pipeline'srelease-gatetests the tip before anything deploys. - The job runs
commit-and-tag-version: bumps the version inpackage.json,mta.yaml, andmanifest.json(kept in sync via.versionrc.json), regeneratesCHANGELOG.mdfrom the Conventional Commits since the last tag, commitschore(release): 1.4.0, tagsv1.4.0, and pushes both tomainusing theRELEASE_TOKENproject access token. - The tag pipeline creates the GitLab Release (notes = newest changelog section), deploys TST automatically, and waits at the PRD gate.
- 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 withRELEASE_AS. - The squash-merged MR titles from
/spexso-finishare the commit messages onmain, and finish derives the type from the issue'stype::label — so the changelog and bump are correct without extra discipline. CHANGELOG.mddocuments what is in the code since the last tag, including dark (flag-off) features. What is live is decided by the flag config inmta_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):
| Variable | Purpose |
|---|---|
CF_DEPLOY_USER | Service-account CF user (Masked + Protected) |
CF_DEPLOY_PWD | Service-account CF password (Masked + Protected) |
CF_ORIGIN | Flexso IDP origin (e.g. af0uogd3r-platform) |
Project-level GitLab CI/CD variable (per project, created during setup):
| Variable | Purpose |
|---|---|
RELEASE_TOKEN | Project 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 onlycommit-and-tag-versionmay touch them. productionshould be a protected environment;deploy-prdis always a manual gate and blocking (allow_failure: false— pinned explicitly, becausewhen: manualalone 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 aboutcf deploy, not about the app. - Hotfixes are a documented manual procedure (branch from the tag, run
prepare-releaseon thehotfix/*branch, cherry-pick the fix back tomain) — spexso ships no tooling for them, and using the flow requires protectinghotfix/*branches. See the generatedCICD-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.yamlfiles (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.yamlitself (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).
Related
/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).