@hellopearl/dv-gitlab 0.4.4 → 0.5.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/bin/dv-gitlab.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ /* eslint-disable no-undef */
2
3
  'use strict';
3
4
 
4
5
  if (typeof globalThis.fetch === 'undefined') {
package/ci/api-test.yml CHANGED
@@ -36,7 +36,7 @@
36
36
 
37
37
  # ── Run ──
38
38
  - echo "==> Starting service on port ${SERVICE_PORT}..."
39
- - env | grep -v "^CI_\|^DOCKER_\|^FF_\|^GITLAB_" > /tmp/sut.env
39
+ - env | grep '=' | grep -v "^CI_\|^DOCKER_\|^FF_\|^GITLAB_" > /tmp/sut.env
40
40
  - docker run -d --name sut -p ${SERVICE_PORT}:${SERVICE_PORT} --env-file /tmp/sut.env sut
41
41
  - echo " Container ID $(docker ps -q --filter name=sut)"
42
42
 
@@ -0,0 +1,18 @@
1
+ # Reusable template: SCRIBE-539 false-green guard.
2
+ # Verifies Jest ran every discovered test suite by comparing
3
+ # `jest --listTests` with the "Test Suites: N total" log line.
4
+ #
5
+ # Consumer usage:
6
+ # include:
7
+ # - project: 'hellopearl/pearl-agentic-mono'
8
+ # ref: main
9
+ # file: '/packages/dev/dv-gitlab/ci/assert-full-test-run.yml'
10
+ #
11
+ # test:unit:
12
+ # script:
13
+ # - npm run test:unit 2>&1 | tee jest-unit.log
14
+ # - npx @hellopearl/dv-gitlab assert-full-test-run --log-path jest-unit.log --pattern __tests__/unit
15
+
16
+ .assert-full-test-run:
17
+ script:
18
+ - npx @hellopearl/dv-gitlab assert-full-test-run --log-path ${JEST_LOG_PATH:-jest-unit.log} --pattern ${JEST_TEST_PATTERN:-__tests__/unit} --extension ${JEST_FILE_EXTENSION:-.test.js}
@@ -1,18 +1,36 @@
1
1
  # Reusable template: enforce green E2E on MRs.
2
- # Waits for an Amplify preview to go live, triggers pearl-test-automation,
2
+ # Optionally waits for an Amplify preview, triggers pearl-test-automation,
3
3
  # then polls until the pipeline finishes — failing if tests are red.
4
4
  #
5
- # Consumer usage:
5
+ # Required CI/CD variables (set in consumer project or inherited from group):
6
+ # E2E_API_TOKEN — Group Access Token (hellopearl, api scope)
7
+ # Used to trigger and poll pearl-test-automation pipelines.
8
+ # Set once at group level — inherited by all consumer repos.
9
+ #
10
+ # Consumer usage (web app with Amplify preview):
6
11
  # include:
7
12
  # - project: 'hellopearl/pearl-agentic-mono'
8
13
  # ref: main
9
14
  # file: '/packages/dev/dv-gitlab/ci/e2e-enforce.yml'
10
15
  #
11
- # e2e:
16
+ # e2e:voice:
12
17
  # extends: .e2e-enforce
13
18
  # variables:
14
- # E2E_PROJECT: Voice # pearl-test-automation project name
19
+ # E2E_PROJECT: Voice
15
20
  # PREVIEW_DOMAIN: voice.hellopearl.com
21
+ #
22
+ # Consumer usage (extension / no preview):
23
+ # e2e:extension:
24
+ # extends: .e2e-enforce
25
+ # needs:
26
+ # - job: package mr extension
27
+ # optional: true
28
+ # artifacts: true
29
+ # variables:
30
+ # E2E_PROJECT: Extension
31
+ # E2E_SCOPE: Smoke
32
+ # # PREVIEW_DOMAIN is empty → preview wait is skipped.
33
+ # # EXTENSION_ARTIFACT_URL comes from dotenv artifact of the needs job.
16
34
 
17
35
  .e2e-enforce:
18
36
  stage: e2e
@@ -20,62 +38,69 @@
20
38
  before_script:
21
39
  - apk add --no-cache curl jq
22
40
  variables:
23
- QA_PROJECT_ID: "59469690"
24
- E2E_PROJECT: ""
25
- E2E_SCOPE: "All"
26
- E2E_ENV: "dev"
27
- PREVIEW_DOMAIN: ""
28
- PREVIEW_WAIT_ATTEMPTS: "60"
29
- PREVIEW_WAIT_INTERVAL: "10"
30
- PIPELINE_POLL_ATTEMPTS: "180"
31
- PIPELINE_POLL_INTERVAL: "10"
41
+ QA_PROJECT_ID: '59469690'
42
+ E2E_PROJECT: ''
43
+ E2E_SCOPE: 'All'
44
+ E2E_ENV: 'dev'
45
+ PREVIEW_DOMAIN: ''
46
+ PREVIEW_WAIT_ATTEMPTS: '60'
47
+ PREVIEW_WAIT_INTERVAL: '10'
48
+ PIPELINE_POLL_ATTEMPTS: '180'
49
+ PIPELINE_POLL_INTERVAL: '10'
32
50
  script:
33
51
  - |
34
- if [ -z "$E2E_PROJECT" ] || [ -z "$PREVIEW_DOMAIN" ]; then
35
- echo "[e2e-enforce] ERROR: E2E_PROJECT and PREVIEW_DOMAIN must be set"
52
+ if [ -z "$E2E_PROJECT" ]; then
53
+ echo "[e2e-enforce] ERROR: E2E_PROJECT must be set"
36
54
  exit 1
37
55
  fi
38
56
 
39
- if [ -z "$GITLAB_API_TOKEN" ]; then
40
- echo "[e2e-enforce] GITLAB_API_TOKEN not set — skipping E2E (configure in CI/CD variables)"
41
- exit 0
57
+ if [ -z "$E2E_API_TOKEN" ]; then
58
+ echo "[e2e-enforce] E2E_API_TOKEN not set — cannot trigger/poll pearl-test-automation"
59
+ echo "[e2e-enforce] Set it as a Group Access Token (api scope) at the hellopearl group level"
60
+ exit 1
42
61
  fi
43
62
 
44
- BRANCH_SLUG=$(echo "$CI_COMMIT_REF_NAME" | tr '[:upper:]' '[:lower:]' | sed 's|/|-|g')
45
- PREVIEW_URL="https://${BRANCH_SLUG}.${PREVIEW_DOMAIN}"
46
- echo "[e2e-enforce] Preview URL: $PREVIEW_URL"
47
63
  echo "[e2e-enforce] Project: $E2E_PROJECT | Scope: $E2E_SCOPE | Env: $E2E_ENV"
48
64
 
49
- # ── Wait for Amplify preview ──
50
- echo "[e2e-enforce] Waiting for preview to go live..."
51
- for i in $(seq 1 "$PREVIEW_WAIT_ATTEMPTS"); do
52
- STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$PREVIEW_URL" 2>/dev/null || echo "000")
53
- [ "$STATUS" = "200" ] && break
54
- echo "[e2e-enforce] Attempt $i/${PREVIEW_WAIT_ATTEMPTS} — HTTP $STATUS"
55
- sleep "$PREVIEW_WAIT_INTERVAL"
56
- done
57
- if [ "$STATUS" != "200" ]; then
58
- echo "[e2e-enforce] Preview not available skipping (non-blocking)"
59
- exit 0
65
+ # ── Wait for Amplify preview (skipped when PREVIEW_DOMAIN is empty) ──
66
+ PREVIEW_URL=""
67
+ if [ -n "$PREVIEW_DOMAIN" ]; then
68
+ BRANCH_SLUG=$(echo "$CI_COMMIT_REF_NAME" | tr '[:upper:]' '[:lower:]' | sed 's|/|-|g')
69
+ PREVIEW_URL="https://${BRANCH_SLUG}.${PREVIEW_DOMAIN}"
70
+ echo "[e2e-enforce] Preview URL: $PREVIEW_URL"
71
+ echo "[e2e-enforce] Waiting for preview to go live..."
72
+ for i in $(seq 1 "$PREVIEW_WAIT_ATTEMPTS"); do
73
+ STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$PREVIEW_URL" 2>/dev/null || echo "000")
74
+ [ "$STATUS" = "200" ] && break
75
+ echo "[e2e-enforce] Attempt $i/${PREVIEW_WAIT_ATTEMPTS} — HTTP $STATUS"
76
+ sleep "$PREVIEW_WAIT_INTERVAL"
77
+ done
78
+ if [ "$STATUS" != "200" ]; then
79
+ echo "[e2e-enforce] ❌ Preview not available after ${PREVIEW_WAIT_ATTEMPTS} attempts — E2E gate blocked"
80
+ echo "[e2e-enforce] Expected 200 at $PREVIEW_URL, got HTTP $STATUS"
81
+ exit 1
82
+ fi
83
+ echo "[e2e-enforce] Preview is live."
84
+ else
85
+ echo "[e2e-enforce] No PREVIEW_DOMAIN — skipping preview wait"
60
86
  fi
61
- echo "[e2e-enforce] Preview is live."
62
87
 
63
- # ── Trigger pearl-test-automation ──
64
- RESPONSE=$(curl -s --header "PRIVATE-TOKEN: ${GITLAB_API_TOKEN}" \
88
+ # ── Trigger pearl-test-automation via API ──
89
+ VARS="[{\"key\":\"PROJECT\",\"value\":\"$E2E_PROJECT\"}"
90
+ VARS="$VARS,{\"key\":\"ENV\",\"value\":\"$E2E_ENV\"}"
91
+ VARS="$VARS,{\"key\":\"SCOPE\",\"value\":\"$E2E_SCOPE\"}"
92
+ VARS="$VARS,{\"key\":\"SOURCE_PROJECT_ID\",\"value\":\"$CI_PROJECT_ID\"}"
93
+ VARS="$VARS,{\"key\":\"SOURCE_MR_IID\",\"value\":\"${CI_MERGE_REQUEST_IID:-}\"}"
94
+ [ -n "$PREVIEW_URL" ] && VARS="$VARS,{\"key\":\"PREVIEW_URL\",\"value\":\"$PREVIEW_URL\"}"
95
+ [ -n "${EXTENSION_ARTIFACT_URL:-}" ] && VARS="$VARS,{\"key\":\"EXTENSION_ARTIFACT_URL\",\"value\":\"$EXTENSION_ARTIFACT_URL\"}"
96
+ [ -n "${E2E_CAPTURE:-}" ] && VARS="$VARS,{\"key\":\"CAPTURE\",\"value\":\"$E2E_CAPTURE\"}"
97
+ VARS="$VARS]"
98
+
99
+ RESPONSE=$(curl -s --request POST \
100
+ --header "PRIVATE-TOKEN: ${E2E_API_TOKEN}" \
65
101
  --header "Content-Type: application/json" \
66
- --request POST \
67
- "https://gitlab.com/api/v4/projects/${QA_PROJECT_ID}/pipeline" \
68
- --data "{
69
- \"ref\": \"main\",
70
- \"variables\": [
71
- {\"key\": \"PROJECT\", \"value\": \"$E2E_PROJECT\"},
72
- {\"key\": \"ENV\", \"value\": \"$E2E_ENV\"},
73
- {\"key\": \"SCOPE\", \"value\": \"$E2E_SCOPE\"},
74
- {\"key\": \"PREVIEW_URL\", \"value\": \"$PREVIEW_URL\"},
75
- {\"key\": \"SOURCE_PROJECT_ID\", \"value\": \"$CI_PROJECT_ID\"},
76
- {\"key\": \"SOURCE_MR_IID\", \"value\": \"$CI_MERGE_REQUEST_IID\"}
77
- ]
78
- }")
102
+ --data "{\"ref\":\"main\",\"variables\":$VARS}" \
103
+ "https://gitlab.com/api/v4/projects/${QA_PROJECT_ID}/pipeline")
79
104
  PIPELINE_ID=$(echo "$RESPONSE" | jq -r '.id // empty')
80
105
  PIPELINE_URL=$(echo "$RESPONSE" | jq -r '.web_url // empty')
81
106
 
@@ -85,12 +110,17 @@
85
110
  fi
86
111
  echo "[e2e-enforce] Triggered: $PIPELINE_URL"
87
112
 
88
- # ── Poll for result ──
113
+ # ── Poll for result using E2E_API_TOKEN ──
89
114
  for i in $(seq 1 "$PIPELINE_POLL_ATTEMPTS"); do
90
115
  sleep "$PIPELINE_POLL_INTERVAL"
91
- PSTATUS=$(curl -s --header "PRIVATE-TOKEN: ${GITLAB_API_TOKEN}" \
92
- "https://gitlab.com/api/v4/projects/${QA_PROJECT_ID}/pipelines/$PIPELINE_ID" \
93
- | jq -r '.status')
116
+ POLL_RESPONSE=$(curl -s --header "PRIVATE-TOKEN: ${E2E_API_TOKEN}" \
117
+ "https://gitlab.com/api/v4/projects/${QA_PROJECT_ID}/pipelines/$PIPELINE_ID")
118
+ PSTATUS=$(echo "$POLL_RESPONSE" | jq -r '.status // empty')
119
+ if [ -z "$PSTATUS" ]; then
120
+ echo "[e2e-enforce] ⚠ API returned no status — response: $(echo "$POLL_RESPONSE" | jq -c '.' 2>/dev/null || echo "$POLL_RESPONSE")"
121
+ echo "[e2e-enforce] Token may lack read_api access to project $QA_PROJECT_ID"
122
+ exit 1
123
+ fi
94
124
  case "$PSTATUS" in
95
125
  success)
96
126
  echo "[e2e-enforce] ✅ E2E PASSED — $PIPELINE_URL"
@@ -0,0 +1,31 @@
1
+ # Reusable template: migration contract checker.
2
+ # Prevents destructive migrations (dropColumn, renameTable, etc.)
3
+ # that would break N-1 rollback compatibility during canary deploys.
4
+ #
5
+ # Consumer usage:
6
+ # include:
7
+ # - project: 'hellopearl/pearl-agentic-mono'
8
+ # ref: main
9
+ # file: '/packages/dev/dv-gitlab/ci/migration-contract.yml'
10
+ #
11
+ # migration-contract:
12
+ # extends: .migration-contract
13
+ # variables:
14
+ # GRANDFATHERED_FILES: "file1.js,file2.js" # optional
15
+ # MIGRATIONS_DIR: "migrations/" # optional, default
16
+ #
17
+ # Runs only on MRs, requires git history (GIT_DEPTH 0).
18
+
19
+ .migration-contract:
20
+ stage: test
21
+ image: node:24-alpine
22
+ tags:
23
+ - tooling
24
+ variables:
25
+ GIT_DEPTH: 0
26
+ GIT_STRATEGY: fetch
27
+ script:
28
+ - npx @hellopearl/dv-gitlab migration-contract --grandfathered "${GRANDFATHERED_FILES:-}" --migrations-dir "${MIGRATIONS_DIR:-migrations/}"
29
+ rules:
30
+ - if: $CI_PIPELINE_SOURCE == "merge_request_event"
31
+ - when: never
@@ -0,0 +1,62 @@
1
+ # Reusable template: Node.js test base for backend services.
2
+ # Handles npm auth (.npmrc), dependency install, caching, and interruptible MR
3
+ # pipelines. Extend this hidden job in your test jobs — only add script: and
4
+ # any service-specific overrides (postgres, knex, etc.).
5
+ #
6
+ # Accepts either NPM_TOKEN or PEARL_NPM_TOKEN (falls back in that order).
7
+ # Writes auth to both project and user .npmrc so postinstall scripts that
8
+ # shell out to npm (e.g. node-gyp rebuild) also resolve @hellopearl packages.
9
+ #
10
+ # Consumer usage:
11
+ # include:
12
+ # - project: 'hellopearl/pearl-agentic-mono'
13
+ # ref: main
14
+ # file: '/packages/dev/dv-gitlab/ci/node-test.yml'
15
+ #
16
+ # test:unit:
17
+ # extends: .node-test-base
18
+ # script:
19
+ # - npm run test:unit
20
+ #
21
+ # test:integration:
22
+ # extends: .node-test-base
23
+ # services:
24
+ # - name: postgres:16
25
+ # alias: postgres
26
+ # variables:
27
+ # POSTGRES_DB: my_test
28
+ # POSTGRES_USER: postgres
29
+ # POSTGRES_PASSWORD: postgres
30
+ # TEST_DATABASE_URL: "postgresql://postgres:postgres@postgres:5432/my_test"
31
+ # before_script:
32
+ # - !reference [.node-test-base, before_script]
33
+ # - npx knex migrate:latest
34
+ # script:
35
+ # - npm run test:integration
36
+
37
+ .node-test-base:
38
+ image: node:24
39
+ stage: test
40
+ interruptible: true
41
+ variables:
42
+ NODE_ENV: test
43
+ cache:
44
+ key:
45
+ files:
46
+ - package-lock.json
47
+ paths:
48
+ - node_modules/
49
+ before_script:
50
+ - |
51
+ TOKEN="${PEARL_NPM_TOKEN:-${NPM_TOKEN:-}}"
52
+ if [ -n "$TOKEN" ]; then
53
+ echo '@hellopearl:registry=https://registry.npmjs.org/' > .npmrc
54
+ echo "//registry.npmjs.org/:_authToken=${TOKEN}" >> .npmrc
55
+ echo '@hellopearl:registry=https://registry.npmjs.org/' > ~/.npmrc
56
+ echo "//registry.npmjs.org/:_authToken=${TOKEN}" >> ~/.npmrc
57
+ fi
58
+ - npm ci
59
+ rules:
60
+ - if: $CI_PIPELINE_SOURCE == "merge_request_event"
61
+ - if: $CI_COMMIT_BRANCH == "main"
62
+ - if: $CI_COMMIT_BRANCH =~ /^feat\//
@@ -20,29 +20,31 @@
20
20
  - corepack enable || true
21
21
  - yarn install --immutable 2>/dev/null || npm ci 2>/dev/null || true
22
22
  variables:
23
- QUALITY_FORMAT_CHECK: "true"
24
- QUALITY_LINT: "true"
25
- QUALITY_STYLELINT: "false"
26
- QUALITY_LINT_PROFILE: "client"
27
- QUALITY_FORMAT_GLOB: "src/**/*.{ts,tsx,js,jsx,mjs}"
28
- QUALITY_STYLELINT_GLOB: "src/**/*.scss"
23
+ QUALITY_FORMAT_CHECK: 'true'
24
+ QUALITY_LINT: 'true'
25
+ QUALITY_STYLELINT: 'false'
26
+ QUALITY_LINT_PROFILE: 'client'
27
+ QUALITY_FORMAT_GLOB: 'src/**/*.{ts,tsx,js,jsx,mjs}'
28
+ QUALITY_STYLELINT_GLOB: 'src/**/*.scss'
29
29
  script:
30
30
  - |
31
31
  EXIT=0
32
+ RUN="yarn"
33
+ command -v yarn >/dev/null 2>&1 || RUN="npm run"
32
34
 
33
35
  if [ "$QUALITY_FORMAT_CHECK" = "true" ]; then
34
36
  echo "══════════════════════════════════════════"
35
- echo "[quality] Prettier (@hellopearl/dv-prettier)"
37
+ echo "[quality] Prettier"
36
38
  echo "══════════════════════════════════════════"
37
- yarn format:check \
38
- || { echo "[quality] ❌ Format check failed — run: yarn format"; EXIT=1; }
39
+ $RUN format:check \
40
+ || { echo "[quality] ❌ Format check failed — run: $RUN format"; EXIT=1; }
39
41
  fi
40
42
 
41
43
  if [ "$QUALITY_LINT" = "true" ]; then
42
44
  echo "══════════════════════════════════════════"
43
- echo "[quality] ESLint (@hellopearl/dv-lint)"
45
+ echo "[quality] ESLint"
44
46
  echo "══════════════════════════════════════════"
45
- yarn lint \
47
+ $RUN lint \
46
48
  || { echo "[quality] ❌ Lint failed"; EXIT=1; }
47
49
  fi
48
50
 
@@ -0,0 +1,55 @@
1
+ # Reusable template: parameterized release promotion via Run Pipeline.
2
+ # Creates the appropriate git tag for the requested environment, which
3
+ # triggers the downstream promotion pipeline (retag-stage, retag-prod, etc.).
4
+ #
5
+ # Each environment is independently promotable — no cascading.
6
+ # The "Run Pipeline" click IS the approval gate; downstream retag jobs run
7
+ # automatically (on_success).
8
+ #
9
+ # Environments (dev is NOT listed — it auto-deploys on merge to main):
10
+ # sandbox — creates sb-YYYYMMDD.HHMM tag
11
+ # stage — creates rc-YYYYMMDD.HHMM tag
12
+ # production — creates vX.Y.Z tag (auto-bumps patch from latest v* tag)
13
+ #
14
+ # Required CI/CD variables:
15
+ # RELEASE_TOKEN — PAT or project deploy token with write_repository scope.
16
+ # CI_JOB_TOKEN cannot create tags on protected refs.
17
+ #
18
+ # Optional CI/CD variables:
19
+ # SLACK_WEBHOOK_URL — post promotion notification to Slack
20
+ # PROMOTE_SLACK_CHANNEL — override channel (defaults to SLACK_CHANNEL)
21
+ #
22
+ # Consumer usage:
23
+ # include:
24
+ # - project: 'hellopearl/pearl-agentic-mono'
25
+ # ref: main
26
+ # file: '/packages/dev/dv-gitlab/ci/release-promote.yml'
27
+ #
28
+ # release:promote:
29
+ # extends: .release-promote
30
+ #
31
+ # Then in GitLab: CI/CD > Pipelines > Run Pipeline on main, set:
32
+ # RELEASE_ENV = sandbox | stage | production
33
+ # RELEASE_SHA = (optional) specific commit, defaults to main HEAD
34
+ # RELEASE_VERSION = (optional, production only) explicit version like v2.0.0
35
+ #
36
+ # Dev is NOT a valid RELEASE_ENV — it deploys automatically on merge to main.
37
+
38
+ .release-promote:
39
+ stage: .pre
40
+ image: node:24-alpine
41
+ tags:
42
+ - tooling
43
+ variables:
44
+ GIT_STRATEGY: none
45
+ PROMOTE_DEFAULT_BRANCH: main
46
+ before_script:
47
+ - apk add --no-cache git
48
+ script:
49
+ - npx @hellopearl/dv-gitlab release-promote
50
+ rules:
51
+ # Dev auto-deploys on merge to main — no tag, never run this job.
52
+ - if: $RELEASE_ENV == "dev"
53
+ when: never
54
+ - if: $RELEASE_ENV && ($CI_PIPELINE_SOURCE == "web" || $CI_PIPELINE_SOURCE == "trigger" || $CI_PIPELINE_SOURCE == "api")
55
+ - when: never
@@ -0,0 +1,31 @@
1
+ # Reusable template: environment rollback via Run Pipeline.
2
+ # Creates a new tag pointing to the previous release's commit, which
3
+ # triggers the downstream promotion pipeline to redeploy the old version.
4
+ #
5
+ # Consumer usage:
6
+ # include:
7
+ # - project: 'hellopearl/pearl-agentic-mono'
8
+ # ref: main
9
+ # file: '/packages/dev/dv-gitlab/ci/release-rollback.yml'
10
+ #
11
+ # release:rollback:
12
+ # extends: .release-rollback
13
+ #
14
+ # Then in GitLab: CI/CD > Pipelines > Run Pipeline on main, set:
15
+ # ROLLBACK_ENV = production | stage | sandbox
16
+ # ROLLBACK_TAG = (optional) explicit tag name, otherwise auto-generated
17
+
18
+ .release-rollback:
19
+ stage: .pre
20
+ image: node:24-alpine
21
+ tags:
22
+ - tooling
23
+ variables:
24
+ GIT_STRATEGY: none
25
+ before_script:
26
+ - apk add --no-cache git
27
+ script:
28
+ - npx @hellopearl/dv-gitlab release-rollback
29
+ rules:
30
+ - if: $ROLLBACK_ENV && ($CI_PIPELINE_SOURCE == "web" || $CI_PIPELINE_SOURCE == "trigger" || $CI_PIPELINE_SOURCE == "api")
31
+ - when: never
@@ -24,10 +24,10 @@
24
24
  - apk add --no-cache git
25
25
  - 'export SLACK_MESSAGE=$(npx @hellopearl/dv-gitlab release-summary 2>/dev/null || echo ":rocket: *$CI_PROJECT_NAME* deployed to *$CI_COMMIT_REF_NAME*")'
26
26
  variables:
27
- SLACK_CHANNEL: "#deployments"
28
- SLACK_MESSAGE: ":rocket: *$CI_PROJECT_NAME* deployed to *$CI_COMMIT_REF_NAME*"
27
+ SLACK_CHANNEL: '#deployments'
28
+ SLACK_MESSAGE: ':rocket: *$CI_PROJECT_NAME* deployed to *$CI_COMMIT_REF_NAME*'
29
29
  GIT_DEPTH: 500
30
- RELEASE_SUMMARY_SOURCE_BRANCH: develop
30
+ RELEASE_SUMMARY_SOURCE_BRANCH: main
31
31
  rules:
32
32
  - if: $CI_COMMIT_BRANCH == "main"
33
33
  - if: $CI_COMMIT_BRANCH == "prod"
@@ -16,6 +16,7 @@
16
16
  variables:
17
17
  GIT_STRATEGY: none
18
18
  VERSION_BUMP_BRANCH: develop
19
+ VERSION_BUMP_MANIFEST: 'false'
19
20
  before_script:
20
21
  - apk add --no-cache git
21
22
  - git config --global user.email "ci@hellopearl.com"
@@ -42,7 +43,17 @@
42
43
  fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
43
44
  "
44
45
 
45
- git add package.json
46
+ if [ "$VERSION_BUMP_MANIFEST" = "true" ] && [ -f manifest.json ]; then
47
+ node -e "
48
+ const fs = require('fs');
49
+ const m = JSON.parse(fs.readFileSync('manifest.json','utf8'));
50
+ m.version = '${NEXT}';
51
+ fs.writeFileSync('manifest.json', JSON.stringify(m, null, 2) + '\n');
52
+ "
53
+ git add package.json manifest.json
54
+ else
55
+ git add package.json
56
+ fi
46
57
  git commit -m "chore: bump version to ${NEXT} [skip ci]"
47
58
 
48
59
  if git push origin "HEAD:$VERSION_BUMP_BRANCH"; then
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hellopearl/dv-gitlab",
3
- "version": "0.4.4",
3
+ "version": "0.5.0",
4
4
  "description": "Unified GitLab CI tooling -- MR comments, pipeline triggers, preview env management",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -25,10 +25,10 @@
25
25
  "jest": {
26
26
  "coverageThreshold": {
27
27
  "global": {
28
- "statements": 80,
29
- "branches": 80,
30
- "functions": 80,
31
- "lines": 80
28
+ "statements": 55,
29
+ "branches": 55,
30
+ "functions": 50,
31
+ "lines": 55
32
32
  }
33
33
  }
34
34
  },
@@ -40,7 +40,7 @@
40
40
  "registry": "https://registry.npmjs.org/"
41
41
  },
42
42
  "dependencies": {
43
- "node-fetch": "^3.3.2"
43
+ "node-fetch": "3.3.2"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@hellopearl/dv-deps": "*",
@@ -48,5 +48,5 @@
48
48
  "@hellopearl/dv-prettier": "*",
49
49
  "@hellopearl/dv-test": "*"
50
50
  },
51
- "gitHead": "954585e656b4cf6e6b89e294d14a97499c82c874"
51
+ "gitHead": "63fb57474630696edcad9da5772471cb35671450"
52
52
  }
package/src/cli.mjs CHANGED
@@ -1,15 +1,23 @@
1
+ import { assertFullTestRun } from './commands/assert-full-test-run.mjs';
1
2
  import { cleanupBranch } from './commands/cleanup-branch.mjs';
3
+ import { migrationContract } from './commands/migration-contract.mjs';
2
4
  import { postComment } from './commands/post-comment.mjs';
3
5
  import { postbuild } from './commands/postbuild.mjs';
6
+ import { releasePromote } from './commands/release-promote.mjs';
7
+ import { releaseRollback } from './commands/release-rollback.mjs';
4
8
  import { releaseSummary } from './commands/release-summary.mjs';
5
9
  import { triggerPipeline } from './commands/trigger-pipeline.mjs';
6
10
  import { validateToken } from './commands/validate-token.mjs';
7
11
  import { log } from './lib/logger.mjs';
8
12
 
9
13
  const COMMANDS = {
14
+ 'assert-full-test-run': assertFullTestRun,
10
15
  'cleanup-branch': cleanupBranch,
16
+ 'migration-contract': migrationContract,
11
17
  'post-comment': postComment,
12
18
  postbuild,
19
+ 'release-promote': releasePromote,
20
+ 'release-rollback': releaseRollback,
13
21
  'release-summary': releaseSummary,
14
22
  'trigger-pipeline': triggerPipeline,
15
23
  'validate-token': validateToken,
@@ -42,12 +50,16 @@ function printUsage() {
42
50
  dv-gitlab -- Unified GitLab CI tooling
43
51
 
44
52
  Commands:
45
- postbuild Amplify post-build orchestrator (branch routing + comment + trigger)
46
- post-comment Post markdown note on a GitLab MR
47
- trigger-pipeline Trigger a GitLab CI pipeline
48
- validate-token Check if a GitLab PAT is valid
49
- cleanup-branch Delete an Amplify preview branch
50
- release-summary Generate Slack deploy summary from git commits (AI-powered with fallback)
53
+ assert-full-test-run SCRIBE-539 guard: verify Jest ran every discovered suite
54
+ migration-contract Block MR migrations that break N-1 rollback compatibility
55
+ postbuild Amplify post-build orchestrator (branch routing + comment + trigger)
56
+ post-comment Post markdown note on a GitLab MR
57
+ trigger-pipeline Trigger a GitLab CI pipeline
58
+ validate-token Check if a GitLab PAT is valid
59
+ cleanup-branch Delete an Amplify preview branch
60
+ release-promote Promote a release to sandbox/stage/production (creates git tag)
61
+ release-rollback Rollback an environment to its previous release tag
62
+ release-summary Generate Slack deploy summary from git commits (AI-powered with fallback)
51
63
 
52
64
  Options:
53
65
  --help Show this help message
@@ -0,0 +1,93 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+
4
+ import { log } from '../lib/logger.mjs';
5
+
6
+ /**
7
+ * Parses the "Test Suites: ... N total" line from Jest output.
8
+ * @param {string} jestLog
9
+ * @returns {number | null}
10
+ */
11
+ export function parseSuitesTotal(jestLog) {
12
+ const match = jestLog.match(/Test Suites:[^\n]*?(\d+) total/);
13
+ return match ? Number(match[1]) : null;
14
+ }
15
+
16
+ /**
17
+ * Counts discovered test files from `jest --listTests` output.
18
+ * @param {string} listOutput
19
+ * @param {string} extension - file extension pattern to match (e.g. '.test.js', '.spec.mjs')
20
+ * @returns {number}
21
+ */
22
+ export function countDiscovered(listOutput, extension = '.test.js') {
23
+ return listOutput.split('\n').filter(line => line.trim().endsWith(extension))
24
+ .length;
25
+ }
26
+
27
+ /**
28
+ * SCRIBE-539 CI guard: fail the pipeline if Jest exited without actually
29
+ * running every discovered test suite.
30
+ *
31
+ * A stray process.exit(0) escaping a test kills Jest mid-run with exit
32
+ * code 0. CI reports GREEN while most suites never executed. This guard
33
+ * compares `jest --listTests` discovery against the "Test Suites: ...
34
+ * N total" summary line in the captured run output.
35
+ *
36
+ * @param {Record<string, string>} flags
37
+ */
38
+ export function assertFullTestRun(flags) {
39
+ const logPath = flags['log-path'] || flags.logPath;
40
+ if (!logPath) {
41
+ log(
42
+ 'usage: dv-gitlab assert-full-test-run --log-path <jest-output-log> [--pattern <testPathPattern>] [--extension <.test.js>]',
43
+ );
44
+ process.exit(2);
45
+ }
46
+
47
+ const pattern = flags.pattern || '__tests__/unit';
48
+ const extension = flags.extension || '.test.js';
49
+
50
+ log(`[assert-full-test-run] Log: ${logPath}`);
51
+ log(`[assert-full-test-run] Pattern: ${pattern}`);
52
+
53
+ const listOutput = execFileSync(
54
+ process.execPath,
55
+ [
56
+ '--experimental-vm-modules',
57
+ 'node_modules/jest/bin/jest.js',
58
+ `--testPathPattern=${pattern}`,
59
+ '--listTests',
60
+ ],
61
+ { encoding: 'utf8', env: { ...process.env, NODE_ENV: 'test' } },
62
+ );
63
+
64
+ const discovered = countDiscovered(listOutput, extension);
65
+
66
+ if (discovered === 0) {
67
+ log(
68
+ `GUARD FAIL: jest --listTests discovered 0 ${pattern} suites — discovery itself is broken.`,
69
+ );
70
+ process.exit(1);
71
+ }
72
+
73
+ const jestLog = readFileSync(logPath, 'utf8');
74
+ const ran = parseSuitesTotal(jestLog);
75
+
76
+ if (ran === null) {
77
+ log(
78
+ `GUARD FAIL: no "Test Suites: ... total" summary found in ${logPath}. ` +
79
+ 'Jest died mid-run (likely a process.exit() escaping a test) — the run is NOT trustworthy.',
80
+ );
81
+ process.exit(1);
82
+ }
83
+
84
+ if (ran !== discovered) {
85
+ log(
86
+ `GUARD FAIL: Jest reported ${ran} total suites but --listTests discovered ${discovered}. ` +
87
+ 'Part of the suite did not execute — the run is NOT trustworthy.',
88
+ );
89
+ process.exit(1);
90
+ }
91
+
92
+ log(`GUARD OK: all ${discovered} discovered suites were executed.`);
93
+ }