@onlineapps/conn-orch-validator 12.1.0 → 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 +92 -0
- package/package.json +1 -1
- package/src/ValidationOrchestrator.js +28 -9
- package/src/cli/biz-ci-gate.js +14 -2
- package/src/manifest/manifestShape.js +15 -4
- package/src/utils/libCompat.js +69 -22
- package/templates/business-service/.gitlab-ci.yml +203 -37
- package/templates/business-service/README.md +4 -2
- package/templates/business-service/docs/80-setup/INSTALL.md +31 -3
- package/templates/business-service/docs/80-setup/PLATFORM_MATRIX.md +5 -2
- package/templates/business-service/scripts/verify-deploy-uniform.sh +21 -4
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,98 @@ 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
|
+
|
|
17
|
+
## [12.1.1] — 2026-09-25
|
|
18
|
+
|
|
19
|
+
### Fixed — `verify-deploy-uniform.sh`: rada při checkoutu mimo `api_biz/` vede na cestu, kterou šablona skutečně deklaruje (d.899)
|
|
20
|
+
|
|
21
|
+
Hláška radila `GIT_CLONE_PATH: $CI_BUILDS_DIR/oa-uniform/api_biz/<service>` bez `$CI_CONCURRENT_ID`,
|
|
22
|
+
zatímco `.oa-uniform` v šabloně `.gitlab-ci.yml` deklaruje `$CI_BUILDS_DIR/oa-uniform/$CI_CONCURRENT_ID/api_biz/<service>`.
|
|
23
|
+
Nově radí „make this job extend .oa-uniform" a cituje cestu šablony; `biz-deploy-uniform-gate.bats` čte
|
|
24
|
+
očekávanou cestu ze šablony, takže se hláška a šablona nemohou rozejít.
|
|
25
|
+
|
|
26
|
+
### Fixed — `verify-deploy-uniform.sh` smaže jen klon, který sám vytvořil; cizí `api/` odmítne a nechá netknutý (d.885)
|
|
27
|
+
|
|
28
|
+
Šablonový skript brány nasazení odvozuje `API_CHECKOUT="<workspace>/api"` a existující adresář před
|
|
29
|
+
klonem maže, aby runner, který znovu používá svůj builds adresář, neměřil proti zastaralému SSOT. Za
|
|
30
|
+
„svůj" ale uznal každý adresář s `.git` a `config/services.json`, a ty nese KAŽDÝ checkout api
|
|
31
|
+
repozitáře. 2026-09-24 skript spustil vývojář z `api_biz/emailer` na pracovní stanici,
|
|
32
|
+
`<workspace>/api` byl skutečný pracovní strom a skript ho smazal (639 sledovaných souborů; obnoveno).
|
|
33
|
+
|
|
34
|
+
Skript teď po vlastním `git clone` zapíše do klonu značku `.verify-deploy-uniform.clone`
|
|
35
|
+
(`created-by verify-deploy-uniform.sh <ISO čas> <pid>`). Mazat smí jen adresář, který nese `.git`,
|
|
36
|
+
`config/services.json` A tu značku. Bez značky končí `die` (exit 2): „exists and carries no marker …
|
|
37
|
+
Expected: a clone this script made. Fix: remove it yourself, or run this script only from a CI
|
|
38
|
+
checkout where <workspace>/api is free" a na adresář nesáhne. Přepínač, který by kontrolu vypnul,
|
|
39
|
+
neexistuje (`automation-gates.md` §1 požadavky 3 a 5). URL `file://` se nezakazuje: ochranu nese
|
|
40
|
+
značka a druhé pravidlo pro tutéž věc by bylo druhou kolejí.
|
|
41
|
+
|
|
42
|
+
RED (`api/tests/scripts/biz-deploy-uniform-gate.bats`, tři nové případy): `not ok 1 an api checkout
|
|
43
|
+
the gate did not make is refused and left exactly as it was` (`[ "$status" -eq 2 ]` failed: skript
|
|
44
|
+
cizí checkout smazal a naklonoval znovu), `not ok 2` a `not ok 3` (značka neexistuje). GREEN: celý
|
|
45
|
+
soubor `1..20`, 20× `ok`, včetně případů se skutečným enginem, které čtou klon se značkou.
|
|
46
|
+
|
|
47
|
+
### Fixed — konfigurační řádek se severitou `deploy` už neshodí boot služby; start blokuje jen `boot` (d.870)
|
|
48
|
+
|
|
49
|
+
Krok 2 boot validace (`validateConfig`, řádky `C-SERVICE`, `C-OPS`, `C-CONTRACT`) soudil start podle
|
|
50
|
+
`blocking_severities` uniformy. Ten seznam je ale **verdikt nasaditelnosti**: `runManifest` z něj počítá
|
|
51
|
+
`ok`, tištěné jako DEPLOYABLE / NOT DEPLOYABLE, a pro službu zní `["boot", "deploy"]`. Krok 7 přitom
|
|
52
|
+
soudil start jen podle `boot`. Konfigurační řádek se severitou `deploy` by tedy odmítl start služby, kterou
|
|
53
|
+
konfirmace `biz-service-manifest` 001 §4 nechává běžet („`deploy` — the service **starts**") a jen ji označí
|
|
54
|
+
za nenasaditelnou. Dnes to nikdo neviděl, protože všechny tři řádky kroku 2 mají `boot`. Chyba by se ukázala
|
|
55
|
+
v den, kdy jednomu z nich manifest dá `deploy`.
|
|
56
|
+
|
|
57
|
+
Otázku „blokuje tento nález start?" teď oba kroky kladou jedné funkci `blocksBoot` nad konstantou
|
|
58
|
+
`BOOT_SEVERITY` (`src/manifest/manifestShape.js`, vedle `SEVERITIES`). `blocking_severities` zůstává tím,
|
|
59
|
+
čím je: seznamem verdiktu uniformy. Tvar výsledku kroků (`valid`, `errors`, `findings`, `deployable`,
|
|
60
|
+
`deployFindings`) se nemění.
|
|
61
|
+
|
|
62
|
+
RED (`tests/unit/ValidationOrchestrator.manifestStep.test.js`): nový případ „a config row of severity deploy
|
|
63
|
+
does not refuse the boot" `Expected: true / Received: false` na `step2.valid`, `1 failed, 15 passed`. Kontrolní
|
|
64
|
+
případ (týž řádek s `boot` → `valid: false` a přesně jedna chybová zpráva) byl zelený před opravou i po ní.
|
|
65
|
+
|
|
66
|
+
### Documentation — čísla kroků a citace zrušené hlášky v docblocích (d.870)
|
|
67
|
+
|
|
68
|
+
- Docblock `runCookbookTests()` říkal „Step 4", ale běh ho loguje jako `Step 5/7`; docblock
|
|
69
|
+
`validateOperations()` říkal „Step 3" (tedy číslo kroku prostředí), ale běh ho loguje jako `Step 4/7`.
|
|
70
|
+
- Odstavec o zrušeném kroku Readiness (a týž komentář v `tests/unit/ValidationOrchestrator.unit.test.js`)
|
|
71
|
+
citoval jako přítomnou hlášku kroku 2 `operations.json has no operations defined`, kterou žádný kód nevydává.
|
|
72
|
+
Teď cituje řádek uniformy `C-OPS`, který tu otázku zodpovídá. Odkaz na řádek nezestárne jako opsaný text.
|
|
73
|
+
|
|
74
|
+
### Fixed — R6 porovná se SSOT KAŽDÝ pin `@onlineapps/*`, i u knihoven, které infra neinstaluje (d.654, d.654b)
|
|
75
|
+
|
|
76
|
+
Zúžení na `infraConsumed` přeskakovalo i porovnání ROVNOSTI pinu se SSOT, ne jen otázku
|
|
77
|
+
kompatibility s infra vydáním. Pravidlo R6 přitom žádnou infra klauzuli nemá: služba pinuje
|
|
78
|
+
přesně to, co SSOT deklaruje (`.claude/rules/architecture-principles.md` § Version pinning).
|
|
79
|
+
`notGated` proto **už není výjimka z porovnání, jen informace** o tom, kterou VĚTU nález
|
|
80
|
+
dostane — infra balíček „biz pins X, infra ships Y", balíček bez infra konzumenta „service
|
|
81
|
+
<jméno> pins X, the platform library SSOT declares Y … Fix: npm install <pkg>@Y --save-exact".
|
|
82
|
+
Zelený řádek navíc říká, kolik pinů se doopravdy porovnalo, aby se coverage brány nedala
|
|
83
|
+
odhadnout z názvu druhého seznamu.
|
|
84
|
+
|
|
85
|
+
**Nahrazuje chování popsané u d.413** („`notGated` zůstává pro deklarovaný balíček, který
|
|
86
|
+
infra neinstaluje" — tam byl `notGated` výjimka z porovnání; od d.654 už není). Položka
|
|
87
|
+
vydané verze zůstává, jak byla: popisuje, co ta verze dělala.
|
|
88
|
+
|
|
89
|
+
Změřeno nad `api_biz/ingest` (13 pinů `@onlineapps/*`, SSOT deklaruje 28 knihoven a
|
|
90
|
+
`infraConsumed` 7) — PŘED (vydaný validátor 12.1.0): `not gated (no infra service installs
|
|
91
|
+
these): …8 balíčků…` + `OK verify-lib-compat — 5 pin(s) match the platform library set`;
|
|
92
|
+
PO: `no infra comparison (no infra service installs these; their SSOT pin is compared all
|
|
93
|
+
the same): …8 balíčků…` + `OK verify-lib-compat — 13 pin(s) match the platform library
|
|
94
|
+
SSOT, 5 of them also compared against the infra library set`. Falzifikace na kopii
|
|
95
|
+
`package.json` s `@onlineapps/service-wrapper` 10.1.2 proti SSOT 10.1.1 (balíček, který
|
|
96
|
+
žádná infra služba neinstaluje): 12.1.0 exit 0 „5 pin(s) match", nový validátor exit 1
|
|
97
|
+
s jmenovaným nálezem a opravou. Nedotčená kopie dál exit 0.
|
|
98
|
+
|
|
7
99
|
## [12.1.0] — 2026-09-18
|
|
8
100
|
|
|
9
101
|
### Fixed — lint, který se odmítl spustit, je NOT RUN, ne šest nálezů o dokumentaci (d.632, W632)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onlineapps/conn-orch-validator",
|
|
3
|
-
"version": "12.
|
|
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"
|
|
@@ -24,6 +24,21 @@ const { loadManifest, DEFAULT_MANIFEST_PATH } = require('./manifest/loadManifest
|
|
|
24
24
|
const { runManifest } = require('./manifest/runManifest');
|
|
25
25
|
const { resolveWorkspaceRoot } = require('./manifest/workspaceRoot');
|
|
26
26
|
const { renderBanner, describeFinding } = require('./manifest/report');
|
|
27
|
+
const { BOOT_SEVERITY } = require('./manifest/manifestShape');
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Whether a finding refuses the service's start — the one question steps 2 and
|
|
31
|
+
* 7 both ask, so they ask it here (`.claude/rules/change-discipline.md` § One
|
|
32
|
+
* rail per concern). Until d.870 step 2 answered it with the uniform's
|
|
33
|
+
* `blocking_severities`, which is the deployability verdict and names `deploy`
|
|
34
|
+
* as well, while step 7 answered `boot` alone: a config row given `deploy`
|
|
35
|
+
* would have refused a start that confirmation `biz-service-manifest` 001 §4
|
|
36
|
+
* says goes ahead.
|
|
37
|
+
*
|
|
38
|
+
* @param {{severity: string}} finding
|
|
39
|
+
* @returns {boolean}
|
|
40
|
+
*/
|
|
41
|
+
const blocksBoot = (finding) => finding.severity === BOOT_SEVERITY;
|
|
27
42
|
const { buildDeployabilitySignal, writeDeployabilitySignal } = require('./manifest/deployabilitySignal');
|
|
28
43
|
const { isGitCheckout, NOT_A_CHECKOUT } = require('./manifest/gitCheckout');
|
|
29
44
|
|
|
@@ -392,8 +407,8 @@ class ValidationOrchestrator {
|
|
|
392
407
|
* (handler presence and shape, bundle_scope, input, output, the retired
|
|
393
408
|
* v2 fields) — the two loops were textual copies of one another.
|
|
394
409
|
* - Its only two extra guards, "operations must be an object" and "no
|
|
395
|
-
* operations defined", are step 2
|
|
396
|
-
*
|
|
410
|
+
* operations defined", are answered by step 2 through the uniform row
|
|
411
|
+
* `C-OPS`.
|
|
397
412
|
* - Run against a valid baseline plus 13 mutations covering every rule,
|
|
398
413
|
* its verdict equalled `step2 && step3` in all 14 cases. Against the
|
|
399
414
|
* eight live biz services it emitted one check, scored 80 out of a
|
|
@@ -651,9 +666,13 @@ class ValidationOrchestrator {
|
|
|
651
666
|
* leaves them out, because they are answered, not because they stopped
|
|
652
667
|
* mattering.
|
|
653
668
|
*
|
|
654
|
-
*
|
|
655
|
-
*
|
|
656
|
-
*
|
|
669
|
+
* Only a `boot` finding fails the start (`blocksBoot`, the same question
|
|
670
|
+
* step 7 asks). The uniform's `blocking_severities` is a different list: the
|
|
671
|
+
* deployability verdict, `["boot", "deploy"]` for a service — a `deploy` row
|
|
672
|
+
* here is reported, and the service starts undeployable (confirmation
|
|
673
|
+
* `biz-service-manifest` 001 §4; d.870). This step only ever runs the service
|
|
674
|
+
* uniform (`manifestRun()` loads `DEFAULT_MANIFEST_PATH`), so no library
|
|
675
|
+
* severity reaches it.
|
|
657
676
|
*
|
|
658
677
|
* @returns {{valid: boolean, errors: string[], findings: object[]}}
|
|
659
678
|
*/
|
|
@@ -661,7 +680,7 @@ class ValidationOrchestrator {
|
|
|
661
680
|
try {
|
|
662
681
|
const run = this.manifestRun();
|
|
663
682
|
const findings = run.findings.filter((finding) => CONFIG_STEP_ROWS.includes(finding.id));
|
|
664
|
-
const blocking = findings.filter(
|
|
683
|
+
const blocking = findings.filter(blocksBoot);
|
|
665
684
|
|
|
666
685
|
this.logger.info(`[ValidationOrchestrator] ✓ Config files: ${blocking.length === 0 ? 'PASS' : 'FAIL'}`);
|
|
667
686
|
return {
|
|
@@ -701,7 +720,7 @@ class ValidationOrchestrator {
|
|
|
701
720
|
}
|
|
702
721
|
|
|
703
722
|
/**
|
|
704
|
-
* Step
|
|
723
|
+
* Step 4: Validate operations compliance (v3 — handler registry dispatch).
|
|
705
724
|
* Required per operation: handler ('handlers/<path>#<export>'), bundle_scope, input, output.
|
|
706
725
|
* Forbidden (v2): endpoint, method, path.
|
|
707
726
|
* Warned about: a missing `description` — inherited from the removed
|
|
@@ -767,7 +786,7 @@ class ValidationOrchestrator {
|
|
|
767
786
|
}
|
|
768
787
|
|
|
769
788
|
/**
|
|
770
|
-
* Step
|
|
789
|
+
* Step 5: Run cookbook tests via CookbookTestRunner (v3 handler dispatch).
|
|
771
790
|
* Operations must declare a `handler` in operations.json; the runner
|
|
772
791
|
* loads the module via `require()` and calls the exported function
|
|
773
792
|
* in-process. Failures fail this validation step.
|
|
@@ -1003,7 +1022,7 @@ class ValidationOrchestrator {
|
|
|
1003
1022
|
});
|
|
1004
1023
|
|
|
1005
1024
|
const findings = result.findings.filter((finding) => !CONFIG_STEP_ROWS.includes(finding.id));
|
|
1006
|
-
const bootFindings = findings.filter(
|
|
1025
|
+
const bootFindings = findings.filter(blocksBoot);
|
|
1007
1026
|
|
|
1008
1027
|
return {
|
|
1009
1028
|
valid: bootFindings.length === 0,
|
package/src/cli/biz-ci-gate.js
CHANGED
|
@@ -467,8 +467,13 @@ async function runVerifyLibCompat(options) {
|
|
|
467
467
|
const librarySet = await loadLibrarySet(options.libraries);
|
|
468
468
|
const result = checkLibCompat(pkg, librarySet);
|
|
469
469
|
|
|
470
|
+
// What is skipped for these is the INFRA comparison, and nothing else: since
|
|
471
|
+
// d.654 their pin is held against the SSOT like every other. The line used to
|
|
472
|
+
// read "not gated", which after d.654 told the reader the opposite of what
|
|
473
|
+
// the run did.
|
|
470
474
|
if (result.notGated.length > 0) {
|
|
471
|
-
process.stdout.write(
|
|
475
|
+
process.stdout.write('[BizCiGate] no infra comparison (no infra service installs these; their SSOT pin '
|
|
476
|
+
+ `is compared all the same): ${result.notGated.join(', ')}\n`);
|
|
472
477
|
}
|
|
473
478
|
if (!result.ok) {
|
|
474
479
|
for (const violation of result.violations) {
|
|
@@ -476,7 +481,14 @@ async function runVerifyLibCompat(options) {
|
|
|
476
481
|
}
|
|
477
482
|
process.exit(1);
|
|
478
483
|
}
|
|
479
|
-
|
|
484
|
+
// Two numbers, because the run answers two questions and a single count
|
|
485
|
+
// could only ever be true of one of them: how many pins were held against the
|
|
486
|
+
// SSOT (all of them, since d.654), and how many of those infra also installs,
|
|
487
|
+
// which is the compatibility half. Both come from the rule module's own
|
|
488
|
+
// lists, never counted again here (`.claude/rules/automation-gates.md` §5 —
|
|
489
|
+
// a coverage sentence smaller than the coverage is a false signal).
|
|
490
|
+
process.stdout.write(`[BizCiGate] OK verify-lib-compat — ${result.compared.length} pin(s) match the platform `
|
|
491
|
+
+ `library SSOT, ${result.gated.length} of them also compared against the infra library set\n`);
|
|
480
492
|
}
|
|
481
493
|
|
|
482
494
|
async function runVerifyContract(options) {
|
|
@@ -21,16 +21,26 @@
|
|
|
21
21
|
|
|
22
22
|
const { walkManifest, collectRows, isPlainObject } = require('./walk');
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* The one severity that refuses a service's start (001 §4). Every other
|
|
26
|
+
* consequence leaves the service running: `deploy` makes it undeployable,
|
|
27
|
+
* `publish` belongs to a library, which has no start at all, and `warn` stops
|
|
28
|
+
* nothing. It is NOT the uniform's `blocking_severities` — that list is the
|
|
29
|
+
* VERDICT (DEPLOYABLE / PUBLISHABLE, `runManifest` turns it into `ok`), and for
|
|
30
|
+
* a service it names `deploy` too (d.870).
|
|
31
|
+
*/
|
|
32
|
+
const BOOT_SEVERITY = 'boot';
|
|
33
|
+
|
|
34
|
+
/** The one severity that stops nothing: it is printed in the table and nowhere else. */
|
|
35
|
+
const WARN_SEVERITY = 'warn';
|
|
36
|
+
|
|
24
37
|
/**
|
|
25
38
|
* The consequences a row may carry, and no others. `boot` and `deploy` are the
|
|
26
39
|
* service uniform's (001 §4); `publish` is the library uniform's — a library has
|
|
27
40
|
* no boot, and the choke point nothing reaches a service past is
|
|
28
41
|
* `scripts/publish-library.sh` (002 §10).
|
|
29
42
|
*/
|
|
30
|
-
const SEVERITIES = Object.freeze([
|
|
31
|
-
|
|
32
|
-
/** The one severity that stops nothing: it is printed in the table and nowhere else. */
|
|
33
|
-
const WARN_SEVERITY = 'warn';
|
|
43
|
+
const SEVERITIES = Object.freeze([BOOT_SEVERITY, 'deploy', 'publish', WARN_SEVERITY]);
|
|
34
44
|
|
|
35
45
|
/** Which severities a uniform may declare as blocking — every consequence except `warn`. */
|
|
36
46
|
const BLOCKING_CANDIDATES = Object.freeze(SEVERITIES.filter((severity) => severity !== WARN_SEVERITY));
|
|
@@ -435,6 +445,7 @@ module.exports = {
|
|
|
435
445
|
rowNeedsWorkspace,
|
|
436
446
|
describeFromProblem,
|
|
437
447
|
SEVERITIES,
|
|
448
|
+
BOOT_SEVERITY,
|
|
438
449
|
CHECK_SCOPES,
|
|
439
450
|
WORKSPACE_DEPENDENT_SCOPES,
|
|
440
451
|
BLOCKING_CANDIDATES,
|
package/src/utils/libCompat.js
CHANGED
|
@@ -7,26 +7,34 @@
|
|
|
7
7
|
* matches the version the platform declares. Mismatch means the service was
|
|
8
8
|
* built against a different platform contract than the one it will run on.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* it is by definition shipped and must match.
|
|
10
|
+
* EVERY package the SSOT declares and the service pins is compared against the
|
|
11
|
+
* declared version: R6's rule is that a service pins exactly what the SSOT
|
|
12
|
+
* declares (`.claude/rules/architecture-principles.md` § Version pinning), and
|
|
13
|
+
* that sentence carries no infra clause. What the `infraConsumed` list decides
|
|
14
|
+
* is which SENTENCE a stale pin gets, because the two say different things:
|
|
16
15
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* all — so it is refused, never narrowed away. Passing it would be a fallback
|
|
23
|
-
* to "not gated" (principle 3), and it would let a typo or a retired package
|
|
24
|
-
* name travel into an image with the gate reporting OK (measured 2026-09-14).
|
|
16
|
+
* - infra installs the package → biz and infra run different code against
|
|
17
|
+
* one contract, and the fix may be to deploy the matching infra release;
|
|
18
|
+
* - no infra service installs it → there is no infra version to diverge
|
|
19
|
+
* from, so the only statement left is the SSOT one: this pin is not what
|
|
20
|
+
* the platform declares, and the fix is to re-pin.
|
|
25
21
|
*
|
|
26
|
-
*
|
|
27
|
-
* infra
|
|
28
|
-
*
|
|
29
|
-
*
|
|
22
|
+
* Until 2026-09-18 (W651) `infraConsumed` decided more than that: a package no
|
|
23
|
+
* infra service installs skipped the version comparison altogether. Measured
|
|
24
|
+
* then: 20 of the 28 SSOT packages are biz-only, so for most of a service's
|
|
25
|
+
* pins a biz pipeline compared nothing at all, and a service could ship months
|
|
26
|
+
* behind the platform with a green gate. A bare version map has no such list —
|
|
27
|
+
* a platform-release entry records what was deployed, so everything in it is by
|
|
28
|
+
* definition shipped, and every mismatch is a compatibility one.
|
|
29
|
+
*
|
|
30
|
+
* A package the SSOT declares in NEITHER list is a different thing again: not
|
|
31
|
+
* biz-only, but unknown to the platform — there is no version to pin against at
|
|
32
|
+
* all, so it is refused, never narrowed away. Passing it would be a fallback to
|
|
33
|
+
* "not gated" (principle 3), and it would let a typo or a retired package name
|
|
34
|
+
* travel into an image with the gate reporting OK (measured 2026-09-14).
|
|
35
|
+
*
|
|
36
|
+
* The narrowing never relaxes the exact-pin rule either, which `^`, `~` and
|
|
37
|
+
* `latest` break in EVERY `@onlineapps/*` dependency, gated or not — a floating
|
|
30
38
|
* range makes the same commit install different code on different days whether
|
|
31
39
|
* or not infra happens to ship that package too. Until 2026-09-15 (W413) the
|
|
32
40
|
* exactness test sat inside the gated branch, so a biz-only pin could float
|
|
@@ -76,12 +84,39 @@ function normalizeLibrarySet(raw) {
|
|
|
76
84
|
return { versions: raw, infraConsumed: null };
|
|
77
85
|
}
|
|
78
86
|
|
|
87
|
+
/**
|
|
88
|
+
* The service a finding belongs to, taken from its package.json.
|
|
89
|
+
*
|
|
90
|
+
* The SSOT-pin finding names the service, because the cross-repository audit
|
|
91
|
+
* path (`api/scripts/ci/verify-lib-compat.mjs`) reports several repositories in
|
|
92
|
+
* one run. A package.json without a name is invalid input — npm requires the
|
|
93
|
+
* field — so it is refused with an actionable message rather than rendered as
|
|
94
|
+
* "service undefined".
|
|
95
|
+
*/
|
|
96
|
+
function serviceNameOf(pkg) {
|
|
97
|
+
const name = pkg?.name;
|
|
98
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
99
|
+
throw new Error('[LibCompat] Service package.json declares no "name" - a pin finding must say which '
|
|
100
|
+
+ 'service carries the pin. Fix: add a "name" field to the service package.json.');
|
|
101
|
+
}
|
|
102
|
+
return name;
|
|
103
|
+
}
|
|
104
|
+
|
|
79
105
|
/**
|
|
80
106
|
* Compare a package.json against the platform library set.
|
|
81
107
|
*
|
|
108
|
+
* `compared` is what the gate may claim as coverage: the packages whose
|
|
109
|
+
* version was actually held against the declared one. It is appended by the
|
|
110
|
+
* comparing loop itself, never recomputed from the other two lists by a
|
|
111
|
+
* renderer — a second derivation of one fact is the drift
|
|
112
|
+
* `.claude/rules/change-discipline.md` § One rail per concern forbids, and
|
|
113
|
+
* `gated ∪ notGated` is NOT the same set: a caret pin and a package the SSOT
|
|
114
|
+
* declares nowhere are classified and then refused before any comparison
|
|
115
|
+
* happens.
|
|
116
|
+
*
|
|
82
117
|
* @param {object} pkg parsed package.json of the biz service
|
|
83
118
|
* @param {object} librarySet committed SSOT shape or bare version map
|
|
84
|
-
* @returns {{ok: boolean, gated: string[], notGated: string[], violations: Array<{package: string, message: string}>}}
|
|
119
|
+
* @returns {{ok: boolean, gated: string[], notGated: string[], compared: string[], violations: Array<{package: string, message: string}>}}
|
|
85
120
|
*/
|
|
86
121
|
function checkLibCompat(pkg, librarySet) {
|
|
87
122
|
const { versions, infraConsumed } = normalizeLibrarySet(librarySet);
|
|
@@ -91,6 +126,7 @@ function checkLibCompat(pkg, librarySet) {
|
|
|
91
126
|
|
|
92
127
|
const gated = [];
|
|
93
128
|
const notGated = [];
|
|
129
|
+
const compared = [];
|
|
94
130
|
const violations = [];
|
|
95
131
|
|
|
96
132
|
for (const [name, version] of ours) {
|
|
@@ -133,13 +169,23 @@ function checkLibCompat(pkg, librarySet) {
|
|
|
133
169
|
continue;
|
|
134
170
|
}
|
|
135
171
|
|
|
136
|
-
|
|
172
|
+
// The equality comparison is unconditional, for the same reason the
|
|
173
|
+
// exactness test above is: R6 proves a service pins exactly what the SSOT
|
|
174
|
+
// declares, and the SSOT declares a version for the biz-only packages too.
|
|
175
|
+
// Only the sentence differs — there is no infra release to deploy for a
|
|
176
|
+
// package no infra service installs, so naming one would send the reader
|
|
177
|
+
// after a fix that does not exist.
|
|
178
|
+
compared.push(name);
|
|
137
179
|
|
|
138
180
|
if (declared !== version) {
|
|
139
181
|
violations.push({
|
|
140
182
|
package: name,
|
|
141
|
-
message:
|
|
142
|
-
|
|
183
|
+
message: bizOnly
|
|
184
|
+
? `${name}: service ${serviceNameOf(pkg)} pins ${version}, the platform library SSOT declares `
|
|
185
|
+
+ `${declared} — a service pins exactly what the SSOT declares, whether or not an infra service `
|
|
186
|
+
+ `installs the package. Fix: npm install ${name}@${declared} --save-exact.`
|
|
187
|
+
: `${name}: biz pins ${version}, infra ships ${declared} — `
|
|
188
|
+
+ 'rebuild the service against the current platform, or deploy the matching infra release first.'
|
|
143
189
|
});
|
|
144
190
|
}
|
|
145
191
|
}
|
|
@@ -148,6 +194,7 @@ function checkLibCompat(pkg, librarySet) {
|
|
|
148
194
|
ok: violations.length === 0,
|
|
149
195
|
gated,
|
|
150
196
|
notGated,
|
|
197
|
+
compared,
|
|
151
198
|
violations
|
|
152
199
|
};
|
|
153
200
|
}
|
|
@@ -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,
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
echo "[build]
|
|
147
|
-
|
|
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
|
-
|
|
150
|
-
|
|
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:
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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.
|
|
62
|
-
# the
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
58
|
-
stage
|
|
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
|
|
@@ -79,7 +79,7 @@ WORKSPACE_ROOT=$(dirname "$CONTAINER_DIR")
|
|
|
79
79
|
API_CHECKOUT="$WORKSPACE_ROOT/api"
|
|
80
80
|
|
|
81
81
|
if [ "$(basename "$CONTAINER_DIR")" != "api_biz" ]; then
|
|
82
|
-
die "The checkout is not in the layout the uniform needs - $SERVICE_ROOT lies in '$(basename "$CONTAINER_DIR")', and the engine answers the rows that read the platform SSOT only for a service under <root>/api_biz/ beside <root>/api. Expected: the job checks this project out there. Fix: in .gitlab-ci.yml
|
|
82
|
+
die "The checkout is not in the layout the uniform needs - $SERVICE_ROOT lies in '$(basename "$CONTAINER_DIR")', and the engine answers the rows that read the platform SSOT only for a service under <root>/api_biz/ beside <root>/api. Expected: the job checks this project out there. Fix: make this job extend .oa-uniform in .gitlab-ci.yml (it sets GIT_CLONE_PATH: \$CI_BUILDS_DIR/oa-uniform/\$CI_CONCURRENT_ID/api_biz/<service>), and confirm the runner allows a custom build directory ([runners.custom_build_dir] enabled)." 2
|
|
83
83
|
fi
|
|
84
84
|
|
|
85
85
|
command -v git >/dev/null 2>&1 || die "Missing tool - 'git' does not run, and the api checkout this run reads cannot be fetched without it. Fix: install git in the job image (apk add --no-cache git)." 2
|
|
@@ -90,12 +90,24 @@ command -v node >/dev/null 2>&1 || die "Missing tool - 'node' does not run, and
|
|
|
90
90
|
# Read-only and shallow: the uniform reads files, never history. A runner reuses
|
|
91
91
|
# its builds directory between pipelines, so a checkout left by an earlier run
|
|
92
92
|
# would silently measure this deploy against a stale SSOT — it is removed and
|
|
93
|
-
# fetched again, and
|
|
93
|
+
# fetched again, and ONLY when this script can prove it made it.
|
|
94
|
+
#
|
|
95
|
+
# The proof is the marker the clone step below writes. `.git` plus
|
|
96
|
+
# `config/services.json` is no proof at all: every checkout of the api repository
|
|
97
|
+
# carries both. On 2026-09-24 the script was run from api_biz/emailer on a
|
|
98
|
+
# workstation, <workspace>/api was the developer's real working tree, it passed
|
|
99
|
+
# that test and was deleted (639 tracked files; d.885). A directory without the
|
|
100
|
+
# marker is somebody's work, so the script refuses and touches nothing
|
|
101
|
+
# (`automation-gates.md` §1 requirement 3, Safe). There is no flag that skips
|
|
102
|
+
# the check: the one systemic path is a free place or a clone this script made.
|
|
103
|
+
CLONE_MARKER=".verify-deploy-uniform.clone"
|
|
94
104
|
if [ -e "$API_CHECKOUT" ]; then
|
|
95
|
-
if [ -d "$API_CHECKOUT/.git" ] && [ -f "$API_CHECKOUT/config/services.json" ]
|
|
105
|
+
if [ -d "$API_CHECKOUT/.git" ] && [ -f "$API_CHECKOUT/config/services.json" ] \
|
|
106
|
+
&& [ -f "$API_CHECKOUT/$CLONE_MARKER" ] \
|
|
107
|
+
&& head -n 1 "$API_CHECKOUT/$CLONE_MARKER" | grep -q '^created-by verify-deploy-uniform.sh '; then
|
|
96
108
|
rm -rf "$API_CHECKOUT"
|
|
97
109
|
else
|
|
98
|
-
die "Cannot place the api checkout - $API_CHECKOUT
|
|
110
|
+
die "Cannot place the api checkout - $API_CHECKOUT exists and carries no marker $CLONE_MARKER, so it is not a clone this script made, and deleting it could destroy somebody's work. Expected: a clone this script made. Fix: remove it yourself, or run this script only from a CI checkout where <workspace>/api is free (the job's GIT_CLONE_PATH points at a build directory this pipeline owns)." 2
|
|
99
111
|
fi
|
|
100
112
|
fi
|
|
101
113
|
|
|
@@ -106,6 +118,11 @@ if ! git clone --depth 1 --single-branch --branch "$API_REF" "$API_URL" "$API_CH
|
|
|
106
118
|
die "The api checkout could not be cloned - git clone --branch $API_REF $SAFE_URL failed, so api/config/services.json cannot be read and the uniform would answer a fraction of its rows. A partial answer is not a deploy permit, so this refuses rather than continues. Expected: read access for this pipeline's job token. Fix: in GitLab open the api project, Settings > CI/CD > Job token permissions, and add this project to the allowlist (owner's step, confirmation biz-service-manifest 008 point 2); check also that the ref '$API_REF' exists there." 2
|
|
107
119
|
fi
|
|
108
120
|
|
|
121
|
+
# The marker is what lets the NEXT run delete this clone, so it is written the
|
|
122
|
+
# moment the clone exists and before anything reads it.
|
|
123
|
+
printf 'created-by verify-deploy-uniform.sh %s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$$" > "$API_CHECKOUT/$CLONE_MARKER" \
|
|
124
|
+
|| die "Cannot mark the api checkout - writing $API_CHECKOUT/$CLONE_MARKER failed, and without it the next run would refuse to replace this clone. Expected: a writable build directory. Fix: check the permissions of $WORKSPACE_ROOT on the runner." 2
|
|
125
|
+
|
|
109
126
|
# --- the engine -------------------------------------------------------------
|
|
110
127
|
#
|
|
111
128
|
# The pin decides which uniform this service is measured against, so the engine
|