@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 +1 -0
- package/ci/api-test.yml +1 -1
- package/ci/assert-full-test-run.yml +18 -0
- package/ci/e2e-enforce.yml +82 -52
- package/ci/migration-contract.yml +31 -0
- package/ci/node-test.yml +62 -0
- package/ci/quality-gate.yml +13 -11
- package/ci/release-promote.yml +55 -0
- package/ci/release-rollback.yml +31 -0
- package/ci/slack-deploy.yml +3 -3
- package/ci/version-bump.yml +12 -1
- package/package.json +7 -7
- package/src/cli.mjs +18 -6
- package/src/commands/assert-full-test-run.mjs +93 -0
- package/src/commands/migration-contract.mjs +318 -0
- package/src/commands/postbuild.mjs +140 -72
- package/src/commands/release-promote.mjs +225 -0
- package/src/commands/release-rollback.mjs +233 -0
- package/src/commands/release-summary.mjs +86 -42
package/bin/dv-gitlab.mjs
CHANGED
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}
|
package/ci/e2e-enforce.yml
CHANGED
|
@@ -1,18 +1,36 @@
|
|
|
1
1
|
# Reusable template: enforce green E2E on MRs.
|
|
2
|
-
#
|
|
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
|
-
#
|
|
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
|
|
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:
|
|
24
|
-
E2E_PROJECT:
|
|
25
|
-
E2E_SCOPE:
|
|
26
|
-
E2E_ENV:
|
|
27
|
-
PREVIEW_DOMAIN:
|
|
28
|
-
PREVIEW_WAIT_ATTEMPTS:
|
|
29
|
-
PREVIEW_WAIT_INTERVAL:
|
|
30
|
-
PIPELINE_POLL_ATTEMPTS:
|
|
31
|
-
PIPELINE_POLL_INTERVAL:
|
|
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" ]
|
|
35
|
-
echo "[e2e-enforce] ERROR: E2E_PROJECT
|
|
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 "$
|
|
40
|
-
echo "[e2e-enforce]
|
|
41
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
echo "[e2e-enforce]
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
--
|
|
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
|
-
|
|
92
|
-
"https://gitlab.com/api/v4/projects/${QA_PROJECT_ID}/pipelines/$PIPELINE_ID"
|
|
93
|
-
|
|
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
|
package/ci/node-test.yml
ADDED
|
@@ -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\//
|
package/ci/quality-gate.yml
CHANGED
|
@@ -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:
|
|
24
|
-
QUALITY_LINT:
|
|
25
|
-
QUALITY_STYLELINT:
|
|
26
|
-
QUALITY_LINT_PROFILE:
|
|
27
|
-
QUALITY_FORMAT_GLOB:
|
|
28
|
-
QUALITY_STYLELINT_GLOB:
|
|
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
|
|
37
|
+
echo "[quality] Prettier"
|
|
36
38
|
echo "══════════════════════════════════════════"
|
|
37
|
-
|
|
38
|
-
|| { echo "[quality] ❌ Format check failed — run:
|
|
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
|
|
45
|
+
echo "[quality] ESLint"
|
|
44
46
|
echo "══════════════════════════════════════════"
|
|
45
|
-
|
|
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
|
package/ci/slack-deploy.yml
CHANGED
|
@@ -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:
|
|
28
|
-
SLACK_MESSAGE:
|
|
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:
|
|
30
|
+
RELEASE_SUMMARY_SOURCE_BRANCH: main
|
|
31
31
|
rules:
|
|
32
32
|
- if: $CI_COMMIT_BRANCH == "main"
|
|
33
33
|
- if: $CI_COMMIT_BRANCH == "prod"
|
package/ci/version-bump.yml
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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":
|
|
29
|
-
"branches":
|
|
30
|
-
"functions":
|
|
31
|
-
"lines":
|
|
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": "
|
|
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": "
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
+
}
|