@hellopearl/dv-gitlab 0.4.3 → 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 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') {
@@ -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"
@@ -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
 
@@ -22,10 +22,10 @@
22
22
  needs: []
23
23
  before_script:
24
24
  - apk add --no-cache git
25
- - export SLACK_MESSAGE=$(npx @hellopearl/dv-gitlab release-summary 2>/dev/null || echo ":rocket: *${CI_PROJECT_NAME}* deployed to *${CI_COMMIT_REF_NAME}*")
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
30
  RELEASE_SUMMARY_SOURCE_BRANCH: develop
31
31
  rules:
@@ -16,17 +16,18 @@
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"
22
23
  - git config --global user.name "GitLab CI"
23
- - git clone --depth=1 "https://oauth2:${GITLAB_TOKEN}@gitlab.com/${CI_PROJECT_PATH}.git" repo
24
+ - git clone --depth=1 -b "$VERSION_BUMP_BRANCH" "https://oauth2:${GITLAB_TOKEN}@gitlab.com/${CI_PROJECT_PATH}.git" repo
24
25
  - cd repo
25
26
  script:
26
27
  - |
27
28
  MAX_RETRIES=3
28
29
  for i in $(seq 1 $MAX_RETRIES); do
29
- git fetch origin "$VERSION_BUMP_BRANCH"
30
+ git fetch origin "$VERSION_BUMP_BRANCH:refs/remotes/origin/$VERSION_BUMP_BRANCH"
30
31
  git reset --hard "origin/$VERSION_BUMP_BRANCH"
31
32
 
32
33
  CURRENT=$(node -p "require('./package.json').version")
@@ -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.3",
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": 80,
29
- "branches": 80,
30
- "functions": 80,
31
- "lines": 80
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": "^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": "508b5d90b3bb0dda4f04fc867b813547e4a9b429"
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 pipelineUrl = env('QA_PIPELINE_URL');
48
- const token = process.env.QA_PIPELINE_TRIGGER_TOKEN || resolveToken();
48
+ const triggerToken = process.env.QA_PIPELINE_TRIGGER_TOKEN || '';
49
+ const apiToken = resolveToken();
49
50
 
50
- if (!token || !pipelineUrl) {
51
- log('[postbuild] missing pipeline trigger config -- skipping');
51
+ if (!triggerToken && !apiToken) {
52
+ log('[postbuild] no trigger token available -- skipping');
52
53
  return;
53
54
  }
54
55
 
55
- const payload = {
56
- inputs: {
57
- capture: process.env.QA_PIPELINE_CAPTURE || '',
58
- environment: process.env.QA_PIPELINE_ENVIRONMENT || '',
59
- project: process.env.QA_PIPELINE_PROJECT || '',
60
- scope: process.env.QA_PIPELINE_SCOPE || '',
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
- debug(`[trigger-simple] payload=${JSON.stringify(payload)}`);
66
-
67
- const res = await fetch(pipelineUrl, {
68
- body: JSON.stringify(payload),
69
- headers: {
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.ok) {
77
- log(`[postbuild] QA pipeline triggered (http=${res.status})`);
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
- * @param {{token: string, projectId: string, mrIid: string, branch: string, appId: string}} opts
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 pipelineUrl = process.env.QA_PIPELINE_URL;
96
- const triggerToken = process.env.QA_PIPELINE_TRIGGER_TOKEN || token;
170
+ const triggerToken = process.env.QA_PIPELINE_TRIGGER_TOKEN || '';
97
171
 
98
- if (!pipelineUrl) {
99
- log('[postbuild] QA_PIPELINE_URL not set -- skipping trigger');
172
+ if (!triggerToken && !token) {
173
+ log('[postbuild] no trigger token available -- skipping');
100
174
  return null;
101
175
  }
102
176
 
103
- const payload = {
104
- inputs: {
105
- capture: process.env.QA_PIPELINE_CAPTURE || '',
106
- environment: process.env.QA_PIPELINE_ENVIRONMENT || '',
107
- project: process.env.QA_PIPELINE_PROJECT || '',
108
- scope: process.env.QA_PIPELINE_SCOPE || '',
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
- debug(`[trigger-preview] payload=${JSON.stringify(payload)}`);
122
-
123
- const res = await fetch(pipelineUrl, {
124
- body: JSON.stringify(payload),
125
- headers: {
126
- 'Content-Type': 'application/json',
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
- debug(`[trigger-preview] http=${res.status}`);
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
- const mr = await client.findOpenMr(projectId, branch);
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}`
@@ -202,8 +272,11 @@ export async function postbuild() {
202
272
 
203
273
  const buildCandidates = [
204
274
  process.env.CODEBUILD_SRC_DIR && `${process.env.CODEBUILD_SRC_DIR}/build`,
275
+ process.env.CODEBUILD_SRC_DIR && `${process.env.CODEBUILD_SRC_DIR}/out`,
205
276
  'build',
277
+ 'out',
206
278
  'dist',
279
+ '.next',
207
280
  ].filter(Boolean);
208
281
 
209
282
  let buildExists = false;
@@ -232,11 +305,7 @@ export async function postbuild() {
232
305
  '<!-- qa:automation-results -->',
233
306
  ];
234
307
  for (const marker of markers) {
235
- const deleted = await client.deleteNotesByMarker(
236
- projectId,
237
- mr.iid,
238
- marker,
239
- );
308
+ const deleted = await client.deleteNotesByMarker(projectId, mr.iid, marker);
240
309
  if (deleted) {
241
310
  debug(`[postbuild] removed ${deleted} stale comment(s) [${marker}]`);
242
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.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
27
+ return text
28
+ .replace(/&/g, '&amp;')
29
+ .replace(/</g, '&lt;')
30
+ .replace(/>/g, '&gt;');
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((c) => !isNoiseCommit(c));
38
+ return commits.filter(c => !isNoiseCommit(c));
35
39
  }
36
40
 
37
41
  export function shouldResolveFromSourceBranch(commits) {
38
- if (!commits.length || commits.length > 3) return false;
39
- const nonMerge = commits.filter((c) => !/^merge\b/i.test(c.trim()));
40
- if (!nonMerge.length) return false;
41
- return nonMerge.every((c) => /^release:\s*/i.test(c.trim()));
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') return [];
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') return [];
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(`[release-summary] fetch origin/${sourceBranch} failed: ${err.message}`);
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) untilArgs = ` --until=${shellQuote(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(`[release-summary] git log ${from}..origin/${sourceBranch} failed: ${err.message}`);
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') return '';
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) return '';
108
+ if (!lines.length) {
109
+ return '';
110
+ }
89
111
  const shown = lines.slice(0, 80);
90
- const suffix = lines.length > 80 ? `\n…and ${lines.length - 80} more paths` : '';
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) return header;
102
- const bullets = list.slice(0, 10).map((c) => `• ${escapeSlack(c)}`).join('\n');
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(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
110
- const payload = Buffer.from(JSON.stringify({
111
- iss: credentials.client_email,
112
- sub: credentials.client_email,
113
- aud: 'https://oauth2.googleapis.com/token',
114
- iat: now,
115
- exp: now + 3600,
116
- scope: 'https://www.googleapis.com/auth/cloud-platform',
117
- })).toString('base64url');
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) throw new Error(`Token exchange failed: ${res.status}`);
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: [{ role: 'user', parts: [{ text: prompt }] }],
198
+ contents: [{ parts: [{ text: prompt }], role: 'user' }],
165
199
  generationConfig: {
166
- temperature: 0.3,
167
200
  maxOutputTokens: 1024,
168
- topP: 0.8,
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
- method: 'POST',
208
+ body: JSON.stringify(body),
175
209
  headers: {
176
210
  Authorization: `Bearer ${accessToken}`,
177
211
  'Content-Type': 'application/json',
178
212
  },
179
- body: JSON.stringify(body),
213
+ method: 'POST',
180
214
  signal: AbortSignal.timeout(15_000),
181
215
  });
182
216
 
183
- if (!res.ok) throw new Error(`Gemini API error: ${res.status}`);
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) throw new Error('Empty Gemini response');
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(`[release-summary] squash release detected; resolving via origin/${sourceBranch}`);
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) commits = fromSource;
249
+ if (fromSource.length) {
250
+ commits = fromSource;
251
+ }
210
252
  }
211
253
 
212
254
  const filtered = filterNoiseCommits(commits);
213
- if (filtered.length) commits = filtered;
255
+ if (filtered.length) {
256
+ commits = filtered;
257
+ }
214
258
 
215
259
  log(`[release-summary] resolved ${commits.length} commit(s)`);
216
260