@hellopearl/dv-gitlab 0.4.4 → 0.4.5
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/e2e-enforce.yml +82 -52
- package/ci/quality-gate.yml +13 -11
- package/ci/slack-deploy.yml +2 -2
- package/ci/version-bump.yml +12 -1
- package/package.json +7 -7
- package/src/commands/postbuild.mjs +138 -72
- package/src/commands/release-summary.mjs +84 -40
package/bin/dv-gitlab.mjs
CHANGED
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"
|
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
|
|
package/ci/slack-deploy.yml
CHANGED
|
@@ -24,8 +24,8 @@
|
|
|
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
30
|
RELEASE_SUMMARY_SOURCE_BRANCH: develop
|
|
31
31
|
rules:
|
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.4.
|
|
3
|
+
"version": "0.4.5",
|
|
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": 69,
|
|
29
|
+
"branches": 69,
|
|
30
|
+
"functions": 59,
|
|
31
|
+
"lines": 69
|
|
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": "622d62ac414fa6bf3d28d0cddd4917682f820dc1"
|
|
52
52
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/* eslint-disable no-use-before-define */
|
|
1
2
|
import { GitLabClient } from '../lib/gitlab-client.mjs';
|
|
2
3
|
import { debug, log } from '../lib/logger.mjs';
|
|
3
4
|
import { renderTemplate } from '../lib/template.mjs';
|
|
@@ -44,44 +45,118 @@ function resolveProjectId() {
|
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
async function triggerPipelineSimple() {
|
|
47
|
-
const
|
|
48
|
-
const
|
|
48
|
+
const triggerToken = process.env.QA_PIPELINE_TRIGGER_TOKEN || '';
|
|
49
|
+
const apiToken = resolveToken();
|
|
49
50
|
|
|
50
|
-
if (!
|
|
51
|
-
log('[postbuild]
|
|
51
|
+
if (!triggerToken && !apiToken) {
|
|
52
|
+
log('[postbuild] no trigger token available -- skipping');
|
|
52
53
|
return;
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
},
|
|
62
|
-
ref: process.env.QA_PIPELINE_REF || '',
|
|
56
|
+
const ref = process.env.QA_PIPELINE_REF || 'main';
|
|
57
|
+
const variables = {
|
|
58
|
+
CAPTURE: process.env.QA_PIPELINE_CAPTURE || '',
|
|
59
|
+
ENV: process.env.QA_PIPELINE_ENVIRONMENT || '',
|
|
60
|
+
PROJECT: process.env.QA_PIPELINE_PROJECT || '',
|
|
61
|
+
SCOPE: process.env.QA_PIPELINE_SCOPE || '',
|
|
63
62
|
};
|
|
64
63
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
'Content-Type': 'application/json',
|
|
71
|
-
'PRIVATE-TOKEN': token,
|
|
72
|
-
},
|
|
73
|
-
method: 'POST',
|
|
64
|
+
const res = await triggerQaPipeline({
|
|
65
|
+
apiToken,
|
|
66
|
+
ref,
|
|
67
|
+
triggerToken,
|
|
68
|
+
variables,
|
|
74
69
|
});
|
|
75
70
|
|
|
76
|
-
if (res
|
|
77
|
-
log(`[postbuild] QA pipeline triggered
|
|
78
|
-
} else {
|
|
79
|
-
log(`[postbuild] QA pipeline trigger failed (http=${res.status})`);
|
|
71
|
+
if (res) {
|
|
72
|
+
log(`[postbuild] QA pipeline triggered id=${res.id}`);
|
|
80
73
|
}
|
|
81
74
|
}
|
|
82
75
|
|
|
83
76
|
/**
|
|
84
|
-
*
|
|
77
|
+
* Triggers a pipeline on pearl-test-automation.
|
|
78
|
+
* Prefers the pipeline trigger token (/trigger/pipeline endpoint) which
|
|
79
|
+
* requires no cross-project access. Falls back to the regular pipeline API
|
|
80
|
+
* with a PAT/project token if no trigger token is set.
|
|
81
|
+
*/
|
|
82
|
+
async function triggerQaPipeline({
|
|
83
|
+
triggerToken,
|
|
84
|
+
apiToken,
|
|
85
|
+
ref,
|
|
86
|
+
variables,
|
|
87
|
+
extraVars = [],
|
|
88
|
+
}) {
|
|
89
|
+
const projectId = process.env.QA_PROJECT_ID || '59469690';
|
|
90
|
+
const base = 'https://gitlab.com/api/v4/projects';
|
|
91
|
+
|
|
92
|
+
if (triggerToken) {
|
|
93
|
+
const form = new URLSearchParams();
|
|
94
|
+
form.append('token', triggerToken);
|
|
95
|
+
form.append('ref', ref);
|
|
96
|
+
for (const [k, v] of Object.entries(variables)) {
|
|
97
|
+
if (v) {
|
|
98
|
+
form.append(`variables[${k}]`, v);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
for (const { key, value } of extraVars) {
|
|
102
|
+
form.append(`variables[${key}]`, value);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
debug(`[trigger] POST /trigger/pipeline vars=${JSON.stringify(variables)}`);
|
|
106
|
+
|
|
107
|
+
const res = await fetch(`${base}/${projectId}/trigger/pipeline`, {
|
|
108
|
+
body: form,
|
|
109
|
+
method: 'POST',
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
if (!res.ok) {
|
|
113
|
+
const body = await res.text();
|
|
114
|
+
log(`[postbuild] pipeline trigger failed (http=${res.status}) ${body}`);
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const data = await res.json();
|
|
119
|
+
return { id: data.id, webUrl: data.web_url };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (apiToken) {
|
|
123
|
+
const payload = {
|
|
124
|
+
ref,
|
|
125
|
+
variables: [
|
|
126
|
+
...Object.entries(variables)
|
|
127
|
+
.filter(([, v]) => v)
|
|
128
|
+
.map(([key, value]) => ({ key, value })),
|
|
129
|
+
...extraVars,
|
|
130
|
+
],
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
debug(`[trigger] POST /pipeline payload=${JSON.stringify(payload)}`);
|
|
134
|
+
|
|
135
|
+
const res = await fetch(`${base}/${projectId}/pipeline`, {
|
|
136
|
+
body: JSON.stringify(payload),
|
|
137
|
+
headers: {
|
|
138
|
+
'Content-Type': 'application/json',
|
|
139
|
+
'PRIVATE-TOKEN': apiToken,
|
|
140
|
+
},
|
|
141
|
+
method: 'POST',
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
if (!res.ok) {
|
|
145
|
+
const body = await res.text();
|
|
146
|
+
log(`[postbuild] pipeline trigger failed (http=${res.status}) ${body}`);
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const data = await res.json();
|
|
151
|
+
return { id: data.id, webUrl: data.web_url };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
log('[postbuild] no token available for pipeline trigger');
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* @param {{token: string, projectId: string, mrIid: string, branch: string, appId: string, previewUrl: string}} opts
|
|
85
160
|
* @returns {Promise<{id: number, webUrl: string} | null>}
|
|
86
161
|
*/
|
|
87
162
|
async function triggerPipelinePreview({
|
|
@@ -92,53 +167,41 @@ async function triggerPipelinePreview({
|
|
|
92
167
|
appId,
|
|
93
168
|
previewUrl,
|
|
94
169
|
}) {
|
|
95
|
-
const
|
|
96
|
-
const triggerToken = process.env.QA_PIPELINE_TRIGGER_TOKEN || token;
|
|
170
|
+
const triggerToken = process.env.QA_PIPELINE_TRIGGER_TOKEN || '';
|
|
97
171
|
|
|
98
|
-
if (!
|
|
99
|
-
log('[postbuild]
|
|
172
|
+
if (!triggerToken && !token) {
|
|
173
|
+
log('[postbuild] no trigger token available -- skipping');
|
|
100
174
|
return null;
|
|
101
175
|
}
|
|
102
176
|
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
},
|
|
110
|
-
ref: process.env.QA_PIPELINE_REF || '',
|
|
111
|
-
variables: [
|
|
112
|
-
{ key: 'SOURCE_PROJECT_ID', value: projectId },
|
|
113
|
-
{ key: 'SOURCE_MR_IID', value: mrIid },
|
|
114
|
-
{ key: 'BRANCH', value: branch },
|
|
115
|
-
{ key: 'AMPLIFY_APP_ID', value: appId },
|
|
116
|
-
{ key: 'PREVIEW_DOMAIN', value: process.env.PREVIEW_DOMAIN || '' },
|
|
117
|
-
{ key: 'PREVIEW_URL', value: previewUrl || '' },
|
|
118
|
-
],
|
|
177
|
+
const ref = process.env.QA_PIPELINE_REF || 'main';
|
|
178
|
+
const variables = {
|
|
179
|
+
CAPTURE: process.env.QA_PIPELINE_CAPTURE || '',
|
|
180
|
+
ENV: process.env.QA_PIPELINE_ENVIRONMENT || '',
|
|
181
|
+
PROJECT: process.env.QA_PIPELINE_PROJECT || '',
|
|
182
|
+
SCOPE: process.env.QA_PIPELINE_SCOPE || '',
|
|
119
183
|
};
|
|
184
|
+
const extraVars = [
|
|
185
|
+
{ key: 'SOURCE_PROJECT_ID', value: projectId },
|
|
186
|
+
{ key: 'SOURCE_MR_IID', value: mrIid },
|
|
187
|
+
{ key: 'BRANCH', value: branch },
|
|
188
|
+
{ key: 'AMPLIFY_APP_ID', value: appId },
|
|
189
|
+
{ key: 'PREVIEW_DOMAIN', value: process.env.PREVIEW_DOMAIN || '' },
|
|
190
|
+
{ key: 'PREVIEW_URL', value: previewUrl || '' },
|
|
191
|
+
];
|
|
120
192
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
'PRIVATE-TOKEN': triggerToken,
|
|
128
|
-
},
|
|
129
|
-
method: 'POST',
|
|
193
|
+
const result = await triggerQaPipeline({
|
|
194
|
+
apiToken: token,
|
|
195
|
+
extraVars,
|
|
196
|
+
ref,
|
|
197
|
+
triggerToken,
|
|
198
|
+
variables,
|
|
130
199
|
});
|
|
131
200
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if (!res.ok) {
|
|
135
|
-
log(`[postbuild] pipeline trigger failed (http=${res.status})`);
|
|
136
|
-
return null;
|
|
201
|
+
if (result) {
|
|
202
|
+
log(`[postbuild] pipeline triggered id=${result.id} url=${result.webUrl}`);
|
|
137
203
|
}
|
|
138
|
-
|
|
139
|
-
const data = await res.json();
|
|
140
|
-
log(`[postbuild] pipeline triggered id=${data.id} url=${data.web_url}`);
|
|
141
|
-
return { id: data.id, webUrl: data.web_url };
|
|
204
|
+
return result;
|
|
142
205
|
}
|
|
143
206
|
|
|
144
207
|
/**
|
|
@@ -181,10 +244,17 @@ export async function postbuild() {
|
|
|
181
244
|
|
|
182
245
|
const projectId = resolveProjectId();
|
|
183
246
|
const client = new GitLabClient({ token });
|
|
184
|
-
|
|
247
|
+
let mr = await client.findOpenMr(projectId, branch);
|
|
248
|
+
|
|
249
|
+
if (!mr) {
|
|
250
|
+
const retrySec = parseInt(process.env.POSTBUILD_MR_RETRY_DELAY || '45', 10);
|
|
251
|
+
log(`[postbuild] no open MR for branch=${branch} -- retrying in ${retrySec}s`);
|
|
252
|
+
await new Promise((r) => setTimeout(r, retrySec * 1000));
|
|
253
|
+
mr = await client.findOpenMr(projectId, branch);
|
|
254
|
+
}
|
|
185
255
|
|
|
186
256
|
if (!mr) {
|
|
187
|
-
log(`[postbuild] no open MR for branch=${branch} -- skipping`);
|
|
257
|
+
log(`[postbuild] no open MR for branch=${branch} after retry -- skipping`);
|
|
188
258
|
return;
|
|
189
259
|
}
|
|
190
260
|
|
|
@@ -193,7 +263,7 @@ export async function postbuild() {
|
|
|
193
263
|
return;
|
|
194
264
|
}
|
|
195
265
|
|
|
196
|
-
const previewBranch = branch.toLowerCase().replace(/\//g, '-');
|
|
266
|
+
const previewBranch = branch.toLowerCase().replace(/\//g, '-').slice(0, 63);
|
|
197
267
|
const previewDomain = process.env.PREVIEW_DOMAIN || '';
|
|
198
268
|
const previewUrl = previewDomain
|
|
199
269
|
? `https://${previewBranch}.${previewDomain}`
|
|
@@ -235,11 +305,7 @@ export async function postbuild() {
|
|
|
235
305
|
'<!-- qa:automation-results -->',
|
|
236
306
|
];
|
|
237
307
|
for (const marker of markers) {
|
|
238
|
-
const deleted = await client.deleteNotesByMarker(
|
|
239
|
-
projectId,
|
|
240
|
-
mr.iid,
|
|
241
|
-
marker,
|
|
242
|
-
);
|
|
308
|
+
const deleted = await client.deleteNotesByMarker(projectId, mr.iid, marker);
|
|
243
309
|
if (deleted) {
|
|
244
310
|
debug(`[postbuild] removed ${deleted} stale comment(s) [${marker}]`);
|
|
245
311
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { execSync } from 'node:child_process';
|
|
2
2
|
import { createSign } from 'node:crypto';
|
|
3
|
+
|
|
3
4
|
import { log } from '../lib/logger.mjs';
|
|
4
5
|
|
|
5
6
|
const MAX_COMMITS = 30;
|
|
@@ -11,19 +12,22 @@ function env(key, fallback = '') {
|
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
function shellQuote(value) {
|
|
14
|
-
return `'${String(value).replace(/'/g,
|
|
15
|
+
return `'${String(value).replace(/'/g, "'\\''")}'`;
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
function git(command) {
|
|
18
19
|
return execSync(`git ${command}`, {
|
|
19
20
|
encoding: 'utf8',
|
|
20
|
-
timeout: 60_000,
|
|
21
21
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
22
|
+
timeout: 60_000,
|
|
22
23
|
});
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
export function escapeSlack(text) {
|
|
26
|
-
return text
|
|
27
|
+
return text
|
|
28
|
+
.replace(/&/g, '&')
|
|
29
|
+
.replace(/</g, '<')
|
|
30
|
+
.replace(/>/g, '>');
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
export function isNoiseCommit(subject) {
|
|
@@ -31,18 +35,24 @@ export function isNoiseCommit(subject) {
|
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
export function filterNoiseCommits(commits) {
|
|
34
|
-
return commits.filter(
|
|
38
|
+
return commits.filter(c => !isNoiseCommit(c));
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
export function shouldResolveFromSourceBranch(commits) {
|
|
38
|
-
if (!commits.length || commits.length > 3)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
+
if (!commits.length || commits.length > 3) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
const nonMerge = commits.filter(c => !/^merge\b/i.test(c.trim()));
|
|
46
|
+
if (!nonMerge.length) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
return nonMerge.every(c => /^release:\s*/i.test(c.trim()));
|
|
42
50
|
}
|
|
43
51
|
|
|
44
52
|
export function getCommitsInRange(from, to) {
|
|
45
|
-
if (!from || from === '0000000000000000000000000000000000000000')
|
|
53
|
+
if (!from || from === '0000000000000000000000000000000000000000') {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
46
56
|
try {
|
|
47
57
|
const raw = git(`log ${from}..${to} --no-merges --format="%s"`);
|
|
48
58
|
return raw.trim().split('\n').filter(Boolean);
|
|
@@ -53,18 +63,24 @@ export function getCommitsInRange(from, to) {
|
|
|
53
63
|
}
|
|
54
64
|
|
|
55
65
|
export function getCommitsFromSourceBranch(from, to, sourceBranch) {
|
|
56
|
-
if (!from || from === '0000000000000000000000000000000000000000')
|
|
66
|
+
if (!from || from === '0000000000000000000000000000000000000000') {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
57
69
|
try {
|
|
58
70
|
git(`fetch origin ${sourceBranch} --depth=500`);
|
|
59
71
|
} catch (err) {
|
|
60
|
-
log(
|
|
72
|
+
log(
|
|
73
|
+
`[release-summary] fetch origin/${sourceBranch} failed: ${err.message}`,
|
|
74
|
+
);
|
|
61
75
|
return [];
|
|
62
76
|
}
|
|
63
77
|
|
|
64
78
|
let untilArgs = '';
|
|
65
79
|
try {
|
|
66
80
|
const iso = git(`show -s --format=%cI ${to}`).trim();
|
|
67
|
-
if (iso)
|
|
81
|
+
if (iso) {
|
|
82
|
+
untilArgs = ` --until=${shellQuote(iso)}`;
|
|
83
|
+
}
|
|
68
84
|
} catch {
|
|
69
85
|
// optional
|
|
70
86
|
}
|
|
@@ -75,19 +91,26 @@ export function getCommitsFromSourceBranch(from, to, sourceBranch) {
|
|
|
75
91
|
);
|
|
76
92
|
return raw.trim().split('\n').filter(Boolean);
|
|
77
93
|
} catch (err) {
|
|
78
|
-
log(
|
|
94
|
+
log(
|
|
95
|
+
`[release-summary] git log ${from}..origin/${sourceBranch} failed: ${err.message}`,
|
|
96
|
+
);
|
|
79
97
|
return [];
|
|
80
98
|
}
|
|
81
99
|
}
|
|
82
100
|
|
|
83
101
|
export function getChangedPathsSummary(from, to) {
|
|
84
|
-
if (!from || from === '0000000000000000000000000000000000000000')
|
|
102
|
+
if (!from || from === '0000000000000000000000000000000000000000') {
|
|
103
|
+
return '';
|
|
104
|
+
}
|
|
85
105
|
try {
|
|
86
106
|
const raw = git(`diff --name-status --diff-filter=ACMR ${from}...${to}`);
|
|
87
107
|
const lines = raw.trim().split('\n').filter(Boolean);
|
|
88
|
-
if (!lines.length)
|
|
108
|
+
if (!lines.length) {
|
|
109
|
+
return '';
|
|
110
|
+
}
|
|
89
111
|
const shown = lines.slice(0, 80);
|
|
90
|
-
const suffix =
|
|
112
|
+
const suffix =
|
|
113
|
+
lines.length > 80 ? `\n…and ${lines.length - 80} more paths` : '';
|
|
91
114
|
return `${shown.join('\n')}${suffix}`;
|
|
92
115
|
} catch {
|
|
93
116
|
return '';
|
|
@@ -98,23 +121,32 @@ function fallbackMessage(project, branch, commits) {
|
|
|
98
121
|
const header = `:rocket: *${project}* deployed to *${branch}*`;
|
|
99
122
|
const useful = filterNoiseCommits(commits);
|
|
100
123
|
const list = useful.length ? useful : commits;
|
|
101
|
-
if (!list.length)
|
|
102
|
-
|
|
124
|
+
if (!list.length) {
|
|
125
|
+
return header;
|
|
126
|
+
}
|
|
127
|
+
const bullets = list
|
|
128
|
+
.slice(0, 10)
|
|
129
|
+
.map(c => `• ${escapeSlack(c)}`)
|
|
130
|
+
.join('\n');
|
|
103
131
|
const suffix = list.length > 10 ? `\n_…and ${list.length - 10} more_` : '';
|
|
104
132
|
return `${header}\n\n${bullets}${suffix}`;
|
|
105
133
|
}
|
|
106
134
|
|
|
107
135
|
async function getAccessToken(credentials) {
|
|
108
136
|
const now = Math.floor(Date.now() / 1000);
|
|
109
|
-
const header = Buffer.from(
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
137
|
+
const header = Buffer.from(
|
|
138
|
+
JSON.stringify({ alg: 'RS256', typ: 'JWT' }),
|
|
139
|
+
).toString('base64url');
|
|
140
|
+
const payload = Buffer.from(
|
|
141
|
+
JSON.stringify({
|
|
142
|
+
aud: 'https://oauth2.googleapis.com/token',
|
|
143
|
+
exp: now + 3600,
|
|
144
|
+
iat: now,
|
|
145
|
+
iss: credentials.client_email,
|
|
146
|
+
scope: 'https://www.googleapis.com/auth/cloud-platform',
|
|
147
|
+
sub: credentials.client_email,
|
|
148
|
+
}),
|
|
149
|
+
).toString('base64url');
|
|
118
150
|
|
|
119
151
|
const sign = createSign('RSA-SHA256');
|
|
120
152
|
sign.update(`${header}.${payload}`);
|
|
@@ -122,11 +154,13 @@ async function getAccessToken(credentials) {
|
|
|
122
154
|
const jwt = `${header}.${payload}.${signature}`;
|
|
123
155
|
|
|
124
156
|
const res = await fetch('https://oauth2.googleapis.com/token', {
|
|
125
|
-
method: 'POST',
|
|
126
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
127
157
|
body: `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=${jwt}`,
|
|
158
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
159
|
+
method: 'POST',
|
|
128
160
|
});
|
|
129
|
-
if (!res.ok)
|
|
161
|
+
if (!res.ok) {
|
|
162
|
+
throw new Error(`Token exchange failed: ${res.status}`);
|
|
163
|
+
}
|
|
130
164
|
const data = await res.json();
|
|
131
165
|
return data.access_token;
|
|
132
166
|
}
|
|
@@ -161,29 +195,33 @@ Commits:
|
|
|
161
195
|
${commitList}${pathsBlock}`;
|
|
162
196
|
|
|
163
197
|
const body = {
|
|
164
|
-
contents: [{
|
|
198
|
+
contents: [{ parts: [{ text: prompt }], role: 'user' }],
|
|
165
199
|
generationConfig: {
|
|
166
|
-
temperature: 0.3,
|
|
167
200
|
maxOutputTokens: 1024,
|
|
168
|
-
|
|
201
|
+
temperature: 0.3,
|
|
169
202
|
thinkingConfig: { thinkingBudget: 0 },
|
|
203
|
+
topP: 0.8,
|
|
170
204
|
},
|
|
171
205
|
};
|
|
172
206
|
|
|
173
207
|
const res = await fetch(endpoint, {
|
|
174
|
-
|
|
208
|
+
body: JSON.stringify(body),
|
|
175
209
|
headers: {
|
|
176
210
|
Authorization: `Bearer ${accessToken}`,
|
|
177
211
|
'Content-Type': 'application/json',
|
|
178
212
|
},
|
|
179
|
-
|
|
213
|
+
method: 'POST',
|
|
180
214
|
signal: AbortSignal.timeout(15_000),
|
|
181
215
|
});
|
|
182
216
|
|
|
183
|
-
if (!res.ok)
|
|
217
|
+
if (!res.ok) {
|
|
218
|
+
throw new Error(`Gemini API error: ${res.status}`);
|
|
219
|
+
}
|
|
184
220
|
const data = await res.json();
|
|
185
221
|
const text = data?.candidates?.[0]?.content?.parts?.[0]?.text;
|
|
186
|
-
if (!text)
|
|
222
|
+
if (!text) {
|
|
223
|
+
throw new Error('Empty Gemini response');
|
|
224
|
+
}
|
|
187
225
|
return text.trim();
|
|
188
226
|
}
|
|
189
227
|
|
|
@@ -204,13 +242,19 @@ export async function releaseSummary() {
|
|
|
204
242
|
let commits = getCommitsInRange(fromSha, toSha);
|
|
205
243
|
|
|
206
244
|
if (shouldResolveFromSourceBranch(commits)) {
|
|
207
|
-
log(
|
|
245
|
+
log(
|
|
246
|
+
`[release-summary] squash release detected; resolving via origin/${sourceBranch}`,
|
|
247
|
+
);
|
|
208
248
|
const fromSource = getCommitsFromSourceBranch(fromSha, toSha, sourceBranch);
|
|
209
|
-
if (fromSource.length)
|
|
249
|
+
if (fromSource.length) {
|
|
250
|
+
commits = fromSource;
|
|
251
|
+
}
|
|
210
252
|
}
|
|
211
253
|
|
|
212
254
|
const filtered = filterNoiseCommits(commits);
|
|
213
|
-
if (filtered.length)
|
|
255
|
+
if (filtered.length) {
|
|
256
|
+
commits = filtered;
|
|
257
|
+
}
|
|
214
258
|
|
|
215
259
|
log(`[release-summary] resolved ${commits.length} commit(s)`);
|
|
216
260
|
|