@onlineapps/conn-orch-validator 12.1.1 → 12.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,16 @@ All notable changes to this package. Follows [Keep a Changelog](https://keepacha
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ### Changed — šablona biz CI: obraz se staví jen na `main`, produkce nasazuje digest z registru (d.671, d.671b; conf image-promotion 001)
8
+
9
+ Job `build` v `templates/business-service/.gitlab-ci.yml` běží jen na `main` a před stavbou se ptá
10
+ registru, zda tag `:$CI_COMMIT_SHA` už existuje (existuje → nestaví a vypíše digest); tag se nikdy
11
+ nepřepíše. `deploy-production` nebere digest z dotenv artefaktu (`dependencies: []`), ale týmž
12
+ dotazem do registru (`.oa-registry-digest`); když obraz pro commit nenajde, zastaví nasazení
13
+ jmenovanou hláškou a nikdy nestaví náhradu. Fixtury manifestu nesou tentýž blok `oa-ci v1`;
14
+ tvar i tělo dotazu měří `tests/unit/templateImagePromotion.test.js`
15
+ a `tests/unit/templateImagePromotionShell.integration.test.js`.
16
+
7
17
  ## [12.1.1] — 2026-09-25
8
18
 
9
19
  ### Fixed — `verify-deploy-uniform.sh`: rada při checkoutu mimo `api_biz/` vede na cestu, kterou šablona skutečně deklaruje (d.899)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlineapps/conn-orch-validator",
3
- "version": "12.1.1",
3
+ "version": "12.2.0",
4
4
  "description": "Validation orchestrator for OA Drive microservices - coordinates validation across all layers (base, infra, orch, business)",
5
5
  "oa": {
6
6
  "category": "orchestration"
@@ -1,15 +1,17 @@
1
1
  # --- oa-ci v1
2
2
  # Written by @onlineapps/conn-orch-validator (row G-CI of
3
3
  # manifests/biz-service.manifest.json). The PLATFORM half of this pipeline: which
4
- # pipelines run at all, how a service is built, how its image is identified (R5),
5
- # how the digest reaches the deploy (R1/R2), the uniform gate that runs on every
6
- # pipeline (job validate-uniform, confirmation biz-service-manifest 010) and
7
- # again before the SSH step as the binding instance (008), and the post-deploy
8
- # gate that runs after it (confirmation deploy-gate-targets 003). The
9
- # installation contract is checked by that uniform gate, in the manifest rows
10
- # G-SETUP, D-DB-PACKAGE and D-DB-HEADERS, and no longer by a job of its own
11
- # (d.470). npx oa-sync-template .gitlab-ci.yml --target . rewrites everything
12
- # between these two markers and nothing outside them.
4
+ # pipelines run at all, how a service is built - ONCE, on main, never again on
5
+ # production (confirmation image-promotion 001) - how its image is identified
6
+ # (R5), how that one image's digest reaches the deploy (R1/R2), the uniform gate
7
+ # that runs on every pipeline (job validate-uniform, confirmation
8
+ # biz-service-manifest 010) and again before the SSH step as the binding instance
9
+ # (008), and the post-deploy gate that runs after it (confirmation
10
+ # deploy-gate-targets 003). The installation contract is checked by that uniform
11
+ # gate, in the manifest rows G-SETUP, D-DB-PACKAGE and D-DB-HEADERS, and no
12
+ # longer by a job of its own (d.470). npx oa-sync-template .gitlab-ci.yml
13
+ # --target . rewrites everything between these two markers and nothing outside
14
+ # them.
13
15
  #
14
16
  # Outside them is this repository's own: its test job - which database, which
15
17
  # ci:gate:* steps and which artefacts its integration needs is a fact about the
@@ -87,6 +89,126 @@ variables:
87
89
  # runner this job carries onto the box comes from that same clone.
88
90
  API_UNIFORM_REF_DEPLOY: production
89
91
 
92
+ # The ONE question this pipeline asks the container registry (confirmation
93
+ # image-promotion 001): is $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA there, and under
94
+ # which digest? Two jobs need the answer for two different reasons - build, so
95
+ # that a tag already written is never overwritten; deploy-production, because a
96
+ # dotenv artefact does not cross pipelines and production deploys the image main
97
+ # built - and both read it from the registry, the only place both pipelines can
98
+ # see. One question, one implementation: a second copy of this query would be a
99
+ # second rail over one fact (.claude/rules/change-discipline.md § One rail per
100
+ # concern), and the two would answer differently the day one of them was fixed.
101
+ #
102
+ # A hidden key is a declaration and never a job - GitLab runs nothing whose name
103
+ # starts with a dot - so both jobs pull the body in with !reference.
104
+ #
105
+ # In: OA_DIGEST_MODE - who is asking, because a missing image means something
106
+ # different to each of them. "optional": the build's first question, and
107
+ # an absent tag is the ordinary answer. "build": the build's binding read
108
+ # after it has built or reused, where an absent tag is a defect. "deploy":
109
+ # production, where an absent tag is a deploy that must not happen.
110
+ # Out: OA_IMAGE_PRESENT - "yes" or "no"
111
+ # BIZ_IMAGE_DIGEST - sha256:<64 hex> when present, empty when not.
112
+ #
113
+ # A boolean would not do: the two callers that REQUIRE an image need two
114
+ # different sentences, and one message covering both would name neither the
115
+ # situation nor the fix (architecture-principles.md §5).
116
+ #
117
+ # curl and jq are a declared precondition, not an assumption: the body checks for
118
+ # them before it asks anything (automation-gates.md §2). The deploy job installs
119
+ # them for the post-deploy gate already; the build job's image is alpine and
120
+ # carries neither, so it adds them in a before_script of its own.
121
+ .oa-registry-digest:
122
+ script:
123
+ - |
124
+ # oa-registry-digest
125
+ for oa_tool in curl jq; do
126
+ if ! command -v "$oa_tool" > /dev/null 2>&1; then
127
+ echo "[registry] FATAL: $oa_tool is not on PATH, and the image of this commit can only be found by asking the registry. Expected: curl and jq in this job's image. Fix: add 'apk add --no-cache curl jq' to this job's before_script." >&2
128
+ exit 1
129
+ fi
130
+ done
131
+ case "${OA_DIGEST_MODE:-}" in
132
+ optional|build|deploy) ;;
133
+ *)
134
+ echo "[registry] FATAL: OA_DIGEST_MODE is '${OA_DIGEST_MODE:-}', and this query has to know what a commit with no image means to the job asking. Expected: 'optional' (the build's first question), 'build' (its binding read after building) or 'deploy' (production). Fix: declare OA_DIGEST_MODE in this job's variables." >&2
135
+ exit 1 ;;
136
+ esac
137
+ # A pull token for THIS project, from this job's own CI_JOB_TOKEN. Both
138
+ # calls take their credentials AND their URL on stdin (--config -): an
139
+ # argument stands in the runner's process table for the length of the call,
140
+ # which is the same reason the registry login below takes its password
141
+ # there rather than on a command line.
142
+ oa_exit=0
143
+ oa_token_json=$(curl --silent --show-error --fail --config - <<CFG
144
+ user = "gitlab-ci-token:$CI_JOB_TOKEN"
145
+ url = "$CI_SERVER_URL/jwt/auth?service=container_registry&scope=repository:$CI_PROJECT_PATH:pull"
146
+ CFG
147
+ ) || oa_exit=$?
148
+ if [ "$oa_exit" != "0" ]; then
149
+ echo "[registry] FATAL: no pull token for $CI_PROJECT_PATH - $CI_SERVER_URL/jwt/auth exited $oa_exit. Expected: a JSON body carrying .token for scope repository:$CI_PROJECT_PATH:pull. Fix: re-run once GitLab answers; this job never continues without a token." >&2
150
+ exit 1
151
+ fi
152
+ oa_token=$(printf '%s' "$oa_token_json" | jq -r '.token // empty')
153
+ if [ -z "$oa_token" ]; then
154
+ echo "[registry] FATAL: the token endpoint answered without a .token for $CI_PROJECT_PATH. Expected: {\"token\":\"…\"} for scope repository:$CI_PROJECT_PATH:pull. Fix: check that this project's CI job token may pull its own registry." >&2
155
+ exit 1
156
+ fi
157
+ # HEAD, because the digest is a HEADER: the registry answers
158
+ # Docker-Content-Digest for a tag without sending the manifest at all. All
159
+ # four media types are offered, so a single-platform image and an index
160
+ # both answer with their own digest instead of 406.
161
+ oa_headers=$(mktemp)
162
+ oa_exit=0
163
+ oa_status=$(curl --silent --show-error --head --output /dev/null \
164
+ --dump-header "$oa_headers" --write-out '%{http_code}' --config - <<CFG
165
+ header = "Authorization: Bearer $oa_token"
166
+ header = "Accept: application/vnd.docker.distribution.manifest.v2+json"
167
+ header = "Accept: application/vnd.oci.image.manifest.v1+json"
168
+ header = "Accept: application/vnd.docker.distribution.manifest.list.v2+json"
169
+ header = "Accept: application/vnd.oci.image.index.v1+json"
170
+ url = "https://$CI_REGISTRY/v2/$CI_PROJECT_PATH/manifests/$CI_COMMIT_SHA"
171
+ CFG
172
+ ) || oa_exit=$?
173
+ if [ "$oa_exit" != "0" ]; then
174
+ rm -f "$oa_headers"
175
+ echo "[registry] FATAL: the registry did not answer - HEAD https://$CI_REGISTRY/v2/$CI_PROJECT_PATH/manifests/$CI_COMMIT_SHA, curl exited $oa_exit. Expected: HTTP 200 (the tag is there) or 404 (it is not). Fix: re-run this job once $CI_REGISTRY is reachable - a registry that could not answer is not a registry that said no." >&2
176
+ exit 1
177
+ fi
178
+ # Header names are case-insensitive and the value is lowercase hex either
179
+ # way, so the whole file is lowercased rather than the match loosened.
180
+ oa_digest=$(tr '[:upper:]' '[:lower:]' < "$oa_headers" | sed -n 's/^docker-content-digest:[[:space:]]*//p' | tr -d '\r' | head -n 1)
181
+ rm -f "$oa_headers"
182
+ case "$oa_status" in
183
+ 200)
184
+ BIZ_IMAGE_DIGEST="$oa_digest"
185
+ if ! printf '%s' "${BIZ_IMAGE_DIGEST:-}" | grep -qE '^sha256:[0-9a-f]{64}$'; then
186
+ echo "[registry] FATAL: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA is in the registry and its Docker-Content-Digest reads '$BIZ_IMAGE_DIGEST' - expected sha256:<64 hex>, the immutable target R1 pins the production compose to. Fix: re-run this job; if it repeats, the registry is answering a shape this platform does not deploy." >&2
187
+ exit 1
188
+ fi
189
+ OA_IMAGE_PRESENT=yes
190
+ echo "[registry] $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA -> $BIZ_IMAGE_DIGEST" ;;
191
+ 404)
192
+ OA_IMAGE_PRESENT=no
193
+ BIZ_IMAGE_DIGEST=""
194
+ # Each caller is told what ITS absent image means, because the three
195
+ # situations have three different fixes.
196
+ case "$OA_DIGEST_MODE" in
197
+ deploy)
198
+ echo "[deploy] No image for this commit - the registry holds no $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA, so there is nothing main built for production to promote. Expected: the image the main pipeline of this same commit built and pushed (confirmation image-promotion 001). Fix: run a green pipeline on main over $CI_COMMIT_SHA first, then fast-forward production onto it - this deploy never builds a substitute." >&2
199
+ exit 1 ;;
200
+ build)
201
+ echo "[build] FATAL: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA is not in the registry after this job did its work - either the push reported success and the registry does not hold it, or the tag this job found at its start is gone. Expected: the tag, since the digest of THIS build is what the production deploy will read back. Fix: re-run this job; if it repeats, the registry is accepting pushes it does not store." >&2
202
+ exit 1 ;;
203
+ *)
204
+ echo "[registry] $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA is not in the registry yet" ;;
205
+ esac ;;
206
+ *)
207
+ echo "[registry] FATAL: HEAD https://$CI_REGISTRY/v2/$CI_PROJECT_PATH/manifests/$CI_COMMIT_SHA answered HTTP $oa_status - expected 200 (the tag is there) or 404 (it is not). Fix: re-run once the registry answers one of those; this job never reads any other answer as an absent image." >&2
208
+ exit 1 ;;
209
+ esac
210
+ # end oa-registry-digest
211
+
90
212
  # The uniform of THIS commit, on every pipeline - confirmation
91
213
  # biz-service-manifest 010. Entry 008 places the BINDING run before the SSH step
92
214
  # of a production deploy; it never said that is the only place it runs, and read
@@ -121,39 +243,68 @@ validate-uniform:
121
243
  - if: $CI_COMMIT_BRANCH == "devel"
122
244
  - if: $CI_COMMIT_BRANCH == "production"
123
245
 
246
+ # The image of a commit is built HERE and nowhere else (confirmation
247
+ # image-promotion 001 point 1). production does not build: measured on
248
+ # biz-pdfgen 59b2d59 on 2026-09-19, both branches pushed the same tag :<sha> and
249
+ # the second push overwrote the first, so production ran sha256:1b2a136f… while
250
+ # what main had tested was sha256:5f62e220… and was by then reachable under no
251
+ # tag at all.
124
252
  build:
125
253
  stage: build
126
254
  image: docker:24
127
255
  services:
128
256
  - docker:24-dind
257
+ variables:
258
+ # A tag already in the registry is the ordinary case here (a re-run of this
259
+ # pipeline), never a defect - so the query below answers and the job decides.
260
+ # The SECOND question this job asks, after it has built or reused, is asked
261
+ # in mode "build", where an absent tag is a defect.
262
+ OA_DIGEST_MODE: "optional"
263
+ before_script:
264
+ # docker:24 is alpine and carries neither: the registry query needs both, and
265
+ # says so by name if they are missing.
266
+ - apk add --no-cache curl jq
129
267
  script:
130
- # A password on a command line stands in the runner's process table for the
131
- # length of the build and in every trace that echoes the command. The deploy
132
- # host's own login has read it from stdin since it was written; both ends of
133
- # one login now agree.
134
- - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
135
- - docker build --target production -t $IMAGE -t $IMAGE_LATEST .
136
- - docker push $IMAGE
137
- - docker push $IMAGE_LATEST
138
- # R1: the deploy target is the digest, and it exists only after the push.
268
+ - !reference [.oa-registry-digest, script]
139
269
  - |
140
- # --format is not honoured by the buildx on the runner: it prints the whole
141
- # descriptor (Name/MediaType/Digest), so the digest would be captured
142
- # multi-line and build.env rejected as an invalid dotenv artifact. Parse
143
- # the Digest field.
144
- BIZ_IMAGE_DIGEST=$(docker buildx imagetools inspect "$IMAGE" | awk '/^Digest:/ { print $2; exit }')
145
- if ! printf '%s' "$BIZ_IMAGE_DIGEST" | grep -qE '^sha256:[0-9a-f]{64}$'; then
146
- echo "[build] FATAL: no valid digest resolved for $IMAGE - got '$BIZ_IMAGE_DIGEST', expected sha256:<64 hex>. The deploy would have no immutable target. Fix: confirm the push succeeded and that buildx can read $CI_REGISTRY." >&2
147
- exit 1
270
+ if [ "$OA_IMAGE_PRESENT" = "yes" ]; then
271
+ # :<sha> is written once and never overwritten (image-promotion 001): a
272
+ # re-run reuses the image this commit already has, and leaves :latest
273
+ # where it is - re-running an older pipeline must not drag the moving tag
274
+ # backwards. Said out loud, because a step that decided not to work must
275
+ # not read like one that worked (automation-gates.md §5).
276
+ echo "[build] tag :$CI_COMMIT_SHA is already in the registry - not building, digest $BIZ_IMAGE_DIGEST"
277
+ else
278
+ # A password on a command line stands in the runner's process table for
279
+ # the length of the build and in every trace that echoes the command. The
280
+ # deploy host's own login has read it from stdin since it was written;
281
+ # both ends of one login now agree.
282
+ echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
283
+ docker build --target production -t $IMAGE -t $IMAGE_LATEST .
284
+ docker push $IMAGE
285
+ docker push $IMAGE_LATEST
148
286
  fi
149
- echo "[build] $IMAGE -> $BIZ_IMAGE_DIGEST"
150
- echo "BIZ_IMAGE_DIGEST=$BIZ_IMAGE_DIGEST" > build.env
287
+ # R1: the deploy target is the digest, and BOTH branches now read it from
288
+ # the registry, with the query above - the one the production deploy will
289
+ # ask too. `docker buildx imagetools inspect` used to answer it here, which
290
+ # made two implementations of one question ("what digest does :<sha>
291
+ # have") in one file (change-discipline.md § One rail per concern). What it
292
+ # reported was the same manifest digest, because this job builds one
293
+ # platform (a plain `docker build`, no --platform and no `buildx build
294
+ # --push`, so no index and no attestations) - but the day those differed,
295
+ # the pipeline would have proved one digest and promoted another.
296
+ OA_DIGEST_MODE=build
297
+ - !reference [.oa-registry-digest, script]
298
+ # The rest of THIS pipeline sees one digest, whether the image was built now
299
+ # or written by an earlier run. The production deploy does not read this
300
+ # artefact - it cannot, being in another pipeline - and asks the registry the
301
+ # same question instead.
302
+ - echo "BIZ_IMAGE_DIGEST=$BIZ_IMAGE_DIGEST" > build.env
151
303
  artifacts:
152
304
  reports:
153
305
  dotenv: build.env
154
306
  rules:
155
307
  - if: $CI_COMMIT_BRANCH == "main"
156
- - if: $CI_COMMIT_BRANCH == "production"
157
308
 
158
309
  secret_detection:
159
310
  stage: secret-detection
@@ -177,6 +328,24 @@ deploy-production:
177
328
  # and the name is the service's registry identity — the one
178
329
  # api/config/services.json carries and the contract files its target under.
179
330
  DEPLOY_GATE_TARGETS: "biz:__REGISTRY_NAME__"
331
+ # A production deploy promotes the image main built for this same commit, so
332
+ # a commit with none is a deploy that must not happen (confirmation
333
+ # image-promotion 001 point 1). The query below says so by name and stops.
334
+ OA_DIGEST_MODE: "deploy"
335
+ # Nothing this job needs comes from another job. It used to take the digest
336
+ # from build's dotenv artefact, which is why the two jobs had to be in ONE
337
+ # pipeline - and on production there is no build job at all (image-promotion
338
+ # 001). Empty rather than absent: absent means "every artefact of every earlier
339
+ # stage", and a dotenv from somewhere else could then define BIZ_IMAGE_DIGEST
340
+ # behind this job's back (architecture-principles.md §8).
341
+ #
342
+ # It sits HERE, between two mappings, and not behind the before_script below:
343
+ # a comment indented under a key is not something the extractor of
344
+ # api/tests/scripts/biz-deploy-secret-handling.bats can end a literal block on,
345
+ # so this prose used to be handed to `sh` as part of the script (measured
346
+ # 2026-09-19 on b94c78e5, two suites red). templateImagePromotion.test.js keeps
347
+ # that shape now.
348
+ dependencies: []
180
349
  before_script:
181
350
  # Preconditions first, before anything is installed or deployed
182
351
  # (automation-gates.md §1 requirement 4). The post-deploy gate reads the
@@ -250,8 +419,6 @@ deploy-production:
250
419
  echo " StrictHostKeyChecking yes"
251
420
  echo " UserKnownHostsFile ~/.ssh/known_hosts"
252
421
  } > ~/.ssh/config
253
- dependencies:
254
- - build
255
422
  script:
256
423
  # The uniform of this checkout, complete, BEFORE anything is deployed
257
424
  # (confirmation biz-service-manifest 006 point 3, refined by 008): deploy is
@@ -260,12 +427,11 @@ deploy-production:
260
427
  # variable that turns this off (automation-gates.md §1 requirement 5).
261
428
  - npm ci
262
429
  - sh node_modules/@onlineapps/conn-orch-validator/templates/business-service/scripts/verify-deploy-uniform.sh "$CI_PROJECT_DIR" "https://gitlab-ci-token:${CI_JOB_TOKEN}@gitlab.com/${API_PROJECT_PATH}.git" "$API_UNIFORM_REF_DEPLOY"
263
- # R1: refuse to deploy without the immutable target the build stage published.
264
- - |
265
- if [ -z "${BIZ_IMAGE_DIGEST:-}" ]; then
266
- echo "[deploy] FATAL: BIZ_IMAGE_DIGEST is empty - the build stage publishes it as a dotenv artifact. Refusing to deploy without an immutable image target." >&2
267
- exit 1
268
- fi
430
+ # R1: the immutable target, resolved from the registry for THIS commit. The
431
+ # digest main proved is the digest production runs, and a commit main never
432
+ # built stops here with a named message rather than being built now
433
+ # (image-promotion 001; architecture-principles.md §3 No Fallbacks).
434
+ - !reference [.oa-registry-digest, script]
269
435
  - echo "Deploying $BIZ_IMAGE_DIGEST to $DEPLOY_HOST ($CI_ENVIRONMENT_URL)"
270
436
  # The read-only api clone the uniform step already made beside this checkout.
271
437
  # Derived from CI_PROJECT_DIR rather than spelled out a second time: the
@@ -58,8 +58,10 @@ cp config/env-templates/service.env config/env-active/__SERVICE_NAME__.env
58
58
  docker compose up -d --build
59
59
 
60
60
  # 4. Start (production) — the image is pinned by digest, so it is PULLED, never
61
- # built here: `docker build` refuses a tag that carries a digest. CI publishes
62
- # the value; a manual run has to name the digest it wants.
61
+ # built here: `docker build` refuses a tag that carries a digest. The image is
62
+ # built once, by the main pipeline of that commit, and the production deploy
63
+ # reads its digest back from the registry (docs/80-setup/INSTALL.md §
64
+ # Production); a manual run has to name the digest it wants.
63
65
  BIZ_IMAGE_DIGEST=sha256:… docker compose -f docker-compose.production.yml pull
64
66
  BIZ_IMAGE_DIGEST=sha256:… docker compose -f docker-compose.production.yml up -d
65
67
  ```
@@ -90,9 +90,37 @@ BIZ_IMAGE_DIGEST=sha256:… docker compose -f docker-compose.production.yml pull
90
90
  BIZ_IMAGE_DIGEST=sha256:… docker compose -f docker-compose.production.yml up -d
91
91
  ```
92
92
 
93
- CI publishes that digest from the build stage and the deploy job refuses to run
94
- without it (requirement R1). A first deploy also clones the checkout on the box
95
- if it is missing, so onboarding a new service needs no manual step there.
93
+ ### One image per commit: built on `main`, promoted to production
94
+
95
+ The image of a commit is built **once**, by the `build` job of that commit's
96
+ `main` pipeline, and pushed as `:<commit sha>`. That tag is written once and
97
+ never overwritten: a re-run of the same pipeline finds it in the registry, says
98
+ so, and neither builds nor pushes.
99
+
100
+ A production deploy does not build. It asks the registry for the digest of
101
+ `:<commit sha>` and deploys that — the image `main` tested, by digest
102
+ (requirement R1). A commit whose `main` pipeline never produced one cannot reach
103
+ production: the deploy stops with a message naming the commit, and never builds a
104
+ substitute. The way forward is always the same one — a green `main` pipeline over
105
+ that commit, then a fast-forward of `production` onto it. Owner decision:
106
+ `api/docs/governance/confirmations/image-promotion.md` 001.
107
+
108
+ Two things this rests on, and both are preconditions rather than details:
109
+
110
+ - **Registry retention.** The image must still be there when `production`
111
+ deploys, and when a rollback needs the last green one. A cleanup policy on a
112
+ biz project may therefore be enabled only together with a rule that never
113
+ deletes a tag matching a 40-character commit sha; `:latest` and any other
114
+ moving tag are not part of this and may be cleaned freely.
115
+ - **Rollback reads the same place.** There is no automatic rollback for the first
116
+ deployments (`api/docs/governance/confirmations/biz-rollback-first-deploy.md`
117
+ 001): the return is the manual runbook step above — `docker compose up` with
118
+ the digest of the last green commit, read from the registry under that commit's
119
+ own `:<sha>`. Nothing writes that digest down anywhere else, which is exactly
120
+ why the retention rule is not optional.
121
+
122
+ A first deploy also clones the checkout on the box if it is missing, so
123
+ onboarding a new service needs no manual step there.
96
124
 
97
125
  ### Migrations at deploy time
98
126
 
@@ -54,8 +54,11 @@ the service.
54
54
  `@onlineapps/*` pins against the platform library set) and the service's
55
55
  dependencies installed — the test-coverage half asks the service's own jest
56
56
  which files it would run.
57
- - The build stage publishes the image digest as a dotenv artifact and the deploy
58
- stage refuses to run without it; both stages are declared in `.gitlab-ci.yml`.
57
+ - The build stage runs on `main` and builds the image of that commit once; the
58
+ deploy stage reads the digest of `:<commit sha>` back from the registry and
59
+ refuses to deploy a commit that has none. Both stages are declared in
60
+ `.gitlab-ci.yml`, and what each may do is
61
+ [INSTALL.md § Production](INSTALL.md).
59
62
  - Windows is outside the supported matrix.
60
63
 
61
64
  ## Status